65일차 실습자료
This commit is contained in:
1 parent
d6a6c62a3f
commit
152344cb13
42 files changed
+4545
No files matched your search
@@ -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}
|
||||
Reference in new issue
Block a user