65일차 실습자료

This commit is contained in:
mcchae committed 2026-10-13 17:09:59 +09:00
1 parent d6a6c62a3f
commit 152344cb13
42 files changed
+4545

No files matched your search

@@ -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,192 @@
"""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) -> 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, 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), "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,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()]