68일차 교안·실습(복합 업무 시나리오 · Multi-Agent 라우터) 추가, 결석 수강생용 최소 준비 문서 포함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a57360d4d9
commit
587ca5c2b3
42 files changed
+5481
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
runs/
|
||||||
|
tools/.cache/
|
||||||
|
my-work/*.json
|
||||||
@@ -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 키를 읽어서 출력하지 않는다.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -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`에 요청문을 모았다.
|
||||||
@@ -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 비교 표.
|
||||||
@@ -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`로 채웠다.
|
||||||
@@ -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일차 준비
|
||||||
@@ -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`로 확인할 수 있습니다.
|
||||||
@@ -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초 |
|
||||||
@@ -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`.
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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": "자동 답변"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message": "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요.", "user": "박서연", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message": "", "user": "이준호"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message": "프린터 출력이 안 돼요. 그리고 다음 주 회식 장소도 추천해 주세요.", "user": "최유진"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message": "VPN이 계속 끊겨요. 어떻게 해야 하나요?", "user": "이준호"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"message": "다음 주 회식 장소 추천해 주세요", "user": "최유진"}
|
||||||
@@ -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)로 고친 것 중 어느 쪽이 더 안정적이었나?
|
||||||
|
-
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
# 68일차 개요 · 복합 업무 시나리오와 Multi-Agent 워크플로우
|
||||||
|
|
||||||
|
> 운영·확장 관점의 AI 워크플로우 설계 고도화 · 2026-10-20
|
||||||
|
> 13장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~5장을 보고, 나머지 장은 각 단계를 설명할 때 함께 띄웁니다.
|
||||||
|
|
||||||
|
| 장 | 범위 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1~5 | 표지 · 목표 · 시간표 · 새 단원 · 오늘의 그림 |
|
||||||
|
| 6~7 | 1단계 · 복합 업무 시나리오 분석 |
|
||||||
|
| 8~9 | 2단계 · Multi-Agent 워크플로우 설계 |
|
||||||
|
| 10~11 | 3단계 · 장애 격리와 확장 |
|
||||||
|
| 12~13 | 7교시 바이브 코딩 · 정리와 예고 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 / 13 · 표지
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
R["복합<br/>요청"] --> S["나누기"]
|
||||||
|
S --> I["IT<br/>Agent"] & M["회의록<br/>Agent"]
|
||||||
|
I & M --> O["통합<br/>응답"]
|
||||||
|
classDef g fill:#8A9BA0,stroke:#8A9BA0,color:#fff
|
||||||
|
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
|
||||||
|
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
|
||||||
|
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
|
||||||
|
class R g
|
||||||
|
class S o
|
||||||
|
class I,M t
|
||||||
|
class O d
|
||||||
|
```
|
||||||
|
|
||||||
|
**68일차**
|
||||||
|
|
||||||
|
# 복합 업무 시나리오와 Multi-Agent 워크플로우
|
||||||
|
|
||||||
|
업무 분석 · 업무 나누기 · Agent에게 분배 · 결과 통합 · 장애 격리
|
||||||
|
|
||||||
|
<sub>운영·확장 관점의 AI 워크플로우 설계 고도화 · 2026-10-20</sub>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 / 13 · 오늘 할 수 있게 될 세 가지
|
||||||
|
|
||||||
|
| | 오늘 할 수 있게 될 것 |
|
||||||
|
| :-: | --- |
|
||||||
|
| **①** | 요청 하나에 섞인 업무를 **분석표**로 나누고, 그대로 나누는 워크플로우를 요청문으로 만든다 |
|
||||||
|
| **②** | 이미 만든 Agent의 **계약**을 정리하고, 그 Agent들을 부르는 **라우터**를 만든다. Supervisor와 비교한다 |
|
||||||
|
| **③** | Agent 하나가 꺼지거나 입력이 빠져도 **나머지 답은 나오게** 만들고, 측정값으로 확장 계획을 쓴다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 / 13 · 오늘의 시간표
|
||||||
|
|
||||||
|
| ① 1~2교시 | ② 3~4교시 | ③ 5~6교시 | ④ 7~8교시 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **1단계** | **2단계** | **3단계** | **바이브 코딩** |
|
||||||
|
| 복합 업무 시나리오 분석 | Multi-Agent 설계 | 검증 · 장애 격리 · 확장 | `tflow` |
|
||||||
|
| Lab 01 분석표 · 나누기 | Lab 02 라우터 · Supervisor | Lab 03 빠진 입력 · 꺼진 Agent | 만들기 · 발표 · 정리 |
|
||||||
|
|
||||||
|
> 09:00 시작 · 50분 수업 10분 휴식 · 4교시 뒤 13:00~14:00 점심
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 / 13 · 새 단원: 하나의 Agent에서 여러 Agent로
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
D66["66 · 회의록<br/>→ 할 일"] --> D67["67 · IT 문의<br/>Agent"] --> D68["68 · 둘을 묶는<br/>라우터"] --> D69["69 · 역할 분리<br/>협업"] --> D70["70 · 템플릿<br/>재사용"]
|
||||||
|
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
|
||||||
|
classDef g fill:#8A9BA0,stroke:#8A9BA0,color:#fff
|
||||||
|
class D68 t
|
||||||
|
class D66,D67,D69,D70 g
|
||||||
|
```
|
||||||
|
|
||||||
|
- 66·67일차에 만든 것이 오늘은 **부품**(`agents/`)이 됩니다. 오늘 만드는 것은 그 부품을 묶는 쪽입니다.
|
||||||
|
- 방식은 67일차와 같습니다: 요청문은 내가, JSON은 에이전트가, push·test는 내가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 / 13 · 오늘의 그림
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W(["요청<br/>message · user · notes"]) --> S{{"Split tasks"}} --> SW{"Route by type"}
|
||||||
|
SW -->|it| IT{{"IT 문의 Agent"}}
|
||||||
|
SW -->|meeting| MT{{"회의록 Agent"}}
|
||||||
|
SW -->|other| O["담당자 연결"]
|
||||||
|
SW -->|needs input| N["입력 필요"]
|
||||||
|
IT & MT & O & N --> M["Collect · Build reply"] --> R(["count · results · reply"])
|
||||||
|
```
|
||||||
|
|
||||||
|
> 이 그림을 Lab 01(나누기) → Lab 02(분배·통합) → Lab 03(입력 필요·장애 격리) 순서로 완성합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 / 13 · 1단계 · 업무 분석 다섯 칸
|
||||||
|
|
||||||
|
> "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요."
|
||||||
|
|
||||||
|
| 업무 | 유형 | 필요한 입력 | 요청에 있나 | 관계 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| T-1002 처리 상태 | it | 접수 번호, 요청한 사람 | 문장 + `user` | 독립 |
|
||||||
|
| 회의록 할 일 정리 | meeting | 회의록 원문 | `notes` | 독립 |
|
||||||
|
| 프린터 출력 안 됨 | it | 증상 | 문장 | 독립 |
|
||||||
|
|
||||||
|
> 필요한 입력은 **문장 밖**에도 있습니다. 나누기는 LLM이, 빠진 입력 확인은 규칙이.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 / 13 · 1단계 · LLM이 할 일, 규칙이 할 일
|
||||||
|
|
||||||
|
| 일 | 누가 | 이유 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 문장을 업무로 나누기, 유형 고르기 | LLM | 말의 뜻을 알아야 함 |
|
||||||
|
| 유형 → 담당 Agent | 규칙 (표) | 늘 같은 답이어야 함 |
|
||||||
|
| 필요한 입력이 있나 | 규칙 (빈 칸 확인) | 짐작하면 안 됨 |
|
||||||
|
|
||||||
|
수업 준비 때 정답 워크플로우: 나누기 `lab01` 7/7, 규칙을 더한 과제 `lab01-challenge` 0/4 → 4/4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 / 13 · 2단계 · Agent를 묶는 네 가지 모양
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 라우터
|
||||||
|
a1["나누기"] --> a2["A"] & a3["B"]
|
||||||
|
end
|
||||||
|
subgraph 파이프라인
|
||||||
|
b1["A"] --> b2["B"] --> b3["C"]
|
||||||
|
end
|
||||||
|
subgraph Supervisor
|
||||||
|
c1{{"LLM"}} -.-> c2["A"] & c3["B"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
| 모양 | 언제 | 오늘 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 라우터 | 업무가 서로 **독립** | Lab 02 예제 2 |
|
||||||
|
| 파이프라인 | 앞 결과가 뒤에 필요 | 69일차 |
|
||||||
|
| Fork-Join | 같은 입력을 여럿이 동시에 보고 합침 | 69일차 |
|
||||||
|
| Supervisor | 요청이 열려 있고 LLM이 고르게 | Lab 02 과제 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9 / 13 · 2단계 · 라우터 vs Supervisor (수업 준비 측정)
|
||||||
|
|
||||||
|
| | 라우터 | Supervisor |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 누가 Agent를 고르나 | 유형 표 (코드) | LLM (Tool 설명·지시문) |
|
||||||
|
| 복합 요청 (업무 3개) | 4.5~6.6초 | 9.6~10.8초 |
|
||||||
|
| 결과 | 업무별 `results[]` (계약) | 답 글자 하나 |
|
||||||
|
| 라우터 테스트(`lab02`)를 그대로 | 6/6 | 1/6 |
|
||||||
|
| 흔들린 곳 | — | 지시문 낱말 하나로 4/5 ↔ 5/5 |
|
||||||
|
|
||||||
|
> 정해진 업무는 라우터, 열린 요청은 Supervisor. **계약이 있으면 둘을 같은 테스트로 비교**할 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10 / 13 · 3단계 · 하나가 꺼지면 전체가 멈춘다
|
||||||
|
|
||||||
|
| 회의록 Agent 꺼짐 · 복합 요청 | 결과 |
|
||||||
|
| --- | --- |
|
||||||
|
| 고치기 전 (Lab 02 예제 라우터) | **HTTP 500** · T-1002 답까지 사라짐 (`lab03-down` 1/3) |
|
||||||
|
| `neverError` + 빈 결과는 담당자 연결 | 200 · IT 둘은 자동 답변, 회의록만 담당자 연결 (3/3) |
|
||||||
|
|
||||||
|
> 회의록 없이 온 회의 요청도 같은 문제(Agent가 400 → 전체 500). **부르기 전에 막거나, 받은 오류를 결과로 바꾸거나.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11 / 13 · 3단계 · 측정으로 확장 계획
|
||||||
|
|
||||||
|
| 수업 준비 측정 (1회) | 시간 |
|
||||||
|
| --- | --- |
|
||||||
|
| 기타만 (LLM 나누기 한 번) | 0.9~1.0초 |
|
||||||
|
| IT 하나 | 2.3~2.6초 |
|
||||||
|
| 회의록 하나 | 3.5초 |
|
||||||
|
| 복합 요청 (업무 3개) | 4.5~6.6초 |
|
||||||
|
|
||||||
|
- 요청 하나가 Agent 실행 여러 개를 만든다 → 21명 동시면 n8n 실행 수는 몇 배?
|
||||||
|
- 시간 대부분은 LLM API 대기 → 더 큰 서버(수직)보다 작업자 여럿(수평)·속도 제한 관리가 먼저.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12 / 13 · 7교시 · 바이브 코딩 `tflow`
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ tflow agents/it-agent.json
|
||||||
|
▼ IT question webhook [webhook POST /it-agent]
|
||||||
|
▼ Has question? [if]
|
||||||
|
├ true ▼ Has user? [if] ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- 에이전트가 만든 n8n JSON을 **트리 · mermaid · 한국어 설명**으로 확인하는 TUI
|
||||||
|
- 오늘 만든 `my-work/assistant.json`을 열어 내 설계(`DESIGN.md`)와 같은지 봅니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13 / 13 · 정리와 69일차 예고
|
||||||
|
|
||||||
|
| 오늘 | 한 줄 |
|
||||||
|
| --- | --- |
|
||||||
|
| 1단계 | 업무 분석이 먼저, 나누기는 LLM, 담당·빠진 입력은 규칙 |
|
||||||
|
| 2단계 | Agent는 계약으로 묶는다. 라우터와 Supervisor는 같은 테스트로 비교 |
|
||||||
|
| 3단계 | 하나가 꺼져도 나머지는 답한다. 확장은 측정값으로 |
|
||||||
|
|
||||||
|
- 69일차: **역할 기반 Agent 분리** (작성자 → 검토자 → 정리자) · 바이브 코딩 `tcrew`
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# 68일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것
|
||||||
|
|
||||||
|
- 수업일: 2026-10-20 (68일차) · 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 설계
|
||||||
|
- 준비 시간: 약 15분 (67일차 준비를 했다면 1·3절만)
|
||||||
|
- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu).
|
||||||
|
- **수업 전날까지** 1~3절을 끝내 주세요.
|
||||||
|
- 65~67일차를 하지 못해 n8n · Credential · 데이터 API가 없다면 [PREPARE-68-FROM-65.md](../../Last3Mon/68/PREPARE-68-FROM-65.md)를 따라 하면 최소 명령으로 `ready`까지 갑니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["1. n8n · 데이터 API 켜기"] --> B["2. 두 Agent 게시"] --> C["3. doctor<br/>ready"] --> D["4. 미리 읽기"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. n8n과 데이터 API 켜기 (전날 필수, 3분)
|
||||||
|
|
||||||
|
67일차와 같습니다. n8n은 65일차 설치(`~/day65-n8n`)를 `Last3Mon/65/n8n`에서 켜고, 데이터 API는 67일차 폴더의 것을 그대로 씁니다.
|
||||||
|
|
||||||
|
```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"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **66일차부터 LLM은 OpenAI API 키(`Last3Mon/.env`, Credential `OpenAI (.env)`)만 씁니다.** Ollama는 쓰지 않으므로 위처럼 꺼서 메모리를 아낍니다. 다음 날부터는 같은 명령을 그대로 써도 됩니다.
|
||||||
|
- `network day65-n8n_default not found` → n8n이 켜져 있지 않음. 위 n8n 부분부터 다시.
|
||||||
|
- `.../day65-n8n/compose.yml: no such file or directory` → 홈이 아닌 곳에 설치함. `Last3Mon/65/n8n/.env`에 `N8N_HOME=<설치 폴더>` ([67일차 준비](../67/00-DAY67-PREP.md) 1절).
|
||||||
|
|
||||||
|
## 2. 오늘 부를 Agent 두 개 게시 (전날 필수, 2분)
|
||||||
|
|
||||||
|
오늘 라우터가 부르는 Agent입니다. 만들지 않고 **게시만** 합니다.
|
||||||
|
|
||||||
|
| Agent | 파일 | 웹훅 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| IT 문의 (67일차 Lab 03 완성본) | `agents/it-agent.json` | `/webhook/it-agent` |
|
||||||
|
| 회의록 (66일차 회의록 → 할 일) | `agents/meeting-agent.json` | `/webhook/meeting-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
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 경로를 쓰던 예전 워크플로우가 있으면 `push`가 게시를 해제하고 `! unpublished ...`로 알려 줍니다 (정상).
|
||||||
|
|
||||||
|
## 3. 준비 확인 (전날 필수, 2분)
|
||||||
|
|
||||||
|
```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 container · data API | 1절 |
|
||||||
|
| credential | `cd ~/aio-01d4-stu/Last3Mon/65/n8n && bash register-credentials.sh` |
|
||||||
|
| agent webhook | 2절 `push` |
|
||||||
|
| CLI coding agent | [67일차 준비](../67/00-DAY67-PREP.md) 2절 (설치·로그인) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 미리 읽어 오면 좋은 내용 (선택, 약 5분)
|
||||||
|
|
||||||
|
### 4.1 요청 하나에 업무가 여럿
|
||||||
|
|
||||||
|
> "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요."
|
||||||
|
|
||||||
|
이 문장에 업무가 몇 개인지, 각각 누가(어느 Agent가) 맡으면 좋을지 생각해 옵니다.
|
||||||
|
|
||||||
|
### 4.2 용어
|
||||||
|
|
||||||
|
| 용어 | 한 줄 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| Multi-Agent | 일을 나눠 맡은 Agent 여럿이 함께 하나의 업무를 처리 |
|
||||||
|
| 라우터 | 요청을 보고 담당 Agent에게 **보내 주는** 정해진 흐름 |
|
||||||
|
| Supervisor | LLM 하나가 필요한 Agent를 **골라 부르는** 방식 |
|
||||||
|
| 계약 | Agent의 입력·결과 모양 약속. 계약을 지키면 Agent 속은 바꿔도 됨 |
|
||||||
|
|
||||||
|
## 5. 당일 준비물
|
||||||
|
|
||||||
|
- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL) 창 **두 개**, 웹 브라우저
|
||||||
|
- 67일차 `my-work/it-agent.json` (지우지 마세요, 7교시 `tflow` 연습에 씀)
|
||||||
|
|
||||||
|
## 확인
|
||||||
|
|
||||||
|
- [ ] `curl -s localhost:8800/health` → `{"status": "ok"}`
|
||||||
|
- [ ] `call it-agent`, `call meeting-agent` 응답을 받았다
|
||||||
|
- [ ] `uv run tools/flow.py doctor` → 마지막 줄 `ready`
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 1단계 · 복합 업무 시나리오 분석
|
||||||
|
|
||||||
|
- 교시: 1~2교시 (09:00~10:50)
|
||||||
|
- 실습: [Lab 01](../../Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md)
|
||||||
|
- 이 단계가 끝나면: 요청 하나에 섞인 업무를 **분석표**로 나눌 수 있다. 업무 나누기에서 **LLM이 할 일과 규칙이 할 일**을 구분해 요청문에 적을 수 있다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["1. 요청 하나,<br/>업무 여럿"] --> B["2. 분석 다섯 칸"] --> C["3. 업무 관계"] --> D["4. 나누기는 LLM,<br/>판단은 규칙"] --> E["5. 분석표를<br/>요청문으로"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. 요청 하나에 업무가 여럿
|
||||||
|
|
||||||
|
67일차 IT 문의 Agent는 질문 하나에 답 하나를 했습니다. 실제 직원 요청은 이렇게 옵니다.
|
||||||
|
|
||||||
|
> "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요."
|
||||||
|
|
||||||
|
이 요청을 67일차 IT 문의 Agent에 그대로 보내면 회의록 정리는 할 수 없습니다 (그 Agent에는 회의록 Tool이 없음). Agent 하나에 Tool을 계속 붙이는 대신, **업무를 나눠 맡은 Agent 여럿**이 일하게 하는 것이 오늘의 주제입니다.
|
||||||
|
|
||||||
|
| Agent 하나에 다 붙이기 | 업무별 Agent로 나누기 |
|
||||||
|
| --- | --- |
|
||||||
|
| 지시문이 계속 길어지고 서로 부딪힘 | Agent마다 짧은 지시문, 자기 Tool만 |
|
||||||
|
| 하나를 고치면 전부 다시 테스트 | 고친 Agent만 테스트 (계약이 그대로면) |
|
||||||
|
| 하나가 느리면 전부 느림 | 느린 Agent만 따로 늘리거나 끌 수 있음 (3단계) |
|
||||||
|
|
||||||
|
## 2. 업무 분석 다섯 칸
|
||||||
|
|
||||||
|
요청을 받으면 에이전트에게 맡기기 전에 **사람이 먼저** 표를 채웁니다. 이 표가 곧 요청문과 테스트의 재료입니다.
|
||||||
|
|
||||||
|
| 칸 | 묻는 것 | 위 요청에서 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 업무 | 한 번에 처리할 수 있는 단위는? | ① T-1002 상태 ② 회의록 할 일 ③ 프린터 |
|
||||||
|
| 유형 | 지금 있는 Agent 중 누구 일인가? | it · meeting · it |
|
||||||
|
| 필요한 입력 | 처리하려면 무엇이 있어야 하나? | ① 번호 + **요청한 사람** ② **회의록 원문** ③ 증상 |
|
||||||
|
| 요청에 있나 | 문장 안? 본문의 다른 칸? 없음? | ① `user` ② `notes` ③ 문장 |
|
||||||
|
| 관계 | 다른 업무의 결과가 필요한가? | 셋 다 독립 |
|
||||||
|
|
||||||
|
- **업무 수는 접속사 수가 아닙니다.** "비밀번호를 잊어버렸어요. 그리고 메일함 용량이 꽉 찼대요"는 같은 IT Agent지만 업무 둘입니다.
|
||||||
|
- **필요한 입력은 문장 밖에도 있습니다.** 회의록 원문은 문장에 없고 요청 본문의 `notes` 칸에 붙어 옵니다. 없으면 회의록 Agent를 불러도 소용이 없습니다.
|
||||||
|
- **맡을 Agent가 없는 업무**(회식 장소 추천)는 `other`로 두고 사람에게 넘깁니다. 억지로 다른 Agent에게 보내지 않습니다.
|
||||||
|
|
||||||
|
## 3. 업무 관계: 독립인가, 순서가 있나
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 독립["독립 (오늘)"]
|
||||||
|
a["T-1002 상태"]
|
||||||
|
b["회의록 할 일"]
|
||||||
|
c["프린터"]
|
||||||
|
end
|
||||||
|
subgraph 순서["순서 있음 (69일차)"]
|
||||||
|
d["보고서 초안"] --> e["검토"] --> f["정리"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
| 관계 | 처리 | 모양 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 독립 | 따로 처리해 마지막에 모음 | 라우터 (2단계) |
|
||||||
|
| 순서 | 앞 결과를 뒤에 넘김 | 파이프라인 (69일차) |
|
||||||
|
|
||||||
|
오늘 Lab의 요청은 모두 독립입니다. 내 업무 시나리오(64일차 Lab 03)에서 순서가 있는 업무가 있는지도 표에 적어 둡니다.
|
||||||
|
|
||||||
|
## 4. 나누기는 LLM, 판단은 규칙
|
||||||
|
|
||||||
|
업무 나누기 워크플로우에서 할 일을 둘로 나눕니다.
|
||||||
|
|
||||||
|
| 일 | 누가 | 왜 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 문장을 업무로 나누고 유형 고르기 | LLM (AI Agent, Tool 없음) | 문장의 뜻을 알아야 함 |
|
||||||
|
| LLM 답을 JSON으로 읽고, 이상한 값 고치기 | Code | LLM은 앞뒤에 다른 글자를 붙이거나 없는 유형을 쓰기도 함 |
|
||||||
|
| 유형 → 담당 Agent | Code (표) | 늘 같은 답 |
|
||||||
|
| 필요한 입력이 있나 | Code (본문 빈 칸 확인) | 짐작하면 안 됨. 테스트로 확인할 수 있어야 함 |
|
||||||
|
|
||||||
|
수업 준비 때 정답 워크플로우의 결과 (1회, gpt-4.1-mini):
|
||||||
|
|
||||||
|
| 워크플로우 | `cases/lab01.json` | `cases/lab01-challenge.json` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 나누기만 (예제 2) | 7/7 (0.9~3.2초) | 0/4 (담당·빠진 입력 칸 없음) |
|
||||||
|
| 규칙을 Code에 더함 (과제) | 7/7 | 4/4 |
|
||||||
|
|
||||||
|
> 67일차의 "LLM이 정하면 안 되는 값"(요청한 사람)과 같은 생각입니다. **매번 같아야 하는 것은 규칙으로.**
|
||||||
|
|
||||||
|
## 5. 분석표를 요청문으로
|
||||||
|
|
||||||
|
67일차 요청문 다섯 가지(① 파일 ② 입력 ③ 처리 ④ 결과 모양 ⑤ 기대 결과)는 그대로입니다. 오늘은 ③ 처리에 **분석표의 기준**이 들어갑니다. Lab과 다른 업무(총무 요청)로 예를 듭니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
my-work/office.json 에 n8n 워크플로우를 만들어 줘.
|
||||||
|
① 파일: my-work/office.json
|
||||||
|
② 입력: POST 웹훅 my-office, 본문 message(요청 문장), room(회의실 이름, 없을 수 있음)
|
||||||
|
③ 처리: AI Agent(Tool 없음)가 문장을 업무로 나눈다. 유형은 reserve(회의실 예약), parcel(택배 문의), other.
|
||||||
|
업무 하나 = 한 번에 처리할 수 있는 요청 하나. 날짜·시간은 쓴 그대로 남긴다.
|
||||||
|
Code에서 결과를 읽고, reserve 인데 본문 room 이 비었으면 missing ["room"].
|
||||||
|
④ 결과: {"count": 2, "tasks": [{"type": "reserve", "text": "...", "missing": []}]}
|
||||||
|
⑤ 기대 결과: cases/office.json (케이스 4개)
|
||||||
|
```
|
||||||
|
|
||||||
|
- 유형마다 **정의**(무엇이 여기에 드는지)를 적었습니다. 정의가 없으면 "회의실이 추워요"가 reserve인지 other인지를 LLM이 그때그때 정하게 됩니다.
|
||||||
|
- 규칙 부분은 "Code에서"라고 **어디서 할지**까지 적었습니다.
|
||||||
|
- Lab 01에서는 이 모양을 보고 **IT 문의 · 회의록** 요청문을 직접 씁니다.
|
||||||
|
|
||||||
|
## 정리
|
||||||
|
|
||||||
|
- [ ] 업무 분석 다섯 칸: 업무 · 유형 · 필요한 입력 · 요청에 있나 · 관계
|
||||||
|
- [ ] 필요한 입력은 문장 밖(본문의 다른 칸)에도 있다
|
||||||
|
- [ ] 나누기와 유형은 LLM, 담당과 빠진 입력은 규칙
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
# 2단계 · Multi-Agent 워크플로우 설계
|
||||||
|
|
||||||
|
- 교시: 3~4교시 (11:00~12:50)
|
||||||
|
- 실습: [Lab 02](../../Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md)
|
||||||
|
- 이 단계가 끝나면: Agent의 **계약**을 정리하고, 업무를 유형별로 담당 Agent에게 보내 결과를 모으는 **라우터**를 요청문으로 만들 수 있다. 라우터와 **Supervisor**의 차이를 측정값으로 설명할 수 있다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["1. 계약이 있는<br/>Agent"] --> B["2. 네 가지 모양"] --> C["3. Agent를 부르는 법"] --> D["4. 라우터 구현"] --> E["5. Supervisor와<br/>비교"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. 계약이 있는 Agent
|
||||||
|
|
||||||
|
Agent를 여럿 묶으려면 각 Agent가 **무엇을 받고 무엇을 돌려주는지**가 정해져 있어야 합니다. 이것이 계약입니다.
|
||||||
|
|
||||||
|
| Agent | 웹훅 | 입력 | 결과 | 실패할 때 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| IT 문의 (67일차) | `/webhook/it-agent` | `question`, `user` 필수 | `route`, `answer`, `tools_used`, `sources` | 입력 없음 400 · 근거 없음·남의 접수 건 `route: 담당자 연결` |
|
||||||
|
| 회의록 (66일차) | `/webhook/meeting-agent` | `title`, `notes`(필수) | `route`, `count`, `summary`, `actions[]` | `notes` 없음 400 |
|
||||||
|
|
||||||
|
- 라우터는 Agent의 **속**(지시문, Tool)을 모릅니다. 계약만 압니다. 그래서 IT 문의 Agent의 지시문을 고쳐도 계약이 그대로면 라우터는 그대로입니다.
|
||||||
|
- 계약에서 가장 빠뜨리기 쉬운 칸이 **실패할 때**입니다. 3단계에서 이 칸 때문에 전체가 멈추는 것을 봅니다.
|
||||||
|
- 계약 표는 에이전트에게 **Agent 파일을 읽혀** 만들게 할 수 있습니다 (Lab 02 예제 1). 그 표를 `call` 결과와 대조하는 것은 사람의 일입니다.
|
||||||
|
|
||||||
|
수업 준비 때 직접 부른 결과: IT 문의 4.8초 (`Get_ticket`, "처리 중"), 회의록 2.3초 (할 일 4개), `notes` 없이 400 `{"error": "notes are required"}`.
|
||||||
|
|
||||||
|
## 2. Agent를 묶는 네 가지 모양
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph R["라우터"]
|
||||||
|
r0["나누기"] --> r1["A"] & r2["B"] --> r3["모으기"]
|
||||||
|
end
|
||||||
|
subgraph P["파이프라인"]
|
||||||
|
p1["A"] --> p2["B"] --> p3["C"]
|
||||||
|
end
|
||||||
|
subgraph F["Fork-Join"]
|
||||||
|
f0["같은 입력"] --> f1["A"] & f2["B"] --> f3["합치기"]
|
||||||
|
end
|
||||||
|
subgraph S["Supervisor"]
|
||||||
|
s0{{"LLM"}} -.->|Tool| s1["A"]
|
||||||
|
s0 -.->|Tool| s2["B"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
| 모양 | 누가 다음을 정하나 | 어울리는 업무 | 이 과정에서 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 라우터 | 정해진 규칙 (Switch) | 업무가 독립이고 종류가 정해짐 | 오늘 Lab 02 |
|
||||||
|
| 파이프라인 | 정해진 순서 | 앞 결과가 뒤에 필요 (초안 → 검토) | 69일차 |
|
||||||
|
| Fork-Join | 정해진 흐름 | 같은 입력을 여러 관점으로 보고 합침 | 69일차 |
|
||||||
|
| Supervisor | LLM | 요청이 열려 있고 무엇이 필요한지 미리 모름 | 오늘 Lab 02 과제 |
|
||||||
|
|
||||||
|
## 3. n8n에서 다른 Agent를 부르는 법
|
||||||
|
|
||||||
|
| 방법 | 어떻게 | 오늘 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| HTTP Request로 웹훅 부르기 | 게시된 Agent의 웹훅에 POST | **라우터** (계약을 HTTP로 확인하기 쉬움) |
|
||||||
|
| HTTP Request **Tool**로 붙이기 | Agent가 Tool로 골라 부름 | **Supervisor** |
|
||||||
|
| Execute Workflow | 같은 n8n 안의 워크플로우를 직접 실행 | 70일차 (모듈화) |
|
||||||
|
|
||||||
|
n8n 안에서 같은 n8n의 웹훅을 부를 때 주소는 `http://localhost:5678/webhook/<path>`입니다 (같은 컨테이너 안). 데이터 API(`http://data-api:8800`)와 다릅니다. [AGENTS.md](../../Last3Mon/68/AGENTS.md) 2절에 적어 두었습니다.
|
||||||
|
|
||||||
|
넘기는 값의 **출처**를 정합니다 (67일차 2단계와 같은 질문).
|
||||||
|
|
||||||
|
| 값 | 출처 | 이유 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| IT 질문 문장 | 나누기 결과 `text` | LLM이 나눈 조각 |
|
||||||
|
| 요청한 사람 `user` | 웹훅 본문 그대로 | LLM이 바꾸면 남의 접수 건이 보일 수 있음 |
|
||||||
|
| 회의록 원문 `notes` | 웹훅 본문 그대로 | LLM이 다시 쓰면 내용이 줄거나 바뀔 수 있음 |
|
||||||
|
|
||||||
|
## 4. 라우터 구현
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W(["Assistant webhook"]) --> H{"Has message?"} --> S{{"Split tasks"}} --> P["Parse tasks"] --> O["One item per task"] --> SW{"Route by type"}
|
||||||
|
SW -->|it| CI["Call IT agent"] --> RI["IT result"]
|
||||||
|
SW -->|meeting| CM["Call meeting agent"] --> RM["Meeting result"]
|
||||||
|
SW -->|other| RO["Other result"]
|
||||||
|
RI & RM & RO --> M["Collect results<br/>(Merge)"] --> A["All results<br/>(Aggregate)"] --> B["Build reply"] --> R(["Send result"])
|
||||||
|
```
|
||||||
|
|
||||||
|
| 노드 | 하는 일 | 놓치기 쉬운 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| One item per task (Code) | 업무 하나당 항목 하나, `index` 붙이기 | 다음 노드는 **항목마다** 실행됨 |
|
||||||
|
| Route by type (Switch) | 유형별 출력 | 출력 순서 = 연결의 칸 순서 |
|
||||||
|
| Call … agent (HTTP Request) | Agent 웹훅에 POST | 본문 값의 출처 (3절) |
|
||||||
|
| … result (Set) | Agent마다 다른 결과를 **같은 모양**으로 | 회의록은 `summary`·`actions` → `answer` |
|
||||||
|
| Collect results (Merge) · All results (Aggregate) | 갈래를 모아 `results` 배열 하나로 | 데이터가 안 온 갈래가 있어도 동작 |
|
||||||
|
| Build reply (Code) | `index` 순으로 정렬해 `reply` | 업무 순서 = 요청 순서 |
|
||||||
|
|
||||||
|
수업 준비 때 정답 라우터 (`reference/lab02-example2-router.json`, 1회):
|
||||||
|
|
||||||
|
| 케이스 | 결과 · 시간 |
|
||||||
|
| --- | --- |
|
||||||
|
| `cases/lab02.json` 전체 | 6/6 |
|
||||||
|
| 복합 요청 (업무 3개) | 6.6초 |
|
||||||
|
| IT 하나 · 회의록 하나 | 2.6초 · 3.5초 |
|
||||||
|
| 기타만 (Agent를 부르지 않음) | 0.9초 |
|
||||||
|
|
||||||
|
n8n **Executions**를 열면 `my-assistant` 실행 하나에 `it-agent`, `meeting-agent` 실행이 **따로** 생깁니다. Agent마다 실행 기록이 남으니 어느 Agent에서 문제가 났는지 찾기 쉽습니다.
|
||||||
|
|
||||||
|
## 5. Supervisor와 비교
|
||||||
|
|
||||||
|
같은 요청을 LLM 하나(Supervisor)가 두 Agent를 Tool로 골라 부르게 만들 수 있습니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
W(["Assistant webhook"]) --> SV{{"Supervisor agent"}} --> B["Build reply<br/>agents_used"] --> R(["Send result"])
|
||||||
|
M["OpenAI chat model"] -.-> SV
|
||||||
|
T1["Ask IT agent"] -.-> SV
|
||||||
|
T2["Ask meeting agent"] -.-> SV
|
||||||
|
```
|
||||||
|
|
||||||
|
수업 준비 때 정답 두 개를 같은 요청으로 비교했습니다 (gpt-4.1-mini, 측정값).
|
||||||
|
|
||||||
|
| | 라우터 | Supervisor |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 누가 Agent를 고르나 | 유형 표 (코드) | LLM (Tool 설명·지시문) |
|
||||||
|
| 복합 요청 시간 | 4.5~6.6초 | 9.6~10.8초 |
|
||||||
|
| 결과 | 업무별 `results[]` | 답 글자 하나 + `agents_used` |
|
||||||
|
| 라우터 테스트 `lab02.json`을 그대로 | 6/6 | 1/6 (빈 요청만, 계약이 다름) |
|
||||||
|
| 자기 테스트 `lab02-challenge.json` | — | 처음 4/5 → 지시문 고친 뒤 5/5 |
|
||||||
|
| Agent 하나 더 | Switch 갈래 + 결과 Set | Tool 하나 + 지시문 한 줄 |
|
||||||
|
|
||||||
|
- Supervisor가 처음 실패한 이유: 기타 요청에 "**담당 직원**이 도와드릴 예정입니다"라고 답해 테스트의 `담당자`가 없었습니다. 지시문에 정확한 문장을 주자 통과했습니다. **Supervisor는 지시문이 곧 분배 규칙**입니다.
|
||||||
|
- 결론은 "어느 쪽이 낫다"가 아니라 **계약이 있으면 같은 테스트로 비교할 수 있다**는 것입니다. 업무 종류가 정해져 있고 결과를 다른 시스템이 다시 쓰면 라우터, 요청이 열려 있고 사람에게 바로 답하면 Supervisor.
|
||||||
|
|
||||||
|
## 정리
|
||||||
|
|
||||||
|
- [ ] Agent는 계약(입력 · 결과 · 실패할 때)으로 묶는다
|
||||||
|
- [ ] 라우터: 나누기 → 항목마다 → Switch → HTTP → 같은 모양 → Merge·Aggregate → 답
|
||||||
|
- [ ] LLM이 정하면 안 되는 값(`user`, `notes`)은 웹훅 본문에서 그대로
|
||||||
|
- [ ] 라우터와 Supervisor는 시간·안정성·결과 모양이 다르고, 계약이 있어야 비교할 수 있다
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# 3단계 · 검증과 장애 격리, 확장 고려
|
||||||
|
|
||||||
|
- 교시: 5~6교시 (14:00~15:50)
|
||||||
|
- 실습: [Lab 03](../../Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md)
|
||||||
|
- 이 단계가 끝나면: Multi-Agent 워크플로우에서 **한 곳의 문제가 전체 답을 망치지 않게** 만들 수 있다. 측정값으로 확장 계획(수직·수평)을 설명할 수 있다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["1. Multi-Agent는<br/>어디서 깨지나"] --> B["2. 빠진 입력"] --> C["3. 꺼진 Agent"] --> D["4. 어려운 테스트"] --> E["5. 측정과 확장"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Multi-Agent는 어디서 깨지나
|
||||||
|
|
||||||
|
Agent가 하나일 때보다 깨지는 곳이 늘어납니다.
|
||||||
|
|
||||||
|
| 깨지는 곳 | 예 | 테스트 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 나누기 | 업무 수가 틀림, 유형이 틀림 | `lab01` |
|
||||||
|
| 넘기는 값 | 필요한 입력이 없음 (`notes`, `user`) | `lab03` · 과제 |
|
||||||
|
| 부른 Agent | 꺼짐(404), 오류(400·500), 느림 | `lab03-down` |
|
||||||
|
| 모으기 | 결과 모양이 다름, 순서가 섞임 | `lab02` |
|
||||||
|
| Agent 규칙 | 라우터를 거치면서 67일차 규칙(남의 접수 건)이 깨짐 | 과제 |
|
||||||
|
|
||||||
|
## 2. 빠진 입력: 부르기 전에 막는다
|
||||||
|
|
||||||
|
Lab 02 예제 라우터에 회의록 없이 회의 요청을 보내면:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant 직원
|
||||||
|
participant 라우터 as my-assistant
|
||||||
|
participant 회의록 as meeting-agent
|
||||||
|
직원->>라우터: "회의록 할 일 정리 부탁해요" (notes 없음)
|
||||||
|
라우터->>회의록: POST {notes: ""}
|
||||||
|
회의록-->>라우터: 400 notes are required
|
||||||
|
Note over 라우터: Call meeting agent에서 멈춤
|
||||||
|
라우터-->>직원: 500 (아무 결과 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
수업 준비 때 `cases/lab03.json` 4/5, 이 케이스만 HTTP 500.
|
||||||
|
|
||||||
|
| 막는 방법 | 어떻게 | 직원이 받는 답 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 부르기 전에 (권장) | Lab 01 과제의 `missing`이 있으면 Agent를 부르지 않고 `route: 입력 필요` | "회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요." |
|
||||||
|
| 받은 뒤에 | 400을 결과로 바꿈 (3절) | "담당자에게 전달" (다시 보내면 되는 걸 모름) |
|
||||||
|
|
||||||
|
> 직원이 **고쳐서 다시 보낼 수 있는 실패**는 무엇이 빠졌는지 알려 주고, 고칠 수 없는 실패(Agent 장애)는 사람에게 넘깁니다.
|
||||||
|
|
||||||
|
## 3. 꺼진 Agent: 하나가 꺼져도 나머지는 답한다
|
||||||
|
|
||||||
|
`uv run tools/flow.py off agents/meeting-agent.json`으로 회의록 Agent를 끄고 복합 요청을 보냅니다.
|
||||||
|
|
||||||
|
| | 응답 | T-1002 답 | 회의록 업무 | `lab03-down` |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 고치기 전 | **HTTP 500** | 사라짐 | — | 1/3 |
|
||||||
|
| `neverError` + 빈 결과는 담당자 연결 | 200 | "처리 중" | `담당자 연결` · "담당 Agent가 응답하지 않아 …" | 3/3 |
|
||||||
|
|
||||||
|
(수업 준비 때 측정, 1회)
|
||||||
|
|
||||||
|
장애 격리 세 가지:
|
||||||
|
|
||||||
|
| 장치 | n8n에서 | 하는 일 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 오류를 결과로 | HTTP Request `options.response.response.neverError: true` | 404·500에도 멈추지 않고 응답을 다음 노드로 |
|
||||||
|
| 기다리는 한도 | `options.timeout` (예: 60000 ms) | 느린 Agent 때문에 직원이 끝없이 기다리지 않게 |
|
||||||
|
| 대체 답 | 결과 Set에서 `{{ $json.route \|\| '담당자 연결' }}` | 빈 결과를 정해진 답으로 |
|
||||||
|
|
||||||
|
- 67일차 IT 문의 Agent의 "검증 실패 → 담당자 연결"과 같은 생각을 **Agent 사이**에 한 번 더 둡니다.
|
||||||
|
- Agent를 나눠 둔 덕분에 회의록 Agent만 꺼도 IT 답은 나옵니다. Agent 하나에 다 붙였다면 함께 멈췄습니다.
|
||||||
|
|
||||||
|
## 4. 어려운 테스트: 내가 찾는 구멍
|
||||||
|
|
||||||
|
67일차 Lab 03처럼, 고친 것만 확인하는 테스트로는 부족합니다. 수업 준비 때 쓴 어려운 케이스 7개를 예제 2 정답(빠진 입력 + 장애 격리)에 돌린 결과 5/7이었습니다.
|
||||||
|
|
||||||
|
| 실패한 케이스 | 원인 | 고침 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| user 없이 IT 질문 | `missing: ["user"]`를 보지 않고 IT Agent를 부름 → 400 → 장애로 처리해 `담당자 연결` | IT 갈래도 `missing`을 보고 `입력 필요` |
|
||||||
|
| 공백만 보냄 | `" "`도 "비어 있지 않음" → LLM까지 감 | 앞뒤 공백을 지우고 확인 |
|
||||||
|
|
||||||
|
통과한 케이스도 남겨 둡니다: 남의 접수 건이 섞인 요청(라우터를 거쳐도 "정우성"이 나오지 않음), 업무 4개, 소문자 `t1002`, 영어 질문. 고친 뒤 7/7, `lab02` 6/6, `lab03` 5/5, `lab03-down` 3/3.
|
||||||
|
|
||||||
|
> 수업 준비 때 "남의 접수 건" 케이스의 기대를 처음에 `본인이 접수한`(답 문장)으로 써서 실패했습니다. IT 문의 Agent의 계약은 이때 `route: 담당자 연결`입니다. **테스트가 틀렸는지 워크플로우가 틀렸는지는 계약 표로 판단**합니다.
|
||||||
|
|
||||||
|
## 5. 측정과 확장
|
||||||
|
|
||||||
|
수업 준비 때 정답 워크플로우의 시간 (1회씩, 여러 테스트에서 모음):
|
||||||
|
|
||||||
|
| 요청 | 시간 |
|
||||||
|
| --- | --- |
|
||||||
|
| 기타만 (LLM 나누기 한 번, Agent 없음) | 0.9~1.0초 |
|
||||||
|
| IT 하나 | 2.3~2.6초 |
|
||||||
|
| 회의록 하나 | 3.5초 |
|
||||||
|
| 복합 요청 (업무 3개) | 4.5~6.6초 |
|
||||||
|
| 업무 4개 | 4.6~4.8초 |
|
||||||
|
|
||||||
|
이 숫자에서 읽을 것:
|
||||||
|
|
||||||
|
- 시간 대부분은 **LLM API를 기다리는 시간**입니다 (나누기 한 번 약 1초, Agent마다 LLM 한두 번).
|
||||||
|
- 요청 하나가 n8n 실행 여러 개를 만듭니다: 복합 요청 = 라우터 1 + Agent 3. 21명이 동시에 보내면 n8n 실행은 약 84개.
|
||||||
|
|
||||||
|
| 확장 | 무엇 | 이 측정값에서 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 수직 | 더 큰 서버 (CPU · 메모리) | 기다리는 시간이 대부분이라 효과가 작음 |
|
||||||
|
| 수평 | n8n 큐 모드: 메인 1 + 작업자(worker) 여러 개, Redis 큐 | 동시에 처리하는 실행 수를 늘림 |
|
||||||
|
| Agent별 분리 | 자주 쓰는 Agent만 작업자를 더 주거나 따로 운영 | 업무별 Agent로 나눴기 때문에 가능 |
|
||||||
|
| 바깥 한도 | OpenAI 분당 요청·토큰 한도 | 실행을 늘리면 여기서 막힘 → 75일차 비용·사용량 |
|
||||||
|
|
||||||
|
- 큐 모드는 오늘 실습하지 않습니다. 확장 계획은 `DESIGN.md` 6절에 **내 측정값**으로 씁니다.
|
||||||
|
|
||||||
|
## 정리
|
||||||
|
|
||||||
|
- [ ] 고칠 수 있는 실패(빠진 입력)는 부르기 전에 막고, 무엇이 빠졌는지 알려 준다
|
||||||
|
- [ ] 부른 Agent의 실패는 `neverError` · timeout · 대체 답으로 그 업무에서 멈춘다
|
||||||
|
- [ ] 테스트 기대가 맞는지는 Agent 계약으로 판단한다
|
||||||
|
- [ ] 확장은 측정값으로: 기다리는 시간이 대부분이면 수평 확장과 바깥 한도가 먼저
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# 7·8교시 · 바이브 코딩 (`tflow`)
|
||||||
|
|
||||||
|
- 교시: 7교시 16:00~16:50 (각자 만들기), 8교시 17:00~17:50 (발표 · 정리 · 강사 바이브 코딩 · 팁 · 예고)
|
||||||
|
- 과제: [VIBE-Q-TFLOW](../../Last3Mon/68/VIBE-Q-TFLOW.md)
|
||||||
|
- 오늘은 `tflow`: n8n 워크플로우 JSON을 **트리 · mermaid · 노드별 한국어 설명**으로 보여 주는 TUI입니다. 에이전트가 만든 n8n 파일을 확인하는 도구라 오늘 오전 실습과 바로 이어집니다.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["7교시<br/>각자 만들기 50분"] --> B["발표<br/>10분"] --> C["강사 정리<br/>10분"] --> D["강사 바이브 코딩<br/>15분"] --> E["보충 · 팁<br/>10분"] --> F["69일차 예고<br/>5분"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. 7교시 · 각자 만들기 (50분)
|
||||||
|
|
||||||
|
| 시간 | 단계 | 확인 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 16:00~16:03 | 0. 준비 (`~/vibe/tflow`, `git init`, 에이전트 실행) | |
|
||||||
|
| 16:03~16:15 | 1. 글자로 트리 · mermaid (R1 · R4) | `uv run tflow agents/it-agent.json`, `--mermaid` |
|
||||||
|
| 16:15~16:30 | 2. TUI · 상세 · 경고 (R2 · R3 · R5) | `IT help agent`의 모델·Tool, 망가진 파일 |
|
||||||
|
| 16:30~16:43 | 3. 한국어 설명 · 캐시 (R3) | 두 번째 선택은 바로 |
|
||||||
|
| 16:43~16:50 | 4. 정리 · 내 `assistant.json` 열어 보기 · 발표 메모 | |
|
||||||
|
|
||||||
|
강사는 교실을 돌며 아래를 봅니다.
|
||||||
|
|
||||||
|
| 볼 것 | 막혔을 때 한마디 |
|
||||||
|
| --- | --- |
|
||||||
|
| 한 번에 TUI까지 만들어 달라고 했나 | "1단계는 글자 출력만. 트리가 맞는지부터" |
|
||||||
|
| 트리가 끝없이 길어지거나 같은 노드 반복 | "다시 모이는 노드(`Send result`)는 두 번째부터 한 줄로" |
|
||||||
|
| mermaid가 안 그려짐 | "노드 이름에 `?`·괄호가 있어요. id는 번호로" |
|
||||||
|
| If 갈래가 반대 | "`main` 바깥 목록 0번이 true" |
|
||||||
|
| 설명에 Credential이 들어감 | "credentials는 빼고 보내라고 하세요" |
|
||||||
|
| 실행해 보지 않고 다음 요청 | "에이전트가 됐다고 해도 직접 `uv run tflow`" |
|
||||||
|
|
||||||
|
확인 기준 (입력 파일에서 센 값): `agents/it-agent.json` 노드 14 · main 11 · 모델 1 · Tool 2, `reference/lab03-example2-isolated.json` 노드 18 · main 19 · Switch 출력 4개. 수업 준비 때 tflow를 미리 만들어 보지는 않았습니다. 강사 바이브 코딩(4절)에서 걸린 시간을 재어 정답본(`VIBE-A-TFLOW.md`)에 남깁니다.
|
||||||
|
|
||||||
|
## 2. 8교시 · 발표 (10분)
|
||||||
|
|
||||||
|
- 1~2명. 7교시에 돌면서 **다시 모이는 노드나 mermaid 깨짐을 스스로 푼 사람**을 먼저 고릅니다.
|
||||||
|
- 3분 발표 · 2분 질문: 잘 먹힌 요청문 / 안 먹힌 요청문 / 에이전트가 틀린 곳과 알아챈 방법 / 내 `assistant.json` 시연.
|
||||||
|
|
||||||
|
## 3. 강사 정리 (10분)
|
||||||
|
|
||||||
|
오늘 n8n 실습과 tflow를 이 표로 묶습니다.
|
||||||
|
|
||||||
|
| | n8n 사내 업무 도우미 | tflow |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 나누기 | 요청 → 업무 (LLM) | 파일 → 노드·연결 목록 (코드) |
|
||||||
|
| 계약 | Agent 입력·결과 | `model.py`의 Node·Link (트리·mermaid·TUI가 함께 씀) |
|
||||||
|
| 규칙이 할 일 | 담당 Agent, 빠진 입력 | 갈래 이름, 다시 모이는 노드, 경고 |
|
||||||
|
| LLM이 할 일 | 업무 나누기 | 노드 설명 (캐시) |
|
||||||
|
| 실패해도 계속 | 꺼진 Agent → 담당자 연결 | 키 없음·LLM 실패 → 트리는 그대로 |
|
||||||
|
| 확인 | `cases/*.json` | 노드·연결 수 테스트, 망가진 파일 |
|
||||||
|
|
||||||
|
## 4. 강사 바이브 코딩 (15분)
|
||||||
|
|
||||||
|
학생 화면 앞에서 강사가 직접 이어서 만듭니다. 하나를 고릅니다.
|
||||||
|
|
||||||
|
| 주제 | 요청문 요지 | 보여 줄 것 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `tflow diff a.json b.json` | "두 워크플로우의 노드 추가·삭제, 바뀐 parameters를 표로" | 74일차 `tver`의 예고. 에이전트가 고친 곳을 한눈에 |
|
||||||
|
| `flow.py check` 가져오기 | "Last3Mon/68/tools/flow.py 의 check 규칙을 읽고 tflow 경고에 더해" | 이미 있는 코드를 **읽혀서** 재사용 |
|
||||||
|
| 실행 기록 붙이기 | "runs/*.json 보고서를 읽어 실패한 케이스가 지나간 노드를 표시" | 테스트와 구조를 한 화면에 (77일차 `tlog` 예고) |
|
||||||
|
|
||||||
|
걸린 시간과 결과를 `Last3Mon/68/VIBE-A-TFLOW.md`에 적어 둡니다.
|
||||||
|
|
||||||
|
## 5. 보충 · 팁 (10분)
|
||||||
|
|
||||||
|
| 팁 | 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| 데이터 모양부터 | "파일 → Node·Link"를 먼저 만들고 테스트하면 화면은 나중에 얼마든지 바꿈 |
|
||||||
|
| 테스트 자료는 진짜 파일로 | 오늘 쓴 `agents/*.json`을 `tests/data`에 복사. 숫자(노드 14)로 확인 |
|
||||||
|
| 망가진 입력도 테스트 | 경고 기능은 망가진 파일이 있어야 확인됨 |
|
||||||
|
| 비싼 일은 캐시 | LLM 설명은 내용(해시)으로 캐시, 바뀌면 다시 |
|
||||||
|
| 비밀은 보내지도 않기 | Credential, 키는 LLM 입력에서 빼기 |
|
||||||
|
| 단계마다 `git commit` | 에이전트가 망가뜨려도 돌아갈 곳 |
|
||||||
|
|
||||||
|
## 6. 69일차 예고 (5분)
|
||||||
|
|
||||||
|
- 오늘: 독립 업무를 Agent에게 **나눠** 맡김 (라우터). 내일: 한 업무를 역할별 Agent가 **이어서** 처리 (작성자 → 검토자 → 정리자, 역할 기반 분리와 협업).
|
||||||
|
- 바이브 코딩: `tcrew` (한 질문을 역할별 LLM이 차례로 처리하는 TUI). 오늘 `tflow`는 지우지 마세요. 내일 만든 워크플로우를 여는 데 씁니다.
|
||||||
|
|
||||||
|
## 확인 (강사)
|
||||||
|
|
||||||
|
- [ ] 7교시 시작 전 `OPENAI_API_KEY` 꺼내는 명령을 화면에 띄웠다 (키 값은 띄우지 않음).
|
||||||
|
- [ ] 발표자 1~2명, 강사 정리 표, 강사 바이브 코딩 1개(시간 기록), 팁, 예고.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 68일차 · 복합 업무 시나리오 분석과 Multi-Agent 기반 워크플로우 설계 (바이브 코딩)
|
||||||
|
|
||||||
|
- 일자: 2026-10-20 (68일차)
|
||||||
|
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
|
||||||
|
- 단원: 운영·확장 관점의 AI 워크플로우 설계 고도화
|
||||||
|
- 학습 내용: 복합 업무 시나리오 분석, Multi-Agent 기반 Workflow 설계
|
||||||
|
- 실습 폴더: [Last3Mon/68](../../Last3Mon/68/README.md)
|
||||||
|
- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`) + 67일차 데이터 API + **CLI 코딩 에이전트**(Codex · Claude Code · Antigravity 중 하나)
|
||||||
|
- **실습 준비: [00-DAY68-PREP.md](00-DAY68-PREP.md) (수업 전날까지 1~3절 완료)**
|
||||||
|
|
||||||
|
## 오늘의 시간표
|
||||||
|
|
||||||
|
| 교시 | 시간 | 내용 | 자료 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | 09:00~09:50 | 오늘의 개요 · 1단계 · 복합 업무 시나리오 분석 | [개요](00-DAY68-OVERVIEW.md), [1단계](01-STAGE1-COMPOSITE-SCENARIO.md) |
|
||||||
|
| 2 | 10:00~10:50 | 1단계 · Lab 01 (분석표, 업무 나누기, 과제: 담당·빠진 입력) | [Lab 01](../../Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md) |
|
||||||
|
| 3 | 11:00~11:50 | 2단계 · Multi-Agent 워크플로우 설계, Lab 02 예제 1·2 | [2단계](02-STAGE2-MULTI-AGENT-DESIGN.md), [Lab 02](../../Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md) |
|
||||||
|
| 4 | 12:00~12:50 | 2단계 · Lab 02 과제 35분 (Supervisor로도 만들어 비교) · 풀이 | [Lab 02](../../Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md) |
|
||||||
|
| - | 13:00~14:00 | 점심 | |
|
||||||
|
| 5 | 14:00~14:50 | 3단계 · 검증과 장애 격리, Lab 03 예제 1·2 | [3단계](03-STAGE3-VERIFY-AND-SCALE.md), [Lab 03](../../Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md) |
|
||||||
|
| 6 | 15:00~15:50 | 3단계 · Lab 03 과제 40분 (어려운 테스트 · 확장 계획) · 풀이 | [Lab 03](../../Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md) |
|
||||||
|
| 7 | 16:00~16:50 | 바이브 코딩 · `tflow` 만들기 (각자) | [VIBE-Q-TFLOW](../../Last3Mon/68/VIBE-Q-TFLOW.md), [진행](04-VIBE-CODING.md) |
|
||||||
|
| 8 | 17:00~17:50 | 바이브 코딩 발표 · 정리 · 강사 바이브 코딩 · 팁 · 69일차 예고 | [진행](04-VIBE-CODING.md) |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
P["개요"] --> S1["1단계<br/>복합 시나리오 분석<br/>업무 나누기"] --> S2["2단계<br/>라우터 · Supervisor"] --> S3["3단계<br/>장애 격리 · 확장"] --> V["7·8교시<br/>바이브 코딩 tflow"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 자료 목록
|
||||||
|
|
||||||
|
| 파일 | 내용 |
|
||||||
|
| --- | --- |
|
||||||
|
| `00-DAY68-PREP.md` | 실습 준비: n8n · 데이터 API 켜기, 두 Agent 게시, `flow.py doctor` (수업 전) |
|
||||||
|
| `00-DAY68-OVERVIEW.md` | 개요 13장 (PPT 대신 Markdown, 가로줄로 장 구분) |
|
||||||
|
| `01-STAGE1-COMPOSITE-SCENARIO.md` | 복합 요청, 업무 분석 다섯 칸, 업무 관계, LLM이 할 일과 규칙이 할 일 |
|
||||||
|
| `02-STAGE2-MULTI-AGENT-DESIGN.md` | Agent 계약, 라우터 · 파이프라인 · Fork-Join · Supervisor, 라우터 구현, Supervisor와 비교 |
|
||||||
|
| `03-STAGE3-VERIFY-AND-SCALE.md` | Multi-Agent 테스트, 빠진 입력, 장애 격리(`neverError`, 대체 답), 측정, 수직·수평 확장 |
|
||||||
|
| `04-VIBE-CODING.md` | 7·8교시 바이브 코딩 진행 (tflow), 발표, 강사 정리, 추가 바이브 코딩, 팁 |
|
||||||
Reference in new issue
Block a user