Files
aio-01d4-stu/Last3Mon/78/LAB-03-Q-QUALITY-MANAGEMENT.md

43 KiB
Raw Permalink Blame History

Lab 03 · 품질 관리 · 스키마로 넓히고, 고쳐서 다시, 추세로 보기

  • 관련 강의: 3단계 · 품질 관리
  • 소요 시간: 50분 (6교시 한 시간) · 예제 1 14분 · 예제 2 8분 · 예제 3 6분 · 과제 1 12분 · 과제 2 10분
  • 준비: Lab 02의 my-faq-import(과제 2까지)가 게시돼 있음
  • 목표: FAQ에만 맞춘 규칙을 스키마(규칙을 적은 데이터)로 넓혀 직원 CSV도 같은 워크플로우로 검사한다. 고친 줄을 다시 넣으면 격리함에서 빼고, 새 스키마를 더해도 워크플로우를 고치지 않는 것을 확인한다. 품질 기록으로 보고서(평균 · 가장 나쁜 배치 · 흔한 문제 · 추세)를 만들고, 데이터셋마다 나눠 본다.
flowchart LR
  E1["예제 1<br/>스키마 검사"] --> E2["예제 2<br/>고쳐서 다시"] --> E3["예제 3<br/>스키마만 바꾸면"] --> T1["과제 1<br/>품질 보고서"] --> T2["과제 2<br/>데이터셋별 품질"]

복사해 붙일 요청문은 없습니다. 막히면 ▶ 를 하나씩 펼칩니다. 연습이 끝나면 uv run tools/flow.py quality --reset.

예제 1 · 스키마 검사 (14분)

이 실습의 의도

한 줄로: 인사팀이 새 직원 9명을 CSV로 보냈습니다. FAQ 검사로는 읽지도 못합니다. 규칙을 코드에 쓰지 않고 데이터 API의 스키마(/schemas/employee)에서 읽어 판정하는 검사(my-check)를 만들어, 같은 워크플로우로 FAQ도 직원도 검사합니다.

생활 속 예 · 품목마다 다른 검수 카드. 채소 · 고기 · 생선은 보는 것이 다릅니다 (채소는 무른 곳, 고기는 온도와 라벨, 생선은 눈 색). 품목마다 주방장을 따로 두지 않고, 품목마다 검수 카드를 만들어 두면 한 사람이 카드를 바꿔 들고 무엇이든 검수합니다. 예제 1은 검수 카드(스키마)를 읽어 판정하는 검사를 만드는 실습입니다.

상황. requests/employees-new.json은 {"schema": "employee", "batch": "HR-1", "csv": "name,dept,email,phone,joined\n…"} 꼴입니다. Lab 01의 my-faq-check에 보내면 items가 없어 400입니다. FAQ용 규칙을 복사해 직원용 Code를 하나 더 만들 수도 있지만, 그러면 데이터가 늘 때마다 워크플로우가 늘어납니다.

스키마의 칸 (/schemas/employee) 뜻
key 배치 안에서 겹치면 안 되는 칸 (email)
fields.<이름>.type string · enum · email · phone · date · pattern · list
required · min · max · values · pattern 타입마다 확인할 것

순서. FAQ 검사에 직원 CSV를 먼저 보내 봄 → 요청문 → push → 4/4.

끝나면 알게 되는 것. 규칙을 데이터로 두면(스키마) 검사 하나로 여러 데이터를 볼 수 있다는 것. 스키마로 되는 규칙(형식 · 목록 · 필수)과 안 되는 규칙(개인정보 · 이미 있는 데이터 · 뜻)의 경계.

적을 곳. my-work/QUALITY.md 8절 표.

uv run tools/flow.py call my-faq-check requests/employees-new.json     # 고치기 전 · FAQ 검사에 직원 CSV
uv run tools/flow.py test cases/lab03.json -v                          # 고치기 전 · my-check가 아직 없음

이해하기

지금 무엇이 문제인가. Lab 01의 검사는 FAQ의 칸 이름(title · answer)과 규칙이 Code 안에 박혀 있습니다. 직원 데이터는 칸도 규칙도 다릅니다. 고치는 일은 "규칙을 Code 밖(스키마)으로 꺼내고, Code는 스키마를 읽어 타입마다 같은 방식으로 판정하게" 하는 것입니다.

flowchart LR
  W(["schema · batch ·<br/>items 또는 csv"]) --> S["Get schema<br/>GET /schemas/&lt;schema&gt;"] --> K{"fields가 있나?"}
  K -->|아니오| R(["400 unknown schema"])
  K -->|예| C["Check rows<br/>csv → 줄로<br/>fields를 읽어 타입마다"] --> O(["rows · codes"])

스키마(schema)란. 데이터의 모양을 적은 데이터입니다: 어떤 칸이 있고, 칸마다 타입은 무엇이고, 꼭 있어야 하나. 업계에서는 JSON Schema · 데이터베이스의 테이블 정의 · Great Expectations의 기대값 등이 같은 일을 합니다. 오늘 스키마는 AGENTS.md 3절 "스키마" 표의 작은 판입니다.

직원 CSV의 줄 스키마의 규칙 코드
minsu.kang@example email 형식 bad_format:email
마케팅팀 enum — 여섯 팀 중 하나 not_allowed:dept
2026-13-01 date — 실제 있는 날짜 bad_format:joined
이름이 빈 줄 required missing:name
같은 메일이 두 번 key duplicate_in_batch

CSV. 첫 줄이 열 이름이고 쉼표로 나눕니다 (오늘은 따옴표 없음). row는 사람이 세는 번호(1부터, 열 이름 줄 제외)입니다.

테스트 케이스.

