Initial commit

This commit is contained in:
mcchae committed 2026-10-12 09:55:49 +09:00
commit 5eedbdd039
30 files changed
+2827

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
# Lab 01 · 첫 번째 워크플로우 실행과 실행 기록 읽기
- 관련 강의: [1단계 · AI 워크플로우의 개념과 전체 아키텍처](../../Lecture/64/01-STAGE1-AI-WORKFLOW-CONCEPT.md)
- 소요 시간: 약 35분
- 목표: 노드·연결·페이로드·실행 기록을 직접 눈으로 확인한다.
## 1. 설치 확인 (5분)
[사전 준비 안내](SETUP-WSL-OLLAMA.md)를 마친 상태에서 WSL 터미널에서 실행합니다.
```bash
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
uv run pytest -q # 12 passed, 1 skipped
uv run miniflow check # Reply (ollama, ...) 가 나오면 준비 완료
```
## 2. 가장 단순한 워크플로우 실행 (10분)
`flows/01-llm-only.json`을 열어 구조를 확인합니다.
```mermaid
flowchart LR
start[/"start<br/>input"/] --> summarize{{"summarize<br/>llm"}} --> result[\"result<br/>output"\]
```
```bash
uv run miniflow run flows/01-llm-only.json -i text="다음 주 화요일 10시에 기획 회의를 진행합니다. 각 팀은 진행 현황을 준비해 주세요."
```
출력 예시:
```text
Workflow: 01 LLM only
------------------------------------------------------------------------
[1/3] start input ok 0.0ms {"text": "다음 주 화요일 ..."}
[2/3] summarize llm ok 7071.5ms "다음 주 화요일 10시에 기획 회의가 진행됩니다. 각 팀은 ..."
[3/3] result output ok 0.1ms "다음 주 화요일 10시에 기획 회의가 진행됩니다. 각 팀은 ..."
------------------------------------------------------------------------
Status: completed
Trace saved: runs/20261014-101500-01-llm-only.json
```
노트북의 Ollama 모델(`gemma3:4b`)이 요약을 만들었습니다. 요약 문장과 소요 시간은 실행할 때마다, 노트북마다 다릅니다.
같은 명령을 **Mock LLM**으로도 실행해 비교해 봅니다. Mock은 노드에 적어 둔 정해진 답을 돌려줍니다.
```bash
MINIFLOW_LLM=mock uv run miniflow run flows/01-llm-only.json -i text="다음 주 화요일 10시에 기획 회의를 진행합니다."
```
## 3. 실행 기록 읽기 (10분)
`runs/` 폴더에 생긴 JSON 파일을 열고 다음 질문에 답해 보세요.
1. `summarize` 노드의 `prompt` 필드에는 무엇이 들어 있나요? `{{input.text}}`는 어떻게 바뀌었나요?
2. `provider` 값은 무엇인가요? Mock으로 실행한 기록과 비교하면 무엇이 다른가요?
3. 각 노드의 `ms` 값을 보면 어느 노드가 가장 오래 걸렸나요? Mock으로 실행했을 때와 비교하면 얼마나 차이 나나요?
```bash
uv run miniflow show flows/01-llm-only.json
```
위 명령은 워크플로우를 Mermaid 문법으로 출력합니다. 출력을 [Mermaid Live Editor](https://mermaid.live)에 붙여 넣으면 그림으로 볼 수 있습니다.
## 4. 지난 프로젝트를 노드로 다시 그리기 (10분)
지난 실습 프로젝트 하나를 골라 **노드와 연결**로 다시 그립니다. 아래 예시를 참고하세요.
`mini_agent_03_mcp` (8월)를 워크플로우로 그리면:
```mermaid
flowchart LR
i[/"사용자 질문"/] --> l1{{"LLM<br/>Tool 선택"}}
l1 --> t1["Tool<br/>Travel MCP"]
l1 --> t2["Tool<br/>Policy MCP"]
t1 --> l2{{"LLM<br/>최종 답변"}}
t2 --> l2
l2 --> o[\"답변"\]
```
다음 중 하나를 골라 `Last3Mon/64/my-work/LAB-01-MY-DIAGRAM.md` 파일에 Mermaid로 그려 보세요.
- `mini_agent_04_rag`: 질문 → 검색 → 답변 → 캐시
- `mini_agent_05_memory`: 질문 → 기억 조회 → 답변 → 기억 저장
- `mini_multi_agent_04_distributed_collaboration`: 계획 → 병렬 Agent 3개 → Join → 일정
그린 뒤, 각 노드가 LLM·Tool·Memory·데이터 처리 중 무엇인지 표시합니다.
## 확인
- [ ] `pytest`가 모두 통과했다.
- [ ] `01-llm-only.json`을 실행하고 실행 기록을 열어 보았다.
- [ ] 지난 프로젝트 하나를 노드와 연결로 그렸다.
+120
View File
@@ -0,0 +1,120 @@
# Lab 02 · LLM·Tool·Memory 연결하기
- 관련 강의: [2단계 · LLM·Tool·Memory 노드와 데이터 흐름](../../Lecture/64/02-STAGE2-LLM-TOOL-MEMORY.md)
- 소요 시간: 약 45분
- 목표: Tool 결과와 Memory가 LLM 노드로 흘러가는 과정을 실행 기록으로 확인하고, 워크플로우를 직접 수정한다.
## 1. Tool → LLM: 근거 제공 패턴 (10분)
```bash
uv run miniflow tools
uv run miniflow run flows/02-llm-tool.json -i city=제주 -i question="우산이 필요할까?"
```
```text
[2/4] weather tool ok 0.1ms {"city": "제주", "condition": "비", "temp_c": 20, "rain_chance": 80, ...}
[3/4] answer llm ok 3783.8ms "네, 비가 오므로 우산이 필요할 것 같습니다."
```
확인할 것
1. 실행 기록에서 `answer` 노드의 `prompt`를 찾아, `{{nodes.weather.output}}`이 실제 데이터로 바뀐 것을 확인합니다.
2. `city=평양`으로 실행해 봅니다. 어느 노드에서 멈추나요? 그 뒤의 노드는 실행되었나요?
## 2. Memory: 실행과 실행 사이를 잇기 (10분)
```bash
uv run miniflow memory --clear
uv run miniflow run flows/03-save-profile.json -i user=minji -i preference="실내 전시"
uv run miniflow run flows/04-recommend-with-memory.json -i user=minji -i city=부산
uv run miniflow memory
```
```mermaid
flowchart LR
subgraph R1["실행 1 · 03-save-profile"]
a[/"minji, 실내 전시"/] --> b[("Memory 쓰기<br/>user:minji")]
end
subgraph R2["실행 2 · 04-recommend-with-memory"]
c[/"minji, 부산"/] --> d[("Memory 읽기")]
c --> e["날씨 Tool"]
d --> f{{"LLM 추천"}}
e --> f
f --> g[("Memory 쓰기<br/>last_city")]
end
b -. "다음 실행에서 읽음" .-> d
```
확인할 것
1. `04`를 `city=강릉`으로 한 번 더 실행합니다. `recall` 노드 출력에 `last_city`가 무엇으로 나오나요?
2. `user=jun`처럼 저장하지 않은 사용자로 실행하면 추천 문장이 어떻게 바뀌나요? 왜 그런가요?
## 3. 워크플로우 수정하기 (15분)
`flows/02-llm-tool.json`을 복사해 `flows/my-02-with-count.json`을 만들고 다음을 수정합니다.
1. `answer` 노드 뒤에 `count_chars` Tool 노드를 추가합니다.
- id: `length`
- args: `{"text": "{{nodes.answer.output}}"}`
2. `result` 노드의 값을 아래처럼 바꿔 두 결과를 함께 내보냅니다.
```json
{"answer": "{{nodes.answer.output}}", "length": "{{nodes.length.output}}"}
```
3. 연결(edges)을 `answer → length → result` 순서로 고칩니다.
```bash
uv run miniflow validate flows/my-02-with-count.json
uv run miniflow show flows/my-02-with-count.json
uv run miniflow run flows/my-02-with-count.json -i city=서울 -i question="산책하기 좋을까?"
```
`validate`가 오류를 내면 오류 메시지를 읽고 고칩니다. 대부분 노드 id 오타나 연결 누락입니다.
## 4. Mock과 실제 LLM 비교 (10분)
지금까지는 Ollama 모델이 답했습니다. 같은 워크플로우를 Mock과 번갈아 실행해 비교합니다.
```bash
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
MINIFLOW_LLM=mock uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="프린터에서 출력이 안 됩니다"
```
| 비교할 것 | Mock | Ollama |
| --- | --- | --- |
| `classify`·`draft` 노드의 `ms` | | |
| 두 번 실행한 답변이 같은가? | | |
| 분류 결과가 정해진 값(`device`)과 정확히 같은가? | | |
수업 전에 확인한 결과(CPU 1개, 메모리 8GB 환경)는 다음과 같았습니다. 내 노트북 결과와 비교해 보세요.
| 모델 | 분류 4건 정답 | 분류 노드 | 답변 초안 노드 |
| --- | --- | --- | --- |
| `gemma3:1b` | 0/4 (전부 `software`, 한 번은 `Software`) | 약 1초 | 4~7초 |
| `gemma3:4b` | 4/4 | 약 1.7초 | 7~11초 |
작은 모델은 빠르지만 판단이 틀리거나 대소문자처럼 형식이 흔들립니다. 다음 노드가 분류값을 비교해야 한다면, LLM 출력을 그대로 믿지 말고 **검증하는 단계**가 필요합니다. (73일차 입력·출력 검증)
(선택) OpenAI/Anthropic/Gemini 중 API 키가 있으면 클라우드 모델과도 비교할 수 있습니다. 가지고 있는 키 하나만 설정하면 됩니다.
| 제공사 | 설치 | 설정 |
| --- | --- | --- |
| OpenAI | `uv sync --extra openai` | `export MINIFLOW_LLM=openai`, `export OPENAI_API_KEY=...` |
| Anthropic | `uv sync --extra anthropic` | `export MINIFLOW_LLM=anthropic`, `export ANTHROPIC_API_KEY=...` |
| Gemini (10일차에 발급한 키) | `uv sync --extra gemini` | `export MINIFLOW_LLM=gemini`, `export GEMINI_API_KEY=...` |
## 도전 과제: 인자 추출 패턴 (LLM → Tool)
지금은 사용자가 `city=부산`처럼 도시를 직접 입력합니다. "다음 주에 부산 가는데 날씨 어때?" 같은 문장만 받아서 도시를 뽑아내도록 바꿔 보세요.
- `inputs`를 `["question"]`으로 바꾸고, 맨 앞에 도시 이름만 답하는 `llm` 노드(`extract_city`)를 추가합니다.
- 프롬프트에 "도시 이름만 답하세요. 예: 부산"처럼 출력 형식을 정해 줍니다. Mock으로도 확인하려면 `mock.rules`에 `{"if_contains": ["부산"], "then": "부산"}` 같은 규칙을 넣습니다.
- `weather` 노드의 args를 `{"city": "{{nodes.extract_city.output}}"}`로 바꿉니다.
## 확인
- [ ] Tool 결과가 LLM 프롬프트에 들어간 것을 실행 기록에서 찾았다.
- [ ] Memory가 두 번의 실행을 이어 주는 것을 확인했다.
- [ ] `my-02-with-count.json`을 만들어 `validate`와 `run`을 통과했다.
- [ ] Mock과 Ollama의 결과·속도 차이를 표로 정리했다.
+98
View File
@@ -0,0 +1,98 @@
# Lab 03 · 업무 자동화 시나리오 분석과 워크플로우 설계
- 관련 강의: [3단계 · 업무 자동화 시나리오 분석](../../Lecture/64/03-STAGE3-AUTOMATION-SCENARIO.md)
- 소요 시간: 약 45분 (개인 또는 2인 1조)
- 목표: 하나의 업무를 분석해 노드와 연결로 바꾸고, 실제로 실행해 검증한다.
- 제출물
- `my-work/LAB-03-SCENARIO-ANALYSIS.md` (분석 문서)
- `workflow-lab/flows/my-scenario.json` (워크플로우)
## 1. 예시 먼저 실행해 보기 (10분)
```bash
cd Last3Mon/64/workflow-lab
uv run miniflow memory --clear
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="비밀번호를 잊어버려서 로그인이 안 돼요"
uv run miniflow run flows/05-tech-support-ticket.json -i user=kim -i ticket="VPN 접속이 계속 끊겨요"
uv run miniflow run flows/05-tech-support-ticket.json -i user=lee -i ticket="점심 메뉴 추천해줘"
```
| 실행 | 확인할 것 |
| --- | --- |
| 1번째 | `category`와 `faq_id`가 무엇인가? |
| 2번째 | `history` 노드 출력에 1번째 문의가 남아 있는가? |
| 3번째 | 어느 노드에서 왜 멈추었는가? `draft` 노드는 무엇이라고 답했는가? 이 경우 실제 업무에서는 어떻게 처리해야 할까? |
3번째 실행에서는 FAQ 검색 결과가 없는데도 LLM이 그럴듯한 답변 초안을 만들고, 마지막 `result` 노드에서 멈춥니다. Mock으로 실행(`MINIFLOW_LLM=mock`)하면 `draft` 노드에서 바로 멈춥니다. **근거가 없을 때 실제 LLM이 어떻게 행동하는지** 실행 기록에서 확인해 보세요.
`uv run miniflow show flows/05-tech-support-ticket.json`으로 구조도 확인합니다.
## 2. 시나리오 분석 (15분)
1. 아래 시나리오 중 하나를 고릅니다. 직접 겪은 업무로 만들어도 됩니다.
| 번호 | 시나리오 |
| --- | --- |
| S1 | 회의록 요약 후 할 일 정리 |
| S2 | 쇼핑몰 상품 리뷰 분류와 답글 초안 |
| S3 | 신규 입사자 질문 응답 |
| S4 | 출장 전 날씨·준비물 안내 |
| S5 | IT 문의 처리 확장 (분류별 담당 부서 안내) |
2. 템플릿을 복사합니다.
```bash
mkdir -p Last3Mon/64/my-work
cp Last3Mon/64/templates/SCENARIO-ANALYSIS-TEMPLATE.md Last3Mon/64/my-work/LAB-03-SCENARIO-ANALYSIS.md
```
3. 모든 항목을 채우고 AS-IS·TO-BE를 Mermaid로 그립니다.
4. TO-BE의 각 단계를 LLM·Tool·Memory·데이터 처리·사람 확인 중 하나로 분류합니다.
## 3. 워크플로우로 옮기기 (15분)
TO-BE를 `flows/my-scenario.json`으로 작성합니다. 지금 사용할 수 있는 노드와 Tool로 표현할 수 있는 부분만 작성하고, 나머지(메일 발송, 사람 확인 등)는 분석 문서에 "향후 노드"로 적습니다.
시작용 뼈대:
```json
{
"name": "My scenario",
"description": "시나리오 설명",
"inputs": ["user", "text"],
"nodes": [
{"id": "start", "type": "input"},
{"id": "history", "type": "memory_read", "key": "my:{{input.user}}"},
{"id": "judge", "type": "llm",
"prompt": "다음 내용을 분류해 주세요: {{input.text}}",
"mock": {"rules": [{"if_contains": ["키워드"], "then": "분류값"}], "default": "other"}},
{"id": "save", "type": "memory_write", "key": "my:{{input.user}}",
"value": {"last_text": "{{input.text}}", "last_label": "{{nodes.judge.output}}"}},
{"id": "result", "type": "output", "value": {"label": "{{nodes.judge.output}}"}}
],
"edges": [["start", "history"], ["start", "judge"], ["history", "save"], ["judge", "save"], ["save", "result"]]
}
```
```bash
uv run miniflow validate flows/my-scenario.json
uv run miniflow show flows/my-scenario.json
uv run miniflow run flows/my-scenario.json -i user=me -i text="..."
```
Tip
- LLM 노드의 프롬프트에는 답의 형식(정해진 값, 문장 수 등)을 분명히 적습니다. Ollama 답이 매번 달라 흐름 확인이 어려우면 `mock`의 `rules`·`template`을 함께 적고 `MINIFLOW_LLM=mock`으로 구조부터 확인합니다.
- 사용할 수 있는 Tool은 `uv run miniflow tools`로 확인합니다 (`get_weather`, `search_faq`, `count_chars`).
- `show` 결과를 분석 문서의 "구현한 워크플로우" 항목에 붙여 넣습니다.
## 4. 검증 (5분)
정상 입력 2개와 문제가 생길 수 있는 입력 1개로 실행하고, 결과를 분석 문서의 "실행 결과" 표에 기록합니다.
## 확인
- [ ] 분석 템플릿의 모든 항목을 채웠다.
- [ ] AS-IS와 TO-BE를 Mermaid로 그렸다.
- [ ] `my-scenario.json`이 `validate`를 통과했다.
- [ ] 정상 입력과 문제 입력의 실행 결과를 비교했다.
+51
View File
@@ -0,0 +1,51 @@
# 64일차 실습 · AI 워크플로우 구조 이해와 업무 자동화 시나리오 분석
- 일자: 2026-10-14 (64일차)
- 교과목: 노코드 멀티 에이전트 워크플로우 설계 및 운영
- 단원: AI 워크플로우 개념 및 구성 요소 이해
- 강의 자료: [Lecture/64](../../Lecture/64/README.md)
## 구성
```text
Last3Mon/64/
├── README.md
├── SETUP-WSL-OLLAMA.md # 사전 준비 · WSL과 Ollama 로컬 LLM
├── LAB-01-FIRST-WORKFLOW.md # 1단계 · 첫 워크플로우 실행과 실행 기록 읽기
├── LAB-02-LLM-TOOL-MEMORY.md # 2단계 · LLM·Tool·Memory 연결하기
├── LAB-03-SCENARIO-ANALYSIS.md # 3단계 · 업무 자동화 시나리오 분석과 설계
├── templates/
│ └── SCENARIO-ANALYSIS-TEMPLATE.md
├── my-work/ # 여러분의 결과물 (직접 만듭니다)
└── workflow-lab/ # 미니 워크플로우 엔진 (uv 프로젝트)
├── flows/ # 예제 워크플로우 01~05
├── data/ # 날씨·FAQ 샘플 데이터
├── src/miniflow/ # 엔진 소스
└── tests/
```
## 실습 순서
```mermaid
flowchart LR
L1["Lab 01<br/>워크플로우 실행·실행 기록<br/>(1~2교시)"] --> L2["Lab 02<br/>LLM·Tool·Memory 연결<br/>(3~4교시)"] --> L3["Lab 03<br/>시나리오 분석·설계<br/>(5~6교시)"]
```
| 예제 워크플로우 | 패턴 | 사용하는 노드 |
| --- | --- | --- |
| `01-llm-only.json` | LLM 단독 | input → llm → output |
| `02-llm-tool.json` | 근거 제공 (Tool → LLM) | input → tool → llm → output |
| `03-save-profile.json` | 기억 저장 | input → memory_write → output |
| `04-recommend-with-memory.json` | 개인화 순환 (Memory → LLM → Memory) | memory_read·tool → llm → memory_write |
| `05-tech-support-ticket.json` | 업무 시나리오 (분류 + FAQ 검색 + 이력) | memory_read·llm·tool → llm → memory_write |
## 준비 (수업 전날)
실습은 Windows 11의 WSL(Ubuntu)에서, LLM은 노트북에서 실행하는 Ollama(`gemma3:4b`, 8GB 노트북은 `gemma3:1b`)로 진행합니다. API 키는 필요 없습니다.
먼저 **[실습 준비 안내](../../Lecture/64/00-DAY64-PREP.md)** 로 전체 준비 항목을 확인하고, 설치는 **[SETUP-WSL-OLLAMA.md](SETUP-WSL-OLLAMA.md)** 의 순서대로 진행한 뒤 마지막에 아래 두 명령이 통과하는지 확인합니다.
```bash
uv run pytest -q # 12 passed, 1 skipped
uv run miniflow check # Reply (ollama, ...)
```
+144
View File
@@ -0,0 +1,144 @@
# 사전 준비 · WSL에서 실습 환경과 Ollama 로컬 LLM 준비
64일차 실습은 Windows 11의 **WSL(Ubuntu)** 에서 진행하고, LLM은 노트북에서 직접 실행하는 **Ollama**를 사용합니다. API 키가 필요 없고 사용량 제한도 없습니다.
> 모델 파일이 1~3GB입니다. 수업 중에 21명이 동시에 받으면 강의실 네트워크가 느려지므로 **수업 전날 4단계(모델 다운로드)까지 끝내 주세요.**
```mermaid
flowchart LR
subgraph WIN["Windows 11"]
T["Windows Terminal"]
subgraph WSL["WSL2 · Ubuntu"]
M["miniflow<br/>(uv 프로젝트)"] -->|"http://localhost:11434"| O["Ollama 서버"]
O --> G[("gemma3:4b 모델")]
end
T --> WSL
end
```
## 1. WSL 확인 (5분)
PowerShell에서 확인합니다.
```powershell
wsl --list --verbose
```
`Ubuntu`가 `VERSION 2`로 보이면 됩니다. 없으면 `wsl --install -d Ubuntu`로 설치하고 노트북을 다시 시작합니다.
이후 명령은 모두 **WSL(Ubuntu) 터미널**에서 실행합니다.
## 2. 노트북 메모리에 맞는 모델 고르기
WSL은 기본으로 **Windows 전체 메모리의 절반**까지만 사용합니다.
| 노트북 메모리 | WSL이 쓸 수 있는 메모리 | 사용할 모델 |
| --- | --- | --- |
| 8GB | 약 4GB | `gemma3:1b` |
| 16GB 이상 | 약 8GB 이상 | `gemma3:4b` (권장) |
내 노트북 메모리는 PowerShell에서 확인합니다.
```powershell
(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB
```
메모리가 부족하다는 오류가 나면 `C:\Users\<사용자>\.wslconfig` 파일을 만들어 상한을 올린 뒤 PowerShell에서 `wsl --shutdown` 하고 WSL을 다시 엽니다.
```ini
[wsl2]
memory=10GB
swap=4GB
```
## 3. 실습 코드와 uv 준비 (10분)
```bash
# uv (이미 설치되어 있으면 건너뜀)
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.bashrc
# 실습 폴더는 WSL 홈(~) 아래에 둡니다. /mnt/c 아래는 느리고 권한 문제가 생깁니다.
cd ~
# 수업 저장소를 받는 방법은 강의 시간에 안내합니다.
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
uv sync
uv run pytest -q
```
`12 passed, 1 skipped`가 나오면 됩니다. (`skipped` 1개는 선택 기능인 Anthropic 연결 테스트로, 해당 패키지를 설치하지 않으면 건너뜁니다.)
## 4. Ollama 설치와 모델 다운로드 (10~20분)
```bash
curl -fsSL https://ollama.com/install.sh | sh
ollama --version
# 메모리 16GB 이상
ollama pull gemma3:4b
# 메모리 8GB
ollama pull gemma3:1b
ollama list
```
`ollama list`에 모델이 보이면 됩니다. 설치 스크립트가 Ollama 서버를 자동으로 실행합니다. `ollama list`에서 연결 오류가 나면 다른 터미널 창에서 `ollama serve`를 실행해 두세요.
## 5. miniflow와 Ollama 연결 (5분)
`~/.bashrc` 맨 아래에 추가합니다. 8GB 노트북은 모델 이름을 `gemma3:1b`로 바꿉니다.
```bash
cat >> ~/.bashrc <<'EOF'
export MINIFLOW_LLM=ollama
export OLLAMA_MODEL=gemma3:4b
EOF
source ~/.bashrc
```
연결을 확인합니다.
```bash
cd ~/<수업 저장소>/Last3Mon/64/workflow-lab
uv run miniflow check
```
```text
MINIFLOW_LLM : ollama
OLLAMA_HOST : http://localhost:11434
OLLAMA_MODEL : gemma3:4b
Reply (ollama, 18.8s): Ready
```
첫 호출은 모델을 메모리에 올리느라 10~20초 걸립니다. 두 번째부터는 빨라집니다.
## 6. 이미 Windows나 Docker에 Ollama가 있는 경우
9월 실습에서 Docker로 Ollama를 실행해 둔 경우에는 WSL에 새로 설치하지 않고 그대로 써도 됩니다.
| 상황 | 설정 |
| --- | --- |
| Docker Desktop으로 실행한 Ollama (`11434` 포트 공개) | 컨테이너를 켜 두면 WSL에서도 `http://localhost:11434`로 연결됩니다. `uv run miniflow check`로 확인 |
| Windows용 Ollama 앱 | `.wslconfig`에 `networkingMode=mirrored`를 추가하고 `wsl --shutdown` 후 다시 열면 `localhost`로 연결됩니다 |
```ini
[wsl2]
networkingMode=mirrored
```
## 7. 문제 해결
| 증상 | 해결 |
| --- | --- |
| `Cannot reach Ollama at http://localhost:11434` | `ollama serve` 실행, 또는 6절의 연결 설정 확인 |
| `Model 'gemma3:4b' not found on Ollama` | `ollama pull gemma3:4b` |
| 응답이 1분 넘게 걸림, WSL이 멈춤 | 메모리 부족. `gemma3:1b`로 바꾸거나 `.wslconfig`의 `memory` 상향 |
| `uv: command not found` | `source ~/.bashrc` 또는 터미널 다시 열기 |
| 한글이 깨져 보임 | Windows Terminal 사용 (기본 UTF-8) |
| Ollama가 없거나 문제가 계속됨 | `export MINIFLOW_LLM=mock`으로 Mock LLM 사용 (결과는 정해진 답) |
## 확인
- [ ] `uv run pytest -q` → `12 passed, 1 skipped`
- [ ] `ollama list`에 모델이 보인다
- [ ] `uv run miniflow check`에서 `Reply (ollama, ...)`가 나온다
@@ -0,0 +1,76 @@
# 업무 자동화 시나리오 분석
- 작성자:
- 선택한 시나리오:
## 1. 자동화 후보 판단
| 기준 | 점수 (1~5) | 근거 |
| --- | :-: | --- |
| 반복성 | | |
| 절차의 명확성 | | |
| 데이터 준비 | | |
| 판단의 난이도 (AI가 잘하는 판단인가) | | |
| 실패했을 때 영향 (작을수록 높은 점수) | | |
| 합계 | | |
## 2. 분석 항목
| 항목 | 내용 |
| --- | --- |
| 목표 | |
| 시작 조건 | |
| 입력 데이터 (필드) | |
| 처리 단계 | |
| 필요한 Tool·데이터 | |
| 기억 (읽기 / 쓰기) | |
| 결과와 전달 | |
| 사람 확인 단계 | |
| 예외 상황 | |
| 성공 기준 | |
## 3. AS-IS (현재 방식)
```mermaid
flowchart LR
A["단계 1"] --> B["단계 2"] --> C["단계 3"]
```
| 단계 | 담당 | 소요 시간 | 문제점 |
| --- | --- | --- | --- |
| | | | |
## 4. TO-BE (자동화 이후)
```mermaid
flowchart LR
T(["시작"]) --> L{{"LLM 노드"}} --> X["Tool 노드"] --> M[("Memory 노드")] --> O(["결과"])
```
## 5. 단계 → 노드 매핑
| TO-BE 단계 | 노드 종류 (LLM·Tool·Memory·데이터 처리·사람) | 입력 | 출력 | 이유 |
| --- | --- | --- | --- | --- |
| | | | | |
## 6. 구현한 워크플로우
- 파일: `workflow-lab/flows/my-scenario.json`
- 이번 실습에서 구현하지 못한 노드 (향후 노드):
```mermaid
flowchart LR
A["uv run miniflow show flows/my-scenario.json 결과로 바꾸세요"]
```
## 7. 실행 결과
| 구분 | 입력 | 결과 (status / result) | 메모 |
| --- | --- | --- | --- |
| 정상 1 | | | |
| 정상 2 | | | |
| 문제 입력 | | | 어느 노드에서, 왜 |
## 8. 개선 아이디어
-
+5
View File
@@ -0,0 +1,5 @@
runs/
.miniflow-memory.json
.venv/
.pytest_cache/
__pycache__/
+75
View File
@@ -0,0 +1,75 @@
# workflow-lab: JSON으로 정의하는 미니 AI 워크플로우
노코드 워크플로우 도구는 화면에서 **노드를 놓고 선으로 연결**합니다. 이 실습에서는 같은 구조를 JSON 파일로 적고 `miniflow`로 실행합니다. 화면 대신 파일을 쓸 뿐, 노드·연결·데이터 전달이라는 개념은 같습니다.
```mermaid
flowchart LR
J["flows/*.json<br/>노드와 연결 정의"] --> E["miniflow 엔진<br/>순서 계산 · 실행"]
E --> L{{"LLM 노드<br/>ollama · mock · openai · 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 check` | 설정한 LLM에 연결되는지 확인 |
| `uv run miniflow memory` / `--clear` | Memory 내용 보기 / 지우기 |
실행할 때마다 각 노드의 입력·출력·소요 시간이 `runs/` 폴더에 JSON으로 저장됩니다.
## 3. 노드 종류
| type | 역할 | 필수 필드 |
| --- | --- | --- |
| `input` | 실행 시 받은 입력을 흐름에 넣음 | 없음 |
| `llm` | 프롬프트를 LLM에 보내고 답을 받음 | `prompt` (선택: `system`, `temperature`, `mock`) |
| `tool` | 등록된 함수를 호출해 데이터를 가져옴 | `tool`, `args` |
| `memory_read` | 저장된 기억을 키로 읽음 | `key` |
| `memory_write` | 기억을 키에 저장(기존 값과 병합) | `key`, `value` |
| `output` | 최종 결과를 만듦 | `value` |
## 4. 데이터 전달 규칙
앞 노드의 결과는 `{{...}}`로 꺼내 씁니다.
| 표현 | 의미 |
| --- | --- |
| `{{input.city}}` | 실행할 때 받은 입력 `city` |
| `{{nodes.weather.output}}` | `weather` 노드의 결과 전체 |
| `{{nodes.weather.output.temp_c}}` | 결과 안의 특정 값 |
| `{{nodes.faq.output.0.answer}}` | 목록의 첫 번째 항목 안의 값 |
## 5. LLM 선택
`MINIFLOW_LLM` 환경 변수로 LLM을 고릅니다. 수업 기본값은 노트북에서 실행하는 **Ollama**입니다. 준비 방법은 [SETUP-WSL-OLLAMA.md](../SETUP-WSL-OLLAMA.md)를 참고하세요.
| `MINIFLOW_LLM` | LLM | 필요한 것 |
| --- | --- | --- |
| `ollama` (수업 기본) | 로컬 모델 (`OLLAMA_MODEL`, 기본 `gemma3:4b`) | Ollama 설치, 모델 다운로드 |
| `mock` (설정하지 않았을 때) | 노드의 `mock` 설정대로 정해진 답 | 없음 (자동 테스트, Ollama가 안 될 때) |
| `openai` | OpenAI (`OPENAI_MODEL`) | `uv sync --extra openai`, `OPENAI_API_KEY` |
| `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` |
```bash
export MINIFLOW_LLM=ollama
export OLLAMA_MODEL=gemma3:4b # 8GB 노트북은 gemma3:1b
export OLLAMA_HOST=http://localhost:11434 # 기본값이라 생략 가능
uv run miniflow check # 연결 확인
```
+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,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"]]
}
+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 = []
[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,126 @@
"""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}")
if provider == "ollama":
print(f"OLLAMA_HOST : {os.getenv('OLLAMA_HOST', 'http://localhost:11434')}")
print(f"OLLAMA_MODEL : {os.getenv('OLLAMA_MODEL', 'gemma3:4b')}")
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_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)
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,163 @@
"""Load, validate and run a workflow made of nodes and edges."""
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"}
REQUIRED_FIELDS = {
"llm": ["prompt"],
"tool": ["tool"],
"memory_read": ["key"],
"memory_write": ["key", "value"],
"output": ["value"],
}
class WorkflowError(ValueError):
pass
@dataclass
class Workflow:
name: str
nodes: dict[str, dict[str, Any]]
edges: list[tuple[str, str]]
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. Day 64 workflows must flow in one direction.")
return ordered
def to_mermaid(self) -> str:
shapes = {
"input": ('[/"', '"/]'),
"llm": ('{{"', '"}}'),
"tool": ('["', '"]'),
"memory_read": ('[("', '")]'),
"memory_write": ('[("', '")]'),
"output": ('[\\"', '"\\]'),
}
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}")
lines += [f" {source} --> {target}" for source, target in self.edges]
return "\n".join(lines)
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']}'")
nodes[node_id] = node
if not nodes:
raise WorkflowError("Workflow has no nodes")
edges = [tuple(edge) for edge in data.get("edges", [])]
for source, target 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}'")
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 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), {}
raise WorkflowError(f"Unsupported node type: {kind}")
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]] = []
order = workflow.order()
result: Any = None
for index, node_id in enumerate(order, start=1):
node = workflow.nodes[node_id]
started = time.perf_counter()
step = {"step": index, "id": node_id, "type": node["type"]}
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}
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,145 @@
"""LLM providers: mock (default, no API key), ollama (local), openai, anthropic, gemini.
Select with the MINIFLOW_LLM environment variable.
"""
import json
import os
import urllib.error
import urllib.request
from typing import Any
from miniflow.template import render
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) -> str:
"""Call a local Ollama server through its /api/chat endpoint (no extra package needed)."""
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}]
body = json.dumps({
"model": model,
"messages": messages,
"stream": False,
"options": {"temperature": temperature},
}).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) -> str:
try:
from openai import OpenAI
except ImportError as error:
raise LLMError("Install the extra first: uv sync --extra openai") from error
if not os.getenv("OPENAI_API_KEY"):
raise LLMError("OPENAI_API_KEY is not set")
client = OpenAI()
response = client.responses.create(
model=os.getenv("OPENAI_MODEL", "gpt-4.1-mini"),
instructions=system or None,
input=prompt,
temperature=temperature,
)
return response.output_text
def complete(node: dict[str, Any], prompt: str, system: str, context: dict[str, Any]) -> tuple[str, str]:
"""Return (reply, provider_name)."""
provider = os.getenv("MINIFLOW_LLM", "mock").lower()
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), "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), "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,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,62 @@
"""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())}
TOOLS: dict[str, Callable[..., Any]] = {
"get_weather": get_weather,
"search_faq": search_faq,
"count_chars": count_chars,
}
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,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,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 @@
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="서울")
+1054
View File
File diff suppressed because it is too large. Load diff