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

779 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lab 03 · 품질 관리 · 스키마로 넓히고, 고쳐서 다시, 추세로 보기
- 관련 강의: [3단계 · 품질 관리](../../Lecture/78/03-STAGE3-QUALITY-MANAGEMENT.md)
- 소요 시간: 50분 (6교시 한 시간) · 예제 1 14분 · 예제 2 8분 · 예제 3 6분 · 과제 1 12분 · 과제 2 10분
- 준비: Lab 02의 `my-faq-import`(과제 2까지)가 게시돼 있음
- 목표: FAQ에만 맞춘 규칙을 **스키마**(규칙을 적은 데이터)로 넓혀 직원 CSV도 같은 워크플로우로 검사한다. 고친 줄을 다시 넣으면 격리함에서 빼고, **새 스키마**를 더해도 워크플로우를 고치지 않는 것을 확인한다. 품질 기록으로 **보고서**(평균 · 가장 나쁜 배치 · 흔한 문제 · 추세)를 만들고, 데이터셋마다 나눠 본다.
```mermaid
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절 표.
```bash
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는 스키마를 읽어 타입마다 같은 방식으로 판정하게" 하는 것입니다.
```mermaid
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.
<details><summary>▶ 1단계 · 타입마다 한 번씩</summary>
`for (const [name, f] of Object.entries(schema.fields))`로 칸을 돌며, 비었으면 `required`만 보고 다음 칸으로, 아니면 `f.type`마다 확인합니다. 칸 이름(`email`)을 Code에 쓰지 않는 것이 핵심입니다.
</details>
<details><summary>▶ 2단계 · `2026-13-01`이 통과한다면</summary>
`YYYY-MM-DD` 꼴만 보면 13월도 통과합니다. `new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v`처럼 실제 있는 날짜인지까지 봅니다 (예제 3의 `2026-02-30`도).
</details>
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 모르는 스키마 ② CSV 읽기 ③ 규칙의 출처 ④ 줄 번호 · 결과 모양 · 기대 결과**. 아래 빈칸 4개를 채웁니다.
```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 ____① {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절 스키마 표 아래 줄.
</details>
```bash
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`가 없을 때
실행한 명령:
```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)
✘ 직원 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`
실행한 명령:
```bash
uv run tools/flow.py push my-work/check.json
uv run tools/flow.py test cases/lab03.json -v
```
출력 (결과 줄과 `-v`의 직원 응답을 줄마다 한 줄로):
```text
✔ 직원 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 줄).
```bash
uv run tools/flow.py test cases/lab03-fix.json -v # 고치기 전 (Lab 02 과제 2까지의 가져오기)
uv run tools/flow.py quality
```
### 이해하기
```mermaid
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.
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 뺄 id ② 걸린 줄 ③ 앞의 기록 ④ 응답**. 아래 빈칸 4개를 채웁니다.
```text
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")
</details>
```bash
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까지의 가져오기
실행한 명령:
```bash
uv run tools/flow.py test cases/lab03-fix.json -v
uv run tools/flow.py quality
```
출력 (결과 줄과 `quality`):
```text
✔ 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는 예상한 결과.
#### 고친 뒤
실행한 명령:
```bash
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
```
출력:
```text
✔ 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".
```bash
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.
<details><summary>▶ 1단계 · 5/5가 아니라면</summary>
예제 1의 Code가 칸 이름이나 타입을 직원 · FAQ에만 맞춰 썼는지 봅니다 (예: `pattern`을 `id`에만, `min`을 `answer`에만). 스키마의 `fields`를 돌며 타입마다 판정하면 새 스키마에도 그대로 됩니다. 실패하면 예제 1로 돌아가 고칩니다.
</details>
### 실행 결과 · 스키마만 바꾸면
수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 1의 `my-check`, 고치지 않음).
이 예제는 **고치지 않습니다.** 새 스키마의 데이터를 두 검사에 보냅니다.
#### 같은 줄을 FAQ 검사에
실행한 명령:
```bash
uv run tools/flow.py call my-faq-check requests/tickets-new.json
```
출력 (줄마다 한 줄로, `fix_list`는 줄임):
```text
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`, 고치지 않음)
실행한 명령:
```bash
uv run tools/flow.py test cases/lab03-schema.json -v
```
출력 (결과 줄과 `-v`의 접수 건 응답을 줄마다 한 줄로):
```text
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절 표와 "회의에 어떻게 말할까".
```bash
uv run tools/flow.py test cases/lab03-challenge.json -v # 고치기 전 (my-quality-report가 아직 없음)
```
### 이해하기
```mermaid
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.
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 반올림 ② 같을 때 (배치) ③ 같을 때 (코드) ④ 추세의 나머지**. 아래 빈칸 4개를 채웁니다.
```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 (소수 ____①자리, 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건뿐이면.
</details>
```bash
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`가 없을 때
실행한 명령:
```bash
uv run tools/flow.py test cases/lab03-challenge.json -v
```
출력 (`before`와 결과 줄):
```text
! 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는 예상한 결과.
#### 고친 뒤
실행한 명령:
```bash
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`):
```text
✔ 배치 네 개 · 평균 · 가장 나쁜 배치 · 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절 "가장 손볼 데이터셋과 그 이유".
```bash
uv run tools/flow.py test cases/lab03-challenge2.json -v # 고치기 전 (예제 1의 검사 · 과제 1의 보고서)
```
### 이해하기
```mermaid
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`도 그대로.
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
요청문에 꼭 들어갈 것: **① 데이터셋 이름 ② 순서 ③ 가장 손볼 곳 ④ 앞의 것**. 두 워크플로우를 한 요청문에서 고칩니다.
```text
두 워크플로우를 고쳐 줘.
① 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의 테스트가 계속 통과하려면.
</details>
```bash
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의 보고서
실행한 명령:
```bash
uv run tools/flow.py test cases/lab03-challenge2.json -v
```
출력 (`before`와 결과 줄):
```text
✔ 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는 예상한 결과.
#### 고친 뒤
실행한 명령:
```bash
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`):
```text
✔ 검사도 품질 기록 (직원 · 티켓 · 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`.