케이스 기대
직원 CSV · 9줄 중 4줄 통과 schema employee, 7째 줄(사무실 번호 02-123-4567)은 통과
직원 CSV · 줄마다 이유 위 표의 코드들
같은 워크플로우로 FAQ 스키마 B-3을 schema: faq로 → 6줄 중 5줄, 5째 줄 too_short:answer
모르는 스키마 400

기대 결과. 고치기 전: FAQ 검사는 400, lab03 0/4 (웹훅이 없음). 고친 뒤 4/4.

▶ 1단계 · 타입마다 한 번씩

for (const [name, f] of Object.entries(schema.fields))로 칸을 돌며, 비었으면 required만 보고 다음 칸으로, 아니면 f.type마다 확인합니다. 칸 이름(email)을 Code에 쓰지 않는 것이 핵심입니다.

▶ 2단계 · `2026-13-01`이 통과한다면

YYYY-MM-DD 꼴만 보면 13월도 통과합니다. new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v처럼 실제 있는 날짜인지까지 봅니다 (예제 3의 2026-02-30도).

▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대

요청문에 꼭 들어갈 것: ① 모르는 스키마 ② CSV 읽기 ③ 규칙의 출처 ④ 줄 번호 · 결과 모양 · 기대 결과. 아래 빈칸 4개를 채웁니다.

my-work/check.json 을 새로 만들어 줘. 웹훅 POST my-check, 이름 "78 Schema check (my)".
Get schema: HTTP GET http://data-api:8800/schemas/<본문 schema> (neverError). 스키마가 없으면(fields 가 없음) HTTP ____① {error: "unknown schema", schema}.
Check rows(Code): 본문 csv 가 있으면 첫 줄을 ____②으로 쉼표로 나눠 줄로 바꾸고, items 가 있으면 그대로.
필드 규칙은 코드에 쓰지 말고 스키마의 ____③ 를 읽어 AGENTS.md 3절 타입 표대로 (required · string · enum · email · phone · date · pattern · list), 키는 배치 안 중복.
결과 {schema, batch, total, valid, invalid, rows: [{row (____④), key, ok, errors}], codes}. 기대 결과 cases/lab03.json.

빈칸 힌트:

  • ① 테스트의 "모르는 스키마" 케이스가 기대하는 상태.
  • ② CSV의 첫 줄은 무엇인가요?
  • ③ 규칙을 Code에 쓰지 않으면, 스키마 JSON의 어느 칸을 읽나요?
  • ④ AGENTS.md 3절 스키마 표 아래 줄.
uv run tools/flow.py push my-work/check.json
uv run tools/flow.py test cases/lab03.json -v

실행 결과 · 스키마 검사

수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 판정은 스키마대로라 누구에게나 같습니다.

고치기 전 · FAQ 검사에 직원 CSV, 그리고 my-check가 없을 때

실행한 명령:

uv run tools/flow.py call my-faq-check requests/employees-new.json
uv run tools/flow.py test cases/lab03.json -v

출력:

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)
✘ 직원 CSV · 줄마다 이유 · 0.0s · status 404 (expected 200); rows.2.errors has none of ['bad_format:email']; …
✘ 같은 워크플로우로 FAQ 스키마 · 0.0s · status 404 (expected 200); schema = null (expected faq); …
✘ 모르는 스키마 → 400 · 0.0s · status 404 (expected 400)
Passed 0/4

왜 이렇게 나왔나 (고치기 전)

  • FAQ 검사는 400: 직원 배치에는 items가 없고 csv 글자가 있습니다. Lab 01의 검사는 items 목록만 압니다. 칸 이름(title · answer)도 규칙도 FAQ에 박혀 있어, 설령 줄로 바꿔 넣어도 모든 줄이 title_missing일 것입니다 (예제 3에서 접수 건으로 확인).
  • lab03 404: my-check 웹훅이 아직 없습니다. 0/4는 예상한 결과.

고친 뒤 · 내 my-check

실행한 명령:

uv run tools/flow.py push my-work/check.json
uv run tools/flow.py test cases/lab03.json -v

출력 (결과 줄과 -v의 직원 응답을 줄마다 한 줄로):

✔ 직원 CSV · 9줄 중 4줄 통과 · 0.9s
✔ 직원 CSV · 줄마다 이유 · 0.0s
✔ 같은 워크플로우로 FAQ 스키마 · 0.1s
✔ 모르는 스키마 → 400 · 0.0s
Passed 4/4

{ "schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, "invalid": 5,
  "rows": [ 1 sehoon.oh@example.com ok · 2 haneul.yoon@example.com ok · 3 minsu.kang@example bad_format:email ·
            4 jia.seo@example.com not_allowed:dept · 5 doyun.lim@example.com bad_format:joined ·
            6 noname@example.com missing:name · 7 sua.bae@example.com ok · 8 sua.bae@example.com duplicate_in_batch ·
            9 daeun.jung@example.com ok ],
  "codes": { "bad_format:email": 1, "not_allowed:dept": 1, "bad_format:joined": 1, "missing:name": 1, "duplicate_in_batch": 1 } }
FAQ 스키마로 B-3:  6줄 중 5줄 · 5 FAQ-305 too_short:answer

고친 뒤 이렇게 나온 까닭

