72일차 자료 추가(답 제외)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aaf77d4e18
commit
febde9757c
63 files changed
+12260
No files matched your search
@@ -0,0 +1,3 @@
|
||||
runs/
|
||||
tools/.cache/
|
||||
my-work/*.json
|
||||
@@ -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 키를 읽어서 출력하지 않는다.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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`에 요청문을 모았다.
|
||||
@@ -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절에 바꾼 것마다 한 줄 (잃은 것도).
|
||||
@@ -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`.
|
||||
@@ -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일차 준비
|
||||
@@ -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`는 실패해도 되돌립니다.
|
||||
@@ -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에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
|
||||
@@ -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을 쓴 뒤에** 비교하는 데 씁니다.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
"처리 중"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
"처리 중"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
"대기열",
|
||||
"전원"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
"찾지 못",
|
||||
"확인되지",
|
||||
"없습니다",
|
||||
"존재하지"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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를 다시 등록한다.
|
||||
@@ -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가 만든 결과는 사람이 확인한 뒤에 사용한다.
|
||||
@@ -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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
|
||||
]
|
||||
@@ -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": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요."}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
{"question": "", "user": "이준호"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "비밀번호를 잊어버렸어요. 재설정은 어떻게 하나요?", "user": "한지훈"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "프린터 출력이 안 돼요", "user": "최유진"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": " vpn 접속이 자꾸 끊겨요. 어떻게 하나요? ", "user": "최유진"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호", "no_cache": true}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?", "user": "이준호"}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "회의실 화면 공유가 안 되는데 누구에게 물어보나요?", "user": "이준호"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "오늘 점심 메뉴 추천해 주세요", "user": "이준호"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "T-9999 접수 건은 어떻게 됐나요?", "user": "박서연"}
|
||||
@@ -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을 없애서 잃은 것은? 캐시를 써서 생길 수 있는 문제는?
|
||||
-
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user