Files
aio-01d4-stu/Last3Mon/78/LAB-02-Q-QUALITY-GATE.md
T

47 KiB
Raw Blame History

Lab 02 · 품질 게이트 · 막고, 격리하고, 알리기

  • 관련 강의: 2단계 · 품질 게이트
  • 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 10분 · 예제 3 6분 · 과제 1 10분 · 과제 2 12분
  • 준비: Lab 01의 my-faq-check(과제 2까지)가 게시돼 있음, 기준 가져오기 base-faq-import 게시
  • 목표: 검사를 통과한 줄만 FAQ에 넣는 가져오기 게이트를 만든다. 나쁜 줄이 많은 배치는 통째로 거절하고, 나머지 나쁜 줄은 이유와 함께 격리함에 둔다. 규칙으로 못 잡는 줄은 LLM 검사로 거르고, 거절 기준을 바꾸면 무엇이 달라지는지 재 보고, 배치마다 품질 기록 · 알림을 남기고, LLM을 몇 번 부르는지 숫자로 본다.
flowchart LR
  E1["예제 1<br/>배치 게이트 · 격리"] --> E2["예제 2<br/>LLM 검사"] --> E3["예제 3<br/>기준을 바꾸면"] --> T1["과제 1<br/>품질 기록 · 알림"] --> T2["과제 2<br/>LLM 호출 수 ·<br/>늘 같은가"]

복사해 붙일 요청문은 없습니다. 막히면 ▶ 를 하나씩 펼칩니다. 상태를 보는 명령은 uv run tools/flow.py quality, 처음으로는 uv run tools/flow.py quality --reset.

예제 1 · 배치 게이트와 격리 (12분)

이 실습의 의도

한 줄로: 팀이 쓰던 기준 가져오기(base-faq-import)가 나쁜 줄까지 FAQ에 넣는 것을 먼저 보고, Lab 01의 검사를 불러 통과한 줄만 넣고, 나쁜 줄은 격리하고, 너무 나쁜 배치는 통째로 거절하는 내 가져오기(my-faq-import)를 만듭니다.

생활 속 예 · 식당의 반품과 보류 칸. 주방장은 상자를 받을 때 두 번 판단합니다. 상자 열에 일곱이 상했으면 트럭째 돌려보냅니다 (하나하나 골라낼 시간에 다시 받는 게 낫습니다). 대부분 괜찮으면 좋은 것만 냉장고에 넣고, 이상한 것은 보류 칸에 사유를 붙여 둡니다. 예제 1은 "트럭째 반품"(배치 게이트)과 "보류 칸"(격리함)을 만드는 실습입니다.

상황. 기준 가져오기는 검사 없이 모든 줄을 /faq/add로 보냅니다. 데이터 API가 답 없는 줄 정도는 막아 주지만, 개인 전화번호 · 메일 주소 · 바깥 링크가 든 FAQ는 그대로 들어갑니다. 직원이 질문하면 헬프데스크가 그 답을 그대로 보여 줍니다.

단계 규칙 (AGENTS.md 3절 "가져오기 게이트")
검사 규칙을 다시 쓰지 않고 my-faq-check를 부름
배치 게이트 나쁜 줄 비율(invalid / total)이 기준(기본 0.3)을 넘으면 422, 아무것도 넣지 않음
줄 게이트 통과한 줄만 /faq/add, 나머지는 이유(errors)와 함께 /quarantine
결과 imported · quarantined · score(= imported / total) · max_invalid_ratio(쓴 기준)

순서. 같은 테스트를 기준에 먼저 → quality로 무엇이 들어갔나 봄 → 요청문 → push → 6개 중 앞 넷과 FAQ-305 ✔ (FAQ-210은 예제 2).

끝나면 알게 되는 것. 검사(무엇이 나쁜가)와 게이트(그래서 무엇을 하나)는 다른 일이라는 것. 나쁜 데이터는 버리지 않고 이유와 함께 격리해야 고칠 수 있다는 것.

적을 곳. my-work/QUALITY.md 5절 첫 줄 (0.3 · B-1).

uv run tools/flow.py test cases/lab02.json --webhook base-faq-import -v    # 고치기 전 (기준에 같은 테스트)
uv run tools/flow.py quality                                              # 무엇이 들어갔나

이해하기

지금 무엇이 문제인가. 기준 가져오기는 "검사"가 없습니다. 데이터 API는 모양(답 · 키워드가 있나)만 보고, 내용(개인정보 · 링크)은 보지 않습니다. 그래서 고치는 일은 가져오기 앞에 Lab 01의 검사를 세우고, 그 판정으로 갈래를 나누는 것입니다.

flowchart LR
  W(["배치"]) --> C["Run check<br/>my-faq-check"] --> K{"rows가 있나?"}
  K -->|아니오| R4(["400"])
  K -->|예| G{"invalid / total<br/>≤ 기준?"}
  G -->|아니오| R(["422 · 아무것도 넣지 않음"])
  G -->|예| S["Split rows"] --> D["Store row<br/>ok → /faq/add<br/>아니면 → /quarantine"] --> M(["imported · quarantined · score"])

왜 배치를 통째로 거절하나. 13줄 중 10줄이 나쁘면, 좋은 3줄만 넣는 것보다 보낸 사람에게 돌려보내는 게 낫습니다. 나쁜 줄이 그만큼 많으면 배치를 만드는 방법 자체가 잘못됐을 가능성이 크고, 격리함에 10줄이 쌓이면 사람이 하나하나 봐야 합니다. 기준(0.3)은 "열 줄 중 세 줄까지는 골라내 받는다"는 팀의 약속입니다. 예제 3에서 이 기준을 바꿔 봅니다.

격리함(quarantine). 넣지 않은 줄을 이유와 함께 보관하는 곳입니다. 버리면 무엇이 왜 빠졌는지 아무도 모르고, 고친 뒤 다시 넣을 수도 없습니다 (Lab 03 예제 2). 76일차의 DLQ(실패한 요청을 두는 곳)와 같은 생각입니다.

버리기 격리
무엇이 빠졌나 모름 row 그대로
왜 모름 errors
고쳐서 다시 못 함 고친 줄을 다시 보냄 → 격리함에서 뺌

규칙을 다시 쓰지 않는 까닭. 가져오기 안에 규칙을 한 벌 더 쓰면, Lab 01에서 규칙을 고칠 때 두 곳을 고쳐야 하고 언젠가 어긋납니다. 77일차 요약이 보고서를 부른 것처럼, 가져오기는 검사를 부릅니다 (AGENTS.md 5절 "다른 내 웹훅을 부르기").