줄 값 판정 스키마의 어느 규칙
2 phone 빈칸 통과 phone은 required: false — 비었으면 형식을 보지 않음
3 minsu.kang@example bad_format:email email — @ 뒤에 점이 있는 도메인
4 마케팅팀 not_allowed:dept enum 여섯 팀
5 2026-13-01 bad_format:joined date — 13월은 없음
6 이름 빈칸 missing:name required
7 02-123-4567 통과 phone 꼴 0\d{1,2}-\d{3,4}-\d{4} (사무실 번호도 맞음)
8 7째와 같은 메일 duplicate_in_batch key: email
  • 같은 워크플로우, 다른 스키마: B-3을 schema: faq로 보내면 6줄 중 5줄, FAQ-305가 too_short:answer. Lab 01 검사의 answer_too_short와 같은 판정을 **스키마의 min: 10**으로 냈습니다.
  • 스키마로 안 되는 것: FAQ 스키마 검사는 개인정보 · 바깥 링크 · 이미 있는 FAQ를 보지 않습니다 (B-3에는 그런 줄이 없어 결과가 같았을 뿐). 그래서 FAQ는 Lab 01의 검사를 계속 씁니다. QUALITY.md 8절에 이 경계를 적습니다.
  • row는 1부터: CSV의 열 이름 줄을 빼고 사람이 세는 번호입니다 (케이스 파일은 0부터 세므로 3째 줄이 rows.2).

예제 2 · 고쳐서 다시 넣기 (8분)

이 실습의 의도

한 줄로: 콘텐츠 담당자가 격리된 B-2의 두 줄(FAQ-209 메일 주소 · FAQ-210 다른 답)을 고쳐 다시 보냈습니다(faq-batch-2-fixed.json). 넣는 것은 지금 가져오기로도 되지만, 격리함에 옛 줄이 남습니다. 넣은 줄을 격리함에서 빼는 노드를 더합니다.

생활 속 예 · 보류 칸 비우기. 납품업체가 라벨을 붙여 다시 가져온 고기를 냉장고에 넣었는데, 보류 칸의 옛 상자에 "라벨 없음" 쪽지가 그대로 붙어 있습니다. 다음 날 아침 주방장은 "아직 해결 안 됐네" 하고 또 전화합니다. 예제 2는 다시 받은 것을 보류 칸에서 치우는 실습입니다.

상황. 테스트가 먼저 B-2를 가져와 격리함에 2줄을 만들고, 고친 배치를 보냅니다. 격리함은 "사람이 볼 일"의 목록입니다. 해결된 줄이 남아 있으면 목록을 믿을 수 없습니다.

노드 하는 일
Release fixed (HTTP) POST /quarantine/remove {ids: imported_ids} — 이번에 넣은 줄만
Result again (Code) 응답은 가져오기 결과 그대로 (Lab 02 과제 1과 같음)

끝나면 알게 되는 것. 격리 → 사람이 고침 → 다시 넣음 → 격리함에서 뺌, 이 되돌림 고리가 품질 관리의 한 바퀴라는 것. 고쳤는데 또 틀린 줄은 격리함에 남아야 한다는 것.

적을 곳. my-work/QUALITY.md 9절 (B-2-fix 줄).

uv run tools/flow.py test cases/lab03-fix.json -v    # 고치기 전 (Lab 02 과제 2까지의 가져오기)
uv run tools/flow.py quality

이해하기

flowchart LR
  Q[("격리함<br/>FAQ-209 · FAQ-210")] -.->|사람이 고침| F(["B-2-fix"]) --> I["my-faq-import<br/>검사 · LLM · 넣기"] --> X["Release fixed<br/>넣은 id만 뺌"] --> Q
  I --> L["품질 기록 · score 1"]

넣은 줄만 빼는 까닭. 고친 줄이 또 규칙이나 LLM에 걸리면 그 줄은 다시 격리됩니다 (같은 배치 · 같은 id면 바뀜 — AGENTS.md 2절). 이번에 넣은 id만 빼야, 아직 해결되지 않은 줄이 목록에서 사라지지 않습니다.

테스트 케이스.

케이스 기대
고친 두 줄 다시 가져오기 넣음 2 · 격리 0 · score 1
격리함이 비었나 size 0
FAQ에 FAQ-209 · FAQ-210 FAQ 16개 (처음 6 + B-2 8 + 고친 2)
품질 기록 · 점수 1 둘째 줄 score 1

기대 결과. 고치기 전 3/4 (격리함만 ✘ — 2줄 그대로), 고친 뒤 4/4.

▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대

요청문에 꼭 들어갈 것: ① 뺄 id ② 걸린 줄 ③ 앞의 기록 ④ 응답. 아래 빈칸 4개를 채웁니다.

my-work/faq-import.json 에서 고쳐서 다시 넣은 줄을 격리함에서 빼 줘.
Summarize 뒤에 Release fixed: POST http://data-api:8800/quarantine/remove {ids: ____①}.
넣은 줄만 뺀다 — 이번에도 걸린 줄은 격리함에 ____②. 품질 기록 · 알림은 ____③.
응답은 Summarize 의 결과를 ____④ (Release fixed 의 응답이 아님). 기대 결과 cases/lab03-fix.json.

빈칸 힌트:

  • ① Summarize의 결과 칸 중 이번에 넣은 id 목록.
  • ② 고쳤는데 또 틀린 줄은 격리함에서 어떻게 되어야 하나요?
  • ③ Lab 02 과제 1에서 더한 것.
  • ④ HTTP 노드 뒤에서 응답하면 무엇이 나가나요? (Lab 02 과제 1의 "Result again")
uv run tools/flow.py push my-work/faq-import.json
uv run tools/flow.py test cases/lab03-fix.json -v
uv run tools/flow.py quality

실행 결과 · 고쳐서 다시 넣기

수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.

고치기 전 · Lab 02 과제 2까지의 가져오기

실행한 명령:

uv run tools/flow.py test cases/lab03-fix.json -v
uv run tools/flow.py quality

출력 (결과 줄과 quality):

✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, …
POST http://localhost:5678/webhook/my-faq-import · 4 case(s) × 1
✔ 고친 두 줄 다시 가져오기 · 2.5s
✘ 격리함이 비었나 · 0.0s · size = 2 (expected 0)
✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s
✔ 품질 기록 · 점수 1 · 0.0s
Passed 3/4

FAQ: 6  (nothing added)
quarantine: 2
quality log: 2
alerts: 1

