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

+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