Initial commit
This commit is contained in:
commit
5eedbdd039
30 files changed
+2827
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,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()]
|
||||
Reference in new issue
Block a user