74일차 자료 추가(답 제외), 73일차 정답 공개(LAB-A · VIBE-A · answers · tscan), 64 · 65 · 68~73일차 교안 · 실습 문서 수정 반영
This commit is contained in:
1 parent
4ef0464525
commit
bb719d81fa
191 files changed
+36875
-64
No files matched your search
@@ -0,0 +1,3 @@
|
||||
runs/
|
||||
tools/.cache/
|
||||
my-work/*.json
|
||||
@@ -0,0 +1,200 @@
|
||||
# AGENTS.md · n8n 모델·Prompt·Workflow 버전 관리 작업 규칙 (74일차 실습 폴더)
|
||||
|
||||
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 73일차 규칙과 같고, 3절이 오늘 만드는 **버전이 붙은 헬프데스크 · 릴리스 점검의 계약**, 5절에 **릴리스 정보 · 지시문 저장소 · 기본 지시문 · 응답 코드로 가르기 · 카나리 · 점검 기록** 노드가 더해졌습니다.
|
||||
|
||||
## 1. 작업 순서 (항상 이 순서)
|
||||
|
||||
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 버전 요청이면 **무엇이 워크플로우 안에 남고, 무엇이 밖(데이터 API)으로 나가는지**도 함께 보여 준다.
|
||||
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>`, **n8n 안에서도** `http://localhost:5678/webhook/<path>` (같은 컨테이너) |
|
||||
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`. 모델 이름: `gpt-4.1-mini`, `gpt-5.4-nano` (같은 Credential) |
|
||||
| 다른 Credential | 쓰지 않음 (Ollama 등 없음) |
|
||||
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (**74일차 판** `Last3Mon/74/data-api`: 73일차 + 지시문 저장소 + 릴리스 + 점검 기록 · 배포 정책) |
|
||||
| 비교용 파일 | `reference/helpdesk-v1.json` (`rel-v1`), `reference/helpdesk-v2.json` (`rel-v2`), 고치지 않는다 |
|
||||
|
||||
### 데이터 API (74일차 판)
|
||||
|
||||
| 요청 | 응답 |
|
||||
| --- | --- |
|
||||
| `GET /prompts/helpdesk` | `{"name", "versions": ["p1", "p2", "p3"]}` |
|
||||
| `GET /prompts/helpdesk/<버전>` | `{"name", "version", "text"}` (지시문 글자) · 없으면 **404** `{"error": "prompt not found"}` · 고장 스위치 `prompts`가 `error`면 **500**, `slow`면 8초 늦게 |
|
||||
| `GET /releases/helpdesk` | `{"name", "active": "1.0.0", "canary": null \| {"release", "percent"}, "require_gate": false, "releases": {"1.0.0": {"prompt_version", "model", "note"}, ...}}` |
|
||||
| `POST /releases/helpdesk` | `{"active": "1.1.0"}` · `{"canary": {"release": "1.1.0", "percent": 50}}` · `{"canary": null}` (**사람이** `flow.py release`로). 배포 정책이 켜져 있으면 마지막 점검 기록이 `promote`인 릴리스만, 아니면 **409** `{"error": "not gated", "release", "last_gate"}` |
|
||||
| `POST /releases/helpdesk/policy` | `{"require_gate": true \| false}` (**사람이** `flow.py release helpdesk --require-gate on`) |
|
||||
| `POST /releases/helpdesk/reset` | 처음 상태로 (active 1.0.0, 카나리 없음, 정책 꺼짐. 점검 기록은 남음) |
|
||||
| `POST /gates/helpdesk` | 점검 기록 한 줄 `{"release", "verdict": "promote" \| "rollback", "passed", "total", "source", ...}` → `{"stored": true, "count"}` (Lab 03 과제 2, `flow.py gate`도 남김) |
|
||||
| `GET /gates/helpdesk` · `POST /gates/helpdesk/clear` | `{"name", "gates": [{"t", ...}]}` · 비우기 `{"cleared"}` |
|
||||
| `POST /admin/fault` | `{"prompts": "ok" \| "slow" \| "error"}` (`faq` · `tickets`도, 72일차와 같음) · `flow.py fault prompts error` |
|
||||
| `GET /faq?q=` · `GET /tickets/<id>?requester=` · `/audit` | 72 · 73일차와 같음 |
|
||||
|
||||
| 릴리스 | 지시문 | 모델 | 내용 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1.0.0 | p1 | gpt-4.1-mini | 첫 릴리스 |
|
||||
| 1.1.0 | p2 | gpt-4.1-mini | FAQ 답 끝에 출처 번호 `[FAQ-003]` |
|
||||
| 1.2.0 | p3 | gpt-4.1-mini | 답을 한 문장으로 짧게 |
|
||||
| 2.0.0 | p2 | gpt-5.4-nano | 더 작은 모델 (모델만 바뀜) |
|
||||
| 2.1.0 | **p9** | gpt-4.1-mini | 저장소에 없는 지시문을 가리킴 (Lab 02 과제 2의 망가진 릴리스) |
|
||||
|
||||
## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)
|
||||
|
||||
| 무엇 | 파일 · 웹훅 | 입력 (본문) | 결과 |
|
||||
| --- | --- | --- | --- |
|
||||
| 헬프데스크 | `my-work/helpdesk.json` · `POST /webhook/my-helpdesk` (처음은 `reference/helpdesk-v1.json`을 복사) | `question`(필수), `user`, `release`(있으면 그 릴리스로 고정 — 후보 시험용) | `{"route", "answer", "sources", "version": {"release", "prompt_version", "model", "channel", "prompt_source"}}` |
|
||||
| 릴리스 점검 | `my-work/release-check.json` · `POST /webhook/my-release-check` | `release`(필수) | `{"release", "passed", "total", "failed", "seconds", "verdict": "promote" \| "rollback"}` |
|
||||
|
||||
- `version.channel`: `stable`(active) · `canary`(카나리 대상) · `pinned`(본문 `release`로 고정). `version.prompt_source`: 지시문을 저장소에서 가져왔으면 `registry`, 저장소가 멈춰 기본 지시문으로 답했으면 `fallback`.
|
||||
- 없는 릴리스를 고정하면 HTTP 400 `{"error": "unknown release", "release", "known": [...]}`.
|
||||
- **저장소가 멈추면(500 · 2초 넘음) 기본 지시문으로 답한다** (Lab 01 과제 2). 기본 지시문은 p1과 같은 글을 워크플로우 안에 둔다.
|
||||
- **저장소가 404면(릴리스가 없는 지시문을 가리킴) 기본 지시문으로 넘어가지 않고** HTTP 400 `{"error": "release broken", "release", "prompt_version"}` (Lab 02 과제 2).
|
||||
- 릴리스 점검(Lab 03 과제 2): 판단 뒤 `POST http://data-api:8800/gates/helpdesk`에 기록하고 결과에 `recorded: true|false`. **워크플로우가 배포(`/releases`에 POST)하지 않는다.**
|
||||
- **카나리 대상 규칙**: `user` 이름 글자 코드의 합 % 100 이 `percent`보다 작으면 카나리. 같은 사람은 늘 같은 쪽. (`flow.py release helpdesk`가 대상 직원을 보여 줌)
|
||||
- 고정(`release`) > 카나리 > active 순서로 정한다.
|
||||
|
||||
## 4. 워크플로우 파일 규칙
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "74 IT helpdesk (my)",
|
||||
"nodes": [ ... ],
|
||||
"connections": { ... },
|
||||
"settings": { "executionOrder": "v1" }
|
||||
}
|
||||
```
|
||||
|
||||
- 파일 하나에 워크플로우 하나. 노드 `name`은 영어로, 겹치지 않게. 노드마다 `type`, `typeVersion`, `position`, `parameters`.
|
||||
- **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다.
|
||||
- 웹훅은 `POST`, `responseMode: "responseNode"`, 결과는 Respond to Webhook 노드로.
|
||||
- 요청 본문은 `$json.body.question`처럼 `body` 아래. 다른 노드 뒤에서는 `$('Question webhook').first().json.body`.
|
||||
|
||||
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
|
||||
|
||||
### 릴리스 정보 (Set) · 모델 이름을 표현식으로
|
||||
```json
|
||||
{"name": "Release info", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [440, 300],
|
||||
"parameters": {"assignments": {"assignments": [
|
||||
{"id": "r1", "name": "release", "value": "1.0.0", "type": "string"},
|
||||
{"id": "r2", "name": "prompt_version", "value": "p1", "type": "string"},
|
||||
{"id": "r3", "name": "model", "value": "gpt-4.1-mini", "type": "string"}]}, "options": {}}}
|
||||
```
|
||||
```json
|
||||
{"name": "Answer model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [1100, 520],
|
||||
"parameters": {"model": {"__rl": true, "mode": "id", "value": "={{ $('Release info').first().json.model }}"}, "options": {"temperature": 0}},
|
||||
"credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
|
||||
```
|
||||
|
||||
### 지시문 저장소 (HTTP) → Agent의 systemMessage
|
||||
```json
|
||||
{"name": "Get prompt", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [660, 300],
|
||||
"parameters": {"url": "=http://data-api:8800/prompts/helpdesk/{{ $('Release info').first().json.prompt_version }}", "options": {}}}
|
||||
```
|
||||
```json
|
||||
{"name": "Answer", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [1100, 300],
|
||||
"parameters": {"promptType": "define", "text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: ...\n접수 건: ...",
|
||||
"options": {"systemMessage": "={{ $('Get prompt').first().json.text }}", "maxIterations": 1}}}
|
||||
```
|
||||
|
||||
### 지시문 저장소가 멈추면 (Lab 01 과제 2)
|
||||
- `Get prompt`에 `"options": {"timeout": 2000}`, 노드에 `"onError": "continueErrorOutput"`. `main`은 `[ [성공 쪽], [실패 쪽] ]`.
|
||||
- 성공 쪽 `Registry prompt`(Set, `text` = `={{ $json.text }}`), 실패 쪽 `Fallback prompt`(Set, `text` = p1과 같은 글). 둘 다 `Search FAQ`로.
|
||||
- 읽을 때: `"systemMessage": "={{ $('Registry prompt').isExecuted ? $('Registry prompt').first().json.text : $('Fallback prompt').first().json.text }}"`.
|
||||
|
||||
### 응답 코드로 가르기 (Lab 02 과제 2)
|
||||
```json
|
||||
{"name": "Get prompt", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1320, 300], "onError": "continueErrorOutput",
|
||||
"parameters": {"url": "=http://data-api:8800/prompts/helpdesk/{{ $('Pick release').first().json.prompt_version }}",
|
||||
"options": {"timeout": 2000, "response": {"response": {"neverError": true, "fullResponse": true}}}}}
|
||||
```
|
||||
- `fullResponse`면 다음 노드가 `$json.statusCode` · `$json.body`를 받는다. `neverError`면 404 · 500도 성공 쪽으로 온다 (시간 초과만 실패 쪽).
|
||||
- 다음 Code에서 `statusCode`가 200 · 404 · 그 밖으로 가르고, If(문자열 `equals`)로 400 갈래.
|
||||
|
||||
### 릴리스 고르기 (HTTP → Code → If)
|
||||
```json
|
||||
{"name": "Get release", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [440, 300],
|
||||
"parameters": {"url": "http://data-api:8800/releases/helpdesk", "options": {}}}
|
||||
```
|
||||
```json
|
||||
{"name": "Pick release", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [660, 300],
|
||||
"parameters": {"jsCode": "const body = $('Question webhook').first().json.body;\nconst r = $json;\nlet release = r.active, channel = 'stable';\nif (body.release) { release = String(body.release); channel = 'pinned'; }\nconst info = r.releases?.[release];\n...\nreturn [{ json: { ok: Boolean(info), release, channel, prompt_version: info?.prompt_version, model: info?.model } }];"}}
|
||||
```
|
||||
- 이름 글자 코드의 합: `[...name].reduce((sum, ch) => sum + ch.codePointAt(0), 0) % 100`.
|
||||
|
||||
### HTTP로 내 웹훅 부르기 (릴리스 점검) — 항목마다 한 번
|
||||
```json
|
||||
{"name": "Ask helpdesk", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [660, 300],
|
||||
"parameters": {"method": "POST", "url": "http://localhost:5678/webhook/my-helpdesk", "sendBody": true, "specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ question: $json.question, user: $json.user, release: $json.release }) }}",
|
||||
"options": {"response": {"response": {"neverError": true}}, "timeout": 30000}}}
|
||||
```
|
||||
- 앞 Code가 항목 6개를 돌려주면 HTTP 노드가 항목마다 한 번씩 부른다. 다음 Code에서 `$input.all()`과 `$('Golden questions').all()`을 같은 순서로 맞춰 읽는다.
|
||||
|
||||
### 점검 기록 (Lab 03 과제 2)
|
||||
```json
|
||||
{"name": "Record gate", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [880, 300], "onError": "continueRegularOutput",
|
||||
"parameters": {"method": "POST", "url": "http://data-api:8800/gates/helpdesk", "sendBody": true, "specifyBody": "json",
|
||||
"jsonBody": "={{ JSON.stringify({ release: $json.release, verdict: $json.verdict, passed: $json.passed, total: $json.total, failed: $json.failed, source: 'my-release-check' }) }}",
|
||||
"options": {"timeout": 3000, "response": {"response": {"neverError": true}}}}}
|
||||
```
|
||||
- 기록이 실패해도 판단은 보낸다 (`continueRegularOutput`). 다음 Respond에서 `$('Judge').first().json`에 `recorded: $json.stored === true`를 더한다.
|
||||
|
||||
### 그 밖 (72 · 73일차와 같음)
|
||||
- Webhook · If · Respond to Webhook · Search FAQ(HTTP `/faq`) · Get ticket(HTTP `/tickets`, `neverError`) · Code.
|
||||
- 오류 응답: `"responseBody": "={{ JSON.stringify({ error: 'unknown release', release: $json.release, known: $json.known }) }}"`, `"options": {"responseCode": 400}`.
|
||||
|
||||
## 6. 연결 (`connections`)
|
||||
|
||||
```json
|
||||
{
|
||||
"Known release?": {"main": [[{"node": "Get prompt", "type": "main", "index": 0}], [{"node": "Reject release", "type": "main", "index": 0}]]},
|
||||
"Answer model": {"ai_languageModel": [[{"node": "Answer", "type": "ai_languageModel", "index": 0}]]}
|
||||
}
|
||||
```
|
||||
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If의 `main`은 `[ [true 쪽], [false 쪽] ]`.
|
||||
|
||||
## 7. 표현식 주의
|
||||
|
||||
- 표현식은 `=`로 시작한다. `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다.
|
||||
- 모델 이름 · 지시문을 표현식으로 읽을 때 앞 노드 이름이 정확해야 한다 (`$('Release info')`, `$('Pick release')`).
|
||||
|
||||
## 8. 테스트 파일 · 게이트
|
||||
|
||||
```json
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [{"url": "http://localhost:8800/releases/helpdesk/reset", "body": {}}],
|
||||
"cases": [
|
||||
{"name": "후보 1.1.0", "body": {"question": "VPN ...", "user": "이준호", "release": "1.1.0"},
|
||||
"expect": {"equals": {"version.release": "1.1.0", "version.channel": "pinned"}, "contains": {"answer": ["[FAQ-003]"]}}},
|
||||
{"name": "배포 뒤", "before": [{"url": "http://localhost:8800/releases/helpdesk", "body": {"active": "1.1.0"}}],
|
||||
"request": "requests/faq-vpn.json", "expect": {"equals": {"version.channel": "stable"}}}
|
||||
]
|
||||
}
|
||||
```
|
||||
- `uv run tools/flow.py test <파일> -v --set release=1.1.0`: 모든 요청 본문에 `release`를 더한다 (후보 시험). `-v`는 케이스마다 요청 · 응답 JSON을 보여 준다. **모든 명령이 `-v`를 받는다** (73일차와 같음).
|
||||
- `uv run tools/flow.py gate <파일> --set release=1.1.0 --min-pass 1.0 --max-p95 3`: 통과율과 p95로 PASS/FAIL (종료 코드 0/1). 결과를 `/gates/helpdesk`에 남긴다 (`--no-record`면 남기지 않음).
|
||||
- `before` · `after` 단계에 `"status": 409`를 적으면 그 응답 코드를 기대한다 (예: 정책이 배포를 거절하는지).
|
||||
- `--no-before`: 파일 맨 위 `before`를 건너뛴다 (Lab 03 예제 3, 카나리를 끈 뒤 다시 잴 때).
|
||||
- `uv run tools/flow.py diff a.json b.json [--full]`: 두 워크플로우의 노드 · 설정 · 연결 차이.
|
||||
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
|
||||
|
||||
## 9. 하지 말 것
|
||||
|
||||
- `tools/`, `data-api/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만). 지시문 파일(`data-api/data/prompts/`)과 `releases.json`은 사람이 정한다.
|
||||
- 워크플로우 안에서 `/releases/...`에 POST 하지 않는다 (배포 · 되돌리기는 사람이 `flow.py release`로).
|
||||
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
|
||||
- `.env`나 API 키를 읽어서 출력하지 않는다.
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -0,0 +1,428 @@
|
||||
# Lab 01 · 무엇이 바뀌었나, 답마다 버전, 지시문은 저장소로
|
||||
|
||||
- 관련 강의: [1단계 · 무엇이 바뀌나](../../Lecture/74/01-STAGE1-WHAT-CHANGES.md)
|
||||
- 소요 시간: 50분 (2교시 한 시간) · 예제 1 10분 · 예제 2 12분 · 예제 3 8분 · 과제 1 10분 · 과제 2 10분
|
||||
- 준비: [실습 준비](../../Lecture/74/00-DAY74-PREP.md)의 `doctor` → `ready`, `rel-v1` · `rel-v2` 게시
|
||||
- 목표: AI 워크플로우에서 **바뀌는 것 세 가지(모델 · 지시문 · 흐름)** 를 구분해 읽고, 차이가 **진짜 바뀐 것인지 흔들림인지** 재서 가르고, 답마다 어느 버전이 답했는지 남기고, 지시문을 워크플로우 밖 저장소로 꺼낸다. 저장소가 멈춰도 답은 나가게 한다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E1["예제 1<br/>diff로 v1 → v2 읽기"] --> E2["예제 2<br/>답에 version"] --> E3["예제 3<br/>흔들림인가<br/>바뀜인가"] --> T1["과제 1<br/>지시문을 저장소에서"] --> T2["과제 2<br/>저장소가 멈추면<br/>기본 지시문"]
|
||||
```
|
||||
|
||||
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
|
||||
|
||||
## 시작하기 · 터미널 두 개
|
||||
|
||||
| 터미널 | 하는 일 |
|
||||
| --- | --- |
|
||||
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/74` 후 `codex` (또는 `claude`, `agy`). 내가 쓴 요청문을 보낸다 |
|
||||
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/74` 에서 `diff` · `push` · `test` · `bench`를 **내가** 실행한다 |
|
||||
|
||||
`cp templates/CHANGELOG-TEMPLATE.md my-work/CHANGELOG.md` 후 표를 채워 갑니다. 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다.
|
||||
|
||||
## 예제 1 · v1 → v2에서 무엇이 바뀌었나 (10분, 에이전트 없이)
|
||||
|
||||
누군가 헬프데스크를 고쳐 v2를 만들었습니다. 무엇을 고쳤는지 적어 두지 않았습니다.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py diff reference/helpdesk-v1.json reference/helpdesk-v2.json
|
||||
uv run tools/flow.py diff reference/helpdesk-v1.json reference/helpdesk-v2.json --full # 긴 값(지시문) 전 · 후
|
||||
uv run tools/flow.py call rel-v1 requests/faq-vpn.json -v
|
||||
uv run tools/flow.py call rel-v2 requests/faq-vpn.json -v
|
||||
```
|
||||
|
||||
`CHANGELOG.md` 1절을 채웁니다. 답이 달라진 이유는 지시문 때문일까요, 모델 때문일까요, 새 노드 때문일까요?
|
||||
|
||||
### 이해하기
|
||||
|
||||
**왜 "무엇이 바뀌었나"부터 읽나.** 어제 잘 되던 헬프데스크가 오늘 이상한 답을 한다는 문의가 왔습니다. 가장 먼저 묻는 질문은 "그 사이 무엇이 바뀌었나"입니다. 일반 프로그램이면 코드의 차이(`git diff`)를 봅니다. n8n 워크플로우는 JSON 한 덩어리라서, 그대로 글자 비교를 하면 노드 위치 · id 같은 의미 없는 차이가 섞여 정작 중요한 한 줄이 묻힙니다. `flow.py diff`는 **노드 이름끼리 맞춰** 노드 · 설정 · 연결의 차이만 보여 줍니다.
|
||||
|
||||
**AI 워크플로우에서 바뀌는 것 셋.**
|
||||
|
||||
| 바뀌는 것 | v2에서 | 같은 질문에 답이 바뀌나 |
|
||||
| --- | --- | --- |
|
||||
| 모델 | `Answer model`의 모델 이름 | 예. 같은 지시문이라도 모델이 다르면 말투 · 길이 · 정확도가 다름 |
|
||||
| 지시문 (Prompt) | `Answer`의 `systemMessage` 길이가 바뀜 | 예. 한 문장만 더해도 답의 모양이 바뀜 |
|
||||
| 흐름 (Workflow) | 새 노드 하나 · 연결 셋 · 웹훅 경로 | 경우에 따라. 답을 자르거나 거르는 노드면 예 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph v1
|
||||
A1{{"Answer<br/>지시문 v1"}} --> B1["Build result"]
|
||||
M1["mini"] -.-> A1
|
||||
end
|
||||
subgraph v2
|
||||
A2{{"Answer<br/>지시문 v2"}} --> R2["Review check<br/>(새 노드)"] --> B2["Build result"]
|
||||
M2["nano"] -.-> A2
|
||||
end
|
||||
```
|
||||
|
||||
**한꺼번에 바뀌면 원인을 모릅니다.** v2는 셋이 **동시에** 바뀌었습니다. v2의 답 끝에 무엇이 붙었다면, 그것이 지시문의 새 문장 때문인지 모델 때문인지 새 노드 때문인지 이 한 번의 비교로는 가를 수 없습니다. 72일차 성능 튜닝의 "한 번에 하나만 바꾸고 다시 잰다"가 버전 관리에서도 그대로입니다.
|
||||
|
||||
**`--full`로 긴 값을 봅니다.** 지시문처럼 70자가 넘는 값은 "changed (307 → 419 chars)"로만 나옵니다. `--full`(또는 `-v`)을 붙이면 전 · 후 글자가 다 나옵니다. 어느 문장이 더해졌는지 찾습니다.
|
||||
|
||||
**기대 결과.** 바뀐 것 7개. `CHANGELOG.md` 1절에 지시문 · 모델 · 노드 · 연결의 전 · 후와, `call` 두 번의 답 차이(출처 번호 · 길이 · 시간)를 적습니다.
|
||||
|
||||
<details><summary>▶ 1단계 · 바뀐 것 7개를 셋으로 나누기</summary>
|
||||
|
||||
`diff`의 줄 앞 기호: `+` 더함, `-` 뺌, `~` 값이 바뀜. 각 줄이 모델 · 지시문 · 흐름 중 어디에 속하나요? 웹훅 경로(`rel-v1 → rel-v2`)는 답을 바꾸나요?
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 브라우저로 보면</summary>
|
||||
|
||||
n8n 화면에서 두 워크플로우를 열어 비교해 봅니다. 지시문 한 문장 차이를 화면에서 찾을 수 있나요? `diff`가 하는 일을 7교시 `tver`로 직접 만듭니다.
|
||||
</details>
|
||||
|
||||
## 예제 2 · 답마다 버전 남기기 (12분)
|
||||
|
||||
**만들 것**: v1을 복사해, 모델 이름을 **한 곳**(릴리스 정보)에서 정하고 답마다 `version`을 남기기.
|
||||
|
||||
```bash
|
||||
cp reference/helpdesk-v1.json my-work/helpdesk.json
|
||||
```
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 파일 · 웹훅 | `my-work/helpdesk.json` · `my-helpdesk` (경로 · 이름을 바꿈) |
|
||||
| 릴리스 정보 | `release` `1.0.0`, `prompt_version` `p1`, `model` `gpt-4.1-mini` — 워크플로우 앞쪽 한 곳 (Set) |
|
||||
| 결과 | `version: {release, prompt_version, model}` ([AGENTS.md](AGENTS.md) 3절) |
|
||||
| 기대 결과 | `cases/lab01.json` |
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지금 무엇이 문제인가.** v1의 답은 `{route, answer, sources}`뿐입니다. 답을 받은 사람도, 나중에 로그를 보는 사람도 **어느 버전이 이 답을 썼는지** 모릅니다. 내일 1.1.0을 배포하고 모레 2.0.0을 카나리로 내보내면, 같은 시각에 여러 버전이 동시에 답합니다. 문의가 왔을 때 답에 버전이 없으면 원인을 좁힐 수 없습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 지금
|
||||
Q1(["질문"]) --> A1{{"Answer<br/>모델 이름이 노드 안에"}} --> O1(["answer만"])
|
||||
end
|
||||
subgraph 바꾼 뒤
|
||||
Q2(["질문"]) --> R2["Release info<br/>1.0.0 · p1 · mini"] --> A2{{"Answer<br/>모델 = Release info.model"}} --> O2(["answer + version"])
|
||||
end
|
||||
```
|
||||
|
||||
**한 곳에서 정하고, 모두 그것을 읽는다.** 모델 이름을 `Answer model` 노드 안에 그대로 두고 결과에도 따로 `gpt-4.1-mini`라고 적으면, 누군가 노드에서만 모델을 바꿨을 때 **결과의 이름과 실제 모델이 달라집니다.** 기록이 거짓말을 하게 됩니다. 그래서 릴리스 정보(Set) 하나에 적고, 모델 노드는 표현식으로 그 값을 **읽고**, 결과도 같은 값을 **읽습니다** (70일차 설정 표와 같은 생각).
|
||||
|
||||
**버전의 세 칸.**
|
||||
|
||||
| 칸 | 뜻 | 오늘의 값 |
|
||||
| --- | --- | --- |
|
||||
| `release` | 이 답을 낸 묶음 전체의 이름 | `1.0.0` |
|
||||
| `prompt_version` | 지시문 자체의 버전 | `p1` |
|
||||
| `model` | 실제로 부른 모델 | `gpt-4.1-mini` |
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 보내는 것 | 기대 |
|
||||
| --- | --- | --- |
|
||||
| 버전이 결과에 | VPN 질문 | `version.release` 1.0.0 · `prompt_version` p1 · `model` gpt-4.1-mini, 답에 "인증서" |
|
||||
| 접수 현황도 버전과 함께 | T-1002 (박서연) | `version.release` 1.0.0, "처리 중" |
|
||||
| 빈 질문 | `question: ""` | 400 (그대로) |
|
||||
|
||||
**기대 결과.** `lab01` 3/3.
|
||||
|
||||
<details><summary>▶ 1단계 · 한 곳에서</summary>
|
||||
|
||||
모델 노드의 모델 칸도 표현식(`=`로 시작)을 받습니다 ([AGENTS.md](AGENTS.md) 5절 "릴리스 정보 (Set) · 모델 이름을 표현식으로"). 앞 노드 이름이 정확해야 합니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 왜 답마다</summary>
|
||||
|
||||
"어제 오후에 이상한 답이 나갔다"는 문의가 오면, 그 답이 어느 릴리스였는지 알아야 원인을 찾습니다 (77일차 로그 분석). 로그에만 남기지 않고 답에도 넣으면, 문의한 사람이 보낸 화면만으로도 버전을 압니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 경로 ② 릴리스 정보 노드와 세 값 ③ 모델 노드가 읽는 곳 ④ 결과에 더할 칸 ⑤ 그대로 둘 것 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/helpdesk.json (v1 을 복사한 것) 의 웹훅 경로를 ____① 로, 이름을 "74 IT helpdesk (my)" 로 바꿔 줘.
|
||||
Has question? 뒤에 Release info(Set) 를 넣어 release 1.0.0, prompt_version p1, model gpt-4.1-mini.
|
||||
Answer model 의 모델 값은 ____② 로 Release info 의 model 을 읽게.
|
||||
Build result 결과에 ____③ : {release, prompt_version, model} 을 Release info 에서 더해 줘. 지시문은 ____④ .
|
||||
기대 결과 cases/lab01.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① `cases/lab01.json`의 맨 위 `webhook` 값입니다 (AGENTS.md 3절 표에도).
|
||||
- ② n8n에서 앞 노드의 값을 읽는 글자입니다. `=`로 시작하고 `$('노드 이름')`을 씁니다 (AGENTS.md 5절).
|
||||
- ③ AGENTS.md 3절 결과 모양에서 버전 세 칸을 감싸는 이름입니다.
|
||||
- ④ 예제 2에서 바꾸면 안 되는 것. 지시문까지 바꾸면 무엇 때문에 답이 바뀌었는지 또 모르게 됩니다.
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/helpdesk.json
|
||||
uv run tools/flow.py test cases/lab01.json -v
|
||||
```
|
||||
|
||||
## 예제 3 · 흔들림인가, 바뀜인가 (8분, 에이전트 없이)
|
||||
|
||||
예제 1에서 v1과 v2의 답이 달랐습니다. 그런데 LLM은 같은 질문에도 가끔 다른 답을 합니다. 그 차이가 **v2가 바뀌어서** 생긴 것인지, **그냥 흔들린 것**인지 어떻게 알까요?
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab01-compare.json --webhook rel-v1 --repeat 3
|
||||
uv run tools/flow.py test cases/lab01-compare.json --webhook rel-v2 --repeat 3
|
||||
uv run tools/flow.py bench rel-v1 requests/faq-vpn.json -n 5
|
||||
uv run tools/flow.py bench rel-v2 requests/faq-vpn.json -n 5
|
||||
```
|
||||
|
||||
`CHANGELOG.md` 1절의 "실제로" 칸에 통과 수와 p50을 적습니다.
|
||||
|
||||
### 이해하기
|
||||
|
||||
**한 번의 비교는 증거가 약합니다.** 답 하나씩을 나란히 놓고 "v2가 더 낫다"고 하면, 그 한 번이 우연일 수 있습니다. 같은 케이스를 **여러 번** 돌려서 결과가 늘 같으면 그 차이는 진짜이고, 돌릴 때마다 달라지면 흔들림입니다. 이것이 실험의 기본입니다: **신호(바뀐 것) 대 잡음(흔들림)**.
|
||||
|
||||
| 결과 | 3번 돌리면 | 뜻 |
|
||||
| --- | --- | --- |
|
||||
| v1은 3번 모두 ✘, v2는 3번 모두 ✔ | 늘 같음 | 진짜 바뀜 (신호) |
|
||||
| v1이 ✔ ✘ ✔, v2도 ✘ ✔ ✔ | 흔들림 | 바뀐 것인지 알 수 없음 (잡음) |
|
||||
| 둘 다 3번 모두 ✔ | 늘 같음 | 이 케이스로는 차이 없음 |
|
||||
|
||||
**`temperature 0`이어도 흔들립니다.** 오늘 모델 노드는 `temperature 0`입니다. 대체로 같은 답을 하지만 공급자 서버의 계산 순서 같은 이유로 **조금씩** 다를 수 있습니다. 그래서 테스트는 답 글자 전체가 아니라 **꼭 있어야 할 낱말**(해결 낱말 · 출처 번호)을 봅니다.
|
||||
|
||||
**`lab01-compare`가 보는 것.**
|
||||
|
||||
| 케이스 | 기대 낱말 | 바뀌었다면 어디 때문일까 |
|
||||
| --- | --- | --- |
|
||||
| VPN · 해결 낱말 | "인증서" | |
|
||||
| VPN · 출처 번호 | `[FAQ-003]` | 지시문 (v2에 출처 번호 문장) |
|
||||
| 프린터 · 해결 낱말 | "대기열" · "전원" 중 하나 | |
|
||||
| 프린터 · 출처 번호 | `[FAQ-` | 지시문 |
|
||||
| 접수 현황 | "처리 중" | |
|
||||
|
||||
**시간도 바뀐 것입니다.** `bench`로 다섯 번 재서 p50을 비교합니다. 모델이 작아지면(mini → nano) 보통 빨라집니다. 하지만 이것도 예제 1처럼 **셋이 한꺼번에** 바뀌었으므로, 빨라진 것이 모델 때문이라고 단정할 수는 없습니다. 2단계(Lab 02)에서는 한 릴리스에 한 가지만 바꿔 이 문제를 풉니다.
|
||||
|
||||
**기대 결과.** v1은 출처 번호 두 케이스가 3번 모두 ✘(나머지 ✔), v2는 모두 ✔. 출처 번호는 흔들림이 아니라 **바뀐 것**입니다. p50은 v1 · v2가 다릅니다.
|
||||
|
||||
<details><summary>▶ 1단계 · 흔들린 케이스가 있으면</summary>
|
||||
|
||||
3번 중 한 번만 ✘인 케이스가 있으면 그 답을 `runs/*.json`에서 읽습니다. 낱말을 다르게 썼나요(예: "재부팅" vs "다시 시작"), 아니면 정말 내용이 빠졌나요? 테스트의 기대 낱말이 너무 좁은 것일 수도 있습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 원인을 가르려면</summary>
|
||||
|
||||
v2의 세 변화(지시문 · 모델 · 새 노드)를 하나씩 따로 켠 버전 셋을 만들어 같은 테스트를 돌리면 원인이 갈립니다. 오늘은 그 일을 2단계에서 **릴리스**(1.1.0은 지시문만, 2.0.0은 모델만)로 합니다.
|
||||
</details>
|
||||
|
||||
## 과제 1 · 지시문은 저장소에서 (10분)
|
||||
|
||||
팀장이 요청했습니다.
|
||||
|
||||
> "지시문 한 문장 고칠 때마다 워크플로우를 열어 고치고 push 하니, 무엇이 바뀌었는지 섞여요. 지시문은 따로 관리하고 버전으로 부르게 해 주세요."
|
||||
|
||||
데이터 API에 지시문 저장소가 있습니다.
|
||||
|
||||
```bash
|
||||
curl -s localhost:8800/prompts/helpdesk
|
||||
curl -s localhost:8800/prompts/helpdesk/p1
|
||||
```
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 바꿀 것 | 답 Agent의 지시문을 `GET /prompts/helpdesk/<prompt_version>`의 `text`로 |
|
||||
| 결과 | `version.prompt_source: "registry"` |
|
||||
| 기대 결과 | `cases/lab01-challenge.json`, `cases/lab01.json`도 |
|
||||
|
||||
**먼저 생각해 보기 (2분)**: 지시문을 밖으로 꺼내면 얻는 것과 새로 생기는 위험(저장소가 멈추면?)은?
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지시문은 코드와 다른 속도로 바뀝니다.** 노드 · 연결은 한 달에 몇 번 바뀌지만, 지시문은 하루에도 몇 번 고칩니다 ("출처 번호를 붙여라", "존댓말로"). 지시문이 워크플로우 안에 있으면 한 문장을 고칠 때마다 워크플로우 전체가 새 버전이 되고, push로 n8n이 재시작되고, 변경 기록에 지시문 변경과 흐름 변경이 섞입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 지금
|
||||
W1["워크플로우 JSON<br/>노드 + 지시문 글자"]
|
||||
end
|
||||
subgraph 바꾼 뒤
|
||||
W2["워크플로우 JSON<br/>노드 + 지시문 이름 p1"] --> G["GET /prompts/helpdesk/p1"] --> S[("지시문 저장소<br/>p1 · p2 · p3")]
|
||||
end
|
||||
```
|
||||
|
||||
**저장소가 주는 것.**
|
||||
|
||||
| 지시문이 워크플로우 안에 | 지시문이 저장소에 |
|
||||
| --- | --- |
|
||||
| 한 문장 고쳐도 push · 재시작 | 저장소에 새 버전을 더함, 워크플로우는 그대로 |
|
||||
| 변경 기록에 흐름 변경이 섞임 | 지시문 변경만 따로 (p1 → p2) |
|
||||
| 예전 지시문은 git 기록을 뒤져야 | p1 · p2 · p3이 나란히 남음 (되돌리기 쉬움) |
|
||||
| 지시문을 고치는 사람 = n8n을 고치는 사람 | 지시문 담당(기획 · 운영)이 따로 고칠 수 있음 |
|
||||
|
||||
**버전은 바꾸지 않고 더합니다.** p1을 고치고 싶으면 p1을 덮어쓰지 않고 p4를 만듭니다. p1이 그대로 남아 있어야 "어제의 답"을 다시 만들 수 있고, 되돌릴 수 있습니다 (3단계). 이것을 **불변(immutable) 버전**이라고 합니다.
|
||||
|
||||
**`prompt_source`.** 결과에 지시문을 **어디서** 가져왔는지(`registry`)를 남깁니다. 과제 2에서 저장소가 멈추면 이 값이 `fallback`이 됩니다.
|
||||
|
||||
**테스트 케이스 (`lab01-challenge`).**
|
||||
|
||||
| 케이스 | 기대 |
|
||||
| --- | --- |
|
||||
| 지시문은 저장소에서 | `prompt_source` registry, `prompt_version` p1, "인증서" |
|
||||
| p1은 출처 번호를 붙이지 않음 | "대기열" · "전원", `[FAQ-` **없음** |
|
||||
| 접수 현황 | "처리 중" |
|
||||
|
||||
**기대 결과.** 예제 2 상태로 돌리면 첫 케이스가 ✘(`prompt_source`가 없음), 고친 뒤 3/3. `lab01`도 3/3.
|
||||
|
||||
<details><summary>▶ 1단계 · 지시문을 표현식으로</summary>
|
||||
|
||||
Agent의 `systemMessage`도 표현식으로 앞 노드 결과를 읽을 수 있습니다 ([AGENTS.md](AGENTS.md) 5절 "지시문 저장소 (HTTP) → Agent의 systemMessage"). 어느 버전을 가져올지는 릴리스 정보의 `prompt_version`.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 확인</summary>
|
||||
|
||||
p1은 출처 번호를 붙이지 않습니다. 테스트가 그것도 봅니다. 릴리스 정보의 `prompt_version`만 `p2`로 바꿔 push 해 보면 답이 어떻게 바뀌나요? (확인 후 `p1`로 되돌리기)
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 넣을 노드와 자리 ② 부를 주소 ③ 지시문 칸이 읽을 곳 ④ 결과에 더할 칸 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/helpdesk.json 의 Release info 뒤에 Get prompt(HTTP GET) 를 넣어 줘.
|
||||
주소는 http://data-api:8800/prompts/helpdesk/ ____① (Release info 의 값을 표현식으로).
|
||||
Answer 의 systemMessage 는 지금 글자 대신 ____② 의 text 를 표현식으로 읽게.
|
||||
Build result 의 version 에 ____③ : ' ____④ ' 를 더해 줘. 다른 것은 그대로.
|
||||
기대 결과 cases/lab01-challenge.json, cases/lab01.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① 릴리스 정보 세 값 중 지시문의 버전 이름 칸입니다 (예제 2에서 넣은 것).
|
||||
- ② 지시문 글자를 가져오는 노드의 이름입니다 (이 요청문의 첫 줄).
|
||||
- ③ 지시문을 어디서 가져왔는지 남기는 칸 이름입니다 (AGENTS.md 3절 `version` 안).
|
||||
- ④ 저장소에서 가져왔을 때의 값입니다 (`cases/lab01-challenge.json`의 기대).
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
|
||||
</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
|
||||
```
|
||||
|
||||
## 과제 2 · 저장소가 멈추면, 기본 지시문으로 (10분)
|
||||
|
||||
> "지시문 저장소 서버를 점검하느라 5분 껐더니 헬프데스크가 통째로 500을 냈대요. 지시문 하나 못 가져왔다고 답을 아예 안 하는 건 좀… 그리고 저장소가 느릴 때 직원이 한참 기다리는 것도 안 돼요."
|
||||
|
||||
먼저 겪어 봅니다.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py fault prompts error
|
||||
uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v # 무엇이 오나
|
||||
uv run tools/flow.py fault prompts ok # 반드시 되돌리기
|
||||
uv run tools/flow.py test cases/lab01-challenge2.json -v # 고치기 전 (케이스마다 고장을 켜고 끔)
|
||||
```
|
||||
|
||||
**만들 것**: 지시문 저장소가 **오류를 내거나 2초 안에 답하지 않으면**, 워크플로우 안에 둔 기본 지시문(p1과 같은 글)으로 답하고 `prompt_source: "fallback"`.
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 기다리는 한도 | `Get prompt` timeout 2초 |
|
||||
| 실패하면 | 오류 출력 → 기본 지시문(Set, p1과 같은 글) → 원래 다음 노드 |
|
||||
| 결과 | `version.prompt_source`: `registry` 또는 `fallback` |
|
||||
| 기대 결과 | `cases/lab01-challenge2.json` 4개, `lab01-challenge`도 그대로 |
|
||||
|
||||
### 이해하기
|
||||
|
||||
**밖으로 꺼낸 것은 멈출 수 있습니다.** 과제 1에서 지시문을 저장소로 꺼내면서 워크플로우는 **부르는 곳이 하나 늘었습니다.** 저장소가 멈추면 `Get prompt`가 실패하고, 실패한 노드에서 워크플로우가 멈춰 HTTP 500이 나갑니다. 저장소가 느리면 직원은 그만큼 기다립니다. 의존하는 것이 늘면 장애가 들어올 문도 늘어납니다 (72일차 Lab 03).
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 지금
|
||||
G1["Get prompt"] -->|실패| X1(["500 · 답 없음"])
|
||||
end
|
||||
subgraph 바꾼 뒤
|
||||
G2["Get prompt<br/>timeout 2초"] -->|성공| R2["저장소 지시문"] --> N2["Search FAQ …"]
|
||||
G2 -->|실패 · 오류 출력| F2["기본 지시문<br/>(p1과 같은 글)"] --> N2
|
||||
end
|
||||
```
|
||||
|
||||
**fail-open이 맞는 자리.** 73일차 판별 LLM이 멈추면 **막는 쪽(fail-closed)**을 골랐습니다. 보안 관문이었기 때문입니다. 지시문 저장소는 보안 관문이 아니라 **편의를 위한 의존**입니다. 지시문을 못 가져와도 미리 넣어 둔 기본 지시문으로 답하면 직원은 도움을 받습니다. 그래서 여기서는 **계속 가는 쪽(fail-open, graceful degradation)**을 고릅니다.
|
||||
|
||||
| | 73일차 판별 LLM | 오늘 지시문 저장소 |
|
||||
| --- | --- | --- |
|
||||
| 멈추면 생기는 위험 | 공격이 그대로 지나감 | 지시문이 최신이 아닐 수 있음 |
|
||||
| 고른 쪽 | 막는다 (`guard_down`) | 기본 지시문으로 답한다 (`fallback`) |
|
||||
| 사용자에게 | "잠시 뒤 다시" | 평소처럼 답 |
|
||||
|
||||
**기본 지시문은 무엇으로.** 가장 최근에 **검증된** 지시문(오늘은 p1)을 워크플로우 안에 복사해 둡니다. 이것을 "마지막으로 잘 됐던 것(last known good)"이라고 부릅니다. 기본 지시문으로 답했다는 것은 `prompt_source: fallback`으로 남겨, 나중에 몇 번이나 저장소 없이 답했는지 셀 수 있게 합니다.
|
||||
|
||||
**오래 기다리지 않게.** 오류(500)는 바로 오지만, 느린 저장소는 응답이 올 때까지 기다리게 합니다. `timeout 2000`(ms)을 두면 2초가 지났을 때 실패로 보고 기본 지시문으로 갑니다. 그래서 느림 케이스의 기대는 `max_seconds 6`입니다 (2초 + LLM 답).
|
||||
|
||||
**테스트의 `before` · `after`.** 케이스마다 `before`가 `fault prompts error`(또는 `slow`)를 켜고, `after`가 **반드시** `ok`로 되돌립니다 (실패해도). 손으로 `fault`를 켰다면 직접 `fault prompts ok`.
|
||||
|
||||
**테스트 케이스.**
|
||||
|
||||
| 케이스 | 고장 | 기대 |
|
||||
| --- | --- | --- |
|
||||
| 평소 · 저장소에서 | 없음 | `registry`, "인증서" |
|
||||
| 저장소 고장 · 기본 지시문으로 답 | `prompts error` | HTTP 200, `자동 답변`, `fallback`, "인증서" |
|
||||
| 저장소 느림 · 오래 기다리지 않음 | `prompts slow` (8초) | `fallback`, 6초 안 |
|
||||
| 되돌린 뒤 · 다시 저장소에서 | 없음 | `registry`, "대기열" |
|
||||
|
||||
**기대 결과.** 고치기 전에는 고장 · 느림 두 케이스가 ✘(500, 또는 8초 넘게 걸림). 고친 뒤 4/4.
|
||||
|
||||
<details><summary>▶ 1단계 · 어디서 멈추나</summary>
|
||||
|
||||
`fault prompts error` 상태에서 `call -v`의 응답을 봅니다. 몇 번 노드에서 멈췄을까요? n8n 화면의 Executions에서 빨간 노드를 찾습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 실패해도 멈추지 않게</summary>
|
||||
|
||||
72일차 Lab 03과 73일차 Lab 02 과제 2에서 쓴 노드 설정 하나입니다. 실패하면 멈추지 않고 **두 번째 출력**으로 보냅니다 (AGENTS.md 5절 "지시문 저장소가 멈추면").
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 고칠 노드와 기다리는 한도 ② 실패했을 때 가는 곳 ③ 기본 지시문의 글 ④ 두 갈래가 다시 만나는 곳 ⑤ 지시문 칸과 결과 칸 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/helpdesk.json 의 Get prompt 에 options.timeout ____① 과 onError ____② 를 넣어 줘.
|
||||
성공 쪽은 Registry prompt(Set, text = $json.text), 실패 쪽은 Fallback prompt(Set, text = data-api/data/prompts/helpdesk- ____③ .md 와 같은 글).
|
||||
두 Set 모두 Search FAQ 로 이어 줘. Answer 의 systemMessage 는 Registry prompt 가 실행됐으면 그 text, 아니면 Fallback prompt 의 text.
|
||||
version.prompt_source 는 Registry prompt 가 실행됐으면 'registry', 아니면 ' ____④ '.
|
||||
기대 결과 cases/lab01-challenge2.json, cases/lab01-challenge.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① 단위는 ms입니다. 위 조건 표의 "2초".
|
||||
- ② 실패해도 멈추지 않고 두 번째 출력으로 보내는 값입니다 (73일차 Lab 02 과제 2와 같은 값).
|
||||
- ③ 마지막으로 검증된 지시문의 버전 이름입니다 (오늘 운영 중인 릴리스 1.0.0의 지시문).
|
||||
- ④ `cases/lab01-challenge2.json`의 고장 케이스 기대값입니다.
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
|
||||
</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 doctor # fault switch가 모두 ok인지
|
||||
```
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| `version.model`과 실제 모델이 다름 | 모델 노드의 모델 값이 릴리스 정보를 읽는지 |
|
||||
| 지시문이 글자 그대로 `{{ ... }}` | 표현식 앞에 `=`가 있는지 |
|
||||
| `Get prompt` 404 | `prompt_version` 값(`p1`)과 저장소 버전 이름 |
|
||||
| 고장 케이스가 여전히 500 | `onError`가 `Get prompt` 노드 바로 아래(파라미터 밖)에 있는지, 오류 출력이 연결됐는지 |
|
||||
| 느림 케이스가 8초 넘게 걸림 | `timeout`이 `options` 안에 있는지 (`"options": {"timeout": 2000}`) |
|
||||
| `fault` 뒤 다른 테스트가 이상함 | `uv run tools/flow.py fault prompts ok` |
|
||||
| 계속 안 됨 | `cp reference/lab01-example2-version.json my-work/helpdesk.json` 후 push |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 예제 1: v1 → v2의 바뀐 것 7개를 `CHANGELOG.md` 1절에 셋(모델 · 지시문 · 흐름)으로 나눠 적었다.
|
||||
- [ ] 예제 2: `cases/lab01.json` 3/3.
|
||||
- [ ] 예제 3: `lab01-compare`를 v1 · v2에 3번씩, 출처 번호가 흔들림이 아니라 바뀐 것임을 숫자로, p50 둘.
|
||||
- [ ] 과제 1: `cases/lab01-challenge.json` 3/3, `lab01`도 3/3.
|
||||
- [ ] 과제 2: `cases/lab01-challenge2.json` 4/4, `fault prompts ok`로 되돌림.
|
||||
- [ ] `my-work/PROMPTS.md`.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Lab 02 · 릴리스 고르기, 후보 시험, 배포 전 게이트
|
||||
|
||||
- 관련 강의: [2단계 · 릴리스와 배포 전 게이트](../../Lecture/74/02-STAGE2-RELEASE-GATE.md)
|
||||
- 소요 시간: 50분 (4교시 한 시간) · 예제 1 12분 · 예제 2 8분 · 예제 3 7분 · 과제 1 13분 · 과제 2 10분
|
||||
- 준비: Lab 01의 `my-work/helpdesk.json` (없으면 `cp reference/lab01-example2-version.json my-work/helpdesk.json` 후 push. 지시문 저장소 · 기본 지시문은 Lab 01 과제 1 · 2를 끝낸 것)
|
||||
- 목표: 워크플로우가 **운영 중인 릴리스**를 데이터 API에서 읽게 하고, 후보 릴리스를 운영을 바꾸지 않고 **고정해서 시험**하고, 배포 전에 **게이트**로 통과 여부를 숫자로 정한다. 게이트의 기준은 재서 정하고, 나쁜 릴리스를 못 막는 게이트를 고치고, **망가진 릴리스**는 장애와 구분해 크게 실패하게 한다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E1["예제 1<br/>릴리스 읽기 ·<br/>후보 고정"] --> E2["예제 2<br/>게이트로<br/>네 릴리스 재기"] --> E3["예제 3<br/>기준선 재기<br/>시간 · 흔들림"] --> T1["과제 1<br/>나쁜 릴리스를<br/>막는 게이트"] --> T2["과제 2<br/>망가진 릴리스는<br/>400"]
|
||||
```
|
||||
|
||||
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다. 기록은 `cp templates/RELEASE-CHECKLIST-TEMPLATE.md my-work/RELEASE.md`에.
|
||||
|
||||
## 예제 1 · 운영 중인 릴리스를 읽고, 후보는 고정해서 (12분)
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py release helpdesk # active 1.0.0, 릴리스 다섯 개
|
||||
uv run tools/flow.py test cases/lab02.json -v # 고치기 전
|
||||
```
|
||||
|
||||
**만들 것**: 릴리스 정보(Set)를 데이터 API(`GET /releases/helpdesk`)로 바꾸기. 본문에 `release`가 있으면 그 릴리스로 **고정**.
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 정하는 순서 | 본문 `release`(고정, `channel: pinned`) → 없으면 `active`(`channel: stable`) |
|
||||
| 없는 릴리스 | HTTP 400 `{"error": "unknown release", "release", "known"}` |
|
||||
| 지시문 · 모델 | 고른 릴리스의 `prompt_version` · `model` |
|
||||
| 기대 결과 | `cases/lab02.json` 5개, `lab01-challenge` · `lab01-challenge2`도 그대로 |
|
||||
|
||||
### 이해하기
|
||||
|
||||
**지금 무엇이 문제인가.** Lab 01의 릴리스 정보는 워크플로우 **안의** Set 노드입니다. 1.1.0을 내보내려면 Set의 값을 고치고 push 해야 합니다. push 하면 n8n이 재시작되고, "무엇이 바뀌었나"에 워크플로우 변경이 섞이고, 되돌릴 때도 다시 push 합니다. 그리고 1.1.0을 **운영에 내보내기 전에** 시험해 볼 방법이 없습니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph 지금
|
||||
S1["Release info (Set)<br/>1.0.0 고정"] --> A1{{"Answer"}}
|
||||
end
|
||||
subgraph 바꾼 뒤
|
||||
G2["GET /releases/helpdesk"] --> P2{"Pick release<br/>본문 release?"}
|
||||
P2 -->|있음| F2["그 릴리스 · pinned"]
|
||||
P2 -->|없음| S2["active · stable"]
|
||||
F2 --> A2{{"Answer"}}
|
||||
S2 --> A2
|
||||
end
|
||||
```
|
||||
|
||||
**배포 = 데이터 API의 값 하나.** 운영 중인 릴리스(`active`)를 워크플로우가 **매번 읽게** 하면, 배포는 그 값을 바꾸는 일이 됩니다 (3단계 `flow.py release`). 워크플로우는 그대로, push도 재시작도 없습니다. 이것을 **설정과 코드의 분리**, 또는 원격 설정(remote config)이라고 합니다.
|
||||
|
||||
**고정(pin)은 후보 시험의 문.** 본문에 `release: "1.1.0"`을 넣으면 운영과 상관없이 그 릴리스로 답합니다. 운영 사용자는 계속 1.0.0을 받고, 테스트만 1.1.0을 받습니다. `flow.py test ... --set release=1.1.0`이 모든 요청에 이 칸을 더해 줍니다. 실제 서비스에서는 이 문을 **운영자 · 테스트에만** 엽니다 (73일차: 누구나 쓰게 두면 공격면).
|
||||
|
||||
**`channel` 세 가지.**
|
||||
|
||||
| `channel` | 언제 | 예제 |
|
||||
| --- | --- | --- |
|
||||
| `stable` | 본문에 `release`가 없고 카나리 대상도 아님 | 오늘 예제 1 |
|
||||
| `pinned` | 본문 `release`로 고정 | 오늘 예제 1 |
|
||||
| `canary` | 카나리 대상 직원 (Lab 03 예제 2) | 3단계 |
|
||||
|
||||
**없는 릴리스는 400.** `release: "9.9.9"`처럼 모르는 이름을 고정하면, 그냥 active로 답해 버리지 말고 **400으로 거절**합니다. 테스트가 엉뚱한 릴리스를 시험하고 PASS를 받는 일을 막습니다.
|
||||
|
||||
**테스트 케이스 (`lab02`).**
|
||||
|
||||
| 케이스 | 보내는 것 | 기대 |
|
||||
| --- | --- | --- |
|
||||
| 운영 중인 릴리스 (active) | VPN | 1.0.0 · `stable` · p1 |
|
||||
| 후보 시험 · 1.1.0 고정 | VPN + `release 1.1.0` | 1.1.0 · `pinned` · p2, `[FAQ-003]` |
|
||||
| 후보 시험 · 2.0.0 (작은 모델) | VPN + `release 2.0.0` | 2.0.0 · `gpt-5.4-nano` |
|
||||
| 없는 릴리스 | `release 9.9.9` | 400, `known`에 1.0.0 |
|
||||
| 빈 질문 | | 400 |
|
||||
|
||||
**기대 결과.** 고치기 전에는 고정 케이스들이 ✘(늘 1.0.0). 고친 뒤 5/5, `lab01-challenge` 3/3, `lab01-challenge2` 4/4.
|
||||
|
||||
<details><summary>▶ 1단계 · 왜 밖에서 읽나</summary>
|
||||
|
||||
릴리스를 바꿀 때마다 push 하면 n8n이 재시작되고, "무엇이 바뀌었나"에 워크플로우 변경이 섞입니다. 밖에서 읽으면 **배포 = 데이터 API의 값 하나**를 바꾸는 일이 됩니다 (3단계). `curl -s localhost:8800/releases/helpdesk`로 무엇이 오는지 먼저 봅니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 고르는 일은 코드가</summary>
|
||||
|
||||
"본문에 release가 있으면 그것, 없으면 active"는 틀리면 안 되는 규칙이라 LLM이 아니라 Code 노드가 정합니다 (AGENTS.md 5절 "릴리스 고르기"). 고른 릴리스의 `prompt_version` · `model`을 다음 노드들이 읽습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 바꿀 노드와 새 노드 ② 고르는 순서와 channel ③ 모르는 릴리스 ④ 다음 노드들이 읽을 곳 ⑤ 그대로 둘 것 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/helpdesk.json 의 Release info(Set) 를 Get release(HTTP GET http://data-api:8800/releases/helpdesk) → Pick release(Code) → Known release?(If) 로 바꿔 줘.
|
||||
Pick release: 본문 release 가 있으면 그 릴리스 · channel ____① , 없으면 active · channel ____② . 고른 릴리스의 prompt_version · model 을 돌려준다.
|
||||
releases 에 없으면 Known release? false → Reject release(400, {error: 'unknown release', release, ____③ }).
|
||||
Get prompt · Answer model · Build result 는 Release info 대신 ____④ 를 읽게. 기본 지시문(Fallback prompt)은 그대로.
|
||||
기대 결과 cases/lab02.json, cases/lab01-challenge.json, cases/lab01-challenge2.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① · ② 위 이해하기 "`channel` 세 가지" 표.
|
||||
- ③ 400 응답에 "알려진 릴리스 목록"을 담는 칸 이름입니다 (`cases/lab02.json`의 "없는 릴리스" 기대, 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-challenge.json # 원래 것도
|
||||
uv run tools/flow.py test cases/lab01-challenge2.json
|
||||
```
|
||||
|
||||
## 예제 2 · 게이트로 네 릴리스를 재기 (8분, 에이전트 없이)
|
||||
|
||||
`cases/golden.json`은 운영팀이 "배포 전에 꼭 돌리는" 테스트입니다. 열어 읽어 봅니다.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py gate cases/golden.json --set release=1.0.0 --max-p95 3
|
||||
uv run tools/flow.py gate cases/golden.json --set release=1.1.0 --max-p95 3
|
||||
uv run tools/flow.py gate cases/golden.json --set release=1.2.0 --max-p95 3
|
||||
uv run tools/flow.py gate cases/golden.json --set release=2.0.0 --max-p95 3
|
||||
uv run tools/flow.py call my-helpdesk '{"question": "프린터 출력이 안 돼요", "user": "최유진", "release": "1.2.0"}' -v
|
||||
```
|
||||
|
||||
`RELEASE.md` 1절의 "약한 golden" 칸을 채웁니다. 1.2.0의 답을 직접 읽어 보면 내보내도 될까요?
|
||||
|
||||
### 이해하기
|
||||
|
||||
**게이트(gate)는 문지기입니다.** 배포 전에 정해 둔 테스트를 돌리고, 기준을 넘으면 PASS(문이 열림), 못 넘으면 FAIL(문이 닫힘). 사람의 "괜찮아 보이는데요"가 아니라 **숫자**로 정합니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C(["후보 1.2.0"]) --> T["golden.json<br/>--set release=1.2.0"] --> J{"통과율 ≥ 100%<br/>p95 ≤ 3초"}
|
||||
J -->|예| P(["PASS · 종료 코드 0"])
|
||||
J -->|아니오| F(["FAIL · 종료 코드 1"])
|
||||
```
|
||||
|
||||
| 게이트 기준 | 오늘 | 뜻 |
|
||||
| --- | --- | --- |
|
||||
| 통과율 | `--min-pass 1.0` (기본) | 케이스가 모두 통과해야 |
|
||||
| 처리시간 | `--max-p95 3` | 케이스 시간의 p95가 3초 이내 |
|
||||
| 결과 | PASS / FAIL, 종료 코드 0 / 1 | 자동화(CI)가 종료 코드로 다음 단계를 정함 |
|
||||
| 기록 | `/gates/helpdesk`에 한 줄 | 3단계에서 "게이트를 지난 것만 배포"에 씀 |
|
||||
|
||||
**네 릴리스는 한 가지씩만 다릅니다.** 1.1.0은 지시문만(출처 번호), 1.2.0은 지시문만(한 문장으로 짧게), 2.0.0은 모델만(작은 모델) 바뀌었습니다. 그래서 1.0.0과 결과가 다르면 **그 한 가지** 때문이라고 말할 수 있습니다. Lab 01 예제 3에서 v2로는 할 수 없던 일입니다.
|
||||
|
||||
**golden이 무엇을 확인하나 읽어 봅니다.** `golden.json`의 케이스 6개는 `status: 200`과 `route: 자동 답변`만 봅니다. 답의 **내용**은 보지 않습니다. 이 게이트가 PASS라는 말은 "200으로 답이 나왔다"는 뜻일 뿐입니다.
|
||||
|
||||
**기대 결과.** 네 릴리스 모두 PASS. 그런데 1.2.0의 답은 "IT 포털에서 확인해 주세요" 같은 떠넘기기입니다. **게이트는 테스트만큼만 막습니다.**
|
||||
|
||||
<details><summary>▶ 1단계 · 게이트가 PASS인데</summary>
|
||||
|
||||
게이트는 테스트를 돌릴 뿐입니다. golden이 무엇을 확인하는지(상태 코드? 답의 내용?) 보면 PASS의 뜻이 보입니다. 1.2.0의 지시문은 `curl -s localhost:8800/prompts/helpdesk/p3`.
|
||||
</details>
|
||||
|
||||
## 예제 3 · 기준선 재기: 시간과 흔들림 (7분, 에이전트 없이)
|
||||
|
||||
예제 2의 `--max-p95 3`은 누가 정한 숫자일까요? 기준은 **지금(1.0.0)을 재서** 정합니다.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 --set release=1.0.0
|
||||
uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 --set release=2.0.0
|
||||
uv run tools/flow.py gate cases/golden.json --set release=1.0.0 --repeat 3 --max-p95 3
|
||||
```
|
||||
|
||||
`RELEASE.md` 1절 아래에 "기준선" 한 줄(1.0.0 p50 · p95, 2.0.0 p50 · p95, 3번 반복 통과 수)을 적습니다.
|
||||
|
||||
### 이해하기
|
||||
|
||||
**기준선(baseline)이 없으면 기준도 없습니다.** "3초 안"이라는 기준은 지금 운영 중인 1.0.0이 보통 몇 초인지 알아야 의미가 있습니다. 1.0.0이 늘 1초면 3초는 너무 느슨하고, 늘 2.8초면 너무 빡빡해서 같은 릴리스도 FAIL이 납니다. 기준은 **재서 정하고, 여유를 둡니다.**
|
||||
|
||||
| 정하는 법 | 예 | 좋은 점 · 나쁜 점 |
|
||||
| --- | --- | --- |
|
||||
| 감으로 | "3초면 되겠지" | 쉽지만 근거 없음 |
|
||||
| 기준선 × 여유 | 1.0.0 p95 × 1.5 | 지금보다 많이 나빠지는 것만 막음 |
|
||||
| 서비스 목표(SLO)에서 | "직원 문의 95%는 5초 안" | 사용자 쪽 약속에서 내려옴 (78일차) |
|
||||
|
||||
**흔들림도 기준선입니다.** 같은 릴리스로 같은 테스트를 3번 돌려 **3번 다 통과하는지** 봅니다. 1.0.0이 가끔 FAIL이면, 후보가 FAIL 했을 때 그것이 후보 탓인지 흔들림 탓인지 모릅니다. 흔들리는 테스트(flaky test)는 게이트를 믿지 못하게 만듭니다. 고치거나(기대 낱말을 데이터에 맞게), 반복을 기준에 넣습니다 (`--repeat 3`).
|
||||
|
||||
**작은 모델의 시간.** 2.0.0은 지시문은 1.1.0과 같고 모델만 작습니다. 빨라졌다면 모델 덕분이라고 말할 수 있습니다 (한 가지만 바꿨으니까). 75일차에는 같은 비교를 **비용**으로 합니다.
|
||||
|
||||
**기대 결과.** 1.0.0 · 2.0.0의 p50 · p95 (실행마다 다름), 1.0.0의 golden 3번 반복 18/18.
|
||||
|
||||
<details><summary>▶ 1단계 · p95는 다섯 번으로 충분한가</summary>
|
||||
|
||||
`-n 5`면 p95는 다섯 개 중 가장 느린 값입니다 (72일차 nearest-rank). 한 번 튄 값이 그대로 p95가 됩니다. 기준을 정할 때는 더 많이(`-n 20`) 재는 것이 좋지만 오늘은 시간과 비용 때문에 5번입니다.
|
||||
</details>
|
||||
|
||||
## 과제 1 · 나쁜 릴리스를 막는 게이트 (13분)
|
||||
|
||||
> "어제 1.2.0을 게이트 통과로 내보냈다가 '포털 보세요'만 하는 답 때문에 문의가 쏟아졌어요. 게이트가 이런 릴리스를 막게 해 주세요. 대신 1.0.0 · 1.1.0은 지금처럼 통과해야 합니다. 그리고 작은 모델(2.0.0)로 바꿔도 되는지도 같은 게이트로 알려 주세요."
|
||||
|
||||
**만들 것**: `cases/my-golden.json` — 1.0.0 · 1.1.0은 PASS, 1.2.0은 FAIL이 되는 게이트 테스트. 그 게이트로 2.0.0을 판단.
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 바꾸지 않을 것 | 워크플로우 (`my-work/helpdesk.json`) — **테스트만** 고친다 |
|
||||
| 기대 | `gate cases/my-golden.json --set release=1.0.0` · `1.1.0` PASS, `1.2.0` FAIL |
|
||||
| 판단 | 2.0.0의 게이트 결과 · p95를 `RELEASE.md`에, 내보낼지 한 줄 |
|
||||
|
||||
**먼저 생각해 보기 (3분)**: 1.2.0의 답에 **없는** 것은 무엇인가요? 질문마다 "이 말이 없으면 답이 아니다"인 낱말을 정답표처럼 정합니다 (71일차).
|
||||
|
||||
### 이해하기
|
||||
|
||||
**테스트를 강하게.** 약한 golden은 "답이 나왔나"만 봤습니다. 강한 golden은 "**맞는** 답이 나왔나"를 봅니다. 그 기준을 어디서 가져오느냐가 핵심입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D[("FAQ · 접수 건 데이터")] --> K["질문마다<br/>꼭 있어야 할 낱말"] --> G["my-golden.json<br/>contains · not_contains"]
|
||||
G --> X{"게이트"}
|
||||
X -->|1.0.0 · 1.1.0| P(["PASS"])
|
||||
X -->|1.2.0| F(["FAIL"])
|
||||
```
|
||||
|
||||
**기대는 데이터에서, 사람이.** 기대 낱말을 정하는 방법은 둘입니다.
|
||||
|
||||
| | 지금 답을 보고 기대를 만듦 | 데이터를 보고 기대를 정함 |
|
||||
| --- | --- | --- |
|
||||
| 방법 | 1.0.0의 답을 복사해 기대로 | FAQ의 해결 방법 · 접수 건의 상태 · 예정일에서 |
|
||||
| 문제 | 1.0.0이 틀린 것도 정답이 됨 | 사람이 시간을 씀 |
|
||||
| 오늘 | 하지 않음 | 이렇게 |
|
||||
|
||||
`curl -s 'localhost:8800/faq?q=프린터'`, `curl -s 'localhost:8800/tickets/T-1002'`로 데이터를 봅니다.
|
||||
|
||||
**무엇을 확인하나.**
|
||||
|
||||
| 질문 | 확인할 것 | 판정 칸 |
|
||||
| --- | --- | --- |
|
||||
| FAQ 질문 4개 | 해결 방법의 핵심 낱말 (예: VPN은 "인증서") | `contains` |
|
||||
| 접수 질문 2개 | 상태("처리 중" · "완료"), 예정일 | `contains` |
|
||||
| 떠넘기기 | "포털을 확인" 같은 말이 없음 | `not_contains` |
|
||||
|
||||
`contains`는 목록의 낱말 중 **하나라도** 있으면 통과입니다. 같은 뜻의 다른 말(예: "최신" · "업데이트")을 같이 적으면 흔들림이 줄어듭니다.
|
||||
|
||||
**금지 문구는 글자가 정확해야.** `not_contains: ["포털을 참고"]`를 적었는데 1.2.0이 "포털**에서** … 확인해 주세요"라고 쓰면 걸리지 않습니다. 떠넘기기를 막는 데는 금지 문구보다 **해결 낱말이 있는지**(`contains`)가 더 튼튼합니다.
|
||||
|
||||
**작은 모델 판단.** 2.0.0이 PASS라면 "게이트가 보는 것은 괜찮다"는 뜻입니다. 말투 · 길이 · 드문 질문 · 비용은 게이트가 보지 않습니다. 그래서 바로 전원에게 내보내지 않고 카나리로 먼저 (3단계).
|
||||
|
||||
**기대 결과.** 1.0.0 · 1.1.0 PASS, 1.2.0 FAIL (해결 낱말 케이스 대부분 ✘), 2.0.0은 결과를 보고 판단.
|
||||
|
||||
<details><summary>▶ 1단계 · 무엇을 확인하나</summary>
|
||||
|
||||
FAQ 질문마다 해결 방법의 핵심 낱말, 접수 질문마다 상태 · 예정일. 그리고 "포털을 보라"로 떠넘기지 않는지(`not_contains`). `cp cases/golden.json cases/my-golden.json`에서 시작해 `expect`만 바꿉니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 에이전트에게 맡길 수 있나</summary>
|
||||
|
||||
테스트를 에이전트가 써도 됩니다. 단, **기대 낱말은 내가 데이터(FAQ · 접수 건)를 보고 정한 것**이어야 합니다. 에이전트가 1.0.0의 답을 보고 기대를 만들면, 1.0.0이 틀린 것도 정답이 됩니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 3단계 · 1.0.0도 FAIL이면</summary>
|
||||
|
||||
기대 낱말이 너무 좁습니다. 1.0.0의 실패 줄을 보고, 답이 정말 틀렸는지(데이터와 다름) 아니면 같은 뜻을 다른 말로 썼는지 가립니다. 같은 뜻이면 그 말을 목록에 더합니다. **틀렸다면** 기대를 고치지 말고 1.0.0의 문제로 적습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 만들 파일과 시작점 ② 바꾸지 말 것 ③ 질문마다 기대 낱말(내가 데이터에서 정한 것) ④ 떠넘기기 금지 ⑤ 기대**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
cases/golden.json 을 cases/my-golden.json 으로 복사해 expect 만 강하게 바꿔 줘. 워크플로우는 ____① .
|
||||
질문마다 답에 꼭 있어야 할 낱말을 ____② 로 (아래는 내가 FAQ · 접수 건 데이터에서 정한 것):
|
||||
VPN 인증서 / VPN 업데이트 · 최신 / 비밀번호 비밀번호 찾기 · 휴대폰 / 프린터 대기열 · 전원 / 메일함 보관함 · 삭제 /
|
||||
T-1002 처리 중 / T-1002 10월 20일 · 20일 / T-1001 처리 완료 · 완료.
|
||||
프린터 질문에는 " ____③ " 같은 떠넘기기 말이 없어야 한다 (not_contains).
|
||||
기대: gate --set release=1.0.0 · 1.1.0 PASS, release= ____④ FAIL.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① 과제 조건 표의 "바꾸지 않을 것".
|
||||
- ② 목록의 낱말 중 하나라도 있으면 통과인 판정 칸 이름입니다 (AGENTS.md 8절, 위 "무엇을 확인하나" 표).
|
||||
- ③ 1.2.0의 지시문(`/prompts/helpdesk/p3`)이 시키는 말입니다. 실제 1.2.0 답의 글자를 예제 2의 `call -v`에서 찾습니다.
|
||||
- ④ 막아야 할 릴리스입니다 (팀장의 말).
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py gate cases/my-golden.json --set release=1.0.0 --max-p95 3
|
||||
uv run tools/flow.py gate cases/my-golden.json --set release=1.1.0 --max-p95 3
|
||||
uv run tools/flow.py gate cases/my-golden.json --set release=1.2.0 --max-p95 3 # FAIL이어야
|
||||
uv run tools/flow.py gate cases/my-golden.json --set release=2.0.0 --max-p95 3
|
||||
```
|
||||
|
||||
## 과제 2 · 망가진 릴리스는 크게 실패하게 (10분)
|
||||
|
||||
운영팀이 새 릴리스 2.1.0을 데이터 API에 올렸습니다.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py release helpdesk # 2.1.0의 prompt 칸을 봅니다
|
||||
curl -s localhost:8800/prompts/helpdesk # 저장소에 있는 지시문 버전
|
||||
uv run tools/flow.py test cases/lab02-challenge2.json -v # 고치기 전
|
||||
uv run tools/flow.py gate cases/my-golden.json --set release=2.1.0 --max-p95 3
|
||||
```
|
||||
|
||||
> "2.1.0이 게이트를 통과했대서 내보내려는데, 지시문 이름이 저장소에 없는 것 같아요. 그런데 왜 답은 멀쩡히 나오죠? 이러다 진짜 1.1.0의 출처 번호도 없이 기본 지시문으로 계속 나가는 건 아니죠?"
|
||||
|
||||
**만들 것**: 저장소가 **404(그런 지시문이 없음)** 를 주면 기본 지시문으로 넘어가지 말고 HTTP 400 `release broken`. 저장소 **장애(500 · 시간 초과)** 일 때만 기본 지시문.
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 404 | HTTP 400 `{"error": "release broken", "release", "prompt_version"}` |
|
||||
| 500 · 시간 초과 | 지금처럼 기본 지시문, `prompt_source: fallback` |
|
||||
| 200 | 저장소 지시문, `prompt_source: registry` |
|
||||
| 기대 결과 | `cases/lab02-challenge2.json` 5개, `gate my-golden --set release=2.1.0` FAIL, `lab02`도 그대로 |
|
||||
|
||||
### 이해하기
|
||||
|
||||
**Lab 01 과제 2의 기본 지시문이 무엇을 숨겼나.** Lab 01 과제 2에서 "저장소가 실패하면 기본 지시문으로"를 만들었습니다. 그런데 "실패"에는 두 종류가 있습니다.
|
||||
|
||||
| 실패 | 저장소의 답 | 뜻 | 맞는 대응 |
|
||||
| --- | --- | --- | --- |
|
||||
| 장애 | 500, 시간 초과 | 저장소가 **잠깐** 아픔. 다시 하면 될 수도 | 기본 지시문으로 계속 (graceful degradation) |
|
||||
| 설정 오류 | **404** `prompt not found` | 릴리스가 **없는 지시문**을 가리킴. 다시 해도 안 됨 | 크게 실패 (fail fast) |
|
||||
|
||||
2.1.0은 `p9`를 가리키는데 저장소에는 p1~p3만 있습니다. 지금 워크플로우는 404도 "실패"로 보고 기본 지시문(p1)으로 답합니다. 그래서 2.1.0은 **겉으로 멀쩡하고**, 강한 golden도 통과합니다(p1의 답이니까). 그대로 배포하면 운영팀은 2.1.0의 새 지시문이 나간다고 믿지만, 실제로는 1.0.0의 지시문이 나갑니다. **장애를 견디려고 만든 장치가 잘못된 설정을 숨긴 것**입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
G["Get prompt<br/>timeout 2초 · 응답 코드도 받기"] --> C{"응답 코드"}
|
||||
C -->|200| R(["저장소 지시문 · registry"])
|
||||
C -->|404| B(["400 release broken<br/>(설정 오류 · 크게 실패)"])
|
||||
C -->|500| F(["기본 지시문 · fallback"])
|
||||
G -->|시간 초과 · 오류 출력| F
|
||||
```
|
||||
|
||||
**크게 실패해야 게이트가 잡습니다.** 404에서 400 `release broken`을 내면, 2.1.0을 고정한 게이트 테스트가 모두 400을 받아 **FAIL**이 됩니다. 문제는 배포 **전에**, 사람이 아니라 게이트가 찾습니다. "조용히 넘어가는 오류"가 가장 비쌉니다.
|
||||
|
||||
**응답 코드를 읽으려면.** HTTP 노드는 기본으로 4xx · 5xx를 **오류**로 보고 오류 출력으로 보냅니다. 그러면 404와 500이 같은 길로 갑니다. `neverError`(오류로 보지 않음) + `fullResponse`(응답 코드 · 본문을 같이 받음)를 켜면 다음 Code가 `$json.statusCode`로 가를 수 있습니다. 시간 초과는 응답 코드가 없으므로 지금처럼 오류 출력으로 갑니다 ([AGENTS.md](AGENTS.md) 5절 "응답 코드로 가르기").
|
||||
|
||||
**장애일 때 없는 릴리스는?** 저장소 장애 중에도 `release: "9.9.9"`는 400 `unknown release`여야 합니다. 릴리스 고르기(Pick release)는 지시문을 가져오기 **전**이라 영향이 없습니다. 테스트가 이것도 봅니다.
|
||||
|
||||
**테스트 케이스 (`lab02-challenge2`).**
|
||||
|
||||
| 케이스 | 고장 | 기대 |
|
||||
| --- | --- | --- |
|
||||
| 망가진 릴리스 2.1.0 | 없음 | 400 `release broken`, `prompt_version` p9 |
|
||||
| 평소 · 1.1.0 | 없음 | `registry` · p2, `[FAQ-003]` |
|
||||
| 저장소 고장 · 1.1.0 | `prompts error` | 200, 1.1.0, `fallback`, "인증서" |
|
||||
| 저장소 고장 · 없는 릴리스 | `prompts error` | 400 `unknown release` |
|
||||
| 운영(active 1.0.0) | 없음 | 1.0.0 · `registry`, "대기열" |
|
||||
|
||||
**기대 결과.** 고치기 전: 2.1.0 케이스 ✘(200으로 기본 지시문 답), 나머지 ✔. `gate my-golden 2.1.0`은 **PASS**(위험!). 고친 뒤: 5/5, `gate my-golden 2.1.0` FAIL.
|
||||
|
||||
<details><summary>▶ 1단계 · 2.1.0이 왜 멀쩡해 보이나</summary>
|
||||
|
||||
고치기 전 `test -v`에서 2.1.0 케이스의 응답 `version.prompt_source`를 봅니다. `curl -s localhost:8800/prompts/helpdesk/p9`는 무엇을 주나요?
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 두 실패를 가르는 곳</summary>
|
||||
|
||||
`Get prompt`의 성공 쪽 다음 노드(지금 `Registry prompt`)를 Code로 바꿔 응답 코드로 셋으로 가르고(200 · 404 · 그 밖), 404면 If로 400 응답. 실패 쪽(시간 초과)은 그대로 `Fallback prompt`.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① HTTP 노드 응답 옵션 ② 응답 코드별로 할 일 ③ 400 응답 모양 ④ 그대로 둘 것 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/helpdesk.json 의 Get prompt 에 options.response.response 로 neverError true, ____① true 를 더해 줘 (timeout 2000 · onError 는 그대로).
|
||||
성공 쪽 Registry prompt 를 Code 로 바꿔: statusCode 200 이면 {state: 'registry', text: body.text}, ____② 이면 {state: 'broken'}, 그 밖이면 {state: 'fallback', text: 기본 지시문}.
|
||||
Prompt broken?(If, state == 'broken') true → Reject broken(HTTP ____③ , {error: 'release broken', release, prompt_version}), false → Search FAQ.
|
||||
prompt_source 는 Registry prompt 의 state 가 'registry' 면 registry, 아니면 fallback. 실패 쪽(시간 초과) Fallback prompt 는 ____④ .
|
||||
기대 결과 cases/lab02-challenge2.json, cases/lab02.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① 응답 코드와 본문을 같이 받는 옵션 이름입니다 (위 이해하기 "응답 코드를 읽으려면").
|
||||
- ② 저장소가 "그런 지시문이 없다"고 할 때의 응답 코드입니다 (`curl -s -o /dev/null -w '%{http_code}' localhost:8800/prompts/helpdesk/p9`).
|
||||
- ③ `cases/lab02-challenge2.json`의 첫 케이스 `status`.
|
||||
- ④ 장애는 지금처럼 견뎌야 합니다. 바꿀까요, 둘까요?
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
|
||||
</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 gate cases/my-golden.json --set release=2.1.0 --max-p95 3 # FAIL이어야
|
||||
uv run tools/flow.py test cases/lab02.json
|
||||
uv run tools/flow.py test cases/lab01-challenge2.json
|
||||
```
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| 늘 `1.0.0` | 본문 `release`를 읽는지, `--set release=...`를 붙였는지 |
|
||||
| 2.0.0인데 `version.model`이 mini | 모델 노드가 고른 릴리스의 `model`을 읽는지 |
|
||||
| 없는 릴리스가 500 | 모르는 릴리스면 If로 400 갈래 |
|
||||
| 게이트가 1.0.0도 FAIL | 기대 낱말이 너무 좁음. 1.0.0의 실패 줄을 보고 데이터의 낱말로 |
|
||||
| 2.1.0이 여전히 200 | `fullResponse`가 켜졌는지 (`$json.statusCode`가 있는지 `call -v`로), 404 갈래가 If로 이어졌는지 |
|
||||
| 저장소 고장인데 400 | 500을 `broken`으로 본 것. 404만 `broken` |
|
||||
| `fault` 뒤 다른 테스트가 이상함 | `uv run tools/flow.py fault prompts ok` |
|
||||
| 계속 안 됨 | `cp reference/lab02-example1-release.json my-work/helpdesk.json` 후 push |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 예제 1: `cases/lab02.json` 5/5, `lab01-challenge` · `lab01-challenge2`도 통과.
|
||||
- [ ] 예제 2: 네 릴리스의 golden 게이트 결과를 `RELEASE.md` 1절에, 1.2.0의 답을 읽어 봄.
|
||||
- [ ] 예제 3: 1.0.0 · 2.0.0의 p50 · p95, 1.0.0 golden 3번 반복 (기준선).
|
||||
- [ ] 과제 1: `my-golden`으로 1.0.0 · 1.1.0 PASS, 1.2.0 FAIL, 2.0.0 판단 한 줄.
|
||||
- [ ] 과제 2: `cases/lab02-challenge2.json` 5/5, `gate my-golden 2.1.0` FAIL (고치기 전 PASS였음을 `RELEASE.md`에).
|
||||
@@ -0,0 +1,376 @@
|
||||
# Lab 03 · 배포 · 되돌리기, 카나리, 릴리스 점검 워크플로우
|
||||
|
||||
- 관련 강의: [3단계 · 배포 · 되돌리기 · 카나리](../../Lecture/74/03-STAGE3-DEPLOY-AND-ROLLBACK.md)
|
||||
- 소요 시간: 50분 (6교시 한 시간) · 예제 1 8분 · 예제 2 12분 · 예제 3 7분 · 과제 1 13분 · 과제 2 10분
|
||||
- 준비: Lab 02의 `my-work/helpdesk.json` (없으면 `cp reference/lab02-example1-release.json my-work/helpdesk.json` 후 push. 망가진 릴리스 처리는 Lab 02 과제 2를 끝낸 것), Lab 02 과제 1의 `cases/my-golden.json`
|
||||
- 목표: 배포와 되돌리기를 **값 하나**로 하고, 새 릴리스를 일부 직원에게만 먼저 내보내고(카나리), 나쁜 카나리를 보고 끄고, 내보낼지 되돌릴지를 판단하는 점검을 n8n 워크플로우로 만들고, **점검을 지난 릴리스만 배포되게** 한다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
E1["예제 1<br/>배포 · 되돌리기"] --> E2["예제 2<br/>카나리<br/>같은 사람은 같은 쪽"] --> E3["예제 3<br/>나쁜 카나리를<br/>보고 끄기"] --> T1["과제 1<br/>my-release-check<br/>promote · rollback"] --> T2["과제 2<br/>점검 기록 ·<br/>지난 것만 배포"]
|
||||
```
|
||||
|
||||
> 배포 · 되돌리기는 **사람이** `flow.py release`로 합니다. 테스트 파일은 `before`로 바꾸고 `after`로 처음 상태(`--reset`)로 되돌립니다. 끝나면 `uv run tools/flow.py release helpdesk --reset`.
|
||||
|
||||
## 예제 1 · 배포와 되돌리기 (8분, 에이전트 없이)
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py release helpdesk 1.1.0 # 배포
|
||||
uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v # version.release, 답 끝의 [FAQ-003]
|
||||
uv run tools/flow.py release helpdesk 1.0.0 # 되돌리기
|
||||
uv run tools/flow.py call my-helpdesk requests/faq-vpn.json -v
|
||||
uv run tools/flow.py test cases/lab03.json -v
|
||||
```
|
||||
|
||||
`RELEASE.md` 3절에 걸린 시간과 확인 방법을 적습니다. push를 했나요? n8n이 재시작됐나요?
|
||||
|
||||
### 이해하기
|
||||
|
||||
**배포와 되돌리기가 값 하나.** Lab 02 예제 1 덕분에 워크플로우는 질문마다 `GET /releases/helpdesk`로 `active`를 읽습니다. 그래서 배포는 `active`를 1.1.0으로, 되돌리기는 1.0.0으로 바꾸는 **한 줄**입니다. 다음 요청부터 바로 바뀌고, push도 n8n 재시작도 없습니다.
|
||||
|
||||
| 되돌리기 방법 | 걸리는 일 | 위험 |
|
||||
| --- | --- | --- |
|
||||
| 예전 JSON을 찾아 push | 파일 찾기 · import · n8n 재시작 (분 단위) | 그 사이 다른 것도 바뀔 수 있음, 예전 파일이 정말 그때 것인지 모름 |
|
||||
| **`active` 값 하나** | 명령 한 줄 (초 단위) | 되돌릴 릴리스가 남아 있어야 함 |
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A(["release helpdesk 1.1.0"]) --> D[("데이터 API<br/>active = 1.1.0")]
|
||||
D --> W{{"my-helpdesk<br/>다음 요청부터 1.1.0"}}
|
||||
B(["release helpdesk 1.0.0"]) --> D
|
||||
```
|
||||
|
||||
**이전 것을 지우지 않습니다.** 되돌릴 수 있는 것은 1.0.0 릴리스와 p1 지시문이 데이터 API에 **그대로 남아 있기** 때문입니다. Lab 01 과제 1의 "버전은 바꾸지 않고 더한다"가 여기서 값을 냅니다.
|
||||
|
||||
**확인은 답의 `version`으로.** "배포했다"는 명령이 성공한 것이고, "배포됐다"는 실제 답이 바뀐 것입니다. `call -v`의 `version.release`와 답 끝의 출처 번호를 봅니다.
|
||||
|
||||
**기대 결과.** 1.1.0이면 `version.release` 1.1.0 · `[FAQ-003]`, 되돌리면 1.0.0 · 출처 번호 없음. `lab03` 2/2.
|
||||
|
||||
<details><summary>▶ 1단계 · 되돌릴 수 있다는 것</summary>
|
||||
|
||||
되돌리기가 "예전 JSON을 찾아 push"라면 몇 분이 걸리고, 그 사이 다른 것도 바뀔 수 있습니다. 오늘은 **값 하나**입니다. 되돌릴 이전 릴리스가 데이터 API에 그대로 남아 있어야 하는 이유는?
|
||||
</details>
|
||||
|
||||
## 예제 2 · 카나리: 일부 직원에게만 먼저 (12분)
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py release helpdesk --canary 1.1.0 --percent 50 # 대상 직원이 표시됨
|
||||
uv run tools/flow.py test cases/lab03-canary.json -v # 고치기 전
|
||||
```
|
||||
|
||||
**만들 것**: 카나리가 켜져 있으면 대상 직원은 카나리 릴리스, 나머지는 active. **같은 사람은 늘 같은 쪽**.
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 대상 규칙 | 이름 글자 코드의 합 % 100 < `percent` ([AGENTS.md](AGENTS.md) 3절) |
|
||||
| 순서 | 고정(`release`) > 카나리 > active |
|
||||
| 결과 | `version.channel`: `pinned` · `canary` · `stable` |
|
||||
| 기대 결과 | `cases/lab03-canary.json` 6개 (50%: 최유진 · 정우성이 카나리), `lab02`도 |
|
||||
|
||||
### 이해하기
|
||||
|
||||
**카나리(canary)는 탄광의 새입니다.** 옛날 광부들은 유독 가스를 먼저 알아채는 카나리아를 데리고 들어갔습니다. 새 릴리스도 **먼저 일부에게만** 내보내 문제가 있는지 봅니다. 나쁘면 일부만 영향을 받고, 좋으면 넓힙니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
U(["user 이름"]) --> B["글자 코드 합 % 100<br/>= bucket"] --> C{"bucket < percent?"}
|
||||
C -->|예| K["카나리 릴리스<br/>channel canary"]
|
||||
C -->|아니오| S["active<br/>channel stable"]
|
||||
```
|
||||
|
||||
**왜 무작위로 고르지 않나.** 요청마다 무작위로 고르면 같은 사람이 질문할 때마다 다른 릴리스의 답을 받습니다. 대화가 이어질 때 말투가 바뀌고, 문제가 생겼을 때 누가 어느 릴리스를 썼는지 찾기 어렵습니다. 그래서 이름 · 사번처럼 **바뀌지 않는 값**으로 나눕니다. 이것을 고정 배정(sticky bucketing)이라고 합니다.
|
||||
|
||||
**오늘 직원의 bucket.**
|
||||
|
||||
| 직원 | bucket | 50% 카나리일 때 |
|
||||
| --- | --- | --- |
|
||||
| 최유진 | 0 | 카나리 |
|
||||
| 정우성 | 42 | 카나리 |
|
||||
| 한지훈 | 52 | 운영 |
|
||||
| 이준호 | 56 | 운영 |
|
||||
| 박서연 | 57 | 운영 |
|
||||
| 김민지 | 80 | 운영 |
|
||||
|
||||
`flow.py release helpdesk`가 카나리일 때 대상 직원을 보여 줍니다. 워크플로우와 `flow.py`가 **같은 규칙**을 써야 둘의 말이 맞습니다.
|
||||
|
||||
**순서가 중요합니다.** 고정(`release`)이 카나리보다 먼저입니다. 그래야 카나리 대상인 최유진도 테스트에서 `release: 1.0.0`을 고정하면 1.0.0을 받습니다. 순서가 바뀌면 후보 시험이 카나리에 섞입니다.
|
||||
|
||||
**기대 결과.** 고치기 전(Lab 02 상태): 카나리 대상 케이스가 ✘(늘 stable). 고친 뒤 6/6, `lab02` 5/5. 끝나면 `--reset`.
|
||||
|
||||
<details><summary>▶ 1단계 · 왜 무작위로 고르지 않나</summary>
|
||||
|
||||
요청마다 무작위로 고르면 같은 사람이 질문할 때마다 다른 릴리스의 답을 받습니다. 대화가 이어질 때 어색하고, 문제가 생겼을 때 누가 어느 릴리스를 썼는지 찾기 어렵습니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 카나리로 무엇을 보나</summary>
|
||||
|
||||
카나리 직원의 답 · 시간 · 실패를 운영 쪽과 비교합니다 (예제 3, 77일차 로그 분석). 나쁘면 `--no-canary` 하나로 끝납니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 고칠 노드 ② 대상 규칙(식) ③ 순서 ④ channel 값 ⑤ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/helpdesk.json 의 Pick release 에 카나리를 더해 줘.
|
||||
bucket = user 이름 글자의 codePointAt 합 % ____① . 본문 release 가 없고 canary 가 있고 bucket ____② canary.percent 이면
|
||||
canary.release 를 쓰고 channel ' ____③ '. 순서는 고정 > 카나리 > active ( ____④ 가 맨 먼저).
|
||||
기대 결과 cases/lab03-canary.json, cases/lab02.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① AGENTS.md 3절 "카나리 대상 규칙"의 나누는 수.
|
||||
- ② "작으면 카나리"입니다. 같은 값(=)이면 들어가나요?
|
||||
- ③ 위 "`channel` 세 가지"(Lab 02 예제 1) 중 하나.
|
||||
- ④ 후보 시험이 카나리에 섞이지 않으려면 무엇이 가장 먼저여야 하나요?
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/helpdesk.json
|
||||
uv run tools/flow.py test cases/lab03-canary.json -v
|
||||
uv run tools/flow.py test cases/lab02.json
|
||||
uv run tools/flow.py release helpdesk --reset
|
||||
```
|
||||
|
||||
## 예제 3 · 나쁜 카나리를 보고 끄기 (7분, 에이전트 없이)
|
||||
|
||||
누군가 1.2.0을 50% 카나리로 내보냈습니다. 같은 질문을 여섯 직원이 합니다.
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py test cases/lab03-watch.json -v # before가 카나리 1.2.0 50%를 켭니다
|
||||
uv run tools/flow.py release helpdesk # 누가 카나리인가
|
||||
uv run tools/flow.py release helpdesk --no-canary # 카나리 끄기
|
||||
uv run tools/flow.py test cases/lab03-watch.json --no-before -v # 끈 뒤 (before를 건너뜀)
|
||||
uv run tools/flow.py release helpdesk --reset
|
||||
```
|
||||
|
||||
`RELEASE.md` 3절에 "영향 받은 직원 수 / 여섯", "끄는 데 걸린 시간", "끈 뒤 통과 수"를 적습니다.
|
||||
|
||||
### 이해하기
|
||||
|
||||
**카나리는 보는 것까지가 카나리입니다.** 일부에게 내보내기만 하고 아무도 보지 않으면, 그냥 느린 배포입니다. 카나리 쪽과 운영 쪽을 **같은 질문으로 비교**해 차이가 있으면 멈춥니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["카나리 1.2.0 · 50%"] --> W["같은 질문 6명<br/>lab03-watch"]
|
||||
W --> K["카나리 2명<br/>해결 낱말 없음 ✘"]
|
||||
W --> S["운영 4명<br/>해결 낱말 있음 ✔"]
|
||||
K --> N(["--no-canary"]) --> R["다시 6명 ✔"]
|
||||
```
|
||||
|
||||
**피해 반경(blast radius).** 전원에게 바로 배포했다면 여섯 명 모두 "포털에서 확인하세요"를 받았을 것입니다. 50% 카나리라서 둘만 받았고, 끄는 데는 명령 한 줄입니다. 카나리 비율은 처음에 작게(1~5%) 시작해 늘리는 것이 보통입니다. 오늘은 여섯 명이라 50%로 둘만 걸리게 했습니다.
|
||||
|
||||
**무엇을 보나.**
|
||||
|
||||
| 볼 것 | 오늘 | 운영에서 |
|
||||
| --- | --- | --- |
|
||||
| 답의 내용 | 해결 낱말이 있는가 (`lab03-watch`) | 샘플 답 읽기, 사용자 평가(👍👎) |
|
||||
| 실패 · 오류 | HTTP 상태 | 오류율 대시보드 (77일차) |
|
||||
| 시간 | 케이스 시간 | p95 (72일차) |
|
||||
| 누가 받았나 | `version.channel` | 로그의 `version`으로 나눠 세기 |
|
||||
|
||||
**기대 결과.** 켠 상태: 카나리 둘(최유진 · 정우성) ✘, 운영 넷 ✔ (4/6). 끈 뒤 6/6.
|
||||
|
||||
<details><summary>▶ 1단계 · 카나리를 끄면 무엇이 남나</summary>
|
||||
|
||||
`--no-canary` 뒤 `release helpdesk`를 봅니다. active는 그대로 1.0.0입니다. 카나리를 끄는 것은 "되돌리기"가 아니라 **아직 배포하지 않은 것을 거두는 일**입니다. 1.2.0 릴리스 자체는 데이터 API에 남아 있습니다 (왜 지우지 않을까요?).
|
||||
</details>
|
||||
|
||||
## 과제 1 · 릴리스 점검 워크플로우 (13분)
|
||||
|
||||
> "게이트를 매번 노트북에서 돌리니 사람마다 결과가 달라요. n8n에 '이 릴리스 내보내도 돼?'를 물으면 답해 주는 점검을 만들어 주세요. 운영 중인 사람들에게는 영향이 없어야 합니다."
|
||||
|
||||
**만들 것**: `my-work/release-check.json` (`my-release-check`). 릴리스를 받아, 정해 둔 질문 6개를 **그 릴리스로 고정해** 헬프데스크에 물어보고, 답을 검사해 `promote` 또는 `rollback`.
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 계약 | [AGENTS.md](AGENTS.md) 3절 릴리스 점검 |
|
||||
| 질문 · 기대 | Lab 02 과제 1의 `my-golden`에서 6개 (질문 · 사용자 · 기대 낱말) |
|
||||
| 판단 | 6개 모두 통과면 `promote`, 하나라도 실패면 `rollback`, 실패한 질문 목록 |
|
||||
| 기대 결과 | `cases/lab03-challenge.json` (1.1.0 → promote, 1.2.0 → rollback, 릴리스 없음 → 400) |
|
||||
|
||||
**먼저 생각해 보기 (3분)**: 점검이 헬프데스크를 부를 때 왜 `release`를 **고정**해야 할까요? 고정하지 않으면 무엇을 점검하게 되나요?
|
||||
|
||||
### 이해하기
|
||||
|
||||
**노트북의 게이트를 서버로.** `flow.py gate`는 내 노트북에서 돕니다. 사람마다 다른 버전의 테스트 파일, 다른 기준, 다른 시각에 돌리면 결과가 다릅니다. 점검을 **한 곳(n8n)에** 두면 누가 물어도 같은 질문 · 같은 기준으로 답합니다. 회사에서 CI 서버가 테스트를 도는 것과 같은 이유입니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
W(["release 1.2.0"]) --> G["Golden questions<br/>항목 6개"] --> H["Ask helpdesk<br/>항목마다 한 번<br/>release 고정"] --> J["Judge (Code)<br/>기대 낱말 · version"] --> V(["promote · rollback<br/>failed 목록"])
|
||||
```
|
||||
|
||||
**고정해서 물어야 합니다.** 점검이 `release` 없이 헬프데스크를 부르면 **운영 중인 1.0.0**이 답합니다. 1.2.0을 점검한다면서 1.0.0의 답을 채점하게 됩니다. 그래서 Judge는 답의 `version.release`가 점검하는 릴리스와 **같은지**도 봅니다. 다르면 고정이 먹지 않은 것이고, 그 질문은 실패로 셉니다.
|
||||
|
||||
**1 → N → 1.** Code가 질문 6개를 **항목 6개**로 돌려주면 다음 HTTP 노드가 항목마다 한 번씩 부릅니다 (66일차). 그 뒤 Code에서 `$input.all()`(답 6개)과 `$('Golden questions').all()`(질문 6개)을 같은 순서로 맞춰 채점하고 하나로 모읍니다.
|
||||
|
||||
**판단은 코드가.** "답에 기대 낱말이 있는가"는 글자 검사라 LLM이 아니라 Code가 합니다. LLM이 채점하면 채점도 흔들립니다 (LLM 채점은 79일차).
|
||||
|
||||
**기대 결과.** 1.1.0 → `promote` 6/6, 1.2.0 → `rollback` (해결 낱말 없음), 본문에 `release`가 없으면 400. `call my-release-check '{"release": "2.0.0"}'`의 결과를 `RELEASE.md`에.
|
||||
|
||||
<details><summary>▶ 1단계 · 질문 6개를 한 번에</summary>
|
||||
|
||||
Code가 질문 6개를 **항목 6개**로 돌려주면, 다음 HTTP 노드가 항목마다 한 번씩 부릅니다 (66일차 1 → N → 1). 내 웹훅 주소는 n8n 안에서도 `http://localhost:5678/webhook/my-helpdesk` (AGENTS.md 5절 "HTTP로 내 웹훅 부르기").
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 검사는 코드가</summary>
|
||||
|
||||
답에 기대 낱말이 있는지, 답의 `version.release`가 점검한 릴리스와 같은지. 같지 않다면? (고정이 안 먹은 것)
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 3단계 · 운영에서는</summary>
|
||||
|
||||
새 릴리스를 데이터 API에 올리면 이 점검을 자동으로 부르고, `promote`면 카나리 → 배포, `rollback`이면 멈추고 알림. 오늘은 사람이 결과를 보고 `flow.py release`를 합니다. 과제 2에서 "점검을 지난 것만 배포"를 데이터 API가 지키게 합니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 파일 · 웹훅 ② 질문 6개를 항목으로 ③ 부를 곳과 고정 ④ 채점 기준 ⑤ 결과 모양 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/release-check.json 을 새로 만들어 줘. 웹훅 ____① , 본문 release 가 없으면 400.
|
||||
Golden questions(Code): cases/my-golden.json 의 질문 6개(question · user · expect 낱말)를 항목 6개로, 각 항목에 release.
|
||||
Ask helpdesk(HTTP POST http://localhost:5678/webhook/my-helpdesk): 항목마다 {question, user, ____② } 로 (neverError, timeout 30000).
|
||||
Judge(Code): 답에 expect 낱말 중 하나가 있고 답의 version.release 가 점검한 release 와 ____③ 이면 통과.
|
||||
결과 {release, passed, total, failed, seconds, verdict}: 모두 통과면 ' ____④ ', 아니면 'rollback'.
|
||||
기대 결과 cases/lab03-challenge.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① `cases/lab03-challenge.json`의 `webhook` (AGENTS.md 3절 표).
|
||||
- ② 운영이 아니라 후보를 점검하려면 본문에 무엇을 더해야 하나요? ("먼저 생각해 보기")
|
||||
- ③ 고정이 먹었는지 확인하는 비교입니다.
|
||||
- ④ AGENTS.md 3절 `verdict`의 두 값 중 "내보내도 됨".
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/release-check.json
|
||||
uv run tools/flow.py test cases/lab03-challenge.json -v
|
||||
uv run tools/flow.py call my-release-check '{"release": "2.0.0"}' -v
|
||||
```
|
||||
|
||||
## 과제 2 · 점검을 남기고, 지난 것만 배포 (10분)
|
||||
|
||||
> "점검이 rollback이라고 했는데 누가 실수로 그 릴리스를 배포했어요. 점검 결과를 남기고, 점검을 지나지 않은 릴리스는 아예 배포가 안 되게 해 주세요. 카나리도요."
|
||||
|
||||
데이터 API에는 **배포 정책**이 있습니다. 켜면, 마지막 점검 기록이 `promote`인 릴리스만 `active` · 카나리가 됩니다 (아니면 409).
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py release helpdesk --require-gate on
|
||||
uv run tools/flow.py release helpdesk # 릴리스마다 마지막 점검 기록 (Lab 02의 gate가 남긴 것)
|
||||
uv run tools/flow.py release helpdesk 2.1.0 # 기록이 없거나 rollback이면 거절 (409)
|
||||
uv run tools/flow.py release helpdesk --reset # 정책 · active · 카나리는 처음으로 (점검 기록은 남음)
|
||||
uv run tools/flow.py test cases/lab03-challenge2.json -v # 고치기 전
|
||||
```
|
||||
|
||||
**만들 것**: 릴리스 점검이 판단한 뒤 그 결과를 `POST http://data-api:8800/gates/helpdesk`에 남기고, 답에 `recorded: true`.
|
||||
|
||||
| 조건 | 값 |
|
||||
| --- | --- |
|
||||
| 기록할 것 | `{release, verdict, passed, total, failed, seconds, source: "my-release-check"}` |
|
||||
| 기록이 실패하면 | 판단 결과는 그대로 보내고 `recorded: false` |
|
||||
| 결과 | 과제 1의 결과 + `recorded` |
|
||||
| 기대 결과 | `cases/lab03-challenge2.json` 5개 (정책을 켜고 1.2.0 · 1.1.0 점검 → 1.2.0 배포 거부 · 1.1.0 배포, 점검 안 한 2.0.0 카나리 거부) |
|
||||
|
||||
### 이해하기
|
||||
|
||||
**판단과 실행 사이의 틈.** 과제 1의 점검은 `rollback`이라고 **말할** 뿐입니다. 배포는 사람이 `flow.py release`로 하니, 사람이 점검을 안 보거나 잘못 보면 그대로 나갑니다. 판단을 **기록**하고, 배포하는 쪽이 그 기록을 **확인**하게 하면 틈이 닫힙니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["my-release-check"] -->|기록| G[("/gates/helpdesk<br/>1.2.0 rollback<br/>1.1.0 promote")]
|
||||
P(["release helpdesk 1.2.0"]) --> D{"데이터 API<br/>require_gate"}
|
||||
G --> D
|
||||
D -->|마지막 기록이 promote| OK(["배포"])
|
||||
D -->|아니면| NO(["409 not gated"])
|
||||
```
|
||||
|
||||
**누가 무엇을.** 워크플로우는 **기록만** 하고 배포는 하지 않습니다 (AGENTS.md 9절: 워크플로우 안에서 `/releases`에 POST 하지 않는다). 판단하는 쪽과 배포하는 쪽을 나누면, 점검 워크플로우가 고장 나거나 속아도 배포까지 저절로 일어나지는 않습니다. 73일차 "판별 LLM에는 Tool을 주지 않는다"와 같은 생각입니다(최소 권한).
|
||||
|
||||
**정책은 데이터 API가 지킵니다.** "점검을 지난 것만 배포"를 사람의 약속이나 문서로 두지 않고, 배포를 받는 곳(데이터 API)이 **거절**하게 합니다. 이것을 **코드로 된 정책(policy as code)**이라고 합니다. 카나리도 같은 정책을 받습니다. 점검 안 한 2.0.0은 카나리로도 못 나갑니다.
|
||||
|
||||
**기록이 실패해도 판단은 보냅니다.** 기록 노드가 실패했다고 점검 결과까지 500이면, 사람이 판단을 못 봅니다. 기록 노드는 `onError: continueRegularOutput`으로 두고 `recorded`로 성공 여부만 알립니다 (73일차 감사 로그와 같은 모양).
|
||||
|
||||
**`flow.py gate`도 기록합니다.** Lab 02에서 `gate --set release=...`를 돌릴 때마다 `/gates/helpdesk`에 한 줄씩 남았습니다 (`source: flow.py gate`). 그래서 예제 1의 1.1.0 배포는 정책이 켜져 있어도 됩니다(Lab 02 과제 1에서 PASS 기록이 있으면). 테스트의 `before`는 기록을 비우고 시작합니다.
|
||||
|
||||
**테스트 케이스 (`lab03-challenge2`, `before`: 기록 비우기 · reset · 정책 켜기, `after`: reset).**
|
||||
|
||||
| 케이스 | 하는 일 | 기대 |
|
||||
| --- | --- | --- |
|
||||
| 1.2.0 점검 · 기록 | `my-release-check` 1.2.0 | `rollback`, `recorded` true |
|
||||
| 1.1.0 점검 · 기록 | `my-release-check` 1.1.0 | `promote`, `recorded` true |
|
||||
| 1.2.0 배포 거부 | `before`: active 1.2.0 → **409**를 기대 | 운영은 그대로 1.0.0 |
|
||||
| 1.1.0 배포 | `before`: active 1.1.0 → 200 | `version.release` 1.1.0 · `stable` |
|
||||
| 2.0.0 카나리 거부 | `before`: canary 2.0.0 → **409**를 기대 | 최유진도 1.1.0 · `stable` |
|
||||
|
||||
**기대 결과.** 고치기 전: 점검 두 케이스가 ✘(`recorded`가 없음), 그리고 기록이 없어 1.1.0 배포도 409로 멈춥니다. 고친 뒤 5/5. `release helpdesk`에 릴리스마다 마지막 점검이 보입니다.
|
||||
|
||||
<details><summary>▶ 1단계 · 고치기 전에 왜 멈추나</summary>
|
||||
|
||||
고치기 전 `test -v`에서 `before`가 ✘로 멈추는 줄을 봅니다. 1.1.0을 배포하려는데 409 `not gated`, `last_gate: null`. 점검은 `promote`라고 답했지만 아무 데도 남기지 않았기 때문입니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 기록 노드를 어디에</summary>
|
||||
|
||||
Judge 뒤, Send verdict 앞. 기록의 응답(`{"stored": true}`)이 오면 Send verdict는 Judge의 결과에 `recorded`만 더해 보냅니다 (`$('Judge').first().json`).
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대</summary>
|
||||
|
||||
요청문에 꼭 들어갈 것: **① 넣을 노드와 자리 ② 기록 주소와 본문 ③ 실패했을 때 ④ 결과에 더할 칸 ⑤ 하지 말 것 ⑥ 기대 결과**. 아래 빈칸 4개(`____①`~`____④`)를 채워 `my-work/PROMPTS.md`에 쓴 뒤 에이전트에게 보냅니다.
|
||||
|
||||
```text
|
||||
my-work/release-check.json 의 Judge 와 Send verdict 사이에 Record gate(HTTP POST http://data-api:8800/ ____① ) 를 넣어 줘.
|
||||
본문: {release, verdict, passed, total, failed, seconds, source: 'my-release-check'} (Judge 의 결과에서).
|
||||
Record gate 는 timeout 3000, neverError, onError ____② (기록이 실패해도 판단은 보냄).
|
||||
Send verdict 는 Judge 의 결과에 recorded: (기록 응답의 ____③ 가 true 인지) 를 더해 보낸다.
|
||||
워크플로우에서 /releases 에는 ____④ . 기대 결과 cases/lab03-challenge2.json, cases/lab03-challenge.json.
|
||||
```
|
||||
|
||||
빈칸 힌트 (답이 아니라 **어디서 찾고 무엇을 생각하나**):
|
||||
|
||||
- ① AGENTS.md 2절 데이터 API 표에서 점검 기록을 남기는 경로 (릴리스 이름 포함).
|
||||
- ② 실패해도 **원래 출력으로** 계속 가게 하는 값입니다 (73일차 감사 로그 `Log event`와 같은 값).
|
||||
- ③ `POST /gates/helpdesk`가 성공하면 돌려주는 칸 이름 (`curl`로 한 번 보내 봐도 됩니다).
|
||||
- ④ 위 이해하기 "누가 무엇을". 워크플로우는 배포를 하나요?
|
||||
|
||||
채운 요청문은 수업 뒤 공개되는 정답본의 요청문 예와 비교합니다. 노드 이름은 내 파일의 실제 이름을 씁니다.
|
||||
</details>
|
||||
|
||||
```bash
|
||||
uv run tools/flow.py push my-work/release-check.json
|
||||
uv run tools/flow.py test cases/lab03-challenge2.json -v
|
||||
uv run tools/flow.py test cases/lab03-challenge.json
|
||||
uv run tools/flow.py release helpdesk # 릴리스마다 마지막 점검
|
||||
uv run tools/flow.py release helpdesk --reset
|
||||
```
|
||||
|
||||
마지막으로 `RELEASE.md` 2 · 3절, `CHANGELOG.md` 2절을 채웁니다.
|
||||
|
||||
## 막혔을 때
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| 카나리 대상이 테스트와 다름 | 글자 코드 합(`codePointAt`) · `% 100` · `<`(작다) |
|
||||
| 카나리 뒤 다른 테스트가 이상함 | `uv run tools/flow.py release helpdesk --reset` |
|
||||
| `lab03-watch`를 끈 뒤에도 ✘ | `--no-before`를 붙였는지 (붙이지 않으면 before가 카나리를 다시 켬) |
|
||||
| 점검이 늘 `rollback` | 점검이 받은 답의 `version.release`가 고정한 값인지, 기대 낱말이 데이터와 맞는지 |
|
||||
| 점검의 HTTP가 404 | 헬프데스크(`my-helpdesk`)가 push돼 있는지 |
|
||||
| `recorded`가 늘 false | 기록 주소(`/gates/helpdesk`), 본문의 `release` · `verdict`가 비지 않았는지 (`curl -s localhost:8800/gates/helpdesk`) |
|
||||
| 정책을 켠 뒤 배포가 다 409 | 정상. 점검(또는 `gate --set release=...`)을 먼저. 끝나면 `--reset` |
|
||||
| 계속 안 됨 | `cp reference/lab03-example2-canary.json my-work/helpdesk.json` 후 push |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 예제 1: 배포 · 되돌리기를 값 하나로, `cases/lab03.json` 2/2, `RELEASE.md` 3절.
|
||||
- [ ] 예제 2: `cases/lab03-canary.json` 6/6, `lab02`도 통과, `--reset`.
|
||||
- [ ] 예제 3: 나쁜 카나리에 영향 받은 직원 수, `--no-canary` 뒤 6/6, `--reset`.
|
||||
- [ ] 과제 1: `cases/lab03-challenge.json` 3/3, 2.0.0 점검 결과.
|
||||
- [ ] 과제 2: `cases/lab03-challenge2.json` 5/5, `release helpdesk`에 점검 기록, `--reset`.
|
||||
- [ ] `RELEASE.md`, `CHANGELOG.md`.
|
||||
@@ -0,0 +1,178 @@
|
||||
# 74일차 수강 안내 · 수업 준비부터 마칠 때까지
|
||||
|
||||
- 일자: 2026-10-28 (수) · 74일차
|
||||
- 주제: 모델·Prompt·Workflow 버전 관리와 운영 체계 (바뀐 것 남기기 · 게이트 · 카나리 · 되돌리기)
|
||||
- 실습 폴더: [Last3Mon/74](README.md) · 강의 자료: [Lecture/74](../../Lecture/74/README.md)
|
||||
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`, `gpt-5.4-nano`) + **74일차 판 데이터 API**(지시문 저장소 · 릴리스 · 점검 기록 · 배포 정책) + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
|
||||
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
P["전날<br/>준비 · v1 · v2 게시"] --> M["아침<br/>켜기 · doctor"] --> L1["1교시 강의 · 2교시<br/>Lab 01 실습 5개<br/>diff · version · 흔들림 · 저장소"] --> L2["3교시 강의 · 4교시<br/>Lab 02 실습 5개<br/>릴리스 · 게이트 · 기준선 · 망가진 릴리스"] --> L3["5교시 강의 · 6교시<br/>Lab 03 실습 5개<br/>배포 · 카나리 · 점검 · 정책"] --> V["7~8교시<br/>tver"] --> E["마친 뒤<br/>reset · 75 준비"]
|
||||
```
|
||||
|
||||
## 0. 하루 한눈에
|
||||
|
||||
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 전날 | 약 10분 | 자료 받기 · n8n · 74일차 데이터 API · v1 · v2 게시 · doctor | [PREP](../../Lecture/74/00-DAY74-PREP.md) | `ready` |
|
||||
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (무엇이 바뀌나) | [개요](../../Lecture/74/00-DAY74-OVERVIEW.md) 1~14장, [1단계](../../Lecture/74/01-STAGE1-WHAT-CHANGES.md) | |
|
||||
| 2 | 10:00~10:50 | Lab 01 실습 5개 (예제 1~3 · 과제 1~2) | [Lab 01](LAB-01-Q-WHAT-CHANGES.md) | `lab01-challenge` · `lab01-challenge2` 통과 |
|
||||
| 3 | 11:00~11:50 | 2단계 강의 (릴리스와 배포 전 게이트) | 개요 15~21장, [2단계](../../Lecture/74/02-STAGE2-RELEASE-GATE.md) | |
|
||||
| 4 | 12:00~12:50 | Lab 02 실습 5개 | [Lab 02](LAB-02-Q-RELEASE-GATE.md) | `my-golden`으로 1.2.0 FAIL, `lab02-challenge2` 통과 |
|
||||
| - | 13:00~14:00 | 점심 | | |
|
||||
| 5 | 14:00~14:50 | 3단계 강의 (배포 · 되돌리기 · 카나리) | 개요 22~28장, [3단계](../../Lecture/74/03-STAGE3-DEPLOY-AND-ROLLBACK.md) | |
|
||||
| 6 | 15:00~15:50 | Lab 03 실습 5개 | [Lab 03](LAB-03-Q-DEPLOY-AND-ROLLBACK.md) | `lab03-challenge` · `lab03-challenge2` 통과, `--reset` |
|
||||
| 7 | 16:00~16:50 | 바이브 코딩 `tver`: 요청서 → TODO → SPEC → IMP-PLAN → 만들기 → 설치 | [VIBE-REQ-TVER](VIBE-REQ-TVER.md) → [VIBE-Q-TVER](VIBE-Q-TVER.md) | `Doc/*.md` · R1~R6 · R8 · R14 · `pytest` |
|
||||
| 8 | 17:00~17:50 | 바이브 코딩 발표 · **4단계 정리** (오늘의 의미 · 효과 · 회사에서 쓰는 법) · 75일차 예고 | [4단계 정리](../../Lecture/74/04-STAGE4-WRAPUP.md), [진행](../../Lecture/74/05-VIBE-CODING.md) | 최종 프로젝트 체크리스트 |
|
||||
|
||||
## 1. 전날 준비 (약 10분)
|
||||
|
||||
### 1.1 자료 받기
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu && git pull
|
||||
```
|
||||
|
||||
### 1.2 n8n과 **74일차** 데이터 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/74/data-api
|
||||
docker compose up -d --force-recreate # 73일차 판을 74일차 판(+ 지시문 저장소 · 릴리스 · 점검 기록)으로 바꿈
|
||||
sleep 3 # 데이터 API가 뜰 때까지
|
||||
curl -s localhost:8800/releases/helpdesk | head -c 80
|
||||
```
|
||||
|
||||
- 73일차 판별 LLM 멈춤 연습을 되돌리지 않았다면: `cd ~/aio-01d4-stu/Last3Mon/73 && uv run tools/flow.py llm up`.
|
||||
|
||||
### 1.3 v1 · v2 게시 · 준비 확인
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/74
|
||||
uv run tools/flow.py push reference/helpdesk-v1.json reference/helpdesk-v2.json
|
||||
uv run tools/flow.py call rel-v1 requests/faq-vpn.json -v
|
||||
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||
```
|
||||
|
||||
### 1.4 미리 읽기 (선택, 5분)
|
||||
|
||||
[PREP 3절](../../Lecture/74/00-DAY74-PREP.md): 어제와 오늘 답이 다르면 무엇부터 확인할까? 그리고 7교시 [요청서](VIBE-REQ-TVER.md).
|
||||
|
||||
### 준비물
|
||||
|
||||
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, 68일차 `~/vibe/tflow` · 73일차 `~/vibe/tscan` (없어도 됨).
|
||||
|
||||
## 2. 수업 당일 아침 (09:00 전)
|
||||
|
||||
1. 노트북을 껐다 켰다면 1.2를 다시 합니다 (릴리스 · 점검 기록은 처음으로 돌아갑니다 — 괜찮음).
|
||||
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/74`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`diff`·`push`·`test`·`gate`·`release`).
|
||||
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`, `uv run tools/flow.py release helpdesk`.
|
||||
4. `cp templates/CHANGELOG-TEMPLATE.md my-work/CHANGELOG.md`, `cp templates/RELEASE-CHECKLIST-TEMPLATE.md my-work/RELEASE.md`, `my-work/PROMPTS.md`.
|
||||
|
||||
## 3. 실습할 때마다 같은 순서 (오늘은 "바뀐 것 → 버전 → 게이트")
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["① 무엇이 바뀌나<br/>diff · call"] --> B["② 요청문을<br/>메모장에"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test · gate"]
|
||||
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
|
||||
E -->|✔| G["⑦ CHANGELOG 한 줄"]
|
||||
```
|
||||
|
||||
- 배포 · 카나리 · 되돌리기 · 정책은 **내가** `flow.py release`로 합니다. 끝나면 `uv run tools/flow.py release helpdesk --reset`.
|
||||
- 후보는 `--set release=...`로 고정해 시험합니다.
|
||||
- `fault prompts error`(Lab 01 과제 2)는 끝나면 **반드시 되돌립니다** (`fault prompts ok`).
|
||||
|
||||
## 4. 1~2교시 · 1단계 · 무엇이 바뀌나
|
||||
|
||||
1교시는 강의, 2교시는 [Lab 01](LAB-01-Q-WHAT-CHANGES.md) (50분, 실습 5개).
|
||||
|
||||
| 순서 | 할 일 | 파일 · 웹훅 | 확인 (터미널 ②) |
|
||||
| --- | --- | --- | --- |
|
||||
| 예제 1 (10분, 에이전트 없이) | v1 → v2의 바뀐 것 7개 읽기 | `reference/helpdesk-v1.json` · `v2.json` | `diff reference/helpdesk-v1.json reference/helpdesk-v2.json [--full]`, `call rel-v1 -v` · `call rel-v2 -v` → `CHANGELOG.md` 1절 |
|
||||
| 예제 2 (12분) | 모델을 한 곳(릴리스 정보)에서, 답마다 `version` | `cp reference/helpdesk-v1.json my-work/helpdesk.json` → 웹훅 `my-helpdesk`로 | `push my-work/helpdesk.json` → `test cases/lab01.json -v` |
|
||||
| 예제 3 (8분, 에이전트 없이) | 같은 테스트를 v1 · v2에 3번씩, 시간 | (그대로) | `test cases/lab01-compare.json --webhook rel-v1 --repeat 3` · `rel-v2`, `bench rel-v1` · `rel-v2 requests/faq-vpn.json -n 5` |
|
||||
| 과제 1 (10분) | 지시문을 저장소(`/prompts/helpdesk/p1`)에서 | `my-work/helpdesk.json` 고치기 | `test cases/lab01-challenge.json -v`, `test cases/lab01.json -v` |
|
||||
| 과제 2 (10분) | 먼저 겪기: `fault prompts error` → `call` → `fault prompts ok`. 그다음 저장소가 멈추면 기본 지시문(`fallback`) | `my-work/helpdesk.json` 고치기 | `test cases/lab01-challenge2.json -v` (자동으로 고장 켜고 끔), `lab01-challenge`, `doctor` |
|
||||
|
||||
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
|
||||
|
||||
## 5. 3~4교시 · 2단계 · 릴리스와 게이트
|
||||
|
||||
3교시는 강의, 4교시는 [Lab 02](LAB-02-Q-RELEASE-GATE.md) (50분, 실습 5개).
|
||||
|
||||
| 순서 | 할 일 | 확인 |
|
||||
| --- | --- | --- |
|
||||
| 예제 1 (12분) | 운영 중인 릴리스를 `/releases/helpdesk`에서, 본문 `release`면 고정, 없는 릴리스 400 | `push` → `test cases/lab02.json -v`, `test cases/lab01-challenge.json`, `test cases/lab01-challenge2.json` |
|
||||
| 예제 2 (8분, 에이전트 없이) | 네 릴리스를 약한 `golden.json`으로 게이트, 1.2.0 답 읽기 | `gate cases/golden.json --set release=1.0.0 --max-p95 3` (1.1.0 · 1.2.0 · 2.0.0도), `call my-helpdesk '{... "release": "1.2.0"}' -v` → `RELEASE.md` 1절 |
|
||||
| 예제 3 (7분, 에이전트 없이) | 기준선: 1.0.0 · 2.0.0 시간, golden 3번 반복 | `bench my-helpdesk requests/faq-vpn.json -n 5 --set release=1.0.0` (2.0.0도), `gate cases/golden.json --set release=1.0.0 --repeat 3 --max-p95 3` |
|
||||
| 과제 1 (13분) | 1.2.0을 막는 `cases/my-golden.json` (1.0.0 · 1.1.0은 PASS), 2.0.0 판단 | `gate cases/my-golden.json --set release=...` 네 번, `RELEASE.md` 1절 |
|
||||
| 과제 2 (10분) | 망가진 릴리스 2.1.0(p9): 404는 400 `release broken`, 장애(500 · 시간 초과)만 기본 지시문 | `test cases/lab02-challenge2.json -v`, `gate cases/my-golden.json --set release=2.1.0` (고치기 전 PASS → 뒤 FAIL), `lab02` |
|
||||
|
||||
## 6. 5~6교시 · 3단계 · 배포 · 되돌리기 · 카나리
|
||||
|
||||
5교시는 강의, 6교시는 [Lab 03](LAB-03-Q-DEPLOY-AND-ROLLBACK.md) (50분, 실습 5개).
|
||||
|
||||
| 순서 | 할 일 | 확인 |
|
||||
| --- | --- | --- |
|
||||
| 예제 1 (8분, 에이전트 없이) | `release helpdesk 1.1.0` → 확인 → `release helpdesk 1.0.0` | `call my-helpdesk requests/faq-vpn.json -v`, `test cases/lab03.json -v`, `RELEASE.md` 3절 |
|
||||
| 예제 2 (12분) | 카나리: 이름 글자 코드 합 % 100 < percent, 같은 사람은 같은 쪽, 고정이 먼저 | `push` → `test cases/lab03-canary.json -v`, `test cases/lab02.json`, `release helpdesk --reset` |
|
||||
| 예제 3 (7분, 에이전트 없이) | 나쁜 카나리(1.2.0 50%)를 보고 끄기 | `test cases/lab03-watch.json -v` → `release helpdesk --no-canary` → `test cases/lab03-watch.json --no-before -v` → `--reset` |
|
||||
| 과제 1 (13분) | 릴리스 점검 `my-release-check`: 질문 6개를 고정해 묻고 `promote` · `rollback` | `push my-work/release-check.json` → `test cases/lab03-challenge.json -v`, `call my-release-check '{"release": "2.0.0"}' -v` |
|
||||
| 과제 2 (10분) | 점검 결과를 `/gates/helpdesk`에 남기고, 정책(`--require-gate on`)으로 지난 것만 배포 | `test cases/lab03-challenge2.json -v`, `release helpdesk`, `--reset` |
|
||||
|
||||
- 기록: `RELEASE.md` 2 · 3절, `CHANGELOG.md` 2절.
|
||||
|
||||
## 7. 7~8교시 · 바이브 코딩 `tver`
|
||||
|
||||
[VIBE-REQ-TVER](VIBE-REQ-TVER.md)(요청서)를 **먼저** 읽고 [VIBE-Q-TVER](VIBE-Q-TVER.md)의 순서대로 갑니다: `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`(엔진 · TUI 단계) → 단계별 구현 → 확인 → `uv tool install` → 다른 폴더에서 사용. 요구는 14개(R1~R14)이고 50분 안에는 엔진 1 · 2단계 · TUI 1단계 · 설치까지, 나머지는 `Doc/IMP-PLAN.md`의 다음 단계로 집에서 이어 갑니다. 만드는 것은 n8n 워크플로우를 git에 버전으로 저장하고 두 버전을 노드 단위로 비교해 모델 · 지시문 · 흐름으로 나눠 보여 주는 TUI. 정답본 `VIBE-A-TVER.md`는 수업이 끝나면 `git pull`로 받습니다.
|
||||
|
||||
```bash
|
||||
mkdir -p ~/vibe/tver/Doc && cd ~/vibe/tver
|
||||
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~4단계 · TUI 1단계 · 설치) · commit | 단계마다 체크리스트 |
|
||||
| 16:20~16:44 | 4 구현: 엔진 1단계(R1~R4) → 엔진 2단계(R5 · R6) → TUI 1단계(R8), 막히면 5 문제 해결 팁 | 단계마다 `pytest` · 직접 실행 · commit |
|
||||
| 16:44~16:50 | 6 확인 · 7 `uv tool install --editable .` · 8 다른 폴더에서 `tver` | `cd ~/aio-01d4-stu/Last3Mon/74 && tver diff reference/helpdesk-v1.json reference/helpdesk-v2.json` |
|
||||
|
||||
- 8교시: 1~2명 발표 (10분) → [4단계 정리](../../Lecture/74/04-STAGE4-WRAPUP.md) (35분: 오늘 한 일의 의미 · 효과 · 산업에서의 응용 · 면접 질문 · 최종 프로젝트 체크리스트) → 75일차 예고.
|
||||
|
||||
## 8. 마친 뒤
|
||||
|
||||
| 할 일 | 이유 |
|
||||
| --- | --- |
|
||||
| `uv run tools/flow.py release helpdesk --reset`, `fault prompts ok`, `doctor` → `ready` | 내일 처음 상태로 (정책 · 카나리 꺼짐) |
|
||||
| `PROMPTS.md`, `CHANGELOG.md`, `RELEASE.md` 정리 | 최종 프로젝트 재료 (운영 체계) |
|
||||
| `my-work/*.json`, `cases/my-golden.json`, `~/vibe/tver`, `~/.tver/store` 지우지 않기 | 75일차 비용 비교 · 79일차 `tops` |
|
||||
| 75일차 준비: [00-DAY75-PREP](../../Lecture/75/00-DAY75-PREP.md) | 사용량 · 비용 |
|
||||
|
||||
## 9. 막혔을 때 (오늘 공통)
|
||||
|
||||
| 증상 | 할 일 |
|
||||
| --- | --- |
|
||||
| doctor의 releases · gates ✘ | 데이터 API가 예전 판. 1.2의 74일차 `--force-recreate` |
|
||||
| doctor에 `release state is not the start state` | `uv run tools/flow.py release helpdesk --reset` |
|
||||
| doctor에 `fault switch is on` | `uv run tools/flow.py fault all ok` |
|
||||
| 늘 같은 릴리스 | 본문 `release` · `--set`을 붙였는지, 워크플로우가 `/releases`를 읽는지 |
|
||||
| 카나리 · 정책 뒤 다른 테스트가 이상함 | `release helpdesk --reset` |
|
||||
| 배포가 409 `not gated` | 정책이 켜져 있음. 점검(또는 `gate --set release=...`)을 먼저, 끝나면 `--reset` |
|
||||
| `call my-helpdesk`가 404 | 복사한 파일의 웹훅 경로를 바꿨는지, push했는지 |
|
||||
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] 전날 74일차 데이터 API, v1 · v2 게시, `doctor` → `ready`
|
||||
- [ ] Lab 01: `CHANGELOG.md` 1절, `lab01` 3/3, v1 · v2 흔들림 비교, `lab01-challenge` 3/3, `lab01-challenge2` 4/4
|
||||
- [ ] Lab 02: `lab02` 5/5, 네 릴리스 게이트, 기준선, `my-golden`으로 1.2.0 FAIL, `lab02-challenge2` 5/5 · 2.1.0 FAIL
|
||||
- [ ] Lab 03: `lab03` 2/2, `lab03-canary` 6/6, 나쁜 카나리 4/6 → 6/6, `lab03-challenge` 3/3, `lab03-challenge2` 5/5, `RELEASE.md`
|
||||
- [ ] `tver`: `Doc/TODO.md` · `SPEC.md` · `IMP-PLAN.md` 먼저, 엔진 1 · 2단계 · TUI 1단계, `pytest`, 단계마다 commit, `uv tool install`
|
||||
- [ ] `--reset` · `fault prompts ok`, 75일차 준비
|
||||
@@ -0,0 +1,94 @@
|
||||
# 74일차 실습 · 모델·Prompt·Workflow 버전 관리와 운영 체계 (바이브 코딩)
|
||||
|
||||
- 일자: 2026-10-28 (74일차)
|
||||
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||
- 단원: AI 워크플로우 운영 및 관리 역량 강화
|
||||
- 강의 자료: [Lecture/74](../../Lecture/74/README.md)
|
||||
- 하루 수강 안내: [LECTURE-74.md](LECTURE-74.md) (수업 준비부터 마칠 때까지 순서대로)
|
||||
- 67~73일차처럼 CLI 코딩 에이전트에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다. 오늘은 고칠 때마다 **무엇이 바뀌었는지 남기고, 배포 전에 검사하고, 조금씩 내보내고, 되돌릴 수 있게** 합니다.
|
||||
|
||||
## 오늘 만드는 것: 버전이 붙은 헬프데스크
|
||||
|
||||
모델 이름 · 지시문 · 워크플로우가 한 JSON 안에 섞여 있으면 "어제와 무엇이 달라졌나"를 알기 어렵습니다. 지시문과 모델을 **릴리스**로 묶어 밖에서 관리하고, 답마다 버전을 남기고, 후보 릴리스를 **게이트**로 검사한 뒤 카나리 · 배포 · 되돌리기를 합니다. 점검 결과를 남기고, 점검을 지난 릴리스만 배포되게 합니다.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph API["데이터 API (사람이 관리)"]
|
||||
P[("지시문 저장소<br/>p1 · p2 · p3")]
|
||||
REL[("릴리스<br/>1.0.0 · 1.1.0 · 1.2.0 · 2.0.0 · 2.1.0<br/>active · canary · 정책")]
|
||||
GT[("점검 기록 /gates")]
|
||||
end
|
||||
Q(["질문 · user · release?"]) --> PK["릴리스 고르기<br/>고정 > 카나리 > active"] --> GP["지시문 가져오기<br/>장애면 기본 지시문 · 404면 400"] --> A{{"답<br/>모델 = 릴리스의 모델"}} --> O(["답 + version"])
|
||||
REL --> PK
|
||||
P --> GP
|
||||
G["게이트 · my-release-check"] -.->|기록| GT
|
||||
GT -.->|정책| REL
|
||||
```
|
||||
|
||||
## 일하는 방식 (67~73일차와 같음)
|
||||
|
||||
| 누가 | 무엇을 |
|
||||
| --- | --- |
|
||||
| 나 | 바뀐 것 읽기(`diff`), 흔들림 재기, 게이트 테스트 쓰기(기대는 데이터에서), 배포 · 카나리 · 되돌리기 · 정책(`flow.py release`), 판단 |
|
||||
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기 |
|
||||
| `tools/flow.py` | check · push · call · test(`--set release=...`) · **diff** · **gate** · **release** · bench · fault · llm · doctor (모두 `-v`) |
|
||||
|
||||
> 게이트는 **테스트만큼만** 막습니다. 나쁜 릴리스를 막지 못한 게이트는 테스트가 약한 것입니다.
|
||||
|
||||
## 실습 15개 (Lab마다 예제 3 · 과제 2)
|
||||
|
||||
| | 예제 1 | 예제 2 | 예제 3 | 과제 1 | 과제 2 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| [Lab 01](LAB-01-Q-WHAT-CHANGES.md) | v1 → v2 diff (에이전트 없이) | 답마다 `version` | 흔들림인가 바뀜인가 (에이전트 없이) | 지시문을 저장소에서 | 저장소가 멈추면 기본 지시문 |
|
||||
| [Lab 02](LAB-02-Q-RELEASE-GATE.md) | 릴리스 읽기 · 후보 고정 | 약한 golden 게이트 (에이전트 없이) | 기준선: 시간 · 반복 (에이전트 없이) | 나쁜 릴리스를 막는 게이트 | 망가진 릴리스는 400 |
|
||||
| [Lab 03](LAB-03-Q-DEPLOY-AND-ROLLBACK.md) | 배포 · 되돌리기 (에이전트 없이) | 카나리 | 나쁜 카나리 끄기 (에이전트 없이) | 릴리스 점검 워크플로우 | 점검 기록 · 지난 것만 배포 |
|
||||
|
||||
## 실습 문서 읽는 법
|
||||
|
||||
- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TVER.md`)에는 **복사해 붙일 요청문이 없습니다.** 실습마다 `### 이해하기`(왜 · 지금 → 바뀐 뒤 · 테스트 케이스 · 기대 결과)를 읽고, 막히면 `▶` 를 하나씩 펼칩니다. 요청문은 `▶ 빈칸 채우기`의 빈칸 4개(`____①`~`____④`)를 채워서 씁니다.
|
||||
- 쓴 요청문은 `my-work/PROMPTS.md`에, 바뀐 것은 `my-work/CHANGELOG.md`에, 게이트 · 배포 기록은 `my-work/RELEASE.md`에 모아 둡니다.
|
||||
|
||||
## 구성
|
||||
|
||||
```text
|
||||
Last3Mon/74/
|
||||
├── README.md
|
||||
├── LECTURE-74.md # 하루 수강 안내
|
||||
├── AGENTS.md # CLI 에이전트용 작업 규칙 (계약 3절, 릴리스 · 지시문 · 기본 지시문 · 점검 기록 노드 5절)
|
||||
├── LAB-01-Q-WHAT-CHANGES.md # 1단계 · diff · version · 흔들림 · 지시문 저장소 · 저장소가 멈추면
|
||||
├── LAB-02-Q-RELEASE-GATE.md # 2단계 · 릴리스 고르기 · 게이트 · 기준선 · 강한 테스트 · 망가진 릴리스
|
||||
├── LAB-03-Q-DEPLOY-AND-ROLLBACK.md # 3단계 · 배포 · 카나리 · 나쁜 카나리 · 릴리스 점검 · 점검 기록
|
||||
├── VIBE-REQ-TVER.md # 7교시 바이브 코딩 요청서 (가장 먼저 읽기)
|
||||
├── VIBE-Q-TVER.md # 7교시 바이브 코딩 · tver (워크플로우 버전 저장 · 비교 TUI)
|
||||
├── tools/flow.py
|
||||
├── data-api/ # 74일차 판 데이터 API (73일차 + 지시문 저장소 + 릴리스 + 점검 기록 · 배포 정책)
|
||||
│ └── data/prompts/ · releases.json
|
||||
├── cases/ # lab01 · lab01-compare · lab01-challenge(2) · lab02 · golden · lab02-challenge2 · lab03 · lab03-canary · lab03-watch · lab03-challenge(2)
|
||||
├── requests/
|
||||
├── reference/ # helpdesk-v1 · v2 (비교용) + 막혔을 때 이어 가는 워크플로우
|
||||
├── templates/ # 변경 기록 · 릴리스 점검표
|
||||
├── my-work/
|
||||
└── runs/
|
||||
```
|
||||
|
||||
## 준비
|
||||
|
||||
[실습 준비](../../Lecture/74/00-DAY74-PREP.md)를 끝내 주세요. 요약:
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기
|
||||
cd ~/aio-01d4-stu/Last3Mon/74/data-api && docker compose up -d --force-recreate # 74일차 판
|
||||
cd ~/aio-01d4-stu/Last3Mon/74 && uv run tools/flow.py push reference/helpdesk-v1.json reference/helpdesk-v2.json
|
||||
uv run tools/flow.py doctor # 마지막 줄 ready
|
||||
```
|
||||
|
||||
## `flow.py` 명령 (오늘 더해진 것)
|
||||
|
||||
| 명령 | 하는 일 |
|
||||
| --- | --- |
|
||||
| `uv run tools/flow.py diff reference/helpdesk-v1.json reference/helpdesk-v2.json [--full]` | 노드 · 설정 · 연결 차이 (`--full` · `-v`면 긴 값 전 · 후) |
|
||||
| `uv run tools/flow.py test cases/golden.json --set release=1.1.0 -v` | 모든 요청에 `release`를 더해 후보 시험 |
|
||||
| `uv run tools/flow.py gate cases/golden.json --set release=1.2.0 --max-p95 3 [--repeat 3]` | 통과율 · p95로 PASS/FAIL, 결과를 `/gates/helpdesk`에 남김 |
|
||||
| `uv run tools/flow.py bench my-helpdesk requests/faq-vpn.json -n 5 --set release=2.0.0` | 릴리스를 고정해 시간 재기 |
|
||||
| `uv run tools/flow.py release helpdesk` · `release helpdesk 1.1.0` · `--canary 1.1.0 --percent 50` · `--no-canary` · `--require-gate on` · `--reset` | 지금 릴리스(릴리스마다 마지막 점검) 보기 · 배포 · 카나리 · 끄기 · 정책 · 처음 상태로 |
|
||||
| `uv run tools/flow.py fault prompts error\|slow\|ok` | 지시문 저장소 고장 연습 (끝나면 `ok`) |
|
||||
@@ -0,0 +1,291 @@
|
||||
# 7교시 바이브 코딩 · `tver` 만들기
|
||||
|
||||
> **먼저 [VIBE-REQ-TVER.md](VIBE-REQ-TVER.md)(요청서)를 읽고 `~/vibe/tver/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/), git
|
||||
- 결과물: n8n 워크플로우 JSON을 **git에 버전으로 저장**하고, 두 버전을 **노드 단위로** 비교해 바뀐 것을 **모델 · 지시문 · 흐름**으로 나눠 보여 주는 TUI. 지시문은 줄 단위로, 뜻 없는 차이는 빼고, 키가 든 파일은 저장하지 않습니다. `uv tool install`로 설치해 **어느 폴더에서나** `tver`로 씁니다. 오늘 `flow.py diff`로 한 일을 내 도구로.
|
||||
|
||||
```text
|
||||
$ tver ~/aio-01d4-stu/Last3Mon/74/my-work/helpdesk.json
|
||||
┌ tver · helpdesk.json · ~/.tver/store ──────────────────────────────────────┐
|
||||
│ 6 version(s) │
|
||||
│ ┌ 버전 ─────────────────────┐ ┌ 바뀐 것 ──────────────────────────────────┐ │
|
||||
│ │ > 87090f0 10-27 21:08 1 … │ │ b8461aa → e0dc13e · 15 change(s) · model 1 │ │
|
||||
│ │ 9e2ac9a 10-27 21:08 14 … │ │ ├ Get release [flow] + node … │ │
|
||||
│ │ e0dc13e 10-27 21:08 15 … │ │ ├ Answer model [model] ~ model.value … │ │
|
||||
│ │ b8461aa 10-27 21:08 12 … │ │ └ Answer [prompt] ~ systemMessage … │ │
|
||||
│ └────────────────────────────┘ └────────────────────────────────────────────┘ │
|
||||
│ ┌ 자세히 ────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ ~ 'Answer model' parameters.model.value: ={{ $('Release info')… → … │ │
|
||||
│ └────────────────────────────────────────────────────────────────────────┘ │
|
||||
│ [s] 지금 파일을 버전으로 저장 [q] 끝 │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
|
||||
>
|
||||
> **시험할 때 내 n8n을 바꾸지 않습니다.** `tver`는 n8n에서 가져오기만 하고 올리지 않습니다. 저장소는 `~/.tver/store`(또는 `--repo`)에 따로 둡니다.
|
||||
|
||||
## 시간 (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단계 → 엔진 2단계 → TUI 1단계** (막히면 5) | 단계마다 `pytest` · 직접 실행 · commit |
|
||||
| 16:44~16:50 | 6 확인 · 7 `uv tool install` · 8 다른 폴더에서 | `cd ~/aio-01d4-stu/Last3Mon/74 && tver diff reference/helpdesk-v1.json reference/helpdesk-v2.json` |
|
||||
|
||||
50분 안 목표: **R1 · R2 · R3 · R4(엔진 1단계) + R5 · R6(엔진 2단계) + R8(TUI 1단계) + R14(설치)**. R7 · R9~R13은 IMP-PLAN의 다음 단계로 8교시 뒤 · 집에서 이어 갑니다.
|
||||
|
||||
## 0. 준비 (1분)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/vibe/tver/Doc && cd ~/vibe/tver
|
||||
git init
|
||||
# 에이전트는 Doc/TODO.md를 쓴 뒤에 켭니다: codex (또는 claude, agy)
|
||||
```
|
||||
|
||||
## 1. `Doc/TODO.md` · 만들고 싶은 것과 할 일 정리 (8분, 에이전트 없이)
|
||||
|
||||
[VIBE-REQ-TVER.md](VIBE-REQ-TVER.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
|
||||
# tver SPEC
|
||||
|
||||
## 1. 요구 (확인할 수 있게)
|
||||
| 번호 | 요구 | 요청의 어느 말 | 확인 방법 | 꼭 · 나중 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| R1 | | ① | | 꼭 |
|
||||
|
||||
## 2. 숨은 요구
|
||||
## 3. 범위 밖 (하지 않을 것)
|
||||
## 4. 정해 둘 것 (짝짓는 기준, 빼는 칸, 모델 · 지시문 · 흐름 분류 규칙, 저장소 위치, 파일 이름, 종료 코드)
|
||||
```
|
||||
|
||||
<details><summary>▶ 확인 방법은 v1 · v2로</summary>
|
||||
|
||||
"노드 단위로 비교한다"는 확인 방법이 아닙니다. "`helpdesk-v1` → `v2`: 7개, 모델 1 · 지시문 1 · 흐름 5 (오늘 `flow.py diff`와 같은 7줄)"가 확인 방법입니다. **바뀌었다고 나와야 할 것과 나오면 안 되는 것**(노드 위치만 바꾼 파일 → 0개)을 둘 다 적습니다 (오늘 Lab 02 과제 1의 강한 golden과 같음).
|
||||
</details>
|
||||
|
||||
쓴 뒤 아래 표와 비교합니다. 빠진 요구, 내가 더 뽑은 요구를 `Doc/SPEC.md` 끝 "VIBE-Q와 비교"에 한 줄씩 적고 SPEC을 고칩니다.
|
||||
|
||||
### 요구 사항 (요청서에서 뽑은 것, 14개)
|
||||
|
||||
| 번호 | 요구 | 요청서의 말 | 확인 방법 | 50분 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| R1 | 두 파일을 노드 **이름**으로 짝지어 + 노드 · − 노드 · ~ 바뀐 설정(점 경로: 전 → 후) · + / − 연결 (`tver diff a.json b.json`) | ① | v1 → v2 → 7줄, `flow.py diff`와 같음 | 꼭 |
|
||||
| R2 | 뜻 없는 차이 빼기: `id` · `position` · `webhookId` · 내보내기 도장(`versionId` · `updatedAt` 등), 노드 순서 · 키 순서 | ② | 노드 위치 · id를 바꾸고 순서를 뒤집은 파일 → 0개 | 꼭 |
|
||||
| R3 | 80자 넘거나 여러 줄인 값(지시문 · 코드)은 **줄 단위** diff (`+` · `-` 줄) | ③ | v1 → v2의 `systemMessage`에 `+ … [FAQ-003]` 줄 | 꼭 |
|
||||
| R4 | 바뀐 것을 **모델 · 지시문 · 흐름**으로 나눠 한 줄 요약 | ④ | v1 → v2 → "7 change(s) · model 1 · prompt 1 · flow 5" | 꼭 |
|
||||
| R5 | `tver save <파일>`: 정리한 JSON을 git 저장소에 쓰고 커밋, 메시지는 바로 전 버전과의 요약. 같으면 "no change"(커밋 없음) | ⑤ | 같은 파일 두 번 → 버전 1개 | 꼭 |
|
||||
| R6 | `tver log <이름>` 버전 목록(커밋 · 시각 · 요약), `tver diff <rev1> <rev2> <이름>` 두 버전 비교 | ⑥ | 두 번 저장 → 목록 2줄, 비교가 R1 · R4와 같은 모양 | 꼭 |
|
||||
| R7 | 키처럼 보이는 값(`sk-…` 등)이 있으면 저장 거부, 메시지에도 **가려서** | ⑦ | 가짜 키를 넣은 파일 → 저장 안 됨, 메시지에 원래 값 없음 | 나중 |
|
||||
| R8 | TUI: 왼쪽 버전 목록, 오른쪽 바뀐 노드 트리(고른 버전 vs 바로 전), 바뀐 것 하나를 고르면 전 · 후 / 줄 diff. `[s]` 지금 파일을 저장 | ⑧ | `tver <파일>` → 목록 · 트리 · 자세히, `[s]` 뒤 목록이 한 줄 늘어남 | 꼭 |
|
||||
| R9 | `tver pull`: 켜진 n8n 컨테이너에서 모든 워크플로우를 내보내 각각 버전으로 (**n8n에는 쓰지 않음**) | ⑨ · 끝 | `tver pull` → "N new version(s)", n8n은 그대로 | 나중 |
|
||||
| R10 | `tver restore <rev> <이름> -o <파일>`: 예전 버전을 파일로 (저장소는 바꾸지 않음, push는 사람이) | ⑩ | 꺼낸 파일과 그 버전을 `tver diff` → 0개, `log`의 버전 수 그대로 | 나중 |
|
||||
| R11 | `--json` 결과, 종료 코드: diff 0 같음 · 1 다름, 모든 명령 2 오류 | ⑪ | `tver diff a a; echo $?` → 0, `a b` → 1, 없는 파일 → 2 | 나중 |
|
||||
| R12 | 이름만 바뀐 노드: 짝 없는 − · + 노드 중 종류 · 설정이 같으면 "renamed" 한 줄 | ⑫ | `Search FAQ` → `Find FAQ`로만 바꿈 → `renamed` 1개 | 나중 |
|
||||
| R13 | `tver save <폴더>`: 폴더의 `*.json` 중 **워크플로우만**(`nodes` · `connections`) 저장, 나머지는 skipped | 끝 "워크플로우 파일만" | `my-work`에 `cases.json` · 메모 → skipped | 나중 |
|
||||
| R14 | `uv tool install`로 설치해 **어느 폴더에서나** `tver`, 저장소는 내 프로젝트 밖(`~/.tver/store`, `--repo` · `$TVER_REPO`) | 첫 문단 · 끝 | `cd /tmp && tver --help`, `uv tool list`에 `tver` | 꼭 |
|
||||
|
||||
숨은 요구 (요청서에 직접 없지만 없으면 안 되는 것):
|
||||
|
||||
| 숨은 요구 | 어디서 나왔나 |
|
||||
| --- | --- |
|
||||
| **저장할 때도** 정리한 JSON(키 정렬, 뜻 없는 칸 없음)으로 쓴다. 그래야 "같다"를 글자 비교로 판단하고, 저장소의 `git diff`도 읽힌다 | ② + ⑤ |
|
||||
| 분류 규칙을 정해 둔다: Chat model의 `model` → 모델, Agent의 `systemMessage` · `text` · Code의 `jsCode` → 지시문, 나머지 설정과 연결 → 흐름 | ④ |
|
||||
| 파일 이름을 안전하게: 워크플로우 이름의 공백 · `/` · 한글을 바꾼 소문자 이름 | ⑨ |
|
||||
| 엔진(정리 · 비교 · 저장)과 화면(TUI)을 **나눈다.** `diff` · `save` · `--json`은 화면 없이 엔진만 씀 | ⑪ "다른 프로그램이 읽는", 테스트하기 쉬움 |
|
||||
| 테스트용 저장소는 `tmp_path`에. 내 진짜 `~/.tver/store`를 쓰는 테스트 금지 | "시험할 때 망가뜨리면 안 돼요" |
|
||||
| `pyproject.toml`에 명령 이름(`[project.scripts] tver = ...`)이 있어야 `uv tool install`이 된다 | R14 |
|
||||
|
||||
범위 밖 (오늘 하지 않음): n8n에 자동으로 올리기(push), 여러 사람이 동시에 저장할 때 합치기, 원격 git 서버로 보내기, Credential 값 다루기.
|
||||
|
||||
`git add Doc/SPEC.md && git commit -m "SPEC"`
|
||||
|
||||
## 3. `Doc/IMP-PLAN.md` · 구현 계획 (5분)
|
||||
|
||||
SPEC의 요구를 **단계**로 나눕니다. 첫 원칙: **엔진(백엔드)을 먼저, 화면(TUI)은 나중에.** 화면은 엔진이 내놓은 바뀐 것 목록을 보여 주기만 하면 되게 만듭니다. 에이전트에게 "SPEC.md를 읽고 IMP-PLAN.md를 엔진 단계와 TUI 단계로 나눠 써 줘. 단계마다 체크리스트와 확인 명령"이라고 시켜도 됩니다 (내가 읽고 고침).
|
||||
|
||||
```markdown
|
||||
# tver IMP-PLAN
|
||||
|
||||
## 엔진 1단계 · 정리하고 비교하기 (R1 · R2 · R3 · R4)
|
||||
- [ ] clean(workflow): 뜻 없는 칸 빼기, 노드 이름순, dumps는 키 정렬
|
||||
- [ ] compare(old, new) → [Change{op, node, category, key, before, after, lines}]
|
||||
- [ ] category(node, key): model · prompt · flow, summary() · 한 줄 요약
|
||||
- [ ] tests: v1 → v2 7개 · 1/1/5, 위치만 바꾼 파일 0개, 지시문 줄 diff
|
||||
- [ ] cli: tver diff a.json b.json
|
||||
- 확인: uv run pytest -q, uv run tver diff <v1> <v2>
|
||||
|
||||
## 엔진 2단계 · git에 저장 · 목록 (R5 · R6)
|
||||
## TUI 1단계 · 버전 목록 · 바뀐 것 트리 · 자세히 (R8)
|
||||
## 설치 (R14)
|
||||
## 엔진 3단계 · 키 거부 · JSON · 폴더 (R7 · R11 · R13)
|
||||
## 엔진 4단계 · n8n에서 가져오기 · 꺼내기 · 이름 바꿈 (R9 · R10 · R12)
|
||||
```
|
||||
|
||||
<details><summary>▶ 왜 엔진 먼저인가</summary>
|
||||
|
||||
- 엔진은 **화면 없이 테스트**할 수 있습니다 (`pytest`, `tver diff`). 화면부터 만들면 바뀐 수가 맞는지 눈으로만 봅니다.
|
||||
- 배포 전 검사(R11)는 화면 없이 돌아야 합니다. 74일차 게이트처럼 종료 코드로 다음 단계를 정합니다.
|
||||
- TUI는 엔진에게 "이 버전과 바로 전 버전의 바뀐 것"을 받아 트리에 넣기만 하면 됩니다.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 단계 크기</summary>
|
||||
|
||||
한 단계 = 에이전트 요청 한두 번 + 확인 한 번 + commit 한 번 (10분 안팎). 단계가 크면 에이전트가 많이 바꾸고, 틀렸을 때 어디가 틀렸는지 모릅니다. 오늘 Lab의 "한 릴리스에 한 가지"와 같은 생각입니다.
|
||||
</details>
|
||||
|
||||
`git add Doc/IMP-PLAN.md && git commit -m "IMP-PLAN"`
|
||||
|
||||
## 4. 구현 시작 (24분: 엔진 1단계 → 엔진 2단계 → TUI 1단계)
|
||||
|
||||
이제 에이전트를 켭니다. 첫 요청은 늘 같습니다.
|
||||
|
||||
```text
|
||||
Doc/SPEC.md 와 Doc/IMP-PLAN.md 를 읽어 줘. 아직 코드는 쓰지 말고,
|
||||
IMP-PLAN 의 "엔진 1단계"를 어떻게 만들지와 각 체크 항목의 확인 방법을 5줄로 먼저 말해 줘.
|
||||
```
|
||||
|
||||
그다음 **한 단계씩**: "엔진 1단계만 만들어 줘. 끝나면 IMP-PLAN의 체크를 표시하고 확인 명령을 알려 줘" → 내가 확인 → commit → 다음 단계.
|
||||
|
||||
> **규모가 큰 프로젝트일수록 다단계로** 진행합니다. 오늘 `tver`도 요구가 14개라 한 번에 시키면 에이전트가 수백 줄을 한꺼번에 쓰고, 어디가 틀렸는지 찾기 어렵습니다. 단계마다 ① 그 단계만 요청 ② 직접 확인 ③ IMP-PLAN 체크 ④ `git commit`.
|
||||
|
||||
<details><summary>▶ 엔진 1단계 · 무엇으로 짝짓나</summary>
|
||||
|
||||
노드는 `id`도 있고 `name`도 있습니다. 에이전트가 파일을 새로 쓰면 `id`가 바뀌기도 합니다 (오늘 정답 파일들도 그랬습니다). 어느 것으로 맞춰야 "같은 노드"를 찾을까요? 이름을 바꾼 노드는 R12에서.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 엔진 1단계 · 모델 · 지시문 · 흐름</summary>
|
||||
|
||||
어느 설정이 "모델"이고 어느 것이 "지시문"인가요? (Chat model 노드의 `parameters.model.value`, Agent의 `options.systemMessage` · `text`, Code의 `jsCode`) 나머지와 연결은 "흐름". 테스트로 v1 → v2가 "model 1 · prompt 1 · flow 5"가 되는지 고정합니다. 웹훅 경로 변경은 어디로 셀까요?
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 엔진 2단계 · 왜 정리해서 저장하나</summary>
|
||||
|
||||
n8n에서 노드를 조금 옮기기만 해도 `position`이 바뀌어 git diff가 시끄러워집니다. 키 순서도 매번 다를 수 있습니다. 저장 전에 같은 정리(엔진 1단계의 `clean`)를 거치면 "바뀐 게 없음"을 글자 비교로 알 수 있습니다. 커밋 메시지는 바로 전 버전과 `compare`한 요약 한 줄.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ TUI 1단계 · 화면 나누기</summary>
|
||||
|
||||
데이터(두 버전 → 바뀐 것 목록)와 화면을 나눕니다 (`tflow` · `tscan`과 같음). 버전을 고를 때마다 git에서 두 버전을 꺼내 비교하므로 worker로 돌립니다. 트리는 노드 이름 → 바뀐 것들, 잎을 고르면 아래에 전 · 후.
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 다음 단계들 (8교시 뒤 · 집에서)</summary>
|
||||
|
||||
- 엔진 3단계: 키 거부(R7, 73일차 `tscan`의 키 모양 · 가리기), `--json` · 종료 코드(R11), 폴더 저장(R13).
|
||||
- 엔진 4단계: n8n에서 가져오기(R9, `docker exec … n8n export:workflow --all`), 꺼내기(R10, `git show <rev>:<파일>`), 이름 바꿈(R12).
|
||||
</details>
|
||||
|
||||
## 5. 바이브 코딩으로 문제 해결 팁
|
||||
|
||||
| 이럴 때 | 이렇게 |
|
||||
| --- | --- |
|
||||
| 에이전트가 "다 됐다"고 함 | **직접 실행**합니다 (`uv run tver diff ...`, `uv run pytest -q`). 확인 방법은 SPEC에 있습니다 |
|
||||
| 실패 · 오류 | 오류 출력 **전부**와 실행한 명령을 그대로 붙여 줍니다 |
|
||||
| 같은 곳을 3번 고쳐도 안 됨 | 멈추고 `git diff` · `git checkout .`로 되돌린 뒤, 단계를 **더 작게** 나눠 다시 요청 |
|
||||
| 모든 노드가 "바뀜"으로 나옴 | "id · position을 빼고 비교" |
|
||||
| 노드 짝이 안 맞음 | "name으로 짝짓기" |
|
||||
| 지시문 diff가 한 줄로 뭉개짐 | "80자 넘는 값은 difflib로 줄 단위" |
|
||||
| 같은 파일인데 커밋이 계속 생김 | "키 정렬 · 정리 후 저장, 바뀐 게 없으면 커밋하지 않기" |
|
||||
| 테스트가 내 `~/.tver/store`를 씀 | "테스트는 `tmp_path`의 저장소로 (`--repo`)" |
|
||||
| 화면이 이상한데 오류가 안 보임 | `uv add --dev textual-dev` 뒤 다른 터미널에서 `uv run textual console` |
|
||||
| 대화가 길어져 에이전트가 앞 내용을 잊음 | 새 세션을 열고 "Doc/SPEC.md · Doc/IMP-PLAN.md를 읽고, 체크 안 된 첫 단계부터"로 다시 시작 |
|
||||
| 설치한 `tver`가 고친 내용을 모름 | `uv tool install --force .` 로 다시 설치 (또는 처음부터 `--editable`) |
|
||||
|
||||
## 6. 결과 확인
|
||||
|
||||
`Doc/SPEC.md` 1절 표의 확인 방법대로 **내가** 하나씩 하고 체크합니다. 오늘 50분 안 목표만:
|
||||
|
||||
```bash
|
||||
cd ~/vibe/tver
|
||||
L=~/aio-01d4-stu/Last3Mon/74
|
||||
uv run pytest -q
|
||||
uv run tver diff $L/reference/helpdesk-v1.json $L/reference/helpdesk-v2.json # R1 · R3 · R4: 7개 · model 1 · prompt 1 · flow 5
|
||||
uv run tver --repo /tmp/tver-try save $L/reference/helpdesk-v1.json # R5: saved
|
||||
uv run tver --repo /tmp/tver-try save $L/reference/helpdesk-v1.json # R5: no change
|
||||
uv run tver --repo /tmp/tver-try log helpdesk-v1.json # R6: 1줄
|
||||
```
|
||||
|
||||
- [ ] R1 노드 · 설정 · 연결.
|
||||
- [ ] R2 뜻 없는 차이 없음.
|
||||
- [ ] R3 지시문 줄 단위.
|
||||
- [ ] R4 모델 · 지시문 · 흐름 요약.
|
||||
- [ ] R5 저장 · 같으면 저장 안 함.
|
||||
- [ ] R6 목록 · 두 버전 비교.
|
||||
- [ ] R8 TUI 목록 · 트리 · 자세히.
|
||||
- [ ] (이어서) R7 · R9 · R10 · R11 · R12 · R13.
|
||||
|
||||
## 7. `uv tool install`로 설치
|
||||
|
||||
`pyproject.toml`에 명령이 등록되어 있는지 먼저 봅니다.
|
||||
|
||||
```toml
|
||||
[project.scripts]
|
||||
tver = "tver.cli:main"
|
||||
```
|
||||
|
||||
```bash
|
||||
cd ~/vibe/tver
|
||||
uv tool install --editable . # 고친 내용이 바로 반영됨 (수업 중 권함)
|
||||
uv tool list # tver 가 보이는가
|
||||
which tver # ~/.local/bin/tver
|
||||
# 명령을 못 찾으면: uv tool update-shell 뒤 터미널을 새로 엶
|
||||
```
|
||||
|
||||
- 지우기: `uv tool uninstall tver`.
|
||||
|
||||
## 8. 다른 곳에서 사용
|
||||
|
||||
설치했으면 오늘 Lab에 씁니다.
|
||||
|
||||
```bash
|
||||
cd ~/aio-01d4-stu/Last3Mon/74
|
||||
tver diff reference/helpdesk-v1.json reference/helpdesk-v2.json
|
||||
tver save my-work/helpdesk.json # 오늘 단계마다 한 번씩 저장했다면 버전이 여러 개
|
||||
tver log helpdesk.json
|
||||
tver my-work/helpdesk.json # TUI
|
||||
tver save my-work # (R13) 워크플로우만, 나머지는 skipped
|
||||
tver pull # (R9) 지금 n8n의 워크플로우를 모두 버전으로 (읽기만)
|
||||
```
|
||||
|
||||
- `~/vibe/tver`와 `~/.tver/store`는 지우지 마세요. 79일차 `tops`에서 다시 씁니다.
|
||||
|
||||
## 발표 (8교시, 3분)
|
||||
|
||||
| 내용 | |
|
||||
| --- | --- |
|
||||
| TODO → SPEC | 내 SPEC과 VIBE-Q 요구 표가 달랐던 곳 (빠뜨린 요구, 더 뽑은 요구) |
|
||||
| IMP-PLAN | 엔진 · TUI를 어떻게 나눴나, 어디까지 체크했나 |
|
||||
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
|
||||
| 막힌 곳 | 5절의 어느 팁으로 풀었나 (짝짓기 · 시끄러운 diff · 같은 파일 커밋) |
|
||||
| 시연 | 오늘 Lab의 `helpdesk.json` 버전들을 TUI로 |
|
||||
|
||||
## 확인
|
||||
|
||||
- [ ] `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md`를 에이전트 전에 (또는 에이전트와 함께 쓰고 내가 고쳐서) 만들고 commit했다.
|
||||
- [ ] IMP-PLAN의 엔진 1 · 2단계와 TUI 1단계를 체크했다 (단계마다 commit).
|
||||
- [ ] R1 · R2 · R3 · R4 · R5 · R6 · R8 확인, `uv run pytest -q`.
|
||||
- [ ] R14 `uv tool install`, 다른 폴더에서 `tver`.
|
||||
- [ ] 남은 요구는 IMP-PLAN에 체크 안 된 채로 남겨 두었다 (다음에 이어 감).
|
||||
@@ -0,0 +1,109 @@
|
||||
# 7교시 바이브 코딩 · 요청서 `tver` (가장 먼저 읽기)
|
||||
|
||||
- 순서: **이 요청서** → 내 정리 `~/vibe/tver/Doc/TODO.md` (8분) → [VIBE-Q-TVER.md](VIBE-Q-TVER.md)의 순서대로 `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별로 만들기 → 설치 · 다른 곳에서 쓰기 → 수업이 끝나면 `VIBE-A-TVER.md`(정답본)로 확인
|
||||
- 이 문서는 **만들어 달라는 사람의 말**입니다. 요구 사항 표도, 단계도 없습니다. 그것을 뽑아 내는 것이 첫 일입니다. 72일차 `tbench` · 73일차 `tscan`과 같은 순서입니다.
|
||||
|
||||
```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/>다른 곳에서 쓰기"]
|
||||
```
|
||||
|
||||
## 왜 요청서부터인가
|
||||
|
||||
요구 사항 표를 통째로 에이전트에게 주고 "이대로 만들어 줘"라고 하면, 무엇이 만들어지든 **내가 판단할 기준이 없습니다.** 실무에서는 요구 사항 표가 오지 않고, 이 요청서 같은 **사람의 말**이 옵니다. 오늘 Lab 02에서 게이트의 기대 낱말을 **데이터를 보고 내가** 정한 것과 같습니다: 무엇이 맞는지 알아야 맞았는지 말할 수 있습니다.
|
||||
|
||||
| 단계 | 누가 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 요청 → 할 일 목록 | **나** | `Doc/TODO.md`: 요청의 말 ①~⑫를 내 말로, 만들고 싶은 것 |
|
||||
| 할 일 → 요구 | **나** (에이전트와 다듬어도 됨) | `Doc/SPEC.md`: 요구마다 "됐다 / 안 됐다"를 말할 수 있는 확인 방법 |
|
||||
| 요구 → 단계 | **나** (에이전트와 다듬어도 됨) | `Doc/IMP-PLAN.md`: 엔진 단계와 화면(TUI) 단계로 나눈 순서 |
|
||||
| 단계 → 코드 | 에이전트 | 단계마다 요청문 하나, `Doc/*.md`를 함께 읽게 함 |
|
||||
| 확인 | **나** | 직접 `uv run tver …`, `pytest`, 요구 표에 체크 |
|
||||
|
||||
## 요청 (운영팀장에게서 온 메시지, 그대로)
|
||||
|
||||
> 오늘 `flow.py diff`로 v1 → v2를 비교한 거 봤어요. 그런데 그건 파일 두 개가 **이미 있을 때** 얘기잖아요. 우리 팀은 n8n 화면에서 고치고, 에이전트가 고치고, 누가 언제 무엇을 바꿨는지 남는 게 없어요. 워크플로우를 **버전으로 남기고 비교하는** 작은 도구가 있으면 좋겠어요. 터미널에서 돌아가면 되고, **어느 폴더에서나** `tver`라고 치면 되게 해 주세요.
|
||||
>
|
||||
> ① 워크플로우 파일 두 개를 주면 **노드 단위로** 무엇이 더해지고 빠지고 바뀌었는지, 연결은 어떻게 바뀌었는지 보여 주세요. 오늘 `flow.py diff`처럼요.
|
||||
>
|
||||
> ② n8n에서 노드를 옆으로 옮기기만 해도 파일이 바뀌어요. 그런 **뜻 없는 차이**(위치, id, 순서)는 빼 주세요. 그게 섞이면 아무도 diff를 안 읽어요.
|
||||
>
|
||||
> ③ 지시문이 바뀌면 "307자 → 419자"만 나오면 몰라요. **어느 문장이** 바뀌었는지 보여 주세요.
|
||||
>
|
||||
> ④ 오늘 강의에서 바뀌는 게 모델 · 지시문 · 흐름 세 가지라고 했죠? 바뀐 것을 **그 셋으로 나눠** 몇 개씩인지 한 줄로 알려 주세요. 사고가 나면 그 줄부터 봐요.
|
||||
>
|
||||
> ⑤ 파일을 주면 **버전으로 저장**해 주세요. git이면 좋겠어요. 저장할 때 무엇이 바뀌었는지 요약을 같이 남기고, **바뀐 게 없으면 저장하지 마세요.** 의미 없는 버전이 쌓이면 못 읽어요.
|
||||
>
|
||||
> ⑥ 저장한 버전의 **목록**을 보고, 아무 두 버전이나 골라 **비교**할 수 있게요.
|
||||
>
|
||||
> ⑦ 워크플로우 파일에 **API 키** 같은 게 들어 있으면 저장하지 말고 알려 주세요. git에 한 번 들어가면 지워도 남는다고 어제 배웠잖아요.
|
||||
>
|
||||
> ⑧ **화면에서** 버전 목록을 보고, 하나 고르면 그 바로 전 버전과 무엇이 바뀌었는지 노드별로 펼쳐 보고, 바뀐 것 하나를 고르면 전 · 후를 자세히 보여 주세요.
|
||||
>
|
||||
> ⑨ 사람들이 파일로 내보내기를 잊어요. **n8n에서 바로** 지금 워크플로우들을 가져와서 한꺼번에 버전으로 남겨 주면 좋겠어요.
|
||||
>
|
||||
> ⑩ 문제가 생기면 **예전 버전을 파일로 꺼내** 주세요. n8n에 올리는 건 사람이 할게요 (자동으로 올리면 무서워요).
|
||||
>
|
||||
> ⑪ 나중에 배포 전에 자동으로 돌릴 거라, 사람이 아니라 **다른 프로그램이 읽을 수 있는** 결과도 주세요. 바뀐 게 있으면 그걸 알 수 있는 종료 코드도요.
|
||||
>
|
||||
> ⑫ 누가 노드 **이름만 바꾸면** 지금은 "하나 빠지고 하나 더해짐"으로 나와요. 이름만 바뀐 거면 그렇다고 알려 주세요.
|
||||
>
|
||||
> 아, `my-work` 폴더를 통째로 주면 **워크플로우 파일만** 골라서 저장해 주세요. 테스트 파일이나 메모까지 워크플로우인 줄 알면 안 돼요. 그리고 **시험할 때** 진짜 우리 n8n을 망가뜨리면 안 돼요 — 저장소는 따로 두고, 가져오기만 하고 n8n에 올리지는 않게요.
|
||||
|
||||
## 함께 받은 것
|
||||
|
||||
| 파일 · 자료 | 메모 |
|
||||
| --- | --- |
|
||||
| `reference/helpdesk-v1.json` · `helpdesk-v2.json` | 시험 자료. `flow.py diff` 결과(7개)와 같아야 함 |
|
||||
| 오늘 Lab의 `my-work/helpdesk.json` (단계마다) | 버전으로 저장해 볼 것 (예제 2 → 과제 1 → 과제 2 → Lab 02 …) |
|
||||
| `tools/flow.py`의 `diff` | 노드 이름으로 짝짓는 방법 (읽어도 됨) |
|
||||
| 68일차 `~/vibe/tflow` | 노드 · 연결 읽기 코드 (있으면) |
|
||||
| n8n 컨테이너 | `docker exec day65-n8n-n8n-1 n8n export:workflow --all --output=...` (읽기만) |
|
||||
|
||||
## 할 일 · 요청을 내 할 일 목록으로 (8분, 에이전트 없이)
|
||||
|
||||
`~/vibe/tver/Doc/TODO.md`를 만들고 요청의 말 ①~⑫와 맨 끝 부탁 둘을 **내 말로** 한 줄씩 옮깁니다. 아직 "어떻게"는 쓰지 않습니다. **무엇을 · 왜**만 씁니다. 에이전트에게 맡기지 않고 직접 씁니다.
|
||||
|
||||
```markdown
|
||||
# tver TODO
|
||||
|
||||
## 만들고 싶은 것 (한 문단)
|
||||
어느 폴더에서나 `tver`로 n8n 워크플로우를 버전으로 남기고, 두 버전을 노드 단위로 비교해 ...
|
||||
|
||||
## 요청의 말 → 할 일
|
||||
| # | 요청의 말 (짧게) | 할 일 (내 말) | 꼭 · 나중 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① | 노드 단위 비교, 연결 | | 꼭 |
|
||||
| ② | 뜻 없는 차이 빼기 | | |
|
||||
| ... | | | |
|
||||
| 끝 | 폴더 통째로 → 워크플로우만 | | |
|
||||
| 끝 | 시험할 때 n8n을 망가뜨리지 않기 | | |
|
||||
|
||||
## 요청에 없지만 걱정되는 것
|
||||
## 오늘 50분 안에 못 할 것 같은 것
|
||||
```
|
||||
|
||||
예 하나만 보여 드립니다.
|
||||
|
||||
| 요청의 말 | TODO에 쓸 할 일 (내 말) | 다음 단계(SPEC)에서 바뀔 모양 |
|
||||
| --- | --- | --- |
|
||||
| ⑤ "바뀐 게 없으면 저장하지 마세요" | "같은 내용이면 버전을 만들지 않음. 노드만 옮긴 것도 같은 내용으로 봄 (② 와 연결)" | R5: 같은 파일을 두 번 save → 버전 1개, 노드 위치만 바꾼 파일을 save → "no change" |
|
||||
|
||||
<details><summary>▶ 1단계 · 꼭 할 것과 나중에 할 것</summary>
|
||||
|
||||
요청의 말이 열두 개입니다. 50분 안에 다 만들기 어렵습니다. 팀장이 사고가 나면 **"그 줄부터 봐요"**라고 한 것은 무엇인가요? 그리고 버전 목록 · 화면을 만들려면 먼저 무엇이 있어야 하나요? (비교 → 저장 → 목록 → 화면)
|
||||
</details>
|
||||
|
||||
<details><summary>▶ 2단계 · 걱정되는 것 (숨은 요구의 씨앗)</summary>
|
||||
|
||||
- ② "뜻 없는 차이": 비교할 때만 빼면 될까요, 저장할 때도 빼야 할까요? (저장한 파일끼리 git diff를 볼 때)
|
||||
- ⑤ "바뀐 게 없으면": 무엇을 "같다"고 볼까요? 키 순서가 다른 JSON은?
|
||||
- ⑦ "API 키": 저장을 거부할 때, 찾은 키를 오류 메시지에 그대로 보여 주면? (73일차)
|
||||
- ⑨ "n8n에서 바로": 워크플로우마다 파일 이름은 어떻게 정할까요? 이름에 공백 · 한글 · `/`가 있으면?
|
||||
- ⑩ "예전 버전을 파일로": 꺼내는 일이 저장소를 바꾸면 안 됩니다. 왜일까요?
|
||||
- "어느 폴더에서나 `tver`" → 저장소는 어디에 둘까요? (내 프로젝트 폴더 밖, 72일차 `tbench`와 같음)
|
||||
</details>
|
||||
|
||||
## 그다음
|
||||
|
||||
[VIBE-Q-TVER.md](VIBE-Q-TVER.md)를 엽니다. 거기서 `Doc/TODO.md` → `Doc/SPEC.md` → `Doc/IMP-PLAN.md` → 단계별 구현 → 확인 → `uv tool install` → 다른 곳에서 사용 순서로 갑니다. VIBE-Q의 요구 표는 **내 SPEC을 쓴 뒤에** 비교하는 데 씁니다.
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"cases": [
|
||||
{
|
||||
"name": "VPN",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"route": "자동 답변"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "비밀번호",
|
||||
"request": "requests/faq-password.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"route": "자동 답변"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "프린터",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"route": "자동 답변"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "메일함",
|
||||
"request": "requests/faq-mail.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"route": "자동 답변"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "접수 처리 중",
|
||||
"request": "requests/ticket-own.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"route": "자동 답변"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "접수 처리 완료",
|
||||
"request": "requests/ticket-done.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"route": "자동 답변"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"cases": [
|
||||
{
|
||||
"name": "지시문은 저장소에서",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.prompt_source": "registry",
|
||||
"version.prompt_version": "p1"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"인증서"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "p1은 출처 번호를 붙이지 않음",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.prompt_version": "p1"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열",
|
||||
"전원"
|
||||
]
|
||||
},
|
||||
"not_contains": {
|
||||
"answer": [
|
||||
"[FAQ-"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "접수 현황",
|
||||
"request": "requests/ticket-own.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"처리 중"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "ok",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "평소 · 저장소에서",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.prompt_source": "registry"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"인증서"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "저장소 고장 · 기본 지시문으로 답",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "ok"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"route": "자동 답변",
|
||||
"version.prompt_source": "fallback"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"인증서"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "저장소 느림 · 오래 기다리지 않음",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "slow",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "ok",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"version.prompt_source": "fallback"
|
||||
},
|
||||
"max_seconds": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "되돌린 뒤 · 다시 저장소에서",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.prompt_source": "registry"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"webhook": "rel-v1",
|
||||
"cases": [
|
||||
{
|
||||
"name": "VPN · 해결 낱말",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"인증서"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "VPN · 출처 번호",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"[FAQ-003]"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "프린터 · 해결 낱말",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열",
|
||||
"전원"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "프린터 · 출처 번호",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"[FAQ-"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "접수 현황",
|
||||
"request": "requests/ticket-own.json",
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"처리 중"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"cases": [
|
||||
{
|
||||
"name": "버전이 결과에",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.prompt_version": "p1",
|
||||
"version.model": "gpt-4.1-mini"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"인증서"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "접수 현황도 버전과 함께",
|
||||
"request": "requests/ticket-own.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"처리 중"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "빈 질문",
|
||||
"request": "requests/empty-question.json",
|
||||
"expect": {
|
||||
"status": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "ok",
|
||||
"slow_seconds": 8
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "망가진 릴리스 2.1.0 · 400 release broken",
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호",
|
||||
"release": "2.1.0"
|
||||
},
|
||||
"expect": {
|
||||
"status": 400,
|
||||
"equals": {
|
||||
"error": "release broken",
|
||||
"prompt_version": "p9"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "평소 · 1.1.0은 저장소의 p2",
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호",
|
||||
"release": "1.1.0"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.prompt_source": "registry",
|
||||
"version.prompt_version": "p2"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"[FAQ-003]"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "저장소 고장 · 1.1.0도 기본 지시문으로",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "ok"
|
||||
}
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호",
|
||||
"release": "1.1.0"
|
||||
},
|
||||
"expect": {
|
||||
"status": 200,
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.prompt_source": "fallback"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"인증서"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "저장소 고장 · 없는 릴리스는 그대로 400",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "error"
|
||||
}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/admin/fault",
|
||||
"body": {
|
||||
"prompts": "ok"
|
||||
}
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호",
|
||||
"release": "9.9.9"
|
||||
},
|
||||
"expect": {
|
||||
"status": 400,
|
||||
"equals": {
|
||||
"error": "unknown release"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "운영(active 1.0.0)은 그대로",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.prompt_source": "registry"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "운영 중인 릴리스 (active)",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.channel": "stable",
|
||||
"version.prompt_version": "p1"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "후보 시험 · 1.1.0 고정",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.channel": "pinned",
|
||||
"version.prompt_version": "p2"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"[FAQ-003]"
|
||||
]
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호",
|
||||
"release": "1.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "후보 시험 · 2.0.0 (작은 모델)",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "2.0.0",
|
||||
"version.model": "gpt-5.4-nano"
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호",
|
||||
"release": "2.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "없는 릴리스",
|
||||
"expect": {
|
||||
"status": 400,
|
||||
"contains": {
|
||||
"known": [
|
||||
"1.0.0"
|
||||
]
|
||||
}
|
||||
},
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "이준호",
|
||||
"release": "9.9.9"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "빈 질문",
|
||||
"request": "requests/empty-question.json",
|
||||
"expect": {
|
||||
"status": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk",
|
||||
"body": {
|
||||
"canary": {
|
||||
"release": "1.1.0",
|
||||
"percent": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "최유진 (bucket 0) → 카나리",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.channel": "canary"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "정우성 (bucket 42) → 카나리",
|
||||
"body": {
|
||||
"question": "VPN 접속이 자꾸 끊겨요. 어떻게 하나요?",
|
||||
"user": "정우성"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.channel": "canary"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "이준호 (bucket 56) → 운영",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.channel": "stable"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "김민지 (bucket 80) → 운영",
|
||||
"request": "requests/faq-mail.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.channel": "stable"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "고정이 카나리보다 먼저",
|
||||
"body": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진",
|
||||
"release": "1.0.0"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.channel": "pinned"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "같은 사람은 늘 같은 쪽",
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.channel": "canary"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"webhook": "my-release-check",
|
||||
"cases": [
|
||||
{
|
||||
"name": "1.1.0 · 내보내도 됨",
|
||||
"body": {
|
||||
"release": "1.1.0"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"verdict": "promote",
|
||||
"total": 6
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "1.2.0 · 되돌림",
|
||||
"body": {
|
||||
"release": "1.2.0"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"verdict": "rollback"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "릴리스 없음",
|
||||
"body": {},
|
||||
"expect": {
|
||||
"status": 400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"webhook": "my-release-check",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/gates/helpdesk/clear",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/policy",
|
||||
"body": {
|
||||
"require_gate": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "1.2.0 점검 · 기록",
|
||||
"body": {
|
||||
"release": "1.2.0"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"verdict": "rollback",
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "1.1.0 점검 · 기록",
|
||||
"body": {
|
||||
"release": "1.1.0"
|
||||
},
|
||||
"expect": {
|
||||
"equals": {
|
||||
"verdict": "promote",
|
||||
"recorded": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "점검에 떨어진 1.2.0은 배포 거부 (409) · 운영은 1.0.0",
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk",
|
||||
"body": {
|
||||
"active": "1.2.0"
|
||||
},
|
||||
"status": 409
|
||||
}
|
||||
],
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.channel": "stable"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "점검을 지난 1.1.0은 배포",
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk",
|
||||
"body": {
|
||||
"active": "1.1.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.channel": "stable"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "점검 안 한 2.0.0은 카나리도 거부 (409)",
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk",
|
||||
"body": {
|
||||
"canary": {
|
||||
"release": "2.0.0",
|
||||
"percent": 50
|
||||
}
|
||||
},
|
||||
"status": 409
|
||||
}
|
||||
],
|
||||
"request": "requests/faq-printer.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.channel": "stable"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
},
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk",
|
||||
"body": {
|
||||
"canary": {
|
||||
"release": "1.2.0",
|
||||
"percent": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "최유진 · 프린터",
|
||||
"body": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "최유진"
|
||||
},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "정우성 · 프린터",
|
||||
"body": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "정우성"
|
||||
},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "한지훈 · 프린터",
|
||||
"body": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "한지훈"
|
||||
},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "이준호 · 프린터",
|
||||
"body": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "이준호"
|
||||
},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "박서연 · 프린터",
|
||||
"body": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "박서연"
|
||||
},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "김민지 · 프린터",
|
||||
"body": {
|
||||
"question": "프린터 출력이 안 돼요",
|
||||
"user": "김민지"
|
||||
},
|
||||
"expect": {
|
||||
"contains": {
|
||||
"answer": [
|
||||
"대기열"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"webhook": "my-helpdesk",
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk/reset",
|
||||
"body": {}
|
||||
}
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"name": "배포 · 1.1.0을 active로",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.1.0",
|
||||
"version.channel": "stable"
|
||||
},
|
||||
"contains": {
|
||||
"answer": [
|
||||
"[FAQ-003]"
|
||||
]
|
||||
}
|
||||
},
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk",
|
||||
"body": {
|
||||
"active": "1.1.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "되돌리기 · 1.0.0",
|
||||
"request": "requests/faq-vpn.json",
|
||||
"expect": {
|
||||
"equals": {
|
||||
"version.release": "1.0.0",
|
||||
"version.channel": "stable"
|
||||
},
|
||||
"not_contains": {
|
||||
"answer": [
|
||||
"[FAQ-"
|
||||
]
|
||||
}
|
||||
},
|
||||
"before": [
|
||||
{
|
||||
"url": "http://localhost:8800/releases/helpdesk",
|
||||
"body": {
|
||||
"active": "1.0.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Sample company data API for day 74 (day 73 API + prompt registry + releases), 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,26 @@
|
||||
[
|
||||
{
|
||||
"name": "이준호",
|
||||
"dept": "영업팀"
|
||||
},
|
||||
{
|
||||
"name": "박서연",
|
||||
"dept": "재무팀"
|
||||
},
|
||||
{
|
||||
"name": "최유진",
|
||||
"dept": "인사팀"
|
||||
},
|
||||
{
|
||||
"name": "한지훈",
|
||||
"dept": "개발팀"
|
||||
},
|
||||
{
|
||||
"name": "김민지",
|
||||
"dept": "IT팀"
|
||||
},
|
||||
{
|
||||
"name": "정우성",
|
||||
"dept": "IT팀"
|
||||
}
|
||||
]
|
||||
@@ -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 @@
|
||||
You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
|
||||
@@ -0,0 +1,2 @@
|
||||
You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say "찾지 못했습니다" and that a staff member will help. Use 합니다체.
|
||||
When the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003].
|
||||
@@ -0,0 +1 @@
|
||||
You are the company IT help desk. Keep every answer to one short Korean sentence. Do not repeat the steps from the data; tell the employee to check the IT portal instead.
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"helpdesk": {
|
||||
"active": "1.0.0",
|
||||
"releases": {
|
||||
"1.0.0": {"prompt_version": "p1", "model": "gpt-4.1-mini", "note": "첫 릴리스"},
|
||||
"1.1.0": {"prompt_version": "p2", "model": "gpt-4.1-mini", "note": "FAQ 답 끝에 출처 번호"},
|
||||
"1.2.0": {"prompt_version": "p3", "model": "gpt-4.1-mini", "note": "답을 한 문장으로 짧게"},
|
||||
"2.0.0": {"prompt_version": "p2", "model": "gpt-5.4-nano", "note": "더 작은 모델 (비용)"},
|
||||
"2.1.0": {"prompt_version": "p9", "model": "gpt-4.1-mini", "note": "출처 번호 · 존댓말 다듬기 (지시문 이름 확인 필요)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"T-1001": {"title": "노트북 배터리 교체", "requester": "이준호", "status": "처리 완료", "assignee": "김민지", "updated": "2026-10-15", "note": "새 배터리로 교체했습니다.", "contact": {"phone": "010-3141-5926", "email": "junho.lee@example.com"}},
|
||||
"T-1002": {"title": "VPN 인증서 재발급", "requester": "박서연", "status": "처리 중", "assignee": "정우성", "updated": "2026-10-16", "note": "인증서 발급 승인 대기 중이며 10월 20일 완료 예정입니다.", "contact": {"phone": "010-2718-2818", "email": "seoyeon.park@example.com"}},
|
||||
"T-1003": {"title": "모니터 추가 지급", "requester": "최유진", "status": "접수됨", "assignee": "미정", "updated": "2026-10-16", "note": "재고 확인 후 담당자를 배정합니다.", "contact": {"phone": "010-1414-2135", "email": "yujin.choi@example.com"}},
|
||||
"T-1004": {"title": "메일함 용량 증설", "requester": "한지훈", "status": "반려", "assignee": "김민지", "updated": "2026-10-14", "note": "용량 기준을 넘지 않아 반려되었습니다. 오래된 메일을 보관함으로 옮겨 주세요.", "contact": {"phone": "010-1732-0508", "email": "jihoon.han@example.com"}}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Sample company data API for the day 74 labs (day 73 API + prompt registry + releases + gate records;
|
||||
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 /employees -> {"employees": [{"name", "dept"}]}
|
||||
GET /employees/<name> -> {"name", "dept"}, or 404 {"error": "unknown employee"}
|
||||
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", "prompts": "ok", "slow_seconds": 8}
|
||||
POST /admin/fault body {"faq": "ok"|"slow"|"error", "tickets": ..., "prompts": ..., "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"}
|
||||
|
||||
Day 73 addition (in memory, newest 1000 kept):
|
||||
POST /audit body {"route", "blocked_by", "reason", "masked", ...} -> {"stored": true, "count"}
|
||||
GET /audit -> {"events": [{"t", ...the body}]}
|
||||
POST /audit-clear -> {"cleared": <count>}
|
||||
|
||||
Day 74 additions (prompt registry from data/prompts, releases from data/releases.json, state in memory):
|
||||
GET /prompts/<name> -> {"name", "versions": ["p1", ...]}
|
||||
GET /prompts/<name>/<ver> -> {"name", "version", "text"}, or 404 {"error": "prompt not found"} (fault switch "prompts")
|
||||
GET /releases/<name> -> {"name", "active", "canary": null | {"release", "percent"}, "require_gate": false, "releases": {...}}
|
||||
POST /releases/<name> body {"active": "1.1.0"} and/or {"canary": {"release": "1.2.0", "percent": 50}} or {"canary": null}
|
||||
When require_gate is on, a release can become active or canary only if its latest gate
|
||||
record says "promote"; otherwise 409 {"error": "not gated", "release", "last_gate"}.
|
||||
POST /releases/<name>/policy body {"require_gate": true|false}
|
||||
POST /releases/<name>/reset -> back to data/releases.json (active, no canary, require_gate off; gate records stay)
|
||||
POST /gates/<name> body {"release", "verdict": "promote"|"rollback", "passed", "total", "source", ...}
|
||||
-> {"stored": true, "count"}
|
||||
GET /gates/<name> -> {"name", "gates": [{"t", ...the body}]} (newest last, 200 kept)
|
||||
POST /gates/<name>/clear -> {"cleared": <count>}
|
||||
"""
|
||||
|
||||
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", "prompts": "ok", "slow_seconds": 8}
|
||||
CACHE: dict[str, dict] = {}
|
||||
STATS = {"hits": 0, "misses": 0}
|
||||
AUDIT: list[dict] = []
|
||||
RELEASES: dict = {}
|
||||
GATES: dict[str, list[dict]] = {}
|
||||
|
||||
|
||||
def load(name):
|
||||
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def releases():
|
||||
if not RELEASES:
|
||||
for name, item in load("releases.json").items():
|
||||
RELEASES[name] = {"active": item["active"], "canary": None, "require_gate": False, "releases": item["releases"]}
|
||||
return RELEASES
|
||||
|
||||
|
||||
def last_gate(name, release):
|
||||
for record in reversed(GATES.get(name, [])):
|
||||
if record.get("release") == release:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
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 == "/audit":
|
||||
with LOCK:
|
||||
return self.send_json(200, {"events": list(AUDIT)})
|
||||
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 == "/employees":
|
||||
return self.send_json(200, {"employees": load("employees.json")})
|
||||
if url.path.startswith("/employees/"):
|
||||
name = unquote(url.path.removeprefix("/employees/")).strip()
|
||||
for person in load("employees.json"):
|
||||
if person["name"] == name:
|
||||
return self.send_json(200, person)
|
||||
return self.send_json(404, {"error": "unknown employee", "name": name})
|
||||
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})
|
||||
if url.path.startswith("/prompts/"):
|
||||
if self.faulty("prompts"):
|
||||
return None
|
||||
parts = [unquote(p) for p in url.path.removeprefix("/prompts/").split("/") if p]
|
||||
folder = DATA_DIR / "prompts"
|
||||
if len(parts) == 1:
|
||||
versions = sorted(f.stem.removeprefix(parts[0] + "-") for f in folder.glob(f"{parts[0]}-*.md"))
|
||||
return self.send_json(200 if versions else 404, {"name": parts[0], "versions": versions})
|
||||
if len(parts) == 2 and "/" not in parts[1] and (folder / f"{parts[0]}-{parts[1]}.md").exists():
|
||||
text = (folder / f"{parts[0]}-{parts[1]}.md").read_text(encoding="utf-8").strip()
|
||||
return self.send_json(200, {"name": parts[0], "version": parts[1], "text": text})
|
||||
return self.send_json(404, {"error": "prompt not found", "path": url.path})
|
||||
if url.path.startswith("/releases/"):
|
||||
name = unquote(url.path.removeprefix("/releases/")).strip("/")
|
||||
with LOCK:
|
||||
item = releases().get(name)
|
||||
if item is None:
|
||||
return self.send_json(404, {"error": "unknown release name", "name": name})
|
||||
return self.send_json(200, {"name": name, **item})
|
||||
if url.path.startswith("/gates/"):
|
||||
name = unquote(url.path.removeprefix("/gates/")).strip("/")
|
||||
with LOCK:
|
||||
return self.send_json(200, {"name": name, "gates": list(GATES.get(name, []))})
|
||||
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", "prompts"):
|
||||
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 == "/audit":
|
||||
with LOCK:
|
||||
AUDIT.append({"t": round(time.time(), 3), **body})
|
||||
del AUDIT[:-1000]
|
||||
count = len(AUDIT)
|
||||
return self.send_json(200, {"stored": True, "count": count})
|
||||
if url.path == "/audit-clear":
|
||||
with LOCK:
|
||||
count = len(AUDIT)
|
||||
AUDIT.clear()
|
||||
return self.send_json(200, {"cleared": count})
|
||||
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})
|
||||
if url.path.startswith("/gates/"):
|
||||
name = unquote(url.path.removeprefix("/gates/")).strip("/")
|
||||
clear = name.endswith("/clear")
|
||||
name = name.removesuffix("/clear")
|
||||
with LOCK:
|
||||
if clear:
|
||||
count = len(GATES.pop(name, []))
|
||||
return self.send_json(200, {"cleared": count})
|
||||
if not body.get("release") or body.get("verdict") not in ("promote", "rollback"):
|
||||
return self.send_json(400, {"error": "release and verdict (promote or rollback) are required"})
|
||||
records = GATES.setdefault(name, [])
|
||||
records.append({"t": round(time.time(), 3), **body})
|
||||
del records[:-200]
|
||||
return self.send_json(200, {"stored": True, "count": len(records)})
|
||||
if url.path.startswith("/releases/"):
|
||||
name = unquote(url.path.removeprefix("/releases/")).strip("/")
|
||||
action = ""
|
||||
for suffix in ("/reset", "/policy"):
|
||||
if name.endswith(suffix):
|
||||
action, name = suffix[1:], name.removesuffix(suffix)
|
||||
with LOCK:
|
||||
if action == "reset":
|
||||
RELEASES.pop(name, None)
|
||||
item = releases().get(name)
|
||||
if item is None:
|
||||
return self.send_json(404, {"error": "unknown release name", "name": name})
|
||||
if action == "reset":
|
||||
return self.send_json(200, {"name": name, **item})
|
||||
if action == "policy":
|
||||
if not isinstance(body.get("require_gate"), bool):
|
||||
return self.send_json(400, {"error": "require_gate must be true or false"})
|
||||
item["require_gate"] = body["require_gate"]
|
||||
return self.send_json(200, {"name": name, **item})
|
||||
wanted = []
|
||||
if "active" in body:
|
||||
if body["active"] not in item["releases"]:
|
||||
return self.send_json(400, {"error": "unknown release", "known": sorted(item["releases"])})
|
||||
wanted.append(body["active"])
|
||||
canary = body.get("canary")
|
||||
if "canary" in body and canary is not None:
|
||||
if not isinstance(canary, dict) or canary.get("release") not in item["releases"] \
|
||||
or not 0 <= int(canary.get("percent", -1)) <= 100:
|
||||
return self.send_json(400, {"error": "canary needs a known release and percent 0..100"})
|
||||
canary = {"release": canary["release"], "percent": int(canary["percent"])}
|
||||
wanted.append(canary["release"])
|
||||
if item["require_gate"]:
|
||||
for release in wanted:
|
||||
gate = last_gate(name, release)
|
||||
if not gate or gate.get("verdict") != "promote":
|
||||
return self.send_json(409, {"error": "not gated", "release": release, "last_gate": gate})
|
||||
if "active" in body:
|
||||
item["active"] = body["active"]
|
||||
if "canary" in body:
|
||||
item["canary"] = canary
|
||||
return self.send_json(200, {"name": name, **item})
|
||||
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,6 @@
|
||||
# 수업 결과물
|
||||
|
||||
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`helpdesk.json`, `release-check.json`)이 여기에 저장됩니다 (git에 올라가지 않음). 처음에는 `cp reference/helpdesk-v1.json my-work/helpdesk.json`.
|
||||
- `CHANGELOG.md`, `RELEASE.md`: [변경 기록](../templates/CHANGELOG-TEMPLATE.md), [릴리스 점검표](../templates/RELEASE-CHECKLIST-TEMPLATE.md)를 복사해 채웁니다.
|
||||
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
|
||||
- 게이트 테스트 `my-golden.json`은 `cases/`에 만듭니다 (Lab 02 과제 1).
|
||||
@@ -0,0 +1,286 @@
|
||||
{
|
||||
"name": "74 IT helpdesk v1 (provided)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "rel-v1",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "0709e90d-e8ea-509c-8c91-1721c012d260",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "d087d97a-46d1-544f-b742-78984643db81"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "0cafcebc-1ba4-546e-aa2e-22f24643ca5a"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "f60c3d44-3793-50e4-89c1-96c15c0075b0"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "7bf6486f-83e4-56c6-9052-ee94305aa025"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "9f1e31b4-c9b1-5848-8bc7-4f9f5c5b905b"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
|
||||
"options": {
|
||||
"systemMessage": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
|
||||
"maxIterations": 1
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "28d25947-3225-58fb-adec-112be9f61a60"
|
||||
},
|
||||
{
|
||||
"name": "Answer model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.2,
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "gpt-4.1-mini"
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
520
|
||||
],
|
||||
"id": "008c5ad6-673c-5e09-ae15-bbcf04777148"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "5eabccbd-9c75-59f6-ac72-9070ef5f3a89"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "95e15847-a230-567c-a1f5-827a415a52a4"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
{
|
||||
"name": "74 IT helpdesk v2 (provided)",
|
||||
"nodes": [
|
||||
{
|
||||
"name": "Question webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "rel-v2",
|
||||
"responseMode": "responseNode",
|
||||
"options": {}
|
||||
},
|
||||
"webhookId": "4dd9ca0d-6e35-58d9-afb9-36689beca2c7",
|
||||
"position": [
|
||||
0,
|
||||
300
|
||||
],
|
||||
"id": "edf3da10-d647-5671-a906-292126b204df"
|
||||
},
|
||||
{
|
||||
"name": "Has question?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.body.question }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "notEmpty",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
220,
|
||||
300
|
||||
],
|
||||
"id": "cf8a9038-ff3e-56a3-a17f-9c062bdd0391"
|
||||
},
|
||||
{
|
||||
"name": "Reject empty",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "{ \"error\": \"question is required\" }",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"id": "3e81f84e-0040-52a4-80a2-e093464dea75"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "7e858b12-8c3b-5b72-aa02-09276f0d63e0"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "9265daab-0313-5c12-aa77-7a596f23eae5"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
|
||||
"options": {
|
||||
"systemMessage": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.\nWhen the answer comes from a FAQ entry, end with its id in brackets, for example [FAQ-003]. Keep answers short.",
|
||||
"maxIterations": 1
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "027a9c55-9b22-5122-85e5-9f8e347fe189"
|
||||
},
|
||||
{
|
||||
"name": "Answer model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.2,
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "gpt-5.4-nano"
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
520
|
||||
],
|
||||
"id": "bfe53f6e-ecec-5876-8943-03d0b6dad9a5"
|
||||
},
|
||||
{
|
||||
"name": "Review check",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "let answer = String($json.output ?? '').trim();\nif (answer.length > 400) answer = answer.slice(0, 400);\nreturn [{ json: { output: answer } }];"
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "0d0c0486-e241-521a-83ca-66b725fd05d0"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "27c64881-2145-56c1-ab88-8f0a7515dbd2"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "5a7d65a6-3b4e-562d-bf7e-1be3756a8cde"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Review check",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Review check": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
{
|
||||
"name": "74 IT helpdesk + version (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": "12c0c985-096e-52df-aa1c-adf60b24ec37"
|
||||
},
|
||||
{
|
||||
"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": "8228f635-e9f5-5cef-b36a-bc7db9222a73"
|
||||
},
|
||||
{
|
||||
"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": "d3276145-a2db-5c7f-8df3-0e8ad6d575f5"
|
||||
},
|
||||
{
|
||||
"name": "Release info",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "r1",
|
||||
"name": "release",
|
||||
"value": "1.0.0",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "r2",
|
||||
"name": "prompt_version",
|
||||
"value": "p1",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "r3",
|
||||
"name": "model",
|
||||
"value": "gpt-4.1-mini",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "92d52537-7275-5dec-8237-f360569a0aec"
|
||||
},
|
||||
{
|
||||
"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": "ec340048-5ab5-523f-b5f1-78c4b83a4bfe"
|
||||
},
|
||||
{
|
||||
"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": "361b1c61-2b51-5e41-94f2-3220e9441f70"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
|
||||
"options": {
|
||||
"systemMessage": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
|
||||
"maxIterations": 1
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "a48913f5-de5d-5618-a8d4-54241134e6f4"
|
||||
},
|
||||
{
|
||||
"name": "Answer model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.2,
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "={{ $('Release info').first().json.model }}"
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
520
|
||||
],
|
||||
"id": "2e24c3c6-9a59-5b13-98d9-6ff7f88b864f"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nconst i = $('Release info').first().json;\nresult.version = { release: i.release, prompt_version: i.prompt_version, model: i.model };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "250c7c69-ae85-5e10-94eb-60283736ff50"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "ed7592f4-6993-5633-82a4-1ac715e4895b"
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Release info",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Release info": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"name": "74 IT helpdesk + release API (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": "a1c7ad38-705e-5042-a2f4-8dbb40d08c5d"
|
||||
},
|
||||
{
|
||||
"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": "97ced79b-5b5b-5519-ab42-0549d01297fa"
|
||||
},
|
||||
{
|
||||
"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": "32e5818c-2e23-5dfb-a7ca-401c397dbf91"
|
||||
},
|
||||
{
|
||||
"name": "Get release",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/releases/helpdesk",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "4fc56dcd-9bf3-598d-807e-dbad2a261b60"
|
||||
},
|
||||
{
|
||||
"name": "Pick release",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const body = $('Question webhook').first().json.body;\nconst r = $json;\nconst bucket = [...String(body.user ?? '')].reduce((sum, ch) => sum + ch.codePointAt(0), 0) % 100;\nlet release = r.active, channel = 'stable';\nif (body.release) { release = String(body.release); channel = 'pinned'; }\n\nconst info = r.releases?.[release];\nif (!info) return [{ json: { ok: false, release, known: Object.keys(r.releases ?? {}) } }];\nreturn [{ json: { ok: true, release, channel, bucket, prompt_version: info.prompt_version, model: info.model } }];"
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "b4fbbc09-b505-50ad-8df1-5b45d911bc13"
|
||||
},
|
||||
{
|
||||
"name": "Known release?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.ok }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "0a29dfa9-f2d4-5012-8fb9-d55f2f16b134"
|
||||
},
|
||||
{
|
||||
"name": "Reject release",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: 'unknown release', release: $json.release, known: $json.known }) }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
560
|
||||
],
|
||||
"id": "1fba4d4a-1c00-516d-ae17-b92e5859ba05"
|
||||
},
|
||||
{
|
||||
"name": "Get prompt",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/prompts/helpdesk/{{ $('Pick release').first().json.prompt_version }}",
|
||||
"options": {
|
||||
"timeout": 2000
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "8d2a0079-38a2-5b93-8f20-60f49cfa9a95",
|
||||
"onError": "continueErrorOutput"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "4456c2ab-6f27-51f8-85ca-764770500d11"
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "57e1d9e0-0c2c-5fc3-8a4d-07f67ee6fc28"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
|
||||
"options": {
|
||||
"systemMessage": "={{ $('Registry prompt').isExecuted ? $('Registry prompt').first().json.text : $('Fallback prompt').first().json.text }}",
|
||||
"maxIterations": 1
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1980,
|
||||
300
|
||||
],
|
||||
"id": "cd9a4e0f-6647-562b-a8cb-ddd7afc4a630"
|
||||
},
|
||||
{
|
||||
"name": "Answer model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.2,
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "={{ $('Pick release').first().json.model }}"
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1980,
|
||||
520
|
||||
],
|
||||
"id": "5e6f25ad-b0ae-5140-b91f-257f4102d662"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nconst i = $('Pick release').first().json;\nresult.version = { release: i.release, prompt_version: i.prompt_version, model: i.model, channel: i.channel, prompt_source: ($('Registry prompt').isExecuted ? 'registry' : 'fallback') };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
2200,
|
||||
300
|
||||
],
|
||||
"id": "362754dd-f5a7-507b-8b7c-634088466cbc"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "992548f2-1522-5857-b313-70b9986f2781"
|
||||
},
|
||||
{
|
||||
"name": "Registry prompt",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "32f27066-1bd9-5201-a27a-94be437a53cc",
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "f0",
|
||||
"name": "text",
|
||||
"value": "={{ $json.text }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Fallback prompt",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
1320,
|
||||
540
|
||||
],
|
||||
"id": "afbb4b25-a0b2-53a9-b92d-d6908403ba2a",
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "f0",
|
||||
"name": "text",
|
||||
"value": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get release",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get release": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Pick release",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Pick release": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Known release?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Known release?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prompt",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject release",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prompt": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Registry prompt",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Fallback prompt",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Registry prompt": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fallback prompt": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
{
|
||||
"name": "74 IT helpdesk + canary (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": "06a0ce0e-908d-5164-83d4-63446bd439e2"
|
||||
},
|
||||
{
|
||||
"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": "e659472e-b457-5f55-9ebe-a807953752b7"
|
||||
},
|
||||
{
|
||||
"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": "87790677-8068-57d6-bde9-c45381cf7990"
|
||||
},
|
||||
{
|
||||
"name": "Get release",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/releases/helpdesk",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"id": "bc39ca63-fec9-5df8-ba71-f054a7fd54fd"
|
||||
},
|
||||
{
|
||||
"name": "Pick release",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const body = $('Question webhook').first().json.body;\nconst r = $json;\nconst bucket = [...String(body.user ?? '')].reduce((sum, ch) => sum + ch.codePointAt(0), 0) % 100;\nlet release = r.active, channel = 'stable';\nif (body.release) { release = String(body.release); channel = 'pinned'; }\nelse if (r.canary && bucket < r.canary.percent) { release = r.canary.release; channel = 'canary'; }\nconst info = r.releases?.[release];\nif (!info) return [{ json: { ok: false, release, known: Object.keys(r.releases ?? {}) } }];\nreturn [{ json: { ok: true, release, channel, bucket, prompt_version: info.prompt_version, model: info.model } }];"
|
||||
},
|
||||
"position": [
|
||||
660,
|
||||
300
|
||||
],
|
||||
"id": "705632be-ba12-58cd-8321-2b8fd06f2e10"
|
||||
},
|
||||
{
|
||||
"name": "Known release?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.ok }}",
|
||||
"rightValue": "",
|
||||
"operator": {
|
||||
"type": "boolean",
|
||||
"operation": "true",
|
||||
"singleValue": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
880,
|
||||
300
|
||||
],
|
||||
"id": "ec42ebac-269f-50a6-9754-03e9f17923bf"
|
||||
},
|
||||
{
|
||||
"name": "Reject release",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ JSON.stringify({ error: 'unknown release', release: $json.release, known: $json.known }) }}",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
560
|
||||
],
|
||||
"id": "3ea6301e-05c0-56e1-92ae-aa9907bfca81"
|
||||
},
|
||||
{
|
||||
"name": "Get prompt",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/prompts/helpdesk/{{ $('Pick release').first().json.prompt_version }}",
|
||||
"options": {
|
||||
"timeout": 2000,
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true,
|
||||
"fullResponse": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1100,
|
||||
300
|
||||
],
|
||||
"id": "8bdea823-6955-53ae-8488-e1bb9c42b1ca",
|
||||
"onError": "continueErrorOutput"
|
||||
},
|
||||
{
|
||||
"name": "Search FAQ",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "http://data-api:8800/faq",
|
||||
"options": {},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"value": "={{ $('Question webhook').first().json.body.question }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1760,
|
||||
300
|
||||
],
|
||||
"id": "dbd254d6-847b-5bfb-8ab0-67fb37603cd8"
|
||||
},
|
||||
{
|
||||
"name": "Get ticket",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"parameters": {
|
||||
"url": "=http://data-api:8800/tickets/{{ (($('Question webhook').first().json.body.question.match(/T-?\\d{4}/i) || ['NONE'])[0]).toUpperCase().replace(/^T(\\d)/, 'T-$1') }}",
|
||||
"options": {
|
||||
"response": {
|
||||
"response": {
|
||||
"neverError": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"sendQuery": true,
|
||||
"queryParameters": {
|
||||
"parameters": [
|
||||
{
|
||||
"name": "requester",
|
||||
"value": "={{ $('Question webhook').first().json.body.user ?? '' }}"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
1980,
|
||||
300
|
||||
],
|
||||
"id": "7b3f6f46-b5a8-57c0-a36e-4be7e19a4878"
|
||||
},
|
||||
{
|
||||
"name": "Answer",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"typeVersion": 3.1,
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "=질문: {{ $('Question webhook').first().json.body.question }}\nFAQ: {{ JSON.stringify($('Search FAQ').first().json.results ?? []) }}\n접수 건: {{ JSON.stringify($('Get ticket').first().json) }}",
|
||||
"options": {
|
||||
"systemMessage": "={{ $('Registry prompt').isExecuted ? $('Registry prompt').first().json.text : $('Fallback prompt').first().json.text }}",
|
||||
"maxIterations": 1
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2200,
|
||||
300
|
||||
],
|
||||
"id": "783c52b9-67dd-5297-b74d-1329b7ed4b81"
|
||||
},
|
||||
{
|
||||
"name": "Answer model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"typeVersion": 1.2,
|
||||
"parameters": {
|
||||
"model": {
|
||||
"__rl": true,
|
||||
"mode": "id",
|
||||
"value": "={{ $('Pick release').first().json.model }}"
|
||||
},
|
||||
"options": {
|
||||
"temperature": 0
|
||||
}
|
||||
},
|
||||
"credentials": {
|
||||
"openAiApi": {
|
||||
"id": "openAiCourse0001",
|
||||
"name": "OpenAI (.env)"
|
||||
}
|
||||
},
|
||||
"position": [
|
||||
2200,
|
||||
520
|
||||
],
|
||||
"id": "208a8c66-47ab-587d-96ba-3a3038a42e68"
|
||||
},
|
||||
{
|
||||
"name": "Build result",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"parameters": {
|
||||
"jsCode": "const results = $('Search FAQ').first().json.results ?? [];\nconst result = { route: '자동 답변', answer: String($json.output ?? '').trim(), sources: results.map((r) => r.id) };\nconst i = $('Pick release').first().json;\nresult.version = { release: i.release, prompt_version: i.prompt_version, model: i.model, channel: i.channel, prompt_source: ($('Registry prompt').isExecuted && $('Registry prompt').first().json.state === 'registry') ? 'registry' : 'fallback' };\nreturn [{ json: result }];"
|
||||
},
|
||||
"position": [
|
||||
2420,
|
||||
300
|
||||
],
|
||||
"id": "7082b698-3b35-5329-b2c1-0b685a085f0b"
|
||||
},
|
||||
{
|
||||
"name": "Send result",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"responseBody": "={{ $json }}",
|
||||
"options": {}
|
||||
},
|
||||
"position": [
|
||||
2640,
|
||||
300
|
||||
],
|
||||
"id": "b4ca5427-3b0f-5d7b-8417-f054076a9024"
|
||||
},
|
||||
{
|
||||
"name": "Registry prompt",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"typeVersion": 2,
|
||||
"position": [
|
||||
1320,
|
||||
300
|
||||
],
|
||||
"id": "8440ed17-b841-57e3-89d6-e687ff02a1c9",
|
||||
"parameters": {
|
||||
"jsCode": "// 200: the registry prompt. 404: the release names a prompt that does not exist (a broken release).\n// anything else (500 ...): an outage, so use the built-in prompt like the error output does.\nconst status = $json.statusCode;\nif (status === 200 && $json.body?.text) return [{ json: { state: 'registry', text: $json.body.text } }];\nif (status === 404) return [{ json: { state: 'broken' } }];\nreturn [{ json: { state: 'fallback', text: \"You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \\\"찾지 못했습니다\\\" and that a staff member will help. Use 합니다체.\" } }];"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Fallback prompt",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
1320,
|
||||
540
|
||||
],
|
||||
"id": "8ce11216-8b3d-5ac9-9059-a53090e39f8b",
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "f0",
|
||||
"name": "text",
|
||||
"value": "You are the company IT help desk. Answer in Korean in at most 3 sentences, using only the FAQ entries and the ticket record given. Never invent steps, dates or names. For a ticket, say its status and note. If the data does not answer the question, say \"찾지 못했습니다\" and that a staff member will help. Use 합니다체.",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Prompt broken?",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"typeVersion": 2.2,
|
||||
"position": [
|
||||
1540,
|
||||
300
|
||||
],
|
||||
"id": "c5ee6dce-04e4-5211-9b42-a94badf74438",
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"options": {
|
||||
"caseSensitive": true,
|
||||
"leftValue": "",
|
||||
"typeValidation": "loose",
|
||||
"version": 2
|
||||
},
|
||||
"conditions": [
|
||||
{
|
||||
"id": "c1",
|
||||
"leftValue": "={{ $json.state }}",
|
||||
"rightValue": "broken",
|
||||
"operator": {
|
||||
"type": "string",
|
||||
"operation": "equals"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combinator": "and"
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Reject broken",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
1760,
|
||||
780
|
||||
],
|
||||
"id": "4101ba21-8dc8-5b25-9dec-860d3d5de6cf",
|
||||
"parameters": {
|
||||
"respondWith": "json",
|
||||
"options": {
|
||||
"responseCode": 400
|
||||
},
|
||||
"responseBody": "={{ JSON.stringify({ error: 'release broken', release: $('Pick release').first().json.release, prompt_version: $('Pick release').first().json.prompt_version }) }}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Question webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Has question?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Has question?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get release",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject empty",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get release": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Pick release",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Pick release": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Known release?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Known release?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get prompt",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Reject release",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get prompt": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Registry prompt",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Fallback prompt",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Search FAQ": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Get ticket",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Get ticket": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer model": {
|
||||
"ai_languageModel": [
|
||||
[
|
||||
{
|
||||
"node": "Answer",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Answer": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Build result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Build result": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Send result",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Registry prompt": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Prompt broken?",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Fallback prompt": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Prompt broken?": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Reject broken",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
"node": "Search FAQ",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "", "user": "이준호"}
|
||||
@@ -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": "T-1001 어떻게 됐나요?", "user": "이준호"}
|
||||
@@ -0,0 +1 @@
|
||||
{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"}
|
||||
@@ -0,0 +1,37 @@
|
||||
# 변경 기록 · IT 헬프데스크
|
||||
|
||||
- 이 파일을 `my-work/CHANGELOG.md`로 복사해 채웁니다. 새 릴리스가 위로 갑니다.
|
||||
|
||||
## 1. v1 → v2에서 바뀐 것 (Lab 01 예제 1 · `flow.py diff`)
|
||||
|
||||
| 바뀐 곳 | 전 | 후 | 답에 생길 영향 (내 짐작) | 실제로 (`call rel-v1` · `rel-v2`) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 지시문 | | | | |
|
||||
| 모델 | | | | |
|
||||
| 노드 · 연결 | | | | |
|
||||
|
||||
## 1-2. 흔들림인가 바뀜인가 (Lab 01 예제 3)
|
||||
|
||||
| 재기 | v1 (`rel-v1`) | v2 (`rel-v2`) | 흔들림 · 바뀜 |
|
||||
| --- | --- | --- | --- |
|
||||
| `lab01-compare --repeat 3` 통과 수 | | | |
|
||||
| 출처 번호 케이스 (3번) | | | |
|
||||
| VPN `bench -n 5` p50 · p95 | | | |
|
||||
|
||||
## 1-3. 지시문 저장소 (Lab 01 과제 1 · 2)
|
||||
|
||||
| 상황 | `prompt_source` | 답 · 시간 |
|
||||
| --- | --- | --- |
|
||||
| 평소 | | |
|
||||
| 저장소 고장 (`fault prompts error`) 고치기 전 → 뒤 | | |
|
||||
| 저장소 느림 (`fault prompts slow`) 고치기 전 → 뒤 | | |
|
||||
|
||||
## 2. 릴리스 기록
|
||||
|
||||
| 릴리스 | 날짜 | 지시문 | 모델 | 바뀐 것 | 게이트 결과 | 배포 · 되돌림 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 1.0.0 | | p1 | gpt-4.1-mini | 첫 릴리스 | | |
|
||||
| 1.1.0 | | p2 | gpt-4.1-mini | | | |
|
||||
| 1.2.0 | | p3 | gpt-4.1-mini | | | |
|
||||
| 2.0.0 | | p2 | gpt-5.4-nano | | | |
|
||||
| 2.1.0 | | p9 | gpt-4.1-mini | | | |
|
||||
@@ -0,0 +1,35 @@
|
||||
# 릴리스 점검표 · IT 헬프데스크
|
||||
|
||||
- 이 파일을 `my-work/RELEASE.md`로 복사해 채웁니다.
|
||||
|
||||
## 1. 게이트 결과 (Lab 02)
|
||||
|
||||
| 릴리스 | 약한 golden (`cases/golden.json`) | 내가 고친 golden | p95 | 판단 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1.0.0 | | | | |
|
||||
| 1.1.0 | | | | |
|
||||
| 1.2.0 | | | | |
|
||||
| 2.0.0 | | | | |
|
||||
|
||||
| 2.1.0 (Lab 02 과제 2) | | 고치기 전 · 뒤 | | |
|
||||
|
||||
- 기준선 (Lab 02 예제 3): 1.0.0 VPN p50 ___ · p95 ___, 2.0.0 p50 ___ · p95 ___, golden 1.0.0 × 3 ___/___ → `--max-p95` 기준: ___ (까닭: ___)
|
||||
- 2.0.0 판단 한 줄:
|
||||
|
||||
## 2. 배포 순서 (Lab 03)
|
||||
|
||||
- [ ] 후보를 고정(`--set release=`)으로 게이트 → PASS
|
||||
- [ ] 카나리 (`flow.py release helpdesk --canary <후보> --percent <N>`) → 카나리 사용자 답 확인
|
||||
- [ ] 릴리스 점검 (`my-release-check`) → `promote`, `/gates/helpdesk`에 기록 (`recorded: true`)
|
||||
- [ ] 배포 정책을 켠 상태에서 (`--require-gate on`) 점검을 지난 것만 배포되는지
|
||||
- [ ] 배포 (`flow.py release helpdesk <후보>`) → 답의 `version` 확인
|
||||
- [ ] 문제가 생기면 되돌리기 (`flow.py release helpdesk <이전>`, `--no-canary`) → 확인
|
||||
- [ ] `CHANGELOG.md`에 한 줄
|
||||
|
||||
## 3. 되돌리기 연습 기록
|
||||
|
||||
| 상황 | 한 일 | 걸린 시간 | 확인 방법 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1.1.0 배포 → 되돌리기 (Lab 03 예제 1) | | | |
|
||||
| 나쁜 카나리 1.2.0 50% (Lab 03 예제 3): 영향 받은 직원 ___/6 | | | |
|
||||
| 정책이 막은 배포 (Lab 03 과제 2) | | | |
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user