$ uv run tools/flow.py push answers/78-lab03-challenge2-check-log.json answers/78-lab03-challenge2-report.json ! unpublished '78 Schema check (reference)' (w6b17d7c76908a6a): it used the same webhook path ! unpublished '78 Quality report (answer)' (wa6b87a0a6957e93): it used the same webhook path ✔ pushed '78 Schema check + quality log (answer)' (id wf4c2f04db13849f) POST http://localhost:5678/webhook/my-check ✔ pushed '78 Quality report + by dataset (answer)' (id we40b9a92abbf7fd) POST http://localhost:5678/webhook/my-quality-report $ uv run tools/flow.py test cases/lab03-challenge2.json -v ✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 5} → request POST http://localhost:8800/faq-reset {} ← response HTTP 200 { "cleared": 5 } ✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 1} → request POST http://localhost:8800/quarantine-clear {} ← response HTTP 200 { "cleared": 1 } ✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 1} → request POST http://localhost:8800/quality-log-clear {} ← response HTTP 200 { "cleared": 1 } ✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} → request POST http://localhost:8800/alerts-clear {} ← response HTTP 200 { "cleared": 1 } ✔ before: POST /webhook/my-check · HTTP 200 · 0.9s · {"schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, "invalid": 5, "rows": [{"row": 1, "key": "sehoon.oh@exam → request POST /webhook/my-check { "schema": "employee", "batch": "HR-1", "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" } ← response HTTP 200 { "schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, "invalid": 5, "rows": [ { "row": 1, "key": "sehoon.oh@example.com", "ok": true, "errors": [] }, { "row": 2, "key": "haneul.yoon@example.com", "ok": true, "errors": [] }, { "row": 3, "key": "minsu.kang@example", "ok": false, "errors": [ "bad_format:email" ] }, { "row": 4, "key": "jia.seo@example.com", "ok": false, "errors": [ "not_allowed:dept" ] }, { "row": 5, "key": "doyun.lim@example.com", "ok": false, "errors": [ "bad_format:joined" ] }, { "row": 6, "key": "noname@example.com", "ok": false, "errors": [ "missing:name" ] }, { "row": 7, "key": "sua.bae@example.com", "ok": true, "errors": [] }, { "row": 8, "key": "sua.bae@example.com", "ok": false, "errors": [ "duplicate_in_batch" ] }, { "row": 9, "key": "daeun.jung@example.com", "ok": true, "errors": [] } ], "codes": { "bad_format:email": 1, "not_allowed:dept": 1, "bad_format:joined": 1, "missing:name": 1, "duplicate_in_batch": 1 } } ✔ before: POST /webhook/my-check · HTTP 200 · 0.3s · {"schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, "invalid": 7, "rows": [{"row": 1, "key": "T-2001", "ok": t → request POST /webhook/my-check { "schema": "ticket", "batch": "TK-1", "items": [ { "ticket_id": "T-2001", "title": "모니터가 켜지지 않음", "requester": "박서연", "priority": "normal", "opened": "2026-10-28", "contact": "010-2345-6789" }, { "ticket_id": "T-12", "title": "메일 첨부가 안 열림", "requester": "이준호", "priority": "low", "opened": "2026-10-28" }, { "ticket_id": "T-2003", "title": "VPN이 계속 끊김", "requester": "최유진", "priority": "urgent", "opened": "2026-10-29" }, { "ticket_id": "T-2004", "title": "키보드 교체 요청", "requester": "한지훈", "priority": "low", "opened": "2026-02-30" }, { "ticket_id": "T-2005", "title": "프린터", "requester": "김민지", "priority": "normal", "opened": "2026-10-29" }, { "ticket_id": "T-2006", "title": "공유 드라이브 권한", "requester": "박서연", "priority": "normal", "opened": "2026-10-30", "contact": "010-12-3456" }, { "ticket_id": "T-2001", "title": "모니터가 또 꺼짐", "requester": "박서연", "priority": "high", "opened": "2026-10-30" }, { "ticket_id": "T-2008", "title": "화상회의 카메라 대여", "requester": "", "priority": "low", "opened": "2026-10-30" }, { "ticket_id": "T-2009", "title": "새 직원 계정 만들기", "requester": "이준호", "priority": "high", "opened": "2026-10-30" } ] } ← response HTTP 200 { "schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, "invalid": 7, "rows": [ { "row": 1, "key": "T-2001", "ok": true, "errors": [] }, { "row": 2, "key": "T-12", "ok": false, "errors": [ "bad_format:ticket_id" ] }, { "row": 3, "key": "T-2003", "ok": false, "errors": [ "not_allowed:priority" ] }, { "row": 4, "key": "T-2004", "ok": false, "errors": [ "bad_format:opened" ] }, { "row": 5, "key": "T-2005", "ok": false, "errors": [ "too_short:title" ] }, { "row": 6, "key": "T-2006", "ok": false, "errors": [ "bad_format:contact" ] }, { "row": 7, "key": "T-2001", "ok": false, "errors": [ "duplicate_in_batch" ] }, { "row": 8, "key": "T-2008", "ok": false, "errors": [ "missing:requester" ] }, { "row": 9, "key": "T-2009", "ok": true, "errors": [] } ], "codes": { "bad_format:ticket_id": 1, "not_allowed:priority": 1, "bad_format:opened": 1, "too_short:title": 1, "bad_format:contact": 1, "duplicate_in_batch": 1, "missing:requester": 1 } } ✔ before: POST /webhook/my-faq-import · HTTP 200 · 5.0s · {"batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0 → request POST /webhook/my-faq-import { "batch": "B-3", "items": [ { "id": "FAQ-301", "title": "택배 수령", "keywords": [ "택배" ], "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." }, { "id": "FAQ-302", "title": "주차 등록", "keywords": [ "주차" ], "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." }, { "id": "FAQ-303", "title": "명함 신청", "keywords": [ "명함" ], "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." }, { "id": "FAQ-304", "title": "사내 식당 메뉴", "keywords": [ "식당", "메뉴" ], "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." }, { "id": "FAQ-305", "title": "교육 신청", "keywords": [ "교육" ], "answer": "짧" }, { "id": "FAQ-306", "title": "건강검진", "keywords": [ "건강검진" ], "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." } ] } ← response HTTP 200 { "batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0.3, "codes": { "answer_too_short": 1 }, "imported_ids": [ "FAQ-301", "FAQ-302", "FAQ-303", "FAQ-304", "FAQ-306" ], "quarantined_ids": [ "FAQ-305" ], "llm_calls": 5 } POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1 ✔ 검사도 품질 기록 (직원 · 티켓 · FAQ) · 0.0s → request POST http://localhost:8800/quality-log/list {} ← response HTTP 200 { "count": 3, "items": [ { "batch": "HR-1", "dataset": "employee", "total": 9, "valid": 4, "quarantined": 5, "rejected": false, "score": 0.4444, "codes": { "bad_format:email": 1, "not_allowed:dept": 1, "bad_format:joined": 1, "missing:name": 1, "duplicate_in_batch": 1 }, "at": "2026-11-01T18:11:30" }, { "batch": "TK-1", "dataset": "ticket", "total": 9, "valid": 2, "quarantined": 7, "rejected": false, "score": 0.2222, "codes": { "bad_format:ticket_id": 1, "not_allowed:priority": 1, "bad_format:opened": 1, "too_short:title": 1, "bad_format:contact": 1, "duplicate_in_batch": 1, "missing:requester": 1 }, "at": "2026-11-01T18:11:30" }, { "batch": "B-3", "dataset": "faq", "total": 6, "valid": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "codes": { "answer_too_short": 1 }, "at": "2026-11-01T18:11:35" } ] } ✔ 데이터셋마다 나눠 보기 · 0.1s → request POST /webhook/my-quality-report {} ← response HTTP 200 { "count": 3, "batches": [ { "batch": "HR-1", "score": 0.4444, "rejected": false, "total": 9 }, { "batch": "TK-1", "score": 0.2222, "rejected": false, "total": 9 }, { "batch": "B-3", "score": 0.8333, "rejected": false, "total": 6 } ], "avg_score": 0.5, "worst_batch": "TK-1", "rejected_batches": 0, "top_codes": [ { "code": "duplicate_in_batch", "count": 2 }, { "code": "answer_too_short", "count": 1 }, { "code": "bad_format:contact", "count": 1 }, { "code": "bad_format:email", "count": 1 }, { "code": "bad_format:joined", "count": 1 }, { "code": "bad_format:opened", "count": 1 }, { "code": "bad_format:ticket_id", "count": 1 }, { "code": "missing:name", "count": 1 }, { "code": "missing:requester", "count": 1 }, { "code": "not_allowed:dept", "count": 1 }, { "code": "not_allowed:priority", "count": 1 }, { "code": "too_short:title", "count": 1 } ], "trend": "좋아짐", "by_dataset": { "employee": { "count": 1, "avg_score": 0.4444, "worst_batch": "HR-1", "top_code": "bad_format:email" }, "ticket": { "count": 1, "avg_score": 0.2222, "worst_batch": "TK-1", "top_code": "bad_format:contact" }, "faq": { "count": 1, "avg_score": 0.8333, "worst_batch": "B-3", "top_code": "answer_too_short" } }, "worst_dataset": "ticket" } ✔ 가장 나쁜 데이터셋 · 그 이유 · 0.0s → request POST /webhook/my-quality-report {} ← response HTTP 200 { "count": 3, "batches": [ { "batch": "HR-1", "score": 0.4444, "rejected": false, "total": 9 }, { "batch": "TK-1", "score": 0.2222, "rejected": false, "total": 9 }, { "batch": "B-3", "score": 0.8333, "rejected": false, "total": 6 } ], "avg_score": 0.5, "worst_batch": "TK-1", "rejected_batches": 0, "top_codes": [ { "code": "duplicate_in_batch", "count": 2 }, { "code": "answer_too_short", "count": 1 }, { "code": "bad_format:contact", "count": 1 }, { "code": "bad_format:email", "count": 1 }, { "code": "bad_format:joined", "count": 1 }, { "code": "bad_format:opened", "count": 1 }, { "code": "bad_format:ticket_id", "count": 1 }, { "code": "missing:name", "count": 1 }, { "code": "missing:requester", "count": 1 }, { "code": "not_allowed:dept", "count": 1 }, { "code": "not_allowed:priority", "count": 1 }, { "code": "too_short:title", "count": 1 } ], "trend": "좋아짐", "by_dataset": { "employee": { "count": 1, "avg_score": 0.4444, "worst_batch": "HR-1", "top_code": "bad_format:email" }, "ticket": { "count": 1, "avg_score": 0.2222, "worst_batch": "TK-1", "top_code": "bad_format:contact" }, "faq": { "count": 1, "avg_score": 0.8333, "worst_batch": "B-3", "top_code": "answer_too_short" } }, "worst_dataset": "ticket" } ✔ 한 데이터셋만 (앞의 칸은 그대로) · 0.1s → request POST /webhook/my-quality-report { "dataset": "ticket" } ← response HTTP 200 { "count": 1, "batches": [ { "batch": "TK-1", "score": 0.2222, "rejected": false, "total": 9 } ], "avg_score": 0.2222, "worst_batch": "TK-1", "rejected_batches": 0, "top_codes": [ { "code": "bad_format:contact", "count": 1 }, { "code": "bad_format:opened", "count": 1 }, { "code": "bad_format:ticket_id", "count": 1 }, { "code": "duplicate_in_batch", "count": 1 }, { "code": "missing:requester", "count": 1 }, { "code": "not_allowed:priority", "count": 1 }, { "code": "too_short:title", "count": 1 } ], "trend": "그대로", "by_dataset": { "employee": { "count": 1, "avg_score": 0.4444, "worst_batch": "HR-1", "top_code": "bad_format:email" }, "ticket": { "count": 1, "avg_score": 0.2222, "worst_batch": "TK-1", "top_code": "bad_format:contact" }, "faq": { "count": 1, "avg_score": 0.8333, "worst_batch": "B-3", "top_code": "answer_too_short" } }, "worst_dataset": "ticket" } Passed 4/4 report: runs/lab03-challenge2-20261101-181135.json $ uv run tools/flow.py test cases/lab03.json POST http://localhost:5678/webhook/my-check · 4 case(s) × 1 ✔ 직원 CSV · 9줄 중 4줄 통과 · 0.1s ✔ 직원 CSV · 줄마다 이유 · 0.1s ✔ 같은 워크플로우로 FAQ 스키마 · 0.1s ✔ 모르는 스키마 → 400 · 0.0s Passed 4/4 report: runs/lab03-20261101-181136.json $ uv run tools/flow.py test cases/lab03-challenge.json ✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 5} ✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 1} ✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 6} ✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} ! before: POST /webhook/my-faq-import · HTTP 422 · 0.1s (allow_fail: rejected on purpose) · {"error": "batch rejected", "batch": "B-1", "invalid_ratio": 0.7692, "max_invalid_ratio": 0.3, "codes": {"title_missing" ✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.8s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3 ✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.6s · {"batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0 ✔ before: POST /webhook/my-faq-import · HTTP 200 · 1.8s · {"batch": "B-2-fix", "total": 2, "imported": 2, "quarantined": 0, "rejected": false, "score": 1, "max_invalid_ratio": 0. POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1 ✔ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s ✔ 가장 많은 이유 · 흐름 · 0.1s ✔ 배치마다 점수 순서 · 0.0s ✔ 다른 데이터셋으로 거르기 · 0.1s Passed 4/4 report: runs/lab03-challenge-20261101-181151.json ✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 15} $ uv run tools/flow.py quality FAQ: 6 (nothing added) quarantine: 1 B-3 FAQ-305 answer_too_short quality log: 4 B-1 faq total 13 valid 3 score 0 rejected True B-2 faq total 10 valid 8 score 0.8 rejected False B-3 faq total 6 valid 5 score 0.8333 rejected False B-2-fix faq total 2 valid 2 score 1 rejected False alerts: 3 [high] faq-import · batch_rejected · B-1 rejected [high] faq-import · low_quality · B-2 quality 0.8 [high] faq-import · low_quality · B-3 quality 0.8333 $ uv run tools/flow.py ops --reset ✔ live run logs, added FAQ, quarantine, quality log, created tickets, DLQ and alerts emptied, fault switch all ok $ uv run tools/flow.py doctor ! n8n install folder /Users/mcchae/day65-n8n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더> ✔ n8n container day65-n8n-n8n-1 ✔ n8n http://localhost:5678 ✔ data API http://localhost:8800 ✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) ✔ run logs /logs (666 seeded runs) · FAQ additions /faq-extra (day 77 data API) ✔ schemas faq · employee · ticket, quarantine, quality log (day 78 data API) ✔ n8n → http://data-api:8800 ✔ credential 'OpenAI (.env)' (openAiCourse0001) ✔ node catalog (for check) ✔ CLI coding agent: codex, claude, agy ready