diff --git a/Last3Mon/68/LAB-01-A-SCENARIO-AND-SPLIT.md b/Last3Mon/68/LAB-01-A-SCENARIO-AND-SPLIT.md new file mode 100644 index 0000000..8bec46a --- /dev/null +++ b/Last3Mon/68/LAB-01-A-SCENARIO-AND-SPLIT.md @@ -0,0 +1,65 @@ +# Lab 01 · 복합 요청을 업무로 나누기 + +> **정답본** · 수업이 끝난 뒤 공개합니다. 문제는 [LAB-01-Q-SCENARIO-AND-SPLIT.md](LAB-01-Q-SCENARIO-AND-SPLIT.md). +> 68일차부터 수업 준비는 **정답 워크플로우만 한 번 실행해 확인**합니다. 아래 요청문은 예시이며, 수업 준비 때 CLI 에이전트로 보내 결과를 재지는 않았습니다. 결과 숫자는 정답 워크플로우(`reference/`, `answers/`)를 2026-10-18에 1회 실행한 값입니다 (gpt-4.1-mini). + +- 관련 강의: [1단계 · 복합 업무 시나리오 분석](../../Lecture/68/01-STAGE1-COMPOSITE-SCENARIO.md) +- 소요 시간: 약 40분 (예제 1 10분 · 예제 2 15분 · 과제 15분) + +## 예제 1 · 업무 분석표 (정답 예) + +| 요청 | 업무 | 유형 | 필요한 입력 | 요청에 있나 | 관계 | +| --- | --- | --- | --- | --- | --- | +| `composite` | ① T-1002 처리 상태 ② 회의록 할 일 정리 ③ 프린터 출력 안 됨 | it · meeting · it | ① 접수 번호, 요청한 사람 ② 회의록 원문 ③ 증상 | 모두 있음 (`user`, `notes`) | 셋 다 독립 | +| `two-it` | ① 비밀번호 분실 ② 메일함 용량 | it · it | 증상, 요청한 사람 | 있음 | 독립 (같은 Agent에 두 번) | +| `it-and-other` | ① 프린터 ② 회식 장소 추천 | it · other | ① 증상 ② (맡을 Agent 없음) | ① 있음 | 독립 | +| `meeting-no-notes` | ① 회의록 할 일 정리 | meeting | 회의록 원문 | **없음** | — | + +강조할 점: + +- 업무 개수는 "접속사 수"가 아니라 **처리 단위**. `two-it`은 같은 Agent지만 업무 둘. +- 필요한 입력이 **문장 밖(요청 본문의 다른 칸)** 에 있다: `user`, `notes`. 업무 나누기 LLM에게는 문장만 줘도 되지만, 빠진 입력 판단은 본문 전체를 봐야 한다. +- 오늘 업무는 모두 **독립** → 라우터(분배)로 충분. "보고서를 쓰고 그걸 검토"처럼 순서가 있으면 파이프라인 (69일차). + +## 예제 2 · 요청문 예 + +```text +my-work/split.json 에 n8n 워크플로우를 만들어 줘. +- POST 웹훅, 경로 my-split. 직원 요청 문장은 본문의 message. +- message가 비어 있으면 400 {"error": "message is required"}. +- AI Agent(OpenAI gpt-4.1-mini, Tool 없음)가 문장을 업무 목록으로 나눈다. 업무 하나 = 한 번에 처리할 수 있는 요청 하나. + 유형: it(비밀번호·계정·VPN·프린터·메일·소프트웨어, T-1002 같은 접수 현황), meeting(회의록 정리·할 일), other(그 밖). + 업무 텍스트는 직원이 쓴 한국어 그대로, 접수 번호를 지우지 않는다. JSON만 돌려 달라고 한다. +- 다음 Code 노드에서 그 글자를 JSON으로 읽는다. 앞뒤 ``` 는 지우고, 읽지 못하면 빈 목록. 정해지지 않은 유형은 other. +- 결과는 {"count": 업무 수, "tasks": [{"type": "it", "text": "..."}]} (요청에 나온 순서대로). +- 기대 결과는 cases/lab01.json. check까지 하고 push·test 명령을 알려 줘. +``` + +정답: [reference/lab01-example2-split.json](reference/lab01-example2-split.json) (Webhook → Has message? → Split tasks(Agent) → Parse tasks(Code) → Send result). + +| 수업 준비 때 결과 | | +| --- | --- | +| `cases/lab01.json` | 7/7 (케이스당 0.9~3.2초, 복합 요청이 가장 김) | +| `cases/lab01-challenge.json` (예제 상태) | 0/4 (`agent`, `missing` 필드 없음) | + +## 과제 · 요청문 예 + +```text +my-work/split.json 을 고쳐 줘. LLM 지시문(Split tasks)은 그대로 두고 Parse tasks 코드에서만 정한다. +1. 업무마다 agent: it → "it-agent", meeting → "meeting-agent", other → "담당자 연결". +2. 업무마다 missing: 빠진 입력 목록. meeting인데 본문 notes가 비었으면 ["notes"], it인데 본문 user가 비었으면 ["user"], 아니면 []. + 본문은 $('Assistant webhook').first().json.body 로 읽는다. +3. cases/lab01-challenge.json 과 cases/lab01.json 이 둘 다 통과해야 한다. +``` + +정답: [answers/68-lab01-challenge-split.json](answers/68-lab01-challenge-split.json) + +| 수업 준비 때 결과 | | +| --- | --- | +| `cases/lab01-challenge.json` | 4/4 | +| `cases/lab01.json` | 7/7 | + +풀이 포인트: + +- **LLM이 할 일(문장 이해)** 과 **규칙이 할 일(담당 표, 빈 칸 확인)** 을 나눴다. 규칙 쪽은 매번 같은 답 → 테스트가 흔들리지 않는다 (67일차 "LLM이 정하면 안 되는 값"과 같은 생각). +- `user` 빠짐은 테스트에 없지만 넣어 두면 Lab 03 과제의 "user 없이 IT 질문"에서 바로 쓴다. diff --git a/Last3Mon/68/LAB-02-A-ROUTER-AGENTS.md b/Last3Mon/68/LAB-02-A-ROUTER-AGENTS.md new file mode 100644 index 0000000..281f70b --- /dev/null +++ b/Last3Mon/68/LAB-02-A-ROUTER-AGENTS.md @@ -0,0 +1,87 @@ +# Lab 02 · 여러 Agent에게 나눠 맡기는 라우터 + +> **정답본** · 수업이 끝난 뒤 공개합니다. 문제는 [LAB-02-Q-ROUTER-AGENTS.md](LAB-02-Q-ROUTER-AGENTS.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 정답 워크플로우를 2026-10-18에 1회 실행한 값입니다 (gpt-4.1-mini). + +- 관련 강의: [2단계 · Multi-Agent 워크플로우 설계](../../Lecture/68/02-STAGE2-MULTI-AGENT-DESIGN.md) +- 소요 시간: 약 70분 (예제 1 10분 · 예제 2 25분 · 과제 35분) + +## 예제 1 · Agent 계약 (정답) + +| Agent | 맡는 업무 | 웹훅 | 입력 | 결과 | 실패할 때 | +| --- | --- | --- | --- | --- | --- | +| IT 문의 (67일차) | it | `POST /webhook/it-agent` | `question`, `user` 필수 | `route`, `answer`, `tools_used`, `sources` | 입력 없음 400 · 근거 없음·남의 접수 건은 `route: 담당자 연결` | +| 회의록 (66일차) | meeting | `POST /webhook/meeting-agent` | `title`, `notes`(필수) | `route`, `title`, `count`, `summary`, `actions[{owner, task, due}]` | `notes` 없음 400 `{"error": "notes are required"}` | + +수업 준비 때 직접 부른 결과: `it-agent` + `agent-it.json` 4.8초 (`Get_ticket`, "처리 중"), `meeting-agent` + `agent-meeting.json` 2.3초 (할 일 4개, 담당 미정 1개), `notes` 없이 400. + +요청문 예: + +```text +agents/ 폴더의 두 워크플로우를 읽고 my-work/DESIGN.md 2절 표를 채워 줘. 워크플로우는 만들지 마. +칸: Agent, 맡는 업무, 웹훅(메서드와 경로), 입력(필수 표시), 결과 필드 이름, 실패할 때 돌려주는 것(HTTP 코드와 본문). +근거가 된 노드 이름도 표 아래에 적어 줘. +``` + +## 예제 2 · 라우터 요청문 예 + +```text +my-work/split.json 을 바탕으로 my-work/assistant.json 을 새로 만들어 줘 (split.json 은 그대로). +- 웹훅 경로 my-assistant. 본문: message, user, (있으면) title, notes. +- 나누기 결과를 업무 하나당 항목 하나로 바꾸고 index(1부터)를 붙인다. +- Switch로 type별 갈래: it → HTTP POST http://localhost:5678/webhook/it-agent 본문 {question: 업무 text, user: 웹훅 body.user} + meeting → HTTP POST http://localhost:5678/webhook/meeting-agent 본문 {title, notes: 웹훅 body 그대로} + other → Agent를 부르지 않고 route "담당자 연결", answer "IT 문의와 회의록 정리 말고는 도와드릴 수 없어 담당자에게 전달했습니다." +- 갈래마다 결과를 같은 모양으로: index, type, text, agent, route, answer. 회의록은 answer에 summary와 "할 일: 담당 - 일 (기한)" 목록, actions도 남김. +- Merge로 모으고 Aggregate로 results 배열 하나로, Code에서 index 순으로 정렬해 {count, results, reply} 를 만든다. reply는 업무마다 한 줄씩. +- 기대 결과 cases/lab02.json. AGENTS.md 5절 노드를 써 줘. +``` + +정답: [reference/lab02-example2-router.json](reference/lab02-example2-router.json) — 일부러 `neverError`를 넣지 않았고 빠진 입력도 보지 않습니다 (Lab 03에서 드러남). + +| 수업 준비 때 결과 (1회) | | +| --- | --- | +| `cases/lab02.json` | 6/6 | +| 시간 | 복합(업무 3개) 6.6초 · IT 하나 2.6초 · 회의록 3.5초 · IT+기타 2.8초 · 기타만 0.9초 (Agent를 부르지 않음) | +| 직접 `call` 복합 요청 | 4.8초, `results` 3개 (T-1002 "처리 중", 할 일 4개, 프린터 FAQ) | + +풀이 포인트: + +- 라우터 = **정해진 흐름**(Switch). 어떤 Agent를 부를지는 유형 표가 정하고, LLM은 나누기만 한다. +- 결과 모양 맞추기(Set 셋)가 계약을 지키는 곳. Agent가 바뀌어도 라우터 결과 모양은 그대로. +- 기타만 0.9초: LLM 한 번(나누기)뿐. 회의록·IT 시간은 각 Agent 시간 + 나누기. + +## 과제 · Supervisor 요청문 예 + +```text +my-work/supervisor.json 을 새로 만들어 줘 (라우터와 별도). 웹훅 경로 my-supervisor, 입력은 라우터와 같음, message 비면 400. +- AI Agent 하나(Supervisor, gpt-4.1-mini, returnIntermediateSteps true)가 Tool 둘을 쓴다. + Ask IT agent: HTTP Request Tool POST http://localhost:5678/webhook/it-agent, question은 $fromAI, user는 웹훅 body.user (LLM이 정하지 않음) + Ask meeting agent: POST .../meeting-agent, title·notes는 웹훅 body 그대로, LLM은 request(무엇을 원하는지)만 채움 +- 지시문: 직접 답하지 말 것. IT 질문마다 Ask_IT_agent 한 번, 회의록 요청이면 Ask_meeting_agent 한 번. + 그 밖의 요청은 Tool을 부르지 말고 정확히 "그 요청은 담당자에게 전달했습니다." Tool 결과의 이름·날짜·상태는 그대로. +- 다음 Code에서 intermediateSteps의 Tool 이름으로 agents_used(겹치지 않게)를 만들고 {reply, agents_used} 를 돌려준다. +- 기대 결과 cases/lab02-challenge.json. +``` + +정답: [answers/68-lab02-challenge-supervisor.json](answers/68-lab02-challenge-supervisor.json) + +| 수업 준비 때 결과 | | +| --- | --- | +| 처음 (기타 요청 지시가 "직원이 도와준다고 말하라") | `lab02-challenge` 4/5 · 기타만: "회식 장소 추천은 **담당 직원**이 도와드릴 예정입니다" → `담당자` 없음 | +| 지시를 "정확히 '그 요청은 담당자에게 전달했습니다.'"로 바꾼 뒤 | 5/5 | +| 복합 요청 시간 (3번 측정) | Supervisor 10.5 · 9.6 · 10.8초 / 라우터 6.6 · 4.7 · 4.5초 | +| `test cases/lab02.json --webhook my-supervisor` | 1/6 (빈 요청만 통과: `count`, `results`가 없음 = 계약이 다름) | + +비교 표 (풀이 때 함께 채움): + +| | 라우터 | Supervisor | +| --- | --- | --- | +| 누가 Agent를 고르나 | 유형 표 (코드) | LLM | +| 복합 요청 시간 (수업 준비) | 4.5~6.6초 | 9.6~10.8초 (LLM이 Tool마다 판단하고 마지막에 답을 다시 씀) | +| 결과 모양 | 계약 그대로 (`results[]`) | 글자 답 하나, 업무별 결과는 없음 | +| 테스트하기 | 업무별 `results.N.*`로 정확히 | 답 글자에 낱말이 있는지 정도 | +| Agent 하나 더 | Switch 갈래 + 결과 Set 추가 | Tool 하나 + 지시문 한 줄 | +| 어울리는 곳 | 업무 종류가 정해져 있고 결과를 시스템이 다시 씀 | 요청이 열려 있고 사람에게 바로 답만 주면 됨 | + +- 지시문의 낱말 하나로 4/5 ↔ 5/5가 갈렸다: **Supervisor는 지시문이 곧 분배 규칙**이라 흔들리는 곳이 많다. diff --git a/Last3Mon/68/LAB-03-A-VERIFY-AND-ISOLATE.md b/Last3Mon/68/LAB-03-A-VERIFY-AND-ISOLATE.md new file mode 100644 index 0000000..fc43bba --- /dev/null +++ b/Last3Mon/68/LAB-03-A-VERIFY-AND-ISOLATE.md @@ -0,0 +1,257 @@ +# Lab 03 · 빠진 입력, 꺼진 Agent, 어려운 테스트 + +> **정답본** · 수업이 끝난 뒤 공개합니다. 문제는 [LAB-03-Q-VERIFY-AND-ISOLATE.md](LAB-03-Q-VERIFY-AND-ISOLATE.md). +> 요청문은 예시이며 CLI 에이전트로 보내 재지는 않았습니다. 결과 숫자는 정답 워크플로우를 2026-10-18에 1회 실행한 값입니다 (gpt-4.1-mini). + +- 관련 강의: [3단계 · 검증과 장애 격리, 확장 고려](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md) +- 소요 시간: 약 80분 (예제 1 20분 · 예제 2 20분 · 과제 40분) + +## 고치기 전 (Lab 02 예제 정답 `reference/lab02-example2-router.json`) + +| 테스트 | 결과 | 원인 | +| --- | --- | --- | +| `cases/lab03.json` | 4/5 | 회의록 없는 회의 요청 → **HTTP 500** (회의록 Agent가 400을 돌려주자 `Call meeting agent`가 멈추고 전체가 멈춤) | +| `off agents/meeting-agent.json` 후 `cases/lab03-down.json` | 1/3 | 복합 요청 → **HTTP 500**, T-1002 답까지 사라짐 (404에 멈춤). IT만 요청은 통과 | + +## 답안 읽는 법 · 요청과 응답 대조 (수업 준비 실측) + +Lab 03 답안은 새 워크플로우가 아니라 **Lab 02 라우터를 고친 판**입니다. 노드 구성은 같고 몇 곳만 바뀝니다. 아래 JSON은 2026-10-18 수업 준비 때 `flow.py test`가 남긴 보고서(`runs/*.json`, git 제외)에서 그대로 옮겼습니다 (회의록 `notes`만 `…`로 줄임). + +| 판 | 파일 | 보고서 | +| --- | --- | --- | +| 고치기 전 | `reference/lab02-example2-router.json` | `lab03` 4/5 (`runs/lab03-20261018-003309.json`) · `lab03-down` 1/3 (`…-003325`) | +| 예제 1 + 2 | `reference/lab03-example2-isolated.json` | `lab03` 5/5 (`…-003414`) · `lab03-down` 3/3 (`…-003351`) · 과제 케이스 5/7 (`68-lab03-challenge-cases-…-003950`) | +| 과제 최종 | `answers/68-lab03-challenge-final.json` | 과제 케이스 7/7 (`…-003826`) | + +```text +웹훅 → Has message? → Split tasks(LLM) → Parse tasks(Code) → One item per task + → Route by type(Switch) ─it──────────→ Call IT agent ──────→ IT result ──────┐ + ├meeting─────→ Call meeting agent ─→ Meeting result ─┤ + ├other───────→ Other result ─────────────────────────┼→ Collect results → All results → Build reply → 응답 + └needs input─→ Need input (예제 1에서 새로 생김) ───────┘ +``` + +### 대조 1 · 예제 1 · 회의록 없는 회의 요청 + +보낸 요청 (`requests/meeting-no-notes.json`, 두 판 같음): + +```json +{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지"} +``` + +| 고치기 전 · HTTP 500 · 1.0초 | 예제 1 뒤 · HTTP 200 · 2.3초 | +| --- | --- | +| `{"message": "Error in workflow"}` | 아래 JSON | + +```json +{ + "count": 1, + "results": [ + {"index": 1, "type": "meeting", "text": "이번 회의록 할 일 정리 부탁해요", + "agent": "meeting-agent", "route": "입력 필요", + "answer": "회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요."} + ], + "reply": "1. 이번 회의록 할 일 정리 부탁해요\n → [입력 필요] 회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요." +} +``` + +읽는 법: + +- **고치기 전 500의 원인**: 라우터가 회의록 Agent를 그대로 불렀고, 회의록 Agent는 계약대로 `notes`가 없으면 HTTP 400 `{ "error": "notes are required" }`를 돌려줍니다 (`agents/meeting-agent.json`의 `Reject empty` 노드). `Call meeting agent`가 400에 멈추자 라우터 **전체**가 `Error in workflow`(500)가 됐습니다. 수업에서는 `call meeting-agent requests/meeting-no-notes.json`으로 이 400을 직접 보여 줍니다. +- **고친 곳 ① `Parse tasks`(Code)**: 업무마다 `agent`와 `missing`을 코드로 붙입니다 (LLM이 정하지 않음). + ```js + const AGENT = { it: 'it-agent', meeting: 'meeting-agent', other: '담당자 연결' }; + if (task.type === 'meeting' && !String(body.notes ?? '').trim()) missing.push('notes'); + if (task.type === 'it' && !String(body.user ?? '').trim()) missing.push('user'); + ``` +- **고친 곳 ② `Route by type`(Switch)**: 위에서부터 **처음 맞는 갈래 하나**로만 갑니다 (옵션 비어 있음 = 기본값). + + | 출력 | 조건 | + | --- | --- | + | 0 it | `type = it` | + | 1 meeting | `type = meeting` **그리고 missing 0개** | + | 2 other | `type = other` | + | 3 needs input | missing 1개 이상 | + + 회의록 없는 회의 업무는 1번에 걸리지 않고 3번으로 → **Agent를 부르지 않음**. +- **고친 곳 ③ `Need input`(Set)**: `route: 입력 필요`와 답 문장을 바로 만들어 Merge 3번 입력으로. 응답 `results.0.route`가 테스트 기대 `입력 필요`와 같습니다. +- 응답 모양(`count`, `results[]`의 `index · type · text · agent · route · answer`, `reply`)은 Lab 02와 같습니다. 그래서 `lab02`도 계속 6/6 (`runs/lab02-20261018-003429.json`). + +### 대조 2 · 예제 2 · 회의록 Agent 꺼짐 + 복합 요청 + +`off agents/meeting-agent.json` 뒤 보낸 요청 (`requests/composite.json`, 두 판 같음): + +```json +{"message": "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요.", + "user": "박서연", "title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) …"} +``` + +| 고치기 전 · HTTP 500 · 4.3초 | 예제 2 뒤 · HTTP 200 · 4.6초 | +| --- | --- | +| `{"message": "Error in workflow"}` — **T-1002 답도 없음** | 아래 JSON | + +```json +{ + "count": 3, + "results": [ + {"index": 1, "type": "it", "text": "T-1002 처리 상태 알려줘", "agent": "it-agent", "route": "자동 답변", + "answer": "T-1002 티켓은 현재 \"처리 중\" 상태이며, VPN 인증서 발급 승인 대기 중입니다. 10월 20일에 완료될 예정입니다."}, + {"index": 2, "type": "meeting", "text": "첨부한 회의록에서 할 일 정리", "agent": "meeting-agent", "route": "담당자 연결", + "answer": "담당 Agent가 응답하지 않아 담당자에게 전달했습니다.", "actions": []}, + {"index": 3, "type": "it", "text": "프린터 출력이 안 돼요", "agent": "it-agent", "route": "자동 답변", + "answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력해 보세요."} + ], + "reply": "1. T-1002 처리 상태 알려줘\n → [자동 답변] …\n2. 첨부한 회의록에서 할 일 정리\n → [담당자 연결] 담당 Agent가 응답하지 않아 담당자에게 전달했습니다.\n3. 프린터 출력이 안 돼요\n → [자동 답변] …" +} +``` + +읽는 법: + +- **고치기 전**: 4.3초 동안 IT 업무 둘은 이미 답을 받았지만, 꺼진 회의록 Agent의 **404**(게시 해제된 웹훅)에 `Call meeting agent`가 멈춰 전체가 500. 받아 둔 답까지 버려졌습니다. 같은 보고서에서 "회의록만"도 500, "IT만"은 회의록 Agent를 안 부르니 200. +- **고친 곳 ① `Call IT agent` · `Call meeting agent`**: `options.response.response.neverError: true` (timeout 60000은 원래 있음). 404 · 400 · 500도 멈추지 않고 응답을 다음 노드로 넘깁니다. +- **고친 곳 ② `IT result` · `Meeting result`(Set)**: 빈 칸을 대체 값으로. + + | 칸 | 고치기 전 | 고친 뒤 | + | --- | --- | --- | + | `route` | `{{ $json.route \|\| '오류' }}` | `{{ $json.route \|\| '담당자 연결' }}` | + | `answer` (회의록) | 요약이 없으면 `$json.error` | 요약이 없으면 `'담당 Agent가 응답하지 않아 담당자에게 전달했습니다.'` | + + 정상 응답에는 `route`가 있으니 그대로, 꺼진 Agent의 응답에는 없으니 대체 값. 그래서 `results.1`만 `담당자 연결`, `results.0` · `results.2`는 그대로 `자동 답변`. +- 시간 4.6초: 나누기 약 1초 + IT 둘(한 HTTP 노드가 **동시에**) + 회의록(404라 바로 끝남). + +### 대조 3 · 과제 · user 없이 IT 질문 (예제 2 판에서 실패) + +보낸 요청 (과제 케이스 `body`, 두 판 같음) · 기대 `results.0.route = 입력 필요`: + +```json +{"message": "VPN이 계속 끊겨요"} +``` + +| 예제 2 판 · 200 · 2.3초 · ✘ | 최종 · 200 · 3.0초 · ✔ | +| --- | --- | +| `"route": "담당자 연결"`, `"answer": "담당 Agent가 응답하지 않아 담당자에게 전달했습니다."` | `"route": "입력 필요"`, `"answer": "누가 요청했는지(user)를 함께 보내 주세요."` | + +```json +{"count": 1, "results": [{"index": 1, "type": "it", "text": "VPN이 계속 끊겨요", "agent": "it-agent", + "route": "입력 필요", "answer": "누가 요청했는지(user)를 함께 보내 주세요."}], + "reply": "1. VPN이 계속 끊겨요\n → [입력 필요] 누가 요청했는지(user)를 함께 보내 주세요."} +``` + +읽는 법: + +- 예제 2 판에서도 `Parse tasks`는 `missing: ["user"]`를 붙였습니다. 그런데 Switch의 **it 조건에 "missing 0개"가 없어** 첫 갈래(it)에 먼저 걸렸고, IT Agent가 계약대로 400(`user` 필수) → 예제 2의 대체 값 → `담당자 연결`. **장애가 아닌데 장애로 처리**한 것입니다. 응답만 보면 "Agent가 응답하지 않아"라고 거짓말을 합니다. +- **최종에서 고친 곳**: Switch 0번 it에 `missing 0개` 조건을 더함 → needs input으로. `Need input`의 `agent`는 원래 담당(`{{ $('Route by type').item.json.agent }}`), 답은 빠진 것에 따라: `missing.includes('notes') ? '회의록 정리를 하려면 …' : '누가 요청했는지(user)를 함께 보내 주세요.'` + +### 대조 4 · 과제 · 공백만 보냄 (예제 2 판에서 실패) + +보낸 요청 · 기대 HTTP 400: + +```json +{"message": " ", "user": "이준호"} +``` + +| 예제 2 판 · 200 · 0.7초 · ✘ | 최종 · 400 · 0.1초 · ✔ | +| --- | --- | +| 응답 본문이 **빈 글자** `""` | `{"error": "message is required"}` | + +읽는 법: + +- 예제 2 판의 `Has message?`는 `{{ $json.body.message }}`가 notEmpty인지 봅니다. 공백 세 칸은 "비어 있지 않음"이라 통과합니다. 보고서의 빈 응답과 0.7초(나누기 LLM 한 번)로 보아, LLM이 업무 0개를 돌려주고 `One item per task`가 항목 0개를 내보내 뒤 노드(Respond 포함)가 실행되지 않은 것입니다. Respond 노드가 실행되지 않으면 n8n은 **빈 200**을 돌려줍니다. +- **최종에서 고친 곳**: `Has message?`의 왼쪽 값을 `{{ String($json.body.message ?? '').trim() }}`으로 → 공백을 지우면 빈 글자 → `Reject empty` 400. LLM을 부르지도 않아 0.1초. + +### 한 줄로 + +| 실패 | 막는 곳 | 대조에서 본 것 | +| --- | --- | --- | +| 고칠 수 있는 실패 (빠진 입력) | **부르기 전** (Parse tasks + Switch + Need input) | 500 → 200 `입력 필요`, 무엇이 빠졌는지 알려 줌 (대조 1 · 3) | +| 고칠 수 없는 실패 (Agent 꺼짐) | **부른 뒤** (`neverError` + 결과 Set 대체 값) | 500 → 200, 그 업무만 `담당자 연결` (대조 2) | +| 입력 검사의 구멍 | 웹훅 바로 뒤 (`Has message?`) | 빈 200 → 400, LLM 호출 없음 (대조 4) | + +## 예제 1 · 요청문 예 + +- 시나리오는 새로 만들지 않습니다. `requests/meeting-no-notes.json`(회의록 없는 회의 요청) 하나, 고칠 파일은 `my-work/assistant.json`, 테스트는 이미 있는 `cases/lab03.json`. +- 시작: `call my-assistant requests/meeting-no-notes.json` → HTTP 500, `call meeting-agent requests/meeting-no-notes.json` → HTTP 400 `{"error": "notes are required"}`. 이 두 결과를 나란히 놓고 "Agent의 400이 라우터 전체를 500으로 만들었다"를 학생이 말하게 합니다. + +```text +cases/lab03.json 의 "회의록 없는 회의 요청"이 status 500 입니다. 보고서: runs/lab03-<시각>.json +먼저 원인을 설명해 줘. 그다음 my-work/assistant.json 을 고쳐 줘: +- Parse tasks에서 업무마다 missing(빠진 입력)을 정한다 (Lab 01 과제와 같음). +- missing이 있는 업무는 Agent를 부르지 않고 route "입력 필요", answer "회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요." +- 결과 모양(index, type, text, agent, route, answer)은 그대로. cases/lab02.json 도 계속 통과. +``` + +## 예제 2 · 요청문 예 + +- 시나리오는 새로 만들지 않습니다. 회의록 Agent를 `off`로 끈 상태의 `requests/composite.json`. +- 시작: `call my-assistant requests/composite.json` → HTTP 500 (T-1002 답도 없음), `call meeting-agent requests/agent-meeting.json` → HTTP 404 (게시 해제). "404 하나에 라우터 전체가 멈춰 IT 답까지 사라졌다"를 학생이 말하게 합니다. + +```text +회의록 Agent를 꺼 두고(off) cases/lab03-down.json 을 돌리면 복합 요청이 500 이고 IT 답도 사라집니다. +my-work/assistant.json 을 고쳐 줘: +- Call IT agent, Call meeting agent 에 neverError(true)와 timeout 60초. +- 결과 Set에서 route가 비어 있으면 "담당자 연결", answer가 비어 있으면 "담당 Agent가 응답하지 않아 담당자에게 전달했습니다." +- 다른 업무의 결과는 그대로 나와야 한다. +``` + +정답 (예제 1 + 2): [reference/lab03-example2-isolated.json](reference/lab03-example2-isolated.json) — Switch에 `needs input` 갈래와 `Need input` 결과, Merge 입력 4개. + +| 수업 준비 때 결과 (1회) | | +| --- | --- | +| 회의록 Agent 꺼짐 `lab03-down` | 3/3 (복합 4.6초: IT 둘은 자동 답변, 회의록은 담당자 연결) | +| 다시 켠 뒤 `lab03` | 5/5 | +| `lab02` | 6/6 | + +## 과제 · 어려운 케이스 예와 최종본 + +케이스: [answers/68-lab03-challenge-cases.json](answers/68-lab03-challenge-cases.json) + +| 케이스 | 업무 규칙 | 예제 2 정답에서 | +| --- | --- | --- | +| user 없이 IT 질문 | 누가 물었는지 모르면 Agent를 부르지 않고 `입력 필요` | ✘ (`담당자 연결`: IT Agent가 400, 그걸 장애로 처리) | +| 남의 접수 건이 섞인 복합 요청 (이준호가 T-1002) | 라우터를 거쳐도 남의 접수 내용이 나오지 않음, 프린터는 답함 | ✔ (IT Agent가 `담당자 연결`, "정우성" 없음) | +| 회의록은 붙였지만 IT 질문만 | 회의록 Agent를 부르지 않음 | ✔ | +| 업무 4개 (VPN·프린터·회의록·회식) | `count: 4`, 순서대로 | ✔ (4.6초) | +| 소문자 접수 번호 `t1002` | "처리 중" | ✔ (IT Agent가 번호를 고침, 67일차 과제) | +| 공백만 보냄 | 400 | ✘ (200: 공백도 "비어 있지 않음"으로 봄) | +| 영어로 질문 | `it-agent`, 자동 답변 | ✔ | + +예제 2 정답: 5/7 → 최종본: 7/7. + +> 처음에 "남의 접수 건" 케이스의 기대를 `본인이 접수한`(답 문장)으로 썼다가 실패했습니다. 보고서를 보니 IT 문의 Agent는 남의 접수 건이면 `route: 담당자 연결`로 넘깁니다 (67일차 계약). 기대 결과를 Agent의 계약에 맞게 고쳤습니다. 수업에서 "테스트가 틀렸는지 워크플로우가 틀렸는지"는 **계약 표**로 판단한다는 예로 씁니다. + +요청문 예: + +```text +cases/my-cases.json 을 돌린 보고서입니다: runs/my-cases-<시각>.json. 실패: "user 없이 IT 질문", "공백만 보냄". +원인을 먼저 설명하고 my-work/assistant.json 을 고쳐 줘. +- Has message? 는 앞뒤 공백을 지운 뒤 비었는지 본다. +- it 업무도 missing(user)이 있으면 Agent를 부르지 않고 "입력 필요", 답은 "누가 요청했는지(user)를 함께 보내 주세요." + (Need input 의 답은 무엇이 빠졌는지에 따라 다르게, agent 칸은 원래 담당 Agent) +- cases/lab02.json, cases/lab03.json 도 계속 통과. +``` + +최종본: [answers/68-lab03-challenge-final.json](answers/68-lab03-challenge-final.json) + +| 수업 준비 때 결과 (1회) | | +| --- | --- | +| 어려운 케이스 | 7/7 | +| `lab02` · `lab03` | 6/6 · 5/5 | +| 회의록 Agent 꺼짐 `lab03-down` | 3/3 | + +## 확장 계획 예 (DESIGN.md 6절, 수업 준비 측정값) + +| 측정 | 값 | +| --- | --- | +| IT 하나 (`it-only`) | 2.3~2.6초 | +| 회의록 하나 (`meeting-only`) | 3.5초 | +| 복합 요청 (업무 3개) | 4.5~6.6초 | +| 업무 4개 | 4.6~4.8초 | +| 기타만 (나누기 LLM 한 번) | 0.9~1.0초 | + +- 복합 요청(4.5~6.6초)은 단독 시간의 단순 합(2.6 + 3.5 + 2.6 ≈ 8.7초)보다 짧고, 가장 긴 것(3.5초)보다는 깁니다. 이유 두 가지: + - 단독 시간에는 **나누기 LLM 약 1초**(기타만 0.9~1.0초)가 들어 있습니다. Agent만의 시간은 IT 약 1.3~1.6초, 회의록 약 2.5초. 단순 합은 나누기를 세 번 센 값입니다. + - n8n HTTP Request 노드는 받은 항목들을 **동시에** 보냅니다 (n8n 2.42.6 `HttpRequestV3` 코드: 요청을 모아 `Promise.allSettled`, 배치 간격 기본 0). 그래서 IT 업무 둘은 `Call IT agent` 하나에서 함께 가고, IT 갈래와 회의록 갈래는 노드 순서대로 **차례로** 실행됩니다. + - 어림: 나누기 1 + IT 둘 동시 약 1.5 + 회의록 약 2.5 ≈ 5초 → 측정 4.5~6.6초와 맞음. 업무 4개(4.6~4.8초)도 IT 둘 + 회의록 + 기타(Agent 없음)라 거의 같음. +- n8n이 부른 Agent도 각자 실행되므로, 부하가 몰리면 **Agent 실행 수 = 요청 수 × 업무 수**로 늘어난다. +- 21명이 동시에 복합 요청을 보내면 n8n 하나에서 라우터 21개 + Agent 실행 약 63개. 첫 조치는 측정 → 큐 모드(작업자 여러 개, 수평 확장)와 OpenAI 속도 제한 확인. 수직 확장은 LLM 대기 시간이 대부분이라 효과가 작다 (CPU가 아니라 API 응답을 기다림). +- Agent별로 분리해 두었기 때문에 회의록 Agent만 따로 늘리거나 꺼도 IT 답은 나온다 (예제 2에서 확인). diff --git a/Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md b/Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md index d7353ae..ec9ba54 100644 --- a/Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md +++ b/Last3Mon/68/LAB-03-Q-VERIFY-AND-ISOLATE.md @@ -12,111 +12,268 @@ flowchart LR > 복사해 붙일 요청문은 없습니다. 이번 Lab은 **실패를 먼저 보고** 요청문을 씁니다. +## 이 Lab을 읽는 법 + +예제마다 같은 다섯 걸음으로 갑니다. + +```mermaid +flowchart LR + A["① 요청
무엇을 보내나"] --> B["② call
지금 결과"] --> C["③ 원인
Agent를 직접 불러 비교"] --> D["④ 나와야 할 결과
테스트의 기대"] --> E["⑤ 고치고
다시 call · test"] +``` + +- 명령은 모두 `~/aio-01d4-stu/Last3Mon/68`에서 실행합니다. +- `uv run tools/flow.py call <웹훅 경로> <요청>`은 `http://localhost:5678/webhook/<경로>`로 POST를 보내고, 첫 줄에 `HTTP 코드 · 걸린 시간`, 그 아래 응답 JSON을 보여 줍니다. `<요청>`은 `requests/` 파일이거나 `'{"message": "..."}'` 같은 JSON 글자입니다. +- 웹훅 경로: 라우터는 `my-assistant` (Lab 02에서 만든 `my-work/assistant.json`), Agent 둘은 `it-agent` · `meeting-agent` (`agents/`, 준비 때 push). +- 아래 "지금 결과"와 "나와야 할 결과"는 수업 준비 때 실제로 돌린 값입니다. LLM 답 문장과 시간은 조금씩 다를 수 있습니다. + +--- + ## 예제 1 · 빠진 입력 (20분) -**시나리오**: 김민지 님이 회의록을 붙이지 않고 "이번 회의록 할 일 정리 부탁해요"만 보냈습니다 (`requests/meeting-no-notes.json`). 새 시나리오를 만들지 않습니다. 이 요청 하나로 시작합니다. +**시나리오**: 김민지 님이 회의록을 붙이지 않고 "이번 회의록 할 일 정리 부탁해요"만 보냈습니다. 새 시나리오를 만들지 않고 이 요청 하나로 시작합니다. | 조건 | 값 | | --- | --- | | 고칠 파일 | `my-work/assistant.json` (Lab 02 예제 2의 라우터, 새 파일을 만들지 않음) | -| 문제 요청 | `requests/meeting-no-notes.json` → `{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지"}` (`notes` 없음) | -| 바라는 결과 | HTTP 200, 그 업무의 `route: 입력 필요`, `reply`에 회의록을 보내 달라는 말. **회의록 Agent는 부르지 않음** | +| 문제 요청 | `requests/meeting-no-notes.json` | | 테스트 | `cases/lab03.json` (이미 있음, 고치지 않음) · `cases/lab02.json`도 그대로 통과 | -**1) 문제 요청을 직접 불러 봅니다** (라우터 → 회의록 Agent 순서로). +### ① 보내는 요청 · `requests/meeting-no-notes.json` + +```json +{"message": "이번 회의록 할 일 정리 부탁해요", "user": "김민지"} +``` + +회의 업무인데 회의록 원문 `notes`(그리고 `title`)가 **없습니다.** 비교: 회의록이 있는 `requests/meeting-only.json`에는 `"title": "10월 16일 주간 회의", "notes": "참석: …"`가 있습니다. + +### ② 지금 결과 (Lab 02 라우터 그대로) ```bash uv run tools/flow.py push my-work/assistant.json -uv run tools/flow.py call my-assistant requests/meeting-no-notes.json # 라우터: 어떤 HTTP 코드? -uv run tools/flow.py call meeting-agent requests/meeting-no-notes.json # 회의록 Agent 직접: 계약대로 무엇을 돌려주나? +uv run tools/flow.py call my-assistant requests/meeting-no-notes.json ``` -라우터 응답이 200이 아니라면, 회의록 Agent가 돌려준 것이 라우터를 어떻게 만들었는지 두 결과를 나란히 놓고 봅니다. +```text +HTTP 500 · 1.0s +{ + "message": "Error in workflow" +} +``` -**2) 테스트 전체로 확인합니다.** 이 케이스만 실패하고 나머지는 통과하는지 봅니다. +답 대신 오류입니다. 직원은 무엇이 잘못됐는지 알 수 없습니다. + +### ③ 원인 · 회의록 Agent를 라우터 없이 직접 불러 보기 ```bash -uv run tools/flow.py test cases/lab03.json +uv run tools/flow.py call meeting-agent requests/meeting-no-notes.json ``` -| 케이스 | 기대 | +```text +HTTP 400 +{ + "error": "notes are required" +} +``` + +회의록 Agent는 **계약대로** 동작했습니다 (`notes`가 없으면 400, [AGENTS.md](AGENTS.md) 3절 · Lab 02 예제 1에서 쓴 `my-work/DESIGN.md` 2절). 두 결과를 나란히 놓으면 흐름이 보입니다. + +```text +라우터 Split tasks → 업무 1개 (type: meeting) → Switch meeting 갈래 → Call meeting agent (notes 없이 부름) +Agent notes 없음 → 400 {"error": "notes are required"} ← 정상 +라우터 Call meeting agent가 400을 받고 멈춤 → 워크플로우 전체 실패 → 500 {"message": "Error in workflow"} +``` + +n8n **Executions**에서 이 실행을 열면 `Call meeting agent` 노드가 빨갛습니다. + +### ④ 나와야 할 결과 · `cases/lab03.json` 첫 케이스 + +```text +HTTP 200 · 2.3s +{ + "count": 1, + "results": [ + { + "index": 1, + "type": "meeting", + "text": "이번 회의록 할 일 정리 부탁해요", + "agent": "meeting-agent", + "route": "입력 필요", + "answer": "회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요." + } + ], + "reply": "1. 이번 회의록 할 일 정리 부탁해요\n → [입력 필요] 회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요." +} +``` + +| 테스트가 보는 곳 | 값 | | --- | --- | -| 회의록 없는 회의 요청 | HTTP 200, `results.0.route: 입력 필요`, 답에 "회의록"을 보내 달라는 말 | -| IT 둘 · 둘 다 답 | `count: 2`, 두 업무 모두 `자동 답변` | -| 복합 요청 · 오류 없음 | `count: 3`, 어느 결과에도 `오류` 없음 | -| 기타만 · 빈 요청 | 담당자 연결 · 400 | +| `status` | 200 | +| `count` | 1 | +| `results.0.route` | `입력 필요` | +| `reply` | "회의록"이 들어 있음 | -
▶ 1단계 · 보고서 읽기 +- 응답 모양(`count`, `results[]`의 `index · type · text · agent · route · answer`, `reply`)은 Lab 02와 **같습니다.** 칸을 바꾸면 `lab02`가 깨집니다. +- 회의록 Agent는 **부르지 않습니다** (부르면 또 400). -실패한 케이스의 `status`와 `response`를 `runs/lab03-....json`에서 봅니다. 응답이 500이면 라우터 **전체**가 멈춘 것입니다. n8n **Executions**에서 어느 노드가 빨간지, 그 노드가 받은 응답이 무엇인지 확인합니다. 회의록 Agent의 계약(Lab 02 예제 1에서 쓴 `my-work/DESIGN.md` 2절, [AGENTS.md](AGENTS.md) 3절)에서 `notes`가 없을 때 무엇을 돌려준다고 했나요? 1)의 두 번째 `call` 결과와 같은가요? +### ⑤ 고치고 다시 확인 + +```bash +uv run tools/flow.py push my-work/assistant.json +uv run tools/flow.py call my-assistant requests/meeting-no-notes.json # ④와 같은 모양인가 +uv run tools/flow.py test cases/lab03.json # 고치기 전 4/5 → 5/5 +uv run tools/flow.py test cases/lab02.json # 6/6 그대로 +``` + +`cases/lab03.json`의 다른 케이스 (고치기 전에도 통과, 고친 뒤에도 통과해야 함): + +| 케이스 | 요청 | 기대 | +| --- | --- | --- | +| IT 둘 · 둘 다 답 | `requests/two-it.json` | `count: 2`, 두 업무 모두 `자동 답변` | +| 복합 요청 · 오류 없음 | `requests/composite.json` | `count: 3`, 어느 결과에도 `오류` 없음 | +| 기타만 | `requests/other-only.json` | `results.0.route: 담당자 연결` | +| 빈 요청 | `requests/empty-message.json` | HTTP 400 | + +
▶ 1단계 · 어디서 막을까 + +두 가지 방법이 있습니다: (가) Agent를 부르고 400이 오면 처리, (나) **부르기 전에** 빠진 입력을 보고 Agent를 부르지 않음. ③에서 본 것처럼 400은 Agent가 "이 요청은 처리할 수 없다"고 한 것입니다. 부르기 전에 이미 알 수 있는 일인가요? Lab 01 과제에서 만든 `missing`이 있다면 어느 쪽이 쉬운가요?
-
▶ 2단계 · 어디서 막을까 +
▶ 2단계 · 어느 노드를 고치나 -두 가지 방법이 있습니다: (가) Agent를 부르고 오류가 오면 처리, (나) **부르기 전에** 빠진 입력을 보고 Agent를 부르지 않음. Lab 01 과제에서 만든 `missing`이 있다면 어느 쪽이 쉬운가요? 직원에게 무엇이라고 답해야 다음에 제대로 보낼 수 있을까요? +| 노드 | 생각할 것 | +| --- | --- | +| `Parse tasks` (Code) | 업무마다 빠진 입력(`missing`)을 **코드로** 붙일 수 있나요? 회의 업무인데 웹훅 본문의 `notes`가 비었으면? (LLM에게 묻지 않음) | +| `Route by type` (Switch) | 빠진 입력이 있는 업무가 meeting 갈래로 가지 않게 하려면? 그 업무가 갈 **새 갈래**가 필요합니다. Switch는 위에서부터 처음 맞는 갈래 하나로만 갑니다 | +| 새 결과 노드 (Set) | Agent를 부르지 않고 `route: 입력 필요`와 답 문장을 만들어 `Collect results`(Merge)로. Merge의 입력 수도 하나 늘어납니다 |
▶ 3단계 · 요청문 점검 -- [ ] 실패한 케이스 이름, 보고서 경로를 그대로 붙였다 +- [ ] ②의 `call` 결과(500)와 ③의 결과(400)를 그대로 붙였다 - [ ] 원인 설명을 먼저 요구했다 -- [ ] 새 결과 `route` 값과 답 문장을 정했다 -- [ ] `cases/lab02.json`도 계속 통과해야 한다고 적었다 +- [ ] ④의 `route` 값과 답 문장을 정했다 +- [ ] 응답 모양은 그대로, `cases/lab02.json`도 계속 통과해야 한다고 적었다
+--- + ## 예제 2 · Agent가 꺼졌을 때 (20분) -**시나리오**: 회의록 Agent가 고장 나 응답하지 않는 날, 박서연 님이 복합 요청(`requests/composite.json`: T-1002 상태 · 회의록 할 일 · 프린터)을 보냈습니다. 새 시나리오를 만들지 않습니다. +**시나리오**: 회의록 Agent가 고장 나 응답하지 않는 날, 박서연 님이 복합 요청을 보냈습니다. 새 시나리오를 만들지 않습니다. | 조건 | 값 | | --- | --- | | 고칠 파일 | 예제 1을 고친 `my-work/assistant.json` | | 문제 요청 | `requests/composite.json` (회의록 Agent를 `off`로 꺼 둔 상태) | -| 바라는 결과 | HTTP 200. IT 업무 둘은 그대로 답하고, 회의록 업무만 `route: 담당자 연결`, 답에 "응답하지 않아" | | 테스트 | `cases/lab03-down.json` (이미 있음) · 다시 켠 뒤 `cases/lab02.json`, `cases/lab03.json` | -**1) 회의록 Agent를 끄고 문제 요청을 직접 불러 봅니다.** +### ① 보내는 요청 · `requests/composite.json` + +```json +{ + "message": "T-1002 처리 상태 알려주고, 첨부한 회의록에서 할 일도 정리해줘. 그리고 프린터 출력이 안 돼요.", + "user": "박서연", + "title": "10월 16일 주간 회의", + "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) … 다음 회의는 10월 23일." +} +``` + +업무 셋(IT · 회의록 · IT), 입력은 **모두 있습니다.** 회의록 Agent가 켜져 있으면 Lab 02에서 이미 통과한 요청입니다. + +### ② 지금 결과 (회의록 Agent를 끄고) ```bash uv run tools/flow.py off agents/meeting-agent.json -uv run tools/flow.py call my-assistant requests/composite.json # 라우터: 어떤 HTTP 코드? T-1002 상태가 있나? -uv run tools/flow.py call meeting-agent requests/agent-meeting.json # 꺼진 Agent는 무엇을 돌려주나? +uv run tools/flow.py call my-assistant requests/composite.json ``` -**2) 테스트 전체로 확인합니다.** +```text +HTTP 500 · 4.3s +{ + "message": "Error in workflow" +} +``` + +**먼저 생각해 보기**: 4.3초 동안 IT 업무 둘(T-1002, 프린터)은 이미 답을 받았습니다. 그런데 응답에 T-1002 상태가 있나요? 회의록 Agent 하나가 꺼졌는데 왜 IT 답까지 사라졌을까요? + +### ③ 원인 · 꺼진 Agent를 직접 불러 보기 ```bash -uv run tools/flow.py test cases/lab03-down.json +uv run tools/flow.py call meeting-agent requests/agent-meeting.json ``` -| 케이스 (회의록 Agent 꺼짐) | 기대 | -| --- | --- | -| 복합 요청 | HTTP 200, `count: 3`. IT 업무는 그대로 `자동 답변`("처리 중"), 회의록 업무는 `담당자 연결`, 답에 "응답하지 않아" | -| 회의록만 | HTTP 200, `담당자 연결` | -| IT만 | 그대로 `자동 답변` | +```text +HTTP 404 +(웹훅이 게시되어 있지 않다는 n8n의 응답) +``` -**먼저 생각해 보기**: 고치기 전 복합 요청의 응답에 T-1002 상태가 들어 있나요? 회의록 Agent 하나가 꺼졌는데 IT 답까지 사라졌다면 왜일까요? +```text +라우터 Call IT agent → 200 두 번 (T-1002, 프린터 답을 받음) + Call meeting agent → 404 (꺼진 Agent) → 노드가 멈춤 + → 워크플로우 전체 실패 → 500. 받아 둔 IT 답까지 버림 +``` + +`cases/lab03-down.json`을 돌리면 "회의록만"도 500, "IT만"은 회의록 Agent를 부르지 않으니 200입니다 (고치기 전 1/3). + +### ④ 나와야 할 결과 · `cases/lab03-down.json` 첫 케이스 + +```text +HTTP 200 · 4.6s +{ + "count": 3, + "results": [ + {"index": 1, "type": "it", "text": "T-1002 처리 상태 알려줘", "agent": "it-agent", + "route": "자동 답변", "answer": "T-1002 티켓은 현재 \"처리 중\" 상태이며, VPN 인증서 발급 승인 대기 중입니다. …"}, + {"index": 2, "type": "meeting", "text": "첨부한 회의록에서 할 일 정리", "agent": "meeting-agent", + "route": "담당자 연결", "answer": "담당 Agent가 응답하지 않아 담당자에게 전달했습니다.", "actions": []}, + {"index": 3, "type": "it", "text": "프린터 출력이 안 돼요", "agent": "it-agent", + "route": "자동 답변", "answer": "프린터 전원과 네트워크 연결을 확인하고, …"} + ], + "reply": "1. T-1002 처리 상태 알려줘\n → [자동 답변] …\n2. 첨부한 회의록에서 할 일 정리\n → [담당자 연결] 담당 Agent가 응답하지 않아 담당자에게 전달했습니다.\n3. 프린터 출력이 안 돼요\n → [자동 답변] …" +} +``` + +| 케이스 (회의록 Agent 꺼짐) | 요청 | 테스트가 보는 곳 | +| --- | --- | --- | +| 복합 요청 | `requests/composite.json` | HTTP 200, `count: 3`, `results.0.route: 자동 답변` + 답에 "처리 중", `results.1.route: 담당자 연결` + 답에 "응답하지 않아" | +| 회의록만 | `requests/meeting-only.json` | HTTP 200, `results.0.route: 담당자 연결` | +| IT만 | `requests/it-only.json` | HTTP 200, `results.0.route: 자동 답변` | + +고장 난 업무 **하나만** `담당자 연결`, 나머지는 그대로입니다. + +### ⑤ 고치고 다시 확인 (그리고 꼭 다시 켜기) + +```bash +uv run tools/flow.py push my-work/assistant.json +uv run tools/flow.py call my-assistant requests/composite.json # ④와 같은 모양인가 +uv run tools/flow.py test cases/lab03-down.json # 고치기 전 1/3 → 3/3 + +uv run tools/flow.py push agents/meeting-agent.json # 회의록 Agent 다시 켜기 +uv run tools/flow.py test cases/lab02.json # 6/6 +uv run tools/flow.py test cases/lab03.json --repeat 3 # 예제 1이 그대로인지 +```
▶ 1단계 · 오류를 "결과"로 바꾸기 -Agent를 부르는 노드가 404·500을 받으면 n8n은 기본으로 워크플로우를 멈춥니다. 멈추지 않고 그 응답을 다음 노드로 넘기는 설정이 있습니다 ([AGENTS.md](AGENTS.md) 5절 HTTP Request). 넘겨받은 뒤 결과 칸(`route`, `answer`)이 비어 있으면 무엇으로 채울지 정합니다. +Agent를 부르는 노드가 404 · 500을 받으면 n8n은 기본으로 워크플로우를 멈춥니다. 멈추지 않고 그 응답을 다음 노드로 넘기는 설정이 있습니다 ([AGENTS.md](AGENTS.md) 5절 HTTP Request). 넘겨받은 404 응답에는 `route`도 `answer`도 없습니다.
-
▶ 2단계 · 얼마나 기다릴까 +
▶ 2단계 · 어느 노드를 고치나 + +| 노드 | 생각할 것 | +| --- | --- | +| `Call IT agent`, `Call meeting agent` (HTTP Request) | 위 1단계의 설정. 회의록만이 아니라 **둘 다** (IT Agent도 고장 날 수 있음) | +| `IT result`, `Meeting result` (Set) | 응답에 `route`가 있으면 그대로, **없으면** 무엇으로? `answer`가 없으면 어떤 문장으로? (④의 값) | + +정상 응답에는 `route`가 있고 404 응답에는 없다는 차이를 이용합니다. +
+ +
▶ 3단계 · 얼마나 기다릴까 꺼진 Agent는 바로 404가 오지만, **느린** Agent는 응답이 안 올 수도 있습니다. 직원이 얼마나 기다려도 될지 정하고 요청문에 적습니다. (느린 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분) @@ -124,44 +281,117 @@ uv run tools/flow.py test cases/lab03.json --repeat 3 > "다음 주부터 전 직원이 씁니다. 지금 테스트는 우리가 고친 것만 확인하잖아요. 실제로 들어올 만한 어려운 요청으로 테스트를 더 만들고, 통과시키세요. 그리고 사람이 몰리면 어떻게 할지 숫자로 설명해 주세요." -1. `cases/my-cases.json`에 어려운 케이스 **5개 이상**을 직접 씁니다. 맨 위에 `"webhook": "my-assistant"`, 형식은 [AGENTS.md](AGENTS.md) 8절, 요청은 `requests/` 파일 대신 `body`로 직접 써도 됩니다. 모양은 `cases/lab03.json`을 열어 따라 합니다. -2. 지금 라우터로 돌려 실패를 보고, 에이전트에게 고치게 합니다 (테스트의 기대 결과는 바꾸지 않음). +1. `cases/my-cases.json`에 어려운 케이스 **5개 이상**을 직접 씁니다 (아래 예 둘 + 내가 찾은 셋 이상). +2. 지금 라우터로 `call` · `test` 해서 실패를 보고, 에이전트에게 고치게 합니다 (테스트의 기대 결과는 바꾸지 않음). 3. `my-work/DESIGN.md` 5·6절을 채웁니다. 6절의 시간은 **내 `runs/` 보고서의 값**으로 씁니다. +### 케이스 파일 모양 · `cases/my-cases.json` + +```json +{ + "webhook": "my-assistant", + "cases": [ + {"name": "user 없이 IT 질문", "body": {"message": "VPN이 계속 끊겨요"}, + "expect": {"equals": {"results.0.route": "입력 필요"}}}, + {"name": "공백만 보냄", "body": {"message": " ", "user": "이준호"}, + "expect": {"status": 400}} + ] +} +``` + +- `request`(파일) 대신 `body`로 직접 씁니다. 형식은 [AGENTS.md](AGENTS.md) 8절, `cases/lab03.json`을 열어 따라 해도 됩니다. +- 기대 결과는 **업무 규칙 한 줄**에서 나옵니다: "누가 물었는지 모르면 Agent를 부르지 않고 `입력 필요`", "빈 요청은 공백만 있어도 400". + +### 예 A · user 없이 IT 질문 + ```bash +uv run tools/flow.py call my-assistant '{"message": "VPN이 계속 끊겨요"}' +``` + +| | 결과 | +| --- | --- | +| 지금 (예제 2까지 고친 판) ✘ | `HTTP 200 · 2.3s`, `"route": "담당자 연결"`, `"answer": "담당 Agent가 응답하지 않아 담당자에게 전달했습니다."` | +| 나와야 할 결과 ✔ | `HTTP 200`, `"route": "입력 필요"`, `"answer": "누가 요청했는지(user)를 함께 보내 주세요."` | + +**먼저 생각해 보기**: IT Agent는 정말 "응답하지 않았"나요? IT Agent를 직접 불러 봅니다. + +```bash +uv run tools/flow.py call it-agent '{"question": "VPN이 계속 끊겨요"}' # 계약: question · user 둘 다 필수 +``` + +IT Agent는 계약대로 400을 돌려줍니다. 예제 2에서 만든 "응답이 없으면 담당자 연결"이 이 400까지 장애로 처리해 **틀린 말**("응답하지 않아")을 합니다. 테스트가 없었다면 몰랐을 구멍입니다. + +### 예 B · 공백만 보냄 + +```bash +uv run tools/flow.py call my-assistant '{"message": " ", "user": "이준호"}' +``` + +| | 결과 | +| --- | --- | +| 지금 ✘ | `HTTP 200 · 0.7s`, 응답 본문이 **비어 있음** | +| 나와야 할 결과 ✔ | `HTTP 400`, `{"error": "message is required"}` (`requests/empty-message.json`과 같음) | + +**먼저 생각해 보기**: 공백 세 칸은 "비어 있다"인가요? 0.7초는 무엇을 하느라 걸린 시간일까요 (빈 요청 `requests/empty-message.json`은 0.1초도 안 걸림)? + +### 고치고 다시 확인 + +```bash +uv run tools/flow.py push my-work/assistant.json 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단계 · 어디서 어려운 요청이 나오나 +
▶ 1단계 · 어디서 어려운 요청이 나오나 (나머지 셋 이상) -다섯 곳에서 찾아봅니다: ① 입력 칸이 비거나 이상함 ② 업무 수가 많거나 섞임 ③ 67일차에 지킨 규칙(남의 접수 건)이 라우터를 거쳐도 지켜지나 ④ 붙어 온 자료와 요청이 안 맞음 ⑤ 다른 언어·다른 표기. 각 케이스의 기대 결과를 **업무 규칙 한 줄**로 먼저 적고 테스트로 옮깁니다. +다섯 곳에서 찾아봅니다: ① 입력 칸이 비거나 이상함 ② 업무 수가 많거나 섞임 ③ 67일차에 지킨 규칙(남의 접수 건)이 라우터를 거쳐도 지켜지나 ④ 붙어 온 자료와 요청이 안 맞음 ⑤ 다른 언어 · 다른 표기. 각 케이스의 기대 결과를 **업무 규칙 한 줄**로 먼저 적고 테스트로 옮깁니다. 기대가 맞는지 모르겠으면 그 Agent를 직접 `call` 해서 **계약**(AGENTS.md 3절)과 비교합니다.
-
▶ 2단계 · 좋은 케이스의 조건 +
▶ 2단계 · 예 A · B는 어느 노드를 고치나 + +| 예 | 생각할 것 | +| --- | --- | +| A | 예제 1에서 `missing`을 보고 갈래를 나눈 것은 meeting뿐이었나요? it 업무도 같은 검사를 거치게 하려면 Switch의 어느 조건을? 답 문장은 **무엇이 빠졌는지**에 따라 달라야 합니다 (`notes`면 회의록, `user`면 요청한 사람) | +| B | 웹훅 바로 뒤 `Has message?`가 무엇을 "비어 있음"으로 보나요? 앞뒤 공백을 지운 뒤 보게 하면 LLM도 부르지 않습니다 | +
+ +
▶ 3단계 · 좋은 케이스의 조건 - 기대 결과가 **하나로** 정해진다 (사람마다 다르게 판단하면 테스트가 아님) - 통과해도 의미가 있다: 지금 통과하는 케이스도 "고칠 때 깨지지 않게" 남겨 둔다 - `not_contains`로 **나오면 안 되는 것**(남의 이름, 오류)을 확인한다 +- 테스트가 실패하면 먼저 "테스트가 틀렸나, 워크플로우가 틀렸나"를 **계약 표**로 판단한다
-
▶ 3단계 · 확장 계획 생각하기 +
▶ 4단계 · 확장 계획 생각하기 -보고서에서 IT 하나, 회의록 하나, 복합 요청의 시간을 찾습니다. 복합 요청 시간은 각 업무 시간의 합에 가까운가요, 가장 긴 것에 가까운가요? 두 가지를 같이 생각합니다: ① 단독 요청 시간에는 **나누기 LLM(약 1초)** 이 이미 들어 있다 ② 같은 Agent로 가는 업무들은 HTTP 노드 하나가 **동시에** 보내고, 다른 Agent 갈래는 **차례로** 실행된다. 21명이 동시에 보내면 n8n 하나가 몇 개를 동시에 처리해야 하나요? [3단계 교안](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md) 5절의 수직·수평 확장 표를 보고 내 측정값에 맞는 첫 조치를 고릅니다. +보고서에서 IT 하나, 회의록 하나, 복합 요청의 시간을 찾습니다. 복합 요청 시간은 각 업무 시간의 합에 가까운가요, 가장 긴 것에 가까운가요? 두 가지를 같이 생각합니다: ① 단독 요청 시간에는 **나누기 LLM(약 1초)** 이 이미 들어 있다 ② 같은 Agent로 가는 업무들은 HTTP 노드 하나가 **동시에** 보내고, 다른 Agent 갈래는 **차례로** 실행된다. 21명이 동시에 보내면 n8n 하나가 몇 개를 동시에 처리해야 하나요? [3단계 교안](../../Lecture/68/03-STAGE3-VERIFY-AND-SCALE.md) 5절의 수직 · 수평 확장 표를 보고 내 측정값에 맞는 첫 조치를 고릅니다.
+--- + +## 한눈에 + +| 단계 | 요청 | 지금 | 원인 (직접 call로 확인) | 나와야 할 결과 | +| --- | --- | --- | --- | --- | +| 예제 1 | 회의록 없는 회의 | 500 | 회의록 Agent의 400에 라우터가 멈춤 | 200, `입력 필요` | +| 예제 2 | 회의록 Agent 꺼짐 + 복합 | 500, IT 답도 사라짐 | 404에 라우터가 멈춤 | 200, 회의록만 `담당자 연결` | +| 과제 예 A | user 없는 IT | 200 `담당자 연결` (틀린 말) | IT Agent의 400을 장애로 처리 | 200, `입력 필요` (user) | +| 과제 예 B | 공백만 | 빈 200 | 공백도 "비어 있지 않음" | 400 | + ## 막혔을 때 | 증상 | 할 일 | | --- | --- | -| `off` 후 다른 테스트도 이상함 | 회의록 Agent를 다시 켭니다: `uv run tools/flow.py push agents/meeting-agent.json` | +| `call meeting-agent`가 처음부터 404 | 회의록 Agent가 꺼져 있음: `uv run tools/flow.py push agents/meeting-agent.json` | +| `off` 후 다른 테스트도 이상함 | 위와 같이 다시 켭니다 | | 고쳤는데 `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절에 측정값. +- [ ] 예제 1: `call` 결과가 500 → ④ 모양, `cases/lab03.json` 5/5 (빠진 입력은 Agent를 부르지 않고 `입력 필요`). +- [ ] 예제 2: 회의록 Agent가 꺼져도 `lab03-down` 3/3, 다시 켠 뒤 `lab02` · `lab03` 통과. +- [ ] 과제: `cases/my-cases.json` 5개 이상(예 A · B 포함) 3번 반복 통과, `DESIGN.md` 5 · 6절에 측정값. - [ ] [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)를 `my-work/TEST-REPORT.md`로 채웠다. diff --git a/Last3Mon/68/README.md b/Last3Mon/68/README.md index ed4fad2..7b58daa 100644 --- a/Last3Mon/68/README.md +++ b/Last3Mon/68/README.md @@ -40,7 +40,7 @@ flowchart LR ## 실습 문서 읽는 법 - 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TFLOW.md`)에는 **복사해 붙일 요청문이 없습니다.** 만들 것 · 조건 · 기대 결과만 있고, 요청문은 직접 씁니다. -- 바이브 코딩은 **요청서(`VIBE-REQ-TFLOW.md`)부터** 읽고 요구(`SPEC.md`)를 직접 뽑은 뒤 `VIBE-Q-TFLOW.md`와 비교합니다. 정답본 `VIBE-A-TFLOW.md`는 수업이 끝나면 올라옵니다. +- 바이브 코딩은 **요청서(`VIBE-REQ-TFLOW.md`)부터** 읽고 요구(`SPEC.md`)를 직접 뽑은 뒤 `VIBE-Q-TFLOW.md`와 비교합니다. 정답본 `VIBE-A-TFLOW.md`도 올라와 있습니다 (수업 뒤 공개). - 막히면 `▶` 를 하나씩 펼칩니다. 뒤로 갈수록 구체적이 되지만 정답 요청문은 나오지 않습니다. - 쓴 요청문은 `my-work/PROMPTS.md`에 모아 두고, 수업 끝에 옆 사람과 비교합니다. @@ -55,7 +55,8 @@ Last3Mon/68/ ├── LAB-03-Q-VERIFY-AND-ISOLATE.md # 3단계 · 입력 부족 → Agent 장애 격리 → 어려운 테스트 · 확장 계획 ├── VIBE-REQ-TFLOW.md # 7교시 바이브 코딩 · 요청서 (가장 먼저, 여기서 SPEC.md를 뽑음) ├── VIBE-Q-TFLOW.md # 7교시 바이브 코딩 · tflow 요구 · 단계 (SPEC과 비교) -├── *-A-*.md # 정답본 (VIBE-A는 수업이 끝난 뒤 올라옴, LAB-A는 아직 없음) +├── *-A-*.md # 정답본 (수업 뒤 공개) +├── answers/ # 과제 정답 워크플로우 · 케이스 ├── 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 diff --git a/Last3Mon/68/VIBE-A-TFLOW.md b/Last3Mon/68/VIBE-A-TFLOW.md new file mode 100644 index 0000000..de1ac08 --- /dev/null +++ b/Last3Mon/68/VIBE-A-TFLOW.md @@ -0,0 +1,118 @@ +# 7교시 바이브 코딩 · `tflow` 만들기 + +> **정답본** · 수업이 끝난 뒤 공개합니다. 요청서는 [VIBE-REQ-TFLOW.md](VIBE-REQ-TFLOW.md), 문제는 [VIBE-Q-TFLOW.md](VIBE-Q-TFLOW.md). +> 68일차부터 바이브 코딩 프로그램은 **수업 준비 때 미리 만들어 보지 않습니다.** 아래 요청문은 예시이고, 확인 기준은 입력 파일에서 직접 센 값입니다. 걸린 시간은 수업 중 강사 바이브 코딩(8교시)에서 재어 기록합니다. + +- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시 +- 요구 R1~R6은 학생용과 같습니다. + +## 요청서 → 요구 도출 예 (`SPEC.md` 예) + +요청서의 말을 **확인할 수 있는 요구**로 바꾼 예입니다. 정답이 하나는 아닙니다. "됐다 / 안 됐다"를 말할 수 있는지가 기준입니다. + +### 1. 요구 + +| 번호 | 요구 | 요청서의 말 | 확인 방법 | +| --- | --- | --- | --- | +| R1 | 웹훅에서 `main`을 따라가는 트리, If는 true/false · Switch는 출력 이름을 갈래에 | ① | `agents/it-agent.json`의 `Has question?` 아래 true · false. `reference/lab03-example2-isolated.json`의 `Route by type` 아래 4갈래 | +| R2 | `ai_languageModel` · `ai_tool`은 받는 Agent 아래에 따로 | ② | `IT help agent` 아래 모델 1 (`OpenAI chat model`), Tool 2 (`Search FAQ`, `Get ticket`) | +| R3 | 노드를 고르면 종류 · 중요한 설정 · 한국어 설명, 설명은 캐시 | ③ | 같은 노드를 두 번 고르면 두 번째는 LLM 호출 없음 (테스트에서 가짜 설명기 호출 1번) | +| R4 | `--md`로 mermaid 블록이 든 `.md` 파일 (TUI `m`도 같은 파일) | ④ | `it-agent.md`를 미리보기로 열면 그림, 노드 14개 상자 | +| R5 | 없는 노드를 가리키는 연결, 떨어진 노드를 경고 | ⑤ | 망가진 파일 두 개에서 경고 각 1개 | +| R6 | 터미널 TUI | "브라우저 말고 터미널" | `uv run tflow agents/it-agent.json`이 열리고 `q`로 닫힘 | + +### 2. 숨은 요구 + +- 다시 모이는 노드: it-agent의 `Send result`는 `Auto answer` · `Hand off` 두 곳에서 들어옴 → 두 번째는 `↩ Send result (above)` 한 줄. +- 망가진 파일: 받지 않았으므로 테스트용으로 만든다 (연결 이름 하나 틀리게, 떨어진 노드 하나 더하기). +- 키 없음 · LLM 실패: "설명 없음 (키 없음)" / "설명 실패: …", 트리와 mermaid는 그대로. +- 비밀: LLM 입력에서 `credentials` 제외. 캐시 키는 노드 `type + parameters`의 해시 (이름만으로 하면 바뀐 노드에 옛 설명). +- mermaid: id `n0`, `n1` …, 보이는 이름은 `n3["Has question?"]`. + +### 3. 범위 밖 + +편집, n8n API 연결, 실행 기록. (강사 바이브 코딩에서 `diff`, 실행 기록 붙이기를 보여 줌) + +### 4. 순서와 이유 + +1. 파일 → Node · Link 목록 (`model.py`) + 숫자 테스트. 트리 · mermaid · TUI · 경고가 **모두 이것을 씀**. +2. 글자 트리 · `--md` (R1 · R4). 화면 없이 확인이 빠름. 그림은 터미널이 못 그리므로 `.md` 미리보기로. +3. TUI · 상세 · 경고 (R2 · R5 · R6). +4. LLM 설명 · 캐시 (R3). 비싸고 실패할 수 있는 것은 마지막, 없어도 앞의 것은 동작. + +### 자주 빠뜨리는 것 (수업 중 본 것으로 채움) + +| 빠뜨린 것 | 생기는 일 | +| --- | --- | +| 다시 모이는 노드 | 트리에 `Send result` 아래가 두 번, isolated 파일은 네 번 | +| 확인 방법의 숫자 | 에이전트가 "완료"라고 하면 그대로 넘어감 | +| 망가진 파일 | R5를 확인할 방법이 없음 | +| (수업 뒤 기록) | | + +## 확인 기준 (입력 파일에서 센 값) + +| 파일 | 노드 | 연결 | 확인할 것 | +| --- | --- | --- | --- | +| `agents/meeting-agent.json` | 7 | main 5 · ai_languageModel 1 | 웹훅 → `Has notes?` → true `Meeting notes agent` / false `Reject empty` | +| `agents/it-agent.json` | 14 | main 11 · ai_languageModel 1 · ai_tool 2 | `IT help agent`에 모델 `OpenAI chat model`, Tool `Search FAQ`·`Get ticket`. `Send result`는 `Auto answer`·`Hand off` 두 곳에서 들어옴 | +| `reference/lab03-example2-isolated.json` | 18 | main 19 · ai_languageModel 1 | `Route by type` 출력 4개(it · meeting · other · needs input)가 `Collect results` 입력 0~3으로 | + +- 다시 모이는 노드: 두 번째부터는 펼치지 않고 `↩ Send result (위에 있음)`처럼 표시하면 충분. +- mermaid id: `n0`, `n1` … 처럼 번호로, 보이는 이름은 `n3["Has question?"]`. 갈래 이름은 `-->|true|`. + +## 단계별 요청문 예 + +### 1. 글자로 트리와 mermaid + +```text +~/vibe/tflow 에 Python 프로젝트를 만들어 줘. uv 사용, 패키지·명령 이름 tflow, 코드와 화면 글자는 영어(설명만 한국어). +이번 단계는 TUI 없이 글자 출력만. +1. tflow/model.py: n8n 워크플로우 JSON을 읽어 Node(name, type, parameters)와 Link(source, target, kind, output_index) 목록으로. + kind는 main, ai_languageModel, ai_tool 등 connections의 키. output_index는 바깥 목록 순서. +2. tflow/tree.py: 웹훅(또는 들어오는 main 연결이 없는 노드)에서 main을 따라가는 트리 글자. + If는 0=true 1=false, Switch는 parameters.rules.values[i].outputKey 를 갈래 이름으로. 이미 나온 노드는 "↩ 이름 (above)"로 한 줄만. + ai_* 연결은 받는 Agent 아래에 "◇ model …", "◇ tool …"로. +3. tflow/mermaid.py: flowchart LR. id는 n0, n1…, 보이는 이름은 따옴표 안에, 갈래 이름은 화살표 글자, ai 연결은 점선(-.->). +4. tflow <파일> 은 트리, tflow <파일> --md 는 <파일 이름>.md 를 만든다 (--out 으로 경로 지정). + 내용: "# <워크플로우 이름>", "노드 N · 연결 M" 한 줄, 그리고 ```mermaid 블록. +5. pytest: 테스트용으로 ~/aio-01d4-stu/Last3Mon/68/agents/it-agent.json 을 tests/data 에 복사해 두고 + 노드 14, main 연결 11, ai_languageModel 1, ai_tool 2 를 확인. Send result 가 트리에 두 번 펼쳐지지 않는지 확인. +``` + +### 2. TUI · 상세 · 경고 + +```text +이제 Textual TUI를 붙여 줘. model.py 를 그대로 다시 써. +- 왼쪽 Tree 위젯(1단계 트리와 같은 모양), 오른쪽 상세 패널. 위에 경고 줄(없으면 숨김). +- 노드를 고르면 상세에 종류(type 마지막 부분)와 종류별 중요한 설정: + webhook: httpMethod, path / httpRequest·httpRequestTool: method, url / agent: systemMessage 앞 300자, 붙은 모델·Tool + code: jsCode 앞 10줄 / if·switch: 조건의 leftValue, operation, rightValue / set: 필드 이름과 값 +- 경고: 연결이 없는 노드를 가리킴, 어디서도 들어오지 않고 웹훅도 아닌 노드(모델·Tool 제외). +- 테스트: 연결 이름 하나를 틀리게 바꾼 파일, 떨어진 노드를 더한 파일을 tests/data 에 만들고 경고를 확인. +- m 키: --md 와 같은 .md 파일을 저장하고 아래 줄에 저장 경로를 알림 (mermaid.py 를 그대로 다시 씀). +- q 로 종료. 화면 테스트는 Textual의 run_test 로 하나만. +``` + +### 3. 한국어 설명과 캐시 + +```text +상세 패널에 "하는 일:" 한국어 1~2문장을 더해 줘. +- tflow/explain.py: Explainer 인터페이스(explain(node, context) -> str). 실제 구현은 OpenAI gpt-4.1-mini, 키는 OPENAI_API_KEY 환경 변수. + context = 워크플로우 이름, 앞 노드·뒤 노드 이름, 이 노드의 parameters (credentials 는 보내지 않음). +- 캐시: ~/.cache/tflow/explain.json, 키는 노드 type + parameters 의 sha256. 같으면 다시 묻지 않음. +- 키가 없거나 실패하면 "설명 없음 (키 없음)" 또는 "설명 실패: <짧은 이유>", 트리는 그대로. +- 설명은 백그라운드(worker)로 받아 화면이 멈추지 않게. +- 테스트는 FakeExplainer 로. 캐시에 두 번째 요청이 가지 않는지 확인. +``` + +## 7교시에 볼 것 (예상되는 막힘) + +| 볼 것 | 막혔을 때 한마디 | +| --- | --- | +| 트리가 끝없이 길어짐 / 같은 노드 반복 | "이미 나온 노드는 한 줄로 끝내라고 하세요" | +| mermaid가 안 그려짐 | "노드 이름을 id로 쓰지 말고 번호 id" | +| 미리보기에 그림 대신 글자만 | "블록이 ` ```mermaid `로 시작하는지, 앞뒤 펜스가 맞는지" | +| TUI 안에서 그림을 그리려 함 | "터미널은 못 그립니다. `.md`로 내보내 미리보기" | +| If 갈래가 반대 | "connections main 의 0번이 true" | +| 설명이 매번 느림 | "캐시 키를 무엇으로 했는지 물어보세요" | +| Credential이 LLM으로 감 | "credentials 는 빼고 보내라고" | diff --git a/Last3Mon/68/answers/68-lab01-challenge-split.json b/Last3Mon/68/answers/68-lab01-challenge-split.json new file mode 100644 index 0000000..af41825 --- /dev/null +++ b/Last3Mon/68/answers/68-lab01-challenge-split.json @@ -0,0 +1,204 @@ +{ + "name": "answer · 68 Lab 01 challenge (agent and missing input)", + "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, 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": "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/answers/68-lab02-challenge-supervisor.json b/Last3Mon/68/answers/68-lab02-challenge-supervisor.json new file mode 100644 index 0000000..f794a1e --- /dev/null +++ b/Last3Mon/68/answers/68-lab02-challenge-supervisor.json @@ -0,0 +1,301 @@ +{ + "name": "answer · 68 Lab 02 challenge (supervisor)", + "nodes": [ + { + "name": "Assistant webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 300 + ], + "parameters": { + "httpMethod": "POST", + "path": "my-supervisor", + "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": "Supervisor agent", + "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 are the company office assistant supervisor. You do not answer IT or meeting questions yourself. For each IT problem or IT ticket status question in the message, call Ask_IT_agent once with that one question in Korean. If the employee asks to organize meeting notes or action items, call Ask_meeting_agent once. For anything else (food, parties, personal advice), do not call a tool and answer exactly \"그 요청은 담당자에게 전달했습니다.\" Then answer in Korean, one short paragraph per task, using only the tool results. Keep names, dates and ticket status exactly as the tools wrote them.", + "maxIterations": 6, + "returnIntermediateSteps": true + } + } + }, + { + "name": "OpenAI chat model", + "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi", + "typeVersion": 1.2, + "position": [ + 300, + 520 + ], + "parameters": { + "model": { + "__rl": true, + "mode": "id", + "value": "gpt-4.1-mini" + }, + "options": { + "temperature": 0 + } + }, + "credentials": { + "openAiApi": { + "id": "openAiCourse0001", + "name": "OpenAI (.env)" + } + } + }, + { + "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 (password, account, VPN, printer, mail, software, or the status of a ticket like T-1002). 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 + } + } + } + } + }, + { + "name": "Ask meeting agent", + "type": "n8n-nodes-base.httpRequestTool", + "typeVersion": 4.2, + "position": [ + 660, + 520 + ], + "parameters": { + "toolDescription": "Ask the meeting agent to list the action items of the meeting notes attached to this request. Input: what the employee asked, in Korean.", + "method": "POST", + "url": "http://localhost:5678/webhook/meeting-agent", + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "request", + "value": "={{ $fromAI('request', 'What the employee asked about the meeting, in Korean', 'string') }}" + }, + { + "name": "title", + "value": "={{ $('Assistant webhook').first().json.body.title }}" + }, + { + "name": "notes", + "value": "={{ $('Assistant webhook').first().json.body.notes }}" + } + ] + }, + "options": { + "timeout": 60000, + "response": { + "response": { + "neverError": true + } + } + } + } + }, + { + "name": "Build reply", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 760, + 300 + ], + "parameters": { + "jsCode": "// Which agents did the supervisor call, and what did they answer?\nconst steps = $json.intermediateSteps ?? [];\nconst NAMES = { Ask_IT_agent: 'it-agent', Ask_meeting_agent: 'meeting-agent' };\nconst calls = steps.map(step => ({ agent: NAMES[step.action?.tool] ?? step.action?.tool, input: step.action?.toolInput ?? {} }));\nconst agentsUsed = [...new Set(calls.map(call => call.agent).filter(Boolean))];\nreturn [{ json: { reply: String($json.output ?? '').trim(), agents_used: agentsUsed, calls: calls.length } }];" + } + }, + { + "name": "Send result", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 980, + 300 + ], + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + "options": {} + } + } + ], + "connections": { + "Assistant webhook": { + "main": [ + [ + { + "node": "Has message?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Has message?": { + "main": [ + [ + { + "node": "Supervisor agent", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Reject empty", + "type": "main", + "index": 0 + } + ] + ] + }, + "OpenAI chat model": { + "ai_languageModel": [ + [ + { + "node": "Supervisor agent", + "type": "ai_languageModel", + "index": 0 + } + ] + ] + }, + "Ask IT agent": { + "ai_tool": [ + [ + { + "node": "Supervisor agent", + "type": "ai_tool", + "index": 0 + } + ] + ] + }, + "Ask meeting agent": { + "ai_tool": [ + [ + { + "node": "Supervisor agent", + "type": "ai_tool", + "index": 0 + } + ] + ] + }, + "Supervisor agent": { + "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/answers/68-lab03-challenge-cases.json b/Last3Mon/68/answers/68-lab03-challenge-cases.json new file mode 100644 index 0000000..bf96dec --- /dev/null +++ b/Last3Mon/68/answers/68-lab03-challenge-cases.json @@ -0,0 +1,104 @@ +{ + "webhook": "my-assistant", + "cases": [ + { + "name": "user 없이 IT 질문", + "body": { + "message": "VPN이 계속 끊겨요" + }, + "expect": { + "equals": { + "results.0.route": "입력 필요" + } + } + }, + { + "name": "남의 접수 건이 섞인 복합 요청", + "body": { + "message": "T-1002 처리 상태 알려주고 프린터도 안 돼요", + "user": "이준호" + }, + "expect": { + "equals": { + "count": 2, + "results.0.route": "담당자 연결", + "results.1.route": "자동 답변" + }, + "not_contains": { + "reply": [ + "정우성", + "인증서 발급" + ] + } + } + }, + { + "name": "회의록은 붙였지만 IT 질문만", + "body": { + "message": "VPN이 계속 끊겨요", + "user": "이준호", + "title": "주간 회의", + "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일." + }, + "expect": { + "equals": { + "count": 1, + "results.0.agent": "it-agent" + } + } + }, + { + "name": "업무 4개", + "body": { + "message": "VPN이 끊겨요. 프린터도 안 돼요. 회의록 할 일 정리해 주세요. 회식 장소도 추천해 주세요.", + "user": "이준호", + "title": "주간 회의", + "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일." + }, + "expect": { + "equals": { + "count": 4, + "results.2.agent": "meeting-agent", + "results.3.route": "담당자 연결" + } + } + }, + { + "name": "소문자 접수 번호", + "body": { + "message": "t1002 어떻게 되고 있나요?", + "user": "박서연" + }, + "expect": { + "contains": { + "results.0.answer": [ + "처리 중" + ] + } + } + }, + { + "name": "공백만 보냄", + "body": { + "message": " ", + "user": "이준호" + }, + "expect": { + "status": 400 + } + }, + { + "name": "영어로 질문", + "body": { + "message": "My VPN keeps disconnecting. What should I do?", + "user": "이준호" + }, + "expect": { + "equals": { + "results.0.agent": "it-agent", + "results.0.route": "자동 답변" + } + } + } + ] +} diff --git a/Last3Mon/68/answers/68-lab03-challenge-final.json b/Last3Mon/68/answers/68-lab03-challenge-final.json new file mode 100644 index 0000000..3fadef3 --- /dev/null +++ b/Last3Mon/68/answers/68-lab03-challenge-final.json @@ -0,0 +1,788 @@ +{ + "name": "answer · 68 Lab 03 challenge (final)", + "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": "={{ String($json.body.message ?? '').trim() }}", + "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" + } + }, + { + "id": "r-ready", + "leftValue": "={{ ($json.missing ?? []).length }}", + "rightValue": 0, + "operator": { + "type": "number", + "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": "={{ $('Route by type').item.json.agent }}", + "type": "string" + }, + { + "id": "a4", + "name": "route", + "value": "입력 필요", + "type": "string" + }, + { + "id": "a5", + "name": "answer", + "value": "={{ $('Route by type').item.json.missing.includes('notes') ? '회의록 정리를 하려면 회의록 내용(notes)을 함께 보내 주세요.' : '누가 요청했는지(user)를 함께 보내 주세요.' }}", + "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" + } +}