Files
aio-01d4-stu/Last3Mon/78/AGENTS.md
T

220 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md · n8n 데이터 품질 검사 작업 규칙 (78일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 77일차 규칙과 같고, 3절에 오늘 만드는 **검사 · 가져오기 · 스키마 검사 · 품질 보고서의 계약**과 **품질 규칙(오류 코드)**, 5절에 검사 · 격리 노드가 더해졌습니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 규칙 요청이면 **규칙(무엇을 보고 어떤 코드를 붙이는지)** 을 먼저 표로 보여 준다.
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
```
5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다.
6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다.
7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.
## 2. 환경 (바꾸지 말 것)
| 항목 | 값 |
| --- | --- |
| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`), 브라우저 http://localhost:5678 |
| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) |
| 게시된 웹훅 주소 | 밖에서 · n8n 안에서 모두 `http://localhost:5678/webhook/<path>` |
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` |
| 다른 Credential | 쓰지 않음 (Ollama 등 없음) |
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**78일차 판** `Last3Mon/78/data-api`: 77일차 + 격리함 · 스키마 · 품질 기록) |
| 비교 기준 · 제공 | `reference/baseline-faq-check.json` (`base-faq-check`, 팀이 지난달 급히 만든 검사 — 빈 값만 봄) · `reference/baseline-faq-import.json` (`base-faq-import`, 모든 줄을 그대로 넣던 가져오기). **고치지 않는다** (비교용) |
### 데이터 API (78일차 판, 오늘 쓰는 것)
| 요청 | 응답 |
| --- | --- |
| `GET /faq-all` | `{"count", "faqs": [{"id", "title", "keywords", "answer"}]}` — 지금 FAQ 전체 (처음 6개 + 더한 것) |
| `POST /faq/add` 본문 `{"id", "title", "keywords", "answer"}` | FAQ 한 줄 더하기 (같은 id면 바꿈) |
| `POST /quarantine` 본문 `{"items": [{"batch", "row": {...}, "errors": [...]}]}` | 격리함에 넣기. 같은 배치 · 같은 줄 id(`row.id`, 없으면 `row.email`, `row.ticket_id`)면 바꿈 |
| `GET /quarantine` (`POST /quarantine/list`) | `{"size", "items": [{"row_id", "batch", "row", "errors", "at"}]}` |
| `POST /quarantine/remove` 본문 `{"ids": ["FAQ-209"]}` | 격리함에서 빼기 |
| `GET /schemas` (`POST /schemas/list`) | `{"schemas": ["employee", "faq", "ticket"]}` |
| `GET /schemas/<name>` | 데이터 스키마 `{"name", "key", "fields"}`, 없으면 404 |
| `POST /quality-log` 본문 `{"batch", "dataset", "total", "valid", "quarantined", "rejected", "score", "codes"}` | 품질 기록 한 줄 |
| `GET /quality-log` (`POST /quality-log/list`) | `{"count", "items": [...]}` 넣은 순서 |
| `POST /alerts` · `GET /alerts` | 알림 (76일차와 같음) |
| `POST /faq-reset` · `/quarantine-clear` · `/quality-log-clear` · `/alerts-clear` | 처음으로 (**사람이** · 테스트가, `flow.py quality --reset`) |
### 배치 (오늘 들어오는 데이터)
```json
{"batch": "B-2", "items": [{"id": "FAQ-201", "title": "회의실 예약", "keywords": ["회의실", "예약"], "answer": "사내 포털의 ..."}]}
```
- `requests/faq-batch-1.json`(13줄) · `faq-batch-2.json`(10줄) · `faq-batch-3.json`(6줄) · `faq-batch-2-fixed.json`(고친 2줄) · `faq-edges.json`(경계 10줄) · `faq-batch-1-loose.json` · `faq-batch-2-limit-0.1.json` · `faq-batch-2-strict.json`(같은 배치 + `max_invalid_ratio`).
- `employees-new.json`(CSV 9줄, `{"schema": "employee", "batch", "csv"}`) · `tickets-new.json`(목록 9줄, `{"schema": "ticket", "batch", "items"}`).
## 3. 오늘 만드는 것의 계약과 품질 규칙
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
| --- | --- | --- | --- |
| FAQ 검사 | `my-work/faq-check.json` · `POST /webhook/my-faq-check` | 배치 | `{"batch", "total", "valid", "invalid", "rows": [{"id", "ok", "errors", "warnings"}], "codes"}`, `items`가 목록이 아니면 **400**. (Lab 01 과제 2) `fix_list` · `message` 더함 |
| FAQ 가져오기 | `my-work/faq-import.json` · `POST /webhook/my-faq-import` | 배치 (+ 선택 `max_invalid_ratio`, 기본 0.3) | `{"batch", "total", "imported", "quarantined", "rejected", "score", "max_invalid_ratio", "codes", "imported_ids", "quarantined_ids"}`, 배치를 거절하면 **422** `{"error": "batch rejected", "batch", "invalid_ratio", "max_invalid_ratio", "codes"}`. (Lab 02 과제 2) 둘 다 `llm_calls` 더함 |
| 스키마 검사 | `my-work/check.json` · `POST /webhook/my-check` | `{"schema", "batch", "items"}` 또는 `{"schema", "batch", "csv"}` | `{"schema", "batch", "total", "valid", "invalid", "rows": [{"row", "key", "ok", "errors"}], "codes"}`, 모르는 스키마면 **400**. (Lab 03 과제 2) 검사마다 품질 기록 한 줄 |
| 품질 보고서 | `my-work/quality-report.json` · `POST /webhook/my-quality-report` | `{"dataset"}`(선택) | 아래 |
### FAQ 품질 규칙 (오류 코드)
| 코드 | 규칙 |
| --- | --- |
| `id_format` | `id`가 `FAQ-` + 숫자 3자리가 아님 |
| `title_missing` · `title_too_long` | 제목이 비었음 · 40자 넘음 |
| `keywords_missing` | 키워드가 목록이 아니거나 비었음 |
| `answer_missing` · `answer_too_short` · `answer_too_long` | 답이 비었음 · 10자 미만 · 300자 넘음 |
| `duplicate_in_batch` | 같은 배치 안에서 앞에 나온 `id` (두 번째부터) |
| `pii` | 답에 휴대전화 번호(`010-1234-5678` · `01012345678` 꼴, `-` · `.` · 공백 없어도) 또는 메일 주소. 사무실 번호(`02-123-4567`)는 아님 |
| `external_link` | 답에 `http(s)://` 링크가 있는데 호스트가 **정확히** `intra.example.com`이 아님 (`intra.example.com.evil.io`는 바깥) |
| `exists_id` · `same_title` | (Lab 01 과제) 이미 있는 FAQ와 `id` · 제목이 같음 |
| `keyword_used:<낱말>` | (Lab 01 과제, **경고**) 이미 있는 FAQ가 쓰는 키워드 — 통과는 시킴 |
| `llm_mismatch` | (Lab 02) 규칙은 통과했지만 LLM이 "답이 제목의 질문에 답하지 않음"으로 판정 |
- 줄은 `errors`가 비었을 때만 `ok`. `warnings`는 통과에 영향이 없다.
- 공백만 있는 값은 빈 값으로 본다. 글자 수는 앞뒤 공백을 뺀 뒤 센다 (제목 40자 · 답 10자는 통과).
### 고침 목록 (Lab 01 과제 2)
| 필드 | 정의 |
| --- | --- |
| `fix_list` | 통과하지 못한 줄만 줄 순서대로 `[{"row" (1부터), "id", "codes" (그 줄의 errors), "fix": [코드마다 아래 말]}]` |
| `message` | `"<batch>: <total>줄 중 <valid>줄 통과, <invalid>줄 고칠 것"` |
| 코드 | `fix`의 말 |
| --- | --- |
| `id_format` | id를 FAQ-숫자 3자리로 (예: FAQ-104) |
| `title_missing` · `title_too_long` | 제목을 써 주세요 · 제목을 40자 이내로 줄여 주세요 |
| `keywords_missing` | 키워드를 하나 이상 넣어 주세요 |
| `answer_missing` · `answer_too_short` · `answer_too_long` | 답을 써 주세요 · 답을 10자 이상으로 써 주세요 · 답을 300자 이내로 줄여 주세요 |
| `duplicate_in_batch` | 같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요 |
| `pii` | 개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요 |
| `external_link` | 바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요 |
| `exists_id` | 이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요 |
| `same_title` | 같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요 |
### 가져오기 게이트
| 단계 | 규칙 |
| --- | --- |
| 배치 게이트 | `invalid / total`이 **기준을 넘으면** 배치 전체를 거절 (422, 아무것도 넣지 않음). 기준은 본문의 `max_invalid_ratio`, 없으면 **0.3**. 같으면 통과. 응답에 쓴 기준 `max_invalid_ratio`를 함께, `invalid_ratio`는 소수 4자리 |
| 줄 게이트 | 통과한 줄만 `/faq/add`, 나머지는 **이유와 함께** `/quarantine` |
| LLM 검사 (Lab 02 예제 2) | 규칙을 통과한 줄만 LLM에게 (비용 · 75일차) |
| `score` | `imported / total`, 소수 4자리 (거절된 배치는 0) |
| `llm_calls` (Lab 02 과제 2) | LLM 검사에 보낸 줄 수 (= 규칙을 통과한 줄 수). 거절된 배치는 0 |
| 품질 기록 · 알림 (Lab 02 과제) | 배치마다 `/quality-log` 한 줄 (거절도), 거절이면 알림 `reason: "batch_rejected"`, `score < 0.9`면 알림 `reason: "low_quality"` |
### 스키마 (`/schemas/<name>`)
```json
{"name": "employee", "key": "email", "fields": {
"name": {"type": "string", "required": true, "max": 20},
"dept": {"type": "enum", "required": true, "values": ["영업팀", "재무팀", "인사팀", "개발팀", "IT팀", "총무팀"]},
"email": {"type": "email", "required": true}, "phone": {"type": "phone", "required": false}, "joined": {"type": "date", "required": true}}}
```
| 타입 | 확인 | 코드 |
| --- | --- | --- |
| 모든 필드 | `required`인데 비었음 | `missing:<필드>` |
| `string` | `min` · `max` 글자 수 | `too_short:<필드>` · `too_long:<필드>` |
| `enum` | `values` 안에 있나 | `not_allowed:<필드>` |
| `email` · `phone` · `date` · `pattern` | 형식 (`0\d{1,2}-\d{3,4}-\d{4}`, 실제 있는 날짜 `YYYY-MM-DD`, 스키마의 정규식) | `bad_format:<필드>` |
| `list` | 목록인가, `min_items` · `max_items` | `bad_format` · `too_few` · `too_many:<필드>` |
| 키 (`key`) | 배치 안에서 두 번째부터 | `duplicate_in_batch` |
- CSV는 첫 줄이 열 이름, 쉼표로 나눔 (따옴표 없음). `row`는 1부터 (열 이름 줄 제외).
### 품질 보고서 (`my-quality-report`)
| 필드 | 정의 |
| --- | --- |
| `count` · `batches` | 품질 기록 줄 수 · `[{"batch", "score", "rejected", "total"}]` 넣은 순서 (`dataset`을 주면 그 데이터셋만) |
| `avg_score` | `score` 평균, 소수 4자리 (0건이면 0) |
| `worst_batch` | `score`가 가장 낮은 배치 (같으면 먼저 나온 것, 없으면 `null`) |
| `rejected_batches` | 거절된 배치 수 |
| `top_codes` | 모든 기록의 `codes`를 더해 `[{"code", "count"}]`, 많은 순 (같으면 코드 글자순) |
| `trend` | 마지막 `score` − 첫 `score`가 0.05 넘으면 `좋아짐`, −0.05 미만이면 `나빠짐`, 아니면(또는 1건 이하) `그대로` |
| `by_dataset` (Lab 03 과제 2) | 모든 기록을 `dataset`마다 `{"count", "avg_score", "worst_batch", "top_code"}` (처음 나온 순서, `top_code`는 `top_codes`의 첫 코드 또는 `null`) |
| `worst_dataset` (Lab 03 과제 2) | `avg_score`가 가장 낮은 데이터셋 (같으면 먼저 나온 것, 없으면 `null`) |
### 검사도 기록 (Lab 03 과제 2)
- `my-check`는 검사가 끝나면 `/quality-log`에 한 줄: `{"batch", "dataset": <schema>, "total", "valid", "quarantined": invalid, "rejected": false, "score": valid / total (소수 4자리, 0줄이면 0), "codes"}`. 응답은 검사 결과 그대로.
## 4. 워크플로우 파일 규칙
```json
{"name": "78 FAQ check (my)", "nodes": [ ... ], "connections": { ... }, "settings": { "executionOrder": "v1" }}
```
- 파일 하나에 워크플로우 하나. 노드 `name`은 영어로, 겹치지 않게. **API 키를 JSON에 쓰지 않는다.**
- **규칙은 Code가 확인한다.** LLM은 규칙으로 알 수 없는 것(답이 질문에 맞나)만, 규칙을 통과한 줄에만.
- 검사 결과의 `rows`는 들어온 줄과 **같은 순서 · 같은 수**.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
### 본문 확인 (IF) → 줄마다 규칙 (Code)
```json
{"name": "Is a list?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [220, 300],
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2},
"conditions": [{"id": "c1", "leftValue": "={{ Array.isArray($json.body.items) }}", "rightValue": "",
"operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
```json
{"name": "Check rows", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [440, 300],
"parameters": {"jsCode": "const body = $('Check webhook').first().json.body;\nconst seen = new Set();\nconst rows = body.items.map((r) => {\n const errors = [];\n ...\n return { id: String(r?.id ?? ''), ok: errors.length === 0, errors, warnings: [] };\n});\n...\nreturn [{ json: { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid, rows, codes } }];"}}
```
### 다른 내 웹훅을 부르기 (가져오기 → 검사)
```json
{"name": "Run check", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [220, 300],
"parameters": {"method": "POST", "url": "http://localhost:5678/webhook/my-faq-check", "sendBody": true, "specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.body) }}", "options": {"timeout": 30000, "response": {"response": {"neverError": true}}}}}
```
- 400 · 422도 오류로 멈추지 않고 본문을 받는다 (`neverError`). 다음 IF에서 `Array.isArray($json.rows)`로 갈라 본다.
### 줄을 나눠 저장하기 — 한 줄로 (분기 없이)
```json
{"name": "Store row", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 300],
"parameters": {"method": "POST", "url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}",
"sendBody": true, "specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}",
"options": {"response": {"response": {"neverError": true}}}}}
```
- 앞의 Code가 줄마다 아이템 하나(`{id, ok, errors, row, batch}`)를 내면 HTTP 노드는 **아이템마다** 한 번씩 부른다. 주소를 표현식으로 고르면 IF로 갈라 다시 합칠(Merge) 필요가 없다.
- 그 뒤 요약 Code는 `$('Decide').all()`처럼 **저장 전 노드**에서 줄을 다시 읽는다 (HTTP 노드의 출력은 API 응답이다).
- 수업 준비 때: IF로 두 갈래(넣기 · 격리)로 나눴다가 Merge로 합치니, LLM 검사를 더한 뒤 격리 갈래가 두 번 실행되면서 `$('Bad rows').all()`이 **마지막 실행만** 보여 격리 수가 틀렸다. 한 줄로 바꿔 해결.
### LLM 검사 (규칙을 통과한 줄만)
- `Only passed`(Code: `return $input.all().filter((i) => i.json.ok);`) → AI Agent(`Judge`) + OpenAI chat model → `Decide`(Code).
- 지시문: `{"match": true|false, "reason"}` JSON만. `Decide`는 `$('Judge').all()[i]`를 `Only passed`의 i번째 줄과 맞추고, 읽을 수 없으면 `match: false`.
- Agent의 `onError`는 `continueRegularOutput` (LLM 오류여도 그 줄을 격리로 보내고 계속).
## 6. 연결 · 7. 표현식
- 72~77일차와 같다. 다른 노드 뒤에서 웹훅 입력은 `$('Import webhook').first().json.body`.
- 테스트에서 목록 안은 숫자로 내려간다: `rows.2.errors`, `items.1.score`, `top_codes.0.code`.
## 8. 테스트 파일 (`cases/*.json`)
```json
{"webhook": "my-faq-import", "before": [{"url": "http://localhost:8800/faq-reset", "body": {}}], "cases": [
{"name": "나쁜 배치는 통째로 거절", "request": "requests/faq-batch-1.json", "expect": {"status": 422}},
{"name": "격리함", "url": "http://localhost:8800/quarantine/list", "body": {}, "expect": {"contains": {"items": "\"pii\""}}}
]}
```
- `before`의 `"allow_fail": true`: 일부러 거절되는 배치를 미리 넣을 때 (Lab 03 과제).
- 기대 결과는 3절 규칙대로 배치 파일을 확인한 값입니다. **테스트를 통과시키려고 기대 결과나 배치 파일을 바꾸지 않는다.** 규칙이 다르다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `data-api/`(스키마 포함), `requests/`, `reference/`, 이 파일을 고치지 않는다 (요청받은 경우만).
- 워크플로우 안에서 `/faq-reset` · `/quarantine-clear` · `/quality-log-clear` · `/alerts-clear` · 고장 스위치를 부르지 않는다.
- 나쁜 줄을 고쳐서(빈 제목을 채우는 등) 넣지 않는다. **고치는 것은 사람**, 워크플로우는 막고 알린다.
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다. `.env`나 API 키를 출력하지 않는다.