Implement Phase 3: dynamic graph builder, blueprint persistence, and CRUD API

- Add dynamic_graph_builder.py that constructs LangGraph graphs at runtime
  from frontend CouncilBlueprint JSON (no more hardcoded graphs in production)
- Add PostgreSQL persistence via SQLAlchemy async with Blueprint model
- Add blueprint CRUD endpoints (POST/GET/PUT/DELETE /api/councils/)
- Add POST /api/councils/{id}/run to execute blueprints dynamically
- Add Alembic migration infrastructure with initial blueprints table
- Add database.py with async engine and SQLite fallback for dev/test
- Fix missing typing-extensions and add aiosqlite dependency
- Add 42 new tests (80/80 total passing) covering dynamic graph building,
  blueprint service CRUD, and API integration

https://claude.ai/code/session_014yZUxrPsgZbvkebXbCXR4U
This commit is contained in:
Claude 2026-02-21 10:28:27 +00:00
parent 89ba3aacd4
commit 437db4ca68
No known key found for this signature in database
16 changed files with 1698 additions and 11 deletions

View file

@ -0,0 +1,176 @@
"""
Integration tests for the blueprint CRUD REST endpoints.
Overrides the database dependency to use an in-memory SQLite database.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from models.blueprint import Base
from database import get_session
from main import app
# ---------------------------------------------------------------------------
# Test database setup
# ---------------------------------------------------------------------------
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_session():
async with TestSessionLocal() as session:
yield session
app.dependency_overrides[get_session] = override_get_session
@pytest_asyncio.fixture(autouse=True)
async def setup_db():
"""Create and tear down tables for each test."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
# ---------------------------------------------------------------------------
# Sample payload
# ---------------------------------------------------------------------------
SAMPLE_BLUEPRINT = {
"version": 1,
"name": "Test Council",
"nodes": [
{
"id": "node-1",
"label": "Master",
"systemPrompt": "You are the master writer.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 0, "y": 0},
},
{
"id": "node-2",
"label": "Critic",
"systemPrompt": "You evaluate drafts.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 300, "y": 0},
},
],
"edges": [
{"id": "edge-1", "source": "node-1", "target": "node-2", "type": "linear"},
],
}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestBlueprintEndpoints:
@pytest.mark.asyncio
async def test_create_blueprint(self, client):
response = await client.post("/api/councils/", json=SAMPLE_BLUEPRINT)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Council"
assert data["version"] == 1
assert len(data["nodes"]) == 2
assert "id" in data
@pytest.mark.asyncio
async def test_list_blueprints(self, client):
await client.post("/api/councils/", json=SAMPLE_BLUEPRINT)
await client.post(
"/api/councils/",
json={**SAMPLE_BLUEPRINT, "name": "Second Council"},
)
response = await client.get("/api/councils/")
assert response.status_code == 200
data = response.json()
assert len(data) == 2
@pytest.mark.asyncio
async def test_get_blueprint(self, client):
create_resp = await client.post("/api/councils/", json=SAMPLE_BLUEPRINT)
bp_id = create_resp.json()["id"]
response = await client.get(f"/api/councils/{bp_id}")
assert response.status_code == 200
assert response.json()["name"] == "Test Council"
@pytest.mark.asyncio
async def test_get_nonexistent_returns_404(self, client):
response = await client.get("/api/councils/nonexistent-id")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_blueprint(self, client):
create_resp = await client.post("/api/councils/", json=SAMPLE_BLUEPRINT)
bp_id = create_resp.json()["id"]
update_resp = await client.put(
f"/api/councils/{bp_id}",
json={"name": "Renamed Council"},
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "Renamed Council"
@pytest.mark.asyncio
async def test_update_nonexistent_returns_404(self, client):
response = await client.put(
"/api/councils/ghost-id",
json={"name": "Ghost"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_blueprint(self, client):
create_resp = await client.post("/api/councils/", json=SAMPLE_BLUEPRINT)
bp_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/councils/{bp_id}")
assert delete_resp.status_code == 204
get_resp = await client.get(f"/api/councils/{bp_id}")
assert get_resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_nonexistent_returns_404(self, client):
response = await client.delete("/api/councils/ghost-id")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_create_rejects_missing_name(self, client):
payload = {**SAMPLE_BLUEPRINT}
del payload["name"]
response = await client.post("/api/councils/", json=payload)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_create_rejects_empty_name(self, client):
payload = {**SAMPLE_BLUEPRINT, "name": ""}
response = await client.post("/api/councils/", json=payload)
assert response.status_code == 422

View file

@ -0,0 +1,159 @@
"""
Tests for the blueprint CRUD service and API endpoints.
Uses an in-memory SQLite database for isolation.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from models.blueprint import Base, Blueprint
from services.blueprint_service import (
create_blueprint,
delete_blueprint,
get_blueprint,
list_blueprints,
update_blueprint,
)
# ---------------------------------------------------------------------------
# Test database setup (in-memory SQLite)
# ---------------------------------------------------------------------------
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestSessionLocal = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
@pytest_asyncio.fixture
async def session():
"""Create tables and yield a fresh session for each test."""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestSessionLocal() as sess:
yield sess
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
# ---------------------------------------------------------------------------
# Sample data
# ---------------------------------------------------------------------------
SAMPLE_NODES = [
{
"id": "node-1",
"label": "Master",
"systemPrompt": "You are the master.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 0, "y": 0},
},
{
"id": "node-2",
"label": "Critic",
"systemPrompt": "You evaluate drafts.",
"model": "gpt-4o",
"tools": {"webSearch": True, "pdfReader": False},
"position": {"x": 300, "y": 0},
},
]
SAMPLE_EDGES = [
{"id": "edge-1", "source": "node-1", "target": "node-2", "type": "linear"},
]
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestBlueprintCRUD:
@pytest.mark.asyncio
async def test_create_blueprint(self, session):
bp = await create_blueprint(session, "Test Council", SAMPLE_NODES, SAMPLE_EDGES)
assert bp.id is not None
assert bp.name == "Test Council"
assert bp.version == 1
assert len(bp.nodes) == 2
assert len(bp.edges) == 1
@pytest.mark.asyncio
async def test_create_with_custom_id(self, session):
bp = await create_blueprint(
session, "Custom ID", SAMPLE_NODES, SAMPLE_EDGES, blueprint_id="my-custom-id"
)
assert bp.id == "my-custom-id"
@pytest.mark.asyncio
async def test_get_blueprint(self, session):
bp = await create_blueprint(session, "Get Test", SAMPLE_NODES, SAMPLE_EDGES)
fetched = await get_blueprint(session, bp.id)
assert fetched is not None
assert fetched.name == "Get Test"
@pytest.mark.asyncio
async def test_get_nonexistent_returns_none(self, session):
result = await get_blueprint(session, "nonexistent-id")
assert result is None
@pytest.mark.asyncio
async def test_list_blueprints(self, session):
await create_blueprint(session, "First", SAMPLE_NODES, SAMPLE_EDGES)
await create_blueprint(session, "Second", SAMPLE_NODES, SAMPLE_EDGES)
all_bps = await list_blueprints(session)
assert len(all_bps) == 2
@pytest.mark.asyncio
async def test_update_blueprint_name(self, session):
bp = await create_blueprint(session, "Original", SAMPLE_NODES, SAMPLE_EDGES)
updated = await update_blueprint(session, bp.id, name="Renamed")
assert updated is not None
assert updated.name == "Renamed"
@pytest.mark.asyncio
async def test_update_blueprint_nodes(self, session):
bp = await create_blueprint(session, "Nodes Test", SAMPLE_NODES, SAMPLE_EDGES)
new_nodes = [SAMPLE_NODES[0]] # Remove second node
updated = await update_blueprint(session, bp.id, nodes=new_nodes)
assert updated is not None
assert len(updated.nodes) == 1
@pytest.mark.asyncio
async def test_update_nonexistent_returns_none(self, session):
result = await update_blueprint(session, "ghost-id", name="New Name")
assert result is None
@pytest.mark.asyncio
async def test_delete_blueprint(self, session):
bp = await create_blueprint(session, "To Delete", SAMPLE_NODES, SAMPLE_EDGES)
deleted = await delete_blueprint(session, bp.id)
assert deleted is True
assert await get_blueprint(session, bp.id) is None
@pytest.mark.asyncio
async def test_delete_nonexistent_returns_false(self, session):
deleted = await delete_blueprint(session, "ghost-id")
assert deleted is False
@pytest.mark.asyncio
async def test_to_dict_format(self, session):
bp = await create_blueprint(session, "Dict Test", SAMPLE_NODES, SAMPLE_EDGES)
d = bp.to_dict()
assert d["id"] == bp.id
assert d["version"] == 1
assert d["name"] == "Dict Test"
assert "createdAt" in d
assert "updatedAt" in d
assert isinstance(d["nodes"], list)
assert isinstance(d["edges"], list)

View file

@ -0,0 +1,330 @@
"""
Tests for the dynamic graph builder (Phase 3).
Verifies that build_graph_from_blueprint correctly creates LangGraph graphs
from JSON blueprints matching the frontend's CouncilBlueprint format.
All LLM calls are mocked.
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import pytest
from unittest.mock import patch, MagicMock
from services.dynamic_graph_builder import (
build_graph_from_blueprint,
_make_agent_node,
_make_critic_node,
_make_conditional_router,
_is_critic_like,
_get_llm,
)
from services.graph_builder import create_initial_state
from state import CouncilState, APPROVAL_THRESHOLD, MAX_ITERATIONS
# ---------------------------------------------------------------------------
# Sample blueprints for testing
# ---------------------------------------------------------------------------
SIMPLE_LINEAR_BLUEPRINT = {
"version": 1,
"name": "Simple Linear",
"nodes": [
{
"id": "node-1",
"label": "Writer",
"systemPrompt": "You are a professional writer.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 0, "y": 0},
},
{
"id": "node-2",
"label": "Editor",
"systemPrompt": "You are a professional editor. Polish the text.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 300, "y": 0},
},
],
"edges": [
{"id": "edge-1", "source": "node-1", "target": "node-2", "type": "linear"},
],
}
CYCLIC_BLUEPRINT = {
"version": 1,
"name": "Cyclic Council",
"nodes": [
{
"id": "master",
"label": "Master Agent",
"systemPrompt": "You are the master writer.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 0, "y": 0},
},
{
"id": "critic",
"label": "Critic Agent",
"systemPrompt": "You are the critic. Evaluate and score the draft.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 300, "y": 0},
},
{
"id": "writer",
"label": "Final Writer",
"systemPrompt": "You polish approved drafts.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 600, "y": 0},
},
],
"edges": [
{"id": "e1", "source": "master", "target": "critic", "type": "linear"},
{
"id": "e2",
"source": "critic",
"target": "master",
"type": "conditional",
"condition": "rework",
},
{
"id": "e3",
"source": "critic",
"target": "writer",
"type": "conditional",
"condition": "approve",
},
],
}
# ---------------------------------------------------------------------------
# Test: critic detection heuristic
# ---------------------------------------------------------------------------
class TestCriticDetection:
def test_detects_critic_keyword(self):
assert _is_critic_like("You are the critic. Evaluate drafts.") is True
def test_detects_evaluate_keyword(self):
assert _is_critic_like("Your role is to evaluate and score.") is True
def test_detects_review_keyword(self):
assert _is_critic_like("Review the document for quality.") is True
def test_no_match_for_writer(self):
assert _is_critic_like("You are a professional writer.") is False
def test_case_insensitive(self):
assert _is_critic_like("You are the CRITIC agent.") is True
# ---------------------------------------------------------------------------
# Test: conditional routing
# ---------------------------------------------------------------------------
class TestConditionalRouter:
def test_routes_to_correct_target(self):
edges = [
{"target": "node-a", "condition": "rework"},
{"target": "node-b", "condition": "approve"},
]
router = _make_conditional_router("source", edges, None)
state = create_initial_state("topic", "run-1")
state["route_decision"] = "approve"
assert router(state) == "node-b"
def test_routes_rework(self):
edges = [
{"target": "node-a", "condition": "rework"},
{"target": "node-b", "condition": "approve"},
]
router = _make_conditional_router("source", edges, None)
state = create_initial_state("topic", "run-1")
state["route_decision"] = "rework"
assert router(state) == "node-a"
def test_unknown_decision_uses_linear_fallback(self):
edges = [
{"target": "node-a", "condition": "rework"},
]
router = _make_conditional_router("source", edges, "fallback-node")
state = create_initial_state("topic", "run-1")
state["route_decision"] = "unknown"
assert router(state) == "fallback-node"
def test_unknown_decision_uses_first_conditional_as_fallback(self):
edges = [
{"target": "node-a", "condition": "rework"},
{"target": "node-b", "condition": "approve"},
]
router = _make_conditional_router("source", edges, None)
state = create_initial_state("topic", "run-1")
state["route_decision"] = "unknown"
assert router(state) == "node-a"
# ---------------------------------------------------------------------------
# Test: agent node factory
# ---------------------------------------------------------------------------
class TestAgentNodeFactory:
def test_agent_node_returns_draft(self):
mock_response = MagicMock()
mock_response.content = "Generated content about AI."
with patch("services.dynamic_graph_builder.ChatAnthropic") as MockLLM:
MockLLM.return_value.invoke.return_value = mock_response
node_fn = _make_agent_node("node-1", "Writer", "You write.", "claude-3-5-sonnet")
state = create_initial_state("AI basics", "run-1")
result = node_fn(state)
assert result["current_draft"] == "Generated content about AI."
assert result["active_node"] == "node-1"
assert result["iteration_count"] == 1
def test_agent_node_with_existing_draft_and_feedback(self):
mock_response = MagicMock()
mock_response.content = "Improved draft."
with patch("services.dynamic_graph_builder.ChatAnthropic") as MockLLM:
MockLLM.return_value.invoke.return_value = mock_response
node_fn = _make_agent_node("node-1", "Writer", "You write.", "claude-3-5-sonnet")
state = create_initial_state("AI", "run-1")
state["current_draft"] = "First draft"
state["feedback_history"] = ["Needs more detail"]
state["iteration_count"] = 1
result = node_fn(state)
assert result["current_draft"] == "Improved draft."
assert result["iteration_count"] == 2
# ---------------------------------------------------------------------------
# Test: critic node factory
# ---------------------------------------------------------------------------
class TestCriticNodeFactory:
def test_critic_node_approves_high_score(self):
mock_response = MagicMock()
mock_response.content = "SCORE: 9\nVERDICT: approve\nFEEDBACK:\nExcellent work."
with patch("services.dynamic_graph_builder.ChatAnthropic") as MockLLM:
MockLLM.return_value.invoke.return_value = mock_response
node_fn = _make_critic_node("critic-1", "Critic", "You evaluate.", "claude-3-5-sonnet")
state = create_initial_state("Topic", "run-1")
state["current_draft"] = "A great draft"
result = node_fn(state)
assert result["route_decision"] == "approve"
assert result["critic_score"] == 9.0
def test_critic_node_reworks_low_score(self):
mock_response = MagicMock()
mock_response.content = "SCORE: 4\nVERDICT: rework\nFEEDBACK:\nNeeds more structure."
with patch("services.dynamic_graph_builder.ChatAnthropic") as MockLLM:
MockLLM.return_value.invoke.return_value = mock_response
node_fn = _make_critic_node("critic-1", "Critic", "You evaluate.", "claude-3-5-sonnet")
state = create_initial_state("Topic", "run-1")
state["current_draft"] = "Draft"
result = node_fn(state)
assert result["route_decision"] == "rework"
assert result["critic_score"] == 4.0
assert len(result["feedback_history"]) == 1
assert "structure" in result["feedback_history"][0]
def test_critic_safety_valve_at_max_iterations(self):
node_fn = _make_critic_node("critic-1", "Critic", "Evaluate.", "claude-3-5-sonnet")
state = create_initial_state("Topic", "run-1")
state["current_draft"] = "Draft"
state["iteration_count"] = MAX_ITERATIONS
result = node_fn(state)
assert result["route_decision"] == "approve"
assert result["critic_score"] == APPROVAL_THRESHOLD
# ---------------------------------------------------------------------------
# Test: build_graph_from_blueprint
# ---------------------------------------------------------------------------
class TestBuildGraphFromBlueprint:
def test_rejects_empty_blueprint(self):
with pytest.raises(ValueError, match="no nodes"):
build_graph_from_blueprint({"version": 1, "name": "Empty", "nodes": [], "edges": []})
def test_builds_linear_graph(self):
"""A simple linear blueprint should compile without error."""
graph = build_graph_from_blueprint(SIMPLE_LINEAR_BLUEPRINT)
assert graph is not None
def test_builds_cyclic_graph(self):
"""A cyclic blueprint with conditional edges should compile."""
graph = build_graph_from_blueprint(CYCLIC_BLUEPRINT)
assert graph is not None
def test_entry_point_is_node_with_no_incoming(self):
"""The entry point should be the node that has no incoming edges."""
# In CYCLIC_BLUEPRINT, 'master' has no incoming edges except from critic (conditional rework),
# but critic->master is an edge so master IS a target. The first node without incoming = master
# Actually master IS a target of the rework edge. Let's verify with simple linear.
graph = build_graph_from_blueprint(SIMPLE_LINEAR_BLUEPRINT)
assert graph is not None # node-1 has no incoming, so it's the entry
def test_single_node_blueprint(self):
"""A single node with no edges should work (trivial graph)."""
bp = {
"version": 1,
"name": "Single",
"nodes": [
{
"id": "only-node",
"label": "Solo Agent",
"systemPrompt": "You work alone.",
"model": "claude-3-5-sonnet",
"tools": {"webSearch": False, "pdfReader": False},
"position": {"x": 0, "y": 0},
}
],
"edges": [],
}
graph = build_graph_from_blueprint(bp)
assert graph is not None
# ---------------------------------------------------------------------------
# Test: model lookup
# ---------------------------------------------------------------------------
class TestModelLookup:
def test_unknown_model_raises(self):
with pytest.raises(ValueError, match="Unknown model"):
_get_llm("nonexistent-model")
def test_claude_model_creates_instance(self):
with patch("services.dynamic_graph_builder.ChatAnthropic") as MockLLM:
llm = _get_llm("claude-3-5-sonnet")
MockLLM.assert_called_once()
def test_gpt4o_model_creates_instance(self):
with patch("services.dynamic_graph_builder.ChatOpenAI") as MockLLM:
llm = _get_llm("gpt-4o")
MockLLM.assert_called_once()