왜 이렇게 나왔나 (고치기 전)

  • before가 B-2를 가져와 FAQ-209 · FAQ-210을 격리했습니다. 고친 배치(B-2-fix)는 두 줄 모두 규칙 · LLM을 통과해 넣는 것은 됩니다 (score 1, FAQ 16개).
  • 그런데 격리함에 옛 두 줄이 그대로입니다 (size 2). 고친 배치의 batch는 B-2-fix라 "같은 배치 · 같은 id면 바꿈"에도 해당하지 않습니다. 격리함만 보면 FAQ-209 · 210이 아직 문제인 것처럼 보입니다.
  • quality의 알림 1: B-2(0.8)의 low_quality. B-2-fix는 1이라 알림이 없습니다. 3/4는 예상한 결과.

고친 뒤

실행한 명령:

uv run tools/flow.py push my-work/faq-import.json
uv run tools/flow.py test cases/lab03-fix.json -v
uv run tools/flow.py quality

출력:

✔ before: POST /webhook/my-faq-import · HTTP 200 · 10.7s · {"batch": "B-2", "total": 10, "imported": 8, "quarantined": 2, …
✔ 고친 두 줄 다시 가져오기 · 2.0s
✔ 격리함이 비었나 · 0.0s
✔ FAQ에 FAQ-209 · FAQ-210 · 0.0s
✔ 품질 기록 · 점수 1 · 0.0s
Passed 4/4

FAQ: 6  (nothing added)
quarantine: 0
quality log: 2
alerts: 1

고친 뒤 이렇게 나온 까닭

고치기 전 고친 뒤 무엇이 바꿨나
넣은 줄 FAQ-209 · 210 같음 (가져오기는 원래 됨)
격리함 2줄 (해결된 줄이 남음) 0 Release fixed → /quarantine/remove {ids: imported_ids}
응답 score 1 같음 Result again
품질 기록 B-2 0.8 · B-2-fix 1 같음 (Lab 02 과제 1 그대로)
  • 되돌림 고리가 한 바퀴: 격리(B-2) → 사람이 고침(B-2-fix) → 다시 넣음 → 격리함에서 뺌. 품질 기록에는 0.8 → 1이 남아 과제 1의 추세가 됩니다.
  • 넣은 줄만 뺐습니다: 고친 FAQ-210의 답이 또 틀렸다면 imported_ids에 없으니 격리함에 남았을 것입니다.
  • quality의 FAQ 6: 테스트의 after가 FAQ를 비웠습니다. 기록 · 알림은 그대로 남습니다.

예제 3 · 스키마만 바꾸면 (6분, 에이전트 없이)

이 실습의 의도

한 줄로: 데이터 API에 세 번째 스키마 ticket(접수 건)이 있습니다. 예제 1의 my-check를 고치지 않고 접수 건 9줄을 보내, 새 데이터셋이 스키마만으로 검사되는 것을 봅니다. 같은 줄을 FAQ 검사에 보내 비교합니다.

생활 속 예 · 새 품목의 검수 카드 한 장. 식당이 디저트를 시작해 생크림이 들어오기 시작했습니다. 주방장을 새로 뽑지 않고 "생크림 카드"를 한 장 더 만들면 내일부터 같은 사람이 검수합니다. 예제 3은 카드 한 장(스키마)만 더해 새 품목을 검수하는 실습입니다.

상황. 헬프데스크 팀이 76일차부터 만든 접수 건을 다른 시스템에서 옮겨 오려 합니다 (requests/tickets-new.json, {"schema": "ticket", "items": [...]} 9줄). 스키마 ticket은 수업 전에 데이터 API에 들어 있습니다 (/schemas/ticket).

칸 규칙 (/schemas/ticket)
ticket_id (key) pattern ^T-\d{4}$
title string 4~40자
requester string 필수, 20자 이내
priority enum low · normal · high
opened date
contact phone, 선택 (없어도 됨, 있으면 형식)

끝나면 알게 되는 것. 규칙을 데이터로 두면 새 데이터셋에 워크플로우를 고치지 않는다는 것 (설정으로 넓히기). 그리고 FAQ 검사처럼 규칙이 코드에 박힌 검사는 다른 데이터에 쓸 수 없다는 것.

적을 곳. my-work/QUALITY.md 8절 아래 "새 스키마 ticket".

uv run tools/flow.py call my-faq-check requests/tickets-new.json    # 같은 줄을 FAQ 검사에
uv run tools/flow.py test cases/lab03-schema.json -v                # 내 스키마 검사 (고치지 않음)

이해하기

코드에 박힌 규칙 · 데이터로 둔 규칙.

FAQ 검사 (my-faq-check) 스키마 검사 (my-check)
규칙이 있는 곳 Code 안 데이터 API의 스키마
새 데이터셋 Code를 하나 더 · 고쳐서 push 스키마 파일 하나 (워크플로우 그대로)
잘하는 것 데이터에 맞춘 규칙 (개인정보 · 이미 있는 FAQ · 고침 말) 모양 · 형식 · 목록 · 필수 · 중복

둘 중 하나만 고르는 것이 아닙니다. 모양은 스키마로 넓게, 그 데이터만의 규칙은 Code로 깊게. 이 경계를 QUALITY.md 8절에 적습니다.

선택 칸. contact는 required: false입니다. 없으면 통과(9째 줄), 있는데 형식이 틀리면 bad_format:contact(6째 줄)입니다. "없어도 된다"와 "아무거나 돼도 된다"는 다릅니다.

테스트 케이스.

케이스 기대
스키마 목록에 ticket이 있다 /schemas/list에 ticket
새 스키마 ticket · 9줄 중 2줄 통과 1째 · 9째 줄만
형식 · 목록 · 날짜 · 길이 bad_format:ticket_id · not_allowed:priority · bad_format:opened(2월 30일) · too_short:title
선택 칸 형식 · 중복 · 빈 칸 bad_format:contact · duplicate_in_batch · missing:requester, 9째 줄 통과
직원 CSV는 그대로 9줄 중 4줄

기대 결과. FAQ 검사는 9줄 모두 나쁨(칸 이름이 다르니 id_format · title_missing…). 스키마 검사는 고치지 않고 5/5.

▶ 1단계 · 5/5가 아니라면

예제 1의 Code가 칸 이름이나 타입을 직원 · FAQ에만 맞춰 썼는지 봅니다 (예: pattern을 id에만, min을 answer에만). 스키마의 fields를 돌며 타입마다 판정하면 새 스키마에도 그대로 됩니다. 실패하면 예제 1로 돌아가 고칩니다.

실행 결과 · 스키마만 바꾸면

수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 1의 my-check, 고치지 않음).

