From 587ca5c2b31c3609395d9cb975aa5f88f77c60bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B1=84=EB=AC=B8=EC=B0=BD=40MBA?= Date: Mon, 19 Oct 2026 22:05:32 +0900 Subject: [PATCH] =?UTF-8?q?68=EC=9D=BC=EC=B0=A8=20=EA=B5=90=EC=95=88=C2=B7?= =?UTF-8?q?=EC=8B=A4=EC=8A=B5(=EB=B3=B5=ED=95=A9=20=EC=97=85=EB=AC=B4=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20=C2=B7=20Multi-Agent=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=84=B0)=20=EC=B6=94=EA=B0=80,=20=EA=B2=B0?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EA=B0=95=EC=83=9D=EC=9A=A9=20=EC=B5=9C?= =?UTF-8?q?=EC=86=8C=20=EC=A4=80=EB=B9=84=20=EB=AC=B8=EC=84=9C=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Last3Mon/68/.gitignore | 3 + Last3Mon/68/AGENTS.md | 199 +++++ Last3Mon/68/CLAUDE.md | 1 + Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md | 146 ++++ Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md | 143 ++++ Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md | 133 +++ Last3Mon/68/LECTURE-68.md | 186 +++++ Last3Mon/68/PREPARE-68-FROM-65.md | 173 ++++ Last3Mon/68/README.md | 88 ++ Last3Mon/68/VIBE-Q-TFLOW.md | 145 ++++ Last3Mon/68/agents/it-agent.json | 507 ++++++++++++ Last3Mon/68/agents/meeting-agent.json | 204 +++++ Last3Mon/68/cases/lab01-challenge.json | 57 ++ Last3Mon/68/cases/lab01.json | 81 ++ Last3Mon/68/cases/lab02-challenge.json | 73 ++ Last3Mon/68/cases/lab02.json | 93 +++ Last3Mon/68/cases/lab03-down.json | 45 + Last3Mon/68/cases/lab03.json | 72 ++ Last3Mon/68/my-work/README.md | 5 + .../68/reference/lab01-example2-split.json | 204 +++++ .../68/reference/lab02-example2-router.json | 666 +++++++++++++++ .../68/reference/lab03-example2-isolated.json | 779 ++++++++++++++++++ Last3Mon/68/requests/agent-it.json | 1 + Last3Mon/68/requests/agent-meeting.json | 1 + Last3Mon/68/requests/composite.json | 1 + Last3Mon/68/requests/empty-message.json | 1 + Last3Mon/68/requests/it-and-other.json | 1 + Last3Mon/68/requests/it-only.json | 1 + Last3Mon/68/requests/meeting-no-notes.json | 1 + Last3Mon/68/requests/meeting-only.json | 1 + Last3Mon/68/requests/other-only.json | 1 + Last3Mon/68/requests/two-it.json | 1 + .../templates/MULTI-AGENT-DESIGN-TEMPLATE.md | 53 ++ Last3Mon/68/templates/TEST-REPORT-TEMPLATE.md | 30 + Last3Mon/68/tools/flow.py | 592 +++++++++++++ Lecture/68/00-DAY68-OVERVIEW.md | 208 +++++ Lecture/68/00-DAY68-PREP.md | 108 +++ Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md | 106 +++ Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md | 133 +++ Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md | 115 +++ Lecture/68/04-VIBE-CODING.md | 84 ++ Lecture/68/README.md | 39 + 42 files changed, 5481 insertions(+) create mode 100644 Last3Mon/68/.gitignore create mode 100644 Last3Mon/68/AGENTS.md create mode 100644 Last3Mon/68/CLAUDE.md create mode 100644 Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md create mode 100644 Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md create mode 100644 Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md create mode 100644 Last3Mon/68/LECTURE-68.md create mode 100644 Last3Mon/68/PREPARE-68-FROM-65.md create mode 100644 Last3Mon/68/README.md create mode 100644 Last3Mon/68/VIBE-Q-TFLOW.md create mode 100644 Last3Mon/68/agents/it-agent.json create mode 100644 Last3Mon/68/agents/meeting-agent.json create mode 100644 Last3Mon/68/cases/lab01-challenge.json create mode 100644 Last3Mon/68/cases/lab01.json create mode 100644 Last3Mon/68/cases/lab02-challenge.json create mode 100644 Last3Mon/68/cases/lab02.json create mode 100644 Last3Mon/68/cases/lab03-down.json create mode 100644 Last3Mon/68/cases/lab03.json create mode 100644 Last3Mon/68/my-work/README.md create mode 100644 Last3Mon/68/reference/lab01-example2-split.json create mode 100644 Last3Mon/68/reference/lab02-example2-router.json create mode 100644 Last3Mon/68/reference/lab03-example2-isolated.json create mode 100644 Last3Mon/68/requests/agent-it.json create mode 100644 Last3Mon/68/requests/agent-meeting.json create mode 100644 Last3Mon/68/requests/composite.json create mode 100644 Last3Mon/68/requests/empty-message.json create mode 100644 Last3Mon/68/requests/it-and-other.json create mode 100644 Last3Mon/68/requests/it-only.json create mode 100644 Last3Mon/68/requests/meeting-no-notes.json create mode 100644 Last3Mon/68/requests/meeting-only.json create mode 100644 Last3Mon/68/requests/other-only.json create mode 100644 Last3Mon/68/requests/two-it.json create mode 100644 Last3Mon/68/templates/MULTI-AGENT-DESIGN-TEMPLATE.md create mode 100644 Last3Mon/68/templates/TEST-REPORT-TEMPLATE.md create mode 100644 Last3Mon/68/tools/flow.py create mode 100644 Lecture/68/00-DAY68-OVERVIEW.md create mode 100644 Lecture/68/00-DAY68-PREP.md create mode 100644 Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md create mode 100644 Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md create mode 100644 Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md create mode 100644 Lecture/68/04-VIBE-CODING.md create mode 100644 Lecture/68/README.md diff --git a/Last3Mon/68/.gitignore b/Last3Mon/68/.gitignore new file mode 100644 index 0000000..be55978 --- /dev/null +++ b/Last3Mon/68/.gitignore @@ -0,0 +1,3 @@ +runs/ +tools/.cache/ +my-work/*.json diff --git a/Last3Mon/68/AGENTS.md b/Last3Mon/68/AGENTS.md new file mode 100644 index 0000000..0d1049c --- /dev/null +++ b/Last3Mon/68/AGENTS.md @@ -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/`, **n8n 안에서도** `http://localhost:5678/webhook/` (같은 컨테이너) | +| 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 키를 읽어서 출력하지 않는다. diff --git a/Last3Mon/68/CLAUDE.md b/Last3Mon/68/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/Last3Mon/68/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md b/Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.md new file mode 100644 index 0000000..26a046b --- /dev/null +++ b/Last3Mon/68/LAB-01-Q-SCENARIO-AND-SPLIT.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
업무 분석표
(사람)"] --> E2["예제 2
업무 나누기
워크플로우"] --> T["과제
담당 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의 내 업무 시나리오에서 하나 | + +
▶ 1단계 · 무엇을 기준으로 나누나 + +"한 사람이 한 번에 처리할 수 있는 일"이 업무 하나입니다. 접속사(그리고, 또, ~고)에서 끊기지만, 끊긴 조각이 같은 일을 말하면 하나로 둡니다. 유형은 지금 있는 Agent(IT 문의, 회의록) 기준으로 나누고, 맡을 Agent가 없으면 `other`입니다. +
+ +
▶ 2단계 · 빠뜨리기 쉬운 칸 + +- **필요한 입력**: 업무를 처리하려면 무엇이 있어야 하나요? 접수 현황은 "누가 물었는지", 회의록 정리는 "회의록 원문"이 필요합니다. 요청 문장에 없고 **요청 본문의 다른 칸**에 있을 수도 있습니다 (`requests/*.json`을 열어 봅니다). +- **업무 관계**: 한 업무의 결과가 다른 업무에 필요한가요? 필요 없다면 "독립"이고, 순서와 상관없이 따로 처리할 수 있습니다. +
+ +**확인**: 옆 사람과 표를 바꿔 봅니다. 업무 개수가 같은가요? 다르면 어느 쪽 기준이 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` (먼저 열어 읽기) | + +
▶ 1단계 · 예제 1의 표를 요청문으로 + +예제 1에서 정한 **나누는 기준과 유형 정의**를 그대로 요청문에 옮깁니다. 에이전트는 이 기준을 LLM 지시문(systemMessage)에 넣게 됩니다. 유형마다 "어떤 요청이 여기에 드는지" 예를 한두 개씩 적어 보세요. +
+ +
▶ 2단계 · LLM의 답을 믿지 않기 + +LLM에게 JSON으로 달라고 해도 앞뒤에 다른 글자가 붙거나, 정해지지 않은 유형을 쓰기도 합니다. 결과를 읽는 쪽에서 **무엇을 확인하고, 읽지 못하면 어떻게 할지** 요청문에 적었나요? 업무의 순서(요청에 나온 순서)도 지켜야 테스트의 `tasks.0`, `tasks.1`이 맞습니다. +
+ +
▶ 3단계 · 보내기 전 점검표 + +- [ ] 파일 경로, 웹훅 경로, 입력 필드 +- [ ] 유형 셋과 각각의 정의 +- [ ] 결과 JSON 모양 (필드 이름 `count`, `tasks`, `type`, `text`) +- [ ] 빈 요청이면 어떻게 할지 (테스트 파일의 마지막 케이스) +- [ ] 테스트 파일 경로 +
+ +
▶ 4단계 · 테스트가 실패하면 + +실패한 줄과 보고서 경로를 그대로 붙입니다. 업무 개수가 틀렸다면, LLM이 받은 문장과 돌려준 글자를 n8n **Executions**에서 직접 보고 "어느 기준이 모호했는지"를 먼저 묻습니다. 지시문에 예를 더하는 것과 기준을 바꾸는 것은 다른 고침입니다. +
+ +```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 입력 칸이 힌트입니다. + +
▶ 1단계 · "규칙으로"의 뜻 + +어느 Agent가 맡는지는 유형이 정해지면 **표 하나로** 정해집니다. 빠진 입력도 요청 본문에 그 칸이 비었는지만 보면 됩니다. 둘 다 LLM에게 물어볼 일이 아닙니다. 요청문에서 "LLM 지시문을 고치지 말고 결과를 읽는 코드에서" 정하라고 할 수 있습니다. +
+ +
▶ 2단계 · 원래 테스트도 + +고치는 요청입니다. 고칠 파일, 새 필드 이름과 예시 값, 그리고 `cases/lab01.json`도 계속 통과해야 한다는 조건을 적었나요? +
+ +
▶ 3단계 · 하나 더 생각하기 + +IT 문의 Agent는 `user`가 없으면 400을 돌려줍니다. 테스트에는 없지만, 이것도 "빠진 입력"으로 표시할지 정해 보세요. Lab 03 과제에서 다시 만납니다. +
+ +```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`에 요청문을 모았다. diff --git a/Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md b/Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.md new file mode 100644 index 0000000..636e4a7 --- /dev/null +++ b/Last3Mon/68/LAB-02-Q-ROUTER-AGENTS.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
Agent 계약 정리"] --> E2["예제 2
라우터"] --> T["과제
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: 담당자 연결`? | + +
▶ 1단계 · 에이전트에게 무엇을 읽게 하나 + +`agents/` 폴더의 두 파일이 근거입니다. 웹훅 노드, 입력을 검사하는 If 노드, 마지막 Respond 노드를 보면 계약이 나옵니다. "워크플로우를 만들지 말고 문서만"이라고 분명히 적습니다. +
+ +
▶ 2단계 · 에이전트의 표를 믿기 전에 + +위 `call` 세 번의 결과와 에이전트가 쓴 표를 비교합니다. 필드 이름 하나라도 다르면 라우터가 그 값을 읽지 못합니다. [AGENTS.md](AGENTS.md) 3절과도 맞춰 봅니다. +
+ +## 예제 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` | + +
▶ 1단계 · 흐름부터 정하기 + +`DESIGN.md` 3절에 mermaid로 흐름을 먼저 그려 봅니다: 나누기 → 업무 하나씩 → 유형별 갈래 → Agent 부르기 → 결과 모양 맞추기 → 모으기 → 답. 이 흐름을 요청문에 그대로 순서대로 적으면 에이전트가 덜 헤맵니다. [2단계 교안](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md) 4절의 노드 이름을 참고합니다. +
+ +
▶ 2단계 · Agent에게 무엇을 넘기나 + +IT 문의 Agent에는 **업무 텍스트**와 **요청한 사람**, 회의록 Agent에는 **회의록 제목과 원문**을 넘깁니다. 이 중 LLM이 만든 값은 무엇이고, 웹훅 본문에서 그대로 가져와야 하는 값은 무엇인가요? 예제 1의 계약 표를 요청문에 붙이거나 "AGENTS.md 3절의 계약대로"라고 적습니다. +
+ +
▶ 3단계 · 결과를 한 모양으로 + +두 Agent의 결과 모양이 다릅니다 (IT는 `answer`, 회의록은 `summary`·`actions`). 모으기 전에 **같은 모양**(`index, type, text, agent, route, answer`)으로 맞춰야 `reply`를 만들 수 있습니다. 회의록 결과의 `answer`에 무엇을 넣을지, `actions`도 남길지 정합니다. 결과 순서는 요청에 나온 순서(`index`). +
+ +
▶ 4단계 · 보내기 전 점검표 + +- [ ] 바탕 파일과 새 파일 경로, 새 웹훅 경로 +- [ ] 유형별 분배와 각 Agent 주소 (n8n 안에서 부르는 주소) +- [ ] 넘길 값과 그 값의 출처 +- [ ] 결과 한 항목의 모양, 전체 결과 모양 +- [ ] `cases/lab02.json` +
+ +```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에서는 누가 하나요? + +
▶ 1단계 · Agent를 Tool로 + +67일차에 FAQ API를 HTTP Request **Tool**로 붙였던 것처럼, 이번에는 다른 Agent의 웹훅을 Tool로 붙입니다. Tool 설명(`toolDescription`)이 "언제 이 Agent를 부를지"의 근거가 됩니다. +
+ +
▶ 2단계 · LLM에게 맡기면 안 되는 값 + +Tool의 입력 중 **LLM이 채워도 되는 값**(질문 문장)과 **채우면 안 되는 값**(요청한 사람, 회의록 원문)을 나눕니다. 회의록 원문을 LLM이 다시 써서 넘기면 무엇이 달라질 수 있을까요? +
+ +
▶ 3단계 · agents_used는 누가 쓰나 + +`agents_used`를 LLM의 답에서 찾게 할 수도, Agent의 Tool 호출 기록에서 찾게 할 수도 있습니다. 67일차 Lab 01 과제의 `sources`와 같은 질문입니다. +
+ +
▶ 4단계 · 비교하기 + +```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를 하나 더 붙일 때 고칠 곳. 두 번째 명령이 왜 실패하는지도 적습니다. +
+ +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| 응답이 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 비교 표. diff --git a/Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md b/Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md new file mode 100644 index 0000000..52b116d --- /dev/null +++ b/Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md @@ -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
빠진 입력"] --> E2["예제 2
Agent가 꺼졌을 때"] --> T["과제
어려운 테스트 · 확장 계획"] +``` + +> 복사해 붙일 요청문은 없습니다. 이번 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 | + +
▶ 1단계 · 보고서 읽기 + +실패한 케이스의 `status`와 `response`를 `runs/lab03-....json`에서 봅니다. 응답이 500이면 라우터 **전체**가 멈춘 것입니다. n8n **Executions**에서 어느 노드가 빨간지, 그 노드가 받은 응답이 무엇인지 확인합니다. 회의록 Agent의 계약(예제 1의 표)에서 `notes`가 없을 때 무엇을 돌려준다고 했나요? +
+ +
▶ 2단계 · 어디서 막을까 + +두 가지 방법이 있습니다: (가) Agent를 부르고 오류가 오면 처리, (나) **부르기 전에** 빠진 입력을 보고 Agent를 부르지 않음. Lab 01 과제에서 만든 `missing`이 있다면 어느 쪽이 쉬운가요? 직원에게 무엇이라고 답해야 다음에 제대로 보낼 수 있을까요? +
+ +
▶ 3단계 · 요청문 점검 + +- [ ] 실패한 케이스 이름, 보고서 경로를 그대로 붙였다 +- [ ] 원인 설명을 먼저 요구했다 +- [ ] 새 결과 `route` 값과 답 문장을 정했다 +- [ ] `cases/lab02.json`도 계속 통과해야 한다고 적었다 +
+ +## 예제 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 답까지 사라졌다면 왜일까요? + +
▶ 1단계 · 오류를 "결과"로 바꾸기 + +Agent를 부르는 노드가 404·500을 받으면 n8n은 기본으로 워크플로우를 멈춥니다. 멈추지 않고 그 응답을 다음 노드로 넘기는 설정이 있습니다 ([AGENTS.md](AGENTS.md) 5절 HTTP Request). 넘겨받은 뒤 결과 칸(`route`, `answer`)이 비어 있으면 무엇으로 채울지 정합니다. +
+ +
▶ 2단계 · 얼마나 기다릴까 + +꺼진 Agent는 바로 404가 오지만, **느린** Agent는 응답이 안 올 수도 있습니다. 직원이 얼마나 기다려도 될지 정하고 요청문에 적습니다. +
+ +
▶ 3단계 · 다시 켜고 확인 + +고친 뒤 `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 +``` +
+ +## 과제 · 어려운 테스트 쓰고 고치기, 확장 계획 (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 +``` + +
▶ 1단계 · 어디서 어려운 요청이 나오나 + +다섯 곳에서 찾아봅니다: ① 입력 칸이 비거나 이상함 ② 업무 수가 많거나 섞임 ③ 67일차에 지킨 규칙(남의 접수 건)이 라우터를 거쳐도 지켜지나 ④ 붙어 온 자료와 요청이 안 맞음 ⑤ 다른 언어·다른 표기. 각 케이스의 기대 결과를 **업무 규칙 한 줄**로 먼저 적고 테스트로 옮깁니다. +
+ +
▶ 2단계 · 좋은 케이스의 조건 + +- 기대 결과가 **하나로** 정해진다 (사람마다 다르게 판단하면 테스트가 아님) +- 통과해도 의미가 있다: 지금 통과하는 케이스도 "고칠 때 깨지지 않게" 남겨 둔다 +- `not_contains`로 **나오면 안 되는 것**(남의 이름, 오류)을 확인한다 +
+ +
▶ 3단계 · 확장 계획 생각하기 + +보고서에서 IT 하나, 회의록 하나, 복합 요청의 시간을 찾습니다. 복합 요청 시간은 각 업무 시간의 합에 가까운가요, 가장 긴 것에 가까운가요? 21명이 동시에 보내면 n8n 하나가 몇 개를 동시에 처리해야 하나요? [3단계 교안](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md) 5절의 수직·수평 확장 표를 보고 내 측정값에 맞는 첫 조치를 고릅니다. +
+ +## 막혔을 때 + +| 증상 | 할 일 | +| --- | --- | +| `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`로 채웠다. diff --git a/Last3Mon/68/LECTURE-68.md b/Last3Mon/68/LECTURE-68.md new file mode 100644 index 0000000..f28267e --- /dev/null +++ b/Last3Mon/68/LECTURE-68.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["전날
준비 · Agent 게시"] --> M["아침
켜기 · doctor"] --> L1["1~2교시
Lab 01
업무 나누기"] --> L2["3~4교시
Lab 02
라우터 · Supervisor"] --> L3["5~6교시
Lab 03
빠진 입력 · 꺼진 Agent"] --> V["7~8교시
tflow"] --> E["마친 뒤
남기기 · 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["① 기대 결과 읽기
cases/*.json"] --> B["② 요청문을
메모장에 먼저"] --> C["③ 에이전트에게"] --> D["④ 에이전트가
my-work · check"] --> E["⑤ 내가
push · test"] + E -->|✘| F["⑥ 실패 줄 · 보고서 경로
그대로 붙여 주기"] --> 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일차 준비 diff --git a/Last3Mon/68/PREPARE-68-FROM-65.md b/Last3Mon/68/PREPARE-68-FROM-65.md new file mode 100644 index 0000000..76f6bf2 --- /dev/null +++ b/Last3Mon/68/PREPARE-68-FROM-65.md @@ -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
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`로 확인할 수 있습니다. diff --git a/Last3Mon/68/README.md b/Last3Mon/68/README.md new file mode 100644 index 0000000..d53f518 --- /dev/null +++ b/Last3Mon/68/README.md @@ -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
message · user · notes"]) --> S{{"Split tasks
(LLM)"}} --> P["Parse tasks
담당 · 빠진 입력"] --> SW{"Route by type"} + SW -->|it| A1["Call IT agent"] --> IT{{"IT 문의 Agent
agents/it-agent"}} + SW -->|meeting| A2["Call meeting agent"] --> MT{{"회의록 Agent
agents/meeting-agent"}} + SW -->|other| O["담당자 연결"] + SW -->|needs input| N["입력 필요"] + A1 & A2 & O & N --> M["Collect results"] --> B["Build reply"] --> R(["통합 응답
count · results · reply"]) +``` + +## 일하는 방식 (67일차와 같음) + +```mermaid +flowchart LR + Q["요청문
(내가 씀)"] --> G["CLI 에이전트
my-work/*.json · check"] + G --> U["push
(내가 실행)"] --> T["test
(내가 실행)"] + 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초 | diff --git a/Last3Mon/68/VIBE-Q-TFLOW.md b/Last3Mon/68/VIBE-Q-TFLOW.md new file mode 100644 index 0000000..137acb5 --- /dev/null +++ b/Last3Mon/68/VIBE-Q-TFLOW.md @@ -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. 준비
3분"] --> S1["1. 글자로 트리 · mermaid
R1·R4 · 12분"] --> S2["2. TUI · 상세 · 경고
R2·R3·R5 · 15분"] --> S3["3. 한국어 설명 · 캐시
R3 · 13분"] --> S4["4. 정리
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)은 아직 없다. + +
▶ 1단계 · 읽기와 보여 주기를 나누기 + +파일을 읽어 "노드 목록과 연결 목록"으로 바꾸는 부분은 글자 출력, mermaid, TUI가 모두 씁니다. 이 부분을 **따로** 만들고 테스트하게 하려면 무엇을 적어야 할까요? 테스트에 쓸 파일(위 표)과 기대하는 노드 · 연결 수도 적을 수 있습니다. +
+ +
▶ 2단계 · 트리가 아닌 곳 + +워크플로우는 나무가 아니라 **갈래가 다시 모이는** 그림입니다 (it-agent의 `Send result`, Switch 뒤의 `Collect results`). 그대로 따라가면 같은 노드가 여러 번 나오고, 고리(loop)가 있으면 끝나지 않습니다. 이미 나온 노드를 두 번째 만나면 어떻게 보여 줄지 정해 줍니다. +
+ +
▶ 3단계 · mermaid에서 깨지는 것 + +노드 이름에는 빈칸, `?`, 한글, 괄호가 들어갑니다. mermaid 노드 id로 그대로 쓰면 그림이 깨집니다. id와 보이는 이름을 나누라고 요청합니다. 갈래 이름(true/false)은 화살표 글자로. +
+ +**확인**: 세 파일로 `uv run tflow`, `uv run tflow ... --mermaid` → 마크다운 미리보기에 붙여 그림이 나오는지, `uv run pytest -q`, `git commit`. + +### 2. TUI · 상세 · 경고 (15분) + +**목표**: 왼쪽 트리, 오른쪽 상세. 노드를 고르면 종류와 중요한 설정을 보여 준다. 경고가 있으면 위에 보여 준다 (R2 · R3 일부 · R5). + +
▶ 1단계 · "중요한 설정"은 무엇인가 + +노드 종류마다 사람이 확인하고 싶은 칸이 다릅니다. 웹훅은 방식과 경로, HTTP는 주소, Agent는 지시문(systemMessage)과 붙은 모델·Tool, Code는 코드 첫 몇 줄, If는 조건. 종류별로 보여 줄 칸을 표로 적어 요청문에 넣어 봅니다. +
+ +
▶ 2단계 · 경고 만들기 + +경고를 확인하려면 **망가진 파일**이 필요합니다. 연결에서 노드 이름 하나를 틀리게 고친 파일, 아무 데도 연결 안 된 노드를 더한 파일을 테스트용으로 만들게 합니다. 67일차 `flow.py check`가 하던 검사 중 무엇을 가져올지 골라 봅니다. +
+ +**확인**: `uv run tflow agents/it-agent.json`에서 `IT help agent`를 골라 모델과 Tool이 보이는지, 망가진 파일에서 경고, `git commit`. + +### 3. 한국어 설명과 캐시 (13분) + +**목표**: 상세에 "하는 일: …" 한두 문장이 한국어로 나온다. 같은 노드는 다시 묻지 않는다 (R3). + +
▶ 1단계 · LLM에게 무엇을 줄까 + +노드 하나만 주면 "Code 노드입니다" 같은 뻔한 설명이 나옵니다. 앞뒤 노드 이름, 설정(지시문, 코드, 주소)을 함께 주면 "이 워크플로우에서" 하는 일을 씁니다. 반대로 **주면 안 되는 것**은 없나요 (Credential 정보)? +
+ +
▶ 2단계 · 캐시의 열쇠 + +노드 이름만으로 캐시하면, 내용이 바뀐 노드도 옛 설명이 나옵니다. 무엇이 같으면 "같은 노드"로 볼지 정합니다. 키가 없거나 LLM이 실패해도 트리는 그대로 보여야 합니다. +
+ +
▶ 3단계 · 테스트에서 진짜 LLM을 부르지 않게 + +어제 `tman`의 번역기처럼, 설명기를 바꿔 끼울 수 있게 하고 테스트에서는 가짜를 쓰게 합니다. +
+ +```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`. diff --git a/Last3Mon/68/agents/it-agent.json b/Last3Mon/68/agents/it-agent.json new file mode 100644 index 0000000..d3abcd0 --- /dev/null +++ b/Last3Mon/68/agents/it-agent.json @@ -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": {} +} diff --git a/Last3Mon/68/agents/meeting-agent.json b/Last3Mon/68/agents/meeting-agent.json new file mode 100644 index 0000000..0f9624c --- /dev/null +++ b/Last3Mon/68/agents/meeting-agent.json @@ -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" + } +} diff --git a/Last3Mon/68/cases/lab01-challenge.json b/Last3Mon/68/cases/lab01-challenge.json new file mode 100644 index 0000000..16071b9 --- /dev/null +++ b/Last3Mon/68/cases/lab01-challenge.json @@ -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": [] + } + } + } + ] +} diff --git a/Last3Mon/68/cases/lab01.json b/Last3Mon/68/cases/lab01.json new file mode 100644 index 0000000..c8bb7d1 --- /dev/null +++ b/Last3Mon/68/cases/lab01.json @@ -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 + } + } + ] +} diff --git a/Last3Mon/68/cases/lab02-challenge.json b/Last3Mon/68/cases/lab02-challenge.json new file mode 100644 index 0000000..1f26a1f --- /dev/null +++ b/Last3Mon/68/cases/lab02-challenge.json @@ -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 + } + } + ] +} diff --git a/Last3Mon/68/cases/lab02.json b/Last3Mon/68/cases/lab02.json new file mode 100644 index 0000000..13629c9 --- /dev/null +++ b/Last3Mon/68/cases/lab02.json @@ -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 + } + } + ] +} diff --git a/Last3Mon/68/cases/lab03-down.json b/Last3Mon/68/cases/lab03-down.json new file mode 100644 index 0000000..179ed80 --- /dev/null +++ b/Last3Mon/68/cases/lab03-down.json @@ -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": "자동 답변" + } + } + } + ] +} diff --git a/Last3Mon/68/cases/lab03.json b/Last3Mon/68/cases/lab03.json new file mode 100644 index 0000000..52f645f --- /dev/null +++ b/Last3Mon/68/cases/lab03.json @@ -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 + } + } + ] +} diff --git a/Last3Mon/68/my-work/README.md b/Last3Mon/68/my-work/README.md new file mode 100644 index 0000000..ccb3573 --- /dev/null +++ b/Last3Mon/68/my-work/README.md @@ -0,0 +1,5 @@ +# 수업 결과물 + +- CLI 코딩 에이전트가 만든 워크플로우 JSON (`split.json`, `assistant.json`, `supervisor.json`)이 여기에 저장됩니다 (git에 올라가지 않음). +- `DESIGN.md`: [설계서 템플릿](../templates/MULTI-AGENT-DESIGN-TEMPLATE.md)을 복사해 채웁니다. +- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다. diff --git a/Last3Mon/68/reference/lab01-example2-split.json b/Last3Mon/68/reference/lab01-example2-split.json new file mode 100644 index 0000000..d3dd9c4 --- /dev/null +++ b/Last3Mon/68/reference/lab01-example2-split.json @@ -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" + } +} diff --git a/Last3Mon/68/reference/lab02-example2-router.json b/Last3Mon/68/reference/lab02-example2-router.json new file mode 100644 index 0000000..2e00425 --- /dev/null +++ b/Last3Mon/68/reference/lab02-example2-router.json @@ -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" + } +} diff --git a/Last3Mon/68/reference/lab03-example2-isolated.json b/Last3Mon/68/reference/lab03-example2-isolated.json new file mode 100644 index 0000000..05ce9fa --- /dev/null +++ b/Last3Mon/68/reference/lab03-example2-isolated.json @@ -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" + } +} diff --git a/Last3Mon/68/requests/agent-it.json b/Last3Mon/68/requests/agent-it.json new file mode 100644 index 0000000..b32e3ee --- /dev/null +++ b/Last3Mon/68/requests/agent-it.json @@ -0,0 +1 @@ +{"question": "T-1002 처리 상태 알려 주세요", "user": "박서연"} diff --git a/Last3Mon/68/requests/agent-meeting.json b/Last3Mon/68/requests/agent-meeting.json new file mode 100644 index 0000000..6ea14bd --- /dev/null +++ b/Last3Mon/68/requests/agent-meeting.json @@ -0,0 +1 @@ +{"title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."} diff --git a/Last3Mon/68/requests/composite.json b/Last3Mon/68/requests/composite.json new file mode 100644 index 0000000..27e7592 --- /dev/null +++ b/Last3Mon/68/requests/composite.json @@ -0,0 +1 @@ +{"message": "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요.", "user": "박서연", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."} diff --git a/Last3Mon/68/requests/empty-message.json b/Last3Mon/68/requests/empty-message.json new file mode 100644 index 0000000..ef71d2a --- /dev/null +++ b/Last3Mon/68/requests/empty-message.json @@ -0,0 +1 @@ +{"message": "", "user": "이준호"} diff --git a/Last3Mon/68/requests/it-and-other.json b/Last3Mon/68/requests/it-and-other.json new file mode 100644 index 0000000..ae786a7 --- /dev/null +++ b/Last3Mon/68/requests/it-and-other.json @@ -0,0 +1 @@ +{"message": "프린터 출력이 안 돼요. 그리고 다음 주 회식 장소도 추천해 주세요.", "user": "최유진"} diff --git a/Last3Mon/68/requests/it-only.json b/Last3Mon/68/requests/it-only.json new file mode 100644 index 0000000..f64a7f3 --- /dev/null +++ b/Last3Mon/68/requests/it-only.json @@ -0,0 +1 @@ +{"message": "VPN이 계속 끊겨요. 어떻게 해야 하나요?", "user": "이준호"} diff --git a/Last3Mon/68/requests/meeting-no-notes.json b/Last3Mon/68/requests/meeting-no-notes.json new file mode 100644 index 0000000..976c150 --- /dev/null +++ b/Last3Mon/68/requests/meeting-no-notes.json @@ -0,0 +1 @@ +{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지"} diff --git a/Last3Mon/68/requests/meeting-only.json b/Last3Mon/68/requests/meeting-only.json new file mode 100644 index 0000000..eb128b4 --- /dev/null +++ b/Last3Mon/68/requests/meeting-only.json @@ -0,0 +1 @@ +{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."} diff --git a/Last3Mon/68/requests/other-only.json b/Last3Mon/68/requests/other-only.json new file mode 100644 index 0000000..526081e --- /dev/null +++ b/Last3Mon/68/requests/other-only.json @@ -0,0 +1 @@ +{"message": "다음 주 회식 장소 추천해 주세요", "user": "최유진"} diff --git a/Last3Mon/68/requests/two-it.json b/Last3Mon/68/requests/two-it.json new file mode 100644 index 0000000..e2b1361 --- /dev/null +++ b/Last3Mon/68/requests/two-it.json @@ -0,0 +1 @@ +{"message": "비밀번호를 잊어버렸어요. 그리고 메일함 용량이 꽉 찼다고 나와요.", "user": "이준호"} diff --git a/Last3Mon/68/templates/MULTI-AGENT-DESIGN-TEMPLATE.md b/Last3Mon/68/templates/MULTI-AGENT-DESIGN-TEMPLATE.md new file mode 100644 index 0000000..3ecb2c6 --- /dev/null +++ b/Last3Mon/68/templates/MULTI-AGENT-DESIGN-TEMPLATE.md @@ -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별 분리) 중 무엇을 먼저 하나? 이유는? diff --git a/Last3Mon/68/templates/TEST-REPORT-TEMPLATE.md b/Last3Mon/68/templates/TEST-REPORT-TEMPLATE.md new file mode 100644 index 0000000..1a09275 --- /dev/null +++ b/Last3Mon/68/templates/TEST-REPORT-TEMPLATE.md @@ -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)로 고친 것 중 어느 쪽이 더 안정적이었나? +- diff --git a/Last3Mon/68/tools/flow.py b/Last3Mon/68/tools/flow.py new file mode 100644 index 0000000..fb7ddb0 --- /dev/null +++ b/Last3Mon/68/tools/flow.py @@ -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 ).") + 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()) diff --git a/Lecture/68/00-DAY68-OVERVIEW.md b/Lecture/68/00-DAY68-OVERVIEW.md new file mode 100644 index 0000000..dcea522 --- /dev/null +++ b/Lecture/68/00-DAY68-OVERVIEW.md @@ -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["복합
요청"] --> S["나누기"] + S --> I["IT
Agent"] & M["회의록
Agent"] + I & M --> O["통합
응답"] + 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에게 분배 · 결과 통합 · 장애 격리 + +운영·확장 관점의 AI 워크플로우 설계 고도화 · 2026-10-20 + +--- + +## 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 · 회의록
→ 할 일"] --> D67["67 · IT 문의
Agent"] --> D68["68 · 둘을 묶는
라우터"] --> D69["69 · 역할 분리
협업"] --> D70["70 · 템플릿
재사용"] + 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(["요청
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` diff --git a/Lecture/68/00-DAY68-PREP.md b/Lecture/68/00-DAY68-PREP.md new file mode 100644 index 0000000..600e35c --- /dev/null +++ b/Lecture/68/00-DAY68-PREP.md @@ -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
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` diff --git a/Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md b/Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md new file mode 100644 index 0000000..6c3c018 --- /dev/null +++ b/Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md @@ -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. 요청 하나,
업무 여럿"] --> B["2. 분석 다섯 칸"] --> C["3. 업무 관계"] --> D["4. 나누기는 LLM,
판단은 규칙"] --> E["5. 분석표를
요청문으로"] +``` + +## 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, 담당과 빠진 입력은 규칙 diff --git a/Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md b/Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md new file mode 100644 index 0000000..337b205 --- /dev/null +++ b/Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md @@ -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. 계약이 있는
Agent"] --> B["2. 네 가지 모양"] --> C["3. Agent를 부르는 법"] --> D["4. 라우터 구현"] --> E["5. Supervisor와
비교"] +``` + +## 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/`입니다 (같은 컨테이너 안). 데이터 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
(Merge)"] --> A["All results
(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
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는 시간·안정성·결과 모양이 다르고, 계약이 있어야 비교할 수 있다 diff --git a/Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md b/Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md new file mode 100644 index 0000000..5d87483 --- /dev/null +++ b/Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md @@ -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는
어디서 깨지나"] --> 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 계약으로 판단한다 +- [ ] 확장은 측정값으로: 기다리는 시간이 대부분이면 수평 확장과 바깥 한도가 먼저 diff --git a/Lecture/68/04-VIBE-CODING.md b/Lecture/68/04-VIBE-CODING.md new file mode 100644 index 0000000..066dbb3 --- /dev/null +++ b/Lecture/68/04-VIBE-CODING.md @@ -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교시
각자 만들기 50분"] --> B["발표
10분"] --> C["강사 정리
10분"] --> D["강사 바이브 코딩
15분"] --> E["보충 · 팁
10분"] --> F["69일차 예고
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개(시간 기록), 팁, 예고. diff --git a/Lecture/68/README.md b/Lecture/68/README.md new file mode 100644 index 0000000..dda16be --- /dev/null +++ b/Lecture/68/README.md @@ -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단계
복합 시나리오 분석
업무 나누기"] --> S2["2단계
라우터 · Supervisor"] --> S3["3단계
장애 격리 · 확장"] --> V["7·8교시
바이브 코딩 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), 발표, 강사 정리, 추가 바이브 코딩, 팁 |