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,153 @@
"""
REST API routes for council blueprint CRUD.
Endpoints:
GET /api/councils/ List all blueprints
POST /api/councils/ Create a new blueprint
GET /api/councils/{id} Get a specific blueprint
PUT /api/councils/{id} Update a blueprint
DELETE /api/councils/{id} Delete a blueprint
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_session
from services.blueprint_service import (
create_blueprint,
delete_blueprint,
get_blueprint,
list_blueprints,
update_blueprint,
)
blueprint_router = APIRouter()
# ---------------------------------------------------------------------------
# Request / Response Models
# ---------------------------------------------------------------------------
class AgentTools(BaseModel):
webSearch: bool = False
pdfReader: bool = False
class BlueprintNodeSchema(BaseModel):
id: str
label: str
systemPrompt: str = ""
model: str = "claude-3-5-sonnet"
tools: AgentTools = Field(default_factory=AgentTools)
position: dict = Field(default_factory=lambda: {"x": 0, "y": 0})
class BlueprintEdgeSchema(BaseModel):
id: str
source: str
target: str
type: str = "linear"
condition: Optional[str] = None
class BlueprintCreateRequest(BaseModel):
version: int = 1
name: str = Field(..., min_length=1, max_length=255)
nodes: List[BlueprintNodeSchema]
edges: List[BlueprintEdgeSchema] = []
id: Optional[str] = None
class BlueprintUpdateRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255)
nodes: Optional[List[BlueprintNodeSchema]] = None
edges: Optional[List[BlueprintEdgeSchema]] = None
class BlueprintResponse(BaseModel):
id: str
version: int
name: str
nodes: list
edges: list
createdAt: Optional[str] = None
updatedAt: Optional[str] = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@blueprint_router.get("/councils/", response_model=List[BlueprintResponse])
async def list_all_blueprints(
session: AsyncSession = Depends(get_session),
):
"""List all council blueprints."""
blueprints = await list_blueprints(session)
return [bp.to_dict() for bp in blueprints]
@blueprint_router.post(
"/councils/",
response_model=BlueprintResponse,
status_code=201,
)
async def create_new_blueprint(
request: BlueprintCreateRequest,
session: AsyncSession = Depends(get_session),
):
"""Create a new council blueprint."""
bp = await create_blueprint(
session=session,
name=request.name,
nodes=[n.model_dump() for n in request.nodes],
edges=[e.model_dump() for e in request.edges],
blueprint_id=request.id,
version=request.version,
)
return bp.to_dict()
@blueprint_router.get("/councils/{blueprint_id}", response_model=BlueprintResponse)
async def get_single_blueprint(
blueprint_id: str,
session: AsyncSession = Depends(get_session),
):
"""Retrieve a specific blueprint by ID."""
bp = await get_blueprint(session, blueprint_id)
if bp is None:
raise HTTPException(status_code=404, detail=f"Blueprint '{blueprint_id}' not found.")
return bp.to_dict()
@blueprint_router.put("/councils/{blueprint_id}", response_model=BlueprintResponse)
async def update_existing_blueprint(
blueprint_id: str,
request: BlueprintUpdateRequest,
session: AsyncSession = Depends(get_session),
):
"""Update an existing blueprint."""
bp = await update_blueprint(
session=session,
blueprint_id=blueprint_id,
name=request.name,
nodes=[n.model_dump() for n in request.nodes] if request.nodes is not None else None,
edges=[e.model_dump() for e in request.edges] if request.edges is not None else None,
)
if bp is None:
raise HTTPException(status_code=404, detail=f"Blueprint '{blueprint_id}' not found.")
return bp.to_dict()
@blueprint_router.delete("/councils/{blueprint_id}", status_code=204)
async def delete_existing_blueprint(
blueprint_id: str,
session: AsyncSession = Depends(get_session),
):
"""Delete a blueprint by ID."""
deleted = await delete_blueprint(session, blueprint_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Blueprint '{blueprint_id}' not found.")

View file

@ -2,17 +2,22 @@
REST API routes for CouncilOS.
Endpoints:
POST /api/councils/run Start a new council run (async, returns run_id)
GET /api/councils/run/{run_id} Poll the status/result of a run
GET /api/health Health check
POST /api/councils/run Start a new council run (Phase 1 hard-coded graph)
POST /api/councils/{id}/run Start a run from a saved blueprint (Phase 3)
GET /api/councils/run/{run_id} Poll the status/result of a run
GET /api/health Health check
"""
import uuid
from typing import Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from services.graph_builder import run_council_async
from services.dynamic_graph_builder import run_blueprint_council_async
from services.blueprint_service import get_blueprint
from database import get_session
from api.run_store import run_store
@ -64,7 +69,7 @@ async def start_council_run(
background_tasks: BackgroundTasks,
):
"""
Start a new council run.
Start a new council run using the Phase 1 hard-coded graph.
The run executes asynchronously in the background. Poll
GET /api/councils/run/{run_id} for the result, or connect to the
@ -85,6 +90,45 @@ async def start_council_run(
)
@router.post(
"/councils/{blueprint_id}/run",
response_model=CouncilRunResponse,
status_code=202,
)
async def start_blueprint_run(
blueprint_id: str,
request: CouncilRunRequest,
background_tasks: BackgroundTasks,
session: AsyncSession = Depends(get_session),
):
"""
Start a council run using a saved blueprint (Phase 3 dynamic graph).
Reads the blueprint from PostgreSQL and dynamically constructs the
LangGraph execution graph at runtime.
"""
bp = await get_blueprint(session, blueprint_id)
if bp is None:
raise HTTPException(status_code=404, detail=f"Blueprint '{blueprint_id}' not found.")
run_id = str(uuid.uuid4())
run_store.create(run_id, request.input_topic)
blueprint_dict = bp.to_dict()
background_tasks.add_task(
_execute_blueprint_run, run_id, request.input_topic, blueprint_dict
)
return CouncilRunResponse(
run_id=run_id,
status="pending",
message=(
f"Council run started from blueprint '{bp.name}'. "
f"Connect to /ws/council/{run_id} for live updates."
),
)
@router.get("/councils/run/{run_id}", response_model=CouncilResultResponse)
async def get_council_result(run_id: str):
"""
@ -110,7 +154,7 @@ async def get_council_result(run_id: str):
async def _execute_run(run_id: str, input_topic: str) -> None:
"""
Background task that runs the LangGraph council and updates the run store.
Background task that runs the Phase 1 hard-coded LangGraph council.
"""
run_store.update(run_id, {"status": "running"})
try:
@ -133,3 +177,33 @@ async def _execute_run(run_id: str, input_topic: str) -> None:
)
except Exception as exc: # noqa: BLE001
run_store.update(run_id, {"status": "failed", "error": str(exc)})
async def _execute_blueprint_run(
run_id: str, input_topic: str, blueprint: dict
) -> None:
"""
Background task that runs a dynamically built LangGraph from a blueprint.
"""
run_store.update(run_id, {"status": "running"})
try:
final_state = await run_blueprint_council_async(
blueprint=blueprint,
input_topic=input_topic,
run_id=run_id,
on_node_event=lambda nid, node: run_store.update(
nid, {"active_node": node}
),
)
run_store.update(
run_id,
{
"status": "completed",
"final_draft": final_state.get("current_draft"),
"critic_score": final_state.get("critic_score"),
"iteration_count": final_state.get("iteration_count"),
"active_node": "done",
},
)
except Exception as exc: # noqa: BLE001
run_store.update(run_id, {"status": "failed", "error": str(exc)})