이 예제는 고치지 않습니다. 새 스키마의 데이터를 두 검사에 보냅니다.

같은 줄을 FAQ 검사에

실행한 명령:

uv run tools/flow.py call my-faq-check requests/tickets-new.json

출력 (줄마다 한 줄로, fix_list는 줄임):

HTTP 200 · 0.1s
{ "batch": "TK-1", "total": 9, "valid": 0, "invalid": 9,
  "rows": [ 1 "" id_format,keywords_missing,answer_missing ·
            2~9 "" id_format,keywords_missing,answer_missing,duplicate_in_batch ],
  "codes": { "id_format": 9, "keywords_missing": 9, "answer_missing": 9, "duplicate_in_batch": 8 },
  "message": "TK-1: 9줄 중 0줄 통과, 9줄 고칠 것" }

왜 이렇게 나왔나: 접수 건에는 id · keywords · answer 칸이 없습니다. FAQ 검사는 그 칸만 보므로 9줄 모두 "id 형식 · 키워드 없음 · 답 없음"입니다. id가 모두 빈 글자라 둘째 줄부터 duplicate_in_batch까지 붙었습니다. 판정이 틀린 것이 아니라 다른 데이터에 FAQ 규칙을 댄 것입니다. fix_list의 "답을 써 주세요"는 접수 건에는 말이 되지 않습니다.

내 스키마 검사 (my-check, 고치지 않음)

실행한 명령:

uv run tools/flow.py test cases/lab03-schema.json -v

출력 (결과 줄과 -v의 접수 건 응답을 줄마다 한 줄로):

POST http://localhost:5678/webhook/my-check · 5 case(s) × 1
✔ 스키마 목록에 ticket이 있다 · 0.0s
✔ 새 스키마 ticket · 워크플로우는 그대로 · 9줄 중 2줄 통과 · 0.1s
✔ 형식 · 목록 · 날짜 · 길이 · 0.1s
✔ 선택 칸 형식 · 중복 · 빈 칸, 선택 칸이 없어도 통과 · 0.0s
✔ 직원 CSV는 그대로 9줄 중 4줄 · 0.1s
Passed 5/5

{ "schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, "invalid": 7,
  "rows": [ 1 T-2001 ok · 2 T-12 bad_format:ticket_id · 3 T-2003 not_allowed:priority · 4 T-2004 bad_format:opened ·
            5 T-2005 too_short:title · 6 T-2006 bad_format:contact · 7 T-2001 duplicate_in_batch ·
            8 T-2008 missing:requester · 9 T-2009 ok ] }

왜 이렇게 나왔나 (줄마다)

줄 값 판정 규칙
2 T-12 bad_format:ticket_id pattern ^T-\d{4}$
3 urgent not_allowed:priority enum low · normal · high
4 2026-02-30 bad_format:opened date — 2월 30일은 없음
5 프린터 (3자) too_short:title min: 4
6 010-12-3456 bad_format:contact 선택 칸이지만 있으면 형식
7 1째와 같은 T-2001 duplicate_in_batch key: ticket_id
8 requester 빈칸 missing:requester required
9 contact 없음 통과 선택 칸이라 없어도 됨
  • 워크플로우를 한 줄도 고치지 않았습니다. 데이터 API에 ticket.json 한 장이 있었을 뿐입니다. 직원 CSV(9줄 중 4줄)도 그대로입니다.
  • 같은 접수 건 9줄을 FAQ 검사는 "모두 나쁨", 스키마 검사는 "2줄 통과 · 줄마다 다른 이유"로 봤습니다. 규칙이 데이터에 맞아야 판정이 쓸모 있습니다.
  • 이것이 "규칙을 데이터로 둔다"의 효과입니다. 실제로는 스키마를 바꾸는 것도 사람이 검토해야 하는 일(스키마 변경 = 규칙 변경)이라, 스키마 파일을 저장소에 두고 리뷰합니다 (74일차 배포 관문과 같은 생각).

과제 1 · 품질 보고서 (12분)

이 실습의 의도

한 줄로: Lab 02 과제 1부터 쌓인 품질 기록으로 보고서(my-quality-report)를 만듭니다: 배치 수 · 평균 점수 · 가장 나쁜 배치 · 거절 수 · 가장 흔한 문제 · 추세.

생활 속 예 · 한 달 검수 일지 정리. 월말에 사장님이 묻습니다. "이번 달 납품은 어땠어? 제일 나빴던 날은? 반품 이유는 뭐가 제일 많았어? 나아지고 있어?" 일지를 한 장씩 넘기며 말로 답하면 사람마다 답이 다릅니다. 과제 1은 일지를 늘 같은 방식으로 정리하는 보고서를 만드는 실습입니다.

상황. 테스트가 먼저 B-1(거절) · B-2 · B-3 · B-2-fix를 차례로 가져와 기록 네 줄을 만듭니다. 팀장이 회의에서 쓸 숫자입니다.

