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

+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