66일차 교안/실습 추가

This commit is contained in:
mcchae committed 2026-10-15 22:45:16 +09:00
1 parent 787d7d8514
commit d7dc9dd68a
52 files changed
+6144

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
# 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에 위치와 이유를 한 줄씩 적었다.
+191
View File
@@ -0,0 +1,191 @@
# 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`에서 `[]`를 받았다.
+258
View File
@@ -0,0 +1,258 @@
# 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을 확인했다.
+60
View File
@@ -0,0 +1,60 @@
# 66일차 실습 · 단일 업무 자동화 워크플로우 직접 만들기
- 일자: 2026-10-16 (66일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: AI 워크플로우 설계 및 구현
- 강의 자료: [Lecture/66](../../Lecture/66/README.md)
## 오늘 만드는 업무: 회의록 → 할 일 정리
```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
```
## 실습 방식
모든 Lab은 **쉬운 예제 2개 → 예제를 고치는 과제** 순서입니다. 예제는 안내를 그대로 따라 하면 되고, 과제에서 생각합니다.
| 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분** · 내 워크플로우 완성 + 확인 필요 표시 |
## 구성
```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)
```
## 준비
- 65일차의 n8n(Credential `OpenAI (.env)`)과 `Last3Mon/.env`를 그대로 사용합니다: `cd ~/aio-01d4-stu/Last3Mon/65/n8n && docker compose start`
- 실습 엔진 확인:
```bash
cd ~/aio-01d4-stu/Last3Mon/66/workflow-lab && uv sync && uv run pytest -q # 43 passed, 1 skipped
```
+4
View File
@@ -0,0 +1,4 @@
{
"title": "빈 회의록",
"notes": ""
}
+4
View File
@@ -0,0 +1,4 @@
{
"title": "분기 실적 공유",
"notes": "오늘은 지난 분기 실적을 공유하고 질의응답을 했습니다. 별도 결정 사항은 없습니다."
}
+4
View File
@@ -0,0 +1,4 @@
{
"title": "10월 16일 주간 회의",
"notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."
}
@@ -0,0 +1,584 @@
{
"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"
}
@@ -0,0 +1,83 @@
{
"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"
}
@@ -0,0 +1,241 @@
{
"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"
}
@@ -0,0 +1,229 @@
{
"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,59 @@
# 업무 자동화 워크플로우 설계서 (데이터 흐름 포함)
- 작성자:
- 업무 이름:
- 한 줄 목표:
## 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
@@ -0,0 +1,47 @@
# 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을 마친 뒤 (복습 과제)
내 설계와 실제로 만든 워크플로우에서 달라진 점:
-
+5
View File
@@ -0,0 +1,5 @@
runs/
.miniflow-memory.json
.venv/
.pytest_cache/
__pycache__/
+125
View File
@@ -0,0 +1,125 @@
# 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
@@ -0,0 +1,14 @@
[
{"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 포털의 '소프트웨어 신청' 메뉴에서 승인 요청을 올리면 승인 후 원격으로 설치됩니다."}
]
@@ -0,0 +1,10 @@
{
"weekly": {
"title": "10월 16일 주간 회의",
"notes": "참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일."
},
"no_action": {
"title": "분기 실적 공유",
"notes": "오늘은 지난 분기 실적을 공유하고 질의응답을 했습니다. 별도 결정 사항은 없습니다."
}
}
@@ -0,0 +1,6 @@
{
"서울": {"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": "좋음"}
}
@@ -0,0 +1,14 @@
{
"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"]]
}
@@ -0,0 +1,15 @@
{
"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"]]
}
@@ -0,0 +1,12 @@
{
"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"]]
}
@@ -0,0 +1,19 @@
{
"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"]]
}
@@ -0,0 +1,30 @@
{
"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"]]
}
@@ -0,0 +1,38 @@
{
"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"]
]
}
@@ -0,0 +1,29 @@
{
"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"]
]
}
@@ -0,0 +1,168 @@
{
"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
@@ -0,0 +1,24 @@
{
"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
@@ -0,0 +1,28 @@
[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"]
@@ -0,0 +1,5 @@
"""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"]
@@ -0,0 +1,158 @@
"""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())
@@ -0,0 +1,255 @@
"""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}
@@ -0,0 +1,50 @@
"""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
@@ -0,0 +1,193 @@
"""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)")
@@ -0,0 +1,33 @@
"""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)
@@ -0,0 +1,117 @@
"""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))
@@ -0,0 +1,48 @@
"""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)
@@ -0,0 +1,85 @@
"""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()]
@@ -0,0 +1,3 @@
import os
os.environ["MINIFLOW_DOTENV"] = "0" # tests never read the real .env (it may hold API keys)
@@ -0,0 +1,68 @@
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", "", {})
@@ -0,0 +1,104 @@
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"
@@ -0,0 +1,52 @@
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
@@ -0,0 +1,66 @@
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", "", {})
@@ -0,0 +1,80 @@
"""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", "", {})
@@ -0,0 +1,79 @@
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())
@@ -0,0 +1,80 @@
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
+305
View File
@@ -0,0 +1,305 @@
# 66일차 개요 · 단일 업무 자동화 워크플로우 구현
> 노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-16
> 15장 · 각 장은 가로줄(`---`)로 나뉩니다. 1교시에는 1~3장과 14장만 보고, 나머지 장은 각 단계를 설명할 때 함께 띄웁니다.
| 장 | 범위 |
| --- | --- |
| 1~3 | 표지 · 오늘의 목표 · 시간표 |
| 4~7 | 1단계 · 업무 설계와 데이터 흐름 |
| 8~10 | 2단계 · 파싱과 변환 노드 |
| 11~13 | 3단계 · 반복·병렬과 완성 |
| 14~15 | 실습 · 퀴즈와 예고 |
---
## 1 / 15 · 표지
```mermaid
flowchart LR
M["회의록<br/>1건"] --> A1["할 일"] & A2["할 일"] & A3["할 일"] & A4["할 일"]
A1 & A2 & A3 & A4 --> R["결과<br/>1건"]
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
class M d
class A1,A2,A3,A4 o
class R t
```
**66일차**
# 단일 업무 자동화 워크플로우 구현
쉬운 예제 2개 → 고쳐 보는 과제 · 데이터 파싱·변환·집계 · 반복과 병렬
<sub>노코드 멀티 에이전트 워크플로우 설계 및 운영 · 2026-10-16</sub>
---
## 2 / 15 · 오늘 할 수 있게 될 세 가지
| | 목표 | 내용 |
| :-: | --- | --- |
| **①** | **업무 하나를 노드와 데이터로 설계한다** | 목적·입출력·성공 기준 · TCA → 노드 목록 · 연결선마다 항목 수와 필드 · 제어 구조 고르기 |
| **②** | **LLM의 답을 데이터로 바꾼다** | JSON 스키마 출력 · 문자열 파싱 · 검증과 기본값 · 실패 이유 남기기 · Edit Fields 변환 |
| **③** | **반복과 병렬로 워크플로우를 완성한다** | Split Out → Aggregate · Fork-Join(Merge) · 같은 결과 형식 · Python 호출 |
---
## 3 / 15 · 오늘의 시간표
| ① 1~2교시 | ② 3~4교시 | ③ 5~6교시 | ④ 7~8교시 |
| --- | --- | --- | --- |
| **1단계** | **2단계** | **3단계** | **정리** |
| 업무 설계와 데이터 흐름 | 파싱과 변환 노드 | 반복·병렬과 완성 | 퀴즈 A·B·C · 오늘의 정리·예고 |
| Lab 01 예제 2 + 과제 | Lab 02 예제 2 + 과제 | Lab 03 예제 2 + **과제 40분** | 퀴즈 20분 · Python 호출 |
> 09:00 시작 · 50분 수업 10분 휴식 · 12:00~13:00 점심
---
## 4 / 15 · 오늘의 업무: 회의록 → 할 일 정리
| 지금 방식 | 자동화 후 |
| --- | --- |
| 회의록을 읽고 할 일·담당자·기한을 직접 찾음 | 회의록을 보내면 요약·할 일 목록·정리문 |
| 빠진 담당자·기한을 놓치기 쉬움 | 빠진 값은 `미정`으로 바로 보임 |
| 급한 일이 묻힘 | 오늘·내일 마감은 우선순위 `높음` |
| 범위를 좁히는 세 줄 | 오늘의 답 |
| --- | --- |
| **목적 한 문장** | 회의록을 받으면 할 일 목록과 공유용 정리문을 돌려준다 |
| **입력과 출력** | `{title, notes}` → `{title, route, count, summary, actions, report_text}` |
| **성공 기준** | `weekly`에서 할 일 4건, 빠진 값은 `미정`, 오늘 마감은 `높음` |
> **단일 업무로 좁히기** 오늘은 회의록 → 할 일 정리 → 응답까지. 알림·일정 등록은 나중에 확장
---
## 5 / 15 · 예시 데이터로 결과를 먼저 그려 보기
| # | 할 일 | 담당자 | 기한 | 우선순위 | 누가 정하나 |
| --- | --- | --- | --- | --- | --- |
| 1 | 신규 고객 문의 응답 템플릿 정리 | 이준호 | 금요일 | 보통 | 찾기는 **LLM** |
| 2 | VPN 장애 보고서 작성 | 박서연 | 다음 주 월요일 | 보통 | |
| 3 | 서버 점검 일정 공지 | 김민지 | 오늘 | **높음** | 우선순위는 **규칙** |
| 4 | 회의실 예약 규칙 개선 담당자 선정 | **미정** | 10월 23일 | 보통 | 빈 값 채우기는 **규칙** (기한은 LLM이 "다음 회의"에서 추론) |
> **LLM은 찾기만, 채우기·우선순위·정리문은 규칙** · 결과 형식(JSON)은 노드를 만들기 전에 약속한다 · 할 일이 없어도 같은 필드(`route: 할 일 없음`, `count: 0`, `actions: []`)
---
## 6 / 15 · 상자(항목)가 몇 개 지나가나 · 1 → 4 → 1
```mermaid
flowchart LR
A["회의록<br/>1개"] -->|"LLM + 파싱"| B["1개<br/>안에 actions [4]"] -->|"Split Out<br/>1 → 4"| C["할 일<br/>4개"] -->|"항목마다 정리"| D["정리된 할 일<br/>4개"] -->|"Aggregate<br/>4 → 1"| E["결과<br/>1개"]
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
classDef m fill:#5B8E7D,stroke:#5B8E7D,color:#fff
class A d
class B,E t
class C o
class D m
```
| 노드 | 상자 수 |
| --- | --- |
| Edit Fields · **If** · Switch | **그대로** (If는 한쪽 길로 보낼 뿐) |
| **Split Out** | **1 → N** (목록을 펼침) |
| **Aggregate** | **N → 1** (다시 모음) |
| Merge | 두 길 → 하나 |
> **Lab 01 예제 1** 어제 흐름은 모두 1 · **예제 2** 파싱 직후는 아직 1 (목록이 상자 하나에 들어 있음) → Split Out 뒤에 4
---
## 7 / 15 · 제어 구조 고르기 · 요청이 바뀌면
| 조건 분기 | 반복 | 병렬 (Fork-Join) |
| :-: | :-: | :-: |
| **입력에 따라 다른 결과** | **목록의 항목마다 같은 처리** | **서로 결과가 필요 없는 일** |
| 내용 없음 → 거절<br/>할 일 없음 → 다른 결과 | 할 일마다 우선순위 | 할 일 찾기와 요약 |
| If · Switch | Split Out → … → Aggregate | 가지 나누기 + Merge |
| 요청이 바뀌면 묻는 세 질문 | 고칠 곳 |
| --- | --- |
| 할 일 **하나마다** 정하는 값? | Split Out과 Aggregate **사이** (예: `priority`) |
| **모두 모은 뒤**에야 아는 값? | Aggregate **뒤** (예: `count`) |
| 결과 필드가 늘었나? | **모든 길**의 끝 |
> **Lab 01 과제** "담당자·기한이 없으면 확인 필요, 몇 건인지도" → 오후 Lab 03 과제에서 실제로 만든다
---
## 8 / 15 · LLM의 답은 글자다 → 파싱
```text
Fake LLM answer content: "{\"actions\": [{\"task\": \"...\", \"owner\": \"\", ...}]}" ← 글자 한 줄
Parse actions { actions: [ {task, owner: "미정", due: "미정"}, ... ], count: 2 } ← 목록
```
| ① 꺼내기 | ② 글자 → 데이터 | ③ 빈 할 일 버리기 | ④ 빈 값은 `미정` |
| :-: | :-: | :-: | :-: |
| `content` (진짜 LLM은 `choices[0].message.content`) | 실패하면 빈 목록 + `error` | | |
> **Lab 02 예제 1** LLM 없이 가짜 답으로 파싱 연습 · 끝 `}`를 지우면 `error: answer is not valid JSON` → 실패해도 **같은 모양**, 이유는 남긴다
---
## 9 / 15 · 답의 모양 알려 주기 · 하나 더 뽑기
| LLM 노드 JSON 칸 | 하는 일 | 비유 |
| --- | --- | --- |
| ① 스키마 `response_format` | 답의 **모양** | 빈칸이 정해진 답안지 |
| ② 지시문 `system` | 무엇을 찾을지, 없으면 `미정` | 문제 설명 |
| ③ 회의록 `user` | 읽을 글 | 지문 |
```mermaid
flowchart LR
A["① 스키마에 칸 추가"] --> B["② 지시문에 한 문장"] --> L{{"LLM 답<br/>(글자)"}} --> C["③ 파싱 코드 return"] --> R["결과"]
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
class A,B,C t
class L o
```
> **스키마는 모양만, 내용은 보장 안 함** · **Lab 02 과제** 참석자도 뽑기 = 세 곳 고치기 (③을 빠뜨리면 LLM 답에는 있어도 결과에서 사라짐) · `}}`가 붙으면 `invalid syntax` → `} }`
---
## 10 / 15 · Edit Fields와 만들고 확인하는 순서
| 하고 싶은 일 | Edit Fields 표현식 |
| --- | --- |
| 웹훅 입력 꺼내기 | `{{ $json.body.notes }}` |
| 비었으면 기본값 | `{{ $json.body.title \|\| '제목 없음' }}` |
| 여러 단어 중 하나라도 포함 | `{{ ['오늘','내일','긴급'].some(w => $json.due.includes(w)) }}` |
```mermaid
flowchart LR
A["노드 하나 추가"] --> B["이름 짓기"] --> C["설정"] --> D["실행"] --> E["OUTPUT 확인"]
E -->|"다음 노드"| A
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
class A,B,C t
class D,E o
```
> `{{ }}` 값은 **Expression** 모드 · 같은 요청 파일(`weekly`·`no-action`·`empty`)로 매번 확인 · Test URL은 요청마다 Listen 다시
---
## 11 / 15 · 반복과 병렬 · Lab 03 예제 1·2
```mermaid
flowchart LR
T(["Start"]) --> S["Sample actions<br/>[3]"] -->|"1 → 3"| SO["Split Out"] --> N["Normalize<br/>3번"] -->|"3 → 1"| AG["Aggregate"] -->|"Input 1"| J["Merge<br/>Position"]
T --> SS["Sample summary"] -->|"Input 2"| J
J --> O["{actions, summary}<br/>1개"]
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
classDef m fill:#5B8E7D,stroke:#5B8E7D,color:#fff
classDef p fill:#7A6FB0,stroke:#7A6FB0,color:#fff
class S,SS o
class SO,N,AG m
class J p
```
| 펼쳤으면 반드시 모은다 (수업 전 실측) | 결과 |
| --- | --- |
| 모으지 않고 바로 응답 | 200, **첫 할 일 하나만** (조용히 틀림) |
| 모으지 않고 `Build report` | 500 |
> Merge는 **두 입력이 모두 와야** 합침 · 한 길이 실패하면 응답 없음 · 항목마다 LLM을 부르면 Loop Over Items, 시간·비용은 항목 수에 비례
---
## 12 / 15 · 가짜를 진짜로 · Lab 03 과제 40분
| 예제의 가짜 | 과제의 진짜 |
| --- | --- |
| `Sample actions` | `LLM extract actions` → `Parse actions` (Lab 02) |
| `Sample summary` | `LLM summarize` → `Keep summary` |
| Merge · Split Out · Normalize · Aggregate | **복사해 붙이기** |
| (없음) | `Any actions?` · `Build report` · `No actions` · **확인 필요** |
```mermaid
flowchart LR
P["할 일 목록"] --> J["Merge"]
K["요약"] --> J
J --> A{"할 일<br/>있음?"} -->|"예"| L["펼치고 · 정리 · 모으기"] --> B["Build report"]
A -->|"아니오"| NA["No actions"]
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef p fill:#7A6FB0,stroke:#7A6FB0,color:#fff
class A o
class P,K,L,B,NA t
class J p
```
> Merge를 **먼저** → 할 일 없음 길에도 요약이 있다 · 두 길 모두 `{title, route, count, summary, actions, need_check}`
---
## 13 / 15 · 기대 결과로 검증하고, 밖에서 부르기
| 요청 | 기대 결과 (수업 전 측정) |
| --- | --- |
| `weekly` | **할 일 정리** · `count: 4` · 오늘 마감 1건 `높음` · `need_check: 1` · 약 3~4초 |
| `no-action` | **할 일 없음** · `count: 0` · `need_check: 0` · 약 2초 |
| `empty` | **HTTP 400** · 1초 미만 |
| 오류 없이 틀리는 모양 (실측) | 원인 |
| --- | --- |
| 할 일 하나만 | Aggregate 빠짐 |
| `no-action`이 빈 응답 | false 길 끊김 |
| `need_check: 4` | `check`를 String으로 |
```mermaid
flowchart LR
APP["call('meeting')"] --> CFG["platforms.json<br/>웹훅 경로 한 줄"]
CFG --> N8["n8n · 내 워크플로우"]
CFG --> MF["miniflow · 08 flow"]
classDef d fill:#0F3D3E,stroke:#0F3D3E,color:#fff
classDef o fill:#F2A541,stroke:#F2A541,color:#fff
classDef t fill:#0F7C80,stroke:#0F7C80,color:#fff
classDef m fill:#5B8E7D,stroke:#5B8E7D,color:#fff
class APP d
class CFG o
class N8 t
class MF m
```
> 실행 기록 항목 수 1 → 4 → 4 → 1 → 1 · 같은 입력·같은 결과 형식 → **설정 한 줄**로 대상 변경 (8교시 함께)
---
## 14 / 15 · 오늘의 실습
| | 실습 | 예제 1 | 예제 2 | 과제 (생각하기) |
| --- | --- | --- | --- | --- |
| **1** | **Lab 01** · 설계 | 어제 흐름의 항목 수 | 오늘 흐름 빈칸 (1 → 4 → 1) | "확인 필요" 요청 → 어디를 고치나 |
| **2** | **Lab 02** · 파싱 | LLM 없이 글자 → 목록 | LLM 붙은 1부 가져와 실행 | 참석자도 뽑기 (세 곳) |
| **3** | **Lab 03** · 완성 | 펼치고 모으기 (1 → 3 → 1) | 두 길을 Merge로 | **40분** · 내 워크플로우 완성 + 확인 필요 |
<sub>예제는 따라 하기, 과제는 생각하기 · 환경: 65일차 n8n · Credential `OpenAI (.env)` 그대로 · 막히면 `n8n/workflows`의 파일과 비교</sub>
---
## 15 / 15 · 7교시 퀴즈 · 그리고 내일
| A · 기본 | B · 응용 | C · 심화 |
| :-: | :-: | :-: |
| 데이터 노드 · 제어 구조 용어 | 항목 수 · 표현식 · 노드 고르기 | 흐름 오류 찾기 · 설계 판단 |
- 단계별 10문항 · 20분 · 원하는 단계 선택
- 마지막 주관식 '더 알고 싶은 것'은 선택 · 점수 없음
> **67일차 예고**
> AI Agent가 Tool을 골라 쓰는 **Agent·Tool·데이터 연계**
> 실행 결과 검증
### *오늘의 한 문장: 연결선마다 무엇이 몇 개 흐르는지 알면, 워크플로우는 틀리지 않는다*
+80
View File
@@ -0,0 +1,80 @@
# 66일차 실습 준비 · 수업 전에 미리 해 두면 좋은 것
- 수업일: 2026-10-16 (66일차) · 단일 업무 자동화 워크플로우 직접 만들기
- 준비 시간: 약 20분 (새로 받을 이미지·모델 없음)
- 먼저 WSL에서 `cd ~/aio-01d4-stu && git pull`로 오늘 자료를 받습니다 (수강생 저장소: https://gl.isitsoft.kr/publicproj/aio-01d4-stu).
- **수업 전날까지** 1~2절을 끝내 주세요. 3~4절은 시간이 될 때 읽어 오면 좋습니다.
```mermaid
flowchart LR
A["1. 어제 환경 켜기<br/>n8n · .env 키"] --> B["2. 확인<br/>pytest · OpenAI · n8n 로그인"]
B --> C["3. 미리 읽기<br/>JSON 객체·목록"]
C --> D["4. 생각해 오기<br/>항목 수가 바뀌는 곳"]
```
---
## 1. 어제 환경 다시 켜기 (전날 필수, 5분)
오늘은 65일차에 만든 n8n(Credential `OpenAI (.env)`)과 `Last3Mon/.env`를 그대로 씁니다. Ollama는 대체용으로 함께 켜 둡니다.
```bash
sudo systemctl stop ollama # WSL의 Ollama가 켜져 있다면 멈춤
cd ~/aio-01d4-stu/Last3Mon/65/n8n
docker compose start
docker compose ps # n8n, ollama 모두 running
```
브라우저에서 http://localhost:5678 에 로그인합니다. 65일차 준비를 하지 못했다면 [n8n 준비 안내](../../Last3Mon/65/SETUP-N8N.md)를 먼저 끝내 주세요.
## 2. 실습 엔진 확인 (전날 필수, 5분)
```bash
cd ~/aio-01d4-stu/Last3Mon/66/workflow-lab
uv sync
uv run pytest -q # 43 passed, 1 skipped
```
---
## 3. 미리 읽어 오면 좋은 내용 (선택, 약 10분)
### 3.1 JSON의 객체와 목록
| 모양 | 이름 | 예 |
| --- | --- | --- |
| `{ }` | 객체 (이름: 값의 묶음) | `{"task": "보고서 작성", "owner": "박서연", "due": "월요일"}` |
| `[ ]` | 목록 (값을 순서대로) | `[{...}, {...}, {...}]` |
| `"..."` | 문자열 (글자) | `"{\"task\": \"보고서\"}"` ← JSON처럼 보이지만 **글자** |
오늘 LLM은 할 일 목록을 **글자**로 돌려줍니다. 이 글자를 실제 목록으로 바꾸는 작업을 **파싱**이라고 합니다. 64일차 준비 안내의 JSON 기초도 함께 보세요.
### 3.2 오늘 처음 쓰는 n8n 노드
| 노드 | 한 줄 설명 |
| --- | --- |
| Edit Fields (Set) | 필드를 만들고 값을 계산 |
| Code | JavaScript로 데이터 가공 |
| Split Out | 목록 하나를 항목 여러 개로 펼침 |
| Aggregate | 항목 여러 개를 목록 하나로 모음 |
| Merge | 두 갈래의 결과를 하나로 합침 |
---
## 4. 생각해 오기 (선택, 5분)
회의록 1건에서 할 일 4건이 나왔습니다. 할 일마다 우선순위를 정한 뒤 다시 하나의 결과로 돌려주려면, 데이터의 개수가 어떻게 바뀌어야 할까요?
```text
회의록 1건 → ? → ? → 결과 1건
```
## 5. 당일 준비물
- 충전된 노트북과 충전기, WSL의 Docker, Windows Terminal(WSL), 웹 브라우저
## 확인
- [ ] `docker compose ps` → n8n, ollama running
- [ ] `uv run pytest -q` → `43 passed, 1 skipped`
- [ ] JSON 객체·목록·문자열의 차이를 읽었다
@@ -0,0 +1,260 @@
# 1단계 · 단일 업무 자동화 설계와 데이터 흐름
- 시간: 1~2교시 (1교시 앞부분은 [오늘의 개요](00-DAY66-OVERVIEW.md))
- 학습 목표
- 하나의 업무를 목적 하나로 좁히고, 결과를 먼저 그려 볼 수 있다.
- 노드 사이를 지나가는 **항목(item)** 의 개수를 읽고 쓸 수 있다 (1 → 4 → 1).
- LLM이 할 일과 규칙으로 할 일을 나눌 수 있다.
- 조건 분기·반복·병렬 중 어떤 제어 구조가 필요한지 고를 수 있다.
- 요청이 바뀌었을 때 **어느 노드를 고치면 되는지** 찾을 수 있다.
- 실습: [Lab 01 · 회의록 자동 정리 워크플로우 설계하기 (예제 2개 + 과제)](../../Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md)
```mermaid
flowchart LR
A["1. 오늘의 업무"] --> B["2. 결과 먼저"] --> C["3. 항목 읽기<br/>(Lab 01 예제 1)"] --> D["4. 1 → 4 → 1<br/>(Lab 01 예제 2)"] --> E["5. 제어 구조"] --> F["6. 요청이 바뀌면<br/>(Lab 01 과제)"]
```
오늘 수업은 모두 같은 순서로 갑니다. **설명 → 쉬운 예제 2개를 함께 → 그것을 고치는 과제를 혼자**. 이 교안의 절도 Lab의 예제·과제 순서에 맞춰 놓았습니다.
---
## 1. 오늘의 업무: 회의록 → 할 일 정리
64일차 시나리오 S1 "회의록 요약 후 할 일 정리"를 n8n으로 만듭니다.
| 지금 (AS-IS) | 자동화 후 (TO-BE) |
| --- | --- |
| 누군가 회의록을 읽고 할 일·담당자·기한을 찾아 정리 | 회의록을 보내면 요약·할 일 목록·정리문이 바로 나옴 |
| 담당자나 기한이 빠진 일을 놓침 | 빠진 값은 `미정`으로 보임 |
| 급한 일이 묻힘 | 오늘·내일 마감은 우선순위 `높음` |
### 1.1 목적 하나로 좁히기
처음 만드는 자동화는 **목적 하나**만 해결합니다. 메일 발송·일정 등록까지 한 번에 넣으면 어디가 틀렸는지 찾기 어렵습니다.
| 질문 | 오늘의 답 (한 문장씩) |
| --- | --- |
| 목적 | 회의록을 받으면 할 일 목록과 정리문을 돌려준다 |
| 입력 → 출력 | `{title, notes}` → `{title, route, count, summary, actions, report_text}` |
| 성공 기준 | 예시 회의록(`weekly`)에서 할 일 4건, 빠진 값은 `미정`, 오늘 마감은 `높음` |
세 줄이 한 문장씩으로 끝나지 않으면 아직 범위가 넓은 것입니다. 회의록이 **비어 있으면** LLM을 부르기 전에 맨 앞에서 거절(HTTP 400)합니다. 쓸데없는 호출과 비용을 막는 가장 싼 방법입니다.
---
## 2. 결과를 먼저 그려 보기
노드를 생각하기 전에 **예시 입력 한 건과 기대하는 결과**를 손으로 적습니다.
> 참석: 김민지, 이준호, 박서연. 1) 신규 고객 문의 응답 템플릿을 이준호가 금요일까지 정리한다. 2) 박서연은 다음 주 월요일까지 VPN 장애 보고서를 작성한다. 3) 서버 점검 일정은 김민지가 오늘 중 공지한다. 4) 회의실 예약 규칙 개선은 담당자를 다음 회의에서 정한다. 다음 회의는 10월 23일.
| # | 할 일 | 담당자 | 기한 | 우선순위 |
| --- | --- | --- | --- | --- |
| 1 | 신규 고객 문의 응답 템플릿 정리 | 이준호 | 금요일 | 보통 |
| 2 | VPN 장애 보고서 작성 | 박서연 | 다음 주 월요일 | 보통 |
| 3 | 서버 점검 일정 공지 | 김민지 | 오늘 | **높음** |
| 4 | 회의실 예약 규칙 개선 담당자 선정 | **미정** | 10월 23일 | 보통 |
이 표를 채우다 보면 일이 두 종류로 나뉩니다.
| 누가 | 하는 일 | 왜 |
| --- | --- | --- |
| **LLM** | 문장 속에서 할 일·담당자·기한 **찾기**, 요약 | 사람 말을 읽어야 함 |
| **규칙** | 빈 값을 `미정`으로 **채우기**, 우선순위 **정하기**, 정리문 **만들기** | 항상 같은 답이어야 함 |
> 4번의 기한은 회의록에 직접 적혀 있지 않지만, 수업 전 측정(`gpt-4.1-mini`)에서 LLM은 매번 "다음 회의는 10월 23일"을 읽고 `10월 23일`로 채웠습니다. 담당자는 적혀 있지 않아 `미정`입니다. LLM이 **추론한 값**을 받아들일지도 설계에서 정할 일입니다.
### 2.1 결과 형식을 먼저 약속하기
결과를 받는 쪽(사람, 다른 시스템, 65일차의 플랫폼 전환 코드)이 이 모양에 맞춰 만들어지므로, 노드를 만들기 **전에** 정합니다.
```json
{"title": "10월 16일 주간 회의", "route": "할 일 정리", "count": 4, "summary": "요약 2문장",
"actions": [{"task": "...", "owner": "...", "due": "...", "priority": "높음 | 보통"}],
"report_text": "공유용 정리문"}
```
할 일이 없어도 **같은 필드**를 쓰고 `route: 할 일 없음`, `count: 0`, `actions: []`로 둡니다 (65일차 "모든 길이 같은 모양으로 끝나게").
---
## 3. 항목(item) 읽기 · Lab 01 예제 1
n8n에서 노드와 노드 사이를 지나가는 데이터 한 덩어리를 **항목(item)** 이라고 합니다. 택배 상자 하나라고 생각하면 됩니다. 상자 안에는 필드(`user`, `text` ...)가 들어 있습니다.
어제 Lab 02A에서 만든 흐름을 보면서 상자가 몇 개 지나가는지 셉니다.
```mermaid
flowchart LR
T(["Start"]) -->|"1"| S["Sample request<br/>Edit Fields"] -->|"1"| I{"Is urgent?<br/>If"}
I -->|"true · 1"| U["Urgent route"]
I -.->|"false · 0"| N["Normal route"]
```
- 모든 연결선이 **1개**입니다. If는 상자를 **한쪽 길로 보낼 뿐** 개수를 바꾸지 않습니다.
- 개수를 바꾸는 노드는 따로 있습니다. 오늘 새로 쓰는 노드입니다.
| 노드 | 하는 일 | 항목 수 |
| --- | --- | --- |
| Edit Fields (Set) | 필드 만들기·값 계산 | 그대로 |
| If · Switch | 길 나누기 | 그대로 (한쪽으로) |
| Code | 무엇이든 (파싱·검증) | 코드가 정함 |
| **Split Out** | 상자 속 목록을 **상자 여러 개로 펼침** | **1 → N** |
| **Aggregate** | 상자 여러 개를 **목록 하나로 모음** | **N → 1** |
| **Merge** | 두 길에서 온 상자를 **합침** | 둘 → 하나 |
| Filter | 조건에 맞는 상자만 남김 | 줄어듦 |
---
## 4. 오늘 흐름의 항목 수 1 → 4 → 1 · Lab 01 예제 2
회의록 1건이 할 일 4건이 되었다가 다시 결과 1건이 됩니다.
```mermaid
flowchart LR
A["회의록<br/>상자 1개"] -->|"LLM + 파싱"| B["상자 1개<br/>안에 actions 목록 [4]"] -->|"Split Out<br/>1 → 4"| C["할 일<br/>상자 4개"] -->|"항목마다 정리"| D["정리된 할 일<br/>상자 4개"] -->|"Aggregate<br/>4 → 1"| E["결과<br/>상자 1개"]
```
가장 자주 헷갈리는 곳은 **B**입니다. 할 일이 4건이라도 파싱 직후에는 **상자 1개 안에 목록으로** 들어 있습니다. Split Out을 지나야 비로소 상자 4개가 됩니다.
오늘 완성할 전체 그림과 각 노드의 역할입니다. (오후 Lab 03 과제의 완성 그림과 같습니다.)
```mermaid
flowchart LR
W(["Meeting webhook"]) --> S["Settings"] --> H{"Has notes?"}
H -->|false| R0["Reject empty<br/>400"]
H -->|true| L1{{"LLM extract actions"}} --> P["Parse actions"] --> J["Join results<br/>Merge"]
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
```
| 설계에서 | n8n 노드 | 언제 만드나 |
| --- | --- | --- |
| 회의록 받기, 설정 모으기, 비었으면 거절 | Webhook, Edit Fields, If, Respond | Lab 02 예제 2 (가져옴) |
| 할 일 찾기 (LLM) → 글자를 목록으로 | HTTP Request, Code | Lab 02 예제 1·2 |
| 요약 (LLM)과 합치기 | HTTP Request, Edit Fields, Merge | Lab 03 예제 2 → 과제 |
| 할 일마다 우선순위 → 모으기 → 정리문 | Split Out, Edit Fields, Aggregate, Code | Lab 03 예제 1 → 과제 |
| 할 일이 없을 때 | If, Edit Fields | Lab 03 과제 |
### 4.1 연결선 표에 적는 세 가지
| 적는 것 | 예 | 빠뜨리면 |
| --- | --- | --- |
| **항목 수** | Split Out 뒤 4 | 응답이 할 일 하나만 나가는 것을 늦게 알아챔 |
| **필드 이름** | `actions`, `count`, `summary` | 표현식(`{{ $json.actions }}`)에 없는 이름 → 빈 값 |
| **형식** | LLM 바로 뒤는 **글자** | LLM 답을 목록으로 착각 → 2단계의 파싱이 필요 |
---
## 5. 제어 구조 고르기
질문 세 개로 고릅니다.
| 질문 | 예이면 | n8n | 오늘의 예 |
| --- | --- | --- | --- |
| 입력에 따라 **다른 결과**를 내야 하나? | 조건 분기 | If, Switch | 내용 없음 → 거절, 할 일 없음 → 다른 결과 |
| 목록의 **항목마다** 같은 일을 하나? | 반복 | Split Out → … → Aggregate | 할 일마다 우선순위 |
| 서로의 **결과가 필요 없는** 일이 둘 이상인가? | 병렬 (Fork-Join) | 가지 나누기 + Merge | 할 일 찾기와 요약 |
### 5.1 연습
| 업무 | 제어 구조 |
| --- | --- |
| 문의 메일 50통을 각각 분류 | 반복 |
| 리뷰의 감정 분석과 키워드 추출 | 병렬 |
| 감정이 부정이면 담당자에게, 아니면 자동 답글 | 조건 분기 |
| 회의록 요약을 만든 뒤 그 요약으로 제목 짓기 | **순서** (제목에 요약 결과가 필요 → 병렬 불가) |
| 출장자마다 날씨 조회 후 우산 필요 여부 판단 | 반복 + 조건 분기 |
실제 업무는 여러 구조가 섞이고, **앞 결과가 필요하면 병렬로 나눌 수 없습니다.**
---
## 6. 요청이 바뀌면 어디를 고치나 · Lab 01 과제
설계를 해 두면 요청이 바뀌었을 때 고칠 곳을 금방 찾습니다. 찾는 순서는 세 질문입니다.
```mermaid
flowchart LR
Q1{"LLM이 할 일?<br/>규칙이 할 일?"} --> Q2{"할 일 하나마다?<br/>모두 모은 뒤?"} --> Q3{"모든 길이<br/>같은 필드로 끝나나?"}
```
| 질문 | 고칠 곳 |
| --- | --- |
| 할 일 **하나마다** 정하는 값인가? | 상자가 하나씩 흐르는 곳 = Split Out과 Aggregate **사이** |
| **모두 모은 뒤**에야 알 수 있는 값인가? (개수, 합계) | Aggregate **뒤** |
| 결과에 필드가 늘었나? | **모든 길**의 끝 (할 일 없음 길 포함) |
연습 삼아 이미 있는 값으로 확인해 봅니다.
| 값 | 하나마다? 모은 뒤? | 그래서 만드는 곳 |
| --- | --- | --- |
| `priority` (오늘·내일이면 높음) | 하나마다 | `Normalize action` |
| `count` (할 일 개수) | 모은 뒤 | `Build report` |
| `route` | 길마다 | `Build report` / `No actions` |
Lab 01 과제는 이 방법으로 "확인 필요" 요청을 설계하고, 오후 Lab 03 과제에서 실제로 만듭니다.
---
## 7. Lab 01 안내
- 실습 안내: [Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md](../../Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md) (22분, 활동지 `templates/LAB-01-WORKSHEET.md`)
| 순서 | 시간 | 할 일 | 교안 |
| --- | --- | --- | --- |
| 예제 1 | 5분 | 어제 흐름의 연결선 항목 수 읽기 | 3절 |
| 예제 2 | 7분 | 오늘 흐름의 빈칸 다섯 개 (1 → 4 → 1, 병렬 이유, LLM이 하는 일) | 2·4·5절 |
| 과제 | 10분 | "담당자·기한이 없는 할 일은 **확인 필요**로, 몇 건인지도" → 어디를 고치나 | 6절 |
- 전체 설계서(`templates/DATA-FLOW-DESIGN-TEMPLATE.md`)는 오늘 복습 과제에서 **내 업무**로 써 봅니다.
---
## 8. 자주 하는 설계 실수
| 실수 | 증상 | 고치는 법 |
| --- | --- | --- |
| 한 번에 알림·일정까지 넣음 | 어디가 틀렸는지 찾기 어려움 | 목적 하나로, 나머지는 "나중에" |
| LLM에게 우선순위·기본값까지 맡김 | 같은 회의록인데 결과가 매번 다름 | 찾기는 LLM, 채우기·계산은 규칙 |
| 항목 수를 안 적음 | 응답에 할 일이 하나만 | 1 → N → 1 표시 |
| 결과가 필요한 일을 병렬로 | 요약 전에 제목을 지으려 함 | "앞 결과가 필요한가?" |
---
## 9. 스스로 확인하기
<details><summary>Q1. If 노드를 지나면 항목 수는 어떻게 되나요?</summary>
그대로입니다. If는 항목을 true나 false 한쪽 길로 보낼 뿐 개수를 바꾸지 않습니다. 개수를 바꾸는 노드는 Split Out(1 → N), Aggregate(N → 1) 등입니다.
</details>
<details><summary>Q2. 할 일이 4건인데 `Parse actions` 바로 뒤의 항목 수가 1인 이유는?</summary>
4건이 `actions`라는 **목록 하나**에 담겨 상자 1개로 지나가기 때문입니다. Split Out을 지나야 상자 4개가 됩니다.
</details>
<details><summary>Q3. 우선순위를 LLM이 아니라 규칙으로 정하는 이유는?</summary>
"기한에 오늘·내일·긴급이 있으면 높음"은 규칙으로 항상 같게 정할 수 있습니다. LLM에 맡기면 결과가 흔들리고 시간·비용이 듭니다.
</details>
<details><summary>Q4. "요약을 만든 뒤 그 요약으로 제목 짓기"를 병렬로 나눌 수 없는 이유는?</summary>
제목 짓기가 요약 결과를 필요로 하기 때문입니다. 병렬은 서로의 결과가 필요 없을 때만 가능합니다.
</details>
<details><summary>Q5. 할 일 개수(`count`)를 `Normalize action`이 아니라 `Build report`에서 세는 이유는?</summary>
`Normalize action`은 할 일 하나마다 따로 실행되어 전체 개수를 모릅니다. 모두 모은(Aggregate) 뒤에야 셀 수 있습니다.
</details>
## 정리
- 처음 만드는 자동화는 목적 하나로 좁히고, 예시 입력의 기대 결과를 먼저 그립니다.
- LLM은 **찾기**, 규칙은 **채우기·계산·정리문**을 맡습니다. 결과 형식은 노드보다 먼저 약속합니다.
- 노드 사이를 지나가는 상자(항목)의 개수를 셉니다. 오늘 흐름은 **1 → 4 → 1**입니다.
- 결과가 필요 없는 일은 병렬, 항목마다 하는 일은 반복, 입력에 따라 다른 일은 조건 분기입니다.
- 요청이 바뀌면 "하나마다? 모은 뒤? 모든 길?"로 고칠 곳을 찾습니다.
+272
View File
@@ -0,0 +1,272 @@
# 2단계 · 데이터 파싱과 변환 노드
- 시간: 3~4교시 (3교시 끝에 Lab 02 예제 1, 4교시에 예제 2와 과제)
- 학습 목표
- LLM의 답은 JSON처럼 **보이는 글자**라는 것을 OUTPUT에서 구별할 수 있다.
- Code 노드로 글자를 목록으로 파싱하고, 빈 값을 채우고, 실패 이유를 남길 수 있다.
- 지시문과 JSON 스키마로 LLM에게 답의 모양을 알려 줄 수 있다.
- 뽑을 정보를 하나 늘릴 때 고칠 곳(알려 주기 · 받기 · 꺼내기)을 찾을 수 있다.
- Edit Fields와 표현식으로 간단한 변환을 할 수 있다.
- 실습: [Lab 02 · LLM 답을 데이터로 바꾸기 (예제 2개 + 과제)](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md)
```mermaid
flowchart LR
A["1. LLM 답은 글자<br/>(예제 1)"] --> B["2. 파싱<br/>(예제 1)"] --> C["3. 모양 알려 주기<br/>(예제 2)"] --> D["4. 하나 더 뽑기<br/>(과제)"] --> E["5. Edit Fields"] --> F["6. 만들고 확인하는 순서"]
```
---
## 1. LLM의 답은 "글자"다 · Lab 02 예제 1
LLM에게 "JSON으로 답해"라고 하면 이런 답이 옵니다.
```text
{"actions": [{"task": "신규 고객 문의 응답 템플릿 정리", "owner": "이준호", "due": "금요일"}, {"task": "회의실 예약 규칙 개선", "owner": "", "due": ""}]}
```
사람 눈에는 목록처럼 보이지만, n8n에게는 **따옴표로 둘러싸인 긴 글자 한 줄**입니다. 글자 상태로는 `actions`를 꺼낼 수도, 할 일 개수를 셀 수도 없습니다.
| 상태 | n8n OUTPUT에서 보이는 모습 | 할 수 있는 것 |
| --- | --- | --- |
| **글자** (문자열) | `content` 한 칸에 긴 글자 | 읽기만 |
| **데이터** (객체·목록) | Table 보기에서 `actions`가 줄줄이 펼쳐짐 | `$json.actions`, `count`, Split Out |
글자를 데이터로 바꾸는 일을 **파싱(parsing)** 이라고 합니다. 27일차 Structured Output에서 Pydantic으로 하던 일을 오늘은 Code 노드가 합니다.
예제 1에서는 LLM을 부르지 않고 위 글자를 `Fake LLM answer` 노드에 **직접 적어 넣고** 파싱부터 연습합니다. 돈도 시간도 들지 않고 매번 같은 결과가 나와서, 파싱만 따로 이해하기 좋습니다.
```mermaid
flowchart LR
T(["Start"]) --> F["Fake LLM answer<br/>content = 글자"] --> P["Parse actions<br/>글자 → 목록"]
```
---
## 2. 파싱: 글자를 목록으로 · Lab 02 예제 1
`Parse actions`의 코드는 네 단계입니다. 한 줄씩 외울 필요는 없고 **번호의 뜻**만 알면 됩니다.
```javascript
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 } }]; // 항상 같은 모양으로 돌려줌
```
| 번호 | 하는 일 | 예제 1에서 확인 |
| --- | --- | --- |
| ① | 앞 노드에서 글자를 꺼냄 (모델이 ```json 으로 감싸면 벗김) | `content` |
| ② | 글자 → 데이터. 실패하면 빈 목록 + `error` | 끝 `}`를 지우면 `error: answer is not valid JSON` |
| ③ | 할 일이 빈 칸인 것은 버림 | |
| ④ | 담당자·기한이 비면 `미정` | 두 번째 할 일의 `owner`·`due`가 `미정` |
예제 2에서는 ①만 바뀝니다. 가짜 답 대신 진짜 LLM 답이 있는 곳 `choices[0].message.content`를 읽습니다.
### 2.1 어떤 답이 와도 같은 모양으로
| LLM 답 | 그대로 `JSON.parse` | 오늘 코드의 결과 |
| --- | --- | --- |
| `{"actions": [...]}` | 성공 | 그대로 |
| ```` ```json {"actions": [...]} ``` ```` | 실패 | ①에서 감싼 표시를 벗기고 성공 |
| `[{"task": ...}]` (목록만) | 성공 | 목록 그대로 사용 |
| `할 일은 다음과 같습니다 ...` (문장) | 실패 | `{actions: [], count: 0, error: ...}` |
| `{"actions": [{"task": "", ...}]}` | 성공 | 빈 할 일 버림 |
| `{"owner": null}` | 성공 | `owner: "미정"` |
마지막 열이 모두 `{actions, count}` 모양입니다. 다음 노드는 어떤 답이 와도 같은 필드를 받습니다.
### 2.2 실패를 숨기지 않기
| 결과 | `count` | `error` | 뜻 |
| --- | --- | --- | --- |
| 할 일 없는 회의 (`no-action`) | 0 | 없음 | 정상 |
| LLM 답이 깨짐 | 0 | `answer is not valid JSON` | 문제 → 실행 기록 확인 |
`error` 한 칸이 "정말 할 일이 없는 회의"와 "답이 깨진 경우"를 구별합니다. 76일차(예외 대응)에서 이런 경우를 모아 알림을 보내는 Error Workflow를 만듭니다.
---
## 3. LLM에게 답의 모양 알려 주기 · Lab 02 예제 2
예제 2는 예제 1의 앞에 **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"]
```
`LLM extract actions` 노드의 JSON 칸은 세 부분입니다. (실습 파일은 읽기 쉽게 여러 줄로 적혀 있습니다.)
```text
{{ 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 ... Use \"미정\" when ..." }, ← ② 지시문: 무엇을 찾을지
{ role: 'user', content: $('Settings').first().json.notes }, ← ③ 회의록 원문
],
}) }}
```
| 부분 | 하는 일 | 비유 |
| --- | --- | --- |
| ① 스키마 (`response_format`) | "이 칸들로만 답해라" — 답의 **모양**을 정함 | 빈칸이 정해진 답안지 |
| ② 지시문 (`system`) | 무엇을 찾고, 없으면 무엇을 쓸지 | 문제 설명 |
| ③ 회의록 (`user`) | 읽을 글 | 지문 |
### 3.1 스키마는 모양만 지킨다
| | 지시문만 | 지시문 + 스키마 |
| --- | --- | --- |
| 모양 | 가끔 어긋남 (설명 문장, ```json) | 정한 모양으로만 |
| 필드 이름 | 가끔 `assignee`, `deadline` | 스키마 이름 그대로 |
| **내용이 맞나** | 보장 안 함 | **보장 안 함** |
스키마를 써도 담당자를 잘못 찾거나 할 일을 빠뜨리는 것은 막지 못합니다. 4번 할 일의 기한 `10월 23일`(회의록에 직접 적히지 않은 추론)이 그 예입니다. 그래서 스키마를 써도 2절의 파싱·검증은 그대로 둡니다. 그리고 답은 여전히 **글자**(`choices[0].message.content`)로 옵니다.
Anthropic·Gemini에도 비슷한 구조화 출력 기능이 있고, 대체용 Ollama도 같은 `response_format`을 받습니다.
> **표현식 주의**: `{{ }}` 안에서 `}`와 `}`가 **붙으면** 표현식이 거기서 끝나 `invalid syntax`가 납니다. 실습 파일에서 `} }`처럼 띄어 쓴 곳은 일부러 띄운 것입니다.
---
## 4. 뽑을 것을 하나 늘리려면 · Lab 02 과제
새 정보(예: 참석자)가 결과까지 오려면 데이터가 지나가는 길을 따라 **세 곳**이 모두 알아야 합니다.
```mermaid
flowchart LR
A["① 알려 주기<br/>스키마에 칸 추가"] --> B["② 부탁하기<br/>지시문에 한 문장"] --> L{{"LLM 답<br/>(글자)"}} --> C["③ 꺼내기<br/>파싱 코드의 return"] --> R["결과"]
```
| 곳 | 빠뜨리면 |
| --- | --- |
| ① 스키마 | LLM이 그 칸을 만들지 않을 수 있음 |
| ② 지시문 | 무엇을 넣을지 몰라 비우거나 엉뚱한 값 |
| ③ 파싱 코드 | LLM 답에는 있어도 **새 상자를 만들며 버려짐** → 결과에 없음 |
③이 가장 잘 빠집니다. 파싱 코드는 `return [{ json: { actions, count } }]`처럼 **필요한 필드만 골라 새 상자를 만들기** 때문입니다. 어디서 사라졌는지 모를 때는 노드를 하나씩 열어 OUTPUT에 그 필드가 있는지 봅니다.
뽑을 것을 늘린 뒤에는 **원래 기대 결과(할 일 4건)도 다시** 확인합니다. 요청이 바뀌면 원래 뽑던 결과도 흔들릴 수 있습니다.
---
## 5. 변환: Edit Fields와 표현식
코드 없이 필드를 만들거나 값을 계산할 때는 Edit Fields(Set)를 씁니다. 예제 2의 `Settings`가 그 예입니다.
| 하고 싶은 일 | 설정 예 |
| --- | --- |
| 입력값 꺼내기 | `notes` = `{{ $json.body.notes }}` (웹훅 입력은 `body` 안에) |
| 비었으면 기본값 | `title` = `{{ $json.body.title \|\| '제목 없음' }}` |
| 공백 다듬기 | `summary` = `{{ $json.choices[0].message.content.trim() }}` |
| 조건으로 값 정하기 | `priority` = `{{ ['오늘', '내일', '긴급'].some(w => $json.due.includes(w)) ? '높음' : '보통' }}` |
| 빈 목록 | `actions` (Array) = `{{ [] }}` |
- `{{ }}`가 들어간 값은 입력칸이 **Expression** 모드(초록색)여야 합니다. Fixed 모드면 글자 그대로 들어갑니다.
- 필드 형식(String·Number·Array·Boolean)을 맞춥니다. 예를 들어 `count`가 글자 `"0"`이면 숫자 비교가 원하는 대로 되지 않을 수 있습니다.
| Edit Fields | Code |
| --- | --- |
| 필드 몇 개를 만들거나 바꿀 때 | 파싱, 여러 줄 검증, 실패 처리 (`try / catch`) |
| 누구나 읽기 쉬움 | 자유롭지만 실수하기 쉬움 |
---
## 6. 만들고 확인하는 순서
```mermaid
flowchart LR
A["노드 하나 추가"] --> B["이름 짓기<br/>(표현식이 이름을 씀)"] --> C["설정"] --> D["실행"] --> E["OUTPUT 확인"]
E -->|"다음 노드"| A
```
- **한 번에 다 만들지 않습니다.** 노드 하나를 붙일 때마다 실행해 OUTPUT을 봅니다.
- 노드 이름을 안내와 똑같이 짓습니다. `$('Settings')`처럼 표현식이 이름을 씁니다.
- 같은 요청 파일(`weekly`·`no-action`·`empty`)로 매번 확인해야 고친 것의 효과를 비교할 수 있습니다.
- Test URL은 **한 번에 한 요청**만 받습니다. 요청마다 `Listen for test event`를 다시 누릅니다.
---
## 7. 막혔을 때
| 증상 | 원인 | 해결 |
| --- | --- | --- |
| `invalid syntax` | 표현식 안에서 `}}`가 붙음 | `} }`로 띄어 쓰기 |
| `401` | Credential이 `OpenAI (.env)`가 아님 | Authentication 확인 |
| 값에 `{{ $json.body.notes }}` 글자가 그대로 | Fixed 모드 | Expression 모드 |
| `actions`가 비고 `error`가 있음 | LLM 답이 JSON이 아님 | 앞 노드 OUTPUT의 `content` 확인 |
| 새 필드가 결과에 없음 | 파싱 코드의 `return`에 안 넣음 | 4절 ③ |
| 고치다 망가짐 | | 예제 2 파일을 다시 가져오기 |
---
## 8. Lab 02 안내
- 실습 안내: [Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md)
| 순서 | 시간 | 할 일 | 교안 |
| --- | --- | --- | --- |
| 예제 1 | 3교시 끝 15분 (함께) | `Fake LLM answer`(글자) → `Parse actions`(목록), 끝 `}`를 지워 `error` 보기 | 1~2절 |
| 예제 2 | 4교시 15분 | `lab02-example2-meeting-part1.json` 가져오기 → `weekly`·`empty`·`no-action`, JSON 칸의 ①②③ 찾기 | 3절 |
| 과제 | 4교시 25분 | 결과에 **참석자(`attendees`)** 추가 | 4절 |
---
## 9. 스스로 확인하기
<details><summary>Q1. `Fake LLM answer`의 `content`와 `Parse actions`의 `actions`는 무엇이 다른가요?</summary>
`content`는 글자 한 줄이고, `actions`는 파싱해서 만든 목록입니다. 목록이어야 개수를 세고 Split Out으로 펼칠 수 있습니다.
</details>
<details><summary>Q2. 스키마(`response_format`)를 지정했는데도 파싱·검증이 필요한 이유는?</summary>
스키마는 모양만 지켜 줍니다. 답은 여전히 글자로 오므로 파싱이 필요하고, 빈 값이나 잘못 찾은 내용은 검증으로 걸러야 합니다.
</details>
<details><summary>Q3. 파싱에 실패하면 빈 목록을 돌려주면서도 `error`를 남기는 이유는?</summary>
흐름이 멈추지 않게 하면서도, "정말 할 일이 없는 회의"와 "LLM 답이 깨진 경우"를 구별하기 위해서입니다.
</details>
<details><summary>Q4. 스키마와 지시문에 `attendees`를 넣었는데 결과에 없습니다. 어디를 보나요?</summary>
`LLM extract actions`의 OUTPUT에는 있는데 `Parse actions`의 OUTPUT에 없다면, 파싱 코드의 `return`이 `attendees`를 넣지 않은 것입니다. 노드를 앞에서부터 하나씩 열어 어디서 사라졌는지 찾습니다.
</details>
<details><summary>Q5. 기한을 스키마에서 날짜 형식이 아니라 글자(`string`)로 둔 이유는?</summary>
회의록에는 "금요일", "다음 주 월요일"처럼 날짜가 아닌 표현이 많습니다. 날짜로 바꾸려면 회의 날짜를 아는 계산이 필요하므로, 필요하면 규칙으로 따로 합니다.
</details>
## 정리
- LLM의 JSON 답은 **글자**입니다. Code 노드로 파싱해야 목록이 됩니다.
- 파싱은 꺼내기 → 바꾸기(실패하면 `error`) → 빈 할 일 버리기 → `미정` 채우기, 그리고 항상 같은 모양으로 돌려줍니다.
- 스키마는 답의 **모양**, 지시문은 **무엇을 찾을지**를 알려 줍니다. 스키마도 내용은 보장하지 않습니다.
- 뽑을 것을 늘리면 **알려 주기(스키마) · 부탁하기(지시문) · 꺼내기(파싱)** 세 곳을 고칩니다.
- 간단한 변환은 Edit Fields, 파싱·검증은 Code. 노드 하나마다 실행해 OUTPUT을 확인합니다.
@@ -0,0 +1,244 @@
# 3단계 · 반복·병렬 제어 구조와 완성
- 시간: 5~6교시 (5교시 후반에 Lab 03 예제 1·2, 6교시에 과제 40분), 4절 밖에서 부르기는 8교시
- 학습 목표
- 펼치기(Split Out) → 항목마다 처리 → 모으기(Aggregate)로 반복을 만들 수 있다.
- 두 길로 나눈 일을 Merge로 합치는 Fork-Join을 만들 수 있다.
- 연습용 가짜 데이터 자리에 진짜 LLM 결과를 넣어 워크플로우를 완성할 수 있다.
- 기대 결과 표와 실행 기록의 항목 수로 결과를 검증할 수 있다.
- 실습: [Lab 03 · 반복·병렬로 회의록 워크플로우 완성하기 (예제 2개 + 40분 과제)](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md)
```mermaid
flowchart LR
A["1. 반복<br/>(예제 1)"] --> B["2. 병렬<br/>(예제 2)"] --> C["3. 가짜를 진짜로<br/>(과제)"] --> D["4. 기대 결과로 검증<br/>(과제)"] --> E["5. 밖에서 부르기<br/>(8교시)"]
```
---
## 1. 반복: 펼치고 · 항목마다 · 모으기 · Lab 03 예제 1
n8n의 노드는 **들어온 상자(항목)마다 한 번씩** 실행됩니다. 그래서 목록을 상자 여러 개로 펼치기만 하면 반복이 됩니다. 별도의 "for 문" 노드가 필요 없습니다.
예제 1은 할 일 3건이 든 가짜 목록으로 연습합니다.
```mermaid
flowchart LR
S["Sample actions<br/>상자 1개<br/>actions [3]"] -->|"1"| SO["One item per action<br/>Split Out"]
SO -->|"3"| N["Normalize action<br/>Edit Fields<br/>(3번 실행)"]
N -->|"3"| AG["Collect actions<br/>Aggregate"]
AG -->|"1"| OUT["상자 1개<br/>actions [3] + priority"]
```
| 노드 | 설정 | 하는 일 |
| --- | --- | --- |
| Split Out | Fields To Split Out: `actions` | 목록 → 할 일 하나씩 상자로 (**1 → 3**) |
| Edit Fields | `task`, `owner`, `due`, `priority` | 상자마다 실행 (3번) |
| Aggregate | All Item Data, 결과 필드 `actions` | 상자들 → 다시 목록 하나 (**3 → 1**) |
**펼쳤으면 반드시 모은다.** 이것이 반복의 짝입니다. 오후 과제에서는 진짜 회의록이라 같은 모양이 **1 → 4 → 1**이 됩니다.
### 1.1 모으지 않으면? (5교시 시연, 수업 전 실측)
| 틀린 곳 | `weekly` 결과 |
| --- | --- |
| `Normalize action` → 바로 응답 | HTTP 200인데 **첫 할 일 하나만** — 응답 노드는 첫 상자로 한 번만 답함 |
| Aggregate 없이 `Build report` | HTTP 500 `Error in workflow` — 목록을 기대했는데 할 일 하나짜리 상자가 옴 |
첫 번째가 더 위험합니다. 오류 없이 **조용히 틀린 답**이 나갑니다. 그래서 실행 기록에서 노드마다 **항목 수**를 읽는 습관을 들입니다.
### 1.2 상자마다 실행될 때 `$json`이 가리키는 것
| 위치 | `$json` | 다른 값이 필요하면 |
| --- | --- | --- |
| Split Out 뒤 (상자마다) | 할 일 **하나** (`task`, `owner`, `due`) | 회의 제목: `$('Settings').first().json.title` |
| Aggregate 뒤 (한 번) | 모은 결과 `{actions: [...]}` | 요약: `$('Join results').first().json.summary` |
`$('노드 이름')`은 "그 노드의 OUTPUT을 가져와"라는 뜻입니다. 노드 이름을 안내와 똑같이 지어야 하는 이유입니다.
### 1.3 Loop Over Items는 언제?
| 상황 | 선택 |
| --- | --- |
| 상자마다 같은 변환만 (오늘) | Split Out + 일반 노드 |
| 상자마다 외부 API·LLM을 부르는데 한꺼번에 보내면 호출 제한에 걸림 | **Loop Over Items**로 몇 개씩 나눠 차례로 |
반복 안에 LLM을 두면 **시간과 비용이 항목 수에 비례**합니다 (75일차 비용 관리).
---
## 2. 병렬: 나누고 · 합치기 · Lab 03 예제 2
할 일 찾기와 요약은 서로의 결과가 필요 없습니다. 한 노드에서 **두 길로 나누고(Fork)**, 둘 다 끝나면 **합칩니다(Join)**. 합치는 노드가 Merge입니다.
예제 2는 예제 1에 가짜 요약 길을 하나 더 붙입니다.
```mermaid
flowchart LR
T(["Start"]) --> S["Sample actions → … → Collect actions<br/>{actions}"] -->|"Input 1"| J["Join results<br/>Merge · Combine · Position"]
T --> SS["Sample summary<br/>{summary}"] -->|"Input 2"| J
J --> O["상자 1개<br/>{actions, summary}"]
```
| Merge 설정 | 뜻 |
| --- | --- |
| Mode `Combine` | 두 입력의 상자를 하나로 합침 |
| Combine By `Position` | 1번 입력의 첫 상자 + 2번 입력의 첫 상자 → 상자 하나 |
- Merge는 **두 입력이 모두 올 때까지** 기다립니다.
- n8n은 두 길을 동시에가 아니라 **차례로** 실행합니다. 그래도 그림에서 두 일이 서로 독립이라는 것이 드러나고, 한쪽을 고쳐도 다른 쪽에 영향이 없습니다.
### 2.1 Merge의 다른 모드
| 모드 | 하는 일 | 쓰는 때 |
| --- | --- | --- |
| Append | 두 입력을 이어 붙임 (2 + 3 = 5개) | 같은 모양 목록 두 개 |
| **Combine · By Position** | 같은 순서끼리 합침 (1 + 1 = 1개) | **오늘** |
| Combine · By Matching Fields | 같은 값(`customer_id` 등)끼리 짝 | 주문 목록 + 고객 정보 |
| Choose Branch | 한쪽만 내보냄 | 한쪽 결과만 필요할 때 |
### 2.2 한 길이 실패하면
요약 길의 LLM 호출이 실패하면, 할 일 찾기가 성공해도 Merge가 기다리다 **결과가 나오지 않습니다.** 길이 늘수록 실패할 곳도 늘어납니다. 대처는 72일차(재시도)와 76일차(Error Workflow)에서 배웁니다.
---
## 3. 가짜를 진짜로 바꾸기 · Lab 03 과제
예제 1·2는 가짜 데이터였습니다. 과제는 그 자리에 Lab 02의 **진짜 LLM 결과**를 넣어 `66 Meeting notes (my)`를 완성합니다.
| 예제의 가짜 | 과제의 진짜 | 하는 일 |
| --- | --- | --- |
| `Sample actions` | `LLM extract actions` → `Parse actions` (Lab 02) | 할 일 목록 |
| `Sample summary` | `LLM summarize` → `Keep summary` (새로) | 요약 |
| `Join results` | 그대로 복사 | 두 결과 합치기 |
| Split Out → Normalize → Aggregate | 그대로 복사 | 할 일마다 우선순위, 다시 모으기 |
| (없음) | `Any actions?`, `Build report`, `No actions` (새로) | 할 일이 있을 때 / 없을 때 |
```mermaid
flowchart LR
H{"Has notes?"} -->|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["Split Out → Normalize → Aggregate"] --> B["Build report"] --> R["Send result"]
A -->|false| NA["No actions"] --> R
classDef new stroke-width:3px
class L2,K,J,A,SO,B,NA new
```
### 3.1 순서에 담긴 이유
| 질문 | 답 |
| --- | --- |
| 왜 Merge를 `Any actions?` **앞**에 두나? | 할 일이 없는 회의에도 **요약**은 있어야 하니까. 합친 뒤에 나누면 두 길 모두 `summary`를 가짐 |
| 왜 `Any actions?`가 Split Out **앞**에 있나? | 할 일이 0건이면 펼칠 것도, 정리문에 쓸 것도 없음 → 펼치기 전에 길을 나눠 `할 일 없음` 결과를 따로 만듦 |
| 정리문(`report_text`)은 왜 코드로? | 매번 같은 모양이어야 메신저·메일에 붙이기 좋고, 시간·비용이 들지 않음 |
### 3.2 두 길을 같은 필드로 끝내기
| 필드 | 할 일 정리 (`Build report`) | 할 일 없음 (`No actions`) |
| --- | --- | --- |
| `title` | 회의 제목 | 회의 제목 |
| `route` | `할 일 정리` | `할 일 없음` |
| `count` | 4 | 0 |
| `summary` | 요약 | 요약 |
| `actions` | 할 일 4개 (+ `priority`) | `[]` |
| (과제) `need_check` | 확인 필요 건수 | 0 |
과제의 마지막 단계 **확인 필요**는 Lab 01 과제에서 설계한 것입니다. 1단계 6절의 질문 "하나마다? 모은 뒤? 모든 길?"을 다시 꺼내 봅니다.
---
## 4. 기대 결과로 검증하기 · Lab 03 과제
"오류가 안 났다"는 "맞았다"가 아닙니다. 두 가지로 확인합니다.
**① 기대 결과 표와 비교** (수업 전 측정)
| 요청 | 기대 결과 | 처리 시간 |
| --- | --- | --- |
| `weekly` (할 일 4건) | `할 일 정리`, `count: 4`, 오늘 마감 1건 `높음`, `need_check: 1` | 약 3~4초 |
| `no-action` (결정 사항 없음) | `할 일 없음`, `count: 0`, `need_check: 0` | 약 2초 |
| `empty` (빈 회의록) | HTTP 400 | 1초 미만 |
**② 실행 기록의 항목 수 읽기**: `Join results` 1 → `One item per action` 4 → `Normalize action` 4 → `Collect actions` 1 → `Build report` 1
수업 준비 때 실제로 본 **오류 없이 틀리는** 모양입니다.
| 증상 | 원인 |
| --- | --- |
| 할 일 하나만 옴 | Aggregate를 거치지 않음 (1.1절) |
| `no-action`에서 HTTP 200인데 빈 응답 | `Any actions?`의 false 길이 끊김 |
| `need_check: 4` | `check`의 형식이 Boolean이 아니라 String → 글자 `"false"`도 참으로 셈 |
---
## 5. 밖에서 부르기: 설정 한 줄로 대상 바꾸기 · 8교시
65일차의 플랫폼 전환 실행기로 오늘의 업무 `meeting`도 부를 수 있습니다.
```mermaid
flowchart LR
APP["call('meeting', {title, notes})"] --> CFG["platforms.json<br/>meeting → n8n webhook · miniflow flow"]
CFG --> N8["n8n<br/>/webhook/my-meeting-actions"]
CFG --> MF["miniflow<br/>flows/08-meeting-actions.json"]
```
- 내가 만든 워크플로우를 부르려면 `platforms.json`의 웹훅 경로 **한 줄만** `my-meeting-actions`로 바꿉니다. 호출 코드는 그대로입니다.
- n8n이 멈추면 같은 업무를 `miniflow`의 `08` 워크플로우가 이어받습니다. 이것이 되려면 두 플랫폼이 **같은 입력(`title`, `notes`)과 같은 결과 필드**를 지켜야 합니다.
| 하는 일 | n8n (오늘 만든 것) | miniflow `08-meeting-actions.json` |
| --- | --- | --- |
| 입력 | Webhook `body.title`, `body.notes` | `-i title=... -i notes=...` |
| 할 일 찾기 | HTTP Request → OpenAI (스키마) | LLM 노드 |
| 파싱·검증 | Code `Parse actions` | `parse_actions` Tool |
| 결과 | `{title, route, count, summary, actions, ...}` | 같은 필드 |
---
## 6. Lab 03 안내
- 실습 안내: [Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md)
| 순서 | 시간 | 할 일 | 교안 |
| --- | --- | --- | --- |
| 예제 1 | 5교시 끝 12분 | 가짜 할 일 3건 펼치고 모으기, 항목 수 1 → 3 → 3 → 1 | 1절 |
| 예제 2 | 5교시 끝 10분 | `Sample summary`를 Merge(Position)로 합치기 | 2절 |
| **과제** | 6교시 40분 | 예제 노드를 복사해 내 워크플로우 완성 + **확인 필요** | 3~4절 |
| 마무리 | 8교시 함께 | `platforms.json` 한 줄 바꾸고 `miniflow call meeting` | 5절 |
---
## 7. 스스로 확인하기
<details><summary>Q1. `One item per action`이 4개, `Collect actions`가 1개입니다. 그 사이의 `Normalize action`은 몇 번 실행되었나요?</summary>
4번입니다. 대부분의 n8n 노드는 들어온 항목마다 한 번씩 실행됩니다.
</details>
<details><summary>Q2. `Build report`에서 회의 제목을 `$json.title`이 아니라 `$('Settings').first().json.title`로 꺼내는 이유는?</summary>
`Build report` 바로 앞은 Aggregate이고, 그 결과에는 `actions`만 있습니다. 제목은 앞쪽 `Settings` 노드에 있으므로 이름으로 꺼냅니다.
</details>
<details><summary>Q3. Merge를 `Any actions?` 앞에 두는 이유는?</summary>
할 일이 없는 회의에도 요약이 필요하기 때문입니다. 먼저 합치면 할 일 정리 길과 할 일 없음 길 모두 `summary`를 가집니다.
</details>
<details><summary>Q4. 주문 목록과 고객 정보를 `customer_id`로 합치려면 Merge를 어떻게 설정하나요?</summary>
Combine · By Matching Fields로 `customer_id`를 기준으로 합칩니다. By Position은 순서가 같을 때만 맞습니다.
</details>
<details><summary>Q5. 요약 길의 LLM 호출이 실패하면 할 일 찾기가 성공해도 응답이 나오지 않는 이유는?</summary>
Merge는 두 입력이 모두 와야 합치기 때문입니다. 병렬 길이 늘면 실패할 곳도 늘어납니다.
</details>
## 정리
- 반복은 **펼치고(Split Out) · 항목마다 · 모으기(Aggregate)**. 펼쳤으면 반드시 모읍니다.
- 병렬은 **나누고 · Merge로 합치기**. Merge는 두 입력을 모두 기다립니다.
- 예제의 가짜 데이터 자리에 진짜 LLM 결과를 넣으면 워크플로우가 완성됩니다.
- 모든 길이 같은 필드로 끝나게 하고, **기대 결과 표와 항목 수**로 검증합니다. 오류가 없어도 틀릴 수 있습니다.
- 같은 입력·같은 결과 형식을 지키면 설정 한 줄로 호출 대상을 바꾸고, 플랫폼 전환도 그대로 됩니다.
+40
View File
@@ -0,0 +1,40 @@
# 66일차 · 단일 업무 자동화 워크플로우 구현
- 일자: 2026-10-16 (66일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: AI 워크플로우 설계 및 구현
- 학습 내용: 노코드 Workflow 도구 활용, 단일 업무 자동화 Workflow 구현
- 실습 폴더: [Last3Mon/66](../../Last3Mon/66/README.md)
- 실습 환경: 65일차 n8n 2.42.6 + OpenAI API(`gpt-4.1-mini`, Credential `OpenAI (.env)`) 그대로 사용, Ollama는 대체용
- **실습 준비: [00-DAY66-PREP.md](00-DAY66-PREP.md) (수업 전날까지 1~2절 완료)**
## 오늘의 시간표
| 교시 | 시간 | 내용 | 자료 |
| --- | --- | --- | --- |
| 1 | 09:00~09:50 | 오늘의 개요 · 1단계 시작 | [00-DAY66-OVERVIEW.md](00-DAY66-OVERVIEW.md), [1단계](01-STAGE1-DESIGN-AND-DATA-FLOW.md) |
| 2 | 10:00~10:50 | 1단계 · 단일 업무 설계와 데이터 흐름, Lab 01 | [1단계](01-STAGE1-DESIGN-AND-DATA-FLOW.md), [Lab 01](../../Last3Mon/66/LAB-01-DESIGN-DATA-FLOW.md) |
| 3 | 11:00~11:50 | 2단계 · LLM JSON 출력, 파싱과 변환, Lab 02 예제 1 | [2단계](02-STAGE2-PARSE-AND-TRANSFORM.md), [Lab 02](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md) |
| - | 12:00~13:00 | 점심 | |
| 4 | 13:00~13:50 | 2단계 · Lab 02 예제 2와 과제 (참석자 뽑기) | [Lab 02](../../Last3Mon/66/LAB-02-BUILD-PARSE-TRANSFORM.md) |
| 5 | 14:00~14:50 | 3단계 · 반복·병렬 제어 구조와 완성, Lab 03 예제 1·2 | [3단계](03-STAGE3-LOOP-PARALLEL-AND-FINISH.md), [Lab 03](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md) |
| 6 | 15:00~15:50 | 3단계 · Lab 03 과제 40분 (내 워크플로우 완성 + 확인 필요) · 풀이 | [Lab 03](../../Last3Mon/66/LAB-03-BUILD-LOOP-PARALLEL.md) |
| 7 | 16:00~16:50 | 퀴즈 (A·B·C 중 선택, 20분) · 풀이 | [퀴즈와 정리](04-QUIZ-AND-WRAPUP.md) |
| 8 | 17:00~17:50 | 오늘의 정리 · Python에서 부르기(함께) · 다음 시간 예고 · 복습 과제 | [퀴즈와 정리](04-QUIZ-AND-WRAPUP.md) |
```mermaid
flowchart LR
P["개요"] --> S1["1단계<br/>설계·데이터 흐름"] --> S2["2단계<br/>파싱·변환"] --> S3["3단계<br/>반복·병렬·완성"] --> Q["퀴즈·정리"]
```
## 자료 목록
| 파일 | 내용 |
| --- | --- |
| `00-DAY66-PREP.md` | 실습 준비: 어제 환경 켜기, JSON 객체·목록, 새 노드 미리 보기 (수업 전) |
| `00-DAY66-OVERVIEW.md` | 개요 15장 (PPT 대신 Markdown, 가로줄로 장 구분). 1교시에 앞부분, 각 단계 설명 때 해당 장을 함께 띄움 |
| `01-STAGE1-DESIGN-AND-DATA-FLOW.md` | 목적 하나로 좁히기, 결과 먼저, 항목(상자) 읽기 1 → 4 → 1, 제어 구조, 요청이 바뀌면 (Lab 01 예제·과제 순서) |
| `02-STAGE2-PARSE-AND-TRANSFORM.md` | LLM 답은 글자, 파싱, 답의 모양 알려 주기(스키마), 하나 더 뽑기, Edit Fields (Lab 02 예제·과제 순서) |
| `03-STAGE3-LOOP-PARALLEL-AND-FINISH.md` | 펼치고·모으기, Merge로 합치기, 가짜를 진짜로, 기대 결과로 검증, 밖에서 부르기 (Lab 03 예제·과제 순서) |
| `04-QUIZ-AND-WRAPUP.md` | 퀴즈 안내, 오늘의 정리, 자주 막히는 부분, 67일차 예고 |
| `quiz/` | 퀴즈 문항(A·B·C), Google Form 생성 스크립트, 응답 분석 엑셀 |