칸 정의 (AGENTS.md 3절 "품질 보고서")
count · batches 기록 줄 수 · [{batch, score, rejected, total}] 넣은 순서
avg_score 평균, 소수 4자리 (0건이면 0)
worst_batch 가장 낮은 score (같으면 먼저 나온 것)
rejected_batches 거절 수
top_codes codes를 모두 더해 많은 순 (같으면 코드 글자순)
trend 마지막 − 첫 > 0.05 좋아짐, < −0.05 나빠짐, 아니면 그대로

끝나면 알게 되는 것. 품질도 77일차 지표처럼 정의가 먼저라는 것 (거절을 평균에 넣나, 같으면 무엇을 고르나). 숫자는 Code가 세고, 말은 사람이 한다는 것.

적을 곳. my-work/QUALITY.md 9절 표와 "회의에 어떻게 말할까".

uv run tools/flow.py test cases/lab03-challenge.json -v    # 고치기 전 (my-quality-report가 아직 없음)

이해하기

flowchart LR
  L[("품질 기록<br/>B-1 · B-2 · B-3 · B-2-fix")] --> G["Get log"] --> M["Make report (Code)<br/>평균 · 최저 · 거절 · 흔한 코드 · 추세"] --> R(["보고서"])

거절을 평균에 넣는 까닭. B-1은 거절이라 score 0입니다. 평균에서 빼면 0.8778, 넣으면 0.6583입니다. 뺀 숫자는 "받은 배치는 괜찮았다"이고, 넣은 숫자는 "보내온 배치는 그만큼이었다"입니다. 오늘은 넣습니다 — 콘텐츠 담당자가 보내는 배치의 품질을 보는 보고서이기 때문입니다. 무엇을 넣었는지는 보고서와 함께 말합니다.

같으면 무엇을 고르나. B-2와 B-3이 같은 점수라면? top_codes에서 pii와 title_missing이 같은 수라면? 정하지 않으면 실행마다 순서가 바뀔 수 있습니다. 그래서 "먼저 나온 것" · "코드 글자순"으로 정합니다.

테스트 케이스.

케이스 기대
배치 네 개 · 평균 · 가장 나쁜 배치 4 · 0.6583 · B-1 · 거절 1
가장 많은 이유 · 흐름 pii 2, 좋아짐 (0 → 1)
배치마다 점수 순서 0.8 · 0.8333 · B-2-fix
다른 데이터셋으로 거르기 employee → 0건 · 평균 0

기대 결과. 고치기 전 0/4 (404), 고친 뒤 4/4.

▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대

요청문에 꼭 들어갈 것: ① 반올림 ② 같을 때 (배치) ③ 같을 때 (코드) ④ 추세의 나머지. 아래 빈칸 4개를 채웁니다.

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 (소수 ____①자리, 0건이면 0) · worst_batch (같으면 ____②) · rejected_batches.
top_codes: 모든 기록의 codes 를 더해 많은 순, 같으면 코드 ____③. trend: 마지막 score − 첫 score 가 0.05 넘으면 좋아짐, −0.05 미만이면 나빠짐, 아니면 ____④.
LLM 은 쓰지 않는다. 기대 결과 cases/lab03-challenge.json.

빈칸 힌트:

  • ① 테스트의 0.6583은 몇 자리인가요?
  • ② · ③ 3절 표의 괄호 안.
  • ④ 차이가 ±0.05 안이거나 기록이 1건뿐이면.
uv run tools/flow.py push my-work/quality-report.json
uv run tools/flow.py test cases/lab03-challenge.json -v
uv run tools/flow.py call my-quality-report '{}'

실행 결과 · 품질 보고서

수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. 테스트의 before가 네 배치를 가져오므로 LLM을 부르는 데 15초쯤 걸립니다.

고치기 전 · my-quality-report가 없을 때

실행한 명령:

uv run tools/flow.py test cases/lab03-challenge.json -v

출력 (before와 결과 줄):

