72일차 자료 추가(답 제외)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-25 22:57:12 +09:00
1 parent aaf77d4e18
commit febde9757c
63 files changed
+12260

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+232
View File
@@ -0,0 +1,232 @@
# AGENTS.md · n8n 실행 흐름 최적화·안정성 작업 규칙 (72일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 71일차 규칙과 같고, 3절이 오늘 만드는 **헬프데스크 · 상태 점검의 계약**, 5절에 **시간 기록 · 캐시 · 타임아웃 · 재시도 · 오류 출력**이 더해졌습니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 빠르게 하는 요청이면 **어느 단계를 줄이거나 건너뛰는지**, 안정성 요청이면 **어느 노드가 실패하면 어디로 가는지**도 함께 보여 준다.
2. 워크플로우 JSON을 `my-work/<이름>.json`에 쓴다. 이미 있으면 그 파일을 고친다 (새 파일을 만들지 않는다).
3. `uv run tools/flow.py check my-work/<이름>.json` → 오류(✘)가 없을 때까지 고친다.
4. 끝나면 한국어로 짧게 보고한다: 바꾼 노드, check 결과, 그리고 사람이 실행할 명령 두 줄.
```bash
uv run tools/flow.py push my-work/<이름>.json
uv run tools/flow.py test cases/<요청에 나온 테스트 파일>.json -v
```
5. **push와 test는 사람이 실행해 결과를 확인한다.** 사람이 "네가 push·test까지 해"라고 하면 직접 실행해도 된다 (push는 약 10초, n8n이 잠깐 재시작됨).
6. 사람이 실패 보고서(`runs/*.json`)를 알려 주면 실패한 케이스의 요청·응답을 읽고 원인을 설명한 뒤 고친다. **같은 곳을 3번 고쳐도 실패하면 멈추고** 다른 방법을 제안한다.
7. 문서(`my-work/*.md`)를 써 달라는 요청이면 워크플로우를 만들지 않고 문서만 쓴다.
## 2. 환경 (바꾸지 말 것)
| 항목 | 값 |
| --- | --- |
| n8n | 2.42.6, 65일차 공식 설치 `~/day65-n8n` (Docker 프로젝트 `day65-n8n`, 컨테이너 보통 `day65-n8n-n8n-1`), 브라우저 http://localhost:5678 |
| n8n 켜고 끄기 | `~/aio-01d4-stu/Last3Mon/65/n8n`에서 `docker compose start/stop` (사람이 함, Ollama 컨테이너는 없음) |
| 게시된 웹훅 주소 | 밖에서 `http://localhost:5678/webhook/<path>` |
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` |
| 다른 Credential | 쓰지 않음 (Ollama 등 없음, LLM은 모두 위 OpenAI Credential) |
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**72일차 판** `Last3Mon/72/data-api`: 71일차 + 고장 스위치 + 캐시) |
| 비교 기준 | `reference/baseline-helpdesk.json` (웹훅 `base-helpdesk`, 고치지 않는다) |
### 데이터 API (72일차 판)
| 요청 | 응답 |
| --- | --- |
| `GET /faq?q=<질문>` | `{"query", "results": [{"id": "FAQ-003", "title", "answer", "score"}]}` · 없으면 `results: []` |
| `GET /tickets/<id>?requester=<이름>` | 접수 건 · 없으면 404 · 남의 건이면 403 |
| `GET /cache/<key>` | `{"key", "value", "hits"}` · 없으면 HTTP 404 `{"error": "not cached"}` |
| `POST /cache/<key>` 본문 `{"value": {...}, "ttl": 600}` | `{"stored": true}` (ttl 초가 지나면 사라짐) |
| `POST /cache-clear` · `GET /cache-stats` | 캐시 비우기 · `{"entries", "hits", "misses"}` |
| `GET /admin/fault` · `POST /admin/fault` | 고장 스위치 `{"faq": "ok"|"slow"|"error", "tickets": ..., "slow_seconds": 8}` (연습용, **사람이** `flow.py fault`로 바꿈) |
| `GET /health` | `{"status": "ok"}` (고장 스위치와 상관없음) |
## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
| --- | --- | --- | --- |
| 헬프데스크 | `my-work/helpdesk.json` · `POST /webhook/my-helpdesk` (처음은 `reference/baseline-helpdesk.json`을 복사) | `question`(필수), `user`, `no_cache`(true면 캐시를 읽지도 쓰지도 않음), `budget_ms`(Lab 01 과제 2, 기본 3000) | `{"route", "category", "answer", "sources", "llm_calls", "cached", "steps", "timings"}` + 실습마다 더하는 칸 (아래) |
| 상태 점검 | `my-work/health.json` · `POST /webhook/my-health` | 없음 | `{"status": "ok"|"degraded"|"down", "checks": {"data_api", "faq", "llm"}, "failed", "seconds"}` |
- `route`: `자동 답변` · `간단 답변`(LLM 없이 데이터 그대로) · `담당자 연결`. `category`: `faq` · `ticket` · `other`.
- `llm_calls`: 이번 요청에서 실제로 부른 LLM 노드 수. `steps`: 지나간 단계 이름 목록(예: `["rule", "faq", "ticket", "answer", "check"]`). 둘 다 **워크플로우가 정해진 값으로** 넣는다 (LLM에게 쓰게 하지 않는다).
- `timings`: 단계별 걸린 시간 ms (`classify_ms`, `data_ms`, `answer_ms`, `review_ms`, ... `total_ms`). 지나가지 않은 단계는 넣지 않는다.
- 빈 질문은 HTTP 400 `{"error": "question is required"}` (LLM을 부르지 않는다).
- **접수 현황(`ticket`)은 캐시하지 않는다** (상태가 바뀐다). 캐시 키는 질문을 앞뒤 공백 제거 · 소문자 · 연속 공백 하나로 바꾼 글자.
실습마다 더하는 칸 (요청에 나올 때만):
| 실습 | 칸 | 뜻 |
| --- | --- | --- |
| Lab 01 과제 1 | `timings.faq_ms` · `timings.ticket_ms`, `slowest_step` | 데이터 조회를 둘로 나눔, `total_ms`를 뺀 가장 큰 단계 이름 (`_ms` 뺀 것) |
| Lab 01 과제 2 | `llm_ms`, `budget_ms`, `over_budget` | LLM 단계(`classify` · `answer` · `review`) 시간의 합 (`timings` **밖**에 둔다: `slowest_step` 계산에 섞이지 않게), 이번 요청의 시간 예산, `total_ms > budget_ms` |
| Lab 02 과제 2 | `cache_by` | 캐시에서 왔으면 `"question"`(질문 키) 또는 `"faq"`(FAQ 번호 키), 아니면 `null` |
| Lab 03 | `reason` | `담당자 연결` · `간단 답변`의 까닭: `data_api_down` · `tickets_down` · `breaker_open` · `llm_down` |
캐시 키 (데이터 API `/cache/<key>`):
| 키 | 값 | ttl | 실습 |
| --- | --- | --- | --- |
| 질문을 고친 글자 (예: `vpn 접속이 자꾸 끊겨요. 어떻게 하나요?`) | `{route, category, answer, sources}` | 600 | Lab 02 예제 2 |
| `faq-<FAQ 번호>` (예: `faq-FAQ-003`) | 같음 | 600 | Lab 02 과제 2 |
| `breaker-faq` | `{"open": true, "since": <ms>}` | 30 | Lab 03 과제 2 (차단기) |
## 4. 워크플로우 파일 규칙
```json
{
"name": "72 IT helpdesk (my)",
"nodes": [ ... ],
"connections": { ... },
"settings": { "executionOrder": "v1" }
}
```
- 파일 하나에 워크플로우 하나. `id`는 쓰지 않아도 된다 (`push`가 파일 이름으로 정한다).
- 노드 `name`은 영어로, 워크플로우 안에서 겹치지 않게. 노드마다 `type`, `typeVersion`, `position: [x, y]`, `parameters`가 필요하다.
- **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다.
- 웹훅은 `POST`, `responseMode: "responseNode"`, 결과는 Respond to Webhook 노드로 보낸다. 갈래마다 Respond 노드를 따로 두어도 된다.
- 요청 본문은 `$json.body.question`처럼 `body` 아래에 있다. 다른 노드 뒤에서는 `$('Question webhook').first().json.body`.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
### Webhook · If · Respond (71일차와 같음)
```json
{"name": "Question webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300],
"parameters": {"httpMethod": "POST", "path": "my-helpdesk", "responseMode": "responseNode", "options": {}}}
```
```json
{"name": "Cached?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [880, 300],
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "loose", "version": 2},
"conditions": [{"id": "c1", "leftValue": "={{ $json.value !== undefined }}", "rightValue": "",
"operator": {"type": "boolean", "operation": "true", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
- If 출력 0번이 true, 1번이 false. 글자 비교: `"operator": {"type": "string", "operation": "notEquals"}`, `"rightValue": "unknown"`.
```json
{"name": "Send result", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [2600, 300],
"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}}
```
### AI Agent + OpenAI chat model
```json
{"name": "Answer", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [1500, 300],
"parameters": {"promptType": "define", "text": "=질문: {{ $('Question webhook').first().json.body.question }}\n...",
"options": {"systemMessage": "You are the company IT help desk. ...", "maxIterations": 1}}}
```
```json
{"name": "Answer model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [1500, 520],
"parameters": {"model": {"__rl": true, "mode": "id", "value": "gpt-4.1-mini"}, "options": {"temperature": 0}},
"credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
- Agent마다 Chat model 노드를 하나씩 둔다. 결과는 `$json.output`(글자). Chat model `options`에 `"timeout": 8000`(ms), `"maxRetries": 0`을 둘 수 있다.
### HTTP Request (데이터 API · 캐시)
```json
{"name": "Search FAQ", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [660, 300],
"parameters": {"url": "http://data-api:8800/faq", "sendQuery": true,
"queryParameters": {"parameters": [{"name": "q", "value": "={{ $('Question webhook').first().json.body.question }}"}]},
"options": {"timeout": 2000}},
"retryOnFail": true, "maxTries": 2, "waitBetweenTries": 500, "onError": "continueErrorOutput"}
```
```json
{"name": "Store in cache", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [2200, 180],
"parameters": {"method": "POST", "url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"sendBody": true, "specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, answer: $json.answer }, ttl: 600 }) }}",
"options": {"response": {"response": {"neverError": true}}}}}
```
- `options.timeout`: 이 시간(ms) 안에 답이 없으면 실패. 없으면 **끝없이 기다린다**.
- 노드 설정 `retryOnFail` · `maxTries` · `waitBetweenTries`(ms): 실패하면 다시. `parameters` 밖, 노드 바로 아래에 쓴다.
- 노드 설정 `"onError": "continueErrorOutput"`: 실패하면 워크플로우를 멈추지 않고 **두 번째 출력**(오류 출력)으로 보낸다. 연결은 `main`의 1번 칸 (아래 6절). `"continueRegularOutput"`은 실패해도 보통 출력으로 (결과에 `error`가 담김).
- `neverError: true`: **404 같은 4xx를** 실패가 아니라 보통 결과로 받는다 (캐시 없음 404를 읽을 때). 이 n8n(2.42.6)에서 실제로 재 보니(10/24) **500은 그래도 실패**로 보고 재시도 · 오류 출력으로 간다. 그리고 `retryOnFail`이 켜져 있으면 **404도 재시도를 다 한 뒤에** 보통 결과로 받는다 (시간을 씀).
- `fullResponse: true`(`neverError`와 함께): 결과가 `{"statusCode", "headers", "body"}`가 되고 **500도 보통 결과**로 바로 온다 (재시도 · 오류 출력 없음). 상태 코드를 보고 **워크플로우가 직접** 나눌 때 쓴다 (Lab 03 예제 3).
| 설정 (`retryOnFail` 3번, 기다림 1초) | 404 | 500 |
| --- | --- | --- |
| 없음 | 재시도 뒤 오류 출력 (2.1초) | 재시도 뒤 오류 출력 (2.1초) |
| `neverError` | 재시도 뒤 **보통** 출력 (2.2초) | 재시도 뒤 오류 출력 (2.2초) |
| `neverError` + `fullResponse` | 바로 보통 출력, `statusCode: 404` (0.1초) | 바로 보통 출력, `statusCode: 500` (0.1초) |
- Agent 노드에도 `"onError": "continueErrorOutput"`을 둘 수 있다 (LLM이 실패하면 오류 출력으로).
### Code (시간 기록 · 결과 모양)
```json
{"name": "Start clock", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [440, 300],
"parameters": {"jsCode": "return [{ json: { t: Date.now() } }];"}}
```
```json
{"name": "After answer", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [1720, 300],
"parameters": {"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"}}
```
- 단계가 끝날 때마다 `Date.now()`를 남기고, 마지막 Code에서 앞 기록과의 차이를 `timings`로 만든다.
- 지나가지 않았을 수 있는 노드는 `$('노드').isExecuted`(true/false)로 먼저 확인한 뒤 읽는다. `steps`, `llm_calls`도 이것으로 센다.
- 반드시 `[{ json: {...} }]` 배열을 돌려준다. Code 노드 안에는 `URL` 클래스가 없다.
### HTTP Request (상태 코드를 직접 보기, Lab 03 예제 3)
```json
{"name": "Get ticket", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [880, 300],
"parameters": {"url": "=http://data-api:8800/tickets/...", "sendQuery": true, "queryParameters": {"parameters": [{"name": "requester", "value": "={{ ... }}"}]},
"options": {"timeout": 2000, "response": {"response": {"fullResponse": true, "neverError": true}}}},
"retryOnFail": true, "maxTries": 2, "onError": "continueErrorOutput"}
```
```json
{"name": "Ticket check", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [980, 460],
"parameters": {"jsCode": "const status = Number($json.statusCode ?? 200);\nreturn [{ json: { ticket: status >= 500 ? {} : ($json.body ?? {}), ticket_status: status } }];"}}
```
- 결과의 접수 건은 `$json.body`에 있다. 뒤에서 `$('Get ticket').first().json`을 읽던 곳은 `$('Ticket check').first().json.ticket`으로 바꾼다.
- `timeout`(느림)은 여전히 실패로 오류 출력에 간다.
### Edit Fields (Set) — 정해진 답
```json
{"name": "Data down", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1500, 820],
"parameters": {"assignments": {"assignments": [
{"id": "d1", "name": "route", "value": "담당자 연결", "type": "string"},
{"id": "d2", "name": "answer", "value": "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.", "type": "string"}]}, "options": {}}}
```
## 6. 연결 (`connections`)
```json
{
"Has question?": {"main": [[{"node": "Start clock", "type": "main", "index": 0}], [{"node": "Reject empty", "type": "main", "index": 0}]]},
"Search FAQ": {"main": [[{"node": "Get ticket", "type": "main", "index": 0}], [{"node": "Data down", "type": "main", "index": 0}]]},
"Answer model": {"ai_languageModel": [[{"node": "Answer", "type": "ai_languageModel", "index": 0}]]}
}
```
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If의 `main`은 `[ [true 쪽], [false 쪽] ]`.
- `onError: continueErrorOutput`인 노드의 `main`도 `[ [성공 쪽], [실패 쪽] ]`.
- 두 갈래가 한 노드로 다시 들어와도 된다 (한 번에 한 갈래만 지나가므로). 다시 모인 노드에서는 `$json`으로 앞 결과를 읽는다.
## 7. 표현식 주의
- 표현식은 `=`로 시작한다: `"={{ $json.body.question }}"`. `=`가 없으면 그냥 글자로 남는다.
- `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다: `"={{ JSON.stringify({ value: $json }) }}"`
- URL에 질문 같은 글자를 넣을 때는 `encodeURIComponent(...)`.
## 8. 테스트 파일 (`cases/*.json`)
```json
{
"webhook": "my-helpdesk",
"before": [{"url": "http://localhost:8800/cache-clear", "body": {}}],
"after": [{"url": "http://localhost:8800/admin/fault", "body": {"faq": "ok", "tickets": "ok"}}],
"cases": [
{"name": "같은 질문", "request": "requests/faq-vpn-again.json",
"expect": {"equals": {"cached": true, "llm_calls": 0}, "contains": {"answer": ["인증서"]}, "max_seconds": 1}},
{"name": "FAQ 오류", "before": [{"url": "http://localhost:8800/admin/fault", "body": {"faq": "error"}}],
"body": {"question": "비밀번호를 잊어버렸어요", "no_cache": true}, "expect": {"equals": {"route": "담당자 연결"}}},
{"name": "LLM 멈춤", "before": [{"llm": "down"}], "after": [{"llm": "up"}], "request": "requests/faq-vpn-nocache.json",
"expect": {"equals": {"route": "간단 답변"}}}
]
}
```
- `before` · `after`: 테스트 파일 전체 또는 케이스 하나의 앞뒤에 한 번. `{"webhook": ...}`(n8n), `{"url": ...}`(데이터 API에 POST), `{"llm": "down"|"up"}`(OpenAI Credential을 일부러 망가뜨렸다 되돌림). `after`는 실패해도 반드시 실행된다.
- `max_seconds`: 응답이 이 시간 안에 와야 함. `equals` · `contains` · `not_contains` · `status`는 71일차와 같다. 필드 이름은 점으로 내려간다 (`checks.faq`).
- `uv run tools/flow.py test <파일> -v`: `-v`(`--verbose`)는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 준다 (보고서 `runs/*.json`에도 같은 것이 남음). `call ... -v`도 보낸 요청 JSON을 응답 위에 함께 보여 준다. **모든 명령이 `-v`를 받는다**: `bench -v`는 응답 JSON마다 · 정렬한 시간 · 단계별 평균 ms, `check -v` · `push -v`는 노드 · 연결 목록과 실행한 docker 명령, `fault -v` · `doctor -v`는 보낸 요청 · 받은 응답 (API 키는 `sk-***`로 가림).
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만).
- 고장 스위치(`/admin/fault`)나 `flow.py llm`을 워크플로우 안에서 부르지 않는다 (연습은 사람이 한다).
- 빠르게 하려고 **답의 내용 검사(테스트의 `contains`)를 없애지 않는다.** 빨라졌으면 품질이 그대로인지 원래 테스트로 확인한다.
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
- `.env`나 API 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+403
View File
@@ -0,0 +1,403 @@
# Lab 01 · 재고, 단계별 시간을 남기고, 병목 찾기
- 관련 강의: [1단계 · 재기와 병목](../../Lecture/72/01-STAGE1-MEASURE-BOTTLENECK.md)
- 소요 시간: 50분 (2교시 한 시간) · 예제 1 8분 · 예제 2 12분 · 예제 3 8분 · 과제 1 10분 · 과제 2 12분
- 준비: [실습 준비](../../Lecture/72/00-DAY72-PREP.md)의 `doctor` → `ready`, `base-helpdesk` 게시
- 목표: 고치기 전에 **잰다.** 같은 요청을 여러 번 불러 p50·p95를 보고, 워크플로우가 단계별 시간을 결과에 남기게 해 **어디가 느린지** 숫자로 찾는다.
```mermaid
flowchart LR
E1["예제 1<br/>bench로 기준 재기"] --> E2["예제 2<br/>단계별 시간 남기기"] --> E3["예제 3<br/>요청 종류별로 재고<br/>LLM 몫 계산"] --> T1["과제 1<br/>데이터 조회 나누기 ·<br/>가장 느린 단계"] --> T2["과제 2<br/>시간 예산<br/>over_budget"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 시작하기 · 터미널 두 개
| 터미널 | 하는 일 |
| --- | --- |
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/72` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 |
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/72` 에서 `bench`·`push`·`test`를 **내가** 실행한다 |
`cp templates/PERF-REPORT-TEMPLATE.md my-work/PERF.md` 후 표를 채워 갑니다.
## 예제 1 · bench로 기준 재기 (8분, 에이전트 없이)
비교 기준 `base-helpdesk`는 질문마다 **분류 LLM → FAQ·접수 조회 → 답 LLM → 검토 LLM** 순서로 일합니다.
```bash
uv run tools/flow.py call base-helpdesk requests/faq-vpn.json -v
uv run tools/flow.py bench base-helpdesk requests/faq-vpn.json -n 5
uv run tools/flow.py bench base-helpdesk requests/ticket-own.json -n 5
uv run tools/flow.py bench base-helpdesk requests/off-topic.json -n 5
```
`PERF.md` 1절을 채웁니다. 그리고 브라우저 n8n **Executions** → `72 IT helpdesk baseline` 실행 하나 → 노드마다 오른쪽 위에 걸린 시간을 봅니다.
### 이해하기
**왜 먼저 재나.** 오늘 하루는 느린 실습 워크플로우(IT 헬프데스크)를 빠르고 튼튼하게 고치는 날입니다. 그런데 고치기 전에 지금이 몇 초인지 모르면, 고친 뒤에 "빨라졌다"고 말할 근거가 없습니다. 이 예제는 **바꾸기 전 기준 숫자**를 남기는 일입니다. 오늘 뒤의 모든 실습은 이 숫자와 비교합니다.
**지금 워크플로우가 하는 일.** 질문 하나가 오면 아래 순서로 차례차례 일합니다. 한 단계가 끝나야 다음 단계가 시작하므로, 전체 시간은 단계 시간의 **합**입니다.
```mermaid
flowchart LR
Q["질문"] --> C{{"분류 LLM<br/>faq · ticket · other"}} --> D["FAQ 검색 ·<br/>접수 조회 (데이터 API)"] --> A{{"답 LLM"}} --> R{{"검토 LLM<br/>말투 다듬기"}} --> S["결과"]
```
**두 명령의 차이.**
| 명령 | 하는 일 | 보는 것 |
| --- | --- | --- |
| `call ... -v` | 한 번 부르고 요청 · 응답 JSON을 그대로 보여 줌 | 답이 제대로 오는지, 응답에 어떤 칸이 있는지 |
| `bench ... -n 5` | 같은 요청을 5번 부르고 한 번마다 시간을 적은 뒤 p50 · p95 · max · 실패 수 | "보통 얼마, 느린 쪽 얼마" |
- **p50**은 5번을 빠른 순서로 세웠을 때 가운데(3번째), **p95**는 느린 쪽(5번이면 5번째 = max)입니다. 평균 하나만 적으면 "가끔 아주 느림"이 묻힙니다 (교안 1단계 2절).
- **첫 호출**: push 직후에는 n8n이 막 다시 켜져 연결 · 준비에 시간이 더 듭니다. 첫 `call`이 눈에 띄게 느린 것이 정상이고, 그래서 `PERF.md` 1절에 **따로 적는 칸**이 있습니다.
- 질문 세 종류(VPN FAQ · 내 접수 건 · IT와 상관없는 점심 메뉴)를 따로 재는 까닭: 질문마다 할 일이 다르니 시간도 다를 것 같지요? 실제로 그런지 숫자로 확인합니다.
**Executions 화면.** n8n 브라우저 화면의 Executions에서 실행 하나를 열면 노드마다 걸린 시간이 보입니다. 지금은 눈으로 한 번씩만 볼 수 있지만, 예제 2에서 이 시간을 **결과에 직접 남기게** 만듭니다.
**이 예제가 끝나면 손에 있는 것**: 세 종류 질문의 첫 호출 · p50 · p95 · 실패 수, 그리고 "느린 곳은 데이터 API인가 LLM인가"에 대한 첫 짐작.
<details><summary>▶ 1단계 · 무엇을 보나</summary>
- p50(가운데)과 p95(느린 쪽) 중 사용자가 "느리다"고 느끼는 것은 어느 쪽일까요?
- n8n을 다시 켠 직후(또는 push 직후) **첫 호출**은 다른 호출과 같은가요? bench 첫 줄을 봅니다.
- 세 요청 중 LLM이 할 일이 거의 없는데도 같은 시간이 걸리는 것은?
</details>
<details><summary>▶ 2단계 · Executions 화면에서</summary>
노드 시간을 더해 보면 전체와 거의 같나요? 데이터 API 호출(HTTP Request)과 LLM(Agent) 중 어느 쪽이 대부분인가요? 이 답이 오늘 무엇을 고칠지 정합니다.
</details>
## 예제 2 · 단계별 시간을 결과에 남기기 (12분)
**만들 것**: 기준 워크플로우를 복사해, 결과에 **단계별 시간 · 지나간 단계 · LLM 호출 수**를 남기기. 하는 일(답)은 그대로.
```bash
cp reference/baseline-helpdesk.json my-work/helpdesk.json
```
| 조건 | 값 |
| --- | --- |
| 파일 · 웹훅 | `my-work/helpdesk.json` · `my-helpdesk` (복사한 파일의 웹훅 경로와 이름을 바꿈) |
| 결과에 더할 것 | `timings`(단계별 ms + `total_ms`), `steps`, `llm_calls`, `cached`(지금은 늘 false) — [AGENTS.md](AGENTS.md) 3절 |
| 기대 결과 | `cases/lab01.json` (먼저 열어 읽기) |
### 이해하기
**왜 결과에 시간을 남기나.** `bench`는 사용자가 기다린 **전체** 시간만 알려 줍니다. 그 시간이 어느 단계에서 쓰였는지는 모릅니다. Executions 화면으로 한 번씩 볼 수는 있지만, 질문 수백 개를 모아 보기는 어렵습니다. 그래서 워크플로우가 스스로 **단계별 시간 · 지나간 단계 · LLM을 몇 번 불렀는지**를 결과에 적게 합니다. 업계에서는 이것을 계측(instrumentation)이라고 하고, 큰 시스템은 OpenTelemetry 같은 도구로 같은 일을 합니다.
**어떻게 재나.** 단계가 **끝날 때마다** 그 순간의 시각(ms)을 남기는 작은 Code 노드를 끼워 넣고, 마지막에 앞뒤 시각을 빼면 단계 시간입니다.
```mermaid
flowchart LR
S["시각 t0"] --> C{{"분류"}} --> T1["시각 t1"] --> D["데이터"] --> T2["시각 t2"] --> A{{"답"}} --> T3["시각 t3"] --> R{{"검토"}} --> T4["시각 t4"] --> B["결과 만들기<br/>classify = t1 − t0 …<br/>total = 끝 − t0"]
```
- 시각 노드를 단계 **앞**에 두면 차이가 0이나 음수가 됩니다. "끝난 뒤"가 핵심입니다.
- 단계를 지나가지 않는 질문도 있습니다 (예: 빈 질문, Lab 02의 규칙 분류). 지나가지 않은 노드를 읽으면 오류가 나므로, 읽기 전에 **지나갔는지 먼저 확인**하는 방법을 씁니다 (AGENTS.md 5절).
- `steps`와 `llm_calls`는 LLM에게 쓰게 하지 않습니다. 어느 노드를 지나갔는지는 **워크플로우가 정확히 압니다.** LLM에게 "몇 번 불렀는지 적어"라고 하면 틀린 숫자를 지어낼 수 있습니다.
**하는 일(답)은 그대로.** 이 예제는 "재게" 만드는 것이지 "바꾸는" 것이 아닙니다. 답의 내용이 달라지면 안 됩니다. 요청문에 "답은 그대로, 결과에 칸만 더한다"를 꼭 넣는 까닭입니다.
**테스트 `cases/lab01.json`이 보는 것.**
| 케이스 | 확인하는 것 |
| --- | --- |
| VPN FAQ · 단계별 시간 | `timings`에 단계 칸들이 있고 숫자인지 |
| VPN FAQ · 전체 시간 · LLM 호출 수 | `total_ms`, `steps`에 답 단계, `llm_calls`가 숫자인지 |
| 접수 현황 | 칸을 더해도 접수 답(상태 "처리 중")이 그대로인지 |
| 빈 질문 | 지금처럼 HTTP 400으로 거절하는지 |
**결과 읽기.** `call ... -v` 응답의 `timings`를 보고 단계 칸을 더해 `total_ms`와 비교합니다. 몇십 ms 차이는 시각 노드 · 결과 만들기에 쓴 시간입니다. 가장 큰 칸이 오늘의 병목 후보입니다.
> 접수 케이스가 가끔 실패할 수 있습니다: 검토 LLM이 "처리 중"을 다른 말로 바꿔 쓰는 경우입니다. 워크플로우 잘못이 아니므로 한 번 더 돌려 봅니다 (Lab 02 과제 1에서 이 문제가 사라집니다).
<details><summary>▶ 1단계 · 시간은 어디서 재나</summary>
n8n Code 노드에서 `Date.now()`로 지금 시각(ms)을 얻을 수 있습니다. 단계가 **끝날 때마다** 시각을 남기고, 마지막에 앞 시각과의 차이를 구하면 단계별 시간입니다. [AGENTS.md](AGENTS.md) 5절에 시각을 남기는 노드 예가 있습니다.
</details>
<details><summary>▶ 2단계 · steps와 llm_calls는 누가 쓰나</summary>
LLM에게 "몇 번 불렀는지 적어"라고 하면 안 됩니다. 어느 노드를 지나갔는지는 **워크플로우가** 압니다. 지나갔는지 확인하는 방법도 AGENTS.md 5절에 있습니다 (69일차 협업 기록 `steps`와 같은 생각).
</details>
<details><summary>▶ 3단계 · 보내기 전 점검표</summary>
- [ ] 바탕 파일과 새 웹훅 경로 · 워크플로우 이름이 들어 있다
- [ ] "답은 그대로, 결과 필드만 더한다"가 들어 있다
- [ ] 단계 이름(`classify`, `data`, `answer`, `review`)을 정했다
- [ ] 테스트 파일 경로가 들어 있다
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 바탕 파일 · 새 웹훅 경로 · 이름 ② "답은 그대로" ③ 시각을 남길 자리(단계마다 끝난 뒤) ④ 결과에 더할 칸 ⑤ 지나갔는지 확인하는 방법 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 은 reference/baseline-helpdesk.json 을 복사한 것이야. 웹훅 경로를 my-helpdesk, 이름을 "72 IT helpdesk (my)"로 바꾸고,
답은 그대로 두고 결과에 다음을 더해 줘 (AGENTS.md 3절, 5절의 시각 남기기).
- Has question? 다음에 Start clock, Parse category 뒤에 After classify, Get ticket 뒤에 ____① , Answer 뒤에 After answer, Review 뒤에 After review (각각 ____② ).
- Build result 에서 timings {classify_ms, ____③ , answer_ms, review_ms, total_ms}, steps(지나간 단계), llm_calls(지나간 Agent 수), cached:false.
지나갔는지는 ____④ 로.
기대 결과 cases/lab01.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 다른 시각 노드 이름을 보면 "After + 끝난 단계" 모양입니다. FAQ · 접수 조회(데이터)가 끝난 뒤의 노드를 같은 규칙으로 짓습니다.
- ② 지금 시각을 ms 숫자로 돌려주는 자바스크립트 함수입니다. AGENTS.md 5절 `Start clock` Code 예에 있습니다.
- ③ 데이터 조회 단계의 칸입니다. 다른 칸처럼 `<단계>_ms`로 짓고, AGENTS.md 3절 `timings` 설명의 칸 목록과 맞춥니다.
- ④ 노드가 실행됐는지를 true · false로 알려 주는 n8n 표현입니다. AGENTS.md 5절 Code 설명 셋째 줄.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab01.json -v
uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v
```
**관찰**: `timings`를 `PERF.md` 2절에 적습니다. 단계 시간의 합은 `total_ms`와 비슷한가요? 가장 오래 걸린 단계는?
## 예제 3 · 요청 종류별로 재고, LLM이 차지하는 몫 계산 (8분, 에이전트 없이)
예제 2의 워크플로우 그대로 **질문 종류를 바꿔 가며** 잽니다. 같은 워크플로우라도 질문에 따라 지나가는 단계가 다르고, 걸리는 시간도 다릅니다.
```bash
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5 # FAQ 질문 (캐시 없이)
uv run tools/flow.py bench my-helpdesk requests/ticket-own.json -n 5 # 접수 번호 질문
uv run tools/flow.py bench my-helpdesk requests/off-topic.json -n 5 # IT와 상관없는 질문
uv run tools/flow.py call my-helpdesk requests/ticket-own.json -v
uv run tools/flow.py call my-helpdesk requests/off-topic.json -v
```
`PERF.md` 2-1절 표를 채웁니다.
| 질문 종류 | p50 | p95 | `classify_ms` | `data_ms` | `answer_ms` | `review_ms` | `total_ms` | LLM 몫 (%) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| FAQ (VPN) | | | | | | | | |
| 접수 번호 (T-1002) | | | | | | | | |
| IT 밖 (점심 메뉴) | | | | | | | | |
LLM 몫 = (`classify_ms` + `answer_ms` + `review_ms`) ÷ `total_ms` × 100. `timings`는 예제 2의 `call ... -v` 응답에서 읽습니다 (FAQ 줄은 예제 2에서 이미 봄).
### 이해하기
**왜 질문 종류별로 재나.** 한 서비스에는 여러 종류의 요청이 섞여 들어옵니다. 모두 섞어서 재면 "어느 종류가 느린지"가 숨습니다. 업계에서는 이것을 작업 구성(workload mix)이라고 하고, 부하 시험도 실제 요청 비율을 흉내 내어 합니다.
**세 질문이 지나가는 길.**
| 질문 | 데이터 API에서 찾는 것 | LLM이 하는 일 |
| --- | --- | --- |
| FAQ (VPN이 끊겨요) | FAQ 검색 결과가 있음 | 분류 · FAQ로 답 쓰기 · 검토 |
| 접수 번호 (T-1002 어떻게 됐나요) | 내 접수 건이 있음 | 분류 · 상태로 답 쓰기 · 검토 |
| IT 밖 (점심 메뉴) | 아무것도 없음 | 분류 · "찾지 못했다"는 답 쓰기 · 검토 |
- 점심 메뉴 질문은 쓸 데이터가 하나도 없는데도 LLM 세 번을 똑같이 거칩니다. 시간이 다른 질문과 거의 같다면, **할 필요가 없는 일**을 하고 있다는 뜻입니다.
- `requests/faq-vpn-nocache.json`은 `no_cache: true`가 붙은 VPN 질문입니다. 지금은 캐시가 없지만, Lab 02에서 캐시를 만든 뒤에도 같은 조건으로 다시 잴 수 있게 처음부터 이것을 씁니다.
**LLM 몫 계산.** `call ... -v` 응답의 `timings`에서 LLM 단계(분류 · 답 · 검토) 시간을 더해 `total_ms`로 나눕니다.
> 예 (지어낸 숫자): 분류 600 + 답 1100 + 검토 900 = 2600ms, 전체 2700ms → 2600 ÷ 2700 × 100 ≈ 96%
이 몫이 크면 데이터 API를 아무리 빠르게 해도 전체는 거의 그대로입니다 (교안 1단계 7절 암달의 법칙). 큰 덩어리부터 줄여야 합니다.
**`bench`와 `call`의 숫자가 조금 다른 까닭.** `bench`는 밖에서 잰 시간(웹훅을 받고 돌려주는 시간까지), `timings.total_ms`는 워크플로우 안에서 잰 시간입니다. 몇십 ms 차이는 정상입니다.
**이 예제가 끝나면 손에 있는 것**: 종류별 p50 · p95와 LLM 몫. "Lab 02에서 어느 LLM 호출을 줄일까"의 근거가 됩니다.
<details><summary>▶ 1단계 · 무엇을 비교하나</summary>
세 줄의 `data_ms`는 비슷한가요, 다른가요? 세 줄의 LLM 몫은? "IT와 상관없는 질문"은 데이터가 하나도 없는데 왜 시간이 비슷하게 걸릴까요? 어느 단계를 건너뛸 수 있을지 2단계(Lab 02)를 떠올리며 한 줄 적습니다.
</details>
<details><summary>▶ 2단계 · 평균 하나로 말하면 안 되는 까닭</summary>
세 종류가 하루에 똑같이 들어온다고 하면 전체 p50은 어디쯤일까요? 한 종류만 아주 느리면 그 종류를 묻는 사람은 늘 느리다고 느낍니다. **종류별로** 재는 습관이 오늘 Lab 02 · 03에서 "어디를 고칠지"를 정합니다.
</details>
## 과제 1 · 데이터 조회를 나눠 재고, 가장 느린 단계를 알려 주기 (10분)
팀장이 요청했습니다.
> "어디가 느린지 매번 Executions를 열어 볼 수는 없어요. 데이터 조회도 FAQ와 접수 현황을 따로 재 주시고, 결과에 가장 오래 걸린 단계를 같이 주세요."
기대 결과는 `cases/lab01-challenge.json`에 있습니다.
| 케이스 | 기대 |
| --- | --- |
| FAQ · 접수 시간 따로 | `timings`에 `faq_ms`, `ticket_ms` |
| 가장 오래 걸린 단계 | `slowest_step`이 단계 이름 (`classify` · `answer` · `review` 중 하나가 나와야 정상) |
| 접수 현황도 같은 모양 | 답은 그대로, `slowest_step` 있음 |
**먼저 생각해 보기 (3분)**: `slowest_step`을 LLM이 아니라 무엇으로 정할까요? 예제 2의 `timings`만 있으면 계산할 수 있나요?
### 이해하기
**팀장의 말을 바꿔 보면.** "매번 Executions를 열 수 없다" = 사람 눈이 아니라 **결과만 보고** 어디가 느린지 알고 싶다. "FAQ와 접수 현황을 따로" = 지금 `data_ms` 하나로 합쳐진 데이터 조회를 둘로 나눠야 한다.
**왜 데이터 조회를 나누나.** 지금은 FAQ 검색과 접수 조회가 둘 다 빠르니(수십 ms) 합쳐도 문제가 없어 보입니다. 하지만 Lab 03에서 FAQ만 멈추거나 접수 API만 멈추는 고장을 다룹니다. 그때 "어느 쪽이 느린가"를 숫자로 보려면 칸이 따로 있어야 합니다. **나중에 쓸 정보를 미리 남기는** 설계입니다.
```mermaid
flowchart LR
T1["분류 끝 시각"] --> F["FAQ 검색"] --> X["새 시각"] --> G["접수 조회"] --> T2["데이터 끝 시각"]
X -.-> M["FAQ 시간 = 새 시각 − 분류 끝<br/>접수 시간 = 데이터 끝 − 새 시각"]
```
**`slowest_step`은 계산입니다.** `timings`에서 전체(`total_ms`)를 뺀 단계 칸 중 가장 큰 것의 이름을 고르면 됩니다. 판단할 것이 아니라 **세는 것**이므로 LLM이 아니라 Code가 합니다. 숫자가 있으면 판단하지 않고 셉니다.
**결과에서 기대할 것.**
- 데이터 단계 둘은 아주 작고, 가장 느린 단계는 LLM 단계 중 하나일 것입니다.
- 답 LLM과 검토 LLM의 시간이 비슷해서 실행마다 1등이 바뀔 수 있습니다. push 직후 첫 호출에서는 첫 LLM 단계(분류)가 준비 시간을 떠안아 1등이 되기도 합니다. 그래서 테스트는 LLM 단계 셋 중 **하나면** 통과로 봅니다.
**테스트 두 개를 함께 돌리는 까닭.** 새 테스트(`lab01-challenge`)만 통과하고 예제 2의 테스트(`lab01`)가 깨지면, 새 칸을 만들다가 원래 칸을 망가뜨린 것입니다. 고칠 때마다 **앞 테스트도 다시** 돌리는 것이 오늘 내내 쓰는 습관입니다.
<details><summary>▶ 1단계 · 나누려면 시각이 하나 더</summary>
FAQ 조회와 접수 조회 사이에 시각을 하나 더 남기면 됩니다. 그러면 `data_ms`가 둘로 나뉩니다. 이름은 테스트가 찾는 이름으로.
</details>
<details><summary>▶ 2단계 · 고치는 요청문의 모양</summary>
고칠 파일, 더할 시각, 새 필드 이름, 그리고 `cases/lab01.json`도 계속 통과해야 한다는 조건. 한 번에 이 일만 시킵니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 파일 ② 더할 시각과 그 자리 ③ 나눌 칸 이름 두 개 ④ `slowest_step`을 정하는 방법(LLM 아님) ⑤ 두 테스트 모두 통과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Search FAQ 와 Get ticket 사이에 ____① (Date.now()) 를 더해, timings 의 data_ms 를 ____② 와 ____③ 로 나눠 줘.
total_ms 를 뺀 timings 중 ____④ 단계 이름(_ms 뺀 것)을 slowest_step 으로. cases/lab01-challenge.json, cases/lab01.json 둘 다 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 예제 2에서 만든 시각 노드들의 이름 규칙("After + 끝난 단계")을 따릅니다. 이 노드 앞에서 끝난 단계는 무엇인가요?
- ② 테스트가 찾는 칸 이름 그대로 써야 합니다. `cases/lab01-challenge.json`의 `expect`를 열어 봅니다 (AGENTS.md 3절 "실습마다 더하는 칸"의 Lab 01 과제 1 줄도 같음).
- ③ ②와 짝이 되는 나머지 하나입니다. 무엇을 둘로 나누는지 생각합니다.
- ④ "가장 느린"을 숫자로 말하면 어떤 값을 고르는 것인가요? 한 낱말입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab01-challenge.json -v
uv run tools/flow.py test cases/lab01.json -v
```
**더 생각해 보기**: 사용자가 기다리는 시간 중 LLM이 차지하는 비율은? 2단계에서 무엇을 줄이면 가장 빨라질까요? `PERF.md`에 한 줄로 적습니다.
## 과제 2 · 시간 예산을 정하고, 넘었는지 결과에 남기기 (12분)
팀장이 다시 요청했습니다.
> "사용자들이 '3초 넘으면 느리다'고 해요. 요청마다 3초를 넘었는지 결과에 남겨 주세요. 시험할 때는 기준을 바꿔 보고 싶으니 요청에서 정할 수 있게 해 주시고요. 그리고 넘었을 때 LLM 탓인지 바로 보이게 LLM이 쓴 시간도 같이요."
기대 결과는 `cases/lab01-challenge2.json`에 있습니다 (먼저 열어 읽기).
| 케이스 | 기대 |
| --- | --- |
| 예산 안 (`budget_ms: 60000`) | `over_budget: false`, `budget_ms: 60000`, `llm_ms`가 숫자, 답에 "인증서" |
| 예산 넘음 (`budget_ms: 500`) | `over_budget: true`, `budget_ms: 500` |
| 예산을 안 주면 | `budget_ms: 3000` (기본값) |
| 접수 현황도 같은 모양 | `budget_ms: 3000`, `category: ticket`, `llm_ms`, `slowest_step` 그대로 |
**먼저 생각해 보기 (3분)**: 시간 예산(목표 시간)은 누가 정하나요? 넘었을 때 워크플로우가 **답을 멈춰야** 할까요, **기록만** 해야 할까요? `llm_ms`를 `timings` 안에 넣으면 과제 1의 `slowest_step`은 어떻게 될까요?
### 이해하기
**시간 예산이란.** "한 요청은 이 시간 안에 끝나야 한다"는 목표입니다. 기준이 있어야 "빠르다 · 느리다"를 말할 수 있습니다. 운영에서는 이것을 모아 **SLO**(서비스 수준 목표, 예: "요청의 95%가 3초 안")로 씁니다 (교안 1단계 9절).
**세 칸의 뜻.**
| 칸 | 뜻 | 왜 필요한가 |
| --- | --- | --- |
| `budget_ms` | 이번 요청의 예산 (본문으로 바꿀 수 있고, 없으면 기본값) | 시험할 때 일부러 작게 · 크게 주어 두 결과를 다 확인하려고 |
| `over_budget` | 전체 시간이 예산을 넘었나 (true · false) | 하루치를 모아 "몇 %가 넘었나"를 세려고 |
| `llm_ms` | LLM 단계 시간의 합 | 넘었을 때 "LLM 탓인가"를 바로 보려고 |
**넘으면 멈추나?** 아닙니다. 거의 다 만든 답을 버리면 사용자는 더 오래 기다리고도 답을 못 받습니다. 예산은 **기록하고 모아서 고칠 곳을 정하는** 데 씁니다. 오래 걸리는 외부 서비스를 끊는 일은 Lab 03의 timeout이 합니다. 둘을 헷갈리지 않습니다.
**기본값과 숫자 바꾸기.** 본문에 `budget_ms`가 없을 수도 있고, 있어도 글자(`"500"`)로 올 수도 있습니다. 글자 `"500"`과 숫자 `3023`을 비교하면 엉뚱한 결과가 나올 수 있으므로 **숫자로 바꾼 뒤** 비교합니다.
**`llm_ms`를 어디에 두나.** 과제 1의 `slowest_step`은 `timings` 칸 중 가장 큰 것을 고릅니다. LLM 단계를 모두 더한 값을 `timings` 안에 넣으면 늘 그것이 1등이 되어 `slowest_step`이 망가집니다. 그래서 결과의 다른 자리에 둡니다 (AGENTS.md 3절 "실습마다 더하는 칸").
**테스트 `cases/lab01-challenge2.json`의 네 케이스.**
- 아주 넉넉한 예산 → 넘지 않아야 함, 아주 작은 예산(0.5초) → 반드시 넘어야 함. 두 끝을 주면 계산이 맞는지 확실히 알 수 있습니다.
- 예산을 안 주면 기본값이 들어가는지.
- 접수 질문에도 같은 칸이 있는지 (질문 종류마다 칸이 달라지면 모아 보기 어렵습니다).
> 기본 예산 3초에서는 지금 워크플로우가 3초 근처라 실행마다 넘기도 하고 안 넘기도 합니다. 그래서 테스트는 기본값 케이스에서 넘었는지는 보지 않고 `budget_ms` 값만 봅니다. Lab 02에서 LLM을 줄이면 넉넉히 들어옵니다.
<details><summary>▶ 1단계 · 기본값</summary>
본문에 `budget_ms`가 없을 때 3000을 쓰는 것은 한 줄입니다 (`?? 3000`). 숫자로 바꾸는 것도 잊지 않습니다 (본문은 글자일 수도 있음).
</details>
<details><summary>▶ 2단계 · `llm_ms`는 어디에</summary>
`slowest_step`은 `timings`에서 `total_ms`만 빼고 가장 큰 칸을 고릅니다. LLM 단계를 모두 더한 `llm_ms`가 `timings` 안에 있으면 늘 그것이 가장 큽니다. 그래서 `llm_ms`는 결과의 **맨 바깥**에 둡니다 ([AGENTS.md](AGENTS.md) 3절 "실습마다 더하는 칸").
</details>
<details><summary>▶ 3단계 · 운영에서는</summary>
`over_budget: true`가 하루에 몇 번 나왔는지 세면 "3초 안에 답한 비율"이 됩니다. 이것이 운영에서 말하는 **목표 수준(SLO)** 의 씨앗입니다. 오늘은 결과에 남기기까지만 합니다 (76일차 에러 처리 · 79일차 운영 대시보드에서 다시).
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 고칠 노드(결과를 만드는 Code) ② 예산의 이름 · 기본값 · 숫자로 ③ `llm_ms`를 무엇의 합으로 · 어디에 ④ `over_budget` 계산 ⑤ 지금 칸은 그대로 ⑥ 테스트 셋**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Build result 에서, 본문 ____① (없으면 ____② , 숫자로)를 읽어
결과에 llm_ms(classify · answer · review 의 _ms 합, timings ____③ 에 둔다), budget_ms, over_budget( ____④ > budget_ms)을 더해 줘.
timings, slowest_step 은 그대로. cases/lab01-challenge2.json, cases/lab01-challenge.json, cases/lab01.json 모두 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 요청 본문에서 읽는 칸 이름입니다. 결과에도 같은 이름으로 나갑니다 (AGENTS.md 3절 "입력" 칸).
- ② 본문에 없을 때 쓸 기본값입니다. 위 조건 표와 AGENTS.md 3절 "입력" 칸 괄호에 있습니다. 단위는 ms.
- ③ `timings` 안과 밖 중 어디일까요? `slowest_step`은 `timings` 칸 중 가장 큰 것을 고른다는 점을 생각합니다 (교안 1단계 9절).
- ④ 요청 하나 전체가 걸린 시간을 담은 칸입니다. 결과 안의 어디에 있는지까지 점으로 이어 씁니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab01-challenge2.json -v
uv run tools/flow.py test cases/lab01-challenge.json -v
uv run tools/flow.py test cases/lab01.json -v
```
**더 생각해 보기**: 예제 3의 세 종류 중 기본 예산 3초를 자주 넘는 것은? 넘는 종류만 2단계에서 먼저 고치면 됩니다. `PERF.md`에 한 줄로 적습니다.
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `call my-helpdesk`가 404 | 복사한 파일의 웹훅 경로가 아직 `base-helpdesk`. 경로를 바꿨는지, push했는지 |
| `push` 뒤 `base-helpdesk`가 안 됨 | 같은 경로를 쓰는 파일을 push하면 예전 것이 게시 해제됩니다. `uv run tools/flow.py push reference/baseline-helpdesk.json` |
| `timings`의 값이 음수·0 | 시각을 남기는 노드의 순서. 단계가 **끝난 뒤**에 있는지 |
| `Referenced node is unexecuted` | 지나가지 않은 노드를 읽음. `isExecuted`로 먼저 확인 |
| `slowest_step`이 늘 `llm` | `llm_ms`를 `timings` 안에 넣었음. 결과 맨 바깥으로 |
| `over_budget`이 늘 `false` | `budget_ms`를 글자로 비교함. `Number(...)`로 |
| 계속 안 됨 | `cp reference/lab01-example2-timings.json my-work/helpdesk.json` 후 push로 이어 갑니다 |
## 확인
- [ ] 예제 1: 기준 p50 · p95 · 첫 호출을 `PERF.md` 1절에 적었다.
- [ ] 예제 2: `cases/lab01.json` 통과, `timings`를 2절에 적었다.
- [ ] 예제 3: 질문 종류 세 줄의 p50 · p95 · 단계 시간 · LLM 몫을 적었다.
- [ ] 과제 1: `cases/lab01-challenge.json` 통과, `lab01`도 통과.
- [ ] 과제 2: `cases/lab01-challenge2.json` 통과, `lab01-challenge` · `lab01`도 통과.
- [ ] `my-work/PROMPTS.md`에 요청문을 모았다.
+453
View File
@@ -0,0 +1,453 @@
# Lab 02 · 규칙 먼저, 캐시, LLM 한 번 — 빠르게, 품질은 그대로
- 관련 강의: [2단계 · 실행 흐름 최적화](../../Lecture/72/02-STAGE2-OPTIMIZE-FLOW.md)
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 10분 · 예제 2 10분 · 예제 3 6분 · 과제 1 12분 · 과제 2 12분
- 준비: Lab 01의 `my-work/helpdesk.json` (없으면 `cp reference/lab01-example2-timings.json my-work/helpdesk.json` 후 push)
- 목표: Lab 01에서 찾은 병목(LLM 호출)을 **한 가지씩** 줄이고, 바꿀 때마다 **다시 재고** 원래 테스트로 품질을 확인한다.
```mermaid
flowchart LR
E1["예제 1<br/>규칙으로 되면<br/>분류 LLM 생략"] --> E2["예제 2<br/>같은 질문은 캐시"] --> E3["예제 3<br/>캐시 적중률과 시간"] --> T1["과제 1<br/>LLM 한 번 · 2.5초 안"] --> T2["과제 2<br/>FAQ 번호로<br/>한 번 더 캐시"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 바꿀 때마다 `PERF.md` 3절에 한 줄씩 적습니다.
## 예제 1 · 규칙으로 되면 분류 LLM을 부르지 않기 (10분)
**만들 것**: 접수 번호(`T-1002` 같은)가 있으면 `ticket`, FAQ 검색 결과가 있으면 `faq`로 **규칙이** 정하고, 규칙으로 모를 때만 분류 LLM을 부르게 하기.
| 조건 | 값 |
| --- | --- |
| 파일 | `my-work/helpdesk.json` 고치기 |
| `steps` | 규칙으로 정했으면 `rule`, LLM이 정했으면 `classify` |
| 기대 결과 | `cases/lab02.json` 앞 네 케이스 (나머지는 예제 2) |
### 이해하기
**Lab 01에서 알게 된 것.** 시간의 대부분이 LLM 세 번(분류 · 답 · 검토)이었습니다. 그중 **분류**는 "이 질문이 FAQ냐, 접수냐, 그 밖이냐"를 고르는 일입니다. 그런데 이 중 많은 경우는 LLM 없이도 확실히 알 수 있습니다.
| 질문의 단서 | 확실히 아는 것 |
| --- | --- |
| `T-1002`처럼 접수 번호 모양이 있음 | 접수 질문 |
| FAQ 검색에서 결과가 나옴 | FAQ로 답할 수 있는 질문 |
| 둘 다 아님 | 모름 → 이때만 LLM에게 묻는다 |
이렇게 **싼 규칙을 먼저, 모를 때만 비싼 LLM**을 쓰는 것을 단계적 처리(cascade) 또는 라우팅이라고 합니다. 고객센터가 자주 묻는 질문은 자동 응답으로, 나머지만 상담원에게 넘기는 것과 같습니다.
**순서를 바꿔야 하는 까닭.** "FAQ 결과가 있으면 faq"라는 규칙은 FAQ 검색을 **먼저** 해야 쓸 수 있습니다. 지금은 분류가 먼저입니다. 데이터 조회는 수십 ms라 앞으로 옮겨도 손해가 거의 없습니다.
```mermaid
flowchart LR
subgraph 지금
A1{{"분류 LLM"}} --> B1["데이터 조회"] --> C1{{"답"}}
end
subgraph 바꾼 뒤
B2["데이터 조회"] --> R{"규칙으로<br/>아나?"}
R -->|예| C2{{"답"}}
R -->|아니오| A2{{"분류 LLM"}} --> C2
end
```
**두 길이 다시 만날 때.** 규칙 길과 LLM 길이 같은 답 노드로 들어갑니다. 답 노드는 "분류 결과"를 한 가지 방법으로 읽어야 하므로, 두 길의 마지막 노드가 **같은 모양**으로 분류를 내놓게 합니다. 결과의 `steps`에는 어느 길로 왔는지(규칙 · LLM)를 남겨, 나중에 "규칙이 몇 %를 처리했나"를 셀 수 있게 합니다.
**규칙의 위험.** 규칙은 빠르고 늘 같지만 정해 둔 것밖에 모릅니다. FAQ 낱말이 들어 있지만 FAQ가 답이 아닌 질문도 있을 수 있습니다. 그래서 규칙도 테스트합니다: `cases/lab02.json`에는 규칙으로 정해지는 질문(프린터 FAQ · 접수 번호 · 없는 접수 번호)과 **규칙으로 모르는 질문**이 함께 있습니다.
**결과에서 기대할 것.** 규칙으로 정한 질문은 `llm_calls`가 하나 줄고 시간도 그만큼 줄어듭니다. `cases/lab02.json`의 뒤쪽 캐시 케이스는 예제 2에서 만들 것이니 지금은 실패가 정상입니다.
<details><summary>▶ 1단계 · 순서를 바꿔야 하나</summary>
지금은 분류 → 데이터 조회 순서입니다. "FAQ 검색 결과가 있으면 faq"라는 규칙을 쓰려면 무엇이 먼저 있어야 할까요? 68일차 "나누기와 유형은 LLM, 담당과 빠진 입력은 규칙"을 떠올립니다.
</details>
<details><summary>▶ 2단계 · 두 길이 다시 만나는 곳</summary>
규칙으로 정한 길과 LLM이 정한 길이 같은 답 노드로 들어갑니다. 답 노드가 분류를 **어느 길에서 왔든 같은 이름**으로 읽게 하려면? (두 길의 마지막 노드가 같은 모양 `{category}`를 내놓게)
</details>
<details><summary>▶ 3단계 · 규칙이 틀리면</summary>
규칙으로 `faq`가 됐는데 사실 FAQ와 상관없는 질문이라면? 테스트의 "규칙으로 모르면 LLM이 분류" 케이스는 어떤 질문인가요?
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 바뀐 노드 순서 ② 규칙 셋(접수 번호 · FAQ 결과 · 나머지) ③ 규칙으로 모를 때만 분류 LLM ④ 두 길이 같은 모양으로 만나기 ⑤ `steps`에 무엇을 ⑥ 기대 결과(앞 네 케이스)**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 순서를 바꿔 줘: Has question? → Start clock → Search FAQ → Get ticket → After data → Rule classify(Code) → Known?(If).
Rule classify: 질문에 T-1234 같은 번호( ____① )가 있으면 ticket, FAQ 결과가 ____② 이면 faq, 아니면 unknown. {category, by:'rule'}.
Known? true → Answer, false → Classify → Parse category → After classify → Answer. Answer 는 $json.category 로 분류를 읽는다.
Build result: 분류는 ____③ 가 실행됐으면 그것, 아니면 Rule classify. steps 에 ____④ 또는 classify.
나머지(답, 검토, timings)는 그대로. 기대 결과 cases/lab02.json 앞 네 케이스.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 정규식입니다. `T-1002`와 `t1002`가 모두 잡혀야 합니다: 하이픈은 있어도 없어도, 대소문자는 가리지 않고, 숫자는 몇 자리인가요?
- ② FAQ 검색 결과 `results`가 몇 개일 때 "FAQ로 답할 수 있다"고 볼까요? (AGENTS.md 2절 `/faq` 응답 모양)
- ③ LLM이 분류하는 길에서 분류 결과를 마지막으로 만든 노드입니다. 위 줄의 false 쪽 순서를 봅니다.
- ④ 규칙 길을 지났다는 표시입니다. AGENTS.md 3절 `steps` 예에 있는 단계 이름을 봅니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py bench my-helpdesk requests/faq-printer.json -n 5
```
## 예제 2 · 같은 질문은 캐시에서 (10분)
**만들 것**: FAQ 답은 데이터 API의 캐시(`/cache/<key>`)에 저장하고, 같은 질문이 오면 **LLM 없이** 바로 답하기.
| 조건 | 값 |
| --- | --- |
| 캐시 키 | 질문을 앞뒤 공백 제거 · 소문자 · 연속 공백 하나로 ([AGENTS.md](AGENTS.md) 3절) |
| 결과 | 캐시에서 왔으면 `cached: true`, `llm_calls: 0` |
| 캐시하지 않는 것 | 접수 현황(`ticket`), 본문 `no_cache: true` |
| 기대 결과 | `cases/lab02.json` 전체 (`before`가 캐시를 비움) |
### 이해하기
**캐시란.** 한 번 만든 답을 저장해 두었다가, 같은 질문이 오면 다시 만들지 않고 저장한 답을 돌려주는 것입니다. 오늘은 데이터 API에 있는 작은 저장소(`/cache/<key>`)를 씁니다. 운영에서는 Redis 같은 저장소가 같은 일을 합니다.
```mermaid
flowchart LR
Q["질문"] --> K["캐시 키 만들기"] --> L{"캐시에 있나"}
L -->|있음| H["저장한 답 · LLM 0번"]
L -->|없음| W["원래 흐름<br/>(데이터 · 규칙 · LLM)"] --> S["답을 저장"] --> O["답"]
```
- **확인은 일하기 전, 저장은 답을 만든 뒤.** 이 순서가 바뀌면 캐시가 아무 일도 하지 않습니다. 이 방식을 캐시 어사이드(cache-aside)라고 부릅니다.
- **캐시 키**: 같은 답을 받아야 할 질문이 같은 키를 갖게 하는 이름표입니다. `" VPN 접속이…"`와 `"vpn 접속이…"`는 사람 눈에는 같은 질문이므로, 앞뒤 공백 · 대소문자 · 연속 공백을 다듬은 글자를 키로 씁니다.
- **없을 때 404**: 데이터 API는 캐시에 없으면 HTTP 404를 돌려줍니다. 이것은 고장이 아니라 "없음"이라는 정상 답이므로, 404에서 워크플로우가 멈추지 않게 해야 합니다.
- **수명(ttl)**: 오늘은 600초가 지나면 저절로 지워집니다. FAQ 원문이 바뀌어도 최대 10분 뒤에는 새 답이 나갑니다.
**캐시하면 안 되는 것.**
| 답 | 캐시해도 되나 |
| --- | --- |
| VPN FAQ 답 | 됨: 같은 질문이면 언제 · 누구에게나 같은 답 |
| T-1002 접수 상태 | **안 됨**: 10분 뒤 상태가 바뀌면 예전 답이 나감 |
| `no_cache: true`를 보낸 요청 | 읽지도 쓰지도 않음 (재기 · 테스트용) |
**테스트를 흔드는 캐시.** 앞 테스트가 남긴 캐시 때문에 다음 테스트 결과가 달라질 수 있습니다. 그래서 `cases/lab02.json`은 맨 앞 `before`에서 캐시를 비우고 시작합니다. 손으로 시험할 때도 결과가 이상하면 먼저 캐시를 비워 봅니다.
**결과에서 기대할 것.** 처음 묻는 VPN은 평소 시간, 띄어쓰기만 다른 같은 질문은 0.1초 안팎에 `cached: true`, `llm_calls: 0`. 접수 질문은 두 번 물어도 매번 새로 조회합니다.
<details><summary>▶ 1단계 · 어디서 확인하고 어디서 저장하나</summary>
캐시는 **일을 하기 전에** 보고, 답을 만든 **뒤에** 저장합니다. 캐시에 없을 때 데이터 API는 404를 돌려줍니다. 404에서 워크플로우가 멈추지 않게 하는 옵션이 AGENTS.md 5절에 있습니다.
</details>
<details><summary>▶ 2단계 · 캐시하면 안 되는 것</summary>
"T-1002 처리 상태"를 캐시하면 10분 뒤 상태가 바뀌어도 예전 답이 나갑니다. 캐시해도 되는 답과 안 되는 답을 나누는 기준을 요청문에 적습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 캐시 키 만드는 법 ② 확인은 일하기 **전**, 404에서 멈추지 않게 ③ 캐시에 있으면 무엇을 돌려주나 ④ 저장은 답을 만든 **뒤**, 무엇만 ⑤ 캐시하지 않는 것 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에 캐시를 더해 줘 (AGENTS.md 2절 데이터 API, 3절 캐시 규칙).
Start clock 다음: Cache key(Code: question 을 ____① , use_cache = ____② )
→ Cache lookup(GET http://data-api:8800/cache/<encodeURIComponent(key)>, ____③ ) → Cached?(value 있고 use_cache)
true → Cached result(route·category·answer·sources + llm_calls 0, cached true, steps ['cache'], timings.total_ms) → Send cached
false → 지금 흐름(Search FAQ ...).
Build result 뒤: Cache it?(category faq 이고 use_cache) true → Store in cache(POST /cache/<key> {value:{route,category,answer,sources}, ttl 600}) → Send result.
____④ 는 저장하지 않는다. 기대 결과 cases/lab02.json 전체, lab01 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 띄어쓰기 · 대소문자만 다른 두 질문이 같은 키가 되게 하는 다듬기 세 가지입니다. AGENTS.md 3절 마지막 줄.
- ② 요청 본문의 어느 칸이 "캐시를 쓰지 마"라는 뜻인가요? (AGENTS.md 3절 "입력" 칸) 그 칸이 true가 **아닐** 때 참이 되는 식입니다.
- ③ 캐시에 없으면 데이터 API가 404를 돌려줍니다. 404에서 워크플로우가 멈추지 않게 하는 HTTP Request 옵션입니다 (AGENTS.md 5절).
- ④ 상태가 바뀌어서 캐시하면 안 되는 답입니다. AGENTS.md 3절의 굵은 글씨 줄.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py test cases/lab01.json -v # 원래 테스트도
curl -s localhost:8800/cache-stats
```
**관찰**: 두 번째 VPN 질문은 몇 초였나요? `cache-stats`의 hits · misses는? 캐시가 맞을 확률(적중률)이 낮은 질문은 어떤 질문일까요?
## 예제 3 · 캐시 적중률과 시간 재기 (6분, 에이전트 없이)
예제 2의 캐시가 **얼마나** 도움이 되는지 숫자로 봅니다.
```bash
curl -s -XPOST localhost:8800/cache-clear
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 # 첫 번은 캐시에 없음, 나머지 4번은 있음
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5 # 캐시를 쓰지 않음
curl -s localhost:8800/cache-stats
```
`PERF.md` 3-1절에 적습니다.
| 재기 | 첫 호출 | p50 | max | 적중 (`hits`) · 못 찾음 (`misses`) |
| --- | --- | --- | --- | --- |
| 캐시 씀 (`faq-vpn`) | | | | |
| 캐시 안 씀 (`faq-vpn-nocache`) | | | | |
### 이해하기
**적중률이란.** 캐시를 찾았을 때 있었던 비율입니다. 데이터 API의 `/cache-stats`가 `hits`(있었음) · `misses`(없었음)를 셉니다.
> 적중률 = hits ÷ (hits + misses)
**적중률이 정하는 평균 시간.** 캐시가 아무리 빨라도, 맞는 경우가 적으면 평균은 거의 그대로입니다.
> 평균 ≈ 적중률 × 캐시 시간 + (1 − 적중률) × 원래 시간
| 적중률 | 캐시 0.1초 · 원래 2초일 때 평균 (계산 예) |
| --- | --- |
| 10% | 0.1 × 0.1 + 0.9 × 2 = 1.81초 |
| 30% | 0.3 × 0.1 + 0.7 × 2 = 1.43초 |
| 60% | 0.6 × 0.1 + 0.4 × 2 = 0.86초 |
**두 번의 bench가 보여 주는 것.**
- 캐시를 비우고 같은 VPN 질문 5번: 첫 번은 캐시에 없어 평소 시간, 나머지 넷은 캐시에서 아주 빠르게. 그래서 **p50은 아주 작고 p95 · max는 첫 번의 시간**이 됩니다. 캐시는 "보통"을 크게 줄이지만 "처음 묻는 질문"은 줄이지 못한다는 뜻입니다.
- `no_cache`로 5번: 매번 원래 흐름. 이것이 캐시 없는 기준입니다.
**숫자가 무엇을 세는지 먼저 확인.** `no_cache` 요청도 캐시를 **조회는** 할 수 있습니다 (쓰지는 않음). 그러면 hits · misses에 함께 세어집니다. 도구가 내놓는 숫자는 늘 "무엇을 셌나"를 먼저 봅니다.
**실제 서비스라면.** 사람들은 같은 것을 **다른 말로** 묻습니다. 질문 글자를 키로 쓰는 캐시는 글자가 같아야만 맞으므로 실제 적중률이 낮을 수 있습니다. 이 생각이 과제 2(FAQ 번호로 캐시)로 이어집니다.
<details><summary>▶ 1단계 · 적중률로 평균 시간 어림하기</summary>
캐시에서 오면 약 0.1초, 아니면 약 2초라고 할 때, 하루 질문의 30%가 캐시에서 온다면 평균은 얼마쯤일까요? (0.3 × 0.1 + 0.7 × 2) 적중률이 10%라면? **캐시가 빨라도 적중률이 낮으면 평균은 거의 그대로**입니다.
</details>
<details><summary>▶ 2단계 · 적중률을 올리려면</summary>
지금 캐시 키는 "질문 글자"입니다. "VPN이 자꾸 끊겨요"와 "VPN 접속이 자꾸 끊겨요"는 다른 키입니다. 같은 답을 받을 질문들이 **같은 키**를 갖게 하려면 무엇을 키로 쓸 수 있을까요? (과제 2)
</details>
## 과제 1 · LLM 한 번, 2.5초 안 (12분)
팀장이 요청했습니다.
> "캐시는 같은 질문에만 도움이 되잖아요. 처음 묻는 FAQ · 접수 질문도 2.5초 안에 답해 주세요. 대신 답의 내용은 지금과 같아야 합니다."
```bash
uv run tools/flow.py test cases/lab02-challenge.json -v # 고치기 전
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5
```
| 케이스 | 기대 |
| --- | --- |
| VPN · 비밀번호 · 접수 현황 (캐시 없이) | `llm_calls: 1`, 답에 핵심 내용, 2.5초 안 |
| 규칙으로 모르는 질문 | `llm_calls: 2` (분류 + 답) |
| 빈 질문 | 400, 1초 안 (LLM 없이) |
**먼저 생각해 보기 (5분)**: 지금 남은 LLM 호출은 무엇무엇인가요? 그중 **셀 수 있는 검사**로 바꿀 수 있는 것은? (69일차 "셀 수 있는 검사는 규칙, 뜻을 봐야 하는 검사만 LLM")
### 이해하기
**팀장의 말을 바꿔 보면.** 캐시는 **두 번째 같은 질문**에만 도움이 됩니다. 처음 묻는 질문을 빠르게 하려면 흐름 자체에서 LLM을 줄여야 합니다. 지금 남은 LLM은 (규칙으로 정해진 질문이면) 답 · 검토 두 번입니다. 목표는 한 번입니다.
**검토 LLM이 실제로 하는 일.** Executions에서 답 LLM의 출력과 검토 LLM의 출력을 나란히 보면, 대부분 **말투만** 다듬습니다. 그런데 가끔 사실을 바꿔 쓰기도 합니다 (Lab 01에서 접수 상태 "처리 중"이 다른 말로 바뀌어 테스트가 실패한 일). 검토가 오히려 틀림을 만든 것입니다.
**두 가지 검사 방법.**
| 방법 | 잘하는 것 | 약한 것 |
| --- | --- | --- |
| 코드 검사 | 빠름(ms), 늘 같음: 빈 답, 한국어인지, 너무 긴지 | 뜻은 모름 |
| LLM 검토 | 뜻을 봄: 사실과 맞나, 없는 약속 | 느리고(약 1초) 비싸고, 흔들림 |
말투는 처음부터 **답 LLM의 지시문**으로 정하면 검토가 필요 없습니다. 셀 수 있는 것은 코드가 봅니다 (69일차 "셀 수 있는 검사는 규칙, 뜻을 봐야 하는 검사만 LLM").
```mermaid
flowchart LR
subgraph 지금
A1{{"답 LLM"}} --> R1{{"검토 LLM"}} --> B1["결과"]
end
subgraph 바꾼 뒤
A2{{"답 LLM<br/>(지시문에 말투)"}} --> R2["검사 Code<br/>빈 답 · 한국어 · 길이"] --> B2["결과"]
end
```
**잃는 것도 있습니다.** 검토가 하던 "뜻을 보는" 일은 이제 아무도 하지 않습니다. 그 일이 꼭 필요한 업무(예: 규제가 있는 답)라면 검토를 남기고 다른 곳을 줄여야 합니다. 바꾼 것과 잃은 것을 `PERF.md` 3절에 함께 적습니다.
**테스트 `cases/lab02-challenge.json`의 조건.**
- 처음 묻는 FAQ · 접수 질문(캐시 없이)이 LLM 한 번, 2.5초 안.
- 규칙으로 모르는 질문은 분류 + 답 두 번 (분류는 남김).
- 빈 질문은 LLM 없이 바로 거절.
- 시간 기대가 있는 테스트는 첫 호출에 흔들리므로, 이 파일의 `before`가 먼저 한 번 불러 둡니다(워밍업). `--repeat 3`으로 세 번씩 돌려 운이 아닌지 확인합니다.
**결과에서 기대할 것.** LLM 하나를 빼면 약 1초가 줄어듭니다 (Lab 01 예제 3의 검토 단계 시간과 비슷한 만큼). 같은 요청으로 `bench`를 다시 재어 전 · 후를 같은 날 비교합니다.
<details><summary>▶ 1단계 · 검토 LLM은 무엇을 하나</summary>
Executions에서 검토 LLM의 입력과 출력을 비교해 봅니다. 답의 **사실**이 바뀌나요, 말투만 바뀌나요? 말투는 답 LLM의 지시문으로도 정할 수 있지 않을까요?
</details>
<details><summary>▶ 2단계 · 무엇을 남길까</summary>
검토 LLM을 없애면 잃는 것이 있습니다. 빈 답, 한국어가 아님, 너무 김 같은 것은 Code로 확인할 수 있습니다. `steps`의 이름도 바뀌겠지요 (예: `check`).
</details>
<details><summary>▶ 3단계 · 확인 순서</summary>
`lab02-challenge` → `lab02` → `lab01` 순서로 모두. 그리고 `bench`로 p50 · p95를 다시 재 `PERF.md` 3절에 적습니다. 첫 호출이 느리면 테스트가 실패할 수 있습니다. 이 테스트 파일의 `before`가 무엇을 하는지 열어 봅니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 지울 노드(검토 LLM과 그 모델) ② 그 자리에 둘 Code와 셀 수 있는 검사 셋 ③ 말투는 어디서 정하나 ④ `steps` · `llm_calls`가 어떻게 바뀌나 ⑤ "나머지는 그대로" ⑥ 테스트 셋 모두**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에서 Review(검토 LLM)와 Review model 을 지우고, 그 자리에 Review check(Code)를 둬:
Answer 의 output 을 trim, 비었으면 empty, 한글이 없으면 not_korean, ____① 자 넘으면 자르고 too_long 을 review_problems 에.
Answer 지시문에 " ____② "가 이미 있는지 확인. steps 에는 review 대신 ____③ , llm_calls 는 ____④ .
나머지는 그대로. cases/lab02-challenge.json, lab02.json, lab01.json 모두 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 답 길이의 상한입니다. 교안 2단계 6절의 검사 표에 있습니다. 내 업무라면 몇 자가 알맞을지도 생각해 봅니다.
- ② 검토 LLM이 하던 말투 다듬기를 대신할 지시입니다. 말투 하나와 길이 제한 하나를 넣습니다.
- ③ 검토 대신 코드 검사를 지났다는 짧은 단계 이름입니다. 보고서에서 알아볼 수 있으면 내가 정해도 됩니다.
- ④ 지운 노드는 세면 안 됩니다. 이제 요청 하나에 LLM 노드를 몇 개 지나가나요? (테스트의 `llm_calls` 기대도 확인)
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02-challenge.json -v --repeat 3
uv run tools/flow.py test cases/lab02.json -v
uv run tools/flow.py test cases/lab01.json -v
uv run tools/flow.py bench my-helpdesk requests/faq-vpn-nocache.json -n 5
```
**더 생각해 보기**: 더 작은 모델(예: 분류만 작은 모델)로 바꾸는 방법도 있습니다. 그때 무엇을 다시 재야 할까요? (75일차 비용에서 다시)
## 과제 2 · FAQ 번호로 한 번 더 캐시 (12분)
팀장이 다시 요청했습니다.
> "캐시 적중이 생각보다 적어요. 다들 VPN이 끊긴다고 묻는데, 말이 조금씩 달라서 매번 LLM을 부르더라고요. 같은 FAQ로 답하는 질문이면 말이 달라도 저장해 둔 답을 쓰게 해 주세요. 그리고 어떤 캐시에서 왔는지 결과에 남겨 주세요."
기대 결과는 `cases/lab02-challenge2.json`에 있습니다 (`before`가 캐시를 비움).
| 케이스 | 기대 |
| --- | --- |
| 처음 묻는 VPN | `cached: false` |
| 다른 말로 묻는 VPN ("VPN 연결이 계속 끊어지는데요") | `cached: true`, `cache_by: "faq"`, `llm_calls: 0`, 1초 안 |
| 같은 말 (띄어쓰기 · 대소문자만 다름) | `cached: true`, `cache_by: "question"` |
| `no_cache: true` | 두 캐시 모두 쓰지 않음 |
| 다른 FAQ (프린터 처음) | `cached: false` |
| 접수 현황 | `cached: false` |
**먼저 생각해 보기 (3분)**: FAQ 번호를 알려면 무엇을 먼저 해야 하나요? 그러면 두 번째 캐시는 흐름의 **어디에** 들어가나요? 질문 캐시(예제 2)와 무엇이 다른가요? 잃는 것은 무엇일까요?
### 이해하기
**팀장의 말을 바꿔 보면.** 질문 캐시는 글자가 같아야 맞습니다. 사람들은 같은 것을 다른 말로 묻습니다 ("VPN이 끊겨요" · "VPN 연결이 계속 끊어지는데요"). 그런데 이 두 질문은 결국 **같은 FAQ**(같은 FAQ 번호)로 답합니다. 그렇다면 "질문 글자"가 아니라 "FAQ 번호"를 키로 쓰면 더 많이 맞습니다.
**키를 넓게 잡을수록.**
| 키 | 같은 키가 되는 질문 | 적중 | 잘못 맞을 위험 |
| --- | --- | --- | --- |
| 질문을 다듬은 글자 (예제 2) | 띄어쓰기 · 대소문자만 다른 질문 | 낮음 | 거의 없음 |
| FAQ 번호 (이 과제) | 같은 FAQ로 답하는 질문 모두 | 높음 | 답이 질문에 꼭 맞지 않을 수 있음 |
| 뜻 (임베딩, 오늘 안 함) | 비슷한 뜻의 질문 | 더 높음 | 비슷하지만 다른 질문에 같은 답 |
**두 번째 캐시의 자리.** FAQ 번호는 FAQ 검색을 해야 알 수 있습니다. 그래서 이 캐시는 질문 캐시처럼 맨 앞이 아니라 **FAQ 검색 뒤, LLM 앞**에 들어갑니다. 데이터 조회(수십 ms)는 하고 LLM(1초 이상)만 건너뜁니다.
```mermaid
flowchart LR
Q["질문"] --> C1{"질문 캐시"}
C1 -->|있음| A1["답 · 어느 캐시: 질문"]
C1 -->|없음| D["FAQ 검색 · 규칙"] --> C2{"FAQ 번호 캐시"}
C2 -->|있음| A2["답 · 어느 캐시: FAQ"]
C2 -->|없음| L{{"LLM"}} --> S["두 키에 저장"]
```
**어느 캐시에서 왔는지 남기기.** 결과에 `cache_by`를 두어 질문 캐시 · FAQ 번호 캐시 · 캐시 아님을 구분합니다. 나중에 "FAQ 번호 캐시가 하루에 몇 번 맞았나"를 세어 이 캐시가 쓸모 있는지 판단할 수 있습니다.
**캐시하지 않는 것은 그대로.** 접수 현황과 `no_cache` 요청은 두 캐시 모두 읽지도 쓰지도 않습니다. FAQ가 아닌 질문은 FAQ 번호가 없으니 이 캐시를 쓸 수 없습니다.
**테스트 `cases/lab02-challenge2.json`의 순서가 중요합니다.** 캐시를 비운 뒤 (1) 처음 VPN → 캐시에 저장, (2) 다른 말 VPN → FAQ 번호 캐시에 맞아야 함, (3) 처음 질문과 띄어쓰기만 다른 것 → 질문 캐시, (4) `no_cache` → 두 캐시 모두 안 씀, (5) 다른 FAQ(프린터) 처음 → 캐시 없음, (6) 접수 → 캐시 안 함. 앞 케이스가 저장한 것을 뒤 케이스가 쓰므로 순서대로 읽습니다.
**잃는 것.** FAQ 번호 캐시의 답은 **처음 물은 사람의 질문**에 맞춰 쓴 답입니다. "VPN이 끊겨요"에 맞춘 답이 "VPN 설치는 어떻게 해요?"에도 나갈 수 있습니다 (둘 다 같은 FAQ라면). 답이 FAQ 원문 요약이면 괜찮고, 질문마다 달라야 하면 이 캐시는 맞지 않습니다.
<details><summary>▶ 1단계 · 두 캐시의 자리</summary>
질문 캐시는 일하기 **전**(FAQ 검색보다 앞)에 봅니다. FAQ 번호 캐시는 FAQ 검색이 끝나야 번호를 알 수 있으므로 **FAQ 검색 뒤, LLM 앞**에 봅니다. 데이터 조회(수십 ms)는 하고, LLM(1초 이상)만 건너뜁니다.
</details>
<details><summary>▶ 2단계 · 저장은 두 번</summary>
답을 만든 뒤 질문 키에도, `faq-<FAQ 번호>` 키에도 저장합니다 ([AGENTS.md](AGENTS.md) 3절 캐시 키 표). 접수 현황 · `no_cache`는 둘 다 저장하지 않습니다.
</details>
<details><summary>▶ 3단계 · 잃는 것</summary>
FAQ 번호 캐시에서 온 답은 **처음 물은 사람의 질문**에 맞춰 쓴 답입니다. 다른 사람이 조금 다른 것을 물어도 같은 답이 나갑니다. 답이 FAQ 원문 요약이라 괜찮은지, 질문마다 달라야 하는지 `PERF.md` 3절에 한 줄 적습니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 두 번째 캐시를 볼 자리 ② 키 만드는 법과 쓰지 않는 경우 ③ 있으면 무엇을 돌려주나(`cache_by`) ④ 없으면 원래 흐름으로 ⑤ 저장 자리 ⑥ 질문 캐시 · 결과에 `cache_by` ⑦ 테스트**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에 FAQ 번호 캐시를 더해 줘 (AGENTS.md 3절 캐시 키 표).
Rule classify 다음: FAQ cache key(Code: category 가 faq 이고 use_cache 이고 FAQ 결과가 있으면 faq_key = ' ____① ' + 첫 FAQ id, 아니면 'none')
→ FAQ cache lookup(GET /cache/<faq_key>, neverError) → FAQ cached?(value 있고 faq_key 가 'none' 아님)
true → FAQ cached result(llm_calls 0, cached true, cache_by ' ____② ', steps ['faq', 'rule', 'cache']) → Send
false → Back to rule(Code: Rule classify 의 결과를 그대로) → Known?
Store in cache 뒤에 Store by FAQ(POST /cache/<faq_key>, 같은 value, ttl ____③ ) → Result again.
질문 캐시의 Cached result 에는 cache_by ' ____④ ', Build result 에는 cache_by null.
기대 결과 cases/lab02-challenge2.json, lab02-challenge.json, lab02.json 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① AGENTS.md 3절 캐시 키 표의 둘째 줄을 봅니다. FAQ 번호 **앞에** 붙는 글자입니다.
- ② AGENTS.md 3절 "실습마다 더하는 칸"의 Lab 02 과제 2 줄을 봅니다. 어느 키로 찾았는지에 따라 값이 둘입니다.
- ③ 질문 캐시(예제 2)와 같게 둡니다. 캐시 키 표의 ttl 칸.
- ④ ②와 짝이 되는 나머지 값입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab02-challenge2.json -v
uv run tools/flow.py test cases/lab02-challenge.json -v
uv run tools/flow.py test cases/lab02.json -v
curl -s localhost:8800/cache-stats
```
**더 생각해 보기**: 예제 3의 적중률 계산을 다시 해 봅니다. FAQ가 6개뿐이라면 FAQ 질문의 적중률은 어디까지 오를 수 있을까요? 그 대신 FAQ 원문이 바뀌면 캐시는 언제 새 답을 내나요? (ttl 600초)
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 캐시 케이스가 늘 `cached: false` | 캐시 키가 저장할 때와 찾을 때 같은지 (`call`로 두 번 부르고 `curl -s localhost:8800/cache-stats`) |
| 캐시 조회에서 워크플로우가 멈춤 (404) | 캐시 조회 HTTP 노드에 `neverError` |
| 테스트를 다시 돌리면 결과가 다름 | 캐시가 남아 있음. `curl -s -XPOST localhost:8800/cache-clear` 또는 `no_cache: true` |
| `llm_calls`가 기대와 다름 | `Build result`가 세는 노드 이름이 실제 노드 이름과 같은지 |
| `cache_by`가 늘 `null` | 두 캐시 결과 Code에 `cache_by`를 넣었는지, `FAQ cached?`가 true 쪽으로 가는지 (`call ... -v`) |
| 다른 말 VPN이 `cached: false` | 저장할 때 `faq_key`를 쓰는지 (`curl -s localhost:8800/cache/faq-FAQ-003`) |
| 계속 안 됨 | `reference/lab02-example1-rules.json` 또는 `reference/lab02-example2-cache.json`을 `my-work/helpdesk.json`으로 복사 후 push |
## 확인
- [ ] 예제 1: `cases/lab02.json` 앞 네 케이스 통과, 규칙 질문에 `classify`가 없다.
- [ ] 예제 2: `cases/lab02.json` 9/9, `lab01`도 통과, 캐시 적중 시간을 적었다.
- [ ] 예제 3: 캐시 씀 · 안 씀 bench와 hits · misses, 적중률 어림을 적었다.
- [ ] 과제 1: `cases/lab02-challenge.json` 3번 반복 통과, `lab02` · `lab01` 통과, 바꾸기 전·후 p50 · p95.
- [ ] 과제 2: `cases/lab02-challenge2.json` 통과, `lab02-challenge` · `lab02`도 통과, 잃는 것 한 줄.
- [ ] `PERF.md` 3절에 바꾼 것마다 한 줄 (잃은 것도).
+498
View File
@@ -0,0 +1,498 @@
# Lab 03 · 멈춰도 답하게: 타임아웃 · 재시도 · 대체 경로 · 상태 점검
- 관련 강의: [3단계 · 운영 관점의 안정성](../../Lecture/72/03-STAGE3-STABILITY.md)
- 소요 시간: 50분 (6교시 한 시간) · 예제 1 10분 · 예제 2 10분 · 예제 3 10분 · 과제 1 10분 · 과제 2 10분
- 준비: Lab 02의 `my-work/helpdesk.json` (없으면 `cp reference/lab02-example2-cache.json my-work/helpdesk.json` 후 push)
- 목표: 데이터 API나 LLM이 **느려지거나 멈췄을 때** 워크플로우가 끝없이 기다리거나 500으로 끝나지 않고, **정해진 시간 안에 정해진 답**을 하게 만든다. 그리고 무엇이 멈췄는지 바로 알 수 있는 상태 점검을 만든다.
```mermaid
flowchart LR
E1["예제 1<br/>데이터 API 고장<br/>timeout · 재시도 · 담당자"] --> E2["예제 2<br/>LLM 멈춤<br/>간단 답변"] --> E3["예제 3<br/>접수 API만 고장<br/>부분 고장"] --> T1["과제 1<br/>상태 점검 my-health"] --> T2["과제 2<br/>차단기<br/>기다리지 않기"]
```
> 고장은 **일부러** 냅니다. `uv run tools/flow.py fault faq slow|error|ok`, `uv run tools/flow.py llm down|up`. 연습이 끝나면 반드시 되돌립니다 (테스트 파일은 `after`로 되돌림).
## 예제 1 · 데이터 API가 느리거나 오류일 때 (10분)
먼저 지금 워크플로우로 겪어 봅니다.
```bash
uv run tools/flow.py fault faq slow # FAQ가 8초 뒤에 답함
uv run tools/flow.py call my-helpdesk '{"question": "비밀번호를 잊어버렸어요", "no_cache": true}' -v
uv run tools/flow.py fault faq error # FAQ가 500
uv run tools/flow.py call my-helpdesk '{"question": "비밀번호를 잊어버렸어요", "no_cache": true}' -v
uv run tools/flow.py fault all ok
```
`PERF.md` 4절 "고치기 전"을 채웁니다. 사용자는 몇 초를 기다렸고, 무엇을 받았나요?
**만들 것**: FAQ · 접수 조회가 **2초 안에** 답하지 않거나 실패하면 한 번 더 해 보고, 그래도 안 되면 `담당자 연결`로 정해진 답.
| 조건 | 값 |
| --- | --- |
| 기다리는 시간 | 2초, 다시 해 보기 1번 (0.5초 뒤) |
| 실패했을 때 | HTTP 200, `route: 담당자 연결`, 이유 (예: `reason: data_api_down`) |
| 기대 결과 | `cases/lab03.json` (케이스마다 `before`로 고장, `after`로 되돌림) |
### 이해하기
**외부 서비스는 반드시 멈춥니다.** 데이터 API, LLM, 어떤 서비스든 언젠가는 느려지거나 오류를 냅니다. 안정성 설계는 "멈추지 않게"가 아니라 **"멈춰도 정해진 시간 안에 정해진 답을 하게"** 만드는 것입니다.
**고치기 전에 겪은 두 가지.**
| 고장 | 사용자가 겪는 것 | 까닭 |
| --- | --- | --- |
| FAQ가 느림 (8초 뒤 답) | 8초 넘게 기다린 뒤에야 답 | 기다리는 한도가 없어 끝까지 기다림 |
| FAQ가 오류 (500) | 곧바로 HTTP 500, 아무 안내 없음 | 오류에서 워크플로우가 멈춤 |
느림이 오류보다 나쁠 때가 많습니다. 기다리는 동안 n8n도 그 일을 붙잡고 있어, 사람이 몰리면 다른 요청까지 밀립니다.
**세 가지 설정.**
| 설정 | 하는 일 | 비유 |
| --- | --- | --- |
| 기다리는 한도 (timeout) | 이 시간 안에 답이 없으면 실패로 봄 | 전화를 몇 번 울려도 안 받으면 끊기 |
| 다시 해 보기 (retry) | 실패하면 잠깐 쉬고 한 번 더 | 한 번 더 걸어 보기 |
| 실패해도 다른 길로 (오류 출력) | 멈추지 않고 두 번째 출력으로 보냄 | 안 되면 "담당자에게 전해 드릴게요" |
```mermaid
flowchart LR
F["FAQ 검색<br/>2초 기다림"] -->|성공| N["평소 흐름"]
F -->|실패| W["0.5초 쉬고 한 번 더"] -->|성공| N
W -->|또 실패| D["정해진 답<br/>담당자 연결"]
```
**최악의 시간을 먼저 계산합니다.** 기다림 2초, 한 번 더, 사이 0.5초면 최악 = 2 + 0.5 + 2 = 4.5초에 다른 단계 시간이 조금 더해집니다. 테스트(`cases/lab03.json`)의 느림 케이스는 이 안쪽(7초 안)을 기대합니다. 재시도를 늘리면 최악도 늘어납니다. 재시도는 "잠깐 끊긴" 고장에 쓰는 것이지 "계속 느린" 고장에 쓰는 것이 아닙니다 (계속 느리면 과제 2의 차단기).
**정해진 답에 까닭 남기기.** `route: 담당자 연결`만이 아니라 `reason`(예: `data_api_down`)을 두면, 운영자가 나중에 "오늘 데이터 고장으로 넘긴 것이 몇 건"인지 셀 수 있습니다.
**캐시는 고장에도 도움이 됩니다.** 질문 캐시를 FAQ 검색 **앞**에서 보므로, FAQ가 멈춰도 캐시에 있는 질문은 평소처럼 답합니다. 테스트 마지막 케이스가 이것을 확인합니다 (먼저 한 번 물어 캐시에 넣고 → FAQ를 고장 내고 → 같은 질문).
**테스트의 `before` · `after`.** 케이스 앞에서 고장을 켜고, 뒤에서 **반드시** 끕니다 (실패해도). 손으로 고장을 켰다면 직접 `fault all ok`로 되돌립니다.
<details><summary>▶ 1단계 · 세 가지 설정</summary>
기다리는 시간(timeout), 다시 해 보기(retry), 실패해도 멈추지 않고 다른 길로(error output). [AGENTS.md](AGENTS.md) 5절에 세 가지가 어디에 쓰이는지 있습니다. 하나는 `parameters` 안, 둘은 노드 바로 아래입니다.
</details>
<details><summary>▶ 2단계 · 최악의 시간은 얼마인가</summary>
timeout 2초 × 2번 + 기다림 0.5초. 테스트의 `max_seconds`는 몇 초인가요? retry를 3번으로 늘리면?
</details>
<details><summary>▶ 3단계 · 캐시가 있으면</summary>
FAQ가 멈춰도 캐시에 있는 질문은 답할 수 있어야 합니다. 테스트의 마지막 케이스는 어떤 순서로 무엇을 하나요? 캐시 확인이 FAQ 조회보다 **앞에** 있어야 하는 이유는?
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 어느 노드에 ② 기다리는 시간 ③ 다시 해 보기 횟수 · 기다림 ④ 실패하면 어디로(오류 출력) ⑤ 정해진 답의 칸(route · answer · reason) ⑥ 그대로 둘 설정 ⑦ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Search FAQ 와 Get ticket 에 options.timeout ____① , 노드 설정 retryOnFail true, maxTries ____② , waitBetweenTries 500,
onError ____③ 을 넣어 줘. 두 노드의 오류 출력(main 1번)은 Data down(Set: route 담당자 연결, answer "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.", reason data_api_down)
→ Send data down(Respond). Get ticket 은 404 를 받아야 하니 ____④ 는 그대로 둔다. 기대 결과 cases/lab03.json, lab02 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 단위는 ms입니다. 위 조건 표의 "몇 초 안에"를 ms로 바꿉니다.
- ② 처음 한 번 + 다시 한 번입니다. 최악의 시간(시도 × timeout + 기다림)이 `cases/lab03.json`의 느림 케이스 `max_seconds`를 넘지 않는지 계산합니다 (교안 3단계 3절).
- ③ 실패해도 멈추지 않고 **두 번째 출력**으로 보내는 값입니다 (AGENTS.md 5절).
- ④ 4xx를 보통 결과로 받게 해 주는 옵션입니다. Lab 02 예제 2의 캐시 조회에도 썼습니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03.json -v
uv run tools/flow.py test cases/lab02.json -v # 평소에는 그대로인지
```
## 예제 2 · LLM이 멈췄을 때 (10분)
OpenAI 키가 만료되거나, 서비스가 멈추거나, 사용 한도를 넘으면 LLM 노드가 실패합니다. 일부러 겪어 봅니다.
```bash
uv run tools/flow.py llm down
uv run tools/flow.py call my-helpdesk requests/faq-vpn-nocache.json -v
uv run tools/flow.py llm up # 반드시 되돌리기
```
**만들 것**: LLM 노드(분류 · 답)가 실패하면, LLM 없이 **데이터 그대로** 답하기.
| 질문 | LLM이 멈췄을 때 |
| --- | --- |
| FAQ가 있는 질문 | `route: 간단 답변`, FAQ 원문 + "자동 답변을 잠시 쓸 수 없다"는 안내 |
| 접수 번호 질문 | `route: 간단 답변`, 상태와 메모를 그대로 |
| 그 밖 | `route: 담당자 연결` |
| 기대 결과 | `cases/lab03-llm-down.json` (`before`가 `llm down`, `after`가 `llm up`) |
### 이해하기
**LLM이 멈추는 경우.** API 키 만료, 사용 한도 초과, 회사 쪽 장애, 네트워크. 오늘은 `llm down`으로 키를 일부러 틀리게 바꿔 이 상황을 만듭니다. 지금 워크플로우는 LLM 노드가 실패하면 그대로 멈춰 HTTP 500을 돌려줍니다.
**데이터는 이미 있습니다.** LLM이 실패하는 시점에는 FAQ 검색 결과와 접수 건을 **이미 조회했습니다.** LLM이 하던 일은 "그 데이터로 친절한 문장 쓰기"였습니다. 문장을 다듬지 못하더라도 데이터 자체는 사용자에게 줄 수 있습니다.
```mermaid
flowchart LR
D["데이터 조회<br/>(이미 끝남)"] --> L{{"LLM"}}
L -->|성공| A["평소 답"]
L -->|실패| S["간단 답변 Code<br/>데이터 그대로"]
S --> T["접수 건 → 상태 · 메모 그대로"]
S --> F["FAQ → 원문 + 안내 한 줄"]
S --> H["둘 다 없음 → 담당자"]
```
**대안(fallback)의 순서.** 다른 회사 LLM으로 바꾸는 방법도 있지만, 키 · 비용 · 품질을 하나 더 관리해야 합니다. 오늘은 **데이터로 답할 수 있는 것은 데이터로** 답합니다. 데이터도 없으면 사람에게 넘깁니다.
**사용자에게 솔직하게.** 평소 답과 같은 척하지 않습니다. FAQ 원문을 그대로 줄 때는 "자동 답변 기능을 잠시 쓸 수 없어 원문으로 안내한다"는 한 줄을 붙입니다. 결과의 `route`도 평소(`자동 답변`)와 다르게 두어, 운영자가 "오늘 간단 답변이 몇 건이었나"를 셀 수 있게 합니다.
**LLM 노드도 오류 출력을 가질 수 있습니다.** HTTP 노드처럼 Agent 노드에도 "실패하면 두 번째 출력으로" 설정을 둘 수 있습니다 (AGENTS.md 5절). 분류 LLM과 답 LLM 중 어느 것이 먼저 실패하든 같은 간단 답변으로 가게 잇습니다.
**테스트 `cases/lab03-llm-down.json`.** `before`가 `llm down`, `after`가 `llm up`입니다. FAQ 질문 · 접수 질문 · 모르는 질문 · 빈 질문 네 가지가 각각 맞는 대안으로 가는지 봅니다. 테스트가 중간에 멈춰도 `after`는 실행되지만, 혹시 LLM이 계속 실패하면 직접 `llm up`을 합니다.
<details><summary>▶ 1단계 · 데이터는 이미 있다</summary>
LLM이 실패하는 시점에는 FAQ 결과와 접수 건을 **이미 조회했습니다.** 그것을 그대로 쓰는 답을 Code로 만들 수 있습니다. 어느 노드의 오류 출력을 어디로 이을까요?
</details>
<details><summary>▶ 2단계 · 사용자에게 무엇을 알리나</summary>
평소 답과 같은 척하지 않고, 지금은 간단한 답이라는 것을 알립니다. `route`를 다르게 두는 이유는? (운영자가 나중에 세어 볼 수 있게)
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 어느 LLM 노드에 오류 출력 ② 오류 출력을 어디로 ③ 접수 번호가 있을 때의 답 ④ FAQ가 있을 때의 답과 안내 문장 ⑤ 둘 다 아닐 때 ⑥ 공통 칸(`llm_calls` · `reason`) ⑦ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Classify 와 Answer Agent 에 onError continueErrorOutput 을 넣고, 두 노드의 오류 출력을 Simple answer(Code) → Send simple answer 로 이어 줘.
Simple answer: 질문에 접수 번호가 있고 Get ticket 결과에 status 가 있으면 route ____① , "<번호> 접수 건은 현재 '<status>' 상태입니다. <note>".
FAQ 결과가 있으면 route ____① , 첫 FAQ answer + " ( ____② )", sources [그 id].
아니면 route ____③ . 모두 llm_calls 0, reason ____④ . 기대 결과 cases/lab03-llm-down.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① AGENTS.md 3절의 `route` 세 값 중 "LLM 없이 데이터 그대로"인 것입니다 (두 곳에 같은 값).
- ② 사용자에게 평소 답이 아니라는 것을 알리는 안내 한 문장입니다. 내가 정합니다 (교안 3단계 5절 "같은 척하지 않는다").
- ③ 데이터로도 답할 수 없을 때입니다. 사람에게 넘기는 `route`.
- ④ AGENTS.md 3절 Lab 03 `reason` 값들 중 LLM이 멈춘 경우입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03-llm-down.json -v
uv run tools/flow.py test cases/lab02.json -v # llm up 이 된 뒤 평소대로인지
```
## 예제 3 · 접수 API만 멈췄을 때 (부분 고장, 10분)
먼저 예제 2까지 만든 워크플로우로 겪어 봅니다.
```bash
uv run tools/flow.py fault tickets error # 접수 API만 500 (FAQ는 멀쩡)
uv run tools/flow.py call my-helpdesk requests/ticket-own.json -v
uv run tools/flow.py call my-helpdesk requests/faq-vpn-nocache.json -v
uv run tools/flow.py fault all ok
uv run tools/flow.py test cases/lab03-ticket.json -v # 고치기 전
```
`PERF.md` 4절에 적습니다: 접수 질문과 **VPN 질문**은 각각 무엇을 받았나요? FAQ는 멀쩡한데 VPN 질문은 왜 그렇게 됐을까요?
**만들 것**: 접수 API가 500이면 **접수 질문만** `담당자 연결`(`reason: tickets_down`)로 보내고, 접수와 상관없는 FAQ 질문은 평소대로 답하기. 없는 접수 번호(404)는 고장이 아니므로 지금처럼.
| 질문 | 접수 API 500일 때 |
| --- | --- |
| 접수 번호 질문 (T-1002) | `route: 담당자 연결`, `reason: tickets_down`, 상태를 지어내지 않음, 3초 안 |
| FAQ 질문 (VPN) | `route: 자동 답변`, 답에 "인증서" |
| 없는 접수 번호 (T-9999, 평소) | `route: 자동 답변`, "찾지 못" 같은 말 |
| 기대 결과 | `cases/lab03-ticket.json` |
### 이해하기
**부분 고장.** 시스템 전체가 아니라 **일부만** 멈추는 고장입니다. 오늘은 접수 API만 500을 내고 FAQ는 멀쩡한 상황을 만듭니다. 실제 서비스에서 가장 흔한 고장 모양입니다.
**고치기 전에 겪는 이상한 일.** 예제 1에서 접수 조회가 실패하면 `Data down`(담당자 연결)으로 가게 이었습니다. 그런데 **VPN 질문도** 접수 조회를 거칩니다 (접수 번호가 없으면 없는 번호로 조회). 그래서 접수 API가 멈추면 접수와 상관없는 VPN 질문까지 담당자로 넘어갑니다. 멀쩡한 기능까지 함께 멈춘 것입니다.
```mermaid
flowchart LR
subgraph 지금
G1["접수 조회 500"] -->|오류 출력| D1["모든 질문이 담당자로"]
end
subgraph 바꾼 뒤
G2["접수 조회<br/>(상태 코드를 보통 결과로)"] --> C{"500 이상이고<br/>접수 번호 질문?"}
C -->|예| T["접수 질문만 담당자<br/>reason: tickets_down"]
C -->|아니오| N["평소 흐름<br/>(VPN은 그대로 답)"]
end
```
**왜 오류 출력으로는 안 되나.** 오류 출력은 "실패했다"만 알 뿐, 그 요청이 어떤 질문인지 가리지 않고 한 길로 보냅니다. 질문에 따라 다르게 하려면 실패를 **보통 결과로 받아** 상태 코드와 질문을 함께 보고 워크플로우가 직접 나눠야 합니다.
**404와 500은 다릅니다.**
| 상태 코드 | 뜻 | 할 일 |
| --- | --- | --- |
| 200 | 접수 건이 있음 | 평소대로 |
| 404 | 그런 번호가 없음 (정상적인 답) | 평소대로 "찾지 못했다" |
| 500 이상 | 접수 시스템이 멈춤 | 접수 질문이면 담당자, 아니면 평소대로 |
**옵션은 재 보고 씁니다.** 이 n8n(2.42.6)에서 어떤 옵션 조합이 500을 보통 결과로 주는지는 강사가 직접 재 본 표가 AGENTS.md 5절에 있습니다. 결과 모양이 바뀌므로(`statusCode` · `body`), 뒤에서 접수 건을 읽던 곳(답 LLM의 지시문, 예제 2의 간단 답변)도 **모두** 새 자리에서 읽게 바꿔야 합니다. 한 곳이라도 남으면 답에 접수 상태가 빠집니다.
**덤으로 빨라지는 것.** 예제 1에서 접수 조회에 재시도를 켰더니, 접수 번호가 없는 평소 질문(없는 번호 → 404)도 재시도를 하느라 0.5초씩 더 기다리고 있었습니다. 상태 코드를 보통 결과로 받으면 404에 재시도하지 않으므로 평소 시간도 줄어듭니다. **안정성을 위해 넣은 설정이 평소를 느리게 만들 수 있다**는 예입니다.
**이 원칙의 이름.** 고장 난 부분만 줄이고 나머지는 평소대로: 우아한 저하(graceful degradation). 의존 서비스마다 실패를 따로 다루는 설계를 격벽(bulkhead)이라고 부릅니다.
<details><summary>▶ 1단계 · 오류 출력은 "어느 질문인지"를 모른다</summary>
예제 1에서 `Get ticket`의 오류 출력을 `Data down`에 이었습니다. 접수 API가 500이면 **모든 질문**이 그 길로 갑니다 (FAQ 질문도 접수 조회를 거치니까요). 질문에 따라 다르게 하려면, 실패를 오류 출력이 아니라 **보통 결과**로 받아 워크플로우가 직접 보고 나눠야 합니다.
</details>
<details><summary>▶ 2단계 · 상태 코드를 받으려면</summary>
[AGENTS.md](AGENTS.md) 5절의 표를 봅니다. 이 n8n에서는 `neverError`만으로는 500이 여전히 실패이고, `fullResponse`를 함께 켜야 `statusCode`가 담긴 보통 결과가 옵니다. 그러면 접수 건은 `$json.body`로 옮겨 갑니다. 뒤에서 `Get ticket`을 읽던 곳(답 Agent, 간단 답변 Code)도 함께 바꿔야 합니다.
</details>
<details><summary>▶ 3단계 · 404와 500</summary>
404(없는 번호)는 "답할 수 있는 결과"이고, 500은 "시스템이 멈춤"입니다. `statusCode >= 500`일 때만 고장으로 봅니다. 그리고 질문에 접수 번호가 있을 때만 `담당자 연결`입니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① `Get ticket`의 응답 설정 ② 바로 뒤에 둘 Code와 그 결과 칸 ③ 고장으로 보는 조건 둘 ④ 고장일 때의 정해진 답 ⑤ `Get ticket`을 읽던 곳 바꾸기 ⑥ 테스트**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 의 Get ticket 응답 설정에 ____① 와 neverError 를 켜 줘 (결과가 {statusCode, body}).
Get ticket 보통 출력 다음에 Ticket check(Code): ticket = statusCode ____② 이면 {} 아니면 body, ticket_status = statusCode,
tickets_down = statusCode ____② 이고 질문에 접수 번호(/T-?\d{4}/i)가 있을 때만 true.
→ Tickets down?(If) true → Tickets down(Set: route 담당자 연결, category ticket, answer "접수 현황 시스템이 응답하지 않아 담당자에게 연결합니다. 잠시 뒤 다시 물어봐 주세요.", reason ____③ , llm_calls 0) → Send tickets down
false → 지금 흐름(After data …).
$('Get ticket').first().json 을 읽던 곳은 모두 ____④ 로 바꾼다. 기대 결과 cases/lab03-ticket.json, lab03.json, lab03-llm-down.json 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 상태 코드 · 머리글 · 본문을 통째로 받게 하는 옵션입니다. AGENTS.md 5절 `neverError` 표의 셋째 줄.
- ② 서버 고장을 뜻하는 상태 코드 범위입니다. 404(없는 번호)는 고장이 아니라는 점을 생각합니다 (두 곳에 같은 조건).
- ③ AGENTS.md 3절 Lab 03 `reason` 값들 중 접수 API만 멈춘 경우입니다.
- ④ 이제 접수 건은 `Ticket check` 결과의 어느 칸에 있나요? AGENTS.md 5절 `Ticket check` 예 아래 줄.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03-ticket.json -v
uv run tools/flow.py test cases/lab03.json -v
uv run tools/flow.py test cases/lab03-llm-down.json -v
```
## 과제 1 · 상태 점검 워크플로우 (10분)
팀장이 요청했습니다.
> "어제 오후에 답이 이상하다는 문의가 몰렸는데, 데이터 쪽 문제인지 AI 쪽 문제인지 아무도 몰랐어요. 버튼 하나로 지금 무엇이 멀쩡한지 알려 주는 걸 만들어 주세요. 그리고 점검하다가 점검이 멈추면 안 됩니다."
| 조건 | 값 |
| --- | --- |
| 파일 · 웹훅 | `my-work/health.json` · `my-health` ([AGENTS.md](AGENTS.md) 3절) |
| 결과 | `status`(`ok` · `degraded` · `down`), `checks`(`data_api` · `faq` · `llm`), `failed`, `seconds` |
| 기대 결과 | `cases/lab03-challenge.json` (평소 · FAQ 오류 · FAQ 느림 · LLM 멈춤) |
**먼저 생각해 보기 (5분)**: 상태 점검이 FAQ를 8초 기다리면 점검의 뜻이 있을까요? 하나가 실패했을 때 나머지 점검은 계속해야 할까요?
### 이해하기
**상태 점검이란.** "지금 무엇이 멀쩡하고 무엇이 멈췄나"를 묻는 작은 요청입니다. 문의가 몰렸을 때 가장 먼저 열어 보는 화면이고, 운영에서는 기계(알림 시스템 · 부하 분산기)도 이 결과를 읽습니다.
**무엇을 점검하나.** 오늘 실습 워크플로우가 기대는 것 셋입니다.
| 점검 | 방법 | 실패하면 |
| --- | --- | --- |
| 데이터 API가 살아 있나 | `/health` 부르기 | 데이터 전부 못 씀 |
| FAQ 검색이 되나 | 실제로 짧은 검색 한 번 | FAQ 답을 못 씀 |
| LLM이 답하나 | 아주 짧은 질문 한 번 ("한 낱말로 답해") | 자동 답변을 못 씀 |
`/health`만 보면 안 되는 까닭: 오늘 고장 스위치는 `/health`에 영향을 주지 않습니다. 살아는 있는데 FAQ는 못 하는 상태를 놓칩니다. **실제 일과 같은 길**로 한 번씩 해 봐야 압니다.
**점검이 멈추면 안 됩니다.**
- 점검마다 **짧은 기다리는 한도**. FAQ가 8초 느리면 점검도 8초 걸리면 점검의 뜻이 없습니다.
- 하나가 실패해도 **다음 점검을 계속**. 오류 출력으로 빠지면 나머지가 멀쩡한지 모릅니다. 실패해도 보통 출력으로 계속 가는 설정을 씁니다 (AGENTS.md 5절).
```mermaid
flowchart LR
W(["my-health"]) --> A["데이터 API 점검"] --> F["FAQ 점검"] --> L{{"LLM 점검"}} --> S["Code가 정함<br/>ok · degraded · down"]
```
**상태는 코드가 정합니다.** LLM에게 "상태를 판단해"라고 하면 안 됩니다: LLM이 멈췄을 수도 있으니까요. 세 점검의 참 · 거짓으로 Code가 정합니다.
- `ok`: 모두 됨.
- `degraded`: 일부만 됨 (사용자에게 줄 답이 조금은 있음).
- `down`: 사용자에게 줄 것이 없음.
`degraded`와 `down`의 정확한 기준은 내가 정해 요청문에 적습니다. 어떤 것이 멈추면 "아무것도 못 한다"고 볼지 생각해 봅니다 (예제 1 · 2에서 무엇이 멈춰도 무엇은 답할 수 있었나).
**결과 모양.** `status`, 점검별 참 · 거짓(`checks`), 실패한 것 이름(`failed`), 걸린 시간(`seconds`). 사람도 기계도 같은 모양을 읽습니다 (AGENTS.md 3절).
**테스트 `cases/lab03-challenge.json`.** 평소 · FAQ 오류 · FAQ 느림 · LLM 멈춤 네 경우마다 상태가 맞는지, 그리고 FAQ가 느릴 때도 점검이 **빨리 끝나는지** 봅니다.
<details><summary>▶ 1단계 · 점검 하나하나</summary>
데이터 API는 `/health`, FAQ는 실제 검색 한 번, LLM은 아주 짧은 질문 한 번. 점검마다 **짧은 timeout**과 "실패해도 다음으로"가 필요합니다. 오류 출력 대신 보통 출력으로 계속 가는 설정도 AGENTS.md 5절에 있습니다.
</details>
<details><summary>▶ 2단계 · ok · degraded · down을 누가 정하나</summary>
LLM에게 "상태를 판단해"라고 하지 않습니다 (LLM이 멈췄을 수도 있으니까요). 세 점검의 참·거짓으로 Code가 정합니다. `degraded`와 `down`의 기준을 직접 정해 요청문에 적습니다.
</details>
<details><summary>▶ 3단계 · 운영에서는</summary>
n8n에는 일정 시간마다 실행하는 Schedule Trigger가 있습니다. 5분마다 점검해 `degraded`이면 알림을 보내려면 무엇을 더해야 할까요? (오늘은 웹훅으로만, 76일차 에러 처리에서 다시)
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 새 파일 · 웹훅 ② 점검 셋(무엇을 어떻게 부르나) ③ 점검마다 짧은 timeout과 "실패해도 다음으로" ④ LLM 점검의 질문과 시간 ⑤ 참 · 거짓을 정하는 법 ⑥ `ok` · `degraded` · `down` 기준 ⑦ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/health.json 을 새로 만들어 줘 (웹훅 my-health, AGENTS.md 3절).
Start(Code t0) → Check data API(GET http://data-api:8800/health, timeout 2000, onError ____① , alwaysOutputData)
→ Check FAQ(GET /faq?q=vpn, 같은 설정) → Check LLM(Agent "Reply with the single word OK.", onError ____① , alwaysOutputData;
모델 options timeout 8000, maxRetries 0) → Summarize(Code): data_api = ____② , faq = results 가 배열, llm = ____③ .
failed = 거짓인 것 이름. status: 모두 참 ok, ____④ , 아니면 down. seconds.
기대 결과 cases/lab03-challenge.json.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 실패해도 **보통** 출력으로 다음 점검에 넘어가게 하는 값입니다. 오류 출력으로 보내는 값과 다릅니다 (AGENTS.md 5절).
- ② `/health`가 평소 돌려주는 JSON 모양을 봅니다 (AGENTS.md 2절 표 마지막 줄). 그 모양이면 참.
- ③ LLM에게 무엇이라고 답하라고 했나요? 대문자 · 마침표가 붙어도 참이 되게 씁니다.
- ④ `degraded`의 기준입니다. "일부는 된다"를 어느 점검의 참 · 거짓으로 말할까요? 위 조건 표와 교안 3단계 8절 `status` 표. 내가 정해 적어도 됩니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/health.json
uv run tools/flow.py test cases/lab03-challenge.json -v
uv run tools/flow.py doctor # 끝나면: 고장 스위치 · OpenAI 키가 평소대로인지
```
## 과제 2 · 차단기: 멈춘 곳은 잠깐 부르지 않기 (10분)
팀장이 다시 요청했습니다.
> "FAQ가 멈췄을 때 담당자로 넘기는 건 좋은데, 사람마다 5초씩 기다렸다가 넘어가요. 한 번 멈춘 걸 알았으면 잠깐은 기다리지 말고 바로 넘겨 주세요. 다시 살아나면 저절로 평소대로 돌아와야 하고요."
| 조건 | 값 |
| --- | --- |
| 기억하는 곳 | 데이터 API 캐시 키 `breaker-faq`, ttl **30초** ([AGENTS.md](AGENTS.md) 3절 캐시 키 표) |
| FAQ가 실패했을 때 | 지금처럼 `담당자 연결`(`reason: data_api_down`) + `breaker-faq` 저장 |
| `breaker-faq`가 있을 때 | FAQ를 부르지 않고 바로 `담당자 연결`, `reason: breaker_open`, 1초 안 |
| 기대 결과 | `cases/lab03-challenge2.json` (평소 · FAQ 느림 처음 · 다음 질문 · 되살아남) |
**먼저 생각해 보기 (2분)**: 30초가 지나면 무슨 일이 생기나요? 그때 FAQ가 아직 멈춰 있다면? 질문 캐시(예제 2)에 있는 질문은 차단기가 열려 있어도 답해야 할까요?
### 이해하기
**예제 1만으로 남는 문제.** FAQ가 계속 느리면, 질문하는 **사람마다** 4~5초씩 기다린 뒤 담당자로 넘어갑니다. 첫 사람에게서 이미 "FAQ가 멈췄다"를 알았는데도요. 그동안 멈춘 FAQ 서비스에는 요청이 계속 들어가 회복도 늦어집니다.
**차단기(circuit breaker).** 집의 전기 차단기처럼, 문제가 생기면 잠깐 **길을 끊습니다.**
```mermaid
stateDiagram-v2
[*] --> 닫힘
닫힘 --> 열림: FAQ 실패 (표시를 저장)
열림 --> 닫힘: 정한 시간이 지나 표시가 사라짐
닫힘: 닫힘 · FAQ를 부름
열림: 열림 · FAQ를 부르지 않고 바로 담당자
```
| 때 | 하는 일 |
| --- | --- |
| 평소 (표시 없음) | FAQ를 평소처럼 부름 |
| FAQ가 실패 | 예제 1처럼 담당자로 넘기고, **"FAQ 멈춤" 표시를 저장** |
| 표시가 있음 | FAQ를 부르지 않고 바로 담당자 (`reason`을 다르게 두어 구분), 1초 안 |
| 정한 시간이 지남 | 표시가 저절로 사라짐 → 다음 질문이 다시 FAQ를 불러 봄. 아직 멈춰 있으면 또 표시 |
**표시를 어디에 두나.** 웹훅 워크플로우는 요청마다 새로 시작해 앞 요청을 기억하지 못합니다. 그래서 표시는 **바깥**에 둡니다: 오늘은 데이터 API 캐시의 정해진 키, 운영에서는 Redis 같은 저장소. 저장할 때 수명(ttl)을 주면 닫는 일을 따로 만들 필요가 없습니다.
**얼마나 열어 둘까.** 짧으면 자주 다시 불러 보느라 기다리는 사람이 늘고, 길면 FAQ가 되살아난 뒤에도 한동안 담당자로 넘깁니다. 오늘은 30초로 정했습니다 (위 조건 표).
**캐시와의 순서.** 질문 캐시에 있는 질문은 FAQ를 부르지 않고도 답할 수 있으므로, 차단기보다 **먼저** 봅니다. 차단기가 열려 있어도 캐시에 있는 질문은 평소처럼 답합니다.
**테스트 `cases/lab03-challenge2.json`의 순서.** (1) 평소 → (2) FAQ를 느리게 하고 첫 질문 → 기다린 뒤 담당자, 표시 저장 → (3) 다음 질문 → 기다리지 않고 바로 담당자 → (4) FAQ를 되돌리고 캐시(표시 포함)를 비운 뒤 → 평소 답. (3)이 1초 안이어야 하는 것이 이 과제의 핵심입니다.
> 이 과제 뒤에 `cases/lab03.json`을 돌리면, 앞 케이스(FAQ 느림)가 연 차단기가 아직 30초 안이라 다음 케이스(FAQ 오류)가 `reason: breaker_open`으로 아주 빨리 끝날 수 있습니다. 차단기가 제대로 일한 것입니다 (테스트는 `route`만 봅니다).
<details><summary>▶ 1단계 · 차단기를 보는 자리</summary>
질문 캐시를 본 **뒤**(캐시에 있으면 FAQ와 상관없이 답함), FAQ 검색 **앞**에 `breaker-faq`를 봅니다. 없으면(404) 평소대로, 있으면 바로 정해진 답.
</details>
<details><summary>▶ 2단계 · 여는 곳과 닫히는 때</summary>
FAQ 검색의 오류 출력에서 `breaker-faq`를 저장한 뒤 `Data down`으로 갑니다. 닫는 노드는 따로 만들지 않습니다: **ttl 30초가 지나면 데이터 API가 지웁니다.** 그 뒤 첫 질문이 다시 FAQ를 불러 보고, 아직 멈춰 있으면 또 엽니다.
</details>
<details><summary>▶ 빈칸 채우기 · 요청문 뼈대</summary>
요청문에 꼭 들어갈 것: **① 차단기를 볼 자리 ② 열려 있을 때의 정해진 답(`reason`) ③ 여는 자리와 저장할 값 · ttl ④ 닫는 법 ⑤ 테스트**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
```text
my-work/helpdesk.json 에 FAQ 차단기를 더해 줘 (AGENTS.md 3절 캐시 키 표).
Cached? false → Breaker check(GET http://data-api:8800/cache/ ____① , neverError) → Breaker open?(value 있음)
true → Breaker open(Set: route 담당자 연결, answer "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.", reason ____② , llm_calls 0) → Send breaker open
false → Search FAQ.
Search FAQ 의 오류 출력 → Open breaker(POST /cache/ ____① {value:{open:true, since: Date.now()}, ttl ____③ }, neverError) → ____④ .
닫는 노드는 만들지 않는다 (ttl 이 지나면 데이터 API가 지움). 기대 결과 cases/lab03-challenge2.json, lab03.json 도 통과.
```
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
- ① 차단기 상태를 저장하는 캐시 키입니다. AGENTS.md 3절 캐시 키 표의 마지막 줄 (두 곳에 같은 키).
- ② AGENTS.md 3절 Lab 03 `reason` 값들 중 차단기가 열린 경우입니다.
- ③ 초 단위입니다. 위 조건 표와 캐시 키 표의 ttl 칸. 너무 길거나 짧으면 어떻게 되는지는 교안 3단계 7절.
- ④ 차단기를 연 뒤 **이번** 사용자에게는 어떤 답이 나가야 하나요? 예제 1에서 만든 정해진 답 노드입니다.
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
</details>
```bash
uv run tools/flow.py push my-work/helpdesk.json
uv run tools/flow.py test cases/lab03-challenge2.json -v
uv run tools/flow.py test cases/lab03.json -v
uv run tools/flow.py doctor # 끝나면: 고장 스위치 · OpenAI 키가 평소대로인지
```
마지막으로 `PERF.md` 4~6절을 채우고, [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웁니다.
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 테스트 뒤에도 FAQ가 이상함 | `uv run tools/flow.py fault all ok` |
| 테스트 뒤에도 LLM이 모두 실패 | `uv run tools/flow.py llm up` (키를 `.env`에서 다시 등록) |
| 오류 출력으로 안 가고 워크플로우가 멈춤 | 그 노드에 `onError: continueErrorOutput`이 있는지, 두 번째 출력이 연결됐는지 |
| 재시도가 안 됨 | `fullResponse`를 켠 노드는 500도 보통 결과로 받아 재시도하지 않습니다. `neverError`만이면 500은 재시도합니다 ([AGENTS.md](AGENTS.md) 5절 표) |
| 예제 3 뒤 답에 접수 상태가 안 나옴 | `$('Get ticket').first().json`을 읽던 곳이 남음. `$('Ticket check').first().json.ticket`으로 |
| 차단기가 안 열림 | 오류 출력에 `Open breaker`가 이어졌는지, `curl -s localhost:8800/cache/breaker-faq` |
| 차단기가 안 닫힘 | ttl 30을 넣었는지. 급하면 `curl -s -XPOST localhost:8800/cache-clear` |
| 느릴 때 담당자 연결이 7초를 넘음 | timeout · 재시도 횟수 · 기다림의 합 |
| 계속 안 됨 | `reference/lab03-example1-resilient.json`, `reference/lab03-example2-llm-fallback.json` |
## 확인
- [ ] 예제 1: 고치기 전 시간 · 결과를 적었고, `cases/lab03.json` 통과, `lab02`도 통과.
- [ ] 예제 2: `cases/lab03-llm-down.json` 통과, `llm up` 뒤 `lab02` 통과.
- [ ] 예제 3: 고치기 전 두 질문의 결과를 적었고, `cases/lab03-ticket.json` 통과, `lab03` · `lab03-llm-down`도 통과.
- [ ] 과제 1: `cases/lab03-challenge.json` 통과.
- [ ] 과제 2: `cases/lab03-challenge2.json` 통과, `lab03`도 통과, 끝난 뒤 `doctor` → `ready`.
- [ ] `my-work/PERF.md`, `my-work/TEST-REPORT.md`.
+179
View File
@@ -0,0 +1,179 @@
# 72일차 수강 안내 · 수업 준비부터 마칠 때까지
- 일자: 2026-10-26 (월) · 72일차
- 주제: Workflow 실행 흐름 최적화와 운영 관점 개선 (빠르게, 멈춰도 답하게)
- 실습 폴더: [Last3Mon/72](README.md) · 강의 자료: [Lecture/72](../../Lecture/72/README.md)
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`) + **72일차 판 데이터 API**(고장 스위치 · 캐시) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
```mermaid
flowchart LR
P["전날<br/>준비 · 기준 게시"] --> M["아침<br/>켜기 · doctor"] --> L1["1교시 강의 · 2교시<br/>Lab 01 실습 5개<br/>재기 · 병목 · 예산"] --> L2["3교시 강의 · 4교시<br/>Lab 02 실습 5개<br/>규칙 · 캐시 · LLM 한 번"] --> L3["5교시 강의 · 6교시<br/>Lab 03 실습 5개<br/>고장 · 부분 고장 · 차단기"] --> V["7~8교시<br/>tbench"] --> E["마친 뒤<br/>되돌리기 · 73 준비"]
```
## 0. 하루 한눈에
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
| --- | --- | --- | --- | --- |
| 전날 | 약 10분 | 자료 받기 · n8n · 72일차 데이터 API · 기준 게시 · doctor | [PREP](../../Lecture/72/00-DAY72-PREP.md) | `ready` |
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (재기와 병목) | [개요](../../Lecture/72/00-DAY72-OVERVIEW.md) 1~14장, [1단계](../../Lecture/72/01-STAGE1-MEASURE-BOTTLENECK.md) | |
| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-MEASURE-BOTTLENECK.md) | `lab01-challenge` · `lab01-challenge2` 통과 |
| 3 | 11:00~11:50 | 2단계 강의 (실행 흐름 최적화) | 개요 15~21장, [2단계](../../Lecture/72/02-STAGE2-OPTIMIZE-FLOW.md) | |
| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-OPTIMIZE-FLOW.md) | `lab02-challenge` · `lab02-challenge2` 통과 |
| - | 13:00~14:00 | 점심 | | |
| 5 | 14:00~14:50 | 3단계 강의 (운영 관점의 안정성) | 개요 22~28장, [3단계](../../Lecture/72/03-STAGE3-STABILITY.md) | |
| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-STABILITY.md) | `lab03-challenge` · `lab03-challenge2` · 보고서 |
| 7 | 16:00~16:50 | 바이브 코딩 `tbench`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TBENCH](VIBE-REQ-TBENCH.md) → [VIBE-Q-TBENCH](VIBE-Q-TBENCH.md) | `Doc/*.md` · R1~R3 · R6~R8 · R14 · `pytest` |
| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 73일차 예고 | [4단계 정리](../../Lecture/72/04-STAGE4-WRAPUP.md), [진행](../../Lecture/72/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 |
## 1. 전날 준비 (약 10분)
### 1.1 자료 받기
```bash
cd ~/aio-01d4-stu && git pull
```
### 1.2 n8n과 **72일차** 데이터 API 켜기 (Ollama는 끔)
```bash
sudo systemctl disable --now ollama 2>/dev/null # WSL에 설치한 Ollama가 있으면 끄고 자동 시작도 끔
cd ~/aio-01d4-stu/Last3Mon/65/n8n
docker compose rm -sf ollama # Ollama 컨테이너가 있으면 지움 (없으면 그냥 지나감)
docker compose start
cd ~/aio-01d4-stu/Last3Mon/72/data-api
docker compose up -d --force-recreate # 71일차 판을 72일차 판(+ 고장 스위치 · 캐시)으로 바꿈
curl -s localhost:8800/admin/fault # {"faq": "ok", "tickets": "ok", "slow_seconds": 8}
```
### 1.3 기준 워크플로우 게시 · 준비 확인
```bash
cd ~/aio-01d4-stu/Last3Mon/72
uv run tools/flow.py push reference/baseline-helpdesk.json
uv run tools/flow.py call base-helpdesk requests/faq-vpn.json -v
uv run tools/flow.py doctor # 마지막 줄 ready
```
### 1.4 미리 읽기 (선택, 5분)
[PREP 3절](../../Lecture/72/00-DAY72-PREP.md): 10번 중 9번은 2초, 1번은 9초라면 "느리다"고 해야 할까? (p50 · p95)
### 준비물
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 66일차 `~/vibe/turl` (없어도 됨).
## 2. 수업 당일 아침 (09:00 전)
1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (같은 명령 그대로). 기준 워크플로우는 게시된 채로 남아 있습니다.
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/72`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`bench`·`push`·`test`).
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
4. `cp templates/PERF-REPORT-TEMPLATE.md my-work/PERF.md`, `my-work/PROMPTS.md`를 만들어 둡니다.
## 3. 실습할 때마다 같은 순서 (오늘은 "재고 → 바꾸고 → 다시 재기")
```mermaid
flowchart LR
A["① 재기<br/>bench · call"] --> B["② 바꿀 것 하나<br/>요청문을 메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
E -->|✔| G["⑦ 다시 재기 ·<br/>PERF.md 한 줄"]
```
- **한 번에 한 가지만** 바꿉니다. 빨라졌으면 원래 테스트(`lab01`, `lab02`)로 품질이 그대로인지 확인합니다.
- 캐시가 남아 있으면 결과가 달라집니다. 이상하면 `curl -s -XPOST localhost:8800/cache-clear`.
- 고장 연습(`fault`, `llm down`)은 끝나면 **반드시 되돌립니다** (`fault all ok`, `llm up`).
## 4. 1~2교시 · 1단계 · 재기와 병목
1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-MEASURE-BOTTLENECK.md) (50분, 실습 5개).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) |
| --- | --- | --- | --- |
| 예제 1 (8분, 에이전트 없이) | 기준 재기 · Executions 노드 시간 보기 | `base-helpdesk` | `bench base-helpdesk requests/faq-vpn.json -n 5` (`ticket-own`, `off-topic`도), `PERF.md` 1절 |
| 예제 2 (12분) | 결과에 `timings` · `steps` · `llm_calls` (답은 그대로) | `cp reference/baseline-helpdesk.json my-work/helpdesk.json` → 웹훅 `my-helpdesk`로 | `push my-work/helpdesk.json` → `test cases/lab01.json` |
| 예제 3 (8분, 에이전트 없이) | 질문 종류 셋(FAQ · 접수 · IT 밖)을 재고 LLM 몫 계산 | (예제 2 그대로) | `bench my-helpdesk requests/ticket-own.json -n 5` 등 셋, `call ... -v`, `PERF.md` 2절 표 |
| 과제 1 (10분) | `faq_ms` · `ticket_ms`로 나누고 `slowest_step` | `my-work/helpdesk.json` 고치기 | `test cases/lab01-challenge.json`, `test cases/lab01.json` |
| 과제 2 (12분) | 시간 예산 `budget_ms`(기본 3000) · `over_budget` · `llm_ms` | `my-work/helpdesk.json` 고치기 | `test cases/lab01-challenge2.json`, `lab01-challenge`, `lab01` |
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
- 볼 것: 첫 호출과 그 뒤 호출의 차이, 단계 시간 중 LLM이 차지하는 몫.
## 5. 3~4교시 · 2단계 · 실행 흐름 최적화
3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-OPTIMIZE-FLOW.md) (50분, 실습 5개).
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (10분) | 데이터 조회를 앞으로, 규칙으로 분류되면 분류 LLM 생략 (`steps`에 `rule`) | `push my-work/helpdesk.json` → `test cases/lab02.json` (앞 네 케이스), `bench my-helpdesk requests/faq-printer.json -n 5` |
| 예제 2 (10분) | FAQ 답은 캐시, 같은 질문은 LLM 없이 (`cached: true`). 접수 현황 · `no_cache`는 캐시 안 함 | `test cases/lab02.json` 9/9, `test cases/lab01.json`, `curl -s localhost:8800/cache-stats` |
| 예제 3 (6분, 에이전트 없이) | 캐시 씀 · 안 씀 bench, `cache-stats`, 적중률 어림 | `bench my-helpdesk requests/faq-vpn.json -n 5`, `... faq-vpn-nocache.json -n 5` |
| 과제 1 (12분) | 처음 묻는 질문도 LLM 한 번 · 2.5초 안, 답 내용은 그대로 | 고치기 전 `test cases/lab02-challenge.json` · `bench ... faq-vpn-nocache -n 5` → 고친 뒤 `test cases/lab02-challenge.json --repeat 3`, `lab02`, `lab01`, 다시 `bench` |
| 과제 2 (12분) | 같은 FAQ로 답하는 질문은 말이 달라도 캐시 (`faq-<번호>` 키, `cache_by`) | `test cases/lab02-challenge2.json`, `lab02-challenge`, `lab02` |
- `PERF.md` 3절에 바꾼 것마다 한 줄: `llm_calls`, p50, p95, 품질 테스트, **잃은 것**.
## 6. 5~6교시 · 3단계 · 운영 관점의 안정성
5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-STABILITY.md) (50분, 실습 5개).
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (10분) | 먼저 겪기: `fault faq slow` · `fault faq error`로 `call` → `fault all ok`. 그다음 FAQ · 접수 조회에 timeout 2초 · 재시도 1번 · 실패하면 `담당자 연결` | `push my-work/helpdesk.json` → `test cases/lab03.json`, `test cases/lab02.json` |
| 예제 2 (10분) | 먼저 겪기: `llm down` → `call` → `llm up`. 그다음 LLM이 실패하면 데이터 그대로 `간단 답변` | `test cases/lab03-llm-down.json` (자동으로 down · up), `test cases/lab02.json` |
| 예제 3 (10분) | 먼저 겪기: `fault tickets error` → 접수 · VPN 질문 `call` → `fault all ok`. 그다음 접수 질문만 `담당자 연결`(`tickets_down`), FAQ는 평소대로 | `test cases/lab03-ticket.json`, `lab03`, `lab03-llm-down` |
| 과제 1 (10분) | 상태 점검 `my-health`: 데이터 API · FAQ · LLM, `ok` · `degraded` · `down` | `my-work/health.json` → `push` → `test cases/lab03-challenge.json` |
| 과제 2 (10분) | 차단기: FAQ가 실패하면 30초 동안 FAQ를 부르지 않고 바로 `담당자 연결`(`breaker_open`) | `test cases/lab03-challenge2.json`, `lab03`, 끝나면 `doctor` |
- 보고서: `PERF.md` 4~6절, `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md`.
## 7. 7~8교시 · 바이브 코딩 `tbench`
[VIBE-REQ-TBENCH](VIBE-REQ-TBENCH.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TBENCH](VIBE-Q-TBENCH.md)의 순서대로 갑니다: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용. 요구는 14개(R1~R14)이고 50분 안에는 엔진 1단계 · TUI 1단계 · 설치까지, 나머지는 `Doc/IMP-PLAN.md`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 URL · 웹훅을 N번 불러 p50 · p95 · max · 실패를 표와 스파크라인으로 보여 주고 바꾸기 전 · 후를 비교하는 TUI. 정답본 `VIBE-A-TBENCH.md`는 수업이 끝나면 `git pull`로 받습니다.
```bash
mkdir -p ~/vibe/tbench/Doc && cd ~/vibe/tbench
git init
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
```
| 시간 | 단계 | 확인 |
| --- | --- | --- |
| 16:00~16:09 | 0 준비 · 1 요청서 → `Doc/TODO.md` (에이전트 없이) | 할 일 ①~⑫ · 꼭 / 나중 |
| 16:09~16:15 | 2 `Doc/SPEC.md` → VIBE-Q 요구 표(R1~R14)와 비교 · commit | 요구마다 확인 방법 |
| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 1~3단계 · TUI 1~2단계 · 설치) · commit | 단계마다 체크리스트 |
| 16:20~16:44 | 4 구현: 엔진 1단계(R1 · R6 · R7 · R8) → TUI 1단계(R2), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit |
| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tbench` | `cd ~/aio-01d4-stu/Last3Mon/72 && tbench ...` |
- 한 단계씩 요청하고, 매번 **직접 실행**하고, 단계마다 `git commit`.
- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/72/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 73일차 예고.
## 8. 마친 뒤
| 할 일 | 이유 |
| --- | --- |
| `uv run tools/flow.py fault all ok`, `uv run tools/flow.py llm up`, `doctor` → `ready` | 고장 연습을 되돌려 두기 (내일도 같은 n8n · 데이터 API) |
| `PROMPTS.md`, `PERF.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 (성능 · 안정성 보고) |
| `my-work/*.json`, `~/vibe/tbench` 지우지 않기 | 79일차 `tops`에서 다시 씀 |
| 73일차 준비: [00-DAY73-PREP](../../Lecture/73/00-DAY73-PREP.md) | 입력 · 출력 검증과 보안 |
## 9. 막혔을 때 (오늘 공통)
| 증상 | 할 일 |
| --- | --- |
| doctor의 fault switch ✘ | 데이터 API가 예전 판. 1.2의 72일차 `--force-recreate` |
| `! fault switch is on` | `uv run tools/flow.py fault all ok` |
| 모든 LLM 단계가 실패 | `llm down` 상태로 남음. `uv run tools/flow.py llm up` |
| 테스트를 다시 돌리면 결과가 다름 | 캐시. `curl -s -XPOST localhost:8800/cache-clear` |
| `call my-helpdesk`가 404 | 복사한 파일의 웹훅 경로를 바꿨는지, push했는지 |
| `base-helpdesk`가 안 됨 | `push reference/baseline-helpdesk.json` |
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
## 확인
- [ ] 전날 72일차 데이터 API, 기준 게시, `doctor` → `ready`
- [ ] Lab 01: 기준 p50 · p95, 종류별 표, `lab01`, `lab01-challenge`, `lab01-challenge2`
- [ ] Lab 02: `lab02` 9/9, 적중률, `lab02-challenge`, `lab02-challenge2`, 바꾸기 전 · 후 p50 · p95
- [ ] Lab 03: `lab03`, `lab03-llm-down`, `lab03-ticket`, `lab03-challenge`, `lab03-challenge2`, `PERF.md` · `TEST-REPORT.md`
- [ ] `tbench`: `Doc/TODO.md` · `SPEC.md` · `IMP-PLAN.md` 먼저, 엔진 1단계 · TUI 1단계, `pytest`, 단계마다 commit, `uv tool install`
- [ ] 고장 되돌리기, 73일차 준비
+87
View File
@@ -0,0 +1,87 @@
# 72일차 실습 · Workflow 실행 흐름 최적화와 운영 관점 개선 (바이브 코딩)
- 일자: 2026-10-26 (72일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 강의 자료: [Lecture/72](../../Lecture/72/README.md)
- 하루 수강 안내: [LECTURE-72.md](LECTURE-72.md) (수업 준비부터 마칠 때까지 순서대로)
- 67~71일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 새 기능이 아니라 **같은 워크플로우를 빠르게, 멈춰도 답하게** 고칩니다.
## 오늘 고치는 것: 느리고, 가끔 멈추는 IT 헬프데스크
`reference/baseline-helpdesk.json`(웹훅 `base-helpdesk`)은 질문마다 LLM을 세 번 부릅니다 (분류 → 답 → 검토). 이것을 **재고 → 병목을 찾고 → 한 가지씩 고치고 → 다시 잽니다.** 그리고 데이터 API나 LLM이 멈춰도 정해진 답을 하게 만듭니다.
```mermaid
flowchart LR
Q(["질문"]) --> C{"캐시?"}
C -->|있음| A0(["바로 답 · LLM 0번"])
C -->|없음| D["FAQ · 접수 조회<br/>timeout · 재시도"]
D -->|실패| H(["담당자 연결"])
D --> R{"규칙으로<br/>분류되나?"}
R -->|예| L{{"답 LLM 1번"}}
R -->|아니오| K{{"분류 LLM"}} --> L
L -->|실패| S(["간단 답변<br/>FAQ 원문"])
L --> V["답 검사 (Code)"] --> A(["자동 답변 + timings"])
```
## 일하는 방식 (67~71일차와 같음)
| 누가 | 무엇을 |
| --- | --- |
| 나 | 재기(`bench`, `timings`), 무엇을 바꿀지 정하기, 요청문 쓰기, 한 가지씩 바꾸고 다시 재기, 고장 연습 |
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
| `tools/flow.py` | check · push · call · test · **bench**(N번 불러 p50·p95) · **fault**(데이터 API 고장 스위치) · **llm down/up**(LLM 멈춤 연습) |
> **빨라졌다면 품질이 그대로인지** 원래 테스트로 확인합니다. 빠른데 틀린 답은 개선이 아닙니다.
## 실습 문서 읽는 법
- 실습 문서(`LAB-0x-Q-*.md`)는 요청문을 바로 주지 않습니다. 막히면 `▶` 를 위에서부터 하나씩 펼칩니다: 생각할 거리 → **빈칸 채우기**(요청문 뼈대의 빈칸 4개 `____①`~`____④`를 내가 채움, 아래 빈칸 힌트는 답이 아니라 찾을 곳). 빈칸을 채운 모양은 실습 문서에 없고, 수업 뒤 공개되는 정답본(`LAB-0x-A-*.md`)의 요청문 예로 비교합니다. `VIBE-Q-TBENCH.md`는 요청서 → `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 구현 → 설치 순서입니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 둡니다.
- 바이브 코딩은 **요청서(`VIBE-REQ-TBENCH.md`)부터** 읽고 `~/vibe/tbench/Doc/TODO.md`를 직접 쓴 뒤, `VIBE-Q-TBENCH.md`의 8단계(SPEC → IMP-PLAN → 구현 → 문제 해결 → 확인 → `uv tool install` → 다른 곳에서 사용)로 갑니다. 정답본 `VIBE-A-TBENCH.md`는 수업이 끝나면 올라옵니다.
## 구성
```text
Last3Mon/72/
├── README.md
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 타임아웃·재시도·오류 출력 5절)
├── LAB-01-Q-MEASURE-BOTTLENECK.md # 1단계 · bench → 단계별 시간 → 가장 느린 단계
├── LAB-02-Q-OPTIMIZE-FLOW.md # 2단계 · 규칙 먼저 → 캐시 → LLM 한 번
├── LAB-03-Q-STABILITY.md # 3단계 · 타임아웃·재시도·대체 경로 → LLM 멈춤 → 상태 점검
├── VIBE-REQ-TBENCH.md # 7교시 바이브 코딩 · 요청서 (가장 먼저, 여기서 Doc/TODO.md를 씀)
├── VIBE-Q-TBENCH.md # 7교시 바이브 코딩 · tbench 8단계 (TODO → SPEC → IMP-PLAN → 구현 → 설치), 요구 14개
├── *-A-*.md # 정답본 (VIBE-A는 수업이 끝난 뒤 올라옴, LAB-A는 아직 없음)
├── answers/ # 정답 워크플로우 · 시나리오 실행 기록 (수업이 끝난 뒤 올라옴)
├── tools/flow.py
├── data-api/ # 72일차 판 데이터 API (71일차 + 고장 스위치 + 캐시)
├── cases/ # lab01 · lab01-challenge(2) · lab02 · lab02-challenge(2) · lab03 · lab03-llm-down · lab03-ticket · lab03-challenge(2)
├── requests/ # 질문 10종 + 상태 점검
├── reference/ # baseline-helpdesk(비교 기준) + 막혔을 때 이어 가는 워크플로우
├── templates/ # 성능·안정성 보고서 · 테스트 보고서
├── my-work/
└── runs/ # 테스트 · bench 보고서
```
## 준비
[실습 준비](../../Lecture/72/00-DAY72-PREP.md)를 끝내 주세요. 요약:
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
cd ~/aio-01d4-stu/Last3Mon/72/data-api && docker compose up -d --force-recreate # 72일차 판으로 바꿈 (고장 스위치 · 캐시)
cd ~/aio-01d4-stu/Last3Mon/72 && uv run tools/flow.py push reference/baseline-helpdesk.json
uv run tools/flow.py doctor # 마지막 줄 ready
```
## `flow.py` 명령
| 명령 | 하는 일 | 수업 준비 때 |
| --- | --- | --- |
| `uv run tools/flow.py bench base-helpdesk requests/faq-vpn.json -n 5` | 같은 요청 N번, 응답마다 시간, p50 · p95 · max · 실패 수, `runs/bench-*.json` | p50 2.61초 · p95 2.83초 |
| `uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v` | 한 번 부르고 결과(`timings` 포함) 보기 | |
| `uv run tools/flow.py test cases/lab02.json -v` | 테스트 (`before` · `after`로 캐시 비우기, 고장 켜고 끄기). `-v`는 케이스마다 보낸 요청 JSON과 받은 응답 JSON을 화면에 보여 줌. **모든 명령이 `-v`를 받음** (`bench -v`: 응답마다 · 정렬한 시간 · 단계별 평균 ms, `check`/`push -v`: 노드 · 연결 · docker 명령, `fault`/`doctor -v`: 요청 · 응답) | |
| `uv run tools/flow.py fault faq slow` · `fault faq error` · `fault all ok` | 데이터 API의 FAQ를 느리게(8초) · 오류(500)로 · 되돌리기 | |
| `uv run tools/flow.py llm down` · `llm up` | n8n의 OpenAI Credential을 일부러 틀린 키로 · `.env`의 키로 되돌리기 | 바로 적용 (재시작 없음) |
> `llm down`을 했다면 반드시 `llm up`. 테스트 파일의 `after`는 실패해도 되돌립니다.
+280
View File
@@ -0,0 +1,280 @@
# 7교시 바이브 코딩 · `tbench` 만들기
> **먼저 [VIBE-REQ-TBENCH.md](VIBE-REQ-TBENCH.md)(요청서)를 읽고 `~/vibe/tbench/Doc/TODO.md`를 직접 쓴 뒤** 이 문서를 엽니다. 아래 1~8을 차례로 갑니다. 이 문서의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 쓰고, 에이전트에게 통째로 붙여 넣지 않습니다.
```mermaid
flowchart LR
T["1. Doc/TODO.md<br/>할 일 정리"] --> S["2. Doc/SPEC.md<br/>확인할 수 있는 요구"] --> P["3. Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> I["4. 구현<br/>단계마다 요청 · 확인"]
I --> H["5. 막히면<br/>문제 해결 팁"] --> I
I --> C["6. 결과 확인<br/>요구 표 체크"] --> U["7. uv tool install"] --> O["8. 다른 곳에서 사용"]
```
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시 · 50분 안에 못 한 단계는 집에서 `Doc/IMP-PLAN.md`의 다음 단계로 이어 갑니다
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/) (`httpx` 등은 자유)
- 결과물: URL · 웹훅을 **N번** 불러 응답 시간 p50 · p95 · max, 실패 수와 실패 종류를 표와 **스파크라인**으로 보여 주는 TUI. 결과를 남겨 **바꾸기 전 · 후를 비교**하고, 기준을 주면 통과 · 실패를 종료 코드로 알립니다. `uv tool install`로 설치해 **어느 폴더에서나** `tbench`로 씁니다. 오늘 `flow.py bench`로 한 일을 내 도구로.
```text
$ tbench http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json -n 20
┌ tbench · POST /webhook/my-helpdesk · 20회 · 동시 1 ─────────────────┐
│ ▁▂▁▁▃▁▂█▁▁▂▁▁▁▂▁▁▁▁▂ │
│ 완료 20/20 실패 0 p50 1.15s p95 1.31s max 3.02s (첫 호출 제외 p50 1.14s) │
│ ┌ # ─ 상태 ─ 시간 ─ 메모 ──────────────────────────────────────────┐ │
│ │ 1 200 3.02s first call │ │
│ │ 2 200 1.14s │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ 단계 평균: classify 0 · data 22 · answer 1080 · total 1130 ms │
│ [s] 저장 [c] 비교 [h] 기록 [q] 끝 │
└──────────────────────────────────────────────────────────────────────┘
```
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
## 시간 (50분)
| 시간 | 단계 | 결과 |
| --- | --- | --- |
| 16:00~16:09 | 0 준비 · 1 `Doc/TODO.md` (요청서 읽고 직접) | 할 일 ①~⑫ · 꼭 / 나중 |
| 16:09~16:15 | 2 `Doc/SPEC.md` → 아래 요구 표와 비교 · 고치기 | 요구마다 확인 방법 |
| 16:15~16:20 | 3 `Doc/IMP-PLAN.md` (엔진 단계 · TUI 단계) | 단계마다 체크리스트 |
| 16:20~16:44 | 4 구현: **엔진 1단계 → TUI 1단계** (막히면 5) | 단계마다 `pytest` · 직접 실행 · commit |
| 16:44~16:50 | 6 확인 · 7 `uv tool install` · 8 다른 폴더에서 | `cd ~/aio-01d4-stu/Last3Mon/72 && tbench ...` |
50분 안 목표: **R1 · R3 · R6 · R7 · R8(엔진 1단계) + R2(TUI 1단계) + R14(설치)**. R4 · R5 · R9~R13은 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다.
## 0. 준비 (1분)
```bash
mkdir -p ~/vibe/tbench/Doc && cd ~/vibe/tbench
git init
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
```
## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이)
[VIBE-REQ-TBENCH.md](VIBE-REQ-TBENCH.md)의 "할 일" 틀대로 씁니다. 요청의 말 ①~⑫를 **내 말로** 옮기고, 꼭 · 나중을 표시하고, 걱정되는 것과 50분 안에 못 할 것을 적습니다. "어떻게"는 아직 쓰지 않습니다.
<details><summary>▶ TODO와 SPEC은 무엇이 다른가</summary>
TODO는 **내가 이해한 것**(무엇을 · 왜)입니다. SPEC은 그것을 **확인할 수 있게** 바꾼 것(됐다 · 안 됐다를 말하는 방법)입니다. TODO에서 빠진 것은 SPEC에서도 빠지므로, TODO는 빠짐없이, SPEC은 정확하게.
</details>
`git add Doc/TODO.md && git commit -m "TODO"`
## 2. `Doc/SPEC.md` · 확인할 수 있는 요구로 (6분)
TODO의 할 일마다 **확인 방법**(파일 · 숫자 · 화면 · 종료 코드)을 붙여 SPEC을 만듭니다. 에이전트에게 "TODO.md를 읽고 SPEC.md 초안을 써 줘. 요구마다 확인 방법을 붙여"라고 시켜도 됩니다. 그러면 **반드시 내가 읽고 고칩니다.**
```markdown
# tbench SPEC
## 1. 요구 (확인할 수 있게)
| 번호 | 요구 | 요청의 어느 말 | 확인 방법 | 꼭 · 나중 |
| --- | --- | --- | --- | --- |
| R1 | | ① | | 꼭 |
## 2. 숨은 요구
## 3. 범위 밖 (하지 않을 것)
## 4. 정해 둘 것 (시간을 재는 범위, 백분위 계산 방법, 저장 폴더, 설정 파일 위치)
```
쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다.
### 요구 사항 (요청서에서 뽑은 것, 14개)
| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 |
| --- | --- | --- | --- | --- |
| R1 | URL과 본문 파일(JSON)을 받아 N번 부르고, 한 번마다 상태 · 시간, 끝에 p50 · p95 · max · 실패 수 (`--plain` 글자 출력) | ① | `tbench <url> --body-file x.json -n 10 --plain`, 백분위 테스트 (1~10초 → 정한 방법의 값), 가짜 서버(3번째마다 500) n=6 → 실패 2 | 꼭 |
| R2 | TUI: 결과가 **오는 대로** 표에 한 줄씩, 위에 스파크라인과 요약 | ② | `tbench <url> --body-file x.json -n 20`, 가짜 서버 지연 0.5초에서 한 줄씩 늘어남 | 꼭 |
| R3 | 결과를 JSON으로 저장하고 두 파일을 비교해 p50 · p95 · max · 실패의 차이(%)를 표로. URL · 본문 해시 · N이 다르면 **경고** | ③ | `tbench compare a.json b.json`, 본문이 다른 두 파일 → 경고 줄 | 꼭 |
| R4 | 동시에 k개씩 (`--concurrency k`) | ④ | 가짜 서버 지연 0.3초 × 6번: k=1 약 1.8초, k=3 약 0.6초 | 나중 |
| R5 | 만들어 둔 요청 고르기: `requests/*.json` 폴더(또는 66일차 `turl` 저장분)에서 TUI로 고르기 | ⑤ | TUI에서 요청 목록 → 하나 고르면 그 본문으로 실행 | 나중 |
| R6 | 첫 호출을 표에 `first call`로 표시, 요약에 **첫 호출 제외 p50**, `--warmup N`(재기 전에 N번 불러 버림) | ⑥ | 가짜 서버가 첫 응답만 2초 → 표 1번 줄에 `first call`, 제외 p50이 따로, `--warmup 1`이면 시간 목록에서 빠짐 | 꼭 |
| R7 | 실패 종류를 따로 셈: `connect`(연결 안 됨) · `timeout`(`--timeout` 넘음) · `http_5xx` · `http_4xx` | ⑦ | 없는 포트 → connect, 지연 3초 + `--timeout 1` → timeout, 가짜 500 → http_5xx | 꼭 |
| R8 | 머리글 넣기(`--header "K: V"` 여러 번), 저장 · 기록 파일에서 `Authorization` · 키 머리글은 `***` | (끝 부탁) | `--header "Authorization: Bearer sk-test"`로 저장 → `grep -c sk-test out.json` → 0 | 꼭 |
| R9 | 응답 JSON에 `timings`가 있으면 단계별 평균(ms) 표 | ⑧ | 내 헬프데스크로 → `classify` · `data` · `answer` · `review` · `total` 평균 줄, `timings`가 없는 응답이면 표를 숨김 | 나중 |
| R10 | 기준 검사: `--max-p95 2.5` · `--max-failures 0` → 끝에 `PASS` · `FAIL` 한 줄, **종료 코드 0 · 1** | ⑨ | 기준을 넘는 가짜 서버 → `echo $?` 1, 넘지 않으면 0 | 나중 |
| R11 | 한 번마다 한 줄 기록: `--log runs.jsonl` (시각 · 번호 · 상태 · 시간 · 실패 종류) | ⑩ | `wc -l runs.jsonl` = N, 줄마다 JSON | 나중 |
| R12 | 설정 저장: `tbench profile save <이름> <url> --body-file ...`, `tbench run <이름>` (설정 파일은 **집 폴더**에, 어느 폴더에서나 같은 설정) | ⑪ | 다른 폴더에서 `tbench run vpn` → 같은 URL · 본문으로 실행 | 나중 |
| R13 | 기록 목록: `tbench history` → 저장한 결과의 날짜 · URL · N · p50 · p95 · 실패 표 (기본 저장 폴더) | ⑫ | 두 번 저장 → 두 줄, 최신이 위 | 나중 |
| R14 | `uv tool install`로 설치해 **어느 폴더에서나** `tbench` | 첫 문단 "어느 폴더에서나" | `cd /tmp && tbench --help`, `uv tool list`에 `tbench` | 꼭 |
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
| 숨은 요구 | 어디서 나왔나 |
| --- | --- |
| 시간은 **보내기 직전 ~ 본문을 다 받을 때까지.** 실패도 한 번으로 세고 시간 목록과 따로 둔다. 기본 `--timeout` 30초 | ① "한 번 한 번", ⑦ "오래 걸려서 포기" |
| p50 · p95 계산 방법을 하나로 정하고 **테스트로 고정** (예: nearest-rank, 1~10초 → p50 5 · p95 10) | ① — 방법마다 값이 달라 비교가 흔들림 |
| 부르는 동안 화면이 멈추지 않는다 (Textual worker) | ② "하나 올 때마다 바로" |
| 저장 파일에 **URL · 메서드 · 본문 해시 · N · 동시 수 · warmup · 시작 시각**을 같이 남긴다 | ③ "다른 질문으로 잰 걸 비교", ⑫ "언제 무엇을" |
| 기본 저장 폴더 · 설정 파일 위치를 **집 폴더 아래 한 곳**으로 (예: `~/.tbench/`) | ⑪ ⑫ + "어느 폴더에서나" |
| 엔진(부르기 · 계산 · 저장)과 화면(TUI)을 **나눈다.** `--plain` · `compare` · `history`는 화면 없이 엔진만 씀 | ⑨ "화면을 안 봐도", 테스트하기 쉬움 |
| 테스트는 **pytest 안에서 띄운 가짜 서버**(일부러 느리게 · 가끔 500 · 첫 응답만 느리게)로. 진짜 헬프데스크를 부르지 않는다 | "진짜 헬프데스크를 수십 번 두드리는 건 곤란 (OpenAI 요금)" |
| `pyproject.toml`에 명령 이름(`[project.scripts] tbench = ...`)이 있어야 `uv tool install`이 된다 | R14 |
범위 밖 (오늘 하지 않음): 그래프를 이미지 파일로 저장, 웹 화면, 여러 주소를 한꺼번에, 수천 번 부하 시험, 결과를 서버로 보내기.
`git add Doc/SPEC.md && git commit -m "SPEC"`
## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분)
SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(백엔드)을 먼저, 화면(TUI)은 나중에.** 화면은 엔진이 내놓은 결과를 보여 주기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침).
```markdown
# tbench IMP-PLAN
## 엔진 1단계 · 부르고 재기 (R1 · R6 · R7 · R8)
- [ ] runner: 한 번 부르기 → {i, status, seconds, error_kind}
- [ ] stats: p50 · p95 · max · 실패 종류별 수 (nearest-rank, 테스트)
- [ ] 가짜 서버 fixture (지연 · N번째마다 500 · 첫 응답만 느림)
- [ ] --plain 출력, --warmup, --header (가리기)
- 확인: uv run pytest -q, uv run tbench <url> -n 5 --plain
## 엔진 2단계 · 남기고 비교 (R3 · R13 · R11)
## 엔진 3단계 · 동시 · 기준 · 응답 분석 · 설정 (R4 · R10 · R9 · R12)
## TUI 1단계 · 오는 대로 보이기 (R2)
## TUI 2단계 · 고르기 · 비교 · 기록 화면 (R5 · R3 · R13)
## 설치 (R14)
```
<details><summary>▶ 왜 엔진 먼저인가</summary>
- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `--plain`). 화면부터 만들면 숫자가 맞는지 눈으로만 봅니다.
- `compare` · `history` · R10의 종료 코드는 화면 없이 써야 합니다 (배포 전에 자동으로 돌림).
- TUI는 엔진이 결과를 **하나 낼 때마다** 받아 표에 더하기만 하면 됩니다. 그래서 엔진의 "한 번 부르기"가 결과를 하나씩 돌려주게(콜백 · 제너레이터) 정해 둡니다.
</details>
<details><summary>▶ 단계 크기</summary>
한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 단계가 크면 에이전트가 많이 바꾸고, 틀렸을 때 어디가 틀렸는지 모릅니다.
</details>
`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"`
## 4. 구현 시작 (24분: 엔진 1단계 → TUI 1단계)
이제 에이전트를 켭니다. 첫 요청은 늘 같습니다.
```text
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽어 줘. 아직 코드는 쓰지 말고,
IMP-PLAN 의 "엔진 1단계"를 어떻게 만들지와 각 체크 항목의 확인 방법을 5줄로 먼저 말해 줘.
```
그다음 **한 단계씩**: "엔진 1단계만 만들어 줘. 끝나면 IMP-PLAN의 체크를 표시하고 확인 명령을 알려 줘" → 내가 확인 → commit → 다음 단계.
> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. 오늘 `tbench`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 어디가 틀렸는지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`. 다음 날 이어 갈 때도 "IMP-PLAN의 체크 안 된 첫 단계부터"로 시작하면 됩니다.
<details><summary>▶ 엔진 1단계 · 시간은 어디서 어디까지</summary>
"보내기 직전부터 응답 본문을 다 받을 때까지"인가요, "첫 바이트까지"인가요? 실패(연결 거부, timeout)도 한 번으로 셀까요? 실패 종류(R7)는 예외 종류(연결 오류 · 시간 초과)와 상태 코드로 나눕니다. 요청문에 정해 줍니다.
</details>
<details><summary>▶ 엔진 1단계 · p95를 직접 계산</summary>
10개로 p95를 구하면 어느 값이 될까요? 계산 방법을 하나로 정하고, 테스트로 고정합니다 (예: 1~10초 열 개 → p50과 p95가 몇인지). 오늘 1단계 강의 2절의 손 계산과 같은 방법이면 비교하기 쉽습니다.
</details>
<details><summary>▶ TUI 1단계 · 화면이 멈추지 않게</summary>
N번 부르는 동안 화면이 굳으면 안 됩니다. Textual에서 오래 걸리는 일을 따로 돌리는 방법(worker)을 요청문에 적습니다. 엔진이 결과를 하나 낼 때마다 표 · 스파크라인 · 요약이 바뀌어야 합니다. 첫 호출(R6)은 메모 칸에.
</details>
<details><summary>▶ 다음 단계들 (8교시 뒤 · 집에서)</summary>
- 엔진 2단계: 저장 칸에 조건을 함께(R3), 기본 저장 폴더에 쌓기(R13), 한 줄 기록(R11).
- 엔진 3단계: 동시 k개(R4, 결과를 보고 판단), 기준 검사 종료 코드(R10), 응답의 `timings` 평균(R9), 설정 파일(R12).
- TUI 2단계: 요청 고르기(R5, `turl` 저장 형식은 사람마다 다르니 내 것을 먼저 열어 보고 요청문에), 비교 · 기록 화면.
</details>
## 5. 바이브 코딩으로 문제 해결 팁
| 이럴 때 | 이렇게 |
| --- | --- |
| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tbench ...`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 |
| 실패 · 오류 | 오류 출력 **전부**와 실행한 명령을 그대로 붙여 줍니다. "안 돼요"만 쓰지 않습니다 |
| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 |
| 에이전트가 요청하지 않은 것까지 만듦 | "SPEC 3절 범위 밖"을 가리키고, 그 부분을 빼 달라고 합니다. 단계마다 commit해 두면 되돌리기 쉽습니다 |
| 화면이 다 끝난 뒤에야 나옴 · 굳음 | "엔진을 worker에서 돌리고 결과마다 표에 더하게" |
| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console`, 앱은 `uv run textual run --dev <파일>`로 (print · 오류가 콘솔에 보임) |
| 테스트가 진짜 헬프데스크를 부름 | "pytest 안에서 가짜 서버(`http.server`)를 띄워서. 진짜 n8n을 부르지 않는다" |
| p95가 늘 max와 같음 | n이 작으면 그럴 수 있습니다 (5개면 p95 = 5번째). 계산 방법을 테스트로 고정 |
| 대화가 길어져 에이전트가 앞 내용을 잊음 | 새 세션을 열고 "Doc/SPEC.md · Doc/IMP-PLAN.md를 읽고, 체크 안 된 첫 단계부터"로 다시 시작 |
| 설치한 `tbench`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) |
## 6. 결과 확인
`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만:
```bash
cd ~/vibe/tbench
uv run pytest -q # 가짜 서버 테스트
uv run tbench http://localhost:5678/webhook/my-helpdesk \
--body-file ~/aio-01d4-stu/Last3Mon/72/requests/faq-vpn-nocache.json -n 5 --plain # R1 · R6 · R7
uv run tbench http://localhost:5678/webhook/my-helpdesk \
--body-file ~/aio-01d4-stu/Last3Mon/72/requests/faq-vpn-nocache.json -n 10 --save before.json # R2 · R3
grep -c "sk-" before.json # R8: 0
```
- [ ] R1 `--plain`으로 상태 · 시간과 p50 · p95 · max · 실패.
- [ ] R2 결과가 오는 대로 표 · 스파크라인.
- [ ] R3 저장 · 비교 표 (다른 본문이면 경고).
- [ ] R6 첫 호출 표시 · 제외 p50 · `--warmup`.
- [ ] R7 실패 종류별 수.
- [ ] R8 저장 파일에 키 없음.
- [ ] (이어서) R4 · R5 · R9 · R10 · R11 · R12 · R13.
## 7. `uv tool install`로 설치
`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다.
```toml
[project.scripts]
tbench = "tbench.cli:main"
```
```bash
cd ~/vibe/tbench
uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함)
# uv tool install . # 지금 상태를 복사해 설치 (고치면 --force 로 다시)
uv tool list # tbench 가 보이는가
which tbench # ~/.local/bin/tbench
# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶
```
- 지우기: `uv tool uninstall tbench`.
- `--editable`이 아닌 설치는 **설치할 때의 코드**를 씁니다. 고친 뒤 `uv tool install --force .`.
## 8. 다른 곳에서 사용
설치했으면 프로젝트 폴더 밖에서 씁니다. 오늘 Lab 02의 바꾸기 전 · 후를 내 도구로 비교해 봅니다.
```bash
cd ~/aio-01d4-stu/Last3Mon/72
tbench --help
uv run tools/flow.py push reference/lab02-example2-cache.json
tbench http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json -n 10 --save ~/before.json
uv run tools/flow.py push my-work/helpdesk.json # 내 Lab 02 과제 1 결과
tbench http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json -n 10 --save ~/after.json
tbench compare ~/before.json ~/after.json
```
- (R10을 만들었다면) `tbench ... --max-p95 2.5; echo $?` → 74일차 배포 전 검사에서 그대로 씁니다.
- (R12를 만들었다면) `tbench profile save vpn http://localhost:5678/webhook/my-helpdesk --body-file requests/faq-vpn-nocache.json` → 어느 폴더에서나 `tbench run vpn`.
- `~/vibe/tbench`는 지우지 마세요. 79일차 `tops`에서 다시 씁니다.
## 발표 (8교시, 3분)
| 내용 | |
| --- | --- |
| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 |
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
| 막힌 곳 | 5절의 어느 팁으로 풀었나 |
| 시연 | 다른 폴더에서 `tbench compare`로 오늘 헬프데스크의 전 · 후 |
## 확인
- [ ] `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`를 에이전트 전에 (또는 에이전트와 함께 쓰고 내가 고쳐서) 만들고 commit했다.
- [ ] IMP-PLAN의 엔진 1단계 · TUI 1단계를 체크했다 (단계마다 commit).
- [ ] R1 · R2 · R3 · R6 · R7 · R8 확인, `uv run pytest -q`(가짜 서버).
- [ ] R14 `uv tool install`, 다른 폴더에서 `tbench`.
- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
+108
View File
@@ -0,0 +1,108 @@
# 7교시 바이브 코딩 · 요청서 `tbench` (가장 먼저 읽기)
- 순서: **이 요청서** → 내 정리 `~/vibe/tbench/Doc/TODO.md` (8분) → [VIBE-Q-TBENCH.md](VIBE-Q-TBENCH.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TBENCH.md`(정답본)로 확인
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 68 · 69 · 71일차와 같은 생각입니다.
```mermaid
flowchart LR
REQ["요청서<br/>(사람의 말)"] --> TODO["Doc/TODO.md<br/>만들고 싶은 것 ·<br/>요청의 말 정리"] --> SPEC["Doc/SPEC.md<br/>확인할 수 있는 요구"] --> PLAN["Doc/IMP-PLAN.md<br/>엔진 → TUI 단계"] --> RUN["단계별로 만들기 ·<br/>직접 확인"] --> USE["uv tool install<br/>다른 곳에서 쓰기"]
```
## 왜 요청서부터인가
VIBE-Q 문서를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 말을 확인할 수 있는 요구로 바꾸는 일은 에이전트가 아니라 내가 합니다. 오늘 오전 Lab 01에서 고치기 전에 **먼저 잰 것**과 같은 일입니다: 기준이 있어야 "좋아졌다"를 말할 수 있습니다.
| 단계 | 누가 | 결과 |
| --- | --- | --- |
| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 |
| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 |
| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `Doc/*.md`를 함께 읽게 함 |
| 확인 | **나** | 직접 `uv run tbench …`, `pytest`, 요구 표에 체크 |
## 요청 (헬프데스크 운영 담당자에게서 온 메시지, 그대로)
> 오늘 헬프데스크 빨라진 거 봤어요. 그런데 저는 n8n 화면도 `flow.py`도 잘 몰라요. 앞으로 뭘 바꿀 때마다 **제가 직접** "빨라졌나, 느려졌나"를 확인할 수 있는 작은 프로그램이 있으면 좋겠어요. 터미널에서 돌아가면 되고, **제 노트북 어느 폴더에서나** `tbench`라고 치면 되게 해 주세요.
>
> ① 주소 하나랑 보낼 내용을 주면 **여러 번** 불러서, 한 번 한 번 얼마나 걸렸는지, **몇 번 실패했는지** 알려 주세요. 평균만 주시면 안 돼요. 사용자들은 "평소엔 괜찮은데 **가끔 엄청 느리다**"고 하거든요. 보통 얼마인지, 느린 쪽은 얼마인지 따로 보고 싶어요.
>
> ② 20번, 50번 부르면 한참 걸리잖아요. 다 끝날 때까지 **멍하니 기다리기 싫어요.** 하나 올 때마다 바로 보이고, 들쭉날쭉한 **모양**을 한눈에 보고 싶어요.
>
> ③ 바꾸기 전에 한 번, 바꾼 뒤에 한 번 재서 **숫자로 비교**하고 싶어요. 어제 잰 것과도 비교할 수 있게 남겨 주세요. 그런데 지난번에 누가 **다른 질문으로 잰 걸** 비교해서 "두 배 빨라졌다"고 했다가 망신당했어요.
>
> ④ 월요일 아침엔 여러 사람이 **한꺼번에** 물어봐요. 그때도 버티는지 보고 싶어요.
>
> ⑤ 보낼 내용을 매번 새로 쓰기 귀찮아요. 이미 만들어 둔 요청들(`requests` 폴더나, 66일차에 만든 `turl`에 저장한 것)을 **골라서** 쓰고 싶어요.
>
> ⑥ 고치고 나서 다시 올리면 **첫 번째만 유난히 느리더라고요.** 그 한 번 때문에 결과가 나빠 보이는 건 싫어요. 첫 번을 빼고도 보고 싶고, 미리 몇 번 불러 두고 재는 것도 되면 좋겠어요.
>
> ⑦ 실패했다고만 하지 말고 **왜** 실패했는지요. 연결이 아예 안 된 건지, 너무 오래 걸려서 포기한 건지, 서버가 오류를 낸 건지에 따라 연락할 사람이 달라요.
>
> ⑧ 헬프데스크가 결과에 **단계별 시간**(`timings`)을 주잖아요. 그것도 모아서 단계마다 평균을 보여 주면, 어디가 느린지 개발자한테 바로 말할 수 있겠어요.
>
> ⑨ 나중에는 제가 화면을 안 봐도 되게, "**느린 쪽이 2.5초를 넘으면 실패**"처럼 기준을 주면 통과 · 실패만 알려 주는 것도 필요해요. 배포 전에 자동으로 돌려 보려고요.
>
> ⑩ 한 번 한 번의 기록이 **줄줄이** 파일에 남으면 좋겠어요. 나중에 다른 도구로 모아 보게요.
>
> ⑪ 매번 주소랑 옵션을 길게 치기 귀찮아요. 자주 쓰는 설정은 **파일에 저장**해 두고 이름으로 부르고 싶어요.
>
> ⑫ 그동안 잰 것들이 **목록으로** 보이면 좋겠어요. 언제, 무엇을, 얼마로 쟀는지요.
>
> 아, 어떤 주소는 열쇠(키)가 머리글에 들어가요. **남기는 파일에 키가 찍히면 안 돼요.** 그리고 이 프로그램을 시험할 때마다 **진짜 헬프데스크를 수십 번 두드리는 건** 곤란해요 (그때마다 OpenAI 요금이 나가요).
## 함께 받은 것
| 파일 · 자료 | 메모 |
| --- | --- |
| `~/aio-01d4-stu/Last3Mon/72/requests/*.json` | 보낼 내용(본문) 예. `faq-vpn-nocache.json`은 캐시 없이 재는 VPN 질문 |
| 오늘 `uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5`의 출력 | `p50 · p95 · max · failures` 한 줄 (Lab 01 예제 1) |
| 오늘 `my-work/PERF.md` 1 · 3절 | 바꾸기 전 · 후를 사람이 손으로 적은 표 |
| 오늘 헬프데스크 응답의 `timings` | `classify_ms` · `data_ms` · `answer_ms` · `review_ms` · `total_ms` (⑧) |
| 66일차 `~/vibe/turl` (있으면) | 저장한 요청의 모양은 각자 다름 |
- 재 볼 주소: 내 헬프데스크 `http://localhost:5678/webhook/my-helpdesk` (POST, JSON 본문).
## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이)
`~/vibe/tbench/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁 둘을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다.
```markdown
# tbench TODO
## 만들고 싶은 것 (한 문단)
어느 폴더에서나 `tbench`로 웹훅을 여러 번 불러 ...
## 요청의 말 → 할 일
| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 |
| --- | --- | --- | --- |
| ① | 여러 번, 몇 번 실패, 보통 · 느린 쪽 | | 꼭 |
| ⑥ | 첫 번만 느림 | | |
## 요청에 없지만 걱정되는 것
## 오늘 50분 안에 못 할 것 같은 것
```
예 하나만 보여 드립니다.
| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 |
| --- | --- | --- |
| ① "평균만 주시면 안 돼요 … 보통 얼마인지, 느린 쪽은 얼마인지 따로" | "N번 부르고 한 번마다 상태 · 시간을 보여 주고, 끝에 보통(p50) · 느린 쪽(p95) · 가장 느린(max) · 실패 수" | R1: 백분위 계산 방법을 정해 테스트로 고정 (1~10초 → p50 · p95가 몇), 가짜 서버로 실패 수 확인 |
<details><summary>▶ 1단계 · 꼭 할 것과 나중에 할 것</summary>
요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 담당자에게 **가장 먼저 필요한 것**은 무엇일까요? (힌트: 첫 문단 "빨라졌나, 느려졌나를 직접 확인") 그것 없이는 나머지가 의미 없는 것부터 "꼭"으로.
</details>
<details><summary>▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗)</summary>
- ① "한 번 한 번 얼마나 걸렸는지": 시간은 **어디서 어디까지**인가요? 끝없이 기다리면?
- ② "하나 올 때마다 바로": 부르는 동안 화면이 멈추면 안 됩니다.
- ③ "다른 질문으로 잰 걸 비교": 결과 파일에 무엇이 함께 남아야 하나요?
- ⑨ "통과 · 실패만": 사람이 아니라 **다른 프로그램**이 결과를 읽으려면 무엇으로 알려야 하나요? (종료 코드)
- "진짜 헬프데스크를 수십 번 두드리는 건 곤란" → 시험할 때 무엇을 대신 부르나요?
- "어느 폴더에서나 `tbench`" → 내 프로젝트 폴더 밖에서도 실행되려면?
</details>
## 그다음
[VIBE-Q-TBENCH.md](VIBE-Q-TBENCH.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다.
+56
View File
@@ -0,0 +1,56 @@
{
"webhook": "my-helpdesk",
"cases": [
{
"name": "FAQ · 접수 시간 따로",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"timings": [
"faq_ms"
]
}
}
},
{
"name": "접수 조회 시간",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"timings": [
"ticket_ms"
]
}
}
},
{
"name": "가장 오래 걸린 단계",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"slowest_step": [
"classify",
"answer",
"review"
]
}
}
},
{
"name": "접수 현황도 같은 모양",
"request": "requests/ticket-own.json",
"expect": {
"contains": {
"answer": [
"처리 중"
],
"slowest_step": [
"classify",
"answer",
"review"
]
}
}
}
]
}
+104
View File
@@ -0,0 +1,104 @@
{
"webhook": "my-helpdesk",
"cases": [
{
"name": "예산 안 (넉넉한 예산)",
"body": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"no_cache": true,
"budget_ms": 60000
},
"expect": {
"equals": {
"over_budget": false,
"budget_ms": 60000
},
"contains": {
"llm_ms": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9"
],
"answer": [
"인증서"
]
}
}
},
{
"name": "예산 넘음 (0.5초 예산)",
"body": {
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
"user": "이준호",
"no_cache": true,
"budget_ms": 500
},
"expect": {
"equals": {
"over_budget": true,
"budget_ms": 500
}
}
},
{
"name": "예산을 안 주면 3초",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"budget_ms": 3000
},
"contains": {
"llm_ms": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9"
]
}
}
},
{
"name": "접수 현황도 같은 모양",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"budget_ms": 3000,
"category": "ticket"
},
"contains": {
"llm_ms": [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9"
],
"slowest_step": [
"classify",
"answer",
"review"
]
}
}
}
]
}
+66
View File
@@ -0,0 +1,66 @@
{
"webhook": "my-helpdesk",
"cases": [
{
"name": "VPN FAQ · 단계별 시간",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"route": "자동 답변",
"category": "faq"
},
"contains": {
"answer": [
"인증서"
],
"timings": [
"answer_ms"
]
}
}
},
{
"name": "VPN FAQ · 전체 시간 · LLM 호출 수",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"contains": {
"timings": [
"total_ms"
],
"steps": [
"answer"
],
"llm_calls": [
"1",
"2",
"3"
]
}
}
},
{
"name": "접수 현황",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"category": "ticket"
},
"contains": {
"answer": [
"처리 중"
],
"timings": [
"answer_ms"
]
}
}
},
{
"name": "빈 질문",
"request": "requests/empty-question.json",
"expect": {
"status": 400
}
}
]
}
+87
View File
@@ -0,0 +1,87 @@
{
"webhook": "my-helpdesk",
"before": [
{
"webhook": "my-helpdesk",
"body": {
"question": "워밍업: 프린터 출력이 안 돼요",
"user": "이준호",
"no_cache": true
}
}
],
"cases": [
{
"name": "VPN · LLM 한 번 · 2.5초 안",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"llm_calls": 1,
"cached": false
},
"contains": {
"answer": [
"인증서"
]
},
"max_seconds": 2.5
}
},
{
"name": "비밀번호 · LLM 한 번",
"body": {
"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?",
"user": "한지훈",
"no_cache": true
},
"expect": {
"equals": {
"llm_calls": 1
},
"contains": {
"answer": [
"비밀번호 찾기",
"휴대폰"
]
},
"max_seconds": 2.5
}
},
{
"name": "접수 현황 · LLM 한 번",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"llm_calls": 1
},
"contains": {
"answer": [
"처리 중"
]
},
"max_seconds": 2.5
}
},
{
"name": "규칙으로 모르면 분류 + 답 두 번",
"expect": {
"equals": {
"llm_calls": 2
}
},
"body": {
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
"user": "이준호",
"no_cache": true
}
},
{
"name": "빈 질문은 LLM 없이",
"request": "requests/empty-question.json",
"expect": {
"status": 400,
"max_seconds": 1
}
}
]
}
+104
View File
@@ -0,0 +1,104 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"cases": [
{
"name": "처음 묻는 VPN (캐시 없음)",
"request": "requests/faq-vpn.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "다른 말로 묻는 VPN → FAQ 번호 캐시",
"body": {
"question": "VPN 연결이 계속 끊어지는데요",
"user": "최유진"
},
"expect": {
"equals": {
"cached": true,
"cache_by": "faq",
"llm_calls": 0
},
"contains": {
"answer": [
"인증서"
]
},
"max_seconds": 1
}
},
{
"name": "같은 말은 질문 캐시",
"request": "requests/faq-vpn-again.json",
"expect": {
"equals": {
"cached": true,
"cache_by": "question",
"llm_calls": 0
},
"max_seconds": 1
}
},
{
"name": "no_cache면 FAQ 번호 캐시도 안 씀",
"body": {
"question": "VPN 연결이 계속 끊어지는데요",
"user": "최유진",
"no_cache": true
},
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "다른 FAQ는 따로 (프린터 처음)",
"request": "requests/faq-printer.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"대기열",
"전원"
]
}
}
},
{
"name": "접수 현황은 캐시 안 함",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"처리 중"
]
}
}
}
]
}
+158
View File
@@ -0,0 +1,158 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"cases": [
{
"name": "예제 1 · 프린터 FAQ는 규칙으로",
"request": "requests/faq-printer.json",
"expect": {
"equals": {
"category": "faq"
},
"contains": {
"steps": [
"rule"
],
"answer": [
"대기열",
"전원"
]
},
"not_contains": {
"steps": [
"classify"
]
}
}
},
{
"name": "예제 1 · 접수 번호는 규칙으로",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"category": "ticket"
},
"contains": {
"steps": [
"rule"
],
"answer": [
"처리 중"
]
},
"not_contains": {
"steps": [
"classify"
]
}
}
},
{
"name": "예제 1 · 없는 접수 번호",
"request": "requests/ticket-missing.json",
"expect": {
"equals": {
"category": "ticket"
},
"contains": {
"answer": [
"찾지 못",
"확인되지",
"없습니다",
"존재하지"
]
},
"not_contains": {
"answer": [
"처리 중",
"처리 완료",
"접수됨",
"반려"
]
}
}
},
{
"name": "예제 1 · 규칙으로 모르면 LLM이 분류",
"expect": {
"contains": {
"steps": [
"classify"
]
}
},
"body": {
"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?",
"user": "이준호",
"no_cache": true
}
},
{
"name": "예제 2 · 처음 묻는 VPN",
"request": "requests/faq-vpn.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "예제 2 · 같은 질문 (띄어쓰기·대소문자 다름)",
"request": "requests/faq-vpn-again.json",
"expect": {
"equals": {
"cached": true,
"llm_calls": 0
},
"contains": {
"answer": [
"인증서"
]
},
"max_seconds": 1
}
},
{
"name": "예제 2 · no_cache면 캐시 안 씀",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"cached": false
}
}
},
{
"name": "예제 2 · 접수 현황은 캐시 안 함 (1)",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"cached": false
}
}
},
{
"name": "예제 2 · 접수 현황은 캐시 안 함 (2)",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"cached": false
},
"contains": {
"answer": [
"처리 중"
]
}
}
}
]
}
+103
View File
@@ -0,0 +1,103 @@
{
"webhook": "my-health",
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"cases": [
{
"name": "평소",
"request": "requests/health.json",
"expect": {
"equals": {
"status": "ok",
"checks.llm": true,
"checks.faq": true
}
}
},
{
"name": "FAQ 오류",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/health.json",
"expect": {
"equals": {
"status": "degraded",
"checks.faq": false,
"checks.data_api": true
}
}
},
{
"name": "FAQ 느림 (8초) · 점검은 빨리 끝남",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "slow",
"slow_seconds": 8
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/health.json",
"expect": {
"equals": {
"status": "degraded",
"checks.faq": false
},
"max_seconds": 5
}
},
{
"name": "LLM 멈춤",
"before": [
{
"llm": "down"
}
],
"after": [
{
"llm": "up"
}
],
"request": "requests/health.json",
"expect": {
"equals": {
"status": "degraded",
"checks.llm": false,
"checks.faq": true
}
}
}
]
}
+106
View File
@@ -0,0 +1,106 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
},
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"cases": [
{
"name": "평소",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "FAQ 느림 · 처음 한 번은 기다린 뒤 담당자",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "slow",
"slow_seconds": 8
}
}
],
"body": {
"question": "비밀번호를 잊어버렸어요",
"user": "한지훈",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결",
"reason": "data_api_down"
},
"max_seconds": 7
}
},
{
"name": "FAQ 느림 · 다음 질문은 기다리지 않음 (차단기 열림)",
"body": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결",
"reason": "breaker_open"
},
"max_seconds": 1
}
},
{
"name": "FAQ가 돌아오고 차단기를 비우면 평소대로",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
},
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"body": {
"question": "프린터 출력이 안 돼요",
"user": "최유진",
"no_cache": true
},
"expect": {
"equals": {
"route": "자동 답변"
},
"contains": {
"answer": [
"대기열",
"전원"
]
}
}
}
]
}
+66
View File
@@ -0,0 +1,66 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
},
{
"llm": "down"
}
],
"after": [
{
"llm": "up"
}
],
"cases": [
{
"name": "VPN · FAQ 원문으로",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"status": 200,
"equals": {
"route": "간단 답변"
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "접수 현황 · 상태 그대로",
"request": "requests/ticket-own.json",
"expect": {
"status": 200,
"equals": {
"route": "간단 답변"
},
"contains": {
"answer": [
"처리 중"
]
}
}
},
{
"name": "모르는 질문 · 담당자",
"request": "requests/off-topic.json",
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결"
}
}
},
{
"name": "빈 질문",
"request": "requests/empty-question.json",
"expect": {
"status": 400
}
}
]
}
+123
View File
@@ -0,0 +1,123 @@
{
"webhook": "my-helpdesk",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"cases": [
{
"name": "평소 접수 현황",
"request": "requests/ticket-own.json",
"expect": {
"equals": {
"route": "자동 답변",
"category": "ticket"
},
"contains": {
"answer": [
"처리 중"
]
}
}
},
{
"name": "접수 API 500 · 접수 질문은 담당자",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"tickets": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/ticket-own.json",
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결",
"reason": "tickets_down"
},
"not_contains": {
"answer": [
"처리 중",
"처리 완료",
"접수됨",
"반려"
]
},
"max_seconds": 3
}
},
{
"name": "접수 API 500 · FAQ 질문은 그대로 답",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"tickets": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/faq-vpn-nocache.json",
"expect": {
"status": 200,
"equals": {
"route": "자동 답변",
"category": "faq"
},
"contains": {
"answer": [
"인증서"
]
}
}
},
{
"name": "없는 접수 번호는 그대로 (404는 고장이 아님)",
"request": "requests/ticket-missing.json",
"expect": {
"equals": {
"route": "자동 답변",
"category": "ticket"
},
"contains": {
"answer": [
"찾지 못",
"확인되지",
"없습니다",
"존재하지"
]
}
}
}
]
}
+123
View File
@@ -0,0 +1,123 @@
{
"webhook": "my-helpdesk",
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"cases": [
{
"name": "평소",
"request": "requests/faq-vpn-nocache.json",
"expect": {
"equals": {
"route": "자동 답변"
}
}
},
{
"name": "FAQ가 느릴 때 (8초)",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "slow",
"slow_seconds": 8
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"body": {
"question": "비밀번호를 잊어버렸어요",
"user": "한지훈",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결"
},
"max_seconds": 7
}
},
{
"name": "FAQ가 오류일 때 (500)",
"before": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"body": {
"question": "비밀번호를 잊어버렸어요",
"user": "한지훈",
"no_cache": true
},
"expect": {
"status": 200,
"equals": {
"route": "담당자 연결"
},
"max_seconds": 5
}
},
{
"name": "캐시에 있으면 FAQ가 멈춰도 답",
"before": [
{
"url": "http://localhost:8800/cache-clear",
"body": {}
},
{
"webhook": "my-helpdesk",
"request": "requests/faq-printer.json"
},
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "error"
}
}
],
"after": [
{
"url": "http://localhost:8800/admin/fault",
"body": {
"faq": "ok",
"tickets": "ok"
}
}
],
"request": "requests/faq-printer.json",
"expect": {
"equals": {
"route": "자동 답변",
"cached": true
}
}
}
]
}
+27
View File
@@ -0,0 +1,27 @@
# Sample company data API for day 72 (day 71 API + fault switch + cache), attached to the day 65 n8n network
# (n8n installed in ~/day65-n8n, Docker project day65-n8n, network day65-n8n_default).
# docker compose up -d (run from this folder, after day 65 n8n is running)
# from n8n : http://data-api:8800
# from WSL : http://localhost:8800
name: day67-data
services:
data-api:
image: python:3.12-slim
command: python /app/server.py
environment:
- DATA_DIR=/data
- PYTHONUNBUFFERED=1
ports:
- "8800:8800"
volumes:
- ./server.py:/app/server.py:ro
- ./data:/data:ro
networks:
- n8n
restart: unless-stopped
networks:
n8n:
external: true
name: day65-n8n_default
@@ -0,0 +1,17 @@
# 업무용 장비 지급·반납 규정
## 1. 지급과 교체
- 업무용 노트북은 입사일에 지급한다.
- 노트북 교체 주기는 4년이다. 고장이나 배터리 교체는 IT 문의로 접수한다.
## 2. 분실과 도난
- 업무용 장비를 잃어버리거나 도난당하면 24시간 이내에 IT 문의와 보안팀에 신고한다.
- 신고가 접수되면 IT 담당자가 원격으로 장비의 데이터를 잠근다.
## 3. 반납
- 퇴사하거나 장비를 교체할 때는 5영업일 이내에 장비를 IT 담당자에게 반납한다.
- 반납 전에 개인 파일을 지우지 말고, 업무 파일은 사내 드라이브로 옮긴다.
## 4. 개인 장비 사용
- 개인 휴대폰이나 태블릿으로는 업무 메일과 메신저만 쓸 수 있다.
- 개인 장비로 업무 메일을 보려면 회사의 모바일 관리 앱(MDM)을 먼저 설치한다.
@@ -0,0 +1,14 @@
# 메일·문서 보안 규정
## 1. 메일함
- 개인 메일함 용량은 10GB이다. 용량이 90%를 넘으면 안내 메일이 발송된다.
- 오래된 메일은 보관함으로 옮겨 용량을 확보한다.
## 2. 첨부 파일
- 메일 첨부 파일은 25MB까지 보낼 수 있다.
- 25MB를 넘는 파일은 사내 드라이브에 올리고 공유 링크를 보낸다.
- 개인정보가 들어 있는 파일을 외부로 보낼 때는 반드시 암호를 걸고, 암호는 메신저 등 다른 경로로 알려 준다.
## 3. 문서 등급과 보관
- 대외비 문서에는 문서 첫 장에 '대외비'라고 표시한다.
- 업무 문서는 3년 동안 보관한 뒤 정리한다.
@@ -0,0 +1,12 @@
# 회의실 이용 규정
## 1. 예약
- 회의실은 사내 포털에서 예약한다. 한 번에 최대 3시간까지 예약할 수 있다.
- 예약 시작 후 10분이 지나도록 아무도 사용하지 않으면 예약이 자동으로 취소된다.
## 2. 장비
- 대회의실의 화상회의 장비를 쓰려면 회의 시작 30분 전까지 IT 문의로 요청한다.
- 프로젝터는 B회의실과 대회의실에 있다.
## 3. 사용 후
- 회의가 끝나면 화이트보드를 지우고 의자를 정리한다.
@@ -0,0 +1,18 @@
# 비밀번호·계정 보안 규정
## 1. 비밀번호 만들기
- 비밀번호는 12자 이상으로 만든다.
- 영문 대문자, 영문 소문자, 숫자, 특수문자 중 3종류 이상을 섞는다.
- 이름, 생일, 전화번호, 회사 이름처럼 추측하기 쉬운 단어는 쓰지 않는다.
## 2. 변경 주기
- 비밀번호는 90일마다 바꾼다. 만료 7일 전부터 사내 포털에 변경 안내가 표시된다.
- 최근에 쓴 비밀번호 5개는 다시 쓸 수 없다.
## 3. 로그인 실패와 잠금
- 로그인에 5번 연속 실패하면 계정이 30분 동안 잠긴다.
- 30분이 지나면 자동으로 풀린다. 급한 경우 IT 문의로 잠금 해제를 요청한다.
## 4. 다단계 인증
- 회사 밖에서 사내 시스템에 접속할 때는 OTP 다단계 인증을 반드시 사용한다.
- OTP 앱이 설치된 휴대폰을 바꾸면 IT 포털에서 OTP를 다시 등록한다.
+13
View File
@@ -0,0 +1,13 @@
# 개인정보 처리 지침
## 1. 수집
- 업무에 꼭 필요한 개인정보만 수집한다.
- 주민등록번호는 법에서 정한 경우가 아니면 수집하지 않는다.
## 2. 이용과 보관
- 고객 개인정보가 보이는 화면은 캡처하거나 촬영하지 않는다.
- 개인정보가 들어 있는 출력물은 다 쓴 뒤 바로 파쇄한다.
## 3. 유출 대응
- 개인정보가 유출되었거나 유출이 의심되면 1시간 이내에 보안팀에 신고한다.
- 신고할 때는 유출된 정보의 종류, 대략적인 건수, 발견한 시각을 함께 알린다.
@@ -0,0 +1,15 @@
# 원격근무·VPN 이용 규정
## 1. 접속 원칙
- 회사 밖에서 사내 시스템에 접속할 때는 반드시 회사 VPN을 먼저 연결한다.
- 카페, 공항 같은 공용 와이파이에서는 VPN 없이 사내 시스템에 접속하지 않는다.
## 2. VPN 인증서
- VPN 인증서의 유효 기간은 1년이다.
- 만료 30일 전부터 IT 포털의 '인증서 재발급' 메뉴에서 새 인증서를 신청한다.
- 인증서가 만료되면 VPN에 접속할 수 없으므로, 만료 전에 재발급한다.
## 3. 재택근무 중 보안
- 자리를 비울 때는 화면을 잠그고, 5분 동안 사용하지 않으면 자동으로 잠기게 설정한다.
- 가족과 함께 쓰는 컴퓨터로는 업무를 하지 않는다.
- VPN 접속 기록은 1년 동안 보관된다.
@@ -0,0 +1,11 @@
# 보안 사고 대응 절차
## 1. 피싱 메일
- 보낸 사람이 의심스럽거나 급하게 클릭을 요구하는 메일은 링크를 누르지 않는다.
- 메일 화면의 '피싱 신고' 버튼으로 신고한다.
- 이미 링크를 눌렀거나 비밀번호를 입력했다면 즉시 비밀번호를 바꾸고 IT 문의로 알린다.
## 2. 악성코드 감염 의심
- 컴퓨터가 갑자기 느려지거나 모르는 프로그램이 실행되면 악성코드 감염을 의심한다.
- 감염이 의심되면 먼저 랜선을 뽑고 와이파이를 꺼서 네트워크에서 분리한 뒤 IT 문의로 알린다.
- 컴퓨터를 끄거나 파일을 지우지 않는다. 원인 분석에 필요하다.
@@ -0,0 +1,14 @@
# 소프트웨어 사용 규정
## 1. 설치와 승인
- 업무용 컴퓨터에는 승인된 소프트웨어만 설치한다.
- 새 소프트웨어가 필요하면 IT 포털의 '소프트웨어 신청'에서 요청한다. 승인까지 보통 2영업일이 걸린다.
- 무료 소프트웨어라도 회사 업무에 쓰려면 상업적 이용이 허용되는지 라이선스를 확인한다.
## 2. 금지 사항
- 불법으로 복제한 소프트웨어는 설치하지 않는다. 적발되면 징계 대상이 된다.
## 3. 생성형 AI 서비스
- 회사가 승인한 생성형 AI 서비스만 업무에 사용한다.
- 고객 정보, 개인정보, 대외비 문서는 생성형 AI 서비스에 입력하지 않는다.
- 생성형 AI가 만든 결과는 사람이 확인한 뒤에 사용한다.
+14
View File
@@ -0,0 +1,14 @@
[
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
+6
View File
@@ -0,0 +1,6 @@
{
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다."},
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다."},
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다."},
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요."}
}
+173
View File
@@ -0,0 +1,173 @@
"""Sample company data API for the day 72 labs (day 71 API + fault switch + cache; Python standard library only).
GET /health -> {"status": "ok"} (never affected by the fault switch)
GET /faq?q=<question> -> {"query": ..., "results": [FAQ entries]}
GET /tickets/<id> -> ticket record, or 404 {"error": "ticket not found"}
GET /tickets/<id>?requester=<name>
-> same, but 403 {"error": "not your ticket"} when the ticket belongs to someone else
GET /docs -> {"docs": [{"name", "title"}]} company IT policy documents (markdown)
GET /docs/<name> -> {"name", "title", "text"}, or 404 {"error": "doc not found"}
Day 72 additions (state lives in memory; restarting the container resets it):
GET /admin/fault -> {"faq": "ok", "tickets": "ok", "slow_seconds": 8}
POST /admin/fault body {"faq": "ok"|"slow"|"error", "tickets": ..., "slow_seconds": 8}
"slow" waits slow_seconds before answering, "error" answers HTTP 500.
GET /cache/<key> -> {"key", "value", "hits"} or 404 {"error": "not cached"}
POST /cache/<key> body {"value": <anything>, "ttl": 600} (PUT works too)
POST /cache-clear -> {"cleared": <count>}
GET /cache-stats -> {"entries", "hits", "misses"}
"""
import json
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlparse
DATA_DIR = Path(os.getenv("DATA_DIR", Path(__file__).resolve().parent / "data"))
PORT = int(os.getenv("PORT", "8800"))
LOCK = threading.Lock()
FAULT = {"faq": "ok", "tickets": "ok", "slow_seconds": 8}
CACHE: dict[str, dict] = {}
STATS = {"hits": 0, "misses": 0}
def load(name):
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
def search_faq(query, top_k=2):
words = {word for word in query.lower().split() if len(word) > 1}
scored = []
for item in load("faq.json"):
keywords = {keyword.lower() for keyword in item["keywords"]}
score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords))
if score:
scored.append({"score": score, **item})
scored.sort(key=lambda item: item["score"], reverse=True)
return scored[:top_k]
class Handler(BaseHTTPRequestHandler):
def send_json(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def url(self):
# Raw UTF-8 in the request line arrives as latin-1 text; percent-encoded paths are unchanged.
return urlparse(self.path.encode("latin-1").decode("utf-8", errors="replace"))
def body(self):
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
def faulty(self, target):
"""Apply the fault switch to one endpoint group. Returns True when the response was already sent."""
mode = FAULT.get(target, "ok")
if mode == "slow":
time.sleep(float(FAULT.get("slow_seconds", 8)))
elif mode == "error":
self.send_json(500, {"error": f"{target} service is failing (fault switch)"})
return True
return False
def do_GET(self):
url = self.url()
if url.path == "/health":
return self.send_json(200, {"status": "ok"})
if url.path == "/admin/fault":
return self.send_json(200, FAULT)
if url.path == "/cache-stats":
with LOCK:
return self.send_json(200, {"entries": len(CACHE), **STATS})
if url.path.startswith("/cache/"):
key = unquote(url.path.removeprefix("/cache/"))
with LOCK:
entry = CACHE.get(key)
if entry and entry["expires"] > time.time():
entry["hits"] += 1
STATS["hits"] += 1
return self.send_json(200, {"key": key, "value": entry["value"], "hits": entry["hits"]})
CACHE.pop(key, None)
STATS["misses"] += 1
return self.send_json(404, {"error": "not cached", "key": key})
if url.path == "/faq":
if self.faulty("faq"):
return None
query = parse_qs(url.query).get("q", [""])[0]
return self.send_json(200, {"query": query, "results": search_faq(query)})
if url.path == "/docs":
docs = [{"name": f.stem, "title": f.read_text(encoding="utf-8").splitlines()[0].lstrip("# ").strip()}
for f in sorted((DATA_DIR / "docs").glob("*.md"))]
return self.send_json(200, {"docs": docs})
if url.path.startswith("/docs/"):
name = unquote(url.path.removeprefix("/docs/")).strip()
path = DATA_DIR / "docs" / f"{name}.md"
if "/" in name or not path.exists():
return self.send_json(404, {"error": "doc not found", "name": name})
text = path.read_text(encoding="utf-8")
return self.send_json(200, {"name": name, "title": text.splitlines()[0].lstrip("# ").strip(), "text": text})
if url.path.startswith("/tickets/"):
if self.faulty("tickets"):
return None
ticket_id = unquote(url.path.removeprefix("/tickets/")).strip().upper()
ticket = load("tickets.json").get(ticket_id)
if ticket is None:
return self.send_json(404, {"error": "ticket not found", "ticket_id": ticket_id})
requester = parse_qs(url.query).get("requester", [None])[0]
if requester is not None and requester.strip() != ticket["requester"]:
return self.send_json(403, {"error": "not your ticket", "ticket_id": ticket_id})
return self.send_json(200, {"ticket_id": ticket_id, **ticket})
return self.send_json(404, {"error": "unknown path"})
def do_POST(self):
url = self.url()
body = self.body()
if body is None or not isinstance(body, dict):
return self.send_json(400, {"error": "body must be a JSON object"})
if url.path == "/admin/fault":
for target in ("faq", "tickets"):
if target in body:
if body[target] not in ("ok", "slow", "error"):
return self.send_json(400, {"error": f"{target} must be ok, slow or error"})
FAULT[target] = body[target]
if "slow_seconds" in body:
FAULT["slow_seconds"] = max(0.0, min(float(body["slow_seconds"]), 60.0))
return self.send_json(200, FAULT)
if url.path == "/cache-clear":
with LOCK:
count = len(CACHE)
CACHE.clear()
STATS.update(hits=0, misses=0)
return self.send_json(200, {"cleared": count})
if url.path.startswith("/cache/"):
key = unquote(url.path.removeprefix("/cache/"))
if not key or "value" not in body:
return self.send_json(400, {"error": "key in the path and value in the body are required"})
ttl = max(1.0, min(float(body.get("ttl", 600)), 86400.0))
with LOCK:
CACHE[key] = {"value": body["value"], "expires": time.time() + ttl, "hits": 0}
return self.send_json(200, {"key": key, "stored": True, "ttl": ttl})
return self.send_json(404, {"error": "unknown path"})
do_PUT = do_POST
def log_message(self, fmt, *args):
print(f"{self.address_string()} {fmt % args}", flush=True)
if __name__ == "__main__":
print(f"data-api listening on :{PORT} (data: {DATA_DIR})", flush=True)
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+5
View File
@@ -0,0 +1,5 @@
# 수업 결과물
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`helpdesk.json`, `health.json`)이 여기에 저장됩니다 (git에 올라가지 않음). 처음에는 `cp reference/baseline-helpdesk.json my-work/helpdesk.json`.
- `PERF.md`: [성능 · 안정성 보고서](../templates/PERF-REPORT-TEMPLATE.md)를 복사해 채웁니다.
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
@@ -0,0 +1,442 @@
{
"name": "72 IT helpdesk baseline (provided)",
"nodes": [
{
"name": "Question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"parameters": {
"httpMethod": "POST",
"path": "base-helpdesk",
"responseMode": "responseNode",
"options": {}
},
"webhookId": "f7f86794-1216-5eae-93b9-9e69bb515ce3",
"position": [
0,
300
],
"id": "73fa6963-23cf-5fab-be79-21db3461385e"
},
{
"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": "108e39d5-615a-517a-9060-e09e7c4379f9"
},
{
"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": "1d7d4c12-dba9-5291-95eb-fbcf440257be"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
440,
300
],
"id": "e44002cd-d810-5fbe-9eef-d0d9e897b974"
},
{
"name": "Classify 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": [
440,
520
],
"id": "555c72bf-fdc6-5560-9c4a-386b1cf78fe7"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
660,
300
],
"id": "169c07e6-bc8e-5307-9502-4a8114c854fa"
},
{
"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": "7459ee39-7124-57c0-8bdf-c596f65f7deb"
},
{
"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": "70d6cf5f-6064-5790-a7e6-628e044f95b9"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $('Parse category').first().json.category }}\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.",
"maxIterations": 1
}
},
"position": [
1320,
300
],
"id": "bd94eff5-ddeb-5a04-bfb7-e8d62ca7ed45"
},
{
"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": [
1320,
520
],
"id": "e40a7e46-cf90-5b11-a629-6f92fe5a043c"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.",
"maxIterations": 1
}
},
"position": [
1540,
300
],
"id": "540513f3-f96b-5672-913f-ba4846dd768b"
},
{
"name": "Review 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": [
1540,
520
],
"id": "6f1ffb5c-b821-593b-8cd2-ebb68d63a277"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nreturn [{ json: result }];"
},
"position": [
1760,
300
],
"id": "a749f7a2-8109-50f4-a3c4-dd1dbe29f8e8"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1980,
300
],
"id": "86789b60-2654-528e-aef9-adf67767dbd3"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Classify",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"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": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,562 @@
{
"name": "72 IT helpdesk + timings (reference)",
"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": "f7eddb61-8a29-5748-9dfe-a33e4db5fb4e"
},
{
"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": "f10269df-f57b-55be-9527-7d99273541e5"
},
{
"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": "ae5fdf1d-0e8e-5b9e-a375-d1c2a8159ff0"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "34e026d9-8555-5c58-98bc-0c8cb3433674"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
660,
300
],
"id": "8ed1076b-57e7-5692-a07b-0dfc406d717d"
},
{
"name": "Classify 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": [
660,
520
],
"id": "115afa58-b938-5f5f-aa27-b90886caafef"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
880,
300
],
"id": "e3a4245a-0d3e-52f9-8bba-fac408559c43"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1100,
300
],
"id": "6367ccc8-0f9a-53b5-ab75-b5b97411bcf4"
},
{
"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": [
1320,
300
],
"id": "e9049433-bf26-5f16-9025-ee8e45168e66"
},
{
"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": [
1540,
300
],
"id": "015582e0-c7ba-5450-afdb-31fe69bd4ed0"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "724f4528-be4b-5a27-95fa-dc4cbe9adc54"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $('Parse category').first().json.category }}\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.",
"maxIterations": 1
}
},
"position": [
1980,
300
],
"id": "73f8249d-0f63-5a27-ba24-7c0160dd4f37"
},
{
"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": [
1980,
520
],
"id": "253ae3fd-037d-50f8-a521-974f74f1ea02"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2200,
300
],
"id": "c91805b8-8a84-5d53-8ef1-24f414f43b4f"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "58a6606c-b82d-585d-98c2-ad0b50d978d2"
},
{
"name": "Review 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": [
2420,
520
],
"id": "9738bf2a-5100-51be-ab12-e932ef259273"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2640,
300
],
"id": "aafed179-dfd6-5b4d-affa-ba03915a19bf"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['classify', t('After classify')], ['data', t('After data')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
2860,
300
],
"id": "4da41b13-2fc9-5423-a782-fb61727ea33a"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3080,
300
],
"id": "2fa9e489-3282-5de4-9c91-f536e53b568a"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,637 @@
{
"name": "72 IT helpdesk + rules first (reference)",
"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": "943597e2-d5eb-5f3c-b36b-f18ee54db8d7"
},
{
"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": "ca5c79c8-323b-5eac-b1c7-50d7383f1fc5"
},
{
"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": "030b2d85-73de-567f-95ce-d25c8729794d"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "2240260c-639c-501a-9347-9da083549304"
},
{
"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": [
660,
300
],
"id": "aabca858-7174-5c55-b7f5-ae3ab4e6a565"
},
{
"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": [
880,
300
],
"id": "92beb818-7b68-528c-9d35-f1c1645ea215"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1100,
300
],
"id": "dbf68168-4cb7-55db-8ba9-9752ff11f684"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1320,
300
],
"id": "36d285f0-4fb3-5697-9053-5ae5e3e8d0b1"
},
{
"name": "Known?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category }}",
"rightValue": "unknown",
"operator": {
"type": "string",
"operation": "notEquals"
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
1540,
300
],
"id": "fd83a3d5-bc74-5739-8185-1924a7939ed8"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
1760,
300
],
"id": "63d9e328-246e-5bd4-9596-40e9b9715464"
},
{
"name": "Classify 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": [
1760,
520
],
"id": "8aabd2dc-204a-5730-9633-1bd0858969ba"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
1980,
300
],
"id": "1150b73c-a088-5d4f-ac61-38faec69bc23"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2200,
300
],
"id": "337a90fe-b0bf-5e29-82ec-c1178ec1eeec"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\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.",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "e23b0a82-1cb0-50e9-bf9c-ea5eb3b053f9"
},
{
"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": [
2420,
520
],
"id": "ce2907bb-2524-54e7-9e0a-92bed701764f"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2640,
300
],
"id": "a0321f1e-05f2-5e4f-b7cf-f0eb06eaf0f1"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.",
"maxIterations": 1
}
},
"position": [
2860,
300
],
"id": "5ee9442d-00be-5492-83bb-a99147e6e282"
},
{
"name": "Review 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": [
2860,
520
],
"id": "b496eea1-f048-550c-9893-515dcc94c84e"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3080,
300
],
"id": "c955c14a-8a9b-538f-94db-27d3fd579751"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3300,
300
],
"id": "185dc7f9-4954-5cef-ba24-f45145bd192a"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3520,
300
],
"id": "4dd5ed77-a95a-5969-a9f7-78ab7d32fb21"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"type": "main",
"index": 0
}
]
]
},
"Rule classify": {
"main": [
[
{
"node": "Known?",
"type": "main",
"index": 0
}
]
]
},
"Known?": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,894 @@
{
"name": "72 IT helpdesk + cache (reference)",
"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": "c9a72036-8a27-592b-bd8b-38a2db4d6d06"
},
{
"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": "6c786a1e-1989-51a4-90a5-db92e73869b1"
},
{
"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": "a4a18f10-bc62-5d90-9b67-68c9feee398e"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "997a12bd-2387-5f2b-9bd8-41d0fd96b3c0"
},
{
"name": "Cache key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nconst key = String(body.question ?? '').trim().toLowerCase().replace(/\\s+/g, ' ');\nreturn [{ json: { ...$json, key, use_cache: body.no_cache !== true } }];"
},
"position": [
660,
300
],
"id": "f5beb6a3-5fd1-5dd9-9940-8180d0461375"
},
{
"name": "Cache lookup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
880,
300
],
"id": "e4ac7f07-094d-5369-877c-b6124bb369be"
},
{
"name": "Cached?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.value !== undefined && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
1100,
300
],
"id": "d7643f14-e849-5965-98c1-0984a9164992"
},
{
"name": "Cached result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const value = $json.value;\nconst t0 = $('Start clock').first().json.t;\nreturn [{ json: { route: value.route, category: value.category, answer: value.answer, sources: value.sources ?? [],\n llm_calls: 0, cached: true, steps: ['cache'], timings: { total_ms: t0 ? Date.now() - t0 : 0 } } }];"
},
"position": [
1320,
40
],
"id": "1eb4a1d1-0752-549e-8419-f421001f6ce7"
},
{
"name": "Send cached",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
40
],
"id": "ef4c0dcc-6923-515a-9a47-16b96f870458"
},
{
"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": [
1320,
300
],
"id": "c0ca4c44-24ab-5e35-bc09-3e1a46b5eaf2"
},
{
"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": [
1540,
300
],
"id": "8bfa2f94-cfd3-5c47-9907-9cbe520709f5"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "ec991b78-5d32-523e-8650-1b705d664347"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1980,
300
],
"id": "3aea9b36-7147-5a1e-b214-dde00e3b07a8"
},
{
"name": "Known?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category }}",
"rightValue": "unknown",
"operator": {
"type": "string",
"operation": "notEquals"
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
2200,
300
],
"id": "cb84100b-77a0-54e2-ab8a-82439f9a06a2"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "24cdba37-3d33-5b58-bdf8-843fdceb921d"
},
{
"name": "Classify 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": [
2420,
520
],
"id": "14a4b607-e7e0-5946-a6dd-429db1749efe"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
2640,
300
],
"id": "e2e83cde-1e8e-57a3-9873-e21b349e8e9c"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2860,
300
],
"id": "52489cc0-dc17-55fd-abc1-d0560f984a09"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\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.",
"maxIterations": 1
}
},
"position": [
3080,
300
],
"id": "af3fff36-6c0f-561b-892c-c114ba138f00"
},
{
"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": [
3080,
520
],
"id": "a17533bf-9c80-521d-b6e3-1a50309fecba"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3300,
300
],
"id": "f8f2abf1-fe1f-5e26-bbdf-a7265cc0af82"
},
{
"name": "Review",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=초안: {{ $('Answer').first().json.output }}",
"options": {
"systemMessage": "You review a Korean IT help-desk answer before it is sent. Keep the facts exactly. Make it polite (합니다체), remove anything not supported by the data, keep at most 3 sentences. Return only the final answer text.",
"maxIterations": 1
}
},
"position": [
3520,
300
],
"id": "db2e207e-bf3f-573b-b812-8903cfe2e8b0"
},
{
"name": "Review 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": [
3520,
520
],
"id": "45781a30-03fe-5886-8936-2d8a9ae3bd31"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3740,
300
],
"id": "e4200614-0722-52ad-b86b-166d8325db9e"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = String($('Review').first().json.output ?? '').trim();\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('review');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 1;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3960,
300
],
"id": "aca47054-6aa3-5773-91c4-1b71155360a1"
},
{
"name": "Cache it?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category === 'faq' && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
4180,
300
],
"id": "6173c5a7-0f27-5472-b65d-7b1e1e4a8c2a"
},
{
"name": "Store in cache",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, category: $json.category, answer: $json.answer, sources: $json.sources }, ttl: 600 }) }}"
},
"position": [
4400,
180
],
"id": "131ba1d7-a5c6-59d8-b998-6d56b6dcbb90"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Build result').first().json }];"
},
"position": [
4620,
180
],
"id": "9fa5ecbb-8ba2-53cf-9ed0-e39b0f0abce4"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4840,
300
],
"id": "6452676c-2624-523c-a63e-cb1f2cdf88ce"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Cache key",
"type": "main",
"index": 0
}
]
]
},
"Cache key": {
"main": [
[
{
"node": "Cache lookup",
"type": "main",
"index": 0
}
]
]
},
"Cache lookup": {
"main": [
[
{
"node": "Cached?",
"type": "main",
"index": 0
}
]
]
},
"Cached?": {
"main": [
[
{
"node": "Cached result",
"type": "main",
"index": 0
}
],
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Cached result": {
"main": [
[
{
"node": "Send cached",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"type": "main",
"index": 0
}
]
]
},
"Rule classify": {
"main": [
[
{
"node": "Known?",
"type": "main",
"index": 0
}
]
]
},
"Known?": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review",
"type": "main",
"index": 0
}
]
]
},
"Review model": {
"ai_languageModel": [
[
{
"node": "Review",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Review": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Cache it?",
"type": "main",
"index": 0
}
]
]
},
"Cache it?": {
"main": [
[
{
"node": "Store in cache",
"type": "main",
"index": 0
}
],
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Store in cache": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,938 @@
{
"name": "72 IT helpdesk + timeout and fallback (reference)",
"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": "d723516c-f180-54b9-87ca-9d10479930ca"
},
{
"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": "b2ec2f0b-bda5-57b7-90bb-a12187a87ada"
},
{
"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": "e8162a04-f42f-5d77-9fb2-8b48bb83fa93"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "8298edad-e01f-5083-a050-e61d239cdd06"
},
{
"name": "Cache key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nconst key = String(body.question ?? '').trim().toLowerCase().replace(/\\s+/g, ' ');\nreturn [{ json: { ...$json, key, use_cache: body.no_cache !== true } }];"
},
"position": [
660,
300
],
"id": "947ac5f9-250f-59f0-a8c2-4f178d3e27bf"
},
{
"name": "Cache lookup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
880,
300
],
"id": "bca22ac4-98e9-5840-9565-0a08abd392dd"
},
{
"name": "Cached?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.value !== undefined && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
1100,
300
],
"id": "c6f11d8c-4cf4-56da-a0c2-86b43d5875b7"
},
{
"name": "Cached result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const value = $json.value;\nconst t0 = $('Start clock').first().json.t;\nreturn [{ json: { route: value.route, category: value.category, answer: value.answer, sources: value.sources ?? [],\n llm_calls: 0, cached: true, steps: ['cache'], timings: { total_ms: t0 ? Date.now() - t0 : 0 } } }];"
},
"position": [
1320,
40
],
"id": "87241232-65a7-5929-8aab-f793cd5eb252"
},
{
"name": "Send cached",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
40
],
"id": "c9ad7484-9647-56c6-bc21-489ae4ba90c9"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1320,
300
],
"id": "7fadfe95-f11a-53e1-9381-6eaabe3b0e33"
},
{
"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
}
},
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1540,
300
],
"id": "63fcb7b8-6a86-5ff2-9ec7-dbf12d90ba53"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "e03ac097-cb39-5e96-b35f-6fef794dccc0"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1980,
300
],
"id": "ad169caa-8acb-5747-9d80-de0809af7722"
},
{
"name": "Known?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category }}",
"rightValue": "unknown",
"operator": {
"type": "string",
"operation": "notEquals"
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
2200,
300
],
"id": "217e4a57-9524-59ca-8a5c-8d802a6ed928"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"position": [
2420,
300
],
"id": "84844dd8-a4c4-5466-8bfa-0164024d6889"
},
{
"name": "Classify 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": [
2420,
520
],
"id": "f97e877f-20e8-56a3-bbe5-504dceb6297f"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
2640,
300
],
"id": "79533502-a17e-5fce-a050-1f2577b3ef9f"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2860,
300
],
"id": "bd326baa-dd79-5020-9634-e80a1503221f"
},
{
"name": "Data down",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"parameters": {
"assignments": {
"assignments": [
{
"id": "d1",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "d2",
"name": "answer",
"value": "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.",
"type": "string"
},
{
"id": "d3",
"name": "reason",
"value": "data_api_down",
"type": "string"
}
]
},
"options": {}
},
"position": [
3080,
820
],
"id": "277957fb-f7b9-52f0-a345-6b0210360668"
},
{
"name": "Send data down",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3300,
820
],
"id": "1a26c632-4637-5a77-bf25-ad0068c539fe"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\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.",
"maxIterations": 1
}
},
"position": [
3080,
300
],
"id": "df2b22cd-48a6-5b76-b884-ed5f116bee5a"
},
{
"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": [
3080,
520
],
"id": "933cc07a-882b-5803-a339-ce7fff18d24e"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3300,
300
],
"id": "1126ce89-1de8-5dfd-af02-dfe16f941d0c"
},
{
"name": "Review check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "let answer = String($('Answer').first().json.output ?? '').trim();\nconst problems = [];\nif (!answer) problems.push('empty');\nif (!/[가-힣]/.test(answer)) problems.push('not_korean');\nif (answer.length > 400) { answer = answer.slice(0, 400); problems.push('too_long'); }\nreturn [{ json: { ...$json, answer, review_problems: problems } }];"
},
"position": [
3520,
300
],
"id": "4ceb73fa-4d1a-5166-9713-53a66329aa4b"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3740,
300
],
"id": "c27c79b7-4a80-5197-9eb8-cc8ea3af07de"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = $('Review check').first().json.answer;\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('check');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 0;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3960,
300
],
"id": "4e59eba2-74ef-5ad9-9322-f2a61dd24c2e"
},
{
"name": "Cache it?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category === 'faq' && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
4180,
300
],
"id": "b02df770-a2da-5ff5-8506-be2aa57f15c4"
},
{
"name": "Store in cache",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, category: $json.category, answer: $json.answer, sources: $json.sources }, ttl: 600 }) }}"
},
"position": [
4400,
180
],
"id": "5e7d5d4d-1b3e-53cb-a622-f8b9057b7948"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Build result').first().json }];"
},
"position": [
4620,
180
],
"id": "a3625557-95dd-5b42-b0cc-a71bf5dfb881"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4840,
300
],
"id": "ec557367-5b2f-59d5-b7e1-ad7965d5b893"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Cache key",
"type": "main",
"index": 0
}
]
]
},
"Cache key": {
"main": [
[
{
"node": "Cache lookup",
"type": "main",
"index": 0
}
]
]
},
"Cache lookup": {
"main": [
[
{
"node": "Cached?",
"type": "main",
"index": 0
}
]
]
},
"Cached?": {
"main": [
[
{
"node": "Cached result",
"type": "main",
"index": 0
}
],
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Cached result": {
"main": [
[
{
"node": "Send cached",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"type": "main",
"index": 0
}
]
]
},
"Rule classify": {
"main": [
[
{
"node": "Known?",
"type": "main",
"index": 0
}
]
]
},
"Known?": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"Data down": {
"main": [
[
{
"node": "Send data down",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review check",
"type": "main",
"index": 0
}
]
]
},
"Review check": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Cache it?",
"type": "main",
"index": 0
}
]
]
},
"Cache it?": {
"main": [
[
{
"node": "Store in cache",
"type": "main",
"index": 0
}
],
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Store in cache": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,993 @@
{
"name": "72 IT helpdesk + LLM fallback (reference)",
"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": "da213575-a9a7-5052-9259-1235f4e340f0"
},
{
"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": "4ea38fda-f28a-5e1d-a504-05874f837f02"
},
{
"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": "ff743009-a8ea-5c5f-8b83-9e29a2c60111"
},
{
"name": "Start clock",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { t: Date.now() } }];"
},
"position": [
440,
300
],
"id": "f1cc57e6-9966-5f49-90c4-cbd65be68041"
},
{
"name": "Cache key",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nconst key = String(body.question ?? '').trim().toLowerCase().replace(/\\s+/g, ' ');\nreturn [{ json: { ...$json, key, use_cache: body.no_cache !== true } }];"
},
"position": [
660,
300
],
"id": "3c70cd68-4a52-59c2-a5c3-d30c86914021"
},
{
"name": "Cache lookup",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"position": [
880,
300
],
"id": "506d8e43-b396-596a-8197-08f2aa58381b"
},
{
"name": "Cached?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.value !== undefined && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
1100,
300
],
"id": "1113fdf6-f964-5534-8d8a-dd41344c2725"
},
{
"name": "Cached result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const value = $json.value;\nconst t0 = $('Start clock').first().json.t;\nreturn [{ json: { route: value.route, category: value.category, answer: value.answer, sources: value.sources ?? [],\n llm_calls: 0, cached: true, steps: ['cache'], timings: { total_ms: t0 ? Date.now() - t0 : 0 } } }];"
},
"position": [
1320,
40
],
"id": "82a7f0f0-356b-5164-baaa-a478f74db65e"
},
{
"name": "Send cached",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
1540,
40
],
"id": "addd6ac5-76a5-5da7-ac2b-84c005eb7520"
},
{
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "http://data-api:8800/faq",
"options": {
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $('Question webhook').first().json.body.question }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1320,
300
],
"id": "52b6a8a9-bf1f-5b58-8644-0077cfd01dd0"
},
{
"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
}
},
"timeout": 2000
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
}
]
}
},
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 500,
"onError": "continueErrorOutput",
"position": [
1540,
300
],
"id": "0a16cd98-b769-5067-b867-beabec01b00d"
},
{
"name": "After data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
1760,
300
],
"id": "ba6f2475-7cea-5c3d-95a6-dc6125a0441c"
},
{
"name": "Rule classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const question = String($('Question webhook').first().json.body.question ?? '');\nconst results = $('Search FAQ').first().json.results ?? [];\nlet category = 'unknown';\nif (/T-?\\d{4}/i.test(question)) category = 'ticket';\nelse if (results.length > 0) category = 'faq';\nreturn [{ json: { category, by: 'rule' } }];"
},
"position": [
1980,
300
],
"id": "a2e2c597-6870-5bcc-833c-a44526b343f3"
},
{
"name": "Known?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category }}",
"rightValue": "unknown",
"operator": {
"type": "string",
"operation": "notEquals"
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
2200,
300
],
"id": "e0758a6f-de55-5309-a696-3ed0755ab1f0"
},
{
"name": "Classify",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "={{ $('Question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You sort one company IT help-desk question. Categories: faq (how to fix or use something: password, account lock, VPN, printer, mail box, software install), ticket (asks about the status of an IT request ticket like T-1002), other (anything else). Return only JSON, no code fence: {\"category\": \"faq\"}",
"maxIterations": 1
}
},
"onError": "continueErrorOutput",
"position": [
2420,
300
],
"id": "05ed7c16-110e-53ab-9b03-ce872d616695"
},
{
"name": "Classify 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": [
2420,
520
],
"id": "5a7c30c2-472e-54b3-89c2-85b53e3f432b"
},
{
"name": "Parse category",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet category = 'other';\ntry { category = JSON.parse(raw).category ?? 'other'; } catch (error) { category = 'other'; }\nif (!['faq', 'ticket', 'other'].includes(category)) category = 'other';\nreturn [{ json: { category, by: 'llm' } }];"
},
"position": [
2640,
300
],
"id": "1407c587-1e10-5d76-b43a-9bf6570eb619"
},
{
"name": "After classify",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
2860,
300
],
"id": "77f26c5e-fd71-59c5-b02f-cee875e87ee7"
},
{
"name": "Data down",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"parameters": {
"assignments": {
"assignments": [
{
"id": "d1",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "d2",
"name": "answer",
"value": "사내 데이터 시스템이 응답하지 않아 담당자에게 연결합니다.",
"type": "string"
},
{
"id": "d3",
"name": "reason",
"value": "data_api_down",
"type": "string"
}
]
},
"options": {}
},
"position": [
3080,
820
],
"id": "7db7288e-627d-5b0f-8634-2bd12ba22e35"
},
{
"name": "Send data down",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
3300,
820
],
"id": "e582596a-b12d-550c-8ba8-ea5661be5b32"
},
{
"name": "Answer",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"parameters": {
"promptType": "define",
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\n분류: {{ $json.category }}\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.",
"maxIterations": 1
}
},
"onError": "continueErrorOutput",
"position": [
3080,
300
],
"id": "a0b37e8a-ba45-5496-baf5-3fe7eb4e11cb"
},
{
"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": [
3080,
520
],
"id": "eadbe0ed-8031-5d85-91c2-258b144dcc6d"
},
{
"name": "After answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3300,
300
],
"id": "2a0a5546-0677-5a3b-89b9-cfb1887a696b"
},
{
"name": "Review check",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "let answer = String($('Answer').first().json.output ?? '').trim();\nconst problems = [];\nif (!answer) problems.push('empty');\nif (!/[가-힣]/.test(answer)) problems.push('not_korean');\nif (answer.length > 400) { answer = answer.slice(0, 400); problems.push('too_long'); }\nreturn [{ json: { ...$json, answer, review_problems: problems } }];"
},
"position": [
3520,
300
],
"id": "24064ede-b012-51e6-8e8a-a40032982e5b"
},
{
"name": "After review",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: { ...$json, t: Date.now() } }];"
},
"position": [
3740,
300
],
"id": "d6ea34a2-56fe-5d19-b1fe-a07a2176b6cf"
},
{
"name": "Build result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const ran = (name) => { try { return $(name).isExecuted; } catch (error) { return false; } };\nconst body = $('Question webhook').first().json.body;\nconst category = $('Parse category').isExecuted ? $('Parse category').first().json.category : $('Rule classify').first().json.category;\nconst answer = $('Review check').first().json.answer;\nconst results = $('Search FAQ').first().json.results ?? [];\nconst sources = category === 'faq' ? results.map((r) => r.id) : [];\nconst steps = [];\nif (ran('Rule classify')) steps.push('rule');\nif (ran('Classify')) steps.push('classify');\nsteps.push('faq', 'ticket', 'answer');\nsteps.push('check');\nconst llm_calls = (ran('Classify') ? 1 : 0) + 1 + 0;\nconst result = { route: '자동 답변', category, answer, sources };\nconst t = (name) => (ran(name) ? $(name).first().json.t : null);\nconst t0 = t('Start clock');\nconst marks = [['data', t('After data')], ['classify', t('After classify')], ['answer', t('After answer')], ['review', t('After review')]];\nconst timings = {};\nlet last = t0;\nfor (const [label, mark] of marks) {\n if (mark === null) continue;\n timings[`${label}_ms`] = mark - last;\n last = mark;\n}\ntimings.total_ms = Date.now() - t0;\nObject.assign(result, { llm_calls, cached: false, steps, timings });\nreturn [{ json: result }];"
},
"position": [
3960,
300
],
"id": "ddb667d7-58f4-591a-99b1-9fdee196e7a9"
},
{
"name": "Cache it?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.category === 'faq' && $('Cache key').first().json.use_cache }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"position": [
4180,
300
],
"id": "be269673-e6ef-5484-adb9-d2b266f59f97"
},
{
"name": "Store in cache",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"parameters": {
"url": "=http://data-api:8800/cache/{{ encodeURIComponent($('Cache key').first().json.key) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"method": "POST",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ value: { route: $json.route, category: $json.category, answer: $json.answer, sources: $json.sources }, ttl: 600 }) }}"
},
"position": [
4400,
180
],
"id": "1d551b65-ef7f-527e-8339-ce95e2272e2d"
},
{
"name": "Result again",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "return [{ json: $('Build result').first().json }];"
},
"position": [
4620,
180
],
"id": "056a1325-c8d0-501f-9dab-599a8380748e"
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4840,
300
],
"id": "0bc9ba7d-cc11-5f62-a05e-107089fe53b4"
},
{
"name": "Simple answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"parameters": {
"jsCode": "const body = $('Question webhook').first().json.body;\nlet results = [];\ntry { results = $('Search FAQ').first().json.results ?? []; } catch (error) { results = []; }\nlet ticket = {};\ntry { ticket = $('Get ticket').first().json ?? {}; } catch (error) { ticket = {}; }\nconst isTicket = /T-?\\d{4}/i.test(String(body.question ?? ''));\nif (isTicket && ticket.status) {\n return [{ json: { route: '간단 답변', category: 'ticket', answer: `${ticket.ticket_id} 접수 건은 현재 '${ticket.status}' 상태입니다. ${ticket.note ?? ''}`.trim(),\n sources: [], llm_calls: 0, reason: 'llm_down' } }];\n}\nif (!isTicket && results.length > 0) {\n return [{ json: { route: '간단 답변', category: 'faq', answer: `${results[0].answer} (자동 답변 기능을 잠시 쓸 수 없어 FAQ 원문으로 안내합니다.)`,\n sources: [results[0].id], llm_calls: 0, reason: 'llm_down' } }];\n}\nreturn [{ json: { route: '담당자 연결', category: 'other', answer: '지금은 자동 답변을 할 수 없어 담당자에게 연결합니다.', sources: [], llm_calls: 0, reason: 'llm_down' } }];"
},
"position": [
4400,
1060
],
"id": "9d9bceef-74c6-5bea-8e09-8d6fca3dd417"
},
{
"name": "Send simple answer",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"position": [
4620,
1060
],
"id": "3ab6f0cb-6d8f-5292-86c1-86b2645c74b5"
}
],
"connections": {
"Question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Start clock",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Start clock": {
"main": [
[
{
"node": "Cache key",
"type": "main",
"index": 0
}
]
]
},
"Cache key": {
"main": [
[
{
"node": "Cache lookup",
"type": "main",
"index": 0
}
]
]
},
"Cache lookup": {
"main": [
[
{
"node": "Cached?",
"type": "main",
"index": 0
}
]
]
},
"Cached?": {
"main": [
[
{
"node": "Cached result",
"type": "main",
"index": 0
}
],
[
{
"node": "Search FAQ",
"type": "main",
"index": 0
}
]
]
},
"Cached result": {
"main": [
[
{
"node": "Send cached",
"type": "main",
"index": 0
}
]
]
},
"Search FAQ": {
"main": [
[
{
"node": "Get ticket",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"Get ticket": {
"main": [
[
{
"node": "After data",
"type": "main",
"index": 0
}
],
[
{
"node": "Data down",
"type": "main",
"index": 0
}
]
]
},
"After data": {
"main": [
[
{
"node": "Rule classify",
"type": "main",
"index": 0
}
]
]
},
"Rule classify": {
"main": [
[
{
"node": "Known?",
"type": "main",
"index": 0
}
]
]
},
"Known?": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Classify",
"type": "main",
"index": 0
}
]
]
},
"Classify model": {
"ai_languageModel": [
[
{
"node": "Classify",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Classify": {
"main": [
[
{
"node": "Parse category",
"type": "main",
"index": 0
}
],
[
{
"node": "Simple answer",
"type": "main",
"index": 0
}
]
]
},
"Parse category": {
"main": [
[
{
"node": "After classify",
"type": "main",
"index": 0
}
]
]
},
"Data down": {
"main": [
[
{
"node": "Send data down",
"type": "main",
"index": 0
}
]
]
},
"After classify": {
"main": [
[
{
"node": "Answer",
"type": "main",
"index": 0
}
]
]
},
"Answer model": {
"ai_languageModel": [
[
{
"node": "Answer",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Answer": {
"main": [
[
{
"node": "After answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Simple answer",
"type": "main",
"index": 0
}
]
]
},
"After answer": {
"main": [
[
{
"node": "Review check",
"type": "main",
"index": 0
}
]
]
},
"Review check": {
"main": [
[
{
"node": "After review",
"type": "main",
"index": 0
}
]
]
},
"After review": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Cache it?",
"type": "main",
"index": 0
}
]
]
},
"Cache it?": {
"main": [
[
{
"node": "Store in cache",
"type": "main",
"index": 0
}
],
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Store in cache": {
"main": [
[
{
"node": "Result again",
"type": "main",
"index": 0
}
]
]
},
"Result again": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Simple answer": {
"main": [
[
{
"node": "Send simple answer",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"question": "", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?", "user": "한지훈"}
+1
View File
@@ -0,0 +1 @@
{"question": "프린터 출력이 안 돼요", "user": "최유진"}
+1
View File
@@ -0,0 +1 @@
{"question": " vpn 접속이 자꾸 끊겨요. 어떻게 하나요? ", "user": "최유진"}
@@ -0,0 +1 @@
{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호", "no_cache": true}
+1
View File
@@ -0,0 +1 @@
{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "오늘 점심 메뉴 추천해 주세요", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"question": "T-9999 접수 건은 어떻게 됐나요?", "user": "박서연"}
+1
View File
@@ -0,0 +1 @@
{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"}
@@ -0,0 +1,84 @@
# 성능 · 안정성 보고서 · IT 헬프데스크
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 이 파일을 `my-work/PERF.md`로 복사해 채웁니다.
## 1. 기준 재기 (Lab 01)
`uv run tools/flow.py bench base-helpdesk requests/<요청>.json -n 5`
| 요청 | p50 | p95 | max | 첫 호출 | 실패 |
| --- | --- | --- | --- | --- | --- |
| `faq-vpn` | | | | | |
| `ticket-own` | | | | | |
| `off-topic` | | | | | |
## 2. 단계별 시간 (Lab 01 · `timings`)
| 요청 | classify | data (faq · ticket) | answer | review | total | 가장 오래 걸린 단계 |
| --- | --- | --- | --- | --- | --- | --- |
| `faq-vpn` | | | | | | |
| `ticket-own` | | | | | | |
- LLM을 부를 필요가 없었던 요청 · 단계는?
### 2-1. 질문 종류별 (Lab 01 예제 3)
| 질문 종류 | p50 | p95 | classify | data | answer | review | total | LLM 몫 (%) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| FAQ (VPN) | | | | | | | | |
| 접수 번호 (T-1002) | | | | | | | | |
| IT 밖 (점심 메뉴) | | | | | | | | |
### 2-2. 시간 예산 (Lab 01 과제 2)
| `budget_ms` | `total_ms` | `llm_ms` | `over_budget` | `slowest_step` |
| --- | --- | --- | --- | --- |
| 60000 | | | | |
| 500 | | | | |
| (기본 3000) | | | | |
## 3. 한 가지씩 바꾸기 (Lab 02)
| 바꾼 것 (한 번에 하나) | `llm_calls` | p50 | p95 | 품질 테스트 (`lab01` · `lab02`) | 잃은 것 |
| --- | --- | --- | --- | --- | --- |
| 기준 | | | | | |
| 규칙 먼저 | | | | | |
| 캐시 (같은 질문 두 번째) | | | | | |
| 검토 → 검사 (과제 1) | | | | | |
| FAQ 번호 캐시 (과제 2, 다른 말 질문) | | | | | |
### 3-1. 캐시 적중률 (Lab 02 예제 3)
| 재기 | 첫 호출 | p50 | max | hits · misses |
| --- | --- | --- | --- | --- |
| 캐시 씀 | | | | |
| 캐시 안 씀 | | | | |
- 적중률 10% · 30%일 때 평균 어림:
## 4. 멈췄을 때 (Lab 03)
| 상황 | 고치기 전 (시간 · 결과) | 고친 뒤 (시간 · route) |
| --- | --- | --- |
| FAQ가 느림 (8초) | | |
| FAQ가 오류 (500) | | |
| LLM이 멈춤 (`llm down`) | | |
| 캐시에 있는 질문 + FAQ 오류 | | |
| 접수 API만 오류 · 접수 질문 (예제 3) | | |
| 접수 API만 오류 · VPN 질문 (예제 3) | | |
| FAQ 느림 · 다음 질문 (과제 2 차단기) | | |
## 5. 상태 점검 (Lab 03 과제)
| 상황 | `status` | `checks` | 걸린 시간 |
| --- | --- | --- | --- |
| 평소 | | | |
| FAQ 오류 | | | |
| FAQ 느림 | | | |
| LLM 멈춤 | | | |
## 6. 운영 관점 개선 제안 (3줄)
-
@@ -0,0 +1,27 @@
# 테스트 보고서 · IT 헬프데스크 (빠르게 · 멈춰도 답하게)
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 워크플로우 파일: `my-work/helpdesk.json`, `my-work/health.json`
## 1. 통과한 테스트
| 테스트 파일 | 결과 |
| --- | --- |
| `cases/lab01.json` (시간 기록) | Passed /4 |
| `cases/lab02.json` (규칙 · 캐시) | Passed /9 |
| `cases/lab02-challenge.json` (LLM 한 번) | Passed /5 |
| `cases/lab03.json` (데이터 API 고장) | Passed /4 |
| `cases/lab03-llm-down.json` (LLM 멈춤) | Passed /4 |
| `cases/lab03-challenge.json` (상태 점검) | Passed /4 |
## 2. 실패와 고친 내용
| 실패한 케이스 | 처음 결과 | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 |
| --- | --- | --- | --- | --- |
| | | | | |
## 3. 빨라진 만큼 잃은 것
- 검토 LLM을 없애서 잃은 것은? 캐시를 써서 생길 수 있는 문제는?
-
+850
View File
@@ -0,0 +1,850 @@
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote.
uv run tools/flow.py doctor # is everything ready?
uv run tools/flow.py check my-work/helpdesk.json # find mistakes before n8n sees the file
uv run tools/flow.py push my-work/helpdesk.json # import + publish (one n8n restart)
uv run tools/flow.py off my-health # unpublish by file or webhook path
uv run tools/flow.py call my-helpdesk requests/faq-vpn.json [-v]
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 10 # p50 · p95 · max · failures
uv run tools/flow.py test cases/lab02.json [-v] [--repeat 3] [--no-before] # -v: print request and response JSON
uv run tools/flow.py fault faq slow|error|ok # make the data API slow or failing (drills)
uv run tools/flow.py llm down|up # outage drill: a broken OpenAI key in n8n, then restore
Every command takes -v (--verbose) for details: the request and response JSON of each call (call, test, bench,
fault, doctor), the nodes and links of a workflow (check, push), and the docker / n8n CLI commands that ran.
Run it from the day folder (Last3Mon/72). n8n is the day 65 install (~/day65-n8n, Docker project
day65-n8n), started from Last3Mon/65/n8n. Environment overrides:
N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1)
N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678)
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CACHE = Path(__file__).resolve().parent / ".cache"
def n8n_home() -> Path:
"""Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n."""
if os.getenv("N8N_HOME"):
return Path(os.environ["N8N_HOME"]).expanduser()
env_file = ROOT.parent / "65" / "n8n" / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
if line.startswith("N8N_HOME="):
value = line.split("=", 1)[1].strip().strip("'\"")
if value:
return (env_file.parent / Path(value).expanduser()).resolve()
return Path.home() / "day65-n8n"
N8N_HOME = n8n_home()
START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)"
def find_container() -> str:
"""The n8n container of the day 65 project, whichever folder it was started from."""
if os.getenv("N8N_CONTAINER"):
return os.environ["N8N_CONTAINER"]
if shutil.which("docker"):
result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n",
"--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"],
capture_output=True, text=True)
names = result.stdout.split()
if names:
return names[0]
return "day65-n8n-n8n-1"
CONTAINER = find_container()
N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/")
# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only).
RECOMMENDED = {
"n8n-nodes-base.webhook": 2,
"n8n-nodes-base.respondToWebhook": 1.1,
"n8n-nodes-base.if": 2.2,
"n8n-nodes-base.switch": 3.2,
"n8n-nodes-base.set": 3.4,
"n8n-nodes-base.code": 2,
"n8n-nodes-base.httpRequest": 4.2,
"n8n-nodes-base.httpRequestTool": 4.2,
"@n8n/n8n-nodes-langchain.agent": 3.1,
"@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3,
"@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1,
}
USES_DATA_API = True # day 72: FAQ, tickets, fault switch and cache come from the data API
AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 72
COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama)
CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"}
SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}")
OK, BAD, WARN = "✔", "✘", "!"
VERBOSE = False # -v on any command (set in main)
# ---------------------------------------------------------------- helpers
def die(message: str) -> None:
print(f"{BAD} {message}", file=sys.stderr)
sys.exit(1)
def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
if not shutil.which("docker"):
die("docker command not found. Start Docker first.")
result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True)
if VERBOSE:
show_command(args, result)
if check and result.returncode != 0:
die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}")
return result
def show_json(label: str, value: object) -> None:
"""Print one JSON value under a label, indented below the result line (-v). API keys are masked."""
text = json.dumps(value, ensure_ascii=False, indent=2) if not isinstance(value, str) else value
print(f" {label}")
for row in SECRET.sub("sk-***", text).splitlines():
print(f" {row}")
def detail(label: str, value: object) -> None:
"""show_json, only with -v."""
if VERBOSE:
show_json(label, value)
def show_command(args: tuple[str, ...], result: subprocess.CompletedProcess) -> None:
"""-v: one docker command and the end of its output. Data dumps (cat of an export) are not printed."""
command = " ".join(arg if len(arg) <= 90 else arg[:87] + "..." for arg in args)
print(f" $ docker {command} → exit {result.returncode}")
if "cat /tmp" in " ".join(args):
return
rows = [row for row in (result.stdout + result.stderr).splitlines() if row.strip()]
for row in rows[-6:]:
print(f" {SECRET.sub('sk-***', row)[:160]}")
def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check)
def container_running() -> bool:
if not shutil.which("docker"):
return False
result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True)
return result.stdout.strip() == "true"
def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]:
data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
start = time.monotonic()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start
text = raw.decode("utf-8", errors="replace")
try:
payload: object = json.loads(text) if text.strip() else ""
except json.JSONDecodeError:
payload = text
return status, payload, time.monotonic() - start
def load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
die(f"file not found: {path}")
except json.JSONDecodeError as error:
die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}")
def resolve(path: str) -> Path:
candidate = Path(path)
if candidate.exists() or candidate.is_absolute():
return candidate
return ROOT / path
def node_versions() -> dict[str, list[float]]:
"""Node types and versions that this n8n really has (exported once, then cached)."""
cache = CACHE / "node-versions.json"
if cache.exists():
return json.loads(cache.read_text())
if not container_running():
return {}
n8n("export:nodes", "--output=/tmp/course-nodes.json")
raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout
versions: dict[str, set[float]] = {}
for node in json.loads(raw):
value = node.get("version")
versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value])
result = {name: sorted(values) for name, values in versions.items()}
CACHE.mkdir(exist_ok=True)
cache.write_text(json.dumps(result))
return result
def walk_strings(value: object, where: str = ""):
if isinstance(value, str):
yield where, value
elif isinstance(value, dict):
for key, item in value.items():
yield from walk_strings(item, f"{where}.{key}" if where else key)
elif isinstance(value, list):
for index, item in enumerate(value):
yield from walk_strings(item, f"{where}[{index}]")
def webhooks(workflow: dict) -> list[tuple[str, str]]:
found = []
for node in workflow.get("nodes", []):
if node.get("type") == "n8n-nodes-base.webhook":
parameters = node.get("parameters", {})
found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/")))
return found
# ---------------------------------------------------------------- check
def check_workflow(workflow: object) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
if not isinstance(workflow, dict):
return ["the file must be one workflow object { name, nodes, connections }"], warnings
if not str(workflow.get("name", "")).strip():
errors.append("missing workflow 'name'")
nodes = workflow.get("nodes")
connections = workflow.get("connections")
if not isinstance(nodes, list) or not nodes:
return errors + ["'nodes' must be a non-empty list"], warnings
if not isinstance(connections, dict):
errors.append("'connections' must be an object")
connections = {}
known = node_versions()
names: dict[str, dict] = {}
for index, node in enumerate(nodes):
label = f"node #{index + 1}"
if not isinstance(node, dict):
errors.append(f"{label} is not an object")
continue
name = node.get("name")
if not name:
errors.append(f"{label} has no 'name'")
continue
label = f"node '{name}'"
if name in names:
errors.append(f"{label} appears twice (node names must be unique)")
names[name] = node
node_type, version = node.get("type"), node.get("typeVersion")
if not node_type or version is None:
errors.append(f"{label} needs 'type' and 'typeVersion'")
elif known and node_type not in known:
errors.append(f"{label}: type '{node_type}' does not exist in this n8n")
elif known and version not in known[node_type]:
errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})")
elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]:
warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})")
if not isinstance(node.get("parameters"), dict):
errors.append(f"{label} needs 'parameters' (use {{}} when empty)")
position = node.get("position")
if not (isinstance(position, list) and len(position) == 2):
errors.append(f"{label} needs 'position': [x, y]")
for kind, credential in (node.get("credentials") or {}).items():
credential_id = (credential or {}).get("id")
if credential_id not in COURSE_CREDENTIALS:
errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}")
for where, text in walk_strings(node.get("parameters", {})):
if SECRET.search(text):
errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.")
if "{{" in text and "}}" in text and not text.startswith("="):
warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)")
targets_of: dict[str, set[str]] = {}
for source, outputs in connections.items():
if source not in names:
errors.append(f"connections: source '{source}' is not a node name")
continue
if not isinstance(outputs, dict):
errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}")
continue
for kind, branches in outputs.items():
if kind not in CONNECTION_TYPES:
errors.append(f"connections['{source}']: unknown connection type '{kind}'")
for branch in branches or []:
for link in branch or []:
target = (link or {}).get("node")
if target not in names:
errors.append(f"connections['{source}'] points to '{target}', which is not a node name")
elif link.get("type") != kind:
errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'")
else:
targets_of.setdefault(target, set()).add(kind)
if kind != "main":
targets_of.setdefault(f"{source}::from", set()).add(kind)
has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values())
for name, node in names.items():
node_type = node.get("type", "")
parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {}
if node_type == "n8n-nodes-base.webhook":
if not str(parameters.get("path", "")).strip("/"):
errors.append(f"node '{name}': webhook needs a 'path'")
if parameters.get("responseMode") == "responseNode" and not has_respond:
errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node")
if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()):
errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'")
if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"):
if "ai_tool" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'")
if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"):
if "ai_languageModel" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'")
is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links
if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \
and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \
and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower():
warnings.append(f"node '{name}': nothing is connected into it (it never runs)")
if not webhooks(workflow):
warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path")
return errors, warnings
def show_workflow(workflow: object) -> None:
"""-v: the nodes, links and webhooks of a workflow file."""
if not VERBOSE or not isinstance(workflow, dict):
return
print(f" workflow '{workflow.get('name')}' · {len(workflow.get('nodes') or [])} node(s)")
for node in workflow.get("nodes") or []:
if isinstance(node, dict):
extra = [key for key in ("onError", "retryOnFail") if key in node]
print(f" {node.get('name')} · {node.get('type')} v{node.get('typeVersion')}" + (f" · {', '.join(extra)}" if extra else ""))
print(" links")
for source, outputs in (workflow.get("connections") or {}).items():
for kind, branches in (outputs or {}).items():
for index, branch in enumerate(branches or []):
targets = ", ".join(str((link or {}).get("node")) for link in branch or [])
if targets:
print(f" {source} --{kind}[{index}]--> {targets}")
for method, hook in webhooks(workflow):
print(f" webhook {method} /webhook/{hook}")
def cmd_check(args: argparse.Namespace) -> int:
path = resolve(args.file)
workflow = load_json(path)
show_workflow(workflow)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {warning}")
for error in errors:
print(f"{BAD} {error}")
if errors:
print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.")
return 1
print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
# ---------------------------------------------------------------- push
def stable_id(path: Path) -> str:
return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15]
def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3)
if status == 200:
break
time.sleep(1)
else:
return False
for method, path in paths:
while time.monotonic() < deadline:
# A webhook that is registered for POST answers a GET with a hint, not "is not registered".
status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5)
text = json.dumps(payload, ensure_ascii=False)
if "not registered for" in text or status not in (0, 404, 500, 502, 503):
break
time.sleep(1)
time.sleep(2) # webhooks of the other published workflows finish registering right after
return time.monotonic() < deadline
def exported_workflows() -> list[dict]:
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; "
"cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout
try:
return json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
return []
def prepare(path: Path) -> dict | None:
workflow = load_json(path)
show_workflow(workflow)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {path.name}: {warning}")
if errors:
for error in errors:
print(f"{BAD} {path.name}: {error}")
return None
workflow = dict(workflow)
if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
workflow["id"] = stable_id(path)
workflow["active"] = False
workflow.setdefault("settings", {}).setdefault("executionOrder", "v1")
for node in workflow["nodes"]:
node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}")))
if node["type"] == "n8n-nodes-base.webhook":
node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}")))
return workflow
def cmd_push(args: argparse.Namespace) -> int:
paths = [resolve(file) for file in args.files]
workflows = [prepare(path) for path in paths]
if None in workflows:
print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check <file>).")
return 1
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
mine_ids = {workflow["id"] for workflow in workflows}
hooks = {hook for workflow in workflows for hook in webhooks(workflow)}
# Another published workflow with the same webhook path would block this one.
for other in exported_workflows():
if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)):
n8n("unpublish:workflow", f"--id={other['id']}")
print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path")
for workflow in workflows:
docker("exec", "-i", CONTAINER, "sh", "-c",
"cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; "
"status=$?; rm -f /tmp/course-workflow.json; exit $status",
stdin=json.dumps(workflow, ensure_ascii=False))
n8n("publish:workflow", f"--id={workflow['id']}")
# The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s).
docker("restart", CONTAINER)
ready = wait_until_ready(sorted(hooks))
for workflow in workflows:
print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})")
for method, hook in sorted(webhooks(workflow)):
print(f" {method} {N8N_URL}/webhook/{hook}")
if not ready:
print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.")
return 0
def cmd_off(args: argparse.Namespace) -> int:
"""Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down."""
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
exported = exported_workflows()
detail("published now", [f"{item.get('id')} · {item.get('name')} · " + ", ".join(f"/{hook}" for _, hook in webhooks(item))
for item in exported if item.get("active")])
wanted: dict[str, str] = {}
for item in args.files:
path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None
if path:
workflow = load_json(path)
workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path)
wanted[workflow_id] = item
hooks = {hook for _, hook in webhooks(workflow)}
else:
hooks = {item.strip("/")}
for other in exported:
if other.get("active") and hooks & {hook for _, hook in webhooks(other)}:
wanted[other["id"]] = item
active = {item.get("id") for item in exported if item.get("active")}
for workflow_id, item in wanted.items():
if workflow_id in active:
n8n("unpublish:workflow", f"--id={workflow_id}")
print(f"{OK} unpublished {item} (id {workflow_id})")
docker("restart", CONTAINER)
wait_until_ready([])
print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push <the workflow file>")
return 0
# ---------------------------------------------------------------- call / test
def request_body(spec: str | dict, base: Path = ROOT) -> object:
if isinstance(spec, dict):
return spec
text = spec.strip()
if text.startswith("{"):
try:
return json.loads(text)
except json.JSONDecodeError as error:
die(f"request is not valid JSON: {error.msg}")
path = Path(text)
if not path.exists():
path = base / text
return load_json(path)
def cmd_call(args: argparse.Namespace) -> int:
body = request_body(args.request) if args.request else {}
hook = args.path.strip('/')
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body)
print(f"HTTP {status} · {seconds:.1f}s")
if args.verbose:
show_json(f"→ request POST /webhook/{hook}", body)
show_json(f"← response HTTP {status}", payload)
return 0 if 200 <= status < 300 else 1
print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload)
return 0 if 200 <= status < 300 else 1
def field(payload: object, dotted: str) -> object:
value = payload
for part in dotted.split("."):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
return None
return value
def as_text(value: object) -> str:
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def judge(expect: dict, status: int, payload: object, seconds: float = 0.0) -> list[str]:
problems = []
if "max_seconds" in expect and seconds > float(expect["max_seconds"]):
problems.append(f"took {seconds:.1f}s (expected at most {expect['max_seconds']}s)")
if status != expect.get("status", 200):
problems.append(f"status {status} (expected {expect.get('status', 200)})")
for name, wanted in (expect.get("equals") or {}).items():
actual = field(payload, name)
if actual != wanted:
problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})")
for name, words in (expect.get("contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
if not any(word in text for word in words):
problems.append(f"{name} has none of {words}")
for name, words in (expect.get("not_contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
leaked = [word for word in words if word in text]
if leaked:
problems.append(f"{name} must not have {leaked}")
return problems
def cmd_test(args: argparse.Namespace) -> int:
path = resolve(args.cases)
suite = load_json(path)
hook = (args.webhook or suite.get("webhook", "")).strip("/")
cases = suite.get("cases", [])
if not hook or not cases:
die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list")
# "before"/"after": calls made once around the cases, e.g. turn a fault on and off again.
if not args.no_before:
for step in suite.get("before", []):
run_step("before", step)
try:
return run_cases(args, path, suite, hook, cases)
finally:
for step in suite.get("after", []):
run_step("after", step)
def run_step(label: str, step: dict) -> None:
"""A step is {"webhook": path} (POST to n8n), {"url": ...} (POST to the data API, e.g. the fault switch)
or {"llm": "down"|"up"} (the outage drill)."""
if step.get("llm") in ("down", "up"):
code = cmd_llm(argparse.Namespace(state=step["llm"]))
if code and label == "before":
die("could not change the OpenAI credential (llm drill)")
if step["llm"] == "down":
time.sleep(1)
return
if step.get("url"):
target = str(step["url"])
else:
target = f"{N8N_URL}/webhook/{str(step.get('webhook', '')).strip('/')}"
body = request_body(step.get("body") or step.get("request", "{}"))
status, payload, seconds = http("POST", target, body)
shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120]
print(f"{OK if 200 <= status < 300 else BAD} {label}: POST {target.replace(N8N_URL, '')} · HTTP {status} · {seconds:.1f}s · {shown}")
detail(f"→ request POST {target.replace(N8N_URL, '')}", body)
detail(f"← response HTTP {status}", payload)
if not 200 <= status < 300 and label == "before":
die(f"the 'before' step failed ({target}). Is it pushed / is the data API the day 72 version?")
def run_cases(args: argparse.Namespace, path: Path, suite: dict, hook: str, cases: list) -> int:
print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}")
results, passed = [], 0
for case in cases:
for step in case.get("before", []):
run_step("before", step)
try:
passed += run_case(args, case, hook, results)
finally:
for step in case.get("after", []):
run_step("after", step)
return finish(path, hook, results, passed)
def run_case(args: argparse.Namespace, case: dict, hook: str, results: list) -> int:
passed = 0
body = request_body(case.get("body") or case.get("request", "{}"))
case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook
for run in range(1, args.repeat + 1):
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{case_hook}", body)
problems = judge(case.get("expect", {}), status, payload, seconds)
passed += not problems
mark = OK if not problems else BAD
suffix = f" #{run}" if args.repeat > 1 else ""
print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s" + ("" if not problems else " · " + "; ".join(problems)))
if args.verbose:
show_json(f"→ request POST /webhook/{case_hook}", body)
show_json(f"← response HTTP {status}", payload)
results.append({"case": case.get("name"), "webhook": case_hook, "run": run, "passed": not problems, "problems": problems,
"seconds": round(seconds, 1), "status": status, "request": body, "response": payload})
return passed
def finish(path: Path, hook: str, results: list, passed: int) -> int:
total = len(results)
print(f"Passed {passed}/{total}")
runs = ROOT / "runs"
runs.mkdir(exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
report = runs / f"{path.stem}-{stamp}.json"
report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total,
"results": results}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report: {report.relative_to(ROOT)}")
return 0 if passed == total else 1
# ---------------------------------------------------------------- bench / fault / llm
def percentile(values: list[float], share: float) -> float:
ordered = sorted(values)
if not ordered:
return 0.0
# nearest rank, as in the lecture: position = ceil(share × n), counted from 1 (10 runs: p50 = 5th, p95 = 10th)
index = min(len(ordered) - 1, max(0, math.ceil(share * len(ordered) - 1e-9) - 1))
return ordered[index]
def cmd_bench(args: argparse.Namespace) -> int:
body = request_body(args.request) if args.request else {}
hook = args.path.strip("/")
print(f"POST {N8N_URL}/webhook/{hook} × {args.n}")
detail(f"→ request POST /webhook/{hook} (the same body every time)", body)
seconds_list, failures, rows = [], 0, []
for run in range(1, args.n + 1):
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{hook}", body)
good = 200 <= status < 300
failures += not good
seconds_list.append(seconds)
extra = ""
if isinstance(payload, dict):
extra = " · " + ", ".join(f"{key}={as_text(payload[key])[:40]}" for key in ("route", "llm_calls", "cached") if key in payload)
print(f"{OK if good else BAD} #{run} · HTTP {status} · {seconds:.2f}s{extra}")
detail(f"← response HTTP {status}", payload)
rows.append({"run": run, "status": status, "seconds": round(seconds, 3), "response": payload})
p50, p95 = percentile(seconds_list, 0.50), percentile(seconds_list, 0.95)
print(f"p50 {p50:.2f}s · p95 {p95:.2f}s · max {max(seconds_list):.2f}s · failures {failures}/{args.n}")
if VERBOSE:
print(" sorted " + " ".join(f"{value:.2f}" for value in sorted(seconds_list)))
steps: dict[str, list[float]] = {}
for row in rows:
timings = row["response"].get("timings") if isinstance(row["response"], dict) else None
for key, value in (timings or {}).items():
if isinstance(value, (int, float)):
steps.setdefault(key, []).append(value)
if steps:
print(" timings (average ms, runs) " + " · ".join(f"{key} {sum(values) / len(values):.0f} ({len(values)})"
for key, values in steps.items()))
runs = ROOT / "runs"
runs.mkdir(exist_ok=True)
report = runs / f"bench-{hook}-{dt.datetime.now().strftime('%Y%m%d-%H%M%S')}.json"
report.write_text(json.dumps({"webhook": hook, "request": body, "n": args.n, "p50": round(p50, 3), "p95": round(p95, 3),
"max": round(max(seconds_list), 3), "failures": failures, "runs": rows},
ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report: {report.relative_to(ROOT)}")
return 0 if not failures else 1
def cmd_fault(args: argparse.Namespace) -> int:
targets = ["faq", "tickets"] if args.target == "all" else [args.target]
body: dict = {target: args.mode for target in targets}
if args.seconds is not None:
body["slow_seconds"] = args.seconds
status, payload, seconds = http("POST", "http://localhost:8800/admin/fault", body, timeout=10)
print(f"{OK if status == 200 else BAD} fault switch: {as_text(payload)}")
detail("→ request POST http://localhost:8800/admin/fault", body)
detail(f"← response HTTP {status} · {seconds:.2f}s", payload)
return 0 if status == 200 else 1
def cmd_llm(args: argparse.Namespace) -> int:
"""Outage drill: register the course OpenAI credential with a broken key (down) or the real key from .env (up)."""
script = ROOT.parent / "65" / "n8n" / "register-credentials.sh"
if not script.exists():
die(f"{script} not found")
env = dict(os.environ, N8N_CONTAINER=CONTAINER)
if args.state == "down":
env["OPENAI_API_KEY"] = "sk-invalid-key-for-drill-000000"
else:
env.pop("OPENAI_API_KEY", None)
result = subprocess.run(["bash", str(script)], env=env, capture_output=True, text=True)
detail(f"$ bash {script.relative_to(ROOT.parent)} → exit {result.returncode}", (result.stdout + result.stderr).strip())
if result.returncode != 0:
print(result.stderr.strip() or result.stdout.strip())
return 1
print(f"{OK} OpenAI credential: " + ("broken key (drill). Restore with: uv run tools/flow.py llm up" if args.state == "down"
else "real key from .env restored"))
return 0
# ---------------------------------------------------------------- doctor
def cmd_doctor(args: argparse.Namespace) -> int:
failures = 0
def line(good: bool, text: str, hint: str = "", seen: object = None) -> None:
nonlocal failures
failures += not good
print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}"))
if seen is not None:
detail("seen", seen)
installed = (N8N_HOME / "compose.yml").exists()
print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else
"\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>"))
running = container_running()
line(running, f"n8n container {CONTAINER}", START_HINT)
status, payload, _ = http("GET", f"{N8N_URL}/healthz", timeout=5)
line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again",
{"GET": "/healthz", "status": status, "response": payload})
status, payload, _ = http("GET", "http://localhost:8800/health", timeout=5)
if USES_DATA_API:
line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/72/data-api && docker compose up -d --force-recreate (start n8n first)",
{"GET": "/health", "status": status, "response": payload})
fault_status, fault, _ = http("GET", "http://localhost:8800/admin/fault", timeout=5)
line(fault_status == 200, "fault switch /admin/fault (day 72 data API)",
"the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/72/data-api && docker compose up -d --force-recreate",
{"GET": "/admin/fault", "status": fault_status, "response": fault})
stats_status, stats, _ = http("GET", "http://localhost:8800/cache-stats", timeout=5)
detail("cache GET /cache-stats", {"status": stats_status, "response": stats})
if fault_status == 200 and isinstance(fault, dict) and (fault.get("faq") != "ok" or fault.get("tickets") != "ok"):
print(f"{WARN} fault switch is on: {fault}\n → uv run tools/flow.py fault all ok")
else:
print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)")
if running:
probe = docker("exec", CONTAINER, "node", "-e",
"fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))",
check=False).stdout.strip()
if USES_DATA_API:
line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)",
{"from n8n": "GET http://data-api:8800/health", "status": probe})
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; "
"cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout
try:
found = json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
found = []
ids = {item.get("id") for item in found}
detail("credentials in n8n (id · name · type, no secrets)",
[f"{item.get('id')} · {item.get('name')} · {item.get('type')}" for item in found])
for credential_id, name in COURSE_CREDENTIALS.items():
line(credential_id in ids, f"credential '{name}' ({credential_id})",
"cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh")
catalog = node_versions()
line(bool(catalog), "node catalog (for check)", seen={"node types": len(catalog), "cache": str(CACHE.relative_to(ROOT))})
for hook in AGENT_HOOKS:
_, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5)
line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}",
"uv run tools/flow.py push <agent workflow file>", {"GET": f"/webhook/{hook}", "response": payload})
agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)]
line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"),
"install one of codex / claude / agy (see Lecture/72/00-DAY72-PREP.md)", {name: shutil.which(name) for name in agents})
print("ready" if not failures else f"{failures} item(s) to fix")
return 0 if not failures else 1
def main() -> int:
global VERBOSE
common = argparse.ArgumentParser(add_help=False)
common.add_argument("-v", "--verbose", action="store_true",
help="details: request and response JSON, workflow nodes and links, docker commands")
parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("doctor", parents=[common], help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor)
item = commands.add_parser("check", parents=[common], help="find mistakes in a workflow file")
item.add_argument("file")
item.set_defaults(run=cmd_check)
item = commands.add_parser("push", parents=[common], help="import and publish workflow files (one n8n restart)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_push)
item = commands.add_parser("off", parents=[common], help="unpublish workflow files or webhook paths (a role that is down)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_off)
item = commands.add_parser("call", parents=[common], help="POST one request to a webhook path")
item.add_argument("path", help="webhook path, e.g. my-notice")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.set_defaults(run=cmd_call)
item = commands.add_parser("test", parents=[common], help="run a test case file")
item.add_argument("cases")
item.add_argument("--repeat", type=int, default=1, help="run every case N times")
item.add_argument("--webhook", help="override the webhook path in the case file")
item.add_argument("--no-before", action="store_true", help="skip the 'before' steps")
item.set_defaults(run=cmd_test)
item = commands.add_parser("bench", parents=[common], help="POST the same request N times: p50, p95, max, failures")
item.add_argument("path", help="webhook path, e.g. my-helpdesk")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.add_argument("-n", type=int, default=10, help="how many calls (default 10)")
item.set_defaults(run=cmd_bench)
item = commands.add_parser("fault", parents=[common], help="data API fault switch for drills")
item.add_argument("target", choices=["faq", "tickets", "all"])
item.add_argument("mode", choices=["ok", "slow", "error"])
item.add_argument("--seconds", type=float, help="how long 'slow' waits (default 8)")
item.set_defaults(run=cmd_fault)
item = commands.add_parser("llm", parents=[common], help="outage drill: break (down) or restore (up) the OpenAI credential in n8n")
item.add_argument("state", choices=["down", "up"])
item.set_defaults(run=cmd_llm)
args = parser.parse_args()
VERBOSE = args.verbose
return args.run(args)
if __name__ == "__main__":
sys.exit(main())