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

@@ -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="서울")