! before: POST /webhook/my-faq-import · HTTP 422 · 0.1s (allow_fail: rejected on purpose) · {"error": "batch rejected", "batch": "B-1", …
✔ before: POST /webhook/my-faq-import · HTTP 200 · 8.4s · {"batch": "B-2", …, "score": 0.8, …
✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.6s · {"batch": "B-3", …, "score": 0.8333, …
✔ before: POST /webhook/my-faq-import · HTTP 200 · 1.9s · {"batch": "B-2-fix", …, "score": 1, …
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); …
✘ 가장 많은 이유 · 흐름 · 0.0s · status 404 (expected 200); …
✘ 배치마다 점수 순서 · 0.0s · status 404 (expected 200); …
✘ 다른 데이터셋으로 거르기 · 0.0s · status 404 (expected 200); …
Passed 0/4

왜 이렇게 나왔나 (고치기 전)

  • 첫 줄의 !: B-1은 일부러 거절되는 배치라 before에 "allow_fail": true가 있습니다. 422여도 테스트를 멈추지 않고 !(일부러 거절)로 표시합니다. 거절도 품질 기록에 남아야 보고서에 "가장 나쁜 배치"가 생깁니다.
  • 기록 네 줄은 생겼지만 보고서 웹훅이 없어 404. 0/4는 예상한 결과.

고친 뒤

실행한 명령:

uv run tools/flow.py push my-work/quality-report.json
uv run tools/flow.py test cases/lab03-challenge.json -v
uv run tools/flow.py call my-quality-report '{}'

출력 (결과 줄과 call):

✔ 배치 네 개 · 평균 · 가장 나쁜 배치 · 0.0s
✔ 가장 많은 이유 · 흐름 · 0.1s
✔ 배치마다 점수 순서 · 0.0s
✔ 다른 데이터셋으로 거르기 · 0.1s
Passed 4/4

HTTP 200 · 0.0s
{ "count": 4,
  "batches": [ {"batch": "B-1", "score": 0, "rejected": true, "total": 13}, {"batch": "B-2", "score": 0.8, …},
               {"batch": "B-3", "score": 0.8333, …}, {"batch": "B-2-fix", "score": 1, …} ],
  "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}, … ],
  "trend": "좋아짐" }

고친 뒤 이렇게 나온 까닭

칸 값 어떻게
avg_score 0.6583 (0 + 0.8 + 0.8333 + 1) / 4, 거절도 넣음
worst_batch B-1 score 0
top_codes 1위 pii 2 B-1의 FAQ-109(전화) + B-2의 FAQ-209(메일)
top_codes 2위부터 1건씩, 글자순 answer_missing · answer_too_long · answer_too_short · …
trend 좋아짐 1(B-2-fix) − 0(B-1) = 1 > 0.05
  • 회의에 말한다면: "배치 넷, 평균 0.66(거절 포함). 가장 흔한 문제는 개인정보 2건 — 콘텐츠 담당자에게 '전화번호 · 메일 대신 IT 포털'을 안내하자. 추세는 좋아짐."
  • trend의 함정: 첫 기록이 거절(0)이고 마지막이 고친 두 줄(1)이라 "좋아짐"이 크게 나옵니다. 고친 두 줄짜리 배치와 13줄짜리 배치를 같은 무게로 비교한 것입니다. 숫자는 정의대로 맞지만, 무엇과 무엇을 비교했는지를 함께 말해야 합니다 (77일차 표본 크기와 같은 이야기).
  • dataset: employee로 거르면 0건 · 평균 0: 0으로 나누지 않습니다.

과제 2 · 데이터셋별 품질 (10분)

이 실습의 의도

한 줄로: 스키마 검사(my-check)도 검사할 때마다 품질 기록을 남기게 하고, 보고서에 데이터셋마다(faq · employee · ticket) 평균 · 가장 나쁜 배치 · 가장 흔한 문제와 가장 손볼 데이터셋을 더합니다.

생활 속 예 · 납품업체별 성적표. 한 달 평균만 보면 "괜찮다"인데, 업체별로 나누면 채소 가게는 늘 좋고 생선 가게만 절반이 반품입니다. 사장님이 전화할 곳은 생선 가게입니다. 과제 2는 검수 일지를 품목(데이터셋)별 성적표로 나누는 실습입니다.

상황. 지금 품질 기록은 FAQ 가져오기만 남깁니다. 직원 CSV · 접수 건은 검사 결과가 부른 사람의 화면에만 있다가 사라집니다. 팀장이 요청했습니다: "인사팀 · 헬프데스크 데이터도 같은 보고서에서 보고 싶어요. 어느 데이터가 제일 나쁜지."

바꿀 곳 무엇을 (AGENTS.md 3절)
my-check 검사가 끝나면 /quality-log 한 줄 {batch, dataset: <schema>, total, valid, quarantined: invalid, rejected: false, score, codes}, 응답은 그대로
my-quality-report by_dataset (데이터셋마다 {count, avg_score, worst_batch, top_code}) · worst_dataset

끝나면 알게 되는 것. 같은 기록 모양을 쓰면 다른 워크플로우의 품질도 한 보고서에서 본다는 것. 전체 평균이 가리는 것을 나눠 보면 보인다는 것 (77일차 기간별 · 시간대별과 같은 생각).

적을 곳. my-work/QUALITY.md 9절 "가장 손볼 데이터셋과 그 이유".

uv run tools/flow.py test cases/lab03-challenge2.json -v    # 고치기 전 (예제 1의 검사 · 과제 1의 보고서)

이해하기

flowchart LR
  C1["my-check employee"] --> L[("품질 기록")]
  C2["my-check ticket"] --> L
  I["my-faq-import B-3"] --> L
  L --> R["my-quality-report<br/>by_dataset · worst_dataset"]

전체 평균이 가리는 것. 세 기록의 평균은 (0.4444 + 0.2222 + 0.8333) / 3 = 0.5만큼입니다. 이것만으로는 무엇을 고칠지 모릅니다. 나누면 접수 건(0.2222)이 가장 나쁘고, 직원 CSV는 메일 형식이 가장 흔한 문제입니다.

테스트 케이스. 테스트가 먼저 직원 CSV · 접수 건을 검사하고 B-3을 가져옵니다.

케이스 기대
검사도 품질 기록 기록 3줄 — employee 0.4444 · ticket · faq
데이터셋마다 나눠 보기 employee 0.4444 · ticket 0.2222 · faq 0.8333
가장 나쁜 데이터셋 · 그 이유 ticket, 직원의 top_code bad_format:email
한 데이터셋만 (앞의 칸은 그대로) {"dataset": "ticket"} → 1건 · TK-1 · 그대로

기대 결과. 고치기 전 0/4, 고친 뒤 4/4, lab03 · lab03-challenge도 그대로.

▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대

요청문에 꼭 들어갈 것: ① 데이터셋 이름 ② 순서 ③ 가장 손볼 곳 ④ 앞의 것. 두 워크플로우를 한 요청문에서 고칩니다.

두 워크플로우를 고쳐 줘.
① my-work/check.json: 검사가 끝나면 POST http://data-api:8800/quality-log 한 줄 {batch, dataset: ____①, 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 도.

빈칸 힌트:

  • ① 검사 결과의 어느 칸이 employee · ticket인가요?
  • ② 보고서가 늘 같은 순서로 나오려면.
  • ③ avg_score가 어때야 "가장 손볼"인가요?
  • ④ 과제 1의 테스트가 계속 통과하려면.
uv run tools/flow.py push my-work/check.json my-work/quality-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

실행 결과 · 데이터셋별 품질

수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다.

고치기 전 · 예제 1의 검사 · 과제 1의 보고서

실행한 명령:

uv run tools/flow.py test cases/lab03-challenge2.json -v

출력 (before와 결과 줄):

✔ before: POST /webhook/my-check · HTTP 200 · 0.1s · {"schema": "employee", "batch": "HR-1", "total": 9, "valid": 4, …
✔ before: POST /webhook/my-check · HTTP 200 · 0.1s · {"schema": "ticket", "batch": "TK-1", "total": 9, "valid": 2, …
✔ before: POST /webhook/my-faq-import · HTTP 200 · 4.0s · {"batch": "B-3", …, "score": 0.8333, …
✘ 검사도 품질 기록 (직원 · 티켓 · FAQ) · 0.0s · count = 1 (expected 3); items.0.dataset = faq (expected employee); items.0.score = 0.8333 (expected 0.4444); …
✘ 데이터셋마다 나눠 보기 · 0.2s · count = 1 (expected 3); by_dataset.employee.avg_score = null (expected 0.4444); …
✘ 가장 나쁜 데이터셋 · 그 이유 · 0.1s · worst_dataset = null (expected ticket); by_dataset.employee.top_code = null (expected bad_format:email)
✘ 한 데이터셋만 (앞의 칸은 그대로) · 0.0s · count = 0 (expected 1); worst_batch = null (expected TK-1)
Passed 0/4

왜 이렇게 나왔나 (고치기 전)

  • 검사 두 번(직원 · 접수 건)은 결과만 돌려주고 기록을 남기지 않았습니다. 기록은 B-3 가져오기의 한 줄뿐이라 count 1, 첫 줄이 faq.
  • 과제 1의 보고서에는 by_dataset · worst_dataset이 없어 null. ticket으로 거르면 기록이 없어 0건. 0/4는 예상한 결과.

고친 뒤

실행한 명령:

uv run tools/flow.py push my-work/check.json my-work/quality-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

출력 (결과 줄, -v의 보고서에서 by_dataset, quality):

✔ 검사도 품질 기록 (직원 · 티켓 · FAQ) · 0.0s
✔ 데이터셋마다 나눠 보기 · 0.1s
✔ 가장 나쁜 데이터셋 · 그 이유 · 0.0s
✔ 한 데이터셋만 (앞의 칸은 그대로) · 0.1s
Passed 4/4

"avg_score": 0.5, "worst_batch": "TK-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"

(lab03) Passed 4/4 · (lab03-challenge) Passed 4/4
quality log: 4

고친 뒤 이렇게 나온 까닭

데이터셋 기록 avg_score top_code 읽는 법
employee HR-1 0.4444 (4/9) bad_format:email 다섯 가지 문제가 1건씩 — 글자순 첫째가 메일
ticket TK-1 0.2222 (2/9) bad_format:contact 일곱 가지가 1건씩, 가장 나쁨 → worst_dataset
faq B-3 0.8333 (5/6) answer_too_short FAQ-305 한 줄
  • 검사도 기록: my-check의 Log quality가 dataset에 스키마 이름을 넣었습니다. 가져오기와 같은 기록 모양이라 보고서를 고치지 않고도 count 3이 됐습니다.
  • 전체 평균 0.5가 가리는 것: 셋을 섞으면 "절반쯤 괜찮다"이지만, 나누면 접수 건이 0.2222로 가장 손볼 곳입니다. 헬프데스크 팀에 "옮기기 전에 접수 건 일곱 줄을 고쳐 달라"고 말할 근거입니다.
  • 섞은 trend는 뜻이 없습니다: 전체 trend가 좋아짐(0.4444 → 0.8333)이지만, 이것은 직원 CSV와 FAQ를 비교한 것입니다. 추세는 같은 데이터셋 안에서 봐야 합니다 ({"dataset": "faq"}처럼 걸러서). top_code가 1건씩 같은 수일 때 글자순 첫째라는 것도 함께 말해야 오해가 없습니다.
  • lab03 · lab03-challenge가 그대로 4/4입니다. lab03-challenge의 quality log: 4는 그 테스트가 마지막에 남긴 네 배치입니다 (my-check는 그 테스트에서 부르지 않음).

막혔을 때

증상 할 일
모르는 스키마에 500 Get schema에 neverError, 다음 IF에서 fields가 있는지
CSV 줄이 하나 더 끝 줄바꿈 — csv.trim() 뒤에 나눔
2026-13-01 · 2026-02-30이 통과 실제 있는 날짜인지 (▶ 예제 1의 2단계)
빈 phone이 bad_format 비었으면 required만 보고 다음 칸으로
격리함이 비지 않음 Release fixed에 imported_ids
응답이 {"removed": …} 끝에 Result again
avg_score가 0.8778 거절 배치(score 0)도 평균에
top_codes 순서가 바뀜 같은 수면 코드 글자순
기록이 1줄 (FAQ만) my-check에 Log quality를 더하고 push (두 파일 모두)
by_dataset이 비어 있음 거르기 전의 모든 기록으로 나눔
계속 안 됨 cp reference/lab03-example1-schema-check.json my-work/check.json 후 push (예제 1까지)

확인

  • 예제 1: cases/lab03.json 4/4 (고치기 전 FAQ 검사는 400, lab03 0/4), QUALITY.md 8절.
  • 예제 2: cases/lab03-fix.json 4/4 (고치기 전 3/4).
  • 예제 3: cases/lab03-schema.json 5/5 (워크플로우를 고치지 않음).
  • 과제 1: cases/lab03-challenge.json 4/4, QUALITY.md 9절.
  • 과제 2: cases/lab03-challenge2.json 4/4, lab03 · lab03-challenge도 통과.
  • my-work/PROMPTS.md, 끝나면 uv run tools/flow.py quality --reset.