65일차 실습자료
This commit is contained in:
1 parent
d6a6c62a3f
commit
152344cb13
42 files changed
+4545
No files matched your search
@@ -0,0 +1,3 @@
|
||||
import os
|
||||
|
||||
os.environ["MINIFLOW_DOTENV"] = "0" # tests never read the real .env (it may hold API keys)
|
||||
@@ -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,104 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from miniflow import WorkflowError, load_workflow, run_workflow
|
||||
from miniflow.engine import evaluate_check
|
||||
|
||||
FLOWS = Path(__file__).resolve().parents[1] / "flows"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "mock")
|
||||
|
||||
|
||||
def statuses(report):
|
||||
return {step["id"]: step["status"] for step in report["trace"]}
|
||||
|
||||
|
||||
def test_condition_true_branch_gives_auto_reply():
|
||||
report = run_workflow(load_workflow(FLOWS / "06-request-with-condition.json"),
|
||||
{"user": "kim", "text": "VPN 접속이 계속 끊겨요"}, verbose=False)
|
||||
assert report["status"] == "completed"
|
||||
assert report["result"]["route"] == "자동 답변"
|
||||
assert report["result"]["faq_id"] == "FAQ-003"
|
||||
assert statuses(report)["hand_off"] == "skipped"
|
||||
|
||||
|
||||
def test_condition_false_branch_hands_off():
|
||||
report = run_workflow(load_workflow(FLOWS / "06-request-with-condition.json"),
|
||||
{"user": "lee", "text": "점심 메뉴 추천해줘"}, verbose=False)
|
||||
assert report["status"] == "completed"
|
||||
assert report["result"]["route"] == "담당자 확인"
|
||||
assert statuses(report)["draft"] == "skipped"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,team", [
|
||||
("비밀번호를 잊어버렸어요", "계정 관리 담당"),
|
||||
("프린터 출력이 안 돼요", "장비 지원 담당"),
|
||||
("회의실 예약은 어떻게 하나요", "IT 문의 접수 창구"),
|
||||
])
|
||||
def test_switch_routes(text, team):
|
||||
report = run_workflow(load_workflow(FLOWS / "07-route-by-category.json"), {"text": text}, verbose=False)
|
||||
assert report["result"]["team"] == team
|
||||
|
||||
|
||||
def test_join_after_exclusive_branches_runs(tmp_path):
|
||||
flow = {
|
||||
"inputs": ["n"],
|
||||
"nodes": [
|
||||
{"id": "start", "type": "input"},
|
||||
{"id": "big", "type": "condition", "check": {"left": "{{input.n}}", "op": "gt", "right": 10}},
|
||||
{"id": "yes", "type": "output", "value": "big"},
|
||||
{"id": "no", "type": "output", "value": "small"},
|
||||
{"id": "done", "type": "output", "value": "done"},
|
||||
],
|
||||
"edges": [["start", "big"], ["big", "yes", "true"], ["big", "no", "false"], ["yes", "done"], ["no", "done"]],
|
||||
}
|
||||
path = tmp_path / "join.json"
|
||||
path.write_text(json.dumps(flow), encoding="utf-8")
|
||||
report = run_workflow(load_workflow(path), {"n": "3"}, verbose=False)
|
||||
assert statuses(report) == {"start": "ok", "big": "ok", "yes": "skipped", "no": "ok", "done": "ok"}
|
||||
|
||||
|
||||
def test_branch_label_validation(tmp_path):
|
||||
flow = {
|
||||
"nodes": [{"id": "a", "type": "input"}, {"id": "b", "type": "output", "value": 1}],
|
||||
"edges": [["a", "b", "true"]],
|
||||
}
|
||||
path = tmp_path / "bad.json"
|
||||
path.write_text(json.dumps(flow), encoding="utf-8")
|
||||
with pytest.raises(WorkflowError):
|
||||
load_workflow(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("check,expected", [
|
||||
({"left": "abc", "op": "contains", "right": "b"}, True),
|
||||
({"left": "", "op": "empty"}, True),
|
||||
({"left": "network", "op": "in", "right": ["network", "device"]}, True),
|
||||
({"left": "5", "op": "lt", "right": 3}, False),
|
||||
])
|
||||
def test_evaluate_check(check, expected):
|
||||
assert evaluate_check(check, {"input": {}, "nodes": {}}) is expected
|
||||
|
||||
|
||||
def test_branch_gate_blocks_even_with_other_plain_input(tmp_path):
|
||||
"""A node behind a skipped condition stays skipped even if another plain edge reaches it."""
|
||||
flow = json.loads((FLOWS / "06-request-with-condition.json").read_text(encoding="utf-8"))
|
||||
flow["nodes"].insert(1, {"id": "is_urgent", "type": "condition",
|
||||
"check": {"left": "{{input.text}}", "op": "contains", "right": "긴급"}})
|
||||
flow["nodes"].append({"id": "urgent_call", "type": "output", "value": {"route": "긴급 담당자 연결"}})
|
||||
flow["edges"] += [["start", "is_urgent"], ["is_urgent", "urgent_call", "true"], ["is_urgent", "has_faq", "false"]]
|
||||
path = tmp_path / "urgent.json"
|
||||
path.write_text(json.dumps(flow, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
urgent = run_workflow(load_workflow(path), {"user": "kim", "text": "긴급! VPN이 끊겼어요"}, verbose=False)
|
||||
assert urgent["result"]["route"] == "긴급 담당자 연결"
|
||||
assert statuses(urgent)["draft"] == "skipped"
|
||||
|
||||
normal = run_workflow(load_workflow(path), {"user": "kim", "text": "VPN이 끊겼어요"}, verbose=False)
|
||||
assert normal["result"]["route"] == "자동 답변"
|
||||
assert statuses(normal)["urgent_call"] == "skipped"
|
||||
@@ -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 @@
|
||||
"""OpenAI provider, .env loading and the Ollama fallback, without network access."""
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from miniflow import envfile, llm
|
||||
|
||||
|
||||
def test_parse_env_lines():
|
||||
values = envfile.parse('# comment\nexport A=1\nB="two words"\nC=3 # note\n\nbroken line\n')
|
||||
assert values == {"A": "1", "B": "two words", "C": "3"}
|
||||
|
||||
|
||||
def test_load_keeps_shell_values(tmp_path, monkeypatch):
|
||||
(tmp_path / ".env").write_text("MINIFLOW_TEST_A=from-file\nMINIFLOW_TEST_B=from-file\n", encoding="utf-8")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("MINIFLOW_DOTENV", "1")
|
||||
monkeypatch.setenv("MINIFLOW_TEST_A", "from-shell")
|
||||
monkeypatch.delenv("MINIFLOW_TEST_B", raising=False)
|
||||
assert envfile.load() == tmp_path / ".env"
|
||||
assert llm.os.environ["MINIFLOW_TEST_A"] == "from-shell"
|
||||
assert llm.os.environ["MINIFLOW_TEST_B"] == "from-file"
|
||||
monkeypatch.delenv("MINIFLOW_TEST_B")
|
||||
|
||||
|
||||
def test_openai_without_key(monkeypatch):
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MINIFLOW_LLM_FALLBACK", raising=False)
|
||||
with pytest.raises(llm.LLMError, match="OPENAI_API_KEY"):
|
||||
llm.complete({}, "hello", "", {})
|
||||
|
||||
|
||||
def test_openai_request_shape(monkeypatch):
|
||||
sent = {}
|
||||
|
||||
class FakeResponses:
|
||||
def create(self, **kwargs):
|
||||
sent.update(kwargs)
|
||||
return types.SimpleNamespace(output_text='{"ok": true}', usage=None)
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **_):
|
||||
self.responses = FakeResponses()
|
||||
|
||||
import openai
|
||||
|
||||
monkeypatch.setattr(openai, "OpenAI", FakeClient)
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
monkeypatch.setenv("OPENAI_MODEL", "test-model")
|
||||
schema = {"type": "object", "properties": {"ok": {"type": "boolean"}}}
|
||||
reply, used = llm.complete({"temperature": 0, "json_schema": schema}, "hello", "be brief", {})
|
||||
assert (reply, used) == ('{"ok": true}', "openai")
|
||||
assert sent["model"] == "test-model" and sent["instructions"] == "be brief" and sent["temperature"] == 0
|
||||
assert sent["text"]["format"]["schema"] == schema
|
||||
|
||||
|
||||
def test_fallback_to_second_provider(monkeypatch, capsys):
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama")
|
||||
monkeypatch.setattr(llm, "call_ollama", lambda *args, **kwargs: "from ollama")
|
||||
reply, used = llm.complete({}, "hello", "", {})
|
||||
assert (reply, used) == ("from ollama", "ollama")
|
||||
assert "openai failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_both_providers_fail(monkeypatch):
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "openai")
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.setenv("MINIFLOW_LLM_FALLBACK", "ollama")
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise llm.LLMError("Cannot reach Ollama")
|
||||
|
||||
monkeypatch.setattr(llm, "call_ollama", broken)
|
||||
with pytest.raises(llm.LLMError, match="OPENAI_API_KEY.*fallback ollama failed"):
|
||||
llm.complete({}, "hello", "", {})
|
||||
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
from miniflow.platforms import FailoverRunner, PlatformError, load_config
|
||||
|
||||
|
||||
class FakeN8n(BaseHTTPRequestHandler):
|
||||
mode = "ok"
|
||||
received: list = []
|
||||
|
||||
def do_POST(self):
|
||||
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
|
||||
FakeN8n.received.append((self.path, body))
|
||||
if FakeN8n.mode == "error":
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
return
|
||||
payload = json.dumps({"category": "network", "faq_id": "FAQ-003", "route": "자동 답변", "reply": "n8n 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 n8n(monkeypatch, tmp_path):
|
||||
server = HTTPServer(("127.0.0.1", 0), FakeN8n)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
FakeN8n.mode, FakeN8n.received = "ok", []
|
||||
monkeypatch.setenv("N8N_BASE_URL", f"http://127.0.0.1:{server.server_port}")
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "mock")
|
||||
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
|
||||
yield server
|
||||
server.shutdown()
|
||||
|
||||
|
||||
INPUTS = {"user": "kim", "text": "VPN 접속이 계속 끊겨요"}
|
||||
|
||||
|
||||
def test_n8n_serves_when_healthy(n8n):
|
||||
report = FailoverRunner(["n8n", "miniflow"]).run("request", INPUTS)
|
||||
assert report["served_by"] == "n8n"
|
||||
assert FakeN8n.received[-1] == ("/webhook/ai-request", INPUTS)
|
||||
|
||||
|
||||
def test_failover_to_miniflow_when_n8n_errors(n8n):
|
||||
FakeN8n.mode = "error"
|
||||
report = FailoverRunner(["n8n", "miniflow"]).run("request", INPUTS)
|
||||
assert report["served_by"] == "miniflow"
|
||||
assert [a["status"] for a in report["attempts"]] == ["failed", "ok"]
|
||||
assert report["result"]["route"] == "자동 답변"
|
||||
|
||||
|
||||
def test_failover_when_n8n_is_down(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("N8N_BASE_URL", "http://127.0.0.1:9")
|
||||
monkeypatch.setenv("N8N_TIMEOUT", "2")
|
||||
monkeypatch.setenv("MINIFLOW_LLM", "mock")
|
||||
monkeypatch.setenv("MINIFLOW_MEMORY_FILE", str(tmp_path / "memory.json"))
|
||||
report = FailoverRunner(["n8n", "miniflow"]).run("request", {"user": "lee", "text": "점심 메뉴 추천해줘"})
|
||||
assert report["served_by"] == "miniflow"
|
||||
assert report["result"]["route"] == "담당자 확인"
|
||||
|
||||
|
||||
def test_all_platforms_failing_raises(n8n):
|
||||
FakeN8n.mode = "error"
|
||||
with pytest.raises(PlatformError, match="All platforms failed"):
|
||||
FailoverRunner(["n8n"]).run("request", INPUTS)
|
||||
|
||||
|
||||
def test_unknown_platform_is_rejected():
|
||||
with pytest.raises(PlatformError, match="Unknown platform"):
|
||||
FailoverRunner(["nowhere"], load_config())
|
||||
@@ -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="서울")
|
||||
Reference in new issue
Block a user