71일차 자료 추가(답 제외)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-23 06:50:50 +09:00
1 parent 505dcd313f
commit 176e195dc2
62 files changed
+6288

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+197
View File
@@ -0,0 +1,197 @@
# AGENTS.md · n8n RAG(검색·응답) 작업 규칙 (71일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 70일차 규칙과 같고, 3절이 오늘 만드는 **색인 · 검색 · 규정 Q&A의 계약**, 5절에 **벡터 저장소 · 임베딩 노드**가 더해졌습니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. RAG면 **문서 → 청크 → 검색 → 답** 각 단계의 입력·결과도 함께 보여 준다.
2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다).
3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다.
4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄.
```bash
uv run tools/flow.py push my-work/<이름>.json
uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json -v
```
5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다 (push는 약 10초, n8n이 잠깐 재시작됨).
6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다.
7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.
## 2. 환경 (바꾸지 말 것)
| 항목 | 값 |
| --- | --- |
| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`, 컨테이너 보통 `day65-n8n-n8n-1`), 브라우저 http://localhost:5678 |
| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) |
| 게시된 웹훅 주소 | 밖에서 `http://localhost:5678/webhook/<path>`, **n8n 안에서도** `http://localhost:5678/webhook/<path>` (같은 컨테이너) |
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` |
| 다른 Credential | 쓰지 않음 (Ollama 등 없음, LLM은 모두 위 OpenAI Credential) |
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**71일차 판** `Last3Mon/71/data-api`: FAQ · 접수 현황 · 규정 문서) |
| 임베딩 | OpenAI `text-embedding-3-large` (같은 Credential) |
| 벡터 저장소 | n8n **메모리** 저장소, 이름(memoryKey) `my_policy_docs`. **n8n이 재시작되면(push 할 때마다) 지워진다** → 색인을 다시 (`call my-index`) |
## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
| --- | --- | --- | --- |
| 색인 | `my-work/index.json` · `POST /webhook/my-index` | `chunking`: `"section"`(기본) 또는 글자 수(숫자) | `{"indexed_chunks", "chunking"}` |
| 검색 | `my-work/search.json` · `POST /webhook/my-search` | `question`(필수), `top_k`(기본 3) | `{"question", "hits": [{"rank", "source", "title", "score", "text"}], "top_score", "indexed"}` |
| 규정 Q&A | `my-work/policy-qa.json` · `POST /webhook/my-policy-qa` | `question`(필수), `min_score`(기본 0.33) | `{"route", "answer", "sources", "cited", "top_score"}` |
- 규정 문서: `GET /docs` → `{"docs": [{"name", "title"}]}` 8개, `GET /docs/<name>` → `{"name", "title", "text"}` (마크다운, `## ` 섹션). `name`이 `source`가 된다 (예: `password-policy`).
- `route`: `자동 답변` 또는 `담당자 연결`. 답 안의 출처는 문서 제목 `[비밀번호·계정 보안 규정]`처럼 보여 준다. `cited`는 답이 실제로 인용한 문서 `name` 목록.
- 관련도 기준: 검색 1등 점수(`top_score`)가 `min_score`보다 낮으면 LLM을 부르지 않고 `담당자 연결` (수업 준비 측정: 규정 질문 0.46~0.62, 규정 밖 0.20~0.26).
## 4. 워크플로우 파일 규칙
```json
{
"name": "71 Policy QA (my)",
"nodes": [ ... ],
"connections": { ... },
"settings": { "executionOrder": "v1" }
}
```
- 파일 하나에 워크플로우 하나. `id`는 쓰지 않아도 된다 (`push`가 파일 이름으로 정한다).
- 노드 `name`은 영어로, 워크플로우 안에서 겹치지 않게. 노드마다 `type`, `typeVersion`, `position: [x, y]`, `parameters`가 필요하다.
- **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다.
- 웹훅은 `POST`, `responseMode: "responseNode"`, 결과는 Respond to Webhook 노드로 보낸다.
- 요청 본문은 `$json.body.question`처럼 `body` 아래에 있다.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
아래 `type`/`typeVersion`을 그대로 쓴다. 다른 노드가 필요하면 써도 되지만 `flow.py check`가 이 n8n에 있는지 확인한다.
### Webhook · If · Respond to Webhook
```json
{"name": "Question webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300],
"parameters": {"httpMethod": "POST", "path": "my-policy-qa", "responseMode": "responseNode", "options": {}}}
```
```json
{"name": "Has question?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [200, 300],
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
"conditions": [{"id": "c1", "leftValue": "={{ $json.body.question }}", "rightValue": "",
"operator": {"type": "string", "operation": "notEmpty", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
- If 출력 0번이 true, 1번이 false. 조건을 둘 넣으면 `combinator: "and"`. 불리언은 `"leftValue": "={{ $json.passed }}", "operator": {"type": "boolean", "operation": "true", "singleValue": true}`.
```json
{"name": "Send result", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [2600, 300],
"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}}
```
- 오류 응답: `"responseBody": "{ \"error\": \"question is required\" }"`, `"options": {"responseCode": 400}`
### HTTP Request · Split Out (문서 가져오기)
```json
{"name": "List docs", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [200, 300], "parameters": {"url": "http://data-api:8800/docs", "options": {}}}
```
```json
{"name": "One item per doc", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [400, 300], "parameters": {"fieldToSplitOut": "docs", "options": {}}}
```
```json
{"name": "Get doc", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [600, 300], "parameters": {"url": "=http://data-api:8800/docs/{{ $json.name }}", "options": {}}}
```
### Code (청크 만들기 · 결과 모양)
```json
{"name": "Make chunks", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [800, 300],
"parameters": {"jsCode": "const chunks = [];\nfor (const item of $input.all()) {\n const { name, title, text } = item.json;\n text.split(/\\n(?=## )/).slice(1).forEach((piece, i) => chunks.push({ json: { source: name, title, chunk: i + 1, text: `${title}\\n${piece}` } }));\n}\nreturn chunks;"}}
```
- 여러 항목을 돌려주면 다음 노드가 항목마다 처리한다. 청크 글 앞에 문서 제목을 붙이면 검색이 문서를 더 잘 찾는다.
- Code 노드 안에는 `URL` 클래스가 없다.
### Vector Store (메모리) · Embeddings · Loader — 색인
```json
{"name": "Store chunks", "type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory", "typeVersion": 1.3, "position": [1020, 300],
"parameters": {"mode": "insert", "memoryKey": {"__rl": true, "mode": "list", "value": "my_policy_docs", "cachedResultName": "my_policy_docs"}, "clearStore": true}}
```
```json
{"name": "Embeddings", "type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi", "typeVersion": 1.2, "position": [960, 520],
"parameters": {"model": "text-embedding-3-large", "options": {}}, "credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
```json
{"name": "Chunk loader", "type": "@n8n/n8n-nodes-langchain.documentDefaultDataLoader", "typeVersion": 1.1, "position": [1120, 520],
"parameters": {"jsonMode": "expressionData", "jsonData": "={{ $json.text }}", "textSplittingMode": "simple",
"options": {"metadata": {"metadataValues": [{"name": "source", "value": "={{ $json.source }}"}, {"name": "title", "value": "={{ $json.title }}"}]}}}}
```
- `clearStore: true`: 색인할 때마다 비우고 다시 넣는다 (같은 청크가 두 번 들어가지 않게).
- Loader의 `textSplittingMode: "simple"`은 거의 나누지 않는다. **청크는 앞의 Code가 정한다.**
- 색인 뒤 개수는 Code에서 `$('Make chunks').all().length`로 센다.
### Vector Store (메모리) — 검색
```json
{"name": "Search policies", "type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory", "typeVersion": 1.3, "position": [420, 300],
"parameters": {"mode": "load", "memoryKey": {"__rl": true, "mode": "list", "value": "my_policy_docs", "cachedResultName": "my_policy_docs"},
"prompt": "={{ $json.body.question }}", "topK": "={{ $json.body.top_k ?? 3 }}", "includeDocumentMetadata": true},
"alwaysOutputData": true}
```
- 결과는 항목 여러 개: `$json.document.pageContent`(글), `$json.document.metadata.source`, `$json.score`(0~1, 클수록 가까움). 같은 `Embeddings` 노드를 `ai_embedding`으로 연결한다.
- `alwaysOutputData: true`: 저장소가 비어도 빈 항목 하나가 나와 워크플로우가 멈추지 않는다. 다음 Code에서 `item.json.document`가 있는 항목만 쓴다.
### AI Agent + OpenAI chat model (근거로 답하기, 질문 다시 쓰기)
```json
{"name": "Answer from policies", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [1080, 200],
"parameters": {"promptType": "define", "text": "=질문: {{ $json.question }}\n\n규정 발췌:\n{{ $json.context }}",
"options": {"systemMessage": "Answer only from the numbered policy excerpts ... write the excerpt number like [2] ...", "maxIterations": 2}}}
```
```json
{"name": "OpenAI chat model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [1040, 420],
"parameters": {"model": {"__rl": true, "mode": "id", "value": "gpt-4.1-mini"}, "options": {"temperature": 0}},
"credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
- Agent의 결과는 `$json.output`(글자). 발췌 번호 `[2]`는 다음 Code에서 문서 제목으로 바꾼다.
### Edit Fields (Set) — 담당자 연결
```json
{"name": "Not in policies", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1080, 520],
"parameters": {"assignments": {"assignments": [
{"id": "a0", "name": "route", "value": "담당자 연결", "type": "string"},
{"id": "a1", "name": "answer", "value": "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.", "type": "string"},
{"id": "a2", "name": "cited", "value": "={{ [] }}", "type": "array"}]}, "options": {}}}
```
## 6. 연결 (`connections`)
```json
{
"Make chunks": {"main": [[{"node": "Store chunks", "type": "main", "index": 0}]]},
"Embeddings": {"ai_embedding": [[{"node": "Store chunks", "type": "ai_embedding", "index": 0}]]},
"Chunk loader": {"ai_document": [[{"node": "Store chunks", "type": "ai_document", "index": 0}]]},
"Relevant?": {"main": [[{"node": "Answer from policies", "type": "main", "index": 0}], [{"node": "Not in policies", "type": "main", "index": 0}]]},
"OpenAI chat model": {"ai_languageModel": [[{"node": "Answer from policies", "type": "ai_languageModel", "index": 0}]]}
}
```
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If의 `main`은 `[ [true 쪽], [false 쪽] ]`.
- Chat model은 `ai_languageModel`, Embeddings는 `ai_embedding`, Loader는 `ai_document`로 연결한다 (`main` 아님).
## 7. 표현식 주의
- 표현식은 `=`로 시작한다: `"={{ $json.body.question }}"`. `=`가 없으면 그냥 글자로 남는다.
- `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다: `"={{ JSON.stringify({ draft: $json.draft }) }}"`
- 다른 노드 뒤에서 웹훅 입력은 `$('Question webhook').first().json.body`로 읽는다 (`$json`은 바로 앞 노드 결과).
## 8. 테스트 파일 (`cases/*.json`)
```json
{
"webhook": "my-search",
"before": [{"webhook": "my-index", "request": "requests/index-section.json"}],
"cases": [
{"name": "비밀번호 길이", "request": "requests/q-password-length.json",
"expect": {"equals": {"hits.0.source": "password-policy"}, "contains": {"hits.0.text": ["12자"]}}},
{"name": "직접 쓴 질문", "body": {"question": "", "top_k": 3}, "expect": {"status": 400}},
{"name": "다른 웹훅으로", "webhook": "my-policy-qa", "body": {"question": "점심 메뉴"}, "expect": {"equals": {"route": "담당자 연결"}}}
]
}
```
- `before`: 케이스보다 먼저 한 번 부른다 (메모리 저장소를 다시 채우는 색인). `--no-before`면 건너뛴다 (내가 다른 설정으로 색인해 둔 것을 그대로 쓸 때).
- 필드 이름은 점으로 내려간다: `hits.0.source` = `hits` 첫 항목의 `source`.
- `equals`: 값이 같아야 함 · `contains`: 목록 중 **하나라도** 들어 있어야 함 (배열·객체는 JSON 글자로 바꿔 찾음) · `not_contains`: 하나도 없어야 함 · `status`: HTTP 코드(기본 200)
- `uv run tools/flow.py test <파일> -v --repeat 3`: LLM 답은 매번 조금씩 다르므로 3번 이상 돌려 본다. `-v`(`--verbose`)는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 준다 (보고서 `runs/*.json`에도 같은 것이 남음). `call ... -v`도 보낸 요청 JSON을 응답 위에 함께 보여 준다.
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만). 규정 문서(`data-api/data/docs`)를 고쳐서 테스트를 통과시키지 않는다.
- 답을 검색 결과 밖의 지식으로 쓰지 않는다 (규정에 없으면 없다고 하고 담당자 연결).
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
- `.env`나 API 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+206
View File
@@ -0,0 +1,206 @@
# Lab 01 · 정답표, 색인, 검색
- 관련 강의: [1단계 · RAG와 색인](../../Lecture/71/01-STAGE1-RAG-AND-INDEX.md)
- 소요 시간: 약 40분 (예제 1 10분 · 예제 2 15분 · 과제 15분)
- 준비: [실습 준비](../../Lecture/71/00-DAY71-PREP.md)의 `uv run tools/flow.py doctor` → `ready` (데이터 API가 **71일차 판**이어야 `/docs`가 보임)
- 목표: 검색을 만들기 **전에** "어느 질문이 어느 문서에서 답을 찾아야 하는지" 정답표를 만들고, 규정 문서를 색인·검색하는 워크플로우를 내가 쓴 요청문으로 만든다.
```mermaid
flowchart LR
E1["예제 1<br/>정답표<br/>(사람)"] --> E2["예제 2<br/>색인 + 검색"] --> T["과제<br/>섹션 출처 · top_k"]
```
> 이 실습에는 **복사해 붙일 요청문이 없습니다.** 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · 정답표 (10분, 에이전트 없이)
`cp templates/RAG-EVAL-TEMPLATE.md my-work/RAG-EVAL.md` 후 1절을 **직접** 채웁니다. 문서는 `data-api/data/docs/`에 있습니다 (또는 `curl -s localhost:8800/docs`).
| 질문 |
| --- |
| 비밀번호는 몇 자 이상이어야 하나요? |
| 암호를 얼마나 자주 바꿔야 해? |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? |
| 첨부 파일이 너무 커서 메일이 안 보내져요 |
| 생성형 AI에 고객 자료를 넣어도 되나요? |
| 연차 휴가는 며칠이에요? |
<details><summary>▶ 1단계 · 왜 먼저 정답표를</summary>
검색 결과를 보고 "그럴듯하다"고 판단하면, 틀린 문서가 나와도 알아채기 어렵습니다. 먼저 사람이 정답(문서 · 섹션 · 답 문구)을 정해 두면 검색이 맞았는지 **숫자로** 셀 수 있습니다 (Lab 03에서 24문항으로).
</details>
<details><summary>▶ 2단계 · 답이 없는 질문</summary>
마지막 질문의 답은 어느 문서에 있나요? "없음"도 정답입니다. 이런 질문에 검색은 무엇을 돌려줄까요? Lab 02에서 다룹니다.
</details>
<details><summary>▶ 정답 (직접 채운 뒤에 펼치기)</summary>
| 질문 | 문서 | 섹션 | 답 문구 | 원문 |
| --- | --- | --- | --- | --- |
| 비밀번호는 몇 자 이상이어야 하나요? | `password-policy` | 1. 비밀번호 만들기 | 12자 이상 | 비밀번호는 12자 이상으로 만든다. |
| 암호를 얼마나 자주 바꿔야 해? | `password-policy` | 2. 변경 주기 | 90일마다 | 비밀번호는 90일마다 바꾼다. |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? | `device-issue-return` | 3. 반납 | 5영업일 이내 | 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. |
| 첨부 파일이 너무 커서 메일이 안 보내져요 | `email-and-documents` | 2. 첨부 파일 | 25MB, 사내 드라이브 | 메일 첨부 파일은 25MB까지 보낼 수 있다. 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다. |
| 생성형 AI에 고객 자료를 넣어도 되나요? | `software-usage` | 3. 생성형 AI 서비스 | 입력하지 않는다 | 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다. |
| 연차 휴가는 며칠이에요? | **없음** | — | — | 규정 8개 어디에도 없음 |
- "암호"(질문)와 "비밀번호"(문서), "그만두면"(질문)과 "퇴사"(문서)처럼 **질문의 낱말이 문서에 없는** 것이 있습니다. 예제 2에서 검색이 이것을 찾는지 봅니다.
- 노트북 질문의 답은 "1. 지급과 교체"가 아니라 "3. 반납"에 있습니다. "노트북"이라는 낱말은 1절에만 나옵니다. Lab 03에서 이 차이가 실패를 만듭니다.
</details>
## 예제 2 · 색인과 검색 (15분)
**만들 것**: 두 워크플로우.
| 파일 | 하는 일 |
| --- | --- |
| `my-work/index.json` (`my-index`) | 규정 문서를 모두 가져와 **섹션(`## `)마다 청크** 하나 → 임베딩 → 메모리 저장소 `my_policy_docs`. 결과 `indexed_chunks` |
| `my-work/search.json` (`my-search`) | 질문과 가까운 청크 `top_k`(기본 3)개. 결과 `hits[]`에 순위·문서·제목·점수·글 |
계약은 [AGENTS.md](AGENTS.md) 3절, 노드는 5절. 기대 결과 `cases/lab01.json` (`before`가 먼저 색인합니다).
<details><summary>▶ 1단계 · 한 번에 하나씩</summary>
색인과 검색은 따로 만들고 따로 확인합니다. 색인을 먼저 push하고 `call my-index`로 청크 수를 봅니다. 섹션이 모두 몇 개인지 문서에서 세어 보면 결과가 맞는지 알 수 있습니다.
</details>
<details><summary>▶ 2단계 · 청크에 무엇을 붙일까</summary>
청크 글만 저장하면 검색 결과가 **어느 문서**에서 왔는지 모릅니다. 문서 이름·제목을 함께 저장하라고 적었나요? 청크 글 앞에 문서 제목을 붙이는 것도 검색에 도움이 됩니다 (AGENTS.md 5절 Code).
</details>
<details><summary>▶ 3단계 · 메모리 저장소의 함정</summary>
색인을 만든 뒤 검색을 push하면 n8n이 재시작되면서 저장소가 **비워집니다.** 검색 결과가 비면 먼저 `call my-index`. 저장소가 비었을 때 검색이 멈추지 않게 하는 설정도 AGENTS.md 5절에 있습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 문서를 어디서 ② 청크를 무엇으로 나누나 ③ 청크와 함께 저장할 것 ④ 저장소 이름 ⑤ 결과 칸 이름 ⑥ 비었을 때 ⑦ 기대 결과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
두 워크플로우를 만들어 줘. 계약은 AGENTS.md 3절, 노드는 5절.
1. my-work/index.json (웹훅 my-index)
- 문서: GET http://data-api:8800/____ 로 목록 → 문서마다 GET /docs/<name>
- 청크: Code 에서 본문을 "____" 마다 하나씩. 청크 글 앞에 ____ 를 붙인다.
- 함께 저장: ____ , ____ , 청크 번호
- 저장: 메모리 Vector Store (insert, memoryKey ____ , 매번 비우고) + OpenAI Embeddings ____
- 결과: { ____ , chunking }
2. my-work/search.json (웹훅 my-search)
- question 이 없으면 ____
- 같은 저장소에서 load, topK = 본문 top_k (없으면 ____ ), 메타데이터 포함
- 저장소가 비어도 멈추지 않게: ____
- 결과: { question, hits: [{ rank, ____ , ____ , score, text }], top_score, indexed }
기대 결과는 cases/lab01.json. 끝나면 check 결과와 내가 실행할 push · test 명령을 알려 줘.
```
채울 값은 모두 [AGENTS.md](AGENTS.md) 3절(계약)과 5절(노드)에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
두 워크플로우를 만들어 줘. 계약은 AGENTS.md 3절, 노드는 5절.
1. my-work/index.json (웹훅 my-index)
- 문서: GET http://data-api:8800/docs 로 목록 → 문서마다 GET /docs/<name>
- 청크: Code 에서 본문을 "## " 섹션마다 하나씩. 청크 글 앞에 문서 제목을 붙인다.
본문 chunking 이 숫자면 그 글자 수로 자른다 (기본 section, Lab 03에서 씀).
- 함께 저장: source(문서 name), title, 청크 번호 (Default Data Loader 메타데이터)
- 저장: 메모리 Vector Store (insert, memoryKey my_policy_docs, clearStore true) + OpenAI Embeddings text-embedding-3-large
- 결과: { indexed_chunks, chunking }
2. my-work/search.json (웹훅 my-search)
- question 이 없으면 400
- 같은 저장소에서 load, topK = 본문 top_k (없으면 3), 메타데이터 포함
- 저장소가 비어도 멈추지 않게: Always Output Data, 다음 Code 에서 document 가 있는 항목만
- 결과: { question, hits: [{ rank, source, title, score(소수 셋째 자리), text }], top_score, indexed }
기대 결과는 cases/lab01.json. 끝나면 check 결과와 내가 실행할 push · test 명령을 알려 줘.
```
- 보내기 전에 내 뼈대와 다른 곳을 찾아 봅니다. 빠뜨린 곳이 에이전트가 **멋대로 정하는 곳**입니다.
- 에이전트가 답하면 바로 push하지 말고, 1절 작업 순서대로 "입력 · 노드 순서 · 결과 모양"을 먼저 보여 주는지 확인합니다.
</details>
```bash
uv run tools/flow.py push my-work/index.json my-work/search.json
uv run tools/flow.py call my-index requests/index-section.json -v
uv run tools/flow.py call my-search requests/q-password-length.json -v
uv run tools/flow.py test cases/lab01.json -v
```
**관찰**: 예제 1의 정답표와 검색 1등이 같은가요? "연차 휴가" 질문의 1등 점수는 다른 질문보다 높나요, 낮나요? 2절 표에 적습니다.
## 과제 · 섹션까지 출처, 개수는 요청에서 (15분)
팀장이 요청했습니다.
> "답에 문서 이름만 나오면 긴 문서에서는 어디를 봐야 할지 몰라요. 섹션 제목까지 주세요. 그리고 몇 개를 찾을지는 부르는 쪽에서 정하게 해 주세요."
기대 결과 `cases/lab01-challenge.json`: `hits[].section`에 섹션 제목(예: `2. 변경 주기`), `top_k` 1 · 5.
<details><summary>▶ 1단계 · 섹션 제목은 언제 정하나</summary>
검색할 때 청크 글에서 찾아낼 수도 있지만, **색인할 때** 저장해 두면 검색은 꺼내기만 하면 됩니다. 색인과 검색 두 파일을 모두 고쳐야 합니다.
</details>
<details><summary>▶ 2단계 · 확인</summary>
색인을 고쳤으면 다시 색인해야 새 정보가 들어갑니다 (`before`가 해 줌). 원래 테스트 `cases/lab01.json`도 그대로 통과해야 합니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 둘 ② 섹션 제목을 언제 · 어디에 저장하나 ③ 결과에 더할 칸 ④ 없을 때 ⑤ `top_k`를 어디서 받나 ⑥ 두 테스트 모두 통과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/index.json 의 ____ (Code) 에서 청크마다 ____ (그 청크의 "## " 제목 글자)를 정하고,
Loader 메타데이터에 ____ 을 더해 줘.
my-work/search.json 의 hits 에 ____ 을 더해 줘 (없으면 ____ ).
top_k 는 ____ 에서 받는다 (기본 ____ ).
____ , ____ 둘 다 통과.
```
채울 값은 위 과제 설명, [AGENTS.md](AGENTS.md) 5절(Loader 메타데이터)에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/index.json 의 Make chunks 에서 청크마다 section(그 청크의 "## " 제목 글자)을 정하고,
Loader 메타데이터에 section 을 더해 줘.
my-work/search.json 의 hits 에 section 을 더해 줘 (없으면 빈 글자).
top_k 는 본문에서 받는다 (기본 3).
cases/lab01-challenge.json, cases/lab01.json 둘 다 통과.
```
- `Make chunks`는 내 `index.json`의 Code 노드 이름입니다. 이름이 다르면 내 파일의 이름으로 씁니다.
- "새로 만들어 줘"가 아니라 "**고쳐 줘**"입니다. 이미 있는 파일을 고치게 해야 예제 2에서 맞춘 계약이 그대로 남습니다.
</details>
```bash
uv run tools/flow.py push my-work/index.json my-work/search.json
uv run tools/flow.py test cases/lab01-challenge.json -v
uv run tools/flow.py test cases/lab01.json -v
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `doctor`에 `policy documents /docs` ✘ | 데이터 API가 예전 판: `cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate` |
| 검색 결과 `hits: []`, `indexed: false` | 저장소가 비었음 → `call my-index` (push 뒤에는 항상) |
| `indexed_chunks`가 문서 수(8)와 같음 | 섹션으로 나누지 않고 문서 통째로 저장됨 → Make chunks 확인 |
| 계속 안 됨 | `cp reference/lab01-example2-index.json my-work/index.json`, `cp reference/lab01-example2-search.json my-work/search.json` 후 push |
## 확인
- [ ] 예제 1: 질문 6개의 정답표 (답 없는 질문 포함).
- [ ] 예제 2: `cases/lab01.json` 통과, 청크 수가 섹션 수와 같다.
- [ ] 과제: `cases/lab01-challenge.json` 통과.
@@ -0,0 +1,258 @@
# Lab 02 · 규정으로만 답하기, 모르면 넘기기, 답 검사
- 관련 강의: [2단계 · 근거로 답하는 RAG 워크플로우](../../Lecture/71/02-STAGE2-RAG-ANSWER.md)
- 소요 시간: 약 70분 (예제 1 20분 · 예제 2 15분 · 과제 35분)
- 준비: Lab 01의 `my-work/index.json`, `my-work/search.json` (없으면 `reference/lab01-example2-*.json`을 복사 후 push)
- 목표: 검색한 규정 발췌로만 답하고 **출처**를 붙인다. 관련 없는 질문은 LLM을 부르지 않고 넘기고, LLM의 답도 **검사**한 뒤 내보낸다.
```mermaid
flowchart LR
E1["예제 1<br/>발췌로만 답 · 출처"] --> E2["예제 2<br/>관련 없으면<br/>담당자 연결"] --> T["과제<br/>점수는 높은데<br/>답이 없을 때"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · 발췌로만 답하고 출처 붙이기 (20분)
**만들 것**: `my-work/policy-qa.json` (`my-policy-qa`). 질문 → 가까운 청크 3개 → 번호 붙인 발췌 → LLM이 **발췌로만** 답하고 번호 인용 → 번호를 문서 제목으로 바꾸고 `cited`에 인용한 문서.
| 조건 | 값 |
| --- | --- |
| 계약 | [AGENTS.md](AGENTS.md) 3절 규정 Q&A |
| 답 | 한국어 3문장 이내, 사실마다 `[번호]` → 결과에서는 `[비밀번호·계정 보안 규정]`처럼 제목 |
| 기대 결과 | `cases/lab02.json` 앞 네 케이스 (나머지는 예제 2) |
<details><summary>▶ 1단계 · 검색을 다시 만들까</summary>
`my-search`를 HTTP로 부를 수도, 같은 검색 노드를 Q&A 안에 둘 수도 있습니다. 어느 쪽이든 **같은 저장소**(`my_policy_docs`)를 씁니다. 70일차에 배운 "모듈로 부르기"와 "한 곳에서만 쓰면 그대로"를 떠올려 정합니다.
</details>
<details><summary>▶ 2단계 · LLM에게 주는 지시</summary>
"발췌로만", "모르면 모른다고", "번호로 출처" 세 가지를 지시문에 넣습니다. 번호를 쓰게 하는 이유는 **코드가** 출처를 확인할 수 있게 하기 위해서입니다 (67일차 Lab 01 과제의 `sources`와 같은 생각).
</details>
<details><summary>▶ 3단계 · 번호를 제목으로</summary>
`[2]`를 사람이 읽는 제목으로 바꾸는 것은 LLM이 아니라 **Code**에서. 범위 밖 번호(`[7]`)는 어떻게 할까요?
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 어느 저장소에서 몇 개 ③ 발췌를 어떤 모양으로 ④ LLM 지시 셋(발췌로만 · 번호 · 모를 때의 문장) ⑤ 번호 → 제목 · `cited`는 어디서 ⑥ 기대 결과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/policy-qa.json 을 만들어 줘. 계약은 AGENTS.md 3절 ____ . question 없으면 ____ .
1. 같은 메모리 저장소( ____ )에서 load, topK ____ , 메타데이터 포함, Always Output Data.
2. Code "Build context": 청크를 ____ 번호 붙인 발췌로, hits(source, title, score), top_score.
3. AI Agent( ____ , Tool 없음): 발췌로만, 한국어 ____ 문장 이내, 사실마다 ____ ,
없으면 정확히 " ____ "
4. Code "Build answer": [번호] → ____ , cited = 인용한 번호의 ____ , route "자동 답변". 범위 밖 번호는 ____ .
기대 결과 cases/lab02.json 앞 네 케이스.
```
채울 값은 [AGENTS.md](AGENTS.md) 3절(계약) · 5절(노드)과 위 표에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/policy-qa.json 을 만들어 줘. 계약은 AGENTS.md 3절 규정 Q&A. question 없으면 400.
1. 같은 메모리 저장소(my_policy_docs)에서 load, topK 3, 메타데이터 포함, Always Output Data.
2. Code "Build context": 청크를 [1] [2] [3] 번호 붙인 발췌로, hits(source, title, score), top_score.
3. AI Agent(gpt-4.1-mini, Tool 없음): 발췌로만, 한국어 3문장 이내, 사실마다 [번호],
없으면 정확히 "규정에서 답을 찾지 못했습니다."
4. Code "Build answer": [번호] → [문서 제목], cited = 인용한 번호의 source, route "자동 답변". 범위 밖 번호는 버린다.
기대 결과 cases/lab02.json 앞 네 케이스.
```
- 이 모양은 검색 노드를 Q&A 안에 다시 둡니다 (1단계 힌트의 두 길 중 하나). `my-search`를 HTTP로 부르게 해도 됩니다.
- 점심 메뉴 · 질문 없음 케이스는 아직 실패해도 됩니다 (예제 2에서).
</details>
```bash
uv run tools/flow.py push my-work/policy-qa.json
uv run tools/flow.py test cases/lab02.json -v
```
## 예제 2 · 관련 없으면 담당자 연결 (15분)
```bash
uv run tools/flow.py call my-policy-qa requests/q-off-topic.json -v
```
점심 메뉴 질문에도 가장 가까운 청크 3개는 **언제나** 나옵니다. 그 점수(`top_score`)를 봅니다.
<details><summary>▶ 1단계 · 기준값을 어디서</summary>
Lab 01에서 적어 둔 1등 점수들(규정 질문, 연차 질문)을 봅니다. 그 사이 어디에 선을 그으면 될까요? [AGENTS.md](AGENTS.md) 3절에 수업 준비 때 잰 범위가 있습니다. 기준값은 바꿔 가며 재 볼 수 있게 요청 본문(`min_score`)으로도 받습니다.
</details>
<details><summary>▶ 2단계 · LLM을 부르지 않는 길</summary>
기준 아래면 LLM을 부르지 않고 바로 정해진 답. 빠르고, 돈이 들지 않고, 지어낼 일이 없습니다. 결과 모양(`route`, `answer`, `cited: []`)은 같게.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 ② 기준값과 그 값을 어디서 받나 ③ 무엇을 비교하나 ④ 기준 아래면 무엇을(LLM 없이) ⑤ 결과 모양은 그대로 ⑥ 기대 결과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/policy-qa.json 을 고쳐 줘.
1. Build context 에 min_score = 본문 ____ (없으면 ____ ), relevant = ____ >= min_score.
2. If "Relevant?" 를 Build context 뒤에 둔다.
3. relevant 가 아니면 LLM 을 ____ 않고 route " ____ ",
answer " ____ ", cited ____ .
4. 결과 모양(route, answer, sources, cited, top_score)은 두 길 모두 같게.
기대 결과 cases/lab02.json 전부.
```
채울 값은 [AGENTS.md](AGENTS.md) 3절(계약) · 5절(노드)과 위 표에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/policy-qa.json 을 고쳐 줘.
1. Build context 에 min_score = 본문 min_score (없으면 0.33), relevant = top_score >= min_score.
2. If "Relevant?" 를 Build context 뒤에 둔다.
3. relevant 가 아니면 LLM 을 부르지 않고 route "담당자 연결",
answer "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.", cited [].
4. 결과 모양(route, answer, sources, cited, top_score)은 두 길 모두 같게.
기대 결과 cases/lab02.json 전부.
```
- 0.33은 AGENTS.md 3절의 측정 범위(규정 질문 0.46~0.62, 규정 밖 0.20~0.26) 사이입니다. 내 Lab 01 기록과도 맞는지 봅니다.
- AGENTS.md 5절의 `Not in policies`(Edit Fields)가 3번의 모양입니다.
</details>
```bash
uv run tools/flow.py push my-work/policy-qa.json
uv run tools/flow.py test cases/lab02.json -v
```
## 과제 · 점수는 높은데 규정에 답이 없을 때 (35분)
먼저 돌려 봅니다.
```bash
uv run tools/flow.py test cases/lab02-challenge.json -v
```
"VPN 접속 속도가 느리면?", "노트북 사양은?", "비밀번호를 잊어버리면 어디서 바꿔요?", "보안 교육은 1년에 몇 번?" — 비슷한 낱말이 규정에 있어 점수는 기준을 넘지만, **답은 규정에 없습니다.** 지금 결과의 `route`와 `answer`를 봅니다.
팀장이 요청했습니다.
> "'규정에서 찾지 못했다'는 답이 '자동 답변'으로 나가면 안 됩니다. 그리고 답에 숫자가 나오면 그 숫자가 정말 규정에 있는지 확인해 주세요."
예제 2까지 만든 Q&A로 먼저 돌리면 **2/8**입니다 (예제 정답 `reference/lab02-example2-qa.json`으로 수업 전날 2026-10-22에 잰 값, 10/18 수업 준비 때도 2/8). 케이스마다:
| 케이스 | 질문 | LLM이 받은 발췌 (검색 top 3) | 지금 결과 (예제 2까지) | 왜 틀리나 | 나와야 할 결과 (테스트가 보는 칸) |
| --- | --- | --- | --- | --- | --- |
| 규정에 없음 (VPN 속도) | VPN 접속 속도가 느리면 어떻게 해요? | [1] 원격근무·VPN · 1. 접속 원칙 0.462<br>[2] 원격근무·VPN · 2. VPN 인증서 0.435<br>[3] 원격근무·VPN · 3. 재택근무 중 보안 0.409 | ✘ `top_score` 0.463 → `자동 답변`: "…언급하고 있지 않습니다. 따라서 규정에서 답을 찾지 못했습니다." | 규정에 VPN 이야기가 있어 기준을 넘음. LLM은 "못 찾았다"고 했지만 그 답이 자동으로 나감 | `route` = `담당자 연결` |
| 규정에 없음 (노트북 사양) | 노트북 사양은 어떻게 되나요? | [1] 장비 · 1. 지급과 교체 0.383<br>[2] 메일·문서 · 1. 메일함 0.234<br>[3] 장비 · 2. 분실과 도난 0.215 | ✘ 0.379 → `자동 답변`: "…포함되어 있지 않습니다." + 출처 3개 | 장비 규정에 노트북은 있지만 사양은 없음. 출처까지 붙어 근거 있는 답처럼 보임 | `route` = `담당자 연결` |
| 규정에 없음 (비밀번호 잊음) | 비밀번호를 잊어버리면 어디서 바꿔요? | [1] 비밀번호 · 2. 변경 주기 0.551<br>[2] 비밀번호 · 1. 만들기 0.484<br>[3] 비밀번호 · 4. 다단계 인증 0.474 | ✘ 0.551 → `자동 답변`: "규정에서 답을 찾지 못했습니다." | 비밀번호 규정에 잊었을 때의 방법이 없음. 정해 준 문장 그대로인데도 자동으로 나감 | `route` = `담당자 연결` |
| 규정에 없음 (보안 교육) | 보안 교육은 1년에 몇 번 받아야 하나요? | [1] 보안 사고 · 1. 피싱 메일 0.353<br>[2] 원격근무·VPN · 2. VPN 인증서 0.334<br>[3] 비밀번호 · 2. 변경 주기 0.304 | ✘ 0.352 → `자동 답변`: "규정에서 답을 찾지 못했습니다." | 보안 사고 절차에 교육 이야기가 없음 | `route` = `담당자 연결` |
| 점수가 낮아 걸러짐 (연차) | 연차 휴가는 며칠이에요? | [1] 회의실 · 1. 예약 0.259<br>[2] 원격근무·VPN · 3. 재택 보안 0.224<br>[3] 원격근무·VPN · 2. VPN 인증서 0.221<br>(기준 미달이라 LLM에게 가지 않음) | ✔ 0.259 → `담당자 연결` (LLM 없음) | 이미 맞음 — 예제 2의 기준값이 거름. 고친 뒤에도 그대로여야 함 | `route` = `담당자 연결` |
| 틀린 전제 (30일) | 비밀번호는 30일마다 바꾸면 되죠? | [1] 비밀번호 · 2. 변경 주기 0.685<br>[2] 비밀번호 · 1. 만들기 0.585<br>[3] 비밀번호 · 3. 잠금 0.552 | ✔ "30일마다 바꾸는 것이 아니라 90일마다 …" | 이미 맞음 — 발췌로만 답하게 해서. 검사를 더해도 이 답을 막으면 안 됨 | `answer`에 "90일", "30일마다 바꾸면 됩니다"는 없음 |
| 근거 있는 답은 그대로 | 암호를 얼마나 자주 바꿔야 해? | [1] 비밀번호 · 2. 변경 주기 0.576<br>[2] 비밀번호 · 1. 만들기 0.512<br>[3] 비밀번호 · 4. 다단계 인증 0.456 | ✘ `자동 답변` "암호는 90일마다 …" — **답은 맞음**. 결과 칸은 `route` · `answer` · `sources` · `cited` · `top_score` 다섯 개뿐 | 이 케이스는 답 내용과 함께 **`failed` 칸**을 봅니다. `failed`는 답 검사를 만들어야 생기는 칸이라 지금은 없음 → 실패. 확인하려는 것: 검사를 더한 뒤에도 **좋은 답이 막히지 않는지** | `route` = `자동 답변`, `failed` = `[]` (검사를 모두 통과), `answer`에 "90일" |
| 검사 기록 | 컴퓨터가 바이러스에 걸린 것 같아요 | [1] 보안 사고 · 2. 악성코드 감염 의심 0.479<br>[2] 보안 사고 · 1. 피싱 메일 0.357<br>[3] 원격근무·VPN · 3. 재택 보안 0.249 | ✘ `자동 답변` "…랜선을 뽑고 와이파이를 꺼서 …" — **답은 맞음**. `checks` 칸이 없음 | 이 케이스는 답이 아니라 **`checks` 칸**만 봅니다. 검사를 만들고 그 기록을 결과에 남겼는지 확인. 지금은 검사가 없으니 칸도 없음 → 실패 | `checks` 목록에 이름이 `grounded`인 검사가 있음 |
- 발췌 열은 같은 질문을 검색만 따로 불러 본 값입니다 (2026-10-22, 섹션 색인). 1등 점수가 Q&A의 `top_score`와 0.004 이내로 같고, 문서도 Q&A 결과의 `sources`와 같습니다.
- 답이 없는 네 질문도 LLM은 **비슷한 주제의 발췌 3개**를 받았습니다. "노트북 사양"의 [2] · [3]은 0.234 · 0.215로 관련이 거의 없는데도 LLM에게 갔고, LLM은 이것까지 출처로 붙였습니다. 기준값은 **1등 점수**만 보기 때문입니다.
- 위 네 줄(✘)이 과제의 핵심입니다: **답이 없는 질문을 사람에게 넘기기.**
- 아래 두 줄(✘)은 답이 아니라 **결과 JSON의 칸**을 봅니다. 답은 이미 맞지만, 과제에서 만들 칸(`checks`, `failed`)이 아직 없어서 실패합니다. 하나는 좋은 답이 검사에 막히지 않는지(`failed: []`), 하나는 검사 기록이 남는지(`checks`)를 확인합니다.
- 가운데 두 줄(✔)은 지금도 맞습니다. 검사를 더한 뒤에도 **깨지지 않아야** 합니다.
<details><summary>▶ 고치기 전 · 후 응답 비교 (VPN 속도)</summary>
고치기 전 (예제 2까지):
```json
{"route": "자동 답변",
"answer": "규정에서는 VPN 접속 속도가 느릴 때의 대처 방법에 대해 언급하고 있지 않습니다. 따라서 규정에서 답을 찾지 못했습니다.",
"sources": ["remote-work-vpn"], "cited": [], "top_score": 0.463}
```
고친 뒤 나와야 할 모양:
```json
{"route": "담당자 연결",
"answer": "규정에서 확실한 답을 찾지 못해 IT 담당자에게 전달했습니다.",
"draft": "(LLM이 쓴 원래 답)",
"sources": ["remote-work-vpn"], "cited": [], "top_score": 0.462,
"checks": [{"name": "…", "passed": false}, {"name": "grounded", "passed": false}, …],
"failed": ["…", "grounded", …]}
```
검사 이름 · 개수는 내가 정합니다 (1단계 힌트). 테스트는 `route`, `failed`(근거 있는 답에서 빈 목록), `checks` 안의 `grounded`만 봅니다.
</details>
<details><summary>▶ 1단계 · 검사 항목 정하기</summary>
67일차 Check answer를 떠올립니다. RAG 답에서 코드로 확인할 수 있는 것은? (모른다고 했나, 인용 번호가 있나, 답 글이 발췌와 겹치나, 답의 숫자가 발췌에 있나) 검사마다 이름을 붙이고 `checks`, `failed`로 남기게 합니다.
</details>
<details><summary>▶ 2단계 · "모른다"는 여러 모양</summary>
지시문에 정확한 문장을 주어도 LLM은 "포함되어 있지 않습니다", "명시되어 있지 않습니다"처럼 다르게 말하기도 합니다. 보고서에서 실제 답을 보고, 검사가 이 모양들을 잡는지 확인합니다.
</details>
<details><summary>▶ 3단계 · 넘길 때 무엇을 남기나</summary>
담당자에게 넘길 때 LLM이 쓴 초안(`draft`)과 실패한 검사(`failed`)를 남기면 담당자가 빨리 판단합니다. 원래 테스트 `cases/lab02.json`도 계속 통과해야 합니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 노드 ② 검사 넷의 이름과 기준 ③ 결과에 남길 칸 ④ 하나라도 실패하면 무엇을 ⑤ 원래 답은 어디에 ⑥ 두 테스트 모두 통과**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/policy-qa.json 의 ____ 에 검사를 더해 줘. 검사마다 {name, passed} 를 ____ 에, 실패 이름을 ____ 에.
- found_in_policies: "찾지 못했", " ____ ", " ____ ", "언급이 없", "나와 있지 않" 이 없어야
- cites_excerpt: 인용 번호가 ____ 이상
- grounded: 답 글(번호 뺀)의 ____ 글자 묶음 중 ____ % 이상이 발췌에 있어야
- numbers_from_policies: 답에 나온 ____ 가 모두 발췌에 있어야
failed 가 있으면 route " ____ ", answer " ____ ", ____ 에 원래 답.
____ , ____ 둘 다 통과.
```
채울 값은 위 1~3단계 힌트와 먼저 돌려 본 보고서(`runs/lab02-challenge-*.json`)의 실제 답에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/policy-qa.json 의 Build answer 에 검사를 더해 줘. 검사마다 {name, passed} 를 checks 에, 실패 이름을 failed 에.
- found_in_policies: "찾지 못했", "포함되어 있지 않", "명시되어 있지 않", "언급이 없", "나와 있지 않" 이 없어야
- cites_excerpt: 인용 번호가 하나 이상
- grounded: 답 글(번호 뺀)의 두 글자 묶음 중 30% 이상이 발췌에 있어야
- numbers_from_policies: 답에 나온 숫자가 모두 발췌에 있어야
failed 가 있으면 route "담당자 연결", answer "규정에서 확실한 답을 찾지 못해 IT 담당자에게 전달했습니다.", draft 에 원래 답.
cases/lab02-challenge.json, cases/lab02.json 둘 다 통과.
```
- "모른다"의 모양은 먼저 돌려 본 보고서의 실제 답에서 찾습니다. 위 다섯 개는 수업 준비 때 본 모양이고, 내 결과에 다른 모양이 있으면 더합니다.
- `--repeat 3`으로 세 번 돌려 확인합니다. LLM 답은 매번 조금 다릅니다.
</details>
```bash
uv run tools/flow.py push my-work/policy-qa.json
uv run tools/flow.py test cases/lab02-challenge.json -v --repeat 3
uv run tools/flow.py test cases/lab02.json -v
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 모든 질문이 `담당자 연결` | 저장소가 비었을 수 있음 (`top_score: 0`) → `call my-index` |
| 답에 `[1]` 그대로 | 번호를 제목으로 바꾸는 Code 확인 |
| 근거 있는 답도 `담당자 연결` | 보고서의 `failed`를 보고 어느 검사가 너무 엄격한지 확인 |
| 계속 안 됨 | `cp reference/lab02-example2-qa.json my-work/policy-qa.json` 후 push |
## 확인
- [ ] 예제 1·2: `cases/lab02.json` 통과, 출처가 제목으로 나온다.
- [ ] 과제: `cases/lab02-challenge.json` 3번 반복 통과, `lab02`도 통과.
- [ ] `RAG-EVAL.md` 3절에 "기준값에 걸러지지 않았는데 답이 없는 질문"을 적었다.
+283
View File
@@ -0,0 +1,283 @@
# Lab 03 · 검색 품질을 숫자로 재고, 한 가지씩 고치기
- 관련 강의: [3단계 · 검색 품질 측정과 개선](../../Lecture/71/03-STAGE3-RETRIEVAL-QUALITY.md)
- 소요 시간: 약 80분 (예제 1 15분 · 예제 2 25분 · 과제 40분)
- 준비: Lab 01·02의 `my-work/index.json`, `search.json`, `policy-qa.json`이 push되어 있어야 합니다.
- 목표: 검색이 "잘 되는 것 같다"가 아니라 **몇 개 중 몇 개**인지 잰다. 설정을 **한 번에 하나만** 바꿔 다시 재고, 어려운 질문으로 고친 것이 다른 것을 깨뜨리지 않는지 확인한다.
```mermaid
flowchart LR
E1["예제 1<br/>기준 재기<br/>doc@1 · answer@1 · answer@3"] --> E2["예제 2<br/>청크 크기 · 기준값<br/>한 가지씩"] --> T["과제<br/>어려운 질문 ·<br/>개선 · 다시 재기"]
```
> 복사해 붙일 요청문은 없습니다. 이번 Lab은 요청문보다 **재는 순서**가 중요합니다.
## 예제 1 · 기준 재기 (15분)
**할 일**: 지금 설정(섹션 청크)의 검색 점수를 세 지표로 잽니다. 예제 2와 과제에서 무엇을 바꾸든 **이 숫자와 비교**합니다. 에이전트는 쓰지 않습니다 (워크플로우를 고치지 않음).
질문 24개와 정답(문서, 답 문구)이 `cases/retrieval-*.json`에 들어 있습니다. 세 파일은 **같은 24개 질문**을 서로 다른 기준으로 채점합니다.
| 파일 | 지표 | 뜻 | 케이스가 보는 칸 (예: "회사를 그만두면 노트북은 …") |
| --- | --- | --- | --- |
| `retrieval-doc.json` | doc@1 | 1등 청크가 **맞는 문서**에서 왔나 | `hits.0.source` = `device-issue-return` |
| `retrieval-answer.json` | answer@1 | 1등 청크 안에 **답 문구**가 있나 | `hits.0.text`에 "5영업일" |
| `retrieval-answer3.json` | answer@3 | 3등 안에 답 문구가 있나 | `hits`(1~3등 전부)에 "5영업일" |
```bash
uv run tools/flow.py call my-index requests/index-section.json -v
uv run tools/flow.py test cases/retrieval-doc.json -v --no-before
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before
uv run tools/flow.py test cases/retrieval-answer3.json -v --no-before
```
`RAG-EVAL.md` 4절 첫 줄(섹션)을 채웁니다. 아래 `▶`를 순서대로 펼치며 진행합니다.
<details><summary>▶ 1단계 · 먼저 색인 상태를 확인</summary>
첫 명령 `call my-index requests/index-section.json -v`는 저장소를 비우고 **섹션 청크로 다시 색인**합니다. 응답이 이렇게 나와야 합니다.
```text
HTTP 200 · …s
→ request POST /webhook/my-index
{
"chunking": "section"
}
← response HTTP 200
{
"indexed_chunks": 25,
"chunking": "section"
}
```
- `indexed_chunks`가 **25**인지 봅니다. 규정 8개의 `## ` 섹션 수를 더하면 25입니다 (Lab 01 예제 2와 같음).
- 25가 아니면 Lab 01 과제까지 한 내 `my-work/index.json`이 push되어 있는지 확인합니다.
- 왜 굳이 다시 색인하나: 앞에서 무엇을 push했는지에 따라 저장소가 비었거나(push하면 n8n 재시작) 다른 설정으로 색인되어 있을 수 있습니다. **잴 때마다 출발점을 내 손으로 정합니다.**
</details>
<details><summary>▶ 2단계 · 왜 <code>--no-before</code>를 붙이나</summary>
세 케이스 파일에는 `before` 칸(명령이 아니라 파일 안의 칸)이 있어서, `test`는 보통 채점 전에 섹션으로 다시 색인합니다. 예제 1은 방금 섹션으로 색인했으니 붙이지 않아도 결과는 같지만, **예제 2에서는 200자로 색인한 것을 그대로 재야** 하므로 지금부터 붙이는 습관을 들입니다.
- 붙였으면 출력 첫 줄이 `POST http://localhost:5678/webhook/my-search · 24 case(s) × 1`입니다.
- 첫 줄에 `✔ before: POST /webhook/my-index …`가 있으면 `--no-before`가 빠진 것입니다.
</details>
<details><summary>▶ 3단계 · 결과 읽는 법</summary>
`-v`를 붙였으므로 케이스마다 요청 · 응답 JSON이 길게 나옵니다. 먼저 **한 줄씩 된 결과**와 **마지막 두 줄**만 봅니다.
```text
POST http://localhost:5678/webhook/my-search · 24 case(s) × 1
✔ 비밀번호는 몇 자 이상이어야 하나요? · 0.3s
✔ 암호를 얼마나 자주 바꿔야 해? · 0.3s
…
✘ 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? · 0.3s · hits.0.text has none of ['5영업일']
…
Passed 22/24
report: runs/retrieval-answer-20261022-201052.json
```
| 부분 | 뜻 |
| --- | --- |
| `✔` / `✘` | 이 질문을 맞혔나 / 틀렸나 |
| `· 0.3s` | 검색 한 번 걸린 시간 |
| `hits.0.text has none of ['5영업일']` | 틀린 까닭: 1등(`hits.0`) 글에 "5영업일"이 없음 |
| `Passed 22/24` | **이 지표의 점수** → 표에 적을 숫자 |
| `report: runs/…json` | 케이스마다 요청 · 응답이 모두 든 보고서 |
틀린 줄만 모아 보려면:
```bash
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before | grep -E "^(✘|Passed)"
```
</details>
<details><summary>▶ 4단계 · 틀린 질문 하나를 열어 보기</summary>
`✘` 줄 바로 아래에 그 질문의 응답이 있습니다. `hits`의 1~3등에서 **문서(`source`) · 섹션(`section`) · 점수(`score`) · 글(`text`)**을 봅니다.
```text
✘ 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? · 0.3s · hits.0.text has none of ['5영업일']
← response HTTP 200
{ "hits": [
{ "rank": 1, "source": "device-issue-return", "section": "1. 지급과 교체", "score": 0.464,
"text": "…업무용 노트북은 입사일에 지급한다. 노트북 교체 주기는 4년이다. …" },
{ "rank": 2, "source": "device-issue-return", "section": "3. 반납", "score": 0.45,
"text": "…퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다. …" },
{ "rank": 3, "source": "device-issue-return", "section": "2. 분실과 도난", "score": 0.369, … } ] }
```
스스로 답해 봅니다.
1. 1등 문서는 맞았나요? (정답 문서 `device-issue-return`)
2. 답 문구 "5영업일"은 몇 등에 있나요?
3. 1등과 그 등수의 점수 차이는 얼마인가요?
4. 1등 섹션에는 질문의 어떤 낱말이 들어 있나요?
같은 질문이 `retrieval-doc.json`, `retrieval-answer3.json`에서는 ✔인지 ✘인지도 봅니다.
</details>
<details><summary>▶ 5단계 · 세 숫자가 다른 이유</summary>
doc@1은 맞는데 answer@1이 틀린 질문을 보고서에서 찾습니다. 맞는 문서의 **다른 섹션**이 1등이었나요? answer@3에서는 맞았다면, LLM에게 3개를 주는 지금 설정에서는 답할 수 있다는 뜻입니다. 어느 지표가 내 업무에 중요한지 정합니다.
| 세 지표 결과 | 뜻 |
| --- | --- |
| doc@1 ✔ · answer@1 ✔ | 1등 하나로 답할 수 있음 |
| doc@1 ✔ · answer@1 ✘ · answer@3 ✔ | 맞는 문서의 다른 섹션이 1등. 3개를 주면 답할 수 있음 |
| doc@1 ✘ | 다른 문서가 1등. 질문의 낱말이 다른 규정과 더 가까움 |
| answer@3 ✘ | 답이 3등 안에 없음. LLM도 답할 수 없음 |
</details>
<details><summary>▶ 6단계 · <code>RAG-EVAL.md</code>에 적기</summary>
4절 표의 첫 줄(섹션)을 채우고, 표 아래에 **틀린 질문**을 적습니다. 예제 2에서 숫자가 같아도 틀린 질문이 다를 수 있기 때문입니다.
```markdown
| 설정 | 청크 수 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 (기준) | 25 | __/24 | __/24 | __/24 |
- 섹션에서 answer@1을 틀린 질문: ______ (답 문구가 __등에 있음), ______
```
</details>
<details><summary>▶ 정답 (직접 잰 뒤에 펼치기)</summary>
수업 준비 때(10/18)와 다시 돌렸을 때(10/22) 모두 같았습니다.
| 설정 | 청크 수 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 (기준) | 25 | 24/24 | 22/24 | 24/24 |
answer@1에서 틀린 두 질문 (둘 다 doc@1 ✔, answer@3 ✔):
| 질문 | 1등 | 답이 있는 섹션 | 점수 차이 |
| --- | --- | --- | --- |
| 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요? | 장비 · 1. 지급과 교체 0.464 | 장비 · 3. 반납 0.45 ("5영업일", 2등) | 0.014 |
| 고객 정보가 밖으로 샌 것 같아요 | 개인정보 · 2. 이용과 보관 0.395 | 개인정보 · 3. 유출 대응 0.375 ("1시간", 2등) | 0.02 |
- 1등 섹션에는 질문의 명사("노트북", "고객")가 그대로 있고, 답이 있는 섹션은 다른 낱말("장비", "유출")로 쓰여 있습니다.
- 시간은 검색 한 번 0.2~0.9초였습니다.
</details>
## 예제 2 · 한 가지씩 바꾸기 (25분)
**청크 크기**: 색인만 다른 설정으로 다시 하고, 검색 테스트는 `--no-before`로 그대로 돌립니다 (워크플로우는 고치지 않음).
```bash
uv run tools/flow.py call my-index requests/index-200.json -v
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before
uv run tools/flow.py call my-index requests/index-400.json -v
uv run tools/flow.py test cases/retrieval-answer.json -v --no-before
```
doc@1, answer@3도 같은 식으로 재어 4절 표를 채웁니다. 끝나면 섹션으로 되돌립니다.
**관련도 기준**: `cases/lab03-threshold.json`은 규정 질문 5개(답해야 함)와 규정 밖 5개(넘겨야 함)입니다.
```bash
uv run tools/flow.py call my-index requests/index-section.json -v
uv run tools/flow.py test cases/lab03-threshold.json -v --no-before
```
<details><summary>▶ 1단계 · 왜 한 번에 하나만</summary>
청크 크기와 기준값을 함께 바꾸고 숫자가 좋아지면, 무엇 때문인지 모릅니다. 바꾼 것 하나, 잰 숫자 셋을 한 줄에 적습니다.
</details>
<details><summary>▶ 2단계 · 기준값을 바꿔 보려면</summary>
보고서의 `top_score`를 봅니다 (`runs/lab03-threshold-....json`). 규정 질문의 가장 낮은 점수와 규정 밖 질문의 가장 높은 점수 사이가 얼마나 벌어져 있나요? Q&A의 기본 `min_score`를 바꿔 push하고 다시 재 봅니다. Lab 02 과제의 "점수는 높은데 답이 없는 질문"은 기준값으로 거를 수 있을까요?
</details>
<details><summary>▶ 3단계 · 청크 크기의 함정</summary>
글자 수로 자르면 섹션 중간이 잘리고, 청크 수도 달라집니다 (`indexed_chunks`). 우리 문서는 짧아서 400자로 자르면 청크가 오히려 **적어집니다.** 문서의 모양을 보고 정합니다.
</details>
## 과제 · 어려운 질문으로 고치기 (40분)
팀장이 요청했습니다.
> "직원들은 규정 낱말로 묻지 않아요. '챗GPT', '폰', '비번'처럼 물어요. 그런 질문으로 테스트를 만들고, 검색을 고쳐 주세요. 대신 지금 잘 되는 24문항이 나빠지면 안 됩니다."
1. `cases/my-cases.json`에 **어려운 질문 6개 이상** (기대: `hits.0.source`). 예제 1의 정답표처럼 답이 있는 문서를 먼저 확인합니다 (답이 **없는** 질문은 넣지 않음).
2. 한 가지 개선을 골라 에이전트에게 맡깁니다.
3. 어려운 질문과 `retrieval-*` 세 파일을 **모두** 다시 잽니다.
```bash
uv run tools/flow.py test cases/my-cases.json -v
uv run tools/flow.py push my-work/search.json
uv run tools/flow.py test cases/my-cases.json -v
uv run tools/flow.py test cases/retrieval-answer.json -v
```
<details><summary>▶ 1단계 · 어떤 개선이 있나</summary>
| 개선 | 어디를 고치나 |
| --- | --- |
| 질문 다시 쓰기: LLM이 질문을 규정 낱말로 바꾼 뒤 검색 | 검색 (LLM 한 번 더) |
| 청크에 문서 제목·섹션 제목 붙이기 | 색인 |
| 같은 뜻 낱말표(챗GPT → 생성형 AI)로 질문 넓히기 | 검색 (Code) |
| 더 많이 찾고(top_k 5) LLM에게 맡기기 | 검색·Q&A |
하나만 고릅니다. 고른 이유를 `RAG-EVAL.md` 5절에 적습니다.
</details>
<details><summary>▶ 2단계 · 실패를 보고 고치되, 맞추지는 않기</summary>
어려운 질문이 실패한 이유를 보고서에서 봅니다 (1등 문서는 어디였나?). 개선을 그 실패에만 맞추면(질문 하나하나의 낱말을 지시문에 넣기) 다른 질문에서는 안 됩니다. 그래서 `retrieval-*` 24문항을 **함께** 잽니다.
</details>
<details><summary>▶ 3단계 · 고쳐서 깨진 것</summary>
개선 뒤 **새로 틀린** 질문이 있나요? 있다면 그대로 적습니다. 좋아진 것과 나빠진 것을 함께 적는 것이 측정입니다. 시간(보고서 `seconds`)도 비교합니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 ② 어디에 무엇을 더하나 ③ LLM에게 줄 지시(무엇으로 바꾸나 · 예 · 그대로 둘 것) ④ 검색이 무엇을 쓰나 ⑤ 결과에 더할 칸 ⑥ 다시 잴 테스트 전부**. 아래 `____`를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/search.json 을 고쳐 줘. 검색 전에 AI Agent " ____ "( ____ , Tool 없음)를 둔다.
지시: 직원 질문을 ____ 의 낱말로 바꾼 짧은 한국어 검색어 한 줄.
예: ____ → ____ , ____ → ____ , ____ → ____ (내 어려운 질문에서 찾은 낱말)
____ 와 ____ 은 그대로.
검색 prompt 는 ____ 의 output. 결과에 ____ 를 더한다. 나머지 계약은 그대로.
____ 과 retrieval-doc · retrieval-answer · retrieval-answer3 를 모두 다시 잰다.
```
채울 값은 1단계 표, 내 `cases/my-cases.json`에서 틀린 질문, [AGENTS.md](AGENTS.md) 5절(AI Agent · 질문 다시 쓰기)에 있습니다.
</details>
<details><summary>▶ 그래도 막히면 · 빈칸을 채운 모양</summary>
```text
my-work/search.json 을 고쳐 줘. 검색 전에 AI Agent "Rewrite query"(gpt-4.1-mini, Tool 없음)를 둔다.
지시: 직원 질문을 규정 문서의 낱말로 바꾼 짧은 한국어 검색어 한 줄. 예: 암호·비번 → 비밀번호, 그만두면 → 퇴사, 깔다 → 설치,
챗GPT·제미나이 → 생성형 AI 서비스, 개인 메일로 보내다 → 외부로 보내다·첨부 파일, 폰 → 휴대폰. 숫자와 이름은 그대로.
검색 prompt 는 Rewrite query 의 output. 결과에 query 를 더한다. 나머지 계약은 그대로.
cases/my-cases.json 과 retrieval-doc · retrieval-answer · retrieval-answer3 를 모두 다시 잰다.
```
- 이 모양은 1단계 표의 **질문 다시 쓰기**를 고른 경우입니다. 다른 개선을 골랐다면 같은 순서(고칠 파일 → 더할 것 → 그대로 둘 것 → 다시 잴 테스트)로 씁니다.
- 예 낱말을 내 어려운 질문에만 맞추면 그 질문만 좋아집니다 (2단계 힌트). 24문항을 함께 재는 줄을 빼지 않습니다.
- 다시 쓴 검색어는 실행마다 조금씩 다릅니다. `--repeat 3`으로 재 보면 범위가 보입니다.
</details>
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 숫자가 매번 다름 | 색인 설정을 확인 (`call my-index`의 `chunking`), `--no-before`를 붙였는지 |
| 200자 색인 뒤 Q&A가 이상함 | 테스트가 끝나면 `call my-index requests/index-section.json`으로 되돌리기 |
| 계속 안 됨 | `reference/`의 파일로 이어 가기 |
## 확인
- [ ] 예제 1: 섹션 설정의 doc@1 · answer@1 · answer@3.
- [ ] 예제 2: 200자 · 400자 줄, 기준값 테스트, 바꾼 것은 한 번에 하나.
- [ ] 과제: 어려운 질문 6개 이상, 개선 전·후, `retrieval-*` 다시, 새로 틀린 것도 적었다.
- [ ] [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웠다.
+181
View File
@@ -0,0 +1,181 @@
# 71일차 수강 안내 · 수업 준비부터 마칠 때까지
- 일자: 2026-10-23 (금) · 71일차
- 주제: RAG 기반 검색·응답 Workflow와 검색 품질 개선
- 실습 폴더: [Last3Mon/71](README.md) · 강의 자료: [Lecture/71](../../Lecture/71/README.md)
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`, 임베딩 `text-embedding-3-large`, 같은 Credential) + **71일차 판 데이터 API**(규정 문서 `/docs`) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
```mermaid
flowchart LR
P["전날<br/>준비 · 71 데이터 API"] --> M["아침<br/>켜기 · doctor"] --> L1["1~2교시<br/>Lab 01<br/>정답표 · 색인 · 검색"] --> L2["3~4교시<br/>Lab 02<br/>근거로 답하기"] --> L3["5~6교시<br/>Lab 03<br/>재기 · 고치기"] --> V["7~8교시<br/>tdoc"] --> E["마친 뒤<br/>남기기 · 72 준비"]
```
## 0. 하루 한눈에
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
| --- | --- | --- | --- | --- |
| 전날 | 약 5분 | 자료 받기 · n8n · 71일차 데이터 API · doctor | [PREP](../../Lecture/71/00-DAY71-PREP.md) | `ready` |
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (RAG와 색인) | [개요](../../Lecture/71/00-DAY71-OVERVIEW.md), [1단계](../../Lecture/71/01-STAGE1-RAG-AND-INDEX.md) | |
| 2 | 10:00~10:50 | Lab 01 예제 1 · 2 · 과제 | [Lab 01](LAB-01-Q-INDEX-AND-SEARCH.md) | `lab01-challenge` 통과 |
| 3 | 11:00~11:50 | 2단계 강의 (근거로 답하기) · Lab 02 예제 1 · 2 | [2단계](../../Lecture/71/02-STAGE2-RAG-ANSWER.md), [Lab 02](LAB-02-Q-ANSWER-FROM-POLICIES.md) | `lab02` 통과 |
| 4 | 12:00~12:50 | Lab 02 과제 35분 (점수는 높은데 답이 없을 때) · 풀이 | Lab 02 | `lab02-challenge` 통과 |
| - | 13:00~14:00 | 점심 | | |
| 5 | 14:00~14:50 | 3단계 강의 (검색 품질) · Lab 03 예제 1 · 2 | [3단계](../../Lecture/71/03-STAGE3-RETRIEVAL-QUALITY.md), [Lab 03](LAB-03-Q-MEASURE-AND-IMPROVE.md) | 지표 셋 · 바꾼 설정별 숫자 |
| 6 | 15:00~15:50 | Lab 03 과제 40분 (어려운 질문으로 고치기) · 풀이 | Lab 03 | 개선 전·후 · 보고서 |
| 7 | 16:00~16:50 | 바이브 코딩 `tdoc`: 요청서 → 내 SPEC → 만들기 | [VIBE-REQ-TDOC](VIBE-REQ-TDOC.md) → [VIBE-Q-TDOC](VIBE-Q-TDOC.md) | `SPEC.md` · R1~R5 · `pytest` |
| 8 | 17:00~17:50 | 발표 · 강사 정리 · 강사 바이브 코딩 · 팁 · 72일차 예고 | [진행](../../Lecture/71/04-VIBE-CODING.md) | |
## 1. 전날 준비 (약 5분)
### 1.1 자료 받기
```bash
cd ~/aio-01d4-stu && git pull
```
### 1.2 n8n과 **71일차** 데이터 API 켜기 (Ollama는 끔)
```bash
sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔
cd ~/aio-01d4-stu/Last3Mon/65/n8n
docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감)
docker compose start
cd ~/aio-01d4-stu/Last3Mon/71/data-api
docker compose up -d --force-recreate # 67일차 데이터 API를 71일차 판(+ 규정 문서)으로 바꿈
curl -s localhost:8800/docs | head -c 120 # {"docs": [{"name": "device-issue-return", ...
```
- LLM과 임베딩 모두 **OpenAI API 키**(`Last3Mon/.env`) 하나로 씁니다. n8n은 `start`로 켭니다.
- 데이터 API는 67일차와 이름이 같아 하나만 돕니다. 71일차 폴더에서 `--force-recreate`로 다시 만들어야 `/docs`가 생깁니다.
### 1.3 준비 확인
```bash
cd ~/aio-01d4-stu/Last3Mon/71
uv run tools/flow.py doctor # 마지막 줄 ready
```
### 1.4 미리 읽기 (선택, 5분)
[PREP 3절](../../Lecture/71/00-DAY71-PREP.md): "비밀번호는 며칠마다 바꿔야 하나요?"를 LLM에게 그냥 물으면 그럴듯한 숫자를 말합니다. 우리 규정(90일)으로 답하게 하려면?
### 준비물
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 7교시 `tdoc`에 쓸 내 md 문서 폴더 (없으면 이 과정의 `Lecture/` 폴더).
## 2. 수업 당일 아침 (09:00 전)
1. 노트북을 껐다 켰다면 1.2를 다시 합니다.
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/71`. ① 에이전트, ② 확인.
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
4. `my-work/PROMPTS.md`를 만들어 둡니다.
## 3. 실습할 때마다 같은 순서
```mermaid
flowchart LR
A["① 기대 결과 읽기<br/>cases/*.json"] --> B["② 요청문을<br/>메모장에 먼저"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
E -->|✔| G["⑦ PROMPTS.md"]
```
- **오늘만의 주의: 메모리 저장소는 n8n이 재시작되면 지워집니다.** `push`할 때마다 n8n이 재시작되므로 색인을 다시 해야 합니다.
- `test`는 케이스 파일의 `before` 단계가 색인을 다시 해 줍니다.
- 직접 `call` 할 때는 먼저 `uv run tools/flow.py call my-index requests/index-section.json -v`.
- Lab 03에서 색인 설정을 바꿔 가며 잴 때는 `--no-before`로 내가 만든 색인을 그대로 씁니다.
- 바꾸는 것은 **한 번에 하나**, 바꿀 때마다 같은 질문 목록으로 다시 잽니다.
## 4. 1~2교시 · 1단계 · 정답표, 색인, 검색
[Lab 01](LAB-01-Q-INDEX-AND-SEARCH.md) (약 40분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (10분, 에이전트 없이) | `cp templates/RAG-EVAL-TEMPLATE.md my-work/RAG-EVAL.md` 후 질문 6개의 정답표(답 없는 질문 포함)를 **직접** (`curl -s localhost:8800/docs`, `data-api/data/docs/`) | | 표 |
| 예제 2 (15분) | 색인: 규정 문서를 섹션(`## `)마다 청크 → 임베딩 → 메모리 저장소. 검색: 질문과 가까운 청크 `top_k`개 | `my-work/index.json` · `my-index`, `my-work/search.json` · `my-search` | `push my-work/index.json my-work/search.json` → `call my-index requests/index-section.json`, `call my-search requests/q-password-length.json`, `test cases/lab01.json` |
| 과제 (15분) | 출처를 섹션 제목까지, 찾을 개수는 부르는 쪽에서 | 두 파일 고치기 | `test cases/lab01-challenge.json`, `test cases/lab01.json` |
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
- 볼 것: 정답표와 검색 1등이 같은지, "연차 휴가"(규정에 없는 질문)의 1등 점수가 다른 질문보다 높은지 낮은지. `RAG-EVAL.md` 2절에 적습니다 (Lab 02의 기준값에 씀).
## 5. 3~4교시 · 2단계 · 규정으로만 답하기, 모르면 넘기기, 답 검사
[Lab 02](LAB-02-Q-ANSWER-FROM-POLICIES.md) (약 70분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (20분) | 질문 → 가까운 청크 3개 → 번호 붙인 발췌 → LLM이 **발췌로만** 답하고 번호 인용 → 번호를 문서 제목으로, `cited` | `my-work/policy-qa.json` · `my-policy-qa` | `push my-work/policy-qa.json` → `test cases/lab02.json` (앞 네 케이스) |
| 예제 2 (15분) | 1등 점수가 기준값 아래면 LLM을 부르지 않고 담당자 연결 (`min_score`로도 받기) | 같은 파일 | 먼저 `call my-policy-qa requests/q-off-topic.json`의 `top_score` 보기 → `test cases/lab02.json` 전체 |
| 과제 (35분, 4교시) | "규정에서 찾지 못했다"는 답은 자동 답변으로 내보내지 않기, 답의 숫자가 규정에 정말 있는지 검사 | 같은 파일 | 고치기 전 `test cases/lab02-challenge.json` → 고친 뒤 `--repeat 3`, `test cases/lab02.json` |
- `RAG-EVAL.md` 3절에 "기준값에 걸러지지 않았는데 답이 없는 질문"을 적습니다.
## 6. 5~6교시 · 3단계 · 검색 품질을 숫자로 재고, 한 가지씩 고치기
[Lab 03](LAB-03-Q-MEASURE-AND-IMPROVE.md) (약 80분).
| 지표 | 테스트 파일 | 뜻 |
| --- | --- | --- |
| doc@1 | `cases/retrieval-doc.json` | 1등 청크가 맞는 문서에서 왔나 |
| answer@1 | `cases/retrieval-answer.json` | 1등 청크 안에 답 문구가 있나 |
| answer@3 | `cases/retrieval-answer3.json` | 3등 안에 답 문구가 있나 |
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (15분) | 섹션 청크로 기준 재기 | `call my-index requests/index-section.json` → 위 세 파일을 `test ... --no-before` |
| 예제 2 (25분) | 청크 크기를 바꿔 가며 재기 (200자 · 400자), 기준값 테스트 | `call my-index requests/index-200.json` → `test cases/retrieval-answer.json --no-before`, `index-400`도 같게, 섹션으로 되돌린 뒤 `test cases/lab03-threshold.json --no-before` |
| 과제 (40분, 6교시) | 직원 말투 질문('챗GPT', '폰', '비번') 6개 이상 `cases/my-cases.json`, 검색 고치기. 지금 잘 되는 문항이 나빠지면 안 됨 | 고치기 전 `test cases/my-cases.json` → `push my-work/search.json` → `test cases/my-cases.json`, `test cases/retrieval-answer.json` |
- 개선 전·후와 **새로 틀린 것**도 적습니다. 보고서: `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md`.
## 7. 7~8교시 · 바이브 코딩 `tdoc`
[VIBE-REQ-TDOC](VIBE-REQ-TDOC.md)(요청서)를 **먼저** 읽고 `~/vibe/tdoc/SPEC.md`를 에이전트 없이 직접 쓴 뒤, [VIBE-Q-TDOC](VIBE-Q-TDOC.md)와 비교합니다. 만드는 것은 내 md 문서 폴더를 색인하고 근거와 함께 답하는 TUI (SQLite 하나에 저장). 정답본 `VIBE-A-TDOC.md`는 수업이 끝나면 `git pull`로 받습니다.
```bash
mkdir -p ~/vibe/tdoc && cd ~/vibe/tdoc
git init
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-) # 처음부터 필요 (임베딩)
# 에이전트는 SPEC.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
```
| 시간 | 단계 | 확인 |
| --- | --- | --- |
| 16:00~16:12 | 0. 준비 · 요청서 → `SPEC.md` (에이전트 없이 10분) → VIBE-Q와 비교 · commit | 요구마다 확인 방법, 비교한 줄 |
| 16:12~16:25 | 1. 색인과 검색 (R1 · R2) | 두 번째 색인은 바뀐 파일만, 점수 · 파일 · 섹션과 함께 `top_k`개 |
| 16:25~16:35 | 2. 답과 근거 (R3) | 근거 번호, 근거가 없으면 모른다고 |
| 16:35~16:45 | 3. TUI와 비교 (R4 · R5) | 근거를 고르면 원문, 같은 질문 목록으로 두 설정 비교 |
| 16:45~16:50 | 4. 정리 | `SPEC.md` 체크, `uv run pytest -q`(가짜 임베딩), `PROMPTS.md` |
- 8교시: 1~2명 발표, 강사 정리 · 시연 · 팁, 72일차 예고.
## 8. 마친 뒤
| 할 일 | 이유 |
| --- | --- |
| `PROMPTS.md`, `RAG-EVAL.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (RAG 측정표) |
| `my-work/*.json`, `~/vibe/tdoc` 지우지 않기 | 72일차 이후 최적화 · 측정 연습 |
| 데이터 API는 71일차 판을 그대로 켜 두기 | 67일차 FAQ · 접수 현황도 들어 있음 |
| 72일차 준비: [00-DAY72-PREP](../../Lecture/72/00-DAY72-PREP.md) 1~2절 | **72일차 판 데이터 API `--force-recreate`** · 기준 워크플로우 게시 · doctor |
## 9. 막혔을 때 (오늘 공통)
| 증상 | 할 일 |
| --- | --- |
| `curl localhost:8800/docs`가 404 | 67일차 판이 켜져 있음. 1.2의 71일차 `--force-recreate` |
| 검색 결과가 비어 있음 · `hits: []` | push로 n8n이 재시작되어 색인이 지워짐. `call my-index requests/index-section.json` |
| Lab 03 숫자가 설정을 바꿔도 같음 | `--no-before`를 빠뜨려 `before`가 섹션 색인으로 되돌림 |
| doctor의 credential ✘ | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` |
| `status 500`이 30초 넘게 | OpenAI 쪽 일시 오류일 수 있음. 한 번 더 |
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
## 확인
- [ ] 전날 71일차 데이터 API, `doctor` → `ready`
- [ ] Lab 01: 정답표, `lab01`, `lab01-challenge`
- [ ] Lab 02: `lab02`, `lab02-challenge`, `RAG-EVAL.md` 3절
- [ ] Lab 03: doc@1 · answer@1 · answer@3, 200자 · 400자, 기준값, 개선 전·후, 보고서
- [ ] `tdoc`: `SPEC.md` 먼저, R1~R5, `pytest`, 단계마다 commit
- [ ] 72일차 준비
+85
View File
@@ -0,0 +1,85 @@
# 71일차 실습 · RAG 기반 규정 검색·응답과 검색 품질 개선 (바이브 코딩)
- 일자: 2026-10-23 (71일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 강의 자료: [Lecture/71](../../Lecture/71/README.md)
- 하루 수강 안내: [LECTURE-71.md](LECTURE-71.md) (수업 준비부터 마칠 때까지 순서대로)
- 67~70일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다.
## 오늘 만드는 것: 사내 IT 규정 Q&A
사내 IT 규정 문서 8개(25개 섹션)를 잘게 나누어 임베딩하고, 질문과 뜻이 가까운 부분을 찾아 **그 내용으로만** 답합니다. 검색이 잘 되는지는 질문 24개로 **숫자로** 재고, 한 가지씩 바꿔 가며 고칩니다.
```mermaid
flowchart LR
subgraph IDX["색인 my-index (가끔)"]
D[("data-api /docs<br/>규정 8개")] --> C["청크<br/>섹션 25개"] --> E["임베딩<br/>text-embedding-3-large"] --> V[("메모리 저장소<br/>my_policy_docs")]
end
subgraph QA["질문할 때마다 my-policy-qa"]
Q(["질문"]) --> S["가까운 청크 3개<br/>(my-search)"] --> R{"관련 있나?<br/>≥ 0.33"}
R -->|예| L{{"발췌로만 답<br/>gpt-4.1-mini"}} --> K["답 검사"] --> A(["답 + 출처"])
R -->|아니오| H(["담당자 연결"])
K -->|실패| H
end
V --> S
```
## 일하는 방식 (67~70일차와 같음)
| 누가 | 무엇을 |
| --- | --- |
| 나 | 정답표(질문 → 문서·답 문구) 만들기, 요청문 쓰기, 한 가지씩 바꾸고 재기, 결과 판단 |
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
| `tools/flow.py` | check · push · call · test (`before`로 테스트 전에 색인, `--no-before`로 건너뛰기) |
> **메모리 저장소는 n8n이 재시작되면 지워집니다.** `push`할 때마다 n8n이 재시작되므로 색인을 다시 해야 합니다. 테스트 파일의 `before`가 이것을 해 줍니다. 직접 `call` 할 때는 먼저 `uv run tools/flow.py call my-index -v`.
## 실습 문서 읽는 법
- 실습 문서(`LAB-0x-Q-*.md`)는 요청문을 바로 주지 않습니다. 막히면 `▶` 를 위에서부터 하나씩 펼칩니다: 생각할 거리 → **빈칸 채우기**(요청문 뼈대의 `____`를 내가 채움) → **그래도 막히면**(빈칸을 채운 모양, 내 것과 비교). `VIBE-Q-TDOC.md`는 요청서 → 내 SPEC 순서입니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 둡니다.
- 바이브 코딩은 **요청서(`VIBE-REQ-TDOC.md`)부터** 읽고 요구(`SPEC.md`)를 직접 뽑은 뒤 `VIBE-Q-TDOC.md`와 비교합니다. 정답본 `VIBE-A-TDOC.md`는 수업이 끝나면 올라옵니다.
## 구성
```text
Last3Mon/71/
├── README.md
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 벡터 저장소 노드 5절)
├── LAB-01-Q-INDEX-AND-SEARCH.md # 1단계 · 정답표 → 색인 · 검색 → 섹션 출처 · top_k
├── LAB-02-Q-ANSWER-FROM-POLICIES.md # 2단계 · 발췌로만 답 · 출처 → 관련 없음 → 답 검사
├── LAB-03-Q-MEASURE-AND-IMPROVE.md # 3단계 · doc@1 · answer@1 · answer@3 → 한 가지씩 바꾸기 → 어려운 질문
├── VIBE-REQ-TDOC.md # 7교시 바이브 코딩 · 요청서 (가장 먼저, 여기서 SPEC.md를 뽑음)
├── VIBE-Q-TDOC.md # 7교시 바이브 코딩 · tdoc 요구 · 단계 (SPEC과 비교)
├── *-A-*.md # 정답본 (VIBE-A는 수업이 끝난 뒤 올라옴, LAB-A는 아직 없음)
├── tools/flow.py
├── data-api/ # 71일차 판 데이터 API (67일차 + 규정 문서 /docs)
│ └── data/docs/*.md # 규정 8개
├── cases/ # lab01 · lab01-challenge · lab02 · lab02-challenge · retrieval-doc · retrieval-answer · retrieval-answer3 · lab03-threshold
├── requests/ # 질문 15종, 색인 설정 3종
├── reference/ # 막혔을 때 이어 가는 워크플로우
├── templates/ # 검색 평가표 · 테스트 보고서
├── my-work/
└── runs/
```
## 준비
[실습 준비](../../Lecture/71/00-DAY71-PREP.md)를 끝내 주세요. 요약:
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate # 71일차 판으로 바꿈 (/docs 추가)
cd ~/aio-01d4-stu/Last3Mon/71 && uv run tools/flow.py doctor # 마지막 줄 ready
```
## `flow.py` 명령
| 명령 | 하는 일 | 수업 준비 때 |
| --- | --- | --- |
| `uv run tools/flow.py push my-work/index.json my-work/search.json` | 게시 (n8n 재시작 → 메모리 저장소 비워짐) | |
| `uv run tools/flow.py call my-index requests/index-section.json -v` | 색인 (섹션 25개) | 약 1.2초 |
| `uv run tools/flow.py call my-search requests/q-password-length.json -v` | 검색만 | 0.2~0.5초 |
| `uv run tools/flow.py test cases/retrieval-answer.json -v` | `before`로 색인한 뒤 24문항. `-v`는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 줌 (`call`도 같음) | 케이스당 0.2~0.5초 |
| `uv run tools/flow.py test cases/retrieval-answer.json -v --no-before` | 지금 색인 그대로 (설정 비교할 때) | |
+130
View File
@@ -0,0 +1,130 @@
# 7교시 바이브 코딩 · `tdoc` 만들기
> **먼저 [VIBE-REQ-TDOC.md](VIBE-REQ-TDOC.md)(요청서)를 읽고 `~/vibe/tdoc/SPEC.md`를 직접 쓴 뒤** 이 문서를 엽니다. 이 문서는 요청서에서 요구를 뽑아 낸 **다음 단계**입니다. 내 SPEC과 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다.
```mermaid
flowchart LR
REQ["VIBE-REQ<br/>요청서"] --> SPEC["내 SPEC.md<br/>10분"] --> Q["VIBE-Q (이 문서)<br/>비교 · 고치기"] --> B["단계별로 만들기"] --> A["수업 뒤<br/>VIBE-A"]
```
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), SQLite
- 결과물: **내 md 문서**를 임베딩해 SQLite에 저장하고, 질문하면 답과 **근거 문단**을 보여 주는 RAG TUI. `top_k`, 청크 크기를 바꿔 가며 결과를 비교합니다. 오늘 n8n으로 한 일을 내 문서(이 과정의 `Lecture/` 교안 등)에 직접 해 봅니다.
```text
$ tdoc ~/aio-01d4-stu/Lecture
┌ tdoc · 문서 N · 청크 M · section ───────────────────────────────┐
│ 질문: [ 메모리 저장소는 언제 지워져요? ] top_k [3] │
│ ┌ 답 ────────────────────────────────────────────────────────────┐ │
│ │ n8n이 재시작되면 지워집니다 [1]. push할 때마다 … │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ ┌ 근거 ──────────────────────────────────────────────────────────┐ │
│ │ [1] 0.xx Lecture/71/01-STAGE1-RAG-AND-INDEX.md · 5. 메모리 저장소 │ │
│ │ [2] 0.xx Lecture/71/00-DAY71-PREP.md · 1. n8n과 데이터 API │ │
└──────────────────────────────────────────────────────────────────┘
```
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
## 요구 사항 (요청서에서 뽑은 것)
| 번호 | 요구 | 요청서의 말 | 확인 방법 |
| --- | --- | --- | --- |
| R1 | 폴더의 `*.md`를 청크로 나눠(섹션 또는 글자 수) 임베딩하고 SQLite 파일 하나에 저장한다. 바뀐 파일만 다시 임베딩한다 | ① 폴더 통째로, 매번 처음부터 다시 읽지 않기, 껐다 켜도 남기 | `tdoc index <폴더>` 두 번 (두 번째는 임베딩 0번), 가짜 임베딩 테스트의 호출 수 |
| R2 | 질문하면 가까운 청크 `top_k`개를 점수·파일·섹션과 함께 보여 준다 | ② 어느 파일 어느 부분, 얼마나 가까운지, 몇 개까지 | `tdoc search "..." --top-k 5` → 5줄, 줄마다 점수 · 파일 · 섹션 |
| R3 | 그 청크들로만 답을 쓰고 `[번호]`로 근거를 단다. 1등 점수가 기준 아래면 LLM을 부르지 않고 모른다고 한다 | ③ 문서 내용으로만, 어디서 나왔는지, 지어내지 말고 모른다고 | `tdoc ask "..."`, 문서에 없는 질문 → "문서에서 찾지 못했습니다" (가짜 LLM 호출 0번) |
| R4 | TUI: 질문 입력, 답, 근거 목록. 근거를 고르면 원문 문단 전체 | ④ 찾은 부분 목록, 고르면 원문 전체 | `tdoc <폴더>` |
| R5 | 청크 설정을 바꿔 **같은 질문 목록**으로 비교한다 (질문 · 기대 파일 → 맞은 수) | ⑤ 자르는 크기를 바꿔 숫자로 | `tdoc eval questions.yaml --chunking 200` → 설정마다 "몇 개 중 몇 개" |
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
| 숨은 요구 | 어디서 나왔나 |
| --- | --- |
| 청크마다 **파일 경로 · 섹션 제목 · 글 · 벡터**를 저장하고, 파일마다 **내용 해시 · 청크 설정**을 저장한다 | ② "어느 파일 어느 부분", ① "바뀐 파일만" |
| 테스트는 **가짜 임베딩 · 가짜 LLM**으로 (진짜를 부르지 않음). 임베딩 · LLM을 바꿔 끼울 수 있게 | "테스트하느라 돈이 나가는 건 싫어요" |
| LLM이 붙인 `[번호]`가 발췌 범위 밖이면 **코드가** 버린다 | ③ "어디서 나왔는지" — 없는 근거를 붙이면 지어낸 것과 같음 (오늘 Lab 02 `cited`는 코드가 만듦) |
| 비교하기 전에 **질문 · 기대 파일 정답표**(`questions.yaml`, 5개 이상)를 먼저 쓴다 | ⑤ "숫자로" (오늘 Lab 01 예제 1) |
| 청크 설정마다 **DB 파일을 따로** (두 설정이 한 DB에 섞이지 않게) | ⑤ 크기를 바꿔 비교 |
| 키는 환경 변수에서만 읽고 코드 · SQLite에 남기지 않는다 | "키가 코드나 저장 파일에 찍히면 안 돼요" |
| 임베딩 · LLM을 부르는 동안 화면이 멈추지 않는다 | ④ (색인 수십 초, 답 수 초) |
범위 밖 (오늘 하지 않음): PDF · 워드 읽기, 여러 사람이 함께 쓰는 서버, 벡터 DB 설치, n8n과 직접 연결하기. (강사 바이브 코딩에서 `tdoc serve`로 n8n에서 부르기를 보여 줄 수 있음)
**비교해 보기**: 내 `SPEC.md`에서 빠진 요구, 내가 더 뽑은 요구, 다르게 정한 순서를 `SPEC.md` 끝에 한 줄씩 적고, 필요하면 내 SPEC을 고칩니다. 아래 단계부터는 **고친 내 SPEC.md**를 기준으로 요청문을 씁니다.
알아 둘 것:
- 임베딩 `text-embedding-3-large`, 답 `gpt-4.1-mini`, 키는 `OPENAI_API_KEY` 환경 변수. **키를 코드에 쓰지 않습니다.**
- 벡터 검색은 SQLite에 숫자 목록(벡터)을 저장하고 파이썬에서 코사인 유사도를 계산해도 충분합니다 (문서 수백 개 규모).
- 오늘 Lab의 `cases/retrieval-*.json`이 R5의 질문 목록 모양입니다.
## 진행 (50분)
```mermaid
flowchart LR
S0["0. 준비 · 요청서 → SPEC<br/>12분"] --> S1["1. 색인 · 검색<br/>R1·R2 · 13분"] --> S2["2. 답 · 근거<br/>R3 · 10분"] --> S3["3. TUI · 비교<br/>R4·R5 · 10분"] --> S4["4. 정리<br/>5분"]
```
### 0. 준비 · 요청서 → SPEC (12분)
```bash
mkdir -p ~/vibe/tdoc && cd ~/vibe/tdoc
git init
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-)
# 에이전트는 SPEC.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
```
1. **에이전트는 아직 켜지 않습니다.** [VIBE-REQ-TDOC.md](VIBE-REQ-TDOC.md)를 읽고 `SPEC.md`를 직접 씁니다 (10분).
2. 이 문서의 요구 표 · 숨은 요구와 비교해 `SPEC.md`를 고치고 `git commit`.
3. 그다음 에이전트를 켭니다: `codex` (또는 `claude`, `agy`). 첫 요청은 "`SPEC.md`를 읽고, 1단계를 만들기 전에 확인 방법부터 말해 줘".
### 1. 색인과 검색 (13분)
<details><summary>▶ 1단계 · 저장할 것</summary>
청크마다 무엇을 저장해야 검색 결과를 보여 줄 수 있나요? (파일 경로, 섹션 제목, 글, 벡터) 그리고 "바뀐 파일만 다시"를 하려면 파일마다 무엇을 기억해야 할까요?
</details>
<details><summary>▶ 2단계 · 테스트에서 진짜 임베딩을 부르지 않게</summary>
`tman`의 번역기, `tcrew`의 LLM처럼 임베딩도 바꿔 끼울 수 있게 합니다. 가짜 임베딩(예: 낱말 수를 센 벡터)만으로도 "같은 낱말이 많은 청크가 1등"을 테스트할 수 있습니다.
</details>
**확인**: `uv run tdoc index ~/aio-01d4-stu/Lecture/71`, 다시 한 번(빠른가), `uv run tdoc search "메모리 저장소"`, `pytest`, `git commit`.
### 2. 답과 근거 (10분)
<details><summary>▶ 1단계 · 오늘 n8n에서 배운 것 그대로</summary>
발췌로만 답하기, 번호로 근거, 모르면 모른다고, 점수가 낮으면 LLM을 부르지 않기. 요청문에 이 네 가지가 있나요? Lab 02 과제의 "점수는 높은데 답이 없을 때"도 생각해 봅니다.
</details>
### 3. TUI와 비교 (10분)
<details><summary>▶ 1단계 · 비교는 같은 질문으로</summary>
`questions.yaml`(질문 · 기대 파일)을 직접 5개 써 봅니다. 청크 설정을 바꿔 맞은 수를 표로 보여 주게 합니다. 오늘 Lab 03과 같습니다: 한 번에 하나만 바꾸기.
</details>
**확인**: `uv run tdoc ~/aio-01d4-stu/Lecture`, 근거 선택 → 원문, `tdoc eval`로 두 설정 비교, `git commit`.
### 4. 정리 (5분)
`SPEC.md` 1절 표의 확인 방법대로 하나씩 체크하고, 발표 메모를 씁니다.
| 발표 (3분) | 내용 |
| --- | --- |
| SPEC | 내 SPEC과 VIBE-Q가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
| 막힌 곳 | 다시 색인이 느림, 근거 번호가 틀림 등 어떻게 알아챘고 고쳤나 |
| 시연 | 교안 폴더에 질문 하나, 두 설정의 비교 표 |
## 확인
- [ ] `SPEC.md`를 에이전트 없이 먼저 쓰고, VIBE-Q와 비교한 줄을 남겼다.
- [ ] R1 SQLite 하나에 저장, 두 번째 색인은 바뀐 파일만.
- [ ] R2 점수 · 파일 · 섹션과 함께 `top_k`개.
- [ ] R3 근거 번호, 근거가 없으면 모른다고.
- [ ] R4 TUI에서 근거를 고르면 원문.
- [ ] R5 같은 질문 목록으로 두 설정 비교.
- [ ] `pytest`(가짜 임베딩), 단계마다 git commit, `PROMPTS.md`.
+98
View File
@@ -0,0 +1,98 @@
# 7교시 바이브 코딩 · 요청서 `tdoc` (가장 먼저 읽기)
- 순서: **이 요청서** → 내가 뽑은 요구 `~/vibe/tdoc/SPEC.md` (10분) → [VIBE-Q-TDOC.md](VIBE-Q-TDOC.md)와 비교 → 단계별로 만들기 → 수업이 끝나면 `VIBE-A-TDOC.md`(정답본)로 확인
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 10분의 일입니다. 68 · 69일차와 같은 순서입니다.
```mermaid
flowchart LR
REQ["요청서<br/>(사람의 말)"] --> SPEC["SPEC.md<br/>확인할 수 있는 요구<br/>숨은 요구 · 범위 밖 · 순서"] --> Q["VIBE-Q와 비교<br/>빠뜨린 것 · 더 뽑은 것"] --> P["단계별 요청문<br/>SPEC.md를 에이전트에게"] --> RUN["실행 · 확인<br/>직접 uv run"] --> A["수업 뒤<br/>VIBE-A로 확인"]
RUN -->|다음 단계| P
```
## 왜 요청서부터인가
VIBE-Q 문서를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 말을 확인할 수 있는 요구로 바꾸는 일은 에이전트가 아니라 내가 합니다. 오늘 오전 Lab 01 예제 1에서 검색을 만들기 전에 **정답표**를 먼저 쓴 것과 같은 일입니다.
| 단계 | 누가 | 결과 |
| --- | --- | --- |
| 요청 → 요구 | **나** | `SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
| 요구 → 단계 | **나** | 작게 나눈 순서 (무엇부터 만들고 확인할지) |
| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `SPEC.md`를 함께 읽게 함 |
| 확인 | **나** | 직접 `uv run tdoc …`, `pytest`, 요구 표에 체크 |
## 요청 (IT 문의 창구 담당자에게서 온 메시지, 그대로)
> 오늘 n8n으로 만든 규정 Q&A 잘 봤어요. 그런데 저희 팀 업무 안내랑 교육 자료는 규정 말고도 md 파일로 폴더에 잔뜩 쌓여 있거든요. n8n은 다시 켜면 색인이 날아간다면서요? 제 노트북 터미널에서 **제 폴더로 바로 물어보는** 작은 프로그램이 있으면 좋겠어요.
>
> ① 폴더를 통째로 넣고 싶어요. 그런데 파일이 자주 **조금씩** 바뀌어요. 한 줄 고쳤다고 **매번 처음부터 다시 읽느라 오래 걸리고 돈이 나가면** 곤란해요. 그리고 노트북을 껐다 켜도 남아 있어야 해요.
>
> ② 물어보면 **어느 파일 어느 부분**에서 찾았는지, **얼마나 가까운지** 보고 싶어요. 몇 개까지 볼지는 제가 정할게요.
>
> ③ 답은 **문서에 있는 내용으로만**, 어디서 나왔는지 붙여서 주세요. 문서에 없는 걸 물으면 **지어내지 말고 모른다고** 해야 해요. 그럴듯하게 지어낸 답이 제일 무서워요.
>
> ④ 답 옆에 찾은 부분 목록이 있고, 하나 고르면 그 부분 **원문 전체**를 화면에서 바로 읽을 수 있으면 좋겠어요.
>
> ⑤ 문서를 자르는 크기를 바꿔 보고 싶은데, "좋아진 것 같다"는 말고 **숫자로** 비교하고 싶어요. 오늘 Lab 03에서 하신 것처럼요.
>
> 키는 이미 환경 변수에 넣어 뒀어요. 그런데 **키가 코드나 저장 파일에 찍히면 안 돼요.** 그리고 프로그램을 고칠 때마다 테스트하느라 돈이 나가는 건 싫어요.
## 함께 받은 것
| 파일 · 자료 | 메모 |
| --- | --- |
| `~/aio-01d4-stu/Lecture/` (또는 내 md 폴더) | 넣어 볼 문서 폴더. 일차마다 `##` 섹션이 있는 md 여러 개 |
| `~/aio-01d4-stu/Last3Mon/71/cases/retrieval-doc.json` | 오늘 Lab 03의 "질문 · 기대 문서" 목록 모양 |
| 오늘 만든 `my-work/policy-qa.json` (Lab 02) | 발췌 · 번호 · 기준값 · "찾지 못했습니다"를 어떻게 했는지 |
| [AGENTS.md](AGENTS.md) 3절 | n8n 색인 · 검색 · Q&A의 계약 (`hits`의 `source` · `section` · `score`, `top_score`) |
- LLM 키는 `OPENAI_API_KEY` 환경 변수, 임베딩 `text-embedding-3-large`, 답 `gpt-4.1-mini`.
## 할 일 · 요청을 요구로 (10분, 에이전트 없이)
`~/vibe/tdoc/SPEC.md`를 만들고 아래 네 가지를 씁니다. **에이전트에게 맡기지 않고 직접** 씁니다 (나중에 에이전트에게 읽힐 문서입니다).
```markdown
# tdoc SPEC
## 1. 요구 (확인할 수 있게)
| 번호 | 요구 | 요청의 어느 말 | 확인 방법 (파일 · 숫자 · 화면) |
| --- | --- | --- | --- |
| R1 | | ① | |
## 2. 숨은 요구 (요청에 직접 없지만 없으면 안 되는 것)
## 3. 범위 밖 (오늘 50분 안에 하지 않을 것)
## 4. 만드는 순서 (그리고 그 이유)
```
예 하나만 보여 드립니다. 나머지는 직접 바꿔 봅니다.
| 요청의 말 | 그대로 요청문에 넣으면 | 확인할 수 있는 요구로 |
| --- | --- | --- |
| ① "한 줄 고쳤다고 매번 처음부터 다시 읽느라 오래 걸리고 돈이 나가면 곤란해요" | "색인을 빠르게 해 줘" → 무엇이 되든 "됐다"가 됨 | **파일마다 내용 해시를 저장하고, 해시가 같은 파일은 다시 임베딩하지 않는다.** 확인: 가짜 임베딩으로 파일 2개를 색인 → 바로 다시 색인하면 임베딩 호출 **0번**, 한 파일만 고치면 **그 파일의 청크 수만큼만** 호출 |
<details><summary>▶ 1단계 · "얼마나 가까운지", "모른다고", "숫자로"를 어떻게 확인하나</summary>
형용사와 부탁은 확인할 수 없습니다. "몇 번 불리면", "어느 파일에 무엇이 남으면", "화면에 무엇이 몇 개 보이면"으로 바꿉니다. ③ "모른다고"는 **언제** 모른다고 하나요? 오늘 Lab 02에서 LLM을 부르기 전에 무엇을 보고 넘겼는지 떠올립니다. 그리고 모른다고 할 때 화면에 **어떤 문장**이 나와야 확인할 수 있나요?
</details>
<details><summary>▶ 2단계 · 숨은 요구</summary>
- ②를 보여 주려면 청크마다 **무엇을 저장**해 두어야 하나요? (파일, 섹션, 글, 벡터 …) ①의 "바뀐 파일만"을 하려면 파일마다 무엇을 기억해야 하나요?
- "테스트하느라 돈이 나가는 건 싫어요" → 테스트에서 진짜 임베딩 · 진짜 LLM을 부르지 않으려면 무엇이 있어야 하나요?
- ③ "어디서 나왔는지 붙여서": LLM이 없는 번호(`[7]`)를 붙이면 어떻게 하나요? 그것은 누가 확인하나요?
- ⑤ "숫자로 비교"하려면 프로그램보다 먼저 **무엇**이 있어야 하나요? (오늘 Lab 01 예제 1)
- ⑤ 크기를 바꿔 색인하면, 앞서 다른 크기로 만든 색인은 어떻게 되나요? 두 설정이 섞이면 비교가 될까요?
- 임베딩 · LLM을 부르는 동안 화면은 어떻게 되어야 할까요?
</details>
<details><summary>▶ 3단계 · 범위 밖과 순서</summary>
50분입니다. PDF · 워드 파일 읽기, 여러 사람이 함께 쓰는 서버, 벡터 DB 설치, n8n과 직접 연결하기는 요청에 없습니다. 순서는 "무엇을 먼저 만들면 나머지가 그것을 다시 쓰나"로 정합니다. 답(③) · 화면(④) · 비교(⑤)가 모두 **같은 것**(색인과, 질문으로 찾은 청크 목록)을 씁니다.
</details>
## 그다음
1. [VIBE-Q-TDOC.md](VIBE-Q-TDOC.md)를 열어 내 `SPEC.md`와 비교합니다. 빠뜨린 요구, 내가 더 뽑은 요구, 다르게 정한 순서를 `SPEC.md` 끝에 한 줄씩 적습니다 (8교시 발표 거리).
2. 단계마다 요청문을 쓸 때 "`SPEC.md`를 읽고 4절의 1단계만 해 줘"처럼 **내 SPEC을 기준으로** 시킵니다. VIBE-Q 문서를 통째로 붙여 넣지 않습니다.
3. 단계가 끝날 때마다 `SPEC.md` 1절 표의 확인 방법대로 **내가** 확인하고 체크합니다.
4. 수업이 끝나면 `git pull`로 정답본 `VIBE-A-TDOC.md`를 받아, 요구 도출 예시와 요청문 예를 내 것과 비교합니다.
+58
View File
@@ -0,0 +1,58 @@
{
"webhook": "my-search",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "섹션 · 비밀번호 길이",
"request": "requests/q-password-length.json",
"expect": {
"contains": {
"hits.0.section": [
"비밀번호 만들기"
]
}
}
},
{
"name": "섹션 · 변경 주기",
"request": "requests/q-password-cycle.json",
"expect": {
"contains": {
"hits.0.section": [
"변경 주기"
]
}
}
},
{
"name": "top_k 5",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요",
"top_k": 5
},
"expect": {
"equals": {
"hits.4.rank": 5
}
}
},
{
"name": "top_k 1",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요",
"top_k": 1
},
"expect": {
"equals": {
"hits.1": null,
"hits.0.rank": 1
}
}
}
]
}
+69
View File
@@ -0,0 +1,69 @@
{
"webhook": "my-search",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "색인 · 청크 수",
"webhook": "my-index",
"request": "requests/index-section.json",
"expect": {
"equals": {
"indexed_chunks": 25
}
}
},
{
"name": "비밀번호 길이 → 비밀번호 규정",
"request": "requests/q-password-length.json",
"expect": {
"equals": {
"hits.0.source": "password-policy"
},
"contains": {
"hits.0.text": [
"12자"
]
}
}
},
{
"name": "퇴사 노트북 → 장비 규정",
"request": "requests/q-return-laptop.json",
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "첨부 파일 → 메일 규정",
"request": "requests/q-big-attachment.json",
"expect": {
"equals": {
"hits.0.source": "email-and-documents"
}
}
},
{
"name": "결과 3개",
"request": "requests/q-virus.json",
"expect": {
"equals": {
"hits.2.rank": 3
}
}
},
{
"name": "질문 없음",
"request": "requests/q-empty.json",
"expect": {
"status": 400
}
}
]
}
+98
View File
@@ -0,0 +1,98 @@
{
"webhook": "my-policy-qa",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "점수는 높은데 규정에 없음 (VPN 속도)",
"request": "requests/q-vpn-slow.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수는 높은데 규정에 없음 (노트북 사양)",
"request": "requests/q-laptop-spec.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수는 높은데 규정에 없음 (비밀번호 잊음)",
"request": "requests/q-forgot-password.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수는 높은데 규정에 없음 (보안 교육)",
"request": "requests/q-security-training.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "점수가 낮아 걸러짐 (연차)",
"request": "requests/q-not-in-policies.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "틀린 전제 (30일)",
"request": "requests/q-wrong-premise.json",
"expect": {
"contains": {
"answer": [
"90일"
]
},
"not_contains": {
"answer": [
"30일마다 바꾸면 됩니다"
]
}
}
},
{
"name": "근거 있는 답은 그대로",
"request": "requests/q-password-cycle.json",
"expect": {
"equals": {
"route": "자동 답변",
"failed": []
},
"contains": {
"answer": [
"90일"
]
}
}
},
{
"name": "검사 기록",
"request": "requests/q-virus.json",
"expect": {
"contains": {
"checks": [
"grounded"
]
}
}
}
]
}
+94
View File
@@ -0,0 +1,94 @@
{
"webhook": "my-policy-qa",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호 주기 · 답과 출처",
"request": "requests/q-password-cycle.json",
"expect": {
"equals": {
"route": "자동 답변"
},
"contains": {
"answer": [
"90일"
],
"cited": [
"password-policy"
]
}
}
},
{
"name": "퇴사 노트북",
"request": "requests/q-return-laptop.json",
"expect": {
"contains": {
"answer": [
"5영업일"
],
"cited": [
"device-issue-return"
]
}
}
},
{
"name": "생성형 AI",
"request": "requests/q-ai-customer-data.json",
"expect": {
"contains": {
"cited": [
"software-usage"
]
},
"not_contains": {
"answer": [
"넣어도 됩니다"
]
}
}
},
{
"name": "출처는 제목으로",
"request": "requests/q-big-attachment.json",
"expect": {
"contains": {
"answer": [
"[메일·문서 보안 규정]",
"25MB"
]
},
"not_contains": {
"answer": [
"[1]",
"[2]",
"[3]"
]
}
}
},
{
"name": "점심 메뉴 · 담당자 연결",
"request": "requests/q-off-topic.json",
"expect": {
"equals": {
"route": "담당자 연결",
"cited": []
}
}
},
{
"name": "질문 없음",
"request": "requests/q-empty.json",
"expect": {
"status": 400
}
}
]
}
+108
View File
@@ -0,0 +1,108 @@
{
"webhook": "my-policy-qa",
"description": "relevance threshold: policy questions answered, others handed off",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "규정 질문 · 암호를 얼마나 자주 바꿔야 해?",
"request": "requests/q-password-cycle.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"request": "requests/q-return-laptop.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 첨부 파일이 너무 커서 메일이 안 보내져요",
"request": "requests/q-big-attachment.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 생성형 AI에 고객 자료를 넣어도 되나요?",
"request": "requests/q-ai-customer-data.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 질문 · 컴퓨터가 바이러스에 걸린 것 같아요",
"request": "requests/q-virus.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "규정 밖 · 오늘 점심 메뉴 추천해줘",
"request": "requests/q-off-topic.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 연차 휴가는 며칠이에요?",
"request": "requests/q-not-in-policies.json",
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 주말에 볼 영화 추천해 주세요",
"body": {
"question": "주말에 볼 영화 추천해 주세요"
},
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 회사 근처 맛집 알려 줘",
"body": {
"question": "회사 근처 맛집 알려 줘"
},
"expect": {
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "규정 밖 · 올해 연봉 인상률은요?",
"body": {
"question": "올해 연봉 인상률은요?"
},
"expect": {
"equals": {
"route": "담당자 연결"
}
}
}
]
}
+324
View File
@@ -0,0 +1,324 @@
{
"webhook": "my-search",
"description": "answer@1: the first chunk contains the answer phrase",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호는 몇 자 이상이어야 하나요?",
"body": {
"question": "비밀번호는 몇 자 이상이어야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"12자"
]
}
}
},
{
"name": "암호를 얼마나 자주 바꿔야 해?",
"body": {
"question": "암호를 얼마나 자주 바꿔야 해?"
},
"expect": {
"contains": {
"hits.0.text": [
"90일"
]
}
}
},
{
"name": "로그인을 여러 번 틀리면 어떻게 돼요?",
"body": {
"question": "로그인을 여러 번 틀리면 어떻게 돼요?"
},
"expect": {
"contains": {
"hits.0.text": [
"30분"
]
}
}
},
{
"name": "카페 와이파이로 회사 시스템에 접속해도 되나요?",
"body": {
"question": "카페 와이파이로 회사 시스템에 접속해도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"공용 와이파이"
]
}
}
},
{
"name": "VPN 인증서는 언제 다시 받아야 해요?",
"body": {
"question": "VPN 인증서는 언제 다시 받아야 해요?"
},
"expect": {
"contains": {
"hits.0.text": [
"30일 전"
]
}
}
},
{
"name": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"body": {
"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"5영업일"
]
}
}
},
{
"name": "노트북을 잃어버렸어요. 어떻게 해야 하나요?",
"body": {
"question": "노트북을 잃어버렸어요. 어떻게 해야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"24시간"
]
}
}
},
{
"name": "개인 휴대폰으로 업무 메일 봐도 되나요?",
"body": {
"question": "개인 휴대폰으로 업무 메일 봐도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"MDM"
]
}
}
},
{
"name": "새 프로그램 설치 승인은 며칠 걸려요?",
"body": {
"question": "새 프로그램 설치 승인은 며칠 걸려요?"
},
"expect": {
"contains": {
"hits.0.text": [
"2영업일"
]
}
}
},
{
"name": "생성형 AI에 고객 자료를 넣어도 되나요?",
"body": {
"question": "생성형 AI에 고객 자료를 넣어도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"입력하지 않는다"
]
}
}
},
{
"name": "첨부 파일이 너무 커서 메일이 안 보내져요",
"body": {
"question": "첨부 파일이 너무 커서 메일이 안 보내져요"
},
"expect": {
"contains": {
"hits.0.text": [
"25MB"
]
}
}
},
{
"name": "고객 주민등록번호를 받아도 되나요?",
"body": {
"question": "고객 주민등록번호를 받아도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"주민등록번호는"
]
}
}
},
{
"name": "고객 정보가 밖으로 샌 것 같아요",
"body": {
"question": "고객 정보가 밖으로 샌 것 같아요"
},
"expect": {
"contains": {
"hits.0.text": [
"1시간"
]
}
}
},
{
"name": "회의실을 하루 종일 잡아도 되나요?",
"body": {
"question": "회의실을 하루 종일 잡아도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"3시간"
]
}
}
},
{
"name": "수상한 메일의 링크를 눌렀어요",
"body": {
"question": "수상한 메일의 링크를 눌렀어요"
},
"expect": {
"contains": {
"hits.0.text": [
"비밀번호를 바꾸"
]
}
}
},
{
"name": "컴퓨터가 바이러스에 걸린 것 같아요",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요"
},
"expect": {
"contains": {
"hits.0.text": [
"랜선"
]
}
}
},
{
"name": "OTP 앱 깔린 휴대폰을 새로 바꿨어요",
"body": {
"question": "OTP 앱 깔린 휴대폰을 새로 바꿨어요"
},
"expect": {
"contains": {
"hits.0.text": [
"다시 등록"
]
}
}
},
{
"name": "출력한 고객 명단은 어떻게 버려요?",
"body": {
"question": "출력한 고객 명단은 어떻게 버려요?"
},
"expect": {
"contains": {
"hits.0.text": [
"파쇄"
]
}
}
},
{
"name": "개인정보 파일을 거래처에 메일로 보내도 되나요?",
"body": {
"question": "개인정보 파일을 거래처에 메일로 보내도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"암호"
]
}
}
},
{
"name": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?",
"body": {
"question": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?"
},
"expect": {
"contains": {
"hits.0.text": [
"5분"
]
}
}
},
{
"name": "화상회의 장비는 언제까지 요청해야 하나요?",
"body": {
"question": "화상회의 장비는 언제까지 요청해야 하나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"30분 전"
]
}
}
},
{
"name": "노트북 배터리가 금방 닳아요",
"body": {
"question": "노트북 배터리가 금방 닳아요"
},
"expect": {
"contains": {
"hits.0.text": [
"배터리 교체"
]
}
}
},
{
"name": "공짜 프로그램은 그냥 써도 되죠?",
"body": {
"question": "공짜 프로그램은 그냥 써도 되죠?"
},
"expect": {
"contains": {
"hits.0.text": [
"라이선스"
]
}
}
},
{
"name": "감염된 것 같은데 컴퓨터를 꺼도 되나요?",
"body": {
"question": "감염된 것 같은데 컴퓨터를 꺼도 되나요?"
},
"expect": {
"contains": {
"hits.0.text": [
"끄거나"
]
}
}
}
]
}
+324
View File
@@ -0,0 +1,324 @@
{
"webhook": "my-search",
"description": "answer@3: one of the top 3 chunks contains the answer phrase",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호는 몇 자 이상이어야 하나요?",
"body": {
"question": "비밀번호는 몇 자 이상이어야 하나요?"
},
"expect": {
"contains": {
"hits": [
"12자"
]
}
}
},
{
"name": "암호를 얼마나 자주 바꿔야 해?",
"body": {
"question": "암호를 얼마나 자주 바꿔야 해?"
},
"expect": {
"contains": {
"hits": [
"90일"
]
}
}
},
{
"name": "로그인을 여러 번 틀리면 어떻게 돼요?",
"body": {
"question": "로그인을 여러 번 틀리면 어떻게 돼요?"
},
"expect": {
"contains": {
"hits": [
"30분"
]
}
}
},
{
"name": "카페 와이파이로 회사 시스템에 접속해도 되나요?",
"body": {
"question": "카페 와이파이로 회사 시스템에 접속해도 되나요?"
},
"expect": {
"contains": {
"hits": [
"공용 와이파이"
]
}
}
},
{
"name": "VPN 인증서는 언제 다시 받아야 해요?",
"body": {
"question": "VPN 인증서는 언제 다시 받아야 해요?"
},
"expect": {
"contains": {
"hits": [
"30일 전"
]
}
}
},
{
"name": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"body": {
"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"
},
"expect": {
"contains": {
"hits": [
"5영업일"
]
}
}
},
{
"name": "노트북을 잃어버렸어요. 어떻게 해야 하나요?",
"body": {
"question": "노트북을 잃어버렸어요. 어떻게 해야 하나요?"
},
"expect": {
"contains": {
"hits": [
"24시간"
]
}
}
},
{
"name": "개인 휴대폰으로 업무 메일 봐도 되나요?",
"body": {
"question": "개인 휴대폰으로 업무 메일 봐도 되나요?"
},
"expect": {
"contains": {
"hits": [
"MDM"
]
}
}
},
{
"name": "새 프로그램 설치 승인은 며칠 걸려요?",
"body": {
"question": "새 프로그램 설치 승인은 며칠 걸려요?"
},
"expect": {
"contains": {
"hits": [
"2영업일"
]
}
}
},
{
"name": "생성형 AI에 고객 자료를 넣어도 되나요?",
"body": {
"question": "생성형 AI에 고객 자료를 넣어도 되나요?"
},
"expect": {
"contains": {
"hits": [
"입력하지 않는다"
]
}
}
},
{
"name": "첨부 파일이 너무 커서 메일이 안 보내져요",
"body": {
"question": "첨부 파일이 너무 커서 메일이 안 보내져요"
},
"expect": {
"contains": {
"hits": [
"25MB"
]
}
}
},
{
"name": "고객 주민등록번호를 받아도 되나요?",
"body": {
"question": "고객 주민등록번호를 받아도 되나요?"
},
"expect": {
"contains": {
"hits": [
"주민등록번호는"
]
}
}
},
{
"name": "고객 정보가 밖으로 샌 것 같아요",
"body": {
"question": "고객 정보가 밖으로 샌 것 같아요"
},
"expect": {
"contains": {
"hits": [
"1시간"
]
}
}
},
{
"name": "회의실을 하루 종일 잡아도 되나요?",
"body": {
"question": "회의실을 하루 종일 잡아도 되나요?"
},
"expect": {
"contains": {
"hits": [
"3시간"
]
}
}
},
{
"name": "수상한 메일의 링크를 눌렀어요",
"body": {
"question": "수상한 메일의 링크를 눌렀어요"
},
"expect": {
"contains": {
"hits": [
"비밀번호를 바꾸"
]
}
}
},
{
"name": "컴퓨터가 바이러스에 걸린 것 같아요",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요"
},
"expect": {
"contains": {
"hits": [
"랜선"
]
}
}
},
{
"name": "OTP 앱 깔린 휴대폰을 새로 바꿨어요",
"body": {
"question": "OTP 앱 깔린 휴대폰을 새로 바꿨어요"
},
"expect": {
"contains": {
"hits": [
"다시 등록"
]
}
}
},
{
"name": "출력한 고객 명단은 어떻게 버려요?",
"body": {
"question": "출력한 고객 명단은 어떻게 버려요?"
},
"expect": {
"contains": {
"hits": [
"파쇄"
]
}
}
},
{
"name": "개인정보 파일을 거래처에 메일로 보내도 되나요?",
"body": {
"question": "개인정보 파일을 거래처에 메일로 보내도 되나요?"
},
"expect": {
"contains": {
"hits": [
"암호"
]
}
}
},
{
"name": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?",
"body": {
"question": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?"
},
"expect": {
"contains": {
"hits": [
"5분"
]
}
}
},
{
"name": "화상회의 장비는 언제까지 요청해야 하나요?",
"body": {
"question": "화상회의 장비는 언제까지 요청해야 하나요?"
},
"expect": {
"contains": {
"hits": [
"30분 전"
]
}
}
},
{
"name": "노트북 배터리가 금방 닳아요",
"body": {
"question": "노트북 배터리가 금방 닳아요"
},
"expect": {
"contains": {
"hits": [
"배터리 교체"
]
}
}
},
{
"name": "공짜 프로그램은 그냥 써도 되죠?",
"body": {
"question": "공짜 프로그램은 그냥 써도 되죠?"
},
"expect": {
"contains": {
"hits": [
"라이선스"
]
}
}
},
{
"name": "감염된 것 같은데 컴퓨터를 꺼도 되나요?",
"body": {
"question": "감염된 것 같은데 컴퓨터를 꺼도 되나요?"
},
"expect": {
"contains": {
"hits": [
"끄거나"
]
}
}
}
]
}
+276
View File
@@ -0,0 +1,276 @@
{
"webhook": "my-search",
"description": "doc@1: the first chunk comes from the right document (24 questions)",
"before": [
{
"webhook": "my-index",
"request": "requests/index-section.json"
}
],
"cases": [
{
"name": "비밀번호는 몇 자 이상이어야 하나요?",
"body": {
"question": "비밀번호는 몇 자 이상이어야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "암호를 얼마나 자주 바꿔야 해?",
"body": {
"question": "암호를 얼마나 자주 바꿔야 해?"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "로그인을 여러 번 틀리면 어떻게 돼요?",
"body": {
"question": "로그인을 여러 번 틀리면 어떻게 돼요?"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "카페 와이파이로 회사 시스템에 접속해도 되나요?",
"body": {
"question": "카페 와이파이로 회사 시스템에 접속해도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "remote-work-vpn"
}
}
},
{
"name": "VPN 인증서는 언제 다시 받아야 해요?",
"body": {
"question": "VPN 인증서는 언제 다시 받아야 해요?"
},
"expect": {
"equals": {
"hits.0.source": "remote-work-vpn"
}
}
},
{
"name": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?",
"body": {
"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "노트북을 잃어버렸어요. 어떻게 해야 하나요?",
"body": {
"question": "노트북을 잃어버렸어요. 어떻게 해야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "개인 휴대폰으로 업무 메일 봐도 되나요?",
"body": {
"question": "개인 휴대폰으로 업무 메일 봐도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "새 프로그램 설치 승인은 며칠 걸려요?",
"body": {
"question": "새 프로그램 설치 승인은 며칠 걸려요?"
},
"expect": {
"equals": {
"hits.0.source": "software-usage"
}
}
},
{
"name": "생성형 AI에 고객 자료를 넣어도 되나요?",
"body": {
"question": "생성형 AI에 고객 자료를 넣어도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "software-usage"
}
}
},
{
"name": "첨부 파일이 너무 커서 메일이 안 보내져요",
"body": {
"question": "첨부 파일이 너무 커서 메일이 안 보내져요"
},
"expect": {
"equals": {
"hits.0.source": "email-and-documents"
}
}
},
{
"name": "고객 주민등록번호를 받아도 되나요?",
"body": {
"question": "고객 주민등록번호를 받아도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "privacy"
}
}
},
{
"name": "고객 정보가 밖으로 샌 것 같아요",
"body": {
"question": "고객 정보가 밖으로 샌 것 같아요"
},
"expect": {
"equals": {
"hits.0.source": "privacy"
}
}
},
{
"name": "회의실을 하루 종일 잡아도 되나요?",
"body": {
"question": "회의실을 하루 종일 잡아도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "meeting-room"
}
}
},
{
"name": "수상한 메일의 링크를 눌렀어요",
"body": {
"question": "수상한 메일의 링크를 눌렀어요"
},
"expect": {
"equals": {
"hits.0.source": "security-incident"
}
}
},
{
"name": "컴퓨터가 바이러스에 걸린 것 같아요",
"body": {
"question": "컴퓨터가 바이러스에 걸린 것 같아요"
},
"expect": {
"equals": {
"hits.0.source": "security-incident"
}
}
},
{
"name": "OTP 앱 깔린 휴대폰을 새로 바꿨어요",
"body": {
"question": "OTP 앱 깔린 휴대폰을 새로 바꿨어요"
},
"expect": {
"equals": {
"hits.0.source": "password-policy"
}
}
},
{
"name": "출력한 고객 명단은 어떻게 버려요?",
"body": {
"question": "출력한 고객 명단은 어떻게 버려요?"
},
"expect": {
"equals": {
"hits.0.source": "privacy"
}
}
},
{
"name": "개인정보 파일을 거래처에 메일로 보내도 되나요?",
"body": {
"question": "개인정보 파일을 거래처에 메일로 보내도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "email-and-documents"
}
}
},
{
"name": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?",
"body": {
"question": "자리 비울 때 화면은 몇 분 뒤 잠기게 해요?"
},
"expect": {
"equals": {
"hits.0.source": "remote-work-vpn"
}
}
},
{
"name": "화상회의 장비는 언제까지 요청해야 하나요?",
"body": {
"question": "화상회의 장비는 언제까지 요청해야 하나요?"
},
"expect": {
"equals": {
"hits.0.source": "meeting-room"
}
}
},
{
"name": "노트북 배터리가 금방 닳아요",
"body": {
"question": "노트북 배터리가 금방 닳아요"
},
"expect": {
"equals": {
"hits.0.source": "device-issue-return"
}
}
},
{
"name": "공짜 프로그램은 그냥 써도 되죠?",
"body": {
"question": "공짜 프로그램은 그냥 써도 되죠?"
},
"expect": {
"equals": {
"hits.0.source": "software-usage"
}
}
},
{
"name": "감염된 것 같은데 컴퓨터를 꺼도 되나요?",
"body": {
"question": "감염된 것 같은데 컴퓨터를 꺼도 되나요?"
},
"expect": {
"equals": {
"hits.0.source": "security-incident"
}
}
}
]
}
+27
View File
@@ -0,0 +1,27 @@
# Sample company data API for day 71 (day 67 API + policy documents), attached to the day 65 n8n network
# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default).
# docker compose up -d (run from this folder, after day 65 n8n is running)
# from n8n : http://data-api:8800
# from WSL : http://localhost:8800
name: day67-data
services:
data-api:
image: python:3.12-slim
command: python /app/server.py
environment:
- DATA_DIR=/data
- PYTHONUNBUFFERED=1
ports:
- "8800:8800"
volumes:
- ./server.py:/app/server.py:ro
- ./data:/data:ro
networks:
- n8n
restart: unless-stopped
networks:
n8n:
external: true
name: day65-n8n_default
@@ -0,0 +1,17 @@
# 업무용 장비 지급·반납 규정
## 1. 지급과 교체
- 업무용 노트북은 입사일에 지급한다.
- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.
## 2. 분실과 도난
- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.
- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.
## 3. 반납
- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.
- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.
## 4. 개인 장비 사용
- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.
- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.
@@ -0,0 +1,14 @@
# 메일·문서 보안 규정
## 1. 메일함
- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.
- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.
## 2. 첨부 파일
- 메일 첨부 파일은 25MB까지 보낼 수 있다.
- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.
- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.
## 3. 문서 등급과 보관
- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.
- 업무 문서는 3년 동안 보관한 뒤 정리한다.
@@ -0,0 +1,12 @@
# 회의실 이용 규정
## 1. 예약
- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.
- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.
## 2. 장비
- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.
- 프로젝터는 B회의실과 대회의실에 있다.
## 3. 사용 후
- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.
@@ -0,0 +1,18 @@
# 비밀번호·계정 보안 규정
## 1. 비밀번호 만들기
- 비밀번호는 12자 이상으로 만든다.
- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.
- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
## 3. 로그인 실패와 잠금
- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.
- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.
## 4. 다단계 인증
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.
- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.
+13
View File
@@ -0,0 +1,13 @@
# 개인정보 처리 지침
## 1. 수집
- 업무에 꼭 필요한 개인정보만 수집한다.
- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.
## 2. 이용과 보관
- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.
- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.
## 3. 유출 대응
- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.
- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.
@@ -0,0 +1,15 @@
# 원격근무·VPN 이용 규정
## 1. 접속 원칙
- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.
- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.
## 2. VPN 인증서
- VPN 인증서의 유효 기간은 1년이다.
- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
## 3. 재택근무 중 보안
- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.
- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.
- VPN 접속 기록은 1년 동안 보관된다.
@@ -0,0 +1,11 @@
# 보안 사고 대응 절차
## 1. 피싱 메일
- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.
- 메일 화면의 '피싱 신고' 버튼으로 신고한다.
- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.
## 2. 악성코드 감염 의심
- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.
- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.
- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.
@@ -0,0 +1,14 @@
# 소프트웨어 사용 규정
## 1. 설치와 승인
- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.
- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.
- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.
## 2. 금지 사항
- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.
## 3. 생성형 AI 서비스
- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.
- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.
- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.
+14
View File
@@ -0,0 +1,14 @@
[
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
+6
View File
@@ -0,0 +1,6 @@
{
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다."},
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다."},
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다."},
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요."}
}
+83
View File
@@ -0,0 +1,83 @@
"""Sample company data API for the day 71 labs (day 67 API + policy documents; Python standard library only).
GET /health -> {"status": "ok"}
GET /faq?q=<question> -> {"query": ..., "results": [FAQ entries]}
GET /tickets/<id> -> ticket record, or 404 {"error": "ticket not found"}
GET /tickets/<id>?requester=<name>
-> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else
GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown)
GET /docs/<name> -> {"name", "title", "text"}, or 404 {"error": "doc not found"}
"""
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
DATA_DIR = Path(os.getenv("DATA_DIR", Path(__file__).resolve().parent / "data"))
PORT = int(os.getenv("PORT", "8800"))
def load(name):
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
def search_faq(query, top_k=2):
words = {word for word in query.lower().split() if len(word) > 1}
scored = []
for item in load("faq.json"):
keywords = {keyword.lower() for keyword in item["keywords"]}
score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords))
if score:
scored.append({"score": score, **item})
scored.sort(key=lambda item: item["score"], reverse=True)
return scored[:top_k]
class Handler(BaseHTTPRequestHandler):
def send_json(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
# Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged.
url = urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace"))
if url.path == "/health":
return self.send_json(200, {"status": "ok"})
if url.path == "/faq":
query = parse_qs(url.query).get("q", [""])[0]
return self.send_json(200, {"query": query, "results": search_faq(query)})
if url.path == "/docs":
docs = [{"name": f.stem, "title": f.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()}
for f in sorted((DATA_DIR / "docs").glob("*.md"))]
return self.send_json(200, {"docs": docs})
if url.path.startswith("/docs/"):
name = unquote(url.path.removeprefix("/docs/")).strip()
path = DATA_DIR / "docs" / f"{name}.md"
if "/" in name or not path.exists():
return self.send_json(404, {"error": "doc not found", "name": name})
text = path.read_text(encoding="utf-8")
return self.send_json(200, {"name": name, "title": text.splitlines()[0].lstrip("# ").strip(), "text": text})
if url.path.startswith("/tickets/"):
ticket_id = unquote(url.path.removeprefix("/tickets/")).strip().upper()
ticket = load("tickets.json").get(ticket_id)
if ticket is None:
return self.send_json(404, {"error": "ticket not found", "ticket_id": ticket_id})
requester = parse_qs(url.query).get("requester", [None])[0]
if requester is not None and requester.strip() != ticket["requester"]:
return self.send_json(403, {"error": "not your ticket", "ticket_id": ticket_id})
return self.send_json(200, {"ticket_id": ticket_id, **ticket})
return self.send_json(404, {"error": "unknown path"})
def log_message(self, fmt, *args):
print(f"{self.address_string()} {fmt % args}", flush=True)
if __name__ == "__main__":
print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+5
View File
@@ -0,0 +1,5 @@
# 수업 결과물
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`index.json`, `search.json`, `policy-qa.json`)이 여기에 저장됩니다 (git에 올라가지 않음).
- `RAG-EVAL.md`: [검색 평가표](../templates/RAG-EVAL-TEMPLATE.md)를 복사해 채웁니다.
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
@@ -0,0 +1,271 @@
{
"name": "71 Index policy docs (reference · Lab 01 example 2)",
"nodes": [
{
"name": "Index webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-index",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "List docs",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
200,
300
],
"parameters": {
"url": "http://data-api:8800/docs",
"options": {}
}
},
{
"name": "One item per doc",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
400,
300
],
"parameters": {
"fieldToSplitOut": "docs",
"options": {}
}
},
{
"name": "Get doc",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
600,
300
],
"parameters": {
"url": "=http://data-api:8800/docs/{{ $json.name }}",
"options": {}
}
},
{
"name": "Make chunks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
800,
300
],
"parameters": {
"jsCode": "// Split each policy document into chunks. Body \"chunking\": \"section\" (one chunk per ## section, default) or a number of characters.\nconst body = $('Index webhook').first().json.body ?? {};\nconst CHUNKING = body.chunking ?? 'section';\nconst chunks = [];\nfor (const item of $input.all()) {\n const { name, title, text } = item.json;\n const content = text.split('\\n').slice(1).join('\\n').trim();\n let pieces;\n if (CHUNKING === 'section') {\n pieces = content.split(/\\n(?=## )/).map(part => part.trim()).filter(Boolean);\n } else {\n const size = Number(CHUNKING), step = size - Math.floor(size / 5);\n pieces = [];\n for (let i = 0; i < content.length; i += step) pieces.push(content.slice(i, i + size));\n }\n pieces.forEach((piece, i) => {\n const section = (piece.match(/^## (.+)$/m) ?? [])[1] ?? '';\n chunks.push({ json: { source: name, title, chunk: i + 1, text: `${title}\\n${piece}` } });\n });\n}\nreturn chunks;"
}
},
{
"name": "Store chunks",
"type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
"typeVersion": 1.3,
"position": [
1020,
300
],
"parameters": {
"mode": "insert",
"memoryKey": {
"__rl": true,
"mode": "list",
"value": "my_policy_docs",
"cachedResultName": "my_policy_docs"
},
"clearStore": true
}
},
{
"name": "Embeddings",
"type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
"typeVersion": 1.2,
"position": [
960,
520
],
"parameters": {
"model": "text-embedding-3-large",
"options": {}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Chunk loader",
"type": "@n8n/n8n-nodes-langchain.documentDefaultDataLoader",
"typeVersion": 1.1,
"position": [
1120,
520
],
"parameters": {
"jsonMode": "expressionData",
"jsonData": "={{ $json.text }}",
"textSplittingMode": "simple",
"options": {
"metadata": {
"metadataValues": [
{
"name": "source",
"value": "={{ $json.source }}"
},
{
"name": "title",
"value": "={{ $json.title }}"
},
{
"name": "chunk",
"value": "={{ $json.chunk }}"
}
]
}
}
}
},
{
"name": "Count",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1260,
300
],
"parameters": {
"jsCode": "// One summary item after all chunks are stored.\nreturn [{ json: { indexed_chunks: $('Make chunks').all().length, chunking: ($('Index webhook').first().json.body ?? {}).chunking ?? 'section' } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1460,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Index webhook": {
"main": [
[
{
"node": "List docs",
"type": "main",
"index": 0
}
]
]
},
"List docs": {
"main": [
[
{
"node": "One item per doc",
"type": "main",
"index": 0
}
]
]
},
"One item per doc": {
"main": [
[
{
"node": "Get doc",
"type": "main",
"index": 0
}
]
]
},
"Get doc": {
"main": [
[
{
"node": "Make chunks",
"type": "main",
"index": 0
}
]
]
},
"Make chunks": {
"main": [
[
{
"node": "Store chunks",
"type": "main",
"index": 0
}
]
]
},
"Store chunks": {
"main": [
[
{
"node": "Count",
"type": "main",
"index": 0
}
]
]
},
"Count": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Embeddings": {
"ai_embedding": [
[
{
"node": "Store chunks",
"type": "ai_embedding",
"index": 0
}
]
]
},
"Chunk loader": {
"ai_document": [
[
{
"node": "Store chunks",
"type": "ai_document",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,203 @@
{
"name": "71 Search policies (reference · Lab 01 example 2)",
"nodes": [
{
"name": "Search webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-search",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
560
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Search policies",
"type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
"typeVersion": 1.3,
"position": [
420,
300
],
"parameters": {
"mode": "load",
"memoryKey": {
"__rl": true,
"mode": "list",
"value": "my_policy_docs",
"cachedResultName": "my_policy_docs"
},
"prompt": "={{ $json.body.question }}",
"topK": "={{ $json.body.top_k ?? 3 }}",
"includeDocumentMetadata": true
},
"alwaysOutputData": true
},
{
"name": "Embeddings",
"type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
"typeVersion": 1.2,
"position": [
560,
520
],
"parameters": {
"model": "text-embedding-3-large",
"options": {}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build hits",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
860,
300
],
"parameters": {
"jsCode": "// The search contract: { question, hits: [{ rank, source, title, score, text }], top_score, indexed }.\n// Always Output Data is on, so an empty store still gives one empty item: keep only items that have a document.\nconst question = $('Search webhook').first().json.body.question;\nconst hits = $input.all().filter(item => item.json.document).map((item, i) => ({\n rank: i + 1, source: item.json.document.metadata?.source, title: item.json.document.metadata?.title, \n score: Math.round((item.json.score ?? 0) * 1000) / 1000, text: item.json.document.pageContent ?? '' }));\nreturn [{ json: { question, hits, top_score: hits.length ? Math.max(...hits.map(hit => hit.score)) : 0, indexed: hits.length > 0 } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1080,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Search webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search policies",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Embeddings": {
"ai_embedding": [
[
{
"node": "Search policies",
"type": "ai_embedding",
"index": 0
}
]
]
},
"Search policies": {
"main": [
[
{
"node": "Build hits",
"type": "main",
"index": 0
}
]
]
},
"Build hits": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,398 @@
{
"name": "71 Policy QA (reference · Lab 02 example 2)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-policy-qa",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
560
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Search policies",
"type": "@n8n/n8n-nodes-langchain.vectorStoreInMemory",
"typeVersion": 1.3,
"position": [
420,
300
],
"parameters": {
"mode": "load",
"memoryKey": {
"__rl": true,
"mode": "list",
"value": "my_policy_docs",
"cachedResultName": "my_policy_docs"
},
"prompt": "={{ $json.body.question }}",
"topK": 3,
"includeDocumentMetadata": true
},
"alwaysOutputData": true
},
{
"name": "Embeddings",
"type": "@n8n/n8n-nodes-langchain.embeddingsOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": "text-embedding-3-large",
"options": {}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build context",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
640,
300
],
"parameters": {
"jsCode": "// Numbered excerpts for the LLM, and the retrieved sources.\nconst question = $('Question webhook').first().json.body.question;\nconst hits = $input.all().filter(item => item.json.document).map(item => ({\n source: item.json.document.metadata?.source, title: item.json.document.metadata?.title,\n score: Math.round((item.json.score ?? 0) * 1000) / 1000, text: item.json.document.pageContent ?? '' }));\nconst context = hits.map((hit, i) => `[${i + 1}] ${hit.text}`).join('\\n\\n');\nconst minScore = Number($('Question webhook').first().json.body.min_score ?? 0.33);\nconst topScore = hits.length ? Math.max(...hits.map(hit => hit.score)) : 0;\nreturn [{ json: { question, hits, context, top_score: topScore, min_score: minScore, relevant: topScore >= minScore } }];"
}
},
{
"name": "Relevant?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
860,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.relevant }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Answer from policies",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
1080,
200
],
"parameters": {
"promptType": "define",
"text": "=질문: {{ $json.question }}\n\n규정 발췌:\n{{ $json.context }}",
"options": {
"systemMessage": "Answer the employee's question only from the numbered policy excerpts, in Korean, in at most 3 sentences. After each fact, write the number of the excerpt it came from in square brackets, such as [2]. If the excerpts do not contain the answer, answer exactly: 규정에서 답을 찾지 못했습니다.",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
1040,
420
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
200
],
"parameters": {
"jsCode": "// Turn excerpt numbers into document titles and keep which documents were cited.\nconst ctx = $('Build context').first().json;\nconst draft = String($json.output ?? '').trim();\nconst numbers = [...draft.matchAll(/\\[(\\d+)\\]/g)].map(m => Number(m[1])).filter(n => n >= 1 && n <= ctx.hits.length);\nconst cited = [...new Set(numbers.map(n => ctx.hits[n - 1].source))];\nconst answer = draft.replace(/\\[(\\d+)\\]/g, (all, n) => ctx.hits[n - 1] ? `[${ctx.hits[n - 1].title}]` : all);\n\nreturn [{ json: { route: '자동 답변', answer, sources: [...new Set(ctx.hits.map(hit => hit.source))], cited, top_score: ctx.top_score } }];"
}
},
{
"name": "Not in policies",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1080,
520
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a1",
"name": "answer",
"value": "규정에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.",
"type": "string"
},
{
"id": "a2",
"name": "sources",
"value": "={{ [] }}",
"type": "array"
},
{
"id": "a3",
"name": "cited",
"value": "={{ [] }}",
"type": "array"
},
{
"id": "a4",
"name": "top_score",
"value": "={{ $json.top_score }}",
"type": "number"
}
]
},
"options": {}
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1540,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search policies",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Embeddings": {
"ai_embedding": [
[
{
"node": "Search policies",
"type": "ai_embedding",
"index": 0
}
]
]
},
"Search policies": {
"main": [
[
{
"node": "Build context",
"type": "main",
"index": 0
}
]
]
},
"Build context": {
"main": [
[
{
"node": "Relevant?",
"type": "main",
"index": 0
}
]
]
},
"Relevant?": {
"main": [
[
{
"node": "Answer from policies",
"type": "main",
"index": 0
}
],
[
{
"node": "Not in policies",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Answer from policies",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer from policies": {
"main": [
[
{
"node": "Build answer",
"type": "main",
"index": 0
}
]
]
},
"Build answer": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Not in policies": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+1
View File
@@ -0,0 +1 @@
{"chunking": 200}
+1
View File
@@ -0,0 +1 @@
{"chunking": 400}
+1
View File
@@ -0,0 +1 @@
{"chunking": "section"}
@@ -0,0 +1 @@
{"question": "생성형 AI에 고객 자료를 넣어도 되나요?"}
@@ -0,0 +1 @@
{"question": "첨부 파일이 너무 커서 메일이 안 보내져요"}
+1
View File
@@ -0,0 +1 @@
{"question": ""}
@@ -0,0 +1 @@
{"question": "비밀번호를 잊어버리면 어디서 바꿔요?"}
+1
View File
@@ -0,0 +1 @@
{"question": "노트북 사양은 어떻게 되나요?"}
@@ -0,0 +1 @@
{"question": "연차 휴가는 며칠이에요?"}
+1
View File
@@ -0,0 +1 @@
{"question": "오늘 점심 메뉴 추천해줘"}
@@ -0,0 +1 @@
{"question": "암호를 얼마나 자주 바꿔야 해?"}
@@ -0,0 +1 @@
{"question": "비밀번호는 몇 자 이상이어야 하나요?"}
@@ -0,0 +1 @@
{"question": "프린터 토너는 몇 달마다 바꿔 주나요?"}
@@ -0,0 +1 @@
{"question": "회사를 그만두면 노트북은 언제까지 돌려줘야 하나요?"}
@@ -0,0 +1 @@
{"question": "보안 교육은 1년에 몇 번 받아야 하나요?"}
+1
View File
@@ -0,0 +1 @@
{"question": "컴퓨터가 바이러스에 걸린 것 같아요"}
+1
View File
@@ -0,0 +1 @@
{"question": "VPN 접속 속도가 느리면 어떻게 해요?"}
@@ -0,0 +1 @@
{"question": "비밀번호는 30일마다 바꾸면 되죠?"}
@@ -0,0 +1,42 @@
# 검색 평가표 · 사내 IT 규정 Q&A
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 이 파일을 `my-work/RAG-EVAL.md`로 복사해 채웁니다.
## 1. 정답표 (Lab 01 예제 1)
| 질문 | 답이 있는 문서 (`name`) | 섹션 | 답 문구 |
| --- | --- | --- | --- |
| | | | |
## 2. 검색 확인 (Lab 01)
| 질문 | 1등 문서 | 1등 점수 | 정답표와 같나 |
| --- | --- | --- | --- |
| | | | |
## 3. 답과 출처 (Lab 02)
| 질문 | route | 답 (요약) | cited | 맞나 |
| --- | --- | --- | --- | --- |
| | | | | |
- 관련도 기준 0.33에 걸러지지 않았는데 규정에 답이 없는 질문:
## 4. 한 가지씩 바꾸고 재기 (Lab 03)
| 설정 | 청크 수 | doc@1 | answer@1 | answer@3 |
| --- | --- | --- | --- | --- |
| 섹션 (기준) | | /24 | /24 | /24 |
| 200자 | | /24 | /24 | /24 |
| 400자 | | /24 | /24 | /24 |
| (내가 바꾼 것) | | /24 | /24 | /24 |
## 5. 어려운 질문 (Lab 03 과제)
| 질문 | 기대 문서 | 바꾸기 전 1등 | 바꾼 뒤 1등 |
| --- | --- | --- | --- |
| | | | |
- 고친 것이 다른 질문을 깨뜨리지 않았나? (`retrieval-*` 다시)
@@ -0,0 +1,32 @@
# 테스트 보고서 · 사내 IT 규정 Q&A
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 워크플로우 파일: `my-work/index.json, my-work/search.json, my-work/policy-qa.json`
## 1. 내가 쓴 테스트 (`cases/my-cases.json`)
| 케이스 | 요청 | 기대 결과 (업무 규칙) |
| --- | --- | --- |
| | | |
## 2. 실패와 고친 내용
| 실패한 케이스 | 처음 결과 (3번 중 통과) | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 (3번 중 통과) |
| --- | --- | --- | --- | --- |
| | | | | |
## 3. 최종 결과
| 테스트 파일 | 결과 |
| --- | --- |
| `cases/lab02.json` (답과 출처) | Passed /6 |
| `cases/lab02-challenge.json` (답 검사) | Passed /8 |
| `cases/retrieval-answer.json` (answer@1) | Passed /24 |
| `cases/lab03-threshold.json` (기준값) | Passed /10 |
| `cases/my-cases.json --repeat 3` | Passed / |
## 4. 남은 문제 · 배운 점
- 지시문(systemMessage)으로 고친 것과 규칙 노드(If·Code)로 고친 것 중 어느 쪽이 더 안정적이었나?
-
+642
View File
@@ -0,0 +1,642 @@
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote.
uv run tools/flow.py doctor # is everything ready?
uv run tools/flow.py check my-work/policy-qa.json # find mistakes before n8n sees the file
uv run tools/flow.py push my-work/index.json my-work/search.json # import + publish (one n8n restart)
uv run tools/flow.py off my-sms # unpublish by file or webhook path (a role that is down)
uv run tools/flow.py call my-index # build the in-memory index (again after every push)
uv run tools/flow.py call my-search requests/q-password-length.json [-v]
uv run tools/flow.py test cases/retrieval-answer.json [-v] [--repeat 3] [--no-before] # -v: print request and response JSON
Run it from the day folder (Last3Mon/71). n8n is the day 65 install (~/day65-n8n, Docker project
day65-n8n), started from Last3Mon/65/n8n. Environment overrides:
N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1)
N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678)
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CACHE = Path(__file__).resolve().parent / ".cache"
def n8n_home() -> Path:
"""Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n."""
if os.getenv("N8N_HOME"):
return Path(os.environ["N8N_HOME"]).expanduser()
env_file = ROOT.parent / "65" / "n8n" / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
if line.startswith("N8N_HOME="):
value = line.split("=", 1)[1].strip().strip("'\"")
if value:
return (env_file.parent / Path(value).expanduser()).resolve()
return Path.home() / "day65-n8n"
N8N_HOME = n8n_home()
START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)"
def find_container() -> str:
"""The n8n container of the day 65 project, whichever folder it was started from."""
if os.getenv("N8N_CONTAINER"):
return os.environ["N8N_CONTAINER"]
if shutil.which("docker"):
result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n",
"--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"],
capture_output=True, text=True)
names = result.stdout.split()
if names:
return names[0]
return "day65-n8n-n8n-1"
CONTAINER = find_container()
N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/")
# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only).
RECOMMENDED = {
"n8n-nodes-base.webhook": 2,
"n8n-nodes-base.respondToWebhook": 1.1,
"n8n-nodes-base.if": 2.2,
"n8n-nodes-base.switch": 3.2,
"n8n-nodes-base.set": 3.4,
"n8n-nodes-base.code": 2,
"n8n-nodes-base.httpRequest": 4.2,
"n8n-nodes-base.httpRequestTool": 4.2,
"@n8n/n8n-nodes-langchain.agent": 3.1,
"@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3,
"@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1,
}
USES_DATA_API = True # day 71: policy documents come from the data API (/docs)
AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 71
COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama)
CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"}
SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}")
OK, BAD, WARN = "✔", "✘", "!"
# ---------------------------------------------------------------- helpers
def die(message: str) -> None:
print(f"{BAD} {message}", file=sys.stderr)
sys.exit(1)
def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
if not shutil.which("docker"):
die("docker command not found. Start Docker first.")
result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True)
if check and result.returncode != 0:
die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}")
return result
def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check)
def container_running() -> bool:
if not shutil.which("docker"):
return False
result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True)
return result.stdout.strip() == "true"
def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]:
data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
start = time.monotonic()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start
text = raw.decode("utf-8", errors="replace")
try:
payload: object = json.loads(text) if text.strip() else ""
except json.JSONDecodeError:
payload = text
return status, payload, time.monotonic() - start
def load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
die(f"file not found: {path}")
except json.JSONDecodeError as error:
die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}")
def resolve(path: str) -> Path:
candidate = Path(path)
if candidate.exists() or candidate.is_absolute():
return candidate
return ROOT / path
def node_versions() -> dict[str, list[float]]:
"""Node types and versions that this n8n really has (exported once, then cached)."""
cache = CACHE / "node-versions.json"
if cache.exists():
return json.loads(cache.read_text())
if not container_running():
return {}
n8n("export:nodes", "--output=/tmp/course-nodes.json")
raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout
versions: dict[str, set[float]] = {}
for node in json.loads(raw):
value = node.get("version")
versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value])
result = {name: sorted(values) for name, values in versions.items()}
CACHE.mkdir(exist_ok=True)
cache.write_text(json.dumps(result))
return result
def walk_strings(value: object, where: str = ""):
if isinstance(value, str):
yield where, value
elif isinstance(value, dict):
for key, item in value.items():
yield from walk_strings(item, f"{where}.{key}" if where else key)
elif isinstance(value, list):
for index, item in enumerate(value):
yield from walk_strings(item, f"{where}[{index}]")
def webhooks(workflow: dict) -> list[tuple[str, str]]:
found = []
for node in workflow.get("nodes", []):
if node.get("type") == "n8n-nodes-base.webhook":
parameters = node.get("parameters", {})
found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/")))
return found
# ---------------------------------------------------------------- check
def check_workflow(workflow: object) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
if not isinstance(workflow, dict):
return ["the file must be one workflow object { name, nodes, connections }"], warnings
if not str(workflow.get("name", "")).strip():
errors.append("missing workflow 'name'")
nodes = workflow.get("nodes")
connections = workflow.get("connections")
if not isinstance(nodes, list) or not nodes:
return errors + ["'nodes' must be a non-empty list"], warnings
if not isinstance(connections, dict):
errors.append("'connections' must be an object")
connections = {}
known = node_versions()
names: dict[str, dict] = {}
for index, node in enumerate(nodes):
label = f"node #{index + 1}"
if not isinstance(node, dict):
errors.append(f"{label} is not an object")
continue
name = node.get("name")
if not name:
errors.append(f"{label} has no 'name'")
continue
label = f"node '{name}'"
if name in names:
errors.append(f"{label} appears twice (node names must be unique)")
names[name] = node
node_type, version = node.get("type"), node.get("typeVersion")
if not node_type or version is None:
errors.append(f"{label} needs 'type' and 'typeVersion'")
elif known and node_type not in known:
errors.append(f"{label}: type '{node_type}' does not exist in this n8n")
elif known and version not in known[node_type]:
errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})")
elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]:
warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})")
if not isinstance(node.get("parameters"), dict):
errors.append(f"{label} needs 'parameters' (use {{}} when empty)")
position = node.get("position")
if not (isinstance(position, list) and len(position) == 2):
errors.append(f"{label} needs 'position': [x, y]")
for kind, credential in (node.get("credentials") or {}).items():
credential_id = (credential or {}).get("id")
if credential_id not in COURSE_CREDENTIALS:
errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}")
for where, text in walk_strings(node.get("parameters", {})):
if SECRET.search(text):
errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.")
if "{{" in text and "}}" in text and not text.startswith("="):
warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)")
targets_of: dict[str, set[str]] = {}
for source, outputs in connections.items():
if source not in names:
errors.append(f"connections: source '{source}' is not a node name")
continue
if not isinstance(outputs, dict):
errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}")
continue
for kind, branches in outputs.items():
if kind not in CONNECTION_TYPES:
errors.append(f"connections['{source}']: unknown connection type '{kind}'")
for branch in branches or []:
for link in branch or []:
target = (link or {}).get("node")
if target not in names:
errors.append(f"connections['{source}'] points to '{target}', which is not a node name")
elif link.get("type") != kind:
errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'")
else:
targets_of.setdefault(target, set()).add(kind)
if kind != "main":
targets_of.setdefault(f"{source}::from", set()).add(kind)
has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values())
for name, node in names.items():
node_type = node.get("type", "")
parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {}
if node_type == "n8n-nodes-base.webhook":
if not str(parameters.get("path", "")).strip("/"):
errors.append(f"node '{name}': webhook needs a 'path'")
if parameters.get("responseMode") == "responseNode" and not has_respond:
errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node")
if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()):
errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'")
if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"):
if "ai_tool" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'")
if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"):
if "ai_languageModel" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'")
is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links
if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \
and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \
and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower():
warnings.append(f"node '{name}': nothing is connected into it (it never runs)")
if not webhooks(workflow):
warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path")
return errors, warnings
def cmd_check(args: argparse.Namespace) -> int:
path = resolve(args.file)
errors, warnings = check_workflow(load_json(path))
for warning in warnings:
print(f"{WARN} {warning}")
for error in errors:
print(f"{BAD} {error}")
if errors:
print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.")
return 1
print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
# ---------------------------------------------------------------- push
def stable_id(path: Path) -> str:
return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15]
def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3)
if status == 200:
break
time.sleep(1)
else:
return False
for method, path in paths:
while time.monotonic() < deadline:
# A webhook that is registered for POST answers a GET with a hint, not "is not registered".
status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5)
text = json.dumps(payload, ensure_ascii=False)
if "not registered for" in text or status not in (0, 404, 500, 502, 503):
break
time.sleep(1)
time.sleep(2) # webhooks of the other published workflows finish registering right after
return time.monotonic() < deadline
def exported_workflows() -> list[dict]:
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; "
"cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout
try:
return json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
return []
def prepare(path: Path) -> dict | None:
workflow = load_json(path)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {path.name}: {warning}")
if errors:
for error in errors:
print(f"{BAD} {path.name}: {error}")
return None
workflow = dict(workflow)
if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
workflow["id"] = stable_id(path)
workflow["active"] = False
workflow.setdefault("settings", {}).setdefault("executionOrder", "v1")
for node in workflow["nodes"]:
node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}")))
if node["type"] == "n8n-nodes-base.webhook":
node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}")))
return workflow
def cmd_push(args: argparse.Namespace) -> int:
paths = [resolve(file) for file in args.files]
workflows = [prepare(path) for path in paths]
if None in workflows:
print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check <file>).")
return 1
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
mine_ids = {workflow["id"] for workflow in workflows}
hooks = {hook for workflow in workflows for hook in webhooks(workflow)}
# Another published workflow with the same webhook path would block this one.
for other in exported_workflows():
if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)):
n8n("unpublish:workflow", f"--id={other['id']}")
print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path")
for workflow in workflows:
docker("exec", "-i", CONTAINER, "sh", "-c",
"cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; "
"status=$?; rm -f /tmp/course-workflow.json; exit $status",
stdin=json.dumps(workflow, ensure_ascii=False))
n8n("publish:workflow", f"--id={workflow['id']}")
# The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s).
docker("restart", CONTAINER)
ready = wait_until_ready(sorted(hooks))
for workflow in workflows:
print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})")
for method, hook in sorted(webhooks(workflow)):
print(f" {method} {N8N_URL}/webhook/{hook}")
if not ready:
print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.")
return 0
def cmd_off(args: argparse.Namespace) -> int:
"""Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down."""
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
exported = exported_workflows()
wanted: dict[str, str] = {}
for item in args.files:
path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None
if path:
workflow = load_json(path)
workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path)
wanted[workflow_id] = item
hooks = {hook for _, hook in webhooks(workflow)}
else:
hooks = {item.strip("/")}
for other in exported:
if other.get("active") and hooks & {hook for _, hook in webhooks(other)}:
wanted[other["id"]] = item
active = {item.get("id") for item in exported if item.get("active")}
for workflow_id, item in wanted.items():
if workflow_id in active:
n8n("unpublish:workflow", f"--id={workflow_id}")
print(f"{OK} unpublished {item} (id {workflow_id})")
docker("restart", CONTAINER)
wait_until_ready([])
print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>")
return 0
# ---------------------------------------------------------------- call / test
def request_body(spec: str | dict, base: Path = ROOT) -> object:
if isinstance(spec, dict):
return spec
text = spec.strip()
if text.startswith("{"):
try:
return json.loads(text)
except json.JSONDecodeError as error:
die(f"request is not valid JSON: {error.msg}")
path = Path(text)
if not path.exists():
path = base / text
return load_json(path)
def show_json(label: str, value: object) -> None:
"""Print one JSON value under a label, indented below the result line (call -v, test -v)."""
text = json.dumps(value, ensure_ascii=False, indent=2) if not isinstance(value, str) else value
print(f" {label}")
for row in text.splitlines():
print(f" {row}")
def cmd_call(args: argparse.Namespace) -> int:
body = request_body(args.request) if args.request else {}
hook = args.path.strip('/')
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body)
print(f"HTTP {status} · {seconds:.1f}s")
if args.verbose:
show_json(f"→ request POST /webhook/{hook}", body)
show_json(f"← response HTTP {status}", payload)
return 0 if 200 <= status < 300 else 1
print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload)
return 0 if 200 <= status < 300 else 1
def field(payload: object, dotted: str) -> object:
value = payload
for part in dotted.split("."):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
return None
return value
def as_text(value: object) -> str:
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def judge(expect: dict, status: int, payload: object) -> list[str]:
problems = []
if status != expect.get("status", 200):
problems.append(f"status {status} (expected {expect.get('status', 200)})")
for name, wanted in (expect.get("equals") or {}).items():
actual = field(payload, name)
if actual != wanted:
problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})")
for name, words in (expect.get("contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
if not any(word in text for word in words):
problems.append(f"{name} has none of {words}")
for name, words in (expect.get("not_contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
leaked = [word for word in words if word in text]
if leaked:
problems.append(f"{name} must not have {leaked}")
return problems
def cmd_test(args: argparse.Namespace) -> int:
path = resolve(args.cases)
suite = load_json(path)
hook = (args.webhook or suite.get("webhook", "")).strip("/")
cases = suite.get("cases", [])
if not hook or not cases:
die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list")
# "before": calls to make once before the cases, e.g. rebuild the in-memory index (lost when n8n restarts).
for step in ([] if args.no_before else suite.get("before", [])):
step_hook = str(step.get("webhook", "")).strip("/")
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{step_hook}", request_body(step.get("body") or step.get("request", "{}")))
shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120]
print(f"{OK if 200 <= status < 300 else BAD} before: POST /webhook/{step_hook} · HTTP {status} · {seconds:.1f}s · {shown}")
if not 200 <= status < 300:
die(f"the 'before' step failed. Is '{step_hook}' pushed? Try: uv run tools/flow.py call {step_hook}")
print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}")
results, passed = [], 0
for case in cases:
body = request_body(case.get("body") or case.get("request", "{}"))
case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook
for run in range(1, args.repeat + 1):
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{case_hook}", body)
problems = judge(case.get("expect", {}), status, payload)
passed += not problems
mark = OK if not problems else BAD
suffix = f" #{run}" if args.repeat > 1 else ""
print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s" + ("" if not problems else " · " + "; ".join(problems)))
if args.verbose:
show_json(f"→ request POST /webhook/{case_hook}", body)
show_json(f"← response HTTP {status}", payload)
results.append({"case": case.get("name"), "webhook": case_hook, "run": run, "passed": not problems, "problems": problems,
"seconds": round(seconds, 1), "status": status, "request": body, "response": payload})
total = len(results)
print(f"Passed {passed}/{total}")
runs = ROOT / "runs"
runs.mkdir(exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
report = runs / f"{path.stem}-{stamp}.json"
report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total,
"results": results}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report: {report.relative_to(ROOT)}")
return 0 if passed == total else 1
# ---------------------------------------------------------------- doctor
def cmd_doctor(args: argparse.Namespace) -> int:
failures = 0
def line(good: bool, text: str, hint: str = "") -> None:
nonlocal failures
failures += not good
print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}"))
installed = (N8N_HOME / "compose.yml").exists()
print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else
"\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>"))
running = container_running()
line(running, f"n8n container {CONTAINER}", START_HINT)
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=5)
line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again")
status, _, _ = http("GET", "http://localhost:8800/health", timeout=5)
if USES_DATA_API:
line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate (start n8n first)")
docs_status, docs, _ = http("GET", "http://localhost:8800/docs", timeout=5)
count = len(docs.get("docs", [])) if isinstance(docs, dict) else 0
line(docs_status == 200 and count > 0, f"policy documents /docs ({count})",
"the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/71/data-api && docker compose up -d --force-recreate")
else:
print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)")
if running:
probe = docker("exec", CONTAINER, "node", "-e",
"fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))",
check=False).stdout.strip()
if USES_DATA_API:
line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)")
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; "
"cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout
try:
ids = {item.get("id") for item in json.loads(exported)} if exported.strip() else set()
except json.JSONDecodeError:
ids = set()
for credential_id, name in COURSE_CREDENTIALS.items():
line(credential_id in ids, f"credential '{name}' ({credential_id})",
"cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh")
line(bool(node_versions()), "node catalog (for check)")
for hook in AGENT_HOOKS:
_, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5)
line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}",
"uv run tools/flow.py push <agent workflow file>")
agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)]
line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"),
"install one of codex / claude / agy (see Lecture/71/00-DAY71-PREP.md)")
print("ready" if not failures else f"{failures} item(s) to fix")
return 0 if not failures else 1
def main() -> int:
parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("doctor", help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor)
item = commands.add_parser("check", help="find mistakes in a workflow file")
item.add_argument("file")
item.set_defaults(run=cmd_check)
item = commands.add_parser("push", help="import and publish workflow files (one n8n restart)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_push)
item = commands.add_parser("off", help="unpublish workflow files or webhook paths (a role that is down)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_off)
item = commands.add_parser("call", help="POST one request to a webhook path")
item.add_argument("path", help="webhook path, e.g. my-notice")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.add_argument("-v", "--verbose", action="store_true", help="print the request JSON as well as the response")
item.set_defaults(run=cmd_call)
item = commands.add_parser("test", help="run a test case file")
item.add_argument("cases")
item.add_argument("-v", "--verbose", action="store_true", help="print the request and response JSON of every run")
item.add_argument("--repeat", type=int, default=1, help="run every case N times")
item.add_argument("--webhook", help="override the webhook path in the case file")
item.add_argument("--no-before", action="store_true", help="skip the 'before' steps (keep the index you built by hand)")
item.set_defaults(run=cmd_test)
args = parser.parse_args()
return args.run(args)
if __name__ == "__main__":
sys.exit(main())