Files
aio-01d4-stu/Last3Mon/78/answers/LAB-TEST-RESULT.md
T

17368 lines
536 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 78일차 Lab 테스트 결과 · 실제로 만든 시나리오와 `test -v` 결과
> **정답 자료** · 78일차 n8n 워크플로우를 **만드는 프롬프트(codex) → push → `call` · `test -v` · `quality` 결과**를 학생이 가는 순서대로 한 문서에 모았습니다. 수업이 끝난 뒤 공개합니다.
- 실행: 2026-11-01, 실습을 Lab마다 5개(예제 3 + 과제 2)로 늘린 뒤 강사 노트북의 65일차 n8n 2.42.6 + 78일차 판 데이터 API(77일차 + 격리함 · 스키마 셋 · 품질 기록), OpenAI `gpt-4.1-mini`(LLM 검사, temperature 0), 케이스 파일마다 **한 번씩**(Lab 02 과제 2는 세 번), 모두 26단계 (00~25)
- 명령: `~/aio-01d4-stu/Last3Mon/78`에서 `uv run tools/flow.py ...`. 아래 단계마다 **실행한 명령**과 **화면에 나온 출력 전부**를 그대로 붙이고, 그 아래 **쉽게 말하면**으로 결과를 풀어 썼습니다 (특히 ✘가 나온 까닭과 그것이 예상한 것인지). 같은 출력이 `NN-<이름>.txt`에, 그 단계 첫 `test`의 보고서가 `NN-<이름>.json`에 있습니다 (00 · 01단계는 보고서 없음).
- 규칙 판정(Code)은 배치 파일과 규칙이 같으면 **늘 같습니다.** 실행마다 다를 수 있는 것은 시간과 LLM 판정입니다 — 이번 실행에서 LLM이 막은 줄은 B-2의 FAQ-210(매번)과, B-1을 받았을 때의 FAQ-113뿐입니다. 정답의 잘못으로 실패한 케이스는 없습니다. ✘는 모두 "고치기 전" 단계와 Lab 02 과제 2의 `--no-before` 확인에서 본 것입니다.
- "고치기 전" 상태: 00단계에서 `my-*` 워크플로우를 모두 **게시 해제**(`off`)하고 `ops --reset`으로 더한 FAQ · 격리함 · 기록 · 알림을 비워 학생이 처음 여는 상태와 같게 했습니다.
## `-v`가 보여 주는 것
```text
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 9.3s ← 케이스 · 시간
→ request POST /webhook/my-faq-import ← 보낸 요청 JSON (배치)
{ "batch": "B-2", "items": [ … 10줄 … ] }
← response HTTP 200 ← 받은 응답 JSON (판정)
{ "total": 10, "imported": 8, "quarantined": 2, "score": 0.8, "codes": { "pii": 1, "llm_mismatch": 1 }, … }
```
오늘은 응답의 **판정**(무엇을 넣고 무엇을 막았나 · 왜)을 보고, 기대와 다를 때 `✘` 줄 끝의 `칸 = 실제 (expected 기대)`나 `has none of [코드]`를 봅니다. 판정이 다르면 거의 언제나 **규칙의 말**(넘으면/이상 · 공백 · 꼴 · 정확히)이 다른 것입니다.
## 이 문서 하나로 따라가기
단계마다 **① codex에 넣는 프롬프트 → ② codex가 `my-work/*.json`을 쓰고 `check` → ③ 내가 `push` → ④ 내가 `test -v` · `call` · `quality`** 순서입니다. 에이전트 없이 하는 예제(Lab 01 예제 1 · 3, Lab 02 예제 3, Lab 03 예제 3)와 고치기 전 단계는 프롬프트가 없습니다.
```mermaid
flowchart LR
P["① 프롬프트<br/>터미널 ① codex"] --> C["② codex<br/>my-work/*.json · check"] --> U["③ push<br/>터미널 ②"] --> T["④ test -v · call · quality<br/>판정 · 기대와 다른 칸"]
T -->|✘| X["실패 줄 · 보고서 경로를<br/>codex에 그대로"] --> C
```
- **프롬프트는 정답본(`LAB-0x-A-*.md`)의 요청문 예**를 그대로 썼습니다 (실습 문서 빈칸 채우기 뼈대의 `____`를 채운 글자).
- **이 기록의 워크플로우는 수업 준비 때 만든 정답 파일**입니다. 프롬프트를 codex에 보내 다시 만들어 재지는 않았습니다 (68일차부터 수업 준비는 정답 워크플로우만 실행).
- 학생은 `my-work/faq-check.json` · `faq-import.json`을 거듭 고쳐 push하고, 이 기록은 같은 웹훅의 단계별 정답 파일을 차례로 push했습니다 (`push`가 앞 판을 내린다는 `! unpublished … same webhook path` 줄이 그것). 그래서 `test` 명령과 결과 모양은 같습니다.
## 시나리오 한눈에
```mermaid
flowchart LR
L1["Lab 01 (00~08)<br/>기준 10/13 통과 → 규칙대로 5/13<br/>경계 6/6 · 이미 있는 FAQ 3/13<br/>고침 목록 10줄"] --> L2["Lab 02 (09~16)<br/>기준 12줄 넣음 → B-1 거절 · 격리<br/>LLM이 FAQ-210 · 기준 0.3/0.8/0.1/0.05<br/>기록 · 알림 · llm_calls 9 · 5"]
L2 --> L3["Lab 03 (17~25)<br/>직원 CSV 4/9 · 고쳐서 격리 0<br/>ticket 스키마 2/9 (워크플로우 그대로)<br/>평균 0.6583 · ticket 0.2222"]
```
| 단계 | 내용 |
| --- | --- |
| 00 | 수업 준비 · 처음 상태 · 기준 검사 · 기준 가져오기 게시 · doctor |
| 01 | Lab 01 예제 1 · 눈으로 검사 · 팀의 기준 검사 |
| 02 | Lab 01 예제 2 하기 전 → `lab01` 1/5 (기준에) |
| 03 | Lab 01 예제 2 · 검사 워크플로우 → 5/5 |
| 04 | Lab 01 예제 3 · 경계에서 재 보기 → 내 검사 6/6 · 기준 0/6 |
| 05 | Lab 01 과제 1 하기 전 → `lab01-challenge` 0/4 |
| 06 | Lab 01 과제 1 · 이미 있는 데이터와 맞춰 보기 → 4/4 |
| 07 | Lab 01 과제 2 하기 전 → `lab01-challenge2` 1/5 |
| 08 | Lab 01 과제 2 · 보낸 사람에게 돌려줄 고침 목록 → 5/5 |
| 09 | Lab 02 하기 전 → `lab02` 0/6 (기준 가져오기에) |
| 10 | Lab 02 예제 1 · 배치 게이트와 격리 → `lab02` 5/6 |
| 11 | Lab 02 예제 2 · LLM 검사 → 6/6 |
| 12 | Lab 02 예제 3 · 기준을 바꾸면 → 6/6 |
| 13 | Lab 02 과제 1 하기 전 → `lab02-challenge` 2/5 |
| 14 | Lab 02 과제 1 · 품질 기록과 알림 → 5/5 |
| 15 | Lab 02 과제 2 하기 전 → `lab02-challenge2` 0/3 |
| 16 | Lab 02 과제 2 · LLM을 몇 번 부르나 · 늘 같은가 → 3/3 × 세 번 |
| 17 | Lab 03 예제 1 하기 전 → FAQ 검사 400 · `lab03` 0/4 |
| 18 | Lab 03 예제 1 · 스키마 검사 → 4/4 |
| 19 | Lab 03 예제 2 하기 전 → `lab03-fix` 3/4 |
| 20 | Lab 03 예제 2 · 고쳐서 다시 넣기 → 4/4 |
| 21 | Lab 03 예제 3 · 스키마만 바꾸면 → 5/5 (워크플로우 그대로) |
| 22 | Lab 03 과제 1 하기 전 → `lab03-challenge` 0/4 |
| 23 | Lab 03 과제 1 · 품질 보고서 → 4/4 |
| 24 | Lab 03 과제 2 하기 전 → `lab03-challenge2` 0/4 |
| 25 | Lab 03 과제 2 · 데이터셋별 품질 → 4/4 |
## 준비
### 00 · 수업 준비 · 처음 상태 · 기준 검사 · 기준 가져오기 게시 · doctor
실행한 명령:
```bash
uv run tools/flow.py ops --reset
uv run tools/flow.py off my-faq-check my-faq-import my-check my-quality-report
uv run tools/flow.py push reference/baseline-faq-check.json reference/baseline-faq-import.json
uv run tools/flow.py quality
uv run tools/flow.py doctor
```
출력 (그대로):
```text
✔ live run logs, added FAQ, quarantine, quality log, created tickets, DLQ and alerts emptied, fault switch all ok
✔ 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>
✔ 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
FAQ: 6 (nothing added)
quarantine: 0
quality log: 0
alerts: 0
! 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
```
**쉽게 말하면**
- 더한 FAQ · 격리함 · 품질 기록 · 알림(그리고 77일차 실습 기록 · 76일차 접수 건 · DLQ)을 비우고 고장 스위치를 모두 `ok`로 돌렸습니다.
- 앞서 게시한 `my-faq-check` · `my-faq-import` · `my-check` · `my-quality-report`를 내려 학생이 처음 여는 상태를 만들고, 팀의 기준 `base-faq-check` · `base-faq-import`만 게시했습니다.
- `quality`: FAQ 6 · 격리 0 · 기록 0 · 알림 0. `schemas faq · employee · ticket, quarantine, quality log (day 78 data API)` ✔는 데이터 API가 78일차 판이라는 뜻입니다. `ready`.
## Lab 01 · 품질 규칙 · 눈으로 검사하고, 규칙으로 바꾸기
### 01 · Lab 01 예제 1 · 눈으로 검사 · 팀의 기준 검사
실행한 명령:
```bash
uv run tools/flow.py call base-faq-check requests/faq-batch-1.json
```
출력 (그대로):
```text
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
}
}
```
**쉽게 말하면**
- 기준 검사는 B-1 13줄 중 **10줄을 통과**시켰습니다. 나쁘다고 한 것은 빈 제목(FAQ-103) · 빈 답(FAQ-105) · 빈 키워드(FAQ-108) 셋뿐입니다.
- 눈으로 규칙표를 대면 분명히 나쁜 줄이 8줄: 위 셋 + id 두 자리(FAQ-10) · 300자 넘는 답(FAQ-106) · 배치 안 중복(둘째 FAQ-102) · 개인 전화번호(FAQ-109) · 바깥 링크(FAQ-110). 기준은 다섯 줄을 놓쳤습니다.
- 기준이 틀린 계산을 한 것이 아니라 **규칙이 셋뿐**입니다. 예상한 결과.
### 02 · Lab 01 예제 2 하기 전 → `lab01` 1/5 (기준에)
실행한 명령:
```bash
uv run tools/flow.py test cases/lab01.json --webhook base-faq-check -v
```
출력 (그대로):
```text
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
```
**쉽게 말하면**
- 빈 값 케이스만 ✔. 형식 · 중복 · 개인정보 · 링크 규칙이 없어 `has none of`, B-2도 10줄 모두 통과(`valid` 10), 목록이 아닌 본문은 Code가 터져 **500**.
- 예상한 결과. 오늘의 규칙은 AGENTS.md 3절.
### 03 · Lab 01 예제 2 · 검사 워크플로우 → 5/5
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-check.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab01-example2-check.json`):
```text
my-work/faq-check.json 을 새로 만들어 줘. 웹훅 POST my-faq-check, 이름 "78 FAQ check (my)".
Is a list?(IF): 본문 items 가 목록이 아니면 Reject body 로 HTTP 400 {"error": "items must be a list"}.
Check rows(Code) 하나에서 줄마다 AGENTS.md 3절 FAQ 품질 규칙대로 errors 를 붙인다. 공백만 있는 값은 빈 값, 글자 수는 앞뒤 공백을 뺀 뒤 센다.
pii 는 하이픈 없는 휴대전화도, external_link 는 호스트가 정확히 intra.example.com 이 아니면.
결과 {batch, total, valid, invalid, rows: [{id, ok, errors, warnings}], codes}, rows 는 들어온 순서 그대로. LLM 은 쓰지 않는다. 기대 결과 cases/lab01.json.
```
실행한 명령:
```bash
uv run tools/flow.py push reference/lab01-example2-check.json
uv run tools/flow.py test cases/lab01.json -v
uv run tools/flow.py call my-faq-check requests/faq-batch-1.json
```
출력 (그대로):
```text
✔ pushed '78 FAQ check (reference)' (id w318880a1bd61aa2)
POST http://localhost:5678/webhook/my-faq-check
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
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
}
}
```
**쉽게 말하면**
- 규칙 10개로 B-1을 다시 보면 13줄 중 5줄 통과 · 8줄 나쁨 — 눈으로 찾은 8줄과 **같은 줄**입니다. 줄마다 코드가 붙어 `codes`에 8가지가 1건씩.
- 남은 통과 5줄 중 FAQ-003 · FAQ-112는 과제 1(이미 있는 FAQ), FAQ-113은 Lab 02 예제 2(LLM)에서 걸러집니다. 목록이 아닌 본문은 400. 0.1초, LLM 없음. 예상한 결과.
### 04 · Lab 01 예제 3 · 경계에서 재 보기 → 내 검사 6/6 · 기준 0/6
실행한 명령:
```bash
uv run tools/flow.py test cases/lab01-edges.json -v
uv run tools/flow.py test cases/lab01-edges.json --webhook base-faq-check
```
출력 (그대로):
```text
POST http://localhost:5678/webhook/my-faq-check · 6 case(s) × 1
✔ 제목 40자는 통과, 41자는 너무 김 · 0.1s
→ request POST /webhook/my-faq-check
{
"batch": "E-1",
"items": [
{
"id": "FAQ-401",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가",
"keywords": [
"메신저",
"알림"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-402",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가요",
"keywords": [
"메신저"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-403",
"title": " ",
"keywords": [
"사원증"
],
"answer": "사원증 사진은 인사 포털에서 바꿉니다."
},
{
"id": "FAQ-404",
"title": "장비 신청",
"keywords": [
"장비"
],
"answer": "IT 포털에서 신청"
},
{
"id": "FAQ-405",
"title": "장비 반납",
"keywords": [
"반납"
],
"answer": "IT포털에서 신청"
},
{
"id": "FAQ-406",
"title": "야간 장애 연락",
"keywords": [
"야간",
"장애"
],
"answer": "야간 장애는 당직자 01012345678 로 전화합니다."
},
{
"id": "FAQ-407",
"title": "IT 문의 창구",
"keywords": [
"문의",
"창구"
],
"answer": "IT 문의 창구 번호는 02-123-4567 (평일 9~18시) 입니다."
},
{
"id": "FAQ-408",
"title": "도움말 모음",
"keywords": [
"도움말"
],
"answer": "도움말은 https://intra.example.com/help 에 모여 있습니다."
},
{
"id": "FAQ-409",
"title": "보안 교육 자료",
"keywords": [
"보안 교육"
],
"answer": "보안 교육 자료는 http://intra.example.com.evil.io/edu 에서 받습니다."
},
{
"id": "faq-410",
"title": "출장 노트북",
"keywords": [
"출장"
],
"answer": "출장용 노트북은 IT팀에 사흘 전까지 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "E-1",
"total": 10,
"valid": 4,
"invalid": 6,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-401",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-402",
"ok": false,
"errors": [
"title_too_long"
],
"warnings": []
},
{
"id": "FAQ-403",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-404",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-405",
"ok": false,
"errors": [
"answer_too_short"
],
"warnings": []
},
{
"id": "FAQ-406",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-407",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-408",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-409",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "faq-410",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
}
],
"codes": {
"title_too_long": 1,
"title_missing": 1,
"answer_too_short": 1,
"pii": 1,
"external_link": 1,
"id_format": 1
}
}
✔ 공백만 있는 제목은 빈 값 · 0.1s
→ request POST /webhook/my-faq-check
{
"batch": "E-1",
"items": [
{
"id": "FAQ-401",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가",
"keywords": [
"메신저",
"알림"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-402",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가요",
"keywords": [
"메신저"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-403",
"title": " ",
"keywords": [
"사원증"
],
"answer": "사원증 사진은 인사 포털에서 바꿉니다."
},
{
"id": "FAQ-404",
"title": "장비 신청",
"keywords": [
"장비"
],
"answer": "IT 포털에서 신청"
},
{
"id": "FAQ-405",
"title": "장비 반납",
"keywords": [
"반납"
],
"answer": "IT포털에서 신청"
},
{
"id": "FAQ-406",
"title": "야간 장애 연락",
"keywords": [
"야간",
"장애"
],
"answer": "야간 장애는 당직자 01012345678 로 전화합니다."
},
{
"id": "FAQ-407",
"title": "IT 문의 창구",
"keywords": [
"문의",
"창구"
],
"answer": "IT 문의 창구 번호는 02-123-4567 (평일 9~18시) 입니다."
},
{
"id": "FAQ-408",
"title": "도움말 모음",
"keywords": [
"도움말"
],
"answer": "도움말은 https://intra.example.com/help 에 모여 있습니다."
},
{
"id": "FAQ-409",
"title": "보안 교육 자료",
"keywords": [
"보안 교육"
],
"answer": "보안 교육 자료는 http://intra.example.com.evil.io/edu 에서 받습니다."
},
{
"id": "faq-410",
"title": "출장 노트북",
"keywords": [
"출장"
],
"answer": "출장용 노트북은 IT팀에 사흘 전까지 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "E-1",
"total": 10,
"valid": 4,
"invalid": 6,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-401",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-402",
"ok": false,
"errors": [
"title_too_long"
],
"warnings": []
},
{
"id": "FAQ-403",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-404",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-405",
"ok": false,
"errors": [
"answer_too_short"
],
"warnings": []
},
{
"id": "FAQ-406",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-407",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-408",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-409",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "faq-410",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
}
],
"codes": {
"title_too_long": 1,
"title_missing": 1,
"answer_too_short": 1,
"pii": 1,
"external_link": 1,
"id_format": 1
}
}
✔ 답 10자는 통과, 9자는 너무 짧음 · 0.0s
→ request POST /webhook/my-faq-check
{
"batch": "E-1",
"items": [
{
"id": "FAQ-401",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가",
"keywords": [
"메신저",
"알림"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-402",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가요",
"keywords": [
"메신저"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-403",
"title": " ",
"keywords": [
"사원증"
],
"answer": "사원증 사진은 인사 포털에서 바꿉니다."
},
{
"id": "FAQ-404",
"title": "장비 신청",
"keywords": [
"장비"
],
"answer": "IT 포털에서 신청"
},
{
"id": "FAQ-405",
"title": "장비 반납",
"keywords": [
"반납"
],
"answer": "IT포털에서 신청"
},
{
"id": "FAQ-406",
"title": "야간 장애 연락",
"keywords": [
"야간",
"장애"
],
"answer": "야간 장애는 당직자 01012345678 로 전화합니다."
},
{
"id": "FAQ-407",
"title": "IT 문의 창구",
"keywords": [
"문의",
"창구"
],
"answer": "IT 문의 창구 번호는 02-123-4567 (평일 9~18시) 입니다."
},
{
"id": "FAQ-408",
"title": "도움말 모음",
"keywords": [
"도움말"
],
"answer": "도움말은 https://intra.example.com/help 에 모여 있습니다."
},
{
"id": "FAQ-409",
"title": "보안 교육 자료",
"keywords": [
"보안 교육"
],
"answer": "보안 교육 자료는 http://intra.example.com.evil.io/edu 에서 받습니다."
},
{
"id": "faq-410",
"title": "출장 노트북",
"keywords": [
"출장"
],
"answer": "출장용 노트북은 IT팀에 사흘 전까지 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "E-1",
"total": 10,
"valid": 4,
"invalid": 6,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-401",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-402",
"ok": false,
"errors": [
"title_too_long"
],
"warnings": []
},
{
"id": "FAQ-403",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-404",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-405",
"ok": false,
"errors": [
"answer_too_short"
],
"warnings": []
},
{
"id": "FAQ-406",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-407",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-408",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-409",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "faq-410",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
}
],
"codes": {
"title_too_long": 1,
"title_missing": 1,
"answer_too_short": 1,
"pii": 1,
"external_link": 1,
"id_format": 1
}
}
✔ 하이픈 없는 휴대전화도 개인정보, 사무실 번호는 통과 · 0.1s
→ request POST /webhook/my-faq-check
{
"batch": "E-1",
"items": [
{
"id": "FAQ-401",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가",
"keywords": [
"메신저",
"알림"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-402",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가요",
"keywords": [
"메신저"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-403",
"title": " ",
"keywords": [
"사원증"
],
"answer": "사원증 사진은 인사 포털에서 바꿉니다."
},
{
"id": "FAQ-404",
"title": "장비 신청",
"keywords": [
"장비"
],
"answer": "IT 포털에서 신청"
},
{
"id": "FAQ-405",
"title": "장비 반납",
"keywords": [
"반납"
],
"answer": "IT포털에서 신청"
},
{
"id": "FAQ-406",
"title": "야간 장애 연락",
"keywords": [
"야간",
"장애"
],
"answer": "야간 장애는 당직자 01012345678 로 전화합니다."
},
{
"id": "FAQ-407",
"title": "IT 문의 창구",
"keywords": [
"문의",
"창구"
],
"answer": "IT 문의 창구 번호는 02-123-4567 (평일 9~18시) 입니다."
},
{
"id": "FAQ-408",
"title": "도움말 모음",
"keywords": [
"도움말"
],
"answer": "도움말은 https://intra.example.com/help 에 모여 있습니다."
},
{
"id": "FAQ-409",
"title": "보안 교육 자료",
"keywords": [
"보안 교육"
],
"answer": "보안 교육 자료는 http://intra.example.com.evil.io/edu 에서 받습니다."
},
{
"id": "faq-410",
"title": "출장 노트북",
"keywords": [
"출장"
],
"answer": "출장용 노트북은 IT팀에 사흘 전까지 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "E-1",
"total": 10,
"valid": 4,
"invalid": 6,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-401",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-402",
"ok": false,
"errors": [
"title_too_long"
],
"warnings": []
},
{
"id": "FAQ-403",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-404",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-405",
"ok": false,
"errors": [
"answer_too_short"
],
"warnings": []
},
{
"id": "FAQ-406",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-407",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-408",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-409",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "faq-410",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
}
],
"codes": {
"title_too_long": 1,
"title_missing": 1,
"answer_too_short": 1,
"pii": 1,
"external_link": 1,
"id_format": 1
}
}
✔ 사내 주소는 통과, 사내 주소처럼 보이는 바깥 링크는 막음 · 0.0s
→ request POST /webhook/my-faq-check
{
"batch": "E-1",
"items": [
{
"id": "FAQ-401",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가",
"keywords": [
"메신저",
"알림"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-402",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가요",
"keywords": [
"메신저"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-403",
"title": " ",
"keywords": [
"사원증"
],
"answer": "사원증 사진은 인사 포털에서 바꿉니다."
},
{
"id": "FAQ-404",
"title": "장비 신청",
"keywords": [
"장비"
],
"answer": "IT 포털에서 신청"
},
{
"id": "FAQ-405",
"title": "장비 반납",
"keywords": [
"반납"
],
"answer": "IT포털에서 신청"
},
{
"id": "FAQ-406",
"title": "야간 장애 연락",
"keywords": [
"야간",
"장애"
],
"answer": "야간 장애는 당직자 01012345678 로 전화합니다."
},
{
"id": "FAQ-407",
"title": "IT 문의 창구",
"keywords": [
"문의",
"창구"
],
"answer": "IT 문의 창구 번호는 02-123-4567 (평일 9~18시) 입니다."
},
{
"id": "FAQ-408",
"title": "도움말 모음",
"keywords": [
"도움말"
],
"answer": "도움말은 https://intra.example.com/help 에 모여 있습니다."
},
{
"id": "FAQ-409",
"title": "보안 교육 자료",
"keywords": [
"보안 교육"
],
"answer": "보안 교육 자료는 http://intra.example.com.evil.io/edu 에서 받습니다."
},
{
"id": "faq-410",
"title": "출장 노트북",
"keywords": [
"출장"
],
"answer": "출장용 노트북은 IT팀에 사흘 전까지 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "E-1",
"total": 10,
"valid": 4,
"invalid": 6,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-401",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-402",
"ok": false,
"errors": [
"title_too_long"
],
"warnings": []
},
{
"id": "FAQ-403",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-404",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-405",
"ok": false,
"errors": [
"answer_too_short"
],
"warnings": []
},
{
"id": "FAQ-406",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-407",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-408",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-409",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "faq-410",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
}
],
"codes": {
"title_too_long": 1,
"title_missing": 1,
"answer_too_short": 1,
"pii": 1,
"external_link": 1,
"id_format": 1
}
}
✔ 소문자 id · 합계 10줄 중 4줄 통과 · 0.1s
→ request POST /webhook/my-faq-check
{
"batch": "E-1",
"items": [
{
"id": "FAQ-401",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가",
"keywords": [
"메신저",
"알림"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-402",
"title": "사내 메신저 알림이 오지 않을 때 확인할 것과 다시 설정하는 법가가가가가요",
"keywords": [
"메신저"
],
"answer": "메신저 설정의 '알림' 탭에서 알림을 다시 켭니다."
},
{
"id": "FAQ-403",
"title": " ",
"keywords": [
"사원증"
],
"answer": "사원증 사진은 인사 포털에서 바꿉니다."
},
{
"id": "FAQ-404",
"title": "장비 신청",
"keywords": [
"장비"
],
"answer": "IT 포털에서 신청"
},
{
"id": "FAQ-405",
"title": "장비 반납",
"keywords": [
"반납"
],
"answer": "IT포털에서 신청"
},
{
"id": "FAQ-406",
"title": "야간 장애 연락",
"keywords": [
"야간",
"장애"
],
"answer": "야간 장애는 당직자 01012345678 로 전화합니다."
},
{
"id": "FAQ-407",
"title": "IT 문의 창구",
"keywords": [
"문의",
"창구"
],
"answer": "IT 문의 창구 번호는 02-123-4567 (평일 9~18시) 입니다."
},
{
"id": "FAQ-408",
"title": "도움말 모음",
"keywords": [
"도움말"
],
"answer": "도움말은 https://intra.example.com/help 에 모여 있습니다."
},
{
"id": "FAQ-409",
"title": "보안 교육 자료",
"keywords": [
"보안 교육"
],
"answer": "보안 교육 자료는 http://intra.example.com.evil.io/edu 에서 받습니다."
},
{
"id": "faq-410",
"title": "출장 노트북",
"keywords": [
"출장"
],
"answer": "출장용 노트북은 IT팀에 사흘 전까지 신청합니다."
}
]
}
← response HTTP 200
{
"batch": "E-1",
"total": 10,
"valid": 4,
"invalid": 6,
"with_warnings": 0,
"rows": [
{
"id": "FAQ-401",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-402",
"ok": false,
"errors": [
"title_too_long"
],
"warnings": []
},
{
"id": "FAQ-403",
"ok": false,
"errors": [
"title_missing"
],
"warnings": []
},
{
"id": "FAQ-404",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-405",
"ok": false,
"errors": [
"answer_too_short"
],
"warnings": []
},
{
"id": "FAQ-406",
"ok": false,
"errors": [
"pii"
],
"warnings": []
},
{
"id": "FAQ-407",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-408",
"ok": true,
"errors": [],
"warnings": []
},
{
"id": "FAQ-409",
"ok": false,
"errors": [
"external_link"
],
"warnings": []
},
{
"id": "faq-410",
"ok": false,
"errors": [
"id_format"
],
"warnings": []
}
],
"codes": {
"title_too_long": 1,
"title_missing": 1,
"answer_too_short": 1,
"pii": 1,
"external_link": 1,
"id_format": 1
}
}
Passed 6/6
report: runs/lab01-edges-20261101-180518.json
POST http://localhost:5678/webhook/base-faq-check · 6 case(s) × 1
✘ 제목 40자는 통과, 41자는 너무 김 · 0.0s · rows.1.ok = true (expected false); rows.1.errors has none of ['title_too_long']
✘ 공백만 있는 제목은 빈 값 · 0.1s · rows.2.errors has none of ['title_missing']
✘ 답 10자는 통과, 9자는 너무 짧음 · 0.0s · rows.4.errors has none of ['answer_too_short']
✘ 하이픈 없는 휴대전화도 개인정보, 사무실 번호는 통과 · 0.1s · rows.5.errors has none of ['pii']
✘ 사내 주소는 통과, 사내 주소처럼 보이는 바깥 링크는 막음 · 0.0s · rows.8.errors has none of ['external_link']
✘ 소문자 id · 합계 10줄 중 4줄 통과 · 0.1s · valid = 10 (expected 4); rows.9.errors has none of ['id_format']
Passed 0/6
report: runs/lab01-edges-20261101-180519.json
```
**쉽게 말하면**
- 내 검사: 40자 통과 · 41자 `title_too_long`, 공백만 있는 제목 `title_missing`, 10자 통과 · 9자 `answer_too_short`, `01012345678` `pii` · `02-123-4567` 통과, `intra.example.com` 통과 · `intra.example.com.evil.io` `external_link`, `faq-410` `id_format` — 10줄 중 4줄 통과.
- 기준: 6케이스 모두 ✘ (10줄 모두 통과). 경계에 대한 규칙이 아예 없기 때문입니다.
- 같은 줄도 규칙의 말 한 마디(넘으면 · 공백 · 꼴 · 정확히)에 따라 판정이 갈립니다. 예상한 결과.
### 05 · Lab 01 과제 1 하기 전 → `lab01-challenge` 0/4
실행한 명령:
```bash
uv run tools/flow.py test cases/lab01-challenge.json -v
```
출력 (그대로):
```text
✔ 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
}
```
**쉽게 말하면**
- 예제 2의 검사는 이미 있는 FAQ를 읽지 않아 FAQ-003 · FAQ-112를 통과시키고 통과가 5줄. 넷째 케이스 앞의 `before: POST /faq/add`는 케이스 하나만의 준비(FAQ-201을 먼저 더함)입니다. 예상한 결과.
### 06 · Lab 01 과제 1 · 이미 있는 데이터와 맞춰 보기 → 4/4
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-check.json` · 이 기록에서 push한 같은 역할의 정답 `answers/78-lab01-challenge-existing.json`):
```text
my-work/faq-check.json 에 이미 있는 데이터와 맞춰 보기를 더해 줘.
Is a list? 와 Check rows 사이에 Get FAQ(HTTP GET http://data-api:8800/faq-all) 를 둔다.
이미 있는 id 면 errors 에 exists_id, 이미 있는 제목이면 same_title.
이미 있는 FAQ 가 쓰는 키워드는 errors 가 아니라 warnings 에 keyword_used:<낱말> (대소문자 무시) — 통과는 시킨다.
앞의 규칙은 그대로. 기대 결과 cases/lab01-challenge.json, cases/lab01.json 도.
```
실행한 명령:
```bash
uv run tools/flow.py push answers/78-lab01-challenge-existing.json
uv run tools/flow.py test cases/lab01-challenge.json -v
uv run tools/flow.py test cases/lab01.json
```
출력 (그대로):
```text
! 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
✔ 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
}
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
```
**쉽게 말하면**
- FAQ-003 `exists_id`, FAQ-112 `same_title` + 경고 `keyword_used:vpn`, B-1 통과 3줄(FAQ-101 · 102 · 113), 막 더한 FAQ-201도 `exists_id` (`/faq-all`은 더한 것까지).
- `lab01` 5/5 그대로. 예상한 결과.
### 07 · Lab 01 과제 2 하기 전 → `lab01-challenge2` 1/5
실행한 명령:
```bash
uv run tools/flow.py test cases/lab01-challenge2.json -v
```
출력 (그대로):
```text
✔ 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 · 5 case(s) × 1
✘ B-1 · 고칠 줄 10개, 줄 순서대로 · 0.0s · fix_list.0.row = null (expected 3); fix_list.0.id = null (expected FAQ-103); fix_list.9.id = null (expected FAQ-112)
→ 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.1s · fix_list.0.fix has none of ['제목을 써 주세요']; fix_list.6.fix has none of ['개인 전화번호']; fix_list.8.fix has none of ['이미 있는 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": 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 · message = null (expected B-1: 13줄 중 3줄 통과, 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": 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
}
}
✘ B-2 · 메일 주소 한 줄만 · 0.1s · fix_list.0.id = null (expected FAQ-209); fix_list.0.codes.0 = null (expected pii); message = null (expected B-2: 10줄 중 9줄 통과, 1줄 고칠 것)
→ 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
}
}
✔ 앞의 칸은 그대로 · 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
}
}
Passed 1/5
report: runs/lab01-challenge2-20261101-180534.json
```
**쉽게 말하면**
- `fix_list` · `message`가 없어 `null`. '앞의 칸은 그대로'만 ✔ (이미 있는 칸을 보는 케이스). 예상한 결과.
### 08 · Lab 01 과제 2 · 보낸 사람에게 돌려줄 고침 목록 → 5/5
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-check.json` · 이 기록에서 push한 같은 역할의 정답 `answers/78-lab01-challenge2-fix-list.json`):
```text
my-work/faq-check.json 의 결과에 보낸 사람에게 돌려줄 고침 목록을 더해 줘.
fix_list: 통과하지 못한 줄만 줄 순서대로 {row (1부터), id, codes (그 줄의 errors), fix}.
fix 는 코드마다 AGENTS.md 3절 "고침 목록" 표의 말을 그대로 쓴다 (LLM 으로 만들지 않는다).
message: "<batch>: <total>줄 중 <valid>줄 통과, <invalid>줄 고칠 것".
나쁜 줄을 고쳐 주지는 않는다 — 고치는 것은 사람. 앞의 칸은 그대로. 기대 결과 cases/lab01-challenge2.json, lab01 · lab01-challenge 도.
```
실행한 명령:
```bash
uv run tools/flow.py push answers/78-lab01-challenge2-fix-list.json
uv run tools/flow.py test cases/lab01-challenge2.json -v
uv run tools/flow.py test cases/lab01.json
uv run tools/flow.py test cases/lab01-challenge.json
uv run tools/flow.py test cases/lab01-edges.json
```
출력 (그대로):
```text
! unpublished '78 FAQ check + existing data (answer)' (w99a77a91b9217c9): it used the same webhook path
✔ pushed '78 FAQ check + fix list (answer)' (id weecbf2cc21d6fd2)
POST http://localhost:5678/webhook/my-faq-check
✔ 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 · 5 case(s) × 1
✔ B-1 · 고칠 줄 10개, 줄 순서대로 · 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
},
"fix_list": [
{
"row": 3,
"id": "FAQ-103",
"codes": [
"title_missing"
],
"fix": [
"제목을 써 주세요"
]
},
{
"row": 4,
"id": "FAQ-10",
"codes": [
"id_format"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)"
]
},
{
"row": 5,
"id": "FAQ-105",
"codes": [
"answer_missing"
],
"fix": [
"답을 써 주세요"
]
},
{
"row": 6,
"id": "FAQ-106",
"codes": [
"answer_too_long"
],
"fix": [
"답을 300자 이내로 줄여 주세요"
]
},
{
"row": 7,
"id": "FAQ-102",
"codes": [
"duplicate_in_batch"
],
"fix": [
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 8,
"id": "FAQ-108",
"codes": [
"keywords_missing"
],
"fix": [
"키워드를 하나 이상 넣어 주세요"
]
},
{
"row": 9,
"id": "FAQ-109",
"codes": [
"pii"
],
"fix": [
"개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요"
]
},
{
"row": 10,
"id": "FAQ-110",
"codes": [
"external_link"
],
"fix": [
"바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요"
]
},
{
"row": 11,
"id": "FAQ-003",
"codes": [
"exists_id"
],
"fix": [
"이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요"
]
},
{
"row": 12,
"id": "FAQ-112",
"codes": [
"same_title"
],
"fix": [
"같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요"
]
}
],
"message": "B-1: 13줄 중 3줄 통과, 10줄 고칠 것"
}
✔ 고칠 것은 사람이 읽는 말로 · 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
},
"fix_list": [
{
"row": 3,
"id": "FAQ-103",
"codes": [
"title_missing"
],
"fix": [
"제목을 써 주세요"
]
},
{
"row": 4,
"id": "FAQ-10",
"codes": [
"id_format"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)"
]
},
{
"row": 5,
"id": "FAQ-105",
"codes": [
"answer_missing"
],
"fix": [
"답을 써 주세요"
]
},
{
"row": 6,
"id": "FAQ-106",
"codes": [
"answer_too_long"
],
"fix": [
"답을 300자 이내로 줄여 주세요"
]
},
{
"row": 7,
"id": "FAQ-102",
"codes": [
"duplicate_in_batch"
],
"fix": [
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 8,
"id": "FAQ-108",
"codes": [
"keywords_missing"
],
"fix": [
"키워드를 하나 이상 넣어 주세요"
]
},
{
"row": 9,
"id": "FAQ-109",
"codes": [
"pii"
],
"fix": [
"개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요"
]
},
{
"row": 10,
"id": "FAQ-110",
"codes": [
"external_link"
],
"fix": [
"바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요"
]
},
{
"row": 11,
"id": "FAQ-003",
"codes": [
"exists_id"
],
"fix": [
"이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요"
]
},
{
"row": 12,
"id": "FAQ-112",
"codes": [
"same_title"
],
"fix": [
"같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요"
]
}
],
"message": "B-1: 13줄 중 3줄 통과, 10줄 고칠 것"
}
✔ 한 줄 요약 · 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
},
"fix_list": [
{
"row": 3,
"id": "FAQ-103",
"codes": [
"title_missing"
],
"fix": [
"제목을 써 주세요"
]
},
{
"row": 4,
"id": "FAQ-10",
"codes": [
"id_format"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)"
]
},
{
"row": 5,
"id": "FAQ-105",
"codes": [
"answer_missing"
],
"fix": [
"답을 써 주세요"
]
},
{
"row": 6,
"id": "FAQ-106",
"codes": [
"answer_too_long"
],
"fix": [
"답을 300자 이내로 줄여 주세요"
]
},
{
"row": 7,
"id": "FAQ-102",
"codes": [
"duplicate_in_batch"
],
"fix": [
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 8,
"id": "FAQ-108",
"codes": [
"keywords_missing"
],
"fix": [
"키워드를 하나 이상 넣어 주세요"
]
},
{
"row": 9,
"id": "FAQ-109",
"codes": [
"pii"
],
"fix": [
"개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요"
]
},
{
"row": 10,
"id": "FAQ-110",
"codes": [
"external_link"
],
"fix": [
"바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요"
]
},
{
"row": 11,
"id": "FAQ-003",
"codes": [
"exists_id"
],
"fix": [
"이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요"
]
},
{
"row": 12,
"id": "FAQ-112",
"codes": [
"same_title"
],
"fix": [
"같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요"
]
}
],
"message": "B-1: 13줄 중 3줄 통과, 10줄 고칠 것"
}
✔ B-2 · 메일 주소 한 줄만 · 0.1s
→ 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
},
"fix_list": [
{
"row": 9,
"id": "FAQ-209",
"codes": [
"pii"
],
"fix": [
"개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요"
]
}
],
"message": "B-2: 10줄 중 9줄 통과, 1줄 고칠 것"
}
✔ 앞의 칸은 그대로 · 0.2s
→ 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
},
"fix_list": [
{
"row": 3,
"id": "FAQ-103",
"codes": [
"title_missing"
],
"fix": [
"제목을 써 주세요"
]
},
{
"row": 4,
"id": "FAQ-10",
"codes": [
"id_format"
],
"fix": [
"id를 FAQ-숫자 3자리로 (예: FAQ-104)"
]
},
{
"row": 5,
"id": "FAQ-105",
"codes": [
"answer_missing"
],
"fix": [
"답을 써 주세요"
]
},
{
"row": 6,
"id": "FAQ-106",
"codes": [
"answer_too_long"
],
"fix": [
"답을 300자 이내로 줄여 주세요"
]
},
{
"row": 7,
"id": "FAQ-102",
"codes": [
"duplicate_in_batch"
],
"fix": [
"같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요"
]
},
{
"row": 8,
"id": "FAQ-108",
"codes": [
"keywords_missing"
],
"fix": [
"키워드를 하나 이상 넣어 주세요"
]
},
{
"row": 9,
"id": "FAQ-109",
"codes": [
"pii"
],
"fix": [
"개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요"
]
},
{
"row": 10,
"id": "FAQ-110",
"codes": [
"external_link"
],
"fix": [
"바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요"
]
},
{
"row": 11,
"id": "FAQ-003",
"codes": [
"exists_id"
],
"fix": [
"이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요"
]
},
{
"row": 12,
"id": "FAQ-112",
"codes": [
"same_title"
],
"fix": [
"같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요"
]
}
],
"message": "B-1: 13줄 중 3줄 통과, 10줄 고칠 것"
}
Passed 5/5
report: runs/lab01-challenge2-20261101-180548.json
POST http://localhost:5678/webhook/my-faq-check · 5 case(s) × 1
✔ 빈 값 (제목 · 답 · 키워드) · 0.0s
✔ 형식 (id · 답 길이) · 0.1s
✔ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.0s
✔ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.1s
✔ 목록이 아닌 본문 → 400 · 0.0s
Passed 5/5
report: runs/lab01-20261101-180549.json
✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0}
POST http://localhost:5678/webhook/my-faq-check · 4 case(s) × 1
✔ 이미 있는 id (FAQ-003) · 0.0s
✔ 이미 있는 제목 · 쓰인 키워드는 경고 · 0.1s
✔ 배치 1 · 통과 3건 · 0.0s
✔ before: POST http://localhost:8800/faq/add · HTTP 200 · 0.0s · {"added": "FAQ-201", "size": 7}
✔ 막 더한 FAQ도 기존 데이터 · 0.1s
Passed 4/4
report: runs/lab01-challenge-20261101-180549.json
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 1}
POST http://localhost:5678/webhook/my-faq-check · 6 case(s) × 1
✔ 제목 40자는 통과, 41자는 너무 김 · 0.0s
✔ 공백만 있는 제목은 빈 값 · 0.1s
✔ 답 10자는 통과, 9자는 너무 짧음 · 0.0s
✔ 하이픈 없는 휴대전화도 개인정보, 사무실 번호는 통과 · 0.1s
✔ 사내 주소는 통과, 사내 주소처럼 보이는 바깥 링크는 막음 · 0.0s
✔ 소문자 id · 합계 10줄 중 4줄 통과 · 0.1s
Passed 6/6
report: runs/lab01-edges-20261101-180549.json
```
**쉽게 말하면**
- B-1: 고칠 줄 10개(3째 FAQ-103 ~ 12째 FAQ-112, 줄 순서), 코드마다 3절 표의 말("제목을 써 주세요" · "개인 전화번호 · 메일 주소를 빼고…"), `message` "B-1: 13줄 중 3줄 통과, 10줄 고칠 것". B-2: FAQ-209 하나.
- `lab01` 5/5 · `lab01-challenge` 4/4 · `lab01-edges` 6/6 그대로. 예상한 결과.
## Lab 02 · 품질 게이트 · 막고, 격리하고, 알리기
### 09 · Lab 02 하기 전 → `lab02` 0/6 (기준 가져오기에)
실행한 명령:
```bash
uv run tools/flow.py test cases/lab02.json --webhook base-faq-import -v
uv run tools/flow.py quality
```
출력 (그대로):
```text
✔ 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
}
FAQ: 6 (nothing added)
quarantine: 0
quality log: 0
alerts: 0
```
**쉽게 말하면**
- 기준 가져오기는 B-1을 **13줄 중 12줄 넣었습니다** (FAQ-105만 데이터 API가 '답이 필요하다'며 거절). 개인 전화번호 FAQ-109, 바깥 링크 FAQ-110, 이미 있는 FAQ-003과 같은 id까지. `score` 0.9231은 '잘 들어갔다'처럼 보입니다.
- B-2도 10줄 모두(메일 주소 FAQ-209 · 다른 답 FAQ-210), B-3도 6줄 모두(답 '짧'). 격리함 0. 끝의 `after: faq-reset`이 넣은 16줄을 비워 `quality`는 다시 FAQ 6. 예상한 결과.
### 10 · Lab 02 예제 1 · 배치 게이트와 격리 → `lab02` 5/6
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-import.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab02-example1-import.json`):
```text
my-work/faq-import.json 을 새로 만들어 줘. 웹훅 POST my-faq-import, 이름 "78 FAQ import (my)".
Run check: 규칙을 다시 쓰지 말고 HTTP POST http://localhost:5678/webhook/my-faq-check 로 부른다 (본문 그대로, neverError, timeout 30000).
Batch ok?(IF): invalid / total 이 기준 이하일 때만 받는다. 기준은 본문 max_invalid_ratio, 없으면 0.3. 넘으면 HTTP 422 {error: "batch rejected", batch, invalid_ratio (4자리), max_invalid_ratio, codes}.
Split rows → Store row: 줄마다 ok 면 POST /faq/add, 아니면 POST /quarantine {items: [{batch, row, errors}]} — IF 로 가르지 말고 HTTP 노드 하나에서 주소를 표현식으로.
Summarize: {batch, total, imported, quarantined, rejected: false, score (imported / total, 4자리), max_invalid_ratio, codes, imported_ids, quarantined_ids}. 기대 결과 cases/lab02.json 앞 네 케이스.
```
실행한 명령:
```bash
uv run tools/flow.py push reference/lab02-example1-import.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py quality
```
출력 (그대로):
```text
✔ pushed '78 FAQ import + gate (reference)' (id we19b3911975e55e)
POST http://localhost:5678/webhook/my-faq-import
✔ 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
}
FAQ: 6 (nothing added)
quarantine: 2
B-2 FAQ-209 pii
B-3 FAQ-305 answer_too_short
quality log: 0
alerts: 0
```
**쉽게 말하면**
- B-1 **422** (`invalid_ratio` 0.7692 > 0.3, 아무것도 넣지 않음), B-2 9줄 넣고 FAQ-209 격리(`score` 0.9), B-3 5줄 넣고 FAQ-305 격리.
- FAQ-210 케이스만 ✘ — 규칙을 모두 통과했으니 아직 들어갑니다 (`imported` 9). 예제 2에서 LLM이 막습니다. `quality`: 격리 2. 예상한 결과.
### 11 · Lab 02 예제 2 · LLM 검사 → 6/6
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-import.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab02-example2-import.json`):
```text
my-work/faq-import.json 에 LLM 검사를 더해 줘.
Split rows 뒤에 Only passed(Code) 로 규칙을 통과한 줄만 Judge(AI Agent + OpenAI gpt-4.1-mini, temperature 0) 에게 보낸다.
지시문: 제목과 답만 주고 "답이 제목의 질문에 답하나?" 를 {"match": true|false, "reason"} JSON 으로만.
Decide(Code): Judge 의 i번째 결과를 Only passed 의 i번째 줄과 맞추고, 읽을 수 없으면 match false. match 가 false 면 errors 에 llm_mismatch.
Judge 의 onError 는 continueRegularOutput. 규칙에 걸린 줄은 LLM 에 보내지 않고 그대로 격리. 앞의 칸은 그대로. 기대 결과 cases/lab02.json 전체.
```
실행한 명령:
```bash
uv run tools/flow.py push reference/lab02-example2-import.json
uv run tools/flow.py test cases/lab02.json -v
```
출력 (그대로):
```text
! 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
✔ 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
}
```
**쉽게 말하면**
- B-2: FAQ-210(밝기 · 연차 휴가) `llm_mismatch`로 격리 → 넣음 8 · 격리 2 · `score` 0.8. B-3: FAQ-305는 규칙에서 이미 걸려 LLM에 가지 않음.
- B-2 가져오기가 1.4초 → **9.3초** (규칙을 통과한 9줄 × LLM 한 번). LLM이 막은 줄은 FAQ-210 하나뿐입니다. 예상한 결과.
### 12 · Lab 02 예제 3 · 기준을 바꾸면 → 6/6
실행한 명령:
```bash
uv run tools/flow.py test cases/lab02-gate.json -v
uv run tools/flow.py quality
```
출력 (그대로):
```text
✔ 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
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
```
**쉽게 말하면**
- B-1: 0.3 거절 / 0.8 받음 — 넣음 2 · 격리 11 · `score` 0.1538 (FAQ-113은 LLM이 막음). B-2: 0.05 거절(0.1 > 0.05) / 0.1 받음(같으면 통과). 격리함 13 = 11 + 2.
- 느슨하면 격리함이 넘치고(사람이 11줄을 봄), 엄하면 멀쩡한 9줄도 돌아갑니다. 기준은 맞고 틀리는 값이 아니라 고르는 값입니다.
- 실험 순서: B-1을 받은 뒤 FAQ를 비우고(③), 엄한 기준을 먼저 보냅니다. 그러지 않으면 앞 배치가 넣은 FAQ 때문에 B-2가 `same_title` · `exists_id`로 나빠집니다 (수업 준비 때 실제로 겪음). 예상한 결과.
### 13 · Lab 02 과제 1 하기 전 → `lab02-challenge` 2/5
실행한 명령:
```bash
uv run tools/flow.py test cases/lab02-challenge.json -v
```
출력 (그대로):
```text
✔ 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
}
```
**쉽게 말하면**
- 가져오기 두 케이스(422 · `score` 0.8)는 예제 2로도 됩니다. 기록 · 알림을 보는 셋은 `count` 0. 예상한 결과.
### 14 · Lab 02 과제 1 · 품질 기록과 알림 → 5/5
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-import.json` · 이 기록에서 push한 같은 역할의 정답 `answers/78-lab02-challenge-log-alert.json`):
```text
my-work/faq-import.json 에 품질 기록과 알림을 더해 줘.
배치마다 POST http://data-api:8800/quality-log 한 줄 {batch, dataset: "faq", total, valid: imported, quarantined, rejected, score, codes} — 거절된 배치도 rejected: true · score 0 으로.
거절이면 POST /alerts {workflow: "faq-import", severity: "high", reason: "batch_rejected", batch, message}.
받은 배치의 score 가 0.9 미만이면 reason "low_quality" 알림.
알림을 보내도 응답은 같은 결과 (Summarize 의 결과를 다시). 기대 결과 cases/lab02-challenge.json, cases/lab02.json 도.
```
실행한 명령:
```bash
uv run tools/flow.py push answers/78-lab02-challenge-log-alert.json
uv run tools/flow.py test cases/lab02-challenge.json -v
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py quality
```
출력 (그대로):
```text
! 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
✔ 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
}
✔ 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}
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
```
**쉽게 말하면**
- 기록: B-1 `rejected: true` · `score` 0, B-2 `valid` 8 · `quarantined` 2 · 0.8. 알림: `batch_rejected` · `low_quality`.
- 응답은 그대로(`Result again`), `lab02` 6/6. `quality`의 기록 4 · 알림 4는 뒤이어 돌린 `lab02`가 남긴 네 배치입니다. 예상한 결과.
### 15 · Lab 02 과제 2 하기 전 → `lab02-challenge2` 0/3
실행한 명령:
```bash
uv run tools/flow.py test cases/lab02-challenge2.json -v
```
출력 (그대로):
```text
✔ 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
```
**쉽게 말하면**
- 가져오기는 되지만 `llm_calls` 칸이 없어 `null`. 예상한 결과.
### 16 · Lab 02 과제 2 · LLM을 몇 번 부르나 · 늘 같은가 → 3/3 × 세 번
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-import.json` · 이 기록에서 push한 같은 역할의 정답 `answers/78-lab02-challenge2-llm-calls.json`):
```text
my-work/faq-import.json 의 결과에 llm_calls 를 더해 줘.
llm_calls = LLM 검사에 보낸 줄 수 (Only passed 의 줄 수). 거절된 배치(422)의 본문에도 llm_calls 0.
LLM 은 규칙을 통과한 줄에만 부른다는 것을 숫자로 보이게 하는 칸이다. 다른 칸은 그대로.
기대 결과 cases/lab02-challenge2.json 을 세 번 돌려 늘 같은지, lab02 · lab02-challenge 도.
```
실행한 명령:
```bash
uv run tools/flow.py push answers/78-lab02-challenge2-llm-calls.json
uv run tools/flow.py test cases/lab02-challenge2.json -v
uv run tools/flow.py test cases/lab02-challenge2.json
uv run tools/flow.py test cases/lab02-challenge2.json
uv run tools/flow.py test cases/lab02-challenge2.json --no-before --repeat 2
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py test cases/lab02-challenge.json
```
출력 (그대로):
```text
! 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
✔ 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
✔ 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
✔ 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
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
✔ 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}
✔ 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}
```
**쉽게 말하면**
- `llm_calls`: B-1(거절) 0 · B-2 9 · B-3 5 = 규칙을 통과한 줄 수. 세 번 모두 FAQ-210만 막고 같은 수를 넣었습니다 (B-2 9.4 · 8.1 · 7.6초 — 시간은 매번 다름).
- `--no-before --repeat 2`는 **2/6**: 앞 실행이 넣은 FAQ가 남아 B-2 · B-3이 `exists_id`로 거절되고 LLM은 0번. 판정이 흔들린 것이 아니라 데이터가 바뀐 것 — 그래서 '늘 같은가'는 처음 상태에서 세 번 돌립니다.
- `lab02` 6/6 · `lab02-challenge` 5/5 그대로. 예상한 결과.
## Lab 03 · 품질 관리 · 스키마로 넓히고, 고쳐서 다시, 추세로 보기
### 17 · Lab 03 예제 1 하기 전 → FAQ 검사 400 · `lab03` 0/4
실행한 명령:
```bash
uv run tools/flow.py call my-faq-check requests/employees-new.json
uv run tools/flow.py test cases/lab03.json -v
```
출력 (그대로):
```text
HTTP 400 · 0.0s
{
"error": "items must be a list"
}
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
```
**쉽게 말하면**
- 직원 배치는 `items`가 아니라 `csv` 글자라 FAQ 검사가 400 (`items must be a list`). `my-check`는 아직 없어 404. 예상한 결과.
### 18 · Lab 03 예제 1 · 스키마 검사 → 4/4
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/check.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab03-example1-schema-check.json`):
```text
my-work/check.json 을 새로 만들어 줘. 웹훅 POST my-check, 이름 "78 Schema check (my)".
Get schema: HTTP GET http://data-api:8800/schemas/<본문 schema> (neverError). 스키마가 없으면(fields 가 없음) HTTP 400 {error: "unknown schema", schema}.
Check rows(Code): 본문 csv 가 있으면 첫 줄을 열 이름으로 쉼표로 나눠 줄로 바꾸고, items 가 있으면 그대로.
필드 규칙은 코드에 쓰지 말고 스키마의 fields 를 읽어 AGENTS.md 3절 타입 표대로 (required · string · enum · email · phone · date · pattern · list), 키는 배치 안 중복.
결과 {schema, batch, total, valid, invalid, rows: [{row (1부터), key, ok, errors}], codes}. 기대 결과 cases/lab03.json.
```
실행한 명령:
```bash
uv run tools/flow.py push reference/lab03-example1-schema-check.json
uv run tools/flow.py test cases/lab03.json -v
```
출력 (그대로):
```text
✔ pushed '78 Schema check (reference)' (id w6b17d7c76908a6a)
POST http://localhost:5678/webhook/my-check
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
```
**쉽게 말하면**
- 직원 CSV 9줄 중 4줄: `minsu.kang@example` 메일 형식, `마케팅팀` 없는 팀, `2026-13-01` 없는 날짜, 이름 빈칸, 같은 메일 두 번. 전화가 비었거나(2째) 사무실 번호(7째)는 통과.
- 같은 워크플로우로 `schema: faq` B-3 → 6줄 중 5줄, FAQ-305 `too_short:answer` (스키마의 `min: 10`). 모르는 스키마 400. 예상한 결과.
### 19 · Lab 03 예제 2 하기 전 → `lab03-fix` 3/4
실행한 명령:
```bash
uv run tools/flow.py test cases/lab03-fix.json -v
uv run tools/flow.py quality
```
출력 (그대로):
```text
✔ 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
}
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
```
**쉽게 말하면**
- `before`가 B-2를 가져와 FAQ-209 · 210을 격리, 고친 배치 B-2-fix는 두 줄 모두 넣음(`score` 1 · FAQ 16). 그런데 격리함에 **옛 두 줄이 그대로** (`size` 2). 예상한 결과.
### 20 · Lab 03 예제 2 · 고쳐서 다시 넣기 → 4/4
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/faq-import.json` · 이 기록에서 push한 같은 역할의 정답 `reference/lab03-example2-import.json`):
```text
my-work/faq-import.json 에서 고쳐서 다시 넣은 줄을 격리함에서 빼 줘.
Summarize 뒤에 Release fixed: POST http://data-api:8800/quarantine/remove {ids: imported_ids}.
넣은 줄만 뺀다 — 이번에도 걸린 줄은 격리함에 남는다. 품질 기록 · 알림은 그대로.
응답은 Summarize 의 결과를 다시 (Release fixed 의 응답이 아님). 기대 결과 cases/lab03-fix.json.
```
실행한 명령:
```bash
uv run tools/flow.py push reference/lab03-example2-import.json
uv run tools/flow.py test cases/lab03-fix.json -v
uv run tools/flow.py quality
```
출력 (그대로):
```text
! 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
✔ 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
}
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
```
**쉽게 말하면**
- `Release fixed`가 넣은 id(FAQ-209 · 210)를 격리함에서 뺐습니다 → 격리 0. 기록은 B-2 0.8 → B-2-fix 1.
- 격리 → 사람이 고침 → 다시 넣음 → 격리함에서 뺌, 되돌림 고리가 한 바퀴. 예상한 결과.
### 21 · Lab 03 예제 3 · 스키마만 바꾸면 → 5/5 (워크플로우 그대로)
실행한 명령:
```bash
uv run tools/flow.py call my-faq-check requests/tickets-new.json
uv run tools/flow.py test cases/lab03-schema.json -v
```
출력 (그대로):
```text
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줄 고칠 것"
}
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
```
**쉽게 말하면**
- 같은 접수 건 9줄을 FAQ 검사에 보내면 0줄 통과 (칸이 달라 `id_format` · `keywords_missing` · `answer_missing` 9, 빈 id 중복 8).
- `my-check`(고치지 않음)는 `ticket` 스키마로 2줄 통과, 줄마다 다른 이유 일곱: `T-12` 형식 · `urgent` · 2월 30일 · 3자 제목 · `010-12-3456` · 중복 · 요청자 빈칸. 선택 칸 `contact`가 없는 9째 줄은 통과.
- 데이터 API의 스키마 한 장으로 새 데이터셋을 검사했습니다. 직원 CSV 4/9 그대로. 예상한 결과.
### 22 · Lab 03 과제 1 하기 전 → `lab03-challenge` 0/4
실행한 명령:
```bash
uv run tools/flow.py test cases/lab03-challenge.json -v
```
출력 (그대로):
```text
✔ 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
}
```
**쉽게 말하면**
- `before`가 B-1(일부러 거절, `!` allow_fail) · B-2 · B-3 · B-2-fix를 가져와 기록 네 줄은 생겼지만, `my-quality-report`가 없어 404. 예상한 결과.
### 23 · Lab 03 과제 1 · 품질 보고서 → 4/4
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/quality-report.json` · 이 기록에서 push한 같은 역할의 정답 `answers/78-lab03-challenge-report.json`):
```text
my-work/quality-report.json 을 새로 만들어 줘. 웹훅 POST my-quality-report, 이름 "78 Quality report (my)".
Get log: HTTP GET http://data-api:8800/quality-log. 본문에 dataset 이 있으면 그 데이터셋만.
Make report(Code) 하나에서 AGENTS.md 3절 "품질 보고서" 정의대로 count · batches · avg_score (소수 4자리, 0건이면 0) · worst_batch (같으면 먼저 나온 것) · rejected_batches.
top_codes: 모든 기록의 codes 를 더해 많은 순, 같으면 코드 글자순. trend: 마지막 score − 첫 score 가 0.05 넘으면 좋아짐, −0.05 미만이면 나빠짐, 아니면 그대로.
LLM 은 쓰지 않는다. 기대 결과 cases/lab03-challenge.json.
```
실행한 명령:
```bash
uv run tools/flow.py push answers/78-lab03-challenge-report.json
uv run tools/flow.py test cases/lab03-challenge.json -v
uv run tools/flow.py call my-quality-report '{}'
```
출력 (그대로):
```text
✔ pushed '78 Quality report (answer)' (id wa6b87a0a6957e93)
POST http://localhost:5678/webhook/my-quality-report
✔ 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
}
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": "좋아짐"
}
```
**쉽게 말하면**
- 배치 4 · 평균 **0.6583** (거절 0 포함, 빼면 0.8778) · 가장 나쁜 B-1 · 거절 1 · 가장 흔한 문제 `pii` 2 (FAQ-109 전화 + FAQ-209 메일) · `좋아짐`.
- `trend`는 13줄 거절(0)과 고친 2줄(1)을 비교한 것 — 숫자는 정의대로지만 무엇을 비교했는지 함께 말해야 합니다. `employee`로 거르면 0건 · 평균 0. 예상한 결과.
### 24 · Lab 03 과제 2 하기 전 → `lab03-challenge2` 0/4
실행한 명령:
```bash
uv run tools/flow.py test cases/lab03-challenge2.json -v
```
출력 (그대로):
```text
✔ 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
```
**쉽게 말하면**
- 검사 두 번(직원 · 접수 건)은 기록을 남기지 않아 기록이 B-3 한 줄뿐(`count` 1). `by_dataset` · `worst_dataset` 없음. 예상한 결과.
### 25 · Lab 03 과제 2 · 데이터셋별 품질 → 4/4
codex에 입력하는 프롬프트 (터미널 ①, 학생이 만들 파일 `my-work/check.json · my-work/quality-report.json` · 이 기록에서 push한 같은 역할의 정답 `answers/78-lab03-challenge2-check-log.json · answers/78-lab03-challenge2-report.json`):
```text
두 워크플로우를 고쳐 줘.
① my-work/check.json: 검사가 끝나면 POST http://data-api:8800/quality-log 한 줄 {batch, dataset: schema, total, valid, quarantined: invalid, rejected: false, score: valid / total (4자리), codes}. 응답은 검사 결과 그대로.
② my-work/quality-report.json: by_dataset — 모든 기록을 dataset 마다 {count, avg_score, worst_batch, top_code} (처음 나온 순서), worst_dataset — avg_score 가 가장 낮은 데이터셋.
본문의 dataset 거르기와 앞의 칸은 그대로. 기대 결과 cases/lab03-challenge2.json, lab03 · lab03-challenge 도.
```
실행한 명령:
```bash
uv run tools/flow.py push answers/78-lab03-challenge2-check-log.json answers/78-lab03-challenge2-report.json
uv run tools/flow.py test cases/lab03-challenge2.json -v
uv run tools/flow.py test cases/lab03.json
uv run tools/flow.py test cases/lab03-challenge.json
uv run tools/flow.py quality
uv run tools/flow.py ops --reset
uv run tools/flow.py doctor
```
출력 (그대로):
```text
! 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
✔ 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
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
✔ 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}
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
✔ live run logs, added FAQ, quarantine, quality log, created tickets, DLQ and alerts emptied, fault switch all ok
! 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
```
**쉽게 말하면**
- 기록 3줄: employee 0.4444 · ticket **0.2222** · faq 0.8333 → `worst_dataset` ticket. 전체 평균 0.5는 '절반쯤 괜찮다'지만 나누면 접수 건이 손볼 곳.
- 전체 `trend`(좋아짐)는 직원 → FAQ를 비교한 것이라 뜻이 없습니다 — 추세는 데이터셋 안에서. `lab03` · `lab03-challenge` 4/4 그대로.
- 마지막 `ops --reset`으로 더한 FAQ · 격리함 · 기록 · 알림을 비우고 `doctor`. 예상한 결과.
## 다시 돌려 보기
강사 노트북에서 같은 순서를 다시 돌리려면 (강사 저장소 `~/work/AIO-01D4/Last3Mon/78`, 정답 파일이 있는 곳):
```bash
cd ~/work/AIO-01D4/Last3Mon/78
uv run tools/flow.py ops --reset
uv run tools/flow.py push reference/baseline-faq-check.json reference/baseline-faq-import.json reference/lab01-example2-check.json
uv run tools/flow.py test cases/lab01-edges.json -v && uv run tools/flow.py test cases/lab01-edges.json --webhook base-faq-check
uv run tools/flow.py push answers/78-lab01-challenge2-fix-list.json reference/lab02-example2-import.json
uv run tools/flow.py test cases/lab02.json --webhook base-faq-import && uv run tools/flow.py test cases/lab02-gate.json -v
uv run tools/flow.py push reference/lab03-example2-import.json && uv run tools/flow.py test cases/lab02-challenge2.json -v
uv run tools/flow.py push answers/78-lab03-challenge2-check-log.json answers/78-lab03-challenge2-report.json
uv run tools/flow.py test cases/lab03-schema.json -v && uv run tools/flow.py test cases/lab03-challenge.json && uv run tools/flow.py test cases/lab03-challenge2.json -v
uv run tools/flow.py ops --reset && uv run tools/flow.py doctor
```
- 결과의 ✔ · ✘(판정)는 같게 나와야 합니다. 규칙 판정은 늘 같고, 시간과 LLM 판정은 다를 수 있습니다 (FAQ-210 · FAQ-113이 아닌 줄을 LLM이 막으면 그 줄의 `reason`을 봅니다).