Implement AI orchestration wedding demo
This commit is contained in:
3
backend/app/api/README.md
Normal file
3
backend/app/api/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# API
|
||||
|
||||
FastAPI routes for transcript ingestion, pending review workflow, generated tools, dashboard state, and the demo REST backend.
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""FastAPI routers."""
|
||||
73
backend/app/api/dependencies.py
Normal file
73
backend/app/api/dependencies.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from functools import lru_cache
|
||||
|
||||
import httpx
|
||||
from fastapi import Request
|
||||
|
||||
from app.ai.tool_generator import OpenAPIToolGenerator
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
from app.ai.wed_api_spec import wed_openapi_subset
|
||||
from app.application.orchestrator import Orchestrator
|
||||
from app.config import get_settings
|
||||
from app.infrastructure.events import EventPublisher
|
||||
from app.memory.context_builder import ContextBuilder
|
||||
from app.memory.memory_manager import MemoryManager
|
||||
from app.planner.llm_planner import HybridPlanner
|
||||
|
||||
|
||||
@lru_cache
|
||||
def context_builder() -> ContextBuilder:
|
||||
return ContextBuilder()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def memory_manager() -> MemoryManager:
|
||||
manager = MemoryManager()
|
||||
manager.seed_demo()
|
||||
return manager
|
||||
|
||||
|
||||
@lru_cache
|
||||
def event_publisher() -> EventPublisher:
|
||||
return EventPublisher()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def wed_api_spec() -> dict:
|
||||
settings = get_settings()
|
||||
if settings.wed_api_openapi_url:
|
||||
try:
|
||||
response = httpx.get(settings.wed_api_openapi_url, timeout=5)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception:
|
||||
return wed_openapi_subset()
|
||||
return wed_openapi_subset()
|
||||
|
||||
|
||||
def tool_registry(request: Request) -> ToolRegistry:
|
||||
spec = wed_api_spec()
|
||||
tools = OpenAPIToolGenerator(spec).generate()
|
||||
filtered = [
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.name
|
||||
in {
|
||||
"create_task",
|
||||
"update_task",
|
||||
"create_note",
|
||||
"update_note",
|
||||
"create_guest_reservation",
|
||||
"update_reservation",
|
||||
}
|
||||
]
|
||||
return ToolRegistry(filtered)
|
||||
|
||||
|
||||
def orchestrator(request: Request) -> Orchestrator:
|
||||
return Orchestrator(
|
||||
context=context_builder(),
|
||||
memory=memory_manager(),
|
||||
planner=HybridPlanner(),
|
||||
registry=tool_registry(request),
|
||||
publisher=event_publisher(),
|
||||
)
|
||||
203
backend/app/api/routes.py
Normal file
203
backend/app/api/routes.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import context_builder, event_publisher, memory_manager, orchestrator, tool_registry
|
||||
from app.domain.schemas import (
|
||||
ActionStatus,
|
||||
DashboardRead,
|
||||
EventType,
|
||||
NoteCreate,
|
||||
NoteRead,
|
||||
PendingActionRead,
|
||||
PendingActionUpdate,
|
||||
ReservationCreate,
|
||||
ReservationPatch,
|
||||
ReservationRead,
|
||||
TaskCreate,
|
||||
TaskPatch,
|
||||
TaskRead,
|
||||
TranscriptChunk,
|
||||
TranscriptionJobRead,
|
||||
)
|
||||
from app.executor.action_executor import ActionExecutor
|
||||
from app.infrastructure.database import Note, PendingAction, Reservation, Task, get_session
|
||||
from app.infrastructure.soniox import SonioxTranscriptionService, jobs
|
||||
|
||||
router = APIRouter()
|
||||
DbSession = Annotated[Session, Depends(get_session)]
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=TaskRead, operation_id="create_task", summary="Create task")
|
||||
def create_task(payload: TaskCreate, session: DbSession) -> Task:
|
||||
task = Task(**payload.model_dump())
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=TaskRead, operation_id="update_task", summary="Update task")
|
||||
def update_task(task_id: int, payload: TaskPatch, session: DbSession) -> Task:
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(task, key, value)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/notes", response_model=NoteRead, operation_id="create_note", summary="Create note")
|
||||
def create_note(payload: NoteCreate, session: DbSession) -> Note:
|
||||
note = Note(**payload.model_dump())
|
||||
session.add(note)
|
||||
session.commit()
|
||||
session.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
@router.post("/reservations", response_model=ReservationRead, operation_id="create_reservation", summary="Create reservation")
|
||||
def create_reservation(payload: ReservationCreate, session: DbSession) -> Reservation:
|
||||
reservation = Reservation(**payload.model_dump())
|
||||
session.add(reservation)
|
||||
session.commit()
|
||||
session.refresh(reservation)
|
||||
return reservation
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/reservations/{reservation_id}",
|
||||
response_model=ReservationRead,
|
||||
operation_id="update_reservation",
|
||||
summary="Update reservation",
|
||||
)
|
||||
def update_reservation(reservation_id: int, payload: ReservationPatch, session: DbSession) -> Reservation:
|
||||
reservation = session.get(Reservation, reservation_id)
|
||||
if not reservation:
|
||||
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||
for key, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(reservation, key, value)
|
||||
session.commit()
|
||||
session.refresh(reservation)
|
||||
return reservation
|
||||
|
||||
|
||||
@router.get("/pending-actions", response_model=list[PendingActionRead])
|
||||
def list_pending_actions(session: DbSession) -> list[PendingAction]:
|
||||
return session.query(PendingAction).order_by(PendingAction.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.patch("/pending-actions/{action_id}", response_model=PendingActionRead)
|
||||
def edit_pending_action(action_id: int, payload: PendingActionUpdate, session: DbSession) -> PendingAction:
|
||||
action = session.get(PendingAction, action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Pending action not found")
|
||||
if payload.payload is not None:
|
||||
action.payload = payload.payload
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
return action
|
||||
|
||||
|
||||
@router.post("/pending-actions/{action_id}/approve", response_model=PendingActionRead)
|
||||
async def approve_action(action_id: int, session: DbSession, request: Request) -> PendingAction:
|
||||
action = session.get(PendingAction, action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Pending action not found")
|
||||
action.status = ActionStatus.APPROVED
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
generated = await ActionExecutor(tool_registry(request)).execute(session, action)
|
||||
event_publisher().publish(
|
||||
EventType.API_CALL_GENERATED,
|
||||
f"approve-{action_id}",
|
||||
{"action_id": action_id, "status": generated.status, "api_call": generated.execution_result},
|
||||
)
|
||||
return generated
|
||||
|
||||
|
||||
@router.post("/pending-actions/{action_id}/reject", response_model=PendingActionRead)
|
||||
def reject_action(action_id: int, session: DbSession) -> PendingAction:
|
||||
action = session.get(PendingAction, action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Pending action not found")
|
||||
action.status = ActionStatus.REJECTED
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
event_publisher().publish(EventType.ACTION_REJECTED, f"reject-{action_id}", {"action_id": action_id})
|
||||
return action
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=DashboardRead)
|
||||
def dashboard(session: DbSession):
|
||||
return DashboardRead(
|
||||
transcript=context_builder().transcript(),
|
||||
context=context_builder().snapshot(
|
||||
{
|
||||
"tasks": session.query(Task).count(),
|
||||
"notes": session.query(Note).count(),
|
||||
"reservations": session.query(Reservation).count(),
|
||||
}
|
||||
),
|
||||
memories=memory_manager().retrieve("wedding venue flowers tasks", limit=6),
|
||||
pending_actions=session.query(PendingAction).order_by(PendingAction.created_at.desc()).all(),
|
||||
tasks=session.query(Task).order_by(Task.created_at.desc()).all(),
|
||||
notes=session.query(Note).order_by(Note.created_at.desc()).all(),
|
||||
reservations=session.query(Reservation).order_by(Reservation.created_at.desc()).all(),
|
||||
events=event_publisher().recent(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
def tools(request: Request):
|
||||
return [tool.model_dump() for tool in tool_registry(request).list()]
|
||||
|
||||
|
||||
@router.post("/transcriptions", response_model=TranscriptionJobRead, status_code=202)
|
||||
async def create_transcription_job(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
audio: UploadFile = File(...),
|
||||
) -> TranscriptionJobRead:
|
||||
payload = await audio.read()
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="No audio file provided.")
|
||||
job = jobs.create()
|
||||
background_tasks.add_task(
|
||||
SonioxTranscriptionService().process_upload,
|
||||
job.job_id,
|
||||
payload,
|
||||
audio.filename or "recording.webm",
|
||||
request,
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/transcriptions/{job_id}", response_model=TranscriptionJobRead)
|
||||
def get_transcription_job(job_id: str) -> TranscriptionJobRead:
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Transcription job not found.")
|
||||
return job
|
||||
|
||||
|
||||
@router.websocket("/ws/transcript")
|
||||
async def transcript_socket(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
db = next(get_session())
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_json()
|
||||
if "timestamp" not in raw:
|
||||
raw["timestamp"] = datetime.utcnow().isoformat()
|
||||
chunk = TranscriptChunk.model_validate(raw)
|
||||
action = await orchestrator(websocket).ingest(db, chunk) # type: ignore[arg-type]
|
||||
await websocket.send_json({"ok": True, "pending_action_id": action.id if action else None})
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user