68일차 교안·실습(복합 업무 시나리오 · Multi-Agent 라우터) 추가, 결석 수강생용 최소 준비 문서 포함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mcchaeandClaude Opus 5.5 committed 2026-10-19 22:05:32 +09:00
1 parent a57360d4d9
commit 587ca5c2b3
42 files changed
+5481

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+199
View File
@@ -0,0 +1,199 @@
# AGENTS.md · n8n Multi-Agent 워크플로우 작업 규칙 (68일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 67일차 규칙과 같고, 여러 Agent를 부르는 라우터용 노드(5절)가 더해졌습니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 업무가 여럿이면 **업무 → 담당 Agent** 표도 함께 보여 준다.
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
```
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` |
| 다른 Credential | 쓰지 않음 (Ollama 등 없음, LLM은 모두 위 OpenAI Credential) |
| 데이터 API | n8n 안에서 `http://data-api:8800`, 터미널에서 `http://localhost:8800` (67일차 `Last3Mon/67/data-api`) |
## 3. 이미 있는 Agent (`agents/`, 고치지 말 것)
라우터는 이 Agent들을 **HTTP로 부른다.** 내용을 바꾸지 말고 계약(입력 · 결과)만 지킨다.
| Agent | 파일 | 웹훅 | 입력 (요청 본문) | 결과 |
| --- | --- | --- | --- | --- |
| IT 문의 Agent (67일차) | `agents/it-agent.json` | `POST /webhook/it-agent` | `question`, `user` (둘 다 필수, 없으면 400) | `route`(자동 답변·담당자 연결), `answer`, `tools_used`, `sources` |
| 회의록 Agent (66일차) | `agents/meeting-agent.json` | `POST /webhook/meeting-agent` | `title`, `notes` (`notes` 필수, 없으면 400 `{"error": "notes are required"}`) | `route`, `title`, `count`, `summary`, `actions: [{owner, task, due}]` |
- IT 문의 Agent는 남의 접수 건을 보여 주지 않고 `route: 담당자 연결`로 돌려준다 (67일차 규칙).
- 라우터의 요청 본문: `message`(직원 요청 문장), `user`(요청한 사람), 회의록이 있으면 `title`, `notes`.
## 4. 워크플로우 파일 규칙
```json
{
"name": "68 Assistant router (my)",
"nodes": [ ... ],
"connections": { ... },
"settings": { "executionOrder": "v1" }
}
```
- 파일 하나에 워크플로우 하나. `id`는 쓰지 않아도 된다 (`push`가 파일 이름으로 정한다).
- 노드 `name`은 영어로, 워크플로우 안에서 겹치지 않게. 노드마다 `type`, `typeVersion`, `position: [x, y]`, `parameters`가 필요하다.
- **API 키를 JSON에 쓰지 않는다.** LLM 노드는 위 Credential id만 쓴다.
- 웹훅은 `POST`, `responseMode: "responseNode"`, 결과는 Respond to Webhook 노드로 보낸다.
- 요청 본문은 `$json.body.message`, `$json.body.user`처럼 `body` 아래에 있다.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
아래 `type`/`typeVersion`을 그대로 쓴다. 다른 노드가 필요하면 써도 되지만 `flow.py check`가 이 n8n에 있는지 확인한다.
### Webhook · If · Respond to Webhook
```json
{"name": "Assistant webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300],
"parameters": {"httpMethod": "POST", "path": "my-assistant", "responseMode": "responseNode", "options": {}}}
```
```json
{"name": "Has message?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [200, 300],
"parameters": {"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
"conditions": [{"id": "c1", "leftValue": "={{ $json.body.message }}", "rightValue": "",
"operator": {"type": "string", "operation": "notEmpty", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
- If 출력 0번이 true, 1번이 false. 숫자 비교는 `"operator": {"type": "number", "operation": "equals"}` (`gt`, `lt`도 있음).
```json
{"name": "Send result", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [2360, 300],
"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}}
```
- 오류 응답: `"responseBody": "{ \"error\": \"message is required\" }"`, `"options": {"responseCode": 400}`
### AI Agent + OpenAI chat model (업무 나누기, Supervisor)
```json
{"name": "Split tasks", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [420, 300],
"parameters": {"promptType": "define", "text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {"systemMessage": "You split one employee message into separate tasks. ... Return only JSON, no code fence: {\"tasks\": [...]}", "maxIterations": 2}}}
```
```json
{"name": "OpenAI chat model", "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", "typeVersion": 1.2, "position": [380, 520],
"parameters": {"model": {"__rl": true, "mode": "id", "value": "gpt-4.1-mini"}, "options": {"temperature": 0}},
"credentials": {"openAiApi": {"id": "openAiCourse0001", "name": "OpenAI (.env)"}}}
```
- Agent의 결과는 `$json.output`(글자). JSON을 달라고 해도 **글자**로 오므로 다음 Code 노드에서 `JSON.parse` 한다 (앞뒤 ```` ``` ````를 지우고, 실패하면 빈 목록).
- `returnIntermediateSteps: true`면 `$json.intermediateSteps`에 Tool 호출 기록: `[{ "action": { "tool": "Ask_IT_agent", "toolInput": {...} }, "observation": "..." }]`. Tool 이름은 노드 이름의 빈칸이 `_`로 바뀐 것.
### Code (JavaScript) · 여러 항목 돌려주기
```json
{"name": "One item per task", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [880, 300],
"parameters": {"jsCode": "return $json.tasks.map((task, index) => ({ json: { index: index + 1, ...task } }));"}}
```
- 반드시 `[{ json: {...} }, ...]` 배열을 돌려준다. 항목이 여럿이면 다음 노드는 **항목마다** 한 번씩 실행된다.
- 앞 노드는 `$('노드 이름').first().json`, 같은 항목의 앞 노드 값은 `$('노드 이름').item.json`.
### Switch (업무 유형별로 나누기)
```json
{"name": "Route by type", "type": "n8n-nodes-base.switch", "typeVersion": 3.2, "position": [1080, 300],
"parameters": {"rules": {"values": [
{"conditions": {"options": {"caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2},
"conditions": [{"id": "r-it", "leftValue": "={{ $json.type }}", "rightValue": "it", "operator": {"type": "string", "operation": "equals"}}],
"combinator": "and"}, "renameOutput": true, "outputKey": "it"},
{"conditions": {"...": "meeting 같은 모양"}, "renameOutput": true, "outputKey": "meeting"}]}, "options": {}}}
```
- 규칙 순서대로 출력 0, 1, 2 …. 연결의 `main`도 같은 순서의 칸.
### HTTP Request (다른 Agent 부르기)
```json
{"name": "Call IT agent", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1300, 100],
"parameters": {"method": "POST", "url": "http://localhost:5678/webhook/it-agent", "sendBody": true, "specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.text, user: $('Assistant webhook').first().json.body.user }) }}",
"options": {"timeout": 60000, "response": {"response": {"neverError": true}}}}}
```
- `neverError: true`가 없으면 부른 Agent가 400·404·500을 돌려줄 때 **워크플로우 전체가 멈춘다** (응답 500).
- 응답 본문이 그대로 다음 노드의 `$json`이 된다 (`$json.route`, `$json.answer` …).
### HTTP Request Tool (Supervisor가 Agent를 Tool로 부를 때)
```json
{"name": "Ask IT agent", "type": "n8n-nodes-base.httpRequestTool", "typeVersion": 4.2, "position": [480, 520],
"parameters": {"toolDescription": "Ask the company IT agent one IT question ... Input: one question in Korean.",
"method": "POST", "url": "http://localhost:5678/webhook/it-agent", "sendBody": true,
"bodyParameters": {"parameters": [
{"name": "question", "value": "={{ $fromAI('question', 'One IT question in Korean', 'string') }}"},
{"name": "user", "value": "={{ $('Assistant webhook').first().json.body.user }}"}]},
"options": {"timeout": 60000, "response": {"response": {"neverError": true}}}}}
```
- `$fromAI`는 LLM이 정하는 값. **LLM이 정하면 안 되는 값**(요청한 사람, 회의록 원문)은 웹훅 입력에서 표현식으로 가져온다.
### Edit Fields (Set) · Merge · Aggregate (결과 모으기)
```json
{"name": "IT result", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1520, 100],
"parameters": {"assignments": {"assignments": [
{"id": "a0", "name": "index", "value": "={{ $('Route by type').item.json.index }}", "type": "number"},
{"id": "a1", "name": "route", "value": "={{ $json.route || '담당자 연결' }}", "type": "string"},
{"id": "a2", "name": "answer", "value": "={{ $json.answer }}", "type": "string"}]}, "options": {}}}
```
```json
{"name": "Collect results", "type": "n8n-nodes-base.merge", "typeVersion": 3, "position": [1760, 300], "parameters": {"numberInputs": 3}}
```
```json
{"name": "All results", "type": "n8n-nodes-base.aggregate", "typeVersion": 1, "position": [1960, 300],
"parameters": {"aggregate": "aggregateAllItemData", "destinationFieldName": "results", "options": {}}}
```
- Merge는 갈래를 하나로 모은다 (입력 수 = `numberInputs`, 연결의 `index`로 몇 번 입력인지 정함). 데이터가 오지 않은 입력이 있어도 동작한다.
- Aggregate는 여러 항목을 `results` 배열 **한 항목**으로 묶는다. 그 뒤 Code에서 `index`로 정렬해 답을 만든다.
## 6. 연결 (`connections`)
```json
{
"Has message?": {"main": [[{"node": "Split tasks", "type": "main", "index": 0}], [{"node": "Reject empty", "type": "main", "index": 0}]]},
"OpenAI chat model": {"ai_languageModel": [[{"node": "Split tasks", "type": "ai_languageModel", "index": 0}]]},
"Route by type": {"main": [[{"node": "Call IT agent", "type": "main", "index": 0}], [{"node": "Call meeting agent", "type": "main", "index": 0}], [{"node": "Other result", "type": "main", "index": 0}]]},
"IT result": {"main": [[{"node": "Collect results", "type": "main", "index": 0}]]},
"Meeting result": {"main": [[{"node": "Collect results", "type": "main", "index": 1}]]}
}
```
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If·Switch의 `main`은 출력 순서대로 칸이 나뉜다.
- Chat model은 `ai_languageModel`, Tool은 `ai_tool`로 **Agent에** 연결한다 (`main` 아님).
- Merge로 들어가는 연결은 `index`가 입력 번호 (0, 1, 2 …).
## 7. 표현식 주의
- 표현식은 `=`로 시작한다: `"={{ $json.body.message }}"`. `=`가 없으면 그냥 글자로 남는다.
- `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다: `"={{ JSON.stringify({ question: $json.text }) }}"`
- Agent·HTTP 뒤 노드에서 웹훅 입력은 `$('Assistant webhook').first().json.body`로 읽는다 (`$json`은 바로 앞 노드 결과).
## 8. 테스트 파일 (`cases/*.json`)
```json
{
"webhook": "my-assistant",
"cases": [
{"name": "복합 요청", "request": "requests/composite.json",
"expect": {"status": 200, "equals": {"count": 3, "results.1.agent": "meeting-agent"},
"contains": {"results.0.answer": ["처리 중"]}, "not_contains": {"reply": ["정우성"]}}},
{"name": "직접 쓴 요청", "body": {"message": "", "user": "이준호"}, "expect": {"status": 400}},
{"name": "다른 웹훅으로", "webhook": "it-agent", "body": {"question": "VPN", "user": "이준호"}, "expect": {}}
]
}
```
- 필드 이름은 점으로 내려간다: `results.0.answer` = `results` 첫 항목의 `answer`.
- `equals`: 값이 같아야 함 · `contains`: 목록 중 **하나라도** 들어 있어야 함 (배열·객체는 JSON 글자로 바꿔 찾음) · `not_contains`: 하나도 없어야 함 · `status`: HTTP 코드(기본 200)
- `uv run tools/flow.py test <파일> --repeat 3`: LLM 답은 매번 조금씩 다르므로 3번 이상 돌려 본다.
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `agents/`, `requests/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만).
- 이미 있는 Agent의 일을 라우터 안에서 다시 만들지 않는다 (FAQ·접수 현황을 라우터가 직접 조회하지 않는다).
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
- `.env`나 API 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+146
View File
@@ -0,0 +1,146 @@
# Lab 01 · 복합 요청을 업무로 나누기
- 관련 강의: [1단계 · 복합 업무 시나리오 분석](../../Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md)
- 소요 시간: 약 40분 (예제 1 10분 · 예제 2 15분 · 과제 15분)
- 준비: [실습 준비](../../Lecture/68/00-DAY68-PREP.md)의 `uv run tools/flow.py doctor` → `ready`
- 목표: 요청 하나에 섞인 업무를 **사람이 먼저 분석**하고, 그 분석대로 업무를 나누는 워크플로우를 **내가 쓴 요청문**으로 만든다.
```mermaid
flowchart LR
E1["예제 1<br/>업무 분석표<br/>(사람)"] --> E2["예제 2<br/>업무 나누기<br/>워크플로우"] --> T["과제<br/>담당 Agent · 빠진 입력"]
```
> 이 실습에는 **복사해 붙일 요청문이 없습니다.** 요청문은 직접 씁니다. 막히면 아래 `▶` 를 하나씩 펼쳐 보세요. 펼칠수록 구체적이 되지만, 정답 요청문은 나오지 않습니다.
## 시작하기 · 터미널 두 개
| 터미널 | 하는 일 |
| --- | --- |
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/68` 후 `codex` (또는 `claude`, `agy`) 실행. 내가 쓴 요청문을 보낸다 |
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/68` 에서 `push`·`test`를 **내가** 실행한다 |
- 에이전트는 이 폴더의 [AGENTS.md](AGENTS.md)를 읽고 일합니다. `agy`가 읽지 않으면 첫 요청 앞에 "AGENTS.md를 먼저 읽어"를 붙입니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모읍니다.
## 예제 1 · 업무 분석표 (10분, 에이전트 없이)
`cp templates/MULTI-AGENT-DESIGN-TEMPLATE.md my-work/DESIGN.md` 후 1절 표를 **직접** 채웁니다. 요청 파일은 `requests/`에 있습니다 (`cat requests/composite.json`).
| 요청 파일 | 직원 요청 |
| --- | --- |
| `composite` | T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요. (회의록 첨부) |
| `two-it` | 비밀번호를 잊어버렸어요. 그리고 메일함 용량이 꽉 찼다고 나와요. |
| `it-and-other` | 프린터 출력이 안 돼요. 그리고 다음 주 회식 장소도 추천해 주세요. |
| `meeting-no-notes` | 이번 회의록 할 일 정리 부탁해요 (회의록 첨부 **없음**) |
| 내 업무 | 64일차 Lab 03의 내 업무 시나리오에서 하나 |
<details><summary>▶ 1단계 · 무엇을 기준으로 나누나</summary>
"한 사람이 한 번에 처리할 수 있는 일"이 업무 하나입니다. 접속사(그리고, 또, ~고)에서 끊기지만, 끊긴 조각이 같은 일을 말하면 하나로 둡니다. 유형은 지금 있는 Agent(IT 문의, 회의록) 기준으로 나누고, 맡을 Agent가 없으면 `other`입니다.
</details>
<details><summary>▶ 2단계 · 빠뜨리기 쉬운 칸</summary>
- **필요한 입력**: 업무를 처리하려면 무엇이 있어야 하나요? 접수 현황은 "누가 물었는지", 회의록 정리는 "회의록 원문"이 필요합니다. 요청 문장에 없고 **요청 본문의 다른 칸**에 있을 수도 있습니다 (`requests/*.json`을 열어 봅니다).
- **업무 관계**: 한 업무의 결과가 다른 업무에 필요한가요? 필요 없다면 "독립"이고, 순서와 상관없이 따로 처리할 수 있습니다.
</details>
**확인**: 옆 사람과 표를 바꿔 봅니다. 업무 개수가 같은가요? 다르면 어느 쪽 기준이 Agent에게 넘기기 좋은가요?
## 예제 2 · 업무 나누기 워크플로우 (15분)
**만들 것**: 직원 요청 문장을 받아 업무 목록으로 나누는 워크플로우. 아직 Agent를 부르지 않고 **나누기만** 합니다.
| 조건 | 값 |
| --- | --- |
| 저장할 파일 | `my-work/split.json` |
| 웹훅 | POST, 경로 `my-split`, 요청 문장은 본문의 `message` |
| 나누는 방법 | LLM(OpenAI `gpt-4.1-mini`)이 나누고, 결과 글자를 코드로 읽어 목록으로 |
| 결과 | `{"count": 3, "tasks": [{"type": "it", "text": "..."}]}` · 유형은 `it`, `meeting`, `other` 셋 |
| 기대 결과 | `cases/lab01.json` (먼저 열어 읽기) |
<details><summary>▶ 1단계 · 예제 1의 표를 요청문으로</summary>
예제 1에서 정한 **나누는 기준과 유형 정의**를 그대로 요청문에 옮깁니다. 에이전트는 이 기준을 LLM 지시문(systemMessage)에 넣게 됩니다. 유형마다 "어떤 요청이 여기에 드는지" 예를 한두 개씩 적어 보세요.
</details>
<details><summary>▶ 2단계 · LLM의 답을 믿지 않기</summary>
LLM에게 JSON으로 달라고 해도 앞뒤에 다른 글자가 붙거나, 정해지지 않은 유형을 쓰기도 합니다. 결과를 읽는 쪽에서 **무엇을 확인하고, 읽지 못하면 어떻게 할지** 요청문에 적었나요? 업무의 순서(요청에 나온 순서)도 지켜야 테스트의 `tasks.0`, `tasks.1`이 맞습니다.
</details>
<details><summary>▶ 3단계 · 보내기 전 점검표</summary>
- [ ] 파일 경로, 웹훅 경로, 입력 필드
- [ ] 유형 셋과 각각의 정의
- [ ] 결과 JSON 모양 (필드 이름 `count`, `tasks`, `type`, `text`)
- [ ] 빈 요청이면 어떻게 할지 (테스트 파일의 마지막 케이스)
- [ ] 테스트 파일 경로
</details>
<details><summary>▶ 4단계 · 테스트가 실패하면</summary>
실패한 줄과 보고서 경로를 그대로 붙입니다. 업무 개수가 틀렸다면, LLM이 받은 문장과 돌려준 글자를 n8n **Executions**에서 직접 보고 "어느 기준이 모호했는지"를 먼저 묻습니다. 지시문에 예를 더하는 것과 기준을 바꾸는 것은 다른 고침입니다.
</details>
```bash
uv run tools/flow.py push my-work/split.json
uv run tools/flow.py test cases/lab01.json --repeat 3
uv run tools/flow.py call my-split requests/composite.json
```
**관찰**: 예제 1의 내 분석표와 워크플로우의 결과가 같은가요? `composite`의 업무 텍스트에 `T-1002`가 남아 있나요 (다음 Agent가 번호를 알아야 합니다)?
## 과제 · 누가 맡는지, 무엇이 빠졌는지 (15분)
팀장이 요청했습니다.
> "업무마다 어느 Agent가 맡는지 같이 적어 주세요. 그리고 처리에 꼭 필요한 게 빠졌으면 무엇이 빠졌는지도요. 그건 LLM이 짐작하지 말고 규칙으로 정해 주세요."
기대 결과는 `cases/lab01-challenge.json`에 있습니다.
| 케이스 | 기대 |
| --- | --- |
| 복합 요청 | `tasks[].agent`에 `it-agent`, `meeting-agent`. 빠진 것 없음 |
| 회의록 없는 회의 요청 | `agent: meeting-agent`, `missing: ["notes"]` |
| 기타 | `agent: 담당자 연결`, `missing: []` |
| IT · 입력 다 있음 | `agent: it-agent`, `missing: []` |
**먼저 생각해 보기 (3분)**: 업무 유형별로 "꼭 필요한 입력"을 적어 봅니다. [AGENTS.md](AGENTS.md) 3절의 Agent 입력 칸이 힌트입니다.
<details><summary>▶ 1단계 · "규칙으로"의 뜻</summary>
어느 Agent가 맡는지는 유형이 정해지면 **표 하나로** 정해집니다. 빠진 입력도 요청 본문에 그 칸이 비었는지만 보면 됩니다. 둘 다 LLM에게 물어볼 일이 아닙니다. 요청문에서 "LLM 지시문을 고치지 말고 결과를 읽는 코드에서" 정하라고 할 수 있습니다.
</details>
<details><summary>▶ 2단계 · 원래 테스트도</summary>
고치는 요청입니다. 고칠 파일, 새 필드 이름과 예시 값, 그리고 `cases/lab01.json`도 계속 통과해야 한다는 조건을 적었나요?
</details>
<details><summary>▶ 3단계 · 하나 더 생각하기</summary>
IT 문의 Agent는 `user`가 없으면 400을 돌려줍니다. 테스트에는 없지만, 이것도 "빠진 입력"으로 표시할지 정해 보세요. Lab 03 과제에서 다시 만납니다.
</details>
```bash
uv run tools/flow.py push my-work/split.json
uv run tools/flow.py test cases/lab01-challenge.json --repeat 3
uv run tools/flow.py test cases/lab01.json
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `check`에 ✘ | 에이전트에게 그 줄을 그대로 붙여 주고 고쳐 달라고 합니다 |
| `push` 후 `call`이 404 | 몇 초 뒤 다시. 계속이면 `uv run tools/flow.py doctor` |
| `count`가 매번 다름 | `--repeat 3` 결과를 보고, 다른 답이 나온 요청을 Executions에서 확인 |
| 에이전트가 계속 엉뚱하게 고침 | 요청을 더 작게 나눕니다. 그래도 안 되면 `cp reference/lab01-example2-split.json my-work/split.json` 후 push로 이어 갑니다 |
## 확인
- [ ] 예제 1: `my-work/DESIGN.md` 1절 분석표를 채웠다.
- [ ] 예제 2: `cases/lab01.json` 통과.
- [ ] 과제: `cases/lab01-challenge.json` 3번 반복 통과, 원래 테스트도 통과.
- [ ] `my-work/PROMPTS.md`에 요청문을 모았다.
+143
View File
@@ -0,0 +1,143 @@
# Lab 02 · 여러 Agent에게 나눠 맡기는 라우터
- 관련 강의: [2단계 · Multi-Agent 워크플로우 설계](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md)
- 소요 시간: 약 70분 (예제 1 10분 · 예제 2 25분 · 과제 35분)
- 준비: Lab 01의 `my-work/split.json` (없으면 `cp reference/lab01-example2-split.json my-work/split.json`)
- 목표: 이미 있는 Agent 둘의 **계약**을 정리하고, 업무를 유형별로 나눠 그 Agent들을 부르는 **라우터**를 만든다. 같은 일을 **Supervisor** 방식으로도 만들어 비교한다.
```mermaid
flowchart LR
E1["예제 1<br/>Agent 계약 정리"] --> E2["예제 2<br/>라우터"] --> T["과제<br/>Supervisor와 비교"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · Agent 계약 정리 (10분)
**할 일**: 두 Agent를 직접 불러 보고, 에이전트에게 Agent 파일을 읽혀 **계약 표**를 `my-work/DESIGN.md` 2절에 쓰게 한다. 워크플로우는 만들지 않는다.
```bash
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json # 준비 때 했으면 생략
uv run tools/flow.py call it-agent requests/agent-it.json
uv run tools/flow.py call meeting-agent requests/agent-meeting.json
uv run tools/flow.py call meeting-agent '{"title": "주간 회의"}'
```
| 계약 표의 칸 | |
| --- | --- |
| Agent · 맡는 업무 · 웹훅 | |
| 입력 (필수인 것) | |
| 결과 (필드 이름) | |
| 실패할 때 돌려주는 것 | 400? `route: 담당자 연결`? |
<details><summary>▶ 1단계 · 에이전트에게 무엇을 읽게 하나</summary>
`agents/` 폴더의 두 파일이 근거입니다. 웹훅 노드, 입력을 검사하는 If 노드, 마지막 Respond 노드를 보면 계약이 나옵니다. "워크플로우를 만들지 말고 문서만"이라고 분명히 적습니다.
</details>
<details><summary>▶ 2단계 · 에이전트의 표를 믿기 전에</summary>
위 `call` 세 번의 결과와 에이전트가 쓴 표를 비교합니다. 필드 이름 하나라도 다르면 라우터가 그 값을 읽지 못합니다. [AGENTS.md](AGENTS.md) 3절과도 맞춰 봅니다.
</details>
## 예제 2 · 라우터 (25분)
**만들 것**: Lab 01의 업무 나누기를 바탕으로, 업무마다 담당 Agent를 HTTP로 부르고 결과를 하나로 모아 답하는 워크플로우.
| 조건 | 값 |
| --- | --- |
| 저장할 파일 | `my-work/assistant.json` (오늘 끝까지 이 파일을 고쳐 나감) |
| 바탕 | `my-work/split.json` (그 파일은 그대로 둔다) |
| 웹훅 | POST, 경로 `my-assistant`, 본문 `message`, `user`, 회의록이 있으면 `title`, `notes` |
| 분배 | `it` → IT 문의 Agent, `meeting` → 회의록 Agent, `other` → Agent를 부르지 않고 `담당자 연결` |
| 결과 | `{"count": 3, "results": [{"index", "type", "text", "agent", "route", "answer"}], "reply": "..."}` |
| 기대 결과 | `cases/lab02.json` |
<details><summary>▶ 1단계 · 흐름부터 정하기</summary>
`DESIGN.md` 3절에 mermaid로 흐름을 먼저 그려 봅니다: 나누기 → 업무 하나씩 → 유형별 갈래 → Agent 부르기 → 결과 모양 맞추기 → 모으기 → 답. 이 흐름을 요청문에 그대로 순서대로 적으면 에이전트가 덜 헤맵니다. [2단계 교안](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md) 4절의 노드 이름을 참고합니다.
</details>
<details><summary>▶ 2단계 · Agent에게 무엇을 넘기나</summary>
IT 문의 Agent에는 **업무 텍스트**와 **요청한 사람**, 회의록 Agent에는 **회의록 제목과 원문**을 넘깁니다. 이 중 LLM이 만든 값은 무엇이고, 웹훅 본문에서 그대로 가져와야 하는 값은 무엇인가요? 예제 1의 계약 표를 요청문에 붙이거나 "AGENTS.md 3절의 계약대로"라고 적습니다.
</details>
<details><summary>▶ 3단계 · 결과를 한 모양으로</summary>
두 Agent의 결과 모양이 다릅니다 (IT는 `answer`, 회의록은 `summary`·`actions`). 모으기 전에 **같은 모양**(`index, type, text, agent, route, answer`)으로 맞춰야 `reply`를 만들 수 있습니다. 회의록 결과의 `answer`에 무엇을 넣을지, `actions`도 남길지 정합니다. 결과 순서는 요청에 나온 순서(`index`).
</details>
<details><summary>▶ 4단계 · 보내기 전 점검표</summary>
- [ ] 바탕 파일과 새 파일 경로, 새 웹훅 경로
- [ ] 유형별 분배와 각 Agent 주소 (n8n 안에서 부르는 주소)
- [ ] 넘길 값과 그 값의 출처
- [ ] 결과 한 항목의 모양, 전체 결과 모양
- [ ] `cases/lab02.json`
</details>
```bash
uv run tools/flow.py push my-work/assistant.json
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py call my-assistant requests/composite.json
```
**관찰**: n8n **Executions**에서 `my-assistant` 한 번의 실행을 엽니다. `Call IT agent` 노드는 항목 몇 개를 처리했나요(IT 업무가 2개면)? 같은 시간에 `it-agent`, `meeting-agent`의 실행도 따로 생겼나요? `test` 결과에서 복합 요청과 단일 요청의 시간을 비교해 메모합니다 (Lab 03에서 씁니다).
## 과제 · Supervisor로도 만들어 비교 (35분)
팀장이 물었습니다.
> "업무를 미리 나누지 말고, AI 하나가 알아서 필요한 Agent를 부르게 하면 더 간단하지 않아요? 해 보고 어느 쪽이 나은지 알려 주세요."
**만들 것**: 같은 요청을 받아, AI Agent 하나(Supervisor)가 두 Agent를 **Tool로** 골라 부르고 답을 쓰는 워크플로우.
| 조건 | 값 |
| --- | --- |
| 저장할 파일 | `my-work/supervisor.json` (라우터와 별도) |
| 웹훅 | POST, 경로 `my-supervisor`, 입력은 라우터와 같음 |
| 결과 | `{"reply": "...", "agents_used": ["it-agent", ...]}` (실제로 부른 Agent, 겹치지 않게) |
| 기대 결과 | `cases/lab02-challenge.json` |
**먼저 생각해 보기 (3분)**: 라우터에서 코드가 하던 일(나누기, 분배, 모으기) 중 Supervisor에서는 누가 하나요?
<details><summary>▶ 1단계 · Agent를 Tool로</summary>
67일차에 FAQ API를 HTTP Request **Tool**로 붙였던 것처럼, 이번에는 다른 Agent의 웹훅을 Tool로 붙입니다. Tool 설명(`toolDescription`)이 "언제 이 Agent를 부를지"의 근거가 됩니다.
</details>
<details><summary>▶ 2단계 · LLM에게 맡기면 안 되는 값</summary>
Tool의 입력 중 **LLM이 채워도 되는 값**(질문 문장)과 **채우면 안 되는 값**(요청한 사람, 회의록 원문)을 나눕니다. 회의록 원문을 LLM이 다시 써서 넘기면 무엇이 달라질 수 있을까요?
</details>
<details><summary>▶ 3단계 · agents_used는 누가 쓰나</summary>
`agents_used`를 LLM의 답에서 찾게 할 수도, Agent의 Tool 호출 기록에서 찾게 할 수도 있습니다. 67일차 Lab 01 과제의 `sources`와 같은 질문입니다.
</details>
<details><summary>▶ 4단계 · 비교하기</summary>
```bash
uv run tools/flow.py test cases/lab02-challenge.json --repeat 3
uv run tools/flow.py test cases/lab02.json --webhook my-supervisor # 라우터의 테스트를 Supervisor에
```
`DESIGN.md` 3절 아래에 표로 정리합니다: 복합 요청 시간, 같은 요청 3번의 결과가 같은가, 결과 모양(계약)이 테스트에 맞는가, Agent를 하나 더 붙일 때 고칠 곳. 두 번째 명령이 왜 실패하는지도 적습니다.
</details>
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 응답이 500, 아무 결과 없음 | 부른 Agent가 오류를 돌려줬을 수 있습니다. Executions에서 어느 노드가 빨간지 보고 그 내용을 에이전트에게 붙입니다 (Lab 03에서 다룸) |
| `Call ... agent`가 404 | 그 Agent가 게시되지 않음: `uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json` |
| `results`가 한 개뿐 | 모으기(Merge·Aggregate) 전에 항목이 하나로 줄었는지 Executions에서 확인 |
| 라우터가 계속 안 됨 | `cp reference/lab02-example2-router.json my-work/assistant.json` 후 push로 이어 갑니다 |
## 확인
- [ ] 예제 1: 두 Agent를 직접 불렀고, 계약 표가 `call` 결과와 맞다.
- [ ] 예제 2: `cases/lab02.json` 통과.
- [ ] 과제: `cases/lab02-challenge.json` 통과, 라우터와 Supervisor 비교 표.
+133
View File
@@ -0,0 +1,133 @@
# Lab 03 · 빠진 입력, 꺼진 Agent, 어려운 테스트
- 관련 강의: [3단계 · 검증과 장애 격리, 확장 고려](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md)
- 소요 시간: 약 80분 (예제 1 20분 · 예제 2 20분 · 과제 40분)
- 준비: Lab 02의 `my-work/assistant.json` (없으면 `cp reference/lab02-example2-router.json my-work/assistant.json` 후 push)
- 목표: Multi-Agent에서 **한 곳의 문제가 전체 답을 망치지 않게** 만든다. 테스트로 문제를 드러내고, 고치고, 측정값으로 확장 계획을 쓴다.
```mermaid
flowchart LR
E1["예제 1<br/>빠진 입력"] --> E2["예제 2<br/>Agent가 꺼졌을 때"] --> T["과제<br/>어려운 테스트 · 확장 계획"]
```
> 복사해 붙일 요청문은 없습니다. 이번 Lab은 **실패를 먼저 보고** 요청문을 씁니다.
## 예제 1 · 빠진 입력 (20분)
먼저 그대로 돌려 봅니다.
```bash
uv run tools/flow.py push my-work/assistant.json
uv run tools/flow.py test cases/lab03.json
```
| 케이스 | 기대 |
| --- | --- |
| 회의록 없는 회의 요청 | HTTP 200, `results.0.route: 입력 필요`, 답에 "회의록"을 보내 달라는 말 |
| IT 둘 · 둘 다 답 | `count: 2`, 두 업무 모두 `자동 답변` |
| 복합 요청 · 오류 없음 | `count: 3`, 어느 결과에도 `오류` 없음 |
| 기타만 · 빈 요청 | 담당자 연결 · 400 |
<details><summary>▶ 1단계 · 보고서 읽기</summary>
실패한 케이스의 `status`와 `response`를 `runs/lab03-....json`에서 봅니다. 응답이 500이면 라우터 **전체**가 멈춘 것입니다. n8n **Executions**에서 어느 노드가 빨간지, 그 노드가 받은 응답이 무엇인지 확인합니다. 회의록 Agent의 계약(예제 1의 표)에서 `notes`가 없을 때 무엇을 돌려준다고 했나요?
</details>
<details><summary>▶ 2단계 · 어디서 막을까</summary>
두 가지 방법이 있습니다: (가) Agent를 부르고 오류가 오면 처리, (나) **부르기 전에** 빠진 입력을 보고 Agent를 부르지 않음. Lab 01 과제에서 만든 `missing`이 있다면 어느 쪽이 쉬운가요? 직원에게 무엇이라고 답해야 다음에 제대로 보낼 수 있을까요?
</details>
<details><summary>▶ 3단계 · 요청문 점검</summary>
- [ ] 실패한 케이스 이름, 보고서 경로를 그대로 붙였다
- [ ] 원인 설명을 먼저 요구했다
- [ ] 새 결과 `route` 값과 답 문장을 정했다
- [ ] `cases/lab02.json`도 계속 통과해야 한다고 적었다
</details>
## 예제 2 · Agent가 꺼졌을 때 (20분)
회의록 Agent를 일부러 끕니다.
```bash
uv run tools/flow.py off agents/meeting-agent.json
uv run tools/flow.py test cases/lab03-down.json
```
| 케이스 (회의록 Agent 꺼짐) | 기대 |
| --- | --- |
| 복합 요청 | HTTP 200, `count: 3`. IT 업무는 그대로 `자동 답변`("처리 중"), 회의록 업무는 `담당자 연결`, 답에 "응답하지 않아" |
| 회의록만 | HTTP 200, `담당자 연결` |
| IT만 | 그대로 `자동 답변` |
**먼저 생각해 보기**: 고치기 전 복합 요청의 응답에 T-1002 상태가 들어 있나요? 회의록 Agent 하나가 꺼졌는데 IT 답까지 사라졌다면 왜일까요?
<details><summary>▶ 1단계 · 오류를 "결과"로 바꾸기</summary>
Agent를 부르는 노드가 404·500을 받으면 n8n은 기본으로 워크플로우를 멈춥니다. 멈추지 않고 그 응답을 다음 노드로 넘기는 설정이 있습니다 ([AGENTS.md](AGENTS.md) 5절 HTTP Request). 넘겨받은 뒤 결과 칸(`route`, `answer`)이 비어 있으면 무엇으로 채울지 정합니다.
</details>
<details><summary>▶ 2단계 · 얼마나 기다릴까</summary>
꺼진 Agent는 바로 404가 오지만, **느린** Agent는 응답이 안 올 수도 있습니다. 직원이 얼마나 기다려도 될지 정하고 요청문에 적습니다.
</details>
<details><summary>▶ 3단계 · 다시 켜고 확인</summary>
고친 뒤 `lab03-down`을 통과하면, 회의록 Agent를 **다시 켜고** 나머지 테스트도 봅니다.
```bash
uv run tools/flow.py push agents/meeting-agent.json
uv run tools/flow.py test cases/lab02.json
uv run tools/flow.py test cases/lab03.json --repeat 3
```
</details>
## 과제 · 어려운 테스트 쓰고 고치기, 확장 계획 (40분)
팀장이 요청했습니다.
> "다음 주부터 전 직원이 씁니다. 지금 테스트는 우리가 고친 것만 확인하잖아요. 실제로 들어올 만한 어려운 요청으로 테스트를 더 만들고, 통과시키세요. 그리고 사람이 몰리면 어떻게 할지 숫자로 설명해 주세요."
1. `cases/my-cases.json`에 어려운 케이스 **5개 이상**을 직접 씁니다 (형식은 [AGENTS.md](AGENTS.md) 8절, `body`로 직접 써도 됨).
2. 지금 라우터로 돌려 실패를 보고, 에이전트에게 고치게 합니다 (테스트의 기대 결과는 바꾸지 않음).
3. `my-work/DESIGN.md` 5·6절을 채웁니다. 6절의 시간은 **내 `runs/` 보고서의 값**으로 씁니다.
```bash
uv run tools/flow.py test cases/my-cases.json --repeat 3
uv run tools/flow.py test cases/lab03.json
uv run tools/flow.py test cases/lab02.json
```
<details><summary>▶ 1단계 · 어디서 어려운 요청이 나오나</summary>
다섯 곳에서 찾아봅니다: ① 입력 칸이 비거나 이상함 ② 업무 수가 많거나 섞임 ③ 67일차에 지킨 규칙(남의 접수 건)이 라우터를 거쳐도 지켜지나 ④ 붙어 온 자료와 요청이 안 맞음 ⑤ 다른 언어·다른 표기. 각 케이스의 기대 결과를 **업무 규칙 한 줄**로 먼저 적고 테스트로 옮깁니다.
</details>
<details><summary>▶ 2단계 · 좋은 케이스의 조건</summary>
- 기대 결과가 **하나로** 정해진다 (사람마다 다르게 판단하면 테스트가 아님)
- 통과해도 의미가 있다: 지금 통과하는 케이스도 "고칠 때 깨지지 않게" 남겨 둔다
- `not_contains`로 **나오면 안 되는 것**(남의 이름, 오류)을 확인한다
</details>
<details><summary>▶ 3단계 · 확장 계획 생각하기</summary>
보고서에서 IT 하나, 회의록 하나, 복합 요청의 시간을 찾습니다. 복합 요청 시간은 각 업무 시간의 합에 가까운가요, 가장 긴 것에 가까운가요? 21명이 동시에 보내면 n8n 하나가 몇 개를 동시에 처리해야 하나요? [3단계 교안](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md) 5절의 수직·수평 확장 표를 보고 내 측정값에 맞는 첫 조치를 고릅니다.
</details>
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `off` 후 다른 테스트도 이상함 | 회의록 Agent를 다시 켭니다: `uv run tools/flow.py push agents/meeting-agent.json` |
| 고쳤는데 `lab02`가 깨짐 | 결과 모양(계약)이 바뀌었는지 확인: `results.N`의 필드 이름 |
| 계속 안 됨 | `cp reference/lab03-example2-isolated.json my-work/assistant.json` 후 push로 과제를 이어 갑니다 |
## 확인
- [ ] 예제 1: `cases/lab03.json` 통과 (빠진 입력은 Agent를 부르지 않고 `입력 필요`).
- [ ] 예제 2: 회의록 Agent가 꺼져도 `lab03-down` 통과, 다시 켠 뒤 `lab02`·`lab03` 통과.
- [ ] 과제: `cases/my-cases.json` 5개 이상 3번 반복 통과, `DESIGN.md` 5·6절에 측정값.
- [ ] [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웠다.
+186
View File
@@ -0,0 +1,186 @@
# 68일차 수강 안내 · 수업 준비부터 마칠 때까지
- 일자: 2026-10-20 (화) · 68일차
- 주제: 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 설계
- 실습 폴더: [Last3Mon/68](README.md) · 강의 자료: [Lecture/68](../../Lecture/68/README.md)
- 환경: 65일차 n8n 2.42.6 + **OpenAI API 키만**(`gpt-4.1-mini`) + 67일차 데이터 API + CLI 코딩 에이전트 하나. **Ollama는 쓰지 않습니다.**
- 이 문서: 오늘 하루 내가 할 일을 수업 준비부터 마칠 때까지 순서대로 모은 수강 안내입니다. 수업 중에도 띄워 두고 어디까지 왔는지 확인하세요.
```mermaid
flowchart LR
P["전날<br/>준비 · Agent 게시"] --> M["아침<br/>켜기 · doctor"] --> L1["1~2교시<br/>Lab 01<br/>업무 나누기"] --> L2["3~4교시<br/>Lab 02<br/>라우터 · Supervisor"] --> L3["5~6교시<br/>Lab 03<br/>빠진 입력 · 꺼진 Agent"] --> V["7~8교시<br/>tflow"] --> E["마친 뒤<br/>남기기 · 69 준비"]
```
## 0. 하루 한눈에
| 교시 | 시간 | 할 일 | 문서 | 끝났다는 표시 |
| --- | --- | --- | --- | --- |
| 전날 | 약 15분 | 자료 받기 · n8n · 데이터 API · 두 Agent 게시 · doctor | [PREP](../../Lecture/68/00-DAY68-PREP.md) (65~67일차를 못 했다면 [PREPARE-68-FROM-65](PREPARE-68-FROM-65.md), 약 30분) | `ready` |
| 1 | 09:00~09:50 | 개요 · 1단계 강의 (복합 업무 시나리오 분석) | [개요](../../Lecture/68/00-DAY68-OVERVIEW.md), [1단계](../../Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md) | |
| 2 | 10:00~10:50 | Lab 01 예제 1 · 2 · 과제 | [Lab 01](LAB-01-Q-SCENARIO-AND-SPLIT.md) | `lab01-challenge` 통과 |
| 3 | 11:00~11:50 | 2단계 강의 · Lab 02 예제 1 · 2 | [2단계](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md), [Lab 02](LAB-02-Q-ROUTER-AGENTS.md) | `lab02` 통과 |
| 4 | 12:00~12:50 | Lab 02 과제 35분 (Supervisor 비교) · 풀이 | Lab 02 | `lab02-challenge` · 비교 표 |
| - | 13:00~14:00 | 점심 | | |
| 5 | 14:00~14:50 | 3단계 강의 · Lab 03 예제 1 · 2 | [3단계](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md), [Lab 03](LAB-03-Q-VERIFY-AND-ISOLATE.md) | `lab03` · `lab03-down` 통과 |
| 6 | 15:00~15:50 | Lab 03 과제 40분 · 풀이 | Lab 03 | 내 케이스 5개 · 보고서 |
| 7 | 16:00~16:50 | 바이브 코딩 `tflow` 각자 만들기 | [VIBE-Q-TFLOW](VIBE-Q-TFLOW.md) | R1~R5 · `pytest` |
| 8 | 17:00~17:50 | 발표 · 강사 정리 · 강사 바이브 코딩 · 팁 · 69일차 예고 | [진행](../../Lecture/68/04-VIBE-CODING.md) | |
## 1. 전날 준비 (약 15분)
### 1.1 자료 받기
```bash
cd ~/aio-01d4-stu && git pull
```
### 1.2 n8n과 데이터 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/67/data-api
docker compose up -d
curl -s localhost:8800/health # {"status": "ok"}
```
- LLM은 **OpenAI API 키**(`Last3Mon/.env`) 하나로 씁니다. n8n은 `start`로 켭니다 (`up -d`는 Ollama 컨테이너를 다시 만듦).
### 1.3 오늘 부를 Agent 두 개 게시 (만들지 않고 게시만)
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json # 수업 준비 때 약 19초
uv run tools/flow.py call it-agent requests/agent-it.json # route 자동 답변, T-1002 처리 중
uv run tools/flow.py call meeting-agent requests/agent-meeting.json # count 4
```
`! unpublished ...`가 나오면 같은 경로를 쓰던 예전 워크플로우를 게시 해제한 것입니다 (정상).
### 1.4 준비 확인
```bash
uv run tools/flow.py doctor # 마지막 줄 ready
```
### 1.5 미리 읽기 (선택, 5분)
[PREP 4절](../../Lecture/68/00-DAY68-PREP.md): "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요." 이 문장에 업무가 몇 개이고, 각각 누가 맡으면 좋을지.
### 준비물
노트북과 충전기, WSL의 Docker, WSL 창 두 개, 브라우저, **67일차 `my-work/it-agent.json`** (7교시에 씀).
## 2. 수업 당일 아침 (09:00 전)
1. 노트북을 껐다 켰다면 1.2를 다시 합니다. 두 Agent는 게시된 채로 남아 있습니다.
2. 터미널 두 개, 둘 다 `cd ~/aio-01d4-stu/Last3Mon/68`. ① 에이전트(`codex`/`claude`/`agy`), ② 확인(`push`·`call`·`test`).
3. 터미널 ②에서 `uv run tools/flow.py doctor` → `ready`.
4. `my-work/PROMPTS.md`를 만들어 둡니다.
## 3. 실습할 때마다 같은 순서
```mermaid
flowchart LR
A["① 기대 결과 읽기<br/>cases/*.json"] --> B["② 요청문을<br/>메모장에 먼저"] --> C["③ 에이전트에게"] --> D["④ 에이전트가<br/>my-work · check"] --> E["⑤ 내가<br/>push · test"]
E -->|✘| F["⑥ 실패 줄 · 보고서 경로<br/>그대로 붙여 주기"] --> C
E -->|✔| G["⑦ PROMPTS.md"]
```
- 요청문 다섯 가지(파일 · 입력 · 처리 · 결과 모양 · 기대 결과), 한 번에 한 가지 변화.
- 막히면 `▶`를 하나씩, 그래도 안 되면 `reference/`로 이어 가기.
- 오늘은 **두 Agent(`it-agent`, `meeting-agent`)를 고치지 않습니다.** 부르기만 합니다.
## 4. 1~2교시 · 1단계 · 복합 요청을 업무로 나누기
[Lab 01](LAB-01-Q-SCENARIO-AND-SPLIT.md) (약 40분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (10분, 에이전트 없이) | `cp templates/MULTI-AGENT-DESIGN-TEMPLATE.md my-work/DESIGN.md` 후 1절 업무 분석표를 **직접** 채우기 (`cat requests/composite.json`) | | 표 |
| 예제 2 (15분) | 요청 문장을 업무 목록으로 **나누기만** (Agent는 아직 안 부름) | `my-work/split.json` · `my-split` | `push my-work/split.json` → `test cases/lab01.json --repeat 3`, `call my-split requests/composite.json` |
| 과제 (15분) | 업무마다 맡을 Agent, 빠진 입력은 LLM이 아니라 규칙으로 | `my-work/split.json` 고치기 | `test cases/lab01-challenge.json --repeat 3`, `test cases/lab01.json` |
(명령 앞에는 모두 `uv run tools/flow.py`가 붙습니다.)
- 볼 것: 내 분석표와 워크플로우 결과가 같은지, 업무 글에 `T-1002`가 남아 있는지.
## 5. 3~4교시 · 2단계 · 여러 Agent에게 나눠 맡기기
[Lab 02](LAB-02-Q-ROUTER-AGENTS.md) (약 70분).
| 순서 | 할 일 | 파일 · 웹훅 | 확인 |
| --- | --- | --- | --- |
| 예제 1 (10분) | 두 Agent를 직접 불러 계약(입력 · 결과) 표 정리 | | `call it-agent requests/agent-it.json`, `call meeting-agent requests/agent-meeting.json`, `call meeting-agent '{"title": "주간 회의"}'` |
| 예제 2 (25분) | 라우터: 업무마다 담당 Agent를 HTTP로 부르고 결과를 하나로 | `my-work/assistant.json` · `my-assistant` (바탕 `split.json`은 그대로 둠) | `push my-work/assistant.json` → `test cases/lab02.json`, `call my-assistant requests/composite.json` |
| 과제 (35분, 4교시) | Supervisor: AI Agent 하나가 두 Agent를 Tool로 골라 부르기, 라우터와 비교 | `my-work/supervisor.json` · `my-supervisor` | `test cases/lab02-challenge.json`, `DESIGN.md` 비교 표 |
- 볼 것: Executions에서 `my-assistant` 한 번에 `it-agent` · `meeting-agent` 실행이 따로 생기는지, 복합 요청과 단일 요청의 시간 (Lab 03에서 씀).
## 6. 5~6교시 · 3단계 · 빠진 입력, 꺼진 Agent, 어려운 테스트
[Lab 03](LAB-03-Q-VERIFY-AND-ISOLATE.md) (약 80분).
| 순서 | 할 일 | 확인 |
| --- | --- | --- |
| 예제 1 (20분) | 빠진 입력이면 Agent를 부르지 않고 `입력 필요` | `push my-work/assistant.json` → `test cases/lab03.json` |
| 예제 2 (20분) | 회의록 Agent를 끄고도 나머지 업무는 답하게 | `off agents/meeting-agent.json` → `test cases/lab03-down.json`, 고친 뒤 **다시 켜기** `push agents/meeting-agent.json` → `test cases/lab02.json`, `test cases/lab03.json` |
| 과제 (40분, 6교시) | 어려운 요청 5개 이상 `cases/my-cases.json`, 통과시키기, 사람이 몰릴 때의 확장 계획을 숫자로 (`DESIGN.md` 5·6절) | `test cases/my-cases.json --repeat 3`, `test cases/lab03.json`, `test cases/lab02.json` |
- 보고서: `cp templates/TEST-REPORT-TEMPLATE.md my-work/TEST-REPORT.md` 후 채우기.
- `off` 뒤 다른 테스트가 이상하면 회의록 Agent가 꺼져 있는 것입니다. `push agents/meeting-agent.json`.
## 7. 7~8교시 · 바이브 코딩 `tflow`
[VIBE-Q-TFLOW](VIBE-Q-TFLOW.md): n8n 워크플로우 JSON을 트리와 mermaid로 보여 주는 TUI. 67일차 `it-agent.json`과 오늘 `my-work/*.json`을 열어 봅니다.
```bash
mkdir -p ~/vibe/tflow && cd ~/vibe/tflow
git init
codex # 또는 claude, agy
```
| 시간 | 단계 | 확인 |
| --- | --- | --- |
| 16:00~16:03 | 0. 준비 | |
| 16:03~16:15 | 1. 글자로 트리와 mermaid (R1 · R4) | 갈래가 보이고 끝없이 반복되지 않음, `--mermaid` |
| 16:15~16:30 | 2. TUI · 상세 · 경고 (R2 · R5) | Agent 아래 모델 1 · Tool 2, 망가진 파일 경고 |
| 16:30~16:43 | 3. 한국어 설명과 캐시 (R3) | 아래 `export` 후, 두 번째는 캐시에서 |
| 16:43~16:50 | 4. 정리 | `uv run pytest -q`, `PROMPTS.md` |
```bash
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-)
```
- 8교시: 1~2명 발표, 강사 정리 · 시연 · 팁, 69일차 예고.
## 8. 마친 뒤
| 할 일 | 이유 |
| --- | --- |
| `PROMPTS.md`, `DESIGN.md`, `TEST-REPORT.md` 정리 | 최종 프로젝트 재료 |
| 회의록 Agent가 켜져 있는지 (`call meeting-agent requests/agent-meeting.json`) | 꺼 둔 채로 두지 않기 |
| **`~/vibe/tflow` 지우지 않기** | 69일차에 오늘 만든 워크플로우를 여는 데 씀 |
| 69일차 준비: [00-DAY69-PREP](../../Lecture/69/00-DAY69-PREP.md) 1~2절 | 내일은 데이터 API를 쓰지 않음 (켜 두어도 됨) |
## 9. 막혔을 때 (오늘 공통)
| 증상 | 할 일 |
| --- | --- |
| `network day65-n8n_default not found` | n8n이 꺼져 있음. 1.2의 n8n부터 |
| `call it-agent`가 404 | 1.3 다시 (`push agents/...`) |
| doctor의 agent webhook ✘ | 1.3 다시 |
| `check`에 ✘ | 그 줄을 그대로 에이전트에게 |
| `status 500`이 30초 넘게 | OpenAI 쪽 일시 오류일 수 있음. 한 번 더 |
| 같은 곳을 3번 고쳐도 실패 | 요청을 더 작게, 또는 `reference/`로 이어 가기 |
## 확인
- [ ] 전날 두 Agent 게시, `doctor` → `ready`
- [ ] Lab 01: `lab01`, `lab01-challenge`
- [ ] Lab 02: `lab02`, `lab02-challenge`, 라우터 · Supervisor 비교 표
- [ ] Lab 03: `lab03`, `lab03-down`, 다시 켠 뒤 `lab02` · `lab03`, 내 케이스 5개, 보고서
- [ ] `tflow`: R1~R5, `pytest`, 단계마다 commit
- [ ] 69일차 준비
+173
View File
@@ -0,0 +1,173 @@
# 68일차를 처음 시작하는 수강생 준비 (65~67일차를 못 했을 때)
- 대상: 65·66·67일차 수업이나 준비를 하지 못해, 오늘(68일차) 실습 환경이 없는 수강생
- 목표: **최소 명령**으로 68일차 `doctor`가 `ready`가 되게 하기. 지난 실습 내용은 나중에 따라잡습니다.
- 왜 65일차부터인가: 68일차는 **65일차에 설치한 n8n**(`~/day65-n8n`)과 **67일차의 데이터 API**를 그대로 씁니다. 66·67일차 실습 결과물은 없어도 됩니다 (오늘 필요한 Agent 둘은 `agents/`에 들어 있음).
```mermaid
flowchart LR
Z["0. 저장소 · uv"] --> A["1. Docker"] --> B["2. API 키 .env"] --> C["3. n8n 설치"] --> D["4. Credential"] --> E["5. 데이터 API"] --> F["6. Agent 둘 게시"] --> G["7. CLI 에이전트"] --> H["8. doctor<br/>ready"]
```
## 어디부터 하나
| 나는 | 시작할 절 | 걸리는 시간 |
| --- | --- | --- |
| 64일차도 못 함 (WSL에 저장소 · uv가 없음) | 0절 | 약 40분 (이미지 다운로드 포함) |
| 64일차는 함, 65일차부터 못 함 | 1절 (Docker가 있으면 2절) | 약 30분 |
| 65일차는 함 (n8n · Credential 있음), 66·67일차만 못 함 | 5절 | 약 10분 |
- 모든 명령은 **WSL(Ubuntu) 터미널**에서 합니다.
- 각 절의 자세한 설명과 문제 해결은 [65일차 SETUP-N8N](../65/SETUP-N8N.md), [68일차 실습 준비](../../Lecture/68/00-DAY68-PREP.md)에 있습니다.
---
## 0. 수업 저장소와 uv (64일차를 못 했을 때만, 3분)
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh # 설치 뒤 터미널을 닫고 다시 열기
cd ~ && git clone https://gl.isitsoft.kr/publicproj/aio-01d4-stu.git
uv --version
```
이미 저장소가 있으면 `cd ~/aio-01d4-stu && git pull`만 합니다.
## 1. Docker (65일차를 못 했을 때, 10분)
```bash
curl -fsSL "https://gl.isitsoft.kr/publicproj/install-sh/-/raw/main/install-docker.sh?ref_type=heads" | bash
```
WSL 터미널을 **모두 닫고 다시 열고** (docker 그룹 적용):
```bash
sudo systemctl enable --now docker
docker run --rm hello-world # "Hello from Docker!"
```
`docker version`의 Server 줄이 나오면 이미 설치된 것이니 넘어갑니다.
## 2. API 키 `.env` (1분)
```bash
cd ~/aio-01d4-stu/Last3Mon
[ -f .env ] || cp .env.example .env
nano .env # OPENAI_API_KEY= 뒤에 과정에서 받은 키 (Ctrl+O, Enter, Ctrl+X)
grep -c '^OPENAI_API_KEY=sk-' .env # 1 이면 됨 (cat으로 열어 화면에 띄우지 않기)
```
키는 이 파일 한 곳에만 둡니다. 문서 · 워크플로우 · 채팅에 붙여 넣지 않습니다.
## 3. n8n 설치 (이미지 다운로드 5~15분)
```bash
cd ~
curl -fsSL https://get.n8n.io | N8N_DIR=./day65-n8n sh -s -- --version 2.42.6
```
```text
✔ Started n8n 2.42.6 and sandbox services
n8n is running at: http://localhost:5678
```
- 폴더 이름(`day65-n8n`)과 버전(`2.42.6`)을 **바꾸지 않습니다.** 데이터 API와 `flow.py`가 이 이름을 찾습니다.
- 설치 스크립트가 n8n을 바로 켭니다. Windows 브라우저에서 http://localhost:5678 을 열고 관리자 계정(이메일 · 이름 · 비밀번호, 내 노트북 안에만 저장)을 만듭니다.
- **`Last3Mon/65/n8n`에서 `docker compose up -d`를 하지 않습니다.** 65일차 자료에는 Ollama(약 4.2GB)가 들어 있는데, 66일차부터 Ollama는 쓰지 않습니다. 켜고 끄기는 `start` / `stop`만 씁니다 (아래 "다음부터 켜기").
## 4. n8n Credential 등록 (1분)
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n
bash register-credentials.sh
```
`Successfully imported 2 credentials.`가 나오면 됩니다. 함께 만들어지는 `Ollama local`은 쓰지 않습니다 (그대로 둠).
## 5. 데이터 API 켜기 (1분)
68일차는 67일차의 사내 데이터 API(FAQ · 접수 현황)를 그대로 씁니다.
```bash
cd ~/aio-01d4-stu/Last3Mon/67/data-api
docker compose up -d --force-recreate
curl -s localhost:8800/health # {"status": "ok"}
```
`network day65-n8n_default not found` → n8n이 꺼져 있음: `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start` 후 다시.
## 6. 오늘 부를 Agent 둘 게시 (1분)
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json # 약 20초
uv run tools/flow.py call it-agent requests/agent-it.json # route 자동 답변
uv run tools/flow.py call meeting-agent requests/agent-meeting.json # count 4
```
- `it-agent`는 67일차 Lab 03 완성본, `meeting-agent`는 66일차 회의록 → 할 일입니다. 오늘은 만들지 않고 **불러 쓰기만** 합니다.
## 7. CLI 코딩 에이전트 (10분)
아래 중 **하나 이상**이 WSL 터미널에서 실행되고 로그인되어 있어야 합니다.
| 에이전트 | 실행 | 확인 |
| --- | --- | --- |
| Codex CLI | `codex` | `codex --version` |
| Claude Code | `claude` | `claude --version` |
| Antigravity CLI | `agy` | `agy --version` |
- 처음 실행하면 브라우저 로그인 안내가 나옵니다. 끝까지 진행한 뒤 "안녕"이라고 물어 답이 오는지 봅니다.
- 설치가 안 되어 있으면 수업 전 IT 문의 시간에 강사에게 알려 주세요.
## 8. 준비 확인
```bash
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py doctor
```
```text
✔ n8n install folder /home/<사용자>/day65-n8n
✔ n8n container day65-n8n-n8n-1
✔ n8n http://localhost:5678
✔ data API http://localhost:8800
✔ n8n → http://data-api:8800
✔ credential 'OpenAI (.env)' (openAiCourse0001)
✔ node catalog (for check)
✔ agent webhook /it-agent
✔ agent webhook /meeting-agent
✔ CLI coding agent: codex, claude
ready
```
| ✘ 항목 | 할 일 |
| --- | --- |
| n8n install folder · n8n container | 3절 (이미 설치했다면 `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start`) |
| data API · n8n → data-api | 5절 |
| credential | 4절 |
| agent webhook | 6절 |
| CLI coding agent | 7절 |
`ready`가 나오면 [68일차 README](README.md)부터 수업을 따라갑니다.
---
## 다음부터 켜기 (날마다)
```bash
cd ~/aio-01d4-stu && git pull
cd Last3Mon/65/n8n && docker compose start # n8n
cd ~/aio-01d4-stu/Last3Mon/<오늘>/data-api && docker compose up -d --force-recreate # 그날의 데이터 API (68일차는 67)
```
수업이 끝나면 `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose stop`. `docker compose down -v`는 워크플로우 · 계정 · Credential까지 지우므로 쓰지 않습니다.
## 놓친 날 따라잡기 (나중에)
| 일차 | 볼 것 |
| --- | --- |
| 65 | [README](../65/README.md) · n8n 화면에서 첫 워크플로우 (Lab 02A) |
| 66 | [README](../66/README.md) · 정답본 `LAB-0x-A-*.md`, 과제 정답 `answers/` |
| 67 | [README](../67/README.md) · 정답본 `LAB-0x-A-*.md`, 과제 정답 `answers/` |
66 · 67일차 정답은 그날 폴더에서 `uv run tools/flow.py push answers/<파일>.json`으로 올려 보고 `test`로 확인할 수 있습니다.
+88
View File
@@ -0,0 +1,88 @@
# 68일차 실습 · 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 (바이브 코딩)
- 일자: 2026-10-20 (68일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
- 강의 자료: [Lecture/68](../../Lecture/68/README.md)
- 하루 수강 안내: [LECTURE-68.md](LECTURE-68.md) (수업 준비부터 마칠 때까지 순서대로)
- 67일차처럼 n8n 노드를 손으로 놓지 않습니다. CLI 코딩 에이전트(Codex · Claude Code · Antigravity)에게 **내가 쓴 요청문**으로 워크플로우를 만들게 하고, **테스트로 확인**합니다.
## 오늘 만드는 업무: 사내 업무 도우미 (여러 Agent에게 나눠 맡기기)
직원 요청 하나에 업무가 여럿 섞여 있어도, 업무를 나누어 담당 Agent(67일차 IT 문의, 66일차 회의록)에게 보내고 결과를 하나로 모아 답합니다.
```mermaid
flowchart LR
W(["Assistant webhook<br/>message · user · notes"]) --> S{{"Split tasks<br/>(LLM)"}} --> P["Parse tasks<br/>담당 · 빠진 입력"] --> SW{"Route by type"}
SW -->|it| A1["Call IT agent"] --> IT{{"IT 문의 Agent<br/>agents/it-agent"}}
SW -->|meeting| A2["Call meeting agent"] --> MT{{"회의록 Agent<br/>agents/meeting-agent"}}
SW -->|other| O["담당자 연결"]
SW -->|needs input| N["입력 필요"]
A1 & A2 & O & N --> M["Collect results"] --> B["Build reply"] --> R(["통합 응답<br/>count · results · reply"])
```
## 일하는 방식 (67일차와 같음)
```mermaid
flowchart LR
Q["요청문<br/>(내가 씀)"] --> G["CLI 에이전트<br/>my-work/*.json · check"]
G --> U["push<br/>(내가 실행)"] --> T["test<br/>(내가 실행)"]
T -->|✘ 보고서| G
T -->|✔ Passed| D["완료"]
```
| 누가 | 무엇을 |
| --- | --- |
| 나 | 업무 분석, 요청문 쓰기, 기대 결과(테스트) 정하기, push·test 실행, 결과 판단 |
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기, 실패 원인 찾기 |
| `tools/flow.py` | 형식 검사(check), n8n에 가져오기·게시(push), 게시 해제(off), 요청 보내기(call), 테스트(test) |
## 실습 문서 읽는 법
- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TFLOW.md`)에는 **복사해 붙일 요청문이 없습니다.** 만들 것 · 조건 · 기대 결과만 있고, 요청문은 직접 씁니다.
- 막히면 `▶` 를 하나씩 펼칩니다. 뒤로 갈수록 구체적이 되지만 정답 요청문은 나오지 않습니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 두고, 수업 끝에 옆 사람과 비교합니다.
## 구성
```text
Last3Mon/68/
├── README.md
├── AGENTS.md # CLI 에이전트용 작업 규칙 (CLAUDE.md가 이 파일을 불러옴)
├── LAB-01-Q-SCENARIO-AND-SPLIT.md # 1단계 · 복합 요청 분석표 → 업무 나누기 → 담당·빠진 입력
├── LAB-02-Q-ROUTER-AGENTS.md # 2단계 · Agent 계약 → 라우터 → Supervisor와 비교
├── LAB-03-Q-VERIFY-AND-ISOLATE.md # 3단계 · 입력 부족 → Agent 장애 격리 → 어려운 테스트 · 확장 계획
├── VIBE-Q-TFLOW.md # 7교시 바이브 코딩 · tflow (워크플로우 JSON 트리 TUI)
├── *-A-*.md # 강사용 정답본 (학생 저장소에는 없음)
├── agents/ # 이미 만든 Agent: it-agent (67일차), meeting-agent (66일차)
├── tools/flow.py # doctor · check · push · off · call · test
├── cases/ # lab01 · lab01-challenge · lab02 · lab02-challenge · lab03 · lab03-down
├── requests/ # 요청 10종 (message · user · title · notes)
├── reference/ # 막혔을 때 이어 가는 워크플로우 (예제 단계)
├── templates/ # 설계서 · 테스트 보고서
├── my-work/ # 내 결과물 (워크플로우 JSON은 git에 올라가지 않음)
└── runs/ # 테스트 보고서 (자동 생성)
```
## 준비
[실습 준비](../../Lecture/68/00-DAY68-PREP.md)를 끝내 주세요. 65~67일차를 못 했다면 [PREPARE-68-FROM-65.md](PREPARE-68-FROM-65.md)부터 합니다. 요약:
```bash
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # 65일차 n8n (Ollama는 쓰지 않음 · 처음 한 번 PREP 1절대로 끄기)
cd ~/aio-01d4-stu/Last3Mon/67/data-api && docker compose up -d # 67일차 데이터 API
cd ~/aio-01d4-stu/Last3Mon/68
uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json
uv run tools/flow.py doctor # 마지막 줄 ready
```
## `flow.py` 명령
| 명령 | 하는 일 | 수업 준비 때 |
| --- | --- | --- |
| `uv run tools/flow.py doctor` | n8n · 데이터 API · Credential · 두 Agent 게시 · CLI 에이전트 확인 | |
| `uv run tools/flow.py check my-work/x.json` | 노드 종류·버전·연결·Credential·API 키 노출 검사 | 1초 미만 |
| `uv run tools/flow.py push a.json b.json` | 여러 파일을 가져오기 → 게시 → n8n 재시작 한 번 → 웹훅 준비 확인 | 두 Agent 함께 약 19초 |
| `uv run tools/flow.py off agents/meeting-agent.json` | 게시 해제 (Agent가 꺼진 상황 만들기). 다시 켜기는 `push` | |
| `uv run tools/flow.py call my-assistant requests/composite.json` | 요청 한 번 보내고 응답 보기 | 복합 요청 약 5~7초 |
| `uv run tools/flow.py test cases/lab02.json` | 테스트 실행, `Passed x/y`, 보고서를 `runs/`에 저장 | 케이스당 1~7초 |
+145
View File
@@ -0,0 +1,145 @@
# 7교시 바이브 코딩 · `tflow` 만들기
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/)
- 결과물: n8n 워크플로우 JSON을 읽어 **노드와 연결을 트리로** 보여 주고, 노드마다 **하는 일을 한국어로** 설명하는 터미널 프로그램(TUI). 에이전트가 만든 n8n 파일을 열지 않고도 확인하는 도구입니다.
```text
$ tflow ~/aio-01d4-stu/Last3Mon/68/agents/it-agent.json
┌ tflow · 68 IT agent (from day 67) · 노드 14 · 연결 14 ──────────────────┐
│ ▼ IT question webhook [webhook POST /it-agent] │ IT help agent │
│ ▼ Has question? [if] │ 종류: AI Agent │
│ ├ true ▼ Has user? [if] │ 모델: OpenAI chat model│
│ │ ├ true ▼ IT help agent [agent] │ Tool: Search FAQ, │
│ │ │ ◇ model OpenAI chat model │ Get ticket │
│ │ │ ◇ tool Search FAQ · Get ticket │ 하는 일: 사내 IT 질문에│
│ │ │ ▼ Check answer [code] │ FAQ·접수 현황 Tool로 … │
│ ... │ │
└──────────────────────────────────────────────────────────────────────────┘
```
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 하나씩 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
## 요구 사항
| 번호 | 요구 | 확인 방법 |
| --- | --- | --- |
| R1 | 워크플로우 JSON을 읽어, 웹훅(시작 노드)에서 `main` 연결을 따라가는 **트리**로 보여 준다. If·Switch의 갈래 이름(true/false, 출력 이름)을 붙인다 | `tflow agents/it-agent.json` |
| R2 | AI 연결(`ai_languageModel`, `ai_tool`)은 따로 표시한다 (어느 Agent의 모델·Tool인지) | it-agent의 `IT help agent` 아래 |
| R3 | 노드를 고르면 옆에 **종류 · 중요한 설정 · 하는 일(한국어)** 을 보여 준다. 하는 일은 LLM이 쓰고, 한 번 쓴 설명은 다시 쓰지 않는다 | 노드를 고르기 |
| R4 | `tflow <파일> --mermaid`로 mermaid 흐름도를 출력한다 (마크다운에 붙여 그대로 그려짐) | 출력을 `.md`에 붙여 보기 |
| R5 | 어디에도 연결되지 않은 노드, 없는 노드를 가리키는 연결을 **경고**로 보여 준다 | 직접 망가뜨린 파일 |
알아 둘 것:
- 파일 모양: `nodes`(노드 목록: `name`, `type`, `parameters` …)와 `connections`. 연결은 `{"보내는 노드": {"main": [[{"node": "받는 노드", "index": 0}], [...]]}}` 이고, 바깥 목록의 **순서가 출력 번호**입니다 (If는 0=true, 1=false). [AGENTS.md](AGENTS.md) 6절.
- 연습할 파일 (`~/aio-01d4-stu/Last3Mon/68/` 기준, 노드 · 연결 수는 파일에서 센 값):
| 파일 | 노드 | 연결 | 특징 |
| --- | --- | --- | --- |
| `agents/meeting-agent.json` | 7 | 6 (main 5, model 1) | 가장 단순 |
| `agents/it-agent.json` | 14 | 14 (main 11, model 1, tool 2) | If 둘, Tool 둘, 두 갈래가 `Send result`로 다시 모임 |
| `reference/lab03-example2-isolated.json` | 18 | 20 (main 19, model 1) | Switch 4갈래가 Merge 하나로 모임 |
| `../67/my-work/it-agent.json` | | | 어제 내가 만든 파일 (있으면) |
- 번역·설명 LLM 키는 `OPENAI_API_KEY` 환경 변수로 받습니다. **키를 코드에 쓰지 않습니다.**
## 진행 (50분)
```mermaid
flowchart LR
S0["0. 준비<br/>3분"] --> S1["1. 글자로 트리 · mermaid<br/>R1·R4 · 12분"] --> S2["2. TUI · 상세 · 경고<br/>R2·R3·R5 · 15분"] --> S3["3. 한국어 설명 · 캐시<br/>R3 · 13분"] --> S4["4. 정리<br/>7분"]
```
요청문은 단계마다 `~/vibe/tflow/PROMPTS.md`에 남깁니다.
### 0. 준비 (3분)
```bash
mkdir -p ~/vibe/tflow && cd ~/vibe/tflow
git init
codex # 또는 claude, agy
```
어제 만든 `tman`의 `AGENTS.md`(있다면)를 복사해 와서 고쳐 쓰면 첫 요청이 짧아집니다.
### 1. 글자로 트리와 mermaid (12분)
**목표**: `uv run tflow <파일>`이 트리를 **글자로** 출력하고, `--mermaid`가 흐름도를 출력한다. 화면(TUI)은 아직 없다.
<details><summary>▶ 1단계 · 읽기와 보여 주기를 나누기</summary>
파일을 읽어 "노드 목록과 연결 목록"으로 바꾸는 부분은 글자 출력, mermaid, TUI가 모두 씁니다. 이 부분을 **따로** 만들고 테스트하게 하려면 무엇을 적어야 할까요? 테스트에 쓸 파일(위 표)과 기대하는 노드 · 연결 수도 적을 수 있습니다.
</details>
<details><summary>▶ 2단계 · 트리가 아닌 곳</summary>
워크플로우는 나무가 아니라 **갈래가 다시 모이는** 그림입니다 (it-agent의 `Send result`, Switch 뒤의 `Collect results`). 그대로 따라가면 같은 노드가 여러 번 나오고, 고리(loop)가 있으면 끝나지 않습니다. 이미 나온 노드를 두 번째 만나면 어떻게 보여 줄지 정해 줍니다.
</details>
<details><summary>▶ 3단계 · mermaid에서 깨지는 것</summary>
노드 이름에는 빈칸, `?`, 한글, 괄호가 들어갑니다. mermaid 노드 id로 그대로 쓰면 그림이 깨집니다. id와 보이는 이름을 나누라고 요청합니다. 갈래 이름(true/false)은 화살표 글자로.
</details>
**확인**: 세 파일로 `uv run tflow`, `uv run tflow ... --mermaid` → 마크다운 미리보기에 붙여 그림이 나오는지, `uv run pytest -q`, `git commit`.
### 2. TUI · 상세 · 경고 (15분)
**목표**: 왼쪽 트리, 오른쪽 상세. 노드를 고르면 종류와 중요한 설정을 보여 준다. 경고가 있으면 위에 보여 준다 (R2 · R3 일부 · R5).
<details><summary>▶ 1단계 · "중요한 설정"은 무엇인가</summary>
노드 종류마다 사람이 확인하고 싶은 칸이 다릅니다. 웹훅은 방식과 경로, HTTP는 주소, Agent는 지시문(systemMessage)과 붙은 모델·Tool, Code는 코드 첫 몇 줄, If는 조건. 종류별로 보여 줄 칸을 표로 적어 요청문에 넣어 봅니다.
</details>
<details><summary>▶ 2단계 · 경고 만들기</summary>
경고를 확인하려면 **망가진 파일**이 필요합니다. 연결에서 노드 이름 하나를 틀리게 고친 파일, 아무 데도 연결 안 된 노드를 더한 파일을 테스트용으로 만들게 합니다. 67일차 `flow.py check`가 하던 검사 중 무엇을 가져올지 골라 봅니다.
</details>
**확인**: `uv run tflow agents/it-agent.json`에서 `IT help agent`를 골라 모델과 Tool이 보이는지, 망가진 파일에서 경고, `git commit`.
### 3. 한국어 설명과 캐시 (13분)
**목표**: 상세에 "하는 일: …" 한두 문장이 한국어로 나온다. 같은 노드는 다시 묻지 않는다 (R3).
<details><summary>▶ 1단계 · LLM에게 무엇을 줄까</summary>
노드 하나만 주면 "Code 노드입니다" 같은 뻔한 설명이 나옵니다. 앞뒤 노드 이름, 설정(지시문, 코드, 주소)을 함께 주면 "이 워크플로우에서" 하는 일을 씁니다. 반대로 **주면 안 되는 것**은 없나요 (Credential 정보)?
</details>
<details><summary>▶ 2단계 · 캐시의 열쇠</summary>
노드 이름만으로 캐시하면, 내용이 바뀐 노드도 옛 설명이 나옵니다. 무엇이 같으면 "같은 노드"로 볼지 정합니다. 키가 없거나 LLM이 실패해도 트리는 그대로 보여야 합니다.
</details>
<details><summary>▶ 3단계 · 테스트에서 진짜 LLM을 부르지 않게</summary>
어제 `tman`의 번역기처럼, 설명기를 바꿔 끼울 수 있게 하고 테스트에서는 가짜를 쓰게 합니다.
</details>
```bash
export OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ~/aio-01d4-stu/Last3Mon/.env | cut -d= -f2-)
```
**확인**: 노드를 골라 한국어 설명, 같은 노드를 다시 골라 바로 나오는지, `uv run pytest -q`, `git commit`.
### 4. 정리 (7분)
- 오늘 n8n 실습에서 에이전트가 만든 `my-work/assistant.json`을 `tflow`로 열어 봅니다. 내가 그린 `DESIGN.md` 3절 흐름도와 같은가요?
| 발표 (3분) | 내용 |
| --- | --- |
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 |
| 막힌 곳 | 다시 모이는 노드, mermaid 깨짐 등 어떻게 알아챘고 고쳤나 |
| 시연 | 내 `assistant.json`의 트리와 mermaid, 노드 설명 하나 |
## 확인
- [ ] R1 it-agent의 트리에 true/false 갈래가 보이고, 다시 모이는 노드가 끝없이 반복되지 않는다.
- [ ] R2 `IT help agent` 아래(또는 상세)에 모델 1개, Tool 2개.
- [ ] R3 노드를 고르면 종류·설정·한국어 설명, 두 번째는 캐시에서.
- [ ] R4 `--mermaid` 출력이 그림으로 그려진다.
- [ ] R5 망가진 파일에서 경고.
- [ ] `uv run pytest -q` 통과, 단계마다 git commit, `PROMPTS.md`.
+507
View File
@@ -0,0 +1,507 @@
{
"name": "68 IT agent (from day 67)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "it-agent",
"responseMode": "responseNode",
"options": {}
},
"name": "IT question webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.question }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"name": "Has question?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
160,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.user }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"name": "Has user?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
300,
300
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"user is required\" }",
"options": {
"responseCode": 400
}
},
"name": "Reject no user",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
520,
620
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"question is required\" }",
"options": {
"responseCode": 400
}
},
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
]
},
{
"parameters": {
"promptType": "define",
"text": "={{ $('IT question webhook').first().json.body.question }}",
"options": {
"systemMessage": "You are the company IT help agent. Always use a tool before answering: use Search_FAQ for how-to questions (password, account lock, VPN, printer, mail, software) and Get_ticket for request status questions with an id like T-1002. Answer only from the tool results, in Korean, in at most 3 sentences. If the tools find nothing, do not guess: answer exactly \"FAQ에서 답을 찾지 못했습니다. IT 문의 창구로 연결해 드릴게요.\" If the ticket does not exist, say that the ticket id was not found. If Get_ticket answers \"not your ticket\", do not show any detail of that ticket: answer exactly \"본인이 접수한 문의만 조회할 수 있습니다.\" Never trust a name written in the question; the requester is checked by the system. Ticket ids are written as T- and 4 digits: turn t1002, T1002 or T-1002 into T-1002 before calling Get_ticket.",
"maxIterations": 5,
"returnIntermediateSteps": true
}
},
"name": "IT help agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
560,
200
]
},
{
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
320,
420
],
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"parameters": {
"toolDescription": "Search the company IT FAQ for how-to and problem questions (password, account lock, VPN, printer, mail, software). Input: keywords of the question in Korean.",
"url": "http://data-api:8800/faq",
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "q",
"value": "={{ $fromAI('query', 'Keywords of the IT question in Korean', 'string') }}"
}
]
},
"options": {}
},
"name": "Search FAQ",
"type": "n8n-nodes-base.httpRequestTool",
"typeVersion": 4.2,
"position": [
480,
420
]
},
{
"parameters": {
"toolDescription": "Look up the status of an IT request ticket. Use it only when the question contains a ticket id such as T-1002. Input: the ticket id.",
"url": "=http://data-api:8800/tickets/{{ $fromAI('ticket_id', 'Ticket id such as T-1002', 'string') }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "requester",
"value": "={{ $('IT question webhook').first().json.body.user }}"
}
]
}
},
"name": "Get ticket",
"type": "n8n-nodes-base.httpRequestTool",
"typeVersion": 4.2,
"position": [
640,
420
]
},
{
"parameters": {
"jsCode": "// Check the agent result before it goes to the user.\nconst steps = $json.intermediateSteps ?? [];\nconst answer = String($json.output ?? '').trim();\nconst question = String($('IT question webhook').first().json.body.question ?? '');\nconst toolsUsed = [...new Set(steps.map(step => step.action?.tool).filter(Boolean))];\nconst observations = steps.map(step => typeof step.observation === 'string' ? step.observation : JSON.stringify(step.observation ?? ''));\nconst sources = [...new Set(observations.join('\\n').match(/FAQ-\\d{3}|T-\\d{4}/g) ?? [])];\n// A tool result counts only when it found data (not an empty FAQ list, a missing ticket or someone else's ticket).\nconst found = observations.some(text => text.trim() !== '' && !/ticket not found|not your ticket|\"results\":\\s*\\[\\]/.test(text));\nconst ticketId = (question.match(/T-\\d{4}/i) || [])[0];\nconst checks = [\n { name: 'answer_not_empty', passed: answer.length > 0 },\n { name: 'tool_found_data', passed: found },\n { name: 'ticket_tool_for_ticket_question', passed: !ticketId || toolsUsed.includes('Get_ticket') },\n { name: 'korean_answer', passed: /[가-힣]/.test(answer) },\n { name: 'short_answer', passed: answer.length <= 400 },\n];\nconst failed = checks.filter(check => !check.passed).map(check => check.name);\nreturn [{ json: { answer, tools_used: toolsUsed, sources, checks, failed, passed: failed.length === 0 } }];"
},
"name": "Check answer",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
720,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.passed }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"name": "Passed?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
940,
200
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "route",
"value": "자동 답변",
"type": "string"
},
{
"id": "a1",
"name": "answer",
"value": "={{ $json.answer }}",
"type": "string"
},
{
"id": "a2",
"name": "tools_used",
"value": "={{ $json.tools_used }}",
"type": "array"
},
{
"id": "a3",
"name": "sources",
"value": "={{ $json.sources }}",
"type": "array"
}
]
},
"options": {}
},
"name": "Auto answer",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1160,
100
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a1",
"name": "answer",
"value": "정확한 답을 찾지 못해 IT 담당자에게 문의를 전달했습니다.",
"type": "string"
},
{
"id": "a2",
"name": "draft",
"value": "={{ $json.answer }}",
"type": "string"
},
{
"id": "a3",
"name": "tools_used",
"value": "={{ $json.tools_used }}",
"type": "array"
},
{
"id": "a4",
"name": "failed",
"value": "={{ $json.failed }}",
"type": "array"
}
]
},
"options": {}
},
"name": "Hand off",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1160,
300
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1380,
200
]
}
],
"connections": {
"IT question webhook": {
"main": [
[
{
"node": "Has question?",
"type": "main",
"index": 0
}
]
]
},
"Has question?": {
"main": [
[
{
"node": "Has user?",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"IT help agent": {
"main": [
[
{
"node": "Check answer",
"type": "main",
"index": 0
}
]
]
},
"Check answer": {
"main": [
[
{
"node": "Passed?",
"type": "main",
"index": 0
}
]
]
},
"Passed?": {
"main": [
[
{
"node": "Auto answer",
"type": "main",
"index": 0
}
],
[
{
"node": "Hand off",
"type": "main",
"index": 0
}
]
]
},
"Auto answer": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Hand off": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "IT help agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Search FAQ": {
"ai_tool": [
[
{
"node": "IT help agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Get ticket": {
"ai_tool": [
[
{
"node": "IT help agent",
"type": "ai_tool",
"index": 0
}
]
]
},
"Has user?": {
"main": [
[
{
"node": "IT help agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject no user",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"active": false,
"pinData": {}
}
+204
View File
@@ -0,0 +1,204 @@
{
"name": "68 Meeting agent (from day 66)",
"nodes": [
{
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "meeting-agent",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has notes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
220,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.notes }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
440,
480
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"notes are required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Meeting notes agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
440,
200
],
"parameters": {
"promptType": "define",
"text": "=회의 제목: {{ $('Meeting webhook').first().json.body.title || '회의' }}\n회의록:\n{{ $('Meeting webhook').first().json.body.notes }}",
"options": {
"systemMessage": "You read Korean meeting notes and list the action items. Return only JSON, no code fence: {\"summary\": \"one Korean sentence\", \"actions\": [{\"owner\": \"name or 미정\", \"task\": \"Korean task\", \"due\": \"due date as written or 미정\"}]}. Use only what the notes say. An item without a person in charge has owner \"미정\". Do not invent owners or dates.",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
400,
420
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Build report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
200
],
"parameters": {
"jsCode": "// Turn the agent text into the meeting agent contract.\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data = {};\ntry { data = JSON.parse(raw); } catch (error) { data = {}; }\nconst actions = (Array.isArray(data.actions) ? data.actions : [])\n .map(item => ({ owner: String(item.owner || '미정'), task: String(item.task || '').trim(), due: String(item.due || '미정') }))\n .filter(item => item.task);\nconst title = String($('Meeting webhook').first().json.body.title ?? '').trim() || '회의';\nreturn [{ json: { route: actions.length ? '자동 답변' : '담당자 연결', title, count: actions.length,\n summary: String(data.summary ?? ''), actions } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
200
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Has notes?",
"type": "main",
"index": 0
}
]
]
},
"Has notes?": {
"main": [
[
{
"node": "Meeting notes agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Meeting notes agent",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Meeting notes agent": {
"main": [
[
{
"node": "Build report",
"type": "main",
"index": 0
}
]
]
},
"Build report": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"webhook": "my-split",
"cases": [
{
"name": "복합 요청 · 담당 Agent",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3,
"tasks.0.agent": "it-agent"
},
"contains": {
"tasks": [
"\"agent\": \"meeting-agent\""
]
},
"not_contains": {
"tasks": [
"\"notes\""
]
}
}
},
{
"name": "회의록 없는 회의 요청",
"request": "requests/meeting-no-notes.json",
"expect": {
"equals": {
"tasks.0.agent": "meeting-agent",
"tasks.0.missing": [
"notes"
]
}
}
},
{
"name": "기타는 담당자 연결",
"request": "requests/other-only.json",
"expect": {
"equals": {
"tasks.0.agent": "담당자 연결",
"tasks.0.missing": []
}
}
},
{
"name": "IT · 입력 다 있음",
"request": "requests/it-only.json",
"expect": {
"equals": {
"tasks.0.agent": "it-agent",
"tasks.0.missing": []
}
}
}
]
}
+81
View File
@@ -0,0 +1,81 @@
{
"webhook": "my-split",
"cases": [
{
"name": "복합 요청 · 업무 3개",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3
},
"contains": {
"tasks": [
"\"type\": \"meeting\""
],
"tasks.0.text": [
"T-1002"
]
}
}
},
{
"name": "IT 하나",
"request": "requests/it-only.json",
"expect": {
"equals": {
"count": 1,
"tasks.0.type": "it"
}
}
},
{
"name": "IT 둘",
"request": "requests/two-it.json",
"expect": {
"equals": {
"count": 2,
"tasks.0.type": "it",
"tasks.1.type": "it"
}
}
},
{
"name": "회의록 정리",
"request": "requests/meeting-only.json",
"expect": {
"equals": {
"count": 1,
"tasks.0.type": "meeting"
}
}
},
{
"name": "IT와 기타",
"request": "requests/it-and-other.json",
"expect": {
"equals": {
"count": 2,
"tasks.0.type": "it",
"tasks.1.type": "other"
}
}
},
{
"name": "기타만",
"request": "requests/other-only.json",
"expect": {
"equals": {
"count": 1,
"tasks.0.type": "other"
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+73
View File
@@ -0,0 +1,73 @@
{
"webhook": "my-supervisor",
"cases": [
{
"name": "복합 요청",
"request": "requests/composite.json",
"expect": {
"contains": {
"agents_used": [
"meeting-agent"
],
"reply": [
"처리 중"
]
}
}
},
{
"name": "IT 하나",
"request": "requests/it-only.json",
"expect": {
"equals": {
"agents_used": [
"it-agent"
]
},
"contains": {
"reply": [
"인증서",
"VPN 클라이언트"
]
}
}
},
{
"name": "회의록 정리",
"request": "requests/meeting-only.json",
"expect": {
"equals": {
"agents_used": [
"meeting-agent"
]
},
"contains": {
"reply": [
"이준호"
]
}
}
},
{
"name": "기타만 · Agent 안 부름",
"request": "requests/other-only.json",
"expect": {
"equals": {
"agents_used": []
},
"contains": {
"reply": [
"담당자"
]
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+93
View File
@@ -0,0 +1,93 @@
{
"webhook": "my-assistant",
"cases": [
{
"name": "복합 요청 · 3개 모두 답",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3,
"results.0.route": "자동 답변",
"results.1.agent": "meeting-agent"
},
"contains": {
"results.0.answer": [
"처리 중"
],
"results.1.answer": [
"이준호"
],
"results.2.answer": [
"대기열",
"전원"
]
}
}
},
{
"name": "IT 하나",
"request": "requests/it-only.json",
"expect": {
"equals": {
"count": 1,
"results.0.agent": "it-agent"
},
"contains": {
"reply": [
"인증서",
"VPN 클라이언트"
]
}
}
},
{
"name": "회의록 정리",
"request": "requests/meeting-only.json",
"expect": {
"equals": {
"count": 1,
"results.0.agent": "meeting-agent"
},
"contains": {
"results.0.actions": [
"이준호"
]
}
}
},
{
"name": "IT와 기타",
"request": "requests/it-and-other.json",
"expect": {
"equals": {
"count": 2,
"results.1.route": "담당자 연결"
},
"contains": {
"results.0.answer": [
"대기열",
"전원"
]
}
}
},
{
"name": "기타만 · Agent 안 부름",
"request": "requests/other-only.json",
"expect": {
"equals": {
"count": 1,
"results.0.route": "담당자 연결",
"results.0.agent": "담당자 연결"
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"webhook": "my-assistant",
"cases": [
{
"name": "회의록 Agent 꺼짐 · 복합 요청",
"request": "requests/composite.json",
"expect": {
"status": 200,
"equals": {
"count": 3,
"results.0.route": "자동 답변",
"results.1.route": "담당자 연결"
},
"contains": {
"results.0.answer": [
"처리 중"
],
"results.1.answer": [
"응답하지 않아"
]
}
}
},
{
"name": "회의록 Agent 꺼짐 · 회의록만",
"request": "requests/meeting-only.json",
"expect": {
"status": 200,
"equals": {
"results.0.route": "담당자 연결"
}
}
},
{
"name": "회의록 Agent 꺼짐 · IT는 그대로",
"request": "requests/it-only.json",
"expect": {
"status": 200,
"equals": {
"results.0.route": "자동 답변"
}
}
}
]
}
+72
View File
@@ -0,0 +1,72 @@
{
"webhook": "my-assistant",
"cases": [
{
"name": "회의록 없는 회의 요청",
"request": "requests/meeting-no-notes.json",
"expect": {
"status": 200,
"equals": {
"count": 1,
"results.0.route": "입력 필요"
},
"contains": {
"reply": [
"회의록"
]
}
}
},
{
"name": "IT 둘 · 둘 다 답",
"request": "requests/two-it.json",
"expect": {
"equals": {
"count": 2,
"results.0.route": "자동 답변",
"results.1.route": "자동 답변"
},
"contains": {
"results.0.answer": [
"비밀번호 찾기",
"휴대폰"
],
"results.1.answer": [
"보관함",
"삭제"
]
}
}
},
{
"name": "복합 요청 · 오류 없음",
"request": "requests/composite.json",
"expect": {
"equals": {
"count": 3
},
"not_contains": {
"results": [
"\"오류\""
]
}
}
},
{
"name": "기타만",
"request": "requests/other-only.json",
"expect": {
"equals": {
"results.0.route": "담당자 연결"
}
}
},
{
"name": "빈 요청",
"request": "requests/empty-message.json",
"expect": {
"status": 400
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
# 수업 결과물
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`split.json`, `assistant.json`, `supervisor.json`)이 여기에 저장됩니다 (git에 올라가지 않음).
- `DESIGN.md`: [설계서 템플릿](../templates/MULTI-AGENT-DESIGN-TEMPLATE.md)을 복사해 채웁니다.
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
@@ -0,0 +1,204 @@
{
"name": "68 Split tasks (reference · Lab 01 example 2)",
"nodes": [
{
"name": "Assistant webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-split",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has message?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.message }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"message is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Split tasks",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {
"systemMessage": "You split one employee message into separate tasks. Task types: \"it\" = IT problems and IT request status (password, account, VPN, printer, mail, software, ticket ids like T-1002); \"meeting\" = organizing meeting notes or action items; \"other\" = anything else. One task per separate request. Keep each task text in Korean as the employee wrote it, and keep ticket ids. Return only JSON, no code fence: {\"tasks\": [{\"type\": \"it\", \"text\": \"...\"}]}",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse tasks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "// Turn the agent text into a list of tasks.\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet items = [];\ntry { const data = JSON.parse(raw); items = Array.isArray(data) ? data : (data.tasks ?? []); } catch (error) { items = []; }\nconst tasks = items\n .map(item => ({ type: ['it', 'meeting', 'other'].includes(item?.type) ? item.type : 'other', text: String(item?.text ?? '').trim() }))\n .filter(task => task.text);\nreturn [{ json: { count: tasks.length, tasks } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
900,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Assistant webhook": {
"main": [
[
{
"node": "Has message?",
"type": "main",
"index": 0
}
]
]
},
"Has message?": {
"main": [
[
{
"node": "Split tasks",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Split tasks",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Split tasks": {
"main": [
[
{
"node": "Parse tasks",
"type": "main",
"index": 0
}
]
]
},
"Parse tasks": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,666 @@
{
"name": "68 Assistant router (reference · Lab 02 example 2)",
"nodes": [
{
"name": "Assistant webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-assistant",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has message?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.message }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"message is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Split tasks",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {
"systemMessage": "You split one employee message into separate tasks. Task types: \"it\" = IT problems and IT request status (password, account, VPN, printer, mail, software, ticket ids like T-1002); \"meeting\" = organizing meeting notes or action items; \"other\" = anything else. One task per separate request. Keep each task text in Korean as the employee wrote it, and keep ticket ids. Return only JSON, no code fence: {\"tasks\": [{\"type\": \"it\", \"text\": \"...\"}]}",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse tasks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "// Turn the agent text into a list of tasks.\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet items = [];\ntry { const data = JSON.parse(raw); items = Array.isArray(data) ? data : (data.tasks ?? []); } catch (error) { items = []; }\nconst tasks = items\n .map(item => ({ type: ['it', 'meeting', 'other'].includes(item?.type) ? item.type : 'other', text: String(item?.text ?? '').trim() }))\n .filter(task => task.text);\nreturn [{ json: { count: tasks.length, tasks } }];"
}
},
{
"name": "One item per task",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
],
"parameters": {
"jsCode": "// One item per task, numbered in the order of the message.\nreturn $json.tasks.map((task, index) => ({ json: { index: index + 1, ...task } }));"
}
},
{
"name": "Route by type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1080,
300
],
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-it",
"leftValue": "={{ $json.type }}",
"rightValue": "it",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "it"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-meeting",
"leftValue": "={{ $json.type }}",
"rightValue": "meeting",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "meeting"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-other",
"leftValue": "={{ $json.type }}",
"rightValue": "other",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "other"
}
]
},
"options": {}
}
},
{
"name": "Call IT agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
100
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/it-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.text, user: $('Assistant webhook').first().json.body.user }) }}",
"options": {
"timeout": 60000
}
}
},
{
"name": "Call meeting agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
300
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/meeting-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ title: $('Assistant webhook').first().json.body.title, notes: $('Assistant webhook').first().json.body.notes }) }}",
"options": {
"timeout": 60000
}
}
},
{
"name": "IT result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
100
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "it-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '오류' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.answer || $json.error || '' }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Meeting result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
300
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "meeting-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '오류' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.summary ? $json.summary + ' 할 일: ' + ($json.actions ?? []).map(a => a.owner + ' - ' + a.task + ' (' + a.due + ')').join('; ') : ($json.error ?? '') }}",
"type": "string"
},
{
"id": "a6",
"name": "actions",
"value": "={{ $json.actions ?? [] }}",
"type": "array"
}
]
},
"options": {}
}
},
{
"name": "Other result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
500
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "IT 문의와 회의록 정리 말고는 도와드릴 수 없어 담당자에게 전달했습니다.",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Collect results",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1760,
300
],
"parameters": {
"numberInputs": 3
}
},
{
"name": "All results",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1960,
300
],
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "results",
"options": {}
}
},
{
"name": "Build reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2160,
300
],
"parameters": {
"jsCode": "// Combine the task results into one reply, in the order of the message.\nconst results = ($json.results ?? []).sort((a, b) => a.index - b.index);\nconst lines = results.map(r => `${r.index}. ${r.text}\\n → [${r.route}] ${r.answer}`);\nreturn [{ json: { count: results.length, results, reply: lines.join('\\n') } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2360,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Assistant webhook": {
"main": [
[
{
"node": "Has message?",
"type": "main",
"index": 0
}
]
]
},
"Has message?": {
"main": [
[
{
"node": "Split tasks",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Split tasks",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Split tasks": {
"main": [
[
{
"node": "Parse tasks",
"type": "main",
"index": 0
}
]
]
},
"Parse tasks": {
"main": [
[
{
"node": "One item per task",
"type": "main",
"index": 0
}
]
]
},
"One item per task": {
"main": [
[
{
"node": "Route by type",
"type": "main",
"index": 0
}
]
]
},
"Route by type": {
"main": [
[
{
"node": "Call IT agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Call meeting agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Other result",
"type": "main",
"index": 0
}
]
]
},
"Call IT agent": {
"main": [
[
{
"node": "IT result",
"type": "main",
"index": 0
}
]
]
},
"Call meeting agent": {
"main": [
[
{
"node": "Meeting result",
"type": "main",
"index": 0
}
]
]
},
"IT result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 0
}
]
]
},
"Meeting result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 1
}
]
]
},
"Other result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 2
}
]
]
},
"Collect results": {
"main": [
[
{
"node": "All results",
"type": "main",
"index": 0
}
]
]
},
"All results": {
"main": [
[
{
"node": "Build reply",
"type": "main",
"index": 0
}
]
]
},
"Build reply": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,779 @@
{
"name": "68 Assistant router (reference · Lab 03 example 2)",
"nodes": [
{
"name": "Assistant webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-assistant",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has message?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c1",
"leftValue": "={{ $json.body.message }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
420,
520
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"message is required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Split tasks",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "={{ $('Assistant webhook').first().json.body.message }}",
"options": {
"systemMessage": "You split one employee message into separate tasks. Task types: \"it\" = IT problems and IT request status (password, account, VPN, printer, mail, software, ticket ids like T-1002); \"meeting\" = organizing meeting notes or action items; \"other\" = anything else. One task per separate request. Keep each task text in Korean as the employee wrote it, and keep ticket ids. Return only JSON, no code fence: {\"tasks\": [{\"type\": \"it\", \"text\": \"...\"}]}",
"maxIterations": 2
}
}
},
{
"name": "OpenAI chat model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse tasks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
],
"parameters": {
"jsCode": "// Turn the agent text into a list of tasks, then decide in code who handles each task\n// and which input is missing (the LLM does not decide this).\nconst body = $('Assistant webhook').first().json.body;\nconst raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet items = [];\ntry { const data = JSON.parse(raw); items = Array.isArray(data) ? data : (data.tasks ?? []); } catch (error) { items = []; }\nconst AGENT = { it: 'it-agent', meeting: 'meeting-agent', other: '담당자 연결' };\nconst tasks = items\n .map(item => ({ type: ['it', 'meeting', 'other'].includes(item?.type) ? item.type : 'other', text: String(item?.text ?? '').trim() }))\n .filter(task => task.text)\n .map(task => {\n const missing = [];\n if (task.type === 'meeting' && !String(body.notes ?? '').trim()) missing.push('notes');\n if (task.type === 'it' && !String(body.user ?? '').trim()) missing.push('user');\n return { ...task, agent: AGENT[task.type], missing };\n });\nreturn [{ json: { count: tasks.length, tasks } }];"
}
},
{
"name": "One item per task",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
300
],
"parameters": {
"jsCode": "// One item per task, numbered in the order of the message.\nreturn $json.tasks.map((task, index) => ({ json: { index: index + 1, ...task } }));"
}
},
{
"name": "Route by type",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
1080,
300
],
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-it",
"leftValue": "={{ $json.type }}",
"rightValue": "it",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "it"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-meeting",
"leftValue": "={{ $json.type }}",
"rightValue": "meeting",
"operator": {
"type": "string",
"operation": "equals"
}
},
{
"id": "r-ready",
"leftValue": "={{ ($json.missing ?? []).length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "meeting"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-other",
"leftValue": "={{ $json.type }}",
"rightValue": "other",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "other"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r-missing",
"leftValue": "={{ ($json.missing ?? []).length }}",
"rightValue": 0,
"operator": {
"type": "number",
"operation": "gt"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "needs input"
}
]
},
"options": {}
}
},
{
"name": "Call IT agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
100
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/it-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ question: $json.text, user: $('Assistant webhook').first().json.body.user }) }}",
"options": {
"timeout": 60000,
"response": {
"response": {
"neverError": true
}
}
}
}
},
{
"name": "Call meeting agent",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1300,
300
],
"parameters": {
"method": "POST",
"url": "http://localhost:5678/webhook/meeting-agent",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ title: $('Assistant webhook').first().json.body.title, notes: $('Assistant webhook').first().json.body.notes }) }}",
"options": {
"timeout": 60000,
"response": {
"response": {
"neverError": true
}
}
}
}
},
{
"name": "IT result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
100
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "it-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '담당자 연결' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.answer || '담당 Agent가 응답하지 않아 담당자에게 전달했습니다.' }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Meeting result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
300
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "meeting-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "={{ $json.route || '담당자 연결' }}",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "={{ $json.summary ? $json.summary + ' 할 일: ' + ($json.actions ?? []).map(a => a.owner + ' - ' + a.task + ' (' + a.due + ')').join('; ') : '담당 Agent가 응답하지 않아 담당자에게 전달했습니다.' }}",
"type": "string"
},
{
"id": "a6",
"name": "actions",
"value": "={{ $json.actions ?? [] }}",
"type": "array"
}
]
},
"options": {}
}
},
{
"name": "Other result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
500
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "담당자 연결",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "IT 문의와 회의록 정리 말고는 도와드릴 수 없어 담당자에게 전달했습니다.",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Need input",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1520,
700
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "index",
"value": "={{ $('Route by type').item.json.index }}",
"type": "number"
},
{
"id": "a1",
"name": "type",
"value": "={{ $('Route by type').item.json.type }}",
"type": "string"
},
{
"id": "a2",
"name": "text",
"value": "={{ $('Route by type').item.json.text }}",
"type": "string"
},
{
"id": "a3",
"name": "agent",
"value": "meeting-agent",
"type": "string"
},
{
"id": "a4",
"name": "route",
"value": "입력 필요",
"type": "string"
},
{
"id": "a5",
"name": "answer",
"value": "회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요.",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Collect results",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1760,
300
],
"parameters": {
"numberInputs": 4
}
},
{
"name": "All results",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1960,
300
],
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "results",
"options": {}
}
},
{
"name": "Build reply",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2160,
300
],
"parameters": {
"jsCode": "// Combine the task results into one reply, in the order of the message.\nconst results = ($json.results ?? []).sort((a, b) => a.index - b.index);\nconst lines = results.map(r => `${r.index}. ${r.text}\\n → [${r.route}] ${r.answer}`);\nreturn [{ json: { count: results.length, results, reply: lines.join('\\n') } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2360,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Assistant webhook": {
"main": [
[
{
"node": "Has message?",
"type": "main",
"index": 0
}
]
]
},
"Has message?": {
"main": [
[
{
"node": "Split tasks",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"OpenAI chat model": {
"ai_languageModel": [
[
{
"node": "Split tasks",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Split tasks": {
"main": [
[
{
"node": "Parse tasks",
"type": "main",
"index": 0
}
]
]
},
"Parse tasks": {
"main": [
[
{
"node": "One item per task",
"type": "main",
"index": 0
}
]
]
},
"One item per task": {
"main": [
[
{
"node": "Route by type",
"type": "main",
"index": 0
}
]
]
},
"Route by type": {
"main": [
[
{
"node": "Call IT agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Call meeting agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Other result",
"type": "main",
"index": 0
}
],
[
{
"node": "Need input",
"type": "main",
"index": 0
}
]
]
},
"Call IT agent": {
"main": [
[
{
"node": "IT result",
"type": "main",
"index": 0
}
]
]
},
"Call meeting agent": {
"main": [
[
{
"node": "Meeting result",
"type": "main",
"index": 0
}
]
]
},
"IT result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 0
}
]
]
},
"Meeting result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 1
}
]
]
},
"Other result": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 2
}
]
]
},
"Collect results": {
"main": [
[
{
"node": "All results",
"type": "main",
"index": 0
}
]
]
},
"All results": {
"main": [
[
{
"node": "Build reply",
"type": "main",
"index": 0
}
]
]
},
"Build reply": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"Need input": {
"main": [
[
{
"node": "Collect results",
"type": "main",
"index": 3
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+1
View File
@@ -0,0 +1 @@
{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"}
+1
View File
@@ -0,0 +1 @@
{"title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
+1
View File
@@ -0,0 +1 @@
{"message": "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요.", "user": "박서연", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
+1
View File
@@ -0,0 +1 @@
{"message": "", "user": "이준호"}
+1
View File
@@ -0,0 +1 @@
{"message": "프린터 출력이 안 돼요. 그리고 다음 주 회식 장소도 추천해 주세요.", "user": "최유진"}
+1
View File
@@ -0,0 +1 @@
{"message": "VPN이 계속 끊겨요. 어떻게 해야 하나요?", "user": "이준호"}
@@ -0,0 +1 @@
{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지"}
+1
View File
@@ -0,0 +1 @@
{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
+1
View File
@@ -0,0 +1 @@
{"message": "다음 주 회식 장소 추천해 주세요", "user": "최유진"}
+1
View File
@@ -0,0 +1 @@
{"message": "비밀번호를 잊어버렸어요. 그리고 메일함 용량이 꽉 찼다고 나와요.", "user": "이준호"}
@@ -0,0 +1,53 @@
# Multi-Agent 설계서 · 사내 업무 도우미
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 이 파일을 `my-work/DESIGN.md`로 복사해 채웁니다.
## 1. 업무 분석표 (Lab 01 예제 1)
| 요청 | 업무 | 유형 (it · meeting · other) | 필요한 입력 | 요청에 있나 | 업무 관계 (독립 · 순서) |
| --- | --- | --- | --- | --- | --- |
| `composite` | | | | | |
| `two-it` | | | | | |
| `it-and-other` | | | | | |
| `meeting-no-notes` | | | | | |
| 내 업무에서 하나 | | | | | |
## 2. Agent 목록과 계약 (Lab 02 예제 1)
| Agent | 맡는 업무 | 웹훅 | 입력 | 결과 | 실패할 때 돌려주는 것 |
| --- | --- | --- | --- | --- | --- |
| | | | | | |
## 3. 흐름도 (mermaid)
```mermaid
flowchart LR
W(["요청"]) --> S["업무 나누기"]
```
## 4. 통합 응답 형식
```json
{"count": 0, "results": [{"index": 1, "type": "", "text": "", "agent": "", "route": "", "answer": ""}], "reply": ""}
```
## 5. 예외 경로 (Lab 03)
| 상황 | 라우터가 할 일 | 확인한 테스트 |
| --- | --- | --- |
| 필요한 입력이 없음 | | |
| 담당 Agent가 꺼짐 | | |
| 맡을 Agent가 없는 업무 | | |
## 6. 확장 계획 (Lab 03 과제, 측정값으로)
| 측정 | 값 (`runs/` 보고서) |
| --- | --- |
| IT 하나 | 초 |
| 회의록 하나 | 초 |
| 복합 요청 (업무 3개) | 초 |
- 직원 21명이 동시에 복합 요청을 보내면 어디가 먼저 막히나?
- 수직 확장(더 큰 서버)과 수평 확장(n8n 작업자 여러 개, Agent별 분리) 중 무엇을 먼저 하나? 이유는?
@@ -0,0 +1,30 @@
# 테스트 보고서 · 사내 업무 도우미 (라우터)
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 워크플로우 파일: `my-work/assistant.json`
## 1. 내가 쓴 테스트 (`cases/my-cases.json`)
| 케이스 | 요청 | 기대 결과 (업무 규칙) |
| --- | --- | --- |
| | | |
## 2. 실패와 고친 내용
| 실패한 케이스 | 처음 결과 (3번 중 통과) | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 (3번 중 통과) |
| --- | --- | --- | --- | --- |
| | | | | |
## 3. 최종 결과
| 테스트 파일 | 결과 |
| --- | --- |
| `cases/lab03.json --repeat 3` (5케이스) | Passed /15 |
| `cases/lab03-down.json` (회의록 Agent 꺼짐) | Passed /3 |
| `cases/my-cases.json --repeat 3` | Passed / |
## 4. 남은 문제 · 배운 점
- 지시문(systemMessage)으로 고친 것과 규칙 노드(If·Code)로 고친 것 중 어느 쪽이 더 안정적이었나?
-
+592
View File
@@ -0,0 +1,592 @@
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Course helper: check, push and test the n8n workflows that a CLI coding agent wrote.
uv run tools/flow.py doctor # is everything ready?
uv run tools/flow.py check my-work/assistant.json # find mistakes before n8n sees the file
uv run tools/flow.py push agents/*.json my-work/assistant.json # import + publish (one n8n restart)
uv run tools/flow.py off agents/meeting-agent.json # unpublish (simulate an agent that is down)
uv run tools/flow.py call my-assistant requests/composite.json
uv run tools/flow.py test cases/lab02.json [--repeat 3]
Run it from the day folder (Last3Mon/68). n8n is the day 65 install (~/day65-n8n, Docker project
day65-n8n), started from Last3Mon/65/n8n. Environment overrides:
N8N_CONTAINER (default: the running n8n service of project day65-n8n, else day65-n8n-n8n-1)
N8N_HOME (default ~/day65-n8n), N8N_URL (default http://localhost:5678)
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import uuid
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
CACHE = Path(__file__).resolve().parent / ".cache"
def n8n_home() -> Path:
"""Where the official n8n install lives: N8N_HOME, else Last3Mon/65/n8n/.env, else ~/day65-n8n."""
if os.getenv("N8N_HOME"):
return Path(os.environ["N8N_HOME"]).expanduser()
env_file = ROOT.parent / "65" / "n8n" / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
if line.startswith("N8N_HOME="):
value = line.split("=", 1)[1].strip().strip("'\"")
if value:
return (env_file.parent / Path(value).expanduser()).resolve()
return Path.home() / "day65-n8n"
N8N_HOME = n8n_home()
START_HINT = "cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start (n8n install: ~/day65-n8n)"
def find_container() -> str:
"""The n8n container of the day 65 project, whichever folder it was started from."""
if os.getenv("N8N_CONTAINER"):
return os.environ["N8N_CONTAINER"]
if shutil.which("docker"):
result = subprocess.run(["docker", "ps", "-a", "--filter", "label=com.docker.compose.project=day65-n8n",
"--filter", "label=com.docker.compose.service=n8n", "--format", "{{.Names}}"],
capture_output=True, text=True)
names = result.stdout.split()
if names:
return names[0]
return "day65-n8n-n8n-1"
CONTAINER = find_container()
N8N_URL = os.getenv("N8N_URL", "http://localhost:5678").rstrip("/")
# Versions written in AGENTS.md. Other versions still work if n8n knows them (warning only).
RECOMMENDED = {
"n8n-nodes-base.webhook": 2,
"n8n-nodes-base.respondToWebhook": 1.1,
"n8n-nodes-base.if": 2.2,
"n8n-nodes-base.switch": 3.2,
"n8n-nodes-base.set": 3.4,
"n8n-nodes-base.code": 2,
"n8n-nodes-base.httpRequest": 4.2,
"n8n-nodes-base.httpRequestTool": 4.2,
"@n8n/n8n-nodes-langchain.agent": 3.1,
"@n8n/n8n-nodes-langchain.lmChatOpenAi": 1.2,
}
AGENT_HOOKS = ("it-agent", "meeting-agent") # the agents in agents/ that the router calls
COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)"} # OpenAI only from day 66 (no Ollama)
CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser"}
SECRET = re.compile(r"\bsk-[A-Za-z0-9_-]{16,}")
OK, BAD, WARN = "✔", "✘", "!"
# ---------------------------------------------------------------- helpers
def die(message: str) -> None:
print(f"{BAD} {message}", file=sys.stderr)
sys.exit(1)
def docker(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
if not shutil.which("docker"):
die("docker command not found. Start Docker first.")
result = subprocess.run(["docker", *args], input=stdin, capture_output=True, text=True)
if check and result.returncode != 0:
die(f"docker {' '.join(args[:2])} failed:\n{(result.stderr or result.stdout).strip()}")
return result
def n8n(*args: str, stdin: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
return docker("exec", "-i", CONTAINER, "n8n", *args, stdin=stdin, check=check)
def container_running() -> bool:
if not shutil.which("docker"):
return False
result = subprocess.run(["docker", "inspect", "-f", "{{.State.Running}}", CONTAINER], capture_output=True, text=True)
return result.stdout.strip() == "true"
def http(method: str, url: str, body: object | None = None, timeout: float = 300) -> tuple[int, object, float]:
data = None if body is None else json.dumps(body, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json"})
start = time.monotonic()
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
status, raw = response.status, response.read()
except urllib.error.HTTPError as error:
status, raw = error.code, error.read()
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
return 0, {"error": str(getattr(error, "reason", error))}, time.monotonic() - start
text = raw.decode("utf-8", errors="replace")
try:
payload: object = json.loads(text) if text.strip() else ""
except json.JSONDecodeError:
payload = text
return status, payload, time.monotonic() - start
def load_json(path: Path) -> object:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError:
die(f"file not found: {path}")
except json.JSONDecodeError as error:
die(f"{path} is not valid JSON (line {error.lineno}, column {error.colno}): {error.msg}")
def resolve(path: str) -> Path:
candidate = Path(path)
if candidate.exists() or candidate.is_absolute():
return candidate
return ROOT / path
def node_versions() -> dict[str, list[float]]:
"""Node types and versions that this n8n really has (exported once, then cached)."""
cache = CACHE / "node-versions.json"
if cache.exists():
return json.loads(cache.read_text())
if not container_running():
return {}
n8n("export:nodes", "--output=/tmp/course-nodes.json")
raw = docker("exec", CONTAINER, "sh", "-c", "cat /tmp/course-nodes.json && rm -f /tmp/course-nodes.json").stdout
versions: dict[str, set[float]] = {}
for node in json.loads(raw):
value = node.get("version")
versions.setdefault(node["name"], set()).update(value if isinstance(value, list) else [value])
result = {name: sorted(values) for name, values in versions.items()}
CACHE.mkdir(exist_ok=True)
cache.write_text(json.dumps(result))
return result
def walk_strings(value: object, where: str = ""):
if isinstance(value, str):
yield where, value
elif isinstance(value, dict):
for key, item in value.items():
yield from walk_strings(item, f"{where}.{key}" if where else key)
elif isinstance(value, list):
for index, item in enumerate(value):
yield from walk_strings(item, f"{where}[{index}]")
def webhooks(workflow: dict) -> list[tuple[str, str]]:
found = []
for node in workflow.get("nodes", []):
if node.get("type") == "n8n-nodes-base.webhook":
parameters = node.get("parameters", {})
found.append((str(parameters.get("httpMethod", "GET")).upper(), str(parameters.get("path", "")).strip("/")))
return found
# ---------------------------------------------------------------- check
def check_workflow(workflow: object) -> tuple[list[str], list[str]]:
errors: list[str] = []
warnings: list[str] = []
if not isinstance(workflow, dict):
return ["the file must be one workflow object { name, nodes, connections }"], warnings
if not str(workflow.get("name", "")).strip():
errors.append("missing workflow 'name'")
nodes = workflow.get("nodes")
connections = workflow.get("connections")
if not isinstance(nodes, list) or not nodes:
return errors + ["'nodes' must be a non-empty list"], warnings
if not isinstance(connections, dict):
errors.append("'connections' must be an object")
connections = {}
known = node_versions()
names: dict[str, dict] = {}
for index, node in enumerate(nodes):
label = f"node #{index + 1}"
if not isinstance(node, dict):
errors.append(f"{label} is not an object")
continue
name = node.get("name")
if not name:
errors.append(f"{label} has no 'name'")
continue
label = f"node '{name}'"
if name in names:
errors.append(f"{label} appears twice (node names must be unique)")
names[name] = node
node_type, version = node.get("type"), node.get("typeVersion")
if not node_type or version is None:
errors.append(f"{label} needs 'type' and 'typeVersion'")
elif known and node_type not in known:
errors.append(f"{label}: type '{node_type}' does not exist in this n8n")
elif known and version not in known[node_type]:
errors.append(f"{label}: {node_type} has no typeVersion {version} (available: {known[node_type]})")
elif node_type in RECOMMENDED and version != RECOMMENDED[node_type]:
warnings.append(f"{label}: typeVersion {version} (AGENTS.md uses {RECOMMENDED[node_type]})")
if not isinstance(node.get("parameters"), dict):
errors.append(f"{label} needs 'parameters' (use {{}} when empty)")
position = node.get("position")
if not (isinstance(position, list) and len(position) == 2):
errors.append(f"{label} needs 'position': [x, y]")
for kind, credential in (node.get("credentials") or {}).items():
credential_id = (credential or {}).get("id")
if credential_id not in COURSE_CREDENTIALS:
errors.append(f"{label}: credential '{kind}' must use a course id {list(COURSE_CREDENTIALS)}, got {credential_id!r}")
for where, text in walk_strings(node.get("parameters", {})):
if SECRET.search(text):
errors.append(f"{label}: parameters.{where} looks like an API key. Use a credential instead.")
if "{{" in text and "}}" in text and not text.startswith("="):
warnings.append(f"{label}: parameters.{where} has {{{{ }}}} but does not start with '=' (it stays plain text)")
targets_of: dict[str, set[str]] = {}
for source, outputs in connections.items():
if source not in names:
errors.append(f"connections: source '{source}' is not a node name")
continue
if not isinstance(outputs, dict):
errors.append(f"connections['{source}'] must be an object like {{\"main\": [[...]]}}")
continue
for kind, branches in outputs.items():
if kind not in CONNECTION_TYPES:
errors.append(f"connections['{source}']: unknown connection type '{kind}'")
for branch in branches or []:
for link in branch or []:
target = (link or {}).get("node")
if target not in names:
errors.append(f"connections['{source}'] points to '{target}', which is not a node name")
elif link.get("type") != kind:
errors.append(f"connections['{source}'] -> '{target}': link type must be '{kind}'")
else:
targets_of.setdefault(target, set()).add(kind)
if kind != "main":
targets_of.setdefault(f"{source}::from", set()).add(kind)
has_respond = any(node.get("type") == "n8n-nodes-base.respondToWebhook" for node in names.values())
for name, node in names.items():
node_type = node.get("type", "")
parameters = node.get("parameters", {}) if isinstance(node.get("parameters"), dict) else {}
if node_type == "n8n-nodes-base.webhook":
if not str(parameters.get("path", "")).strip("/"):
errors.append(f"node '{name}': webhook needs a 'path'")
if parameters.get("responseMode") == "responseNode" and not has_respond:
errors.append(f"node '{name}': responseMode is 'responseNode' but there is no Respond to Webhook node")
if node_type == "@n8n/n8n-nodes-langchain.agent" and "ai_languageModel" not in targets_of.get(name, set()):
errors.append(f"node '{name}': AI Agent needs a chat model connected with 'ai_languageModel'")
if node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool"):
if "ai_tool" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a tool must be connected to an AI Agent with 'ai_tool'")
if node_type.startswith("@n8n/n8n-nodes-langchain.lmChat"):
if "ai_languageModel" not in targets_of.get(f"{name}::from", set()):
errors.append(f"node '{name}': a chat model must be connected to an AI Agent with 'ai_languageModel'")
if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") \
and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \
and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower():
warnings.append(f"node '{name}': nothing is connected into it (it never runs)")
if not webhooks(workflow):
warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path")
return errors, warnings
def cmd_check(args: argparse.Namespace) -> int:
path = resolve(args.file)
errors, warnings = check_workflow(load_json(path))
for warning in warnings:
print(f"{WARN} {warning}")
for error in errors:
print(f"{BAD} {error}")
if errors:
print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.")
return 1
print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
# ---------------------------------------------------------------- push
def stable_id(path: Path) -> str:
return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15]
def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3)
if status == 200:
break
time.sleep(1)
else:
return False
for method, path in paths:
while time.monotonic() < deadline:
# A webhook that is registered for POST answers a GET with a hint, not "is not registered".
status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5)
text = json.dumps(payload, ensure_ascii=False)
if "not registered for" in text or status not in (0, 404, 500, 502, 503):
break
time.sleep(1)
time.sleep(2) # webhooks of the other published workflows finish registering right after
return time.monotonic() < deadline
def exported_workflows() -> list[dict]:
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; "
"cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout
try:
return json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
return []
def prepare(path: Path) -> dict | None:
workflow = load_json(path)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {path.name}: {warning}")
if errors:
for error in errors:
print(f"{BAD} {path.name}: {error}")
return None
workflow = dict(workflow)
if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
workflow["id"] = stable_id(path)
workflow["active"] = False
workflow.setdefault("settings", {}).setdefault("executionOrder", "v1")
for node in workflow["nodes"]:
node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}")))
if node["type"] == "n8n-nodes-base.webhook":
node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}")))
return workflow
def cmd_push(args: argparse.Namespace) -> int:
paths = [resolve(file) for file in args.files]
workflows = [prepare(path) for path in paths]
if None in workflows:
print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check <file>).")
return 1
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
mine_ids = {workflow["id"] for workflow in workflows}
hooks = {hook for workflow in workflows for hook in webhooks(workflow)}
# Another published workflow with the same webhook path would block this one.
for other in exported_workflows():
if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)):
n8n("unpublish:workflow", f"--id={other['id']}")
print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path")
for workflow in workflows:
docker("exec", "-i", CONTAINER, "sh", "-c",
"cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; "
"status=$?; rm -f /tmp/course-workflow.json; exit $status",
stdin=json.dumps(workflow, ensure_ascii=False))
n8n("publish:workflow", f"--id={workflow['id']}")
# The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s).
docker("restart", CONTAINER)
ready = wait_until_ready(sorted(hooks))
for workflow in workflows:
print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})")
for method, hook in sorted(webhooks(workflow)):
print(f" {method} {N8N_URL}/webhook/{hook}")
if not ready:
print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.")
return 0
def cmd_off(args: argparse.Namespace) -> int:
"""Unpublish workflows (by file) to see how the others behave when one agent is down."""
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
wanted = {stable_id(resolve(file)): file for file in args.files}
for file in args.files:
workflow = load_json(resolve(file))
if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
wanted[workflow["id"]] = file
active = {item.get("id") for item in exported_workflows() if item.get("active")}
for workflow_id, file in wanted.items():
if workflow_id in active:
n8n("unpublish:workflow", f"--id={workflow_id}")
print(f"{OK} unpublished {file} (id {workflow_id})")
docker("restart", CONTAINER)
wait_until_ready([])
print(f"{OK} n8n restarted. Turn it back on with: uv run tools/flow.py push {' '.join(args.files)}")
return 0
# ---------------------------------------------------------------- call / test
def request_body(spec: str | dict, base: Path = ROOT) -> object:
if isinstance(spec, dict):
return spec
text = spec.strip()
if text.startswith("{"):
try:
return json.loads(text)
except json.JSONDecodeError as error:
die(f"request is not valid JSON: {error.msg}")
path = Path(text)
if not path.exists():
path = base / text
return load_json(path)
def cmd_call(args: argparse.Namespace) -> int:
body = request_body(args.request) if args.request else {}
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{args.path.strip('/')}", body)
print(f"HTTP {status} · {seconds:.1f}s")
print(json.dumps(payload, ensure_ascii=False, indent=2) if not isinstance(payload, str) else payload)
return 0 if 200 <= status < 300 else 1
def field(payload: object, dotted: str) -> object:
value = payload
for part in dotted.split("."):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
return None
return value
def as_text(value: object) -> str:
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
def judge(expect: dict, status: int, payload: object) -> list[str]:
problems = []
if status != expect.get("status", 200):
problems.append(f"status {status} (expected {expect.get('status', 200)})")
for name, wanted in (expect.get("equals") or {}).items():
actual = field(payload, name)
if actual != wanted:
problems.append(f"{name} = {as_text(actual)[:60]} (expected {as_text(wanted)})")
for name, words in (expect.get("contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
if not any(word in text for word in words):
problems.append(f"{name} has none of {words}")
for name, words in (expect.get("not_contains") or {}).items():
words = [words] if isinstance(words, str) else words
text = as_text(field(payload, name) or "")
leaked = [word for word in words if word in text]
if leaked:
problems.append(f"{name} must not have {leaked}")
return problems
def cmd_test(args: argparse.Namespace) -> int:
path = resolve(args.cases)
suite = load_json(path)
hook = (args.webhook or suite.get("webhook", "")).strip("/")
cases = suite.get("cases", [])
if not hook or not cases:
die(f"{path.name} needs \"webhook\" and a non-empty \"cases\" list")
print(f"POST {N8N_URL}/webhook/{hook} · {len(cases)} case(s) × {args.repeat}")
results, passed = [], 0
for case in cases:
body = request_body(case.get("body") or case.get("request", "{}"))
case_hook = (case.get("webhook") or hook).strip("/") if not args.webhook else hook
for run in range(1, args.repeat + 1):
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{case_hook}", body)
problems = judge(case.get("expect", {}), status, payload)
passed += not problems
mark = OK if not problems else BAD
suffix = f" #{run}" if args.repeat > 1 else ""
print(f"{mark} {case.get('name', '?')}{suffix} · {seconds:.1f}s" + ("" if not problems else " · " + "; ".join(problems)))
results.append({"case": case.get("name"), "webhook": case_hook, "run": run, "passed": not problems, "problems": problems,
"seconds": round(seconds, 1), "status": status, "request": body, "response": payload})
total = len(results)
print(f"Passed {passed}/{total}")
runs = ROOT / "runs"
runs.mkdir(exist_ok=True)
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
report = runs / f"{path.stem}-{stamp}.json"
report.write_text(json.dumps({"cases": str(path.name), "webhook": hook, "passed": passed, "total": total,
"results": results}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"report: {report.relative_to(ROOT)}")
return 0 if passed == total else 1
# ---------------------------------------------------------------- doctor
def cmd_doctor(args: argparse.Namespace) -> int:
failures = 0
def line(good: bool, text: str, hint: str = "") -> None:
nonlocal failures
failures += not good
print(f"{OK if good else BAD} {text}" + ("" if good or not hint else f"\n → {hint}"))
installed = (N8N_HOME / "compose.yml").exists()
print(f"{OK if installed else WARN} n8n install folder {N8N_HOME}" + ("" if installed else
"\n → day 65 SETUP-N8N 5단계 (get.n8n.io 설치). 다른 곳에 설치했다면 Last3Mon/65/n8n/.env 에 N8N_HOME=<폴더>"))
running = container_running()
line(running, f"n8n container {CONTAINER}", START_HINT)
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=5)
line(status == 200, f"n8n {N8N_URL}", "wait 10 seconds after starting n8n, then run doctor again")
status, _, _ = http("GET", "http://localhost:8800/health", timeout=5)
line(status == 200, "data API http://localhost:8800", "cd ~/aio-01d4-stu/Last3Mon/67/data-api && docker compose up -d --force-recreate (start n8n first)")
if running:
probe = docker("exec", CONTAINER, "node", "-e",
"fetch('http://data-api:8800/health').then(r=>console.log(r.status)).catch(()=>console.log(0))",
check=False).stdout.strip()
line(probe == "200", "n8n → http://data-api:8800", "data API must join the day65-n8n_default network (compose.yml)")
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:credentials --all --output=/tmp/course-cred.json >/dev/null 2>&1; "
"cat /tmp/course-cred.json 2>/dev/null; rm -f /tmp/course-cred.json", check=False).stdout
try:
ids = {item.get("id") for item in json.loads(exported)} if exported.strip() else set()
except json.JSONDecodeError:
ids = set()
for credential_id, name in COURSE_CREDENTIALS.items():
line(credential_id in ids, f"credential '{name}' ({credential_id})",
"cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh")
line(bool(node_versions()), "node catalog (for check)")
for hook in AGENT_HOOKS:
_, payload, _ = http("GET", f"{N8N_URL}/webhook/{hook}", timeout=5)
line("not registered for" in json.dumps(payload, ensure_ascii=False), f"agent webhook /{hook}",
"uv run tools/flow.py push agents/it-agent.json agents/meeting-agent.json")
agents = [name for name in ("codex", "claude", "agy") if shutil.which(name)]
line(bool(agents), "CLI coding agent: " + (", ".join(agents) if agents else "none"),
"install one of codex / claude / agy (see Lecture/68/00-DAY68-PREP.md)")
print("ready" if not failures else f"{failures} item(s) to fix")
return 0 if not failures else 1
def main() -> int:
parser = argparse.ArgumentParser(description="Check, push and test course n8n workflows.")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("doctor", help="check n8n, data API, credentials and CLI agents").set_defaults(run=cmd_doctor)
item = commands.add_parser("check", help="find mistakes in a workflow file")
item.add_argument("file")
item.set_defaults(run=cmd_check)
item = commands.add_parser("push", help="import and publish workflow files (one n8n restart)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_push)
item = commands.add_parser("off", help="unpublish workflow files (an agent that is down)")
item.add_argument("files", nargs="+")
item.set_defaults(run=cmd_off)
item = commands.add_parser("call", help="POST one request to a webhook path")
item.add_argument("path", help="webhook path, e.g. my-assistant")
item.add_argument("request", nargs="?", help="request file or inline JSON")
item.set_defaults(run=cmd_call)
item = commands.add_parser("test", help="run a test case file")
item.add_argument("cases")
item.add_argument("--repeat", type=int, default=1, help="run every case N times")
item.add_argument("--webhook", help="override the webhook path in the case file")
item.set_defaults(run=cmd_test)
args = parser.parse_args()
return args.run(args)
if __name__ == "__main__":
sys.exit(main())