테스트 케이스.

케이스 보내는 것 기대
나쁜 배치는 통째로 거절 B-1 422, invalid_ratio 0.3 이상
배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 B-2 거절 아님, FAQ-201은 넣고 FAQ-209는 격리
FAQ에 들어갔고 메일 주소는 안 들어감 (FAQ 목록) FAQ-208 있음, security.desk 없음
격리함에 이유와 함께 (격리함) "pii"
LLM · 제목과 답이 다른 줄 B-2 FAQ-210 격리 · llm_mismatch 1 — 예제 2
LLM · 규칙에 걸린 줄은 그대로 B-3 5줄 넣고 FAQ-305 격리

기대 결과. 기준에 0/6, 내 가져오기 5/6 (FAQ-210 케이스만 ✘ — 예제 2에서).

▶ 1단계 · 검사 결과에서 무엇을 쓰나

Run check의 응답에 total · invalid · rows가 있습니다. 배치 게이트는 invalid / total, 줄 게이트는 rows[i].ok. 줄의 원래 내용(row)은 검사 결과에 없으니 웹훅 본문의 items[i]와 짝지어야 합니다 (Split rows).

▶ 2단계 · 넣기와 격리를 IF로 가르면

AGENTS.md 5절 "줄을 나눠 저장하기 — 한 줄로"를 읽어 보세요. HTTP 노드 하나에서 주소를 표현식으로 고르면, 갈래를 다시 합치지 않아도 되고 예제 2에서 LLM을 더해도 격리 수가 틀리지 않습니다.

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

요청문에 꼭 들어갈 것: ① 부를 검사 ② 기준과 기본값 ③ 거절의 모양 ④ 저장하는 노드 ⑤ 결과 모양 · 기대 결과. 아래 빈칸 4개를 채웁니다.

