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:
parent
89ba3aacd4
commit
437db4ca68
16 changed files with 1698 additions and 11 deletions
153
backend/api/blueprint_routes.py
Normal file
153
backend/api/blueprint_routes.py
Normal 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.")
|
||||
|
|
@ -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)})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue