66일차 새 방식(CLI 코딩 에이전트로 n8n)으로 교체, 67일차 교안·실습 추가

This commit is contained in:
mcchae committed 2026-10-18 09:47:41 +09:00
1 parent d7dc9dd68a
commit bf55eaf772
120 files changed
+7398 -5988

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
runs/
tools/.cache/
my-work/*.json
+173
View File
@@ -0,0 +1,173 @@
# AGENTS.md · n8n 워크플로우 작업 규칙 (66일차 실습 폴더)
이 파일은 **CLI 코딩 에이전트**(Codex, Claude Code, Antigravity 등)가 이 폴더에서 n8n 워크플로우를 만들 때 따르는 규칙입니다. 사람도 읽을 수 있게 썼습니다. 3절이 오늘 만드는 **회의록 → 할 일 워크플로우의 계약**입니다.
## 1. 작업 순서 (항상 이 순서)
1. 요청을 읽고, 만들 워크플로우의 **입력 · 노드 순서 · 결과 모양**을 3~5줄로 먼저 정리해 보여 준다. 항목이 몇 개씩 지나가는지(1 → N → 1)도 함께 적는다.
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` (사람이 함) |
| 게시된 웹훅 주소 | 밖에서 `http://localhost:5678/webhook/<path>`, **n8n 안에서도** `http://localhost:5678/webhook/<path>` (같은 컨테이너) |
| OpenAI Credential | id `openAiCourse0001`, name `OpenAI (.env)`, 모델 `gpt-4.1-mini` |
| Ollama Credential (대체) | id `ollamaLocal0001`, name `Ollama local`, 모델 `qwen2.5:3b` |
| 데이터 API | 오늘은 쓰지 않음 (67일차부터) |
## 3. 오늘 만드는 것의 계약 (요청에 다른 말이 없으면 이대로)
워크플로우 **하나**(`my-work/meeting.json`, 웹훅 `POST /webhook/my-meeting`)를 Lab 01 → 02 → 03으로 키워 간다.
| Lab | 입력 (요청 본문) | 결과 |
| --- | --- | --- |
| Lab 01 | `title`, `notes` | `{"title", "summary"}` |
| Lab 02 | 같음 | `{"title", "summary", "count", "actions": [{"owner", "task", "due"}]}` |
| Lab 03 | 같음 | Lab 02 + 할 일마다 `needs_check`(불리언), 전체 `needs_check_count` |
- `notes`가 비었거나 공백뿐이면 400 `{"error": "notes are required"}` (LLM을 부르지 않음). `title`이 없으면 `"회의"`.
- 담당자(`owner`)나 기한(`due`)이 회의록에 없으면 `"미정"`. 지어내지 않는다.
- `needs_check`: `owner`나 `due`가 `"미정"`이면 `true`.
## 4. 워크플로우 파일 규칙
```json
{
"name": "66 Meeting actions (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.title`, `$json.body.notes`처럼 `body` 아래에 있다.
## 5. 검증된 노드 (n8n 2.42.6에서 실행 확인)
아래 `type`/`typeVersion`을 그대로 쓴다. 다른 노드가 필요하면 써도 되지만 `flow.py check`가 이 n8n에 있는지 확인한다.
### Webhook · If · Respond to Webhook
```json
{"name": "Meeting webhook", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [0, 300],
"parameters": {"httpMethod": "POST", "path": "my-meeting", "responseMode": "responseNode", "options": {}}}
```
```json
{"name": "Has notes?", "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.notes }}", "rightValue": "",
"operator": {"type": "string", "operation": "notEmpty", "singleValue": true}}], "combinator": "and"}, "options": {}}}
```
- If 출력 0번이 true, 1번이 false. 조건을 둘 넣으면 `combinator: "and"`. 불리언은 `"leftValue": "={{ $json.passed }}", "operator": {"type": "boolean", "operation": "true", "singleValue": true}`.
```json
{"name": "Send result", "type": "n8n-nodes-base.respondToWebhook", "typeVersion": 1.1, "position": [2600, 300],
"parameters": {"respondWith": "json", "responseBody": "={{ $json }}", "options": {}}}
```
- 오류 응답: `"responseBody": "{ \"error\": \"notes are required\" }"`, `"options": {"responseCode": 400}`
### AI Agent + OpenAI chat model (요약, 할 일 뽑기)
```json
{"name": "Extract actions", "type": "@n8n/n8n-nodes-langchain.agent", "typeVersion": 3.1, "position": [680, 300],
"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 every action item. Return only JSON, no code fence: {\"actions\": [...]} ...", "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` 한다 (앞뒤 ```` ``` ````를 지우고, 실패하면 빈 값).
- Chat model 노드 하나는 Agent **하나**에 연결한다. Agent가 둘이면 model 노드도 둘 (`Summary model`, `Actions model`).
### Code (JavaScript) · LLM 글자를 JSON으로
```json
{"name": "Parse actions", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [940, 300],
"parameters": {"jsCode": "const raw = String($json.output ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data = {};\ntry { data = JSON.parse(raw); } catch (error) { data = {}; }\nconst actions = (data.actions ?? []).map(a => ({ owner: a.owner || '미정', task: a.task, due: a.due || '미정' }));\nreturn [{ json: { count: actions.length, actions } }];"}}
```
- 반드시 `[{ json: {...} }]` 배열을 돌려준다. 앞 노드는 `$('노드 이름').first().json`.
- LLM에게 JSON을 달라고 해도 **글자**로 온다. 읽지 못하면 멈추지 말고 빈 목록으로.
### Split Out · Aggregate (항목 하나씩 → 다시 하나로)
```json
{"name": "One item per action", "type": "n8n-nodes-base.splitOut", "typeVersion": 1, "position": [1380, 200], "parameters": {"fieldToSplitOut": "actions", "options": {}}}
```
```json
{"name": "Collect actions", "type": "n8n-nodes-base.aggregate", "typeVersion": 1, "position": [1820, 200],
"parameters": {"aggregate": "aggregateAllItemData", "destinationFieldName": "actions", "options": {}}}
```
- Split Out: 목록 하나 → 항목 여러 개 (1 → N). 그 사이 노드는 **항목마다** 실행된다. Aggregate: 다시 목록 하나로 (N → 1).
- 목록이 비면 Split Out 뒤로 아무것도 흐르지 않아 응답이 없다 → 앞에 If(`count > 0`)를 두고 빈 경우는 따로 응답한다.
### Edit Fields (Set) · Merge (나눈 갈래 모으기)
```json
{"name": "Mark check", "type": "n8n-nodes-base.set", "typeVersion": 3.4, "position": [1600, 200],
"parameters": {"assignments": {"assignments": [
{"id": "a0", "name": "owner", "value": "={{ $json.owner }}", "type": "string"},
{"id": "a1", "name": "task", "value": "={{ $json.task }}", "type": "string"},
{"id": "a2", "name": "due", "value": "={{ $json.due }}", "type": "string"},
{"id": "a3", "name": "needs_check", "value": "={{ $json.owner === '미정' || $json.due === '미정' }}", "type": "boolean"}]}, "options": {}}}
```
```json
{"name": "Join results", "type": "n8n-nodes-base.merge", "typeVersion": 3, "position": [700, 300], "parameters": {"numberInputs": 2}}
```
- Set은 **적은 칸만** 남긴다.
- 한 노드에서 두 노드로 **함께** 보내려면 `main` 첫 칸에 둘을 넣는다 (갈래 나누기). Merge로 다시 모은다. 다음 Code에서 `$('Summarize').first().json.output`처럼 이름으로 읽는다.
## 6. 연결 (`connections`)
```json
{
"Meeting webhook": {"main": [[{"node": "Has notes?", "type": "main", "index": 0}]]},
"Has notes?": {"main": [[{"node": "Summarize", "type": "main", "index": 0}, {"node": "Extract actions", "type": "main", "index": 0}], [{"node": "Reject empty", "type": "main", "index": 0}]]},
"Summary model": {"ai_languageModel": [[{"node": "Summarize", "type": "ai_languageModel", "index": 0}]]},
"Summarize": {"main": [[{"node": "Join results", "type": "main", "index": 0}]]},
"Extract actions": {"main": [[{"node": "Join results", "type": "main", "index": 1}]]}
}
```
- 키는 **보내는 노드 이름**, 안의 `node`는 받는 노드 이름. If의 `main`은 `[ [true 쪽], [false 쪽] ]`.
- Chat model은 `ai_languageModel`로 **Agent에** 연결한다 (`main` 아님).
- Merge로 들어가는 연결은 `index`가 입력 번호 (0, 1, 2 …).
## 7. 표현식 주의
- 표현식은 `=`로 시작한다: `"={{ $json.body.notes }}"`. `=`가 없으면 그냥 글자로 남는다.
- `{{ }}` 안에서 객체를 만들 때 `}}`가 붙지 않게 띄어 쓴다: `"={{ { count: $json.count } }}"`
- 다른 노드 뒤에서 웹훅 입력은 `$('Meeting webhook').first().json.body`로 읽는다 (`$json`은 바로 앞 노드 결과).
## 8. 테스트 파일 (`cases/*.json`)
```json
{
"webhook": "my-meeting",
"cases": [
{"name": "주간 회의", "request": "requests/weekly.json",
"expect": {"status": 200, "equals": {"count": 4, "actions.0.owner": "이준호"}, "contains": {"actions.0.due": ["금요일"]}}},
{"name": "직접 쓴 요청", "body": {"title": "x", "notes": ""}, "expect": {"status": 400}}
]
}
```
- 필드 이름은 점으로 내려간다: `actions.0.owner` = `actions` 첫 항목의 `owner`.
- `equals`: 값이 같아야 함 · `contains`: 목록 중 **하나라도** 들어 있어야 함 (배열·객체는 JSON 글자로 바꿔 찾음) · `not_contains`: 하나도 없어야 함 · `status`: HTTP 코드(기본 200)
- `uv run tools/flow.py test <파일> --repeat 3`: LLM 답은 매번 조금씩 다르므로 3번 이상 돌려 본다.
- **테스트를 통과시키려고 테스트 파일의 기대 결과를 바꾸지 않는다.** 기대 결과가 틀렸다고 생각되면 사람에게 묻는다.
## 9. 하지 말 것
- `tools/`, `requests/`, `reference/`, 이 파일(`AGENTS.md`)을 고치지 않는다 (요청받은 경우만).
- n8n 컨테이너를 지우거나 `docker compose down`을 하지 않는다. `~/day65-n8n`(n8n 설치·비밀값)을 읽거나 고치지 않는다. Credential을 새로 만들지 않는다.
- `.env`나 API 키를 읽어서 출력하지 않는다.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
-108
View File
@@ -1,108 +0,0 @@
# Lab 01 · 회의록 자동 정리 워크플로우 설계하기
- 관련 강의: [1단계 · 단일 업무 자동화 설계와 데이터 흐름](../../Lecture/66/01-STAGE1-DESIGN-AND-DATA-FLOW.md)
- 소요 시간: 약 22분 (예제 1 5분 · 예제 2 7분 · 과제 10분)
- 목표: 노드를 만들기 전에 **연결선마다 몇 개의 항목이 흐르는지** 읽고 쓸 수 있다.
- 결과물: `my-work/LAB-01-DESIGN.md`
```mermaid
flowchart LR
E1["예제 1<br/>어제 만든 흐름 읽기"] --> E2["예제 2<br/>오늘 흐름의 빈칸 채우기"] --> T["과제<br/>요청이 바뀌면<br/>어디를 고치나?"]
```
먼저 활동지를 복사합니다.
```bash
cd ~/aio-01d4-stu/Last3Mon/66
mkdir -p my-work
cp templates/LAB-01-WORKSHEET.md my-work/LAB-01-DESIGN.md
```
## 예제 1 · 어제 만든 흐름 읽기 (5분)
65일차 Lab 02A에서 직접 만든 `65-00 My first workflow`입니다.
```mermaid
flowchart LR
T(["Start<br/>Manual Trigger"]) --> S["Sample request<br/>Edit Fields"] --> I{"Is urgent?<br/>If"}
I -->|true| U["Urgent route"]
I -->|false| N["Normal route"]
```
활동지 1절의 표를 채웁니다. 항목(item)은 노드 사이를 지나가는 **데이터 한 덩어리**입니다.
| 연결 | 항목 수 | 지나가는 필드 |
| --- | --- | --- |
| Start → Sample request | 1 | (없음) |
| Sample request → Is urgent? | ( ? ) | `user`, `text` |
| Is urgent? → Urgent route | ( ? ) | `user`, `text` |
<details><summary>답 보기</summary>
모두 **1개**입니다. If 노드는 항목을 **나누어 보낼 뿐** 개수를 늘리지 않습니다. 요청 1건은 true나 false 중 **한쪽 길로만** 갑니다.
</details>
## 예제 2 · 오늘 흐름의 빈칸 채우기 (7분)
오늘 만들 회의록 워크플로우입니다. 회의록 1건에서 할 일 4건이 나옵니다 (예시: `n8n/requests/weekly.json`).
```mermaid
flowchart LR
W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"}
H -->|false| R0["Reject empty"]
H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results"]
H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J
J --> A{"Any actions?"}
A -->|true| SO["One item per action<br/>Split Out"] --> N["Normalize action"] --> AG["Collect actions<br/>Aggregate"] --> B["Build report"] --> R["Send result"]
A -->|false| NA["No actions"] --> R
```
활동지 2절의 빈칸 다섯 개를 채웁니다.
| 번호 | 질문 | 답 |
| --- | --- | --- |
| ① | `Parse actions` → `Join results` 의 항목 수 | ( ? ) |
| ② | `One item per action` → `Normalize action` 의 항목 수 | ( ? ) |
| ③ | `Collect actions` → `Build report` 의 항목 수 | ( ? ) |
| ④ | `LLM extract actions`와 `LLM summarize`를 **동시에** 돌려도 되는 이유 | ( ? ) |
| ⑤ | 할 일 찾기·요약·`미정` 채우기·우선순위 정하기 중 **LLM이 하는 일** 두 가지 | ( ? ) |
<details><summary>답 보기</summary>
| 번호 | 답 |
| --- | --- |
| ① | 1 (할 일 4건이 `actions` **목록 하나**에 들어 있음) |
| ② | **4** (Split Out이 목록을 할 일 하나씩으로 펼침, 1 → 4) |
| ③ | **1** (Aggregate가 다시 목록 하나로 모음, 4 → 1) |
| ④ | 할 일 찾기와 요약은 **서로의 결과가 필요 없기** 때문 |
| ⑤ | 할 일 찾기, 요약. `미정` 채우기와 우선순위는 **규칙**(항상 같은 답) |
</details>
## 과제 · 요청이 바뀌면 어디를 고치나? (10분)
팀장이 새로 요청했습니다.
> "담당자나 기한이 정해지지 않은 할 일은 **확인 필요**로 표시해 주세요. 그리고 확인이 필요한 할 일이 **몇 건인지**도 결과에 넣어 주세요."
예제 2의 그림을 보면서 활동지 3절의 질문에 답합니다. 노드를 새로 만들 필요가 있는지, 있던 노드를 고치면 되는지 생각해 보세요.
1. 이 판단은 LLM이 할 일인가요, 규칙이 할 일인가요? 왜 그런가요?
2. "이 할 일은 확인 필요인가?"는 할 일 **하나마다** 정하는 일입니다. 그림에서 할 일이 하나씩 흐르는 노드는 어디인가요? 거기에 어떤 필드를 더하나요?
3. "몇 건인지"는 할 일을 **모두 모은 뒤**에야 셀 수 있습니다. 어느 노드에서 세나요?
4. 할 일이 하나도 없는 회의(`No actions` 길)의 결과에는 무엇을 넣어야 하나요?
5. 이 변경으로 연결선의 항목 수가 바뀌는 곳이 있나요?
> 정답은 하나가 아닙니다. 위치와 이유를 한 줄씩 적으면 됩니다. 이 설계는 **Lab 03 과제**에서 실제로 만듭니다.
<details><summary>힌트</summary>
- 규칙으로 쓸 수 있는지 문장으로 적어 보세요: "담당자가 `미정`이거나 기한이 `미정`이면 확인 필요".
- 할 일 하나마다 우선순위(`priority`)를 정하던 노드를 떠올려 보세요.
- 결과 형식은 두 길이 **같은 필드**로 끝나야 합니다 (1단계 2.1절).
</details>
## 확인
- [ ] 예제 1의 표를 채우고, If가 항목 수를 바꾸지 않는다는 것을 설명할 수 있다.
- [ ] 예제 2의 ①~⑤를 채웠다 (1 → 4 → 1).
- [ ] 과제 1~5에 위치와 이유를 한 줄씩 적었다.
+141
View File
@@ -0,0 +1,141 @@
# Lab 01 · 결과 먼저, 첫 요청문
- 관련 강의: [1단계 · 바이브 코딩으로 n8n 만들기, 결과 먼저](../../Lecture/66/01-STAGE1-VIBE-N8N-AND-RESULT-FIRST.md)
- 소요 시간: 약 40분 (예제 1 10분 · 예제 2 15분 · 과제 15분)
- 준비: [실습 준비](../../Lecture/66/00-DAY66-PREP.md)의 `uv run tools/flow.py doctor` → `ready`
- 목표: 만들기 전에 **결과를 먼저** 정하고, 노드를 끌어다 놓지 않고 **내가 쓴 요청문**으로 첫 워크플로우를 만들어 **테스트로 확인**한다.
```mermaid
flowchart LR
E1["예제 1<br/>결과 먼저<br/>(사람)"] --> E2["예제 2<br/>첫 요청문<br/>요약"] --> T["과제<br/>빈 회의록 ·<br/>제목 없음"]
```
> 이 실습에는 **복사해 붙일 요청문이 없습니다.** 요청문은 직접 씁니다. 막히면 아래 `▶` 를 하나씩 펼쳐 보세요. 펼칠수록 구체적이 되지만, 정답 요청문은 나오지 않습니다.
## 시작하기 · 터미널 두 개
| 터미널 | 하는 일 |
| --- | --- |
| ① 에이전트 | `cd ~/aio-01d4-stu/Last3Mon/66` 후 `codex` (또는 `claude`, `agy`) 실행. 내가 쓴 요청문을 보낸다 |
| ② 확인 | `cd ~/aio-01d4-stu/Last3Mon/66` 에서 `push`·`test`를 **내가** 실행한다 |
- 에이전트는 이 폴더의 [AGENTS.md](AGENTS.md)를 읽고 일합니다 (노드 모양, Credential, 오늘의 계약). `agy`가 읽지 않으면 첫 요청 앞에 "AGENTS.md를 먼저 읽어"를 붙입니다.
- 에이전트가 명령 실행이나 파일 쓰기를 허락해 달라고 하면 **무엇을 하는지 읽고** 허용합니다.
- 요청문은 **메모장에 먼저 쓰고** 한 번 읽어 본 뒤 보냅니다. `my-work/PROMPTS.md`에 모아 둡니다.
## 예제 1 · 결과 먼저 (10분, 에이전트 없이)
`cat requests/weekly.json`으로 회의록을 읽고 `my-work/RESULT-FIRST.md`에 두 가지를 씁니다.
1. 오늘 끝(Lab 03)에 이 회의록을 넣으면 나와야 할 **결과 JSON**을 손으로 써 봅니다 ([AGENTS.md](AGENTS.md) 3절 모양).
2. 아래 데이터 흐름 표의 빈칸 (항목 수)
| 단계 | 들어오는 항목 수 | 나가는 항목 수 |
| --- | --- | --- |
| 웹훅 | | 1 |
| 할 일 뽑기 (LLM) | 1 | |
| 할 일 하나씩 | | |
| 다시 모으기 | | |
<details><summary>▶ 1단계 · 왜 결과부터</summary>
결과를 정하지 않으면 에이전트가 만든 것이 맞는지 판단할 기준이 없습니다. 내가 쓴 결과 JSON이 곧 **테스트의 기대 결과**가 됩니다. `cases/lab03.json`을 열어 내 결과와 비교해 보세요.
</details>
<details><summary>▶ 2단계 · 회의록에서 헷갈리는 곳</summary>
4번 "회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다"의 담당자와 기한은 무엇인가요? 회의록에 없는 것을 채워 넣으면 안 됩니다. "다음 회의는 10월 23일"은 할 일인가요?
</details>
## 예제 2 · 첫 요청문: 회의 요약 (15분)
**만들 것**: 회의록을 받아 한 문장으로 요약하는 워크플로우. 할 일은 아직 뽑지 않습니다.
| 조건 | 값 |
| --- | --- |
| 저장할 파일 | `my-work/meeting.json` (오늘 끝까지 이 파일을 고쳐 나감) |
| 웹훅 | POST, 경로 `my-meeting`, 본문 `title`, `notes` |
| 모델 | OpenAI `gpt-4.1-mini` |
| 결과 | `{"title": "...", "summary": "한 문장"}` |
| 기대 결과 | `cases/lab01.json` |
<details><summary>▶ 1단계 · 요청문에 들어갈 다섯 가지</summary>
[1단계 교안](../../Lecture/66/01-STAGE1-VIBE-N8N-AND-RESULT-FIRST.md) 4절: ① 파일 ② 입력 ③ 처리 ④ 결과 모양 ⑤ 기대 결과. 위 표의 줄이 각각 어디에 해당하는지 짝지어 봅니다.
</details>
<details><summary>▶ 2단계 · 빠뜨리기 쉬운 것</summary>
- 요약의 **길이와 언어**를 정했나요? 정하지 않으면 길게 씁니다.
- "회의록에 있는 것만"을 적었나요?
- "할 일은 아직 뽑지 않는다"처럼 **하지 말 것**도 적어야 에이전트가 앞서 나가지 않습니다.
</details>
<details><summary>▶ 3단계 · 보내기 전 점검표</summary>
- [ ] 파일 경로 · 웹훅 경로 · 입력 필드
- [ ] 모델 이름
- [ ] 결과 JSON 모양 (필드 이름 `title`, `summary`)
- [ ] 테스트 파일 경로, "check까지 하고 명령을 알려 달라"
</details>
에이전트가 끝났다고 하면 터미널 ②에서:
```bash
uv run tools/flow.py push my-work/meeting.json
uv run tools/flow.py call my-meeting requests/weekly.json
uv run tools/flow.py test cases/lab01.json --repeat 3
```
**관찰**: 브라우저 n8n(http://localhost:5678)에서 워크플로우를 열어 봅니다. 64·65일차에 손으로 놓던 노드가 어떻게 놓였나요? **Executions**에서 방금 실행을 열어 LLM이 받은 글을 봅니다.
## 과제 · 빈 회의록과 제목 없는 요청 (15분)
먼저 보내 봅니다.
```bash
uv run tools/flow.py call my-meeting requests/empty.json
uv run tools/flow.py call my-meeting requests/no-title.json
```
팀장이 요청했습니다.
> "빈 회의록이 오면 AI를 부르지 말고 바로 돌려보내 주세요. 제목이 없으면 '회의'라고 해 주고요."
기대 결과는 `cases/lab01-challenge.json` (빈 회의록 400, 공백만 보내도 400, 제목 없음 → `회의`, 원래 요약 그대로).
<details><summary>▶ 1단계 · 지금 무슨 일이 일어나나</summary>
빈 회의록에 지금 워크플로우는 무엇을 돌려주나요? LLM이 "내용이 없다"는 글을 만들어 냈다면, 돈과 시간을 쓰고 쓸모없는 답을 받은 것입니다. **어디서** 막아야 LLM을 부르지 않을까요?
</details>
<details><summary>▶ 2단계 · 공백만 보낸 회의록</summary>
`"notes": " "`는 비어 있나요? 사람 눈에는 비었지만 프로그램에는 글자 3개입니다. 요청문에 어떻게 적어야 할까요?
</details>
<details><summary>▶ 3단계 · 고치는 요청문</summary>
새로 만드는 게 아니라 **고치는** 요청입니다. 고칠 파일, 바꿀 것(입력 확인, 제목 기본값), 그대로 둘 것(요약), 그리고 `cases/lab01.json`도 계속 통과해야 한다는 조건.
</details>
```bash
uv run tools/flow.py push my-work/meeting.json
uv run tools/flow.py test cases/lab01-challenge.json
uv run tools/flow.py test cases/lab01.json
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `check`에 ✘ | 에이전트에게 그 줄을 그대로 붙여 주고 고쳐 달라고 합니다 |
| `push` 후 `call`이 404 | 몇 초 뒤 다시. 계속이면 `uv run tools/flow.py doctor` |
| 에이전트가 계속 엉뚱하게 고침 | 요청을 더 작게 나눕니다. 그래도 안 되면 `cp reference/lab01-example2-summary.json my-work/meeting.json` 후 push |
## 확인
- [ ] 예제 1: 결과 JSON과 항목 수 표를 썼다.
- [ ] 예제 2: 노드를 직접 놓지 않고 `cases/lab01.json` 통과.
- [ ] 과제: `cases/lab01-challenge.json` 통과, 원래 테스트도 통과.
- [ ] `my-work/PROMPTS.md`에 요청문을 모았다.
-191
View File
@@ -1,191 +0,0 @@
# Lab 02 · LLM 답을 데이터로 바꾸기 (입력 → LLM → 파싱)
- 관련 강의: [2단계 · 데이터 파싱과 변환 노드](../../Lecture/66/02-STAGE2-PARSE-AND-TRANSFORM.md)
- 소요 시간: 약 55분 (예제 1 15분 · 예제 2 15분 · 과제 25분)
- 준비: 65일차 n8n 실행 (`cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start`), Windows 브라우저에서 http://localhost:5678
- 목표: LLM의 답은 **글자**라는 것을 눈으로 확인하고, Code 노드로 **목록**으로 바꾼다. 그다음 뽑을 정보를 하나 늘려 본다.
```mermaid
flowchart LR
E1["예제 1<br/>LLM 없이<br/>글자 → 목록"] --> E2["예제 2<br/>진짜 LLM이 붙은<br/>1부 실행"] --> T["과제<br/>참석자도 뽑기"]
```
> 노드 이름은 뒤의 표현식에서 그대로 쓰므로 **안내와 똑같이** 지어 주세요. 노드 이름은 노드를 열고 맨 위 제목을 눌러 바꿉니다.
## 예제 1 · LLM 없이 "글자 → 목록" (15분)
LLM을 부르기 전에, LLM이 돌려줄 법한 답을 **직접 적어 넣고** 파싱부터 연습합니다. 돈도 시간도 들지 않고 결과가 항상 같습니다.
```mermaid
flowchart LR
T(["Start<br/>Manual Trigger"]) --> F["Fake LLM answer<br/>Edit Fields"] --> P["Parse actions<br/>Code"]
```
1. 왼쪽 위 **+ (Create) → Workflow**. 이름을 `66 Practice 1 · parse text`로 바꿉니다.
2. **Add first step**에서 `Manual Trigger`(Trigger manually)를 추가하고 이름을 `Start`로 바꿉니다.
3. 오른쪽 `+` → **Edit Fields (Set)**, 이름 `Fake LLM answer`. **Add Field**로 필드 하나를 만듭니다.
| Name | Type | Value (Fixed 모드 그대로) |
| --- | --- | --- |
| `content` | String | 아래 한 줄 |
```text
{"actions": [{"task": "신규 고객 문의 응답 템플릿 정리", "owner": "이준호", "due": "금요일"}, {"task": "회의실 예약 규칙 개선", "owner": "", "due": ""}]}
```
4. 오른쪽 `+` → **Code**, 이름 `Parse actions`. Language `JavaScript`, Mode `Run Once for All Items`. 원래 있던 코드를 지우고 아래를 붙여 넣습니다.
```javascript
// Parse the answer text (JSON written as text) into a clean list of action items.
const raw = String($input.first().json.content ?? '').trim().replace(/^```(json)?\s*|```$/g, '');
let data;
try {
data = JSON.parse(raw);
} catch (error) {
return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }];
}
const items = Array.isArray(data) ? data : (data.actions ?? []);
const actions = items
.filter(item => item && String(item.task ?? '').trim())
.map(item => ({
task: String(item.task).trim(),
owner: String(item.owner || '미정').trim() || '미정',
due: String(item.due || '미정').trim() || '미정',
}));
return [{ json: { actions, count: actions.length } }];
```
5. 아래쪽 **Execute workflow**를 누르고 두 노드의 OUTPUT을 비교합니다. (오른쪽 위 `Table / JSON / Schema` 보기를 바꿔 가며 봅니다.)
| 노드 | 보이는 것 |
| --- | --- |
| Fake LLM answer | `content` **한 칸**에 긴 글자 한 줄 |
| Parse actions | `actions`가 **목록 2개**로 펼쳐짐, `count: 2`, 두 번째 할 일의 `owner`·`due`가 `미정` |
6. **답을 일부러 망가뜨려 봅니다.** `Fake LLM answer`의 값에서 **맨 끝 `}` 하나를 지우고** 다시 실행합니다.
| 결과 | 뜻 |
| --- | --- |
| `{"actions": [], "count": 0, "error": "answer is not valid JSON"}` | 망가진 답이 와도 **같은 모양**으로 끝나고, 이유(`error`)가 남음 |
확인한 뒤 지운 `}`를 다시 넣고 **Save** 합니다.
## 예제 2 · 진짜 LLM이 붙은 1부 실행하기 (15분)
이번에는 같은 파싱 앞에 **Webhook과 LLM**이 붙은 워크플로우를 가져와 실행합니다.
```mermaid
flowchart LR
W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"}
H -->|false| R0["Reject empty<br/>(400)"]
H -->|true| L{{"LLM extract actions"}} --> P["Parse actions"] --> R["Send result"]
```
1. **+ (Create) → Workflow** → 오른쪽 위 **⋯ → Import from File...** → `Last3Mon/66/n8n/workflows/lab02-example2-meeting-part1.json`
- Windows 파일 선택 창 주소창: `\\wsl$\Ubuntu\home\<사용자>\aio-01d4-stu\Last3Mon\66\n8n\workflows`
2. 이름이 `66 Meeting notes (my)`인지 확인하고 **Save** 합니다. (Lab 03에서 이 워크플로우를 이어서 완성합니다.)
3. 노드 세 개를 열어 **어디서 무엇을 하는지** 확인합니다.
| 노드 | 볼 곳 |
| --- | --- |
| `Settings` | `title`, `notes`가 Webhook 입력(`$json.body`)에서 꺼낸 값 |
| `LLM extract actions` | 아래로 내려 **JSON** 칸: ① `schema`(답의 모양) ② `role: 'system'`(지시문) ③ `role: 'user'`(회의록) |
| `Parse actions` | 예제 1과 같은 코드. 첫 줄만 `choices[0].message.content`(LLM 답이 있는 곳)를 읽음 |
4. `Meeting webhook` 노드를 열고 **Listen for test event**를 누른 뒤, WSL 터미널에서 보냅니다.
```bash
cd ~/aio-01d4-stu/Last3Mon/66
curl -s -m 300 -X POST http://localhost:5678/webhook-test/my-meeting-actions \
-H 'Content-Type: application/json' -d @n8n/requests/weekly.json
```
5. 결과와 캔버스의 OUTPUT을 확인합니다. (수업 준비 때 `gpt-4.1-mini`로 실제 실행한 결과)
```text
{"actions":[{"task":"신규 고객 문의 응답 템플릿 정리","owner":"이준호","due":"금요일"},
{"task":"VPN 장애 보고서 작성","owner":"박서연","due":"다음 주 월요일"},
{"task":"서버 점검 일정 공지","owner":"김민지","due":"오늘"},
{"task":"회의실 예약 규칙 개선 담당자 선정","owner":"미정","due":"10월 23일"}],"count":4}
```
| 노드 | 확인할 것 |
| --- | --- |
| LLM extract actions | `choices[0].message.content`가 따옴표로 둘러싸인 **글자** (예제 1의 `Fake LLM answer`와 같은 모양) |
| Parse actions | `actions`가 **목록 4개**, 4번째 `owner`가 `미정` (`due`는 LLM이 "다음 회의는 10월 23일"을 보고 `10월 23일`로 채우는 경우가 많음) |
6. Listen을 다시 누르고 `empty.json` → HTTP **400** `{"error":"notes is required"}`, `no-action.json` → `{"actions":[],"count":0}` 도 확인합니다. (Test URL은 **한 번에 한 요청**만 받습니다.)
## 과제 · 참석자도 뽑기 (25분)
팀장이 요청했습니다.
> "할 일과 함께 **회의 참석자 명단**도 받아 보고 싶어요."
예제 2의 `66 Meeting notes (my)`를 고쳐서, 결과에 `attendees`(참석자 이름 목록)가 들어가게 만드세요.
| 요청 | 기대 결과 |
| --- | --- |
| `weekly.json` | 할 일 4건과 함께 `"attendees":["김민지","이준호","박서연"]` |
| `no-action.json` | `"attendees":[]` (참석자가 적혀 있지 않음) |
| `empty.json` | 그대로 HTTP 400 |
**먼저 생각해 보기 (3분)**: LLM에게 무엇을 더 알려 줘야 하나요? LLM 답이 바뀌면 그다음 노드는 무엇을 더 꺼내야 하나요? 고칠 곳은 **세 군데**입니다. 활동지나 메모장에 세 곳을 먼저 적은 뒤 고칩니다.
<details><summary>힌트 1 · 고칠 세 곳</summary>
| 곳 | 노드 | 무엇을 |
| --- | --- | --- |
| ① 답의 모양 | `LLM extract actions`의 `schema` | `properties`에 `attendees`(글자 목록) 추가, `required`에도 추가 |
| ② 지시문 | `LLM extract actions`의 `role: 'system'` 문장 | 참석자 이름도 `attendees`에 넣으라고 한 문장 추가 |
| ③ 꺼내기 | `Parse actions`의 마지막 `return` | `attendees`도 돌려주기 (없으면 `[]`) |
</details>
<details><summary>힌트 2 · ① schema에 넣을 한 줄</summary>
`actions: { ... } },` 줄 **바로 아래**에 넣습니다.
```text
attendees: { type: 'array', items: { type: 'string' } },
```
그리고 `required: ['actions'],` 를 `required: ['actions', 'attendees'],` 로 바꿉니다.
</details>
<details><summary>힌트 3 · ② 지시문과 ③ 코드</summary>
② `"... when there is no action item.` 뒤, 닫는 따옴표 `"` **앞**에 붙입니다.
```text
Also list the attendee names in \"attendees\" (use [] when not stated).
```
③ 마지막 `return` 줄을 아래 두 줄로 바꿉니다.
```javascript
const attendees = Array.isArray(data.attendees) ? data.attendees.map(name => String(name).trim()).filter(Boolean) : [];
return [{ json: { actions, count: actions.length, attendees } }];
```
</details>
**테스트**: Listen for test event → `weekly.json`, 다시 Listen → `no-action.json`, 다시 Listen → `empty.json`. 세 결과를 위 표와 비교합니다.
> 참석자를 넣었더니 **할 일이 3건**으로 줄었다면 한 번 더 보내 보세요. 수업 준비 때 8번 중 1번 이런 일이 있었습니다. 뽑을 것을 늘리면 원래 뽑던 결과도 흔들릴 수 있으니, 고친 뒤에는 **원래 기대 결과(할 일 4건)도 다시** 확인합니다.
**더 생각해 보기 (빨리 끝난 사람)**
- ①만 고치고 ②를 안 고치면? ③을 안 고치면? 하나씩 되돌려 실행해 보고, 어느 노드의 OUTPUT에서 `attendees`가 사라지는지 찾아보세요.
- 참석자 명단은 LLM이 찾는 일인가요, 규칙이 하는 일인가요?
## 막혔을 때
- `invalid syntax`: JSON 칸에서 `}`와 `}`가 **붙은** 곳이 있는지 봅니다. 표현식 `{{ }}` 안에서 `}}`가 붙으면 표현식이 거기서 끝납니다. `} }`처럼 띄어 씁니다.
- `401`: `LLM extract actions`의 Credential이 `OpenAI (.env)`인지 확인합니다. 없다면 65일차 SETUP 6단계(`register-credentials.sh`)를 다시 실행합니다.
- 고치다가 망가졌다면: 워크플로우를 지우고 예제 2의 파일을 **다시 가져옵니다**.
- Test URL 404: Listen for test event를 다시 누릅니다.
## 확인
- [ ] 예제 1: `Fake LLM answer`(글자)와 `Parse actions`(목록)의 차이를 OUTPUT으로 확인했다. 망가진 답에서 `error`를 봤다.
- [ ] 예제 2: `weekly` 4건, `empty` 400, `no-action` 0건을 확인했다.
- [ ] 과제: `weekly`에서 `attendees` 3명, `no-action`에서 `[]`를 받았다.
+108
View File
@@ -0,0 +1,108 @@
# Lab 02 · LLM에게 JSON으로 받고, 코드로 읽기
- 관련 강의: [2단계 · LLM JSON 출력, 파싱과 변환](../../Lecture/66/02-STAGE2-JSON-AND-PARSE.md)
- 소요 시간: 약 70분 (예제 1 20분 · 예제 2 15분 · 과제 35분)
- 준비: Lab 01의 `my-work/meeting.json` (없으면 `cp reference/lab01-example2-summary.json my-work/meeting.json` 후 push)
- 목표: LLM에게 할 일을 **정해진 JSON 모양**으로 받고, 그 글자를 **코드로 읽어** 목록으로 만든다. 회의록에 없는 값은 지어내지 않고 `미정`으로 채운다.
```mermaid
flowchart LR
E1["예제 1<br/>할 일을 JSON으로"] --> E2["예제 2<br/>빈칸은 미정"] --> T["과제<br/>참석자도 뽑기"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · 할 일을 JSON으로 받아 읽기 (20분)
**만들 것**: Lab 01 워크플로우에 **할 일 뽑기**를 더한다. 요약은 그대로.
| 조건 | 값 |
| --- | --- |
| 고칠 파일 | `my-work/meeting.json` |
| 처리 | 요약 다음에 LLM이 할 일을 JSON으로 → 다음 노드에서 그 글자를 JSON으로 읽기 |
| 결과 | `{"title", "summary", "count", "actions": [{"owner", "task", "due"}]}` |
| 기대 결과 | `cases/lab02.json` 앞 세 케이스 (나머지는 예제 2) |
<details><summary>▶ 1단계 · LLM에게 모양을 알려 주기</summary>
"할 일을 정리해 줘"만 쓰면 LLM은 보기 좋은 글로 답합니다. 프로그램이 읽으려면 **필드 이름까지 정한 JSON 모양**을 지시문에 넣어야 합니다. 예시 한 줄을 넣으면 더 잘 따릅니다.
</details>
<details><summary>▶ 2단계 · LLM의 답은 글자</summary>
JSON을 달라고 해도 n8n이 받는 것은 **글자**입니다. 앞뒤에 ```` ``` ````를 붙이거나 설명을 붙이기도 합니다. 읽는 노드에서 무엇을 지우고, 읽지 못하면 어떻게 할지(멈추지 말고 빈 목록) 요청문에 적습니다.
</details>
<details><summary>▶ 3단계 · 순서</summary>
테스트는 `actions.0`이 이준호의 할 일이라고 봅니다. 회의록에 나온 **순서대로** 달라고 적었나요?
</details>
```bash
uv run tools/flow.py push my-work/meeting.json
uv run tools/flow.py test cases/lab02.json
```
## 예제 2 · 빈칸은 미정, 할 일 없는 회의 (15분)
```bash
uv run tools/flow.py call my-meeting requests/no-action.json
```
| 케이스 | 기대 |
| --- | --- |
| 담당자 없는 할 일 (4번) | `owner: "미정"` |
| 할 일 없는 회의 | `count: 0`, `actions: []` |
| 빈 회의록 | 400 (Lab 01 과제) |
<details><summary>▶ 1단계 · 누가 "미정"을 쓰나</summary>
LLM에게 "없으면 미정"이라고 시킬 수도, LLM은 빈 칸으로 두고 **코드가** 채울 수도 있습니다. 어느 쪽이 매번 같은 답을 낼까요?
</details>
<details><summary>▶ 2단계 · 지어내지 않기</summary>
LLM은 빈칸을 그럴듯한 값으로 채울 수도 있습니다. "회의록에 없으면 비워 둘 것, 지어내지 말 것"을 지시문에 넣었나요?
</details>
```bash
uv run tools/flow.py push my-work/meeting.json
uv run tools/flow.py test cases/lab02.json --repeat 3
```
## 과제 · 참석자도 뽑기 (35분)
팀장이 요청했습니다.
> "할 일 목록 옆에 참석자 명단도 주세요. 회의록의 '참석:' 줄에 있는 사람만요. 없으면 빈 목록으로."
기대 결과는 `cases/lab02-challenge.json` (참석자 3명, 2명, 참석 줄이 없는 회의는 `[]`, **할 일은 그대로 4개**).
<details><summary>▶ 1단계 · 어디를 고치나</summary>
참석자를 뽑으려면 몇 곳을 고쳐야 할까요? LLM에게 주는 **모양**, 읽는 **코드**, **결과** 필드. 세 곳 중 하나라도 빠지면 테스트가 무엇을 보여 줄지 생각해 봅니다.
</details>
<details><summary>▶ 2단계 · 하나 바꾸면 다른 것이 바뀐다</summary>
지시문을 고치면 LLM은 **고치지 않은 부분**도 다르게 답할 수 있습니다. 과제 테스트의 마지막 케이스("할 일은 그대로")와 `cases/lab02.json`을 꼭 같이 돌립니다. 할 일 개수가 바뀌었다면 무엇이 빠졌는지 보고서에서 찾고, 지시문에 무엇을 더할지 정합니다.
</details>
```bash
uv run tools/flow.py push my-work/meeting.json
uv run tools/flow.py test cases/lab02-challenge.json --repeat 3
uv run tools/flow.py test cases/lab02.json
```
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| `count: 0`인데 회의록에는 할 일이 있음 | 읽는 코드가 실패해 빈 목록이 됐을 수 있음 → Executions에서 LLM이 돌려준 글자를 확인 |
| `actions`가 글자 하나로 나옴 | JSON으로 읽지 않고 그대로 넘김 → 읽는 노드 확인 |
| 계속 안 됨 | `cp reference/lab02-example2-actions.json my-work/meeting.json` 후 push |
## 확인
- [ ] 예제 1·2: `cases/lab02.json` 3번 반복 통과.
- [ ] 과제: `cases/lab02-challenge.json` 통과, 할 일 개수가 바뀌지 않았다.
-258
View File
@@ -1,258 +0,0 @@
# Lab 03 · 반복·병렬로 회의록 워크플로우 완성하기
- 관련 강의: [3단계 · 반복·병렬 제어 구조와 완성](../../Lecture/66/03-STAGE3-LOOP-PARALLEL-AND-FINISH.md)
- 소요 시간: 약 65분 (예제 1 12분 · 예제 2 10분 · **과제 40분**)
- 목표: 연습용 데이터로 **펼치기 → 항목마다 처리 → 모으기**와 **병렬 → 합치기**를 먼저 익히고, 이것을 Lab 02의 내 워크플로우에 옮겨 완성한다.
```mermaid
flowchart LR
E1["예제 1<br/>펼치고 모으기<br/>(1 → 3 → 1)"] --> E2["예제 2<br/>두 길을 합치기<br/>(Merge)"] --> T["과제 40분<br/>내 회의록 워크플로우 완성<br/>+ 확인 필요 표시"]
```
## 예제 1 · 펼치고 모으기 (12분)
LLM 없이, 할 일 3건이 든 목록을 직접 만들어 연습합니다.
```mermaid
flowchart LR
T(["Start"]) --> S["Sample actions<br/>Edit Fields"] --> SO["One item per action<br/>Split Out"] --> N["Normalize action<br/>Edit Fields"] --> AG["Collect actions<br/>Aggregate"]
```
1. 새 워크플로우 `66 Practice 2 · loop and merge`, 첫 노드는 `Manual Trigger`(이름 `Start`).
2. **Edit Fields (Set)** `Sample actions`: 필드 하나
| Name | Type | Value (Expression 모드) |
| --- | --- | --- |
| `actions` | Array | 아래 한 줄 |
```text
{{ [ { task: '신규 고객 문의 응답 템플릿 정리', owner: '이준호', due: '금요일' }, { task: '서버 점검 일정 공지', owner: '김민지', due: '오늘' }, { task: '회의실 예약 규칙 개선', owner: '미정', due: '미정' } ] }}
```
3. **Split Out** `One item per action`: Fields To Split Out `actions`
4. **Edit Fields (Set)** `Normalize action`: 필드 네 개
| Name | Type | Value (Expression 모드) |
| --- | --- | --- |
| `task` | String | `{{ $json.task }}` |
| `owner` | String | `{{ $json.owner }}` |
| `due` | String | `{{ $json.due }}` |
| `priority` | String | `{{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}` |
5. **Aggregate** `Collect actions`: Aggregate `All Item Data (Into a Single List)`, Put Output in Field `actions`
6. **Execute workflow** 후, 캔버스의 노드 사이 연결선 위에 표시되는 **항목 수**를 읽습니다.
| 노드 | 항목 수 | 보이는 것 |
| --- | --- | --- |
| Sample actions | 1 | `actions` 목록 하나 (3건) |
| One item per action | **3** | 할 일 하나씩 |
| Normalize action | **3** | + `priority` (`서버 점검 일정 공지`만 `높음`) |
| Collect actions | **1** | 다시 `actions` 목록 하나 |
> 펼쳤으면(1 → 3) 반드시 모읍니다(3 → 1). 모으지 않으면 뒤의 노드가 할 일마다 따로 실행됩니다.
## 예제 2 · 두 길을 합치기 (10분)
같은 워크플로우에 **두 번째 길**을 하나 더 만들고 Merge로 합칩니다.
```mermaid
flowchart LR
T(["Start"]) --> S["Sample actions"] --> X["... Collect actions"] --> J["Join results<br/>Merge"]
T --> SS["Sample summary<br/>Edit Fields"] --> J
```
1. `Start` 노드의 오른쪽 점에서 **선을 하나 더** 끌어 **Edit Fields (Set)** `Sample summary` 추가
| Name | Type | Value |
| --- | --- | --- |
| `summary` | String | `주간 회의에서 할 일 3건을 정했습니다. 서버 점검 공지는 오늘 안에 합니다.` |
2. **Merge** `Join results`: Mode `Combine`, Combine By `Position`
- `Collect actions` → `Join results`의 **Input 1**
- `Sample summary` → `Join results`의 **Input 2**
3. **Execute workflow** → `Join results`의 OUTPUT이 **항목 1개**이고 `actions`(3건)와 `summary`가 **함께** 들어 있는지 확인합니다.
```text
{"actions":[ ...3건, priority 포함 ... ], "summary":"주간 회의에서 할 일 3건을 정했습니다. 서버 점검 공지는 오늘 안에 합니다."}
```
4. **Save** 합니다. 이 워크플로우의 노드는 과제에서 **복사해 붙여** 씁니다.
> 막혔다면 `n8n/workflows/lab03-examples-loop-merge-practice.json`을 가져와 비교합니다.
## 과제 · 내 회의록 워크플로우 완성하기 (40분)
### 상황
팀장의 요청을 모두 담은 회의록 워크플로우를 완성합니다. 예제 1·2는 **가짜 데이터**였습니다. 이제 그 자리에 Lab 02의 **진짜 LLM 결과**를 넣습니다.
> "회의록을 보내면 **2문장 요약**, **우선순위가 붙은 할 일 목록**, **공유용 정리문**을 한 번에 받고 싶어요. 할 일이 없는 회의면 `할 일 없음`으로 알려 주세요. 그리고 담당자나 기한이 정해지지 않은 할 일은 **확인 필요**로 표시하고 몇 건인지 알려 주세요." (Lab 01 과제에서 설계한 내용)
### 완성 그림
`66 Meeting notes (my)`(Lab 02)를 아래 모양으로 만듭니다. 굵은 테두리가 새로 넣을 노드입니다.
```mermaid
flowchart LR
W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"}
H -->|false| R0["Reject empty"]
H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results"]
H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J
J --> A{"Any actions?"}
A -->|true| SO["One item per action"] --> N["Normalize action"] --> AG["Collect actions"] --> B["Build report"] --> R["Send result"]
A -->|false| NA["No actions"] --> R
classDef new stroke-width:3px
class L2,K,J,A,SO,N,AG,B,NA new
```
### 기대 결과 (이 표가 정답 기준입니다)
| 요청 | 기대 결과 |
| --- | --- |
| `weekly.json` | `route: 할 일 정리`, `count: 4`, `need_check: 1`, `summary`(2문장 안팎), 3번째 할 일 `priority: 높음`, 4번째 할 일 `check: true`, `report_text` 4번째 줄 끝에 `※ 확인 필요` |
| `no-action.json` | `route: 할 일 없음`, `count: 0`, `need_check: 0`, `summary`, `actions: []` |
| `empty.json` | HTTP 400 `{"error":"notes is required"}` |
### 진행 순서 (시간은 기준)
각 단계를 마칠 때마다 **Listen for test event → `weekly.json`** 으로 실행해 OUTPUT을 확인하고 다음으로 갑니다.
#### 1단계 · 요약 길을 만들고 합치기 (10분)
- `Parse actions` → `Send result` 연결선을 지웁니다. (선 위에 마우스 → 휴지통)
- `LLM extract actions`를 오른쪽 클릭 → **Duplicate** → 이름 `LLM summarize`. `Has notes?`의 **true**에서 선을 하나 더 끌어 연결하고, JSON 칸을 아래로 **통째로** 바꿉니다.
```text
{{ JSON.stringify({ model: $('Settings').first().json.model, temperature: 0, messages: [ { role: 'system', content: "You summarize meeting notes. Reply only with the summary in Korean, at most 2 sentences. No English, no comments." }, { role: 'user', content: '다음 회의록을 요약하세요.\n\n회의록:\n' + $('Settings').first().json.notes } ] }) }}
```
- **생각할 것**: 요약은 LLM 답(`choices[0].message.content`)에서 꺼내야 합니다. 예제 2의 `Sample summary`처럼 `summary` 필드 하나를 만드는 `Keep summary`를 직접 만들어 보세요. 그다음 `Join results`(예제 2에서 복사)의 Input 1·2에 어느 노드를 연결하나요?
<details><summary>힌트</summary>
- `Keep summary`: Edit Fields, `summary`(String) = `{{ $json.choices[0].message.content.trim() }}`
- `Parse actions` → Input 1, `Keep summary` → Input 2. 합친 결과에 `actions`, `count`, `summary`가 함께 있으면 성공.
</details>
#### 2단계 · 할 일이 있을 때와 없을 때 (5분)
- `Join results` 다음에 **If** `Any actions?`를 넣습니다.
- **생각할 것**: 할 일이 있는지 무엇으로 알 수 있나요? (`Parse actions`가 만든 필드를 떠올려 보세요.)
<details><summary>힌트</summary>
`{{ $json.count }}` · **Number → is greater than** · `0`
</details>
#### 3단계 · 펼치고 모아서 정리문 만들기 (12분)
- 예제 1·2의 워크플로우에서 `One item per action`, `Normalize action`, `Collect actions` **세 노드를 드래그로 함께 선택 → Ctrl+C**, 내 워크플로우에서 **Ctrl+V** 합니다. `Any actions?`의 **true**에 연결합니다.
- `Collect actions` 다음에 **Code** `Build report`를 넣고 아래 코드를 붙여 넣은 뒤 `Send result`에 연결합니다.
```javascript
// Build the final result from the collected actions.
const settings = $('Settings').first().json;
const joined = $('Join results').first().json;
const actions = $input.first().json.actions;
const lines = actions.map((a, i) => `${i + 1}. [${a.priority}] ${a.task} - ${a.owner} (${a.due})`);
return [{ json: {
title: settings.title,
route: '할 일 정리',
count: actions.length,
summary: joined.summary,
actions,
report_text: `${settings.title}\n${joined.summary}\n\n${lines.join('\n')}`,
} }];
```
- `Any actions?`의 **false**에 **Edit Fields** `No actions`를 넣고 `Send result`에 연결합니다.
- **생각할 것**: `No actions`의 결과는 할 일이 있을 때와 **같은 필드**여야 받는 쪽이 편합니다. `Build report`가 돌려주는 필드를 보고 `No actions`의 필드를 정해 보세요. `summary`는 어디서 가져오나요?
<details><summary>힌트</summary>
| Name | Type | Value |
| --- | --- | --- |
| `title` | String | `{{ $('Settings').first().json.title }}` |
| `route` | String | `할 일 없음` |
| `count` | Number | `0` |
| `summary` | String | `{{ $json.summary }}` |
| `actions` | Array | `{{ [] }}` |
`Any actions?`의 false로 들어온 항목은 `Join results`의 결과이므로 `$json.summary`가 있습니다.
</details>
여기까지 `weekly.json`으로 `count: 4`와 정리문(`report_text`)이 나오면, `no-action.json`으로 `할 일 없음`도 확인합니다.
#### 4단계 · 확인 필요 표시 (Lab 01 과제 구현) (8분)
Lab 01 과제에서 적은 내 설계를 펼쳐 놓고 만듭니다.
- **생각할 것 ①**: 할 일 하나마다 `check`(참/거짓)를 붙일 노드는 어디인가요? `priority`를 만든 방식을 참고해, "담당자가 `미정`이거나 기한이 `미정`이면 참"을 표현식으로 써 보세요.
- **생각할 것 ②**: `need_check`(확인 필요 건수)는 할 일을 다 모은 뒤에 셀 수 있습니다. `Build report` 코드의 어디에 무엇을 더하나요? `No actions`에는요?
- (선택) `report_text`에서 확인이 필요한 줄 끝에 `※ 확인 필요`를 붙여 보세요.
<details><summary>힌트 1 · check 필드</summary>
`Normalize action`에 필드를 하나 더합니다. Name `check`, Type **Boolean**, Value (Expression 모드)
```text
{{ $json.owner === '미정' || $json.due === '미정' }}
```
</details>
<details><summary>힌트 2 · need_check와 표시</summary>
`Build report`의 `count: actions.length,` 아래에 한 줄
```javascript
need_check: actions.filter(a => a.check).length,
```
`lines`를 만드는 줄 끝의 `` (${a.due})` `` 를 `` (${a.due})${a.check ? ' ※ 확인 필요' : ''}` `` 로 바꾸면 표시가 붙습니다. `No actions`에는 `need_check`(Number) `0`을 더합니다.
</details>
#### 5단계 · 세 요청 확인과 게시 (5분)
```bash
cd ~/aio-01d4-stu/Last3Mon/66
curl -s -m 300 -w " [HTTP %{http_code}]\n" -X POST http://localhost:5678/webhook-test/my-meeting-actions \
-H 'Content-Type: application/json' -d @n8n/requests/weekly.json
```
요청마다 Listen for test event를 다시 누르고 `no-action.json`, `empty.json`도 보냅니다. 위 **기대 결과** 표와 모두 같으면 오른쪽 위 **Publish** 합니다.
**실행 기록으로 검증**: `weekly.json` 실행에서 `Join results` 1 → `One item per action` 4 → `Normalize action` 4 → `Collect actions` 1 → `Build report` 1 인지 소리 내어 읽어 봅니다.
**Lab 02 과제(참석자)를 한 사람**: `Build report`의 결과에 `attendees`가 없습니다. 왜 사라졌을까요? (`Build report`는 결과를 **새로** 만듭니다.) `attendees: joined.attendees ?? [],` 한 줄로 살려 보세요.
### 막혔을 때
| 증상 | 볼 곳 |
| --- | --- |
| 응답에 첫 할 일 **하나만** 옴 | `Normalize action`에서 `Send result`로 바로 연결하지 않았나? `Collect actions`를 거쳐야 함 |
| `Build report`에서 오류 | `Collect actions`를 빼먹었거나, 노드 이름(`Settings`, `Join results`)이 다름 |
| `summary`가 비어 있음 | `Keep summary`가 `Join results`의 Input 2에 연결됐는지 |
| `no-action`에서 HTTP 200인데 **내용이 비어 있음** | `Any actions?`의 false → `No actions` → `Send result`가 연결됐는지 (수업 준비 때 실측: false 길이 끊기면 오류 없이 빈 응답) |
| `need_check`가 4 (할 일 수와 같음) | `check`의 Type이 **String**이면 `"false"`라는 글자가 되어 모두 참으로 셈 (실측). Type을 **Boolean**으로 |
- 구조가 헷갈리면 `n8n/workflows/01-meeting-actions-solution.json`(확인 필요 표시 **전**까지의 정답)을 새 워크플로우로 가져와 비교합니다.
## 마무리 · Python에서 부르기 (8교시에 함께)
게시한 뒤 `workflow-lab/platforms.json`의 `meeting` → `n8n` → `webhook` 값을 `my-meeting-actions`로 바꾸고 부릅니다.
```bash
cd ~/aio-01d4-stu/Last3Mon/66/workflow-lab
uv run miniflow call meeting -i title="주간 회의" \
-i notes="$(python3 -c "import json;print(json.load(open('data/meeting-notes.json'))['weekly']['notes'])")"
git checkout platforms.json # 끝나면 되돌리기
```
코드는 그대로 두고 **설정 한 줄**만 바꿔 호출 대상이 내 워크플로우로 바뀝니다. n8n을 멈추면 같은 업무를 `miniflow`의 `flows/08-meeting-actions.json`이 이어받습니다.
## 확인
- [ ] 예제 1: 항목 수 1 → 3 → 3 → 1을 확인했다.
- [ ] 예제 2: `Join results`에서 `actions`와 `summary`가 한 항목으로 합쳐졌다.
- [ ] 과제: 세 요청의 기대 결과를 모두 확인했다 (`need_check: 1` 포함).
- [ ] 과제: 실행 기록에서 1 → 4 → 4 → 1 → 1을 확인했다.
+102
View File
@@ -0,0 +1,102 @@
# Lab 03 · 항목 하나씩 다루기, 두 갈래로 나누기, 어려운 회의록
- 관련 강의: [3단계 · 반복·병렬 제어 구조와 검증](../../Lecture/66/03-STAGE3-ITEMS-PARALLEL-AND-VERIFY.md)
- 소요 시간: 약 80분 (예제 1 20분 · 예제 2 20분 · 과제 40분)
- 준비: Lab 02의 `my-work/meeting.json` (없으면 `cp reference/lab02-example2-actions.json my-work/meeting.json` 후 push)
- 목표: 할 일 목록을 **하나씩** 다뤄 확인이 필요한 것을 표시하고 다시 모은다 (1 → N → 1). 서로 기다릴 필요 없는 요약과 할 일 뽑기를 **두 갈래**로 나눈다. 내가 쓴 어려운 테스트로 워크플로우를 완성한다.
```mermaid
flowchart LR
E1["예제 1<br/>하나씩 → 확인 필요<br/>→ 다시 모으기"] --> E2["예제 2<br/>두 갈래 · Merge"] --> T["과제<br/>어려운 회의록"]
```
> 복사해 붙일 요청문은 없습니다. 막히면 `▶` 를 하나씩 펼칩니다.
## 예제 1 · 하나씩 표시하고 다시 모으기 (20분)
팀장: "담당자나 기한이 미정인 할 일은 '확인 필요'로 표시해 주세요. 몇 개인지도요."
| 조건 | 값 |
| --- | --- |
| 고칠 파일 | `my-work/meeting.json` |
| 처리 | 할 일 목록 → **할 일마다** `needs_check` 표시 → 다시 목록으로 |
| 결과 | 할 일마다 `needs_check`(불리언), 전체 `needs_check_count` ([AGENTS.md](AGENTS.md) 3절) |
| 기대 결과 | `cases/lab03.json` |
<details><summary>▶ 1단계 · 왜 Code 한 줄이 아니라 노드로</summary>
Code 하나로도 할 수 있습니다. 오늘은 n8n의 **항목 흐름**(1 → N → 1)을 눈으로 보는 것이 목표입니다. 목록을 항목으로 펼치는 노드, 항목마다 값을 정하는 노드, 다시 모으는 노드를 쓰라고 요청해 봅니다 ([AGENTS.md](AGENTS.md) 5절). Lab 01 예제 1의 항목 수 표와 비교합니다.
</details>
<details><summary>▶ 2단계 · 할 일이 0개면</summary>
`requests/no-action.json`을 보내 봅니다. 펼칠 항목이 없으면 그 뒤 노드가 실행되지 않아 **응답이 오지 않을** 수 있습니다. 펼치기 전에 무엇을 확인하고, 0개일 때는 무엇을 돌려줄지 정합니다.
</details>
```bash
uv run tools/flow.py push my-work/meeting.json
uv run tools/flow.py test cases/lab03.json
```
**관찰**: n8n **Executions**에서 펼친 노드의 출력 항목 수(4), 모은 노드의 출력 항목 수(1)를 확인합니다.
## 예제 2 · 요약과 할 일 뽑기를 두 갈래로 (20분)
요약과 할 일 뽑기는 서로의 결과가 필요 없습니다. 지금은 **차례로** 돌고 있습니다.
| 조건 | 값 |
| --- | --- |
| 처리 | 입력 확인 다음에 요약 · 할 일 뽑기로 **갈래를 나누고**, Merge로 모은 뒤 나머지는 그대로 |
| 기대 결과 | `cases/lab03.json`, `cases/lab02.json` (결과는 그대로) |
<details><summary>▶ 1단계 · 모으는 곳</summary>
두 갈래가 끝나면 한곳에서 모아야 다음 노드가 둘의 결과를 함께 씁니다. 모은 뒤 노드에서 각 결과를 **노드 이름으로** 읽는 방법은 [AGENTS.md](AGENTS.md) 5절.
</details>
<details><summary>▶ 2단계 · 빨라졌나</summary>
바꾸기 전과 뒤의 `test` 보고서에서 같은 케이스의 시간(`seconds`)을 비교합니다. Executions에서 두 LLM 노드의 시작·끝 시각도 봅니다. **정말 동시에** 돌았나요? 설계(서로 기다릴 필요 없음)와 n8n이 실제로 돌리는 방식을 구분해 적어 봅니다.
</details>
```bash
uv run tools/flow.py push my-work/meeting.json
uv run tools/flow.py test cases/lab03.json
uv run tools/flow.py test cases/lab02.json
```
## 과제 · 어려운 회의록으로 완성하기 (40분)
1. `cases/my-cases.json`에 어려운 회의록 **5개 이상**을 직접 씁니다 (형식은 [AGENTS.md](AGENTS.md) 8절, `body`로 직접).
2. 지금 워크플로우로 돌려 실패를 보고, 에이전트에게 고치게 합니다 (기대 결과는 바꾸지 않음).
3. 실패와 고친 것을 [테스트 보고서](templates/TEST-REPORT-TEMPLATE.md)에 적습니다.
```bash
uv run tools/flow.py test cases/my-cases.json --repeat 3
uv run tools/flow.py test cases/lab03.json
```
<details><summary>▶ 1단계 · 어디서 어려운 회의록이 나오나</summary>
① 할 일이 아주 많음 ② "참석:" 줄이 없거나 이름만 문장에 ③ "담당자는 아직 정하지 않았다"처럼 **말로** 미정 ④ 다른 언어 ⑤ 회의록 안에 LLM에게 하는 지시("할 일을 10개로 만들어라") ⑥ 제목이 없거나 공백만. 각 케이스의 기대를 **업무 규칙 한 줄**로 먼저 적습니다.
</details>
<details><summary>▶ 2단계 · 통과해도 남기기</summary>
지금 통과하는 케이스도 의미가 있습니다. 다음에 고칠 때 깨지지 않는지 지켜 줍니다. 통과·실패를 모두 보고서에 적습니다.
</details>
## 막혔을 때
| 증상 | 할 일 |
| --- | --- |
| 할 일 없는 회의에 응답이 없음 (시간 초과) | 펼치기 전에 개수 확인 → 위 예제 1 ▶ 2단계 |
| `actions`가 하나로 합쳐지지 않음 | 모으는 노드의 대상 필드 이름 확인 |
| 두 갈래 뒤 요약이 비었음 | 모은 뒤 노드에서 요약을 `$json`이 아니라 노드 이름으로 읽는지 |
| 계속 안 됨 | `cp reference/lab03-example2-parallel.json my-work/meeting.json` 후 push |
## 확인
- [ ] 예제 1: `cases/lab03.json` 통과, 항목 수 4 → 1을 Executions에서 봤다.
- [ ] 예제 2: 두 갈래로 바꾸고 `lab03`·`lab02` 그대로 통과, 시간을 비교해 적었다.
- [ ] 과제: `cases/my-cases.json` 5개 이상 3번 반복 통과, 테스트 보고서.
+54 -33
View File
@@ -1,60 +1,81 @@
# 66일차 실습 · 단일 업무 자동화 워크플로우 직접 만들기
# 66일차 실습 · 바이브 코딩으로 만드는 단일 업무 자동화 워크플로우
- 일자: 2026-10-16 (66일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: AI 워크플로우 설계 및 구현
- 강의 자료: [Lecture/66](../../Lecture/66/README.md)
- **오늘부터 n8n 노드를 손으로 끌어다 놓지 않습니다.** CLI 코딩 에이전트(Codex · Claude Code · Antigravity)에게 **내가 쓴 요청문**으로 워크플로우 JSON을 만들게 하고, 우리는 **실행 결과를 테스트로 확인**합니다. 64·65일차에 화면에서 배운 노드와 데이터 흐름이 요청문의 재료가 됩니다.
## 오늘 만드는 업무: 회의록 → 할 일 정리
```mermaid
flowchart LR
W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"}
H -->|false| R0["Reject empty"]
H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results"]
H -->|true| L2{{"LLM summarize"}} --> K["Keep summary"] --> J
J --> A{"Any actions?"}
A -->|true| SO["One item per action"] --> N["Normalize action"] --> AG["Collect actions"] --> B["Build report"] --> R["Send result"]
A -->|false| NA["No actions"] --> R
W(["Meeting webhook<br/>title · notes"]) --> H{"Has notes?"}
H -->|false| R0["Reject empty (400)"]
H -->|true| S{{"Summarize"}} --> J["Join results"]
H -->|true| E{{"Extract actions<br/>(JSON)"}} --> J
J --> P["Parse actions"] --> A{"Any actions?"}
A -->|true| SO["One item per action"] --> M["Mark check<br/>미정이면 확인 필요"] --> C["Collect actions"] --> B["Build report"] --> R(["결과"])
A -->|false| N["No actions"] --> R
```
## 실습 방식
## 일하는 방식
모든 Lab은 **쉬운 예제 2개 → 예제를 고치는 과제** 순서입니다. 예제는 안내를 그대로 따라 하면 되고, 과제에서 생각합니다.
```mermaid
flowchart LR
Q["요청문<br/>(내가 씀)"] --> G["CLI 에이전트<br/>my-work/meeting.json · check"]
G --> U["push<br/>(내가 실행)"] --> T["test<br/>(내가 실행)"]
T -->|✘ 보고서| G
T -->|✔ Passed| D["완료"]
```
| Lab | 시간 | 예제 1 | 예제 2 | 과제 |
| --- | --- | --- | --- | --- |
| [Lab 01](LAB-01-DESIGN-DATA-FLOW.md) 설계 | 2교시 22분 | 어제 만든 흐름의 항목 수 읽기 | 오늘 흐름의 빈칸 채우기 (1 → 4 → 1) | "확인 필요" 요청이 오면 어디를 고치나 |
| [Lab 02](LAB-02-BUILD-PARSE-TRANSFORM.md) 파싱 | 3교시 끝 + 4교시 | LLM 없이 글자 → 목록 | 진짜 LLM이 붙은 1부 가져와 실행 | 참석자도 뽑기 (세 곳 고치기) |
| [Lab 03](LAB-03-BUILD-LOOP-PARALLEL.md) 완성 | 5교시 끝 + 6교시 | 펼치고 모으기 (1 → 3 → 1) | 두 길을 Merge로 합치기 | **40분** · 내 워크플로우 완성 + 확인 필요 표시 |
| 누가 | 무엇을 |
| --- | --- |
| 나 | 결과 먼저 정하기, 요청문 쓰기, push·test 실행, 결과 판단 |
| CLI 에이전트 | [AGENTS.md](AGENTS.md) 규칙대로 워크플로우 JSON 작성, `check`로 형식 오류 고치기, 실패 원인 찾기 |
| `tools/flow.py` | 형식 검사(check), n8n에 가져오기·게시(push), 요청 보내기(call), 테스트(test) |
## 실습 문서 읽는 법
- 실습 문서(`LAB-0x-Q-*.md`, `VIBE-Q-TURL.md`)에는 **복사해 붙일 요청문이 없습니다.** 만들 것 · 조건 · 기대 결과만 있고, 요청문은 직접 씁니다.
- 막히면 `▶` 를 하나씩 펼칩니다. 뒤로 갈수록 구체적이 되지만 정답 요청문은 나오지 않습니다.
- 쓴 요청문은 `my-work/PROMPTS.md`에 모아 두고, 수업 끝에 옆 사람과 비교합니다.
## 구성
```text
Last3Mon/66/
├── README.md
├── LAB-01-DESIGN-DATA-FLOW.md # 1단계 · 설계 (예제 2개 + 과제)
├── LAB-02-BUILD-PARSE-TRANSFORM.md # 2단계 · 입력 → LLM → 파싱 (예제 2개 + 과제)
├── LAB-03-BUILD-LOOP-PARALLEL.md # 3단계 · 반복·병렬로 완성 (예제 2개 + 40분 과제), Python 호출
├── templates/
│ ├── LAB-01-WORKSHEET.md # Lab 01 활동지
│ └── DATA-FLOW-DESIGN-TEMPLATE.md # 내 업무 설계서 (복습 과제용)
├── my-work/ # 여러분의 결과물 (직접 만듭니다)
├── n8n/
│ ├── requests/ # 테스트 요청 (weekly · no-action · empty)
│ └── workflows/
│ ├── lab02-example1-parse-practice.json # Lab 02 예제 1 (막혔을 때 비교)
│ ├── lab02-example2-meeting-part1.json # Lab 02 예제 2 (가져와서 시작)
│ ├── lab03-examples-loop-merge-practice.json # Lab 03 예제 1·2 (막혔을 때 비교)
│ └── 01-meeting-actions-solution.json # Lab 03 과제 구조 비교용 (확인 필요 표시 전까지)
└── workflow-lab/ # miniflow (65일차 + JSON 출력·parse_actions·08 회의록 flow)
├── AGENTS.md # CLI 에이전트용 n8n 작업 규칙 (CLAUDE.md가 이 파일을 불러옴)
├── LAB-01-Q-RESULT-FIRST.md # 1단계 · 결과 먼저 → 첫 요청문(요약) → 빈 회의록 · 제목 없음
├── LAB-02-Q-JSON-AND-PARSE.md # 2단계 · 할 일을 JSON으로 → 빈칸 채우기 → 참석자
├── LAB-03-Q-ITEMS-AND-PARALLEL.md # 3단계 · 항목 하나씩 · 확인 필요 → 두 갈래 → 어려운 회의록
├── VIBE-Q-TURL.md # 7교시 바이브 코딩 · turl (curl을 TUI로)
├── *-A-*.md # 강사용 정답본 (학생 저장소에는 없음)
├── tools/flow.py # doctor · check · push · off · call · test
├── cases/ # lab01 · lab01-challenge · lab02 · lab02-challenge · lab03
├── requests/ # weekly · no-action · empty · no-title · vendor
├── reference/ # 막혔을 때 이어 가는 워크플로우 (예제 단계)
├── templates/TEST-REPORT-TEMPLATE.md
├── my-work/ # 에이전트가 만든 워크플로우 (JSON은 git에 올라가지 않음)
└── runs/ # 테스트 보고서 (자동 생성)
```
## 준비
- 65일차의 n8n(Credential `OpenAI (.env)`)과 `Last3Mon/.env`를 그대로 사용합니다: `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start`
- 실습 엔진 확인:
[실습 준비](../../Lecture/66/00-DAY66-PREP.md)를 끝내 주세요 (CLI 코딩 에이전트 설치·로그인 포함). 요약:
```bash
cd ~/aio-01d4-stu/Last3Mon/66/workflow-lab && uv sync && uv run pytest -q # 43 passed, 1 skipped
cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start # 65일차 n8n (설치 폴더 ~/day65-n8n)
cd ~/aio-01d4-stu/Last3Mon/66 && uv run tools/flow.py doctor # 마지막 줄 ready
```
## `flow.py` 명령
| 명령 | 하는 일 | 수업 준비 때 |
| --- | --- | --- |
| `uv run tools/flow.py doctor` | n8n · Credential · CLI 에이전트 확인 | |
| `uv run tools/flow.py check my-work/meeting.json` | 노드 종류·버전·연결·Credential·API 키 노출 검사 | 1초 미만 |
| `uv run tools/flow.py push my-work/meeting.json` | n8n에 가져오기 → 게시 → n8n 재시작 → 웹훅 준비 확인 | |
| `uv run tools/flow.py call my-meeting requests/weekly.json` | 요청 한 번 보내고 응답 보기 | 요약만 1~3초, 할 일까지 2~4초 |
| `uv run tools/flow.py test cases/lab02.json --repeat 3` | 테스트 실행, `Passed x/y`, 보고서를 `runs/`에 저장 | |
+103
View File
@@ -0,0 +1,103 @@
# 7교시 바이브 코딩 · `turl` 만들기
- 시간: 16:00~16:50 (50분) · 발표와 정리는 8교시
- 도구: CLI 코딩 에이전트(codex / claude / agy), Python + uv + [Textual](https://textual.textualize.io/), [httpx](https://www.python-httpx.org/)
- 결과물: 터미널에서 HTTP 요청을 만들고 보내고 응답을 보는 TUI (`curl`을 화면으로). 오늘 만든 `my-meeting` 웹훅을 이 도구로 불러 봅니다.
```text
$ turl
┌ turl ──────────────────────────────────────────────────────────────┐
│ [POST ▾] [ http://localhost:5678/webhook/my-meeting ] [보내기] │
│ ┌ Body ┬ Headers ┬ History ┐ ┌ 응답 · 200 · x.xs ─────────────┐ │
│ │ {"title": "10월 16일 …", │ │ { │ │
│ │ "notes": "참석: …"} │ │ "title": "10월 16일 주간 회의",│ │
│ │ │ │ "count": 4, … │ │
│ └───────────────────────────┘ └─────────────────────────────────┘ │
│ [파일에서 Body 불러오기] [curl 명령으로 복사] │
└────────────────────────────────────────────────────────────────────┘
```
> 요청문은 **직접** 씁니다. 단계마다 `▶` 를 펼치면 생각할 거리가 나옵니다. 정답 요청문은 없습니다.
## 요구 사항
| 번호 | 요구 | 확인 방법 |
| --- | --- | --- |
| R1 | 메서드(GET·POST·PUT·DELETE), URL, 헤더, Body를 정해 보내고, 응답의 **상태 코드 · 걸린 시간 · 본문**(JSON이면 보기 좋게)을 보여 준다 | `turl`에서 `my-meeting`에 `weekly.json` |
| R2 | Body를 **파일에서** 불러온다 (오늘 Lab의 `requests/*.json`) | `requests/weekly.json` 불러오기 |
| R3 | 보낸 요청을 기록(History)에 남기고, 고르면 다시 불러온다. 프로그램을 껐다 켜도 남는다 | 껐다 켜기 |
| R4 | 지금 요청을 **`curl` 명령**으로 만들어 준다 (복사해 터미널에서 그대로 실행되게) | 만든 명령을 터미널에 붙여 실행 |
| R5 | 잘못된 URL, 응답 없음(시간 초과), JSON이 아닌 응답에서도 멈추지 않고 알려 준다 | 꺼진 주소로 보내기 |
## 진행 (50분)
```mermaid
flowchart LR
S0["0. 준비<br/>3분"] --> S1["1. 글자로 보내기<br/>R1 · 12분"] --> S2["2. TUI<br/>R1·R2 · 15분"] --> S3["3. 기록 · curl · 오류<br/>R3·R4·R5 · 13분"] --> S4["4. 정리<br/>7분"]
```
요청문은 단계마다 `~/vibe/turl/PROMPTS.md`에 남깁니다.
### 0. 준비 (3분)
```bash
mkdir -p ~/vibe/turl && cd ~/vibe/turl
git init
codex # 또는 claude, agy
```
### 1. 글자로 보내기 (12분)
**목표**: `uv run turl send POST http://localhost:5678/webhook/my-meeting --body-file requests/weekly.json`이 상태 · 시간 · 본문을 글자로 출력한다. 화면은 아직 없다.
<details><summary>▶ 1단계 · 프로젝트를 어떻게 시작하게 할까</summary>
에이전트는 우리 수업 규칙을 모릅니다. Python 패키지 관리를 무엇으로 할지, 명령 이름, 코드와 화면 글자의 언어를 적었나요? 오늘 n8n 실습의 AGENTS.md처럼 **이 프로젝트의 규칙 파일**을 먼저 만들게 해도 좋습니다.
</details>
<details><summary>▶ 2단계 · 보내는 부분을 따로</summary>
요청을 보내고 결과(상태, 시간, 본문)를 돌려주는 부분은 글자 모드, TUI, 기록이 모두 씁니다. 이 부분을 **따로** 만들고 **테스트**하게 하려면? 테스트에서 진짜 서버 대신 무엇을 쓸까요?
</details>
**확인**: 오늘 만든 `my-meeting`에 보내기, `uv run pytest -q`, `git commit`.
### 2. TUI (15분)
**목표**: 위 그림처럼 메서드 · URL · 보내기, 왼쪽 Body · Headers 탭, 오른쪽 응답.
<details><summary>▶ 1단계 · 화면을 말로 그리기</summary>
화면의 위 · 왼쪽 · 오른쪽에 무엇이 있는지, 버튼을 누르면 무엇이 바뀌는지 순서대로 적습니다. 응답이 오는 동안 화면이 굳지 않게 하려면?
</details>
### 3. 기록 · curl · 오류 (13분)
<details><summary>▶ 1단계 · 기록은 어디에</summary>
껐다 켜도 남으려면 파일에 써야 합니다. 어디에, 어떤 모양으로? 헤더에 비밀값(`Authorization`)이 있으면 기록에 그대로 남겨도 될까요?
</details>
<details><summary>▶ 2단계 · curl 명령의 따옴표</summary>
Body에 한국어, 따옴표, 줄바꿈이 있으면 만든 `curl` 명령이 깨지기 쉽습니다. 만든 명령을 **실제로 실행**해 같은 응답이 오는지 확인하게 합니다.
</details>
**확인**: `my-meeting`에 `empty.json`(400이 보이나), 꺼진 주소(오류가 보이나), curl 명령 실행, `git commit`.
### 4. 정리 (7분)
| 발표 (3분) | 내용 |
| --- | --- |
| 요청문 | 가장 잘 먹힌 요청문 한 개, 안 먹힌 요청문 한 개 (`PROMPTS.md`에서) |
| 막힌 곳 | 에이전트가 틀린 것, 어떻게 알아챘고 고쳤나 |
| 시연 | `turl`로 `my-meeting` 호출, curl 명령 복사 · 실행 |
## 확인
- [ ] R1 `my-meeting`에 보내 상태 · 시간 · 본문이 보인다.
- [ ] R2 `requests/*.json`을 Body로 불러온다.
- [ ] R3 껐다 켜도 기록이 남는다.
- [ ] R4 만든 curl 명령이 그대로 실행된다.
- [ ] R5 오류에서도 멈추지 않는다.
- [ ] `uv run pytest -q` 통과, 단계마다 git commit, `PROMPTS.md`.
+45
View File
@@ -0,0 +1,45 @@
{
"webhook": "my-meeting",
"cases": [
{
"name": "빈 회의록",
"request": "requests/empty.json",
"expect": {
"status": 400
}
},
{
"name": "공백만",
"body": {
"title": "x",
"notes": " "
},
"expect": {
"status": 400
}
},
{
"name": "제목 없음 → 회의",
"request": "requests/no-title.json",
"expect": {
"equals": {
"title": "회의"
},
"contains": {
"summary": [
"노트북"
]
}
}
},
{
"name": "원래 요약도 그대로",
"request": "requests/weekly.json",
"expect": {
"equals": {
"title": "10월 16일 주간 회의"
}
}
}
]
}
+39
View File
@@ -0,0 +1,39 @@
{
"webhook": "my-meeting",
"cases": [
{
"name": "주간 회의 · 제목",
"request": "requests/weekly.json",
"expect": {
"equals": {
"title": "10월 16일 주간 회의"
}
}
},
{
"name": "주간 회의 · 한 문장 요약",
"request": "requests/weekly.json",
"expect": {
"contains": {
"summary": [
"고객",
"VPN",
"서버",
"회의실"
]
}
}
},
{
"name": "할 일 없는 회의도 요약",
"request": "requests/no-action.json",
"expect": {
"contains": {
"summary": [
"실적"
]
}
}
}
]
}
+48
View File
@@ -0,0 +1,48 @@
{
"webhook": "my-meeting",
"cases": [
{
"name": "참석자 3명",
"request": "requests/weekly.json",
"expect": {
"equals": {
"attendees": [
"김민지",
"이준호",
"박서연"
]
}
}
},
{
"name": "참석자 2명",
"request": "requests/vendor.json",
"expect": {
"equals": {
"attendees": [
"한지훈",
"최유진"
]
}
}
},
{
"name": "참석자 줄이 없는 회의",
"request": "requests/no-action.json",
"expect": {
"equals": {
"attendees": []
}
}
},
{
"name": "할 일은 그대로",
"request": "requests/weekly.json",
"expect": {
"equals": {
"count": 4
}
}
}
]
}
+68
View File
@@ -0,0 +1,68 @@
{
"webhook": "my-meeting",
"cases": [
{
"name": "주간 회의 · 할 일 4개",
"request": "requests/weekly.json",
"expect": {
"equals": {
"count": 4
}
}
},
{
"name": "주간 회의 · 첫 할 일",
"request": "requests/weekly.json",
"expect": {
"equals": {
"actions.0.owner": "이준호"
},
"contains": {
"actions.0.due": [
"금요일"
]
}
}
},
{
"name": "담당자 없는 할 일은 미정",
"request": "requests/weekly.json",
"expect": {
"equals": {
"actions.3.owner": "미정"
}
}
},
{
"name": "할 일 없는 회의",
"request": "requests/no-action.json",
"expect": {
"equals": {
"count": 0,
"actions": []
}
}
},
{
"name": "요약도 함께",
"request": "requests/weekly.json",
"expect": {
"contains": {
"summary": [
"고객",
"VPN",
"서버",
"회의실"
]
}
}
},
{
"name": "빈 회의록",
"request": "requests/empty.json",
"expect": {
"status": 400
}
}
]
}
+63
View File
@@ -0,0 +1,63 @@
{
"webhook": "my-meeting",
"cases": [
{
"name": "확인 필요 1개 (담당 미정)",
"request": "requests/weekly.json",
"expect": {
"equals": {
"count": 4,
"needs_check_count": 1,
"actions.3.needs_check": true,
"actions.0.needs_check": false
}
}
},
{
"name": "기한 없는 할 일도 확인 필요",
"request": "requests/vendor.json",
"expect": {
"equals": {
"actions.1.needs_check": true
},
"contains": {
"actions.1.due": [
"미정"
]
}
}
},
{
"name": "할 일 없는 회의 · 끝까지 답",
"request": "requests/no-action.json",
"expect": {
"equals": {
"count": 0,
"needs_check_count": 0,
"actions": []
}
}
},
{
"name": "요약 함께",
"request": "requests/weekly.json",
"expect": {
"contains": {
"summary": [
"고객",
"VPN",
"서버",
"회의실"
]
}
}
},
{
"name": "빈 회의록",
"request": "requests/empty.json",
"expect": {
"status": 400
}
}
]
}
+5
View File
@@ -0,0 +1,5 @@
# 수업 결과물
- CLI 코딩 에이전트가 만든 워크플로우 JSON (`meeting.json`)이 여기에 저장됩니다 (git에 올라가지 않음). 오늘 끝까지 이 파일 하나를 고쳐 나갑니다.
- `RESULT-FIRST.md`: Lab 01 예제 1에서 정한 기대 결과와 데이터 흐름 표.
- `PROMPTS.md`: 오늘 쓴 요청문을 모아 둡니다.
-4
View File
@@ -1,4 +0,0 @@
{
"title": "빈 회의록",
"notes": ""
}
-4
View File
@@ -1,4 +0,0 @@
{
"title": "분기 실적 공유",
"notes": "오늘은 지난 분기 실적을 공유하고 질의응답을 했습니다. 별도 결정 사항은 없습니다."
}
-4
View File
@@ -1,4 +0,0 @@
{
"title": "10월 16일 주간 회의",
"notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."
}
@@ -1,584 +0,0 @@
{
"name": "66-01 Meeting notes to action items",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "meeting-actions",
"responseMode": "responseNode",
"options": {}
},
"id": "b0f12ecc-f34f-5b93-ab1a-b4f0e776cd07",
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "fd884467-4d6e-5e1e-b81c-8d909d0348a5"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a65c3a2e-b4c8-5087-a475-c6a858d9b7d6",
"name": "llm_url",
"value": "https://api.openai.com/v1",
"type": "string"
},
{
"id": "e30dbc5a-e549-5565-9f8f-96e55670f69f",
"name": "model",
"value": "gpt-4.1-mini",
"type": "string"
},
{
"id": "4ef048c7-0ac0-5913-b743-12cda43aa2b2",
"name": "title",
"value": "={{ $json.body.title || '제목 없음' }}",
"type": "string"
},
{
"id": "b901c535-f38f-5a59-975a-05074ce8044c",
"name": "notes",
"value": "={{ $json.body.notes }}",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"id": "99de8b0b-c43f-58dc-ad3d-543621575770",
"name": "Settings",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
220,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "41964561-0243-53d7-b1cd-0e64054d7460",
"leftValue": "={{ $json.notes }}",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
},
"rightValue": ""
}
],
"combinator": "and"
},
"options": {}
},
"id": "21232562-8565-5643-96a1-d643847e0ffb",
"name": "Has notes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
440,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"error\": \"notes is required\" } }}",
"options": {
"responseCode": 400
}
},
"id": "076e2cf3-ed8d-5b8a-bd2c-026784a818e1",
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
680,
260
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Settings').first().json.llm_url }}/chat/completions",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: $('Settings').first().json.model, temperature: 0, response_format: { type: 'json_schema', json_schema: { name: 'reply', schema: {\"type\": \"object\", \"properties\": {\"actions\": {\"type\": \"array\", \"items\": {\"type\": \"object\", \"properties\": {\"task\": {\"type\": \"string\"}, \"owner\": {\"type\": \"string\"}, \"due\": {\"type\": \"string\"} }, \"required\": [\"task\", \"owner\", \"due\"]} } }, \"required\": [\"actions\"]} } }, messages: [ { role: 'system', content: \"Extract action items from the meeting notes. Answer in JSON: {\\\"actions\\\": [{\\\"task\\\": str, \\\"owner\\\": str, \\\"due\\\": str}]}. Write task in Korean. Use \\\"미정\\\" when the owner or due date is not stated. Return {\\\"actions\\\": []} when there is no action item.\" }, { role: 'user', content: $('Settings').first().json.notes } ] }) }}",
"options": {
"timeout": 180000
},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi"
},
"id": "b704079a-e151-5b7f-814a-1e94e3ccd940",
"name": "LLM extract actions",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
-120
],
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"parameters": {
"jsCode": "// Parse the LLM answer (JSON text) into a clean list of action items.\nconst raw = String($input.first().json.choices?.[0]?.message?.content ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data;\ntry {\n data = JSON.parse(raw);\n} catch (error) {\n return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }];\n}\nconst items = Array.isArray(data) ? data : (data.actions ?? []);\nconst actions = items\n .filter(item => item && String(item.task ?? '').trim())\n .map(item => ({\n task: String(item.task).trim(),\n owner: String(item.owner || '미정').trim() || '미정',\n due: String(item.due || '미정').trim() || '미정',\n }));\nreturn [{ json: { actions, count: actions.length } }];"
},
"id": "c78c7bb3-c72d-5ca2-ac9f-d541c024fa75",
"name": "Parse actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
-120
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Settings').first().json.llm_url }}/chat/completions",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ model: $('Settings').first().json.model, temperature: 0, messages: [ { role: 'system', content: \"You summarize meeting notes. Reply only with the summary in Korean, at most 2 sentences. No English, no comments.\" }, { role: 'user', content: '다음 회의록을 요약하세요.\\n\\n회의록:\\n' + $('Settings').first().json.notes } ] }) }}",
"options": {
"timeout": 180000
},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi"
},
"id": "ddb065b8-063a-5741-a301-577f004c04d3",
"name": "LLM summarize",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
100
],
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "1a7470e6-3557-5a05-b3c2-2b52762c1d54",
"name": "summary",
"value": "={{ $json.choices[0].message.content.trim() }}",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"id": "0e8b9b2f-e48c-591d-a712-a351cd715fd3",
"name": "Keep summary",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
900,
100
]
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"id": "506361cb-50b7-5d5d-9dcc-76121628d788",
"name": "Join results",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1120,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "cdd4276c-6039-5731-aadf-bafdf9fdf535",
"leftValue": "={{ $json.count }}",
"operator": {
"type": "number",
"operation": "gt"
},
"rightValue": 0
}
],
"combinator": "and"
},
"options": {}
},
"id": "bf7c1717-f207-5071-a0a8-961a65bfdbe3",
"name": "Any actions?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1340,
0
]
},
{
"parameters": {
"fieldToSplitOut": "actions",
"options": {}
},
"id": "a9f538fb-61c0-5a8d-9051-9778db98a4d1",
"name": "One item per action",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
1560,
-120
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "99cb6b63-de3c-560f-8170-b33332fb5cb9",
"name": "task",
"value": "={{ $json.task }}",
"type": "string"
},
{
"id": "ee9068cb-7666-5a5b-8937-d9c748333d6a",
"name": "owner",
"value": "={{ $json.owner }}",
"type": "string"
},
{
"id": "067da7cb-3f07-5f20-b45d-6f5fe45ca441",
"name": "due",
"value": "={{ $json.due }}",
"type": "string"
},
{
"id": "1efe8c52-179a-5a38-8648-f78d96bb8e88",
"name": "priority",
"value": "={{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"id": "000677ea-2fe7-5e70-9983-db85cafbde43",
"name": "Normalize action",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1780,
-120
]
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "actions",
"options": {}
},
"id": "1adf8b41-b947-52ea-939b-8c630e7b7c2b",
"name": "Collect actions",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
2000,
-120
]
},
{
"parameters": {
"jsCode": "// Build the final result from the collected actions.\nconst settings = $('Settings').first().json;\nconst joined = $('Join results').first().json;\nconst actions = $input.first().json.actions;\nconst lines = actions.map((a, i) => `${i + 1}. [${a.priority}] ${a.task} - ${a.owner} (${a.due})`);\nreturn [{ json: {\n title: settings.title,\n route: '할 일 정리',\n count: actions.length,\n summary: joined.summary,\n actions,\n report_text: `${settings.title}\\n${joined.summary}\\n\\n${lines.join('\\n')}`,\n} }];"
},
"id": "5d19a847-10d1-565b-90ec-4476443e4a35",
"name": "Build report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2220,
-120
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "4fbd9776-6e85-560c-a217-529108a8b787",
"name": "title",
"value": "={{ $('Settings').first().json.title }}",
"type": "string"
},
{
"id": "709b7545-af8b-5be2-8b4a-add33d70849a",
"name": "route",
"value": "할 일 없음",
"type": "string"
},
{
"id": "6563e214-e619-5c86-a3a9-3b40608432ab",
"name": "count",
"value": "0",
"type": "number"
},
{
"id": "1b86f1f8-0486-5c45-853d-8ab5cf62c80c",
"name": "summary",
"value": "={{ $json.summary }}",
"type": "string"
},
{
"id": "406a4e0c-7283-5c3e-9762-52690b884289",
"name": "actions",
"value": "={{ [] }}",
"type": "array"
}
]
},
"includeOtherFields": false,
"options": {}
},
"id": "19b6ad75-3169-59d3-bc1b-5196f43d8d59",
"name": "No actions",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1560,
120
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "b773d7a3-74dc-59f1-8e61-0279ce658cdb",
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2440,
0
]
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Settings",
"type": "main",
"index": 0
}
]
]
},
"Settings": {
"main": [
[
{
"node": "Has notes?",
"type": "main",
"index": 0
}
]
]
},
"Has notes?": {
"main": [
[
{
"node": "LLM extract actions",
"type": "main",
"index": 0
},
{
"node": "LLM summarize",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"LLM extract actions": {
"main": [
[
{
"node": "Parse actions",
"type": "main",
"index": 0
}
]
]
},
"Parse actions": {
"main": [
[
{
"node": "Join results",
"type": "main",
"index": 0
}
]
]
},
"LLM summarize": {
"main": [
[
{
"node": "Keep summary",
"type": "main",
"index": 0
}
]
]
},
"Keep summary": {
"main": [
[
{
"node": "Join results",
"type": "main",
"index": 1
}
]
]
},
"Join results": {
"main": [
[
{
"node": "Any actions?",
"type": "main",
"index": 0
}
]
]
},
"Any actions?": {
"main": [
[
{
"node": "One item per action",
"type": "main",
"index": 0
}
],
[
{
"node": "No actions",
"type": "main",
"index": 0
}
]
]
},
"One item per action": {
"main": [
[
{
"node": "Normalize action",
"type": "main",
"index": 0
}
]
]
},
"Normalize action": {
"main": [
[
{
"node": "Collect actions",
"type": "main",
"index": 0
}
]
]
},
"Collect actions": {
"main": [
[
{
"node": "Build report",
"type": "main",
"index": 0
}
]
]
},
"Build report": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"No actions": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"pinData": {},
"id": "a6c07c353caf55"
}
@@ -1,83 +0,0 @@
{
"name": "66 Practice 1 · parse text",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"name": "Start",
"position": [
0,
0
],
"id": "188d4e06-7014-58e1-8053-cfebb3d2b1ac"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "d932ecca-eec2-5729-96c4-d0fe4e05ed98",
"name": "content",
"value": "{\"actions\": [{\"task\": \"신규 고객 문의 응답 템플릿 정리\", \"owner\": \"이준호\", \"due\": \"금요일\"}, {\"task\": \"회의실 예약 규칙 개선\", \"owner\": \"\", \"due\": \"\"}]}",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"name": "Fake LLM answer",
"position": [
220,
0
],
"id": "92fb2178-5944-5b66-8399-23529f0255f0"
},
{
"parameters": {
"jsCode": "// Parse the answer text (JSON written as text) into a clean list of action items.\nconst raw = String($input.first().json.content ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data;\ntry {\n data = JSON.parse(raw);\n} catch (error) {\n return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }];\n}\nconst items = Array.isArray(data) ? data : (data.actions ?? []);\nconst actions = items\n .filter(item => item && String(item.task ?? '').trim())\n .map(item => ({\n task: String(item.task).trim(),\n owner: String(item.owner || '미정').trim() || '미정',\n due: String(item.due || '미정').trim() || '미정',\n }));\nreturn [{ json: { actions, count: actions.length } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"name": "Parse actions",
"position": [
440,
0
],
"id": "b97d203a-dcbf-5d31-9265-106a5f6a83aa"
}
],
"connections": {
"Start": {
"main": [
[
{
"node": "Fake LLM answer",
"type": "main",
"index": 0
}
]
]
},
"Fake LLM answer": {
"main": [
[
{
"node": "Parse actions",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"pinData": {},
"id": "a6c07c353c6610"
}
@@ -1,241 +0,0 @@
{
"name": "66 Meeting notes (my)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "my-meeting-actions",
"responseMode": "responseNode",
"options": {}
},
"id": "c3c17991-1bf8-5a91-abf1-f1cfdf0f12be",
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "f65c6c90-0042-57c2-832a-bce70f02d66b"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "a65c3a2e-b4c8-5087-a475-c6a858d9b7d6",
"name": "llm_url",
"value": "https://api.openai.com/v1",
"type": "string"
},
{
"id": "e30dbc5a-e549-5565-9f8f-96e55670f69f",
"name": "model",
"value": "gpt-4.1-mini",
"type": "string"
},
{
"id": "4ef048c7-0ac0-5913-b743-12cda43aa2b2",
"name": "title",
"value": "={{ $json.body.title || '제목 없음' }}",
"type": "string"
},
{
"id": "b901c535-f38f-5a59-975a-05074ce8044c",
"name": "notes",
"value": "={{ $json.body.notes }}",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"id": "9f1fe720-925b-5518-9038-88631e8fc018",
"name": "Settings",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
220,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 2
},
"conditions": [
{
"id": "41964561-0243-53d7-b1cd-0e64054d7460",
"leftValue": "={{ $json.notes }}",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
},
"rightValue": ""
}
],
"combinator": "and"
},
"options": {}
},
"id": "ceaaa37c-8541-574a-9fae-1bc55fd90a2a",
"name": "Has notes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
440,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { \"error\": \"notes is required\" } }}",
"options": {
"responseCode": 400
}
},
"id": "35a8cb08-bc4c-502f-9176-e6414457c99e",
"name": "Reject empty",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
660,
120
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $('Settings').first().json.llm_url }}/chat/completions",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({\n model: $('Settings').first().json.model,\n temperature: 0,\n response_format: { type: 'json_schema', json_schema: { name: 'reply', schema: {\n type: 'object',\n properties: {\n actions: { type: 'array', items: { type: 'object',\n properties: { task: { type: 'string' }, owner: { type: 'string' }, due: { type: 'string' } },\n required: ['task', 'owner', 'due'] } },\n },\n required: ['actions'],\n } } },\n messages: [\n { role: 'system', content: \"Extract action items from the meeting notes. Answer in JSON: {\\\"actions\\\": [{\\\"task\\\": str, \\\"owner\\\": str, \\\"due\\\": str}]}. Write task in Korean. Use \\\"미정\\\" when the owner or due date is not stated. Return {\\\"actions\\\": []} when there is no action item.\" },\n { role: 'user', content: $('Settings').first().json.notes },\n ],\n}) }}",
"options": {
"timeout": 180000
},
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi"
},
"id": "bab5c8f4-53c5-5623-b7c8-1f75f962e60b",
"name": "LLM extract actions",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
660,
-100
],
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"parameters": {
"jsCode": "// Parse the LLM answer (JSON text) into a clean list of action items.\nconst raw = String($input.first().json.choices?.[0]?.message?.content ?? '').trim().replace(/^```(json)?\\s*|```$/g, '');\nlet data;\ntry {\n data = JSON.parse(raw);\n} catch (error) {\n return [{ json: { actions: [], count: 0, error: 'answer is not valid JSON' } }];\n}\nconst items = Array.isArray(data) ? data : (data.actions ?? []);\nconst actions = items\n .filter(item => item && String(item.task ?? '').trim())\n .map(item => ({\n task: String(item.task).trim(),\n owner: String(item.owner || '미정').trim() || '미정',\n due: String(item.due || '미정').trim() || '미정',\n }));\nreturn [{ json: { actions, count: actions.length } }];"
},
"id": "2d12968a-6877-53a7-a7e4-ec7c51d893b1",
"name": "Parse actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
880,
-100
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "155b7034-4293-50ca-a6f6-71db885a2e06",
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1100,
-100
]
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Settings",
"type": "main",
"index": 0
}
]
]
},
"Settings": {
"main": [
[
{
"node": "Has notes?",
"type": "main",
"index": 0
}
]
]
},
"Has notes?": {
"main": [
[
{
"node": "LLM extract actions",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"LLM extract actions": {
"main": [
[
{
"node": "Parse actions",
"type": "main",
"index": 0
}
]
]
},
"Parse actions": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"pinData": {},
"id": "a6c07c353c6611"
}
@@ -1,229 +0,0 @@
{
"name": "66 Practice 2 · loop and merge",
"nodes": [
{
"parameters": {},
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"name": "Start",
"position": [
0,
0
],
"id": "188d4e06-7014-58e1-8053-cfebb3d2b1ac"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "8ee7c660-4702-575c-a2b7-cd64a157d621",
"name": "actions",
"value": "={{ [ { task: '신규 고객 문의 응답 템플릿 정리', owner: '이준호', due: '금요일' }, { task: '서버 점검 일정 공지', owner: '김민지', due: '오늘' }, { task: '회의실 예약 규칙 개선', owner: '미정', due: '미정' } ] }}",
"type": "array"
}
]
},
"includeOtherFields": false,
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"name": "Sample actions",
"position": [
220,
-100
],
"id": "f85d4eae-f60b-5f2e-a6f3-592638343b0c"
},
{
"parameters": {
"fieldToSplitOut": "actions",
"options": {}
},
"id": "5fa4a092-6961-574a-89fa-c2e05a5113f3",
"name": "One item per action",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
440,
-100
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "99cb6b63-de3c-560f-8170-b33332fb5cb9",
"name": "task",
"value": "={{ $json.task }}",
"type": "string"
},
{
"id": "ee9068cb-7666-5a5b-8937-d9c748333d6a",
"name": "owner",
"value": "={{ $json.owner }}",
"type": "string"
},
{
"id": "067da7cb-3f07-5f20-b45d-6f5fe45ca441",
"name": "due",
"value": "={{ $json.due }}",
"type": "string"
},
{
"id": "1efe8c52-179a-5a38-8648-f78d96bb8e88",
"name": "priority",
"value": "={{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"id": "3605a441-d86c-5f4a-98f5-2edfae18a003",
"name": "Normalize action",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
660,
-100
]
},
{
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "actions",
"options": {}
},
"id": "44e52bee-7b5a-547d-9c1c-3579d5499c85",
"name": "Collect actions",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
880,
-100
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "78c8fca5-5936-5934-b362-52f09a6df40f",
"name": "summary",
"value": "주간 회의에서 할 일 3건을 정했습니다. 서버 점검 공지는 오늘 안에 합니다.",
"type": "string"
}
]
},
"includeOtherFields": false,
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"name": "Sample summary",
"position": [
220,
120
],
"id": "d551b1bf-b437-5eb6-bde7-9391f51d468a"
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"id": "41b96eeb-592f-5a28-a9a3-dc7e9b077939",
"name": "Join results",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
1100,
0
]
}
],
"connections": {
"Start": {
"main": [
[
{
"node": "Sample actions",
"type": "main",
"index": 0
},
{
"node": "Sample summary",
"type": "main",
"index": 0
}
]
]
},
"Sample actions": {
"main": [
[
{
"node": "One item per action",
"type": "main",
"index": 0
}
]
]
},
"One item per action": {
"main": [
[
{
"node": "Normalize action",
"type": "main",
"index": 0
}
]
]
},
"Normalize action": {
"main": [
[
{
"node": "Collect actions",
"type": "main",
"index": 0
}
]
]
},
"Collect actions": {
"main": [
[
{
"node": "Join results",
"type": "main",
"index": 0
}
]
]
},
"Sample summary": {
"main": [
[
{
"node": "Join results",
"type": "main",
"index": 1
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"pinData": {},
"id": "a6c07c353c6612"
}
@@ -0,0 +1,153 @@
{
"name": "66 Meeting summary (reference · Lab 01 example 2)",
"nodes": [
{
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-meeting",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Summarize",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "=회의 제목: {{ $('Meeting webhook').first().json.body.title || '회의' }}\n회의록:\n{{ $('Meeting webhook').first().json.body.notes }}",
"options": {
"systemMessage": "You summarize Korean meeting notes in one Korean sentence: what the meeting was about and what was decided. Use only the notes. Return only the sentence.",
"maxIterations": 2
}
}
},
{
"name": "Summary 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": "Build result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
660,
300
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "title",
"value": "={{ $('Meeting webhook').first().json.body.title }}",
"type": "string"
},
{
"id": "a1",
"name": "summary",
"value": "={{ $json.output.trim() }}",
"type": "string"
}
]
},
"options": {}
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
880,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Summarize",
"type": "main",
"index": 0
}
]
]
},
"Summary model": {
"ai_languageModel": [
[
{
"node": "Summarize",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Summarize": {
"main": [
[
{
"node": "Build result",
"type": "main",
"index": 0
}
]
]
},
"Build result": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,268 @@
{
"name": "66 Meeting actions (reference · Lab 02 example 2)",
"nodes": [
{
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-meeting",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has notes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ String($json.body.notes ?? '').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,
560
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"notes are required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Summarize",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "=회의 제목: {{ $('Meeting webhook').first().json.body.title || '회의' }}\n회의록:\n{{ $('Meeting webhook').first().json.body.notes }}",
"options": {
"systemMessage": "You summarize Korean meeting notes in one Korean sentence: what the meeting was about and what was decided. Use only the notes. Return only the sentence.",
"maxIterations": 2
}
}
},
{
"name": "Summary 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": "Extract actions",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
680,
300
],
"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 every action item (a task someone must do). Return only JSON, no code fence: {\"actions\": [{\"owner\": \"person in charge or empty\", \"task\": \"the task in Korean\", \"due\": \"due date as written or empty\"}]} Keep the order of the notes. Use only what the notes say: never invent an owner or a date. If there is no action item, return {\"actions\": []}.",
"maxIterations": 2
}
}
},
{
"name": "Actions model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
640,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
940,
300
],
"parameters": {
"jsCode": "// LLM output is text. Read it as JSON; if that fails, treat it as no actions. Then fill the blanks.\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 ?? '').trim() || '미정', task: String(item?.task ?? '').trim(), due: String(item?.due ?? '').trim() || '미정' }))\n .filter(item => item.task);\n\nreturn [{ json: { title: (String($('Meeting webhook').first().json.body.title ?? '').trim() || '회의'), summary: $('Summarize').first().json.output.trim(), count: actions.length, actions } }];"
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1160,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Has notes?",
"type": "main",
"index": 0
}
]
]
},
"Has notes?": {
"main": [
[
{
"node": "Summarize",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Summary model": {
"ai_languageModel": [
[
{
"node": "Summarize",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Summarize": {
"main": [
[
{
"node": "Extract actions",
"type": "main",
"index": 0
}
]
]
},
"Actions model": {
"ai_languageModel": [
[
{
"node": "Extract actions",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Extract actions": {
"main": [
[
{
"node": "Parse actions",
"type": "main",
"index": 0
}
]
]
},
"Parse actions": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,499 @@
{
"name": "66 Meeting actions with check (reference · Lab 03 example 1)",
"nodes": [
{
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-meeting",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has notes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ String($json.body.notes ?? '').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,
560
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"notes are required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Summarize",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
300
],
"parameters": {
"promptType": "define",
"text": "=회의 제목: {{ $('Meeting webhook').first().json.body.title || '회의' }}\n회의록:\n{{ $('Meeting webhook').first().json.body.notes }}",
"options": {
"systemMessage": "You summarize Korean meeting notes in one Korean sentence: what the meeting was about and what was decided. Use only the notes. Return only the sentence.",
"maxIterations": 2
}
}
},
{
"name": "Summary 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": "Extract actions",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
680,
300
],
"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 every action item (a task someone must do). Return only JSON, no code fence: {\"actions\": [{\"owner\": \"person in charge or empty\", \"task\": \"the task in Korean\", \"due\": \"due date as written or empty\"}]} Keep the order of the notes. Use only what the notes say: never invent an owner or a date. If there is no action item, return {\"actions\": []}.",
"maxIterations": 2
}
}
},
{
"name": "Actions model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
640,
520
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Parse actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
940,
300
],
"parameters": {
"jsCode": "// LLM output is text. Read it as JSON; if that fails, treat it as no actions. Then fill the blanks.\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 ?? '').trim() || '미정', task: String(item?.task ?? '').trim(), due: String(item?.due ?? '').trim() || '미정' }))\n .filter(item => item.task);\n\nreturn [{ json: { title: (String($('Meeting webhook').first().json.body.title ?? '').trim() || '회의'), summary: $('Summarize').first().json.output.trim(), count: actions.length, actions } }];"
}
},
{
"name": "Any actions?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1160,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.count > 0 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "One item per action",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
1380,
200
],
"parameters": {
"fieldToSplitOut": "actions",
"options": {}
}
},
{
"name": "Mark check",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1600,
200
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "owner",
"value": "={{ $json.owner }}",
"type": "string"
},
{
"id": "a1",
"name": "task",
"value": "={{ $json.task }}",
"type": "string"
},
{
"id": "a2",
"name": "due",
"value": "={{ $json.due }}",
"type": "string"
},
{
"id": "a3",
"name": "needs_check",
"value": "={{ $json.owner === '미정' || $json.due === '미정' }}",
"type": "boolean"
}
]
},
"options": {}
}
},
{
"name": "Collect actions",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1820,
200
],
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "actions",
"options": {}
}
},
{
"name": "Build report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
200
],
"parameters": {
"jsCode": "// One report: the summary, the checked actions and how many need a person to confirm.\nconst actions = $input.all().length && $input.first().json.actions ? $input.first().json.actions : [];\nconst parsed = $('Parse actions').first().json;\nreturn [{ json: { title: parsed.title, summary: parsed.summary, count: actions.length, actions,\n needs_check_count: actions.filter(action => action.needs_check).length } }];"
}
},
{
"name": "No actions",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1600,
460
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "title",
"value": "={{ $json.title }}",
"type": "string"
},
{
"id": "a1",
"name": "summary",
"value": "={{ $json.summary }}",
"type": "string"
},
{
"id": "a2",
"name": "count",
"value": 0,
"type": "number"
},
{
"id": "a3",
"name": "actions",
"value": "={{ [] }}",
"type": "array"
},
{
"id": "a4",
"name": "needs_check_count",
"value": 0,
"type": "number"
}
]
},
"options": {}
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2260,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Has notes?",
"type": "main",
"index": 0
}
]
]
},
"Has notes?": {
"main": [
[
{
"node": "Summarize",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Summary model": {
"ai_languageModel": [
[
{
"node": "Summarize",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Summarize": {
"main": [
[
{
"node": "Extract actions",
"type": "main",
"index": 0
}
]
]
},
"Actions model": {
"ai_languageModel": [
[
{
"node": "Extract actions",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Extract actions": {
"main": [
[
{
"node": "Parse actions",
"type": "main",
"index": 0
}
]
]
},
"Parse actions": {
"main": [
[
{
"node": "Any actions?",
"type": "main",
"index": 0
}
]
]
},
"Any actions?": {
"main": [
[
{
"node": "One item per action",
"type": "main",
"index": 0
}
],
[
{
"node": "No actions",
"type": "main",
"index": 0
}
]
]
},
"One item per action": {
"main": [
[
{
"node": "Mark check",
"type": "main",
"index": 0
}
]
]
},
"Mark check": {
"main": [
[
{
"node": "Collect actions",
"type": "main",
"index": 0
}
]
]
},
"Collect actions": {
"main": [
[
{
"node": "Build report",
"type": "main",
"index": 0
}
]
]
},
"Build report": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"No actions": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
@@ -0,0 +1,527 @@
{
"name": "66 Meeting actions in parallel (reference · Lab 03 example 2)",
"nodes": [
{
"name": "Meeting webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
300
],
"parameters": {
"httpMethod": "POST",
"path": "my-meeting",
"responseMode": "responseNode",
"options": {}
}
},
{
"name": "Has notes?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
200,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ String($json.body.notes ?? '').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,
560
],
"parameters": {
"respondWith": "json",
"responseBody": "{ \"error\": \"notes are required\" }",
"options": {
"responseCode": 400
}
}
},
{
"name": "Summarize",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
120
],
"parameters": {
"promptType": "define",
"text": "=회의 제목: {{ $('Meeting webhook').first().json.body.title || '회의' }}\n회의록:\n{{ $('Meeting webhook').first().json.body.notes }}",
"options": {
"systemMessage": "You summarize Korean meeting notes in one Korean sentence: what the meeting was about and what was decided. Use only the notes. Return only the sentence.",
"maxIterations": 2
}
}
},
{
"name": "Summary model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
-80
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Extract actions",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [
420,
420
],
"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 every action item (a task someone must do). Return only JSON, no code fence: {\"actions\": [{\"owner\": \"person in charge or empty\", \"task\": \"the task in Korean\", \"due\": \"due date as written or empty\"}]} Keep the order of the notes. Use only what the notes say: never invent an owner or a date. If there is no action item, return {\"actions\": []}.",
"maxIterations": 2
}
}
},
{
"name": "Actions model",
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
"typeVersion": 1.2,
"position": [
380,
620
],
"parameters": {
"model": {
"__rl": true,
"mode": "id",
"value": "gpt-4.1-mini"
},
"options": {
"temperature": 0
}
},
"credentials": {
"openAiApi": {
"id": "openAiCourse0001",
"name": "OpenAI (.env)"
}
}
},
{
"name": "Join results",
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [
700,
300
],
"parameters": {
"numberInputs": 2
}
},
{
"name": "Parse actions",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
300
],
"parameters": {
"jsCode": "// Both LLM answers arrived. Parse the actions answer.\n// LLM output is text. Read it as JSON; if that fails, treat it as no actions. Then fill the blanks.\nconst raw = String($('Extract actions').first().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 ?? '').trim() || '미정', task: String(item?.task ?? '').trim(), due: String(item?.due ?? '').trim() || '미정' }))\n .filter(item => item.task);\n\nreturn [{ json: { title: (String($('Meeting webhook').first().json.body.title ?? '').trim() || '회의'), summary: $('Summarize').first().json.output.trim(), count: actions.length, actions } }];"
}
},
{
"name": "Any actions?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1160,
300
],
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c0",
"leftValue": "={{ $json.count > 0 }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
}
},
{
"name": "One item per action",
"type": "n8n-nodes-base.splitOut",
"typeVersion": 1,
"position": [
1380,
200
],
"parameters": {
"fieldToSplitOut": "actions",
"options": {}
}
},
{
"name": "Mark check",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1600,
200
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "owner",
"value": "={{ $json.owner }}",
"type": "string"
},
{
"id": "a1",
"name": "task",
"value": "={{ $json.task }}",
"type": "string"
},
{
"id": "a2",
"name": "due",
"value": "={{ $json.due }}",
"type": "string"
},
{
"id": "a3",
"name": "needs_check",
"value": "={{ $json.owner === '미정' || $json.due === '미정' }}",
"type": "boolean"
}
]
},
"options": {}
}
},
{
"name": "Collect actions",
"type": "n8n-nodes-base.aggregate",
"typeVersion": 1,
"position": [
1820,
200
],
"parameters": {
"aggregate": "aggregateAllItemData",
"destinationFieldName": "actions",
"options": {}
}
},
{
"name": "Build report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2040,
200
],
"parameters": {
"jsCode": "// One report: the summary, the checked actions and how many need a person to confirm.\nconst actions = $input.all().length && $input.first().json.actions ? $input.first().json.actions : [];\nconst parsed = $('Parse actions').first().json;\nreturn [{ json: { title: parsed.title, summary: parsed.summary, count: actions.length, actions,\n needs_check_count: actions.filter(action => action.needs_check).length } }];"
}
},
{
"name": "No actions",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1600,
460
],
"parameters": {
"assignments": {
"assignments": [
{
"id": "a0",
"name": "title",
"value": "={{ $json.title }}",
"type": "string"
},
{
"id": "a1",
"name": "summary",
"value": "={{ $json.summary }}",
"type": "string"
},
{
"id": "a2",
"name": "count",
"value": 0,
"type": "number"
},
{
"id": "a3",
"name": "actions",
"value": "={{ [] }}",
"type": "array"
},
{
"id": "a4",
"name": "needs_check_count",
"value": 0,
"type": "number"
}
]
},
"options": {}
}
},
{
"name": "Send result",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2260,
300
],
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
}
}
],
"connections": {
"Meeting webhook": {
"main": [
[
{
"node": "Has notes?",
"type": "main",
"index": 0
}
]
]
},
"Has notes?": {
"main": [
[
{
"node": "Summarize",
"type": "main",
"index": 0
},
{
"node": "Extract actions",
"type": "main",
"index": 0
}
],
[
{
"node": "Reject empty",
"type": "main",
"index": 0
}
]
]
},
"Summary model": {
"ai_languageModel": [
[
{
"node": "Summarize",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Actions model": {
"ai_languageModel": [
[
{
"node": "Extract actions",
"type": "ai_languageModel",
"index": 0
}
]
]
},
"Summarize": {
"main": [
[
{
"node": "Join results",
"type": "main",
"index": 0
}
]
]
},
"Extract actions": {
"main": [
[
{
"node": "Join results",
"type": "main",
"index": 1
}
]
]
},
"Join results": {
"main": [
[
{
"node": "Parse actions",
"type": "main",
"index": 0
}
]
]
},
"Parse actions": {
"main": [
[
{
"node": "Any actions?",
"type": "main",
"index": 0
}
]
]
},
"Any actions?": {
"main": [
[
{
"node": "One item per action",
"type": "main",
"index": 0
}
],
[
{
"node": "No actions",
"type": "main",
"index": 0
}
]
]
},
"One item per action": {
"main": [
[
{
"node": "Mark check",
"type": "main",
"index": 0
}
]
]
},
"Mark check": {
"main": [
[
{
"node": "Collect actions",
"type": "main",
"index": 0
}
]
]
},
"Collect actions": {
"main": [
[
{
"node": "Build report",
"type": "main",
"index": 0
}
]
]
},
"Build report": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
},
"No actions": {
"main": [
[
{
"node": "Send result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
}
}
+1
View File
@@ -0,0 +1 @@
{"title": "빈 회의록", "notes": ""}
+1
View File
@@ -0,0 +1 @@
{"title": "분기 실적 공유", "notes": "오늘은 지난 분기 실적을 공유하고 질의응답을 했습니다. 별도 결정 사항은 없습니다."}
+1
View File
@@ -0,0 +1 @@
{"notes": "참석: 정우성, 최유진. 1) 신규 입사자 노트북 준비를 정우성이 수요일까지 끝낸다."}
+1
View File
@@ -0,0 +1 @@
{"title": "협력사 미팅", "notes": "참석: 한지훈, 최유진. 보안 점검 일정을 협력사와 맞췄다. 1) 한지훈은 점검 체크리스트를 10월 28일까지 보낸다. 2) 최유진은 회의실을 예약한다. 3) 계약서 검토는 법무팀에 요청하기로 했다."}
+1
View File
@@ -0,0 +1 @@
{"title": "10월 16일 주간 회의", "notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."}
@@ -1,59 +0,0 @@
# 업무 자동화 워크플로우 설계서 (데이터 흐름 포함)
- 작성자:
- 업무 이름:
- 한 줄 목표:
## 1. Trigger·Condition·Action
| 구분 | 내용 |
| --- | --- |
| Trigger (시작과 입력 필드) | |
| Condition (분기 지점) | |
| Action (각 길에서 하는 일) | |
## 2. 노드 목록
| 번호 | 노드 이름 | 종류 (Trigger·Set·IF·Switch·LLM·Code·Split Out·Aggregate·Merge·Respond 등) | 하는 일 |
| --- | --- | --- | --- |
| N1 | | | |
| N2 | | | |
## 3. 노드 간 데이터 (연결선마다)
| 연결 | 항목 수 | 주요 필드와 형식 | 예시 |
| --- | --- | --- | --- |
| N1 → N2 | 1개 | `title`(문자), `notes`(문자) | `{"title": "주간 회의", "notes": "..."}` |
| | | | |
항목 수가 바뀌는 곳(1개 → 여러 개, 여러 개 → 1개)은 반드시 표시합니다.
## 4. 제어 구조
| 구조 | 어디에 | 이유 |
| --- | --- | --- |
| 조건 분기 (IF·Switch) | | |
| 반복 (항목마다 처리) | | |
| 병렬 (Fork-Join) | | |
## 5. 흐름 그림
```mermaid
flowchart LR
T(["Trigger"]) --> A["노드"] --> C{"조건"}
C -->|"참"| B["노드"]
C -->|"거짓"| D["노드"]
```
## 6. 최종 결과 형식
```json
{"route": "", "count": 0, "summary": "", "actions": []}
```
## 7. 점검
- [ ] 모든 길의 끝에 결과가 있다.
- [ ] 항목 수가 바뀌는 곳을 표시했다.
- [ ] LLM 출력이 다음 노드로 가기 전에 정리·검증하는 노드가 있다.
- [ ] 비어 있거나 잘못된 입력을 맨 앞에서 거절한다.
-47
View File
@@ -1,47 +0,0 @@
# Lab 01 활동지 · 회의록 워크플로우 설계
- 작성자:
## 1. 예제 1 · 어제 만든 흐름 읽기
| 연결 | 항목 수 | 지나가는 필드 |
| --- | --- | --- |
| Start → Sample request | 1 | (없음) |
| Sample request → Is urgent? | | `user`, `text` |
| Is urgent? → Urgent route | | `user`, `text` |
If 노드가 항목 수를 바꾸지 않는 이유:
## 2. 예제 2 · 오늘 흐름의 빈칸
| 번호 | 질문 | 답 |
| --- | --- | --- |
| ① | `Parse actions` → `Join results` 의 항목 수 | |
| ② | `One item per action` → `Normalize action` 의 항목 수 | |
| ③ | `Collect actions` → `Build report` 의 항목 수 | |
| ④ | 두 LLM 노드를 동시에 돌려도 되는 이유 | |
| ⑤ | LLM이 하는 일 두 가지 | |
## 3. 과제 · "확인 필요" 표시 추가
요청: 담당자나 기한이 정해지지 않은 할 일은 **확인 필요**로 표시하고, 그런 할 일이 몇 건인지 결과에 넣는다.
| 번호 | 질문 | 내 답 (위치와 이유 한 줄) |
| --- | --- | --- |
| 1 | LLM이 할 일인가, 규칙이 할 일인가? | |
| 2 | 할 일 하나마다 표시하는 곳과 더할 필드 | |
| 3 | 몇 건인지 세는 곳과 더할 필드 | |
| 4 | `No actions` 길의 결과에 넣을 것 | |
| 5 | 항목 수가 바뀌는 곳이 생기나? | |
바뀐 결과 형식 (예시 JSON):
```json
{"route": "할 일 정리", "count": 4, "summary": "", "actions": []}
```
## 4. Lab 03을 마친 뒤 (복습 과제)
내 설계와 실제로 만든 워크플로우에서 달라진 점:
-
@@ -0,0 +1,30 @@
# 테스트 보고서 · 회의록 → 할 일 정리
- 이름:
- 사용한 CLI 에이전트: codex / claude / agy
- 워크플로우 파일: `my-work/meeting.json`
## 1. 내가 쓴 테스트 (`cases/my-cases.json`)
| 케이스 | 요청 | 기대 결과 (업무 규칙) |
| --- | --- | --- |
| | | |
## 2. 실패와 고친 내용
| 실패한 케이스 | 처음 결과 (3번 중 통과) | 내가 생각한 원인 | 에이전트가 고친 곳 | 고친 뒤 (3번 중 통과) |
| --- | --- | --- | --- | --- |
| | | | | |
## 3. 최종 결과
| 테스트 파일 | 결과 |
| --- | --- |
| `cases/lab02.json --repeat 3` | Passed /18 |
| `cases/lab03.json --repeat 3` | Passed /15 |
| `cases/my-cases.json --repeat 3` | Passed / |
## 4. 남은 문제 · 배운 점
- 지시문(systemMessage)으로 고친 것과 규칙 노드(If·Code)로 고친 것 중 어느 쪽이 더 안정적이었나?
-
+624
View File
@@ -0,0 +1,624 @@
# /// 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/meeting.json # find mistakes before n8n sees the file
uv run tools/flow.py push my-work/meeting.json # import + publish (one n8n restart)
uv run tools/flow.py off my-sms # unpublish by file or webhook path (a role that is down)
uv run tools/flow.py call my-meeting requests/weekly.json
uv run tools/flow.py test cases/lab02.json [--repeat 3]
Run it from the day folder (Last3Mon/66). 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,
"@n8n/n8n-nodes-langchain.lmChatOllama": 1,
"@n8n/n8n-nodes-langchain.vectorStoreInMemory": 1.3,
"@n8n/n8n-nodes-langchain.embeddingsOpenAi": 1.2,
"@n8n/n8n-nodes-langchain.documentDefaultDataLoader": 1.1,
}
USES_DATA_API = False # day 66 labs do not call the data API
AGENT_HOOKS: tuple[str, ...] = () # no ready-made agents on day 66
COURSE_CREDENTIALS = {"openAiCourse0001": "OpenAI (.env)", "ollamaLocal0001": "Ollama local"}
CONNECTION_TYPES = {"main", "ai_tool", "ai_languageModel", "ai_memory", "ai_outputParser", "ai_embedding", "ai_document"}
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'")
is_sub_node = bool(targets_of.get(f"{name}::from")) # model, tool, embeddings, loader: connected by ai_* links
if node_type != "n8n-nodes-base.webhook" and not node_type.startswith("@n8n/n8n-nodes-langchain.lm") and not is_sub_node \
and not (node_type.endswith("Tool") or node_type.startswith("@n8n/n8n-nodes-langchain.tool")) \
and "main" not in targets_of.get(name, set()) and "trigger" not in node_type.lower():
warnings.append(f"node '{name}': nothing is connected into it (it never runs)")
if not webhooks(workflow):
warnings.append("no Webhook node: 'push' works, but 'call' and 'test' need a webhook path")
return errors, warnings
def cmd_check(args: argparse.Namespace) -> int:
path = resolve(args.file)
errors, warnings = check_workflow(load_json(path))
for warning in warnings:
print(f"{WARN} {warning}")
for error in errors:
print(f"{BAD} {error}")
if errors:
print(f"{BAD} {path.name}: {len(errors)} error(s). Fix them and run check again.")
return 1
print(f"{OK} {path.name}: no errors" + (f" ({len(warnings)} warning(s))" if warnings else ""))
return 0
# ---------------------------------------------------------------- push
def stable_id(path: Path) -> str:
return "w" + hashlib.sha1(path.stem.encode("utf-8")).hexdigest()[:15]
def wait_until_ready(paths: list[tuple[str, str]], seconds: int = 90) -> bool:
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
status, _, _ = http("GET", f"{N8N_URL}/healthz", timeout=3)
if status == 200:
break
time.sleep(1)
else:
return False
for method, path in paths:
while time.monotonic() < deadline:
# A webhook that is registered for POST answers a GET with a hint, not "is not registered".
status, payload, _ = http("GET" if method != "GET" else "HEAD", f"{N8N_URL}/webhook/{path}", timeout=5)
text = json.dumps(payload, ensure_ascii=False)
if "not registered for" in text or status not in (0, 404, 500, 502, 503):
break
time.sleep(1)
time.sleep(2) # webhooks of the other published workflows finish registering right after
return time.monotonic() < deadline
def exported_workflows() -> list[dict]:
exported = docker("exec", CONTAINER, "sh", "-c",
"n8n export:workflow --all --output=/tmp/course-all.json >/dev/null 2>&1; "
"cat /tmp/course-all.json 2>/dev/null; rm -f /tmp/course-all.json", check=False).stdout
try:
return json.loads(exported) if exported.strip() else []
except json.JSONDecodeError:
return []
def prepare(path: Path) -> dict | None:
workflow = load_json(path)
errors, warnings = check_workflow(workflow)
for warning in warnings:
print(f"{WARN} {path.name}: {warning}")
if errors:
for error in errors:
print(f"{BAD} {path.name}: {error}")
return None
workflow = dict(workflow)
if not re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))):
workflow["id"] = stable_id(path)
workflow["active"] = False
workflow.setdefault("settings", {}).setdefault("executionOrder", "v1")
for node in workflow["nodes"]:
node.setdefault("id", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/{node['name']}")))
if node["type"] == "n8n-nodes-base.webhook":
node.setdefault("webhookId", str(uuid.uuid5(uuid.NAMESPACE_URL, f"{workflow['id']}/webhook/{node['name']}")))
return workflow
def cmd_push(args: argparse.Namespace) -> int:
paths = [resolve(file) for file in args.files]
workflows = [prepare(path) for path in paths]
if None in workflows:
print(f"{BAD} not pushed. Fix the errors first (uv run tools/flow.py check <file>).")
return 1
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
mine_ids = {workflow["id"] for workflow in workflows}
hooks = {hook for workflow in workflows for hook in webhooks(workflow)}
# Another published workflow with the same webhook path would block this one.
for other in exported_workflows():
if other.get("id") not in mine_ids and other.get("active") and hooks & set(webhooks(other)):
n8n("unpublish:workflow", f"--id={other['id']}")
print(f"{WARN} unpublished '{other.get('name')}' ({other['id']}): it used the same webhook path")
for workflow in workflows:
docker("exec", "-i", CONTAINER, "sh", "-c",
"cat > /tmp/course-workflow.json && n8n import:workflow --input=/tmp/course-workflow.json; "
"status=$?; rm -f /tmp/course-workflow.json; exit $status",
stdin=json.dumps(workflow, ensure_ascii=False))
n8n("publish:workflow", f"--id={workflow['id']}")
# The CLI changes the database only; one restart makes the running n8n load the new versions (about 5 s).
docker("restart", CONTAINER)
ready = wait_until_ready(sorted(hooks))
for workflow in workflows:
print(f"{OK} pushed '{workflow['name']}' (id {workflow['id']})")
for method, hook in sorted(webhooks(workflow)):
print(f" {method} {N8N_URL}/webhook/{hook}")
if not ready:
print(f"{WARN} n8n is still starting. Wait a few seconds before 'call' or 'test'.")
return 0
def cmd_off(args: argparse.Namespace) -> int:
"""Unpublish workflows, given as files or webhook paths, to see how the others behave when one role is down."""
if not container_running():
die(f"n8n container '{CONTAINER}' is not running. {START_HINT}")
exported = exported_workflows()
wanted: dict[str, str] = {}
for item in args.files:
path = resolve(item) if (ROOT / item).exists() or Path(item).exists() else None
if path:
workflow = load_json(path)
workflow_id = workflow.get("id") if re.fullmatch(r"[A-Za-z0-9]{10,}", str(workflow.get("id", ""))) else stable_id(path)
wanted[workflow_id] = item
hooks = {hook for _, hook in webhooks(workflow)}
else:
hooks = {item.strip("/")}
for other in exported:
if other.get("active") and hooks & {hook for _, hook in webhooks(other)}:
wanted[other["id"]] = item
active = {item.get("id") for item in exported if item.get("active")}
for workflow_id, item in wanted.items():
if workflow_id in active:
n8n("unpublish:workflow", f"--id={workflow_id}")
print(f"{OK} unpublished {item} (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 <the workflow file>")
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")
# "before": calls to make once before the cases, e.g. rebuild the in-memory index (lost when n8n restarts).
for step in ([] if args.no_before else suite.get("before", [])):
step_hook = str(step.get("webhook", "")).strip("/")
status, payload, seconds = http("POST", f"{N8N_URL}/webhook/{step_hook}", request_body(step.get("body") or step.get("request", "{}")))
shown = json.dumps(payload, ensure_ascii=False)[:120] if not isinstance(payload, str) else payload[:120]
print(f"{OK if 200 <= status < 300 else BAD} before: POST /webhook/{step_hook} · HTTP {status} · {seconds:.1f}s · {shown}")
if not 200 <= status < 300:
die(f"the 'before' step failed. Is '{step_hook}' pushed? Try: uv run tools/flow.py call {step_hook}")
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)
if USES_DATA_API:
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)")
docs_status, docs, _ = http("GET", "http://localhost:8800/docs", timeout=5)
count = len(docs.get("docs", [])) if isinstance(docs, dict) else 0
line(docs_status == 200 and count > 0, f"policy documents /docs ({count})",
"the data API is an older day's version: cd ~/aio-01d4-stu/Last3Mon/67/data-api && docker compose up -d --force-recreate")
else:
print(f"{OK if status == 200 else WARN} data API http://localhost:8800 (not used today)")
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()
if USES_DATA_API:
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 <agent workflow file>")
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/66/00-DAY66-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 or webhook paths (a role 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-notice")
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.add_argument("--no-before", action="store_true", help="skip the 'before' steps (keep the index you built by hand)")
item.set_defaults(run=cmd_test)
args = parser.parse_args()
return args.run(args)
if __name__ == "__main__":
sys.exit(main())
-5
View File
@@ -1,5 +0,0 @@
runs/
.miniflow-memory.json
.venv/
.pytest_cache/
__pycache__/
-125
View File
@@ -1,125 +0,0 @@
# workflow-lab (66일차): JSON 출력 파싱과 회의록 워크플로우
65일차 버전(조건 분기·플랫폼 전환)에 **LLM JSON 출력(`json_schema`)** 과 **`parse_actions` Tool**, 회의록 워크플로우(`08`)를 더한 버전입니다.
노코드 워크플로우 도구는 화면에서 **노드를 놓고 선으로 연결**합니다. 이 실습에서는 같은 구조를 JSON 파일로 적고 `miniflow`로 실행합니다. 화면 대신 파일을 쓸 뿐, 노드·연결·데이터 전달이라는 개념은 같습니다.
```mermaid
flowchart LR
J["flows/*.json<br/>노드와 연결 정의"] --> E["miniflow 엔진<br/>순서 계산 · 실행"]
E --> L{{"LLM 노드<br/>openai · ollama · mock · anthropic · gemini"}}
E --> T["Tool 노드<br/>get_weather · search_faq"]
E --> M[("Memory 노드<br/>.miniflow-memory.json")]
E --> R["runs/*.json<br/>실행 기록(Trace)"]
```
## 1. 설치와 확인
```bash
cd Last3Mon/64/workflow-lab
uv sync
uv run miniflow tools
uv run pytest -q
```
## 2. 명령어
| 명령 | 하는 일 |
| --- | --- |
| `uv run miniflow run flows/01-llm-only.json -i text="..."` | 워크플로우 실행. 입력은 `-i 이름=값`으로 여러 개 전달 |
| `uv run miniflow validate flows/02-llm-tool.json` | 실행하지 않고 구조만 검사, 실행 순서 출력 |
| `uv run miniflow show flows/04-recommend-with-memory.json` | 워크플로우를 Mermaid 다이어그램으로 출력 |
| `uv run miniflow tools` | 사용할 수 있는 Tool 목록 |
| `uv run miniflow call request -i user=kim -i text="..."` | 같은 업무를 n8n → miniflow 순서로 실행 (앞 플랫폼이 실패하면 다음으로 전환) |
| `uv run miniflow check` | 설정한 LLM에 연결되는지 확인 |
| `uv run miniflow memory` / `--clear` | Memory 내용 보기 / 지우기 |
실행할 때마다 각 노드의 입력·출력·소요 시간이 `runs/` 폴더에 JSON으로 저장됩니다.
## 3. 노드 종류
| type | 역할 | 필수 필드 |
| --- | --- | --- |
| `input` | 실행 시 받은 입력을 흐름에 넣음 | 없음 |
| `llm` | 프롬프트를 LLM에 보내고 답을 받음 | `prompt` (선택: `system`, `temperature`, `mock`, `json_schema`) |
`json_schema`를 적으면 LLM이 그 모양의 JSON으로만 답합니다 (OpenAI 구조화 출력, Ollama `format`). 답은 JSON **문자열**이므로 `parse_actions` 같은 Tool로 정리해서 씁니다.
| `tool` | 등록된 함수를 호출해 데이터를 가져옴 | `tool`, `args` |
| `memory_read` | 저장된 기억을 키로 읽음 | `key` |
| `memory_write` | 기억을 키에 저장(기존 값과 병합) | `key`, `value` |
| `output` | 최종 결과를 만듦 (분기마다 하나씩 둘 수 있음) | `value` |
| `condition` | 조건을 검사해 `true`/`false` 중 한 길로 보냄 | `check` (`left`, `op`, `right`) |
| `switch` | 값에 따라 여러 길 중 하나로 보냄. 맞는 값이 없으면 `default` | `value`, `cases` |
`condition`의 `op`: `equals`, `not_equals`, `contains`, `empty`, `not_empty`, `in`, `gt`, `lt`
분기 노드에서 나가는 연결에는 세 번째 값으로 길 이름을 적습니다. 실행 규칙은 다음과 같습니다.
- 길 이름이 있는 연결은 **관문**입니다. 분기 노드가 다른 길을 골랐거나 분기 노드 자체가 건너뛰어졌으면, 연결된 노드는 실행되지 않고 실행 기록에 `skipped`로 남습니다.
- 길 이름이 없는 일반 연결은 하나라도 이어지면 실행됩니다. 그래서 갈라졌던 두 길이 다시 만나는 노드는 정상적으로 실행됩니다.
```json
"edges": [["has_faq", "draft", "true"], ["has_faq", "hand_off", "false"], ["route", "team_device", "device"]]
```
## 4. 데이터 전달 규칙
앞 노드의 결과는 `{{...}}`로 꺼내 씁니다.
| 표현 | 의미 |
| --- | --- |
| `{{input.city}}` | 실행할 때 받은 입력 `city` |
| `{{nodes.weather.output}}` | `weather` 노드의 결과 전체 |
| `{{nodes.weather.output.temp_c}}` | 결과 안의 특정 값 |
| `{{nodes.faq.output.0.answer}}` | 목록의 첫 번째 항목 안의 값 |
## 5. LLM 선택
`MINIFLOW_LLM`으로 LLM을 고릅니다. 수업 기본값은 **OpenAI API**(`gpt-4.1-mini`)이고, 설정은 `Last3Mon/.env` 한 곳에 둡니다. `miniflow`는 실행한 폴더에서 위로 올라가며 가장 가까운 `.env`를 읽습니다 (셸에서 `export`한 값이 있으면 그 값이 우선). `.env`는 `.gitignore`에 들어 있어 커밋되지 않습니다. 준비 방법은 [65일차 SETUP-N8N.md](../../65/SETUP-N8N.md) 2절을 참고하세요.
| `MINIFLOW_LLM` | LLM | 필요한 것 |
| --- | --- | --- |
| `openai` (수업 기본) | OpenAI (`OPENAI_MODEL`, 기본 `gpt-4.1-mini`) | `.env`의 `OPENAI_API_KEY` |
| `ollama` (대체) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | 65일차 Compose의 Ollama, 모델 다운로드 |
| `mock` (설정하지 않았을 때) | 노드의 `mock` 설정대로 정해진 답 | 없음 (자동 테스트, 키 없이 연습) |
| `anthropic` | Anthropic Claude (`ANTHROPIC_MODEL`, 기본 `claude-opus-5-5`) | `uv sync --extra anthropic`, `ANTHROPIC_API_KEY` |
| `gemini` | Google Gemini (`GEMINI_MODEL`) | `uv sync --extra gemini`, `GEMINI_API_KEY` |
`MINIFLOW_LLM_FALLBACK=ollama`이면 OpenAI 호출이 실패할 때(키 없음, 크레딧 부족, 네트워크 오류) Ollama로 한 번 더 시도하고, 화면에 `[miniflow] openai failed (...); trying ollama`를 남깁니다.
```bash
cat ../../.env | grep -v API_KEY # 설정 확인 (키는 화면에 띄우지 않기)
uv run miniflow check # 연결 확인: Reply (openai, …): ready
uv run miniflow check # 대체 모델 확인 (한 번만 바꿔 실행)
```
## 6. 플랫폼 전환 실행 (`call`)
`platforms.json`에 업무 이름(`request`)과 플랫폼별 실행 방법을 적어 둡니다. 실행 코드는 업무 이름만 알고, 어느 플랫폼에서 실행할지는 설정이 정합니다.
```json
{"workflows": {"request": {
"n8n": {"webhook": "ai-request"},
"miniflow": {"flow": "flows/06-request-with-condition.json"}
}}}
```
| 환경 변수 | 의미 | 기본값 |
| --- | --- | --- |
| `WORKFLOW_PLATFORMS` | 시도할 플랫폼 순서 | `n8n,miniflow` |
| `N8N_BASE_URL` | n8n 주소 | `http://localhost:5678` |
| `N8N_TIMEOUT` | n8n 응답 대기 시간(초) | `120` |
플랫폼을 추가할 때는 `src/miniflow/platforms.py`에 `run(workflow, inputs)`를 가진 어댑터 클래스를 만들고 `register_platform()`으로 등록합니다. `call`을 쓰는 코드는 바꾸지 않습니다.
## 7. 회의록 워크플로우 (`08-meeting-actions.json`)
```bash
uv run miniflow run flows/08-meeting-actions.json -i title="주간 회의" \
-i notes="$(python3 -c "import json;print(json.load(open('data/meeting-notes.json'))['weekly']['notes'])")"
uv run miniflow call meeting -i title="주간 회의" -i notes="..." # n8n → miniflow 순서로 실행
```
| Tool | 하는 일 |
| --- | --- |
| `parse_actions(text)` | LLM의 JSON 답을 `{"actions": [{task, owner, due}], "count": n}`로 정리. 비어 있는 담당자·기한은 `미정`, JSON이 아니면 `count: 0` |
-14
View File
@@ -1,14 +0,0 @@
[
{"id": "FAQ-001", "title": "비밀번호 재설정", "keywords": ["비밀번호", "패스워드", "password", "재설정", "초기화"],
"answer": "사내 포털 로그인 화면의 '비밀번호 찾기'에서 휴대폰 인증 후 새 비밀번호를 설정합니다."},
{"id": "FAQ-002", "title": "계정 잠김 해제", "keywords": ["계정", "잠김", "잠금", "로그인", "실패"],
"answer": "로그인 5회 실패 시 30분 동안 잠깁니다. 30분 후 다시 시도하거나 헬프데스크에 잠금 해제를 요청합니다."},
{"id": "FAQ-003", "title": "VPN 접속 오류", "keywords": ["vpn", "원격", "재택", "접속", "연결"],
"answer": "VPN 클라이언트를 최신 버전으로 업데이트하고, 인증서 만료 여부를 확인한 뒤 다시 접속합니다."},
{"id": "FAQ-004", "title": "프린터 출력 안 됨", "keywords": ["프린터", "출력", "인쇄", "용지"],
"answer": "프린터 전원과 네트워크 연결을 확인하고, 인쇄 대기열을 비운 뒤 다시 출력합니다."},
{"id": "FAQ-005", "title": "메일함 용량 초과", "keywords": ["메일", "용량", "메일함", "첨부"],
"answer": "오래된 메일과 큰 첨부 파일을 보관함으로 옮기거나 삭제해 용량을 확보합니다."},
{"id": "FAQ-006", "title": "소프트웨어 설치 요청", "keywords": ["설치", "소프트웨어", "프로그램", "라이선스"],
"answer": "IT 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
@@ -1,10 +0,0 @@
{
"weekly": {
"title": "10월 16일 주간 회의",
"notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."
},
"no_action": {
"title": "분기 실적 공유",
"notes": "오늘은 지난 분기 실적을 공유하고 질의응답을 했습니다. 별도 결정 사항은 없습니다."
}
}
@@ -1,6 +0,0 @@
{
"서울": {"condition": "맑음", "temp_c": 19, "rain_chance": 10, "air": "보통"},
"부산": {"condition": "흐림", "temp_c": 21, "rain_chance": 40, "air": "좋음"},
"제주": {"condition": "비", "temp_c": 20, "rain_chance": 80, "air": "좋음"},
"강릉": {"condition": "맑음", "temp_c": 17, "rain_chance": 0, "air": "좋음"}
}
@@ -1,14 +0,0 @@
{
"name": "01 LLM only",
"description": "입력 → LLM → 출력. 가장 단순한 AI 워크플로우",
"inputs": ["text"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "summarize", "type": "llm",
"system": "You are a helpful assistant that writes concise Korean summaries.",
"prompt": "다음 글을 3줄 이내의 한국어로 요약해 주세요.\n\n{{input.text}}",
"mock": {"template": "(Mock 응답) 실제 LLM을 연결하면 아래 글이 3줄로 요약됩니다.\n원문: {{input.text}}"}},
{"id": "result", "type": "output", "value": "{{nodes.summarize.output}}"}
],
"edges": [["start", "summarize"], ["summarize", "result"]]
}
@@ -1,15 +0,0 @@
{
"name": "02 LLM + Tool",
"description": "Tool이 가져온 데이터를 LLM이 근거로 사용해 답변",
"inputs": ["city", "question"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "weather", "type": "tool", "tool": "get_weather", "args": {"city": "{{input.city}}"}},
{"id": "answer", "type": "llm",
"system": "Answer only from the given weather data. Reply in Korean.",
"prompt": "날씨 데이터: {{nodes.weather.output}}\n질문: {{input.question}}",
"mock": {"template": "{{nodes.weather.output.city}}의 오늘 날씨는 {{nodes.weather.output.condition}}, 기온 {{nodes.weather.output.temp_c}}도, 강수 확률 {{nodes.weather.output.rain_chance}}%입니다."}},
{"id": "result", "type": "output", "value": "{{nodes.answer.output}}"}
],
"edges": [["start", "weather"], ["weather", "answer"], ["answer", "result"]]
}
@@ -1,12 +0,0 @@
{
"name": "03 Save profile to memory",
"description": "사용자 선호를 Memory에 저장 (다음 실행에서 다시 사용)",
"inputs": ["user", "preference"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "remember", "type": "memory_write", "key": "user:{{input.user}}",
"value": {"preference": "{{input.preference}}"}},
{"id": "result", "type": "output", "value": {"saved_for": "{{input.user}}", "memory": "{{nodes.remember.output}}"}}
],
"edges": [["start", "remember"], ["remember", "result"]]
}
@@ -1,19 +0,0 @@
{
"name": "04 LLM + Tool + Memory",
"description": "Memory(선호) + Tool(날씨) → LLM 추천 → Memory(방문 기록) 갱신",
"inputs": ["user", "city"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "recall", "type": "memory_read", "key": "user:{{input.user}}"},
{"id": "weather", "type": "tool", "tool": "get_weather", "args": {"city": "{{input.city}}"}},
{"id": "recommend", "type": "llm",
"system": "You are a travel assistant. Use the user's memory and the weather. Reply in Korean, 2 sentences.",
"prompt": "사용자 기억: {{nodes.recall.output}}\n날씨: {{nodes.weather.output}}\n오늘 {{input.city}}에서 할 일을 추천해 주세요.",
"mock": {"template": "{{input.user}}님은 '{{nodes.recall.output.preference}}'을(를) 좋아하시죠. {{input.city}}은(는) 오늘 {{nodes.weather.output.condition}}이니 그에 맞는 일정을 추천합니다."}},
{"id": "remember", "type": "memory_write", "key": "user:{{input.user}}",
"value": {"last_city": "{{input.city}}", "last_recommendation": "{{nodes.recommend.output}}"}},
{"id": "result", "type": "output", "value": "{{nodes.recommend.output}}"}
],
"edges": [["start", "recall"], ["start", "weather"], ["recall", "recommend"], ["weather", "recommend"],
["recommend", "remember"], ["remember", "result"]]
}
@@ -1,30 +0,0 @@
{
"name": "05 Tech support ticket",
"description": "업무 자동화 시나리오: 문의 접수 → 분류 → FAQ 검색 → 답변 초안 → 이력 저장",
"inputs": ["user", "ticket"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "history", "type": "memory_read", "key": "ticket:{{input.user}}"},
{"id": "classify", "type": "llm",
"system": "Classify the ticket into one of: account, network, device, software, other. Reply with the label only.",
"prompt": "문의: {{input.ticket}}",
"mock": {"rules": [
{"if_contains": ["비밀번호", "계정", "로그인", "잠김"], "then": "account"},
{"if_contains": ["vpn", "인터넷", "와이파이", "접속"], "then": "network"},
{"if_contains": ["프린터", "모니터", "키보드"], "then": "device"},
{"if_contains": ["설치", "프로그램", "메일"], "then": "software"}
], "default": "other"}},
{"id": "faq", "type": "tool", "tool": "search_faq", "args": {"query": "{{input.ticket}}", "top_k": 2}},
{"id": "draft", "type": "llm",
"system": "Write a polite Korean reply using only the FAQ results. Mention the FAQ id.",
"prompt": "문의: {{input.ticket}}\n분류: {{nodes.classify.output}}\n이전 문의: {{nodes.history.output}}\nFAQ 검색 결과: {{nodes.faq.output}}",
"mock": {"template": "안녕하세요 {{input.user}}님. 문의하신 내용은 [{{nodes.faq.output.0.title}}]에 해당합니다. {{nodes.faq.output.0.answer}} (참고: {{nodes.faq.output.0.id}})"}},
{"id": "log", "type": "memory_write", "key": "ticket:{{input.user}}",
"value": {"last_ticket": "{{input.ticket}}", "last_category": "{{nodes.classify.output}}"}},
{"id": "result", "type": "output",
"value": {"category": "{{nodes.classify.output}}", "faq_id": "{{nodes.faq.output.0.id}}", "reply": "{{nodes.draft.output}}"}}
],
"edges": [["start", "history"], ["start", "classify"], ["start", "faq"],
["history", "draft"], ["classify", "draft"], ["faq", "draft"],
["draft", "log"], ["log", "result"]]
}
@@ -1,38 +0,0 @@
{
"name": "06 Request flow with condition",
"description": "Trigger(입력) → 분류·FAQ 검색 → Condition(FAQ 있음?) → Action(자동 답변 또는 담당자 전달)",
"inputs": ["user", "text"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "history", "type": "memory_read", "key": "request:{{input.user}}"},
{"id": "classify", "type": "llm",
"system": "Classify the IT request into exactly one of: account, network, device, software, other. Reply with the label only.",
"prompt": "문의: {{input.text}}",
"mock": {"rules": [
{"if_contains": ["비밀번호", "계정", "로그인", "잠김", "잠겨"], "then": "account"},
{"if_contains": ["vpn", "인터넷", "와이파이", "접속"], "then": "network"},
{"if_contains": ["프린터", "모니터", "키보드"], "then": "device"},
{"if_contains": ["설치", "프로그램", "메일"], "then": "software"}
], "default": "other"}},
{"id": "faq", "type": "tool", "tool": "search_faq", "args": {"query": "{{input.text}}", "top_k": 1}},
{"id": "log", "type": "memory_write", "key": "request:{{input.user}}",
"value": {"last_text": "{{input.text}}", "last_category": "{{nodes.classify.output}}"}},
{"id": "has_faq", "type": "condition", "check": {"left": "{{nodes.faq.output}}", "op": "not_empty"}},
{"id": "draft", "type": "llm",
"system": "Write a polite 2-sentence Korean reply using only the FAQ answer. End with the FAQ id in parentheses.",
"prompt": "문의: {{input.text}}\n이전 문의: {{nodes.history.output}}\nFAQ: {{nodes.faq.output}}",
"mock": {"template": "{{input.user}}님, {{nodes.faq.output.0.answer}} ({{nodes.faq.output.0.id}})"}},
{"id": "auto_reply", "type": "output",
"value": {"category": "{{nodes.classify.output}}", "faq_id": "{{nodes.faq.output.0.id}}", "route": "자동 답변", "reply": "{{nodes.draft.output}}"}},
{"id": "hand_off", "type": "output",
"value": {"category": "{{nodes.classify.output}}", "faq_id": "", "route": "담당자 확인", "reply": "관련 안내를 찾지 못해 담당자가 확인 후 답변드리겠습니다."}}
],
"edges": [
["start", "history"], ["start", "classify"], ["start", "faq"],
["classify", "log"],
["faq", "has_faq"], ["classify", "has_faq"],
["has_faq", "draft", "true"], ["history", "draft"],
["draft", "auto_reply"],
["has_faq", "hand_off", "false"]
]
}
@@ -1,29 +0,0 @@
{
"name": "07 Route by category",
"description": "LLM 분류 결과로 Switch 분기 → 담당 팀 배정 (정해진 값이 아니면 default)",
"inputs": ["text"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "classify", "type": "llm",
"system": "Classify the IT request into exactly one of: account, network, device, software, other. Reply with the label only.",
"prompt": "문의: {{input.text}}",
"mock": {"rules": [
{"if_contains": ["비밀번호", "계정", "로그인", "잠김", "잠겨"], "then": "account"},
{"if_contains": ["vpn", "인터넷", "와이파이", "접속"], "then": "network"},
{"if_contains": ["프린터", "모니터", "키보드"], "then": "device"},
{"if_contains": ["설치", "프로그램", "메일"], "then": "software"}
], "default": "other"}},
{"id": "route", "type": "switch", "value": "{{nodes.classify.output}}", "cases": ["account", "network", "device", "software"]},
{"id": "team_account", "type": "output", "value": {"category": "account", "team": "계정 관리 담당"}},
{"id": "team_network", "type": "output", "value": {"category": "network", "team": "네트워크 담당"}},
{"id": "team_device", "type": "output", "value": {"category": "device", "team": "장비 지원 담당"}},
{"id": "team_software", "type": "output", "value": {"category": "software", "team": "소프트웨어 담당"}},
{"id": "team_default", "type": "output", "value": {"category": "{{nodes.classify.output}}", "team": "IT 문의 접수 창구"}}
],
"edges": [
["start", "classify"], ["classify", "route"],
["route", "team_account", "account"], ["route", "team_network", "network"],
["route", "team_device", "device"], ["route", "team_software", "software"],
["route", "team_default", "default"]
]
}
@@ -1,168 +0,0 @@
{
"name": "08 Meeting notes to action items",
"description": "회의록 → (병렬) 할 일 추출·요약 → 할 일이 있으면 정리, 없으면 '할 일 없음'",
"inputs": [
"title",
"notes"
],
"nodes": [
{
"id": "start",
"type": "input"
},
{
"id": "has_notes",
"type": "condition",
"check": {
"left": "{{input.notes}}",
"op": "not_empty"
}
},
{
"id": "extract",
"type": "llm",
"system": "Extract action items from the meeting notes. Answer in JSON: {\"actions\": [{\"task\": str, \"owner\": str, \"due\": str}]}. Write task in Korean. Use \"미정\" when the owner or due date is not stated. Return {\"actions\": []} when there is no action item.",
"prompt": "회의록:\n{{input.notes}}",
"temperature": 0,
"json_schema": {
"type": "object",
"properties": {
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"task": {
"type": "string"
},
"owner": {
"type": "string"
},
"due": {
"type": "string"
}
},
"required": [
"task",
"owner",
"due"
]
}
}
},
"required": [
"actions"
]
},
"mock": {
"rules": [
{
"if_contains": [
"결정 사항은 없습니다"
],
"then": "{\"actions\": []}"
}
],
"default": "{\"actions\": [{\"task\": \"신규 고객 문의 응답 템플릿 정리\", \"owner\": \"이준호\", \"due\": \"금요일\"}, {\"task\": \"VPN 장애 보고서 작성\", \"owner\": \"박서연\", \"due\": \"다음 주 월요일\"}, {\"task\": \"서버 점검 일정 공지\", \"owner\": \"김민지\", \"due\": \"오늘\"}, {\"task\": \"회의실 예약 규칙 개선 담당자 지정\", \"owner\": \"\", \"due\": \"\"}]}"
}
},
{
"id": "parse",
"type": "tool",
"tool": "parse_actions",
"args": {
"text": "{{nodes.extract.output}}"
}
},
{
"id": "summarize",
"type": "llm",
"system": "You summarize meeting notes. Reply only with the summary in Korean, at most 2 sentences. No English, no comments.",
"prompt": "다음 회의록을 요약하세요.\n\n회의록:\n{{input.notes}}",
"mock": {
"template": "(Mock 요약) {{input.title}}의 주요 내용을 정리했습니다."
}
},
{
"id": "any_actions",
"type": "condition",
"check": {
"left": "{{nodes.parse.output.count}}",
"op": "gt",
"right": 0
}
},
{
"id": "report",
"type": "output",
"value": {
"title": "{{input.title}}",
"route": "할 일 정리",
"count": "{{nodes.parse.output.count}}",
"summary": "{{nodes.summarize.output}}",
"actions": "{{nodes.parse.output.actions}}"
}
},
{
"id": "no_actions",
"type": "output",
"value": {
"title": "{{input.title}}",
"route": "할 일 없음",
"count": 0,
"summary": "{{nodes.summarize.output}}",
"actions": []
}
},
{
"id": "reject",
"type": "output",
"value": {
"error": "notes is required"
}
}
],
"edges": [
[
"start",
"has_notes"
],
[
"has_notes",
"extract",
"true"
],
[
"has_notes",
"summarize",
"true"
],
[
"has_notes",
"reject",
"false"
],
[
"extract",
"parse"
],
[
"parse",
"any_actions"
],
[
"summarize",
"any_actions"
],
[
"any_actions",
"report",
"true"
],
[
"any_actions",
"no_actions",
"false"
]
]
}
-24
View File
@@ -1,24 +0,0 @@
{
"n8n": {
"base_url": "http://localhost:5678",
"timeout": 120
},
"workflows": {
"request": {
"n8n": {
"webhook": "ai-request"
},
"miniflow": {
"flow": "flows/06-request-with-condition.json"
}
},
"meeting": {
"n8n": {
"webhook": "meeting-actions"
},
"miniflow": {
"flow": "flows/08-meeting-actions.json"
}
}
}
}
-28
View File
@@ -1,28 +0,0 @@
[project]
name = "miniflow"
version = "0.1.0"
description = "A tiny JSON-defined AI workflow runner for learning LLM, Tool and Memory nodes"
readme = "README.md"
requires-python = ">=3.11"
dependencies = ["openai>=1.40"] # course default LLM (key in .env)
[project.optional-dependencies]
gemini = ["google-genai>=1.0"]
openai = ["openai>=1.40"]
anthropic = ["anthropic>=1.13"]
[project.scripts]
miniflow = "miniflow.cli:main"
[dependency-groups]
dev = ["pytest>=8.0"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/miniflow"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -1,5 +0,0 @@
"""miniflow: run small AI workflows described as JSON nodes and edges."""
from miniflow.engine import Workflow, WorkflowError, load_workflow, run_workflow
__all__ = ["Workflow", "WorkflowError", "load_workflow", "run_workflow"]
@@ -1,158 +0,0 @@
"""Command line entry point: uv run miniflow <command> ..."""
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from miniflow import memory, tools
from miniflow.engine import WorkflowError, load_workflow, run_workflow
def parse_inputs(pairs: list[str]) -> dict[str, str]:
inputs = {}
for pair in pairs:
if "=" not in pair:
raise WorkflowError(f"Input must look like name=value, got '{pair}'")
name, value = pair.split("=", 1)
inputs[name.strip()] = value
return inputs
def cmd_run(args: argparse.Namespace) -> int:
workflow = load_workflow(args.flow)
print(f"Workflow: {workflow.name}")
if workflow.description:
print(f" {workflow.description}")
print("-" * 72)
report = run_workflow(workflow, parse_inputs(args.input))
print("-" * 72)
print(f"Status: {report['status']}")
print("Result:")
result = report["result"]
print(result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, indent=2))
runs = Path("runs")
runs.mkdir(exist_ok=True)
log_file = runs / f"{datetime.now():%Y%m%d-%H%M%S}-{Path(args.flow).stem}.json"
log_file.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Trace saved: {log_file}")
return 0 if report["status"] == "completed" else 1
def cmd_validate(args: argparse.Namespace) -> int:
workflow = load_workflow(args.flow)
print(f"OK: {workflow.name} ({len(workflow.nodes)} nodes, {len(workflow.edges)} edges)")
print("Execution order: " + " -> ".join(workflow.order()))
return 0
def cmd_show(args: argparse.Namespace) -> int:
print(load_workflow(args.flow).to_mermaid())
return 0
def cmd_tools(_: argparse.Namespace) -> int:
for name, doc in tools.describe_tools():
print(f"{name:<14} {doc}")
return 0
def cmd_check(_: argparse.Namespace) -> int:
import os
import time
from miniflow import llm
provider = os.getenv("MINIFLOW_LLM", "mock").lower()
print(f"MINIFLOW_LLM : {provider}")
print(f".env : {llm.envfile.LOADED[0] if llm.envfile.LOADED else '(none)'}")
if provider == "openai":
print(f"OPENAI_MODEL : {os.getenv('OPENAI_MODEL', 'gpt-4.1-mini')}")
print(f"API key : {'set' if os.getenv('OPENAI_API_KEY') else 'MISSING (put OPENAI_API_KEY in .env)'}")
if provider == "ollama" or os.getenv("MINIFLOW_LLM_FALLBACK", "").lower() == "ollama":
print(f"OLLAMA_HOST : {os.getenv('OLLAMA_HOST', 'http://localhost:11434')}")
print(f"OLLAMA_MODEL : {os.getenv('OLLAMA_MODEL', 'gemma3:4b')}")
if os.getenv("MINIFLOW_LLM_FALLBACK"):
print(f"FALLBACK : {os.getenv('MINIFLOW_LLM_FALLBACK')}")
started = time.perf_counter()
try:
reply, used = llm.complete({"temperature": 0}, "Reply with exactly one word: ready", "", {})
except llm.LLMError as error:
print(f"FAILED: {error}")
return 1
print(f"Reply ({used}, {time.perf_counter() - started:.1f}s): {reply[:80]}")
return 0
def cmd_call(args: argparse.Namespace) -> int:
import os
from miniflow.platforms import FailoverRunner, PlatformError
order = [name.strip() for name in (args.platforms or os.getenv("WORKFLOW_PLATFORMS", "n8n,miniflow")).split(",")]
print(f"Workflow: {args.workflow} platforms: {' -> '.join(order)}")
try:
report = FailoverRunner(order).run(args.workflow, parse_inputs(args.input))
except PlatformError as error:
print(f"FAILED: {error}")
return 1
for attempt in report["attempts"]:
detail = attempt.get("error", "")
print(f" {attempt['platform']:<10} {attempt['status']:<7} {attempt['ms']:>8}ms {detail}")
print(f"Served by: {report['served_by']}")
print(json.dumps(report["result"], ensure_ascii=False, indent=2))
return 0
def cmd_memory(args: argparse.Namespace) -> int:
if args.clear:
memory.clear()
print("Memory cleared")
return 0
path = memory.memory_path()
print(path.read_text(encoding="utf-8") if path.exists() else "{}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="miniflow", description="Run JSON-defined AI workflows")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="run a workflow file")
run.add_argument("flow")
run.add_argument("--input", "-i", action="append", default=[], metavar="NAME=VALUE")
run.set_defaults(func=cmd_run)
validate = sub.add_parser("validate", help="check a workflow file without running it")
validate.add_argument("flow")
validate.set_defaults(func=cmd_validate)
show = sub.add_parser("show", help="print the workflow as a Mermaid diagram")
show.add_argument("flow")
show.set_defaults(func=cmd_show)
sub.add_parser("tools", help="list available tools").set_defaults(func=cmd_tools)
sub.add_parser("check", help="check the configured LLM provider").set_defaults(func=cmd_check)
call = sub.add_parser("call", help="run a business workflow on platforms with failover")
call.add_argument("workflow", help="workflow name in platforms.json, e.g. request")
call.add_argument("--input", "-i", action="append", default=[], metavar="NAME=VALUE")
call.add_argument("--platforms", help="comma-separated order, default: $WORKFLOW_PLATFORMS or n8n,miniflow")
call.set_defaults(func=cmd_call)
mem = sub.add_parser("memory", help="show or clear the memory store")
mem.add_argument("--clear", action="store_true")
mem.set_defaults(func=cmd_memory)
args = parser.parse_args(argv)
try:
return args.func(args)
except (WorkflowError, FileNotFoundError, json.JSONDecodeError) as error:
print(f"Error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,255 +0,0 @@
"""Load, validate and run a workflow made of nodes and edges.
Day 65 adds branching: `condition` (true/false) and `switch` (one case per value)
nodes, and edges that carry a branch label: ["check", "draft", "true"].
"""
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from miniflow import llm, memory, tools
from miniflow.template import render
NODE_TYPES = {"input", "llm", "tool", "memory_read", "memory_write", "output", "condition", "switch"}
BRANCH_TYPES = {"condition", "switch"}
REQUIRED_FIELDS = {
"llm": ["prompt"],
"tool": ["tool"],
"memory_read": ["key"],
"memory_write": ["key", "value"],
"output": ["value"],
"condition": ["check"],
"switch": ["value", "cases"],
}
CHECK_OPS = {"equals", "not_equals", "contains", "not_empty", "empty", "in", "gt", "lt"}
Edge = tuple[str, str, str | None]
class WorkflowError(ValueError):
pass
@dataclass
class Workflow:
name: str
nodes: dict[str, dict[str, Any]]
edges: list[Edge]
description: str = ""
inputs: list[str] = field(default_factory=list)
def order(self) -> list[str]:
"""Topological order of node ids (Kahn's algorithm, file order breaks ties)."""
indegree = {node_id: 0 for node_id in self.nodes}
for _, target, _ in self.edges:
indegree[target] += 1
ready = [node_id for node_id in self.nodes if indegree[node_id] == 0]
ordered = []
while ready:
current = ready.pop(0)
ordered.append(current)
for source, target, _ in self.edges:
if source == current:
indegree[target] -= 1
if indegree[target] == 0:
ready.append(target)
if len(ordered) != len(self.nodes):
raise WorkflowError("Edges contain a cycle. Workflows must flow in one direction.")
return ordered
def to_mermaid(self) -> str:
shapes = {
"input": ('[/"', '"/]'),
"llm": ('{{"', '"}}'),
"tool": ('["', '"]'),
"memory_read": ('[("', '")]'),
"memory_write": ('[("', '")]'),
"output": ('[\\"', '"\\]'),
"condition": ('{"', '"}'),
"switch": ('{"', '"}'),
}
lines = ["flowchart LR"]
for node_id, node in self.nodes.items():
left, right = shapes[node["type"]]
label = f"{node_id}<br/>{node['type']}"
if node["type"] == "tool":
label += f": {node['tool']}"
lines.append(f" {node_id}{left}{label}{right}")
for source, target, branch in self.edges:
arrow = f" -->|{branch}| " if branch is not None else " --> "
lines.append(f" {source}{arrow}{target}")
return "\n".join(lines)
def _parse_edge(raw: list[Any]) -> Edge:
if not isinstance(raw, list) or len(raw) not in (2, 3):
raise WorkflowError(f"Edge must be [source, target] or [source, target, branch]: {raw}")
return (raw[0], raw[1], str(raw[2]) if len(raw) == 3 else None)
def load_workflow(path: str | Path) -> Workflow:
data = json.loads(Path(path).read_text(encoding="utf-8"))
nodes: dict[str, dict[str, Any]] = {}
for node in data.get("nodes", []):
node_id = node.get("id")
if not node_id:
raise WorkflowError("Every node needs an 'id'")
if node_id in nodes:
raise WorkflowError(f"Duplicate node id: {node_id}")
if node.get("type") not in NODE_TYPES:
raise WorkflowError(f"Node '{node_id}' has unknown type '{node.get('type')}'. Use one of {sorted(NODE_TYPES)}")
missing = [name for name in REQUIRED_FIELDS.get(node["type"], []) if name not in node]
if missing:
raise WorkflowError(f"Node '{node_id}' ({node['type']}) is missing: {', '.join(missing)}")
if node["type"] == "tool" and node["tool"] not in tools.TOOLS:
raise WorkflowError(f"Node '{node_id}' uses unknown tool '{node['tool']}'")
if node["type"] == "condition" and node["check"].get("op") not in CHECK_OPS:
raise WorkflowError(f"Node '{node_id}' uses unknown check op. Use one of {sorted(CHECK_OPS)}")
nodes[node_id] = node
if not nodes:
raise WorkflowError("Workflow has no nodes")
edges = [_parse_edge(edge) for edge in data.get("edges", [])]
for source, target, branch in edges:
for node_id in (source, target):
if node_id not in nodes:
raise WorkflowError(f"Edge {source} -> {target} points to unknown node '{node_id}'")
kind = nodes[source]["type"]
if branch is not None and kind not in BRANCH_TYPES:
raise WorkflowError(f"Edge {source} -> {target} has branch '{branch}' but '{source}' is a {kind} node")
if kind == "condition" and branch not in ("true", "false"):
raise WorkflowError(f"Edge {source} -> {target} needs branch 'true' or 'false'")
if kind == "switch" and branch not in [*map(str, nodes[source]["cases"]), "default"]:
raise WorkflowError(f"Edge {source} -> {target} needs a branch from cases {nodes[source]['cases']} or 'default'")
workflow = Workflow(
name=data.get("name", Path(path).stem),
description=data.get("description", ""),
inputs=data.get("inputs", []),
nodes=nodes,
edges=edges,
)
workflow.order()
return workflow
def evaluate_check(check: dict[str, Any], context: dict[str, Any]) -> bool:
left = render(check.get("left", ""), context)
right = render(check.get("right"), context)
op = check["op"]
if op == "equals":
return left == right
if op == "not_equals":
return left != right
if op == "contains":
return str(right) in str(left)
if op == "empty":
return left in (None, "", [], {})
if op == "not_empty":
return left not in (None, "", [], {})
if op == "in":
return left in (right or [])
if op == "gt":
return float(left) > float(right)
if op == "lt":
return float(left) < float(right)
raise WorkflowError(f"Unsupported check op: {op}")
def run_node(node: dict[str, Any], context: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
"""Execute one node. Returns (output, extra trace info)."""
kind = node["type"]
if kind == "input":
return context["input"], {}
if kind == "llm":
system = str(render(node.get("system", ""), context))
prompt = str(render(node["prompt"], context))
reply, provider = llm.complete(node, prompt, system, context)
return reply, {"provider": provider, "prompt": prompt}
if kind == "tool":
args = render(node.get("args", {}), context)
return tools.call_tool(node["tool"], args), {"args": args}
if kind == "memory_read":
key = str(render(node["key"], context))
return memory.read(key), {"key": key}
if kind == "memory_write":
key = str(render(node["key"], context))
return memory.write(key, render(node["value"], context)), {"key": key}
if kind == "output":
return render(node["value"], context), {}
if kind == "condition":
passed = evaluate_check(node["check"], context)
return {"result": passed, "branch": "true" if passed else "false"}, {}
if kind == "switch":
value = render(node["value"], context)
cases = [str(case) for case in node["cases"]]
branch = str(value) if str(value) in cases else "default"
return {"value": value, "branch": branch}, {}
raise WorkflowError(f"Unsupported node type: {kind}")
def _is_active(node_id: str, workflow: Workflow, status: dict[str, str], context: dict[str, Any]) -> bool:
"""Decide whether a node runs.
- A branch edge works as a gate: if its source was skipped or chose another
branch, the node is skipped.
- Otherwise the node runs if at least one incoming edge is taken, so a node
where two exclusive branches meet again (plain edges) still runs.
"""
incoming = [(source, branch) for source, target, branch in workflow.edges if target == node_id]
if not incoming:
return True
taken = False
for source, branch in incoming:
ran = status.get(source) == "ok"
if branch is not None:
if not ran or context["nodes"][source]["output"].get("branch") != branch:
return False
taken = True
elif ran:
taken = True
return taken
def run_workflow(workflow: Workflow, inputs: dict[str, Any], verbose: bool = True) -> dict[str, Any]:
missing = [name for name in workflow.inputs if name not in inputs]
if missing:
raise WorkflowError(f"Missing inputs: {', '.join(missing)} (pass with --input name=value)")
context: dict[str, Any] = {"input": inputs, "nodes": {}}
trace: list[dict[str, Any]] = []
status: dict[str, str] = {}
order = workflow.order()
result: Any = None
for index, node_id in enumerate(order, start=1):
node = workflow.nodes[node_id]
step = {"step": index, "id": node_id, "type": node["type"]}
if not _is_active(node_id, workflow, status, context):
status[node_id] = "skipped"
step["status"] = "skipped"
trace.append(step)
if verbose:
print(f"[{index}/{len(order)}] {node_id:<14} {node['type']:<12} skipped (branch not taken)")
continue
started = time.perf_counter()
try:
output, extra = run_node(node, context)
except Exception as error:
step.update(status="failed", error=f"{type(error).__name__}: {error}")
trace.append(step)
if verbose:
print(f"[{index}/{len(order)}] {node_id:<14} {node['type']:<12} FAILED {step['error']}")
return {"workflow": workflow.name, "status": "failed", "result": None, "trace": trace}
elapsed = round((time.perf_counter() - started) * 1000, 1)
context["nodes"][node_id] = {"output": output}
status[node_id] = "ok"
step.update(status="ok", ms=elapsed, output=output, **extra)
trace.append(step)
if node["type"] == "output":
result = output
if verbose:
preview = json.dumps(output, ensure_ascii=False)
print(f"[{index}/{len(order)}] {node_id:<14} {node['type']:<12} ok {elapsed:>6}ms {preview[:70]}")
return {"workflow": workflow.name, "status": "completed", "result": result, "trace": trace}
@@ -1,50 +0,0 @@
"""Load settings from the nearest .env file (KEY=VALUE lines, no extra package needed).
Search order: the current folder and its parents, then the folders above this package
(so one Last3Mon/.env serves every day). Only the nearest file is read, and variables
already set in the shell win. Set MINIFLOW_DOTENV=0 to skip (the tests do this).
The .env file holds API keys: it is listed in .gitignore and must never be committed.
"""
import os
from pathlib import Path
LOADED: list[str] = [] # the .env file that was read, if any
def parse(text: str) -> dict[str, str]:
values = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.removeprefix("export ").strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
value = value[1:-1]
elif " #" in value:
value = value.split(" #", 1)[0].rstrip()
values[key] = value
return values
def candidates() -> list[Path]:
paths: list[Path] = []
for start in (Path.cwd(), Path(__file__).resolve().parent):
for folder in (start, *start.parents):
if folder / ".env" not in paths:
paths.append(folder / ".env")
return paths
def load() -> Path | None:
if os.getenv("MINIFLOW_DOTENV", "1") == "0":
return None
for path in candidates():
if path.is_file():
for key, value in parse(path.read_text(encoding="utf-8")).items():
os.environ.setdefault(key, value)
LOADED.append(str(path))
return path
return None
@@ -1,193 +0,0 @@
"""LLM providers: mock (default, no API key), openai (course default, key in .env), ollama (local), anthropic, gemini.
Select with MINIFLOW_LLM (usually set in .env). MINIFLOW_LLM_FALLBACK=ollama retries a failed call locally.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
from typing import Any
from miniflow import envfile
from miniflow.template import render
envfile.load() # OPENAI_API_KEY and other settings from .env
class LLMError(RuntimeError):
pass
def mock_reply(prompt: str, mock: dict[str, Any] | None, context: dict[str, Any]) -> str:
"""Deterministic reply so the workflow runs without any API key.
mock.rules: first rule whose keywords appear in the prompt wins.
mock.template: rendered with the workflow context.
"""
mock = mock or {}
lowered = prompt.lower()
for rule in mock.get("rules", []):
if any(word.lower() in lowered for word in rule.get("if_contains", [])):
return str(render(rule["then"], context))
if "template" in mock:
return str(render(mock["template"], context))
if "default" in mock:
return str(render(mock["default"], context))
first_line = prompt.strip().splitlines()[0] if prompt.strip() else ""
return f"[mock-llm] received {len(prompt)} chars. first line: {first_line[:60]}"
def call_ollama(system: str, prompt: str, temperature: float, schema: dict | None = None) -> str:
"""Call a local Ollama server through its /api/chat endpoint (no extra package needed).
When `schema` is given, Ollama is asked to answer with JSON that follows it.
"""
host = os.getenv("OLLAMA_HOST", "http://localhost:11434").rstrip("/")
if not host.startswith("http"):
host = f"http://{host}"
model = os.getenv("OLLAMA_MODEL", "gemma3:4b")
messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
payload = {"model": model, "messages": messages, "stream": False, "options": {"temperature": temperature}}
if schema:
payload["format"] = schema
body = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(f"{host}/api/chat", data=body, headers={"Content-Type": "application/json"})
timeout = float(os.getenv("OLLAMA_TIMEOUT", "180"))
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
data = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
detail = error.read().decode("utf-8", errors="replace")
if error.code == 404:
raise LLMError(f"Model '{model}' not found on Ollama. Run: ollama pull {model}") from error
raise LLMError(f"Ollama returned HTTP {error.code}: {detail[:200]}") from error
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
raise LLMError(f"Cannot reach Ollama at {host}. Is 'ollama serve' running? ({error})") from error
return data.get("message", {}).get("content", "").strip()
def call_anthropic(system: str, prompt: str) -> str:
"""Call Claude through the Anthropic SDK.
Current Claude models reject sampling parameters, so temperature is not sent.
A server-side fallback model answers if the first model declines the request.
"""
try:
import anthropic
except ImportError as error:
raise LLMError("Install the extra first: uv sync --extra anthropic") from error
client = anthropic.Anthropic()
try:
response = client.beta.messages.create(
model=os.getenv("ANTHROPIC_MODEL", "claude-opus-5-5"),
max_tokens=16000,
system=system or anthropic.omit,
messages=[{"role": "user", "content": prompt}],
betas=["server-side-fallback-2026-07-01"],
fallbacks="default",
)
except anthropic.AuthenticationError as error:
raise LLMError("Anthropic credentials are missing or invalid (set ANTHROPIC_API_KEY)") from error
except anthropic.APIConnectionError as error:
raise LLMError(f"Cannot reach the Anthropic API ({error})") from error
except anthropic.APIStatusError as error:
raise LLMError(f"Anthropic API returned HTTP {error.status_code}: {error.message}") from error
if response.stop_reason == "refusal":
raise LLMError("Claude declined this request (stop_reason=refusal)")
return "".join(block.text for block in response.content if block.type == "text").strip()
def call_gemini(system: str, prompt: str, temperature: float) -> str:
try:
from google import genai
from google.genai import types
except ImportError as error:
raise LLMError("Install the extra first: uv sync --extra gemini") from error
if not os.getenv("GEMINI_API_KEY"):
raise LLMError("GEMINI_API_KEY is not set")
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
response = client.models.generate_content(
model=os.getenv("GEMINI_MODEL", "gemini-2.5-flash"),
contents=prompt,
config=types.GenerateContentConfig(system_instruction=system or None, temperature=temperature),
)
return response.text or ""
def call_openai(system: str, prompt: str, temperature: float, schema: dict | None = None,
max_tokens: int | None = None) -> str:
"""Call the OpenAI Responses API (OPENAI_MODEL, default gpt-4.1-mini).
When `schema` is given, the answer is JSON that follows it (structured output).
A model that does not accept temperature is called again without it.
"""
try:
import openai
except ImportError as error:
raise LLMError("The openai package is missing. Run: uv sync") from error
if not os.getenv("OPENAI_API_KEY"):
raise LLMError("OPENAI_API_KEY is not set (put it in the .env file)")
client = openai.OpenAI(timeout=float(os.getenv("OPENAI_TIMEOUT", "60")),
max_retries=int(os.getenv("OPENAI_MAX_RETRIES", "2")))
model = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
request: dict[str, Any] = {"model": model, "input": prompt, "temperature": temperature}
if system:
request["instructions"] = system
if max_tokens:
request["max_output_tokens"] = max(16, int(max_tokens)) # the API minimum is 16
if schema:
request["text"] = {"format": {"type": "json_schema", "name": "reply", "schema": schema, "strict": False}}
started = time.perf_counter()
try:
try:
response = client.responses.create(**request)
except openai.BadRequestError as error:
if "temperature" not in str(error):
raise
request.pop("temperature")
response = client.responses.create(**request)
except openai.AuthenticationError as error:
raise LLMError("OpenAI rejected the API key (check OPENAI_API_KEY in .env)") from error
except openai.APIConnectionError as error:
raise LLMError(f"Cannot reach the OpenAI API ({error})") from error
except openai.APIStatusError as error:
raise LLMError(f"OpenAI API returned HTTP {error.status_code}: {error.message}") from error
return (response.output_text or "").strip()
def complete(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any]) -> tuple[str, str]:
"""Return (reply, provider_name).
With MINIFLOW_LLM_FALLBACK set (for example ollama), a call that fails on the main provider
is tried once more there, and the reply says which provider answered.
"""
primary = os.getenv("MINIFLOW_LLM", "mock").lower()
try:
return _complete_once(node, prompt, system, context, primary)
except LLMError as error:
fallback = os.getenv("MINIFLOW_LLM_FALLBACK", "").strip().lower()
if not fallback or fallback == primary or type(error).__name__ == "BudgetError":
raise
print(f"[miniflow] {primary} failed ({error}); trying {fallback}", file=sys.stderr)
try:
return _complete_once(node, prompt, system, context, fallback)
except LLMError as second:
raise LLMError(f"{primary} failed: {error} / fallback {fallback} failed: {second}") from second
def _complete_once(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any], provider: str) -> tuple[str, str]:
temperature = float(node.get("temperature", 0.2))
if provider == "mock":
return mock_reply(prompt, node.get("mock"), context), "mock"
if provider == "ollama":
return call_ollama(system, prompt, temperature, node.get("json_schema")), "ollama"
if provider == "anthropic":
return call_anthropic(system, prompt), "anthropic"
if provider == "gemini":
return call_gemini(system, prompt, temperature), "gemini"
if provider == "openai":
return call_openai(system, prompt, temperature, node.get("json_schema")), "openai"
raise LLMError(f"Unknown MINIFLOW_LLM value: {provider} (use mock, ollama, openai, anthropic or gemini)")
@@ -1,33 +0,0 @@
"""A JSON-file memory store shared across workflow runs."""
import json
import os
from pathlib import Path
from typing import Any
def memory_path() -> Path:
return Path(os.getenv("MINIFLOW_MEMORY_FILE", ".miniflow-memory.json"))
def _load() -> dict[str, Any]:
path = memory_path()
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def read(key: str) -> dict[str, Any]:
return _load().get(key, {})
def write(key: str, value: dict[str, Any]) -> dict[str, Any]:
store = _load()
merged = {**store.get(key, {}), **value}
store[key] = merged
memory_path().write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8")
return merged
def clear() -> None:
memory_path().unlink(missing_ok=True)
@@ -1,117 +0,0 @@
"""Run the same business workflow on interchangeable platforms, with failover.
Application code calls `FailoverRunner(["n8n", "miniflow"]).run("request", inputs)`.
Which platforms exist and how each one runs a workflow is decided by adapters
and `platforms.json`, so the calling code never changes when a platform is swapped.
"""
import json
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any, Protocol
from miniflow.engine import WorkflowError, load_workflow, run_workflow
PROJECT_DIR = Path(__file__).resolve().parents[2]
CONFIG_FILE = PROJECT_DIR / "platforms.json"
class PlatformError(RuntimeError):
"""A platform could not run the workflow (down, timeout, bad answer)."""
class WorkflowPlatform(Protocol):
name: str
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]: ...
def load_config(path: Path = CONFIG_FILE) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
class N8nPlatform:
"""Calls an n8n workflow through its production webhook URL."""
name = "n8n"
def __init__(self, config: dict[str, Any]):
self.workflows = config["workflows"]
self.base_url = os.getenv("N8N_BASE_URL", config.get("n8n", {}).get("base_url", "http://localhost:5678"))
self.timeout = float(os.getenv("N8N_TIMEOUT", config.get("n8n", {}).get("timeout", 120)))
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]:
settings = self.workflows.get(workflow, {}).get("n8n")
if not settings:
raise PlatformError(f"n8n has no mapping for workflow '{workflow}'")
url = f"{self.base_url.rstrip('/')}/webhook/{settings['webhook']}"
request = urllib.request.Request(url, data=json.dumps(inputs).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
raise PlatformError(f"n8n returned HTTP {error.code} for {url}") from error
except (urllib.error.URLError, TimeoutError, ConnectionError) as error:
raise PlatformError(f"n8n is not reachable at {url} ({error})") from error
except json.JSONDecodeError as error:
raise PlatformError("n8n answered with something that is not JSON") from error
class MiniflowPlatform:
"""Runs the matching JSON flow with the local miniflow engine."""
name = "miniflow"
def __init__(self, config: dict[str, Any]):
self.workflows = config["workflows"]
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]:
settings = self.workflows.get(workflow, {}).get("miniflow")
if not settings:
raise PlatformError(f"miniflow has no mapping for workflow '{workflow}'")
try:
report = run_workflow(load_workflow(PROJECT_DIR / settings["flow"]), inputs, verbose=False)
except WorkflowError as error:
raise PlatformError(str(error)) from error
if report["status"] != "completed":
failed = report["trace"][-1]
raise PlatformError(f"miniflow failed at node '{failed['id']}': {failed.get('error')}")
return report["result"]
PLATFORMS: dict[str, type] = {"n8n": N8nPlatform, "miniflow": MiniflowPlatform}
def register_platform(name: str, adapter: type) -> None:
"""Add another platform adapter without touching the calling code."""
PLATFORMS[name] = adapter
class FailoverRunner:
"""Try platforms in order; when one raises PlatformError, move on to the next."""
def __init__(self, order: list[str], config: dict[str, Any] | None = None):
config = config or load_config()
unknown = [name for name in order if name not in PLATFORMS]
if unknown:
raise PlatformError(f"Unknown platform(s): {', '.join(unknown)}. Registered: {', '.join(PLATFORMS)}")
self.platforms = [PLATFORMS[name](config) for name in order]
def run(self, workflow: str, inputs: dict[str, Any]) -> dict[str, Any]:
attempts = []
for platform in self.platforms:
started = time.perf_counter()
try:
result = platform.run(workflow, inputs)
except PlatformError as error:
attempts.append({"platform": platform.name, "status": "failed", "error": str(error),
"ms": round((time.perf_counter() - started) * 1000, 1)})
continue
attempts.append({"platform": platform.name, "status": "ok",
"ms": round((time.perf_counter() - started) * 1000, 1)})
return {"workflow": workflow, "served_by": platform.name, "result": result, "attempts": attempts}
raise PlatformError("All platforms failed: " + "; ".join(f"{a['platform']}: {a['error']}" for a in attempts))
@@ -1,48 +0,0 @@
"""Resolve {{path.to.value}} placeholders against the workflow context."""
import json
import re
from typing import Any
PLACEHOLDER = re.compile(r"\{\{\s*([a-zA-Z0-9_.\-]+)\s*\}\}")
class TemplateError(KeyError):
pass
def lookup(context: dict[str, Any], path: str) -> Any:
value: Any = context
for part in path.split("."):
if isinstance(value, dict) and part in value:
value = value[part]
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
raise TemplateError(f"Cannot resolve '{{{{{path}}}}}': '{part}' not found")
return value
def _to_text(value: Any) -> str:
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
def render(template: Any, context: dict[str, Any]) -> Any:
"""Render strings, lists and dicts recursively.
A string that is exactly one placeholder keeps the original type
(dict, list, number). Mixed text is rendered as a string.
"""
if isinstance(template, dict):
return {key: render(value, context) for key, value in template.items()}
if isinstance(template, list):
return [render(item, context) for item in template]
if not isinstance(template, str):
return template
whole = PLACEHOLDER.fullmatch(template.strip())
if whole:
return lookup(context, whole.group(1))
return PLACEHOLDER.sub(lambda m: _to_text(lookup(context, m.group(1))), template)
@@ -1,85 +0,0 @@
"""Tools a workflow can call. Each tool reads local sample data, so no network is needed."""
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
DATA_DIR = Path(__file__).resolve().parents[2] / "data"
class ToolError(RuntimeError):
pass
def _load(name: str) -> Any:
return json.loads((DATA_DIR / name).read_text(encoding="utf-8"))
def get_weather(city: str) -> dict[str, Any]:
"""Return today's sample weather for a city."""
table = _load("weather.json")
if city not in table:
raise ToolError(f"Unknown city '{city}'. Available: {', '.join(table)}")
return {"city": city, **table[city]}
def search_faq(query: str, top_k: int = 2) -> list[dict[str, Any]]:
"""Return FAQ entries ranked by keyword overlap with the query."""
words = {word for word in query.lower().split() if len(word) > 1}
scored = []
for item in _load("faq.json"):
keywords = {keyword.lower() for keyword in item["keywords"]}
score = sum(1 for word in words if any(keyword in word or word in keyword for keyword in keywords))
if score:
scored.append({"score": score, **item})
scored.sort(key=lambda item: item["score"], reverse=True)
return scored[: int(top_k)]
def count_chars(text: str) -> dict[str, int]:
"""Count characters and words of a text."""
return {"chars": len(text), "words": len(text.split())}
def parse_actions(text: str) -> dict[str, Any]:
"""Parse an LLM answer into a clean list of action items (task, owner, due)."""
cleaned = str(text).strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`").split("\n", 1)[-1]
try:
data = json.loads(cleaned)
except json.JSONDecodeError:
return {"actions": [], "count": 0, "error": "answer is not valid JSON"}
items = data.get("actions", []) if isinstance(data, dict) else data
actions = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict) or not str(item.get("task", "")).strip():
continue
actions.append({
"task": str(item["task"]).strip(),
"owner": str(item.get("owner") or "미정").strip() or "미정",
"due": str(item.get("due") or "미정").strip() or "미정",
})
return {"actions": actions, "count": len(actions)}
TOOLS: dict[str, Callable[..., Any]] = {
"get_weather": get_weather,
"search_faq": search_faq,
"count_chars": count_chars,
"parse_actions": parse_actions,
}
def call_tool(name: str, args: dict[str, Any]) -> Any:
if name not in TOOLS:
raise ToolError(f"Tool '{name}' is not registered. Available: {', '.join(TOOLS)}")
try:
return TOOLS[name](**args)
except TypeError as error:
raise ToolError(f"Wrong arguments for tool '{name}': {error}") from error
def describe_tools() -> list[tuple[str, str]]:
return [(name, (func.__doc__ or "").strip()) for name, func in TOOLS.items()]
@@ -1,3 +0,0 @@
import os
os.environ["MINIFLOW_DOTENV"] = "0" # tests never read the real .env (it may hold API keys)
@@ -1,68 +0,0 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
pytest.importorskip("anthropic")
from miniflow import llm # noqa: E402
class FakeAnthropic(BaseHTTPRequestHandler):
received: list[dict] = []
headers_seen: list[dict] = []
stop_reason = "end_turn"
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
FakeAnthropic.received.append(body)
FakeAnthropic.headers_seen.append(dict(self.headers))
reply = {
"id": "msg_test", "type": "message", "role": "assistant", "model": body["model"],
"content": [{"type": "text", "text": " network "}],
"stop_reason": FakeAnthropic.stop_reason, "stop_sequence": None,
"usage": {"input_tokens": 10, "output_tokens": 2},
}
payload = json.dumps(reply).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
@pytest.fixture()
def fake_server(monkeypatch):
server = HTTPServer(("127.0.0.1", 0), FakeAnthropic)
threading.Thread(target=server.serve_forever, daemon=True).start()
FakeAnthropic.received.clear()
FakeAnthropic.headers_seen.clear()
FakeAnthropic.stop_reason = "end_turn"
monkeypatch.setenv("MINIFLOW_LLM", "anthropic")
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
monkeypatch.setenv("ANTHROPIC_BASE_URL", f"http://127.0.0.1:{server.server_port}")
monkeypatch.delenv("ANTHROPIC_MODEL", raising=False)
yield server
server.shutdown()
def test_anthropic_request_and_reply(fake_server):
reply, provider = llm.complete({"temperature": 0}, "문의: VPN", "Reply with the label only.", {})
assert (reply, provider) == ("network", "anthropic")
sent = FakeAnthropic.received[-1]
assert sent["model"] == "claude-opus-5-5"
assert sent["system"] == "Reply with the label only."
assert sent["messages"] == [{"role": "user", "content": "문의: VPN"}]
assert sent["fallbacks"] == "default"
assert "temperature" not in sent
assert "server-side-fallback-2026-07-01" in FakeAnthropic.headers_seen[-1].get("anthropic-beta", "")
def test_refusal_is_reported(fake_server):
FakeAnthropic.stop_reason = "refusal"
with pytest.raises(llm.LLMError, match="declined"):
llm.complete({}, "hi", "", {})
@@ -1,104 +0,0 @@
import json
from pathlib import Path
import pytest
from miniflow import WorkflowError, load_workflow, run_workflow
from miniflow.engine import evaluate_check
FLOWS = Path(__file__).resolve().parents[1] / "flows"
@pytest.fixture(autouse=True)
def isolated(tmp_path, monkeypatch):
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
monkeypatch.setenv("MINIFLOW_LLM", "mock")
def statuses(report):
return {step["id"]: step["status"] for step in report["trace"]}
def test_condition_true_branch_gives_auto_reply():
report = run_workflow(load_workflow(FLOWS / "06-request-with-condition.json"),
{"user": "kim", "text": "VPN 접속이 계속 끊겨요"}, verbose=False)
assert report["status"] == "completed"
assert report["result"]["route"] == "자동 답변"
assert report["result"]["faq_id"] == "FAQ-003"
assert statuses(report)["hand_off"] == "skipped"
def test_condition_false_branch_hands_off():
report = run_workflow(load_workflow(FLOWS / "06-request-with-condition.json"),
{"user": "lee", "text": "점심 메뉴 추천해줘"}, verbose=False)
assert report["status"] == "completed"
assert report["result"]["route"] == "담당자 확인"
assert statuses(report)["draft"] == "skipped"
@pytest.mark.parametrize("text,team", [
("비밀번호를 잊어버렸어요", "계정 관리 담당"),
("프린터 출력이 안 돼요", "장비 지원 담당"),
("회의실 예약은 어떻게 하나요", "IT 문의 접수 창구"),
])
def test_switch_routes(text, team):
report = run_workflow(load_workflow(FLOWS / "07-route-by-category.json"), {"text": text}, verbose=False)
assert report["result"]["team"] == team
def test_join_after_exclusive_branches_runs(tmp_path):
flow = {
"inputs": ["n"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "big", "type": "condition", "check": {"left": "{{input.n}}", "op": "gt", "right": 10}},
{"id": "yes", "type": "output", "value": "big"},
{"id": "no", "type": "output", "value": "small"},
{"id": "done", "type": "output", "value": "done"},
],
"edges": [["start", "big"], ["big", "yes", "true"], ["big", "no", "false"], ["yes", "done"], ["no", "done"]],
}
path = tmp_path / "join.json"
path.write_text(json.dumps(flow), encoding="utf-8")
report = run_workflow(load_workflow(path), {"n": "3"}, verbose=False)
assert statuses(report) == {"start": "ok", "big": "ok", "yes": "skipped", "no": "ok", "done": "ok"}
def test_branch_label_validation(tmp_path):
flow = {
"nodes": [{"id": "a", "type": "input"}, {"id": "b", "type": "output", "value": 1}],
"edges": [["a", "b", "true"]],
}
path = tmp_path / "bad.json"
path.write_text(json.dumps(flow), encoding="utf-8")
with pytest.raises(WorkflowError):
load_workflow(path)
@pytest.mark.parametrize("check,expected", [
({"left": "abc", "op": "contains", "right": "b"}, True),
({"left": "", "op": "empty"}, True),
({"left": "network", "op": "in", "right": ["network", "device"]}, True),
({"left": "5", "op": "lt", "right": 3}, False),
])
def test_evaluate_check(check, expected):
assert evaluate_check(check, {"input": {}, "nodes": {}}) is expected
def test_branch_gate_blocks_even_with_other_plain_input(tmp_path):
"""A node behind a skipped condition stays skipped even if another plain edge reaches it."""
flow = json.loads((FLOWS / "06-request-with-condition.json").read_text(encoding="utf-8"))
flow["nodes"].insert(1, {"id": "is_urgent", "type": "condition",
"check": {"left": "{{input.text}}", "op": "contains", "right": "긴급"}})
flow["nodes"].append({"id": "urgent_call", "type": "output", "value": {"route": "긴급 담당자 연결"}})
flow["edges"] += [["start", "is_urgent"], ["is_urgent", "urgent_call", "true"], ["is_urgent", "has_faq", "false"]]
path = tmp_path / "urgent.json"
path.write_text(json.dumps(flow, ensure_ascii=False), encoding="utf-8")
urgent = run_workflow(load_workflow(path), {"user": "kim", "text": "긴급! VPN이 끊겼어요"}, verbose=False)
assert urgent["result"]["route"] == "긴급 담당자 연결"
assert statuses(urgent)["draft"] == "skipped"
normal = run_workflow(load_workflow(path), {"user": "kim", "text": "VPN이 끊겼어요"}, verbose=False)
assert normal["result"]["route"] == "자동 답변"
assert statuses(normal)["urgent_call"] == "skipped"
@@ -1,52 +0,0 @@
import json
from pathlib import Path
import pytest
from miniflow import load_workflow, run_workflow
from miniflow.tools import parse_actions
ROOT = Path(__file__).resolve().parents[1]
NOTES = json.loads((ROOT / "data" / "meeting-notes.json").read_text(encoding="utf-8"))
@pytest.fixture(autouse=True)
def isolated(tmp_path, monkeypatch):
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
monkeypatch.setenv("MINIFLOW_LLM", "mock")
def run(inputs):
return run_workflow(load_workflow(ROOT / "flows" / "08-meeting-actions.json"), inputs, verbose=False)
def test_weekly_meeting_gives_four_actions_with_defaults():
report = run(NOTES["weekly"])
assert report["result"]["route"] == "할 일 정리"
assert report["result"]["count"] == 4
assert report["result"]["actions"][3] == {"task": "회의실 예약 규칙 개선 담당자 지정", "owner": "미정", "due": "미정"}
def test_meeting_without_actions():
report = run(NOTES["no_action"])
assert report["result"]["route"] == "할 일 없음"
statuses = {step["id"]: step["status"] for step in report["trace"]}
assert statuses["report"] == "skipped"
def test_empty_notes_rejected_before_llm():
report = run({"title": "빈 회의록", "notes": ""})
assert report["result"] == {"error": "notes is required"}
statuses = {step["id"]: step["status"] for step in report["trace"]}
assert statuses["extract"] == statuses["summarize"] == "skipped"
@pytest.mark.parametrize("text,count", [
('{"actions": [{"task": "보고서 작성", "owner": "김", "due": "금"}]}', 1),
('```json\n{"actions": [{"task": "A", "owner": "", "due": null}]}\n```', 1),
('[{"task": "목록 형태", "owner": "이"}]', 1),
('{"actions": [{"task": ""}, "문자열"]}', 0),
("할 일이 없습니다", 0),
])
def test_parse_actions(text, count):
assert parse_actions(text)["count"] == count
@@ -1,66 +0,0 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from miniflow import llm
class FakeOllama(BaseHTTPRequestHandler):
received: list[dict] = []
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
FakeOllama.received.append(body)
if body["model"] == "missing-model":
self.send_response(404)
self.end_headers()
self.wfile.write(b'{"error":"model not found"}')
return
reply = {"message": {"role": "assistant", "content": " network \n"}, "done": True}
payload = json.dumps(reply).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
@pytest.fixture()
def fake_server(monkeypatch):
server = HTTPServer(("127.0.0.1", 0), FakeOllama)
threading.Thread(target=server.serve_forever, daemon=True).start()
FakeOllama.received.clear()
monkeypatch.setenv("MINIFLOW_LLM", "ollama")
monkeypatch.setenv("OLLAMA_HOST", f"127.0.0.1:{server.server_port}")
monkeypatch.setenv("OLLAMA_MODEL", "gemma3:1b")
yield server
server.shutdown()
def test_ollama_request_and_reply(fake_server):
reply, provider = llm.complete({"temperature": 0}, "문의: VPN", "Reply with the label only.", {})
assert (reply, provider) == ("network", "ollama")
sent = FakeOllama.received[-1]
assert sent["model"] == "gemma3:1b"
assert sent["stream"] is False
assert sent["messages"][0] == {"role": "system", "content": "Reply with the label only."}
assert sent["messages"][1]["content"] == "문의: VPN"
def test_missing_model_gives_pull_hint(fake_server, monkeypatch):
monkeypatch.setenv("OLLAMA_MODEL", "missing-model")
with pytest.raises(llm.LLMError, match="ollama pull missing-model"):
llm.complete({}, "hi", "", {})
def test_unreachable_server(monkeypatch):
monkeypatch.setenv("MINIFLOW_LLM", "ollama")
monkeypatch.setenv("OLLAMA_HOST", "http://127.0.0.1:9")
monkeypatch.setenv("OLLAMA_TIMEOUT", "2")
with pytest.raises(llm.LLMError, match="Cannot reach Ollama"):
llm.complete({}, "hi", "", {})
@@ -1,80 +0,0 @@
"""OpenAI provider, .env loading and the Ollama fallback, without network access."""
import types
import pytest
from miniflow import envfile, llm
def test_parse_env_lines():
values = envfile.parse('# comment\nexport A=1\nB="two words"\nC=3 # note\n\nbroken line\n')
assert values == {"A": "1", "B": "two words", "C": "3"}
def test_load_keeps_shell_values(tmp_path, monkeypatch):
(tmp_path / ".env").write_text("MINIFLOW_TEST_A=from-file\nMINIFLOW_TEST_B=from-file\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("MINIFLOW_DOTENV", "1")
monkeypatch.setenv("MINIFLOW_TEST_A", "from-shell")
monkeypatch.delenv("MINIFLOW_TEST_B", raising=False)
assert envfile.load() == tmp_path / ".env"
assert llm.os.environ["MINIFLOW_TEST_A"] == "from-shell"
assert llm.os.environ["MINIFLOW_TEST_B"] == "from-file"
monkeypatch.delenv("MINIFLOW_TEST_B")
def test_openai_without_key(monkeypatch):
monkeypatch.setenv("MINIFLOW_LLM", "openai")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("MINIFLOW_LLM_FALLBACK", raising=False)
with pytest.raises(llm.LLMError, match="OPENAI_API_KEY"):
llm.complete({}, "hello", "", {})
def test_openai_request_shape(monkeypatch):
sent = {}
class FakeResponses:
def create(self, **kwargs):
sent.update(kwargs)
return types.SimpleNamespace(output_text='{"ok": true}', usage=None)
class FakeClient:
def __init__(self, **_):
self.responses = FakeResponses()
import openai
monkeypatch.setattr(openai, "OpenAI", FakeClient)
monkeypatch.setenv("MINIFLOW_LLM", "openai")
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
monkeypatch.setenv("OPENAI_MODEL", "test-model")
schema = {"type": "object", "properties": {"ok": {"type": "boolean"}}}
reply, used = llm.complete({"temperature": 0, "json_schema": schema}, "hello", "be brief", {})
assert (reply, used) == ('{"ok": true}', "openai")
assert sent["model"] == "test-model" and sent["instructions"] == "be brief" and sent["temperature"] == 0
assert sent["text"]["format"]["schema"] == schema
def test_fallback_to_second_provider(monkeypatch, capsys):
monkeypatch.setenv("MINIFLOW_LLM", "openai")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama")
monkeypatch.setattr(llm, "call_ollama", lambda *args, **kwargs: "from ollama")
reply, used = llm.complete({}, "hello", "", {})
assert (reply, used) == ("from ollama", "ollama")
assert "openai failed" in capsys.readouterr().err
def test_both_providers_fail(monkeypatch):
monkeypatch.setenv("MINIFLOW_LLM", "openai")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama")
def broken(*args, **kwargs):
raise llm.LLMError("Cannot reach Ollama")
monkeypatch.setattr(llm, "call_ollama", broken)
with pytest.raises(llm.LLMError, match="OPENAI_API_KEY.*fallback ollama failed"):
llm.complete({}, "hello", "", {})
@@ -1,79 +0,0 @@
import json
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from miniflow.platforms import FailoverRunner, PlatformError, load_config
class FakeN8n(BaseHTTPRequestHandler):
mode = "ok"
received: list = []
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
FakeN8n.received.append((self.path, body))
if FakeN8n.mode == "error":
self.send_response(500)
self.end_headers()
return
payload = json.dumps({"category": "network", "faq_id": "FAQ-003", "route": "자동 답변", "reply": "n8n reply"}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, *args):
pass
@pytest.fixture()
def n8n(monkeypatch, tmp_path):
server = HTTPServer(("127.0.0.1", 0), FakeN8n)
threading.Thread(target=server.serve_forever, daemon=True).start()
FakeN8n.mode, FakeN8n.received = "ok", []
monkeypatch.setenv("N8N_BASE_URL", f"http://127.0.0.1:{server.server_port}")
monkeypatch.setenv("MINIFLOW_LLM", "mock")
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
yield server
server.shutdown()
INPUTS = {"user": "kim", "text": "VPN 접속이 계속 끊겨요"}
def test_n8n_serves_when_healthy(n8n):
report = FailoverRunner(["n8n", "miniflow"]).run("request", INPUTS)
assert report["served_by"] == "n8n"
assert FakeN8n.received[-1] == ("/webhook/ai-request", INPUTS)
def test_failover_to_miniflow_when_n8n_errors(n8n):
FakeN8n.mode = "error"
report = FailoverRunner(["n8n", "miniflow"]).run("request", INPUTS)
assert report["served_by"] == "miniflow"
assert [a["status"] for a in report["attempts"]] == ["failed", "ok"]
assert report["result"]["route"] == "자동 답변"
def test_failover_when_n8n_is_down(monkeypatch, tmp_path):
monkeypatch.setenv("N8N_BASE_URL", "http://127.0.0.1:9")
monkeypatch.setenv("N8N_TIMEOUT", "2")
monkeypatch.setenv("MINIFLOW_LLM", "mock")
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
report = FailoverRunner(["n8n", "miniflow"]).run("request", {"user": "lee", "text": "점심 메뉴 추천해줘"})
assert report["served_by"] == "miniflow"
assert report["result"]["route"] == "담당자 확인"
def test_all_platforms_failing_raises(n8n):
FakeN8n.mode = "error"
with pytest.raises(PlatformError, match="All platforms failed"):
FailoverRunner(["n8n"]).run("request", INPUTS)
def test_unknown_platform_is_rejected():
with pytest.raises(PlatformError, match="Unknown platform"):
FailoverRunner(["nowhere"], load_config())
@@ -1,80 +0,0 @@
import json
from pathlib import Path
import pytest
from miniflow import WorkflowError, load_workflow, run_workflow
from miniflow.template import TemplateError, render
FLOWS = Path(__file__).resolve().parents[1] / "flows"
@pytest.fixture(autouse=True)
def isolated_memory(tmp_path, monkeypatch):
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
monkeypatch.setenv("MINIFLOW_LLM", "mock")
def run(name, **inputs):
return run_workflow(load_workflow(FLOWS / name), inputs, verbose=False)
def test_every_sample_flow_is_valid():
for path in FLOWS.glob("*.json"):
assert load_workflow(path).order()
def test_template_keeps_type_for_whole_placeholder():
context = {"nodes": {"tool": {"output": {"temp": 20}}}}
assert render("{{nodes.tool.output}}", context) == {"temp": 20}
assert render("temp={{nodes.tool.output.temp}}", context) == "temp=20"
with pytest.raises(TemplateError):
render("{{nodes.missing.output}}", context)
def test_llm_only():
report = run("01-llm-only.json", text="오늘 회의에서 일정이 확정되었습니다.")
assert report["status"] == "completed"
assert "Mock" in report["result"]
def test_tool_data_flows_into_llm():
report = run("02-llm-tool.json", city="제주", question="우산이 필요할까?")
assert "제주" in report["result"] and "80%" in report["result"]
def test_unknown_city_fails_at_tool_node():
report = run("02-llm-tool.json", city="평양", question="날씨?")
assert report["status"] == "failed"
assert report["trace"][-1]["id"] == "weather"
def test_memory_survives_between_runs():
run("03-save-profile.json", user="minji", preference="실내 전시")
report = run("04-recommend-with-memory.json", user="minji", city="부산")
assert "실내 전시" in report["result"]
again = run("04-recommend-with-memory.json", user="minji", city="강릉")
recall = next(step for step in again["trace"] if step["id"] == "recall")
assert recall["output"]["last_city"] == "부산"
def test_tech_support_ticket():
report = run("05-tech-support-ticket.json", user="kim", ticket="비밀번호를 잊어버려서 로그인이 안 돼요")
assert report["result"]["category"] == "account"
assert report["result"]["faq_id"] == "FAQ-001"
def test_cycle_is_rejected(tmp_path):
flow = {
"nodes": [{"id": "a", "type": "input"}, {"id": "b", "type": "output", "value": "x"}],
"edges": [["a", "b"], ["b", "a"]],
}
path = tmp_path / "cycle.json"
path.write_text(json.dumps(flow), encoding="utf-8")
with pytest.raises(WorkflowError):
load_workflow(path)
def test_missing_input_is_reported():
with pytest.raises(WorkflowError):
run("02-llm-tool.json", city="서울")
-1058
View File
File diff suppressed because it is too large. Load diff