diff --git a/Last3Mon/78/LAB-01-A-QUALITY-RULES.md b/Last3Mon/78/LAB-01-A-QUALITY-RULES.md new file mode 100644 index 0000000..99593e4 --- /dev/null +++ b/Last3Mon/78/LAB-01-A-QUALITY-RULES.md @@ -0,0 +1,113 @@ +# Lab 01 · 품질 규칙 · 눈으로 검사하고, 규칙으로 바꾸기 + +> **강사용 정답본** · 학생 저장소에는 수업이 끝난 뒤 올립니다. 학생용 문제는 [LAB-01-Q-QUALITY-RULES.md](LAB-01-Q-QUALITY-RULES.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 2026-11-01에 1회씩 실행한 값입니다 (강사 Mac). 판정은 규칙대로라 누가 돌려도 같습니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md). + +## 예제 1 · 결과 (에이전트 없이, `answers/01`) + +| 줄 | id | 내 눈 | 기준 검사 | 규칙 | +| --- | --- | --- | --- | --- | +| 3 · 5 · 8 | FAQ-103 · 105 · 108 | 나쁨 | ✔ 잡음 | `title_missing` · `answer_missing` · `keywords_missing` | +| 4 | FAQ-10 | 나쁨 | **놓침** | `id_format` | +| 6 | FAQ-106 | 나쁨 | **놓침** | `answer_too_long` | +| 7 | FAQ-102 (둘째) | 나쁨 | **놓침** | `duplicate_in_batch` | +| 9 | FAQ-109 | 나쁨 | **놓침** | `pii` (`010-1234-5678`) | +| 10 | FAQ-110 | 나쁨 | **놓침** | `external_link` (`bit.ly`) | +| 11 · 12 | FAQ-003 · 112 | 이미 있는 FAQ를 봐야 | 통과 | 과제 1 | +| 13 | FAQ-113 | 뜻이 다름 | 통과 | Lab 02 예제 2 (LLM) | + +- 기준: 13줄 중 **10줄 통과**(빈 값 셋만). 눈: 분명히 나쁜 8줄 + 판단 보류 3줄 → 좋은 줄 2줄. +- 풀이 때: "기준이 틀렸나?" → 아니오, **규칙이 셋뿐**. 가장 위험한 놓침은 FAQ-109(개인 전화번호)와 FAQ-110(단축 링크) — 73일차 보안과 이어서. + +## 예제 2 · 요청문 예 + +```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. +``` + +**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`) + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `my-faq-check` | AGENTS.md 3절 계약 · 테스트 파일의 `webhook` | +| ② | `400` | 목록이 아닌 본문은 줄을 셀 수 없음 — 기준 검사는 여기서 500으로 멈춤 | +| ③ | 앞뒤 공백을 뺀 뒤 | 공백 세 칸짜리 제목이 '있는 제목'으로 통과하지 않게 (예제 3의 경계) | +| ④ | 정확히 intra.example.com | `intra.example.com.evil.io`처럼 앞만 같은 바깥 주소를 막음 | + +정답: [reference/lab01-example2-check.json](reference/lab01-example2-check.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/02`, 기준에 같은 테스트) | **1/5** · 빈 값만 ✔, 형식 · 중복 · 개인정보 · 링크 없음, B-2 `valid` 10, 목록이 아닌 본문 **500** | +| 고친 뒤 (`answers/03`) | **5/5** · B-1 13줄 중 5줄 통과 · 8줄 나쁨 (눈과 같은 8줄), 목록이 아닌 본문 400, 0.1초 | + +## 예제 3 · 결과 (에이전트 없이, `answers/04`) + +| 경계 | 내 검사 | 기준 | 기준이 틀린 까닭 | +| --- | --- | --- | --- | +| 40자 · 41자 | ✔ 통과 · `title_too_long` | ✘ 둘 다 통과 | 길이 규칙 없음 | +| 공백만 | ✔ `title_missing` | ✘ 통과 | `!r.title` — 공백은 "있음" | +| 10자 · 9자 | ✔ 통과 · `answer_too_short` | ✘ 둘 다 통과 | 길이 규칙 없음 | +| `01012345678` · `02-123-4567` | ✔ `pii` · 통과 | ✘ 둘 다 통과 | 전화 규칙 없음 | +| `intra.example.com/help` · `intra.example.com.evil.io` | ✔ 통과 · `external_link` | ✘ 둘 다 통과 | 링크 규칙 없음 | +| `faq-410` | ✔ `id_format` | ✘ 통과 | 형식 규칙 없음 | + +- 내 검사 **6/6**, 기준 **0/6**. 강사의 첫 판(10/18 판 reference)은 `evil.io`를 통과시켰음 — `(?!intra\.example\.com)`만 보고 호스트가 끝나는지는 안 봄. 지금 판은 `(?!intra\.example\.com(?![\w.-]))`. 학생 판이 FAQ-409에서 ✘라면 좋은 토론거리. + +## 과제 1 · 요청문 예 + +```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 도. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `faq-all` | 처음 6개 + 오늘 더한 FAQ 전체 | +| ② | `same_title` | 제목이 같으면 검색에서 두 답이 겹침 | +| ③ | `warnings` | 키워드가 겹치는 것은 흔하고 나쁘지 않음 — 알려만 줌 | +| ④ | 그대로 | 같은 Code에 더함. `lab01` 5/5가 그대로여야 함 | + +정답: [answers/78-lab01-challenge-existing.json](answers/78-lab01-challenge-existing.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/05`) | **0/4** · `exists_id` · `same_title` 없음, 통과 5 | +| 고친 뒤 (`answers/06`) | **4/4** · FAQ-003 `exists_id`, FAQ-112 `same_title` + 경고 `keyword_used:vpn`, 통과 3, 막 더한 FAQ-201도 `exists_id`. `lab01` 5/5 | + +## 과제 2 · 요청문 예 + +```text +my-work/faq-check.json 의 결과에 보낸 사람에게 돌려줄 고침 목록을 더해 줘. +fix_list: 통과하지 못한 줄만 줄 순서대로 {row (1부터), id, codes (그 줄의 errors), fix}. +fix 는 코드마다 AGENTS.md 3절 "고침 목록" 표의 말을 그대로 쓴다 (LLM 으로 만들지 않는다). +message: ": 줄 중 줄 통과, 줄 고칠 것". +나쁜 줄을 고쳐 주지는 않는다 — 고치는 것은 사람. 앞의 칸은 그대로. 기대 결과 cases/lab01-challenge2.json, lab01 · lab01-challenge 도. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | 줄 순서대로 | 보낸 사람이 자기 파일의 몇째 줄인지 바로 찾게 (`row`는 1부터) | +| ② | 그대로 | 같은 코드에 늘 같은 말. LLM으로 만들면 매번 말이 달라짐 | +| ③ | 고칠 것 | 테스트가 `message`를 글자 그대로 봄 | +| ④ | 사람 | 워크플로우가 고쳐 넣으면 무엇이 틀렸는지 아무도 모름 (README 원칙) | + +정답: [answers/78-lab01-challenge2-fix-list.json](answers/78-lab01-challenge2-fix-list.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/07`) | **1/5** · `fix_list` · `message` 없음 ("앞의 칸은 그대로"만 ✔) | +| 고친 뒤 (`answers/08`) | **5/5** · B-1 고칠 줄 10 (3째 FAQ-103 ~ 12째 FAQ-112), `B-1: 13줄 중 3줄 통과, 10줄 고칠 것`, B-2 FAQ-209 하나. `lab01` 5/5 · `lab01-challenge` 4/4 · `lab01-edges` 6/6 | + +- 풀이 때: "고침 말을 LLM으로 만들면?" — 같은 코드에 날마다 다른 말, 돈, 그리고 지어낸 조언. 표 하나면 충분. diff --git a/Last3Mon/78/LAB-02-A-QUALITY-GATE.md b/Last3Mon/78/LAB-02-A-QUALITY-GATE.md new file mode 100644 index 0000000..ad96db8 --- /dev/null +++ b/Last3Mon/78/LAB-02-A-QUALITY-GATE.md @@ -0,0 +1,125 @@ +# Lab 02 · 품질 게이트 · 막고, 격리하고, 알리기 + +> **강사용 정답본** · 학생 저장소에는 수업이 끝난 뒤 올립니다. 학생용 문제는 [LAB-02-Q-QUALITY-GATE.md](LAB-02-Q-QUALITY-GATE.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 2026-11-01 정답 워크플로우 1회 실행 값입니다 (LLM `gpt-4.1-mini` · temperature 0, 시간은 그때그때 다름). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md). + +## 예제 1 · 요청문 예 + +```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 앞 네 케이스. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `my-faq-check` | 규칙을 다시 쓰지 않고 Lab 01의 검사를 부름 — 규칙이 한 벌 | +| ② | `0.3` | AGENTS.md 3절 기본 기준. 예제 3에서 바꿔 봄 | +| ③ | `422` | 받았지만 처리할 수 없는 내용 (400은 모양이 틀린 것) | +| ④ | 하나 | IF로 갈랐다가 Merge로 합치면 LLM을 더한 뒤 격리 수가 틀림 (AGENTS.md 5절) | + +정답: [reference/lab02-example1-import.json](reference/lab02-example1-import.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/09`, 기준에 같은 테스트) | **0/6** · B-1 **12줄 넣음**(FAQ-105만 API가 막음, FAQ-109 전화 · FAQ-110 링크 · FAQ-003 두 벌), B-2 10줄 (메일 주소 포함), 격리 0 | +| 고친 뒤 (`answers/10`) | **5/6** · B-1 422 (`invalid_ratio` 0.7692), B-2 9줄 · FAQ-209 격리 · `score` 0.9, B-3 FAQ-305 격리. FAQ-210만 ✘ (예제 2) | + +## 예제 2 · 요청문 예 + +```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 전체. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | 통과한 | 규칙에 걸린 줄은 이미 격리 — LLM 비용을 쓸 까닭이 없음 (75일차) | +| ② | `false` | 판정을 읽을 수 없으면 넣지 않음 (fail-closed, 73일차) | +| ③ | `llm_mismatch` | AGENTS.md 3절 코드. 격리함에서 이유가 보임 | +| ④ | `continueRegularOutput` | LLM 오류여도 멈추지 않고 그 줄을 격리로 | + +정답: [reference/lab02-example2-import.json](reference/lab02-example2-import.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 | **5/6** (예제 1 그대로) | +| 고친 뒤 (`answers/11`) | **6/6** · B-2 8줄 · FAQ-209(`pii`) · FAQ-210(`llm_mismatch`) 격리 · `score` 0.8, B-3 5줄. B-2 1.4초 → **9.3초** | + +- LLM이 막은 줄은 FAQ-210 **하나**. 풀이 때: "드문 한 줄을 위해 줄마다 1초" — 가져오기라 괜찮지만 헬프데스크 답이라면? + +## 예제 3 · 결과 (에이전트 없이, `answers/12`) + +| 실험 | 기준 | 결과 | +| --- | --- | --- | +| B-1 | 0.3 | 422 · 0.7692 | +| B-1 | 0.8 | 받음 · 넣음 **2** · 격리 **11** · `score` 0.1538 (FAQ-113은 LLM이 막음) | +| FAQ만 비움 | — | `cleared` 2 | +| B-2 | 0.05 | 422 · 0.1 | +| B-2 | 0.1 | 받음 (딱 같음) · 넣음 8 · 격리 2 | +| 격리함 | — | 13 | + +- **6/6.** 수업 준비 때 실험 순서 때문에 두 번 틀렸음: ① B-1을 받은 뒤 B-2의 FAQ-201이 `same_title` → FAQ 비우기를 넣음, ② B-2를 넣은 뒤 0.05를 보내니 9줄 `exists_id`로 0.9 → 0.05를 먼저. 학생에게도 "앞 배치가 다음 판정을 바꾼다"로. +- 강사 결론: 0.3 유지. 토론 질문: "B-2를 한 줄 때문에 돌려보내는 팀과, B-1을 받아 11줄을 격리하는 팀 중 누가 더 일이 많은가?" + +## 과제 1 · 요청문 예 + +```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 도. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `rejected: true · score 0` | 거절도 기록해야 보고서에 '나쁜 배치'가 보임 | +| ② | `batch_rejected` | AGENTS.md 3절 알림 이유. 테스트가 이 글자를 찾음 | +| ③ | `0.9` | 열 줄 중 한 줄 넘게 격리되면 사람이 볼 일 | +| ④ | 같은 결과 | 알림은 곁가지. 부른 쪽이 받는 결과는 바뀌지 않아야 함 | + +정답: [answers/78-lab02-challenge-log-alert.json](answers/78-lab02-challenge-log-alert.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/13`) | **2/5** · 가져오기 둘만 ✔, 기록 · 알림 0 | +| 고친 뒤 (`answers/14`) | **5/5** · 기록 B-1(거절 · 0) · B-2(0.8, 8 · 2), 알림 `batch_rejected` · `low_quality`. `lab02` 6/6 | + +## 과제 2 · 요청문 예 + +```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 도. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | 수 | `$('Only passed').all().length` | +| ② | `0` | 거절된 배치는 LLM 앞에서 끝남 — 비용 0을 숫자로 | +| ③ | 통과한 | B-2는 10줄 중 9줄, B-3은 6줄 중 5줄만 | +| ④ | 세 번 | LLM 판정이 늘 같은지 (`--repeat`는 같은 배치를 다시 보내 `exists_id`로 막힘 — 실행 결과 참고) | + +정답: [answers/78-lab02-challenge2-llm-calls.json](answers/78-lab02-challenge2-llm-calls.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/15`) | **0/3** · `llm_calls` 없음 | +| 고친 뒤 (`answers/16`) | **3/3 × 세 번** · 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** (B-2 · B-3 거절 — `exists_id`). `lab02` 6/6 · `lab02-challenge` 5/5 | + +- 풀이 때: `--repeat`의 ✘는 판정이 흔들린 것이 아니라 데이터가 바뀐 것. "같은 배치를 두 번 보내도 FAQ가 두 벌 생기지 않는다"는 덤. diff --git a/Last3Mon/78/LAB-03-A-QUALITY-MANAGEMENT.md b/Last3Mon/78/LAB-03-A-QUALITY-MANAGEMENT.md new file mode 100644 index 0000000..ef1752e --- /dev/null +++ b/Last3Mon/78/LAB-03-A-QUALITY-MANAGEMENT.md @@ -0,0 +1,128 @@ +# Lab 03 · 품질 관리 · 스키마로 넓히고, 고쳐서 다시, 추세로 보기 + +> **강사용 정답본** · 학생 저장소에는 수업이 끝난 뒤 올립니다. 학생용 문제는 [LAB-03-Q-QUALITY-MANAGEMENT.md](LAB-03-Q-QUALITY-MANAGEMENT.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 2026-11-01 정답 워크플로우 1회 실행 값입니다. 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md). + +## 예제 1 · 요청문 예 + +```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. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `400` | 모르는 스키마는 검사할 수 없음 | +| ② | 열 이름 | `name,dept,email,phone,joined` | +| ③ | `fields` | 규칙을 데이터(스키마)로 두면 새 데이터셋에 워크플로우를 고치지 않음 (예제 3) | +| ④ | 1부터 | CSV의 열 이름 줄을 빼고 사람이 세는 번호 | + +정답: [reference/lab03-example1-schema-check.json](reference/lab03-example1-schema-check.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/17`) | FAQ 검사에 직원 CSV → **400** (`items must be a list`), `lab03` **0/4** (404) | +| 고친 뒤 (`answers/18`) | **4/4** · 직원 9줄 중 4줄 (메일 · 팀 · 13월 · 이름 없음 · 중복), FAQ 스키마로 B-3 6줄 중 5줄, 모르는 스키마 400 | + +**스키마로 하는 것 · 못 하는 것 (`QUALITY.md` 8절 예)** + +| 규칙 | 스키마로 | 따로 | +| --- | --- | --- | +| 필수 · 길이 · 형식 · 목록 · 중복 | ✔ | | +| 개인정보 (답 안의 전화 · 메일) | | Code (Lab 01) | +| 이미 있는 데이터와 겹침 | | Code + 데이터 API (Lab 01 과제 1) | +| 답이 질문에 맞나 | | LLM (Lab 02 예제 2) | + +## 예제 2 · 요청문 예 + +```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. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `imported_ids` | 이번에 넣은 id | +| ② | 남는다 | 고쳤는데 또 틀린 줄은 다시 사람에게 | +| ③ | 그대로 | Lab 02 과제 1의 기록 · 알림 | +| ④ | 다시 | HTTP 노드의 출력은 API 응답 — 결과는 Summarize에서 다시 꺼냄 | + +정답: [reference/lab03-example2-import.json](reference/lab03-example2-import.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/19`) | **3/4** · 넣기는 됨(`score` 1 · FAQ 16), 격리함 **2** 그대로 | +| 고친 뒤 (`answers/20`) | **4/4** · 격리함 0, 기록 0.8 → 1 | + +## 예제 3 · 결과 (에이전트 없이, `answers/21`) + +| | FAQ 검사 | 스키마 검사 (고치지 않음) | +| --- | --- | --- | +| 접수 건 9줄 | 0줄 통과 (`id_format` · `keywords_missing` · `answer_missing` 9, 빈 id 중복 8) | **2줄 통과**, 줄마다 다른 이유 7가지 | +| 테스트 | — | **5/5** (직원 CSV 4/9 그대로) | + +- 풀이 때: "스키마 파일 한 장으로 새 데이터셋" — 그래서 스키마 변경은 **규칙 변경**이라 리뷰가 필요 (74일차 관문). + +## 과제 1 · 요청문 예 + +```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. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `4` | 0.65833…을 0.6583으로 | +| ② | 먼저 나온 것 | 같은 점수면 순서로 하나를 정함 — 늘 같은 답 | +| ③ | 글자순 | 같은 수면 코드 이름으로 — 늘 같은 순서 | +| ④ | 그대로 | 1건뿐이거나 차이가 작으면 | + +정답: [answers/78-lab03-challenge-report.json](answers/78-lab03-challenge-report.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/22`) | **0/4** · 404 (기록 4줄은 생김, B-1 `!` allow_fail) | +| 고친 뒤 (`answers/23`) | **4/4** · 4 · 0.6583 · B-1 · 거절 1, `pii` 2, `좋아짐` | + +- 풀이 때: 거절을 빼면 0.8778. `trend`가 "13줄 거절 → 고친 2줄"을 비교한 것이라는 함정. + +## 과제 2 · 요청문 예 + +```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 도. +``` + +**빈칸 답** + +| 빈칸 | 답 | 왜 | +| --- | --- | --- | +| ① | `schema` | 직원 · 티켓 · FAQ가 데이터셋 이름 | +| ② | 처음 나온 | 보고서가 늘 같은 순서 | +| ③ | 낮은 | 가장 손볼 데이터셋 | +| ④ | 그대로 | `lab03-challenge` 4/4가 그대로여야 함 | + +정답: [answers/78-lab03-challenge2-check-log.json](answers/78-lab03-challenge2-check-log.json) · [answers/78-lab03-challenge2-report.json](answers/78-lab03-challenge2-report.json) + +| 11/01 결과 | | +| --- | --- | +| 고치기 전 (`answers/24`) | **0/4** · 기록 1줄 (FAQ만), `by_dataset` 없음 | +| 고친 뒤 (`answers/25`) | **4/4** · employee 0.4444 · ticket **0.2222** · faq 0.8333, `worst_dataset` ticket, 전체 0.5. `lab03` · `lab03-challenge` 4/4 | + +- 풀이 때: 섞은 `trend`(좋아짐)는 직원 → FAQ를 비교한 것 — 추세는 데이터셋 안에서. diff --git a/Last3Mon/78/VIBE-A-TCHECK.md b/Last3Mon/78/VIBE-A-TCHECK.md new file mode 100644 index 0000000..ef6d947 --- /dev/null +++ b/Last3Mon/78/VIBE-A-TCHECK.md @@ -0,0 +1,87 @@ +# 7교시 바이브 코딩 · `tcheck` 정답본 + +> **강사용 정답본** · 학생 저장소에는 수업 뒤에 올립니다. 학생용: [VIBE-REQ-TCHECK.md](VIBE-REQ-TCHECK.md) → [VIBE-Q-TCHECK.md](VIBE-Q-TCHECK.md). +> 강사가 직접 만든 본: [tcheck/](tcheck/README.md) (테스트 22개, `uv tool install --editable .`). 아래 판정은 2026-11-01 강사 Mac에서 78일차 데이터 API에 실제로 돌린 값이고, n8n `my-check`의 응답(정답 실행 기록 `answers/18` · `answers/21`)과 비교했습니다. 68일차부터 바이브 코딩은 CLI 에이전트로 다시 만들어 재지 않습니다 (요청문은 예시). + +## 1. TODO 예 (요청의 말 → 할 일) + +| # | 요청의 말 | 할 일 (내 말) | 꼭 · 나중 | +| --- | --- | --- | --- | +| ① | CSV · JSON · 요청 파일 | `.csv` · JSON 목록 · `{"items"}` · `{"schema", "batch", "csv" \| "items"}` | 꼭 | +| ② | 스키마는 API에서, 파일로도 | `GET /schemas/` · `--schema-file`, 필드 이름을 코드에 쓰지 않음 | 꼭 | +| ③ | 몇째 줄 · 왜 · 같은 코드 | 줄 번호 1부터, AGENTS.md 3절 코드 | 꼭 | +| ④ | n8n과 똑같은 판정 | 공백 = 빈 값, 선택 칸, 실제 날짜, 정규식 ASCII, 키 중복 | 꼭 | +| ⑤ | JSON, 같은 칸 이름 | `--json` = `my-check` 모양 | 꼭 | +| ⑥ | 깨끗한 줄 · 나쁜 줄 파일 | `--out` · `--rejects` (+ `errors` 칸, 고치지 않음) | 꼭 | +| ⑦ | 거절될까? · 종료 코드 | `gate --max-invalid 0.3`, 넘으면 4 | 꼭 | +| ⑧ | 개인정보 · 바깥 링크 | `--content answer` (Lab 01 규칙) | 나중 | +| ⑨ | 사람이 읽는 말 · 한 줄 | `--fix` (`fix_list` · `message`) | 나중 | +| ⑩ | 품질 기록에도 | `--log` (`/quality-log`) | 나중 | +| ⑪ | 화면 · 탭 · 거르기 · 내보내기 | `ui` | 꼭 | +| ⑫ | 종료 코드 | 0 · 1 · 2 · 4 | 꼭 | +| 끝 | n8n 없이 시험 · 같은 판정 확인 | 스키마 · 배치 · n8n 결과 사본, 가짜 데이터 API | 꼭 | + +## 2. 요청문 (처음 한 번 + 다음 단계 한 줄) + +단계마다 긴 요청문을 쓰지 않습니다. 할 일은 이미 `Doc/SPEC.md` · `Doc/IMP-PLAN.md`에 있으니, 에이전트가 그 파일을 읽게 하면 됩니다. + +### 처음 한 번 + +```text +Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 체크 안 된 첫 단계만 만들어 줘. uv 프로젝트, 명령 이름 tcheck, 코드와 화면 글자는 영어. +끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘. 다음 단계는 내가 말할 때까지 하지 마. +``` + +### 그다음 단계마다 (같은 한 줄) + +```text +확인했고 commit 했어. 다음 단계. +``` + +- 그냥 "계속"만 치면 단계 두세 개를 한꺼번에 하거나 체크 · 확인 명령을 빼먹기 쉽습니다. 이 한 줄은 처음 요청의 약속(한 단계만 · 체크 · 확인 명령 · 멈춤)을 이어 가게 합니다. +- 대화가 길어지면 새 세션에서 **처음 요청**을 다시 보냅니다. "체크 안 된 첫 단계"부터 이어집니다. +- 에이전트가 단계 내용을 잘못 알았다면, 요청문을 길게 쓰기보다 **SPEC · IMP-PLAN을 고치고** 다시 시킵니다. + +### 틀렸을 때만 · 고쳐 달라는 요청문 예 + +무엇이 틀렸나(보인 것) · SPEC의 어느 요구인가 · 어디만 고치나 · 무엇으로 확인하나, 넷을 적습니다. + +```text +tcheck check tickets-new.json 이 4째 줄(opened 2026-02-30)을 통과시켜. n8n my-check 는 bad_format:opened 야. SPEC R4 대로 실제 있는 날짜인지까지 봐야 해. +checks.py 의 date 판정만 고치고, 2026-02-30 · 2026-13-01 은 bad_format, 2026-10-28 은 통과인 테스트를 넣어 줘. 다른 파일은 건드리지 마. +``` + +## 3. 확인 기준과 강사 실측 (11/01, 78일차 데이터 API) + +| 요구 | 기준 | 실측 | +| --- | --- | --- | +| R1 | 요청 파일(CSV 든) · CSV · 목록 | `employees-new.json` · `employees.csv` · `tickets-new.json` 모두 9줄 | +| R2 | 스키마 셋, 필드 이름 없이 | `employee` · `ticket` · `faq` 같은 `field_errors`로, 없는 스키마 → 1 | +| R3 | 직원 | 3 `bad_format:email` · 4 `not_allowed:dept` · 5 `bad_format:joined` · 6 `missing:name` · 8 `duplicate_in_batch`, 9줄 중 4줄 | +| R4 | `my-check`와 같은 판정 | 직원 HR-1 · 접수 건 TK-1 · FAQ B-3의 `rows` · `codes` · `valid`가 **모두 같음** (`tests/data/n8n-my-check.json`) | +| R5 | `--json` | `{schema, batch, total, valid, invalid, rows, codes}` — 위와 같은 비교 | +| R6 | 파일 둘 | 직원 → 깨끗한 4줄 · 나쁜 5줄 (첫 줄 `errors` = `bad_format:email`) | +| R7 | `tcheck gate` | 직원 0.5556 → `✘ rejected` · **종료 코드 4**, `--max-invalid 0.6` → 0, B-2 `--content answer` 0.1 · 기준 0.1 → `✔ accepted` (같으면 통과) | +| R8 | `--content answer` | B-2 → FAQ-209 `pii`, `faq-edges.json` → 10줄 중 4줄 (FAQ-406 `pii` · FAQ-409 `external_link`) — Lab 01 예제 3의 내 검사와 같은 4줄, B-1 → 13줄 중 8줄 나쁨 (0.6154) | +| R9 | `--fix` 접수 건 | `TK-1: 9줄 중 2줄 통과, 7줄 고칠 것`, 3째 "priority은(는) low · normal · high 중 하나로" | +| R10 | `--log` | `flow.py quality` → `HR-1 employee total 9 valid 4 score 0.4444` | +| R11 | `run_test` | 문제 줄 5 · 코드 5 · 깨끗한 줄 4, `email`로 거르면 1줄, `e` → 깨끗한 4줄 파일 | +| R12 | `uv run pytest -q` | 22 passed in 3.0s | +| R13 | 종료 코드 | 없는 파일 1 · 없는 스키마 1 · 스키마를 안 줌 2 · 모르는 명령 2 · 거절 4 | +| R14 | `/tmp`에서 `tcheck --help` | `usage: tcheck [-h] {check,gate,ui} ...`, `tcheck check …` 0.06초 | + +- 수업 준비 때 찾은 실수 (학생도 겪기 쉬움): ① Python `re`의 `\w`는 한글도 맞아 메일 판정이 JavaScript와 다를 수 있음 → `re.ASCII`. ② TUI 거르기 상자를 연 `/`가 상자 글자에 들어가고, 상자를 닫아도 포커스가 남아 `e`가 글자로 들어감 → `/`를 지우고 닫을 때 표로 포커스. +- 같은 FAQ B-1을 스키마 + 내용 규칙으로 보면 Lab 01 예제 2의 8줄과 **같은 줄**이 나쁩니다. 코드 이름만 다릅니다 (`title_missing` ↔ `missing:title`, `id_format` ↔ `bad_format:id`). 발표 때 "같은 판정 · 다른 이름"을 짚을 거리. + +## 4. 7교시에 볼 것 + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| 판정이 n8n과 다름 | "어느 줄 · 어느 코드가 다른지 붙여 주고, AGENTS.md 3절 스키마 표 그대로" | +| 필드 이름이 코드에 | "스키마의 fields를 돌며 type마다" | +| 2월 30일 · 13월이 통과 | "실제 있는 날짜인지까지" | +| 빈 선택 칸이 `bad_format` | "비었으면 required만 보고 다음 칸으로" | +| 테스트가 진짜 API를 부름 | "가짜 서버로, 진짜는 확인 단계에서만" | +| 규칙이 여기저기 | "checks.py 한 곳" | +| `gate`가 늘 0 | "넘으면 거절, 거절이면 4" | +| 나쁜 줄 파일에 고친 값 | "원래 줄 그대로 + errors 칸만" | diff --git a/Last3Mon/78/answers/00-prep.txt b/Last3Mon/78/answers/00-prep.txt new file mode 100644 index 0000000..161ed9b --- /dev/null +++ b/Last3Mon/78/answers/00-prep.txt @@ -0,0 +1,36 @@ +$ uv run tools/flow.py ops --reset +✔ live run logs, added FAQ, quarantine, quality log, created tickets, DLQ and alerts emptied, fault switch all ok + +$ uv run tools/flow.py off my-faq-check my-faq-import my-check my-quality-report +✔ unpublished my-faq-check (id weecbf2cc21d6fd2) +✔ unpublished my-faq-import (id we9ca88e4f314222) +✔ unpublished my-check (id wf4c2f04db13849f) +✔ unpublished my-quality-report (id we40b9a92abbf7fd) +✔ n8n restarted. Turn it back on with: uv run tools/flow.py push + +$ uv run tools/flow.py push reference/baseline-faq-check.json reference/baseline-faq-import.json +✔ pushed '78 FAQ check (team baseline)' (id w2d09b3af4461197) + POST http://localhost:5678/webhook/base-faq-check +✔ pushed '78 FAQ import (team baseline)' (id w45c6472ffd87501) + POST http://localhost:5678/webhook/base-faq-import + +$ uv run tools/flow.py quality +FAQ: 6 (nothing added) +quarantine: 0 +quality log: 0 +alerts: 0 + +$ uv run tools/flow.py doctor +! n8n install folder /Users/mcchae/day65-n8n + → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더> +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) +✔ run logs /logs (666 seeded runs) · FAQ additions /faq-extra (day 77 data API) +✔ schemas faq · employee · ticket, quarantine, quality log (day 78 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready diff --git a/Last3Mon/78/answers/01-lab01-read.txt b/Last3Mon/78/answers/01-lab01-read.txt new file mode 100644 index 0000000..14367a4 --- /dev/null +++ b/Last3Mon/78/answers/01-lab01-read.txt @@ -0,0 +1,99 @@ +$ uv run tools/flow.py call base-faq-check requests/faq-batch-1.json +HTTP 200 · 0.9s +{ + "batch": "B-1", + "total": 13, + "valid": 10, + "invalid": 3, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "answer_missing": 1, + "keywords_missing": 1 + } +} diff --git a/Last3Mon/78/answers/02-lab01-before.json b/Last3Mon/78/answers/02-lab01-before.json new file mode 100644 index 0000000..0da0542 --- /dev/null +++ b/Last3Mon/78/answers/02-lab01-before.json @@ -0,0 +1,865 @@ +{ + "cases": "lab01.json", + "webhook": "base-faq-check", + "passed": 1, + "total": 5, + "results": [ + { + "case": "빈 값 (제목 · 답 · 키워드)", + "webhook": "base-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 10, + "invalid": 3, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "answer_missing": 1, + "keywords_missing": 1 + } + } + }, + { + "case": "형식 (id · 답 길이)", + "webhook": "base-faq-check", + "run": 1, + "passed": false, + "problems": [ + "rows.3.errors has none of ['id_format']", + "rows.5.errors has none of ['answer_too_long']" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 10, + "invalid": 3, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "answer_missing": 1, + "keywords_missing": 1 + } + } + }, + { + "case": "배치 안 중복 · 개인정보 · 바깥 링크", + "webhook": "base-faq-check", + "run": 1, + "passed": false, + "problems": [ + "rows.6.errors has none of ['duplicate_in_batch']", + "rows.8.errors has none of ['pii']", + "rows.9.errors has none of ['external_link']" + ], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 10, + "invalid": 3, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "answer_missing": 1, + "keywords_missing": 1 + } + } + }, + { + "case": "배치 2 · 메일 주소만 걸림, 사내 링크는 통과", + "webhook": "base-faq-check", + "run": 1, + "passed": false, + "problems": [ + "valid = 10 (expected 9)", + "codes.pii = null (expected 1)", + "rows.8.ok = true (expected false)" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "valid": 10, + "invalid": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": {} + } + }, + { + "case": "목록이 아닌 본문 → 400", + "webhook": "base-faq-check", + "run": 1, + "passed": false, + "problems": [ + "status 500 (expected 400)" + ], + "seconds": 0.1, + "status": 500, + "request": { + "batch": "B-x", + "items": "FAQ-101" + }, + "response": { + "message": "Error in workflow" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/02-lab01-before.txt b/Last3Mon/78/answers/02-lab01-before.txt new file mode 100644 index 0000000..32ffebd --- /dev/null +++ b/Last3Mon/78/answers/02-lab01-before.txt @@ -0,0 +1,818 @@ +$ uv run tools/flow.py test cases/lab01.json --webhook base-faq-check -v +POST http://localhost:5678/webhook/base-faq-check · 5 case(s) × 1 +✔ 빈 값 (제목 · 답 · 키워드) · 0.0s + → request POST /webhook/base-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 10, + "invalid": 3, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "answer_missing": 1, + "keywords_missing": 1 + } + } +✘ 형식 (id · 답 길이) · 0.1s · rows.3.errors has none of ['id_format']; rows.5.errors has none of ['answer_too_long'] + → request POST /webhook/base-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 10, + "invalid": 3, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "answer_missing": 1, + "keywords_missing": 1 + } + } +✘ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.0s · rows.6.errors has none of ['duplicate_in_batch']; rows.8.errors has none of ['pii']; rows.9.errors has none of ['external_link'] + → request POST /webhook/base-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 10, + "invalid": 3, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "answer_missing": 1, + "keywords_missing": 1 + } + } +✘ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.1s · valid = 10 (expected 9); codes.pii = null (expected 1); rows.8.ok = true (expected false) + → request POST /webhook/base-faq-check + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "valid": 10, + "invalid": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": {} + } +✘ 목록이 아닌 본문 → 400 · 0.1s · status 500 (expected 400) + → request POST /webhook/base-faq-check + { + "batch": "B-x", + "items": "FAQ-101" + } + ← response HTTP 500 + { + "message": "Error in workflow" + } +Passed 1/5 +report: runs/lab01-20261101-180505.json diff --git a/Last3Mon/78/answers/03-lab01-check.json b/Last3Mon/78/answers/03-lab01-check.json new file mode 100644 index 0000000..6691325 --- /dev/null +++ b/Last3Mon/78/answers/03-lab01-check.json @@ -0,0 +1,905 @@ +{ + "cases": "lab01.json", + "webhook": "my-faq-check", + "passed": 5, + "total": 5, + "results": [ + { + "case": "빈 값 (제목 · 답 · 키워드)", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.9, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } + }, + { + "case": "형식 (id · 답 길이)", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } + }, + { + "case": "배치 안 중복 · 개인정보 · 바깥 링크", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } + }, + { + "case": "배치 2 · 메일 주소만 걸림, 사내 링크는 통과", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "valid": 9, + "invalid": 1, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "pii": 1 + } + } + }, + { + "case": "목록이 아닌 본문 → 400", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 400, + "request": { + "batch": "B-x", + "items": "FAQ-101" + }, + "response": { + "error": "items must be a list" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/03-lab01-check.txt b/Last3Mon/78/answers/03-lab01-check.txt new file mode 100644 index 0000000..a8bb01c --- /dev/null +++ b/Last3Mon/78/answers/03-lab01-check.txt @@ -0,0 +1,991 @@ +$ uv run tools/flow.py push reference/lab01-example2-check.json +✔ pushed '78 FAQ check (reference)' (id w318880a1bd61aa2) + POST http://localhost:5678/webhook/my-faq-check + +$ uv run tools/flow.py test cases/lab01.json -v +POST http://localhost:5678/webhook/my-faq-check · 5 case(s) × 1 +✔ 빈 값 (제목 · 답 · 키워드) · 0.9s + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } +✔ 형식 (id · 답 길이) · 0.0s + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } +✔ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.1s + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } +✔ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.0s + → request POST /webhook/my-faq-check + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "valid": 9, + "invalid": 1, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "pii": 1 + } + } +✔ 목록이 아닌 본문 → 400 · 0.1s + → request POST /webhook/my-faq-check + { + "batch": "B-x", + "items": "FAQ-101" + } + ← response HTTP 400 + { + "error": "items must be a list" + } +Passed 5/5 +report: runs/lab01-20261101-180518.json + +$ uv run tools/flow.py call my-faq-check requests/faq-batch-1.json +HTTP 200 · 0.1s +{ + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } +} diff --git a/Last3Mon/78/answers/04-lab01-edges.json b/Last3Mon/78/answers/04-lab01-edges.json new file mode 100644 index 0000000..698f172 --- /dev/null +++ b/Last3Mon/78/answers/04-lab01-edges.json @@ -0,0 +1,1124 @@ +{ + "cases": "lab01-edges.json", + "webhook": "my-faq-check", + "passed": 6, + "total": 6, + "results": [ + { + "case": "제목 40자는 통과, 41자는 너무 김", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "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": { + "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 + } + } + }, + { + "case": "공백만 있는 제목은 빈 값", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "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": { + "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 + } + } + }, + { + "case": "답 10자는 통과, 9자는 너무 짧음", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "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": { + "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 + } + } + }, + { + "case": "하이픈 없는 휴대전화도 개인정보, 사무실 번호는 통과", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "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": { + "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 + } + } + }, + { + "case": "사내 주소는 통과, 사내 주소처럼 보이는 바깥 링크는 막음", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "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": { + "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 + } + } + }, + { + "case": "소문자 id · 합계 10줄 중 4줄 통과", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "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": { + "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 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/04-lab01-edges.txt b/Last3Mon/78/answers/04-lab01-edges.txt new file mode 100644 index 0000000..2ac60ee --- /dev/null +++ b/Last3Mon/78/answers/04-lab01-edges.txt @@ -0,0 +1,1095 @@ +$ uv run tools/flow.py test cases/lab01-edges.json -v +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 + +$ uv run tools/flow.py test cases/lab01-edges.json --webhook base-faq-check +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 diff --git a/Last3Mon/78/answers/05-lab01-challenge-before.json b/Last3Mon/78/answers/05-lab01-challenge-before.json new file mode 100644 index 0000000..b5a6a3d --- /dev/null +++ b/Last3Mon/78/answers/05-lab01-challenge-before.json @@ -0,0 +1,899 @@ +{ + "cases": "lab01-challenge.json", + "webhook": "my-faq-check", + "passed": 0, + "total": 4, + "results": [ + { + "case": "이미 있는 id (FAQ-003)", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "rows.10.errors has none of ['exists_id']" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } + }, + { + "case": "이미 있는 제목 · 쓰인 키워드는 경고", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "rows.11.errors has none of ['same_title']", + "rows.11.warnings has none of ['keyword_used:vpn']" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } + }, + { + "case": "배치 1 · 통과 3건", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "valid = 5 (expected 3)", + "invalid = 8 (expected 10)" + ], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } + }, + { + "case": "막 더한 FAQ도 기존 데이터", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "rows.0.errors has none of ['exists_id']" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "valid": 9, + "invalid": 1, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "pii": 1 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/05-lab01-challenge-before.txt b/Last3Mon/78/answers/05-lab01-challenge-before.txt new file mode 100644 index 0000000..ad647fd --- /dev/null +++ b/Last3Mon/78/answers/05-lab01-challenge-before.txt @@ -0,0 +1,890 @@ +$ uv run tools/flow.py test cases/lab01-challenge.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/my-faq-check · 4 case(s) × 1 +✘ 이미 있는 id (FAQ-003) · 0.1s · rows.10.errors has none of ['exists_id'] + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } +✘ 이미 있는 제목 · 쓰인 키워드는 경고 · 0.1s · rows.11.errors has none of ['same_title']; rows.11.warnings has none of ['keyword_used:vpn'] + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } +✘ 배치 1 · 통과 3건 · 0.0s · valid = 5 (expected 3); invalid = 8 (expected 10) + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 5, + "invalid": 8, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1 + } + } +✔ before: POST http://localhost:8800/faq/add · HTTP 200 · 0.0s · {"added": "FAQ-201", "size": 7} + → request POST http://localhost:8800/faq/add + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 예약합니다." + } + ← response HTTP 200 + { + "added": "FAQ-201", + "size": 7 + } +✘ 막 더한 FAQ도 기존 데이터 · 0.1s · rows.0.errors has none of ['exists_id'] + → request POST /webhook/my-faq-check + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "valid": 9, + "invalid": 1, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "pii": 1 + } + } +Passed 0/4 +report: runs/lab01-challenge-20261101-180519.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 1 + } diff --git a/Last3Mon/78/answers/06-lab01-challenge.json b/Last3Mon/78/answers/06-lab01-challenge.json new file mode 100644 index 0000000..62d477a --- /dev/null +++ b/Last3Mon/78/answers/06-lab01-challenge.json @@ -0,0 +1,920 @@ +{ + "cases": "lab01-challenge.json", + "webhook": "my-faq-check", + "passed": 4, + "total": 4, + "results": [ + { + "case": "이미 있는 id (FAQ-003)", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.9, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "이미 있는 제목 · 쓰인 키워드는 경고", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "배치 1 · 통과 3건", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "막 더한 FAQ도 기존 데이터", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "valid": 8, + "invalid": 2, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-201", + "ok": false, + "errors": [ + "exists_id", + "same_title" + ], + "warnings": [ + "keyword_used:회의실" + ] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "exists_id": 1, + "same_title": 1, + "pii": 1 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/06-lab01-challenge.txt b/Last3Mon/78/answers/06-lab01-challenge.txt new file mode 100644 index 0000000..226cfbe --- /dev/null +++ b/Last3Mon/78/answers/06-lab01-challenge.txt @@ -0,0 +1,936 @@ +$ uv run tools/flow.py push answers/78-lab01-challenge-existing.json +! unpublished '78 FAQ check (reference)' (w318880a1bd61aa2): it used the same webhook path +✔ pushed '78 FAQ check + existing data (answer)' (id w99a77a91b9217c9) + POST http://localhost:5678/webhook/my-faq-check + +$ uv run tools/flow.py test cases/lab01-challenge.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/my-faq-check · 4 case(s) × 1 +✔ 이미 있는 id (FAQ-003) · 0.9s + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ 이미 있는 제목 · 쓰인 키워드는 경고 · 0.0s + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ 배치 1 · 통과 3건 · 0.1s + → request POST /webhook/my-faq-check + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ before: POST http://localhost:8800/faq/add · HTTP 200 · 0.0s · {"added": "FAQ-201", "size": 7} + → request POST http://localhost:8800/faq/add + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 예약합니다." + } + ← response HTTP 200 + { + "added": "FAQ-201", + "size": 7 + } +✔ 막 더한 FAQ도 기존 데이터 · 0.0s + → request POST /webhook/my-faq-check + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "valid": 8, + "invalid": 2, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-201", + "ok": false, + "errors": [ + "exists_id", + "same_title" + ], + "warnings": [ + "keyword_used:회의실" + ] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "exists_id": 1, + "same_title": 1, + "pii": 1 + } + } +Passed 4/4 +report: runs/lab01-challenge-20261101-180533.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 1 + } + +$ uv run tools/flow.py test cases/lab01.json +POST http://localhost:5678/webhook/my-faq-check · 5 case(s) × 1 +✔ 빈 값 (제목 · 답 · 키워드) · 0.1s +✔ 형식 (id · 답 길이) · 0.2s +✔ 배치 안 중복 · 개인정보 · 바깥 링크 · 0.1s +✔ 배치 2 · 메일 주소만 걸림, 사내 링크는 통과 · 0.0s +✔ 목록이 아닌 본문 → 400 · 0.1s +Passed 5/5 +report: runs/lab01-20261101-180533.json diff --git a/Last3Mon/78/answers/07-lab01-challenge2-before.json b/Last3Mon/78/answers/07-lab01-challenge2-before.json new file mode 100644 index 0000000..741b6d0 --- /dev/null +++ b/Last3Mon/78/answers/07-lab01-challenge2-before.json @@ -0,0 +1,1170 @@ +{ + "cases": "lab01-challenge2.json", + "webhook": "my-faq-check", + "passed": 1, + "total": 5, + "results": [ + { + "case": "B-1 · 고칠 줄 10개, 줄 순서대로", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "fix_list.0.row = null (expected 3)", + "fix_list.0.id = null (expected FAQ-103)", + "fix_list.9.id = null (expected FAQ-112)" + ], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "고칠 것은 사람이 읽는 말로", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "fix_list.0.fix has none of ['제목을 써 주세요']", + "fix_list.6.fix has none of ['개인 전화번호']", + "fix_list.8.fix has none of ['이미 있는 id']" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "한 줄 요약", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "message = null (expected B-1: 13줄 중 3줄 통과, 10줄 고칠 것)" + ], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "B-2 · 메일 주소 한 줄만", + "webhook": "my-faq-check", + "run": 1, + "passed": false, + "problems": [ + "fix_list.0.id = null (expected FAQ-209)", + "fix_list.0.codes.0 = null (expected pii)", + "message = null (expected B-2: 10줄 중 9줄 통과, 1줄 고칠 것)" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "valid": 9, + "invalid": 1, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "pii": 1 + } + } + }, + { + "case": "앞의 칸은 그대로", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/07-lab01-challenge2-before.txt b/Last3Mon/78/answers/07-lab01-challenge2-before.txt new file mode 100644 index 0000000..0e4792c --- /dev/null +++ b/Last3Mon/78/answers/07-lab01-challenge2-before.txt @@ -0,0 +1,1129 @@ +$ uv run tools/flow.py test cases/lab01-challenge2.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +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 diff --git a/Last3Mon/78/answers/08-lab01-challenge2.json b/Last3Mon/78/answers/08-lab01-challenge2.json new file mode 100644 index 0000000..79031a7 --- /dev/null +++ b/Last3Mon/78/answers/08-lab01-challenge2.json @@ -0,0 +1,1581 @@ +{ + "cases": "lab01-challenge2.json", + "webhook": "my-faq-check", + "passed": 5, + "total": 5, + "results": [ + { + "case": "B-1 · 고칠 줄 10개, 줄 순서대로", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.9, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "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줄 고칠 것" + } + }, + { + "case": "고칠 것은 사람이 읽는 말로", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 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줄 고칠 것" + } + }, + { + "case": "한 줄 요약", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 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줄 고칠 것" + } + }, + { + "case": "B-2 · 메일 주소 한 줄만", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "valid": 9, + "invalid": 1, + "with_warnings": 0, + "rows": [ + { + "id": "FAQ-201", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-202", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-203", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-204", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-205", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-206", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-207", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-208", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-209", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-210", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "pii": 1 + }, + "fix_list": [ + { + "row": 9, + "id": "FAQ-209", + "codes": [ + "pii" + ], + "fix": [ + "개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요" + ] + } + ], + "message": "B-2: 10줄 중 9줄 통과, 1줄 고칠 것" + } + }, + { + "case": "앞의 칸은 그대로", + "webhook": "my-faq-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.2, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "valid": 3, + "invalid": 10, + "with_warnings": 1, + "rows": [ + { + "id": "FAQ-101", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": true, + "errors": [], + "warnings": [] + }, + { + "id": "FAQ-103", + "ok": false, + "errors": [ + "title_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-10", + "ok": false, + "errors": [ + "id_format" + ], + "warnings": [] + }, + { + "id": "FAQ-105", + "ok": false, + "errors": [ + "answer_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-106", + "ok": false, + "errors": [ + "answer_too_long" + ], + "warnings": [] + }, + { + "id": "FAQ-102", + "ok": false, + "errors": [ + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "FAQ-108", + "ok": false, + "errors": [ + "keywords_missing" + ], + "warnings": [] + }, + { + "id": "FAQ-109", + "ok": false, + "errors": [ + "pii" + ], + "warnings": [] + }, + { + "id": "FAQ-110", + "ok": false, + "errors": [ + "external_link" + ], + "warnings": [] + }, + { + "id": "FAQ-003", + "ok": false, + "errors": [ + "exists_id" + ], + "warnings": [] + }, + { + "id": "FAQ-112", + "ok": false, + "errors": [ + "same_title" + ], + "warnings": [ + "keyword_used:vpn" + ] + }, + { + "id": "FAQ-113", + "ok": true, + "errors": [], + "warnings": [] + } + ], + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "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줄 고칠 것" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/08-lab01-challenge2.txt b/Last3Mon/78/answers/08-lab01-challenge2.txt new file mode 100644 index 0000000..af53c1c --- /dev/null +++ b/Last3Mon/78/answers/08-lab01-challenge2.txt @@ -0,0 +1,1592 @@ +$ uv run tools/flow.py push answers/78-lab01-challenge2-fix-list.json +! 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 + +$ uv run tools/flow.py test cases/lab01-challenge2.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +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 + +$ uv run tools/flow.py test cases/lab01.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 + +$ uv run tools/flow.py test cases/lab01-challenge.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} + +$ uv run tools/flow.py test cases/lab01-edges.json +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 diff --git a/Last3Mon/78/answers/09-lab02-before.json b/Last3Mon/78/answers/09-lab02-before.json new file mode 100644 index 0000000..c59a848 --- /dev/null +++ b/Last3Mon/78/answers/09-lab02-before.json @@ -0,0 +1,773 @@ +{ + "cases": "lab02.json", + "webhook": "base-faq-import", + "passed": 0, + "total": 6, + "results": [ + { + "case": "나쁜 배치는 통째로 거절 (422)", + "webhook": "base-faq-import", + "run": 1, + "passed": false, + "problems": [ + "status 200 (expected 422)", + "invalid_ratio = null (expected 0.3..1)" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "imported": 12, + "quarantined": 0, + "rejected": false, + "score": 0.9231, + "imported_ids": [ + "FAQ-101", + "FAQ-102", + "FAQ-103", + "FAQ-10", + "FAQ-106", + "FAQ-102", + "FAQ-108", + "FAQ-109", + "FAQ-110", + "FAQ-003", + "FAQ-112", + "FAQ-113" + ], + "quarantined_ids": [] + } + }, + { + "case": "배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리", + "webhook": "base-faq-import", + "run": 1, + "passed": false, + "problems": [ + "quarantined_ids has none of ['FAQ-209']" + ], + "seconds": 1.1, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 10, + "quarantined": 0, + "rejected": false, + "score": 1, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [] + } + }, + { + "case": "FAQ에 들어갔고 메일 주소는 안 들어감", + "webhook": "http://localhost:8800/faq/list", + "run": 1, + "passed": false, + "problems": [ + "faqs must not have ['security.desk']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 27, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + }, + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + } + ] + } + }, + { + "case": "격리함에 이유와 함께", + "webhook": "http://localhost:8800/quarantine/list", + "run": 1, + "passed": false, + "problems": [ + "items has none of ['\"pii\"']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 0, + "items": [] + } + }, + { + "case": "LLM · 제목과 답이 다른 줄 (FAQ-210)", + "webhook": "base-faq-import", + "run": 1, + "passed": false, + "problems": [ + "imported = 10 (expected 8)", + "quarantined = 0 (expected 2)", + "codes.llm_mismatch = null (expected 1)", + "quarantined_ids has none of ['FAQ-210']" + ], + "seconds": 1.1, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 10, + "quarantined": 0, + "rejected": false, + "score": 1, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [] + } + }, + { + "case": "LLM · 규칙에 걸린 줄은 그대로 (FAQ-305)", + "webhook": "base-faq-import", + "run": 1, + "passed": false, + "problems": [ + "imported = 6 (expected 5)", + "quarantined_ids has none of ['FAQ-305']" + ], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + }, + "response": { + "batch": "B-3", + "total": 6, + "imported": 6, + "quarantined": 0, + "rejected": false, + "score": 1, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-305", + "FAQ-306" + ], + "quarantined_ids": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/09-lab02-before.txt b/Last3Mon/78/answers/09-lab02-before.txt new file mode 100644 index 0000000..b5640d6 --- /dev/null +++ b/Last3Mon/78/answers/09-lab02-before.txt @@ -0,0 +1,771 @@ +$ uv run tools/flow.py test cases/lab02.json --webhook base-faq-import -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/base-faq-import · 6 case(s) × 1 +✘ 나쁜 배치는 통째로 거절 (422) · 0.1s · status 200 (expected 422); invalid_ratio = null (expected 0.3..1) + → request POST /webhook/base-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "imported": 12, + "quarantined": 0, + "rejected": false, + "score": 0.9231, + "imported_ids": [ + "FAQ-101", + "FAQ-102", + "FAQ-103", + "FAQ-10", + "FAQ-106", + "FAQ-102", + "FAQ-108", + "FAQ-109", + "FAQ-110", + "FAQ-003", + "FAQ-112", + "FAQ-113" + ], + "quarantined_ids": [] + } +✘ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.1s · quarantined_ids has none of ['FAQ-209'] + → request POST /webhook/base-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 10, + "quarantined": 0, + "rejected": false, + "score": 1, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [] + } +✘ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s · faqs must not have ['security.desk'] + → request POST http://localhost:8800/faq/list + {} + ← response HTTP 200 + { + "count": 27, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + }, + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + } + ] + } +✘ 격리함에 이유와 함께 · 0.0s · items has none of ['"pii"'] + → request POST http://localhost:8800/quarantine/list + {} + ← response HTTP 200 + { + "size": 0, + "items": [] + } +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 21} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 21 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✘ LLM · 제목과 답이 다른 줄 (FAQ-210) · 1.1s · imported = 10 (expected 8); quarantined = 0 (expected 2); codes.llm_mismatch = null (expected 1); quarantined_ids has none of ['FAQ-210'] + → request POST /webhook/base-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 10, + "quarantined": 0, + "rejected": false, + "score": 1, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [] + } +✘ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s · imported = 6 (expected 5); quarantined_ids has none of ['FAQ-305'] + → request POST /webhook/base-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 6, + "quarantined": 0, + "rejected": false, + "score": 1, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-305", + "FAQ-306" + ], + "quarantined_ids": [] + } +Passed 0/6 +report: runs/lab02-20261101-180552.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 16} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 16 + } + +$ uv run tools/flow.py quality +FAQ: 6 (nothing added) +quarantine: 0 +quality log: 0 +alerts: 0 diff --git a/Last3Mon/78/answers/10-lab02-gate.json b/Last3Mon/78/answers/10-lab02-gate.json new file mode 100644 index 0000000..337f7f8 --- /dev/null +++ b/Last3Mon/78/answers/10-lab02-gate.json @@ -0,0 +1,689 @@ +{ + "cases": "lab02.json", + "webhook": "my-faq-import", + "passed": 5, + "total": 6, + "results": [ + { + "case": "나쁜 배치는 통째로 거절 (422)", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.9, + "status": 422, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.4, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 9, + "quarantined": 1, + "rejected": false, + "score": 0.9, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-210" + ], + "quarantined_ids": [ + "FAQ-209" + ] + } + }, + { + "case": "FAQ에 들어갔고 메일 주소는 안 들어감", + "webhook": "http://localhost:8800/faq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 15, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + }, + { + "case": "격리함에 이유와 함께", + "webhook": "http://localhost:8800/quarantine/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 1, + "items": [ + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:06:06" + } + ] + } + }, + { + "case": "LLM · 제목과 답이 다른 줄 (FAQ-210)", + "webhook": "my-faq-import", + "run": 1, + "passed": false, + "problems": [ + "imported = 9 (expected 8)", + "quarantined = 1 (expected 2)", + "codes.llm_mismatch = null (expected 1)", + "quarantined_ids has none of ['FAQ-210']" + ], + "seconds": 1.2, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 9, + "quarantined": 1, + "rejected": false, + "score": 0.9, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-210" + ], + "quarantined_ids": [ + "FAQ-209" + ] + } + }, + { + "case": "LLM · 규칙에 걸린 줄은 그대로 (FAQ-305)", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + }, + "response": { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/10-lab02-gate.txt b/Last3Mon/78/answers/10-lab02-gate.txt new file mode 100644 index 0000000..78a7707 --- /dev/null +++ b/Last3Mon/78/answers/10-lab02-gate.txt @@ -0,0 +1,705 @@ +$ uv run tools/flow.py push reference/lab02-example1-import.json +✔ pushed '78 FAQ import + gate (reference)' (id we19b3911975e55e) + POST http://localhost:5678/webhook/my-faq-import + +$ uv run tools/flow.py test cases/lab02.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1 +✔ 나쁜 배치는 통째로 거절 (422) · 0.9s + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.4s + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 9, + "quarantined": 1, + "rejected": false, + "score": 0.9, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-210" + ], + "quarantined_ids": [ + "FAQ-209" + ] + } +✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s + → request POST http://localhost:8800/faq/list + {} + ← response HTTP 200 + { + "count": 15, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } +✔ 격리함에 이유와 함께 · 0.0s + → request POST http://localhost:8800/quarantine/list + {} + ← response HTTP 200 + { + "size": 1, + "items": [ + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:06:06" + } + ] + } +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 9} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 9 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✘ LLM · 제목과 답이 다른 줄 (FAQ-210) · 1.2s · imported = 9 (expected 8); quarantined = 1 (expected 2); codes.llm_mismatch = null (expected 1); quarantined_ids has none of ['FAQ-210'] + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 9, + "quarantined": 1, + "rejected": false, + "score": 0.9, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208", + "FAQ-210" + ], + "quarantined_ids": [ + "FAQ-209" + ] + } +✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ] + } +Passed 5/6 +report: runs/lab02-20261101-180607.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 14} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 14 + } + +$ uv run tools/flow.py quality +FAQ: 6 (nothing added) +quarantine: 2 + B-2 FAQ-209 pii + B-3 FAQ-305 answer_too_short +quality log: 0 +alerts: 0 diff --git a/Last3Mon/78/answers/11-lab02-llm.json b/Last3Mon/78/answers/11-lab02-llm.json new file mode 100644 index 0000000..157002e --- /dev/null +++ b/Last3Mon/78/answers/11-lab02-llm.json @@ -0,0 +1,694 @@ +{ + "cases": "lab02.json", + "webhook": "my-faq-import", + "passed": 6, + "total": 6, + "results": [ + { + "case": "나쁜 배치는 통째로 거절 (422)", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.0, + "status": 422, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 9.3, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } + }, + { + "case": "FAQ에 들어갔고 메일 주소는 안 들어감", + "webhook": "http://localhost:8800/faq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 14, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + } + ] + } + }, + { + "case": "격리함에 이유와 함께", + "webhook": "http://localhost:8800/quarantine/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "items": [ + { + "row_id": "FAQ-210", + "batch": "B-2", + "row": { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:12:18" + }, + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:12:18" + } + ] + } + }, + { + "case": "LLM · 제목과 답이 다른 줄 (FAQ-210)", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 8.4, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } + }, + { + "case": "LLM · 규칙에 걸린 줄은 그대로 (FAQ-305)", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.4, + "status": 200, + "request": { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + }, + "response": { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/11-lab02-llm.txt b/Last3Mon/78/answers/11-lab02-llm.txt new file mode 100644 index 0000000..d291666 --- /dev/null +++ b/Last3Mon/78/answers/11-lab02-llm.txt @@ -0,0 +1,708 @@ +$ uv run tools/flow.py push reference/lab02-example2-import.json +! unpublished '78 FAQ import + release fixed rows (reference)' (we9ca88e4f314222): it used the same webhook path +✔ pushed '78 FAQ import + LLM check (reference)' (id wee5dfb169c284f5) + POST http://localhost:5678/webhook/my-faq-import + +$ uv run tools/flow.py test cases/lab02.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1 +✔ 나쁜 배치는 통째로 거절 (422) · 1.0s + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 9.3s + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } +✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s + → request POST http://localhost:8800/faq/list + {} + ← response HTTP 200 + { + "count": 14, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + } + ] + } +✔ 격리함에 이유와 함께 · 0.0s + → request POST http://localhost:8800/quarantine/list + {} + ← response HTTP 200 + { + "size": 2, + "items": [ + { + "row_id": "FAQ-210", + "batch": "B-2", + "row": { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:12:18" + }, + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:12:18" + } + ] + } +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.4s + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } +✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 4.4s + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ] + } +Passed 6/6 +report: runs/lab02-20261101-181231.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 13 + } diff --git a/Last3Mon/78/answers/12-lab02-threshold.json b/Last3Mon/78/answers/12-lab02-threshold.json new file mode 100644 index 0000000..1e3dcb6 --- /dev/null +++ b/Last3Mon/78/answers/12-lab02-threshold.json @@ -0,0 +1,790 @@ +{ + "cases": "lab02-gate.json", + "webhook": "my-faq-import", + "passed": 6, + "total": 6, + "results": [ + { + "case": "B-1 · 기본 기준 0.3 → 거절", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 422, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "B-1 · 기준을 0.8로 풀면 → 받음, 넣는 것은 2줄", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 9.0, + "status": 200, + "request": { + "max_invalid_ratio": 0.8, + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "batch": "B-1", + "total": 13, + "imported": 2, + "quarantined": 11, + "rejected": false, + "score": 0.1538, + "max_invalid_ratio": 0.8, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-101", + "FAQ-102" + ], + "quarantined_ids": [ + "FAQ-103", + "FAQ-10", + "FAQ-105", + "FAQ-106", + "FAQ-102", + "FAQ-108", + "FAQ-109", + "FAQ-110", + "FAQ-003", + "FAQ-112", + "FAQ-113" + ] + } + }, + { + "case": "FAQ만 처음으로 (B-1이 넣은 2줄을 비움, 격리함은 그대로)", + "webhook": "http://localhost:8800/faq-reset", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "cleared": 2 + } + }, + { + "case": "B-2 · 기준 0.05 → 거절", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 422, + "request": { + "max_invalid_ratio": 0.05, + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-2", + "invalid_ratio": 0.1, + "max_invalid_ratio": 0.05, + "codes": { + "pii": 1 + } + } + }, + { + "case": "B-2 · 기준 0.1 · 나쁜 줄이 딱 10% → 받음", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 8.7, + "status": 200, + "request": { + "max_invalid_ratio": 0.1, + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.1, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } + }, + { + "case": "격리함이 13줄 (B-1 11 + B-2 2)", + "webhook": "http://localhost:8800/quarantine/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 13, + "items": [ + { + "row_id": "FAQ-103", + "batch": "B-1", + "row": { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + "errors": [ + "title_missing" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-10", + "batch": "B-1", + "row": { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + "errors": [ + "id_format" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-105", + "batch": "B-1", + "row": { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + "errors": [ + "answer_missing" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-106", + "batch": "B-1", + "row": { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + "errors": [ + "answer_too_long" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-113", + "batch": "B-1", + "row": { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-108", + "batch": "B-1", + "row": { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + "errors": [ + "keywords_missing" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-003", + "batch": "B-1", + "row": { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + "errors": [ + "exists_id" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-109", + "batch": "B-1", + "row": { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-110", + "batch": "B-1", + "row": { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + "errors": [ + "external_link" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-112", + "batch": "B-1", + "row": { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + "errors": [ + "same_title" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-102", + "batch": "B-1", + "row": { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + "errors": [ + "duplicate_in_batch" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-210", + "batch": "B-2", + "row": { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:12:48" + }, + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:12:49" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/12-lab02-threshold.txt b/Last3Mon/78/answers/12-lab02-threshold.txt new file mode 100644 index 0000000..83f50ba --- /dev/null +++ b/Last3Mon/78/answers/12-lab02-threshold.txt @@ -0,0 +1,792 @@ +$ uv run tools/flow.py test cases/lab02-gate.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1 +✔ B-1 · 기본 기준 0.3 → 거절 · 0.1s + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ B-1 · 기준을 0.8로 풀면 → 받음, 넣는 것은 2줄 · 9.0s + → request POST /webhook/my-faq-import + { + "max_invalid_ratio": 0.8, + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-1", + "total": 13, + "imported": 2, + "quarantined": 11, + "rejected": false, + "score": 0.1538, + "max_invalid_ratio": 0.8, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-101", + "FAQ-102" + ], + "quarantined_ids": [ + "FAQ-103", + "FAQ-10", + "FAQ-105", + "FAQ-106", + "FAQ-102", + "FAQ-108", + "FAQ-109", + "FAQ-110", + "FAQ-003", + "FAQ-112", + "FAQ-113" + ] + } +✔ FAQ만 처음으로 (B-1이 넣은 2줄을 비움, 격리함은 그대로) · 0.0s + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ B-2 · 기준 0.05 → 거절 · 0.1s + → request POST /webhook/my-faq-import + { + "max_invalid_ratio": 0.05, + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-2", + "invalid_ratio": 0.1, + "max_invalid_ratio": 0.05, + "codes": { + "pii": 1 + } + } +✔ B-2 · 기준 0.1 · 나쁜 줄이 딱 10% → 받음 · 8.7s + → request POST /webhook/my-faq-import + { + "max_invalid_ratio": 0.1, + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.1, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } +✔ 격리함이 13줄 (B-1 11 + B-2 2) · 0.0s + → request POST http://localhost:8800/quarantine/list + {} + ← response HTTP 200 + { + "size": 13, + "items": [ + { + "row_id": "FAQ-103", + "batch": "B-1", + "row": { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + "errors": [ + "title_missing" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-10", + "batch": "B-1", + "row": { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + "errors": [ + "id_format" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-105", + "batch": "B-1", + "row": { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + "errors": [ + "answer_missing" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-106", + "batch": "B-1", + "row": { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + "errors": [ + "answer_too_long" + ], + "at": "2026-11-01T18:12:39" + }, + { + "row_id": "FAQ-113", + "batch": "B-1", + "row": { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-108", + "batch": "B-1", + "row": { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + "errors": [ + "keywords_missing" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-003", + "batch": "B-1", + "row": { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + "errors": [ + "exists_id" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-109", + "batch": "B-1", + "row": { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-110", + "batch": "B-1", + "row": { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + "errors": [ + "external_link" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-112", + "batch": "B-1", + "row": { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + "errors": [ + "same_title" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-102", + "batch": "B-1", + "row": { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + "errors": [ + "duplicate_in_batch" + ], + "at": "2026-11-01T18:12:40" + }, + { + "row_id": "FAQ-210", + "batch": "B-2", + "row": { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:12:48" + }, + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:12:49" + } + ] + } +Passed 6/6 +report: runs/lab02-gate-20261101-181249.json + +$ uv run tools/flow.py quality +FAQ: 14 (FAQ-202, FAQ-201, FAQ-203, FAQ-204, FAQ-205, FAQ-207, FAQ-208, FAQ-206) +quarantine: 13 + B-1 FAQ-108 keywords_missing + B-1 FAQ-003 exists_id + B-1 FAQ-109 pii + B-1 FAQ-110 external_link + B-1 FAQ-112 same_title + B-1 FAQ-102 duplicate_in_batch + B-2 FAQ-210 llm_mismatch + B-2 FAQ-209 pii +quality log: 0 +alerts: 0 diff --git a/Last3Mon/78/answers/13-lab02-challenge-before.json b/Last3Mon/78/answers/13-lab02-challenge-before.json new file mode 100644 index 0000000..64c93dd --- /dev/null +++ b/Last3Mon/78/answers/13-lab02-challenge-before.json @@ -0,0 +1,330 @@ +{ + "cases": "lab02-challenge.json", + "webhook": "my-faq-import", + "passed": 2, + "total": 5, + "results": [ + { + "case": "거절도 품질 기록", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 422, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "배치 2 가져오기", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 8.7, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } + }, + { + "case": "품질 기록 두 줄 (거절 · 0.8)", + "webhook": "http://localhost:8800/quality-log/list", + "run": 1, + "passed": false, + "problems": [ + "count = 0 (expected 2)", + "items.0.rejected = null (expected true)", + "items.1.score = null (expected 0.8)", + "items.1.valid = null (expected 8)", + "items.1.quarantined = null (expected 2)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 0, + "items": [] + } + }, + { + "case": "알림 두 건 (거절 · 낮은 점수)", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": false, + "problems": [ + "count = 0 (expected 2)", + "alerts has none of ['batch_rejected']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 0, + "alerts": [] + } + }, + { + "case": "알림에 낮은 점수", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": false, + "problems": [ + "alerts has none of ['low_quality']" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 0, + "alerts": [] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/13-lab02-challenge-before.txt b/Last3Mon/78/answers/13-lab02-challenge-before.txt new file mode 100644 index 0000000..63d8a0c --- /dev/null +++ b/Last3Mon/78/answers/13-lab02-challenge-before.txt @@ -0,0 +1,320 @@ +$ uv run tools/flow.py test cases/lab02-challenge.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 13} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 13 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/my-faq-import · 5 case(s) × 1 +✔ 거절도 품질 기록 · 0.1s + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ 배치 2 가져오기 · 8.7s + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } +✘ 품질 기록 두 줄 (거절 · 0.8) · 0.0s · count = 0 (expected 2); items.0.rejected = null (expected true); items.1.score = null (expected 0.8); items.1.valid = null (expected 8); items.1.quarantined = null (expected 2) + → request POST http://localhost:8800/quality-log/list + {} + ← response HTTP 200 + { + "count": 0, + "items": [] + } +✘ 알림 두 건 (거절 · 낮은 점수) · 0.0s · count = 0 (expected 2); alerts has none of ['batch_rejected'] + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 0, + "alerts": [] + } +✘ 알림에 낮은 점수 · 0.0s · alerts has none of ['low_quality'] + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 0, + "alerts": [] + } +Passed 2/5 +report: runs/lab02-challenge-20261101-180707.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } diff --git a/Last3Mon/78/answers/14-lab02-challenge.json b/Last3Mon/78/answers/14-lab02-challenge.json new file mode 100644 index 0000000..82f5007 --- /dev/null +++ b/Last3Mon/78/answers/14-lab02-challenge.json @@ -0,0 +1,396 @@ +{ + "cases": "lab02-challenge.json", + "webhook": "my-faq-import", + "passed": 5, + "total": 5, + "results": [ + { + "case": "거절도 품질 기록", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.3, + "status": 422, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "배치 2 가져오기", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 9.4, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } + }, + { + "case": "품질 기록 두 줄 (거절 · 0.8)", + "webhook": "http://localhost:8800/quality-log/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "items": [ + { + "batch": "B-1", + "dataset": "faq", + "total": 13, + "valid": 3, + "quarantined": 0, + "rejected": true, + "score": 0, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "at": "2026-11-01T18:07:21" + }, + { + "batch": "B-2", + "dataset": "faq", + "total": 10, + "valid": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "at": "2026-11-01T18:07:31" + } + ] + } + }, + { + "case": "알림 두 건 (거절 · 낮은 점수)", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "alerts": [ + { + "workflow": "faq-import", + "node": "Batch ok?", + "severity": "high", + "reason": "batch_rejected", + "batch": "B-1", + "message": "B-1 rejected", + "at": 1793524041.7771661 + }, + { + "workflow": "faq-import", + "node": "Low score?", + "severity": "high", + "reason": "low_quality", + "batch": "B-2", + "score": 0.8, + "message": "B-2 quality 0.8", + "at": 1793524051.234785 + } + ] + } + }, + { + "case": "알림에 낮은 점수", + "webhook": "http://localhost:8800/alerts/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "alerts": [ + { + "workflow": "faq-import", + "node": "Batch ok?", + "severity": "high", + "reason": "batch_rejected", + "batch": "B-1", + "message": "B-1 rejected", + "at": 1793524041.7771661 + }, + { + "workflow": "faq-import", + "node": "Low score?", + "severity": "high", + "reason": "low_quality", + "batch": "B-2", + "score": 0.8, + "message": "B-2 quality 0.8", + "at": 1793524051.234785 + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/14-lab02-challenge.txt b/Last3Mon/78/answers/14-lab02-challenge.txt new file mode 100644 index 0000000..6b7eeef --- /dev/null +++ b/Last3Mon/78/answers/14-lab02-challenge.txt @@ -0,0 +1,436 @@ +$ uv run tools/flow.py push answers/78-lab02-challenge-log-alert.json +! unpublished '78 FAQ import + LLM check (reference)' (wee5dfb169c284f5): it used the same webhook path +✔ pushed '78 FAQ import + quality log and alerts (answer)' (id w53039b5a517bf0d) + POST http://localhost:5678/webhook/my-faq-import + +$ uv run tools/flow.py test cases/lab02-challenge.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.1s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +POST http://localhost:5678/webhook/my-faq-import · 5 case(s) × 1 +✔ 거절도 품질 기록 · 1.3s + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✔ 배치 2 가져오기 · 9.4s + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } +✔ 품질 기록 두 줄 (거절 · 0.8) · 0.0s + → request POST http://localhost:8800/quality-log/list + {} + ← response HTTP 200 + { + "count": 2, + "items": [ + { + "batch": "B-1", + "dataset": "faq", + "total": 13, + "valid": 3, + "quarantined": 0, + "rejected": true, + "score": 0, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "at": "2026-11-01T18:07:21" + }, + { + "batch": "B-2", + "dataset": "faq", + "total": 10, + "valid": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "at": "2026-11-01T18:07:31" + } + ] + } +✔ 알림 두 건 (거절 · 낮은 점수) · 0.0s + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 2, + "alerts": [ + { + "workflow": "faq-import", + "node": "Batch ok?", + "severity": "high", + "reason": "batch_rejected", + "batch": "B-1", + "message": "B-1 rejected", + "at": 1793524041.7771661 + }, + { + "workflow": "faq-import", + "node": "Low score?", + "severity": "high", + "reason": "low_quality", + "batch": "B-2", + "score": 0.8, + "message": "B-2 quality 0.8", + "at": 1793524051.234785 + } + ] + } +✔ 알림에 낮은 점수 · 0.0s + → request POST http://localhost:8800/alerts/list + {} + ← response HTTP 200 + { + "count": 2, + "alerts": [ + { + "workflow": "faq-import", + "node": "Batch ok?", + "severity": "high", + "reason": "batch_rejected", + "batch": "B-1", + "message": "B-1 rejected", + "at": 1793524041.7771661 + }, + { + "workflow": "faq-import", + "node": "Low score?", + "severity": "high", + "reason": "low_quality", + "batch": "B-2", + "score": 0.8, + "message": "B-2 quality 0.8", + "at": 1793524051.234785 + } + ] + } +Passed 5/5 +report: runs/lab02-challenge-20261101-180731.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 8 + } + +$ uv run tools/flow.py test cases/lab02.json +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} +POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1 +✔ 나쁜 배치는 통째로 거절 (422) · 0.1s +✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 8.8s +✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s +✔ 격리함에 이유와 함께 · 0.0s +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.6s +✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 4.5s +Passed 6/6 +report: runs/lab02-20261101-180753.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13} + +$ uv run tools/flow.py quality +FAQ: 6 (nothing added) +quarantine: 3 + B-2 FAQ-210 llm_mismatch + B-2 FAQ-209 pii + B-3 FAQ-305 answer_too_short +quality log: 4 + B-1 faq total 13 valid 3 score 0 rejected True + B-2 faq total 10 valid 8 score 0.8 rejected False + B-2 faq total 10 valid 8 score 0.8 rejected False + B-3 faq total 6 valid 5 score 0.8333 rejected False +alerts: 4 + [high] faq-import · low_quality · B-2 quality 0.8 + [high] faq-import · low_quality · B-2 quality 0.8 + [high] faq-import · low_quality · B-3 quality 0.8333 diff --git a/Last3Mon/78/answers/15-lab02-challenge2-before.json b/Last3Mon/78/answers/15-lab02-challenge2-before.json new file mode 100644 index 0000000..e80cea5 --- /dev/null +++ b/Last3Mon/78/answers/15-lab02-challenge2-before.json @@ -0,0 +1,368 @@ +{ + "cases": "lab02-challenge2.json", + "webhook": "my-faq-import", + "passed": 0, + "total": 3, + "results": [ + { + "case": "거절된 배치는 LLM을 부르지 않음", + "webhook": "my-faq-import", + "run": 1, + "passed": false, + "problems": [ + "llm_calls = null (expected 0)" + ], + "seconds": 0.1, + "status": 422, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } + }, + { + "case": "B-2 · 규칙을 통과한 9줄만 LLM에", + "webhook": "my-faq-import", + "run": 1, + "passed": false, + "problems": [ + "llm_calls = null (expected 9)" + ], + "seconds": 8.6, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } + }, + { + "case": "B-3 · 5줄만 LLM에", + "webhook": "my-faq-import", + "run": 1, + "passed": false, + "problems": [ + "llm_calls = null (expected 5)" + ], + "seconds": 4.3, + "status": 200, + "request": { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + }, + "response": { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/15-lab02-challenge2-before.txt b/Last3Mon/78/answers/15-lab02-challenge2-before.txt new file mode 100644 index 0000000..3a41e86 --- /dev/null +++ b/Last3Mon/78/answers/15-lab02-challenge2-before.txt @@ -0,0 +1,368 @@ +$ uv run tools/flow.py test cases/lab02-challenge2.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 4} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 4 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 4} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 4 + } +POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1 +✘ 거절된 배치는 LLM을 부르지 않음 · 0.1s · llm_calls = null (expected 0) + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + } + } +✘ B-2 · 규칙을 통과한 9줄만 LLM에 · 8.6s · llm_calls = null (expected 9) + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ] + } +✘ B-3 · 5줄만 LLM에 · 4.3s · llm_calls = null (expected 5) + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ] + } +Passed 0/3 +report: runs/lab02-challenge2-20261101-180806.json diff --git a/Last3Mon/78/answers/16-lab02-challenge2.json b/Last3Mon/78/answers/16-lab02-challenge2.json new file mode 100644 index 0000000..c63b40f --- /dev/null +++ b/Last3Mon/78/answers/16-lab02-challenge2.json @@ -0,0 +1,365 @@ +{ + "cases": "lab02-challenge2.json", + "webhook": "my-faq-import", + "passed": 3, + "total": 3, + "results": [ + { + "case": "거절된 배치는 LLM을 부르지 않음", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 1.1, + "status": 422, + "request": { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + }, + "response": { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "llm_calls": 0 + } + }, + { + "case": "B-2 · 규칙을 통과한 9줄만 LLM에", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 9.4, + "status": 200, + "request": { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + }, + "response": { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ], + "llm_calls": 9 + } + }, + { + "case": "B-3 · 5줄만 LLM에", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 4.1, + "status": 200, + "request": { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + }, + "response": { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ], + "llm_calls": 5 + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/16-lab02-challenge2.txt b/Last3Mon/78/answers/16-lab02-challenge2.txt new file mode 100644 index 0000000..6a413e3 --- /dev/null +++ b/Last3Mon/78/answers/16-lab02-challenge2.txt @@ -0,0 +1,444 @@ +$ uv run tools/flow.py push answers/78-lab02-challenge2-llm-calls.json +! unpublished '78 FAQ import + quality log and alerts (answer)' (w53039b5a517bf0d): it used the same webhook path +✔ pushed '78 FAQ import + LLM calls (answer)' (id w4b0730166dd93f5) + POST http://localhost:5678/webhook/my-faq-import + +$ uv run tools/flow.py test cases/lab02-challenge2.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 13 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1 +✔ 거절된 배치는 LLM을 부르지 않음 · 1.1s + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "llm_calls": 0 + } +✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 9.4s + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ], + "llm_calls": 9 + } +✔ B-3 · 5줄만 LLM에 · 4.1s + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ], + "llm_calls": 5 + } +Passed 3/3 +report: runs/lab02-challenge2-20261101-180834.json + +$ uv run tools/flow.py test cases/lab02-challenge2.json +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1 +✔ 거절된 배치는 LLM을 부르지 않음 · 0.1s +✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 8.1s +✔ B-3 · 5줄만 LLM에 · 4.4s +Passed 3/3 +report: runs/lab02-challenge2-20261101-180846.json + +$ uv run tools/flow.py test cases/lab02-challenge2.json +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} +POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 1 +✔ 거절된 배치는 LLM을 부르지 않음 · 0.1s +✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 7.6s +✔ B-3 · 5줄만 LLM에 · 4.0s +Passed 3/3 +report: runs/lab02-challenge2-20261101-180858.json + +$ uv run tools/flow.py test cases/lab02-challenge2.json --no-before --repeat 2 +POST http://localhost:5678/webhook/my-faq-import · 3 case(s) × 2 +✔ 거절된 배치는 LLM을 부르지 않음 #1 · 0.1s +✔ 거절된 배치는 LLM을 부르지 않음 #2 · 0.1s +✘ B-2 · 규칙을 통과한 9줄만 LLM에 #1 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 9); imported = null (expected 8); quarantined_ids has none of ['FAQ-210'] +✘ B-2 · 규칙을 통과한 9줄만 LLM에 #2 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 9); imported = null (expected 8); quarantined_ids has none of ['FAQ-210'] +✘ B-3 · 5줄만 LLM에 #1 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 5); imported = null (expected 5) +✘ B-3 · 5줄만 LLM에 #2 · 0.1s · status 422 (expected 200); llm_calls = 0 (expected 5); imported = null (expected 5) +Passed 2/6 +report: runs/lab02-challenge2-20261101-180859.json + +$ uv run tools/flow.py test cases/lab02.json +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 9} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 9} +POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1 +✔ 나쁜 배치는 통째로 거절 (422) · 0.1s +✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 8.2s +✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s +✔ 격리함에 이유와 함께 · 0.0s +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2} +✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.1s +✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 5.0s +Passed 6/6 +report: runs/lab02-20261101-180921.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 13} + +$ uv run tools/flow.py test cases/lab02-challenge.json +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 3} +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 4} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 4} +POST http://localhost:5678/webhook/my-faq-import · 5 case(s) × 1 +✔ 거절도 품질 기록 · 0.1s +✔ 배치 2 가져오기 · 8.5s +✔ 품질 기록 두 줄 (거절 · 0.8) · 0.0s +✔ 알림 두 건 (거절 · 낮은 점수) · 0.0s +✔ 알림에 낮은 점수 · 0.0s +Passed 5/5 +report: runs/lab02-challenge-20261101-180930.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 8} diff --git a/Last3Mon/78/answers/17-lab03-before.json b/Last3Mon/78/answers/17-lab03-before.json new file mode 100644 index 0000000..5b1ec97 --- /dev/null +++ b/Last3Mon/78/answers/17-lab03-before.json @@ -0,0 +1,154 @@ +{ + "cases": "lab03.json", + "webhook": "my-check", + "passed": 0, + "total": 4, + "results": [ + { + "case": "직원 CSV · 9줄 중 4줄 통과", + "webhook": "my-check", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "schema = null (expected employee)", + "total = null (expected 9)", + "valid = null (expected 4)", + "rows.6.ok = null (expected true)" + ], + "seconds": 0.0, + "status": 404, + "request": { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + }, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "직원 CSV · 줄마다 이유", + "webhook": "my-check", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "rows.2.errors has none of ['bad_format:email']", + "rows.3.errors has none of ['not_allowed:dept']", + "rows.4.errors has none of ['bad_format:joined']", + "rows.5.errors has none of ['missing:name']", + "rows.7.errors has none of ['duplicate_in_batch']" + ], + "seconds": 0.0, + "status": 404, + "request": { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + }, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "같은 워크플로우로 FAQ 스키마", + "webhook": "my-check", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "schema = null (expected faq)", + "total = null (expected 6)", + "valid = null (expected 5)", + "rows.4.errors has none of ['too_short:answer']" + ], + "seconds": 0.0, + "status": 404, + "request": { + "schema": "faq", + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + }, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "모르는 스키마 → 400", + "webhook": "my-check", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 400)" + ], + "seconds": 0.0, + "status": 404, + "request": { + "schema": "invoice", + "items": [] + }, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/17-lab03-before.txt b/Last3Mon/78/answers/17-lab03-before.txt new file mode 100644 index 0000000..37d1ec3 --- /dev/null +++ b/Last3Mon/78/answers/17-lab03-before.txt @@ -0,0 +1,111 @@ +$ uv run tools/flow.py call my-faq-check requests/employees-new.json +HTTP 400 · 0.0s +{ + "error": "items must be a list" +} + +$ uv run tools/flow.py test cases/lab03.json -v +POST http://localhost:5678/webhook/my-check · 4 case(s) × 1 +✘ 직원 CSV · 9줄 중 4줄 통과 · 0.0s · status 404 (expected 200); schema = null (expected employee); total = null (expected 9); valid = null (expected 4); rows.6.ok = null (expected true) + → request POST /webhook/my-check + { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + } + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ 직원 CSV · 줄마다 이유 · 0.0s · status 404 (expected 200); rows.2.errors has none of ['bad_format:email']; rows.3.errors has none of ['not_allowed:dept']; rows.4.errors has none of ['bad_format:joined']; rows.5.errors has none of ['missing:name']; rows.7.errors has none of ['duplicate_in_batch'] + → request POST /webhook/my-check + { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + } + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ 같은 워크플로우로 FAQ 스키마 · 0.0s · status 404 (expected 200); schema = null (expected faq); total = null (expected 6); valid = null (expected 5); rows.4.errors has none of ['too_short:answer'] + → request POST /webhook/my-check + { + "schema": "faq", + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ 모르는 스키마 → 400 · 0.0s · status 404 (expected 400) + → request POST /webhook/my-check + { + "schema": "invoice", + "items": [] + } + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-check\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +Passed 0/4 +report: runs/lab03-20261101-180930.json diff --git a/Last3Mon/78/answers/18-lab03-schema-check.json b/Last3Mon/78/answers/18-lab03-schema-check.json new file mode 100644 index 0000000..fd8d05a --- /dev/null +++ b/Last3Mon/78/answers/18-lab03-schema-check.json @@ -0,0 +1,327 @@ +{ + "cases": "lab03.json", + "webhook": "my-check", + "passed": 4, + "total": 4, + "results": [ + { + "case": "직원 CSV · 9줄 중 4줄 통과", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.9, + "status": 200, + "request": { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + }, + "response": { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } + }, + { + "case": "직원 CSV · 줄마다 이유", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + }, + "response": { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } + }, + { + "case": "같은 워크플로우로 FAQ 스키마", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "schema": "faq", + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + }, + "response": { + "schema": "faq", + "batch": "B-3", + "total": 6, + "valid": 5, + "invalid": 1, + "rows": [ + { + "row": 1, + "key": "FAQ-301", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "FAQ-302", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "FAQ-303", + "ok": true, + "errors": [] + }, + { + "row": 4, + "key": "FAQ-304", + "ok": true, + "errors": [] + }, + { + "row": 5, + "key": "FAQ-305", + "ok": false, + "errors": [ + "too_short:answer" + ] + }, + { + "row": 6, + "key": "FAQ-306", + "ok": true, + "errors": [] + } + ], + "codes": { + "too_short:answer": 1 + } + } + }, + { + "case": "모르는 스키마 → 400", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 400, + "request": { + "schema": "invoice", + "items": [] + }, + "response": { + "error": "unknown schema", + "schema": "invoice" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/18-lab03-schema-check.txt b/Last3Mon/78/answers/18-lab03-schema-check.txt new file mode 100644 index 0000000..435d5f0 --- /dev/null +++ b/Last3Mon/78/answers/18-lab03-schema-check.txt @@ -0,0 +1,303 @@ +$ uv run tools/flow.py push reference/lab03-example1-schema-check.json +✔ pushed '78 Schema check (reference)' (id w6b17d7c76908a6a) + POST http://localhost:5678/webhook/my-check + +$ uv run tools/flow.py test cases/lab03.json -v +POST http://localhost:5678/webhook/my-check · 4 case(s) × 1 +✔ 직원 CSV · 9줄 중 4줄 통과 · 0.9s + → request POST /webhook/my-check + { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + } + ← response HTTP 200 + { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } +✔ 직원 CSV · 줄마다 이유 · 0.0s + → request POST /webhook/my-check + { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + } + ← response HTTP 200 + { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } +✔ 같은 워크플로우로 FAQ 스키마 · 0.1s + → request POST /webhook/my-check + { + "schema": "faq", + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "schema": "faq", + "batch": "B-3", + "total": 6, + "valid": 5, + "invalid": 1, + "rows": [ + { + "row": 1, + "key": "FAQ-301", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "FAQ-302", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "FAQ-303", + "ok": true, + "errors": [] + }, + { + "row": 4, + "key": "FAQ-304", + "ok": true, + "errors": [] + }, + { + "row": 5, + "key": "FAQ-305", + "ok": false, + "errors": [ + "too_short:answer" + ] + }, + { + "row": 6, + "key": "FAQ-306", + "ok": true, + "errors": [] + } + ], + "codes": { + "too_short:answer": 1 + } + } +✔ 모르는 스키마 → 400 · 0.0s + → request POST /webhook/my-check + { + "schema": "invoice", + "items": [] + } + ← response HTTP 400 + { + "error": "unknown schema", + "schema": "invoice" + } +Passed 4/4 +report: runs/lab03-20261101-180943.json diff --git a/Last3Mon/78/answers/19-lab03-fix-before.json b/Last3Mon/78/answers/19-lab03-fix-before.json new file mode 100644 index 0000000..2b72bb5 --- /dev/null +++ b/Last3Mon/78/answers/19-lab03-fix-before.json @@ -0,0 +1,317 @@ +{ + "cases": "lab03-fix.json", + "webhook": "my-faq-import", + "passed": 3, + "total": 4, + "results": [ + { + "case": "고친 두 줄 다시 가져오기", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.5, + "status": 200, + "request": { + "batch": "B-2-fix", + "items": [ + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + }, + "response": { + "batch": "B-2-fix", + "total": 2, + "imported": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "max_invalid_ratio": 0.3, + "codes": {}, + "imported_ids": [ + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [], + "llm_calls": 2 + } + }, + { + "case": "격리함이 비었나", + "webhook": "http://localhost:8800/quarantine/list", + "run": 1, + "passed": false, + "problems": [ + "size = 2 (expected 0)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 2, + "items": [ + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:09:51" + }, + { + "row_id": "FAQ-210", + "batch": "B-2", + "row": { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:09:51" + } + ] + } + }, + { + "case": "FAQ에 FAQ-209 · FAQ-210", + "webhook": "http://localhost:8800/faq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 16, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } + }, + { + "case": "품질 기록 · 점수 1", + "webhook": "http://localhost:8800/quality-log/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "items": [ + { + "batch": "B-2", + "dataset": "faq", + "total": 10, + "valid": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "at": "2026-11-01T18:09:51" + }, + { + "batch": "B-2-fix", + "dataset": "faq", + "total": 2, + "valid": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "codes": {}, + "at": "2026-11-01T18:09:54" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/19-lab03-fix-before.txt b/Last3Mon/78/answers/19-lab03-fix-before.txt new file mode 100644 index 0000000..2d25511 --- /dev/null +++ b/Last3Mon/78/answers/19-lab03-fix-before.txt @@ -0,0 +1,457 @@ +$ uv run tools/flow.py test cases/lab03-fix.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3 + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ], + "llm_calls": 9 + } +POST http://localhost:5678/webhook/my-faq-import · 4 case(s) × 1 +✔ 고친 두 줄 다시 가져오기 · 2.5s + → request POST /webhook/my-faq-import + { + "batch": "B-2-fix", + "items": [ + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2-fix", + "total": 2, + "imported": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "max_invalid_ratio": 0.3, + "codes": {}, + "imported_ids": [ + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [], + "llm_calls": 2 + } +✘ 격리함이 비었나 · 0.0s · size = 2 (expected 0) + → request POST http://localhost:8800/quarantine/list + {} + ← response HTTP 200 + { + "size": 2, + "items": [ + { + "row_id": "FAQ-209", + "batch": "B-2", + "row": { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + "errors": [ + "pii" + ], + "at": "2026-11-01T18:09:51" + }, + { + "row_id": "FAQ-210", + "batch": "B-2", + "row": { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + }, + "errors": [ + "llm_mismatch" + ], + "at": "2026-11-01T18:09:51" + } + ] + } +✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s + → request POST http://localhost:8800/faq/list + {} + ← response HTTP 200 + { + "count": 16, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } +✔ 품질 기록 · 점수 1 · 0.0s + → request POST http://localhost:8800/quality-log/list + {} + ← response HTTP 200 + { + "count": 2, + "items": [ + { + "batch": "B-2", + "dataset": "faq", + "total": 10, + "valid": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "at": "2026-11-01T18:09:51" + }, + { + "batch": "B-2-fix", + "dataset": "faq", + "total": 2, + "valid": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "codes": {}, + "at": "2026-11-01T18:09:54" + } + ] + } +Passed 3/4 +report: runs/lab03-fix-20261101-180954.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 10} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 10 + } + +$ uv run tools/flow.py quality +FAQ: 6 (nothing added) +quarantine: 2 + B-2 FAQ-209 pii + B-2 FAQ-210 llm_mismatch +quality log: 2 + B-2 faq total 10 valid 8 score 0.8 rejected False + B-2-fix faq total 2 valid 2 score 1 rejected False +alerts: 1 + [high] faq-import · low_quality · B-2 quality 0.8 diff --git a/Last3Mon/78/answers/20-lab03-fix.json b/Last3Mon/78/answers/20-lab03-fix.json new file mode 100644 index 0000000..87f5fcd --- /dev/null +++ b/Last3Mon/78/answers/20-lab03-fix.json @@ -0,0 +1,281 @@ +{ + "cases": "lab03-fix.json", + "webhook": "my-faq-import", + "passed": 4, + "total": 4, + "results": [ + { + "case": "고친 두 줄 다시 가져오기", + "webhook": "my-faq-import", + "run": 1, + "passed": true, + "problems": [], + "seconds": 2.0, + "status": 200, + "request": { + "batch": "B-2-fix", + "items": [ + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + }, + "response": { + "batch": "B-2-fix", + "total": 2, + "imported": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "max_invalid_ratio": 0.3, + "codes": {}, + "imported_ids": [ + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [], + "llm_calls": 2 + } + }, + { + "case": "격리함이 비었나", + "webhook": "http://localhost:8800/quarantine/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "size": 0, + "items": [] + } + }, + { + "case": "FAQ에 FAQ-209 · FAQ-210", + "webhook": "http://localhost:8800/faq/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 16, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } + }, + { + "case": "품질 기록 · 점수 1", + "webhook": "http://localhost:8800/quality-log/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 2, + "items": [ + { + "batch": "B-2", + "dataset": "faq", + "total": 10, + "valid": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "at": "2026-11-01T18:10:18" + }, + { + "batch": "B-2-fix", + "dataset": "faq", + "total": 2, + "valid": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "codes": {}, + "at": "2026-11-01T18:10:20" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/20-lab03-fix.txt b/Last3Mon/78/answers/20-lab03-fix.txt new file mode 100644 index 0000000..e2ac65f --- /dev/null +++ b/Last3Mon/78/answers/20-lab03-fix.txt @@ -0,0 +1,426 @@ +$ uv run tools/flow.py push reference/lab03-example2-import.json +! unpublished '78 FAQ import + LLM calls (answer)' (w4b0730166dd93f5): it used the same webhook path +✔ pushed '78 FAQ import + release fixed rows (reference)' (id we9ca88e4f314222) + POST http://localhost:5678/webhook/my-faq-import + +$ uv run tools/flow.py test cases/lab03-fix.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 10.7s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3 + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ], + "llm_calls": 9 + } +POST http://localhost:5678/webhook/my-faq-import · 4 case(s) × 1 +✔ 고친 두 줄 다시 가져오기 · 2.0s + → request POST /webhook/my-faq-import + { + "batch": "B-2-fix", + "items": [ + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2-fix", + "total": 2, + "imported": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "max_invalid_ratio": 0.3, + "codes": {}, + "imported_ids": [ + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [], + "llm_calls": 2 + } +✔ 격리함이 비었나 · 0.0s + → request POST http://localhost:8800/quarantine/list + {} + ← response HTTP 200 + { + "size": 0, + "items": [] + } +✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s + → request POST http://localhost:8800/faq/list + {} + ← response HTTP 200 + { + "count": 16, + "faqs": [ + { + "id": "FAQ-001", + "title": "비밀번호 재설정", + "keywords": [ + "비밀번호", + "패스워드", + "password", + "재설정", + "초기화" + ], + "answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다." + }, + { + "id": "FAQ-002", + "title": "계정 잠김 해제", + "keywords": [ + "계정", + "잠김", + "잠금", + "로그인", + "실패" + ], + "answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "원격", + "재택", + "접속", + "연결" + ], + "answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다." + }, + { + "id": "FAQ-004", + "title": "프린터 출력 안 됨", + "keywords": [ + "프린터", + "출력", + "인쇄", + "용지" + ], + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다." + }, + { + "id": "FAQ-005", + "title": "메일함 용량 초과", + "keywords": [ + "메일", + "용량", + "메일함", + "첨부" + ], + "answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다." + }, + { + "id": "FAQ-006", + "title": "소프트웨어 설치 요청", + "keywords": [ + "설치", + "소프트웨어", + "프로그램", + "라이선스" + ], + "answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다." + }, + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } +✔ 품질 기록 · 점수 1 · 0.0s + → request POST http://localhost:8800/quality-log/list + {} + ← response HTTP 200 + { + "count": 2, + "items": [ + { + "batch": "B-2", + "dataset": "faq", + "total": 10, + "valid": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "at": "2026-11-01T18:10:18" + }, + { + "batch": "B-2-fix", + "dataset": "faq", + "total": 2, + "valid": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "codes": {}, + "at": "2026-11-01T18:10:20" + } + ] + } +Passed 4/4 +report: runs/lab03-fix-20261101-181020.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 10} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 10 + } + +$ uv run tools/flow.py quality +FAQ: 6 (nothing added) +quarantine: 0 +quality log: 2 + B-2 faq total 10 valid 8 score 0.8 rejected False + B-2-fix faq total 2 valid 2 score 1 rejected False +alerts: 1 + [high] faq-import · low_quality · B-2 quality 0.8 diff --git a/Last3Mon/78/answers/21-lab03-new-schema.json b/Last3Mon/78/answers/21-lab03-new-schema.json new file mode 100644 index 0000000..b032c17 --- /dev/null +++ b/Last3Mon/78/answers/21-lab03-new-schema.json @@ -0,0 +1,617 @@ +{ + "cases": "lab03-schema.json", + "webhook": "my-check", + "passed": 5, + "total": 5, + "results": [ + { + "case": "스키마 목록에 ticket이 있다", + "webhook": "http://localhost:8800/schemas/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "schemas": [ + "employee", + "faq", + "ticket" + ] + } + }, + { + "case": "새 스키마 ticket · 워크플로우는 그대로 · 9줄 중 2줄 통과", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + }, + "response": { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } + }, + { + "case": "형식 · 목록 · 날짜 · 길이", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + }, + "response": { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } + }, + { + "case": "선택 칸 형식 · 중복 · 빈 칸, 선택 칸이 없어도 통과", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + }, + "response": { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } + }, + { + "case": "직원 CSV는 그대로 9줄 중 4줄", + "webhook": "my-check", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + }, + "response": { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/21-lab03-new-schema.txt b/Last3Mon/78/answers/21-lab03-new-schema.txt new file mode 100644 index 0000000..55a40d3 --- /dev/null +++ b/Last3Mon/78/answers/21-lab03-new-schema.txt @@ -0,0 +1,844 @@ +$ uv run tools/flow.py call my-faq-check requests/tickets-new.json +HTTP 200 · 0.1s +{ + "batch": "TK-1", + "total": 9, + "valid": 0, + "invalid": 9, + "with_warnings": 0, + "rows": [ + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + }, + { + "id": "", + "ok": false, + "errors": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "warnings": [] + } + ], + "codes": { + "id_format": 9, + "keywords_missing": 9, + "answer_missing": 9, + "duplicate_in_batch": 8 + }, + "fix_list": [ + { + "row": 1, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요" + ] + }, + { + "row": 2, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + }, + { + "row": 3, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + }, + { + "row": 4, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + }, + { + "row": 5, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + }, + { + "row": 6, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + }, + { + "row": 7, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + }, + { + "row": 8, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + }, + { + "row": 9, + "id": "", + "codes": [ + "id_format", + "keywords_missing", + "answer_missing", + "duplicate_in_batch" + ], + "fix": [ + "id를 FAQ-숫자 3자리로 (예: FAQ-104)", + "키워드를 하나 이상 넣어 주세요", + "답을 써 주세요", + "같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요" + ] + } + ], + "message": "TK-1: 9줄 중 0줄 통과, 9줄 고칠 것" +} + +$ uv run tools/flow.py test cases/lab03-schema.json -v +POST http://localhost:5678/webhook/my-check · 5 case(s) × 1 +✔ 스키마 목록에 ticket이 있다 · 0.0s + → request POST http://localhost:8800/schemas/list + {} + ← response HTTP 200 + { + "schemas": [ + "employee", + "faq", + "ticket" + ] + } +✔ 새 스키마 ticket · 워크플로우는 그대로 · 9줄 중 2줄 통과 · 0.1s + → request POST /webhook/my-check + { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + } + ← response HTTP 200 + { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } +✔ 형식 · 목록 · 날짜 · 길이 · 0.1s + → request POST /webhook/my-check + { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + } + ← response HTTP 200 + { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } +✔ 선택 칸 형식 · 중복 · 빈 칸, 선택 칸이 없어도 통과 · 0.0s + → request POST /webhook/my-check + { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + } + ← response HTTP 200 + { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } +✔ 직원 CSV는 그대로 9줄 중 4줄 · 0.1s + → request POST /webhook/my-check + { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + } + ← response HTTP 200 + { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } +Passed 5/5 +report: runs/lab03-schema-20261101-181021.json diff --git a/Last3Mon/78/answers/22-lab03-challenge-before.json b/Last3Mon/78/answers/22-lab03-challenge-before.json new file mode 100644 index 0000000..844e34c --- /dev/null +++ b/Last3Mon/78/answers/22-lab03-challenge-before.json @@ -0,0 +1,90 @@ +{ + "cases": "lab03-challenge.json", + "webhook": "my-quality-report", + "passed": 0, + "total": 4, + "results": [ + { + "case": "배치 네 개 · 평균 · 가장 나쁜 배치", + "webhook": "my-quality-report", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "count = null (expected 4)", + "avg_score = null (expected 0.6583)", + "worst_batch = null (expected B-1)", + "rejected_batches = null (expected 1)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "가장 많은 이유 · 흐름", + "webhook": "my-quality-report", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "top_codes.0.code = null (expected pii)", + "top_codes.0.count = null (expected 2)", + "trend = null (expected 좋아짐)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "배치마다 점수 순서", + "webhook": "my-quality-report", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "batches.1.score = null (expected 0.8)", + "batches.2.score = null (expected 0.8333)", + "batches.3.batch = null (expected B-2-fix)" + ], + "seconds": 0.0, + "status": 404, + "request": {}, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + }, + { + "case": "다른 데이터셋으로 거르기", + "webhook": "my-quality-report", + "run": 1, + "passed": false, + "problems": [ + "status 404 (expected 200)", + "count = null (expected 0)", + "avg_score = null (expected 0)" + ], + "seconds": 0.0, + "status": 404, + "request": { + "dataset": "employee" + }, + "response": { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/22-lab03-challenge-before.txt b/Last3Mon/78/answers/22-lab03-challenge-before.txt new file mode 100644 index 0000000..9e4b101 --- /dev/null +++ b/Last3Mon/78/answers/22-lab03-challenge-before.txt @@ -0,0 +1,457 @@ +$ uv run tools/flow.py test cases/lab03-challenge.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 2} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 2 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +! before: POST /webhook/my-faq-import · HTTP 422 · 0.1s (allow_fail: rejected on purpose) · {"error": "batch rejected", "batch": "B-1", "invalid_ratio": 0.7692, "max_invalid_ratio": 0.3, "codes": {"title_missing" + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "llm_calls": 0 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3 + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ], + "llm_calls": 9 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.6s · {"batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0 + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ], + "llm_calls": 5 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 1.9s · {"batch": "B-2-fix", "total": 2, "imported": 2, "quarantined": 0, "rejected": false, "score": 1, "max_invalid_ratio": 0. + → request POST /webhook/my-faq-import + { + "batch": "B-2-fix", + "items": [ + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2-fix", + "total": 2, + "imported": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "max_invalid_ratio": 0.3, + "codes": {}, + "imported_ids": [ + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [], + "llm_calls": 2 + } +POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1 +✘ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s · status 404 (expected 200); count = null (expected 4); avg_score = null (expected 0.6583); worst_batch = null (expected B-1); rejected_batches = null (expected 1) + → request POST /webhook/my-quality-report + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ 가장 많은 이유 · 흐름 · 0.0s · status 404 (expected 200); top_codes.0.code = null (expected pii); top_codes.0.count = null (expected 2); trend = null (expected 좋아짐) + → request POST /webhook/my-quality-report + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ 배치마다 점수 순서 · 0.0s · status 404 (expected 200); batches.1.score = null (expected 0.8); batches.2.score = null (expected 0.8333); batches.3.batch = null (expected B-2-fix) + → request POST /webhook/my-quality-report + {} + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +✘ 다른 데이터셋으로 거르기 · 0.0s · status 404 (expected 200); count = null (expected 0); avg_score = null (expected 0) + → request POST /webhook/my-quality-report + { + "dataset": "employee" + } + ← response HTTP 404 + { + "code": 404, + "message": "The requested webhook \"POST my-quality-report\" is not registered.", + "hint": "The workflow must be active for a production URL to run successfully. You can activate the workflow using the toggle in the top-right of the editor. Note that unlike test URL calls, production URL calls aren't shown on the canvas (only in the executions list)" + } +Passed 0/4 +report: runs/lab03-challenge-20261101-181036.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 15} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 15 + } diff --git a/Last3Mon/78/answers/23-lab03-challenge.json b/Last3Mon/78/answers/23-lab03-challenge.json new file mode 100644 index 0000000..e8b53ee --- /dev/null +++ b/Last3Mon/78/answers/23-lab03-challenge.json @@ -0,0 +1,308 @@ +{ + "cases": "lab03-challenge.json", + "webhook": "my-quality-report", + "passed": 4, + "total": 4, + "results": [ + { + "case": "배치 네 개 · 평균 · 가장 나쁜 배치", + "webhook": "my-quality-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 4, + "batches": [ + { + "batch": "B-1", + "score": 0, + "rejected": true, + "total": 13 + }, + { + "batch": "B-2", + "score": 0.8, + "rejected": false, + "total": 10 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + }, + { + "batch": "B-2-fix", + "score": 1, + "rejected": false, + "total": 2 + } + ], + "avg_score": 0.6583, + "worst_batch": "B-1", + "rejected_batches": 1, + "top_codes": [ + { + "code": "pii", + "count": 2 + }, + { + "code": "answer_missing", + "count": 1 + }, + { + "code": "answer_too_long", + "count": 1 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "exists_id", + "count": 1 + }, + { + "code": "external_link", + "count": 1 + }, + { + "code": "id_format", + "count": 1 + }, + { + "code": "keywords_missing", + "count": 1 + }, + { + "code": "llm_mismatch", + "count": 1 + }, + { + "code": "same_title", + "count": 1 + }, + { + "code": "title_missing", + "count": 1 + } + ], + "trend": "좋아짐" + } + }, + { + "case": "가장 많은 이유 · 흐름", + "webhook": "my-quality-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": {}, + "response": { + "count": 4, + "batches": [ + { + "batch": "B-1", + "score": 0, + "rejected": true, + "total": 13 + }, + { + "batch": "B-2", + "score": 0.8, + "rejected": false, + "total": 10 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + }, + { + "batch": "B-2-fix", + "score": 1, + "rejected": false, + "total": 2 + } + ], + "avg_score": 0.6583, + "worst_batch": "B-1", + "rejected_batches": 1, + "top_codes": [ + { + "code": "pii", + "count": 2 + }, + { + "code": "answer_missing", + "count": 1 + }, + { + "code": "answer_too_long", + "count": 1 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "exists_id", + "count": 1 + }, + { + "code": "external_link", + "count": 1 + }, + { + "code": "id_format", + "count": 1 + }, + { + "code": "keywords_missing", + "count": 1 + }, + { + "code": "llm_mismatch", + "count": 1 + }, + { + "code": "same_title", + "count": 1 + }, + { + "code": "title_missing", + "count": 1 + } + ], + "trend": "좋아짐" + } + }, + { + "case": "배치마다 점수 순서", + "webhook": "my-quality-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 4, + "batches": [ + { + "batch": "B-1", + "score": 0, + "rejected": true, + "total": 13 + }, + { + "batch": "B-2", + "score": 0.8, + "rejected": false, + "total": 10 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + }, + { + "batch": "B-2-fix", + "score": 1, + "rejected": false, + "total": 2 + } + ], + "avg_score": 0.6583, + "worst_batch": "B-1", + "rejected_batches": 1, + "top_codes": [ + { + "code": "pii", + "count": 2 + }, + { + "code": "answer_missing", + "count": 1 + }, + { + "code": "answer_too_long", + "count": 1 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "exists_id", + "count": 1 + }, + { + "code": "external_link", + "count": 1 + }, + { + "code": "id_format", + "count": 1 + }, + { + "code": "keywords_missing", + "count": 1 + }, + { + "code": "llm_mismatch", + "count": 1 + }, + { + "code": "same_title", + "count": 1 + }, + { + "code": "title_missing", + "count": 1 + } + ], + "trend": "좋아짐" + } + }, + { + "case": "다른 데이터셋으로 거르기", + "webhook": "my-quality-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "dataset": "employee" + }, + "response": { + "count": 0, + "batches": [], + "avg_score": 0, + "worst_batch": null, + "rejected_batches": 0, + "top_codes": [], + "trend": "그대로" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/23-lab03-challenge.txt b/Last3Mon/78/answers/23-lab03-challenge.txt new file mode 100644 index 0000000..607338b --- /dev/null +++ b/Last3Mon/78/answers/23-lab03-challenge.txt @@ -0,0 +1,785 @@ +$ uv run tools/flow.py push answers/78-lab03-challenge-report.json +✔ pushed '78 Quality report (answer)' (id wa6b87a0a6957e93) + POST http://localhost:5678/webhook/my-quality-report + +$ uv run tools/flow.py test cases/lab03-challenge.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 4} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 4 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +! before: POST /webhook/my-faq-import · HTTP 422 · 1.2s (allow_fail: rejected on purpose) · {"error": "batch rejected", "batch": "B-1", "invalid_ratio": 0.7692, "max_invalid_ratio": 0.3, "codes": {"title_missing" + → request POST /webhook/my-faq-import + { + "batch": "B-1", + "items": [ + { + "id": "FAQ-101", + "title": "사내 와이파이 연결", + "keywords": [ + "와이파이", + "wifi", + "무선" + ], + "answer": "사내 와이파이는 'CORP-WIFI'에 사번으로 로그인합니다. 잡히지 않으면 공유기 번호를 IT 포털에 신고합니다." + }, + { + "id": "FAQ-102", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-103", + "title": "", + "keywords": [ + "출입증" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 재발급을 신청합니다." + }, + { + "id": "FAQ-10", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북을 반납합니다." + }, + { + "id": "FAQ-105", + "title": "모니터 추가 신청", + "keywords": [ + "모니터" + ], + "answer": "" + }, + { + "id": "FAQ-106", + "title": "회의실 예약 규칙", + "keywords": [ + "회의실 규칙" + ], + "answer": "회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. 회의실은 사내 포털의 '회의실 예약' 메뉴에서 날짜와 시간을 고른 뒤 인원과 목적을 적어 예약합니다. " + }, + { + "id": "FAQ-102", + "title": "회의실 예약 (중복)", + "keywords": [ + "회의실" + ], + "answer": "사내 포털에서 회의실을 예약합니다. 취소도 같은 메뉴에서 합니다." + }, + { + "id": "FAQ-108", + "title": "화상회의 장비", + "keywords": [], + "answer": "화상회의 장비는 각 층 비품실에서 빌릴 수 있습니다." + }, + { + "id": "FAQ-109", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT팀 김민지(010-1234-5678)에게 바로 전화해 주세요." + }, + { + "id": "FAQ-110", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 http://bit.ly/corp-msg 에서 받아 설치합니다." + }, + { + "id": "FAQ-003", + "title": "VPN 접속 오류 (새 판)", + "keywords": [ + "재택근무" + ], + "answer": "VPN 클라이언트를 최신으로 업데이트하고 인증서를 확인합니다." + }, + { + "id": "FAQ-112", + "title": "VPN 접속 오류", + "keywords": [ + "vpn", + "재택근무" + ], + "answer": "재택근무 중 VPN이 끊기면 클라이언트를 다시 시작하고 인증서 만료일을 확인합니다." + }, + { + "id": "FAQ-113", + "title": "프린터 토너 교체", + "keywords": [ + "토너" + ], + "answer": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] + } + ← response HTTP 422 + { + "error": "batch rejected", + "batch": "B-1", + "invalid_ratio": 0.7692, + "max_invalid_ratio": 0.3, + "codes": { + "title_missing": 1, + "id_format": 1, + "answer_missing": 1, + "answer_too_long": 1, + "duplicate_in_batch": 1, + "keywords_missing": 1, + "pii": 1, + "external_link": 1, + "exists_id": 1, + "same_title": 1 + }, + "llm_calls": 0 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 9.5s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3 + → request POST /webhook/my-faq-import + { + "batch": "B-2", + "items": [ + { + "id": "FAQ-201", + "title": "회의실 예약", + "keywords": [ + "회의실", + "예약" + ], + "answer": "사내 포털의 '회의실 예약' 메뉴에서 날짜 · 시간 · 인원을 적어 예약합니다." + }, + { + "id": "FAQ-202", + "title": "출입증 재발급", + "keywords": [ + "출입증", + "재발급" + ], + "answer": "출입증을 잃어버렸으면 총무팀에 신분증을 가지고 가서 재발급을 신청합니다." + }, + { + "id": "FAQ-203", + "title": "노트북 반납", + "keywords": [ + "노트북", + "반납" + ], + "answer": "퇴사 5영업일 전까지 IT팀에 노트북과 충전기를 반납합니다." + }, + { + "id": "FAQ-204", + "title": "모니터 추가 신청", + "keywords": [ + "모니터", + "추가" + ], + "answer": "IT 포털의 '장비 신청'에서 모니터 추가를 신청하면 재고 확인 뒤 지급합니다." + }, + { + "id": "FAQ-205", + "title": "윈도우 업데이트", + "keywords": [ + "업데이트", + "윈도우" + ], + "answer": "윈도우 업데이트는 매달 둘째 주 수요일 밤에 자동으로 설치됩니다. 끝나면 다시 시작합니다." + }, + { + "id": "FAQ-206", + "title": "화상회의 장비 대여", + "keywords": [ + "화상회의", + "카메라" + ], + "answer": "화상회의 카메라와 마이크는 각 층 비품실에서 빌리고 같은 날 돌려줍니다." + }, + { + "id": "FAQ-207", + "title": "사내 메신저 설치", + "keywords": [ + "메신저" + ], + "answer": "사내 메신저는 IT 포털(https://intra.example.com/msg)에서 받아 설치합니다." + }, + { + "id": "FAQ-208", + "title": "공유 드라이브 권한", + "keywords": [ + "공유 드라이브", + "권한" + ], + "answer": "공유 드라이브 권한은 팀장이 IT 포털에서 신청하면 하루 안에 열립니다." + }, + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 security.desk@example.com 으로 바로 메일을 보내 주세요." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "연차 휴가는 입사 1년 뒤부터 15일이며 인사 포털에서 신청합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2", + "total": 10, + "imported": 8, + "quarantined": 2, + "rejected": false, + "score": 0.8, + "max_invalid_ratio": 0.3, + "codes": { + "pii": 1, + "llm_mismatch": 1 + }, + "imported_ids": [ + "FAQ-201", + "FAQ-202", + "FAQ-203", + "FAQ-204", + "FAQ-205", + "FAQ-206", + "FAQ-207", + "FAQ-208" + ], + "quarantined_ids": [ + "FAQ-209", + "FAQ-210" + ], + "llm_calls": 9 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.3s · {"batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0 + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ], + "llm_calls": 5 + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 2.2s · {"batch": "B-2-fix", "total": 2, "imported": 2, "quarantined": 0, "rejected": false, "score": 1, "max_invalid_ratio": 0. + → request POST /webhook/my-faq-import + { + "batch": "B-2-fix", + "items": [ + { + "id": "FAQ-209", + "title": "보안 사고 신고", + "keywords": [ + "보안", + "신고" + ], + "answer": "보안 사고는 IT 포털의 '보안 신고' 메뉴로 바로 알려 주세요. 급하면 IT 문의 창구로 연락합니다." + }, + { + "id": "FAQ-210", + "title": "모니터 밝기 조절", + "keywords": [ + "밝기" + ], + "answer": "모니터 밝기는 모니터 아래 버튼이나 윈도우 '디스플레이 설정'에서 조절합니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-2-fix", + "total": 2, + "imported": 2, + "quarantined": 0, + "rejected": false, + "score": 1, + "max_invalid_ratio": 0.3, + "codes": {}, + "imported_ids": [ + "FAQ-209", + "FAQ-210" + ], + "quarantined_ids": [], + "llm_calls": 2 + } +POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1 +✔ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s + → request POST /webhook/my-quality-report + {} + ← response HTTP 200 + { + "count": 4, + "batches": [ + { + "batch": "B-1", + "score": 0, + "rejected": true, + "total": 13 + }, + { + "batch": "B-2", + "score": 0.8, + "rejected": false, + "total": 10 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + }, + { + "batch": "B-2-fix", + "score": 1, + "rejected": false, + "total": 2 + } + ], + "avg_score": 0.6583, + "worst_batch": "B-1", + "rejected_batches": 1, + "top_codes": [ + { + "code": "pii", + "count": 2 + }, + { + "code": "answer_missing", + "count": 1 + }, + { + "code": "answer_too_long", + "count": 1 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "exists_id", + "count": 1 + }, + { + "code": "external_link", + "count": 1 + }, + { + "code": "id_format", + "count": 1 + }, + { + "code": "keywords_missing", + "count": 1 + }, + { + "code": "llm_mismatch", + "count": 1 + }, + { + "code": "same_title", + "count": 1 + }, + { + "code": "title_missing", + "count": 1 + } + ], + "trend": "좋아짐" + } +✔ 가장 많은 이유 · 흐름 · 0.1s + → request POST /webhook/my-quality-report + {} + ← response HTTP 200 + { + "count": 4, + "batches": [ + { + "batch": "B-1", + "score": 0, + "rejected": true, + "total": 13 + }, + { + "batch": "B-2", + "score": 0.8, + "rejected": false, + "total": 10 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + }, + { + "batch": "B-2-fix", + "score": 1, + "rejected": false, + "total": 2 + } + ], + "avg_score": 0.6583, + "worst_batch": "B-1", + "rejected_batches": 1, + "top_codes": [ + { + "code": "pii", + "count": 2 + }, + { + "code": "answer_missing", + "count": 1 + }, + { + "code": "answer_too_long", + "count": 1 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "exists_id", + "count": 1 + }, + { + "code": "external_link", + "count": 1 + }, + { + "code": "id_format", + "count": 1 + }, + { + "code": "keywords_missing", + "count": 1 + }, + { + "code": "llm_mismatch", + "count": 1 + }, + { + "code": "same_title", + "count": 1 + }, + { + "code": "title_missing", + "count": 1 + } + ], + "trend": "좋아짐" + } +✔ 배치마다 점수 순서 · 0.0s + → request POST /webhook/my-quality-report + {} + ← response HTTP 200 + { + "count": 4, + "batches": [ + { + "batch": "B-1", + "score": 0, + "rejected": true, + "total": 13 + }, + { + "batch": "B-2", + "score": 0.8, + "rejected": false, + "total": 10 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + }, + { + "batch": "B-2-fix", + "score": 1, + "rejected": false, + "total": 2 + } + ], + "avg_score": 0.6583, + "worst_batch": "B-1", + "rejected_batches": 1, + "top_codes": [ + { + "code": "pii", + "count": 2 + }, + { + "code": "answer_missing", + "count": 1 + }, + { + "code": "answer_too_long", + "count": 1 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "exists_id", + "count": 1 + }, + { + "code": "external_link", + "count": 1 + }, + { + "code": "id_format", + "count": 1 + }, + { + "code": "keywords_missing", + "count": 1 + }, + { + "code": "llm_mismatch", + "count": 1 + }, + { + "code": "same_title", + "count": 1 + }, + { + "code": "title_missing", + "count": 1 + } + ], + "trend": "좋아짐" + } +✔ 다른 데이터셋으로 거르기 · 0.1s + → request POST /webhook/my-quality-report + { + "dataset": "employee" + } + ← response HTTP 200 + { + "count": 0, + "batches": [], + "avg_score": 0, + "worst_batch": null, + "rejected_batches": 0, + "top_codes": [], + "trend": "그대로" + } +Passed 4/4 +report: runs/lab03-challenge-20261101-181105.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 15} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 15 + } + +$ uv run tools/flow.py call my-quality-report '{}' +HTTP 200 · 0.0s +{ + "count": 4, + "batches": [ + { + "batch": "B-1", + "score": 0, + "rejected": true, + "total": 13 + }, + { + "batch": "B-2", + "score": 0.8, + "rejected": false, + "total": 10 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + }, + { + "batch": "B-2-fix", + "score": 1, + "rejected": false, + "total": 2 + } + ], + "avg_score": 0.6583, + "worst_batch": "B-1", + "rejected_batches": 1, + "top_codes": [ + { + "code": "pii", + "count": 2 + }, + { + "code": "answer_missing", + "count": 1 + }, + { + "code": "answer_too_long", + "count": 1 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "exists_id", + "count": 1 + }, + { + "code": "external_link", + "count": 1 + }, + { + "code": "id_format", + "count": 1 + }, + { + "code": "keywords_missing", + "count": 1 + }, + { + "code": "llm_mismatch", + "count": 1 + }, + { + "code": "same_title", + "count": 1 + }, + { + "code": "title_missing", + "count": 1 + } + ], + "trend": "좋아짐" +} diff --git a/Last3Mon/78/answers/24-lab03-challenge2-before.json b/Last3Mon/78/answers/24-lab03-challenge2-before.json new file mode 100644 index 0000000..89c070f --- /dev/null +++ b/Last3Mon/78/answers/24-lab03-challenge2-before.json @@ -0,0 +1,136 @@ +{ + "cases": "lab03-challenge2.json", + "webhook": "my-quality-report", + "passed": 0, + "total": 4, + "results": [ + { + "case": "검사도 품질 기록 (직원 · 티켓 · FAQ)", + "webhook": "http://localhost:8800/quality-log/list", + "run": 1, + "passed": false, + "problems": [ + "count = 1 (expected 3)", + "items.0.dataset = faq (expected employee)", + "items.0.score = 0.8333 (expected 0.4444)", + "items.1.dataset = null (expected ticket)", + "items.2.dataset = null (expected faq)" + ], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 1, + "items": [ + { + "batch": "B-3", + "dataset": "faq", + "total": 6, + "valid": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "codes": { + "answer_too_short": 1 + }, + "at": "2026-11-01T18:11:10" + } + ] + } + }, + { + "case": "데이터셋마다 나눠 보기", + "webhook": "my-quality-report", + "run": 1, + "passed": false, + "problems": [ + "count = 1 (expected 3)", + "by_dataset.employee.avg_score = null (expected 0.4444)", + "by_dataset.ticket.avg_score = null (expected 0.2222)", + "by_dataset.faq.avg_score = null (expected 0.8333)" + ], + "seconds": 0.2, + "status": 200, + "request": {}, + "response": { + "count": 1, + "batches": [ + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.8333, + "worst_batch": "B-3", + "rejected_batches": 0, + "top_codes": [ + { + "code": "answer_too_short", + "count": 1 + } + ], + "trend": "그대로" + } + }, + { + "case": "가장 나쁜 데이터셋 · 그 이유", + "webhook": "my-quality-report", + "run": 1, + "passed": false, + "problems": [ + "worst_dataset = null (expected ticket)", + "by_dataset.employee.top_code = null (expected bad_format:email)" + ], + "seconds": 0.1, + "status": 200, + "request": {}, + "response": { + "count": 1, + "batches": [ + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.8333, + "worst_batch": "B-3", + "rejected_batches": 0, + "top_codes": [ + { + "code": "answer_too_short", + "count": 1 + } + ], + "trend": "그대로" + } + }, + { + "case": "한 데이터셋만 (앞의 칸은 그대로)", + "webhook": "my-quality-report", + "run": 1, + "passed": false, + "problems": [ + "count = 0 (expected 1)", + "worst_batch = null (expected TK-1)" + ], + "seconds": 0.0, + "status": 200, + "request": { + "dataset": "ticket" + }, + "response": { + "count": 0, + "batches": [], + "avg_score": 0, + "worst_batch": null, + "rejected_batches": 0, + "top_codes": [], + "trend": "그대로" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/24-lab03-challenge2-before.txt b/Last3Mon/78/answers/24-lab03-challenge2-before.txt new file mode 100644 index 0000000..536fd0e --- /dev/null +++ b/Last3Mon/78/answers/24-lab03-challenge2-before.txt @@ -0,0 +1,447 @@ +$ uv run tools/flow.py test cases/lab03-challenge2.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 0} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 0 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 4} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 4 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 3} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 3 + } +✔ before: POST /webhook/my-check · HTTP 200 · 0.1s · {"schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, "invalid": 5, "rows": [{"row": 1, "key": "sehoon.oh@exam + → request POST /webhook/my-check + { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + } + ← response HTTP 200 + { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } +✔ before: POST /webhook/my-check · HTTP 200 · 0.1s · {"schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, "invalid": 7, "rows": [{"row": 1, "key": "T-2001", "ok": t + → request POST /webhook/my-check + { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + } + ← response HTTP 200 + { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.0s · {"batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0 + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ], + "llm_calls": 5 + } +POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1 +✘ 검사도 품질 기록 (직원 · 티켓 · FAQ) · 0.0s · count = 1 (expected 3); items.0.dataset = faq (expected employee); items.0.score = 0.8333 (expected 0.4444); items.1.dataset = null (expected ticket); items.2.dataset = null (expected faq) + → request POST http://localhost:8800/quality-log/list + {} + ← response HTTP 200 + { + "count": 1, + "items": [ + { + "batch": "B-3", + "dataset": "faq", + "total": 6, + "valid": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "codes": { + "answer_too_short": 1 + }, + "at": "2026-11-01T18:11:10" + } + ] + } +✘ 데이터셋마다 나눠 보기 · 0.2s · count = 1 (expected 3); by_dataset.employee.avg_score = null (expected 0.4444); by_dataset.ticket.avg_score = null (expected 0.2222); by_dataset.faq.avg_score = null (expected 0.8333) + → request POST /webhook/my-quality-report + {} + ← response HTTP 200 + { + "count": 1, + "batches": [ + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.8333, + "worst_batch": "B-3", + "rejected_batches": 0, + "top_codes": [ + { + "code": "answer_too_short", + "count": 1 + } + ], + "trend": "그대로" + } +✘ 가장 나쁜 데이터셋 · 그 이유 · 0.1s · worst_dataset = null (expected ticket); by_dataset.employee.top_code = null (expected bad_format:email) + → request POST /webhook/my-quality-report + {} + ← response HTTP 200 + { + "count": 1, + "batches": [ + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.8333, + "worst_batch": "B-3", + "rejected_batches": 0, + "top_codes": [ + { + "code": "answer_too_short", + "count": 1 + } + ], + "trend": "그대로" + } +✘ 한 데이터셋만 (앞의 칸은 그대로) · 0.0s · count = 0 (expected 1); worst_batch = null (expected TK-1) + → request POST /webhook/my-quality-report + { + "dataset": "ticket" + } + ← response HTTP 200 + { + "count": 0, + "batches": [], + "avg_score": 0, + "worst_batch": null, + "rejected_batches": 0, + "top_codes": [], + "trend": "그대로" + } +Passed 0/4 +report: runs/lab03-challenge2-20261101-181110.json diff --git a/Last3Mon/78/answers/25-lab03-challenge2.json b/Last3Mon/78/answers/25-lab03-challenge2.json new file mode 100644 index 0000000..b7aace3 --- /dev/null +++ b/Last3Mon/78/answers/25-lab03-challenge2.json @@ -0,0 +1,366 @@ +{ + "cases": "lab03-challenge2.json", + "webhook": "my-quality-report", + "passed": 4, + "total": 4, + "results": [ + { + "case": "검사도 품질 기록 (직원 · 티켓 · FAQ)", + "webhook": "http://localhost:8800/quality-log/list", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 3, + "items": [ + { + "batch": "HR-1", + "dataset": "employee", + "total": 9, + "valid": 4, + "quarantined": 5, + "rejected": false, + "score": 0.4444, + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + }, + "at": "2026-11-01T18:11:30" + }, + { + "batch": "TK-1", + "dataset": "ticket", + "total": 9, + "valid": 2, + "quarantined": 7, + "rejected": false, + "score": 0.2222, + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + }, + "at": "2026-11-01T18:11:30" + }, + { + "batch": "B-3", + "dataset": "faq", + "total": 6, + "valid": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "codes": { + "answer_too_short": 1 + }, + "at": "2026-11-01T18:11:35" + } + ] + } + }, + { + "case": "데이터셋마다 나눠 보기", + "webhook": "my-quality-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": {}, + "response": { + "count": 3, + "batches": [ + { + "batch": "HR-1", + "score": 0.4444, + "rejected": false, + "total": 9 + }, + { + "batch": "TK-1", + "score": 0.2222, + "rejected": false, + "total": 9 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.5, + "worst_batch": "TK-1", + "rejected_batches": 0, + "top_codes": [ + { + "code": "duplicate_in_batch", + "count": 2 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "bad_format:contact", + "count": 1 + }, + { + "code": "bad_format:email", + "count": 1 + }, + { + "code": "bad_format:joined", + "count": 1 + }, + { + "code": "bad_format:opened", + "count": 1 + }, + { + "code": "bad_format:ticket_id", + "count": 1 + }, + { + "code": "missing:name", + "count": 1 + }, + { + "code": "missing:requester", + "count": 1 + }, + { + "code": "not_allowed:dept", + "count": 1 + }, + { + "code": "not_allowed:priority", + "count": 1 + }, + { + "code": "too_short:title", + "count": 1 + } + ], + "trend": "좋아짐", + "by_dataset": { + "employee": { + "count": 1, + "avg_score": 0.4444, + "worst_batch": "HR-1", + "top_code": "bad_format:email" + }, + "ticket": { + "count": 1, + "avg_score": 0.2222, + "worst_batch": "TK-1", + "top_code": "bad_format:contact" + }, + "faq": { + "count": 1, + "avg_score": 0.8333, + "worst_batch": "B-3", + "top_code": "answer_too_short" + } + }, + "worst_dataset": "ticket" + } + }, + { + "case": "가장 나쁜 데이터셋 · 그 이유", + "webhook": "my-quality-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.0, + "status": 200, + "request": {}, + "response": { + "count": 3, + "batches": [ + { + "batch": "HR-1", + "score": 0.4444, + "rejected": false, + "total": 9 + }, + { + "batch": "TK-1", + "score": 0.2222, + "rejected": false, + "total": 9 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.5, + "worst_batch": "TK-1", + "rejected_batches": 0, + "top_codes": [ + { + "code": "duplicate_in_batch", + "count": 2 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "bad_format:contact", + "count": 1 + }, + { + "code": "bad_format:email", + "count": 1 + }, + { + "code": "bad_format:joined", + "count": 1 + }, + { + "code": "bad_format:opened", + "count": 1 + }, + { + "code": "bad_format:ticket_id", + "count": 1 + }, + { + "code": "missing:name", + "count": 1 + }, + { + "code": "missing:requester", + "count": 1 + }, + { + "code": "not_allowed:dept", + "count": 1 + }, + { + "code": "not_allowed:priority", + "count": 1 + }, + { + "code": "too_short:title", + "count": 1 + } + ], + "trend": "좋아짐", + "by_dataset": { + "employee": { + "count": 1, + "avg_score": 0.4444, + "worst_batch": "HR-1", + "top_code": "bad_format:email" + }, + "ticket": { + "count": 1, + "avg_score": 0.2222, + "worst_batch": "TK-1", + "top_code": "bad_format:contact" + }, + "faq": { + "count": 1, + "avg_score": 0.8333, + "worst_batch": "B-3", + "top_code": "answer_too_short" + } + }, + "worst_dataset": "ticket" + } + }, + { + "case": "한 데이터셋만 (앞의 칸은 그대로)", + "webhook": "my-quality-report", + "run": 1, + "passed": true, + "problems": [], + "seconds": 0.1, + "status": 200, + "request": { + "dataset": "ticket" + }, + "response": { + "count": 1, + "batches": [ + { + "batch": "TK-1", + "score": 0.2222, + "rejected": false, + "total": 9 + } + ], + "avg_score": 0.2222, + "worst_batch": "TK-1", + "rejected_batches": 0, + "top_codes": [ + { + "code": "bad_format:contact", + "count": 1 + }, + { + "code": "bad_format:opened", + "count": 1 + }, + { + "code": "bad_format:ticket_id", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "missing:requester", + "count": 1 + }, + { + "code": "not_allowed:priority", + "count": 1 + }, + { + "code": "too_short:title", + "count": 1 + } + ], + "trend": "그대로", + "by_dataset": { + "employee": { + "count": 1, + "avg_score": 0.4444, + "worst_batch": "HR-1", + "top_code": "bad_format:email" + }, + "ticket": { + "count": 1, + "avg_score": 0.2222, + "worst_batch": "TK-1", + "top_code": "bad_format:contact" + }, + "faq": { + "count": 1, + "avg_score": 0.8333, + "worst_batch": "B-3", + "top_code": "answer_too_short" + } + }, + "worst_dataset": "ticket" + } + } + ] +} \ No newline at end of file diff --git a/Last3Mon/78/answers/25-lab03-challenge2.txt b/Last3Mon/78/answers/25-lab03-challenge2.txt new file mode 100644 index 0000000..c92e8c3 --- /dev/null +++ b/Last3Mon/78/answers/25-lab03-challenge2.txt @@ -0,0 +1,761 @@ +$ uv run tools/flow.py push answers/78-lab03-challenge2-check-log.json answers/78-lab03-challenge2-report.json +! unpublished '78 Schema check (reference)' (w6b17d7c76908a6a): it used the same webhook path +! unpublished '78 Quality report (answer)' (wa6b87a0a6957e93): it used the same webhook path +✔ pushed '78 Schema check + quality log (answer)' (id wf4c2f04db13849f) + POST http://localhost:5678/webhook/my-check +✔ pushed '78 Quality report + by dataset (answer)' (id we40b9a92abbf7fd) + POST http://localhost:5678/webhook/my-quality-report + +$ uv run tools/flow.py test cases/lab03-challenge2.json -v +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 5} + → request POST http://localhost:8800/faq-reset + {} + ← response HTTP 200 + { + "cleared": 5 + } +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/quarantine-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/quality-log-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} + → request POST http://localhost:8800/alerts-clear + {} + ← response HTTP 200 + { + "cleared": 1 + } +✔ before: POST /webhook/my-check · HTTP 200 · 0.9s · {"schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, "invalid": 5, "rows": [{"row": 1, "key": "sehoon.oh@exam + → request POST /webhook/my-check + { + "schema": "employee", + "batch": "HR-1", + "csv": "name,dept,email,phone,joined\n오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01\n윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05\n강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06\n서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07\n임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01\n,IT팀,noname@example.com,,2026-10-09\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12\n정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14" + } + ← response HTTP 200 + { + "schema": "employee", + "batch": "HR-1", + "total": 9, + "valid": 4, + "invalid": 5, + "rows": [ + { + "row": 1, + "key": "sehoon.oh@example.com", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "haneul.yoon@example.com", + "ok": true, + "errors": [] + }, + { + "row": 3, + "key": "minsu.kang@example", + "ok": false, + "errors": [ + "bad_format:email" + ] + }, + { + "row": 4, + "key": "jia.seo@example.com", + "ok": false, + "errors": [ + "not_allowed:dept" + ] + }, + { + "row": 5, + "key": "doyun.lim@example.com", + "ok": false, + "errors": [ + "bad_format:joined" + ] + }, + { + "row": 6, + "key": "noname@example.com", + "ok": false, + "errors": [ + "missing:name" + ] + }, + { + "row": 7, + "key": "sua.bae@example.com", + "ok": true, + "errors": [] + }, + { + "row": 8, + "key": "sua.bae@example.com", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 9, + "key": "daeun.jung@example.com", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + } + } +✔ before: POST /webhook/my-check · HTTP 200 · 0.3s · {"schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, "invalid": 7, "rows": [{"row": 1, "key": "T-2001", "ok": t + → request POST /webhook/my-check + { + "schema": "ticket", + "batch": "TK-1", + "items": [ + { + "ticket_id": "T-2001", + "title": "모니터가 켜지지 않음", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-28", + "contact": "010-2345-6789" + }, + { + "ticket_id": "T-12", + "title": "메일 첨부가 안 열림", + "requester": "이준호", + "priority": "low", + "opened": "2026-10-28" + }, + { + "ticket_id": "T-2003", + "title": "VPN이 계속 끊김", + "requester": "최유진", + "priority": "urgent", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2004", + "title": "키보드 교체 요청", + "requester": "한지훈", + "priority": "low", + "opened": "2026-02-30" + }, + { + "ticket_id": "T-2005", + "title": "프린터", + "requester": "김민지", + "priority": "normal", + "opened": "2026-10-29" + }, + { + "ticket_id": "T-2006", + "title": "공유 드라이브 권한", + "requester": "박서연", + "priority": "normal", + "opened": "2026-10-30", + "contact": "010-12-3456" + }, + { + "ticket_id": "T-2001", + "title": "모니터가 또 꺼짐", + "requester": "박서연", + "priority": "high", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2008", + "title": "화상회의 카메라 대여", + "requester": "", + "priority": "low", + "opened": "2026-10-30" + }, + { + "ticket_id": "T-2009", + "title": "새 직원 계정 만들기", + "requester": "이준호", + "priority": "high", + "opened": "2026-10-30" + } + ] + } + ← response HTTP 200 + { + "schema": "ticket", + "batch": "TK-1", + "total": 9, + "valid": 2, + "invalid": 7, + "rows": [ + { + "row": 1, + "key": "T-2001", + "ok": true, + "errors": [] + }, + { + "row": 2, + "key": "T-12", + "ok": false, + "errors": [ + "bad_format:ticket_id" + ] + }, + { + "row": 3, + "key": "T-2003", + "ok": false, + "errors": [ + "not_allowed:priority" + ] + }, + { + "row": 4, + "key": "T-2004", + "ok": false, + "errors": [ + "bad_format:opened" + ] + }, + { + "row": 5, + "key": "T-2005", + "ok": false, + "errors": [ + "too_short:title" + ] + }, + { + "row": 6, + "key": "T-2006", + "ok": false, + "errors": [ + "bad_format:contact" + ] + }, + { + "row": 7, + "key": "T-2001", + "ok": false, + "errors": [ + "duplicate_in_batch" + ] + }, + { + "row": 8, + "key": "T-2008", + "ok": false, + "errors": [ + "missing:requester" + ] + }, + { + "row": 9, + "key": "T-2009", + "ok": true, + "errors": [] + } + ], + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + } + } +✔ before: POST /webhook/my-faq-import · HTTP 200 · 5.0s · {"batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0 + → request POST /webhook/my-faq-import + { + "batch": "B-3", + "items": [ + { + "id": "FAQ-301", + "title": "택배 수령", + "keywords": [ + "택배" + ], + "answer": "회사로 온 택배는 1층 안내 데스크에서 사원증을 보여 주고 받습니다." + }, + { + "id": "FAQ-302", + "title": "주차 등록", + "keywords": [ + "주차" + ], + "answer": "주차 등록은 총무팀 포털에서 차량 번호를 적어 신청합니다." + }, + { + "id": "FAQ-303", + "title": "명함 신청", + "keywords": [ + "명함" + ], + "answer": "명함은 인사 포털에서 신청하면 일주일 안에 자리로 배달됩니다." + }, + { + "id": "FAQ-304", + "title": "사내 식당 메뉴", + "keywords": [ + "식당", + "메뉴" + ], + "answer": "사내 식당 메뉴는 매주 월요일 아침 사내 게시판에 올라옵니다." + }, + { + "id": "FAQ-305", + "title": "교육 신청", + "keywords": [ + "교육" + ], + "answer": "짧" + }, + { + "id": "FAQ-306", + "title": "건강검진", + "keywords": [ + "건강검진" + ], + "answer": "건강검진은 매년 9월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] + } + ← response HTTP 200 + { + "batch": "B-3", + "total": 6, + "imported": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "max_invalid_ratio": 0.3, + "codes": { + "answer_too_short": 1 + }, + "imported_ids": [ + "FAQ-301", + "FAQ-302", + "FAQ-303", + "FAQ-304", + "FAQ-306" + ], + "quarantined_ids": [ + "FAQ-305" + ], + "llm_calls": 5 + } +POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1 +✔ 검사도 품질 기록 (직원 · 티켓 · FAQ) · 0.0s + → request POST http://localhost:8800/quality-log/list + {} + ← response HTTP 200 + { + "count": 3, + "items": [ + { + "batch": "HR-1", + "dataset": "employee", + "total": 9, + "valid": 4, + "quarantined": 5, + "rejected": false, + "score": 0.4444, + "codes": { + "bad_format:email": 1, + "not_allowed:dept": 1, + "bad_format:joined": 1, + "missing:name": 1, + "duplicate_in_batch": 1 + }, + "at": "2026-11-01T18:11:30" + }, + { + "batch": "TK-1", + "dataset": "ticket", + "total": 9, + "valid": 2, + "quarantined": 7, + "rejected": false, + "score": 0.2222, + "codes": { + "bad_format:ticket_id": 1, + "not_allowed:priority": 1, + "bad_format:opened": 1, + "too_short:title": 1, + "bad_format:contact": 1, + "duplicate_in_batch": 1, + "missing:requester": 1 + }, + "at": "2026-11-01T18:11:30" + }, + { + "batch": "B-3", + "dataset": "faq", + "total": 6, + "valid": 5, + "quarantined": 1, + "rejected": false, + "score": 0.8333, + "codes": { + "answer_too_short": 1 + }, + "at": "2026-11-01T18:11:35" + } + ] + } +✔ 데이터셋마다 나눠 보기 · 0.1s + → request POST /webhook/my-quality-report + {} + ← response HTTP 200 + { + "count": 3, + "batches": [ + { + "batch": "HR-1", + "score": 0.4444, + "rejected": false, + "total": 9 + }, + { + "batch": "TK-1", + "score": 0.2222, + "rejected": false, + "total": 9 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.5, + "worst_batch": "TK-1", + "rejected_batches": 0, + "top_codes": [ + { + "code": "duplicate_in_batch", + "count": 2 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "bad_format:contact", + "count": 1 + }, + { + "code": "bad_format:email", + "count": 1 + }, + { + "code": "bad_format:joined", + "count": 1 + }, + { + "code": "bad_format:opened", + "count": 1 + }, + { + "code": "bad_format:ticket_id", + "count": 1 + }, + { + "code": "missing:name", + "count": 1 + }, + { + "code": "missing:requester", + "count": 1 + }, + { + "code": "not_allowed:dept", + "count": 1 + }, + { + "code": "not_allowed:priority", + "count": 1 + }, + { + "code": "too_short:title", + "count": 1 + } + ], + "trend": "좋아짐", + "by_dataset": { + "employee": { + "count": 1, + "avg_score": 0.4444, + "worst_batch": "HR-1", + "top_code": "bad_format:email" + }, + "ticket": { + "count": 1, + "avg_score": 0.2222, + "worst_batch": "TK-1", + "top_code": "bad_format:contact" + }, + "faq": { + "count": 1, + "avg_score": 0.8333, + "worst_batch": "B-3", + "top_code": "answer_too_short" + } + }, + "worst_dataset": "ticket" + } +✔ 가장 나쁜 데이터셋 · 그 이유 · 0.0s + → request POST /webhook/my-quality-report + {} + ← response HTTP 200 + { + "count": 3, + "batches": [ + { + "batch": "HR-1", + "score": 0.4444, + "rejected": false, + "total": 9 + }, + { + "batch": "TK-1", + "score": 0.2222, + "rejected": false, + "total": 9 + }, + { + "batch": "B-3", + "score": 0.8333, + "rejected": false, + "total": 6 + } + ], + "avg_score": 0.5, + "worst_batch": "TK-1", + "rejected_batches": 0, + "top_codes": [ + { + "code": "duplicate_in_batch", + "count": 2 + }, + { + "code": "answer_too_short", + "count": 1 + }, + { + "code": "bad_format:contact", + "count": 1 + }, + { + "code": "bad_format:email", + "count": 1 + }, + { + "code": "bad_format:joined", + "count": 1 + }, + { + "code": "bad_format:opened", + "count": 1 + }, + { + "code": "bad_format:ticket_id", + "count": 1 + }, + { + "code": "missing:name", + "count": 1 + }, + { + "code": "missing:requester", + "count": 1 + }, + { + "code": "not_allowed:dept", + "count": 1 + }, + { + "code": "not_allowed:priority", + "count": 1 + }, + { + "code": "too_short:title", + "count": 1 + } + ], + "trend": "좋아짐", + "by_dataset": { + "employee": { + "count": 1, + "avg_score": 0.4444, + "worst_batch": "HR-1", + "top_code": "bad_format:email" + }, + "ticket": { + "count": 1, + "avg_score": 0.2222, + "worst_batch": "TK-1", + "top_code": "bad_format:contact" + }, + "faq": { + "count": 1, + "avg_score": 0.8333, + "worst_batch": "B-3", + "top_code": "answer_too_short" + } + }, + "worst_dataset": "ticket" + } +✔ 한 데이터셋만 (앞의 칸은 그대로) · 0.1s + → request POST /webhook/my-quality-report + { + "dataset": "ticket" + } + ← response HTTP 200 + { + "count": 1, + "batches": [ + { + "batch": "TK-1", + "score": 0.2222, + "rejected": false, + "total": 9 + } + ], + "avg_score": 0.2222, + "worst_batch": "TK-1", + "rejected_batches": 0, + "top_codes": [ + { + "code": "bad_format:contact", + "count": 1 + }, + { + "code": "bad_format:opened", + "count": 1 + }, + { + "code": "bad_format:ticket_id", + "count": 1 + }, + { + "code": "duplicate_in_batch", + "count": 1 + }, + { + "code": "missing:requester", + "count": 1 + }, + { + "code": "not_allowed:priority", + "count": 1 + }, + { + "code": "too_short:title", + "count": 1 + } + ], + "trend": "그대로", + "by_dataset": { + "employee": { + "count": 1, + "avg_score": 0.4444, + "worst_batch": "HR-1", + "top_code": "bad_format:email" + }, + "ticket": { + "count": 1, + "avg_score": 0.2222, + "worst_batch": "TK-1", + "top_code": "bad_format:contact" + }, + "faq": { + "count": 1, + "avg_score": 0.8333, + "worst_batch": "B-3", + "top_code": "answer_too_short" + } + }, + "worst_dataset": "ticket" + } +Passed 4/4 +report: runs/lab03-challenge2-20261101-181135.json + +$ uv run tools/flow.py test cases/lab03.json +POST http://localhost:5678/webhook/my-check · 4 case(s) × 1 +✔ 직원 CSV · 9줄 중 4줄 통과 · 0.1s +✔ 직원 CSV · 줄마다 이유 · 0.1s +✔ 같은 워크플로우로 FAQ 스키마 · 0.1s +✔ 모르는 스키마 → 400 · 0.0s +Passed 4/4 +report: runs/lab03-20261101-181136.json + +$ uv run tools/flow.py test cases/lab03-challenge.json +✔ before: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 5} +✔ before: POST http://localhost:8800/quarantine-clear · HTTP 200 · 0.0s · {"cleared": 1} +✔ before: POST http://localhost:8800/quality-log-clear · HTTP 200 · 0.0s · {"cleared": 6} +✔ before: POST http://localhost:8800/alerts-clear · HTTP 200 · 0.0s · {"cleared": 1} +! before: POST /webhook/my-faq-import · HTTP 422 · 0.1s (allow_fail: rejected on purpose) · {"error": "batch rejected", "batch": "B-1", "invalid_ratio": 0.7692, "max_invalid_ratio": 0.3, "codes": {"title_missing" +✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.8s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, "rejected": false, "score": 0.8, "max_invalid_ratio": 0.3 +✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.6s · {"batch": "B-3", "total": 6, "imported": 5, "quarantined": 1, "rejected": false, "score": 0.8333, "max_invalid_ratio": 0 +✔ before: POST /webhook/my-faq-import · HTTP 200 · 1.8s · {"batch": "B-2-fix", "total": 2, "imported": 2, "quarantined": 0, "rejected": false, "score": 1, "max_invalid_ratio": 0. +POST http://localhost:5678/webhook/my-quality-report · 4 case(s) × 1 +✔ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s +✔ 가장 많은 이유 · 흐름 · 0.1s +✔ 배치마다 점수 순서 · 0.0s +✔ 다른 데이터셋으로 거르기 · 0.1s +Passed 4/4 +report: runs/lab03-challenge-20261101-181151.json +✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 15} + +$ uv run tools/flow.py quality +FAQ: 6 (nothing added) +quarantine: 1 + B-3 FAQ-305 answer_too_short +quality log: 4 + B-1 faq total 13 valid 3 score 0 rejected True + B-2 faq total 10 valid 8 score 0.8 rejected False + B-3 faq total 6 valid 5 score 0.8333 rejected False + B-2-fix faq total 2 valid 2 score 1 rejected False +alerts: 3 + [high] faq-import · batch_rejected · B-1 rejected + [high] faq-import · low_quality · B-2 quality 0.8 + [high] faq-import · low_quality · B-3 quality 0.8333 + +$ uv run tools/flow.py ops --reset +✔ live run logs, added FAQ, quarantine, quality log, created tickets, DLQ and alerts emptied, fault switch all ok + +$ uv run tools/flow.py doctor +! n8n install folder /Users/mcchae/day65-n8n + → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더> +✔ n8n container day65-n8n-n8n-1 +✔ n8n http://localhost:5678 +✔ data API http://localhost:8800 +✔ dead letter queue /dlq · alerts · fault switch dlq (day 76 data API) +✔ run logs /logs (666 seeded runs) · FAQ additions /faq-extra (day 77 data API) +✔ schemas faq · employee · ticket, quarantine, quality log (day 78 data API) +✔ n8n → http://data-api:8800 +✔ credential 'OpenAI (.env)' (openAiCourse0001) +✔ node catalog (for check) +✔ CLI coding agent: codex, claude, agy +ready diff --git a/Last3Mon/78/answers/78-lab01-challenge-existing.json b/Last3Mon/78/answers/78-lab01-challenge-existing.json new file mode 100644 index 0000000..09fd8cb --- /dev/null +++ b/Last3Mon/78/answers/78-lab01-challenge-existing.json @@ -0,0 +1,177 @@ +{ + "name": "78 FAQ check + existing data (answer)", + "nodes": [ + { + "name": "Check webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-faq-check", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "6751875f-37e8-51bf-9685-da994f9dec81", + "webhookId": "736fe44f-e179-58a3-9856-295b28a7073d" + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.items) }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "56a85def-836a-56b6-981a-3327bee5c75e" + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"items must be a list\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "4b6d0650-0ddb-5d07-8ced-af0e3ee7c7cc" + }, + { + "name": "Get FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq-all", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 440, + 300 + ], + "id": "9bf7a3b3-52ec-5976-803b-54cae1e2244b" + }, + { + "name": "Check rows", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const body = $('Check webhook').first().json.body;\n\nconst existing = $('Get FAQ').first().json.faqs ?? [];\nconst existingIds = new Set(existing.map((f) => f.id));\nconst existingTitles = new Set(existing.map((f) => String(f.title).trim()));\nconst existingKeywords = new Set(existing.flatMap((f) => (f.keywords ?? []).map((k) => String(k).toLowerCase())));\nconst seen = new Set();\nconst rows = body.items.map((r) => {\n const errors = [], warnings = [];\n const id = String(r?.id ?? '');\n const title = String(r?.title ?? '').trim();\n const answer = String(r?.answer ?? '').trim();\n if (!/^FAQ-\\d{3}$/.test(id)) errors.push('id_format');\n if (!title) errors.push('title_missing'); else if (title.length > 40) errors.push('title_too_long');\n if (!Array.isArray(r?.keywords) || r.keywords.length === 0) errors.push('keywords_missing');\n if (!answer) errors.push('answer_missing'); else if (answer.length < 10) errors.push('answer_too_short'); else if (answer.length > 300) errors.push('answer_too_long');\n if (seen.has(id)) errors.push('duplicate_in_batch');\n seen.add(id);\n if (/01[016789][-. ]?\\d{3,4}[-. ]?\\d{4}/.test(answer) || /[\\w.+-]+@[\\w-]+\\.[\\w.]+/.test(answer)) errors.push('pii');\n if (/https?:\\/\\/(?!intra\\.example\\.com(?![\\w.-]))/i.test(answer)) errors.push('external_link');\n\n if (existingIds.has(id)) errors.push('exists_id');\n if (title && existingTitles.has(title)) errors.push('same_title');\n for (const k of (Array.isArray(r?.keywords) ? r.keywords : [])) if (existingKeywords.has(String(k).toLowerCase())) warnings.push(`keyword_used:${k}`);\n return { id, ok: errors.length === 0, errors, warnings };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nconst out = { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid,\n with_warnings: rows.filter((r) => r.warnings.length).length, rows, codes };\nreturn [{ json: out }];" + }, + "position": [ + 660, + 300 + ], + "id": "a5d95489-c8a9-5734-ac13-39c87bc61620" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 880, + 300 + ], + "id": "0331ae2d-ec56-5470-856a-f92507037bab" + } + ], + "connections": { + "Is a list?": { + "main": [ + [ + { + "node": "Get FAQ", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get FAQ": { + "main": [ + [ + { + "node": "Check rows", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check rows": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/78/answers/78-lab01-challenge2-fix-list.json b/Last3Mon/78/answers/78-lab01-challenge2-fix-list.json new file mode 100644 index 0000000..48ad456 --- /dev/null +++ b/Last3Mon/78/answers/78-lab01-challenge2-fix-list.json @@ -0,0 +1,177 @@ +{ + "name": "78 FAQ check + fix list (answer)", + "nodes": [ + { + "name": "Check webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-faq-check", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "7ded9091-d32b-514c-9bba-8cc9010cd8c5", + "webhookId": "cf6a2ab6-8e06-511c-b239-ef1a5207977f" + }, + { + "name": "Is a list?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.body.items) }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 220, + 300 + ], + "id": "4485bb43-0e0f-5736-9855-cf88dc66cdef" + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "{ \"error\": \"items must be a list\" }", + "options": { + "responseCode": 400 + } + }, + "position": [ + 440, + 560 + ], + "id": "b2273143-7dc4-52c9-b1f3-36fd45b4a595" + }, + { + "name": "Get FAQ", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/faq-all", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 440, + 300 + ], + "id": "eac0cc92-5d69-5d2a-b999-7a071a4cd7e5" + }, + { + "name": "Check rows", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const body = $('Check webhook').first().json.body;\n\nconst existing = $('Get FAQ').first().json.faqs ?? [];\nconst existingIds = new Set(existing.map((f) => f.id));\nconst existingTitles = new Set(existing.map((f) => String(f.title).trim()));\nconst existingKeywords = new Set(existing.flatMap((f) => (f.keywords ?? []).map((k) => String(k).toLowerCase())));\nconst seen = new Set();\nconst rows = body.items.map((r) => {\n const errors = [], warnings = [];\n const id = String(r?.id ?? '');\n const title = String(r?.title ?? '').trim();\n const answer = String(r?.answer ?? '').trim();\n if (!/^FAQ-\\d{3}$/.test(id)) errors.push('id_format');\n if (!title) errors.push('title_missing'); else if (title.length > 40) errors.push('title_too_long');\n if (!Array.isArray(r?.keywords) || r.keywords.length === 0) errors.push('keywords_missing');\n if (!answer) errors.push('answer_missing'); else if (answer.length < 10) errors.push('answer_too_short'); else if (answer.length > 300) errors.push('answer_too_long');\n if (seen.has(id)) errors.push('duplicate_in_batch');\n seen.add(id);\n if (/01[016789][-. ]?\\d{3,4}[-. ]?\\d{4}/.test(answer) || /[\\w.+-]+@[\\w-]+\\.[\\w.]+/.test(answer)) errors.push('pii');\n if (/https?:\\/\\/(?!intra\\.example\\.com(?![\\w.-]))/i.test(answer)) errors.push('external_link');\n\n if (existingIds.has(id)) errors.push('exists_id');\n if (title && existingTitles.has(title)) errors.push('same_title');\n for (const k of (Array.isArray(r?.keywords) ? r.keywords : [])) if (existingKeywords.has(String(k).toLowerCase())) warnings.push(`keyword_used:${k}`);\n return { id, ok: errors.length === 0, errors, warnings };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nconst out = { batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid,\n with_warnings: rows.filter((r) => r.warnings.length).length, rows, codes };\n\nconst FIX = {\n id_format: 'id를 FAQ-숫자 3자리로 (예: FAQ-104)',\n title_missing: '제목을 써 주세요',\n title_too_long: '제목을 40자 이내로 줄여 주세요',\n keywords_missing: '키워드를 하나 이상 넣어 주세요',\n answer_missing: '답을 써 주세요',\n answer_too_short: '답을 10자 이상으로 써 주세요',\n answer_too_long: '답을 300자 이내로 줄여 주세요',\n duplicate_in_batch: '같은 배치에 같은 id가 또 있습니다. 하나만 남겨 주세요',\n pii: '개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요',\n external_link: '바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요',\n exists_id: '이미 있는 id입니다. 새 id를 쓰거나, 고치려는 것이면 FAQ 담당자에게 알려 주세요',\n same_title: '같은 제목의 FAQ가 이미 있습니다. 제목을 바꾸거나 기존 FAQ와 합쳐 주세요',\n};\nout.fix_list = rows.map((r, i) => ({ row: i + 1, id: r.id, codes: r.errors, fix: r.errors.map((e) => FIX[e] ?? e) })).filter((r) => r.codes.length);\nout.message = `${out.batch}: ${out.total}줄 중 ${out.valid}줄 통과, ${out.invalid}줄 고칠 것`;\nreturn [{ json: out }];" + }, + "position": [ + 660, + 300 + ], + "id": "71deee57-d95f-500e-87cc-e352362e221a" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 880, + 300 + ], + "id": "26ac83fd-b10f-56ae-8669-52a14deafc35" + } + ], + "connections": { + "Is a list?": { + "main": [ + [ + { + "node": "Get FAQ", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get FAQ": { + "main": [ + [ + { + "node": "Check rows", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check rows": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check webhook": { + "main": [ + [ + { + "node": "Is a list?", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/78/answers/78-lab02-challenge-log-alert.json b/Last3Mon/78/answers/78-lab02-challenge-log-alert.json new file mode 100644 index 0000000..72e9684 --- /dev/null +++ b/Last3Mon/78/answers/78-lab02-challenge-log-alert.json @@ -0,0 +1,641 @@ +{ + "name": "78 FAQ import + quality log and alerts (answer)", + "nodes": [ + { + "name": "Import webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-faq-import", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "e1896aee-de3a-5afe-89ab-af4a1e59a7fe", + "webhookId": "40c49459-ef29-5162-b270-9f02debc6803" + }, + { + "name": "Run check", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://localhost:5678/webhook/my-faq-check", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 30000 + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.body) }}" + }, + "position": [ + 220, + 300 + ], + "id": "3b92ccd8-144b-50af-8826-e9b2112763f0" + }, + { + "name": "Checked?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.rows) && $json.rows.length > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 440, + 300 + ], + "id": "f56c9042-070b-5c0a-b2be-a818784927f9" + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: $json.error ?? 'check failed' }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 660, + 760 + ], + "id": "62c6cdf6-bc48-50e8-8255-af71ee09b8c8" + }, + { + "name": "Batch ok?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.invalid / $json.total <= ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3) }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "ee39faa6-b293-5cc0-aa10-f1f1afe4a23d" + }, + { + "name": "Reject batch", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: 'batch rejected', batch: $('Run check').first().json.batch, invalid_ratio: Math.round($('Run check').first().json.invalid / $('Run check').first().json.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3), codes: $('Run check').first().json.codes }) }}", + "options": { + "responseCode": 422 + } + }, + "position": [ + 1320, + 560 + ], + "id": "c36ae045-13ee-5c26-8c55-596102c81464" + }, + { + "name": "Log rejected", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ batch: $json.batch, dataset: 'faq', total: $json.total, valid: $json.valid, quarantined: 0, rejected: true, score: 0, codes: $json.codes }) }}" + }, + "position": [ + 880, + 560 + ], + "id": "6a210860-265e-5675-b62c-9aa055215e27" + }, + { + "name": "Alert rejected", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/alerts", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Batch ok?', severity: 'high', reason: 'batch_rejected', batch: $('Run check').first().json.batch, message: $('Run check').first().json.batch + ' rejected' }) }}" + }, + "position": [ + 1100, + 560 + ], + "id": "a27d519d-a8ba-5da8-8b20-91666e65f435" + }, + { + "name": "Split rows", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const c = $('Run check').first().json;\nconst items = $('Import webhook').first().json.body.items;\nreturn c.rows.map((r, i) => ({ json: { ...r, row: items[i], batch: c.batch } }));" + }, + "position": [ + 880, + 300 + ], + "id": "d21b12e6-d7c3-5693-ab62-1690f04fe10b" + }, + { + "name": "Only passed", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().filter((i) => i.json.ok);" + }, + "position": [ + 1100, + 300 + ], + "id": "0a85d85b-5527-584e-8956-5a33eeb770a0" + }, + { + "name": "Judge", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "=제목: {{ $json.row.title }}\n답: {{ $json.row.answer }}", + "options": { + "systemMessage": "You check a company IT FAQ entry before it is published. Does the answer actually answer the title's topic? Ignore style. Return only JSON, no code fence: {\"match\": true, \"reason\": \"short reason in Korean\"}", + "maxIterations": 1 + } + }, + "onError": "continueRegularOutput", + "position": [ + 1320, + 300 + ], + "id": "7c0e4c8e-3e63-539b-9902-ea10d43acca2" + }, + { + "name": "Judge model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 1320, + 520 + ], + "id": "4d1aac5b-af62-54ea-8cf5-3fa807ed58db" + }, + { + "name": "Decide", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const judged = $('Only passed').all().map((item, i) => {\n let v = { match: false, reason: 'judge unreadable' };\n try { v = JSON.parse(String($('Judge').all()[i].json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) {}\n return { ...item.json, ok: v.match === true, errors: v.match === true ? [] : ['llm_mismatch'], reason: String(v.reason ?? '') };\n});\nlet k = 0;\nreturn $('Split rows').all().map((i) => ({ json: i.json.ok ? judged[k++] : i.json }));" + }, + "position": [ + 1540, + 300 + ], + "id": "d775abc0-029e-5213-8d2f-63ed42224453" + }, + { + "name": "Store row", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}" + }, + "position": [ + 1760, + 300 + ], + "id": "8abf3036-e347-514d-8140-8bc277ceb4a3" + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const c = $('Run check').first().json;\nconst rows = $('Decide').all().map((i) => i.json);\nconst imported = rows.filter((r) => r.ok);\nconst quarantined = rows.filter((r) => !r.ok);\nconst codes = { ...c.codes };\nfor (const r of quarantined) for (const e of r.errors) if (e === 'llm_mismatch') codes[e] = (codes[e] ?? 0) + 1;\nreturn [{ json: { batch: c.batch, total: c.total, imported: imported.length, quarantined: quarantined.length, rejected: false,\n score: Math.round(imported.length / c.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3),\n codes, imported_ids: imported.map((r) => r.id), quarantined_ids: quarantined.map((r) => r.id) } }];" + }, + "position": [ + 1980, + 300 + ], + "id": "f904284d-6948-5dfc-85db-023fb7e7bbfa" + }, + { + "name": "Log quality", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ batch: $('Summarize').first().json.batch, dataset: 'faq', total: $('Summarize').first().json.total, valid: $('Summarize').first().json.imported, quarantined: $('Summarize').first().json.quarantined, rejected: false, score: $('Summarize').first().json.score, codes: $('Summarize').first().json.codes }) }}" + }, + "position": [ + 2200, + 300 + ], + "id": "0fb77808-55c4-54fc-b1ba-9bb98478a8dc" + }, + { + "name": "Low score?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $('Summarize').first().json.score < 0.9 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2420, + 300 + ], + "id": "5c9f4cf0-8451-58f4-aa92-6d21adb1ef7c" + }, + { + "name": "Alert low score", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/alerts", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Low score?', severity: 'high', reason: 'low_quality', batch: $('Summarize').first().json.batch, score: $('Summarize').first().json.score, message: $('Summarize').first().json.batch + ' quality ' + $('Summarize').first().json.score }) }}" + }, + "position": [ + 2640, + 160 + ], + "id": "09f87701-f48a-51c1-bd8c-d15486860992" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: $('Summarize').first().json }];" + }, + "position": [ + 2860, + 300 + ], + "id": "77f7a5b7-4ad0-50ec-8b9b-5fed5597b549" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3080, + 300 + ], + "id": "1a367765-da4c-5405-8c06-441779da8090" + } + ], + "connections": { + "Import webhook": { + "main": [ + [ + { + "node": "Run check", + "type": "main", + "index": 0 + } + ] + ] + }, + "Run check": { + "main": [ + [ + { + "node": "Checked?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Checked?": { + "main": [ + [ + { + "node": "Batch ok?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Batch ok?": { + "main": [ + [ + { + "node": "Split rows", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log rejected": { + "main": [ + [ + { + "node": "Alert rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alert rejected": { + "main": [ + [ + { + "node": "Reject batch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split rows": { + "main": [ + [ + { + "node": "Only passed", + "type": "main", + "index": 0 + } + ] + ] + }, + "Only passed": { + "main": [ + [ + { + "node": "Judge", + "type": "main", + "index": 0 + } + ] + ] + }, + "Judge": { + "main": [ + [ + { + "node": "Decide", + "type": "main", + "index": 0 + } + ] + ] + }, + "Judge model": { + "ai_languageModel": [ + [ + { + "node": "Judge", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Decide": { + "main": [ + [ + { + "node": "Store row", + "type": "main", + "index": 0 + } + ] + ] + }, + "Store row": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Log quality", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log quality": { + "main": [ + [ + { + "node": "Low score?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Low score?": { + "main": [ + [ + { + "node": "Alert low score", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alert low score": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/78/answers/78-lab02-challenge2-llm-calls.json b/Last3Mon/78/answers/78-lab02-challenge2-llm-calls.json new file mode 100644 index 0000000..573ea26 --- /dev/null +++ b/Last3Mon/78/answers/78-lab02-challenge2-llm-calls.json @@ -0,0 +1,641 @@ +{ + "name": "78 FAQ import + LLM calls (answer)", + "nodes": [ + { + "name": "Import webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-faq-import", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "97923cda-6ae1-50d4-a0ad-df64c161c39c", + "webhookId": "23a35671-e3aa-5f09-a094-45b3da4c643c" + }, + { + "name": "Run check", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://localhost:5678/webhook/my-faq-check", + "options": { + "response": { + "response": { + "neverError": true + } + }, + "timeout": 30000 + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.body) }}" + }, + "position": [ + 220, + 300 + ], + "id": "9ebb8863-ee15-58d6-9115-b6ff5a1bbfd0" + }, + { + "name": "Checked?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ Array.isArray($json.rows) && $json.rows.length > 0 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 440, + 300 + ], + "id": "4a3efc44-a09f-5cf8-8654-8c976fc86008" + }, + { + "name": "Reject body", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: $json.error ?? 'check failed' }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 660, + 760 + ], + "id": "88c8455e-da32-5781-8c2d-40c1262fcd70" + }, + { + "name": "Batch ok?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.invalid / $json.total <= ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3) }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "74cca906-ea01-5106-a9b7-e9166ccfa367" + }, + { + "name": "Reject batch", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: 'batch rejected', batch: $('Run check').first().json.batch, invalid_ratio: Math.round($('Run check').first().json.invalid / $('Run check').first().json.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3), codes: $('Run check').first().json.codes, llm_calls: 0 }) }}", + "options": { + "responseCode": 422 + } + }, + "position": [ + 1320, + 560 + ], + "id": "50f5afd9-e4fb-5f03-b99c-b0bbbd02f637" + }, + { + "name": "Log rejected", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ batch: $json.batch, dataset: 'faq', total: $json.total, valid: $json.valid, quarantined: 0, rejected: true, score: 0, codes: $json.codes }) }}" + }, + "position": [ + 880, + 560 + ], + "id": "b1d03d7a-d131-59b4-b7c3-63c90c4bd3e2" + }, + { + "name": "Alert rejected", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/alerts", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Batch ok?', severity: 'high', reason: 'batch_rejected', batch: $('Run check').first().json.batch, message: $('Run check').first().json.batch + ' rejected' }) }}" + }, + "position": [ + 1100, + 560 + ], + "id": "0f3fae58-f846-5ff3-99b0-ad519ff51546" + }, + { + "name": "Split rows", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const c = $('Run check').first().json;\nconst items = $('Import webhook').first().json.body.items;\nreturn c.rows.map((r, i) => ({ json: { ...r, row: items[i], batch: c.batch } }));" + }, + "position": [ + 880, + 300 + ], + "id": "aa264c2a-0b2c-5c68-ab00-48e193d952d8" + }, + { + "name": "Only passed", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return $input.all().filter((i) => i.json.ok);" + }, + "position": [ + 1100, + 300 + ], + "id": "57ba3d3a-7a75-5848-90f7-cc4072007dd2" + }, + { + "name": "Judge", + "type": "@n8n/n8n-nodes-langchain.agent", + "typeVersion": 3.1, + "parameters": { + "promptType": "define", + "text": "=제목: {{ $json.row.title }}\n답: {{ $json.row.answer }}", + "options": { + "systemMessage": "You check a company IT FAQ entry before it is published. Does the answer actually answer the title's topic? Ignore style. Return only JSON, no code fence: {\"match\": true, \"reason\": \"short reason in Korean\"}", + "maxIterations": 1 + } + }, + "onError": "continueRegularOutput", + "position": [ + 1320, + 300 + ], + "id": "8936b8b1-19a9-57fe-aae5-d934a6c1e6c7" + }, + { + "name": "Judge model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + }, + "position": [ + 1320, + 520 + ], + "id": "ada85f33-792e-562b-8802-463ced397599" + }, + { + "name": "Decide", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const judged = $('Only passed').all().map((item, i) => {\n let v = { match: false, reason: 'judge unreadable' };\n try { v = JSON.parse(String($('Judge').all()[i].json.output ?? '').replace(/^```(json)?\\s*|```$/g, '')); } catch (e) {}\n return { ...item.json, ok: v.match === true, errors: v.match === true ? [] : ['llm_mismatch'], reason: String(v.reason ?? '') };\n});\nlet k = 0;\nreturn $('Split rows').all().map((i) => ({ json: i.json.ok ? judged[k++] : i.json }));" + }, + "position": [ + 1540, + 300 + ], + "id": "43e33f20-a354-548d-98fe-a22a8a295de5" + }, + { + "name": "Store row", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "={{ $json.ok ? 'http://data-api:8800/faq/add' : 'http://data-api:8800/quarantine' }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify($json.ok ? $json.row : { items: [{ batch: $json.batch, row: $json.row, errors: $json.errors }] }) }}" + }, + "position": [ + 1760, + 300 + ], + "id": "8df856a9-afff-5685-8095-51fceddbd4de" + }, + { + "name": "Summarize", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const c = $('Run check').first().json;\nconst rows = $('Decide').all().map((i) => i.json);\nconst imported = rows.filter((r) => r.ok);\nconst quarantined = rows.filter((r) => !r.ok);\nconst codes = { ...c.codes };\nfor (const r of quarantined) for (const e of r.errors) if (e === 'llm_mismatch') codes[e] = (codes[e] ?? 0) + 1;\nreturn [{ json: { batch: c.batch, total: c.total, imported: imported.length, quarantined: quarantined.length, rejected: false,\n score: Math.round(imported.length / c.total * 10000) / 10000, max_invalid_ratio: ($('Import webhook').first().json.body.max_invalid_ratio ?? 0.3),\n codes, imported_ids: imported.map((r) => r.id), quarantined_ids: quarantined.map((r) => r.id), llm_calls: $('Only passed').all().length } }];" + }, + "position": [ + 1980, + 300 + ], + "id": "c287c65f-99fd-59ef-a44d-dc4b021d7a9c" + }, + { + "name": "Log quality", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ batch: $('Summarize').first().json.batch, dataset: 'faq', total: $('Summarize').first().json.total, valid: $('Summarize').first().json.imported, quarantined: $('Summarize').first().json.quarantined, rejected: false, score: $('Summarize').first().json.score, codes: $('Summarize').first().json.codes }) }}" + }, + "position": [ + 2200, + 300 + ], + "id": "9607be48-7b62-5f67-9aec-23db3fb0fbb8" + }, + { + "name": "Low score?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $('Summarize').first().json.score < 0.9 }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 2420, + 300 + ], + "id": "4d32f16f-6554-580b-83eb-2c489df417d3" + }, + { + "name": "Alert low score", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/alerts", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ workflow: 'faq-import', node: 'Low score?', severity: 'high', reason: 'low_quality', batch: $('Summarize').first().json.batch, score: $('Summarize').first().json.score, message: $('Summarize').first().json.batch + ' quality ' + $('Summarize').first().json.score }) }}" + }, + "position": [ + 2640, + 160 + ], + "id": "e7dcfdb3-8ed7-5783-93cb-4daf2a75cbb6" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: $('Summarize').first().json }];" + }, + "position": [ + 2860, + 300 + ], + "id": "90b42f0a-eaa4-55d7-b579-c2e6c67ea701" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 3080, + 300 + ], + "id": "700d4355-509a-546a-936e-d8e96c297925" + } + ], + "connections": { + "Import webhook": { + "main": [ + [ + { + "node": "Run check", + "type": "main", + "index": 0 + } + ] + ] + }, + "Run check": { + "main": [ + [ + { + "node": "Checked?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Checked?": { + "main": [ + [ + { + "node": "Batch ok?", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject body", + "type": "main", + "index": 0 + } + ] + ] + }, + "Batch ok?": { + "main": [ + [ + { + "node": "Split rows", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Log rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log rejected": { + "main": [ + [ + { + "node": "Alert rejected", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alert rejected": { + "main": [ + [ + { + "node": "Reject batch", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split rows": { + "main": [ + [ + { + "node": "Only passed", + "type": "main", + "index": 0 + } + ] + ] + }, + "Only passed": { + "main": [ + [ + { + "node": "Judge", + "type": "main", + "index": 0 + } + ] + ] + }, + "Judge": { + "main": [ + [ + { + "node": "Decide", + "type": "main", + "index": 0 + } + ] + ] + }, + "Judge model": { + "ai_languageModel": [ + [ + { + "node": "Judge", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Decide": { + "main": [ + [ + { + "node": "Store row", + "type": "main", + "index": 0 + } + ] + ] + }, + "Store row": { + "main": [ + [ + { + "node": "Summarize", + "type": "main", + "index": 0 + } + ] + ] + }, + "Summarize": { + "main": [ + [ + { + "node": "Log quality", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log quality": { + "main": [ + [ + { + "node": "Low score?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Low score?": { + "main": [ + [ + { + "node": "Alert low score", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Alert low score": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/78/answers/78-lab03-challenge-report.json b/Last3Mon/78/answers/78-lab03-challenge-report.json new file mode 100644 index 0000000..5f35fe2 --- /dev/null +++ b/Last3Mon/78/answers/78-lab03-challenge-report.json @@ -0,0 +1,108 @@ +{ + "name": "78 Quality report (answer)", + "nodes": [ + { + "name": "Report webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-quality-report", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "d39880e6-d916-5d87-a2d7-39a1f1201094", + "webhookId": "2b2b3919-d93a-579c-ae38-a51266677926" + }, + { + "name": "Get log", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 220, + 300 + ], + "id": "ddba6f7c-251f-5309-87dd-cdaf072aa5c8" + }, + { + "name": "Make report", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const want = $('Report webhook').first().json.body?.dataset;\nconst all = $json.items ?? [];\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst summary = (items) => {\n const batches = items.map((i) => ({ batch: i.batch, score: i.score, rejected: i.rejected === true, total: i.total }));\n const codes = {};\n for (const i of items) for (const [k, v] of Object.entries(i.codes ?? {})) codes[k] = (codes[k] ?? 0) + v;\n const top_codes = Object.entries(codes).map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));\n const avg = batches.length ? r4(batches.reduce((s, b) => s + b.score, 0) / batches.length) : 0;\n const worst = batches.reduce((w, b) => (w === null || b.score < w.score ? b : w), null);\n let trend = '그대로';\n if (batches.length >= 2) { const d = batches.at(-1).score - batches[0].score; trend = d > 0.05 ? '좋아짐' : d < -0.05 ? '나빠짐' : '그대로'; }\n return { count: batches.length, batches, avg_score: avg, worst_batch: worst?.batch ?? null, rejected_batches: batches.filter((b) => b.rejected).length, top_codes, trend };\n};\nconst out = summary(all.filter((i) => !want || i.dataset === want));\nreturn [{ json: out }];" + }, + "position": [ + 440, + 300 + ], + "id": "e4e4147a-061c-5ef0-8ada-d588ef283c8c" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "61076c2a-8679-511f-877a-f16b8e7983e4" + } + ], + "connections": { + "Report webhook": { + "main": [ + [ + { + "node": "Get log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get log": { + "main": [ + [ + { + "node": "Make report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make report": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/78/answers/78-lab03-challenge2-check-log.json b/Last3Mon/78/answers/78-lab03-challenge2-check-log.json new file mode 100644 index 0000000..5513976 --- /dev/null +++ b/Last3Mon/78/answers/78-lab03-challenge2-check-log.json @@ -0,0 +1,236 @@ +{ + "name": "78 Schema check + quality log (answer)", + "nodes": [ + { + "name": "Check webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-check", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "71f637f7-7dd3-5109-b1eb-e41f19084672", + "webhookId": "2e73b93d-487c-5d47-abf7-0367dc834812" + }, + { + "name": "Get schema", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "=http://data-api:8800/schemas/{{ encodeURIComponent($json.body.schema ?? '') }}", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 220, + 300 + ], + "id": "c2e0870e-6d3c-52e5-9eea-c2d612aa9c02" + }, + { + "name": "Known schema?", + "type": "n8n-nodes-base.if", + "typeVersion": 2.2, + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "loose", + "version": 2 + }, + "conditions": [ + { + "id": "c1", + "leftValue": "={{ $json.fields !== undefined }}", + "rightValue": "", + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "position": [ + 440, + 300 + ], + "id": "5cbdeaec-a7b4-5d52-b1dc-a9652df407f7" + }, + { + "name": "Unknown schema", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ error: 'unknown schema', schema: $('Check webhook').first().json.body.schema ?? null }) }}", + "options": { + "responseCode": 400 + } + }, + "position": [ + 660, + 560 + ], + "id": "0f5c53bb-1643-5cab-958d-183709fc78bd" + }, + { + "name": "Check rows", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const body = $('Check webhook').first().json.body;\nconst schema = $json;\nlet items = body.items;\nif (typeof body.csv === 'string') {\n const [head, ...lines] = body.csv.trim().split(/\\r?\\n/);\n const cols = head.split(',').map((c) => c.trim());\n items = lines.map((line) => Object.fromEntries(line.split(',').map((v, i) => [cols[i], v.trim()])));\n}\nconst isDate = (v) => { if (!/^\\d{4}-\\d{2}-\\d{2}$/.test(v)) return false; const d = new Date(v + 'T00:00:00Z'); return !isNaN(d) && d.toISOString().slice(0, 10) === v; };\nconst seen = new Set();\nconst rows = (Array.isArray(items) ? items : []).map((r, index) => {\n const errors = [];\n for (const [name, f] of Object.entries(schema.fields)) {\n const v = r?.[name];\n const empty = v === undefined || v === null || (typeof v === 'string' && v.trim() === '') || (Array.isArray(v) && v.length === 0);\n if (empty) { if (f.required) errors.push(`missing:${name}`); continue; }\n const s = String(v);\n if (f.type === 'string') { if (f.max && s.length > f.max) errors.push(`too_long:${name}`); if (f.min && s.length < f.min) errors.push(`too_short:${name}`); }\n if (f.type === 'enum' && !f.values.includes(s)) errors.push(`not_allowed:${name}`);\n if (f.type === 'email' && !/^[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+$/.test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'phone' && !/^0\\d{1,2}-\\d{3,4}-\\d{4}$/.test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'date' && !isDate(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'pattern' && !new RegExp(f.pattern).test(s)) errors.push(`bad_format:${name}`);\n if (f.type === 'list') { if (!Array.isArray(v)) errors.push(`bad_format:${name}`); else { if (f.min_items && v.length < f.min_items) errors.push(`too_few:${name}`); if (f.max_items && v.length > f.max_items) errors.push(`too_many:${name}`); } }\n }\n const key = String(r?.[schema.key] ?? '');\n if (key && seen.has(key)) errors.push('duplicate_in_batch');\n if (key) seen.add(key);\n return { row: index + 1, key, ok: errors.length === 0, errors };\n});\nconst codes = {};\nfor (const row of rows) for (const e of row.errors) codes[e] = (codes[e] ?? 0) + 1;\nconst valid = rows.filter((r) => r.ok).length;\nreturn [{ json: { schema: schema.name, batch: body.batch ?? '', total: rows.length, valid, invalid: rows.length - valid, rows, codes } }];" + }, + "position": [ + 660, + 300 + ], + "id": "709a8af9-9764-5bc9-9ba6-832437e37d66" + }, + { + "name": "Log quality", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": { + "response": { + "response": { + "neverError": true + } + } + }, + "method": "POST", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ JSON.stringify({ batch: $('Check rows').first().json.batch, dataset: $('Check rows').first().json.schema, total: $('Check rows').first().json.total, valid: $('Check rows').first().json.valid, quarantined: $('Check rows').first().json.invalid, rejected: false, score: $('Check rows').first().json.total ? Math.round($('Check rows').first().json.valid / $('Check rows').first().json.total * 10000) / 10000 : 0, codes: $('Check rows').first().json.codes }) }}" + }, + "position": [ + 880, + 300 + ], + "id": "6b7a1222-281c-5a0b-8e5d-ce8340bace06" + }, + { + "name": "Result again", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "return [{ json: $('Check rows').first().json }];" + }, + "position": [ + 1100, + 300 + ], + "id": "44b28b34-d252-5957-8be7-db1828cfe536" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 1320, + 300 + ], + "id": "bde5ad2b-cfad-55b6-893b-1110d25c6d0b" + } + ], + "connections": { + "Check webhook": { + "main": [ + [ + { + "node": "Get schema", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get schema": { + "main": [ + [ + { + "node": "Known schema?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Known schema?": { + "main": [ + [ + { + "node": "Check rows", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Unknown schema", + "type": "main", + "index": 0 + } + ] + ] + }, + "Check rows": { + "main": [ + [ + { + "node": "Log quality", + "type": "main", + "index": 0 + } + ] + ] + }, + "Log quality": { + "main": [ + [ + { + "node": "Result again", + "type": "main", + "index": 0 + } + ] + ] + }, + "Result again": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/78/answers/78-lab03-challenge2-report.json b/Last3Mon/78/answers/78-lab03-challenge2-report.json new file mode 100644 index 0000000..b31d5e0 --- /dev/null +++ b/Last3Mon/78/answers/78-lab03-challenge2-report.json @@ -0,0 +1,108 @@ +{ + "name": "78 Quality report + by dataset (answer)", + "nodes": [ + { + "name": "Report webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "parameters": { + "httpMethod": "POST", + "path": "my-quality-report", + "responseMode": "responseNode", + "options": {} + }, + "position": [ + 0, + 300 + ], + "id": "2a4e58b4-7434-5181-9fca-13bc3ba808cf", + "webhookId": "e900dd73-cfae-5c26-9e5b-204c1e0867ec" + }, + { + "name": "Get log", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "parameters": { + "url": "http://data-api:8800/quality-log", + "options": { + "response": { + "response": { + "neverError": true + } + } + } + }, + "position": [ + 220, + 300 + ], + "id": "52996b11-04b3-5567-b18f-061a3e1bc213" + }, + { + "name": "Make report", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "parameters": { + "jsCode": "const want = $('Report webhook').first().json.body?.dataset;\nconst all = $json.items ?? [];\nconst r4 = (v) => Math.round(v * 10000) / 10000;\nconst summary = (items) => {\n const batches = items.map((i) => ({ batch: i.batch, score: i.score, rejected: i.rejected === true, total: i.total }));\n const codes = {};\n for (const i of items) for (const [k, v] of Object.entries(i.codes ?? {})) codes[k] = (codes[k] ?? 0) + v;\n const top_codes = Object.entries(codes).map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count || (a.code < b.code ? -1 : a.code > b.code ? 1 : 0));\n const avg = batches.length ? r4(batches.reduce((s, b) => s + b.score, 0) / batches.length) : 0;\n const worst = batches.reduce((w, b) => (w === null || b.score < w.score ? b : w), null);\n let trend = '그대로';\n if (batches.length >= 2) { const d = batches.at(-1).score - batches[0].score; trend = d > 0.05 ? '좋아짐' : d < -0.05 ? '나빠짐' : '그대로'; }\n return { count: batches.length, batches, avg_score: avg, worst_batch: worst?.batch ?? null, rejected_batches: batches.filter((b) => b.rejected).length, top_codes, trend };\n};\nconst out = summary(all.filter((i) => !want || i.dataset === want));\n\nout.by_dataset = {};\nfor (const name of [...new Set(all.map((i) => i.dataset ?? ''))]) {\n const s = summary(all.filter((i) => (i.dataset ?? '') === name));\n out.by_dataset[name] = { count: s.count, avg_score: s.avg_score, worst_batch: s.worst_batch, top_code: s.top_codes[0]?.code ?? null };\n}\nconst names = Object.keys(out.by_dataset);\nout.worst_dataset = names.length ? names.reduce((w, n) => (out.by_dataset[n].avg_score < out.by_dataset[w].avg_score ? n : w)) : null;\nreturn [{ json: out }];" + }, + "position": [ + 440, + 300 + ], + "id": "c4fa7448-4a77-5856-91b6-37ae2d841a7c" + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + }, + "position": [ + 660, + 300 + ], + "id": "79bb121c-6ef3-546d-8b1c-9ae6dda0a815" + } + ], + "connections": { + "Report webhook": { + "main": [ + [ + { + "node": "Get log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get log": { + "main": [ + [ + { + "node": "Make report", + "type": "main", + "index": 0 + } + ] + ] + }, + "Make report": { + "main": [ + [ + { + "node": "Send result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/Last3Mon/78/answers/LAB-TEST-RESULT.md b/Last3Mon/78/answers/LAB-TEST-RESULT.md new file mode 100644 index 0000000..4a37a4f --- /dev/null +++ b/Last3Mon/78/answers/LAB-TEST-RESULT.md @@ -0,0 +1,17367 @@ +# 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["① 프롬프트
터미널 ① codex"] --> C["② codex
my-work/*.json · check"] --> U["③ push
터미널 ②"] --> T["④ test -v · call · quality
판정 · 기대와 다른 칸"] + T -->|✘| X["실패 줄 · 보고서 경로를
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)
기준 10/13 통과 → 규칙대로 5/13
경계 6/6 · 이미 있는 FAQ 3/13
고침 목록 10줄"] --> L2["Lab 02 (09~16)
기준 12줄 넣음 → B-1 거절 · 격리
LLM이 FAQ-210 · 기준 0.3/0.8/0.1/0.05
기록 · 알림 · llm_calls 9 · 5"] + L2 --> L3["Lab 03 (17~25)
직원 CSV 4/9 · 고쳐서 격리 0
ticket 스키마 2/9 (워크플로우 그대로)
평균 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 + +✔ 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: ": 줄 중 줄 통과, 줄 고칠 것". +나쁜 줄을 고쳐 주지는 않는다 — 고치는 것은 사람. 앞의 칸은 그대로. 기대 결과 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`을 봅니다). diff --git a/Last3Mon/78/tcheck/.gitignore b/Last3Mon/78/tcheck/.gitignore new file mode 100644 index 0000000..17cd2fc --- /dev/null +++ b/Last3Mon/78/tcheck/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Last3Mon/78/tcheck/Doc/IMP-PLAN.md b/Last3Mon/78/tcheck/Doc/IMP-PLAN.md new file mode 100644 index 0000000..c4edb5d --- /dev/null +++ b/Last3Mon/78/tcheck/Doc/IMP-PLAN.md @@ -0,0 +1,29 @@ +# tcheck IMP-PLAN + +## 엔진 1단계 · 읽기 · 스키마 · 코드 · n8n과 같은 결과 (R1 · R2 · R3 · R4 · R12 · R13) +- [x] load.py: `read_rows(path)` — `.csv`(첫 줄 열 이름) · JSON 목록 · `{"items"}` · 78일차 요청 파일(`{"schema", "batch", "csv" | "items"}`), `read_schema(name, url, file)` — 데이터 API `GET /schemas/` 또는 `--schema-file` +- [x] checks.py: `check(rows, schema, batch)` — 스키마의 `fields`를 돌며 타입마다 (필드 이름을 코드에 쓰지 않음), 코드는 AGENTS.md 3절 그대로, 키 중복, `row`는 1부터 +- [x] cli: `tcheck check FILE [--schema] [--url | $TCHECK_URL]`, 종료 코드 0 · 1(못 읽음) · 2(쓰는 법) +- [x] tests: 직원 9줄 중 4줄 · 접수 건 2줄 · FAQ B-3 5줄, n8n `my-check`가 낸 rows · codes와 **같음** (`tests/data/n8n-my-check.json`), 가짜 데이터 API +- 확인: `uv run pytest -q`, `uv run tcheck check ../requests/employees-new.json --url http://localhost:8800` + +## 엔진 2단계 · JSON · 내보내기 · 게이트 (R5 · R6 · R7) +- [x] `--json`: my-check와 같은 모양 +- [x] `--out` 깨끗한 줄 · `--rejects` 나쁜 줄 + `errors` 칸 (.csv · .json) +- [x] `tcheck gate --max-invalid 0.3`: 넘으면 종료 코드 4, 같으면 통과 +- [x] tests: 파일 두 개 · 직원 0.5556 → 4 · 0.6 → 0 · 1/10 · 0.1 → 통과 +- 확인: `uv run tcheck gate ../requests/employees-new.json --url http://localhost:8800; echo $?` + +## TUI 1단계 · 탭 · 거르기 · 내보내기 (R11) +- [x] app.py `CheckApp`: 위 요약 한 줄, 탭(문제 줄 · 코드별 · 깨끗한 줄), `[/]` 거르기(키 · 코드), `[Esc]` 풀기, `[e]` 깨끗한 줄 내보내기 +- [x] 엔진(`checks`)의 결과를 보여 주기만 +- [x] tests: `run_test`로 5 · 5 · 4줄, `email`로 거르면 1줄, `e` → 파일 + +## 설치 (R14) +- [x] `[project.scripts] tcheck = "tcheck.cli:main"`, `uv tool install --editable .`, `cd /tmp && tcheck --help` + +## 엔진 3단계 · 내용 규칙 · 고침 목록 · 품질 기록 (R8 · R9 · R10) +- [x] `--content answer`: Lab 01의 `pii`(하이픈 없는 휴대전화 · 메일) · `external_link`(호스트가 정확히 intra.example.com) +- [x] `--fix`: `fix_list`(줄 순서 · 코드마다 말) · `message`(`TK-1: 9줄 중 2줄 통과, 7줄 고칠 것`) +- [x] `--log`: 데이터 API `POST /quality-log`에 my-check(Lab 03 과제 2)와 같은 한 줄 +- [x] tests: 내용 규칙 다섯 줄, 고침 말, 가짜 API에 기록 한 줄 diff --git a/Last3Mon/78/tcheck/README.md b/Last3Mon/78/tcheck/README.md new file mode 100644 index 0000000..9287708 --- /dev/null +++ b/Last3Mon/78/tcheck/README.md @@ -0,0 +1,45 @@ +# tcheck · 강사가 수업 준비 때 직접 만든 본 (2026-11-01) + +> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TCHECK.md](../VIBE-A-TCHECK.md)와 함께 올립니다. +> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-Q의 요구 R1~R14와 [Doc/IMP-PLAN.md](Doc/IMP-PLAN.md)를 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구의 **확인 방법**이 같으면 됩니다. + +## 설치 · 실행 + +```bash +cd ~/aio-01d4-stu/Last3Mon/78/tcheck +uv sync && uv run pytest -q # 테스트 22개 (n8n · 데이터 API 없이, 가짜 서버) +uv tool install --editable . # 어느 폴더에서나 tcheck (R14) +cd /tmp && tcheck --help +``` + +| 하고 싶은 것 | 명령 | +| --- | --- | +| 검사 (n8n `my-check`와 같은 판정) | `tcheck check ../requests/employees-new.json --url http://localhost:8800` (`--json`) | +| 스키마를 파일에서 | `tcheck check employees.csv --schema-file ../data-api/data/schemas/employee.json` | +| 깨끗한 줄 · 나쁜 줄 파일 | `tcheck check … --out clean.csv --rejects rejects.csv` | +| 배치 게이트 (넘으면 종료 코드 4) | `tcheck gate … --max-invalid 0.3` | +| FAQ 내용 규칙 (개인정보 · 바깥 링크) | `tcheck check ../requests/faq-batch-2.json --schema faq --content answer` | +| 보낸 사람에게 고침 목록 | `tcheck check … --fix` | +| 품질 기록 한 줄 | `tcheck check … --log` (데이터 API `/quality-log`) | +| 화면(TUI) | `tcheck ui …` (`[/]` 거르기, `[Esc]` 풀기, `[e]` 깨끗한 줄 내보내기, `[q]` 끝) | + +- 종료 코드: 0 됨 · 1 파일이나 스키마를 못 읽음 · 2 쓰는 법이 틀림 · 4 게이트가 배치를 거절. +- 데이터 API 주소는 `--url` 또는 `$TCHECK_URL`. `--schema-file`을 주면 파일이 먼저입니다. 스키마 이름은 `--schema` 또는 요청 파일의 `"schema"`. +- 판정 규칙은 [AGENTS.md](../AGENTS.md) 3절 "스키마" 표와 같습니다 (공백만 있는 값은 빈 값, 선택 칸은 비면 통과, 실제 있는 날짜, 키 중복, `row`는 1부터, 소수 4자리 half up). + +## 파일 + +| 파일 | 하는 일 | +| --- | --- | +| `src/tcheck/load.py` | 읽는 층 (R1 · R2 · R10): CSV · JSON · 요청 파일, 스키마(API · 파일), 품질 기록 보내기 | +| `src/tcheck/checks.py` | 규칙 한 곳 (R3 · R7 · R8 · R9 · R13): 타입마다 판정 · 내용 규칙 · 게이트 · 고침 목록 · 기록 한 줄 · 나누기 | +| `src/tcheck/cli.py` | `check` · `gate` · `ui`, 종료 코드 (R5 · R6 · R12) | +| `src/tcheck/app.py` | Textual 화면 (R11) | +| `tests/` | 직원 CSV · 접수 건 · FAQ B-3 · 스키마 셋 사본, n8n `my-check`가 낸 결과(`n8n-my-check.json`), 가짜 데이터 API (R12) | + +## 수업 준비 때 확인한 것 (11/01) + +- 같은 세 배치(직원 HR-1 · 접수 건 TK-1 · FAQ B-3)의 `rows` · `codes` · `valid`가 n8n `my-check`의 응답(`answers/18` · `answers/21`)과 **모두 같음** (`test_same_rows_and_codes_as_n8n`). +- FAQ B-1에 `--schema faq --content answer`: 13줄 중 8줄 나쁨 (0.6154) — Lab 01 예제 2의 내 검사와 같은 8줄 (코드는 스키마 꼴 `missing:title` · `bad_format:id` …). +- 반올림은 JavaScript `Math.round`처럼 half up (`js_round`). 77일차 `tlog`와 같은 까닭. +- 처음 판에서 TUI의 거르기 상자를 연 `/` 글자가 상자에 들어가고, 상자를 닫은 뒤에도 포커스가 남아 `e`(내보내기)가 글자로 들어갔음 → 글자 앞의 `/`를 지우고, 닫을 때 표로 포커스를 옮김. diff --git a/Last3Mon/78/tcheck/pyproject.toml b/Last3Mon/78/tcheck/pyproject.toml new file mode 100644 index 0000000..99ae3cb --- /dev/null +++ b/Last3Mon/78/tcheck/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "tcheck" +version = "0.1.0" +description = "Check CSV · JSON rows against a data schema (the day 78 data API), with the same codes and results as n8n my-check, a batch gate, and a TUI" +requires-python = ">=3.11" +dependencies = ["textual>=0.80"] + +[project.scripts] +tcheck = "tcheck.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/tcheck"] + +[dependency-groups] +dev = ["pytest>=8", "pytest-asyncio>=0.23"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/Last3Mon/78/tcheck/src/tcheck/__init__.py b/Last3Mon/78/tcheck/src/tcheck/__init__.py new file mode 100644 index 0000000..43ef301 --- /dev/null +++ b/Last3Mon/78/tcheck/src/tcheck/__init__.py @@ -0,0 +1 @@ +"""tcheck · day 78 data quality checks in the terminal.""" diff --git a/Last3Mon/78/tcheck/src/tcheck/app.py b/Last3Mon/78/tcheck/src/tcheck/app.py new file mode 100644 index 0000000..888f9c4 --- /dev/null +++ b/Last3Mon/78/tcheck/src/tcheck/app.py @@ -0,0 +1,96 @@ +"""Textual screen (R11): the summary on top, three tabs (problem rows · by code · clean rows), [/] filter, [e] export. +The screen only shows what the engine (checks) already decided.""" +from __future__ import annotations + +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import DataTable, Footer, Header, Input, Static, TabbedContent, TabPane + +from . import checks + + +class CheckApp(App): + TITLE = "tcheck" + BINDINGS = [("slash", "filter", "Filter"), ("e", "export", "Export clean rows"), ("escape", "clear", "Clear filter"), + ("q", "quit", "Quit")] + CSS = "#top { height: auto; margin: 0 1; }" + + def __init__(self, args, rows: list, schema: dict, result: dict): + super().__init__() + self.args, self.rows, self.schema, self.result = args, rows, schema, result + self.word = "" + self.exported = "" + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="top", markup=False) + yield Input(placeholder="filter: key or code, Enter to apply, Esc to clear", id="filter") + with TabbedContent(id="tabs"): + with TabPane("Problem rows", id="problems"): + yield DataTable(id="problem-table", cursor_type="row") + with TabPane("By code", id="codes"): + yield DataTable(id="code-table", cursor_type="row") + with TabPane("Clean rows", id="clean"): + yield DataTable(id="clean-table", cursor_type="row") + yield Footer() + + def on_mount(self): + self.query_one("#filter", Input).display = False + r = self.result + self.sub_title = f"{self.args.file} · schema {r['schema']}" + self.query_one("#top", Static).update( + f"{r['batch']} · {r['total']} rows · valid {r['valid']} · invalid {r['invalid']} · score {checks.score(r)}") + self.fill() + + def keep(self, row: dict) -> bool: + return not self.word or self.word in row["key"] or any(self.word in e for e in row["errors"]) + + def fill(self): + problems = self.query_one("#problem-table", DataTable) + problems.clear(columns=True) + problems.add_columns("row", "key", "errors") + for row in self.result["rows"]: + if not row["ok"] and self.keep(row): + problems.add_row(str(row["row"]), row["key"] or "-", ", ".join(row["errors"])) + codes = self.query_one("#code-table", DataTable) + codes.clear(columns=True) + codes.add_columns("count", "code", "rows") + for code, count in sorted(self.result["codes"].items(), key=lambda kv: (-kv[1], kv[0])): + if not self.word or self.word in code: + where = [str(r["row"]) for r in self.result["rows"] if code in r["errors"]] + codes.add_row(str(count), code, " ".join(where)) + clean = self.query_one("#clean-table", DataTable) + clean.clear(columns=True) + clean.add_columns("row", "key") + for row in self.result["rows"]: + if row["ok"] and self.keep(row): + clean.add_row(str(row["row"]), row["key"] or "-") + + def action_filter(self): + box = self.query_one("#filter", Input) + box.value, box.display = "", True + self.call_after_refresh(box.focus) + + def on_input_submitted(self, event: Input.Submitted): + self.word = event.value.strip().lstrip("/").strip() # the "/" that opened the box may land in it + event.input.display = False + self.fill() + self.query_one("#problem-table", DataTable).focus() + + def action_clear(self): + self.word = "" + box = self.query_one("#filter", Input) + box.value, box.display = "", False + self.fill() + self.query_one("#problem-table", DataTable).focus() + + def action_export(self): + from .cli import write_rows + + source = Path(self.args.file) + target = self.args.out or str(source.with_suffix(".clean" + (".csv" if source.suffix.lower() == ".csv" else ".json"))) + clean, _ = checks.split(self.rows, self.result) + write_rows(target, clean) + self.exported = target + self.notify(f"{len(clean)} clean rows → {target}") diff --git a/Last3Mon/78/tcheck/src/tcheck/checks.py b/Last3Mon/78/tcheck/src/tcheck/checks.py new file mode 100644 index 0000000..70f2332 --- /dev/null +++ b/Last3Mon/78/tcheck/src/tcheck/checks.py @@ -0,0 +1,165 @@ +"""The rules, in one place (R13): schema types (R2 · R3), content rules (R8), the gate (R7), the fix list (R9) and +the quality log line (R10). Same codes and the same results as the day 78 n8n my-check (AGENTS.md 3).""" +from __future__ import annotations + +import datetime +import math +import re + +EMAIL = re.compile(r"^[\w.+-]+@[\w-]+(\.[\w-]+)+$", re.ASCII) +PHONE = re.compile(r"^0\d{1,2}-\d{3,4}-\d{4}$", re.ASCII) +DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$", re.ASCII) +PII = (re.compile(r"01[016789][-. ]?\d{3,4}[-. ]?\d{4}", re.ASCII), re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+", re.ASCII)) +EXTERNAL = re.compile(r"https?://(?!intra\.example\.com(?![\w.-]))", re.ASCII | re.IGNORECASE) + + +def js_round(value: float, digits: int = 4) -> float: + """Half up like JavaScript Math.round (n8n Code nodes), not Python's round-half-even.""" + scale = 10 ** digits + out = math.floor(value * scale + 0.5) / scale + return int(out) if out == int(out) else out + + +def js_str(value) -> str: + if value is True or value is False: + return "true" if value else "false" + if isinstance(value, float) and value == int(value): + return str(int(value)) + return str(value) + + +def is_empty(value) -> bool: + return value is None or (isinstance(value, str) and value.strip() == "") or (isinstance(value, list) and not value) + + +def is_date(text: str) -> bool: + if not DATE.match(text): + return False + try: + return datetime.date.fromisoformat(text).isoformat() == text + except ValueError: + return False + + +def field_errors(name: str, rule: dict, value) -> list[str]: + """One field against one schema rule. The field name never appears in this code (R2).""" + if is_empty(value): + return [f"missing:{name}"] if rule.get("required") else [] + kind, text, out = rule.get("type"), js_str(value), [] + if kind == "string": + if rule.get("max") and len(text) > rule["max"]: + out.append(f"too_long:{name}") + if rule.get("min") and len(text) < rule["min"]: + out.append(f"too_short:{name}") + elif kind == "enum" and text not in [js_str(v) for v in rule.get("values", [])]: + out.append(f"not_allowed:{name}") + elif kind == "email" and not EMAIL.match(text): + out.append(f"bad_format:{name}") + elif kind == "phone" and not PHONE.match(text): + out.append(f"bad_format:{name}") + elif kind == "date" and not is_date(text): + out.append(f"bad_format:{name}") + elif kind == "pattern" and not re.search(rule.get("pattern", ""), text, re.ASCII): + out.append(f"bad_format:{name}") + elif kind == "list": + if not isinstance(value, list): + out.append(f"bad_format:{name}") + else: + if rule.get("min_items") and len(value) < rule["min_items"]: + out.append(f"too_few:{name}") + if rule.get("max_items") and len(value) > rule["max_items"]: + out.append(f"too_many:{name}") + return out + + +def content_errors(value) -> list[str]: + """R8: Lab 01 content rules on a text field (personal phone · e-mail, links outside intra.example.com).""" + text = "" if value is None else js_str(value).strip() + out = [] + if any(p.search(text) for p in PII): + out.append("pii") + if EXTERNAL.search(text): + out.append("external_link") + return out + + +def check(rows: list, schema: dict, batch: str = "", content: tuple[str, ...] = ()) -> dict: + """{"schema", "batch", "total", "valid", "invalid", "rows": [{"row", "key", "ok", "errors"}], "codes"} like my-check.""" + seen, out = set(), [] + key_name = schema.get("key", "") + for index, row in enumerate(rows, 1): + row = row if isinstance(row, dict) else {} + errors: list[str] = [] + for name, rule in schema["fields"].items(): + errors += field_errors(name, rule, row.get(name)) + for name in content: + errors += content_errors(row.get(name)) + key = "" if row.get(key_name) is None else js_str(row.get(key_name)) + if key and key in seen: + errors.append("duplicate_in_batch") + if key: + seen.add(key) + out.append({"row": index, "key": key, "ok": not errors, "errors": errors}) + codes: dict[str, int] = {} + for r in out: + for e in r["errors"]: + codes[e] = codes.get(e, 0) + 1 + valid = sum(r["ok"] for r in out) + return {"schema": schema.get("name", ""), "batch": batch, "total": len(out), "valid": valid, + "invalid": len(out) - valid, "rows": out, "codes": codes} + + +def score(result: dict) -> float: + return js_round(result["valid"] / result["total"]) if result["total"] else 0 + + +def gate(result: dict, max_invalid: float = 0.3) -> dict: + """R7: reject when invalid / total is above the limit (equal passes), like the Lab 02 batch gate.""" + ratio = js_round(result["invalid"] / result["total"]) if result["total"] else 0 + raw = result["invalid"] / result["total"] if result["total"] else 0 + return {"accepted": raw <= max_invalid, "invalid_ratio": ratio, "max_invalid_ratio": max_invalid} + + +FIX = { + "missing": "{f} 칸을 채워 주세요", + "too_long": "{f}을(를) 더 짧게 ({max}자 이내)", + "too_short": "{f}을(를) 더 길게 ({min}자 이상)", + "not_allowed": "{f}은(는) {values} 중 하나로", + "bad_format": "{f}의 형식을 확인해 주세요", + "too_few": "{f}에 {min_items}개 이상", + "too_many": "{f}에 {max_items}개 이하", + "duplicate_in_batch": "같은 배치에 같은 {key}가 또 있습니다. 하나만 남겨 주세요", + "pii": "개인 전화번호 · 메일 주소를 빼고 IT 포털 안내로 바꿔 주세요", + "external_link": "바깥 링크 대신 사내 주소(intra.example.com)를 써 주세요", +} + + +def fix_text(code: str, schema: dict) -> str: + kind, _, name = code.partition(":") + rule = schema["fields"].get(name, {}) + values = " · ".join(map(str, rule.get("values", []))) + return FIX.get(kind, code).format(f=name, key=schema.get("key", "key"), values=values, + max=rule.get("max"), min=rule.get("min"), + min_items=rule.get("min_items"), max_items=rule.get("max_items")) + + +def fix_list(result: dict, schema: dict) -> dict: + """R9: what the sender should fix, in row order, plus a one-line message (Lab 01 task 2).""" + items = [{"row": r["row"], "key": r["key"], "codes": r["errors"], "fix": [fix_text(c, schema) for c in r["errors"]]} + for r in result["rows"] if r["errors"]] + message = f"{result['batch']}: {result['total']}줄 중 {result['valid']}줄 통과, {result['invalid']}줄 고칠 것" + return {"fix_list": items, "message": message} + + +def quality_entry(result: dict) -> dict: + """R10: the line my-check writes to /quality-log (Lab 03 task 2).""" + return {"batch": result["batch"], "dataset": result["schema"], "total": result["total"], "valid": result["valid"], + "quarantined": result["invalid"], "rejected": False, "score": score(result), "codes": result["codes"]} + + +def split(rows: list, result: dict) -> tuple[list[dict], list[dict]]: + """R6: clean rows as they came, rejected rows with an "errors" column.""" + clean = [rows[r["row"] - 1] for r in result["rows"] if r["ok"]] + rejects = [{**(rows[r["row"] - 1] if isinstance(rows[r["row"] - 1], dict) else {}), "errors": " ".join(r["errors"])} + for r in result["rows"] if not r["ok"]] + return clean, rejects diff --git a/Last3Mon/78/tcheck/src/tcheck/cli.py b/Last3Mon/78/tcheck/src/tcheck/cli.py new file mode 100644 index 0000000..fc9be11 --- /dev/null +++ b/Last3Mon/78/tcheck/src/tcheck/cli.py @@ -0,0 +1,143 @@ +"""tcheck commands: check · gate · ui. Exit codes (R12): 0 ok · 1 cannot read · 2 usage · 4 batch rejected by the gate.""" +from __future__ import annotations + +import argparse +import csv +import json +import os +import sys +from pathlib import Path + +from . import checks, load + +EXIT_OK, EXIT_READ, EXIT_USAGE, EXIT_REJECTED = 0, 1, 2, 4 + + +class Parser(argparse.ArgumentParser): + def error(self, message): # usage mistakes are exit code 2, with the message on stderr + self.print_usage(sys.stderr) + print(f"tcheck: {message}", file=sys.stderr) + sys.exit(EXIT_USAGE) + + +def common(p: argparse.ArgumentParser) -> None: + p.add_argument("file", help="rows: .csv, a JSON list, {\"items\": [...]} or a day 78 request {\"schema\", \"batch\", \"csv\" | \"items\"}") + p.add_argument("--schema", default="", help="schema name in the data API (faq · employee · ticket); default: the file's \"schema\"") + p.add_argument("--schema-file", help="read the schema from a JSON file instead of the data API") + p.add_argument("--url", default=os.environ.get("TCHECK_URL"), help="data API base, e.g. http://localhost:8800 ($TCHECK_URL)") + p.add_argument("--batch", default="", help="batch name (default: the file's \"batch\" or its file name)") + p.add_argument("--content", action="append", default=[], metavar="FIELD", + help="also apply the content rules (pii · external_link) to this text field (R8), e.g. --content answer") + p.add_argument("--json", action="store_true", help="print JSON in the shape of n8n my-check (R5)") + + +def load_all(args) -> tuple[list, dict, dict]: + rows, meta = load.read_rows(args.file) + name = args.schema or meta.get("schema", "") + if not args.schema_file and not name: + raise SystemExit(usage("which schema? give --schema NAME (or a request file with \"schema\") or --schema-file")) + schema = load.read_schema(name, args.url, args.schema_file) + result = checks.check(rows, schema, args.batch or meta.get("batch", ""), tuple(args.content)) + return rows, schema, result + + +def usage(message: str) -> int: + print(f"tcheck: {message}", file=sys.stderr) + return EXIT_USAGE + + +def write_rows(path: str, rows: list[dict]) -> None: + """R6: .csv keeps columns in first-seen order, anything else is a JSON list.""" + target = Path(path) + if target.suffix.lower() == ".csv": + cols: list[str] = [] + for row in rows: + cols += [c for c in row if c not in cols] + with target.open("w", encoding="utf-8", newline="") as out: + writer = csv.DictWriter(out, fieldnames=cols) + writer.writeheader() + writer.writerows([{c: (json.dumps(v, ensure_ascii=False) if isinstance(v, (list, dict)) else v) + for c, v in row.items()} for row in rows]) + else: + target.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def print_result(r: dict, fixes: dict | None) -> None: + print(f"{r['schema']} · {r['batch']} · {r['total']} rows · valid {r['valid']} · invalid {r['invalid']} · score {checks.score(r)}") + for row in r["rows"]: + if not row["ok"]: + print(f" {row['row']:>3} {row['key'] or '-':<26} {', '.join(row['errors'])}") + if r["codes"]: + print("codes: " + " · ".join(f"{k} {v}" for k, v in sorted(r["codes"].items(), key=lambda kv: (-kv[1], kv[0])))) + if fixes: + print(fixes["message"]) + for item in fixes["fix_list"]: + print(f" {item['row']:>3} " + " / ".join(item["fix"])) + + +def main(argv: list[str] | None = None) -> int: + parser = Parser(prog="tcheck", description="Check CSV · JSON rows against a day 78 data schema, like n8n my-check.") + commands = parser.add_subparsers(dest="command", required=True) + p = commands.add_parser("check", help="rows · codes · valid (R1 · R2 · R3 · R5), clean / rejected files (R6)") + common(p) + p.add_argument("--out", help="write the clean rows here (.csv or .json)") + p.add_argument("--rejects", help="write the rejected rows with an 'errors' column here") + p.add_argument("--fix", action="store_true", help="add the fix list and the one-line message (R9)") + p.add_argument("--log", action="store_true", help="write one line to the data API quality log (R10)") + p = commands.add_parser("gate", help="the batch gate: exit code 4 when invalid / total is above --max-invalid (R7)") + common(p) + p.add_argument("--max-invalid", type=float, default=0.3, help="limit for invalid / total, equal passes (default 0.3)") + p = commands.add_parser("ui", help="the TUI: problem rows · by code · clean rows (R11)") + common(p) + p.add_argument("--out", help="where [e] writes the clean rows (default: .clean.csv or .json)") + try: + args = parser.parse_args(argv) + except SystemExit as stop: # --help is 0, usage mistakes are 2 + return int(stop.code or 0) + + try: + rows, schema, r = load_all(args) + if args.command == "ui": + from .app import CheckApp + CheckApp(args, rows, schema, r).run() + return EXIT_OK + if args.command == "gate": + g = checks.gate(r, args.max_invalid) + out = {**{k: r[k] for k in ("schema", "batch", "total", "valid", "invalid", "codes")}, **g} + if args.json: + print(json.dumps(out, ensure_ascii=False, indent=2)) + else: + mark = "✔ accepted" if g["accepted"] else "✘ rejected" + print(f"{mark} · {r['batch']} · invalid {r['invalid']}/{r['total']} = {g['invalid_ratio']} (limit {args.max_invalid})") + return EXIT_OK if g["accepted"] else EXIT_REJECTED + fixes = checks.fix_list(r, schema) if args.fix else None + if args.out or args.rejects: + clean, rejects = checks.split(rows, r) + if args.out: + write_rows(args.out, clean) + if args.rejects: + write_rows(args.rejects, rejects) + if args.log: + if not args.url: + return usage("--log needs --url (or $TCHECK_URL)") + load.post_log(args.url, checks.quality_entry(r)) + if args.json: + print(json.dumps({**r, **(fixes or {})}, ensure_ascii=False, indent=2)) + else: + print_result(r, fixes) + if args.out: + print(f"clean rows → {args.out}") + if args.rejects: + print(f"rejected rows → {args.rejects}") + if args.log: + print(f"quality log ← {r['batch']} · {r['schema']} · score {checks.score(r)}") + return EXIT_OK + except load.LoadError as error: + print(f"tcheck: cannot read: {error}", file=sys.stderr) + return EXIT_READ + except SystemExit as stop: + return int(stop.code or 0) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Last3Mon/78/tcheck/src/tcheck/load.py b/Last3Mon/78/tcheck/src/tcheck/load.py new file mode 100644 index 0000000..201aa3b --- /dev/null +++ b/Last3Mon/78/tcheck/src/tcheck/load.py @@ -0,0 +1,85 @@ +"""Read rows (R1) from CSV · JSON files, and the schema (R2) from the data API or a file.""" +from __future__ import annotations + +import csv +import io +import json +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +class LoadError(Exception): + """The rows or the schema could not be read (exit code 1).""" + + +def parse_csv(text: str) -> list[dict]: + """First line = column names, comma separated (AGENTS.md 3: no quotes needed). Values are trimmed.""" + reader = csv.reader(io.StringIO(text.strip())) + lines = [line for line in reader if line] + if not lines: + return [] + cols = [c.strip() for c in lines[0]] + return [{cols[i]: v.strip() for i, v in enumerate(line) if i < len(cols)} for line in lines[1:]] + + +def read_rows(path: str) -> tuple[list[dict], dict]: + """A .csv file, a JSON list, {"items": [...]}, or a day 78 request {"schema", "batch", "csv" | "items"}. + Returns (rows, meta) where meta may hold "schema" and "batch".""" + file = Path(path) + if not file.exists(): + raise LoadError(f"no such file: {path}") + text = file.read_text(encoding="utf-8") + if file.suffix.lower() == ".csv": + return parse_csv(text), {"batch": file.stem} + try: + data = json.loads(text) + except json.JSONDecodeError as error: + raise LoadError(f"not CSV or JSON: {path} ({error.msg})") from error + if isinstance(data, list): + return data, {"batch": file.stem} + if isinstance(data, dict): + meta = {k: data[k] for k in ("schema", "batch") if k in data} + meta.setdefault("batch", file.stem) + if isinstance(data.get("csv"), str): + return parse_csv(data["csv"]), meta + if isinstance(data.get("items"), list): + return data["items"], meta + raise LoadError(f"expected a list of rows, {{\"items\": [...]}} or {{\"csv\": \"...\"}}: {path}") + + +def http_json(url: str, body: dict | None = None, timeout: float = 10) -> object: + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(request, timeout=timeout) as answer: + return json.loads(answer.read()) + + +def read_schema(name: str = "", url: str | None = None, file: str | None = None) -> dict: + """--schema-file wins over the data API ($TCHECK_URL may be set for every call).""" + if file: + try: + schema = json.loads(Path(file).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise LoadError(f"cannot read schema file {file}: {error}") from error + elif url and name: + try: + schema = http_json(url.rstrip("/") + "/schemas/" + urllib.parse.quote(name)) + except urllib.error.HTTPError as error: + raise LoadError(f"unknown schema '{name}' (data API {error.code})") from error + except (urllib.error.URLError, OSError, json.JSONDecodeError) as error: + raise LoadError(f"data API {url}: {error}") from error + else: + raise LoadError("no schema: give --schema NAME with --url (or $TCHECK_URL), or --schema-file") + if not isinstance(schema, dict) or not isinstance(schema.get("fields"), dict): + raise LoadError("the schema has no 'fields'") + return schema + + +def post_log(url: str, entry: dict) -> dict: + """R10: one line in the data API quality log (POST /quality-log).""" + try: + return http_json(url.rstrip("/") + "/quality-log", entry) + except (urllib.error.URLError, OSError, json.JSONDecodeError) as error: + raise LoadError(f"data API {url}: {error}") from error diff --git a/Last3Mon/78/tcheck/tests/conftest.py b/Last3Mon/78/tcheck/tests/conftest.py new file mode 100644 index 0000000..baf1a41 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/conftest.py @@ -0,0 +1,55 @@ +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +import pytest + +DATA = Path(__file__).parent / "data" + + +@pytest.fixture(scope="session") +def n8n(): + """What n8n my-check answered on 2026-11-01 for the same three batches (Last3Mon/78/answers/18 · 21).""" + return json.loads((DATA / "n8n-my-check.json").read_text(encoding="utf-8")) + + +@pytest.fixture +def api(): + """A local stand-in for the day 78 data API: GET /schemas/ from tests/data/schemas, POST /quality-log kept in memory.""" + log: list[dict] = [] + + class Handler(BaseHTTPRequestHandler): + def send(self, status, body): + data = json.dumps(body, ensure_ascii=False).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + path = DATA / "schemas" / (self.path.removeprefix("/schemas/") + ".json") + if self.path.startswith("/schemas/") and path.exists(): + return self.send(200, json.loads(path.read_text(encoding="utf-8"))) + return self.send(404, {"error": "schema not found"}) + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}") + if self.path == "/quality-log": + log.append(body) + return self.send(200, {"stored": 1, "count": len(log)}) + return self.send(404, {}) + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + server.log = log + yield server + server.shutdown() + + +@pytest.fixture +def url(api): + return f"http://127.0.0.1:{api.server_address[1]}" diff --git a/Last3Mon/78/tcheck/tests/data/employees.csv b/Last3Mon/78/tcheck/tests/data/employees.csv new file mode 100644 index 0000000..440fb68 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/employees.csv @@ -0,0 +1,10 @@ +name,dept,email,phone,joined +오세훈,영업팀,sehoon.oh@example.com,010-2222-3333,2026-10-01 +윤하늘,재무팀,haneul.yoon@example.com,,2026-10-05 +강민수,개발팀,minsu.kang@example,010-4444-5555,2026-10-06 +서지아,마케팅팀,jia.seo@example.com,010-6666-7777,2026-10-07 +임도윤,인사팀,doyun.lim@example.com,010-8888-9999,2026-13-01 +,IT팀,noname@example.com,,2026-10-09 +배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12 +배수아,총무팀,sua.bae@example.com,02-123-4567,2026-10-12 +정다은,IT팀,daeun.jung@example.com,010-1111-2222,2026-10-14 diff --git a/Last3Mon/78/tcheck/tests/data/faq-batch-1.json b/Last3Mon/78/tcheck/tests/data/faq-batch-1.json new file mode 100644 index 0000000..c787865 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/faq-batch-1.json @@ -0,0 +1,113 @@ +{ + "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": "메일함 용량이 꽉 차면 오래된 메일을 보관함으로 옮겨 용량을 확보합니다." + } + ] +} diff --git a/Last3Mon/78/tcheck/tests/data/faq-batch-3.json b/Last3Mon/78/tcheck/tests/data/faq-batch-3.json new file mode 100644 index 0000000..9f4ade4 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/faq-batch-3.json @@ -0,0 +1,54 @@ +{ + "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월에 인사팀이 안내하며 지정 병원에서 받습니다." + } + ] +} diff --git a/Last3Mon/78/tcheck/tests/data/n8n-my-check.json b/Last3Mon/78/tcheck/tests/data/n8n-my-check.json new file mode 100644 index 0000000..fe1adf7 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/n8n-my-check.json @@ -0,0 +1,218 @@ +{ + "employee": { + "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": { + "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 + } + }, + "ticket": { + "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 + } + } +} \ No newline at end of file diff --git a/Last3Mon/78/tcheck/tests/data/schemas/employee.json b/Last3Mon/78/tcheck/tests/data/schemas/employee.json new file mode 100644 index 0000000..61c5940 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/schemas/employee.json @@ -0,0 +1,11 @@ +{ + "name": "employee", + "key": "email", + "fields": { + "name": {"type": "string", "required": true, "max": 20}, + "dept": {"type": "enum", "required": true, "values": ["영업팀", "재무팀", "인사팀", "개발팀", "IT팀", "총무팀"]}, + "email": {"type": "email", "required": true}, + "phone": {"type": "phone", "required": false}, + "joined": {"type": "date", "required": true} + } +} diff --git a/Last3Mon/78/tcheck/tests/data/schemas/faq.json b/Last3Mon/78/tcheck/tests/data/schemas/faq.json new file mode 100644 index 0000000..4de0d5f --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/schemas/faq.json @@ -0,0 +1,10 @@ +{ + "name": "faq", + "key": "id", + "fields": { + "id": {"type": "pattern", "required": true, "pattern": "^FAQ-\\d{3}$"}, + "title": {"type": "string", "required": true, "max": 40}, + "keywords": {"type": "list", "required": true, "min_items": 1, "max_items": 8}, + "answer": {"type": "string", "required": true, "min": 10, "max": 300} + } +} diff --git a/Last3Mon/78/tcheck/tests/data/schemas/ticket.json b/Last3Mon/78/tcheck/tests/data/schemas/ticket.json new file mode 100644 index 0000000..fc62533 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/schemas/ticket.json @@ -0,0 +1,12 @@ +{ + "name": "ticket", + "key": "ticket_id", + "fields": { + "ticket_id": {"type": "pattern", "required": true, "pattern": "^T-\\d{4}$"}, + "title": {"type": "string", "required": true, "min": 4, "max": 40}, + "requester": {"type": "string", "required": true, "max": 20}, + "priority": {"type": "enum", "required": true, "values": ["low", "normal", "high"]}, + "opened": {"type": "date", "required": true}, + "contact": {"type": "phone", "required": false} + } +} diff --git a/Last3Mon/78/tcheck/tests/data/tickets-new.json b/Last3Mon/78/tcheck/tests/data/tickets-new.json new file mode 100644 index 0000000..1efb730 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/data/tickets-new.json @@ -0,0 +1,71 @@ +{ + "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" + } + ] +} diff --git a/Last3Mon/78/tcheck/tests/test_app.py b/Last3Mon/78/tcheck/tests/test_app.py new file mode 100644 index 0000000..b5a7559 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/test_app.py @@ -0,0 +1,32 @@ +import json +from types import SimpleNamespace + +from tcheck import checks, load +from tcheck.app import CheckApp +from conftest import DATA + + +def make(tmp_path): + rows, _ = load.read_rows(str(DATA / "employees.csv")) + schema = json.loads((DATA / "schemas" / "employee.json").read_text()) + args = SimpleNamespace(file=str(DATA / "employees.csv"), out=str(tmp_path / "clean.csv")) + return CheckApp(args, rows, schema, checks.check(rows, schema, "HR-1")) + + +async def test_tabs_filter_and_export(tmp_path): + app = make(tmp_path) + async with app.run_test() as pilot: + assert "9 rows · valid 4" in str(app.query_one("#top").render()) + assert app.query_one("#problem-table").row_count == 5 + assert app.query_one("#code-table").row_count == 5 + assert app.query_one("#clean-table").row_count == 4 + await pilot.press("slash") + await pilot.pause() + for key in "email": + await pilot.press(key) + await pilot.press("enter") + assert app.query_one("#problem-table").row_count == 1 + await pilot.press("escape") + assert app.query_one("#problem-table").row_count == 5 + await pilot.press("e") + assert app.exported.endswith("clean.csv") and (tmp_path / "clean.csv").read_text().count("\n") == 5 diff --git a/Last3Mon/78/tcheck/tests/test_checks.py b/Last3Mon/78/tcheck/tests/test_checks.py new file mode 100644 index 0000000..5512f1b --- /dev/null +++ b/Last3Mon/78/tcheck/tests/test_checks.py @@ -0,0 +1,78 @@ +import json + +import pytest + +from tcheck import checks, load +from conftest import DATA + +FILES = {"employee": "employees.csv", "ticket": "tickets-new.json", "faq": "faq-batch-3.json"} + + +def schema(name): + return json.loads((DATA / "schemas" / f"{name}.json").read_text(encoding="utf-8")) + + +def run(name, batch="X", content=()): + rows, _ = load.read_rows(str(DATA / FILES[name])) + return checks.check(rows, schema(name), batch, content) + + +@pytest.mark.parametrize("name", ["employee", "ticket", "faq"]) +def test_same_rows_and_codes_as_n8n(name, n8n): + """R4: the same file gives the same rows · codes · valid as n8n my-check.""" + r = run(name, n8n[name]["batch"]) + for key in ("schema", "batch", "total", "valid", "invalid", "rows", "codes"): + assert r[key] == n8n[name][key], key + + +def test_employee_reasons(): + r = run("employee") + assert [row["errors"] for row in r["rows"]] == [[], [], ["bad_format:email"], ["not_allowed:dept"], ["bad_format:joined"], + ["missing:name"], [], ["duplicate_in_batch"], []] + + +def test_ticket_optional_field_and_impossible_date(): + r = run("ticket") + assert r["rows"][3]["errors"] == ["bad_format:opened"] # 2026-02-30 + assert r["rows"][5]["errors"] == ["bad_format:contact"] # present but wrong + assert r["rows"][8]["ok"] # contact missing is fine + + +def test_string_list_and_blank_edges(): + s = {"name": "t", "key": "id", "fields": {"id": {"type": "string", "required": True, "min": 2, "max": 3}, + "tags": {"type": "list", "required": False, "min_items": 1, "max_items": 2}}} + r = checks.check([{"id": "ab"}, {"id": "abcd"}, {"id": " "}, {"id": "abc", "tags": "x"}, {"id": "ab2", "tags": [1, 2, 3]}], s) + assert [row["errors"] for row in r["rows"]] == [[], ["too_long:id"], ["missing:id"], ["bad_format:tags"], ["too_many:tags"]] + + +def test_content_rules_on_a_field(): + """R8: Lab 01 rules — phone without dashes, exact intra host.""" + s = {"name": "faq", "key": "id", "fields": {}} + rows = [{"id": "1", "answer": "당직 01012345678"}, {"id": "2", "answer": "02-123-4567"}, + {"id": "3", "answer": "https://intra.example.com/help"}, {"id": "4", "answer": "http://intra.example.com.evil.io/x"}, + {"id": "5", "answer": "a@b.com"}] + r = checks.check(rows, s, content=("answer",)) + assert [row["errors"] for row in r["rows"]] == [["pii"], [], [], ["external_link"], ["pii"]] + + +def test_gate_equal_passes(): + r = run("employee") # 5 / 9 = 0.5556 + assert checks.gate(r, 0.3) == {"accepted": False, "invalid_ratio": 0.5556, "max_invalid_ratio": 0.3} + assert checks.gate({"invalid": 1, "total": 10}, 0.1)["accepted"] is True + assert checks.gate({"invalid": 0, "total": 0}, 0.3)["accepted"] is True + + +def test_fix_list_and_message(): + r = run("ticket", "TK-1") + f = checks.fix_list(r, schema("ticket")) + assert f["message"] == "TK-1: 9줄 중 2줄 통과, 7줄 고칠 것" + assert [i["row"] for i in f["fix_list"]] == [2, 3, 4, 5, 6, 7, 8] + assert f["fix_list"][1]["fix"] == ["priority은(는) low · normal · high 중 하나로"] + assert "ticket_id" in f["fix_list"][5]["fix"][0] + + +def test_quality_entry_and_round(): + e = checks.quality_entry(run("employee", "HR-1")) + assert e == {"batch": "HR-1", "dataset": "employee", "total": 9, "valid": 4, "quarantined": 5, "rejected": False, + "score": 0.4444, "codes": e["codes"]} + assert checks.js_round(0.00005) == 0.0001 and checks.js_round(2 / 3) == 0.6667 diff --git a/Last3Mon/78/tcheck/tests/test_cli.py b/Last3Mon/78/tcheck/tests/test_cli.py new file mode 100644 index 0000000..e4771e2 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/test_cli.py @@ -0,0 +1,49 @@ +import csv +import json + +from tcheck import cli +from conftest import DATA + +EMP = str(DATA / "employees.csv") +EMP_SCHEMA = str(DATA / "schemas" / "employee.json") + + +def test_check_plain(capsys): + assert cli.main(["check", EMP, "--schema-file", EMP_SCHEMA]) == 0 + out = capsys.readouterr().out + assert "9 rows · valid 4 · invalid 5 · score 0.4444" in out and "bad_format:email" in out + + +def test_check_json_like_my_check(capsys, url, n8n): + assert cli.main(["check", str(DATA / "tickets-new.json"), "--url", url, "--json"]) == 0 # schema from the file + out = json.loads(capsys.readouterr().out) + assert {k: out[k] for k in ("schema", "batch", "valid", "rows", "codes")} == {k: n8n["ticket"][k] for k in ("schema", "batch", "valid", "rows", "codes")} + + +def test_out_and_rejects_files(tmp_path): + clean, rejects = tmp_path / "clean.csv", tmp_path / "rejects.csv" + assert cli.main(["check", EMP, "--schema-file", EMP_SCHEMA, "--out", str(clean), "--rejects", str(rejects)]) == 0 + assert len(list(csv.DictReader(clean.open()))) == 4 + bad = list(csv.DictReader(rejects.open())) + assert len(bad) == 5 and bad[0]["errors"] == "bad_format:email" + + +def test_gate_exit_codes(capsys): + assert cli.main(["gate", EMP, "--schema-file", EMP_SCHEMA]) == 4 + assert "rejected" in capsys.readouterr().out + assert cli.main(["gate", EMP, "--schema-file", EMP_SCHEMA, "--max-invalid", "0.6"]) == 0 + + +def test_fix_and_log(capsys, url, api): + assert cli.main(["check", EMP, "--schema", "employee", "--url", url, "--batch", "HR-1", "--fix", "--log"]) == 0 + out = capsys.readouterr().out + assert "HR-1: 9줄 중 4줄 통과, 5줄 고칠 것" in out and "email의 형식을 확인해 주세요" in out + assert api.log == [{"batch": "HR-1", "dataset": "employee", "total": 9, "valid": 4, "quarantined": 5, "rejected": False, + "score": 0.4444, "codes": api.log[0]["codes"]}] + + +def test_exit_codes_read_and_usage(tmp_path, url, capsys): + assert cli.main(["check", str(tmp_path / "none.csv"), "--schema-file", EMP_SCHEMA]) == 1 + assert cli.main(["check", EMP, "--schema", "nothing", "--url", url]) == 1 + assert cli.main(["check", EMP]) == 2 # which schema? + assert cli.main(["nonsense"]) == 2 diff --git a/Last3Mon/78/tcheck/tests/test_load.py b/Last3Mon/78/tcheck/tests/test_load.py new file mode 100644 index 0000000..5c16dd3 --- /dev/null +++ b/Last3Mon/78/tcheck/tests/test_load.py @@ -0,0 +1,44 @@ +import json + +import pytest + +from tcheck import load +from conftest import DATA + + +def test_csv_first_line_is_columns(): + rows, meta = load.read_rows(str(DATA / "employees.csv")) + assert len(rows) == 9 and rows[2]["email"] == "minsu.kang@example" and rows[1]["phone"] == "" + assert meta["batch"] == "employees" + + +def test_request_file_with_csv_or_items(tmp_path): + rows, meta = load.read_rows(str(DATA / "tickets-new.json")) + assert len(rows) == 9 and meta == {"schema": "ticket", "batch": "TK-1"} + f = tmp_path / "hr.json" + f.write_text(json.dumps({"schema": "employee", "batch": "HR-1", "csv": (DATA / "employees.csv").read_text()})) + assert len(load.read_rows(str(f))[0]) == 9 + + +def test_plain_list(tmp_path): + f = tmp_path / "rows.json" + f.write_text("[{\"a\": 1}]") + assert load.read_rows(str(f)) == ([{"a": 1}], {"batch": "rows"}) + + +def test_missing_or_bad_files(tmp_path): + with pytest.raises(load.LoadError): + load.read_rows(str(tmp_path / "nope.csv")) + bad = tmp_path / "bad.json" + bad.write_text("{\"x\": 1}") + with pytest.raises(load.LoadError): + load.read_rows(str(bad)) + + +def test_schema_from_api_or_file(url): + assert load.read_schema("employee", url)["key"] == "email" + assert load.read_schema("", None, str(DATA / "schemas" / "ticket.json"))["name"] == "ticket" + with pytest.raises(load.LoadError): + load.read_schema("nothing", url) + with pytest.raises(load.LoadError): + load.read_schema("employee", None) diff --git a/Last3Mon/78/tcheck/uv.lock b/Last3Mon/78/tcheck/uv.lock new file mode 100644 index 0000000..324c47f --- /dev/null +++ b/Last3Mon/78/tcheck/uv.lock @@ -0,0 +1,195 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/4e/2684728d9f2c8c4110738dcadca7e0bed4b60c8dc68c40c2a9e0cf017900/platformdirs-4.14.2.tar.gz", hash = "sha256:6d13b4c1d66b7548dcec9e3db13c1ba9d9adb65041b5d07869313afc07e6e41b", size = 70792, upload-time = "2026-10-31T15:10:34.029Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2d/98af31fdcd7c95c7114e2eb7c2eb0fa55d2a012c37ba4eb3722b7272abcb/platformdirs-4.14.2-py3-none-any.whl", hash = "sha256:dc1f93390f2a9265e44e5afc8667ec7faf737739604e39bc538fec889720c36b", size = 35291, upload-time = "2026-10-31T15:10:32.615Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/9a/887535e477847f5159377ab35303923644840b13c0ca03c0a467f84d4680/pytest_asyncio-1.4.1.tar.gz", hash = "sha256:e8266eb094e5ee9ffdb2068c9836f33e209ea77a970eb846933d416b5602669b", size = 145237, upload-time = "2026-10-25T21:05:37.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/d7/cd9e2708afdbf059658adaf08bdb0792ca52489871051d6ce7d498b51277/pytest_asyncio-1.4.1-py3-none-any.whl", hash = "sha256:507afa78ec77c6661164ef1a2368bc7820383fd1a14b2a7f813de8b5a92949cc", size = 17061, upload-time = "2026-10-25T21:05:35.473Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "tcheck" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "textual" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "textual", specifier = ">=0.80" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]