74일차 정답 공개 (LAB-A · VIBE-A · answers · tver), Lab 03 예제 3 이해하기 보강

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-28 20:43:38 +09:00
1 parent 2ae33432a9
commit d663d045e3
86 files changed
+16144

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
# Lab 01 · 무엇이 바뀌었나, 답마다 버전, 지시문은 저장소로
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-01-Q-WHAT-CHANGES.md](LAB-01-Q-WHAT-CHANGES.md).
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 실습을 5개로 늘린 뒤 정답 워크플로우를 학생 순서대로 2026-10-27에 1회씩 실행한 값입니다 (강사 Mac, `gpt-4.1-mini` · `gpt-5.4-nano`, bench는 n=5). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
## 예제 1 · 결과 (에이전트 없이)
| 바뀐 곳 (`diff`, 7개) | 전 | 후 | 셋 중 |
| --- | --- | --- | --- |
| `Answer` 지시문 | 307자 | 419자 (+ "FAQ에서 온 답이면 끝에 `[FAQ-003]`", "Keep answers short.") | 지시문 |
| `Answer model` | `gpt-4.1-mini` | `gpt-5.4-nano` | 모델 |
| 노드 `Review check` + 연결 셋 | 없음 | 답을 400자에서 자름 | 흐름 |
| 웹훅 경로 | `rel-v1` | `rel-v2` | 흐름 (답에는 영향 없음) |
| `call` (`answers/02`) | 시간 | 답 |
| --- | --- | --- |
| `rel-v1` | 2.8초 | "… 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다 …" |
| `rel-v2` | 1.0초 | "… 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. **[FAQ-003]**" |
- 풀이 때: "출처 번호는 어디 때문?" → 지시문일 가능성이 높지만 **이 비교만으로는 확정 못 함** (모델도 바뀜). "빨라진 것은?" → 모델일 것 같지만 마찬가지.
- **수업 준비 중 실제로 본 것**: 10/18 판 v2는 `gpt-4.1-nano`였고 10/27에 OpenAI가 지원 종료해 HTTP 500이 났습니다. 워크플로우를 하나도 안 바꿨는데 멈춘 실제 예라 1단계 2절에 넣었습니다.
## 예제 2 · 요청문 예
```text
my-work/helpdesk.json (v1 을 복사한 것) 의 웹훅 경로를 my-helpdesk 로, 이름을 "74 IT helpdesk (my)" 로 바꿔 줘.
Has question? 뒤에 Release info(Set) 를 넣어 release 1.0.0, prompt_version p1, model gpt-4.1-mini.
Answer model 의 모델 값은 ={{ $('Release info').first().json.model }} 로 Release info 의 model 을 읽게.
Build result 결과에 version : {release, prompt_version, model} 을 Release info 에서 더해 줘. 지시문은 그대로 .
기대 결과 cases/lab01.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `my-helpdesk` | `cases/lab01.json`의 `webhook` (AGENTS.md 3절) |
| ② | `={{ $('Release info').first().json.model }}` | 앞 노드 값을 읽는 n8n 표현식, 모델 이름을 한 곳에서 |
| ③ | `version` | AGENTS.md 3절 결과 모양의 버전 세 칸을 감싸는 이름 |
| ④ | 그대로 | 지시문까지 바꾸면 무엇 때문에 답이 바뀌었는지 또 모름 |
정답: [reference/lab01-example2-version.json](reference/lab01-example2-version.json)
| 10/27 결과 | |
| --- | --- |
| `cases/lab01.json` (`answers/03`) | **3/3** |
## 예제 3 · 결과 (에이전트 없이)
| 재기 | v1 (`rel-v1`) | v2 (`rel-v2`) |
| --- | --- | --- |
| `lab01-compare --repeat 3` (`answers/04` · `05`) | **9/15** — 출처 번호 두 케이스가 세 번 모두 ✘ | **15/15** |
| `bench -n 5` VPN (`answers/06`) | p50 1.23초 · p95 1.34초 | p50 0.98초 · p95 1.20초 |
- 출처 번호는 세 번 모두 같은 쪽 → **흔들림이 아니라 바뀐 것**. 해결 낱말 · 접수 현황은 둘 다 세 번 모두 ✔ (흔들림 없음).
- 시간 차이는 0.25초 정도. 모델 · 지시문 · 노드가 함께 바뀌어 원인을 말할 수 없습니다 → Lab 02의 한 가지씩 바꾼 릴리스로 이어 갑니다.
## 과제 1 · 요청문 예
```text
my-work/helpdesk.json 의 Release info 뒤에 Get prompt(HTTP GET) 를 넣어 줘.
주소는 http://data-api:8800/prompts/helpdesk/ prompt_version (Release info 의 값을 표현식으로).
Answer 의 systemMessage 는 지금 글자 대신 Get prompt 의 text 를 표현식으로 읽게.
Build result 의 version 에 prompt_source : ' registry ' 를 더해 줘. 다른 것은 그대로.
기대 결과 cases/lab01-challenge.json, cases/lab01.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `prompt_version` | 릴리스 정보 세 값 중 지시문 버전 이름 (`{{ $('Release info').first().json.prompt_version }}`) |
| ② | `Get prompt` | 지시문 글자를 가져오는 노드 |
| ③ | `prompt_source` | 지시문을 어디서 가져왔는지 (AGENTS.md 3절) |
| ④ | `registry` | `cases/lab01-challenge.json`의 기대 |
정답: [answers/74-lab01-challenge-registry.json](answers/74-lab01-challenge-registry.json)
| 10/27 결과 | 고치기 전 (예제 2 정답) | 고친 뒤 |
| --- | --- | --- |
| `cases/lab01-challenge.json` (`answers/07` · `08`) | 2/3 (`prompt_source` 없음) | **3/3** |
| `cases/lab01.json` (`answers/09`) | | 3/3 |
## 과제 2 · 요청문 예
```text
my-work/helpdesk.json 의 Get prompt 에 options.timeout 2000 과 onError continueErrorOutput 를 넣어 줘.
성공 쪽은 Registry prompt(Set, text = $json.text), 실패 쪽은 Fallback prompt(Set, text = data-api/data/prompts/helpdesk- p1 .md 와 같은 글).
두 Set 모두 Search FAQ 로 이어 줘. Answer 의 systemMessage 는 Registry prompt 가 실행됐으면 그 text, 아니면 Fallback prompt 의 text.
version.prompt_source 는 Registry prompt 가 실행됐으면 'registry', 아니면 ' fallback '.
기대 결과 cases/lab01-challenge2.json, cases/lab01-challenge.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `2000` | 2초를 ms로 |
| ② | `continueErrorOutput` | 실패해도 멈추지 않고 두 번째(오류) 출력으로 (72일차 Lab 03, 73일차 Lab 02 과제 2와 같음) |
| ③ | `p1` | 마지막으로 검증된 지시문 (운영 중인 1.0.0의 지시문) |
| ④ | `fallback` | `cases/lab01-challenge2.json`의 고장 케이스 기대 |
정답: [answers/74-lab01-challenge2-fallback.json](answers/74-lab01-challenge2-fallback.json) (과제 1 정답 + `Get prompt`의 `timeout` · `onError`, 노드 2개: `Registry prompt` · `Fallback prompt`)
| 10/27 결과 (`cases/lab01-challenge2.json`) | 고치기 전 (과제 1 정답, `answers/10`) | 고친 뒤 (`answers/11`) |
| --- | --- | --- |
| 평소 · 저장소에서 | ✔ | ✔ `registry` |
| 저장소 고장 (`prompts error`) | ✘ **HTTP 500** `Error in workflow` (0.1초) | ✔ 200 · `fallback` · "인증서" (1.3초) |
| 저장소 느림 (`prompts slow` 8초) | ✘ **9.1초** (8초를 다 기다림, `registry`) | ✔ `fallback` · 3.0초 |
| 되돌린 뒤 | ✔ | ✔ `registry` |
| 합계 | 2/4 | **4/4** |
| `lab01-challenge` (`answers/12`) | | 3/3 |
- 풀이 때 "73일차 판별 LLM은 막았는데 왜 여기는 계속 가나?"를 묻습니다: 판별 LLM은 보안 관문(멈추면 공격이 지나감), 지시문 저장소는 편의 의존(멈춰도 예전 지시문으로 답할 뿐).
- **다음 단계로 넘기는 질문**: "저장소가 404(그런 지시문 없음)를 주면 이 기본 지시문은 어떻게 할까?" → Lab 02 과제 2.
+119
View File
@@ -0,0 +1,119 @@
# Lab 02 · 릴리스 고르기, 후보 시험, 배포 전 게이트
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-02-Q-RELEASE-GATE.md](LAB-02-Q-RELEASE-GATE.md).
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 정답 워크플로우를 학생 순서대로 2026-10-27에 1회씩 실행한 값입니다 (강사 Mac, `gpt-4.1-mini` · `gpt-5.4-nano`, bench는 n=5). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
## 예제 1 · 요청문 예
```text
my-work/helpdesk.json 의 Release info(Set) 를 Get release(HTTP GET http://data-api:8800/releases/helpdesk) → Pick release(Code) → Known release?(If) 로 바꿔 줘.
Pick release: 본문 release 가 있으면 그 릴리스 · channel pinned , 없으면 active · channel stable . 고른 릴리스의 prompt_version · model 을 돌려준다.
releases 에 없으면 Known release? false → Reject release(400, {error: 'unknown release', release, known }).
Get prompt · Answer model · Build result 는 Release info 대신 Pick release 를 읽게. 기본 지시문(Fallback prompt)은 그대로.
기대 결과 cases/lab02.json, cases/lab01-challenge.json, cases/lab01-challenge2.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `pinned` | 본문 `release`로 고정 |
| ② | `stable` | 운영 중인 `active` |
| ③ | `known` | 알려진 릴리스 목록 (`cases/lab02.json` "없는 릴리스" 기대) |
| ④ | `Pick release` | 릴리스를 고른 노드 |
정답: [reference/lab02-example1-release.json](reference/lab02-example1-release.json) (Lab 01 과제 1 · 2 포함)
| 10/27 결과 | 고치기 전 (Lab 01 과제 2 정답, `answers/13`) | 고친 뒤 (`answers/14` · `15`) |
| --- | --- | --- |
| `cases/lab02.json` | 1/5 (고정이 안 먹음, 9.9.9도 200, `channel` 없음) | **5/5** |
| `lab01-challenge` · `lab01-challenge2` | | 3/3 · 4/4 |
## 예제 2 · 결과 (에이전트 없이)
| 약한 golden (`answers/16`) | 결과 | p95 |
| --- | --- | --- |
| 1.0.0 | 6/6 PASS | 1.6초 |
| 1.1.0 | 6/6 PASS | 1.1초 |
| **1.2.0** | **6/6 PASS** | 1.5초 |
| 2.0.0 | 6/6 PASS | 2.0초 |
- 1.2.0 프린터 답 (`call -v`): **"IT 포털에서 프린터 문제 해결 방법을 확인해 주세요."** 내보내면 안 되는 답이 PASS.
## 예제 3 · 결과 (에이전트 없이)
| 재기 (`answers/17`) | 결과 |
| --- | --- |
| VPN `bench -n 5 --set release=1.0.0` | p50 **1.23초** · p95 1.42초 |
| VPN `bench -n 5 --set release=2.0.0` | p50 **1.12초** · p95 1.24초 |
| `gate golden --set release=1.0.0 --repeat 3` | **18/18** PASS, p95 1.5초 |
- 기준선 한 줄 예: "1.0.0 p95 1.4초 근처, 3번 반복에도 흔들림 없음 → `--max-p95 3`은 약 두 배 여유, 통과율 100%를 그대로 기준으로."
- 2.0.0은 1.1.0과 지시문이 같고 모델만 다릅니다. 빨라진 0.1초는 모델 때문이라고 말할 수 있습니다 (한 가지만 바꿨으니). 다만 5번이라 차이가 작으면 흔들림 안쪽일 수 있다고 짚습니다.
## 과제 1 · 요청문 예
```text
cases/golden.json 을 cases/my-golden.json 으로 복사해 expect 만 강하게 바꿔 줘. 워크플로우는 바꾸지 않는다 .
질문마다 답에 꼭 있어야 할 낱말을 contains 로 (아래는 내가 FAQ · 접수 건 데이터에서 정한 것):
VPN 인증서 / VPN 업데이트 · 최신 / 비밀번호 비밀번호 찾기 · 휴대폰 / 프린터 대기열 · 전원 / 메일함 보관함 · 삭제 /
T-1002 처리 중 / T-1002 10월 20일 · 20일 / T-1001 처리 완료 · 완료.
프린터 질문에는 " IT 포털을 확인 · 포털을 참고 " 같은 떠넘기기 말이 없어야 한다 (not_contains).
기대: gate --set release=1.0.0 · 1.1.0 PASS, release= 1.2.0 FAIL.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | 바꾸지 않는다 | 과제 조건: 테스트만 고친다 |
| ② | `contains` | 목록 중 하나라도 있으면 통과 (AGENTS.md 8절) |
| ③ | `IT 포털을 확인` · `포털을 참고` (정답 파일) | 1.2.0 지시문(p3)이 "IT 포털을 보라"고 시킴. **실제 답 "포털에서 … 확인해 주세요"는 이 글자에 안 걸림** → 풀이 거리 |
| ④ | `1.2.0` | 막아야 할 릴리스 |
정답 (테스트 파일): [answers/74-lab02-challenge-golden.json](answers/74-lab02-challenge-golden.json) (9케이스)
| 10/27 결과 (`answers/18`) | 강한 golden | p95 |
| --- | --- | --- |
| 1.0.0 | **9/9 PASS** | 1.4초 |
| 1.1.0 | **9/9 PASS** | 1.7초 |
| 1.2.0 | **1/9 FAIL** | 1.3초 |
| 2.0.0 | **9/9 PASS** | 1.3초 |
- 1.2.0이 통과한 하나 = "포털로 떠넘기지 않기"(`not_contains`). 해결 낱말 8케이스가 막았습니다.
- 학생이 ③에 "포털"만 적으면: 프린터 케이스에만 걸면 1.2.0의 그 케이스도 ✘가 됩니다 (1.0.0 · 1.1.0의 프린터 답에는 "포털"이 없었음). 그런데 **모든 질문에** `not_contains "포털"`을 걸면 1.0.0 · 1.1.0도 FAIL — 비밀번호 답이 "**사내 포털** 로그인 화면의 '비밀번호 찾기'"라고 정상으로 씁니다 (`runs/74-lab02-challenge-golden-*.json`의 1.0.0 · 1.1.0 응답). 금지 낱말이 넓으면 지나치게 막는다는 73일차 이야기와 같습니다. 어느 쪽이든 1.2.0은 FAIL.
- 2.0.0 판단 예: "강한 golden PASS, p95 1.3초 → 카나리 10%로 먼저, 비용(75일차) 본 뒤 배포."
## 과제 2 · 요청문 예
```text
my-work/helpdesk.json 의 Get prompt 에 options.response.response 로 neverError true, fullResponse true 를 더해 줘 (timeout 2000 · onError 는 그대로).
성공 쪽 Registry prompt 를 Code 로 바꿔: statusCode 200 이면 {state: 'registry', text: body.text}, 404 이면 {state: 'broken'}, 그 밖이면 {state: 'fallback', text: 기본 지시문}.
Prompt broken?(If, state == 'broken') true → Reject broken(HTTP 400 , {error: 'release broken', release, prompt_version}), false → Search FAQ.
prompt_source 는 Registry prompt 의 state 가 'registry' 면 registry, 아니면 fallback. 실패 쪽(시간 초과) Fallback prompt 는 그대로 둔다 .
기대 결과 cases/lab02-challenge2.json, cases/lab02.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `fullResponse` | 응답 코드 · 본문을 같이 받음 (`$json.statusCode`) |
| ② | `404` | `/prompts/helpdesk/p9` → 404 `prompt not found` |
| ③ | `400` | `cases/lab02-challenge2.json` 첫 케이스 `status` |
| ④ | 그대로 둔다 | 장애(시간 초과)는 지금처럼 견딤 |
정답: [answers/74-lab02-challenge2-broken.json](answers/74-lab02-challenge2-broken.json) (예제 1 정답 + `Get prompt` 응답 옵션, `Registry prompt`를 Code로, 노드 2개: `Prompt broken?` · `Reject broken`)
| 10/27 결과 | 고치기 전 (예제 1 정답, `answers/19`) | 고친 뒤 (`answers/20`) |
| --- | --- | --- |
| 망가진 릴리스 2.1.0 | ✘ **200** (기본 지시문 p1으로 답) | ✔ 400 `release broken` · `p9` |
| 평소 · 1.1.0 | ✔ | ✔ `registry` · `[FAQ-003]` |
| 저장소 고장 · 1.1.0 | ✔ `fallback` | ✔ `fallback` |
| 저장소 고장 · 9.9.9 | ✔ 400 | ✔ 400 |
| 운영 1.0.0 | ✔ | ✔ |
| 합계 | 4/5 | **5/5** |
| `gate my-golden --set release=2.1.0` | **9/9 PASS** (p95 1.5초) | **0/9 FAIL** |
| `lab02` · `lab01-challenge2` (`answers/21`) | | 5/5 · 4/4 |
- 풀이 핵심: **장애를 견디는 장치(Lab 01 과제 2)가 설정 오류를 숨겼고, 그래서 게이트도 PASS를 줬다.** 4xx는 크게 실패(fail fast), 5xx · 시간 초과만 견딘다 — 72일차 재시도 원칙과 같은 구분.
+120
View File
@@ -0,0 +1,120 @@
# Lab 03 · 배포 · 되돌리기, 카나리, 릴리스 점검 워크플로우
> **강사용 정답본** · 학생 저장소에 올리지 않습니다. 학생용 문제는 [LAB-03-Q-DEPLOY-AND-ROLLBACK.md](LAB-03-Q-DEPLOY-AND-ROLLBACK.md).
> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 정답 워크플로우를 학생 순서대로 2026-10-27에 1회씩 실행한 값입니다 (강사 Mac, `gpt-4.1-mini` · `gpt-5.4-nano`). 명령 · 출력 전부: [answers/LAB-TEST-RESULT.md](answers/LAB-TEST-RESULT.md).
## 예제 1 · 결과 (에이전트 없이)
| 재기 (`answers/22`) | 결과 |
| --- | --- |
| `release helpdesk 1.1.0` → `call` | `version.release` 1.1.0 · `stable`, 답 끝 `[FAQ-003]` |
| `release helpdesk 1.0.0` → `call` | 1.0.0, 출처 번호 없음 |
| `cases/lab03.json` | **2/2** |
- push · n8n 재시작 없음. `RELEASE.md` 3절 예: "1.1.0 배포 → 1.0.0 되돌림, 명령 한 줄, 다음 요청부터, `call -v`의 `version.release`로 확인".
## 예제 2 · 요청문 예
```text
my-work/helpdesk.json 의 Pick release 에 카나리를 더해 줘.
bucket = user 이름 글자의 codePointAt 합 % 100 . 본문 release 가 없고 canary 가 있고 bucket < canary.percent 이면
canary.release 를 쓰고 channel ' canary '. 순서는 고정 > 카나리 > active ( 고정(본문 release) 가 맨 먼저).
기대 결과 cases/lab03-canary.json, cases/lab02.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `100` | AGENTS.md 3절 카나리 대상 규칙 (`% 100`) |
| ② | `<` | 작으면 카나리 (같으면 들어가지 않음) |
| ③ | `canary` | `channel` 세 값 중 하나 |
| ④ | 고정 (본문 `release`) | 테스트 · 점검이 카나리에 섞이지 않게 |
정답: [reference/lab03-example2-canary.json](reference/lab03-example2-canary.json) (Lab 01 · 02 과제 포함)
| 10/27 결과 | 고치기 전 (Lab 02 과제 2 정답, `answers/23`) | 고친 뒤 (`answers/24`) |
| --- | --- | --- |
| `cases/lab03-canary.json` | 3/6 (카나리 대상 둘 · "같은 사람" 이 `stable`) | **6/6** |
| `lab02` · `lab02-challenge2` | | 5/5 · 5/5 |
## 예제 3 · 결과 (에이전트 없이)
| 재기 | 결과 |
| --- | --- |
| `lab03-watch` (1.2.0 50% 카나리, `answers/25`) | **4/6** — 최유진 · 정우성 ✘ ("대기열" 없는 한 문장 답), 운영 넷 ✔ |
| `--no-canary` 뒤 `--no-before` (`answers/26`) | **6/6** |
- `RELEASE.md` 3절 예: "1.2.0 카나리 50% → 영향 2/6명, `--no-canary` 한 줄, 끈 뒤 6/6. 전원 배포였다면 6/6명이 영향."
- 풀이 때 "1.2.0 릴리스는 왜 지우지 않나?" → 기록 · 재현 (불변 버전), 원인 분석.
## 과제 1 · 요청문 예
```text
my-work/release-check.json 을 새로 만들어 줘. 웹훅 my-release-check , 본문 release 가 없으면 400.
Golden questions(Code): cases/my-golden.json 의 질문 6개(question · user · expect 낱말)를 항목 6개로, 각 항목에 release.
Ask helpdesk(HTTP POST http://localhost:5678/webhook/my-helpdesk): 항목마다 {question, user, release } 로 (neverError, timeout 30000).
Judge(Code): 답에 expect 낱말 중 하나가 있고 답의 version.release 가 점검한 release 와 같으면 이면 통과.
결과 {release, passed, total, failed, seconds, verdict}: 모두 통과면 ' promote ', 아니면 'rollback'.
기대 결과 cases/lab03-challenge.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `my-release-check` | `cases/lab03-challenge.json`의 `webhook` |
| ② | `release` | 운영이 아니라 후보를 점검하려면 고정 |
| ③ | 같으면 | 고정이 먹었는지 확인 |
| ④ | `promote` | "내보내도 됨" |
정답: [answers/74-lab03-challenge-release-check.json](answers/74-lab03-challenge-release-check.json)
| 10/27 결과 (`answers/27`) | |
| --- | --- |
| 1.1.0 | `promote` 6/6 (3.1초) |
| 1.2.0 | `rollback` 0/6 (1.6초) |
| `release` 없음 | 400 |
| `cases/lab03-challenge.json` | **3/3** |
| `call my-release-check '{"release": "2.0.0"}'` | `promote` 6/6 (1.8초) |
## 과제 2 · 요청문 예
```text
my-work/release-check.json 의 Judge 와 Send verdict 사이에 Record gate(HTTP POST http://data-api:8800/ gates/helpdesk ) 를 넣어 줘.
본문: {release, verdict, passed, total, failed, seconds, source: 'my-release-check'} (Judge 의 결과에서).
Record gate 는 timeout 3000, neverError, onError continueRegularOutput (기록이 실패해도 판단은 보냄).
Send verdict 는 Judge 의 결과에 recorded: (기록 응답의 stored 가 true 인지) 를 더해 보낸다.
워크플로우에서 /releases 에는 POST 하지 않는다 . 기대 결과 cases/lab03-challenge2.json, cases/lab03-challenge.json.
```
**빈칸 답** (Q 문서 ▶ 빈칸 채우기 `____①`~`____④`)
| 빈칸 | 답 | 왜 |
| --- | --- | --- |
| ① | `gates/helpdesk` | AGENTS.md 2절 점검 기록 경로 |
| ② | `continueRegularOutput` | 실패해도 원래 출력으로 계속 (73일차 `Log event`와 같음) |
| ③ | `stored` | `POST /gates/helpdesk`의 응답 `{"stored": true, "count"}` |
| ④ | POST 하지 않는다 | 판단하는 쪽은 실행하지 않음 (AGENTS.md 9절, 최소 권한) |
정답: [answers/74-lab03-challenge2-gated.json](answers/74-lab03-challenge2-gated.json) (과제 1 정답 + 노드 `Record gate`, `Send verdict`에 `recorded`)
| 10/27 결과 (`cases/lab03-challenge2.json`) | 고치기 전 (과제 1 정답, `answers/28`) | 고친 뒤 (`answers/29`) |
| --- | --- | --- |
| 1.2.0 점검 · 기록 | ✘ `recorded` 없음 (24.2초, 이때 OpenAI가 느림) | ✔ `rollback` · `recorded` true (10.6초) |
| 1.1.0 점검 · 기록 | ✘ `recorded` 없음 | ✔ `promote` · `recorded` true |
| 1.2.0 배포 거부 (409 기대) | ✔ (기록이 **아예 없어서** 409) | ✔ (마지막 기록 rollback이라 409) |
| 1.1.0 배포 | **멈춤** — `before`가 409 `not gated` · `last_gate: null` | ✔ 200 → 1.1.0 · `stable` |
| 2.0.0 카나리 거부 (409 기대) | (돌지 않음) | ✔ 409, 최유진도 1.1.0 · `stable` |
| 합계 | 멈춤 (보고서 없음) | **5/5** |
`answers/30` (정책 켜고 손으로):
| 명령 | 결과 |
| --- | --- |
| `release helpdesk --require-gate on` → `release helpdesk 2.1.0` | `✘ refused: 2.1.0 has no passing gate record (never gated)` |
| `gate cases/golden.json --set release=2.0.0 --max-p95 3` | 6/6 PASS → `gate record → /gates/helpdesk: 2.0.0 promote` |
| `release helpdesk 2.0.0` | 됨 (`active 2.0.0 · deploy needs a passing gate`) |
| `release helpdesk --reset` · `doctor` | 1.0.0 · 정책 꺼짐 · `ready` |
- 풀이 때: 약한 golden으로 2.0.0을 게이트해도 기록이 남아 배포가 됩니다. **정책은 "기록이 있느냐"만 보고, 기록의 질은 게이트(테스트)의 질입니다.** 운영에서는 어느 테스트 파일로 · 누가 돌린 기록만 인정할지도 정합니다 (`source` · `cases` 칸).
@@ -161,6 +161,24 @@ flowchart LR
K --> N(["--no-canary"]) --> R["다시 6명 ✔"] K --> N(["--no-canary"]) --> R["다시 6명 ✔"]
``` ```
**이 예제에서는 아무것도 만들지 않습니다.** 예제 2까지 만든 헬프데스크를 그대로 두고, 누가 잘못된 카나리를 켰을 때 **알아채고 끄는 연습**만 합니다.
**상황.** 운영 릴리스(active)는 1.0.0입니다. 누군가 1.2.0을 50% 카나리로 내보냈습니다. 1.2.0은 지시문 p3("답을 한 문장으로 짧게", [AGENTS.md](AGENTS.md) 2절)를 씁니다. 답이 짧아지면서 해결 방법(프린터 **대기열** 비우기)이 빠집니다. HTTP는 200이라 오류로 보이지 않습니다. **답의 내용을 봐야 알 수 있는 나쁜 릴리스**입니다.
**명령마다 하는 일.**
| 순서 | 명령 | 하는 일 |
| --- | --- | --- |
| 1 | `test cases/lab03-watch.json -v` | 테스트 파일 맨 위 `before`가 `reset` 뒤 **카나리 1.2.0 50%를 켭니다** (사고 상황 만들기). 이어서 여섯 직원이 같은 질문 "프린터 출력이 안 돼요"를 보내고, 답에 `대기열`이 있는지 봅니다 |
| 2 | `release helpdesk` | 지금 상태(active · canary)와 카나리 대상 직원을 봅니다. 1번에서 ✘가 난 직원과 맞는지 확인합니다 |
| 3 | `release helpdesk --no-canary` | 카나리를 끕니다. 명령 한 줄 |
| 4 | `test cases/lab03-watch.json --no-before -v` | 같은 테스트를 다시 돌립니다. **`--no-before`가 없으면 `before`가 카나리를 다시 켜서** 끈 효과를 잴 수 없습니다 |
| 5 | `release helpdesk --reset` | 다음 과제를 위해 처음 상태로 |
**누가 ✘가 나나.** 예제 2의 규칙(bucket < 50이면 카나리)대로 최유진(0) · 정우성(42)만 1.2.0을 받습니다. 1번 출력에서 두 사람의 `version.channel`은 `canary`, `version.release`는 `1.2.0`이고, 나머지 넷은 `stable` · `1.0.0`입니다. 같은 질문을 했는데 **카나리 쪽만** 해결 낱말이 없습니다. 이렇게 운영 쪽과 나란히 비교해 차이를 찾는 것이 "카나리를 본다"의 뜻입니다.
**끈 뒤.** 카나리가 없으면 여섯 명 모두 active 1.0.0을 받습니다. 4번 출력에서 모두의 `version.channel`이 `stable`인지 봅니다.
**피해 반경(blast radius).** 전원에게 바로 배포했다면 여섯 명 모두 "포털에서 확인하세요"를 받았을 것입니다. 50% 카나리라서 둘만 받았고, 끄는 데는 명령 한 줄입니다. 카나리 비율은 처음에 작게(1~5%) 시작해 늘리는 것이 보통입니다. 오늘은 여섯 명이라 50%로 둘만 걸리게 했습니다. **피해 반경(blast radius).** 전원에게 바로 배포했다면 여섯 명 모두 "포털에서 확인하세요"를 받았을 것입니다. 50% 카나리라서 둘만 받았고, 끄는 데는 명령 한 줄입니다. 카나리 비율은 처음에 작게(1~5%) 시작해 늘리는 것이 보통입니다. 오늘은 여섯 명이라 50%로 둘만 걸리게 했습니다.
**무엇을 보나.** **무엇을 보나.**
+123
View File
@@ -0,0 +1,123 @@
# 7교시 바이브 코딩 · `tver` 정답본
> **강사용 정답본** · 학생 저장소에는 수업 뒤에 올립니다. 학생용은 [VIBE-REQ-TVER.md](VIBE-REQ-TVER.md)(요청서) → [VIBE-Q-TVER.md](VIBE-Q-TVER.md).
> 강사가 직접 만든 본: [tver/](tver/README.md) (2026-10-27, Claude Code가 아래 SPEC · IMP-PLAN을 보고 직접 씀, 테스트 11개). CLI 에이전트에 아래 요청문을 보내 재지는 않았습니다. 강사 바이브 코딩 시간은 6절에 수업 때 적습니다.
## 1. `Doc/TODO.md` 예
```markdown
# tver TODO
## 만들고 싶은 것
어느 폴더에서나 `tver`로 n8n 워크플로우를 git에 버전으로 남기고, 두 버전을 노드 단위로 비교해
바뀐 것을 모델 · 지시문 · 흐름으로 나눠 보여 주는 도구. 화면(TUI)도, 다른 프로그램용 결과도.
## 요청의 말 → 할 일
| # | 요청의 말 | 할 일 | 꼭 · 나중 |
| --- | --- | --- | --- |
| ① | 노드 단위 비교 | 이름으로 짝지어 더함 · 뺌 · 바뀐 설정 · 연결 | 꼭 |
| ② | 뜻 없는 차이 빼기 | 위치 · id · 순서는 비교에서도 저장에서도 뺌 | 꼭 |
| ③ | 어느 문장이 | 긴 글은 줄 단위 | 꼭 |
| ④ | 셋으로 나눠 한 줄 | 모델 · 지시문 · 흐름 개수 | 꼭 |
| ⑤ | 버전으로, 같으면 저장 X | git 커밋, 요약을 메시지로 | 꼭 |
| ⑥ | 목록 · 두 버전 비교 | log, 두 커밋 비교 | 꼭 |
| ⑦ | 키가 있으면 저장 X | 키 모양을 찾으면 거부, 메시지도 가려서 | 나중 |
| ⑧ | 화면에서 | 목록 · 트리 · 자세히 | 꼭 |
| ⑨ | n8n에서 바로 | 컨테이너에서 내보내 모두 저장 (읽기만) | 나중 |
| ⑩ | 예전 버전을 파일로 | 꺼내기만, 올리는 건 사람 | 나중 |
| ⑪ | 다른 프로그램이 읽게 | JSON, 종료 코드 | 나중 |
| ⑫ | 이름만 바뀐 것 | renamed | 나중 |
| 끝 | 폴더 → 워크플로우만 | nodes · connections가 있는 것만 | 나중 |
| 끝 | n8n을 망가뜨리지 않기 | 저장소는 따로, n8n에 쓰지 않음 | 꼭 |
## 걱정되는 것
- 저장할 때도 정리해야 "같다"를 알 수 있다
- 이름에 공백 · 한글 · `/`가 있는 워크플로우의 파일 이름
- 키를 거부할 때 메시지에 키가 그대로 나오면 안 됨
```
## 2. `Doc/SPEC.md` 요점
VIBE-Q의 요구 표(R1~R14)와 같습니다. 학생 SPEC과 비교할 때 자주 빠지는 것:
| 자주 빠지는 것 | 왜 필요한가 |
| --- | --- |
| R2를 **저장에도** 적용 (정리한 글로 저장) | 그래야 R5 "같으면 저장 안 함"이 글자 비교로 되고 저장소의 git diff도 읽힘 |
| 분류 규칙을 글로 정해 둠 | 정해 두지 않으면 에이전트가 웹훅 경로를 "모델"로 세기도 함 |
| R10 꺼내기가 저장소를 바꾸지 않음 | 꺼낼 때마다 버전이 생기면 목록이 엉킴 |
| 테스트 저장소는 `tmp_path` | 진짜 `~/.tver/store`가 테스트로 더러워짐 |
| 종료 코드 (diff 0 · 1, 오류 2) | 배포 전 검사(게이트)에서 씀 |
## 3. `Doc/IMP-PLAN.md`
[tver/Doc/IMP-PLAN.md](tver/Doc/IMP-PLAN.md) — 엔진 1단계(R1~R4) → 엔진 2단계(R5 · R6) → TUI 1단계(R8) → 설치(R14) → 엔진 3단계(R7 · R11 · R13) → 엔진 4단계(R9 · R10 · R12).
## 4. 단계별 요청문 예
### 엔진 1단계
```text
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽고 "엔진 1단계"만 만들어 줘. uv 프로젝트, 명령 이름 tver, 코드와 화면 글자는 영어.
- workflow.clean(): 노드에서 id, position, webhookId 와 내보내기 도장(versionId, updatedAt ...)을 빼고 노드를 name 순으로. dumps() 는 키 정렬 JSON.
- diff.compare(old, new): 노드를 name 으로 짝지어 +node, -node, ~set(점 경로 key, before, after), +link, -link.
80자 넘거나 여러 줄인 값은 difflib 줄 diff 를 lines 에.
- category: Chat model 노드의 parameters.model.value 는 model, systemMessage · text · jsCode 는 prompt, 나머지와 연결은 flow.
summary 한 줄 "N change(s) · model N · prompt N · flow N".
- tver diff a.json b.json. tests: tests/data 의 helpdesk-v1 → v2 가 7개 · model 1 · prompt 1 · flow 5,
위치 · id 를 바꾸고 노드 순서를 뒤집은 파일은 0개, systemMessage 의 lines 에 "[FAQ-003]" 이 있는 + 줄.
끝나면 IMP-PLAN 체크를 표시하고 확인 명령을 알려 줘.
```
### 엔진 2단계
```text
엔진 2단계만: Store(repo, 기본 ~/.tver/store, --repo, $TVER_REPO).
- save(파일): dumps 한 글이 저장소의 같은 이름 파일과 같으면 "no change", 다르면 쓰고 git commit. 메시지 "<이름>: " + 바로 전 버전과의 summary 한 줄 (처음이면 new).
- log(이름): 커밋 · 시각 · 메시지. show(rev, 이름): git show rev:이름. tver diff <rev1> <rev2> <이름>.
- tests 는 tmp_path 저장소만: 두 번 저장 → 1개, v2 로 바꿔 저장 → 메시지에 "model 1 · prompt 1 · flow 5".
```
### TUI 1단계
```text
TUI 1단계만: tver <파일> 이면 Textual 화면. 왼쪽 ListView 버전 목록, 오른쪽 Tree(노드 → 바뀐 것, 고른 버전 vs 바로 전 버전, worker 로 비교),
트리의 잎을 고르면 아래 Static 에 줄 diff 또는 전 · 후. [s] 지금 파일을 버전으로 저장하고 목록을 다시, [q] 끝.
엔진(diff · store)은 고치지 말고 불러 쓰기만. pytest 로 [s] 뒤 목록이 하나 늘고 트리 제목에 요약이 나오는지.
```
## 5. 결과 확인 (강사 본, 2026-10-27)
| 요구 | 확인 | 결과 |
| --- | --- | --- |
| R1 · R3 · R4 | `tver diff reference/helpdesk-v1.json reference/helpdesk-v2.json` | 7줄 (`flow.py diff`와 같음), systemMessage 줄 diff, `model 1 · prompt 1 · flow 5` |
| R2 | 위치 · id · 순서만 바꾼 파일 (pytest) | 0개 |
| R5 · R6 | 같은 파일 두 번 save → `log` | 1개, 바꿔 저장 → 2개 |
| R7 | 가짜 키(테스트 안에서 만듦)를 넣고 save | 거부, 메시지에 원래 값 없음 |
| R8 | pytest `run_test` | `[s]` 뒤 목록 2개, 트리 제목 요약 |
| R9 | `tver pull` (강사 n8n) | 192 new version(s) |
| R10 | `restore` → 원본과 `diff` | 0 change(s), 버전 수 그대로 |
| R11 | `diff a a` · `a b` · 없는 파일 | 0 · 1 · 2 |
| R12 | `Search FAQ` → `Find FAQ` (pytest) | `renamed` 1개 |
| R13 | `tver save answers` | 5개 저장, golden 테스트 파일 skipped |
| R14 | `uv tool install --editable .`, `/tmp`에서 `tver --help` | 됨 |
오늘 Lab의 helpdesk 정답 여섯 단계를 차례로 저장했을 때의 `log` (위가 최신):
```text
87090f0 helpdesk.json: 1 change(s) · model 0 · prompt 1 · flow 0 ← Lab 03 예제 2 (카나리: Pick release의 jsCode)
9e2ac9a helpdesk.json: 14 change(s) · model 0 · prompt 2 · flow 12 ← Lab 02 과제 2 (응답 코드 · 400)
e0dc13e helpdesk.json: 15 change(s) · model 1 · prompt 1 · flow 13 ← Lab 02 예제 1 (릴리스를 밖에서)
b8461aa helpdesk.json: 12 change(s) · model 0 · prompt 2 · flow 10 ← Lab 01 과제 2 (기본 지시문)
76c5e18 helpdesk.json: 6 change(s) · model 0 · prompt 2 · flow 4 ← Lab 01 과제 1 (지시문 저장소)
7c337b5 helpdesk.json: new ← Lab 01 예제 2
```
- 풀이 거리: 카나리(87090f0)는 `Pick release`의 **Code** 한 칸이라 "prompt 1"로 셉니다. 규칙상 `jsCode`를 지시문으로 셌기 때문입니다. 코드와 지시문을 나눠 세고 싶으면 `code` 분류를 하나 더 두면 됩니다 — 분류 규칙은 **팀이 정하는 것**이라는 예.
## 6. 강사 바이브 코딩 (8교시, 수업 때 적음)
| 주제 | 요청문 요지 | 걸린 시간 · 결과 |
| --- | --- | --- |
| 게이트 붙이기 | "tver save 전에 지정한 테스트 파일로 flow.py gate 를 돌려 FAIL 이면 저장 안 함" | |
| 지시문만 따로 | "systemMessage 를 꺼내 prompts/<노드>.md 로 저장, 지시문 diff 만 보기" | |
| 코드 분류 | "jsCode 는 prompt 가 아니라 code 로 따로 세기" | |
+26
View File
@@ -0,0 +1,26 @@
$ uv run tools/flow.py release helpdesk --reset
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 6/6
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate promote 6/6
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate rollback 0/6
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)
$ uv run tools/flow.py push reference/helpdesk-v1.json reference/helpdesk-v2.json
✔ pushed '74 IT helpdesk v1 (provided)' (id w0ed73a832099852)
POST http://localhost:5678/webhook/rel-v1
✔ pushed '74 IT helpdesk v2 (provided)' (id w8e65b72e125f790)
POST http://localhost:5678/webhook/rel-v2
$ 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
✔ releases /releases/helpdesk · gates /gates/helpdesk (day 74 data API)
✔ n8n → http://data-api:8800
✔ credential 'OpenAI (.env)' (openAiCourse0001)
✔ node catalog (for check)
✔ CLI coding agent: codex, claude, agy
ready
+24
View File
@@ -0,0 +1,24 @@
$ uv run tools/flow.py diff reference/helpdesk-v1.json reference/helpdesk-v2.json
74 IT helpdesk v1 (provided) → 74 IT helpdesk v2 (provided)
+ node 'Review check' (code)
~ 'Answer' parameters.options.systemMessage: changed (307 → 419 chars)
~ 'Answer model' parameters.model.value: gpt-4.1-mini → gpt-5.4-nano
~ 'Question webhook' parameters.path: rel-v1 → rel-v2
+ link Answer → Review check (main)
+ link Review check → Build result (main)
- link Answer → Build result (main)
7 change(s)
$ uv run tools/flow.py diff reference/helpdesk-v1.json reference/helpdesk-v2.json --full
74 IT helpdesk v1 (provided) → 74 IT helpdesk v2 (provided)
+ node 'Review check' (code)
~ 'Answer' parameters.options.systemMessage: changed (307 → 419 chars)
before: You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
after: You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
When the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003]. Keep answers short.
~ 'Answer model' parameters.model.value: gpt-4.1-mini → gpt-5.4-nano
~ 'Question webhook' parameters.path: rel-v1 → rel-v2
+ link Answer → Review check (main)
+ link Review check → Build result (main)
- link Answer → Build result (main)
7 change(s)
@@ -0,0 +1,31 @@
$ uv run tools/flow.py call rel-v1 requests/faq-vpn.json -v
HTTP 200 · 2.8s
→ request POST /webhook/rel-v1
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
]
}
$ uv run tools/flow.py call rel-v2 requests/faq-vpn.json -v
HTTP 200 · 1.0s
→ request POST /webhook/rel-v2
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
]
}
+72
View File
@@ -0,0 +1,72 @@
{
"cases": "lab01.json",
"webhook": "my-helpdesk",
"passed": 3,
"total": 3,
"results": [
{
"case": "버전이 결과에",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 2.5,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
},
{
"case": "접수 현황도 버전과 함께",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
},
{
"case": "빈 질문",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 400,
"request": {
"question": "",
"user": "이준호"
},
"response": {
"error": "question is required"
}
}
]
}
+55
View File
@@ -0,0 +1,55 @@
$ uv run tools/flow.py push reference/lab01-example2-version.json
! unpublished '74 IT helpdesk + release API (reference)' (w2caac7c1a6981b8): it used the same webhook path
✔ pushed '74 IT helpdesk + version (reference)' (id w473a152b356a298)
POST http://localhost:5678/webhook/my-helpdesk
$ uv run tools/flow.py test cases/lab01.json -v
POST http://localhost:5678/webhook/my-helpdesk · 3 case(s) × 1
✔ 버전이 결과에 · 2.5s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
✔ 접수 현황도 버전과 함께 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
✔ 빈 질문 · 0.0s
→ request POST /webhook/my-helpdesk
{
"question": "",
"user": "이준호"
}
← response HTTP 400
{
"error": "question is required"
}
Passed 3/3
report: runs/lab01-20261027-183702.json
@@ -0,0 +1,314 @@
{
"cases": "lab01-compare.json",
"webhook": "rel-v1",
"passed": 9,
"total": 15,
"results": [
{
"case": "VPN · 해결 낱말",
"webhook": "rel-v1",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 해결 낱말",
"webhook": "rel-v1",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없으며, 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 해결 낱말",
"webhook": "rel-v1",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주십시오.",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 출처 번호",
"webhook": "rel-v1",
"run": 1,
"passed": false,
"problems": [
"answer has none of ['[FAQ-003]']"
],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 출처 번호",
"webhook": "rel-v1",
"run": 2,
"passed": false,
"problems": [
"answer has none of ['[FAQ-003]']"
],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 출처 번호",
"webhook": "rel-v1",
"run": 3,
"passed": false,
"problems": [
"answer has none of ['[FAQ-003]']"
],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 내역은 없으며, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
]
}
},
{
"case": "프린터 · 해결 낱말",
"webhook": "rel-v1",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 해결 낱말",
"webhook": "rel-v1",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 해결 낱말",
"webhook": "rel-v1",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 출처 번호",
"webhook": "rel-v1",
"run": 1,
"passed": false,
"problems": [
"answer has none of ['[FAQ-']"
],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 출처 번호",
"webhook": "rel-v1",
"run": 2,
"passed": false,
"problems": [
"answer has none of ['[FAQ-']"
],
"seconds": 1.2,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 출처 번호",
"webhook": "rel-v1",
"run": 3,
"passed": false,
"problems": [
"answer has none of ['[FAQ-']"
],
"seconds": 1.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "접수 현황",
"webhook": "rel-v1",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": []
}
},
{
"case": "접수 현황",
"webhook": "rel-v1",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": []
}
},
{
"case": "접수 현황",
"webhook": "rel-v1",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": []
}
}
]
}
@@ -0,0 +1,19 @@
$ uv run tools/flow.py test cases/lab01-compare.json --webhook rel-v1 --repeat 3
POST http://localhost:5678/webhook/rel-v1 · 5 case(s) × 3
✔ VPN · 해결 낱말 #1 · 1.2s
✔ VPN · 해결 낱말 #2 · 1.2s
✔ VPN · 해결 낱말 #3 · 1.1s
✘ VPN · 출처 번호 #1 · 1.1s · answer has none of ['[FAQ-003]']
✘ VPN · 출처 번호 #2 · 1.2s · answer has none of ['[FAQ-003]']
✘ VPN · 출처 번호 #3 · 1.3s · answer has none of ['[FAQ-003]']
✔ 프린터 · 해결 낱말 #1 · 1.0s
✔ 프린터 · 해결 낱말 #2 · 1.1s
✔ 프린터 · 해결 낱말 #3 · 1.1s
✘ 프린터 · 출처 번호 #1 · 1.1s · answer has none of ['[FAQ-']
✘ 프린터 · 출처 번호 #2 · 1.2s · answer has none of ['[FAQ-']
✘ 프린터 · 출처 번호 #3 · 1.0s · answer has none of ['[FAQ-']
✔ 접수 현황 #1 · 0.9s
✔ 접수 현황 #2 · 1.0s
✔ 접수 현황 #3 · 1.1s
Passed 9/15
report: runs/lab01-compare-20261027-183719.json
@@ -0,0 +1,302 @@
{
"cases": "lab01-compare.json",
"webhook": "rel-v2",
"passed": 15,
"total": 15,
"results": [
{
"case": "VPN · 해결 낱말",
"webhook": "rel-v2",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 해결 낱말",
"webhook": "rel-v2",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 해결 낱말",
"webhook": "rel-v2",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 출처 번호",
"webhook": "rel-v2",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 출처 번호",
"webhook": "rel-v2",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
]
}
},
{
"case": "VPN · 출처 번호",
"webhook": "rel-v2",
"run": 3,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
]
}
},
{
"case": "프린터 · 해결 낱말",
"webhook": "rel-v2",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 해결 낱말",
"webhook": "rel-v2",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 해결 낱말",
"webhook": "rel-v2",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 출처 번호",
"webhook": "rel-v2",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인한 뒤, 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 출처 번호",
"webhook": "rel-v2",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인한 뒤, 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "프린터 · 출처 번호",
"webhook": "rel-v2",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
]
}
},
{
"case": "접수 현황",
"webhook": "rel-v2",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002의 현재 상태는 “처리 중”입니다. 메모에 따르면 인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.",
"sources": []
}
},
{
"case": "접수 현황",
"webhook": "rel-v2",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002의 현재 상태는 “처리 중”입니다. 메모에 따르면 인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.",
"sources": []
}
},
{
"case": "접수 현황",
"webhook": "rel-v2",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002의 현재 상태는 “처리 중”입니다. 인증서 발급 승인 대기 중이며 “10월 20일 완료 예정”이라고 기록되어 있습니다.",
"sources": []
}
}
]
}
@@ -0,0 +1,19 @@
$ uv run tools/flow.py test cases/lab01-compare.json --webhook rel-v2 --repeat 3
POST http://localhost:5678/webhook/rel-v2 · 5 case(s) × 3
✔ VPN · 해결 낱말 #1 · 1.0s
✔ VPN · 해결 낱말 #2 · 1.0s
✔ VPN · 해결 낱말 #3 · 1.0s
✔ VPN · 출처 번호 #1 · 0.9s
✔ VPN · 출처 번호 #2 · 1.1s
✔ VPN · 출처 번호 #3 · 0.9s
✔ 프린터 · 해결 낱말 #1 · 1.4s
✔ 프린터 · 해결 낱말 #2 · 1.1s
✔ 프린터 · 해결 낱말 #3 · 1.2s
✔ 프린터 · 출처 번호 #1 · 1.4s
✔ 프린터 · 출처 번호 #2 · 1.1s
✔ 프린터 · 출처 번호 #3 · 1.2s
✔ 접수 현황 #1 · 1.1s
✔ 접수 현황 #2 · 1.0s
✔ 접수 현황 #3 · 1.0s
Passed 15/15
report: runs/lab01-compare-20261027-183736.json
@@ -0,0 +1,19 @@
$ uv run tools/flow.py bench rel-v1 requests/faq-vpn.json -n 5
POST http://localhost:5678/webhook/rel-v1 × 5
✔ #1 · HTTP 200 · 1.26s · route=자동 답변
✔ #2 · HTTP 200 · 1.11s · route=자동 답변
✔ #3 · HTTP 200 · 1.23s · route=자동 답변
✔ #4 · HTTP 200 · 1.34s · route=자동 답변
✔ #5 · HTTP 200 · 1.18s · route=자동 답변
p50 1.23s · p95 1.34s · max 1.34s · failures 0/5
report: runs/bench-rel-v1-20261027-183742.json
$ uv run tools/flow.py bench rel-v2 requests/faq-vpn.json -n 5
POST http://localhost:5678/webhook/rel-v2 × 5
✔ #1 · HTTP 200 · 1.20s · route=자동 답변
✔ #2 · HTTP 200 · 0.97s · route=자동 답변
✔ #3 · HTTP 200 · 0.98s · route=자동 답변
✔ #4 · HTTP 200 · 1.02s · route=자동 답변
✔ #5 · HTTP 200 · 0.98s · route=자동 답변
p50 0.98s · p95 1.20s · max 1.20s · failures 0/5
report: runs/bench-rel-v2-20261027-183747.json
@@ -0,0 +1,83 @@
{
"cases": "lab01-challenge.json",
"webhook": "my-helpdesk",
"passed": 2,
"total": 3,
"results": [
{
"case": "지시문은 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"version.prompt_source = null (expected registry)"
],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
},
{
"case": "p1은 출처 번호를 붙이지 않음",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
},
{
"case": "접수 현황",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
}
]
}
@@ -0,0 +1,59 @@
$ uv run tools/flow.py test cases/lab01-challenge.json -v
POST http://localhost:5678/webhook/my-helpdesk · 3 case(s) × 1
✘ 지시문은 저장소에서 · 1.2s · version.prompt_source = null (expected registry)
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
✔ p1은 출처 번호를 붙이지 않음 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
✔ 접수 현황 · 1.2s
→ request POST /webhook/my-helpdesk
{
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini"
}
}
Passed 2/3
report: runs/lab01-challenge-20261027-183751.json
@@ -0,0 +1,84 @@
{
"cases": "lab01-challenge.json",
"webhook": "my-helpdesk",
"passed": 3,
"total": 3,
"results": [
{
"case": "지시문은 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 2.7,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "p1은 출처 번호를 붙이지 않음",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "접수 현황",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,67 @@
$ uv run tools/flow.py push answers/74-lab01-challenge-registry.json
! unpublished '74 IT helpdesk + version (reference)' (w473a152b356a298): it used the same webhook path
✔ pushed '74 IT helpdesk + prompt registry (answer)' (id w10c91b5c7439d37)
POST http://localhost:5678/webhook/my-helpdesk
$ uv run tools/flow.py test cases/lab01-challenge.json -v
POST http://localhost:5678/webhook/my-helpdesk · 3 case(s) × 1
✔ 지시문은 저장소에서 · 2.7s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ p1은 출처 번호를 붙이지 않음 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ 접수 현황 · 1.4s
→ request POST /webhook/my-helpdesk
{
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
Passed 3/3
report: runs/lab01-challenge-20261027-183810.json
@@ -0,0 +1,74 @@
{
"cases": "lab01.json",
"webhook": "my-helpdesk",
"passed": 3,
"total": 3,
"results": [
{
"case": "버전이 결과에",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "접수 현황도 버전과 함께",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.9,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "빈 질문",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 400,
"request": {
"question": "",
"user": "이준호"
},
"response": {
"error": "question is required"
}
}
]
}
@@ -0,0 +1,52 @@
$ uv run tools/flow.py test cases/lab01.json -v
POST http://localhost:5678/webhook/my-helpdesk · 3 case(s) × 1
✔ 버전이 결과에 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ 접수 현황도 버전과 함께 · 0.9s
→ request POST /webhook/my-helpdesk
{
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ 빈 질문 · 0.0s
→ request POST /webhook/my-helpdesk
{
"question": "",
"user": "이준호"
}
← response HTTP 400
{
"error": "question is required"
}
Passed 3/3
report: runs/lab01-20261027-183812.json
@@ -0,0 +1,110 @@
{
"cases": "lab01-challenge2.json",
"webhook": "my-helpdesk",
"passed": 2,
"total": 4,
"results": [
{
"case": "평소 · 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "저장소 고장 · 기본 지시문으로 답",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 500 (expected 200)",
"route = null (expected 자동 답변)",
"version.prompt_source = null (expected fallback)",
"answer has none of ['인증서']"
],
"seconds": 0.1,
"status": 500,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"message": "Error in workflow"
}
},
{
"case": "저장소 느림 · 오래 기다리지 않음",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"took 9.1s (expected at most 6s)",
"version.prompt_source = registry (expected fallback)"
],
"seconds": 9.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "되돌린 뒤 · 다시 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,137 @@
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
POST http://localhost:5678/webhook/my-helpdesk · 4 case(s) × 1
✔ 평소 · 저장소에서 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "error"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "error",
"slow_seconds": 8.0
}
✘ 저장소 고장 · 기본 지시문으로 답 · 0.1s · status 500 (expected 200); route = null (expected 자동 답변); version.prompt_source = null (expected fallback); answer has none of ['인증서']
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 500
{
"message": "Error in workflow"
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "slow", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "slow",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "slow",
"slow_seconds": 8.0
}
✘ 저장소 느림 · 오래 기다리지 않음 · 9.1s · took 9.1s (expected at most 6s); version.prompt_source = registry (expected fallback)
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ 되돌린 뒤 · 다시 저장소에서 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
Passed 2/4
report: runs/lab01-challenge2-20261027-183824.json
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
@@ -0,0 +1,112 @@
{
"cases": "lab01-challenge2.json",
"webhook": "my-helpdesk",
"passed": 4,
"total": 4,
"results": [
{
"case": "평소 · 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 2.6,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "저장소 고장 · 기본 지시문으로 답",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "fallback"
}
}
},
{
"case": "저장소 느림 · 오래 기다리지 않음",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 3.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "fallback"
}
}
},
{
"case": "되돌린 뒤 · 다시 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
}
]
}
+152
View File
@@ -0,0 +1,152 @@
$ uv run tools/flow.py push answers/74-lab01-challenge2-fallback.json
! unpublished '74 IT helpdesk + prompt registry (answer)' (w10c91b5c7439d37): it used the same webhook path
✔ pushed '74 IT helpdesk + prompt fallback (answer)' (id w5215c7f75644ed0)
POST http://localhost:5678/webhook/my-helpdesk
$ uv run tools/flow.py test cases/lab01-challenge2.json -v
POST http://localhost:5678/webhook/my-helpdesk · 4 case(s) × 1
✔ 평소 · 저장소에서 · 2.6s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "error"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "error",
"slow_seconds": 8.0
}
✔ 저장소 고장 · 기본 지시문으로 답 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "fallback"
}
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "slow", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "slow",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "slow",
"slow_seconds": 8.0
}
✔ 저장소 느림 · 오래 기다리지 않음 · 3.0s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "fallback"
}
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ 되돌린 뒤 · 다시 저장소에서 · 1.4s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
Passed 4/4
report: runs/lab01-challenge2-20261027-183845.json
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
@@ -0,0 +1,84 @@
{
"cases": "lab01-challenge.json",
"webhook": "my-helpdesk",
"passed": 3,
"total": 3,
"results": [
{
"case": "지시문은 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "p1은 출처 번호를 붙이지 않음",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "접수 현황",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,62 @@
$ uv run tools/flow.py test cases/lab01-challenge.json -v
POST http://localhost:5678/webhook/my-helpdesk · 3 case(s) × 1
✔ 지시문은 저장소에서 · 1.2s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ p1은 출처 번호를 붙이지 않음 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ 접수 현황 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
Passed 3/3
report: runs/lab01-challenge-20261027-183849.json
+144
View File
@@ -0,0 +1,144 @@
{
"cases": "lab02.json",
"webhook": "my-helpdesk",
"passed": 1,
"total": 5,
"results": [
{
"case": "운영 중인 릴리스 (active)",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"version.channel = null (expected stable)"
],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "후보 시험 · 1.1.0 고정",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"version.release = 1.0.0 (expected 1.1.0)",
"version.channel = null (expected pinned)",
"version.prompt_version = p1 (expected p2)",
"answer has none of ['[FAQ-003]']"
],
"seconds": 1.6,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "후보 시험 · 2.0.0 (작은 모델)",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"version.release = 1.0.0 (expected 2.0.0)",
"version.model = gpt-4.1-mini (expected gpt-5.4-nano)"
],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "없는 릴리스",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 200 (expected 400)",
"known has none of ['1.0.0']"
],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "9.9.9"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
},
{
"case": "빈 질문",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 400,
"request": {
"question": "",
"user": "이준호"
},
"response": {
"error": "question is required"
}
}
]
}
+142
View File
@@ -0,0 +1,142 @@
$ uv run tools/flow.py release helpdesk
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 6/6
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate promote 6/6
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate rollback 0/6
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)
$ uv run tools/flow.py test cases/lab02.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 5 case(s) × 1
✘ 운영 중인 릴리스 (active) · 1.2s · version.channel = null (expected stable)
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✘ 후보 시험 · 1.1.0 고정 · 1.6s · version.release = 1.0.0 (expected 1.1.0); version.channel = null (expected pinned); version.prompt_version = p1 (expected p2); answer has none of ['[FAQ-003]']
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✘ 후보 시험 · 2.0.0 (작은 모델) · 1.3s · version.release = 1.0.0 (expected 2.0.0); version.model = gpt-4.1-mini (expected gpt-5.4-nano)
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✘ 없는 릴리스 · 1.2s · status 200 (expected 400); known has none of ['1.0.0']
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "9.9.9"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"prompt_source": "registry"
}
}
✔ 빈 질문 · 0.0s
→ request POST /webhook/my-helpdesk
{
"question": "",
"user": "이준호"
}
← response HTTP 400
{
"error": "question is required"
}
Passed 1/5
report: runs/lab02-20261027-183854.json
+132
View File
@@ -0,0 +1,132 @@
{
"cases": "lab02.json",
"webhook": "my-helpdesk",
"passed": 5,
"total": 5,
"results": [
{
"case": "운영 중인 릴리스 (active)",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 2.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "후보 시험 · 1.1.0 고정",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당 직원이 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "후보 시험 · 2.0.0 (작은 모델)",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. 접수 건은 찾지 못했습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "없는 릴리스",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 400,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "9.9.9"
},
"response": {
"error": "unknown release",
"release": "9.9.9",
"known": [
"1.0.0",
"1.1.0",
"1.2.0",
"2.0.0",
"2.1.0"
]
}
},
{
"case": "빈 질문",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.2,
"status": 400,
"request": {
"question": "",
"user": "이준호"
},
"response": {
"error": "question is required"
}
}
]
}
+140
View File
@@ -0,0 +1,140 @@
$ uv run tools/flow.py push reference/lab02-example1-release.json
! unpublished '74 IT helpdesk + prompt fallback (answer)' (w5215c7f75644ed0): it used the same webhook path
✔ pushed '74 IT helpdesk + release API (reference)' (id w2caac7c1a6981b8)
POST http://localhost:5678/webhook/my-helpdesk
$ uv run tools/flow.py test cases/lab02.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 5 case(s) × 1
✔ 운영 중인 릴리스 (active) · 2.3s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 후보 시험 · 1.1.0 고정 · 1.2s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당 직원이 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
✔ 후보 시험 · 2.0.0 (작은 모델) · 1.0s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. 접수 건은 찾지 못했습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
✔ 없는 릴리스 · 0.1s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "9.9.9"
}
← response HTTP 400
{
"error": "unknown release",
"release": "9.9.9",
"known": [
"1.0.0",
"1.1.0",
"1.2.0",
"2.0.0",
"2.1.0"
]
}
✔ 빈 질문 · 0.2s
→ request POST /webhook/my-helpdesk
{
"question": "",
"user": "이준호"
}
← response HTTP 400
{
"error": "question is required"
}
Passed 5/5
report: runs/lab02-20261027-183911.json
@@ -0,0 +1,116 @@
{
"cases": "lab01-challenge2.json",
"webhook": "my-helpdesk",
"passed": 4,
"total": 4,
"results": [
{
"case": "평소 · 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "저장소 고장 · 기본 지시문으로 답",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "fallback"
}
}
},
{
"case": "저장소 느림 · 오래 기다리지 않음",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 3.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "fallback"
}
}
},
{
"case": "되돌린 뒤 · 다시 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,21 @@
$ uv run tools/flow.py test cases/lab01-challenge.json
POST http://localhost:5678/webhook/my-helpdesk · 3 case(s) × 1
✔ 지시문은 저장소에서 · 1.2s
✔ p1은 출처 번호를 붙이지 않음 · 1.3s
✔ 접수 현황 · 1.2s
Passed 3/3
report: runs/lab01-challenge-20261027-183915.json
$ uv run tools/flow.py test cases/lab01-challenge2.json
POST http://localhost:5678/webhook/my-helpdesk · 4 case(s) × 1
✔ 평소 · 저장소에서 · 1.4s
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
✔ 저장소 고장 · 기본 지시문으로 답 · 1.1s
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "slow", "slow_seconds": 8.0}
✔ 저장소 느림 · 오래 기다리지 않음 · 3.1s
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
✔ 되돌린 뒤 · 다시 저장소에서 · 1.3s
Passed 4/4
report: runs/lab01-challenge2-20261027-183922.json
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
+172
View File
@@ -0,0 +1,172 @@
{
"cases": "golden.json",
"webhook": "my-helpdesk",
"passed": 6,
"total": 6,
"results": [
{
"case": "VPN",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. 접수 건은 찾지 못했습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "비밀번호",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정합니다. [FAQ-001] \n접수 건은 찾지 못했습니다.",
"sources": [
"FAQ-001"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 2.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인한 뒤, 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "메일함",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보해 주시기 바랍니다. [FAQ-005] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 중",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002의 현재 상태는 **처리 중**입니다. 메모에 따르면 **인증서 발급 승인 대기 중이며 10월 20일 완료 예정**입니다.",
"sources": [],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1001은 **처리 완료** 상태이며, **새 배터리로 교체했습니다**. 추가로 확인이 필요하시면 담당자에게 문의해 주세요.",
"sources": [],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
}
]
}
+75
View File
@@ -0,0 +1,75 @@
$ uv run tools/flow.py gate cases/golden.json --set release=1.0.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✔ VPN · 1.2s
✔ 비밀번호 · 1.2s
✔ 프린터 · 1.1s
✔ 메일함 · 1.3s
✔ 접수 처리 중 · 1.0s
✔ 접수 처리 완료 · 1.6s
Passed 6/6
report: runs/golden-20261027-183929.json
gate [release=1.0.0]: pass 6/6 (100%) · p95 1.6s → PASS
✔ gate record → /gates/helpdesk: 1.0.0 promote
$ uv run tools/flow.py gate cases/golden.json --set release=1.1.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✔ VPN · 1.1s
✔ 비밀번호 · 1.0s
✔ 프린터 · 1.1s
✔ 메일함 · 1.1s
✔ 접수 처리 중 · 1.1s
✔ 접수 처리 완료 · 1.1s
Passed 6/6
report: runs/golden-20261027-183936.json
gate [release=1.1.0]: pass 6/6 (100%) · p95 1.1s → PASS
✔ gate record → /gates/helpdesk: 1.1.0 promote
$ uv run tools/flow.py gate cases/golden.json --set release=1.2.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✔ VPN · 0.8s
✔ 비밀번호 · 0.9s
✔ 프린터 · 0.8s
✔ 메일함 · 1.0s
✔ 접수 처리 중 · 1.2s
✔ 접수 처리 완료 · 1.5s
Passed 6/6
report: runs/golden-20261027-183942.json
gate [release=1.2.0]: pass 6/6 (100%) · p95 1.5s → PASS
✔ gate record → /gates/helpdesk: 1.2.0 promote
$ uv run tools/flow.py gate cases/golden.json --set release=2.0.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✔ VPN · 1.1s
✔ 비밀번호 · 1.1s
✔ 프린터 · 2.0s
✔ 메일함 · 1.2s
✔ 접수 처리 중 · 1.0s
✔ 접수 처리 완료 · 1.2s
Passed 6/6
report: runs/golden-20261027-183950.json
gate [release=2.0.0]: pass 6/6 (100%) · p95 2.0s → PASS
✔ gate record → /gates/helpdesk: 2.0.0 promote
$ uv run tools/flow.py call my-helpdesk '{"question": "프린터 출력이 안 돼요", "user": "최유진", "release": "1.2.0"}' -v
HTTP 200 · 0.9s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "1.2.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "IT 포털에서 프린터 문제 해결 방법을 확인해 주세요.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.2.0",
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
+500
View File
@@ -0,0 +1,500 @@
{
"cases": "golden.json",
"webhook": "my-helpdesk",
"passed": 18,
"total": 18,
"results": [
{
"case": "VPN",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "VPN",
"webhook": "my-helpdesk",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "VPN",
"webhook": "my-helpdesk",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "비밀번호",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-001"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "비밀번호",
"webhook": "my-helpdesk",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 현재 접수된 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-001"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "비밀번호",
"webhook": "my-helpdesk",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 현재 접수된 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-001"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "프린터",
"webhook": "my-helpdesk",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "프린터",
"webhook": "my-helpdesk",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "메일함",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "메일함",
"webhook": "my-helpdesk",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "메일함",
"webhook": "my-helpdesk",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 중",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 중",
"webhook": "my-helpdesk",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 중",
"webhook": "my-helpdesk",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.5,
"status": 200,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체되었습니다. 추가 문의 사항이 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 2,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체되었습니다. 추가 문의 사항이 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 3,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "티켓 T-1001은 처리 완료되었으며, 새 배터리로 교체되었습니다. 추가 문의 사항이 있으시면 연락 바랍니다.",
"sources": [],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
}
]
}
+44
View File
@@ -0,0 +1,44 @@
$ uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 --set release=1.0.0
POST http://localhost:5678/webhook/my-helpdesk × 5
✔ #1 · HTTP 200 · 1.18s · route=자동 답변, release=1.0.0
✔ #2 · HTTP 200 · 1.42s · route=자동 답변, release=1.0.0
✔ #3 · HTTP 200 · 1.17s · route=자동 답변, release=1.0.0
✔ #4 · HTTP 200 · 1.23s · route=자동 답변, release=1.0.0
✔ #5 · HTTP 200 · 1.23s · route=자동 답변, release=1.0.0
p50 1.23s · p95 1.42s · max 1.42s · failures 0/5
report: runs/bench-my-helpdesk-20261027-183957.json
$ uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 --set release=2.0.0
POST http://localhost:5678/webhook/my-helpdesk × 5
✔ #1 · HTTP 200 · 1.10s · route=자동 답변, release=2.0.0
✔ #2 · HTTP 200 · 1.24s · route=자동 답변, release=2.0.0
✔ #3 · HTTP 200 · 1.05s · route=자동 답변, release=2.0.0
✔ #4 · HTTP 200 · 1.12s · route=자동 답변, release=2.0.0
✔ #5 · HTTP 200 · 1.12s · route=자동 답변, release=2.0.0
p50 1.12s · p95 1.24s · max 1.24s · failures 0/5
report: runs/bench-my-helpdesk-20261027-184003.json
$ uv run tools/flow.py gate cases/golden.json --set release=1.0.0 --repeat 3 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 3
✔ VPN #1 · 1.1s
✔ VPN #2 · 1.1s
✔ VPN #3 · 1.2s
✔ 비밀번호 #1 · 1.1s
✔ 비밀번호 #2 · 1.3s
✔ 비밀번호 #3 · 1.3s
✔ 프린터 #1 · 1.1s
✔ 프린터 #2 · 1.0s
✔ 프린터 #3 · 1.3s
✔ 메일함 #1 · 1.1s
✔ 메일함 #2 · 1.1s
✔ 메일함 #3 · 1.1s
✔ 접수 처리 중 #1 · 1.1s
✔ 접수 처리 중 #2 · 1.0s
✔ 접수 처리 중 #3 · 1.0s
✔ 접수 처리 완료 #1 · 1.5s
✔ 접수 처리 완료 #2 · 1.1s
✔ 접수 처리 완료 #3 · 1.1s
Passed 18/18
report: runs/golden-20261027-184024.json
gate [release=1.0.0]: pass 18/18 (100%) · p95 1.5s → PASS
✔ gate record → /gates/helpdesk: 1.0.0 promote
+254
View File
@@ -0,0 +1,254 @@
{
"cases": "74-lab02-challenge-golden.json",
"webhook": "my-helpdesk",
"passed": 9,
"total": 9,
"results": [
{
"case": "VPN · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003] \n접수 건은 찾지 못했습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "VPN · 순서도 (업데이트)",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "비밀번호 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정합니다. [FAQ-001] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-001"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "프린터 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인한 뒤, 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "메일함 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보하시면 됩니다. [FAQ-005] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 중 · 상태와 예정일",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002의 현재 상태는 **처리 중**입니다. 메모에 따르면 **인증서 발급 승인 대기 중이며 10월 20일 완료 예정**입니다.",
"sources": [],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 중 · 예정일",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002의 현재 상태는 **처리 중**입니다. 메모에 따르면 **인증서 발급 승인 대기 중이며 10월 20일 완료 예정**입니다.",
"sources": [],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1001은 **처리 완료** 상태이며, **새 배터리로 교체했습니다**. 추가로 확인이 필요하시면 담당자에게 문의하시기 바랍니다.",
"sources": [],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "포털로 떠넘기지 않기",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,63 @@
$ uv run tools/flow.py gate answers/74-lab02-challenge-golden.json --set release=1.0.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 9 case(s) × 1
✔ VPN · 해결 방법 · 1.2s
✔ VPN · 순서도 (업데이트) · 1.3s
✔ 비밀번호 · 해결 방법 · 1.4s
✔ 프린터 · 해결 방법 · 1.1s
✔ 메일함 · 해결 방법 · 1.2s
✔ 접수 처리 중 · 상태와 예정일 · 1.0s
✔ 접수 처리 중 · 예정일 · 1.0s
✔ 접수 처리 완료 · 1.1s
✔ 포털로 떠넘기지 않기 · 1.1s
Passed 9/9
report: runs/74-lab02-challenge-golden-20261027-184034.json
gate [release=1.0.0]: pass 9/9 (100%) · p95 1.4s → PASS
✔ gate record → /gates/helpdesk: 1.0.0 promote
$ uv run tools/flow.py gate answers/74-lab02-challenge-golden.json --set release=1.1.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 9 case(s) × 1
✔ VPN · 해결 방법 · 1.0s
✔ VPN · 순서도 (업데이트) · 1.7s
✔ 비밀번호 · 해결 방법 · 1.1s
✔ 프린터 · 해결 방법 · 1.3s
✔ 메일함 · 해결 방법 · 1.0s
✔ 접수 처리 중 · 상태와 예정일 · 1.0s
✔ 접수 처리 중 · 예정일 · 1.0s
✔ 접수 처리 완료 · 1.0s
✔ 포털로 떠넘기지 않기 · 1.2s
Passed 9/9
report: runs/74-lab02-challenge-golden-20261027-184045.json
gate [release=1.1.0]: pass 9/9 (100%) · p95 1.7s → PASS
✔ gate record → /gates/helpdesk: 1.1.0 promote
$ uv run tools/flow.py gate answers/74-lab02-challenge-golden.json --set release=1.2.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 9 case(s) × 1
✘ VPN · 해결 방법 · 0.9s · answer has none of ['인증서']
✘ VPN · 순서도 (업데이트) · 0.7s · answer has none of ['업데이트', '최신']
✘ 비밀번호 · 해결 방법 · 0.9s · answer has none of ['비밀번호 찾기', '휴대폰']
✘ 프린터 · 해결 방법 · 0.9s · answer has none of ['대기열', '전원']
✘ 메일함 · 해결 방법 · 0.9s · answer has none of ['보관함', '삭제']
✘ 접수 처리 중 · 상태와 예정일 · 1.0s · answer has none of ['처리 중']
✘ 접수 처리 중 · 예정일 · 0.9s · answer has none of ['10월 20일', '20일']
✘ 접수 처리 완료 · 1.3s · answer has none of ['처리 완료', '완료']
✔ 포털로 떠넘기지 않기 · 0.7s
Passed 1/9
report: runs/74-lab02-challenge-golden-20261027-184053.json
gate [release=1.2.0]: pass 1/9 (11%) · p95 1.3s → FAIL · pass rate 11% < 100%
✔ gate record → /gates/helpdesk: 1.2.0 rollback
$ uv run tools/flow.py gate answers/74-lab02-challenge-golden.json --set release=2.0.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 9 case(s) × 1
✔ VPN · 해결 방법 · 1.1s
✔ VPN · 순서도 (업데이트) · 1.3s
✔ 비밀번호 · 해결 방법 · 1.3s
✔ 프린터 · 해결 방법 · 1.3s
✔ 메일함 · 해결 방법 · 1.3s
✔ 접수 처리 중 · 상태와 예정일 · 1.0s
✔ 접수 처리 중 · 예정일 · 1.1s
✔ 접수 처리 완료 · 1.2s
✔ 포털로 떠넘기지 않기 · 1.1s
Passed 9/9
report: runs/74-lab02-challenge-golden-20261027-184104.json
gate [release=2.0.0]: pass 9/9 (100%) · p95 1.3s → PASS
✔ gate record → /gates/helpdesk: 2.0.0 promote
@@ -0,0 +1,254 @@
{
"cases": "74-lab02-challenge-golden.json",
"webhook": "my-helpdesk",
"passed": 9,
"total": 9,
"results": [
{
"case": "VPN · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다만, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "VPN · 순서도 (업데이트)",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없으며, 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "비밀번호 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.5,
"status": 200,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "비밀번호 재설정은 사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. 현재 접수된 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-001"
],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "프린터 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "메일함 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "접수 처리 중 · 상태와 예정일",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "접수 처리 중 · 예정일",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002 티켓은 현재 처리 중이며, 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다. 추가 문의가 있으시면 연락 주시기 바랍니다.",
"sources": [],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "티켓 T-1001은 처리 완료되었습니다. 새 배터리로 교체했습니다. 추가 도움이 필요하시면 연락 바랍니다.",
"sources": [],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "포털로 떠넘기지 않기",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.1.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
}
]
}
@@ -0,0 +1,261 @@
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 5 case(s) × 1
✘ 망가진 릴리스 2.1.0 · 400 release broken · 1.3s · status 200 (expected 400); error = null (expected release broken); prompt_version = null (expected p9)
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.1.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.1.0",
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
✔ 평소 · 1.1.0은 저장소의 p2 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당자가 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "error"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "error",
"slow_seconds": 8.0
}
✔ 저장소 고장 · 1.1.0도 기본 지시문으로 · 1.2s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "error"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "error",
"slow_seconds": 8.0
}
✔ 저장소 고장 · 없는 릴리스는 그대로 400 · 0.0s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "9.9.9"
}
← response HTTP 400
{
"error": "unknown release",
"release": "9.9.9",
"known": [
"1.0.0",
"1.1.0",
"1.2.0",
"2.0.0",
"2.1.0"
]
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ 운영(active 1.0.0)은 그대로 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
Passed 4/5
report: runs/lab02-challenge2-20261027-184109.json
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
$ uv run tools/flow.py gate answers/74-lab02-challenge-golden.json --set release=2.1.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 9 case(s) × 1
✔ VPN · 해결 방법 · 1.2s
✔ VPN · 순서도 (업데이트) · 1.2s
✔ 비밀번호 · 해결 방법 · 1.5s
✔ 프린터 · 해결 방법 · 1.1s
✔ 메일함 · 해결 방법 · 1.3s
✔ 접수 처리 중 · 상태와 예정일 · 1.0s
✔ 접수 처리 중 · 예정일 · 1.0s
✔ 접수 처리 완료 · 1.0s
✔ 포털로 떠넘기지 않기 · 1.0s
Passed 9/9
report: runs/74-lab02-challenge-golden-20261027-184120.json
gate [release=2.1.0]: pass 9/9 (100%) · p95 1.5s → PASS
✔ gate record → /gates/helpdesk: 2.1.0 promote
@@ -0,0 +1,206 @@
{
"cases": "74-lab02-challenge-golden.json",
"webhook": "my-helpdesk",
"passed": 0,
"total": 9,
"results": [
{
"case": "VPN · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"route = null (expected 자동 답변)",
"answer has none of ['인증서']"
],
"seconds": 0.1,
"status": 400,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "VPN · 순서도 (업데이트)",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"answer has none of ['업데이트', '최신']"
],
"seconds": 0.1,
"status": 400,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "비밀번호 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"answer has none of ['비밀번호 찾기', '휴대폰']"
],
"seconds": 0.1,
"status": 400,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "프린터 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"answer has none of ['대기열', '전원']"
],
"seconds": 0.0,
"status": 400,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "메일함 · 해결 방법",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"answer has none of ['보관함', '삭제']"
],
"seconds": 0.1,
"status": 400,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "접수 처리 중 · 상태와 예정일",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"answer has none of ['처리 중']"
],
"seconds": 0.1,
"status": 400,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "접수 처리 중 · 예정일",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"answer has none of ['10월 20일', '20일']"
],
"seconds": 0.1,
"status": 400,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)",
"answer has none of ['처리 완료', '완료']"
],
"seconds": 0.0,
"status": 400,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "포털로 떠넘기지 않기",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"status 400 (expected 200)"
],
"seconds": 0.0,
"status": 400,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
}
]
}
+257
View File
@@ -0,0 +1,257 @@
$ uv run tools/flow.py push answers/74-lab02-challenge2-broken.json
! unpublished '74 IT helpdesk + release API (reference)' (w2caac7c1a6981b8): it used the same webhook path
✔ pushed '74 IT helpdesk + broken release check (answer)' (id w86d00ad9781296e)
POST http://localhost:5678/webhook/my-helpdesk
$ uv run tools/flow.py test cases/lab02-challenge2.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 5 case(s) × 1
✔ 망가진 릴리스 2.1.0 · 400 release broken · 1.0s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.1.0"
}
← response HTTP 400
{
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
✔ 평소 · 1.1.0은 저장소의 p2 · 2.0s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당자가 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "error"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "error",
"slow_seconds": 8.0
}
✔ 저장소 고장 · 1.1.0도 기본 지시문으로 · 1.2s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "error"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "error",
"slow_seconds": 8.0
}
✔ 저장소 고장 · 없는 릴리스는 그대로 400 · 0.1s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "9.9.9"
}
← response HTTP 400
{
"error": "unknown release",
"release": "9.9.9",
"known": [
"1.0.0",
"1.1.0",
"1.2.0",
"2.0.0",
"2.1.0"
]
}
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok"
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ 운영(active 1.0.0)은 그대로 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
Passed 5/5
report: runs/lab02-challenge2-20261027-184139.json
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
→ request POST http://localhost:8800/admin/fault
{
"prompts": "ok",
"slow_seconds": 8
}
← response HTTP 200
{
"faq": "ok",
"tickets": "ok",
"prompts": "ok",
"slow_seconds": 8.0
}
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
$ uv run tools/flow.py gate answers/74-lab02-challenge-golden.json --set release=2.1.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 9 case(s) × 1
✘ VPN · 해결 방법 · 0.1s · status 400 (expected 200); route = null (expected 자동 답변); answer has none of ['인증서']
✘ VPN · 순서도 (업데이트) · 0.1s · status 400 (expected 200); answer has none of ['업데이트', '최신']
✘ 비밀번호 · 해결 방법 · 0.1s · status 400 (expected 200); answer has none of ['비밀번호 찾기', '휴대폰']
✘ 프린터 · 해결 방법 · 0.0s · status 400 (expected 200); answer has none of ['대기열', '전원']
✘ 메일함 · 해결 방법 · 0.1s · status 400 (expected 200); answer has none of ['보관함', '삭제']
✘ 접수 처리 중 · 상태와 예정일 · 0.1s · status 400 (expected 200); answer has none of ['처리 중']
✘ 접수 처리 중 · 예정일 · 0.1s · status 400 (expected 200); answer has none of ['10월 20일', '20일']
✘ 접수 처리 완료 · 0.0s · status 400 (expected 200); answer has none of ['처리 완료', '완료']
✘ 포털로 떠넘기지 않기 · 0.0s · status 400 (expected 200)
Passed 0/9
report: runs/74-lab02-challenge-golden-20261027-184140.json
gate [release=2.1.0]: pass 0/9 (0%) · p95 0.0s → FAIL · pass rate 0% < 100%
✔ gate record → /gates/helpdesk: 2.1.0 rollback
@@ -0,0 +1,116 @@
{
"cases": "lab01-challenge2.json",
"webhook": "my-helpdesk",
"passed": 4,
"total": 4,
"results": [
{
"case": "평소 · 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "저장소 고장 · 기본 지시문으로 답",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "fallback"
}
}
},
{
"case": "저장소 느림 · 오래 기다리지 않음",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 3.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "fallback"
}
}
},
{
"case": "되돌린 뒤 · 다시 저장소에서",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,24 @@
$ uv run tools/flow.py test cases/lab02.json
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
POST http://localhost:5678/webhook/my-helpdesk · 5 case(s) × 1
✔ 운영 중인 릴리스 (active) · 1.3s
✔ 후보 시험 · 1.1.0 고정 · 1.1s
✔ 후보 시험 · 2.0.0 (작은 모델) · 1.2s
✔ 없는 릴리스 · 0.1s
✔ 빈 질문 · 0.1s
Passed 5/5
report: runs/lab02-20261027-184144.json
$ uv run tools/flow.py test cases/lab01-challenge2.json
POST http://localhost:5678/webhook/my-helpdesk · 4 case(s) × 1
✔ 평소 · 저장소에서 · 1.2s
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
✔ 저장소 고장 · 기본 지시문으로 답 · 1.2s
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "slow", "slow_seconds": 8.0}
✔ 저장소 느림 · 오래 기다리지 않음 · 3.0s
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
✔ 되돌린 뒤 · 다시 저장소에서 · 1.0s
Passed 4/4
report: runs/lab01-challenge2-20261027-184150.json
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
+62
View File
@@ -0,0 +1,62 @@
{
"cases": "lab03.json",
"webhook": "my-helpdesk",
"passed": 2,
"total": 2,
"results": [
{
"case": "배포 · 1.1.0을 active로",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당자가 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "되돌리기 · 1.0.0",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
+260
View File
@@ -0,0 +1,260 @@
$ uv run tools/flow.py release helpdesk 1.1.0
✔ helpdesk: active 1.1.0 · no canary
1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
* 1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 9/9
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 1/9
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 9/9
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요) gate rollback 0/9
$ uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v
HTTP 200 · 1.2s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당 직원이 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
$ uv run tools/flow.py release helpdesk 1.0.0
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 9/9
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 1/9
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 9/9
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요) gate rollback 0/9
$ uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v
HTTP 200 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없으며, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
$ uv run tools/flow.py test cases/lab03.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 2 case(s) × 1
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.1.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk
{
"active": "1.1.0"
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.1.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ 배포 · 1.1.0을 active로 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당자가 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk
{
"active": "1.0.0"
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ 되돌리기 · 1.0.0 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
Passed 2/2
report: runs/lab03-20261027-184155.json
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
@@ -0,0 +1,180 @@
{
"cases": "lab03-canary.json",
"webhook": "my-helpdesk",
"passed": 3,
"total": 6,
"results": [
{
"case": "최유진 (bucket 0) → 카나리",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"version.release = 1.0.0 (expected 1.1.0)",
"version.channel = stable (expected canary)"
],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "정우성 (bucket 42) → 카나리",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"version.release = 1.0.0 (expected 1.1.0)",
"version.channel = stable (expected canary)"
],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "정우성"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "이준호 (bucket 56) → 운영",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "김민지 (bucket 80) → 운영",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "고정이 카나리보다 먼저",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "1.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "같은 사람은 늘 같은 쪽",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"version.release = 1.0.0 (expected 1.1.0)",
"version.channel = stable (expected canary)"
],
"seconds": 1.2,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,259 @@
$ uv run tools/flow.py release helpdesk --canary 1.1.0 --percent 50
✔ helpdesk: active 1.0.0 · canary 1.1.0 50%
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
c 1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 9/9
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 1/9
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 9/9
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요) gate rollback 0/9
canary users: 최유진(0), 정우성(42)
$ uv run tools/flow.py test cases/lab03-canary.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": {"release": "1.1.0", "percent": 50}, "require_gate": false, "releases"
→ request POST http://localhost:8800/releases/helpdesk
{
"canary": {
"release": "1.1.0",
"percent": 50
}
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": {
"release": "1.1.0",
"percent": 50
},
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✘ 최유진 (bucket 0) → 카나리 · 1.1s · version.release = 1.0.0 (expected 1.1.0); version.channel = stable (expected canary)
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✘ 정우성 (bucket 42) → 카나리 · 1.3s · version.release = 1.0.0 (expected 1.1.0); version.channel = stable (expected canary)
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "정우성"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 이준호 (bucket 56) → 운영 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건은 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 김민지 (bucket 80) → 운영 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 고정이 카나리보다 먼저 · 1.0s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "1.0.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
✘ 같은 사람은 늘 같은 쪽 · 1.2s · version.release = 1.0.0 (expected 1.1.0); version.channel = stable (expected canary)
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
Passed 3/6
report: runs/lab03-canary-20261027-184202.json
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"cases": "lab02-challenge2.json",
"webhook": "my-helpdesk",
"passed": 5,
"total": 5,
"results": [
{
"case": "망가진 릴리스 2.1.0 · 400 release broken",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.1,
"status": 400,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.1.0"
},
"response": {
"error": "release broken",
"release": "2.1.0",
"prompt_version": "p9"
}
},
{
"case": "평소 · 1.1.0은 저장소의 p2",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류가 발생하면 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 담당자가 도와드리겠습니다. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "저장소 고장 · 1.1.0도 기본 지시문으로",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "1.1.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주십시오.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "fallback"
}
}
},
{
"case": "저장소 고장 · 없는 릴리스는 그대로 400",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 400,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "9.9.9"
},
"response": {
"error": "unknown release",
"release": "9.9.9",
"known": [
"1.0.0",
"1.1.0",
"1.2.0",
"2.0.0",
"2.1.0"
]
}
},
{
"case": "운영(active 1.0.0)은 그대로",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
+291
View File
@@ -0,0 +1,291 @@
$ uv run tools/flow.py push reference/lab03-example2-canary.json
! unpublished '74 IT helpdesk + broken release check (answer)' (w86d00ad9781296e): it used the same webhook path
✔ pushed '74 IT helpdesk + canary (reference)' (id w2e1e5fc96604a25)
POST http://localhost:5678/webhook/my-helpdesk
$ uv run tools/flow.py test cases/lab03-canary.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": {"release": "1.1.0", "percent": 50}, "require_gate": false, "releases"
→ request POST http://localhost:8800/releases/helpdesk
{
"canary": {
"release": "1.1.0",
"percent": 50
}
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": {
"release": "1.1.0",
"percent": 50
},
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✔ 최유진 (bucket 0) → 카나리 · 2.8s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하면 담당 직원이 도와드리겠습니다. [FAQ-004]",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "canary",
"prompt_source": "registry"
}
}
✔ 정우성 (bucket 42) → 카나리 · 1.4s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "정우성"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하면 문의해 주세요. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "canary",
"prompt_source": "registry"
}
}
✔ 이준호 (bucket 56) → 운영 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주세요.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 김민지 (bucket 80) → 운영 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우, 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제하여 용량을 확보하시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 고정이 카나리보다 먼저 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "1.0.0"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "pinned",
"prompt_source": "registry"
}
}
✔ 같은 사람은 늘 같은 쪽 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하면 담당 직원이 도와드리겠습니다. [FAQ-004]",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "canary",
"prompt_source": "registry"
}
}
Passed 6/6
report: runs/lab03-canary-20261027-184226.json
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
$ uv run tools/flow.py test cases/lab02.json
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
POST http://localhost:5678/webhook/my-helpdesk · 5 case(s) × 1
✔ 운영 중인 릴리스 (active) · 1.6s
✔ 후보 시험 · 1.1.0 고정 · 1.3s
✔ 후보 시험 · 2.0.0 (작은 모델) · 1.4s
✔ 없는 릴리스 · 0.1s
✔ 빈 질문 · 0.1s
Passed 5/5
report: runs/lab02-20261027-184231.json
$ uv run tools/flow.py test cases/lab02-challenge2.json
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
POST http://localhost:5678/webhook/my-helpdesk · 5 case(s) × 1
✔ 망가진 릴리스 2.1.0 · 400 release broken · 0.1s
✔ 평소 · 1.1.0은 저장소의 p2 · 1.2s
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
✔ 저장소 고장 · 1.1.0도 기본 지시문으로 · 1.2s
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
✔ before: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "error", "slow_seconds": 8.0}
✔ 저장소 고장 · 없는 릴리스는 그대로 400 · 0.0s
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
✔ 운영(active 1.0.0)은 그대로 · 1.1s
Passed 5/5
report: runs/lab02-challenge2-20261027-184235.json
✔ after: POST http://localhost:8800/admin/fault · HTTP 200 · 0.0s · {"faq": "ok", "tickets": "ok", "prompts": "ok", "slow_seconds": 8.0}
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
$ uv run tools/flow.py release helpdesk --reset
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 9/9
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 1/9
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 9/9
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요) gate rollback 0/9
+174
View File
@@ -0,0 +1,174 @@
{
"cases": "lab03-watch.json",
"webhook": "my-helpdesk",
"passed": 4,
"total": 6,
"results": [
{
"case": "최유진 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"answer has none of ['대기열']"
],
"seconds": 0.9,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "IT 포털에서 프린터 문제 해결 방법을 확인해 주세요.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.2.0",
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"channel": "canary",
"prompt_source": "registry"
}
}
},
{
"case": "정우성 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": false,
"problems": [
"answer has none of ['대기열']"
],
"seconds": 0.9,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "정우성"
},
"response": {
"route": "자동 답변",
"answer": "IT 포털에서 프린터 문제 해결 방법을 확인해 주세요.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.2.0",
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"channel": "canary",
"prompt_source": "registry"
}
}
},
{
"case": "한지훈 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "한지훈"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "이준호 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "박서연 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "김민지 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "김민지"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
+221
View File
@@ -0,0 +1,221 @@
$ uv run tools/flow.py test cases/lab03-watch.json -v
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": {"release": "1.2.0", "percent": 50}, "require_gate": false, "releases"
→ request POST http://localhost:8800/releases/helpdesk
{
"canary": {
"release": "1.2.0",
"percent": 50
}
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": {
"release": "1.2.0",
"percent": 50
},
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✘ 최유진 · 프린터 · 0.9s · answer has none of ['대기열']
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "IT 포털에서 프린터 문제 해결 방법을 확인해 주세요.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.2.0",
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"channel": "canary",
"prompt_source": "registry"
}
}
✘ 정우성 · 프린터 · 0.9s · answer has none of ['대기열']
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "정우성"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "IT 포털에서 프린터 문제 해결 방법을 확인해 주세요.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.2.0",
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"channel": "canary",
"prompt_source": "registry"
}
}
✔ 한지훈 · 프린터 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "한지훈"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 이준호 · 프린터 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 박서연 · 프린터 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "박서연"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 김민지 · 프린터 · 1.1s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "김민지"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
Passed 4/6
report: runs/lab03-watch-20261027-184241.json
$ uv run tools/flow.py release helpdesk
✔ helpdesk: active 1.0.0 · canary 1.2.0 50%
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 9/9
c 1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 1/9
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 9/9
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요) gate rollback 0/9
canary users: 최유진(0), 정우성(42)
@@ -0,0 +1,170 @@
{
"cases": "lab03-watch.json",
"webhook": "my-helpdesk",
"passed": 6,
"total": 6,
"results": [
{
"case": "최유진 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "정우성 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "정우성"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "한지훈 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.0,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "한지훈"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "이준호 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "박서연 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.2,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "박서연"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "김민지 · 프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "김민지"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,146 @@
$ uv run tools/flow.py release helpdesk --no-canary
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 9/9
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 1/9
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 9/9
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요) gate rollback 0/9
$ uv run tools/flow.py test cases/lab03-watch.json --no-before -v
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✔ 최유진 · 프린터 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 정우성 · 프린터 · 1.4s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "정우성"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 접수된 문의 건은 없으며, 추가 도움이 필요하시면 담당자가 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 한지훈 · 프린터 · 1.0s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "한지훈"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 이준호 · 프린터 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 박서연 · 프린터 · 1.2s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "박서연"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ 김민지 · 프린터 · 1.4s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "김민지"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 현재 접수된 관련 문의는 없습니다. 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
Passed 6/6
report: runs/lab03-watch-20261027-184249.json
$ uv run tools/flow.py release helpdesk --reset
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스 gate promote 9/9
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 9/9
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 1/9
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 9/9
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요) gate rollback 0/9
@@ -0,0 +1,68 @@
{
"cases": "lab03-challenge.json",
"webhook": "my-release-check",
"passed": 3,
"total": 3,
"results": [
{
"case": "1.1.0 · 내보내도 됨",
"webhook": "my-release-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 3.1,
"status": 200,
"request": {
"release": "1.1.0"
},
"response": {
"release": "1.1.0",
"passed": 6,
"total": 6,
"failed": [],
"seconds": 2,
"verdict": "promote"
}
},
{
"case": "1.2.0 · 되돌림",
"webhook": "my-release-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.6,
"status": 200,
"request": {
"release": "1.2.0"
},
"response": {
"release": "1.2.0",
"passed": 0,
"total": 6,
"failed": [
"VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"프린터 출력이 안 돼요",
"메일함 용량이 꽉 찼대요",
"T-1002 처리 상태 알려 주세요",
"T-1001 어떻게 됐나요?"
],
"seconds": 1.6,
"verdict": "rollback"
}
},
{
"case": "릴리스 없음",
"webhook": "my-release-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 0.0,
"status": 400,
"request": {},
"response": {
"error": "release is required"
}
}
]
}
@@ -0,0 +1,67 @@
$ uv run tools/flow.py push answers/74-lab03-challenge-release-check.json
! unpublished '74 Release check + gate record (answer)' (w9589252e9498695): it used the same webhook path
✔ pushed '74 Release check (answer)' (id w50ae3687e1cc859)
POST http://localhost:5678/webhook/my-release-check
$ uv run tools/flow.py test cases/lab03-challenge.json -v
POST http://localhost:5678/webhook/my-release-check · 3 case(s) × 1
✔ 1.1.0 · 내보내도 됨 · 3.1s
→ request POST /webhook/my-release-check
{
"release": "1.1.0"
}
← response HTTP 200
{
"release": "1.1.0",
"passed": 6,
"total": 6,
"failed": [],
"seconds": 2,
"verdict": "promote"
}
✔ 1.2.0 · 되돌림 · 1.6s
→ request POST /webhook/my-release-check
{
"release": "1.2.0"
}
← response HTTP 200
{
"release": "1.2.0",
"passed": 0,
"total": 6,
"failed": [
"VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"프린터 출력이 안 돼요",
"메일함 용량이 꽉 찼대요",
"T-1002 처리 상태 알려 주세요",
"T-1001 어떻게 됐나요?"
],
"seconds": 1.6,
"verdict": "rollback"
}
✔ 릴리스 없음 · 0.0s
→ request POST /webhook/my-release-check
{}
← response HTTP 400
{
"error": "release is required"
}
Passed 3/3
report: runs/lab03-challenge-20261027-184308.json
$ uv run tools/flow.py call my-release-check '{"release": "2.0.0"}' -v
HTTP 200 · 1.9s
→ request POST /webhook/my-release-check
{
"release": "2.0.0"
}
← response HTTP 200
{
"release": "2.0.0",
"passed": 6,
"total": 6,
"failed": [],
"seconds": 1.8,
"verdict": "promote"
}
@@ -0,0 +1,201 @@
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
✔ before: POST http://localhost:8800/gates/helpdesk/clear · HTTP 200 · 0.0s · {"cleared": 19}
→ request POST http://localhost:8800/gates/helpdesk/clear
{}
← response HTTP 200
{
"cleared": 19
}
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ before: POST http://localhost:8800/releases/helpdesk/policy · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": true, "releases": {"1.0.0": {"prompt_version": "
→ request POST http://localhost:8800/releases/helpdesk/policy
{
"require_gate": true
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": true,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-release-check · 5 case(s) × 1
✘ 1.2.0 점검 · 기록 · 24.2s · recorded = null (expected true)
→ request POST /webhook/my-release-check
{
"release": "1.2.0"
}
← response HTTP 200
{
"release": "1.2.0",
"passed": 0,
"total": 6,
"failed": [
"VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"프린터 출력이 안 돼요",
"메일함 용량이 꽉 찼대요",
"T-1002 처리 상태 알려 주세요",
"T-1001 어떻게 됐나요?"
],
"seconds": 24.1,
"verdict": "rollback"
}
✘ 1.1.0 점검 · 기록 · 7.9s · recorded = null (expected true)
→ request POST /webhook/my-release-check
{
"release": "1.1.0"
}
← response HTTP 200
{
"release": "1.1.0",
"passed": 6,
"total": 6,
"failed": [],
"seconds": 7.9,
"verdict": "promote"
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 409 · 0.0s · {"error": "not gated", "release": "1.2.0", "last_gate": null}
→ request POST http://localhost:8800/releases/helpdesk
{
"active": "1.2.0"
}
← response HTTP 409
{
"error": "not gated",
"release": "1.2.0",
"last_gate": null
}
✔ 점검에 떨어진 1.2.0은 배포 거부 (409) · 운영은 1.0.0 · 7.9s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 관련 문의는 없으며, 추가 도움이 필요하시면 담당 직원이 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✘ before: POST http://localhost:8800/releases/helpdesk · HTTP 409 · 0.0s · {"error": "not gated", "release": "1.1.0", "last_gate": null}
→ request POST http://localhost:8800/releases/helpdesk
{
"active": "1.1.0"
}
← response HTTP 409
{
"error": "not gated",
"release": "1.1.0",
"last_gate": null
}
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✘ the 'before' step failed (http://localhost:8800/releases/helpdesk). Is it pushed / is the data API the day 74 version?
@@ -0,0 +1,138 @@
{
"cases": "lab03-challenge2.json",
"webhook": "my-release-check",
"passed": 5,
"total": 5,
"results": [
{
"case": "1.2.0 점검 · 기록",
"webhook": "my-release-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 10.6,
"status": 200,
"request": {
"release": "1.2.0"
},
"response": {
"release": "1.2.0",
"passed": 0,
"total": 6,
"failed": [
"VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"프린터 출력이 안 돼요",
"메일함 용량이 꽉 찼대요",
"T-1002 처리 상태 알려 주세요",
"T-1001 어떻게 됐나요?"
],
"seconds": 9.5,
"verdict": "rollback",
"recorded": true
}
},
{
"case": "1.1.0 점검 · 기록",
"webhook": "my-release-check",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.9,
"status": 200,
"request": {
"release": "1.1.0"
},
"response": {
"release": "1.1.0",
"passed": 6,
"total": 6,
"failed": [],
"seconds": 1.7,
"verdict": "promote",
"recorded": true
}
},
{
"case": "점검에 떨어진 1.2.0은 배포 거부 (409) · 운영은 1.0.0",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 7.7,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주십시오.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "점검을 지난 1.1.0은 배포",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 문의해 주세요. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
},
{
"case": "점검 안 한 2.0.0은 카나리도 거부 (409)",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진"
},
"response": {
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하면 담당 직원이 도와드리겠습니다. [FAQ-004]",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
}
]
}
+317
View File
@@ -0,0 +1,317 @@
$ uv run tools/flow.py push answers/74-lab03-challenge2-gated.json
! unpublished '74 Release check (answer)' (w50ae3687e1cc859): it used the same webhook path
✔ pushed '74 Release check + gate record (answer)' (id w9589252e9498695)
POST http://localhost:5678/webhook/my-release-check
$ uv run tools/flow.py test cases/lab03-challenge2.json -v
✔ before: POST http://localhost:8800/gates/helpdesk/clear · HTTP 200 · 0.0s · {"cleared": 0}
→ request POST http://localhost:8800/gates/helpdesk/clear
{}
← response HTTP 200
{
"cleared": 0
}
✔ before: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ before: POST http://localhost:8800/releases/helpdesk/policy · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": true, "releases": {"1.0.0": {"prompt_version": "
→ request POST http://localhost:8800/releases/helpdesk/policy
{
"require_gate": true
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": true,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
POST http://localhost:5678/webhook/my-release-check · 5 case(s) × 1
✔ 1.2.0 점검 · 기록 · 10.6s
→ request POST /webhook/my-release-check
{
"release": "1.2.0"
}
← response HTTP 200
{
"release": "1.2.0",
"passed": 0,
"total": 6,
"failed": [
"VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"프린터 출력이 안 돼요",
"메일함 용량이 꽉 찼대요",
"T-1002 처리 상태 알려 주세요",
"T-1001 어떻게 됐나요?"
],
"seconds": 9.5,
"verdict": "rollback",
"recorded": true
}
✔ 1.1.0 점검 · 기록 · 1.9s
→ request POST /webhook/my-release-check
{
"release": "1.1.0"
}
← response HTTP 200
{
"release": "1.1.0",
"passed": 6,
"total": 6,
"failed": [],
"seconds": 1.7,
"verdict": "promote",
"recorded": true
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 409 · 0.0s · {"error": "not gated", "release": "1.2.0", "last_gate": {"t": 1793094252.829, "release": "1.2.0", "verdict": "rollback",
→ request POST http://localhost:8800/releases/helpdesk
{
"active": "1.2.0"
}
← response HTTP 409
{
"error": "not gated",
"release": "1.2.0",
"last_gate": {
"t": 1793094252.829,
"release": "1.2.0",
"verdict": "rollback",
"passed": 0,
"total": 6,
"failed": [
"VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"프린터 출력이 안 돼요",
"메일함 용량이 꽉 찼대요",
"T-1002 처리 상태 알려 주세요",
"T-1001 어떻게 됐나요?"
],
"seconds": 9.5,
"source": "my-release-check"
}
}
✔ 점검에 떨어진 1.2.0은 배포 거부 (409) · 운영은 1.0.0 · 7.7s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 현재 접수된 문의 건이 없어 추가 지원이 필요하시면 IT 담당자에게 문의해 주십시오.",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.0.0",
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.1.0", "canary": null, "require_gate": true, "releases": {"1.0.0": {"prompt_version": "
→ request POST http://localhost:8800/releases/helpdesk
{
"active": "1.1.0"
}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.1.0",
"canary": null,
"require_gate": true,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
✔ 점검을 지난 1.1.0은 배포 · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "VPN 접속 오류는 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속하시기 바랍니다. 추가 도움이 필요하시면 문의해 주세요. [FAQ-003]",
"sources": [
"FAQ-003"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
✔ before: POST http://localhost:8800/releases/helpdesk · HTTP 409 · 0.0s · {"error": "not gated", "release": "2.0.0", "last_gate": null}
→ request POST http://localhost:8800/releases/helpdesk
{
"canary": {
"release": "2.0.0",
"percent": 50
}
}
← response HTTP 409
{
"error": "not gated",
"release": "2.0.0",
"last_gate": null
}
✔ 점검 안 한 2.0.0은 카나리도 거부 (409) · 1.3s
→ request POST /webhook/my-helpdesk
{
"question": "프린터 출력이 안 돼요",
"user": "최유진"
}
← response HTTP 200
{
"route": "자동 답변",
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보시기 바랍니다. 추가 도움이 필요하면 담당 직원이 도와드리겠습니다. [FAQ-004]",
"sources": [
"FAQ-004"
],
"version": {
"release": "1.1.0",
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"channel": "stable",
"prompt_source": "registry"
}
}
Passed 5/5
report: runs/lab03-challenge2-20261027-184425.json
✔ after: POST http://localhost:8800/releases/helpdesk/reset · HTTP 200 · 0.0s · {"name": "helpdesk", "active": "1.0.0", "canary": null, "require_gate": false, "releases": {"1.0.0": {"prompt_version":
→ request POST http://localhost:8800/releases/helpdesk/reset
{}
← response HTTP 200
{
"name": "helpdesk",
"active": "1.0.0",
"canary": null,
"require_gate": false,
"releases": {
"1.0.0": {
"prompt_version": "p1",
"model": "gpt-4.1-mini",
"note": "첫 릴리스"
},
"1.1.0": {
"prompt_version": "p2",
"model": "gpt-4.1-mini",
"note": "FAQ 답 끝에 출처 번호"
},
"1.2.0": {
"prompt_version": "p3",
"model": "gpt-4.1-mini",
"note": "답을 한 문장으로 짧게"
},
"2.0.0": {
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"note": "더 작은 모델 (비용)"
},
"2.1.0": {
"prompt_version": "p9",
"model": "gpt-4.1-mini",
"note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"
}
}
}
$ uv run tools/flow.py release helpdesk
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 6/6
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 0/6
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용)
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)
+172
View File
@@ -0,0 +1,172 @@
{
"cases": "golden.json",
"webhook": "my-helpdesk",
"passed": 6,
"total": 6,
"results": [
{
"case": "VPN",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "VPN 접속이 끊기는 경우 VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속해 보시기 바랍니다. [FAQ-003] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-003"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "비밀번호",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "사내 포털 로그인 화면의 ‘비밀번호 찾기’에서 휴대폰 인증 후 새 비밀번호를 설정하시면 됩니다. [FAQ-001] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-001"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "프린터",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.3,
"status": 200,
"request": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "프린터 출력이 안 될 때는 프린터 전원과 네트워크 연결을 확인한 뒤, 인쇄 대기열을 비우고 다시 출력해 보시기 바랍니다. [FAQ-004] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-004"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "메일함",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.4,
"status": 200,
"request": {
"question": "메일함 용량이 꽉 찼대요",
"user": "김민지",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "메일함 용량이 초과된 경우 오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보해 주시면 됩니다. [FAQ-005] \n접수 건은 찾지 못했습니다. 직원이 도와드리겠습니다.",
"sources": [
"FAQ-005"
],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 중",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1002 처리 상태 알려 주세요",
"user": "박서연",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1002의 현재 상태는 **처리 중**입니다. 메모에 따르면 **인증서 발급 승인 대기 중이며 10월 20일 완료 예정**입니다.",
"sources": [],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
},
{
"case": "접수 처리 완료",
"webhook": "my-helpdesk",
"run": 1,
"passed": true,
"problems": [],
"seconds": 1.1,
"status": 200,
"request": {
"question": "T-1001 어떻게 됐나요?",
"user": "이준호",
"release": "2.0.0"
},
"response": {
"route": "자동 답변",
"answer": "T-1001은 **처리 완료** 상태이며, **새 배터리로 교체했습니다**. 추가로 확인이 필요하시면 담당자에게 문의해 주세요.",
"sources": [],
"version": {
"release": "2.0.0",
"prompt_version": "p2",
"model": "gpt-5.4-nano",
"channel": "pinned",
"prompt_source": "registry"
}
}
}
]
}
@@ -0,0 +1,53 @@
$ uv run tools/flow.py release helpdesk --require-gate on
✔ helpdesk: active 1.0.0 · no canary · deploy needs a passing gate
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 6/6
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 0/6
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용)
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)
$ uv run tools/flow.py release helpdesk 2.1.0
✘ refused: 2.1.0 has no passing gate record (never gated)
→ run the gate (flow.py gate ... --set release=<version>) or my-release-check first
$ uv run tools/flow.py gate cases/golden.json --set release=2.0.0 --max-p95 3
POST http://localhost:5678/webhook/my-helpdesk · 6 case(s) × 1
✔ VPN · 1.1s
✔ 비밀번호 · 1.1s
✔ 프린터 · 1.3s
✔ 메일함 · 1.4s
✔ 접수 처리 중 · 1.1s
✔ 접수 처리 완료 · 1.1s
Passed 6/6
report: runs/golden-20261027-184432.json
gate [release=2.0.0]: pass 6/6 (100%) · p95 1.4s → PASS
✔ gate record → /gates/helpdesk: 2.0.0 promote
$ uv run tools/flow.py release helpdesk 2.0.0
✔ helpdesk: active 2.0.0 · no canary · deploy needs a passing gate
1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 6/6
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 0/6
* 2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 6/6
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)
$ uv run tools/flow.py release helpdesk --reset
✔ helpdesk: active 1.0.0 · no canary
* 1.0.0 prompt p1 model gpt-4.1-mini 첫 릴리스
1.1.0 prompt p2 model gpt-4.1-mini FAQ 답 끝에 출처 번호 gate promote 6/6
1.2.0 prompt p3 model gpt-4.1-mini 답을 한 문장으로 짧게 gate rollback 0/6
2.0.0 prompt p2 model gpt-5.4-nano 더 작은 모델 (비용) gate promote 6/6
2.1.0 prompt p9 model gpt-4.1-mini 출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)
$ 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
✔ releases /releases/helpdesk · gates /gates/helpdesk (day 74 data API)
✔ n8n → http://data-api:8800
✔ credential 'OpenAI (.env)' (openAiCourse0001)
✔ node catalog (for check)
✔ CLI coding agent: codex, claude, agy
ready
@@ -0,0 +1,357 @@
{
"name": "74 IT helpdesk + prompt registry (answer)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "d898f230-a253-55da-9ec5-4288291c08dd"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "185f04ea-0ee8-514b-b3fd-11b89b1c0c2e"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "8a3b377f-f3fa-56e8-a9c4-98a3e014ce61"
},
{
"name": "Release info",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"parameters": {
"assignments": {
"assignments": [
{
"id": "r1",
"name": "release",
"value": "1.0.0",
"type": "string"
},
{
"id": "r2",
"name": "prompt_version",
"value": "p1",
"type": "string"
},
{
"id": "r3",
"name": "model",
"value": "gpt-4.1-mini",
"type": "string"
}
]
},
"options": {}
},
"position": [
440,
300
],
"id": "b33700b3-849d-5b4f-8d8c-42723207125b"
},
{
"name": "Get prompt",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/prompts/helpdesk/{{ $('Release info').first().json.prompt_version }}",
"options": {}
},
"position": [
660,
300
],
"id": "a39d24f1-c645-50c9-940e-59e381744fc1"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
880,
300
],
"id": "c7fa8ee8-ba2b-5266-9d1a-ffa5dd8743b5"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
1100,
300
],
"id": "b4fd19f5-03d4-5b31-a654-4b0ac4c32fac"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "={{ $('Get prompt').first().json.text }}",
"maxIterations": 1
}
},
"position": [
1320,
300
],
"id": "1a80728e-83e0-58d7-9477-42d4099502c6"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "={{ $('Release info').first().json.model }}"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1320,
520
],
"id": "4c69869f-3300-5834-8124-e64dfcc8135e"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nconst i = $('Release info').first().json;\nresult.version = { release: i.release, prompt_version: i.prompt_version, model: i.model, prompt_source: 'registry' };\nreturn [{ json: result }];"
},
"position": [
1540,
300
],
"id": "7c284309-570a-5987-b139-e1be350994fd"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1760,
300
],
"id": "32a9aaf4-2a59-5388-8a24-0e9f28c346e4"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Release info",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Release info": {
"main": [
[
{
"node": "Get prompt",
"type": "main",
"index": 0
}
]
]
},
"Get prompt": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,435 @@
{
"name": "74 IT helpdesk + prompt fallback (answer)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "956f6ead-f979-5d78-bedf-13743406c6b5"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "b946f570-248c-5436-a6d6-06ab2b15db29"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "e9818b28-ea3d-517c-ab00-02f86427c3c5"
},
{
"name": "Release info",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"parameters": {
"assignments": {
"assignments": [
{
"id": "r1",
"name": "release",
"value": "1.0.0",
"type": "string"
},
{
"id": "r2",
"name": "prompt_version",
"value": "p1",
"type": "string"
},
{
"id": "r3",
"name": "model",
"value": "gpt-4.1-mini",
"type": "string"
}
]
},
"options": {}
},
"position": [
440,
300
],
"id": "0ffcc09f-8eff-56b4-801e-9c8e2a40930c"
},
{
"name": "Get prompt",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/prompts/helpdesk/{{ $('Release info').first().json.prompt_version }}",
"options": {
"timeout": 2000
}
},
"position": [
660,
300
],
"id": "578695ac-1a56-57c4-863b-08677838f02c",
"onError": "continueErrorOutput"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
1100,
300
],
"id": "322d506e-a6a2-52bc-b153-170da84009c2"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
1320,
300
],
"id": "3caf9b96-2920-54de-a905-3eabacb6f6e9"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "={{ $('Registry prompt').isExecuted ? $('Registry prompt').first().json.text : $('Fallback prompt').first().json.text }}",
"maxIterations": 1
}
},
"position": [
1540,
300
],
"id": "500a6055-13cd-5000-9255-4f854efc5bb5"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "={{ $('Release info').first().json.model }}"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
1540,
520
],
"id": "efcecd80-eb64-54b9-ba80-23ce47e0d416"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nconst i = $('Release info').first().json;\nresult.version = { release: i.release, prompt_version: i.prompt_version, model: i.model, prompt_source: ($('Registry prompt').isExecuted ? 'registry' : 'fallback') };\nreturn [{ json: result }];"
},
"position": [
1760,
300
],
"id": "999ca8b6-3571-5e6d-8c0d-d681a8cf40ee"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1980,
300
],
"id": "f9bbb5e8-0e9c-5c3b-9a23-204b66884821"
},
{
"name": "Registry prompt",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
880,
300
],
"id": "8161625d-2c7f-5acc-9377-3545a0ced05c",
"parameters": {
"assignments": {
"assignments": [
{
"id": "f0",
"name": "text",
"value": "={{ $json.text }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Fallback prompt",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
880,
540
],
"id": "d9000207-de8d-561e-865c-216430daeaa5",
"parameters": {
"assignments": {
"assignments": [
{
"id": "f0",
"name": "text",
"value": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
"type": "string"
}
]
},
"options": {}
}
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Release info",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Release info": {
"main": [
[
{
"node": "Get prompt",
"type": "main",
"index": 0
}
]
]
},
"Get prompt": {
"main": [
[
{
"node": "Registry prompt",
"type": "main",
"index": 0
}
],
[
{
"node": "Fallback prompt",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Registry prompt": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Fallback prompt": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,114 @@
{
"webhook": "my-helpdesk",
"cases": [
{
"name": "VPN · 해결 방법",
"request": "requests/faq-vpn.json",
"expect": {
"equals": {
"route": "자동 답변"
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "VPN · 순서도 (업데이트)",
"request": "requests/faq-vpn.json",
"expect": {
"contains": {
"answer": [
"업데이트",
"최신"
]
}
}
},
{
"name": "비밀번호 · 해결 방법",
"request": "requests/faq-password.json",
"expect": {
"contains": {
"answer": [
"비밀번호 찾기",
"휴대폰"
]
}
}
},
{
"name": "프린터 · 해결 방법",
"request": "requests/faq-printer.json",
"expect": {
"contains": {
"answer": [
"대기열",
"전원"
]
}
}
},
{
"name": "메일함 · 해결 방법",
"request": "requests/faq-mail.json",
"expect": {
"contains": {
"answer": [
"보관함",
"삭제"
]
}
}
},
{
"name": "접수 처리 중 · 상태와 예정일",
"request": "requests/ticket-own.json",
"expect": {
"contains": {
"answer": [
"처리 중"
]
}
}
},
{
"name": "접수 처리 중 · 예정일",
"request": "requests/ticket-own.json",
"expect": {
"contains": {
"answer": [
"10월 20일",
"20일"
]
}
}
},
{
"name": "접수 처리 완료",
"request": "requests/ticket-done.json",
"expect": {
"contains": {
"answer": [
"처리 완료",
"완료"
]
}
}
},
{
"name": "포털로 떠넘기지 않기",
"request": "requests/faq-printer.json",
"expect": {
"not_contains": {
"answer": [
"IT 포털을 확인",
"포털을 참고"
]
}
}
}
]
}
@@ -0,0 +1,571 @@
{
"name": "74 IT helpdesk + broken release check (answer)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "de518e93-ed19-541f-a6fc-687a4efaf1b2",
"position": [
0,
300
],
"id": "a45a7cee-22e6-5b71-a0fd-3229330b4bbc"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "299d8e82-eeab-5f99-9a46-137788c8ebb9"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "03fcdd93-2dbb-572f-b5fb-1c9ff133f3ea"
},
{
"name": "Get release",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/releases/helpdesk",
"options": {}
},
"position": [
440,
300
],
"id": "03108783-0952-544a-a765-fa3f085af473"
},
{
"name": "Pick release",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nconst r = $json;\nconst bucket = [...String(body.user ?? '')].reduce((sum, ch) => sum + ch.codePointAt(0), 0) % 100;\nlet release = r.active, channel = 'stable';\nif (body.release) { release = String(body.release); channel = 'pinned'; }\n\nconst info = r.releases?.[release];\nif (!info) return [{ json: { ok: false, release, known: Object.keys(r.releases ?? {}) } }];\nreturn [{ json: { ok: true, release, channel, bucket, prompt_version: info.prompt_version, model: info.model } }];"
},
"position": [
660,
300
],
"id": "c8b634b5-7537-561f-95ea-c96ef4bca3f5"
},
{
"name": "Known release?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.ok }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
880,
300
],
"id": "1ed7677b-7a5f-51d2-a53e-ba8492a36fd4"
},
{
"name": "Reject release",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ error: 'unknown release', release: $json.release, known: $json.known }) }}",
"options": {
"responseCode": 400
}
},
"position": [
1100,
560
],
"id": "64c8dfc5-03fb-5c28-b8b9-1982526a211a"
},
{
"name": "Get prompt",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/prompts/helpdesk/{{ $('Pick release').first().json.prompt_version }}",
"options": {
"timeout": 2000,
"response": {
"response": {
"neverError": true,
"fullResponse": true
}
}
}
},
"position": [
1100,
300
],
"id": "77e6bc79-43c9-530b-8b64-905fa6e2d7b8",
"onError": "continueErrorOutput"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
1760,
300
],
"id": "255ba11b-b6c8-5d20-81d3-0ef7031b9f53"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
1980,
300
],
"id": "b1e5fa0b-afae-5885-a4cf-e0703d11057d"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "={{ $('Registry prompt').isExecuted ? $('Registry prompt').first().json.text : $('Fallback prompt').first().json.text }}",
"maxIterations": 1
}
},
"position": [
2200,
300
],
"id": "3ef8a476-ca75-5139-8560-67ef9c9f2e50"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "={{ $('Pick release').first().json.model }}"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
2200,
520
],
"id": "1dfcbaad-7d59-5856-be0c-5d2321a43364"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nconst i = $('Pick release').first().json;\nresult.version = { release: i.release, prompt_version: i.prompt_version, model: i.model, channel: i.channel, prompt_source: ($('Registry prompt').isExecuted && $('Registry prompt').first().json.state === 'registry') ? 'registry' : 'fallback' };\nreturn [{ json: result }];"
},
"position": [
2420,
300
],
"id": "96b32475-5056-5d64-9d49-a4422d523aa4"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
2640,
300
],
"id": "45eb0e50-740f-5969-9ddf-af23290e4484"
},
{
"name": "Registry prompt",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
300
],
"id": "516ccc9d-19d1-5b72-b514-55df1f1c6426",
"parameters": {
"jsCode": "// 200: the registry prompt. 404: the release names a prompt that does not exist (a broken release).\n// anything else (500 ...): an outage, so use the built-in prompt like the error output does.\nconst status = $json.statusCode;\nif (status === 200 && $json.body?.text) return [{ json: { state: 'registry', text: $json.body.text } }];\nif (status === 404) return [{ json: { state: 'broken' } }];\nreturn [{ json: { state: 'fallback', text: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" } }];"
}
},
{
"name": "Fallback prompt",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1320,
540
],
"id": "21a69db0-9a21-5f97-94a5-55679b268089",
"parameters": {
"assignments": {
"assignments": [
{
"id": "f0",
"name": "text",
"value": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Prompt broken?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1540,
300
],
"id": "9d349380-cde8-5736-ae9c-301eac84eb07",
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.state }}",
"rightValue": "broken",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject broken",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1760,
780
],
"id": "d1579bb1-e209-584f-a9f4-89d779cdad40",
"parameters": {
"respondWith": "json",
"options": {
"responseCode": 400
},
"responseBody": "={{ JSON.stringify({ error: 'release broken', release: $('Pick release').first().json.release, prompt_version: $('Pick release').first().json.prompt_version }) }}"
}
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Get release",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Get release": {
"main": [
[
{
"node": "Pick release",
"type": "main",
"index": 0
}
]
]
},
"Pick release": {
"main": [
[
{
"node": "Known release?",
"type": "main",
"index": 0
}
]
]
},
"Known release?": {
"main": [
[
{
"node": "Get prompt",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject release",
"type": "main",
"index": 0
}
]
]
},
"Get prompt": {
"main": [
[
{
"node": "Registry prompt",
"type": "main",
"index": 0
}
],
[
{
"node": "Fallback prompt",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Registry prompt": {
"main": [
[
{
"node": "Prompt broken?",
"type": "main",
"index": 0
}
]
]
},
"Fallback prompt": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Prompt broken?": {
"main": [
[
{
"node": "Reject broken",
"type": "main",
"index": 0
}
],
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,206 @@
{
"name": "74 Release check (answer)",
"nodes": [
{
"name": "Check webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-release-check",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "32c84fbc-d108-5979-a1bf-a11fb0028d96",
"position": [
0,
300
],
"id": "cf6ae2a5-a340-50d9-8826-86dcde970c8a"
},
{
"name": "Has release?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.release }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "07285064-a3b8-5605-a6ee-580e64e38fbc"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"release is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "34e77f24-bb35-5cf2-80e3-457681e2bd0e"
},
{
"name": "Golden questions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const release = String($('Check webhook').first().json.body.release);\nconst golden = [\n { question: 'VPN 접속이 자꾸 끊겨요. 어떻게 하나요?', user: '이준호', expect: ['인증서'] },\n { question: '비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?', user: '한지훈', expect: ['비밀번호 찾기', '휴대폰'] },\n { question: '프린터 출력이 안 돼요', user: '최유진', expect: ['대기열', '전원'] },\n { question: '메일함 용량이 꽉 찼대요', user: '김민지', expect: ['보관함', '삭제'] },\n { question: 'T-1002 처리 상태 알려 주세요', user: '박서연', expect: ['처리 중'] },\n { question: 'T-1001 어떻게 됐나요?', user: '이준호', expect: ['처리 완료', '완료'] },\n];\nreturn golden.map((g) => ({ json: { ...g, release, t0: Date.now() } }));"
},
"position": [
440,
300
],
"id": "9f9a1078-aab9-55d5-ac81-4292f7d22e73"
},
{
"name": "Ask helpdesk",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-helpdesk",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.question, user: $json.user, release: $json.release }) }}"
},
"position": [
660,
300
],
"id": "30940a89-e2b9-5153-bb09-9eca37792e8a"
},
{
"name": "Judge",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const golden = $('Golden questions').all().map((item) => item.json);\nconst answers = $input.all().map((item) => item.json);\nconst rows = golden.map((g, i) => {\n const a = answers[i] ?? {};\n const text = String(a.answer ?? '');\n const ok = !a.error && g.expect.some((word) => text.includes(word)) && (a.version?.release === g.release);\n return { question: g.question, ok, release_seen: a.version?.release ?? null };\n});\nconst passed = rows.filter((row) => row.ok).length;\nconst failed = rows.filter((row) => !row.ok).map((row) => row.question);\nconst seconds = Math.round((Date.now() - golden[0].t0) / 100) / 10;\nreturn [{ json: { release: golden[0].release, passed, total: rows.length, failed, seconds,\n verdict: passed === rows.length ? 'promote' : 'rollback' } }];"
},
"position": [
880,
300
],
"id": "5cbfc582-550a-5e91-aab4-bf3df3e17fcf"
},
{
"name": "Send verdict",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1100,
300
],
"id": "f93d3e49-5d3d-5a46-afb6-c694f03e65ee"
}
],
"connections": {
"Check webhook": {
"main": [
[
{
"node": "Has release?",
"type": "main",
"index": 0
}
]
]
},
"Has release?": {
"main": [
[
{
"node": "Golden questions",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Golden questions": {
"main": [
[
{
"node": "Ask helpdesk",
"type": "main",
"index": 0
}
]
]
},
"Ask helpdesk": {
"main": [
[
{
"node": "Judge",
"type": "main",
"index": 0
}
]
]
},
"Judge": {
"main": [
[
{
"node": "Send verdict",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,243 @@
{
"name": "74 Release check + gate record (answer)",
"nodes": [
{
"name": "Check webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "my-release-check",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "32c84fbc-d108-5979-a1bf-a11fb0028d96",
"position": [
0,
300
],
"id": "d961a9dc-450e-5d96-8451-dbc5fca3ba66"
},
{
"name": "Has release?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.release }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "2ba94bc7-bef4-51e1-b534-669e49072171"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"release is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "87efe7b5-90b5-5674-8e8b-5e28a9cca01c"
},
{
"name": "Golden questions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const release = String($('Check webhook').first().json.body.release);\nconst golden = [\n { question: 'VPN 접속이 자꾸 끊겨요. 어떻게 하나요?', user: '이준호', expect: ['인증서'] },\n { question: '비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?', user: '한지훈', expect: ['비밀번호 찾기', '휴대폰'] },\n { question: '프린터 출력이 안 돼요', user: '최유진', expect: ['대기열', '전원'] },\n { question: '메일함 용량이 꽉 찼대요', user: '김민지', expect: ['보관함', '삭제'] },\n { question: 'T-1002 처리 상태 알려 주세요', user: '박서연', expect: ['처리 중'] },\n { question: 'T-1001 어떻게 됐나요?', user: '이준호', expect: ['처리 완료', '완료'] },\n];\nreturn golden.map((g) => ({ json: { ...g, release, t0: Date.now() } }));"
},
"position": [
440,
300
],
"id": "9b1783b9-375e-509d-8990-992a70b79062"
},
{
"name": "Ask helpdesk",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://localhost:5678/webhook/my-helpdesk",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.question, user: $json.user, release: $json.release }) }}"
},
"position": [
660,
300
],
"id": "b2944162-4641-5f43-9468-89d4d3cab67d"
},
{
"name": "Judge",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const golden = $('Golden questions').all().map((item) => item.json);\nconst answers = $input.all().map((item) => item.json);\nconst rows = golden.map((g, i) => {\n const a = answers[i] ?? {};\n const text = String(a.answer ?? '');\n const ok = !a.error && g.expect.some((word) => text.includes(word)) && (a.version?.release === g.release);\n return { question: g.question, ok, release_seen: a.version?.release ?? null };\n});\nconst passed = rows.filter((row) => row.ok).length;\nconst failed = rows.filter((row) => !row.ok).map((row) => row.question);\nconst seconds = Math.round((Date.now() - golden[0].t0) / 100) / 10;\nreturn [{ json: { release: golden[0].release, passed, total: rows.length, failed, seconds,\n verdict: passed === rows.length ? 'promote' : 'rollback' } }];"
},
"position": [
880,
300
],
"id": "00c58707-3b5f-5880-921e-b664bb6c6001"
},
{
"name": "Send verdict",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({ ...$('Judge').first().json, recorded: $json.stored === true }) }}",
"options": {}
},
"position": [
1320,
300
],
"id": "548d6719-91e4-5920-ba38-2abb01730d92"
},
{
"name": "Record gate",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1100,
300
],
"id": "37a5dc22-d6e9-58c7-ac41-fdafcd25d9d0",
"onError": "continueRegularOutput",
"parameters": {
"method": "POST",
"url": "http://data-api:8800/gates/helpdesk",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ release: $json.release, verdict: $json.verdict, passed: $json.passed, total: $json.total, failed: $json.failed, seconds: $json.seconds, source: 'my-release-check' }) }}",
"options": {
"timeout": 3000,
"response": {
"response": {
"neverError": true
}
}
}
}
}
],
"connections": {
"Check webhook": {
"main": [
[
{
"node": "Has release?",
"type": "main",
"index": 0
}
]
]
},
"Has release?": {
"main": [
[
{
"node": "Golden questions",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Golden questions": {
"main": [
[
{
"node": "Ask helpdesk",
"type": "main",
"index": 0
}
]
]
},
"Ask helpdesk": {
"main": [
[
{
"node": "Judge",
"type": "main",
"index": 0
}
]
]
},
"Judge": {
"main": [
[
{
"node": "Record gate",
"type": "main",
"index": 0
}
]
]
},
"Record gate": {
"main": [
[
{
"node": "Send verdict",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Quick check of the day 74 data API (instructor, before class): releases, policy, gates, prompt fault switch.
set -u
B=localhost:8800
show() { echo "\$ $1"; shift; "$@"; echo; }
show "GET /releases/helpdesk" curl -s $B/releases/helpdesk
show "policy on" curl -s -XPOST $B/releases/helpdesk/policy -d '{"require_gate":true}'
show "deploy 1.1.0 without a gate record (409)" curl -s -XPOST $B/releases/helpdesk -d '{"active":"1.1.0"}'
show "gate record 1.1.0 promote" curl -s -XPOST $B/gates/helpdesk -d '{"release":"1.1.0","verdict":"promote","passed":6,"total":6,"source":"check"}'
show "deploy 1.1.0 again (200)" curl -s -XPOST $B/releases/helpdesk -d '{"active":"1.1.0"}'
show "fault prompts error" curl -s -XPOST $B/admin/fault -d '{"prompts":"error"}'
show "GET /prompts/helpdesk/p1 (500)" curl -s $B/prompts/helpdesk/p1
show "fault prompts ok" curl -s -XPOST $B/admin/fault -d '{"prompts":"ok"}'
show "GET /prompts/helpdesk/p9 (404)" curl -s $B/prompts/helpdesk/p9
show "reset" curl -s -XPOST $B/releases/helpdesk/reset
show "gates clear" curl -s -XPOST $B/gates/helpdesk/clear
show "GET /audit (day 73 endpoint still there)" curl -s $B/audit
+36
View File
@@ -0,0 +1,36 @@
# tver IMP-PLAN
## 엔진 1단계 · 정리하고 비교하기 (R1 · R2 · R3 · R4)
- [x] `workflow.clean`: id · position · webhookId · 내보내기 도장 빼기, 노드 이름순 · `dumps`는 키 정렬
- [x] `diff.compare(old, new)` → `Change{op, node, category, key, before, after, lines}`
- [x] `diff.category`: Chat model의 model → model, systemMessage · text · jsCode → prompt, 나머지 → flow
- [x] 80자 넘거나 여러 줄이면 `difflib` 줄 diff
- [x] tests: v1 → v2 7개 · 1/1/5, 위치 · id · 순서만 바꾼 파일 0개, systemMessage에 `+ … [FAQ-003]`
- [x] cli: `tver diff a.json b.json`
- 확인: `uv run pytest -q`, `uv run tver diff tests/data/helpdesk-v1.json tests/data/helpdesk-v2.json`
## 엔진 2단계 · git에 저장 · 목록 (R5 · R6)
- [x] `Store.save_workflow`: 정리한 글이 같으면 "no change", 다르면 쓰고 커밋 (메시지 = 바로 전과의 요약)
- [x] `Store.log` · `Store.show`, `tver diff <rev1> <rev2> <name>`
- [x] tests: 두 번 저장 → 버전 1개, v2로 바꿔 저장 → 메시지에 "model 1 · prompt 1 · flow 5"
- 확인: `uv run tver --repo /tmp/s save tests/data/helpdesk-v1.json` 두 번, `log`
## TUI 1단계 · 버전 목록 · 바뀐 것 트리 · 자세히 (R8)
- [x] 왼쪽 ListView(버전), 오른쪽 Tree(노드 → 바뀐 것), 아래 자세히 (줄 diff 또는 전 · 후)
- [x] 고른 버전 vs 바로 전 버전 비교는 worker
- [x] `[s]` 지금 파일을 버전으로 저장
- [x] tests: 저장 1개 → `[s]` → 2개, 트리 제목에 "model 1 · prompt 1 · flow 5"
## 설치 (R14)
- [x] `[project.scripts] tver = "tver.cli:main"`, 저장소 기본 `~/.tver/store` (`--repo` · `$TVER_REPO`)
- 확인: `uv tool install --editable .`, `cd /tmp && tver --help`
## 엔진 3단계 · 키 거부 · JSON · 폴더 (R7 · R11 · R13)
- [x] `find_secrets`: `sk-…` · AWS · GitHub · Slack 토큰 모양, 메시지에는 앞 5자 + 끝 2자만
- [x] `--json`, 종료 코드 0 / 1 / 2
- [x] `save <폴더>`: `nodes` · `connections`가 있는 것만, 나머지 skipped
## 엔진 4단계 · n8n에서 가져오기 · 꺼내기 · 이름 바꿈 (R9 · R10 · R12)
- [x] `pull`: `docker exec <컨테이너> n8n export:workflow --all` → 이름별 저장 (n8n에는 쓰지 않음)
- [x] `restore <rev> <name> -o <파일>`: `git show`로 꺼내 파일로, 저장소는 그대로
- [x] 짝 없는 − · + 노드 중 종류 · 설정이 같은 한 쌍 → `renamed`, 연결도 새 이름으로 맞춰 비교
+48
View File
@@ -0,0 +1,48 @@
# tver · 강사가 수업 준비 때 직접 만든 본 (2026-10-27)
> **정답 자료** · 학생 저장소에는 수업 뒤 [VIBE-A-TVER.md](../VIBE-A-TVER.md)와 함께 올립니다.
> 이 본은 강사가 CLI 에이전트에 단계 요청문을 보내 만든 것이 아니라, Claude Code가 VIBE-A의 SPEC · IMP-PLAN을 보고 직접 썼습니다. 학생이 만든 것과 파일 구성이 달라도 됩니다. 요구 R1~R14의 **확인 방법**이 같으면 됩니다.
## 설치 · 실행
```bash
cd ~/aio-01d4-stu/Last3Mon/74/tver
uv sync && uv run pytest -q # 테스트 11개 (저장소는 tmp_path, 가짜 키는 테스트 안에서 만듦)
uv tool install --editable . # 어느 폴더에서나 tver (R14)
cd /tmp && tver --help
```
| 하고 싶은 것 | 명령 |
| --- | --- |
| 두 파일 비교 | `tver diff a.json b.json` · 줄 diff 없이 `--short` · 다른 프로그램용 `--json` |
| 버전으로 저장 | `tver save my-work/helpdesk.json` · 폴더 `tver save my-work` (워크플로우만) |
| 버전 목록 · 비교 | `tver log helpdesk.json` · `tver diff <rev1> <rev2> helpdesk.json` |
| 한 버전 보기 · 꺼내기 | `tver show <rev> helpdesk.json` · `tver restore <rev> helpdesk.json -o my-work/helpdesk.json` |
| n8n에서 모두 가져오기 | `tver pull [--container day65-n8n-n8n-1]` (읽기만) |
| 화면(TUI) | `tver my-work/helpdesk.json` (`[s]` 지금 파일을 저장, `[q]` 끝) |
- 저장소: `~/.tver/store` (또는 `--repo <폴더>`, `$TVER_REPO`). 내 프로젝트 폴더 밖에 따로 둡니다.
- 종료 코드: `diff` 0 같음 · 1 다름, 모든 명령 2 오류 (파일이 없음 · 워크플로우가 아님 · 키가 있어 저장 거부).
## 파일
| 파일 | 하는 일 | 요구 |
| --- | --- | --- |
| `src/tver/workflow.py` | 읽기, 뜻 없는 칸 빼기(`clean`), 키 정렬 저장 글(`dumps`), 키 모양 찾기(가려서) | R2 · R7 |
| `src/tver/diff.py` | 노드 이름으로 짝짓기, 설정 · 연결 비교, 줄 diff, 모델 · 지시문 · 흐름 분류, 이름 바꿈 | R1 · R3 · R4 · R12 |
| `src/tver/store.py` | git 저장소: 저장(같으면 안 함) · 목록 · 꺼내기, 폴더 저장, n8n에서 내보내기 | R5 · R6 · R9 · R10 · R13 |
| `src/tver/cli.py` | 명령 · `--json` · 종료 코드 | R11 |
| `src/tver/app.py` | Textual 화면 | R8 |
| `tests/` | v1 · v2(`tests/data`, 비밀 없음)로 엔진 · 저장 · 화면 | |
| `Doc/IMP-PLAN.md` | 만든 순서 (엔진 1 → 2 → TUI 1 → 설치 → 엔진 3 · 4) | |
## 2026-10-27에 확인한 것
| 확인 | 결과 |
| --- | --- |
| `uv run pytest -q` | 11 passed |
| `tver diff reference/helpdesk-v1.json reference/helpdesk-v2.json --short` | `flow.py diff`와 같은 7줄, `7 change(s) · model 1 · prompt 1 · flow 5`, 종료 코드 1 |
| 오늘 Lab의 helpdesk 정답 여섯 단계를 차례로 `save` | 버전 6개, 메시지 예 "15 change(s) · model 1 · prompt 1 · flow 13" (Lab 01 과제 2 → Lab 02 예제 1) |
| `tver save answers` | 워크플로우 5개 저장, 테스트 파일 `74-lab02-challenge-golden.json`은 skipped |
| `tver pull` (강사 n8n) | 192 new version(s) — 강사 n8n에 있던 워크플로우 전부, n8n은 그대로 |
| `uv tool install --editable .` → `/tmp`에서 `tver restore` → 원본과 `tver diff` | `0 change(s)`, 종료 코드 0 |
+22
View File
@@ -0,0 +1,22 @@
[project]
name = "tver"
version = "0.1.0"
description = "Keep n8n workflow files as versions in git and compare two versions node by node"
requires-python = ">=3.11"
dependencies = ["textual>=0.80"]
[project.scripts]
tver = "tver.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/tver"]
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
+1
View File
@@ -0,0 +1 @@
"""tver: n8n workflow versions in git, compared node by node."""
+101
View File
@@ -0,0 +1,101 @@
"""Textual screen (R8): versions on the left, changed nodes as a tree on the right, the chosen change below.
`[s]` saves the file again as a new version, `[q]` quits."""
from pathlib import Path
from textual import work
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Footer, Header, ListItem, ListView, Static, Tree
from . import diff
from .store import Store, safe_name
from .workflow import load
class VersionApp(App):
TITLE = "tver"
BINDINGS = [("s", "save", "Save file as a version"), ("q", "quit", "Quit")]
CSS = """
#summary { height: auto; margin: 0 1; }
#versions { width: 40; border: round $accent; }
#changes { border: round $accent; }
#detail { height: 10; border: round $accent; padding: 0 1; }
"""
def __init__(self, target, store: Store | None = None):
super().__init__()
self.file = Path(target) if Path(target).exists() else None
self.name_ = safe_name(Path(target).name)
self.store = store or Store()
self.versions = []
self.changes = []
def compose(self) -> ComposeResult:
yield Header()
yield Static("loading...", id="summary")
with Horizontal():
yield ListView(id="versions")
yield Tree("changes", id="changes")
yield Static("", id="detail")
yield Footer()
def on_mount(self):
self.sub_title = f"{self.name_} · {self.store.repo}"
self.reload()
def reload(self):
self.versions = self.store.log(self.name_)
view = self.query_one(ListView)
view.clear()
for v in self.versions:
view.append(ListItem(Static(f"{v.rev} {v.date[5:16]} {v.message.split(': ', 1)[-1]}")))
self.query_one("#summary", Static).update(
f"{len(self.versions)} version(s)" + ("" if self.versions else f" · none yet: press [s] or run tver save {self.file or self.name_}"))
if self.versions:
view.index = 0
self.compare(0)
def on_list_view_highlighted(self, event):
if event.list_view.index is not None and 0 <= event.list_view.index < len(self.versions):
self.compare(event.list_view.index)
@work(thread=True, exclusive=True)
def compare(self, index: int):
"""The chosen version against the one before it (the oldest against nothing)."""
new = self.store.show(self.versions[index].rev, self.name_)
old = self.store.show(self.versions[index + 1].rev, self.name_) if index + 1 < len(self.versions) \
else {"nodes": [], "connections": {}}
self.call_from_thread(self.show_changes, index, diff.compare(old, new))
def show_changes(self, index: int, changes):
self.changes = changes
tree = self.query_one(Tree)
tree.clear()
before = self.versions[index + 1].rev if index + 1 < len(self.versions) else "(empty)"
tree.root.set_label(f"{before} → {self.versions[index].rev} · {diff.summary_line(diff.summary(changes))}")
groups = {}
for i, change in enumerate(changes):
parent = groups.get(change.node)
if parent is None:
parent = groups[change.node] = tree.root.add(change.node, expand=True)
parent.add_leaf(f"[{change.category}] {change.title()}", data=i)
tree.root.expand()
self.query_one("#detail", Static).update("choose a change" if changes else "no change")
def on_tree_node_highlighted(self, event):
index = event.node.data
if index is None or not 0 <= index < len(self.changes):
return
change = self.changes[index]
rows = [change.title()]
rows += change.lines or [f"- {diff.short(change.before, 200)}", f"+ {diff.short(change.after, 200)}"] \
if change.op == "~set" else []
self.query_one("#detail", Static).update("\n".join(rows[:12]))
def action_save(self):
if not self.file:
self.query_one("#summary", Static).update("open tver with a file path to save it")
return
status, message = self.store.save_workflow(load(self.file), self.name_)
self.reload()
self.query_one("#summary", Static).update(f"{status} {message}".strip() + f" · {len(self.versions)} version(s)")
+121
View File
@@ -0,0 +1,121 @@
"""Command line. Exit codes: diff 0 same · 1 different, 2 error (any command)."""
import argparse
import json
import sys
from pathlib import Path
from . import diff
from .store import DEFAULT_REPO, Store, StoreError, export_from_n8n, safe_name
from .workflow import NotWorkflow, load
COMMANDS = ("diff", "save", "log", "show", "restore", "pull")
def load_side(store: Store, spec: str, name: str | None) -> dict:
"""A file path, or a revision of <name> in the store."""
if Path(spec).exists():
return load(spec)
if not name:
raise StoreError(f"{spec}: no such file (to compare versions give the workflow name: tver diff <rev1> <rev2> <name>)")
return store.show(spec, name)
def print_changes(changes, full=False):
for change in changes:
print(change.title())
if change.lines and full:
for row in change.lines:
print(f" {row}")
print(diff.summary_line(diff.summary(changes)))
def run_diff(args, store):
old = load_side(store, args.old, args.name)
new = load_side(store, args.new, args.name)
changes = diff.compare(old, new)
if args.json:
print(json.dumps({"changes": [c.to_dict() for c in changes], "summary": diff.summary(changes)},
ensure_ascii=False, indent=2))
else:
print(f"{old.get('name', args.old)} → {new.get('name', args.new)}")
print_changes(changes, full=not args.short)
return 1 if changes else 0
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if argv and argv[0] not in COMMANDS and not argv[0].startswith("-"):
from .app import VersionApp # `tver <workflow file or name>` opens the TUI
repo = None
if "--repo" in argv:
repo = argv[argv.index("--repo") + 1]
VersionApp(argv[0], Store(repo)).run()
sys.exit(0)
p = argparse.ArgumentParser(prog="tver", description="n8n workflow versions in git, compared node by node. "
"`tver <file or name>` opens the TUI.")
p.add_argument("--repo", default=None, help=f"version store (default {DEFAULT_REPO}, or $TVER_REPO)")
sub = p.add_subparsers(dest="cmd", required=True)
d = sub.add_parser("diff", help="compare two files, or two versions: tver diff <rev1> <rev2> <name>")
d.add_argument("old")
d.add_argument("new")
d.add_argument("name", nargs="?", help="workflow name in the store when old/new are revisions")
d.add_argument("--plain", action="store_true", help="plain lines (default)")
d.add_argument("--short", action="store_true", help="no line diff for long text")
d.add_argument("--json", action="store_true", help="JSON for other programs")
s = sub.add_parser("save", help="save a workflow file (or every workflow file in a folder) as a new version")
s.add_argument("path")
s.add_argument("--allow-secrets", action="store_true", help="save even if it looks like it has a secret (not advised)")
lg = sub.add_parser("log", help="versions of one workflow")
lg.add_argument("name")
lg.add_argument("--json", action="store_true")
sh = sub.add_parser("show", help="print one version")
sh.add_argument("rev")
sh.add_argument("name")
r = sub.add_parser("restore", help="write an old version to a file (then push it yourself)")
r.add_argument("rev")
r.add_argument("name")
r.add_argument("-o", "--out", required=True, help="file to write, e.g. my-work/helpdesk.json")
pl = sub.add_parser("pull", help="export every workflow from the running n8n and save each as a version")
pl.add_argument("--container", default="day65-n8n-n8n-1")
args = p.parse_args(argv)
store = Store(args.repo)
try:
if args.cmd == "diff":
return run_diff(args, store)
if args.cmd == "save":
rows = store.save(args.path, args.allow_secrets)
for name, status, message in rows:
print(f"{status:9} {name}" + (f" {message}" if message and status == "saved" else ""))
return 0
if args.cmd == "log":
versions = store.log(args.name)
if args.json:
print(json.dumps([v.__dict__ for v in versions], ensure_ascii=False, indent=2))
for v in versions if not args.json else []:
print(f"{v.rev} {v.date} {v.message}")
if not versions:
print(f"no versions of {safe_name(Path(args.name).name)} in {store.repo}", file=sys.stderr)
return 0
if args.cmd == "show":
print(json.dumps(store.show(args.rev, args.name), ensure_ascii=False, indent=2))
return 0
if args.cmd == "restore":
out = store.restore(args.rev, args.name, args.out)
print(f"wrote {out} from {args.rev}. Push it yourself: uv run tools/flow.py push {out}")
return 0
if args.cmd == "pull":
saved = 0
for workflow in export_from_n8n(args.container):
status, message = store.save_workflow(workflow, safe_name(str(workflow.get("name", "workflow"))))
saved += status == "saved"
print(f"{status:9} {safe_name(str(workflow.get('name')))}" + (f" {message}" if status == "saved" else ""))
print(f"{saved} new version(s) in {store.repo}")
return 0
except (StoreError, NotWorkflow) as error:
print(f"tver: {error}", file=sys.stderr)
return 2
return 2
if __name__ == "__main__":
sys.exit(main())
+147
View File
@@ -0,0 +1,147 @@
"""Node-by-node difference of two workflows (R1), line diff for long text (R3),
model · prompt · flow summary (R4), renamed nodes (R12)."""
import difflib
import json
from dataclasses import asdict, dataclass, field
from .workflow import NOISE
LONG = 80
PROMPT_KEYS = ("systemMessage", "text", "jsCode", "pythonCode", "prompt")
@dataclass
class Change:
op: str # "+node" "-node" "~set" "renamed" "+link" "-link"
node: str
category: str # "model" | "prompt" | "flow"
key: str = ""
before: object = None
after: object = None
lines: list = field(default_factory=list) # unified diff lines for long text
def to_dict(self):
return asdict(self)
def title(self):
if self.op == "+node":
return f"+ node '{self.node}' ({self.after})"
if self.op == "-node":
return f"- node '{self.node}' ({self.before})"
if self.op == "renamed":
return f"~ renamed '{self.before}' → '{self.after}'"
if self.op in ("+link", "-link"):
return f"{self.op[0]} link {self.node} → {self.after if self.op == '+link' else self.before} ({self.key})"
if self.lines:
return f"~ '{self.node}' {self.key}: changed ({len(text(self.before))} → {len(text(self.after))} chars)"
return f"~ '{self.node}' {self.key}: {short(self.before)} → {short(self.after)}"
def text(value) -> str:
if value is None:
return "(none)"
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True)
def short(value, width=60) -> str:
s = text(value)
return s if len(s) <= width else s[: width - 1] + "…"
def flat(value, prefix=""):
if isinstance(value, dict) and value:
out = {}
for key, item in value.items():
out.update(flat(item, f"{prefix}.{key}" if prefix else str(key)))
return out
return {prefix: value}
def kind(node) -> str:
return str(node.get("type", "?")).split(".")[-1]
def category(node, key: str) -> str:
"""Chat model's model → model; Agent / chain text · systemMessage and Code → prompt; the rest → flow."""
t = kind(node).lower()
last = key.split(".")[-1]
if t.startswith("lmchat") or t.startswith("lm") or "embeddings" in t:
if last in ("model", "value", "modelName") and ("model" in key):
return "model"
if last in PROMPT_KEYS:
return "prompt"
return "flow"
def node_settings(node) -> dict:
body = {k: v for k, v in node.items() if k not in NOISE and k != "name"}
return flat(body)
def links(workflow) -> set:
out = set()
for src, kinds in (workflow.get("connections") or {}).items():
for link_kind, outputs in (kinds or {}).items():
for index, output in enumerate(outputs or []):
for dst in output or []:
out.add((src, f"{link_kind}[{index}]", dst.get("node")))
return out
def line_diff(before, after) -> list[str]:
a, b = text(before).splitlines(), text(after).splitlines()
return [row for row in difflib.unified_diff(a, b, "before", "after", lineterm="", n=1)][2:]
def compare(old: dict, new: dict) -> list[Change]:
old_nodes = {n.get("name"): n for n in old.get("nodes", [])}
new_nodes = {n.get("name"): n for n in new.get("nodes", [])}
gone = sorted(set(old_nodes) - set(new_nodes))
added = sorted(set(new_nodes) - set(old_nodes))
renamed = {}
for name in list(gone):
twin = [a for a in added if kind(new_nodes[a]) == kind(old_nodes[name])
and node_settings(new_nodes[a]) == node_settings(old_nodes[name])]
if len(twin) == 1:
renamed[name] = twin[0]
gone.remove(name)
added.remove(twin[0])
changes = []
for name in added:
changes.append(Change("+node", name, "flow", after=kind(new_nodes[name])))
for name in gone:
changes.append(Change("-node", name, "flow", before=kind(old_nodes[name])))
for old_name, new_name in sorted(renamed.items()):
changes.append(Change("renamed", new_name, "flow", before=old_name, after=new_name))
pairs = [(n, n) for n in sorted(set(old_nodes) & set(new_nodes))] + sorted(renamed.items())
for old_name, new_name in pairs:
a, b = node_settings(old_nodes[old_name]), node_settings(new_nodes[new_name])
for key in sorted(set(a) | set(b)):
if a.get(key) == b.get(key):
continue
before, after = a.get(key), b.get(key)
long = max(len(text(before)), len(text(after))) > LONG or "\n" in text(before) + text(after)
changes.append(Change("~set", new_name, category(new_nodes[new_name], key), key, before, after,
line_diff(before, after) if long else []))
rename = {o: n for o, n in renamed.items()}
old_links = {(rename.get(s, s), k, rename.get(d, d)) for s, k, d in links(old)}
new_links = links(new)
for src, link_kind, dst in sorted(new_links - old_links):
changes.append(Change("+link", src, "flow", link_kind, after=dst))
for src, link_kind, dst in sorted(old_links - new_links):
changes.append(Change("-link", src, "flow", link_kind, before=dst))
return changes
def summary(changes: list[Change]) -> dict:
counts = {"model": 0, "prompt": 0, "flow": 0}
for change in changes:
counts[change.category] += 1
nodes = {"+": sum(c.op == "+node" for c in changes), "-": sum(c.op == "-node" for c in changes),
"~": len({c.node for c in changes if c.op == "~set"}), "renamed": sum(c.op == "renamed" for c in changes)}
return {"total": len(changes), **counts, "nodes": nodes,
"links": {"+": sum(c.op == "+link" for c in changes), "-": sum(c.op == "-link" for c in changes)}}
def summary_line(s: dict) -> str:
return f"{s['total']} change(s) · model {s['model']} · prompt {s['prompt']} · flow {s['flow']}"
+118
View File
@@ -0,0 +1,118 @@
"""Versions in a git repository (R5 save · R6 log/show · R10 restore · R13 folder · R9 pull from n8n)."""
import json
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from . import diff
from .workflow import NotWorkflow, dumps, find_secrets, is_workflow, load, parse
DEFAULT_REPO = Path(os.getenv("TVER_REPO", Path.home() / ".tver" / "store"))
class StoreError(RuntimeError):
pass
@dataclass
class Version:
rev: str
date: str
message: str
def safe_name(name: str) -> str:
keep = "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in name.strip())
return (keep or "workflow").lower() + ("" if keep.endswith(".json") else ".json")
class Store:
def __init__(self, repo=None):
self.repo = Path(repo or DEFAULT_REPO).expanduser()
def git(self, *args, check=True) -> str:
result = subprocess.run(["git", "-C", str(self.repo), *args], capture_output=True, text=True)
if check and result.returncode != 0:
raise StoreError((result.stderr or result.stdout).strip() or f"git {args[0]} failed")
return result.stdout
def ensure(self):
if not (self.repo / ".git").exists():
self.repo.mkdir(parents=True, exist_ok=True)
self.git("init", "-q")
self.git("config", "user.name", "tver")
self.git("config", "user.email", "tver@localhost")
def save_workflow(self, workflow: dict, name: str, force_secrets=False) -> tuple[str, str]:
"""Write the cleaned JSON as <name> and commit when it changed. Returns (status, message)."""
text = dumps(workflow)
secrets = find_secrets(text)
if secrets and not force_secrets:
raise StoreError(f"{name}: looks like a secret ({', '.join(secrets)}). Not saved. "
"Remove it (use an n8n Credential) and save again.")
self.ensure()
target = self.repo / name
previous = target.read_text(encoding="utf-8") if target.exists() else None
if previous == text:
return "no change", ""
message = f"{name}: new" if previous is None else \
f"{name}: " + diff.summary_line(diff.summary(diff.compare(parse(previous, name), parse(text, name))))
target.write_text(text, encoding="utf-8")
self.git("add", name)
self.git("commit", "-q", "-m", message)
return "saved", message
def save(self, path, force_secrets=False) -> list[tuple[str, str, str]]:
"""A file, or every workflow file in a folder (others are skipped). Returns (name, status, message)."""
path = Path(path)
files = sorted(path.glob("*.json")) if path.is_dir() else [path]
out = []
for file in files:
try:
workflow = load(file)
except NotWorkflow:
if path.is_dir():
out.append((file.name, "skipped", "not a workflow"))
continue
raise
status, message = self.save_workflow(workflow, safe_name(file.name), force_secrets)
out.append((safe_name(file.name), status, message))
return out
def names(self) -> list[str]:
if not (self.repo / ".git").exists():
return []
return sorted(line for line in self.git("ls-files").splitlines() if line.endswith(".json"))
def log(self, name: str) -> list[Version]:
name = safe_name(Path(name).name)
if not (self.repo / ".git").exists():
return []
rows = self.git("log", "--format=%h%x09%ad%x09%s", "--date=format:%Y-%m-%d %H:%M:%S", "--", name, check=False)
return [Version(*row.split("\t", 2)) for row in rows.splitlines() if row.count("\t") >= 2]
def show(self, rev: str, name: str) -> dict:
name = safe_name(Path(name).name)
return parse(self.git("show", f"{rev}:{name}"), f"{rev}:{name}")
def restore(self, rev: str, name: str, out) -> Path:
"""Write that version to a file (the person pushes it to n8n). The store itself is not changed."""
workflow = self.show(rev, name)
out = Path(out)
out.write_text(json.dumps(workflow, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return out
def export_from_n8n(container: str) -> list[dict]:
"""R9: every workflow in the running n8n container (docker exec ... n8n export:workflow --all)."""
command = ["docker", "exec", container, "sh", "-c",
"n8n export:workflow --all --output=/tmp/tver-all.json >/dev/null 2>&1; cat /tmp/tver-all.json; rm -f /tmp/tver-all.json"]
try:
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
except (OSError, subprocess.TimeoutExpired) as error:
raise StoreError(f"docker exec failed: {error}") from error
if result.returncode != 0 or not result.stdout.strip():
raise StoreError((result.stderr or "no output").strip() + f"\n Is the n8n container '{container}' running?")
data = json.loads(result.stdout)
return [item for item in (data if isinstance(data, list) else [data]) if is_workflow(item)]
+55
View File
@@ -0,0 +1,55 @@
"""Read and clean n8n workflow JSON: drop values that change without meaning (R2), find secrets (R7)."""
import json
import re
from pathlib import Path
NOISE = {"id", "position", "webhookId", "versionId", "meta", "pinData", "updatedAt", "createdAt", "active", "shared", "tags"}
SECRET = re.compile(r"\b(sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{30,}|xox[abp]-[A-Za-z0-9-]{10,})")
class NotWorkflow(ValueError):
pass
def is_workflow(data) -> bool:
return isinstance(data, dict) and isinstance(data.get("nodes"), list) and isinstance(data.get("connections"), dict)
def load(path) -> dict:
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise NotWorkflow(f"{path}: {error}") from error
if not is_workflow(data):
raise NotWorkflow(f"{path}: not an n8n workflow (needs nodes and connections)")
return data
def parse(text: str, label: str = "workflow") -> dict:
try:
data = json.loads(text)
except json.JSONDecodeError as error:
raise NotWorkflow(f"{label}: {error}") from error
if not is_workflow(data):
raise NotWorkflow(f"{label}: not an n8n workflow")
return data
def clean(workflow: dict) -> dict:
"""Same meaning → same JSON: no ids, positions or export stamps; nodes sorted by name."""
nodes = []
for node in workflow.get("nodes", []):
nodes.append({k: v for k, v in node.items() if k not in NOISE})
nodes.sort(key=lambda n: str(n.get("name", "")))
out = {k: v for k, v in workflow.items() if k not in NOISE | {"nodes"}}
out["nodes"] = nodes
return out
def dumps(workflow: dict) -> str:
return json.dumps(clean(workflow), ensure_ascii=False, indent=2, sort_keys=True) + "\n"
def find_secrets(text: str) -> list[str]:
"""Masked secret-looking values (sk-… keys etc.). Credential references (id · name) are fine."""
return [m.group(0)[:5] + "…" + m.group(0)[-2:] for m in SECRET.finditer(text)]
+33
View File
@@ -0,0 +1,33 @@
import json
import shutil
from pathlib import Path
import pytest
DATA = Path(__file__).parent / "data"
@pytest.fixture
def v1():
return json.loads((DATA / "helpdesk-v1.json").read_text(encoding="utf-8"))
@pytest.fixture
def v2():
return json.loads((DATA / "helpdesk-v2.json").read_text(encoding="utf-8"))
@pytest.fixture
def work(tmp_path):
"""A my-work folder with v1 as helpdesk.json, a note and a cases file (not workflows)."""
folder = tmp_path / "my-work"
folder.mkdir()
shutil.copy(DATA / "helpdesk-v1.json", folder / "helpdesk.json")
(folder / "PROMPTS.md").write_text("# prompts\n", encoding="utf-8")
(folder / "cases.json").write_text('{"webhook": "x", "cases": []}', encoding="utf-8")
return folder
@pytest.fixture
def store_dir(tmp_path):
return tmp_path / "store"
@@ -0,0 +1,286 @@
{
"name": "74 IT helpdesk v1 (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "rel-v1",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "0709e90d-e8ea-509c-8c91-1721c012d260",
"position": [
0,
300
],
"id": "d087d97a-46d1-544f-b742-78984643db81"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "0cafcebc-1ba4-546e-aa2e-22f24643ca5a"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "f60c3d44-3793-50e4-89c1-96c15c0075b0"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
440,
300
],
"id": "7bf6486f-83e4-56c6-9052-ee94305aa025"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
660,
300
],
"id": "9f1e31b4-c9b1-5848-8bc7-4f9f5c5b905b"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
"maxIterations": 1
}
},
"position": [
880,
300
],
"id": "28d25947-3225-58fb-adec-112be9f61a60"
},
{
"name": "Answer 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": [
880,
520
],
"id": "008c5ad6-673c-5e09-ae15-bbcf04777148"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
},
"position": [
1100,
300
],
"id": "5eabccbd-9c75-59f6-ac72-9070ef5f3a89"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1320,
300
],
"id": "95e15847-a230-567c-a1f5-827a415a52a4"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,310 @@
{
"name": "74 IT helpdesk v2 (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "rel-v2",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "4dd9ca0d-6e35-58d9-afb9-36689beca2c7",
"position": [
0,
300
],
"id": "edf3da10-d647-5671-a906-292126b204df"
},
{
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
220,
300
],
"id": "cf8a9038-ff3e-56a3-a17f-9c062bdd0391"
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"position": [
440,
560
],
"id": "3e81f84e-0040-52a4-80a2-e093464dea75"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"position": [
440,
300
],
"id": "7e858b12-8c3b-5b72-aa02-09276f0d63e0"
},
{
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"position": [
660,
300
],
"id": "9265daab-0313-5c12-aa77-7a596f23eae5"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
"options": {
"systemMessage": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.\nWhen the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003]. Keep answers short.",
"maxIterations": 1
}
},
"position": [
880,
300
],
"id": "027a9c55-9b22-5122-85e5-9f8e347fe189"
},
{
"name": "Answer model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-5.4-nano"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
},
"position": [
880,
520
],
"id": "bfe53f6e-ecec-5876-8943-03d0b6dad9a5"
},
{
"name": "Review check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "let answer = String($json.output ?? '').trim();\nif (answer.length > 400) answer = answer.slice(0, 400);\nreturn [{ json: { output: answer } }];"
},
"position": [
1100,
300
],
"id": "0d0c0486-e241-521a-83ca-66b725fd05d0"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
},
"position": [
1320,
300
],
"id": "27c64881-2145-56c1-ab88-8f0a7515dbd2"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
300
],
"id": "5a7d65a6-3b4e-562d-bf7e-1be3756a8cde"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "Review check",
"type": "main",
"index": 0
}
]
]
},
"Review check": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+34
View File
@@ -0,0 +1,34 @@
"""R8: versions list, change tree, detail of the chosen change, [s] saves."""
import asyncio
import json
from textual.widgets import ListView, Tree
from tver.app import VersionApp
from tver.store import Store
async def wait_for(pilot, check):
for _ in range(60):
await asyncio.sleep(0.05)
await pilot.pause()
if check():
return True
return False
async def test_versions_tree_and_save(work, store_dir, v2):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
app = VersionApp(str(work / "helpdesk.json"), store)
async with app.run_test() as pilot:
assert len(app.query_one(ListView).children) == 1
await pilot.press("s")
assert await wait_for(pilot, lambda: len(app.query_one(ListView).children) == 2)
assert await wait_for(pilot, lambda: "model 1 · prompt 1 · flow 5" in str(app.query_one(Tree).root.label))
tree = app.query_one(Tree)
tree.focus()
await pilot.press("down", "down")
await pilot.pause()
assert str(app.query_one("#detail").render()).strip() != ""
+60
View File
@@ -0,0 +1,60 @@
"""R1 · R2 · R3 · R4 · R11 · R12 with the day 74 v1 → v2 files."""
import copy
import json
from tver import diff
from tver.cli import main
from tver.workflow import clean, dumps
def test_v1_to_v2_is_seven_changes_like_flow_py(v1, v2):
changes = diff.compare(v1, v2)
titles = [c.title() for c in changes]
assert "+ node 'Review check' (code)" in titles
assert any(t.startswith("~ 'Answer' parameters.options.systemMessage: changed") for t in titles)
assert "~ 'Answer model' parameters.model.value: gpt-4.1-mini → gpt-5.4-nano" in titles
assert "~ 'Question webhook' parameters.path: rel-v1 → rel-v2" in titles
s = diff.summary(changes)
assert (s["total"], s["model"], s["prompt"], s["flow"]) == (7, 1, 1, 5)
assert s["links"] == {"+": 2, "-": 1}
def test_noise_is_not_a_change(v1):
moved = copy.deepcopy(v1)
for node in moved["nodes"]:
node["position"] = [node["position"][0] + 100, 0]
node["id"] = "other-" + node["name"]
moved["nodes"].reverse()
assert diff.compare(v1, moved) == []
assert dumps(v1) == dumps(moved)
assert all("position" not in n and "id" not in n for n in clean(v1)["nodes"])
def test_long_prompt_gets_line_diff(v1, v2):
change = next(c for c in diff.compare(v1, v2) if c.key.endswith("systemMessage"))
assert change.category == "prompt"
assert any(row.startswith("+") and "[FAQ-003]" in row for row in change.lines)
def test_renamed_node_is_paired(v1):
renamed = copy.deepcopy(v1)
for node in renamed["nodes"]:
if node["name"] == "Search FAQ":
node["name"] = "Find FAQ"
conn = json.dumps(renamed["connections"]).replace('"Search FAQ"', '"Find FAQ"')
renamed["connections"] = json.loads(conn)
changes = diff.compare(v1, renamed)
assert [c.op for c in changes] == ["renamed"]
assert (changes[0].before, changes[0].after) == ("Search FAQ", "Find FAQ")
def test_cli_diff_exit_codes_and_json(tmp_path, v1, v2, capsys):
a, b = tmp_path / "a.json", tmp_path / "b.json"
a.write_text(json.dumps(v1), encoding="utf-8")
b.write_text(json.dumps(v2), encoding="utf-8")
assert main(["diff", str(a), str(a)]) == 0
capsys.readouterr()
assert main(["diff", str(a), str(b), "--json"]) == 1
out = json.loads(capsys.readouterr().out)
assert out["summary"]["total"] == 7
assert main(["diff", str(a), str(tmp_path / "missing.json")]) == 2
+57
View File
@@ -0,0 +1,57 @@
"""R5 save · R6 log/diff of versions · R7 no secrets · R10 restore · R13 folder."""
import json
import pytest
from tver.cli import main
from tver.store import Store, StoreError
FAKE_KEY = "sk-" + "test-" + "a" * 24 # made here, never stored in a file of this repository
def test_save_twice_makes_one_commit(work, store_dir):
store = Store(store_dir)
assert store.save(work / "helpdesk.json")[0][1] == "saved"
assert store.save(work / "helpdesk.json")[0][1] == "no change"
assert len(store.log("helpdesk.json")) == 1
def test_versions_and_diff_between_them(work, store_dir, v2, capsys):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
name, status, message = store.save(work / "helpdesk.json")[0]
assert status == "saved" and "model 1 · prompt 1 · flow 5" in message
versions = store.log("helpdesk.json")
assert [v.message.split(": ")[0] for v in versions] == ["helpdesk.json", "helpdesk.json"]
assert main(["--repo", str(store_dir), "diff", versions[1].rev, versions[0].rev, "helpdesk.json", "--short"]) == 1
assert "7 change(s) · model 1 · prompt 1 · flow 5" in capsys.readouterr().out
def test_secret_is_not_saved(work, store_dir):
data = json.loads((work / "helpdesk.json").read_text(encoding="utf-8"))
data["nodes"][0]["parameters"]["note"] = FAKE_KEY
(work / "helpdesk.json").write_text(json.dumps(data), encoding="utf-8")
with pytest.raises(StoreError) as error:
Store(store_dir).save(work / "helpdesk.json")
assert FAKE_KEY not in str(error.value)
assert not (store_dir / "helpdesk.json").exists()
def test_restore_writes_a_file_and_keeps_the_store(work, store_dir, v2, tmp_path):
store = Store(store_dir)
store.save(work / "helpdesk.json")
(work / "helpdesk.json").write_text(json.dumps(v2), encoding="utf-8")
store.save(work / "helpdesk.json")
oldest = store.log("helpdesk.json")[-1].rev
out = store.restore(oldest, "helpdesk.json", tmp_path / "back.json")
restored = json.loads(out.read_text(encoding="utf-8"))
model = next(n for n in restored["nodes"] if n["name"] == "Answer model")
assert model["parameters"]["model"]["value"] == "gpt-4.1-mini"
assert len(store.log("helpdesk.json")) == 2
def test_folder_save_skips_files_that_are_not_workflows(work, store_dir):
rows = Store(store_dir).save(work)
assert ("cases.json", "skipped", "not a workflow") in rows
assert [r[0] for r in rows if r[1] == "saved"] == ["helpdesk.json"]
+195
View File
@@ -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.13.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/43/8c4b5fe23f0e119cf4e1b16c2d16240ab9057cdbb138568c91d5a48abf81/platformdirs-4.13.6.tar.gz", hash = "sha256:211d176e24674b5895dedf9021c262bcf9fad008a70610bfb147d4a8d6d8fe1b", size = 62750, upload-time = "2026-10-27T08:19:11.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/24/b312a9466839b1f708eed42790b47e927dc7f774a528bb49b1cdf8b734b9/platformdirs-4.13.6-py3-none-any.whl", hash = "sha256:21fc198fb09f3e78cadcdfd8a6ae4fd65cadc64a3cba78b694321c2996114a3e", size = 32767, upload-time = "2026-10-27T08:19:10.405Z" },
]
[[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.1.1"
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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[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 = "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 = "tver"
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 = "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" },
]