my-work/faq-import.json 을 새로 만들어 줘. 웹훅 POST my-faq-import, 이름 "78 FAQ import (my)".
Run check: 규칙을 다시 쓰지 말고 HTTP POST http://localhost:5678/webhook/____① 로 부른다 (본문 그대로, neverError, timeout 30000).
Batch ok?(IF): invalid / total 이 기준 이하일 때만 받는다. 기준은 본문 max_invalid_ratio, 없으면 ____②. 넘으면 HTTP ____③ {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 앞 네 케이스.

빈칸 힌트:

  • ① Lab 01에서 만든 웹훅입니다. 규칙을 여기 다시 쓰지 않습니다.
  • ② AGENTS.md 3절 "가져오기 게이트" 표의 배치 게이트 줄에서 굵은 숫자. 본문에 기준이 없을 때 씁니다.
  • ③ 같은 표의 배치 게이트 줄, 또는 3절 계약 표 "FAQ 가져오기"의 거절 응답. 400과 무엇이 다른가요?
  • ④ AGENTS.md 5절의 수업 준비 메모를 봅니다. 노드가 몇 개여야 Merge가 필요 없나요?
uv run tools/flow.py push my-work/faq-import.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py quality

실행 결과 · 배치 게이트와 격리

수업 준비 때(11/01) 강사 노트북에서 정답 워크플로우로 실제로 나온 출력입니다. 검사 · 게이트는 규칙대로라 누구에게나 같고, 시간만 다릅니다.

고치기 전 · 기준(base-faq-import)에 같은 테스트

실행한 명령:

uv run tools/flow.py test cases/lab02.json --webhook base-faq-import -v
uv run tools/flow.py quality

출력 (결과 줄과, -v로 본 첫 두 응답):

✘ 나쁜 배치는 통째로 거절 (422) · 0.1s · status 200 (expected 422); invalid_ratio = null (expected 0.3..1)
✘ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.1s · quarantined_ids has none of ['FAQ-209']
✘ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s · faqs must not have ['security.desk']
✘ 격리함에 이유와 함께 · 0.0s · items has none of ['"pii"']
✘ 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']
✘ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s · imported = 6 (expected 5); quarantined_ids has none of ['FAQ-305']
Passed 0/6
✔ after: POST http://localhost:8800/faq-reset · HTTP 200 · 0.0s · {"cleared": 16}

← B-1  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"]}
← B-2  HTTP 200 {"batch": "B-2", "total": 10, "imported": 10, "quarantined": 0, "score": 1, ...}

FAQ: 6  (nothing added)
quarantine: 0

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

  • B-1을 13줄 중 12줄 넣었습니다. 빠진 것은 답이 빈 FAQ-105 하나뿐인데, 그것도 기준이 막은 게 아니라 데이터 API의 /faq/add가 "답이 필요하다"며 거절한 것입니다. 제목 없는 FAQ-103, 개인 전화번호 FAQ-109, 바깥 링크 FAQ-110, 그리고 이미 있던 FAQ-003(VPN)과 같은 id를 한 벌 더 넣었습니다 (FAQ 목록에 FAQ-003이 둘 — 검색이 어느 답을 줄지 모름). 기준의 score 0.9231은 "잘 들어갔다"는 숫자로 보이지만 실제로는 나쁜 줄이 대부분입니다.
  • B-2도 10줄 모두 넣었습니다: 메일 주소가 든 FAQ-209, 답이 다른 FAQ-210까지. 그래서 "메일 주소는 안 들어감" 케이스가 security.desk를 FAQ 목록에서 찾았습니다.
  • 격리함이 0: 기준에는 격리라는 갈래가 없습니다. 무엇을 넣지 않았는지 기록이 없습니다.
  • 끝의 after: faq-reset이 테스트가 넣은 16줄을 비워서, 바로 뒤 quality는 다시 FAQ 6입니다. 테스트가 끝날 때 상태를 되돌리는 것도 테스트의 일입니다.
  • 0/6은 예상한 결과입니다.

고친 뒤 · 내 my-faq-import (예제 1)

실행한 명령:

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

출력 (결과 줄과 -v의 응답 둘):

✔ 나쁜 배치는 통째로 거절 (422) · 0.9s
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 1.4s
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
✔ 격리함에 이유와 함께 · 0.0s
✘ 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']
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 0.1s
Passed 5/6

← B-1  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  HTTP 200 {"batch": "B-2", "total": 10, "imported": 9, "quarantined": 1, "rejected": false, "score": 0.9,
                "max_invalid_ratio": 0.3, "codes": {"pii": 1}, "quarantined_ids": ["FAQ-209"], ...}

FAQ: 6  (nothing added)
quarantine: 2

고친 뒤 이렇게 나온 까닭

배치 고치기 전 (기준) 고친 뒤 무엇이 바꿨나
B-1 12줄 넣음 (FAQ-003이 둘) 422, 아무것도 넣지 않음 나쁜 줄 10/13 = 0.7692 > 0.3 (배치 게이트)
B-2 10줄 넣음 (메일 주소 포함) 9줄 넣고 FAQ-209 격리 줄 게이트 + pii
B-3 6줄 넣음 (답 "짧") 5줄 넣고 FAQ-305 격리 answer_too_short
격리함 없음 줄마다 row · errors /quarantine
  • B-1의 invalid_ratio 0.7692: Lab 01 과제 2까지의 검사로 13줄 중 10줄이 나쁩니다. 422 본문의 codes를 그대로 콘텐츠 담당자에게 돌려주면 무엇을 고칠지 압니다 (고침 목록은 my-faq-check를 부르면 나옴).
  • FAQ-210 ✘ 하나: 규칙은 모두 통과했으므로 아직 들어갑니다 (imported 9). 예제 2에서 LLM이 막습니다. **예상한 ✘**입니다.
  • quality의 격리함 2줄: 마지막 두 케이스가 남긴 B-2의 FAQ-209와 B-3의 FAQ-305입니다. 테스트가 FAQ는 비웠지만 격리함은 사람이 볼 곳이라 그대로 둡니다.
  • 1~1.4초: 검사 웹훅을 한 번 부르고(0.1초), 줄마다 HTTP 한 번씩. LLM은 아직 없습니다.

예제 2 · LLM 검사 (10분)

이 실습의 의도

한 줄로: 규칙을 모두 통과했지만 답이 제목의 질문에 답하지 않는 줄(FAQ-210 "모니터 밝기 조절"인데 답은 연차 휴가)을 LLM에게 물어 거릅니다. 규칙을 통과한 줄에만 묻습니다.

생활 속 예 · 맛보기. 검수 기준표로 무게 · 온도 · 유통기한은 잴 수 있습니다. 하지만 "이 소스, 주문한 맛이 맞나?"는 기준표로 못 잽니다. 주방장이 한 숟가락 맛봅니다. 그런데 상한 것까지 맛볼 필요는 없습니다 — 이미 기준표에서 떨어진 것은 맛보지 않습니다. 예제 2는 기준표를 통과한 것만 맛보는(LLM 검사) 실습입니다.

상황. B-2의 FAQ-210은 제목 · 키워드 · 길이 · 형식이 모두 멀쩡합니다. 그런데 답이 연차 휴가 이야기입니다. 이런 줄이 들어가면 "모니터 밝기"를 물은 직원이 연차 휴가 안내를 받습니다. 규칙(Code)으로는 "뜻이 맞나"를 알 수 없습니다.

노드 하는 일
Only passed (Code) 규칙을 통과한 줄만 남김
Judge (AI Agent + gpt-4.1-mini, temperature 0) 제목과 답만 보고 {"match": true/false, "reason"}
Decide (Code) match가 false이거나 읽을 수 없으면 llm_mismatch로 격리

끝나면 알게 되는 것. 규칙은 Code가, 뜻은 LLM이 — 둘을 나누는 선. LLM 판정을 읽을 수 없을 때 넣지 않는 쪽으로(fail-closed, 73일차) 정하는 까닭. 줄마다 1초쯤 걸린다는 것.

적을 곳. my-work/QUALITY.md 6절 (B-2 · B-3의 줄 · 규칙 통과 · LLM이 막은 줄).

이해하기

지금 무엇이 없나. 예제 1의 가져오기는 검사 결과의 ok만 봅니다. FAQ-210은 ok이므로 들어갑니다. 그래서 Split rows와 Store row 사이에 "통과한 줄만 LLM에게 묻고, 그 판정으로 ok를 다시 정하는" 세 노드를 넣습니다.

flowchart LR
  S["Split rows<br/>(줄마다)"] --> P["Only passed<br/>규칙 통과만"] --> J{{"Judge<br/>답이 질문에 맞나?"}} --> D["Decide<br/>판정을 줄에 맞춤<br/>읽을 수 없으면 false"] --> T["Store row"]
  S -.->|규칙에 걸린 줄| D

규칙을 통과한 줄에만 묻는 까닭. 규칙에 걸린 줄은 이미 격리됩니다. 그 줄을 LLM에게 물어도 결과는 같고, 돈과 시간만 듭니다 (75일차: LLM 호출 하나가 1~2초와 비용). B-2는 10줄 중 9줄만, B-3은 6줄 중 5줄만 묻습니다. 과제 2에서 이 수를 응답에 넣어 확인합니다.

판정을 읽을 수 없으면. LLM이 JSON이 아닌 말을 하거나, 오류가 나면 그 줄을 넣지 않습니다 (match: false). "모르면 넣지 않는다"가 품질 게이트의 기본입니다. 반대로 "모르면 넣는다"로 하면 LLM이 멈춘 날 모든 줄이 검사 없이 들어갑니다.

Decide가 줄을 맞추는 법. Judge는 Only passed가 넘긴 줄 수만큼 결과를 냅니다. Decide는 $('Judge').all()[i]를 $('Only passed').all()[i]와 짝짓고, 그다음 Split rows의 줄 순서대로 판정을 다시 끼워 넣습니다. 그래야 결과의 imported_ids · quarantined_ids가 들어온 순서와 맞습니다.

기대 결과. lab02 6/6. B-2 가져오기가 1초 안팎에서 8~12초로 늘어납니다 (9줄 × LLM 한 번).

▶ 1단계 · 지시문

LLM에게는 제목과 답만 줍니다. "답이 제목의 질문에 답하나? 문체는 보지 말 것. JSON만"이라고 씁니다. 규칙이 이미 본 것(길이 · 형식)을 다시 묻지 않습니다.

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

요청문에 꼭 들어갈 것: ① 누구에게 묻나 ② 읽을 수 없을 때 ③ 붙일 코드 ④ LLM 오류일 때. 아래 빈칸 4개를 채웁니다.

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 ____②. match 가 false 면 errors 에 ____③.
Judge 의 onError 는 ____④. 규칙에 걸린 줄은 LLM 에 보내지 않고 그대로 격리. 앞의 칸은 그대로. 기대 결과 cases/lab02.json 전체.

빈칸 힌트:

  • ① AGENTS.md 3절 "가져오기 게이트" 표의 LLM 검사 줄. 어떤 줄만 LLM에게 보내나요?
  • ② "모르면 넣지 않는다"를 match 값으로 쓰면.
  • ③ AGENTS.md 3절 규칙 표의 마지막 코드.
  • ④ AGENTS.md 5절 "LLM 검사" 셋째 줄. LLM이 멈춰도 워크플로우가 멈추지 않게.
uv run tools/flow.py push my-work/faq-import.json
uv run tools/flow.py test cases/lab02.json -v

실행 결과 · LLM 검사

수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다. LLM(gpt-4.1-mini, temperature 0)을 부르므로 시간은 그때그때 다릅니다.

고치기 전 · 예제 1의 가져오기

예제 1의 실행 결과 그대로입니다: lab02 5/6, FAQ-210이 imported_ids에 들어가 llm_mismatch가 없음.

고친 뒤

실행한 명령:

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

출력 (결과 줄과 -v의 B-2 · B-3 응답):

✔ 나쁜 배치는 통째로 거절 (422) · 1.0s
✔ 배치 2 · 통과한 줄만 넣고 나쁜 줄은 격리 · 9.3s
✔ FAQ에 들어갔고 메일 주소는 안 들어감 · 0.0s
✔ 격리함에 이유와 함께 · 0.0s
✔ LLM · 제목과 답이 다른 줄 (FAQ-210) · 8.4s
✔ LLM · 규칙에 걸린 줄은 그대로 (FAQ-305) · 4.4s
Passed 6/6

← B-2  {"total": 10, "imported": 8, "quarantined": 2, "score": 0.8, "codes": {"pii": 1, "llm_mismatch": 1},
        "imported_ids": ["FAQ-201", …, "FAQ-208"], "quarantined_ids": ["FAQ-209", "FAQ-210"]}
← B-3  {"total": 6, "imported": 5, "quarantined": 1, "score": 0.8333, "codes": {"answer_too_short": 1},
        "quarantined_ids": ["FAQ-305"]}

고친 뒤 이렇게 나온 까닭

줄 · 케이스 고치기 전 (예제 1) 고친 뒤 무엇이 바꿨나
FAQ-210 (밝기 · 연차 휴가) 넣음 격리 llm_mismatch Judge가 match: false
B-2 9줄 · score 0.9 8줄 · 0.8 위 한 줄
B-3 5줄 5줄 (같음) 5줄 모두 질문에 맞는 답 · FAQ-305는 규칙에서 이미 격리
B-2 시간 1.4초 9.3초 규칙을 통과한 9줄 × LLM 한 번
  • FAQ-305는 LLM에 가지 않았습니다: 답이 "짧" 한 글자라 규칙(answer_too_short)에서 걸렸습니다. LLM에게 물었다면 돈을 쓰고도 같은 결과입니다. 과제 2에서 몇 번 불렀는지 숫자로 봅니다.
  • LLM이 막은 것은 FAQ-210 하나: B-2의 다른 8줄과 B-3의 5줄은 모두 match: true. "규칙은 통과했지만 뜻이 틀린 줄"은 드뭅니다. 그 드문 줄을 위해 줄마다 1초쯤을 씁니다 — 이 대가를 알고 쓰는 것이 중요합니다.
  • 시간 9초: 직원이 기다리는 헬프데스크 답이라면 너무 깁니다. 하지만 가져오기는 콘텐츠 담당자가 배치를 보낼 때 한 번 도는 일이라 괜찮다고 판단했습니다. 같은 LLM 검사도 어디에 쓰느냐에 따라 허용 시간이 다릅니다.

예제 3 · 기준을 바꾸면 (6분, 에이전트 없이)

이 실습의 의도

한 줄로: 예제 1에서 정한 거절 기준(0.3)을 본문의 max_invalid_ratio로 0.8 · 0.1 · 0.05로 바꿔 보내, 같은 배치가 받히기도 하고 거절되기도 하는 것을 봅니다. 워크플로우는 고치지 않습니다.

생활 속 예 · 반품 기준을 느슨하게 하면. "상자 열에 셋까지는 골라 받는다"를 "열에 여덟까지"로 바꾸면 트럭을 돌려보내는 일은 줄지만, 보류 칸이 넘치고 주방장이 하루 종일 골라내야 합니다. "열에 하나도 안 된다"로 바꾸면 보류 칸은 비지만 거의 모든 트럭이 돌아갑니다. 예제 3은 반품 기준을 바꿔 가며 무엇이 늘고 주는지 재 보는 실습입니다.

상황. 팀에서 "0.3은 너무 엄하다"와 "B-2도 한 줄 나쁘면 돌려보내자"는 말이 나왔습니다. 말로 다투기 전에, 같은 배치를 기준만 바꿔 보내 결과를 재 봅니다. cases/lab02-gate.json을 예제 2의 내 가져오기에 돌립니다.

실험 보내는 것 기준 기대
① B-1 기본 faq-batch-1.json 0.3 (없음) 거절 · invalid_ratio 0.7692
② B-1 느슨하게 faq-batch-1-loose.json 0.8 받음 · 넣는 것 2줄 · 격리 11줄
③ FAQ만 처음으로 /faq-reset — ②가 넣은 2줄을 비움 (격리함은 그대로)
④ B-2 엄하게 faq-batch-2-strict.json 0.05 거절 (아무것도 넣지 않음)
⑤ B-2 · 딱 같음 faq-batch-2-limit-0.1.json 0.1 나쁜 줄 10% = 기준 → 받음
⑥ 격리함 — — 13줄 (B-1 11 + B-2 2)

끝나면 알게 되는 것. 기준은 맞고 틀리는 값이 아니라 고르는 값이고, 고르면 대가가 따른다는 것 (거절된 배치 수 ↔ 격리함의 줄 수 ↔ 사람의 일). "넘으면"과 "같으면"의 경계. 기준을 바꿔도 워크플로우를 고치지 않게 하려면 기준을 입력으로 받아야 한다는 것.

적을 곳. my-work/QUALITY.md 5절 표 넷째 줄까지, 그리고 "우리 팀의 기준과 그 이유".

uv run tools/flow.py test cases/lab02-gate.json -v
uv run tools/flow.py quality

이해하기

기준 하나가 두 실수를 맞바꿉니다.

기준 잘못 받는 일 (나쁜 배치를 받음) 잘못 돌려보내는 일 (괜찮은 배치를 거절)
느슨하게 (0.8) 늘어남 — 격리함이 넘치고 사람이 골라냄 줄어듦
엄하게 (0.05) 줄어듦 늘어남 — 한 줄 실수로 배치 전체가 돌아감

77일차의 SLO 기준과 같습니다. 기준을 낮추면 알림(일)이 늘고, 높이면 문제를 놓칩니다. 그래서 기준은 팀이 정하고 적어 두는 값입니다.

경계 · 딱 같으면. AGENTS.md 3절은 "기준을 넘으면 거절, 같으면 통과"라고 씁니다. B-2는 10줄 중 1줄이 나빠 invalid_ratio가 정확히 0.1입니다. 기준 0.05면 거절하고, 0.1이면 받습니다. Lab 01 예제 3의 "40자는 통과"와 같은 종류의 경계입니다.

③ FAQ를 비우고 ④를 ⑤보다 먼저 하는 까닭. ②에서 B-1의 FAQ-102("회의실 예약")가 들어가면, B-2의 FAQ-201도 제목이 "회의실 예약"이라 same_title(Lab 01 과제 1)로 나빠집니다. 그러면 B-2의 나쁜 줄이 2줄(0.2)이 되어 ⑤가 거절됩니다. 또 ⑤가 B-2를 넣은 뒤에 엄한 기준을 보내면 9줄이 exists_id라 비율이 0.9가 됩니다. 실험을 하나씩 떼어 보려고 FAQ를 비우고, 아무것도 넣지 않는 ④를 먼저 합니다. 이것도 배울 점입니다: 앞 배치가 넣은 데이터가 다음 배치의 판정을 바꿉니다.

기대 결과. 6/6. quality에 격리함 13줄.

▶ 1단계 · 6/6이 아니라면

예제 1의 요청문에 "기준은 본문 max_invalid_ratio, 없으면 0.3"과 "응답에 max_invalid_ratio"가 들어갔는지 봅니다. 0.3을 IF 안에 숫자로 박아 두면 ②와 ④가 바뀌지 않습니다. 이 예제는 에이전트 없이 하는 실험이지만, 실패하면 예제 1의 가져오기로 돌아가 고칩니다.

실행 결과 · 기준을 바꾸면

수업 준비 때(11/01) 강사 노트북에서 실제로 나온 출력입니다 (예제 2의 가져오기, LLM 포함).

이 예제는 고치지 않습니다. 같은 배치를 본문의 기준(max_invalid_ratio)만 바꿔 보냅니다.

실행한 명령:

uv run tools/flow.py test cases/lab02-gate.json -v
uv run tools/flow.py quality

출력 (결과 줄, -v의 응답 요약, quality):

POST http://localhost:5678/webhook/my-faq-import · 6 case(s) × 1
✔ B-1 · 기본 기준 0.3 → 거절 · 0.1s
✔ B-1 · 기준을 0.8로 풀면 → 받음, 넣는 것은 2줄 · 9.0s
✔ FAQ만 처음으로 (B-1이 넣은 2줄을 비움, 격리함은 그대로) · 0.0s
✔ B-2 · 기준 0.05 → 거절 · 0.1s
✔ B-2 · 기준 0.1 · 나쁜 줄이 딱 10% → 받음 · 8.7s
✔ 격리함이 13줄 (B-1 11 + B-2 2) · 0.0s
Passed 6/6

← ① B-1 기준 0.3   HTTP 422 {"invalid_ratio": 0.7692, "max_invalid_ratio": 0.3}
← ② B-1 기준 0.8   HTTP 200 {"total": 13, "imported": 2, "quarantined": 11, "score": 0.1538, "max_invalid_ratio": 0.8,
                              "imported_ids": ["FAQ-101", "FAQ-102"], "codes": {…, "llm_mismatch": 1}}
← ④ B-2 기준 0.05  HTTP 422 {"invalid_ratio": 0.1, "max_invalid_ratio": 0.05, "codes": {"pii": 1}}
← ⑤ B-2 기준 0.1   HTTP 200 {"imported": 8, "quarantined": 2, "score": 0.8, "max_invalid_ratio": 0.1}

FAQ: 14  (FAQ-202, FAQ-201, FAQ-203, FAQ-204, FAQ-205, FAQ-207, FAQ-208, FAQ-206)
quarantine: 13

왜 이렇게 나왔나 (실험마다)

실험 기준 결과 까닭
① B-1 0.3 거절 0.7692 > 0.3
② B-1 0.8 받음 · 넣음 2 · 격리 11 0.7692 ≤ 0.8. 규칙을 통과한 3줄 중 FAQ-113(토너 · 메일함)은 LLM이 막음
④ B-2 0.05 거절 0.1 > 0.05 — 메일 주소 한 줄 때문에 9줄도 못 들어감
⑤ B-2 0.1 받음 0.1 = 0.1, "넘으면 거절"이라 같으면 받음
⑥ 격리함 13줄 ②의 11줄 + ⑤의 2줄 (④는 아무것도 남기지 않음)
  • ② 느슨한 기준의 대가: B-1을 받았지만 넣은 것은 2줄, score 0.1538입니다. 격리함에 11줄이 쌓였고, 사람이 11줄을 하나씩 열어 봐야 합니다. 거절했다면 콘텐츠 담당자가 고침 목록(Lab 01 과제 2)을 받아 한 번에 고쳐 다시 보냈을 일입니다.
  • ④ 엄한 기준의 대가: B-2는 9줄이 멀쩡한데 한 줄 때문에 모두 돌아갔습니다. 그날 직원들은 회의실 예약 · 출입증 FAQ를 못 봅니다.
  • ⑤ 경계: 같은 B-2가 0.05에서는 거절, 0.1에서는 받힘. "넘으면"을 "이상이면"으로 쓰면 결과가 바뀝니다. Lab 01 예제 3과 같은 종류의 경계입니다.
  • ③을 넣은 까닭 (실험 설계): ②가 넣은 FAQ-102("회의실 예약")를 그대로 두면 B-2의 FAQ-201이 same_title로 나빠져 비율이 0.2가 됩니다. ④를 ⑤보다 먼저 한 것도, ⑤가 B-2를 넣은 뒤에 ④를 보내면 9줄이 exists_id라 비율이 0.9가 되기 때문입니다 (수업 준비 때 실제로 겪음). 앞 배치가 넣은 데이터가 다음 배치의 판정을 바꿉니다.
  • quality의 FAQ 14: 처음 6 + ⑤의 8줄. 테스트에 after가 없어 남아 있습니다 (다음 과제의 테스트가 비움).
  • 결론을 QUALITY.md 5절에: 강사는 0.3을 그대로 두었습니다 — "열에 셋까지는 골라 받고, 그보다 나쁘면 돌려보내 고치게 한다." 정답은 없고, 팀이 정해 적어 두는 것이 답입니다.

과제 1 · 품질 기록과 알림 (10분)

이 실습의 의도

한 줄로: 가져오기가 끝날 때마다 품질 기록(/quality-log) 한 줄을 남기고, 배치를 거절했거나 점수가 0.9 미만이면 알림을 보냅니다. 거절한 배치도 기록합니다.

생활 속 예 · 검수 일지와 사장님 호출. 주방장은 상자마다 검수 일지에 "몇 개 받음 · 몇 개 반품 · 이유"를 적습니다. 일지가 있어야 "요즘 그 납품업체가 나빠졌다"를 말할 수 있습니다. 트럭째 반품한 날이나 절반 넘게 보류한 날은 사장님께 바로 전화합니다. 과제 1은 검수 일지(품질 기록)와 사장님 호출(알림)을 붙이는 실습입니다.

상황. 지금은 가져오기 결과가 부른 사람의 화면에만 남고 사라집니다. 팀장이 요청했습니다: "배치마다 점수를 남겨 주세요. 다음 주에 추세를 보겠습니다 (Lab 03 과제 1). 거절했거나 점수가 낮으면 바로 알려 주세요."

언제 무엇을 (AGENTS.md 3절)
배치마다 (거절도) /quality-log 한 줄 {batch, dataset: "faq", total, valid, quarantined, rejected, score, codes}
거절 /alerts reason: "batch_rejected"
score < 0.9 /alerts reason: "low_quality"

끝나면 알게 되는 것. 품질은 한 번 재고 끝나는 것이 아니라 기록이 쌓여야 관리된다는 것. 알림을 보내도 부른 쪽이 받는 결과는 바뀌지 않아야 한다는 것 (곁가지).

적을 곳. my-work/QUALITY.md 7절.

uv run tools/flow.py test cases/lab02-challenge.json -v    # 고치기 전 (예제 2의 가져오기)

이해하기

두 갈래 모두 기록. 거절 갈래(422)와 받는 갈래(200) 둘 다 기록을 남깁니다. 거절을 기록하지 않으면 품질 보고서에서 "가장 나쁜 배치"가 사라집니다.

flowchart LR
  G{"Batch ok?"} -->|거절| L1["Log rejected<br/>rejected: true · score 0"] --> A1["Alert rejected<br/>batch_rejected"] --> R1(["422"])
  G -->|받음| S["… Summarize"] --> L2["Log quality"] --> Q{"score < 0.9?"}
  Q -->|예| A2["Alert low score<br/>low_quality"] --> R2["Result again"]
  Q -->|아니오| R2 --> O(["200 · 같은 결과"])

Result again이 필요한 까닭. Log quality · Alert low score는 HTTP 노드라 그 출력은 데이터 API의 응답({"stored": 1})입니다. 그대로 응답하면 부른 쪽은 가져오기 결과를 잃습니다. 그래서 끝에서 Summarize의 결과를 다시 꺼냅니다 (Lab 03 예제 2에서도 씀).

테스트 케이스.

케이스 기대
거절도 품질 기록 B-1 → 422
배치 2 가져오기 score 0.8
품질 기록 두 줄 (거절 · 0.8) 첫 줄 rejected: true, 둘째 줄 score 0.8 · valid 8 · quarantined 2
알림 두 건 batch_rejected
알림에 낮은 점수 low_quality

기대 결과. 고치기 전 2/5 (가져오기 두 케이스는 원래 됨), 고친 뒤 5/5, lab02 6/6 그대로.

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

요청문에 꼭 들어갈 것: ① 거절도 기록 ② 알림 이유 ③ 낮은 점수의 기준 ④ 응답. 아래 빈칸 4개를 채웁니다.

my-work/faq-import.json 에 품질 기록과 알림을 더해 줘.
배치마다 POST http://data-api:8800/quality-log 한 줄 {batch, dataset: "faq", total, valid: imported, quarantined, rejected, score, codes} — 거절된 배치도 ____① 으로.
거절이면 POST /alerts {workflow: "faq-import", severity: "high", reason: "____②", batch, message}.
받은 배치의 score 가 ____③ 미만이면 reason "low_quality" 알림.
알림을 보내도 응답은 ____④ (Summarize 의 결과를 다시). 기대 결과 cases/lab02-challenge.json, cases/lab02.json 도.

빈칸 힌트:

  • ① 거절된 배치의 기록에서 rejected와 score는 무엇인가요? (3절 score 줄)
  • ② 3절 "가져오기 게이트" 표의 마지막 줄. 테스트가 이 글자를 찾습니다.
  • ③ 같은 줄의 숫자.
  • ④ 위 "Result again이 필요한 까닭".
uv run tools/flow.py push my-work/faq-import.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

실행 결과 · 품질 기록과 알림

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

고치기 전 · 예제 2의 가져오기

실행한 명령:

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

출력 (결과 줄만):

✔ 거절도 품질 기록 · 0.1s
✔ 배치 2 가져오기 · 8.7s
✘ 품질 기록 두 줄 (거절 · 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)
✘ 알림 두 건 (거절 · 낮은 점수) · 0.0s · count = 0 (expected 2); alerts has none of ['batch_rejected']
✘ 알림에 낮은 점수 · 0.0s · alerts has none of ['low_quality']
Passed 2/5

왜 이렇게 나왔나 (고치기 전): 앞 두 케이스는 가져오기 자체(422 · score 0.8)라 예제 2로도 됩니다. 뒤 셋은 /quality-log/list · /alerts/list를 보는데, 예제 2는 아무것도 남기지 않으니 count 0. 예상한 결과.

고친 뒤

실행한 명령:

uv run tools/flow.py push my-work/faq-import.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

출력 (결과 줄과 quality):

✔ 거절도 품질 기록 · 1.3s
✔ 배치 2 가져오기 · 9.4s
✔ 품질 기록 두 줄 (거절 · 0.8) · 0.0s
✔ 알림 두 건 (거절 · 낮은 점수) · 0.0s
✔ 알림에 낮은 점수 · 0.0s
Passed 5/5

(lab02) Passed 6/6

FAQ: 6  (nothing added)
quarantine: 3
quality log: 4
alerts: 4

고친 뒤 이렇게 나온 까닭

배치 응답 품질 기록 알림
B-1 422 (그대로) rejected: true · score 0 batch_rejected
B-2 200 · score 0.8 (그대로) valid 8 · quarantined 2 · 0.8 low_quality (0.8 < 0.9)
  • 응답은 바뀌지 않았습니다: 기록 · 알림은 곁가지이고, 끝의 Result again이 Summarize의 결과를 다시 꺼내 응답합니다. 그래서 lab02 6/6이 그대로입니다.
  • quality의 기록 4 · 알림 4 · 격리 3: 뒤이어 돌린 lab02가 남긴 것입니다. lab02의 before가 기록 · 알림을 비운 뒤 B-1(거절) · B-2(0.8) · B-2 다시(0.8) · B-3(0.8333)을 보내 기록 4줄, 네 배치 모두 거절이거나 0.9 미만이라 알림 4건. 격리함 3줄은 둘째 B-2의 FAQ-209 · 210과 B-3의 FAQ-305입니다. 테스트마다 before가 무엇을 비우는지 읽어 보면 숫자가 맞습니다.
  • B-2가 알림을 받는 까닭: 10줄 중 2줄을 격리하면 0.8. 열 줄에 한 줄 넘게 막혔다는 것은 "사람이 격리함을 볼 일이 생겼다"는 뜻이라 기준 0.9를 썼습니다.

과제 2 · LLM을 몇 번 부르나 · 늘 같은가 (12분)

이 실습의 의도

한 줄로: 가져오기 결과에 LLM에게 보낸 줄 수(llm_calls)를 더해 "규칙을 통과한 줄에만 묻는다"를 숫자로 확인하고, 같은 테스트를 세 번 돌려 LLM 판정이 늘 같은지 봅니다.

생활 속 예 · 맛보기 횟수와 맛보는 사람. 사장님이 묻습니다. "오늘 맛보기를 몇 번 했지? 상한 것까지 맛본 건 아니지?" 그리고 "어제 맛본 사람과 오늘 맛본 사람의 판정이 같나?" 맛보기(LLM)는 비싸고, 사람마다 · 날마다 다를 수 있기 때문입니다. 과제 2는 맛보기 횟수를 일지에 적고, 같은 상자를 세 번 맛보게 하는 실습입니다.

상황. 예제 2에서 B-2 가져오기가 1초에서 10초 안팎으로 늘었습니다. 팀장이 묻습니다: "LLM을 줄마다 부르는 거죠? 정말 통과한 줄에만? 그리고 LLM이 오늘은 FAQ-210을 막고 내일은 넣으면 곤란합니다."

칸 정의 (AGENTS.md 3절)
llm_calls LLM 검사에 보낸 줄 수 (= 규칙을 통과한 줄 수)
거절된 배치 422 본문에도 llm_calls: 0

끝나면 알게 되는 것. LLM 비용은 부른 횟수로 설명할 수 있다는 것 (75일차). temperature 0이어도 "늘 같은가"는 재 봐야 안다는 것. 같은 배치를 다시 보내면 무엇이 달라지는지(이미 넣은 FAQ).

적을 곳. my-work/QUALITY.md 6절 (llm_calls · 세 번 돌려 같았나 · 다시 보내면).

uv run tools/flow.py test cases/lab02-challenge2.json -v    # 고치기 전 (과제 1까지의 가져오기)

이해하기

호출 수로 비용을 말한다. 줄 하나에 LLM 한 번이므로, llm_calls는 곧 이 배치의 LLM 비용입니다. B-2(10줄)는 9번, B-3(6줄)은 5번, 거절된 B-1(13줄)은 0번. "규칙을 앞에 두면 LLM 비용이 줄어든다"를 말이 아니라 숫자로 보입니다.

배치 줄 규칙에 걸림 llm_calls LLM 없이 모든 줄을 물었다면
B-1 (거절) 13 10 0 13
B-2 10 1 9 10
B-3 6 1 5 6

늘 같은가 — 세 번 돌리기. test를 세 번 돌립니다. 테스트 파일의 before가 매번 FAQ · 격리함을 비우므로, 세 번 모두 같은 처음 상태에서 같은 배치를 보냅니다. 세 번 모두 FAQ-210이 막히고 llm_calls · imported가 같으면 "늘 같다"고 말할 수 있습니다.

--repeat를 쓰면 안 되는 까닭. --repeat 2는 before를 한 번만 하고 같은 배치를 연달아 보냅니다. 두 번째 B-2는 첫 번째가 넣은 FAQ-201~208과 id가 같아 모두 exists_id(Lab 01 과제 1) → 나쁜 줄 9/10 → 거절됩니다. 판정이 흔들린 것이 아니라 데이터가 바뀐 것입니다. 마지막 명령(--no-before --repeat 2)으로 이것도 직접 봅니다.

기대 결과. 고치기 전 0/3, 고친 뒤 3/3 × 세 번, lab02 · lab02-challenge도 그대로. 마지막 --no-before는 거절이 나오는 것이 정상입니다.

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

요청문에 꼭 들어갈 것: ① 무엇을 세나 ② 거절일 때 ③ 이 칸의 뜻 ④ 확인 방법. 아래 빈칸 4개를 채웁니다.

my-work/faq-import.json 의 결과에 llm_calls 를 더해 줘.
llm_calls = LLM 검사에 보낸 줄 수 (Only passed 의 줄 ____①). 거절된 배치(422)의 본문에도 llm_calls ____②.
LLM 은 규칙을 ____③ 줄에만 부른다는 것을 숫자로 보이게 하는 칸이다. 다른 칸은 그대로.
기대 결과 cases/lab02-challenge2.json 을 ____④ 돌려 늘 같은지, lab02 · lab02-challenge 도.

빈칸 힌트:

  • ① Only passed가 넘긴 것의 무엇인가요?
  • ② 거절된 배치는 LLM 앞에서 끝납니다.
  • ③ 3절 "가져오기 게이트" 표의 LLM 검사 줄.
  • ④ "늘 같은가"를 보려면 테스트를 몇 번, 어떻게 돌리나요? (--repeat가 아닌 까닭은 위에)
uv run tools/flow.py push my-work/faq-import.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

실행 결과 · LLM 호출 수 · 늘 같은가

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

고치기 전 · 과제 1까지의 가져오기

실행한 명령:

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

출력 (결과 줄만):

✘ 거절된 배치는 LLM을 부르지 않음 · 0.1s · llm_calls = null (expected 0)
✘ B-2 · 규칙을 통과한 9줄만 LLM에 · 8.6s · llm_calls = null (expected 9)
✘ B-3 · 5줄만 LLM에 · 4.3s · llm_calls = null (expected 5)
Passed 0/3

왜 이렇게 나왔나 (고치기 전): 가져오기 자체는 되지만(시간 8.6 · 4.3초) llm_calls 칸이 없습니다. 몇 번 불렀는지는 시간으로 짐작할 뿐입니다. 예상한 결과.

고친 뒤 · 세 번 돌리기

실행한 명령:

uv run tools/flow.py push my-work/faq-import.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

출력 (세 번의 결과 줄과 첫 번째의 응답):

✔ 거절된 배치는 LLM을 부르지 않음 · 1.1s     ✔ · 0.1s     ✔ · 0.1s
✔ B-2 · 규칙을 통과한 9줄만 LLM에 · 9.4s     ✔ · 8.1s     ✔ · 7.6s
✔ B-3 · 5줄만 LLM에 · 4.1s                   ✔ · 4.4s     ✔ · 4.0s
Passed 3/3                                   Passed 3/3   Passed 3/3

← B-1  HTTP 422 {"invalid_ratio": 0.7692, "max_invalid_ratio": 0.3, …, "llm_calls": 0}
← B-2  HTTP 200 {"imported": 8, "quarantined": 2, "quarantined_ids": ["FAQ-209", "FAQ-210"], "llm_calls": 9}
← B-3  HTTP 200 {"imported": 5, "quarantined": 1, "quarantined_ids": ["FAQ-305"], "llm_calls": 5}

고친 뒤 이렇게 나온 까닭

배치 줄 llm_calls 세 번 모두 시간 (세 번)
B-1 (거절) 13 0 같음 0.1초 안팎
B-2 10 9 FAQ-210만 막음 · 넣음 8 9.4 · 8.1 · 7.6초
B-3 6 5 넣음 5 4.1 · 4.4 · 4.0초
  • 호출 수 = 규칙을 통과한 줄 수: 9 = 10 − 1(FAQ-209), 5 = 6 − 1(FAQ-305). 거절된 B-1은 0. "규칙을 앞에 둔다"가 이제 숫자로 보입니다. 75일차처럼 호출당 비용을 알면 배치의 LLM 비용을 바로 셀 수 있습니다.
  • 세 번 모두 같은 판정: temperature 0에서 이 13줄은 세 번 모두 같은 판정이었습니다. 다만 "세 번 같았다"는 "늘 같다"의 증거일 뿐 보증은 아닙니다. 판정이 갈리는 줄이 생기면 그 줄은 사람이 보도록 격리하는 쪽이 안전합니다 (읽을 수 없으면 false와 같은 생각).
  • 시간은 매번 다릅니다: 같은 9번의 호출이 7.6~9.4초. 시간이 아니라 llm_calls로 비용을 말하는 까닭입니다.

같은 배치를 연달아 다시 보내면 (--no-before --repeat 2)

실행한 명령:

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

왜 이렇게 나왔나: --no-before라 바로 앞 실행이 넣은 FAQ-201208 · FAQ-301 · 302 · 303 · 304 · 306이 그대로 있습니다. B-2를 다시 보내면 FAQ-201208의 8줄이 exists_id(Lab 01 과제 1), FAQ-209는 pii → 나쁜 줄 9/10 = 0.9로 거절됩니다 (FAQ-210은 지난번에 격리돼 FAQ에 없으니 규칙은 통과). B-3도 5줄이 exists_id + FAQ-305 → 6/6으로 거절. 거절이니 LLM은 한 번도 부르지 않습니다(llm_calls 0). LLM 판정이 흔들린 것이 아니라 데이터가 바뀐 것입니다. 그래서 "늘 같은가"는 --repeat가 아니라 처음 상태(before)에서 세 번 돌려 봅니다. 이 ✘는 예상한 결과이고, 같은 배치를 두 번 보내도 FAQ가 두 벌 생기지 않는다는 확인이기도 합니다.

뒤이어 lab02 6/6 · lab02-challenge 5/5 그대로 (answers/16).

막혔을 때

증상 할 일
B-1이 422가 아님 Batch ok?가 invalid / total <= 기준인지 (<가 아니라 <=), 기준 기본값 0.3
422 본문에 invalid_ratio가 없음 Reject batch의 응답에 $('Run check')의 값으로
격리 수가 틀림 넣기 · 격리를 IF로 갈랐다가 합쳤는지 → HTTP 노드 하나로 (AGENTS.md 5절)
security.desk가 FAQ에 Store row의 주소 표현식이 ok를 보는지
FAQ-210이 들어감 Judge에 제목과 답이 다 가는지, Decide가 match === true일 때만 ok
Decide에서 줄이 어긋남 Judge의 i번째는 Only passed의 i번째 (Split rows가 아님)
품질 기록에 거절이 없음 거절 갈래에도 Log rejected
응답이 {"stored": 1} 끝에 Result again (Summarize를 다시)
llm_calls가 null 거절 응답과 받는 응답 둘 다에
두 번째 실행이 거절 --repeat로 같은 배치를 연달아 보냄 → test를 따로 세 번
계속 안 됨 cp reference/lab02-example2-import.json my-work/faq-import.json 후 push (예제 2까지)

확인

  • 예제 1: cases/lab02.json 5/6 (고치기 전 기준에 0/6), quality로 격리함 확인.
  • 예제 2: cases/lab02.json 6/6.
  • 예제 3: cases/lab02-gate.json 6/6, QUALITY.md 5절과 우리 팀의 기준.
  • 과제 1: cases/lab02-challenge.json 5/5, lab02도 통과, QUALITY.md 7절.
  • 과제 2: cases/lab02-challenge2.json 3/3 × 세 번, QUALITY.md 6절.
  • my-work/PROMPTS.md.