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

17 KiB
Raw Blame History

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 결과, 그리고 사람이 실행할 명령 두 줄.
    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)

배치 (오늘 들어오는 데이터)

{"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>)

{"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. 워크플로우 파일 규칙

{"name": "78 FAQ check (my)", "nodes": [ ... ], "connections": { ... }, "settings": { "executionOrder": "v1" }}
  • 파일 하나에 워크플로우 하나. 노드 name은 영어로, 겹치지 않게. API 키를 JSON에 쓰지 않는다.
  • 규칙은 Code가 확인한다. LLM은 규칙으로 알 수 없는 것(답이 질문에 맞나)만, 규칙을 통과한 줄에만.
  • 검사 결과의 rows는 들어온 줄과 같은 순서 · 같은 수.

5. 검증된 노드 (n8n 2.42.6에서 실행 확인)

본문 확인 (IF) → 줄마다 규칙 (Code)

{"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": {}}}
{"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 } }];"}}

다른 내 웹훅을 부르기 (가져오기 → 검사)

{"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)로 갈라 본다.

줄을 나눠 저장하기 — 한 줄로 (분기 없이)

{"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)

{"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 키를 출력하지 않는다.