78일차 정답: LAB-A · VIBE-A-TCHECK · answers(정답 워크플로우 · 실행 기록) · 강사가 만든 tcheck

This commit is contained in:
mcchae committed 2026-11-02 20:54:06 +09:00
1 parent acf3a93041
commit c7df2e5344
85 files changed
+52637

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
$ 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 off my-faq-check my-faq-import my-check my-quality-report
✔ unpublished my-faq-check (id weecbf2cc21d6fd2)
✔ unpublished my-faq-import (id we9ca88e4f314222)
✔ unpublished my-check (id wf4c2f04db13849f)
✔ unpublished my-quality-report (id we40b9a92abbf7fd)
✔ n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>
$ uv run tools/flow.py push reference/baseline-faq-check.json reference/baseline-faq-import.json
✔ pushed '78 FAQ check (team baseline)' (id w2d09b3af4461197)
POST http://localhost:5678/webhook/base-faq-check
✔ pushed '78 FAQ import (team baseline)' (id w45c6472ffd87501)
POST http://localhost:5678/webhook/base-faq-import
$ uv run tools/flow.py quality
FAQ: 6 (nothing added)
quarantine: 0
quality log: 0
alerts: 0
$ 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
+99
View File
@@ -0,0 +1,99 @@
$ uv run tools/flow.py call base-faq-check requests/faq-batch-1.json
HTTP 200 · 0.9s
{
"batch": "B-1",
"total": 13,
"valid": 10,
"invalid": 3,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-110",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"answer_missing": 1,
"keywords_missing": 1
}
}
+865
View File
@@ -0,0 +1,865 @@
{
"cases": "lab01.json",
"webhook": "base-faq-check",
"passed": 1,
"total": 5,
"results": [
{
"case": "빈 값 (제목 · 답 · 키워드)",
"webhook": "base-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 10,
"invalid": 3,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-110",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"answer_missing": 1,
"keywords_missing": 1
}
}
},
{
"case": "형식 (id · 답 길이)",
"webhook": "base-faq-check",
"run": 1,
"passed": false,
"problems": [
"rows.3.errors has none of ['id_format']",
"rows.5.errors has none of ['answer_too_long']"
],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 10,
"invalid": 3,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-110",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"answer_missing": 1,
"keywords_missing": 1
}
}
},
{
"case": "배치 안 중복 · 개인정보 · 바깥 링크",
"webhook": "base-faq-check",
"run": 1,
"passed": false,
"problems": [
"rows.6.errors has none of ['duplicate_in_batch']",
"rows.8.errors has none of ['pii']",
"rows.9.errors has none of ['external_link']"
],
"seconds": 0.0,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 10,
"invalid": 3,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-110",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"answer_missing": 1,
"keywords_missing": 1
}
}
},
{
"case": "배치 2 · 메일 주소만 걸림, 사내 링크는 통과",
"webhook": "base-faq-check",
"run": 1,
"passed": false,
"problems": [
"valid = 10 (expected 9)",
"codes.pii = null (expected 1)",
"rows.8.ok = true (expected false)"
],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"valid": 10,
"invalid": 0,
"rows": [
{
"id": "FAQ-201",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {}
}
},
{
"case": "목록이 아닌 본문 → 400",
"webhook": "base-faq-check",
"run": 1,
"passed": false,
"problems": [
"status 500 (expected 400)"
],
"seconds": 0.1,
"status": 500,
"request": {
"batch": "B-x",
"items": "FAQ-101"
},
"response": {
"message": "Error in workflow"
}
}
]
}
+818
View File
@@ -0,0 +1,818 @@
$ uv run tools/flow.py test cases/lab01.json --webhook base-faq-check -v
POST http://localhost:5678/webhook/base-faq-check · 5 case(s) × 1
✔ 빈 값 (제목 · 답 · 키워드) · 0.0s
→ request POST /webhook/base-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 10,
"invalid": 3,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-110",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"answer_missing": 1,
"keywords_missing": 1
}
}
✘ 형식 (id · 답 길이) · 0.1s · rows.3.errors has none of ['id_format']; rows.5.errors has none of ['answer_too_long']
→ request POST /webhook/base-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 10,
"invalid": 3,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-110",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"answer_missing": 1,
"keywords_missing": 1
}
}
✘ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.0s · rows.6.errors has none of ['duplicate_in_batch']; rows.8.errors has none of ['pii']; rows.9.errors has none of ['external_link']
→ request POST /webhook/base-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 10,
"invalid": 3,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-110",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"answer_missing": 1,
"keywords_missing": 1
}
}
✘ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.1s · valid = 10 (expected 9); codes.pii = null (expected 1); rows.8.ok = true (expected false)
→ request POST /webhook/base-faq-check
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"valid": 10,
"invalid": 0,
"rows": [
{
"id": "FAQ-201",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {}
}
✘ 목록이 아닌 본문 → 400 · 0.1s · status 500 (expected 400)
→ request POST /webhook/base-faq-check
{
"batch": "B-x",
"items": "FAQ-101"
}
← response HTTP 500
{
"message": "Error in workflow"
}
Passed 1/5
report: runs/lab01-20261101-180505.json
+905
View File
@@ -0,0 +1,905 @@
{
"cases": "lab01.json",
"webhook": "my-faq-check",
"passed": 5,
"total": 5,
"results": [
{
"case": "빈 값 (제목 · 답 · 키워드)",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
},
{
"case": "형식 (id · 답 길이)",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
},
{
"case": "배치 안 중복 · 개인정보 · 바깥 링크",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
},
{
"case": "배치 2 · 메일 주소만 걸림, 사내 링크는 통과",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"valid": 9,
"invalid": 1,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-201",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"pii": 1
}
}
},
{
"case": "목록이 아닌 본문 → 400",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 400,
"request": {
"batch": "B-x",
"items": "FAQ-101"
},
"response": {
"error": "items must be a list"
}
}
]
}
+991
View File
@@ -0,0 +1,991 @@
$ uv run tools/flow.py push reference/lab01-example2-check.json
✔ pushed '78 FAQ check (reference)' (id w318880a1bd61aa2)
POST http://localhost:5678/webhook/my-faq-check
$ uv run tools/flow.py test cases/lab01.json -v
POST http://localhost:5678/webhook/my-faq-check · 5 case(s) × 1
✔ 빈 값 (제목 · 답 · 키워드) · 0.9s
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
✔ 형식 (id · 답 길이) · 0.0s
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
✔ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.1s
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
✔ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.0s
→ request POST /webhook/my-faq-check
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"valid": 9,
"invalid": 1,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-201",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"pii": 1
}
}
✔ 목록이 아닌 본문 → 400 · 0.1s
→ request POST /webhook/my-faq-check
{
"batch": "B-x",
"items": "FAQ-101"
}
← response HTTP 400
{
"error": "items must be a list"
}
Passed 5/5
report: runs/lab01-20261101-180518.json
$ uv run tools/flow.py call my-faq-check requests/faq-batch-1.json
HTTP 200 · 0.1s
{
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,899 @@
{
"cases": "lab01-challenge.json",
"webhook": "my-faq-check",
"passed": 0,
"total": 4,
"results": [
{
"case": "이미 있는 id (FAQ-003)",
"webhook": "my-faq-check",
"run": 1,
"passed": false,
"problems": [
"rows.10.errors has none of ['exists_id']"
],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
},
{
"case": "이미 있는 제목 · 쓰인 키워드는 경고",
"webhook": "my-faq-check",
"run": 1,
"passed": false,
"problems": [
"rows.11.errors has none of ['same_title']",
"rows.11.warnings has none of ['keyword_used:vpn']"
],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
},
{
"case": "배치 1 · 통과 3건",
"webhook": "my-faq-check",
"run": 1,
"passed": false,
"problems": [
"valid = 5 (expected 3)",
"invalid = 8 (expected 10)"
],
"seconds": 0.0,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
},
{
"case": "막 더한 FAQ도 기존 데이터",
"webhook": "my-faq-check",
"run": 1,
"passed": false,
"problems": [
"rows.0.errors has none of ['exists_id']"
],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"valid": 9,
"invalid": 1,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-201",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"pii": 1
}
}
}
]
}
@@ -0,0 +1,890 @@
$ uv run tools/flow.py test cases/lab01-challenge.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/my-faq-check · 4 case(s) × 1
✘ 이미 있는 id (FAQ-003) · 0.1s · rows.10.errors has none of ['exists_id']
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
✘ 이미 있는 제목 · 쓰인 키워드는 경고 · 0.1s · rows.11.errors has none of ['same_title']; rows.11.warnings has none of ['keyword_used:vpn']
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
✘ 배치 1 · 통과 3건 · 0.0s · valid = 5 (expected 3); invalid = 8 (expected 10)
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 5,
"invalid": 8,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-112",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1
}
}
✔ before: POST http://localhost:8800/faq/add · HTTP 200 · 0.0s · {"added": "FAQ-201", "size": 7}
→ request POST http://localhost:8800/faq/add
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 예약합니다."
}
← response HTTP 200
{
"added": "FAQ-201",
"size": 7
}
✘ 막 더한 FAQ도 기존 데이터 · 0.1s · rows.0.errors has none of ['exists_id']
→ request POST /webhook/my-faq-check
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"valid": 9,
"invalid": 1,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-201",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"pii": 1
}
}
Passed 0/4
report: runs/lab01-challenge-20261101-180519.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 1}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 1
}
+920
View File
@@ -0,0 +1,920 @@
{
"cases": "lab01-challenge.json",
"webhook": "my-faq-check",
"passed": 4,
"total": 4,
"results": [
{
"case": "이미 있는 id (FAQ-003)",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 3,
"invalid": 10,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": false,
"errors": [
"exists_id"
],
"warnings": []
},
{
"id": "FAQ-112",
"ok": false,
"errors": [
"same_title"
],
"warnings": [
"keyword_used:vpn"
]
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "이미 있는 제목 · 쓰인 키워드는 경고",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 3,
"invalid": 10,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": false,
"errors": [
"exists_id"
],
"warnings": []
},
{
"id": "FAQ-112",
"ok": false,
"errors": [
"same_title"
],
"warnings": [
"keyword_used:vpn"
]
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "배치 1 · 통과 3건",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"valid": 3,
"invalid": 10,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": false,
"errors": [
"exists_id"
],
"warnings": []
},
{
"id": "FAQ-112",
"ok": false,
"errors": [
"same_title"
],
"warnings": [
"keyword_used:vpn"
]
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "막 더한 FAQ도 기존 데이터",
"webhook": "my-faq-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"valid": 8,
"invalid": 2,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-201",
"ok": false,
"errors": [
"exists_id",
"same_title"
],
"warnings": [
"keyword_used:회의실"
]
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"exists_id": 1,
"same_title": 1,
"pii": 1
}
}
}
]
}
+936
View File
@@ -0,0 +1,936 @@
$ uv run tools/flow.py push answers/78-lab01-challenge-existing.json
! unpublished '78 FAQ check (reference)' (w318880a1bd61aa2): it used the same webhook path
✔ pushed '78 FAQ check + existing data (answer)' (id w99a77a91b9217c9)
POST http://localhost:5678/webhook/my-faq-check
$ uv run tools/flow.py test cases/lab01-challenge.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/my-faq-check · 4 case(s) × 1
✔ 이미 있는 id (FAQ-003) · 0.9s
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 3,
"invalid": 10,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": false,
"errors": [
"exists_id"
],
"warnings": []
},
{
"id": "FAQ-112",
"ok": false,
"errors": [
"same_title"
],
"warnings": [
"keyword_used:vpn"
]
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ 이미 있는 제목 · 쓰인 키워드는 경고 · 0.0s
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 3,
"invalid": 10,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": false,
"errors": [
"exists_id"
],
"warnings": []
},
{
"id": "FAQ-112",
"ok": false,
"errors": [
"same_title"
],
"warnings": [
"keyword_used:vpn"
]
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ 배치 1 · 통과 3건 · 0.1s
→ request POST /webhook/my-faq-check
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"valid": 3,
"invalid": 10,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-101",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-102",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-103",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-10",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
},
{
"id": "FAQ-105",
"ok": false,
"errors": [
"answer_missing"
],
"warnings": []
},
{
"id": "FAQ-106",
"ok": false,
"errors": [
"answer_too_long"
],
"warnings": []
},
{
"id": "FAQ-102",
"ok": false,
"errors": [
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "FAQ-108",
"ok": false,
"errors": [
"keywords_missing"
],
"warnings": []
},
{
"id": "FAQ-109",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-110",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "FAQ-003",
"ok": false,
"errors": [
"exists_id"
],
"warnings": []
},
{
"id": "FAQ-112",
"ok": false,
"errors": [
"same_title"
],
"warnings": [
"keyword_used:vpn"
]
},
{
"id": "FAQ-113",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ before: POST http://localhost:8800/faq/add · HTTP 200 · 0.0s · {"added": "FAQ-201", "size": 7}
→ request POST http://localhost:8800/faq/add
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 예약합니다."
}
← response HTTP 200
{
"added": "FAQ-201",
"size": 7
}
✔ 막 더한 FAQ도 기존 데이터 · 0.0s
→ request POST /webhook/my-faq-check
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"valid": 8,
"invalid": 2,
"with_warnings": 1,
"rows": [
{
"id": "FAQ-201",
"ok": false,
"errors": [
"exists_id",
"same_title"
],
"warnings": [
"keyword_used:회의실"
]
},
{
"id": "FAQ-202",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-203",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-204",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-205",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-206",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-207",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-208",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-209",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-210",
"ok": true,
"errors": [],
"warnings": []
}
],
"codes": {
"exists_id": 1,
"same_title": 1,
"pii": 1
}
}
Passed 4/4
report: runs/lab01-challenge-20261101-180533.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 1}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 1
}
$ uv run tools/flow.py test cases/lab01.json
POST http://localhost:5678/webhook/my-faq-check · 5 case(s) × 1
✔ 빈 값 (제목 · 답 · 키워드) · 0.1s
✔ 형식 (id · 답 길이) · 0.2s
✔ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.1s
✔ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.0s
✔ 목록이 아닌 본문 → 400 · 0.1s
Passed 5/5
report: runs/lab01-20261101-180533.json
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+773
View File
@@ -0,0 +1,773 @@
{
"cases": "lab02.json",
"webhook": "base-faq-import",
"passed": 0,
"total": 6,
"results": [
{
"case": "나쁜 배치는 통째로 거절 (422)",
"webhook": "base-faq-import",
"run": 1,
"passed": false,
"problems": [
"status 200 (expected 422)",
"invalid_ratio = null (expected 0.3..1)"
],
"seconds": 0.1,
"status": 200,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"imported": 12,
"quarantined": 0,
"rejected": false,
"score": 0.9231,
"imported_ids": [
"FAQ-101",
"FAQ-102",
"FAQ-103",
"FAQ-10",
"FAQ-106",
"FAQ-102",
"FAQ-108",
"FAQ-109",
"FAQ-110",
"FAQ-003",
"FAQ-112",
"FAQ-113"
],
"quarantined_ids": []
}
},
{
"case": "배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리",
"webhook": "base-faq-import",
"run": 1,
"passed": false,
"problems": [
"quarantined_ids has none of ['FAQ-209']"
],
"seconds": 1.1,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 10,
"quarantined": 0,
"rejected": false,
"score": 1,
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": []
}
},
{
"case": "FAQ에 들어갔고 메일 주소는 안 들어감",
"webhook": "http://localhost:8800/faq/list",
"run": 1,
"passed": false,
"problems": [
"faqs must not have ['security.desk']"
],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 27,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
},
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
}
]
}
},
{
"case": "격리함에 이유와 함께",
"webhook": "http://localhost:8800/quarantine/list",
"run": 1,
"passed": false,
"problems": [
"items has none of ['\"pii\"']"
],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"size": 0,
"items": []
}
},
{
"case": "LLM · 제목과 답이 다른 줄 (FAQ-210)",
"webhook": "base-faq-import",
"run": 1,
"passed": false,
"problems": [
"imported = 10 (expected 8)",
"quarantined = 0 (expected 2)",
"codes.llm_mismatch = null (expected 1)",
"quarantined_ids has none of ['FAQ-210']"
],
"seconds": 1.1,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 10,
"quarantined": 0,
"rejected": false,
"score": 1,
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": []
}
},
{
"case": "LLM · 규칙에 걸린 줄은 그대로 (FAQ-305)",
"webhook": "base-faq-import",
"run": 1,
"passed": false,
"problems": [
"imported = 6 (expected 5)",
"quarantined_ids has none of ['FAQ-305']"
],
"seconds": 0.1,
"status": 200,
"request": {
"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": {
"batch": "B-3",
"total": 6,
"imported": 6,
"quarantined": 0,
"rejected": false,
"score": 1,
"imported_ids": [
"FAQ-301",
"FAQ-302",
"FAQ-303",
"FAQ-304",
"FAQ-305",
"FAQ-306"
],
"quarantined_ids": []
}
}
]
}
+771
View File
@@ -0,0 +1,771 @@
$ uv run tools/flow.py test cases/lab02.json --webhook base-faq-import -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/base-faq-import · 6 case(s) × 1
✘ 나쁜 배치는 통째로 거절 (422) · 0.1s · status 200 (expected 422); invalid_ratio = null (expected 0.3..1)
→ request POST /webhook/base-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"imported": 12,
"quarantined": 0,
"rejected": false,
"score": 0.9231,
"imported_ids": [
"FAQ-101",
"FAQ-102",
"FAQ-103",
"FAQ-10",
"FAQ-106",
"FAQ-102",
"FAQ-108",
"FAQ-109",
"FAQ-110",
"FAQ-003",
"FAQ-112",
"FAQ-113"
],
"quarantined_ids": []
}
✘ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.1s · quarantined_ids has none of ['FAQ-209']
→ request POST /webhook/base-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 10,
"quarantined": 0,
"rejected": false,
"score": 1,
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": []
}
✘ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s · faqs must not have ['security.desk']
→ request POST http://localhost:8800/faq/list
{}
← response HTTP 200
{
"count": 27,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
},
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
}
]
}
✘ 격리함에 이유와 함께 · 0.0s · items has none of ['"pii"']
→ request POST http://localhost:8800/quarantine/list
{}
← response HTTP 200
{
"size": 0,
"items": []
}
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 21}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 21
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 0
}
✘ LLM · 제목과 답이 다른 줄 (FAQ-210) · 1.1s · imported = 10 (expected 8); quarantined = 0 (expected 2); codes.llm_mismatch = null (expected 1); quarantined_ids has none of ['FAQ-210']
→ request POST /webhook/base-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 10,
"quarantined": 0,
"rejected": false,
"score": 1,
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": []
}
✘ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s · imported = 6 (expected 5); quarantined_ids has none of ['FAQ-305']
→ request POST /webhook/base-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": 6,
"quarantined": 0,
"rejected": false,
"score": 1,
"imported_ids": [
"FAQ-301",
"FAQ-302",
"FAQ-303",
"FAQ-304",
"FAQ-305",
"FAQ-306"
],
"quarantined_ids": []
}
Passed 0/6
report: runs/lab02-20261101-180552.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 16}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 16
}
$ uv run tools/flow.py quality
FAQ: 6 (nothing added)
quarantine: 0
quality log: 0
alerts: 0
+689
View File
@@ -0,0 +1,689 @@
{
"cases": "lab02.json",
"webhook": "my-faq-import",
"passed": 5,
"total": 6,
"results": [
{
"case": "나쁜 배치는 통째로 거절 (422)",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 422,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 9,
"quarantined": 1,
"rejected": false,
"score": 0.9,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-210"
],
"quarantined_ids": [
"FAQ-209"
]
}
},
{
"case": "FAQ에 들어갔고 메일 주소는 안 들어감",
"webhook": "http://localhost:8800/faq/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 15,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
},
{
"case": "격리함에 이유와 함께",
"webhook": "http://localhost:8800/quarantine/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"size": 1,
"items": [
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:06:06"
}
]
}
},
{
"case": "LLM · 제목과 답이 다른 줄 (FAQ-210)",
"webhook": "my-faq-import",
"run": 1,
"passed": false,
"problems": [
"imported = 9 (expected 8)",
"quarantined = 1 (expected 2)",
"codes.llm_mismatch = null (expected 1)",
"quarantined_ids has none of ['FAQ-210']"
],
"seconds": 1.2,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 9,
"quarantined": 1,
"rejected": false,
"score": 0.9,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-210"
],
"quarantined_ids": [
"FAQ-209"
]
}
},
{
"case": "LLM · 규칙에 걸린 줄은 그대로 (FAQ-305)",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"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": {
"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"
]
}
}
]
}
+705
View File
@@ -0,0 +1,705 @@
$ uv run tools/flow.py push reference/lab02-example1-import.json
✔ pushed '78 FAQ import + gate (reference)' (id we19b3911975e55e)
POST http://localhost:5678/webhook/my-faq-import
$ uv run tools/flow.py test cases/lab02.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1
✔ 나쁜 배치는 통째로 거절 (422) · 0.9s
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.4s
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 9,
"quarantined": 1,
"rejected": false,
"score": 0.9,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-210"
],
"quarantined_ids": [
"FAQ-209"
]
}
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
→ request POST http://localhost:8800/faq/list
{}
← response HTTP 200
{
"count": 15,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
✔ 격리함에 이유와 함께 · 0.0s
→ request POST http://localhost:8800/quarantine/list
{}
← response HTTP 200
{
"size": 1,
"items": [
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:06:06"
}
]
}
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 9}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 9
}
✔ 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
}
✘ LLM · 제목과 답이 다른 줄 (FAQ-210) · 1.2s · imported = 9 (expected 8); quarantined = 1 (expected 2); codes.llm_mismatch = null (expected 1); quarantined_ids has none of ['FAQ-210']
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 9,
"quarantined": 1,
"rejected": false,
"score": 0.9,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208",
"FAQ-210"
],
"quarantined_ids": [
"FAQ-209"
]
}
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s
→ 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"
]
}
Passed 5/6
report: runs/lab02-20261101-180607.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 14}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 14
}
$ uv run tools/flow.py quality
FAQ: 6 (nothing added)
quarantine: 2
B-2 FAQ-209 pii
B-3 FAQ-305 answer_too_short
quality log: 0
alerts: 0
+694
View File
@@ -0,0 +1,694 @@
{
"cases": "lab02.json",
"webhook": "my-faq-import",
"passed": 6,
"total": 6,
"results": [
{
"case": "나쁜 배치는 통째로 거절 (422)",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 422,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 9.3,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
},
{
"case": "FAQ에 들어갔고 메일 주소는 안 들어감",
"webhook": "http://localhost:8800/faq/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 14,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
}
]
}
},
{
"case": "격리함에 이유와 함께",
"webhook": "http://localhost:8800/quarantine/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"size": 2,
"items": [
{
"row_id": "FAQ-210",
"batch": "B-2",
"row": {
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:12:18"
},
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:12:18"
}
]
}
},
{
"case": "LLM · 제목과 답이 다른 줄 (FAQ-210)",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 8.4,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
},
{
"case": "LLM · 규칙에 걸린 줄은 그대로 (FAQ-305)",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 4.4,
"status": 200,
"request": {
"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": {
"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"
]
}
}
]
}
+708
View File
@@ -0,0 +1,708 @@
$ uv run tools/flow.py push reference/lab02-example2-import.json
! unpublished '78 FAQ import + release fixed rows (reference)' (we9ca88e4f314222): it used the same webhook path
✔ pushed '78 FAQ import + LLM check (reference)' (id wee5dfb169c284f5)
POST http://localhost:5678/webhook/my-faq-import
$ uv run tools/flow.py test cases/lab02.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1
✔ 나쁜 배치는 통째로 거절 (422) · 1.0s
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 9.3s
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
→ request POST http://localhost:8800/faq/list
{}
← response HTTP 200
{
"count": 14,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
}
]
}
✔ 격리함에 이유와 함께 · 0.0s
→ request POST http://localhost:8800/quarantine/list
{}
← response HTTP 200
{
"size": 2,
"items": [
{
"row_id": "FAQ-210",
"batch": "B-2",
"row": {
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:12:18"
},
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:12:18"
}
]
}
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 8
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.4s
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 4.4s
→ 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"
]
}
Passed 6/6
report: runs/lab02-20261101-181231.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 13
}
+790
View File
@@ -0,0 +1,790 @@
{
"cases": "lab02-gate.json",
"webhook": "my-faq-import",
"passed": 6,
"total": 6,
"results": [
{
"case": "B-1 · 기본 기준 0.3 → 거절",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 422,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "B-1 · 기준을 0.8로 풀면 → 받음, 넣는 것은 2줄",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 9.0,
"status": 200,
"request": {
"max_invalid_ratio": 0.8,
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"batch": "B-1",
"total": 13,
"imported": 2,
"quarantined": 11,
"rejected": false,
"score": 0.1538,
"max_invalid_ratio": 0.8,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-101",
"FAQ-102"
],
"quarantined_ids": [
"FAQ-103",
"FAQ-10",
"FAQ-105",
"FAQ-106",
"FAQ-102",
"FAQ-108",
"FAQ-109",
"FAQ-110",
"FAQ-003",
"FAQ-112",
"FAQ-113"
]
}
},
{
"case": "FAQ만 처음으로 (B-1이 넣은 2줄을 비움, 격리함은 그대로)",
"webhook": "http://localhost:8800/faq-reset",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"cleared": 2
}
},
{
"case": "B-2 · 기준 0.05 → 거절",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 422,
"request": {
"max_invalid_ratio": 0.05,
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-2",
"invalid_ratio": 0.1,
"max_invalid_ratio": 0.05,
"codes": {
"pii": 1
}
}
},
{
"case": "B-2 · 기준 0.1 · 나쁜 줄이 딱 10% → 받음",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 8.7,
"status": 200,
"request": {
"max_invalid_ratio": 0.1,
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.1,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
},
{
"case": "격리함이 13줄 (B-1 11 + B-2 2)",
"webhook": "http://localhost:8800/quarantine/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"size": 13,
"items": [
{
"row_id": "FAQ-103",
"batch": "B-1",
"row": {
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
"errors": [
"title_missing"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-10",
"batch": "B-1",
"row": {
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
"errors": [
"id_format"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-105",
"batch": "B-1",
"row": {
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
"errors": [
"answer_missing"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-106",
"batch": "B-1",
"row": {
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
"errors": [
"answer_too_long"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-113",
"batch": "B-1",
"row": {
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-108",
"batch": "B-1",
"row": {
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
"errors": [
"keywords_missing"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-003",
"batch": "B-1",
"row": {
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
"errors": [
"exists_id"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-109",
"batch": "B-1",
"row": {
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-110",
"batch": "B-1",
"row": {
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
"errors": [
"external_link"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-112",
"batch": "B-1",
"row": {
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
"errors": [
"same_title"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-102",
"batch": "B-1",
"row": {
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
"errors": [
"duplicate_in_batch"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-210",
"batch": "B-2",
"row": {
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:12:48"
},
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:12:49"
}
]
}
}
]
}
+792
View File
@@ -0,0 +1,792 @@
$ uv run tools/flow.py test cases/lab02-gate.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 3
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1
✔ B-1 · 기본 기준 0.3 → 거절 · 0.1s
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ B-1 · 기준을 0.8로 풀면 → 받음, 넣는 것은 2줄 · 9.0s
→ request POST /webhook/my-faq-import
{
"max_invalid_ratio": 0.8,
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 200
{
"batch": "B-1",
"total": 13,
"imported": 2,
"quarantined": 11,
"rejected": false,
"score": 0.1538,
"max_invalid_ratio": 0.8,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-101",
"FAQ-102"
],
"quarantined_ids": [
"FAQ-103",
"FAQ-10",
"FAQ-105",
"FAQ-106",
"FAQ-102",
"FAQ-108",
"FAQ-109",
"FAQ-110",
"FAQ-003",
"FAQ-112",
"FAQ-113"
]
}
✔ FAQ만 처음으로 (B-1이 넣은 2줄을 비움, 격리함은 그대로) · 0.0s
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 2
}
✔ B-2 · 기준 0.05 → 거절 · 0.1s
→ request POST /webhook/my-faq-import
{
"max_invalid_ratio": 0.05,
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-2",
"invalid_ratio": 0.1,
"max_invalid_ratio": 0.05,
"codes": {
"pii": 1
}
}
✔ B-2 · 기준 0.1 · 나쁜 줄이 딱 10% → 받음 · 8.7s
→ request POST /webhook/my-faq-import
{
"max_invalid_ratio": 0.1,
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.1,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
✔ 격리함이 13줄 (B-1 11 + B-2 2) · 0.0s
→ request POST http://localhost:8800/quarantine/list
{}
← response HTTP 200
{
"size": 13,
"items": [
{
"row_id": "FAQ-103",
"batch": "B-1",
"row": {
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
"errors": [
"title_missing"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-10",
"batch": "B-1",
"row": {
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
"errors": [
"id_format"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-105",
"batch": "B-1",
"row": {
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
"errors": [
"answer_missing"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-106",
"batch": "B-1",
"row": {
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
"errors": [
"answer_too_long"
],
"at": "2026-11-01T18:12:39"
},
{
"row_id": "FAQ-113",
"batch": "B-1",
"row": {
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-108",
"batch": "B-1",
"row": {
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
"errors": [
"keywords_missing"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-003",
"batch": "B-1",
"row": {
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
"errors": [
"exists_id"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-109",
"batch": "B-1",
"row": {
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-110",
"batch": "B-1",
"row": {
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
"errors": [
"external_link"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-112",
"batch": "B-1",
"row": {
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
"errors": [
"same_title"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-102",
"batch": "B-1",
"row": {
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
"errors": [
"duplicate_in_batch"
],
"at": "2026-11-01T18:12:40"
},
{
"row_id": "FAQ-210",
"batch": "B-2",
"row": {
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:12:48"
},
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:12:49"
}
]
}
Passed 6/6
report: runs/lab02-gate-20261101-181249.json
$ uv run tools/flow.py quality
FAQ: 14 (FAQ-202, FAQ-201, FAQ-203, FAQ-204, FAQ-205, FAQ-207, FAQ-208, FAQ-206)
quarantine: 13
B-1 FAQ-108 keywords_missing
B-1 FAQ-003 exists_id
B-1 FAQ-109 pii
B-1 FAQ-110 external_link
B-1 FAQ-112 same_title
B-1 FAQ-102 duplicate_in_batch
B-2 FAQ-210 llm_mismatch
B-2 FAQ-209 pii
quality log: 0
alerts: 0
@@ -0,0 +1,330 @@
{
"cases": "lab02-challenge.json",
"webhook": "my-faq-import",
"passed": 2,
"total": 5,
"results": [
{
"case": "거절도 품질 기록",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 422,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "배치 2 가져오기",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 8.7,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
},
{
"case": "품질 기록 두 줄 (거절 · 0.8)",
"webhook": "http://localhost:8800/quality-log/list",
"run": 1,
"passed": false,
"problems": [
"count = 0 (expected 2)",
"items.0.rejected = null (expected true)",
"items.1.score = null (expected 0.8)",
"items.1.valid = null (expected 8)",
"items.1.quarantined = null (expected 2)"
],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 0,
"items": []
}
},
{
"case": "알림 두 건 (거절 · 낮은 점수)",
"webhook": "http://localhost:8800/alerts/list",
"run": 1,
"passed": false,
"problems": [
"count = 0 (expected 2)",
"alerts has none of ['batch_rejected']"
],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 0,
"alerts": []
}
},
{
"case": "알림에 낮은 점수",
"webhook": "http://localhost:8800/alerts/list",
"run": 1,
"passed": false,
"problems": [
"alerts has none of ['low_quality']"
],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 0,
"alerts": []
}
}
]
}
@@ -0,0 +1,320 @@
$ uv run tools/flow.py test cases/lab02-challenge.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 8
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 13}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 13
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/my-faq-import · 5 case(s) × 1
✔ 거절도 품질 기록 · 0.1s
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ 배치 2 가져오기 · 8.7s
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
✘ 품질 기록 두 줄 (거절 · 0.8) · 0.0s · count = 0 (expected 2); items.0.rejected = null (expected true); items.1.score = null (expected 0.8); items.1.valid = null (expected 8); items.1.quarantined = null (expected 2)
→ request POST http://localhost:8800/quality-log/list
{}
← response HTTP 200
{
"count": 0,
"items": []
}
✘ 알림 두 건 (거절 · 낮은 점수) · 0.0s · count = 0 (expected 2); alerts has none of ['batch_rejected']
→ request POST http://localhost:8800/alerts/list
{}
← response HTTP 200
{
"count": 0,
"alerts": []
}
✘ 알림에 낮은 점수 · 0.0s · alerts has none of ['low_quality']
→ request POST http://localhost:8800/alerts/list
{}
← response HTTP 200
{
"count": 0,
"alerts": []
}
Passed 2/5
report: runs/lab02-challenge-20261101-180707.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 8
}
+396
View File
@@ -0,0 +1,396 @@
{
"cases": "lab02-challenge.json",
"webhook": "my-faq-import",
"passed": 5,
"total": 5,
"results": [
{
"case": "거절도 품질 기록",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 422,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "배치 2 가져오기",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 9.4,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
},
{
"case": "품질 기록 두 줄 (거절 · 0.8)",
"webhook": "http://localhost:8800/quality-log/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 2,
"items": [
{
"batch": "B-1",
"dataset": "faq",
"total": 13,
"valid": 3,
"quarantined": 0,
"rejected": true,
"score": 0,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
},
"at": "2026-11-01T18:07:21"
},
{
"batch": "B-2",
"dataset": "faq",
"total": 10,
"valid": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"at": "2026-11-01T18:07:31"
}
]
}
},
{
"case": "알림 두 건 (거절 · 낮은 점수)",
"webhook": "http://localhost:8800/alerts/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 2,
"alerts": [
{
"workflow": "faq-import",
"node": "Batch ok?",
"severity": "high",
"reason": "batch_rejected",
"batch": "B-1",
"message": "B-1 rejected",
"at": 1793524041.7771661
},
{
"workflow": "faq-import",
"node": "Low score?",
"severity": "high",
"reason": "low_quality",
"batch": "B-2",
"score": 0.8,
"message": "B-2 quality 0.8",
"at": 1793524051.234785
}
]
}
},
{
"case": "알림에 낮은 점수",
"webhook": "http://localhost:8800/alerts/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 2,
"alerts": [
{
"workflow": "faq-import",
"node": "Batch ok?",
"severity": "high",
"reason": "batch_rejected",
"batch": "B-1",
"message": "B-1 rejected",
"at": 1793524041.7771661
},
{
"workflow": "faq-import",
"node": "Low score?",
"severity": "high",
"reason": "low_quality",
"batch": "B-2",
"score": 0.8,
"message": "B-2 quality 0.8",
"at": 1793524051.234785
}
]
}
}
]
}
+436
View File
@@ -0,0 +1,436 @@
$ uv run tools/flow.py push answers/78-lab02-challenge-log-alert.json
! unpublished '78 FAQ import + LLM check (reference)' (wee5dfb169c284f5): it used the same webhook path
✔ pushed '78 FAQ import + quality log and alerts (answer)' (id w53039b5a517bf0d)
POST http://localhost:5678/webhook/my-faq-import
$ uv run tools/flow.py test cases/lab02-challenge.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.1s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 0
}
POST http://localhost:5678/webhook/my-faq-import · 5 case(s) × 1
✔ 거절도 품질 기록 · 1.3s
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✔ 배치 2 가져오기 · 9.4s
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
✔ 품질 기록 두 줄 (거절 · 0.8) · 0.0s
→ request POST http://localhost:8800/quality-log/list
{}
← response HTTP 200
{
"count": 2,
"items": [
{
"batch": "B-1",
"dataset": "faq",
"total": 13,
"valid": 3,
"quarantined": 0,
"rejected": true,
"score": 0,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
},
"at": "2026-11-01T18:07:21"
},
{
"batch": "B-2",
"dataset": "faq",
"total": 10,
"valid": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"at": "2026-11-01T18:07:31"
}
]
}
✔ 알림 두 건 (거절 · 낮은 점수) · 0.0s
→ request POST http://localhost:8800/alerts/list
{}
← response HTTP 200
{
"count": 2,
"alerts": [
{
"workflow": "faq-import",
"node": "Batch ok?",
"severity": "high",
"reason": "batch_rejected",
"batch": "B-1",
"message": "B-1 rejected",
"at": 1793524041.7771661
},
{
"workflow": "faq-import",
"node": "Low score?",
"severity": "high",
"reason": "low_quality",
"batch": "B-2",
"score": 0.8,
"message": "B-2 quality 0.8",
"at": 1793524051.234785
}
]
}
✔ 알림에 낮은 점수 · 0.0s
→ request POST http://localhost:8800/alerts/list
{}
← response HTTP 200
{
"count": 2,
"alerts": [
{
"workflow": "faq-import",
"node": "Batch ok?",
"severity": "high",
"reason": "batch_rejected",
"batch": "B-1",
"message": "B-1 rejected",
"at": 1793524041.7771661
},
{
"workflow": "faq-import",
"node": "Low score?",
"severity": "high",
"reason": "low_quality",
"batch": "B-2",
"score": 0.8,
"message": "B-2 quality 0.8",
"at": 1793524051.234785
}
]
}
Passed 5/5
report: runs/lab02-challenge-20261101-180731.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 8
}
$ uv run tools/flow.py test cases/lab02.json
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1
✔ 나쁜 배치는 통째로 거절 (422) · 0.1s
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 8.8s
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
✔ 격리함에 이유와 함께 · 0.0s
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2}
✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.6s
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 4.5s
Passed 6/6
report: runs/lab02-20261101-180753.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13}
$ uv run tools/flow.py quality
FAQ: 6 (nothing added)
quarantine: 3
B-2 FAQ-210 llm_mismatch
B-2 FAQ-209 pii
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-2 faq total 10 valid 8 score 0.8 rejected False
B-3 faq total 6 valid 5 score 0.8333 rejected False
alerts: 4
[high] faq-import · low_quality · B-2 quality 0.8
[high] faq-import · low_quality · B-2 quality 0.8
[high] faq-import · low_quality · B-3 quality 0.8333
@@ -0,0 +1,368 @@
{
"cases": "lab02-challenge2.json",
"webhook": "my-faq-import",
"passed": 0,
"total": 3,
"results": [
{
"case": "거절된 배치는 LLM을 부르지 않음",
"webhook": "my-faq-import",
"run": 1,
"passed": false,
"problems": [
"llm_calls = null (expected 0)"
],
"seconds": 0.1,
"status": 422,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
},
{
"case": "B-2 · 규칙을 통과한 9줄만 LLM에",
"webhook": "my-faq-import",
"run": 1,
"passed": false,
"problems": [
"llm_calls = null (expected 9)"
],
"seconds": 8.6,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
},
{
"case": "B-3 · 5줄만 LLM에",
"webhook": "my-faq-import",
"run": 1,
"passed": false,
"problems": [
"llm_calls = null (expected 5)"
],
"seconds": 4.3,
"status": 200,
"request": {
"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": {
"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"
]
}
}
]
}
@@ -0,0 +1,368 @@
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 3
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 4}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 4
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 4}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 4
}
POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1
✘ 거절된 배치는 LLM을 부르지 않음 · 0.1s · llm_calls = null (expected 0)
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
}
}
✘ B-2 · 규칙을 통과한 9줄만 LLM에 · 8.6s · llm_calls = null (expected 9)
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
]
}
✘ B-3 · 5줄만 LLM에 · 4.3s · llm_calls = null (expected 5)
→ 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"
]
}
Passed 0/3
report: runs/lab02-challenge2-20261101-180806.json
@@ -0,0 +1,365 @@
{
"cases": "lab02-challenge2.json",
"webhook": "my-faq-import",
"passed": 3,
"total": 3,
"results": [
{
"case": "거절된 배치는 LLM을 부르지 않음",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 422,
"request": {
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
},
"response": {
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
},
"llm_calls": 0
}
},
{
"case": "B-2 · 규칙을 통과한 9줄만 LLM에",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 9.4,
"status": 200,
"request": {
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
},
"response": {
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
],
"llm_calls": 9
}
},
{
"case": "B-3 · 5줄만 LLM에",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 4.1,
"status": 200,
"request": {
"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": {
"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
}
}
]
}
+444
View File
@@ -0,0 +1,444 @@
$ uv run tools/flow.py push answers/78-lab02-challenge2-llm-calls.json
! unpublished '78 FAQ import + quality log and alerts (answer)' (w53039b5a517bf0d): it used the same webhook path
✔ pushed '78 FAQ import + LLM calls (answer)' (id w4b0730166dd93f5)
POST http://localhost:5678/webhook/my-faq-import
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 13
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 3
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 3}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 3
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 3
}
POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1
✔ 거절된 배치는 LLM을 부르지 않음 · 1.1s
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
},
"llm_calls": 0
}
✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 9.4s
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
],
"llm_calls": 9
}
✔ B-3 · 5줄만 LLM에 · 4.1s
→ 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
}
Passed 3/3
report: runs/lab02-challenge2-20261101-180834.json
$ uv run tools/flow.py test cases/lab02-challenge2.json
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 3}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1
✔ 거절된 배치는 LLM을 부르지 않음 · 0.1s
✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 8.1s
✔ B-3 · 5줄만 LLM에 · 4.4s
Passed 3/3
report: runs/lab02-challenge2-20261101-180846.json
$ uv run tools/flow.py test cases/lab02-challenge2.json
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 3}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1
✔ 거절된 배치는 LLM을 부르지 않음 · 0.1s
✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 7.6s
✔ B-3 · 5줄만 LLM에 · 4.0s
Passed 3/3
report: runs/lab02-challenge2-20261101-180858.json
$ uv run tools/flow.py test cases/lab02-challenge2.json --no-before --repeat 2
POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 2
✔ 거절된 배치는 LLM을 부르지 않음 #1 · 0.1s
✔ 거절된 배치는 LLM을 부르지 않음 #2 · 0.1s
✘ B-2 · 규칙을 통과한 9줄만 LLM에 #1 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 9); imported = null (expected 8); quarantined_ids has none of ['FAQ-210']
✘ B-2 · 규칙을 통과한 9줄만 LLM에 #2 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 9); imported = null (expected 8); quarantined_ids has none of ['FAQ-210']
✘ B-3 · 5줄만 LLM에 #1 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 5); imported = null (expected 5)
✘ B-3 · 5줄만 LLM에 #2 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 5); imported = null (expected 5)
Passed 2/6
report: runs/lab02-challenge2-20261101-180859.json
$ uv run tools/flow.py test cases/lab02.json
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 9}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 9}
POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1
✔ 나쁜 배치는 통째로 거절 (422) · 0.1s
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 8.2s
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
✔ 격리함에 이유와 함께 · 0.0s
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2}
✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.1s
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 5.0s
Passed 6/6
report: runs/lab02-20261101-180921.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13}
$ uv run tools/flow.py test cases/lab02-challenge.json
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 4}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 4}
POST http://localhost:5678/webhook/my-faq-import · 5 case(s) × 1
✔ 거절도 품질 기록 · 0.1s
✔ 배치 2 가져오기 · 8.5s
✔ 품질 기록 두 줄 (거절 · 0.8) · 0.0s
✔ 알림 두 건 (거절 · 낮은 점수) · 0.0s
✔ 알림에 낮은 점수 · 0.0s
Passed 5/5
report: runs/lab02-challenge-20261101-180930.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8}
+154
View File
@@ -0,0 +1,154 @@
{
"cases": "lab03.json",
"webhook": "my-check",
"passed": 0,
"total": 4,
"results": [
{
"case": "직원 CSV · 9줄 중 4줄 통과",
"webhook": "my-check",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 200)",
"schema = null (expected employee)",
"total = null (expected 9)",
"valid = null (expected 4)",
"rows.6.ok = null (expected true)"
],
"seconds": 0.0,
"status": 404,
"request": {
"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": {
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
},
{
"case": "직원 CSV · 줄마다 이유",
"webhook": "my-check",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 200)",
"rows.2.errors has none of ['bad_format:email']",
"rows.3.errors has none of ['not_allowed:dept']",
"rows.4.errors has none of ['bad_format:joined']",
"rows.5.errors has none of ['missing:name']",
"rows.7.errors has none of ['duplicate_in_batch']"
],
"seconds": 0.0,
"status": 404,
"request": {
"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": {
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
},
{
"case": "같은 워크플로우로 FAQ 스키마",
"webhook": "my-check",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 200)",
"schema = null (expected faq)",
"total = null (expected 6)",
"valid = null (expected 5)",
"rows.4.errors has none of ['too_short:answer']"
],
"seconds": 0.0,
"status": 404,
"request": {
"schema": "faq",
"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": {
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
},
{
"case": "모르는 스키마 → 400",
"webhook": "my-check",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 400)"
],
"seconds": 0.0,
"status": 404,
"request": {
"schema": "invoice",
"items": []
},
"response": {
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
}
]
}
+111
View File
@@ -0,0 +1,111 @@
$ uv run tools/flow.py call my-faq-check requests/employees-new.json
HTTP 400 · 0.0s
{
"error": "items must be a list"
}
$ uv run tools/flow.py test cases/lab03.json -v
POST http://localhost:5678/webhook/my-check · 4 case(s) × 1
✘ 직원 CSV · 9줄 중 4줄 통과 · 0.0s · status 404 (expected 200); schema = null (expected employee); total = null (expected 9); valid = null (expected 4); rows.6.ok = null (expected true)
→ 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 404
{
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
✘ 직원 CSV · 줄마다 이유 · 0.0s · status 404 (expected 200); rows.2.errors has none of ['bad_format:email']; rows.3.errors has none of ['not_allowed:dept']; rows.4.errors has none of ['bad_format:joined']; rows.5.errors has none of ['missing:name']; rows.7.errors has none of ['duplicate_in_batch']
→ 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 404
{
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
✘ 같은 워크플로우로 FAQ 스키마 · 0.0s · status 404 (expected 200); schema = null (expected faq); total = null (expected 6); valid = null (expected 5); rows.4.errors has none of ['too_short:answer']
→ request POST /webhook/my-check
{
"schema": "faq",
"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 404
{
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
✘ 모르는 스키마 → 400 · 0.0s · status 404 (expected 400)
→ request POST /webhook/my-check
{
"schema": "invoice",
"items": []
}
← response HTTP 404
{
"code": 404,
"message": "The requested webhook \"POST my-check\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
Passed 0/4
report: runs/lab03-20261101-180930.json
@@ -0,0 +1,327 @@
{
"cases": "lab03.json",
"webhook": "my-check",
"passed": 4,
"total": 4,
"results": [
{
"case": "직원 CSV · 9줄 중 4줄 통과",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 200,
"request": {
"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": {
"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
}
}
},
{
"case": "직원 CSV · 줄마다 이유",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {
"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": {
"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
}
}
},
{
"case": "같은 워크플로우로 FAQ 스키마",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"schema": "faq",
"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": {
"schema": "faq",
"batch": "B-3",
"total": 6,
"valid": 5,
"invalid": 1,
"rows": [
{
"row": 1,
"key": "FAQ-301",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "FAQ-302",
"ok": true,
"errors": []
},
{
"row": 3,
"key": "FAQ-303",
"ok": true,
"errors": []
},
{
"row": 4,
"key": "FAQ-304",
"ok": true,
"errors": []
},
{
"row": 5,
"key": "FAQ-305",
"ok": false,
"errors": [
"too_short:answer"
]
},
{
"row": 6,
"key": "FAQ-306",
"ok": true,
"errors": []
}
],
"codes": {
"too_short:answer": 1
}
}
},
{
"case": "모르는 스키마 → 400",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 400,
"request": {
"schema": "invoice",
"items": []
},
"response": {
"error": "unknown schema",
"schema": "invoice"
}
}
]
}
@@ -0,0 +1,303 @@
$ uv run tools/flow.py push reference/lab03-example1-schema-check.json
✔ pushed '78 Schema check (reference)' (id w6b17d7c76908a6a)
POST http://localhost:5678/webhook/my-check
$ uv run tools/flow.py test cases/lab03.json -v
POST http://localhost:5678/webhook/my-check · 4 case(s) × 1
✔ 직원 CSV · 9줄 중 4줄 통과 · 0.9s
→ 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
}
}
✔ 직원 CSV · 줄마다 이유 · 0.0s
→ 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
}
}
✔ 같은 워크플로우로 FAQ 스키마 · 0.1s
→ request POST /webhook/my-check
{
"schema": "faq",
"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
{
"schema": "faq",
"batch": "B-3",
"total": 6,
"valid": 5,
"invalid": 1,
"rows": [
{
"row": 1,
"key": "FAQ-301",
"ok": true,
"errors": []
},
{
"row": 2,
"key": "FAQ-302",
"ok": true,
"errors": []
},
{
"row": 3,
"key": "FAQ-303",
"ok": true,
"errors": []
},
{
"row": 4,
"key": "FAQ-304",
"ok": true,
"errors": []
},
{
"row": 5,
"key": "FAQ-305",
"ok": false,
"errors": [
"too_short:answer"
]
},
{
"row": 6,
"key": "FAQ-306",
"ok": true,
"errors": []
}
],
"codes": {
"too_short:answer": 1
}
}
✔ 모르는 스키마 → 400 · 0.0s
→ request POST /webhook/my-check
{
"schema": "invoice",
"items": []
}
← response HTTP 400
{
"error": "unknown schema",
"schema": "invoice"
}
Passed 4/4
report: runs/lab03-20261101-180943.json
@@ -0,0 +1,317 @@
{
"cases": "lab03-fix.json",
"webhook": "my-faq-import",
"passed": 3,
"total": 4,
"results": [
{
"case": "고친 두 줄 다시 가져오기",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 2.5,
"status": 200,
"request": {
"batch": "B-2-fix",
"items": [
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
},
"response": {
"batch": "B-2-fix",
"total": 2,
"imported": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"max_invalid_ratio": 0.3,
"codes": {},
"imported_ids": [
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": [],
"llm_calls": 2
}
},
{
"case": "격리함이 비었나",
"webhook": "http://localhost:8800/quarantine/list",
"run": 1,
"passed": false,
"problems": [
"size = 2 (expected 0)"
],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"size": 2,
"items": [
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:09:51"
},
{
"row_id": "FAQ-210",
"batch": "B-2",
"row": {
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:09:51"
}
]
}
},
{
"case": "FAQ에 FAQ-209 · FAQ-210",
"webhook": "http://localhost:8800/faq/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 16,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
},
{
"case": "품질 기록 · 점수 1",
"webhook": "http://localhost:8800/quality-log/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 2,
"items": [
{
"batch": "B-2",
"dataset": "faq",
"total": 10,
"valid": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"at": "2026-11-01T18:09:51"
},
{
"batch": "B-2-fix",
"dataset": "faq",
"total": 2,
"valid": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"codes": {},
"at": "2026-11-01T18:09:54"
}
]
}
}
]
}
+457
View File
@@ -0,0 +1,457 @@
$ uv run tools/flow.py test cases/lab03-fix.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
],
"llm_calls": 9
}
POST http://localhost:5678/webhook/my-faq-import · 4 case(s) × 1
✔ 고친 두 줄 다시 가져오기 · 2.5s
→ request POST /webhook/my-faq-import
{
"batch": "B-2-fix",
"items": [
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2-fix",
"total": 2,
"imported": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"max_invalid_ratio": 0.3,
"codes": {},
"imported_ids": [
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": [],
"llm_calls": 2
}
✘ 격리함이 비었나 · 0.0s · size = 2 (expected 0)
→ request POST http://localhost:8800/quarantine/list
{}
← response HTTP 200
{
"size": 2,
"items": [
{
"row_id": "FAQ-209",
"batch": "B-2",
"row": {
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
"errors": [
"pii"
],
"at": "2026-11-01T18:09:51"
},
{
"row_id": "FAQ-210",
"batch": "B-2",
"row": {
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
},
"errors": [
"llm_mismatch"
],
"at": "2026-11-01T18:09:51"
}
]
}
✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s
→ request POST http://localhost:8800/faq/list
{}
← response HTTP 200
{
"count": 16,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
✔ 품질 기록 · 점수 1 · 0.0s
→ request POST http://localhost:8800/quality-log/list
{}
← response HTTP 200
{
"count": 2,
"items": [
{
"batch": "B-2",
"dataset": "faq",
"total": 10,
"valid": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"at": "2026-11-01T18:09:51"
},
{
"batch": "B-2-fix",
"dataset": "faq",
"total": 2,
"valid": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"codes": {},
"at": "2026-11-01T18:09:54"
}
]
}
Passed 3/4
report: runs/lab03-fix-20261101-180954.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 10}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 10
}
$ uv run tools/flow.py quality
FAQ: 6 (nothing added)
quarantine: 2
B-2 FAQ-209 pii
B-2 FAQ-210 llm_mismatch
quality log: 2
B-2 faq total 10 valid 8 score 0.8 rejected False
B-2-fix faq total 2 valid 2 score 1 rejected False
alerts: 1
[high] faq-import · low_quality · B-2 quality 0.8
+281
View File
@@ -0,0 +1,281 @@
{
"cases": "lab03-fix.json",
"webhook": "my-faq-import",
"passed": 4,
"total": 4,
"results": [
{
"case": "고친 두 줄 다시 가져오기",
"webhook": "my-faq-import",
"run": 1,
"passed": true,
"problems": [],
"seconds": 2.0,
"status": 200,
"request": {
"batch": "B-2-fix",
"items": [
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
},
"response": {
"batch": "B-2-fix",
"total": 2,
"imported": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"max_invalid_ratio": 0.3,
"codes": {},
"imported_ids": [
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": [],
"llm_calls": 2
}
},
{
"case": "격리함이 비었나",
"webhook": "http://localhost:8800/quarantine/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"size": 0,
"items": []
}
},
{
"case": "FAQ에 FAQ-209 · FAQ-210",
"webhook": "http://localhost:8800/faq/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 16,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
},
{
"case": "품질 기록 · 점수 1",
"webhook": "http://localhost:8800/quality-log/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 2,
"items": [
{
"batch": "B-2",
"dataset": "faq",
"total": 10,
"valid": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"at": "2026-11-01T18:10:18"
},
{
"batch": "B-2-fix",
"dataset": "faq",
"total": 2,
"valid": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"codes": {},
"at": "2026-11-01T18:10:20"
}
]
}
}
]
}
+426
View File
@@ -0,0 +1,426 @@
$ uv run tools/flow.py push reference/lab03-example2-import.json
! unpublished '78 FAQ import + LLM calls (answer)' (w4b0730166dd93f5): it used the same webhook path
✔ pushed '78 FAQ import + release fixed rows (reference)' (id we9ca88e4f314222)
POST http://localhost:5678/webhook/my-faq-import
$ uv run tools/flow.py test cases/lab03-fix.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ 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-faq-import · HTTP 200 · 10.7s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
],
"llm_calls": 9
}
POST http://localhost:5678/webhook/my-faq-import · 4 case(s) × 1
✔ 고친 두 줄 다시 가져오기 · 2.0s
→ request POST /webhook/my-faq-import
{
"batch": "B-2-fix",
"items": [
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2-fix",
"total": 2,
"imported": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"max_invalid_ratio": 0.3,
"codes": {},
"imported_ids": [
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": [],
"llm_calls": 2
}
✔ 격리함이 비었나 · 0.0s
→ request POST http://localhost:8800/quarantine/list
{}
← response HTTP 200
{
"size": 0,
"items": []
}
✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s
→ request POST http://localhost:8800/faq/list
{}
← response HTTP 200
{
"count": 16,
"faqs": [
{
"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."
},
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
✔ 품질 기록 · 점수 1 · 0.0s
→ request POST http://localhost:8800/quality-log/list
{}
← response HTTP 200
{
"count": 2,
"items": [
{
"batch": "B-2",
"dataset": "faq",
"total": 10,
"valid": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"at": "2026-11-01T18:10:18"
},
{
"batch": "B-2-fix",
"dataset": "faq",
"total": 2,
"valid": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"codes": {},
"at": "2026-11-01T18:10:20"
}
]
}
Passed 4/4
report: runs/lab03-fix-20261101-181020.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 10}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 10
}
$ uv run tools/flow.py quality
FAQ: 6 (nothing added)
quarantine: 0
quality log: 2
B-2 faq total 10 valid 8 score 0.8 rejected False
B-2-fix faq total 2 valid 2 score 1 rejected False
alerts: 1
[high] faq-import · low_quality · B-2 quality 0.8
@@ -0,0 +1,617 @@
{
"cases": "lab03-schema.json",
"webhook": "my-check",
"passed": 5,
"total": 5,
"results": [
{
"case": "스키마 목록에 ticket이 있다",
"webhook": "http://localhost:8800/schemas/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"schemas": [
"employee",
"faq",
"ticket"
]
}
},
{
"case": "새 스키마 ticket · 워크플로우는 그대로 · 9줄 중 2줄 통과",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"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": {
"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
}
}
},
{
"case": "형식 · 목록 · 날짜 · 길이",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"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": {
"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
}
}
},
{
"case": "선택 칸 형식 · 중복 · 빈 칸, 선택 칸이 없어도 통과",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {
"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": {
"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
}
}
},
{
"case": "직원 CSV는 그대로 9줄 중 4줄",
"webhook": "my-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"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": {
"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
}
}
}
]
}
+844
View File
@@ -0,0 +1,844 @@
$ uv run tools/flow.py call my-faq-check requests/tickets-new.json
HTTP 200 · 0.1s
{
"batch": "TK-1",
"total": 9,
"valid": 0,
"invalid": 9,
"with_warnings": 0,
"rows": [
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
},
{
"id": "",
"ok": false,
"errors": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"warnings": []
}
],
"codes": {
"id_format": 9,
"keywords_missing": 9,
"answer_missing": 9,
"duplicate_in_batch": 8
},
"fix_list": [
{
"row": 1,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요"
]
},
{
"row": 2,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 3,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 4,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 5,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 6,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 7,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 8,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 9,
"id": "",
"codes": [
"id_format",
"keywords_missing",
"answer_missing",
"duplicate_in_batch"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)",
"키워드를 하나 이상 넣어 주세요",
"답을 써 주세요",
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
}
],
"message": "TK-1: 9줄 중 0줄 통과, 9줄 고칠 것"
}
$ uv run tools/flow.py test cases/lab03-schema.json -v
POST http://localhost:5678/webhook/my-check · 5 case(s) × 1
✔ 스키마 목록에 ticket이 있다 · 0.0s
→ request POST http://localhost:8800/schemas/list
{}
← response HTTP 200
{
"schemas": [
"employee",
"faq",
"ticket"
]
}
✔ 새 스키마 ticket · 워크플로우는 그대로 · 9줄 중 2줄 통과 · 0.1s
→ 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
}
}
✔ 형식 · 목록 · 날짜 · 길이 · 0.1s
→ 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
}
}
✔ 선택 칸 형식 · 중복 · 빈 칸, 선택 칸이 없어도 통과 · 0.0s
→ 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
}
}
✔ 직원 CSV는 그대로 9줄 중 4줄 · 0.1s
→ 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
}
}
Passed 5/5
report: runs/lab03-schema-20261101-181021.json
@@ -0,0 +1,90 @@
{
"cases": "lab03-challenge.json",
"webhook": "my-quality-report",
"passed": 0,
"total": 4,
"results": [
{
"case": "배치 네 개 · 평균 · 가장 나쁜 배치",
"webhook": "my-quality-report",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 200)",
"count = null (expected 4)",
"avg_score = null (expected 0.6583)",
"worst_batch = null (expected B-1)",
"rejected_batches = null (expected 1)"
],
"seconds": 0.0,
"status": 404,
"request": {},
"response": {
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
},
{
"case": "가장 많은 이유 · 흐름",
"webhook": "my-quality-report",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 200)",
"top_codes.0.code = null (expected pii)",
"top_codes.0.count = null (expected 2)",
"trend = null (expected 좋아짐)"
],
"seconds": 0.0,
"status": 404,
"request": {},
"response": {
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
},
{
"case": "배치마다 점수 순서",
"webhook": "my-quality-report",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 200)",
"batches.1.score = null (expected 0.8)",
"batches.2.score = null (expected 0.8333)",
"batches.3.batch = null (expected B-2-fix)"
],
"seconds": 0.0,
"status": 404,
"request": {},
"response": {
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
},
{
"case": "다른 데이터셋으로 거르기",
"webhook": "my-quality-report",
"run": 1,
"passed": false,
"problems": [
"status 404 (expected 200)",
"count = null (expected 0)",
"avg_score = null (expected 0)"
],
"seconds": 0.0,
"status": 404,
"request": {
"dataset": "employee"
},
"response": {
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
}
]
}
@@ -0,0 +1,457 @@
$ uv run tools/flow.py test cases/lab03-challenge.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/quarantine-clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 2
}
✔ 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-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"
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
},
"llm_calls": 0
}
✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
],
"llm_calls": 9
}
✔ 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
→ 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
}
✔ before: POST /webhook/my-faq-import · HTTP 200 · 1.9s · {"batch": "B-2-fix", "total": 2, "imported": 2, "quarantined": 0, "rejected": false, "score": 1, "max_invalid_ratio": 0.
→ request POST /webhook/my-faq-import
{
"batch": "B-2-fix",
"items": [
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2-fix",
"total": 2,
"imported": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"max_invalid_ratio": 0.3,
"codes": {},
"imported_ids": [
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": [],
"llm_calls": 2
}
POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1
✘ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s · status 404 (expected 200); count = null (expected 4); avg_score = null (expected 0.6583); worst_batch = null (expected B-1); rejected_batches = null (expected 1)
→ request POST /webhook/my-quality-report
{}
← response HTTP 404
{
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
✘ 가장 많은 이유 · 흐름 · 0.0s · status 404 (expected 200); top_codes.0.code = null (expected pii); top_codes.0.count = null (expected 2); trend = null (expected 좋아짐)
→ request POST /webhook/my-quality-report
{}
← response HTTP 404
{
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
✘ 배치마다 점수 순서 · 0.0s · status 404 (expected 200); batches.1.score = null (expected 0.8); batches.2.score = null (expected 0.8333); batches.3.batch = null (expected B-2-fix)
→ request POST /webhook/my-quality-report
{}
← response HTTP 404
{
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
✘ 다른 데이터셋으로 거르기 · 0.0s · status 404 (expected 200); count = null (expected 0); avg_score = null (expected 0)
→ request POST /webhook/my-quality-report
{
"dataset": "employee"
}
← response HTTP 404
{
"code": 404,
"message": "The requested webhook \"POST my-quality-report\" is not registered.",
"hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)"
}
Passed 0/4
report: runs/lab03-challenge-20261101-181036.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 15}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 15
}
+308
View File
@@ -0,0 +1,308 @@
{
"cases": "lab03-challenge.json",
"webhook": "my-quality-report",
"passed": 4,
"total": 4,
"results": [
{
"case": "배치 네 개 · 평균 · 가장 나쁜 배치",
"webhook": "my-quality-report",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 4,
"batches": [
{
"batch": "B-1",
"score": 0,
"rejected": true,
"total": 13
},
{
"batch": "B-2",
"score": 0.8,
"rejected": false,
"total": 10
},
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
},
{
"batch": "B-2-fix",
"score": 1,
"rejected": false,
"total": 2
}
],
"avg_score": 0.6583,
"worst_batch": "B-1",
"rejected_batches": 1,
"top_codes": [
{
"code": "pii",
"count": 2
},
{
"code": "answer_missing",
"count": 1
},
{
"code": "answer_too_long",
"count": 1
},
{
"code": "answer_too_short",
"count": 1
},
{
"code": "duplicate_in_batch",
"count": 1
},
{
"code": "exists_id",
"count": 1
},
{
"code": "external_link",
"count": 1
},
{
"code": "id_format",
"count": 1
},
{
"code": "keywords_missing",
"count": 1
},
{
"code": "llm_mismatch",
"count": 1
},
{
"code": "same_title",
"count": 1
},
{
"code": "title_missing",
"count": 1
}
],
"trend": "좋아짐"
}
},
{
"case": "가장 많은 이유 · 흐름",
"webhook": "my-quality-report",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {},
"response": {
"count": 4,
"batches": [
{
"batch": "B-1",
"score": 0,
"rejected": true,
"total": 13
},
{
"batch": "B-2",
"score": 0.8,
"rejected": false,
"total": 10
},
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
},
{
"batch": "B-2-fix",
"score": 1,
"rejected": false,
"total": 2
}
],
"avg_score": 0.6583,
"worst_batch": "B-1",
"rejected_batches": 1,
"top_codes": [
{
"code": "pii",
"count": 2
},
{
"code": "answer_missing",
"count": 1
},
{
"code": "answer_too_long",
"count": 1
},
{
"code": "answer_too_short",
"count": 1
},
{
"code": "duplicate_in_batch",
"count": 1
},
{
"code": "exists_id",
"count": 1
},
{
"code": "external_link",
"count": 1
},
{
"code": "id_format",
"count": 1
},
{
"code": "keywords_missing",
"count": 1
},
{
"code": "llm_mismatch",
"count": 1
},
{
"code": "same_title",
"count": 1
},
{
"code": "title_missing",
"count": 1
}
],
"trend": "좋아짐"
}
},
{
"case": "배치마다 점수 순서",
"webhook": "my-quality-report",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 4,
"batches": [
{
"batch": "B-1",
"score": 0,
"rejected": true,
"total": 13
},
{
"batch": "B-2",
"score": 0.8,
"rejected": false,
"total": 10
},
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
},
{
"batch": "B-2-fix",
"score": 1,
"rejected": false,
"total": 2
}
],
"avg_score": 0.6583,
"worst_batch": "B-1",
"rejected_batches": 1,
"top_codes": [
{
"code": "pii",
"count": 2
},
{
"code": "answer_missing",
"count": 1
},
{
"code": "answer_too_long",
"count": 1
},
{
"code": "answer_too_short",
"count": 1
},
{
"code": "duplicate_in_batch",
"count": 1
},
{
"code": "exists_id",
"count": 1
},
{
"code": "external_link",
"count": 1
},
{
"code": "id_format",
"count": 1
},
{
"code": "keywords_missing",
"count": 1
},
{
"code": "llm_mismatch",
"count": 1
},
{
"code": "same_title",
"count": 1
},
{
"code": "title_missing",
"count": 1
}
],
"trend": "좋아짐"
}
},
{
"case": "다른 데이터셋으로 거르기",
"webhook": "my-quality-report",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"dataset": "employee"
},
"response": {
"count": 0,
"batches": [],
"avg_score": 0,
"worst_batch": null,
"rejected_batches": 0,
"top_codes": [],
"trend": "그대로"
}
}
]
}
+785
View File
@@ -0,0 +1,785 @@
$ uv run tools/flow.py push answers/78-lab03-challenge-report.json
✔ pushed '78 Quality report (answer)' (id wa6b87a0a6957e93)
POST http://localhost:5678/webhook/my-quality-report
$ uv run tools/flow.py test cases/lab03-challenge.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ 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": 4}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 4
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 3
}
! before: POST /webhook/my-faq-import · HTTP 422 · 1.2s (allow_fail: rejected on purpose) · {"error": "batch rejected", "batch": "B-1", "invalid_ratio": 0.7692, "max_invalid_ratio": 0.3, "codes": {"title_missing"
→ request POST /webhook/my-faq-import
{
"batch": "B-1",
"items": [
{
"id": "FAQ-101",
"title": "사내 와이파이 연결",
"keywords": [
"와이파이",
"wifi",
"무선"
],
"answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다."
},
{
"id": "FAQ-102",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-103",
"title": "",
"keywords": [
"출입증"
],
"answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다."
},
{
"id": "FAQ-10",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다."
},
{
"id": "FAQ-105",
"title": "모니터 추가 신청",
"keywords": [
"모니터"
],
"answer": ""
},
{
"id": "FAQ-106",
"title": "회의실 예약 규칙",
"keywords": [
"회의실 규칙"
],
"answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. "
},
{
"id": "FAQ-102",
"title": "회의실 예약 (중복)",
"keywords": [
"회의실"
],
"answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다."
},
{
"id": "FAQ-108",
"title": "화상회의 장비",
"keywords": [],
"answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다."
},
{
"id": "FAQ-109",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요."
},
{
"id": "FAQ-110",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다."
},
{
"id": "FAQ-003",
"title": "VPN 접속 오류 (새 판)",
"keywords": [
"재택근무"
],
"answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다."
},
{
"id": "FAQ-112",
"title": "VPN 접속 오류",
"keywords": [
"vpn",
"재택근무"
],
"answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다."
},
{
"id": "FAQ-113",
"title": "프린터 토너 교체",
"keywords": [
"토너"
],
"answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다."
}
]
}
← response HTTP 422
{
"error": "batch rejected",
"batch": "B-1",
"invalid_ratio": 0.7692,
"max_invalid_ratio": 0.3,
"codes": {
"title_missing": 1,
"id_format": 1,
"answer_missing": 1,
"answer_too_long": 1,
"duplicate_in_batch": 1,
"keywords_missing": 1,
"pii": 1,
"external_link": 1,
"exists_id": 1,
"same_title": 1
},
"llm_calls": 0
}
✔ before: POST /webhook/my-faq-import · HTTP 200 · 9.5s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3
→ request POST /webhook/my-faq-import
{
"batch": "B-2",
"items": [
{
"id": "FAQ-201",
"title": "회의실 예약",
"keywords": [
"회의실",
"예약"
],
"answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다."
},
{
"id": "FAQ-202",
"title": "출입증 재발급",
"keywords": [
"출입증",
"재발급"
],
"answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다."
},
{
"id": "FAQ-203",
"title": "노트북 반납",
"keywords": [
"노트북",
"반납"
],
"answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다."
},
{
"id": "FAQ-204",
"title": "모니터 추가 신청",
"keywords": [
"모니터",
"추가"
],
"answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다."
},
{
"id": "FAQ-205",
"title": "윈도우 업데이트",
"keywords": [
"업데이트",
"윈도우"
],
"answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다."
},
{
"id": "FAQ-206",
"title": "화상회의 장비 대여",
"keywords": [
"화상회의",
"카메라"
],
"answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다."
},
{
"id": "FAQ-207",
"title": "사내 메신저 설치",
"keywords": [
"메신저"
],
"answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다."
},
{
"id": "FAQ-208",
"title": "공유 드라이브 권한",
"keywords": [
"공유 드라이브",
"권한"
],
"answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다."
},
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2",
"total": 10,
"imported": 8,
"quarantined": 2,
"rejected": false,
"score": 0.8,
"max_invalid_ratio": 0.3,
"codes": {
"pii": 1,
"llm_mismatch": 1
},
"imported_ids": [
"FAQ-201",
"FAQ-202",
"FAQ-203",
"FAQ-204",
"FAQ-205",
"FAQ-206",
"FAQ-207",
"FAQ-208"
],
"quarantined_ids": [
"FAQ-209",
"FAQ-210"
],
"llm_calls": 9
}
✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.3s · {"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
}
✔ before: POST /webhook/my-faq-import · HTTP 200 · 2.2s · {"batch": "B-2-fix", "total": 2, "imported": 2, "quarantined": 0, "rejected": false, "score": 1, "max_invalid_ratio": 0.
→ request POST /webhook/my-faq-import
{
"batch": "B-2-fix",
"items": [
{
"id": "FAQ-209",
"title": "보안 사고 신고",
"keywords": [
"보안",
"신고"
],
"answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다."
},
{
"id": "FAQ-210",
"title": "모니터 밝기 조절",
"keywords": [
"밝기"
],
"answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다."
}
]
}
← response HTTP 200
{
"batch": "B-2-fix",
"total": 2,
"imported": 2,
"quarantined": 0,
"rejected": false,
"score": 1,
"max_invalid_ratio": 0.3,
"codes": {},
"imported_ids": [
"FAQ-209",
"FAQ-210"
],
"quarantined_ids": [],
"llm_calls": 2
}
POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1
✔ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s
→ request POST /webhook/my-quality-report
{}
← response HTTP 200
{
"count": 4,
"batches": [
{
"batch": "B-1",
"score": 0,
"rejected": true,
"total": 13
},
{
"batch": "B-2",
"score": 0.8,
"rejected": false,
"total": 10
},
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
},
{
"batch": "B-2-fix",
"score": 1,
"rejected": false,
"total": 2
}
],
"avg_score": 0.6583,
"worst_batch": "B-1",
"rejected_batches": 1,
"top_codes": [
{
"code": "pii",
"count": 2
},
{
"code": "answer_missing",
"count": 1
},
{
"code": "answer_too_long",
"count": 1
},
{
"code": "answer_too_short",
"count": 1
},
{
"code": "duplicate_in_batch",
"count": 1
},
{
"code": "exists_id",
"count": 1
},
{
"code": "external_link",
"count": 1
},
{
"code": "id_format",
"count": 1
},
{
"code": "keywords_missing",
"count": 1
},
{
"code": "llm_mismatch",
"count": 1
},
{
"code": "same_title",
"count": 1
},
{
"code": "title_missing",
"count": 1
}
],
"trend": "좋아짐"
}
✔ 가장 많은 이유 · 흐름 · 0.1s
→ request POST /webhook/my-quality-report
{}
← response HTTP 200
{
"count": 4,
"batches": [
{
"batch": "B-1",
"score": 0,
"rejected": true,
"total": 13
},
{
"batch": "B-2",
"score": 0.8,
"rejected": false,
"total": 10
},
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
},
{
"batch": "B-2-fix",
"score": 1,
"rejected": false,
"total": 2
}
],
"avg_score": 0.6583,
"worst_batch": "B-1",
"rejected_batches": 1,
"top_codes": [
{
"code": "pii",
"count": 2
},
{
"code": "answer_missing",
"count": 1
},
{
"code": "answer_too_long",
"count": 1
},
{
"code": "answer_too_short",
"count": 1
},
{
"code": "duplicate_in_batch",
"count": 1
},
{
"code": "exists_id",
"count": 1
},
{
"code": "external_link",
"count": 1
},
{
"code": "id_format",
"count": 1
},
{
"code": "keywords_missing",
"count": 1
},
{
"code": "llm_mismatch",
"count": 1
},
{
"code": "same_title",
"count": 1
},
{
"code": "title_missing",
"count": 1
}
],
"trend": "좋아짐"
}
✔ 배치마다 점수 순서 · 0.0s
→ request POST /webhook/my-quality-report
{}
← response HTTP 200
{
"count": 4,
"batches": [
{
"batch": "B-1",
"score": 0,
"rejected": true,
"total": 13
},
{
"batch": "B-2",
"score": 0.8,
"rejected": false,
"total": 10
},
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
},
{
"batch": "B-2-fix",
"score": 1,
"rejected": false,
"total": 2
}
],
"avg_score": 0.6583,
"worst_batch": "B-1",
"rejected_batches": 1,
"top_codes": [
{
"code": "pii",
"count": 2
},
{
"code": "answer_missing",
"count": 1
},
{
"code": "answer_too_long",
"count": 1
},
{
"code": "answer_too_short",
"count": 1
},
{
"code": "duplicate_in_batch",
"count": 1
},
{
"code": "exists_id",
"count": 1
},
{
"code": "external_link",
"count": 1
},
{
"code": "id_format",
"count": 1
},
{
"code": "keywords_missing",
"count": 1
},
{
"code": "llm_mismatch",
"count": 1
},
{
"code": "same_title",
"count": 1
},
{
"code": "title_missing",
"count": 1
}
],
"trend": "좋아짐"
}
✔ 다른 데이터셋으로 거르기 · 0.1s
→ request POST /webhook/my-quality-report
{
"dataset": "employee"
}
← response HTTP 200
{
"count": 0,
"batches": [],
"avg_score": 0,
"worst_batch": null,
"rejected_batches": 0,
"top_codes": [],
"trend": "그대로"
}
Passed 4/4
report: runs/lab03-challenge-20261101-181105.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 15}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 15
}
$ uv run tools/flow.py call my-quality-report '{}'
HTTP 200 · 0.0s
{
"count": 4,
"batches": [
{
"batch": "B-1",
"score": 0,
"rejected": true,
"total": 13
},
{
"batch": "B-2",
"score": 0.8,
"rejected": false,
"total": 10
},
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
},
{
"batch": "B-2-fix",
"score": 1,
"rejected": false,
"total": 2
}
],
"avg_score": 0.6583,
"worst_batch": "B-1",
"rejected_batches": 1,
"top_codes": [
{
"code": "pii",
"count": 2
},
{
"code": "answer_missing",
"count": 1
},
{
"code": "answer_too_long",
"count": 1
},
{
"code": "answer_too_short",
"count": 1
},
{
"code": "duplicate_in_batch",
"count": 1
},
{
"code": "exists_id",
"count": 1
},
{
"code": "external_link",
"count": 1
},
{
"code": "id_format",
"count": 1
},
{
"code": "keywords_missing",
"count": 1
},
{
"code": "llm_mismatch",
"count": 1
},
{
"code": "same_title",
"count": 1
},
{
"code": "title_missing",
"count": 1
}
],
"trend": "좋아짐"
}
@@ -0,0 +1,136 @@
{
"cases": "lab03-challenge2.json",
"webhook": "my-quality-report",
"passed": 0,
"total": 4,
"results": [
{
"case": "검사도 품질 기록 (직원 · 티켓 · FAQ)",
"webhook": "http://localhost:8800/quality-log/list",
"run": 1,
"passed": false,
"problems": [
"count = 1 (expected 3)",
"items.0.dataset = faq (expected employee)",
"items.0.score = 0.8333 (expected 0.4444)",
"items.1.dataset = null (expected ticket)",
"items.2.dataset = null (expected faq)"
],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"count": 1,
"items": [
{
"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:10"
}
]
}
},
{
"case": "데이터셋마다 나눠 보기",
"webhook": "my-quality-report",
"run": 1,
"passed": false,
"problems": [
"count = 1 (expected 3)",
"by_dataset.employee.avg_score = null (expected 0.4444)",
"by_dataset.ticket.avg_score = null (expected 0.2222)",
"by_dataset.faq.avg_score = null (expected 0.8333)"
],
"seconds": 0.2,
"status": 200,
"request": {},
"response": {
"count": 1,
"batches": [
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
}
],
"avg_score": 0.8333,
"worst_batch": "B-3",
"rejected_batches": 0,
"top_codes": [
{
"code": "answer_too_short",
"count": 1
}
],
"trend": "그대로"
}
},
{
"case": "가장 나쁜 데이터셋 · 그 이유",
"webhook": "my-quality-report",
"run": 1,
"passed": false,
"problems": [
"worst_dataset = null (expected ticket)",
"by_dataset.employee.top_code = null (expected bad_format:email)"
],
"seconds": 0.1,
"status": 200,
"request": {},
"response": {
"count": 1,
"batches": [
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
}
],
"avg_score": 0.8333,
"worst_batch": "B-3",
"rejected_batches": 0,
"top_codes": [
{
"code": "answer_too_short",
"count": 1
}
],
"trend": "그대로"
}
},
{
"case": "한 데이터셋만 (앞의 칸은 그대로)",
"webhook": "my-quality-report",
"run": 1,
"passed": false,
"problems": [
"count = 0 (expected 1)",
"worst_batch = null (expected TK-1)"
],
"seconds": 0.0,
"status": 200,
"request": {
"dataset": "ticket"
},
"response": {
"count": 0,
"batches": [],
"avg_score": 0,
"worst_batch": null,
"rejected_batches": 0,
"top_codes": [],
"trend": "그대로"
}
}
]
}
@@ -0,0 +1,447 @@
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/faq-reset
{}
← response HTTP 200
{
"cleared": 0
}
✔ 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": 4}
→ request POST http://localhost:8800/quality-log-clear
{}
← response HTTP 200
{
"cleared": 4
}
✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3}
→ request POST http://localhost:8800/alerts-clear
{}
← response HTTP 200
{
"cleared": 3
}
✔ before: POST /webhook/my-check · HTTP 200 · 0.1s · {"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.1s · {"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 · 4.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 · count = 1 (expected 3); items.0.dataset = faq (expected employee); items.0.score = 0.8333 (expected 0.4444); items.1.dataset = null (expected ticket); items.2.dataset = null (expected faq)
→ request POST http://localhost:8800/quality-log/list
{}
← response HTTP 200
{
"count": 1,
"items": [
{
"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:10"
}
]
}
✘ 데이터셋마다 나눠 보기 · 0.2s · count = 1 (expected 3); by_dataset.employee.avg_score = null (expected 0.4444); by_dataset.ticket.avg_score = null (expected 0.2222); by_dataset.faq.avg_score = null (expected 0.8333)
→ request POST /webhook/my-quality-report
{}
← response HTTP 200
{
"count": 1,
"batches": [
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
}
],
"avg_score": 0.8333,
"worst_batch": "B-3",
"rejected_batches": 0,
"top_codes": [
{
"code": "answer_too_short",
"count": 1
}
],
"trend": "그대로"
}
✘ 가장 나쁜 데이터셋 · 그 이유 · 0.1s · worst_dataset = null (expected ticket); by_dataset.employee.top_code = null (expected bad_format:email)
→ request POST /webhook/my-quality-report
{}
← response HTTP 200
{
"count": 1,
"batches": [
{
"batch": "B-3",
"score": 0.8333,
"rejected": false,
"total": 6
}
],
"avg_score": 0.8333,
"worst_batch": "B-3",
"rejected_batches": 0,
"top_codes": [
{
"code": "answer_too_short",
"count": 1
}
],
"trend": "그대로"
}
✘ 한 데이터셋만 (앞의 칸은 그대로) · 0.0s · count = 0 (expected 1); worst_batch = null (expected TK-1)
→ request POST /webhook/my-quality-report
{
"dataset": "ticket"
}
← response HTTP 200
{
"count": 0,
"batches": [],
"avg_score": 0,
"worst_batch": null,
"rejected_batches": 0,
"top_codes": [],
"trend": "그대로"
}
Passed 0/4
report: runs/lab03-challenge2-20261101-181110.json
@@ -0,0 +1,366 @@
{
"cases": "lab03-challenge2.json",
"webhook": "my-quality-report",
"passed": 4,
"total": 4,
"results": [
{
"case": "검사도 품질 기록 (직원 · 티켓 · FAQ)",
"webhook": "http://localhost:8800/quality-log/list",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"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"
}
]
}
},
{
"case": "데이터셋마다 나눠 보기",
"webhook": "my-quality-report",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {},
"response": {
"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"
}
},
{
"case": "가장 나쁜 데이터셋 · 그 이유",
"webhook": "my-quality-report",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 200,
"request": {},
"response": {
"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"
}
},
{
"case": "한 데이터셋만 (앞의 칸은 그대로)",
"webhook": "my-quality-report",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 200,
"request": {
"dataset": "ticket"
},
"response": {
"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"
}
}
]
}
+761
View File
@@ -0,0 +1,761 @@
$ 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
@@ -0,0 +1,177 @@
{
"name": "78 FAQ check + existing data (answer)",
"nodes": [
{
"name": "Check webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-faq-check",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "6751875f-37e8-51bf-9685-da994f9dec81",
"webhookId": "736fe44f-e179-58a3-9856-295b28a7073d"
},
{
"name": "Is a list?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"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": {}
},
"position": [
220,
300
],
"id": "56a85def-836a-56b6-981a-3327bee5c75e"
},
{
"name": "Reject body",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"items must be a list\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "4b6d0650-0ddb-5d07-8ced-af0e3ee7c7cc"
},
{
"name": "Get FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq-all",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
440,
300
],
"id": "9bf7a3b3-52ec-5976-803b-54cae1e2244b"
},
{
"name": "Check rows",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Check webhook').first().json.body;\n\nconst existing = $('Get FAQ').first().json.faqs ?? [];\nconst existingIds = new Set(existing.map((f) => f.id));\nconst existingTitles = new Set(existing.map((f) => String(f.title).trim()));\nconst existingKeywords = new Set(existing.flatMap((f) => (f.keywords ?? []).map((k) => String(k).toLowerCase())));\nconst seen = new Set();\nconst rows = body.items.map((r) => {\n const errors = [], warnings = [];\n const id = String(r?.id ?? '');\n const title = String(r?.title ?? '').trim();\n const answer = String(r?.answer ?? '').trim();\n if (!/^FAQ-\\d{3}$/.test(id)) errors.push('id_format');\n if (!title) errors.push('title_missing'); else if (title.length > 40) errors.push('title_too_long');\n if (!Array.isArray(r?.keywords) || r.keywords.length === 0) errors.push('keywords_missing');\n if (!answer) errors.push('answer_missing'); else if (answer.length < 10) errors.push('answer_too_short'); else if (answer.length > 300) errors.push('answer_too_long');\n if (seen.has(id)) errors.push('duplicate_in_batch');\n seen.add(id);\n if (/01[016789][-. ]?\\d{3,4}[-. ]?\\d{4}/.test(answer) || /[\\w.+-]+@[\\w-]+\\.[\\w.]+/.test(answer)) errors.push('pii');\n if (/https?:\\/\\/(?!intra\\.example\\.com(?![\\w.-]))/i.test(answer)) errors.push('external_link');\n\n if (existingIds.has(id)) errors.push('exists_id');\n if (title && existingTitles.has(title)) errors.push('same_title');\n for (const k of (Array.isArray(r?.keywords) ? r.keywords : [])) if (existingKeywords.has(String(k).toLowerCase())) warnings.push(`keyword_used:${k}`);\n return { id, ok: errors.length === 0, errors, warnings };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nconst out = { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid,\n with_warnings: rows.filter((r) => r.warnings.length).length, rows, codes };\nreturn [{ json: out }];"
},
"position": [
660,
300
],
"id": "a5d95489-c8a9-5734-ac13-39c87bc61620"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
880,
300
],
"id": "0331ae2d-ec56-5470-856a-f92507037bab"
}
],
"connections": {
"Is a list?": {
"main": [
[
{
"node": "Get FAQ",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject body",
"type": "main",
"index": 0
}
]
]
},
"Get FAQ": {
"main": [
[
{
"node": "Check rows",
"type": "main",
"index": 0
}
]
]
},
"Check rows": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Check webhook": {
"main": [
[
{
"node": "Is a list?",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,177 @@
{
"name": "78 FAQ check + fix list (answer)",
"nodes": [
{
"name": "Check webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-faq-check",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "7ded9091-d32b-514c-9bba-8cc9010cd8c5",
"webhookId": "cf6a2ab6-8e06-511c-b239-ef1a5207977f"
},
{
"name": "Is a list?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"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": {}
},
"position": [
220,
300
],
"id": "4485bb43-0e0f-5736-9855-cf88dc66cdef"
},
{
"name": "Reject body",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"items must be a list\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "b2273143-7dc4-52c9-b1f3-36fd45b4a595"
},
{
"name": "Get FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq-all",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
440,
300
],
"id": "eac0cc92-5d69-5d2a-b999-7a071a4cd7e5"
},
{
"name": "Check rows",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Check webhook').first().json.body;\n\nconst existing = $('Get FAQ').first().json.faqs ?? [];\nconst existingIds = new Set(existing.map((f) => f.id));\nconst existingTitles = new Set(existing.map((f) => String(f.title).trim()));\nconst existingKeywords = new Set(existing.flatMap((f) => (f.keywords ?? []).map((k) => String(k).toLowerCase())));\nconst seen = new Set();\nconst rows = body.items.map((r) => {\n const errors = [], warnings = [];\n const id = String(r?.id ?? '');\n const title = String(r?.title ?? '').trim();\n const answer = String(r?.answer ?? '').trim();\n if (!/^FAQ-\\d{3}$/.test(id)) errors.push('id_format');\n if (!title) errors.push('title_missing'); else if (title.length > 40) errors.push('title_too_long');\n if (!Array.isArray(r?.keywords) || r.keywords.length === 0) errors.push('keywords_missing');\n if (!answer) errors.push('answer_missing'); else if (answer.length < 10) errors.push('answer_too_short'); else if (answer.length > 300) errors.push('answer_too_long');\n if (seen.has(id)) errors.push('duplicate_in_batch');\n seen.add(id);\n if (/01[016789][-. ]?\\d{3,4}[-. ]?\\d{4}/.test(answer) || /[\\w.+-]+@[\\w-]+\\.[\\w.]+/.test(answer)) errors.push('pii');\n if (/https?:\\/\\/(?!intra\\.example\\.com(?![\\w.-]))/i.test(answer)) errors.push('external_link');\n\n if (existingIds.has(id)) errors.push('exists_id');\n if (title && existingTitles.has(title)) errors.push('same_title');\n for (const k of (Array.isArray(r?.keywords) ? r.keywords : [])) if (existingKeywords.has(String(k).toLowerCase())) warnings.push(`keyword_used:${k}`);\n return { id, ok: errors.length === 0, errors, warnings };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nconst out = { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid,\n with_warnings: rows.filter((r) => r.warnings.length).length, rows, codes };\n\nconst FIX = {\n id_format: 'id를 FAQ-숫자 3자리로 (예: FAQ-104)',\n title_missing: '제목을 써 주세요',\n title_too_long: '제목을 40자 이내로 줄여 주세요',\n keywords_missing: '키워드를 하나 이상 넣어 주세요',\n answer_missing: '답을 써 주세요',\n answer_too_short: '답을 10자 이상으로 써 주세요',\n answer_too_long: '답을 300자 이내로 줄여 주세요',\n duplicate_in_batch: '같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요',\n pii: '개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요',\n external_link: '바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요',\n exists_id: '이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요',\n same_title: '같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요',\n};\nout.fix_list = rows.map((r, i) => ({ row: i + 1, id: r.id, codes: r.errors, fix: r.errors.map((e) => FIX[e] ?? e) })).filter((r) => r.codes.length);\nout.message = `${out.batch}: ${out.total}줄 중 ${out.valid}줄 통과, ${out.invalid}줄 고칠 것`;\nreturn [{ json: out }];"
},
"position": [
660,
300
],
"id": "71deee57-d95f-500e-87cc-e352362e221a"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
880,
300
],
"id": "26ac83fd-b10f-56ae-8669-52a14deafc35"
}
],
"connections": {
"Is a list?": {
"main": [
[
{
"node": "Get FAQ",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject body",
"type": "main",
"index": 0
}
]
]
},
"Get FAQ": {
"main": [
[
{
"node": "Check rows",
"type": "main",
"index": 0
}
]
]
},
"Check rows": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Check webhook": {
"main": [
[
{
"node": "Is a list?",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,641 @@
{
"name": "78 FAQ import + quality log and alerts (answer)",
"nodes": [
{
"name": "Import webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-faq-import",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "e1896aee-de3a-5afe-89ab-af4a1e59a7fe",
"webhookId": "40c49459-ef29-5162-b270-9f02debc6803"
},
{
"name": "Run check",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-faq-check",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.body) }}"
},
"position": [
220,
300
],
"id": "3b92ccd8-144b-50af-8826-e9b2112763f0"
},
{
"name": "Checked?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ Array.isArray($json.rows) && $json.rows.length > 0 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
440,
300
],
"id": "f56c9042-070b-5c0a-b2be-a818784927f9"
},
{
"name": "Reject body",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: $json.error ?? 'check failed' }) }}",
"options": {
"responseCode": 400
}
},
"position": [
660,
760
],
"id": "62c6cdf6-bc48-50e8-8255-af71ee09b8c8"
},
{
"name": "Batch ok?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.invalid / $json.total <= ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3) }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
660,
300
],
"id": "ee39faa6-b293-5cc0-aa10-f1f1afe4a23d"
},
{
"name": "Reject batch",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: 'batch rejected', batch: $('Run check').first().json.batch, invalid_ratio: Math.round($('Run check').first().json.invalid / $('Run check').first().json.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3), codes: $('Run check').first().json.codes }) }}",
"options": {
"responseCode": 422
}
},
"position": [
1320,
560
],
"id": "c36ae045-13ee-5c26-8c55-596102c81464"
},
{
"name": "Log rejected",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ batch: $json.batch, dataset: 'faq', total: $json.total, valid: $json.valid, quarantined: 0, rejected: true, score: 0, codes: $json.codes }) }}"
},
"position": [
880,
560
],
"id": "6a210860-265e-5675-b62c-9aa055215e27"
},
{
"name": "Alert rejected",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/alerts",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Batch ok?', severity: 'high', reason: 'batch_rejected', batch: $('Run check').first().json.batch, message: $('Run check').first().json.batch + ' rejected' }) }}"
},
"position": [
1100,
560
],
"id": "a27d519d-a8ba-5da8-8b20-91666e65f435"
},
{
"name": "Split rows",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const c = $('Run check').first().json;\nconst items = $('Import webhook').first().json.body.items;\nreturn c.rows.map((r, i) => ({ json: { ...r, row: items[i], batch: c.batch } }));"
},
"position": [
880,
300
],
"id": "d21b12e6-d7c3-5693-ab62-1690f04fe10b"
},
{
"name": "Only passed",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return $input.all().filter((i) => i.json.ok);"
},
"position": [
1100,
300
],
"id": "0a85d85b-5527-584e-8956-5a33eeb770a0"
},
{
"name": "Judge",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=제목: {{ $json.row.title }}\n답: {{ $json.row.answer }}",
"options": {
"systemMessage": "You check a company IT FAQ entry before it is published. Does the answer actually answer the title's topic? Ignore style. Return only JSON, no code fence: {\"match\": true, \"reason\": \"short reason in Korean\"}",
"maxIterations": 1
}
},
"onError": "continueRegularOutput",
"position": [
1320,
300
],
"id": "7c0e4c8e-3e63-539b-9902-ea10d43acca2"
},
{
"name": "Judge model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1320,
520
],
"id": "4d1aac5b-af62-54ea-8cf5-3fa807ed58db"
},
{
"name": "Decide",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const judged = $('Only passed').all().map((item, i) => {\n let v = { match: false, reason: 'judge unreadable' };\n try { v = JSON.parse(String($('Judge').all()[i].json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) {}\n return { ...item.json, ok: v.match === true, errors: v.match === true ? [] : ['llm_mismatch'], reason: String(v.reason ?? '') };\n});\nlet k = 0;\nreturn $('Split rows').all().map((i) => ({ json: i.json.ok ? judged[k++] : i.json }));"
},
"position": [
1540,
300
],
"id": "d775abc0-029e-5213-8d2f-63ed42224453"
},
{
"name": "Store row",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}"
},
"position": [
1760,
300
],
"id": "8abf3036-e347-514d-8140-8bc277ceb4a3"
},
{
"name": "Summarize",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const c = $('Run check').first().json;\nconst rows = $('Decide').all().map((i) => i.json);\nconst imported = rows.filter((r) => r.ok);\nconst quarantined = rows.filter((r) => !r.ok);\nconst codes = { ...c.codes };\nfor (const r of quarantined) for (const e of r.errors) if (e === 'llm_mismatch') codes[e] = (codes[e] ?? 0) + 1;\nreturn [{ json: { batch: c.batch, total: c.total, imported: imported.length, quarantined: quarantined.length, rejected: false,\n score: Math.round(imported.length / c.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3),\n codes, imported_ids: imported.map((r) => r.id), quarantined_ids: quarantined.map((r) => r.id) } }];"
},
"position": [
1980,
300
],
"id": "f904284d-6948-5dfc-85db-023fb7e7bbfa"
},
{
"name": "Log quality",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ batch: $('Summarize').first().json.batch, dataset: 'faq', total: $('Summarize').first().json.total, valid: $('Summarize').first().json.imported, quarantined: $('Summarize').first().json.quarantined, rejected: false, score: $('Summarize').first().json.score, codes: $('Summarize').first().json.codes }) }}"
},
"position": [
2200,
300
],
"id": "0fb77808-55c4-54fc-b1ba-9bb98478a8dc"
},
{
"name": "Low score?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $('Summarize').first().json.score < 0.9 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
2420,
300
],
"id": "5c9f4cf0-8451-58f4-aa92-6d21adb1ef7c"
},
{
"name": "Alert low score",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/alerts",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Low score?', severity: 'high', reason: 'low_quality', batch: $('Summarize').first().json.batch, score: $('Summarize').first().json.score, message: $('Summarize').first().json.batch + ' quality ' + $('Summarize').first().json.score }) }}"
},
"position": [
2640,
160
],
"id": "09f87701-f48a-51c1-bd8c-d15486860992"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Summarize').first().json }];"
},
"position": [
2860,
300
],
"id": "77f7a5b7-4ad0-50ec-8b9b-5fed5597b549"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3080,
300
],
"id": "1a367765-da4c-5405-8c06-441779da8090"
}
],
"connections": {
"Import webhook": {
"main": [
[
{
"node": "Run check",
"type": "main",
"index": 0
}
]
]
},
"Run check": {
"main": [
[
{
"node": "Checked?",
"type": "main",
"index": 0
}
]
]
},
"Checked?": {
"main": [
[
{
"node": "Batch ok?",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject body",
"type": "main",
"index": 0
}
]
]
},
"Batch ok?": {
"main": [
[
{
"node": "Split rows",
"type": "main",
"index": 0
}
],
[
{
"node": "Log rejected",
"type": "main",
"index": 0
}
]
]
},
"Log rejected": {
"main": [
[
{
"node": "Alert rejected",
"type": "main",
"index": 0
}
]
]
},
"Alert rejected": {
"main": [
[
{
"node": "Reject batch",
"type": "main",
"index": 0
}
]
]
},
"Split rows": {
"main": [
[
{
"node": "Only passed",
"type": "main",
"index": 0
}
]
]
},
"Only passed": {
"main": [
[
{
"node": "Judge",
"type": "main",
"index": 0
}
]
]
},
"Judge": {
"main": [
[
{
"node": "Decide",
"type": "main",
"index": 0
}
]
]
},
"Judge model": {
"ai_languageModel": [
[
{
"node": "Judge",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Decide": {
"main": [
[
{
"node": "Store row",
"type": "main",
"index": 0
}
]
]
},
"Store row": {
"main": [
[
{
"node": "Summarize",
"type": "main",
"index": 0
}
]
]
},
"Summarize": {
"main": [
[
{
"node": "Log quality",
"type": "main",
"index": 0
}
]
]
},
"Log quality": {
"main": [
[
{
"node": "Low score?",
"type": "main",
"index": 0
}
]
]
},
"Low score?": {
"main": [
[
{
"node": "Alert low score",
"type": "main",
"index": 0
}
],
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Alert low score": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,641 @@
{
"name": "78 FAQ import + LLM calls (answer)",
"nodes": [
{
"name": "Import webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-faq-import",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "97923cda-6ae1-50d4-a0ad-df64c161c39c",
"webhookId": "23a35671-e3aa-5f09-a094-45b3da4c643c"
},
{
"name": "Run check",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-faq-check",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.body) }}"
},
"position": [
220,
300
],
"id": "9ebb8863-ee15-58d6-9115-b6ff5a1bbfd0"
},
{
"name": "Checked?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ Array.isArray($json.rows) && $json.rows.length > 0 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
440,
300
],
"id": "4a3efc44-a09f-5cf8-8654-8c976fc86008"
},
{
"name": "Reject body",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: $json.error ?? 'check failed' }) }}",
"options": {
"responseCode": 400
}
},
"position": [
660,
760
],
"id": "88c8455e-da32-5781-8c2d-40c1262fcd70"
},
{
"name": "Batch ok?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.invalid / $json.total <= ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3) }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
660,
300
],
"id": "74cca906-ea01-5106-a9b7-e9166ccfa367"
},
{
"name": "Reject batch",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: 'batch rejected', batch: $('Run check').first().json.batch, invalid_ratio: Math.round($('Run check').first().json.invalid / $('Run check').first().json.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3), codes: $('Run check').first().json.codes, llm_calls: 0 }) }}",
"options": {
"responseCode": 422
}
},
"position": [
1320,
560
],
"id": "50f5afd9-e4fb-5f03-b99c-b0bbbd02f637"
},
{
"name": "Log rejected",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ batch: $json.batch, dataset: 'faq', total: $json.total, valid: $json.valid, quarantined: 0, rejected: true, score: 0, codes: $json.codes }) }}"
},
"position": [
880,
560
],
"id": "b1d03d7a-d131-59b4-b7c3-63c90c4bd3e2"
},
{
"name": "Alert rejected",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/alerts",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Batch ok?', severity: 'high', reason: 'batch_rejected', batch: $('Run check').first().json.batch, message: $('Run check').first().json.batch + ' rejected' }) }}"
},
"position": [
1100,
560
],
"id": "0f3fae58-f846-5ff3-99b0-ad519ff51546"
},
{
"name": "Split rows",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const c = $('Run check').first().json;\nconst items = $('Import webhook').first().json.body.items;\nreturn c.rows.map((r, i) => ({ json: { ...r, row: items[i], batch: c.batch } }));"
},
"position": [
880,
300
],
"id": "aa264c2a-0b2c-5c68-ab00-48e193d952d8"
},
{
"name": "Only passed",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return $input.all().filter((i) => i.json.ok);"
},
"position": [
1100,
300
],
"id": "57ba3d3a-7a75-5848-90f7-cc4072007dd2"
},
{
"name": "Judge",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=제목: {{ $json.row.title }}\n답: {{ $json.row.answer }}",
"options": {
"systemMessage": "You check a company IT FAQ entry before it is published. Does the answer actually answer the title's topic? Ignore style. Return only JSON, no code fence: {\"match\": true, \"reason\": \"short reason in Korean\"}",
"maxIterations": 1
}
},
"onError": "continueRegularOutput",
"position": [
1320,
300
],
"id": "8936b8b1-19a9-57fe-aae5-d934a6c1e6c7"
},
{
"name": "Judge model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1320,
520
],
"id": "ada85f33-792e-562b-8802-463ced397599"
},
{
"name": "Decide",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const judged = $('Only passed').all().map((item, i) => {\n let v = { match: false, reason: 'judge unreadable' };\n try { v = JSON.parse(String($('Judge').all()[i].json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) {}\n return { ...item.json, ok: v.match === true, errors: v.match === true ? [] : ['llm_mismatch'], reason: String(v.reason ?? '') };\n});\nlet k = 0;\nreturn $('Split rows').all().map((i) => ({ json: i.json.ok ? judged[k++] : i.json }));"
},
"position": [
1540,
300
],
"id": "43e33f20-a354-548d-98fe-a22a8a295de5"
},
{
"name": "Store row",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}"
},
"position": [
1760,
300
],
"id": "8df856a9-afff-5685-8095-51fceddbd4de"
},
{
"name": "Summarize",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const c = $('Run check').first().json;\nconst rows = $('Decide').all().map((i) => i.json);\nconst imported = rows.filter((r) => r.ok);\nconst quarantined = rows.filter((r) => !r.ok);\nconst codes = { ...c.codes };\nfor (const r of quarantined) for (const e of r.errors) if (e === 'llm_mismatch') codes[e] = (codes[e] ?? 0) + 1;\nreturn [{ json: { batch: c.batch, total: c.total, imported: imported.length, quarantined: quarantined.length, rejected: false,\n score: Math.round(imported.length / c.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3),\n codes, imported_ids: imported.map((r) => r.id), quarantined_ids: quarantined.map((r) => r.id), llm_calls: $('Only passed').all().length } }];"
},
"position": [
1980,
300
],
"id": "c287c65f-99fd-59ef-a44d-dc4b021d7a9c"
},
{
"name": "Log quality",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ batch: $('Summarize').first().json.batch, dataset: 'faq', total: $('Summarize').first().json.total, valid: $('Summarize').first().json.imported, quarantined: $('Summarize').first().json.quarantined, rejected: false, score: $('Summarize').first().json.score, codes: $('Summarize').first().json.codes }) }}"
},
"position": [
2200,
300
],
"id": "9607be48-7b62-5f67-9aec-23db3fb0fbb8"
},
{
"name": "Low score?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $('Summarize').first().json.score < 0.9 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
2420,
300
],
"id": "4d32f16f-6554-580b-83eb-2c489df417d3"
},
{
"name": "Alert low score",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/alerts",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Low score?', severity: 'high', reason: 'low_quality', batch: $('Summarize').first().json.batch, score: $('Summarize').first().json.score, message: $('Summarize').first().json.batch + ' quality ' + $('Summarize').first().json.score }) }}"
},
"position": [
2640,
160
],
"id": "e7dcfdb3-8ed7-5783-93cb-4daf2a75cbb6"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Summarize').first().json }];"
},
"position": [
2860,
300
],
"id": "90b42f0a-eaa4-55d7-b579-c2e6c67ea701"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3080,
300
],
"id": "700d4355-509a-546a-936e-d8e96c297925"
}
],
"connections": {
"Import webhook": {
"main": [
[
{
"node": "Run check",
"type": "main",
"index": 0
}
]
]
},
"Run check": {
"main": [
[
{
"node": "Checked?",
"type": "main",
"index": 0
}
]
]
},
"Checked?": {
"main": [
[
{
"node": "Batch ok?",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject body",
"type": "main",
"index": 0
}
]
]
},
"Batch ok?": {
"main": [
[
{
"node": "Split rows",
"type": "main",
"index": 0
}
],
[
{
"node": "Log rejected",
"type": "main",
"index": 0
}
]
]
},
"Log rejected": {
"main": [
[
{
"node": "Alert rejected",
"type": "main",
"index": 0
}
]
]
},
"Alert rejected": {
"main": [
[
{
"node": "Reject batch",
"type": "main",
"index": 0
}
]
]
},
"Split rows": {
"main": [
[
{
"node": "Only passed",
"type": "main",
"index": 0
}
]
]
},
"Only passed": {
"main": [
[
{
"node": "Judge",
"type": "main",
"index": 0
}
]
]
},
"Judge": {
"main": [
[
{
"node": "Decide",
"type": "main",
"index": 0
}
]
]
},
"Judge model": {
"ai_languageModel": [
[
{
"node": "Judge",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Decide": {
"main": [
[
{
"node": "Store row",
"type": "main",
"index": 0
}
]
]
},
"Store row": {
"main": [
[
{
"node": "Summarize",
"type": "main",
"index": 0
}
]
]
},
"Summarize": {
"main": [
[
{
"node": "Log quality",
"type": "main",
"index": 0
}
]
]
},
"Log quality": {
"main": [
[
{
"node": "Low score?",
"type": "main",
"index": 0
}
]
]
},
"Low score?": {
"main": [
[
{
"node": "Alert low score",
"type": "main",
"index": 0
}
],
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Alert low score": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,108 @@
{
"name": "78 Quality report (answer)",
"nodes": [
{
"name": "Report webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-quality-report",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "d39880e6-d916-5d87-a2d7-39a1f1201094",
"webhookId": "2b2b3919-d93a-579c-ae38-a51266677926"
},
{
"name": "Get log",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
220,
300
],
"id": "ddba6f7c-251f-5309-87dd-cdaf072aa5c8"
},
{
"name": "Make report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const want = $('Report webhook').first().json.body?.dataset;\nconst all = $json.items ?? [];\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst summary = (items) => {\n const batches = items.map((i) => ({ batch: i.batch, score: i.score, rejected: i.rejected === true, total: i.total }));\n const codes = {};\n for (const i of items) for (const [k, v] of Object.entries(i.codes ?? {})) codes[k] = (codes[k] ?? 0) + v;\n const top_codes = Object.entries(codes).map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));\n const avg = batches.length ? r4(batches.reduce((s, b) => s + b.score, 0) / batches.length) : 0;\n const worst = batches.reduce((w, b) => (w === null || b.score < w.score ? b : w), null);\n let trend = '그대로';\n if (batches.length >= 2) { const d = batches.at(-1).score - batches[0].score; trend = d > 0.05 ? '좋아짐' : d < -0.05 ? '나빠짐' : '그대로'; }\n return { count: batches.length, batches, avg_score: avg, worst_batch: worst?.batch ?? null, rejected_batches: batches.filter((b) => b.rejected).length, top_codes, trend };\n};\nconst out = summary(all.filter((i) => !want || i.dataset === want));\nreturn [{ json: out }];"
},
"position": [
440,
300
],
"id": "e4e4147a-061c-5ef0-8ada-d588ef283c8c"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
660,
300
],
"id": "61076c2a-8679-511f-877a-f16b8e7983e4"
}
],
"connections": {
"Report webhook": {
"main": [
[
{
"node": "Get log",
"type": "main",
"index": 0
}
]
]
},
"Get log": {
"main": [
[
{
"node": "Make report",
"type": "main",
"index": 0
}
]
]
},
"Make report": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,236 @@
{
"name": "78 Schema check + quality log (answer)",
"nodes": [
{
"name": "Check webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-check",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "71f637f7-7dd3-5109-b1eb-e41f19084672",
"webhookId": "2e73b93d-487c-5d47-abf7-0367dc834812"
},
{
"name": "Get schema",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/schemas/{{ encodeURIComponent($json.body.schema ?? '') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
220,
300
],
"id": "c2e0870e-6d3c-52e5-9eea-c2d612aa9c02"
},
{
"name": "Known schema?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.fields !== undefined }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
440,
300
],
"id": "5cbdeaec-a7b4-5d52-b1dc-a9652df407f7"
},
{
"name": "Unknown schema",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: 'unknown schema', schema: $('Check webhook').first().json.body.schema ?? null }) }}",
"options": {
"responseCode": 400
}
},
"position": [
660,
560
],
"id": "0f5c53bb-1643-5cab-958d-183709fc78bd"
},
{
"name": "Check rows",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Check webhook').first().json.body;\nconst schema = $json;\nlet items = body.items;\nif (typeof body.csv === 'string') {\n const [head, ...lines] = body.csv.trim().split(/\\r?\\n/);\n const cols = head.split(',').map((c) => c.trim());\n items = lines.map((line) => Object.fromEntries(line.split(',').map((v, i) => [cols[i], v.trim()])));\n}\nconst isDate = (v) => { if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(v)) return false; const d = new Date(v + 'T00:00:00Z'); return !isNaN(d) && d.toISOString().slice(0, 10) === v; };\nconst seen = new Set();\nconst rows = (Array.isArray(items) ? items : []).map((r, index) => {\n const errors = [];\n for (const [name, f] of Object.entries(schema.fields)) {\n const v = r?.[name];\n const empty = v === undefined || v === null || (typeof v === 'string' && v.trim() === '') || (Array.isArray(v) && v.length === 0);\n if (empty) { if (f.required) errors.push(`missing:${name}`); continue; }\n const s = String(v);\n if (f.type === 'string') { if (f.max && s.length > f.max) errors.push(`too_long:${name}`); if (f.min && s.length < f.min) errors.push(`too_short:${name}`); }\n if (f.type === 'enum' && !f.values.includes(s)) errors.push(`not_allowed:${name}`);\n if (f.type === 'email' && !/^[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+$/.test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'phone' && !/^0\\d{1,2}-\\d{3,4}-\\d{4}$/.test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'date' && !isDate(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'pattern' && !new RegExp(f.pattern).test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'list') { if (!Array.isArray(v)) errors.push(`bad_format:${name}`); else { if (f.min_items && v.length < f.min_items) errors.push(`too_few:${name}`); if (f.max_items && v.length > f.max_items) errors.push(`too_many:${name}`); } }\n }\n const key = String(r?.[schema.key] ?? '');\n if (key && seen.has(key)) errors.push('duplicate_in_batch');\n if (key) seen.add(key);\n return { row: index + 1, key, ok: errors.length === 0, errors };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nreturn [{ json: { schema: schema.name, batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid, rows, codes } }];"
},
"position": [
660,
300
],
"id": "709a8af9-9764-5bc9-9ba6-832437e37d66"
},
{
"name": "Log quality",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ batch: $('Check rows').first().json.batch, dataset: $('Check rows').first().json.schema, total: $('Check rows').first().json.total, valid: $('Check rows').first().json.valid, quarantined: $('Check rows').first().json.invalid, rejected: false, score: $('Check rows').first().json.total ? Math.round($('Check rows').first().json.valid / $('Check rows').first().json.total * 10000) / 10000 : 0, codes: $('Check rows').first().json.codes }) }}"
},
"position": [
880,
300
],
"id": "6b7a1222-281c-5a0b-8e5d-ce8340bace06"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Check rows').first().json }];"
},
"position": [
1100,
300
],
"id": "44b28b34-d252-5957-8be7-db1828cfe536"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1320,
300
],
"id": "bde5ad2b-cfad-55b6-893b-1110d25c6d0b"
}
],
"connections": {
"Check webhook": {
"main": [
[
{
"node": "Get schema",
"type": "main",
"index": 0
}
]
]
},
"Get schema": {
"main": [
[
{
"node": "Known schema?",
"type": "main",
"index": 0
}
]
]
},
"Known schema?": {
"main": [
[
{
"node": "Check rows",
"type": "main",
"index": 0
}
],
[
{
"node": "Unknown schema",
"type": "main",
"index": 0
}
]
]
},
"Check rows": {
"main": [
[
{
"node": "Log quality",
"type": "main",
"index": 0
}
]
]
},
"Log quality": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,108 @@
{
"name": "78 Quality report + by dataset (answer)",
"nodes": [
{
"name": "Report webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-quality-report",
"responseMode": "responseNode",
"options": {}
},
"position": [
0,
300
],
"id": "2a4e58b4-7434-5181-9fca-13bc3ba808cf",
"webhookId": "e900dd73-cfae-5c26-9e5b-204c1e0867ec"
},
{
"name": "Get log",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/quality-log",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
220,
300
],
"id": "52996b11-04b3-5567-b18f-061a3e1bc213"
},
{
"name": "Make report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const want = $('Report webhook').first().json.body?.dataset;\nconst all = $json.items ?? [];\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst summary = (items) => {\n const batches = items.map((i) => ({ batch: i.batch, score: i.score, rejected: i.rejected === true, total: i.total }));\n const codes = {};\n for (const i of items) for (const [k, v] of Object.entries(i.codes ?? {})) codes[k] = (codes[k] ?? 0) + v;\n const top_codes = Object.entries(codes).map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));\n const avg = batches.length ? r4(batches.reduce((s, b) => s + b.score, 0) / batches.length) : 0;\n const worst = batches.reduce((w, b) => (w === null || b.score < w.score ? b : w), null);\n let trend = '그대로';\n if (batches.length >= 2) { const d = batches.at(-1).score - batches[0].score; trend = d > 0.05 ? '좋아짐' : d < -0.05 ? '나빠짐' : '그대로'; }\n return { count: batches.length, batches, avg_score: avg, worst_batch: worst?.batch ?? null, rejected_batches: batches.filter((b) => b.rejected).length, top_codes, trend };\n};\nconst out = summary(all.filter((i) => !want || i.dataset === want));\n\nout.by_dataset = {};\nfor (const name of [...new Set(all.map((i) => i.dataset ?? ''))]) {\n const s = summary(all.filter((i) => (i.dataset ?? '') === name));\n out.by_dataset[name] = { count: s.count, avg_score: s.avg_score, worst_batch: s.worst_batch, top_code: s.top_codes[0]?.code ?? null };\n}\nconst names = Object.keys(out.by_dataset);\nout.worst_dataset = names.length ? names.reduce((w, n) => (out.by_dataset[n].avg_score < out.by_dataset[w].avg_score ? n : w)) : null;\nreturn [{ json: out }];"
},
"position": [
440,
300
],
"id": "c4fa7448-4a77-5856-91b6-37ae2d841a7c"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
660,
300
],
"id": "79bb121c-6ef3-546d-8b1c-9ae6dda0a815"
}
],
"connections": {
"Report webhook": {
"main": [
[
{
"node": "Get log",
"type": "main",
"index": 0
}
]
]
},
"Get log": {
"main": [
[
{
"node": "Make report",
"type": "main",
"index": 0
}
]
]
},
"Make report": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
File diff suppressed because it is too large. Load diff