Implement AI orchestration wedding demo
This commit is contained in:
11
backend/README.md
Normal file
11
backend/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Backend
|
||||
|
||||
FastAPI service containing the REST demo backend, WebSocket streaming gateway, generated tool registry, planner, policy engine, validation layer, executor, memory adapters, and event publisher.
|
||||
|
||||
Run locally:
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
The planner uses Anthropic Claude when `ANTHROPIC_API_KEY` is configured and otherwise falls back to deterministic intent extraction. The default model is `claude-sonnet-4-6`.
|
||||
36
backend/alembic.ini
Normal file
36
backend/alembic.ini
Normal file
@@ -0,0 +1,36 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
sqlalchemy.url = sqlite:///./orchestrator.db
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
35
backend/alembic/env.py
Normal file
35
backend/alembic/env.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.infrastructure.database import Base
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(url=get_settings().database_url, target_metadata=target_metadata, literal_binds=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
70
backend/alembic/versions/0001_initial.py
Normal file
70
backend/alembic/versions/0001_initial.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-06-26 00:00:00.000000
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0001_initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tasks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("title", sa.String(length=240), nullable=False),
|
||||
sa.Column("assignee", sa.String(length=120), nullable=True),
|
||||
sa.Column("due_date", sa.DateTime(), nullable=True),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"notes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("content", sa.String(length=2000), nullable=False),
|
||||
sa.Column("category", sa.String(length=40), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"reservations",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("vendor", sa.String(length=180), nullable=False),
|
||||
sa.Column("reservation_date", sa.DateTime(), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_table(
|
||||
"pending_actions",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("meeting_id", sa.String(length=120), nullable=False),
|
||||
sa.Column("tool", sa.String(length=120), nullable=False),
|
||||
sa.Column("intent", sa.String(length=120), nullable=False),
|
||||
sa.Column("reasoning", sa.String(length=1000), nullable=False),
|
||||
sa.Column("confidence", sa.Float(), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("extracted_entities", sa.JSON(), nullable=False),
|
||||
sa.Column("validation", sa.JSON(), nullable=False),
|
||||
sa.Column("policy", sa.JSON(), nullable=False),
|
||||
sa.Column("status", sa.String(length=40), nullable=False),
|
||||
sa.Column("execution_result", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_pending_actions_meeting_id", "pending_actions", ["meeting_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_pending_actions_meeting_id", table_name="pending_actions")
|
||||
op.drop_table("pending_actions")
|
||||
op.drop_table("reservations")
|
||||
op.drop_table("notes")
|
||||
op.drop_table("tasks")
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Wedding planning AI orchestrator backend."""
|
||||
3
backend/app/ai/README.md
Normal file
3
backend/app/ai/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# AI
|
||||
|
||||
OpenAPI tool generation and registry code. REST operations are converted into planner-visible tool definitions so tools are not hand-authored.
|
||||
1
backend/app/ai/__init__.py
Normal file
1
backend/app/ai/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""AI planner and tool generation modules."""
|
||||
71
backend/app/ai/tool_generator.py
Normal file
71
backend/app/ai/tool_generator.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.domain.schemas import ToolDefinition
|
||||
|
||||
|
||||
def operation_name(method: str, path: str) -> str:
|
||||
clean = path.strip("/").replace("{", "").replace("}", "")
|
||||
parts = [part for part in re.split(r"[/_-]+", clean) if part and part != "id"]
|
||||
resource = "_".join(parts)
|
||||
verb = {"POST": "create", "PATCH": "update", "PUT": "replace", "GET": "get", "DELETE": "delete"}[method.upper()]
|
||||
return f"{verb}_{resource}".rstrip("_")
|
||||
|
||||
|
||||
def path_params(path: str) -> list[str]:
|
||||
return re.findall(r"{([^}]+)}", path)
|
||||
|
||||
|
||||
class OpenAPIToolGenerator:
|
||||
"""Generates callable tool metadata from OpenAPI operations."""
|
||||
|
||||
def __init__(self, spec: dict[str, Any]) -> None:
|
||||
self.spec = spec
|
||||
|
||||
def generate(self) -> list[ToolDefinition]:
|
||||
tools: list[ToolDefinition] = []
|
||||
for path, methods in self.spec.get("paths", {}).items():
|
||||
for method, operation in methods.items():
|
||||
upper = method.upper()
|
||||
if upper not in {"POST", "PATCH", "PUT", "DELETE", "GET"}:
|
||||
continue
|
||||
request_schema = self._request_schema(operation)
|
||||
params = path_params(path)
|
||||
if not request_schema and upper in {"POST", "PATCH", "PUT"}:
|
||||
continue
|
||||
schema = request_schema or {"type": "object", "properties": {}}
|
||||
if params:
|
||||
schema = {
|
||||
**schema,
|
||||
"properties": {
|
||||
**schema.get("properties", {}),
|
||||
**{param: {"type": "string"} for param in params},
|
||||
},
|
||||
"required": list(dict.fromkeys([*schema.get("required", []), *params])),
|
||||
}
|
||||
tools.append(
|
||||
ToolDefinition(
|
||||
name=operation.get("operationId") or operation_name(upper, path),
|
||||
description=operation.get("summary") or operation.get("description") or f"{upper} {path}",
|
||||
method=upper,
|
||||
path=path,
|
||||
schema=schema,
|
||||
required=schema.get("required", []),
|
||||
path_params=params,
|
||||
)
|
||||
)
|
||||
return tools
|
||||
|
||||
def _request_schema(self, operation: dict[str, Any]) -> dict[str, Any] | None:
|
||||
body = operation.get("requestBody", {}).get("content", {}).get("application/json", {})
|
||||
schema = body.get("schema")
|
||||
if not schema:
|
||||
return None
|
||||
return self._resolve(schema)
|
||||
|
||||
def _resolve(self, schema: dict[str, Any]) -> dict[str, Any]:
|
||||
ref = schema.get("$ref")
|
||||
if not ref:
|
||||
return schema
|
||||
name = ref.split("/")[-1]
|
||||
return self.spec.get("components", {}).get("schemas", {}).get(name, {})
|
||||
15
backend/app/ai/tool_registry.py
Normal file
15
backend/app/ai/tool_registry.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from app.domain.schemas import ToolDefinition
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self, tools: list[ToolDefinition]) -> None:
|
||||
self._tools = {tool.name: tool for tool in tools}
|
||||
|
||||
def list(self) -> list[ToolDefinition]:
|
||||
return list(self._tools.values())
|
||||
|
||||
def get(self, name: str) -> ToolDefinition | None:
|
||||
return self._tools.get(name)
|
||||
|
||||
def names(self) -> set[str]:
|
||||
return set(self._tools)
|
||||
168
backend/app/ai/wed_api_spec.py
Normal file
168
backend/app/ai/wed_api_spec.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Wed backend OpenAPI subset used to generate orchestration tools.
|
||||
|
||||
The source backend lives at `/Volumes/Corsair/Wed/wed-backend` and exposes these
|
||||
FastAPI routes:
|
||||
|
||||
- `POST /tasks/`
|
||||
- `PATCH /tasks/{task_id}`
|
||||
- `POST /notes/`
|
||||
- `PATCH /notes/{note_id}`
|
||||
- `POST /reservations/guest`
|
||||
- `PATCH /reservations/{reservation_id}`
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def wed_openapi_subset() -> dict[str, Any]:
|
||||
"""Return a minimal OpenAPI document matching the real Wed backend payloads."""
|
||||
|
||||
return {
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "Wedding Plan API", "version": "1.0"},
|
||||
"paths": {
|
||||
"/tasks/": {
|
||||
"post": {
|
||||
"operationId": "create_task",
|
||||
"summary": "Create task in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/TaskCreate"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/tasks/{task_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_task",
|
||||
"summary": "Update task in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UpdateTask"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/notes/": {
|
||||
"post": {
|
||||
"operationId": "create_note",
|
||||
"summary": "Create note in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NoteCreateSchema"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/notes/{note_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_note",
|
||||
"summary": "Update note in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/NoteUpdateSchema"}}}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/reservations/guest": {
|
||||
"post": {
|
||||
"operationId": "create_guest_reservation",
|
||||
"summary": "Create guest reservation request in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/ReservationPendingCreateGuestSchema"}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
"/reservations/{reservation_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_reservation",
|
||||
"summary": "Update reservation in Wed backend",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/ReservationsUpdateSchema"}}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"TaskCreate": {
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"description": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None},
|
||||
"notes": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None},
|
||||
"is_completed": {"type": "boolean", "default": False},
|
||||
"due_date": {
|
||||
"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}],
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
"UpdateTask": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"description": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"notes": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"is_completed": {"anyOf": [{"type": "boolean"}, {"type": "null"}]},
|
||||
"due_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
"NoteCreateSchema": {
|
||||
"type": "object",
|
||||
"required": ["content"],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"reservation_id": {
|
||||
"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}],
|
||||
"default": None,
|
||||
},
|
||||
},
|
||||
},
|
||||
"NoteUpdateSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"content": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"reservation_id": {"anyOf": [{"type": "string", "format": "uuid"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
"ReservationPendingCreateGuestSchema": {
|
||||
"type": "object",
|
||||
"required": ["partner_id", "guest_first_name", "guest_last_name", "guest_email"],
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"partner_id": {"type": "string", "format": "uuid"},
|
||||
"guest_first_name": {"type": "string"},
|
||||
"guest_last_name": {"type": "string"},
|
||||
"guest_email": {"type": "string", "format": "email"},
|
||||
"guest_phone": {"anyOf": [{"type": "string"}, {"type": "null"}], "default": None},
|
||||
"event_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]},
|
||||
"details": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"budget_per_reservation": {"anyOf": [{"type": "number"}, {"type": "null"}]},
|
||||
"interested_dates": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"guest_count": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
|
||||
"event_type": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"other_comments": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
"ReservationsUpdateSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"status": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"event_date": {"anyOf": [{"type": "string", "format": "date-time"}, {"type": "null"}]},
|
||||
"details": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"budget_per_reservation": {"anyOf": [{"type": "number"}, {"type": "null"}]},
|
||||
"interested_dates": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"guest_count": {"anyOf": [{"type": "integer"}, {"type": "null"}]},
|
||||
"event_type": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"other_comments": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
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()
|
||||
3
backend/app/application/README.md
Normal file
3
backend/app/application/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Application
|
||||
|
||||
Application services coordinate transcript events, context, memory, planning, validation, policy checks, persistence, and event publication.
|
||||
1
backend/app/application/__init__.py
Normal file
1
backend/app/application/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Application services coordinating domain and infrastructure."""
|
||||
72
backend/app/application/orchestrator.py
Normal file
72
backend/app/application/orchestrator.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
from app.domain.schemas import EventType, PendingActionCreate, TranscriptChunk
|
||||
from app.infrastructure.database import PendingAction
|
||||
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
|
||||
from app.validation.action_validator import ActionValidator
|
||||
from app.validation.policy_engine import PolicyEngine
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""Turns transcript events into pending, validated review actions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
context: ContextBuilder,
|
||||
memory: MemoryManager,
|
||||
planner: HybridPlanner,
|
||||
registry: ToolRegistry,
|
||||
publisher: EventPublisher,
|
||||
) -> None:
|
||||
self.context = context
|
||||
self.memory = memory
|
||||
self.planner = planner
|
||||
self.registry = registry
|
||||
self.publisher = publisher
|
||||
|
||||
async def ingest(self, session: Session, chunk: TranscriptChunk) -> PendingAction | None:
|
||||
request_id = str(uuid4())
|
||||
self.context.add_chunk(chunk)
|
||||
self.publisher.publish(EventType.CONVERSATION_EVENT, request_id, chunk.model_dump(mode="json"))
|
||||
memories = self.memory.retrieve(chunk.text)
|
||||
decision = await self.planner.plan(chunk, self.context.snapshot(), memories, self.registry.list())
|
||||
self.context.add_decision(decision.model_dump())
|
||||
|
||||
validation = ActionValidator(self.registry).validate(decision)
|
||||
existing = [row.payload for row in session.query(PendingAction).filter(PendingAction.status == "pending_review").all()]
|
||||
policy = PolicyEngine(existing).evaluate(decision)
|
||||
if not validation.valid or not policy.allowed:
|
||||
self.publisher.publish(
|
||||
EventType.ACTION_REJECTED,
|
||||
request_id,
|
||||
{"decision": decision.model_dump(), "validation": validation.model_dump(), "policy": policy.model_dump()},
|
||||
)
|
||||
return None
|
||||
action_in = PendingActionCreate(
|
||||
meeting_id="demo-meeting",
|
||||
tool=decision.tool or "",
|
||||
intent=decision.intent,
|
||||
reasoning=decision.reasoning,
|
||||
confidence=decision.confidence,
|
||||
payload=decision.arguments,
|
||||
extracted_entities=decision.extracted_entities,
|
||||
validation=validation,
|
||||
policy=policy,
|
||||
)
|
||||
row = action_in.model_dump(mode="json")
|
||||
row["validation"] = validation.model_dump()
|
||||
row["policy"] = policy.model_dump()
|
||||
action = PendingAction(**row, status="pending_review")
|
||||
session.add(action)
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
self.memory.upsert("pending_action", f"{action.intent}: {action.payload}", {"action_id": action.id})
|
||||
self.publisher.publish(EventType.ACTION_CREATED, request_id, {"action_id": action.id, "decision": decision.model_dump()})
|
||||
self.publisher.publish(EventType.MEMORY_UPDATED, request_id, {"action_id": action.id, "type": "pending_action"})
|
||||
return action
|
||||
38
backend/app/config.py
Normal file
38
backend/app/config.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Runtime configuration shared by API, workers, and simulator."""
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
app_name: str = "AI Orchestrator Wedding Demo"
|
||||
environment: str = "local"
|
||||
database_url: str = "sqlite:///./orchestrator.db"
|
||||
redis_url: str = "redis://redis:6379/0"
|
||||
qdrant_url: str = "http://qdrant:6333"
|
||||
anthropic_api_key: str | None = None
|
||||
anthropic_model: str = "claude-sonnet-4-6"
|
||||
wed_api_base_url: str = "http://localhost:8000"
|
||||
wed_api_openapi_url: str | None = None
|
||||
wed_api_bearer_token: str | None = None
|
||||
wed_default_partner_id: str | None = None
|
||||
wed_guest_first_name: str = "Demo"
|
||||
wed_guest_last_name: str = "Customer"
|
||||
wed_guest_email: str = "demo.customer@example.com"
|
||||
wed_guest_phone: str | None = None
|
||||
soniox_api_key: str | None = None
|
||||
soniox_model: str = "stt-async-preview"
|
||||
soniox_poll_interval_seconds: float = 3.0
|
||||
soniox_poll_attempts: int = 240
|
||||
planner_confidence_threshold: float = 0.72
|
||||
backend_base_url: str = "http://localhost:9000"
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173", "http://127.0.0.1:5173"])
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
3
backend/app/domain/README.md
Normal file
3
backend/app/domain/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Domain
|
||||
|
||||
Pydantic models and lifecycle enums shared across planner, validation, executor, API, and frontend payloads.
|
||||
1
backend/app/domain/__init__.py
Normal file
1
backend/app/domain/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Domain models and business concepts."""
|
||||
178
backend/app/domain/schemas.py
Normal file
178
backend/app/domain/schemas.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ActionStatus(StrEnum):
|
||||
CREATED = "created"
|
||||
PENDING_REVIEW = "pending_review"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXECUTED = "executed"
|
||||
EXECUTION_FAILED = "execution_failed"
|
||||
|
||||
|
||||
class EventType(StrEnum):
|
||||
CONVERSATION_EVENT = "ConversationEvent"
|
||||
ACTION_CREATED = "ActionCreated"
|
||||
ACTION_REJECTED = "ActionRejected"
|
||||
ACTION_EXECUTED = "ActionExecuted"
|
||||
API_CALL_GENERATED = "ApiCallGenerated"
|
||||
MEMORY_UPDATED = "MemoryUpdated"
|
||||
CONVERSATION_SUMMARIZED = "ConversationSummarized"
|
||||
|
||||
|
||||
class TranscriptChunk(BaseModel):
|
||||
speaker: str
|
||||
timestamp: datetime
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
|
||||
class ConversationEvent(BaseModel):
|
||||
id: UUID = Field(default_factory=uuid4)
|
||||
request_id: str
|
||||
meeting_id: str = "demo-meeting"
|
||||
chunk: TranscriptChunk
|
||||
|
||||
|
||||
class ToolDefinition(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
method: Literal["GET", "POST", "PATCH", "PUT", "DELETE"]
|
||||
path: str
|
||||
schema: dict[str, Any]
|
||||
required: list[str] = Field(default_factory=list)
|
||||
path_params: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlannerDecision(BaseModel):
|
||||
intent: str
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
reasoning: str
|
||||
tool: str | None = None
|
||||
arguments: dict[str, Any] = Field(default_factory=dict)
|
||||
extracted_entities: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ValidationResult(BaseModel):
|
||||
valid: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PolicyResult(BaseModel):
|
||||
allowed: bool
|
||||
reasons: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PendingActionCreate(BaseModel):
|
||||
meeting_id: str
|
||||
tool: str
|
||||
intent: str
|
||||
reasoning: str
|
||||
confidence: float
|
||||
payload: dict[str, Any]
|
||||
extracted_entities: dict[str, Any] = Field(default_factory=dict)
|
||||
validation: ValidationResult
|
||||
policy: PolicyResult
|
||||
|
||||
|
||||
class PendingActionRead(PendingActionCreate):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
status: ActionStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
execution_result: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PendingActionUpdate(BaseModel):
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
assignee: str | None = None
|
||||
due_date: datetime | None = None
|
||||
status: Literal["pending", "in_progress", "done"] = "pending"
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
title: str | None = None
|
||||
assignee: str | None = None
|
||||
due_date: datetime | None = None
|
||||
status: Literal["pending", "in_progress", "done"] | None = None
|
||||
|
||||
|
||||
class TaskRead(TaskCreate):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
content: str
|
||||
category: Literal["general", "preference", "vendor", "budget"] = "general"
|
||||
|
||||
|
||||
class NoteRead(NoteCreate):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ReservationCreate(BaseModel):
|
||||
vendor: str
|
||||
reservation_date: datetime
|
||||
status: Literal["pending", "reserved", "cancelled"] = "pending"
|
||||
|
||||
|
||||
class ReservationPatch(BaseModel):
|
||||
vendor: str | None = None
|
||||
reservation_date: datetime | None = None
|
||||
status: Literal["pending", "reserved", "cancelled"] | None = None
|
||||
|
||||
|
||||
class ReservationRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
vendor: str
|
||||
reservation_date: datetime
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DashboardRead(BaseModel):
|
||||
transcript: list[TranscriptChunk]
|
||||
context: dict[str, Any]
|
||||
memories: list[dict[str, Any]]
|
||||
pending_actions: list[PendingActionRead]
|
||||
tasks: list[TaskRead]
|
||||
notes: list[NoteRead]
|
||||
reservations: list[ReservationRead]
|
||||
events: list[dict[str, Any]]
|
||||
|
||||
|
||||
class TranscriptSegment(BaseModel):
|
||||
id: str
|
||||
speaker: str
|
||||
original_text: str
|
||||
edited_text: str
|
||||
start_ms: int
|
||||
end_ms: int
|
||||
|
||||
|
||||
class TranscriptionJobRead(BaseModel):
|
||||
job_id: str
|
||||
status: Literal["uploading", "queued", "processing", "completed", "error"]
|
||||
transcription_id: str | None = None
|
||||
segments: list[TranscriptSegment] = Field(default_factory=list)
|
||||
generated_action_ids: list[int] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
created_at: datetime
|
||||
3
backend/app/executor/README.md
Normal file
3
backend/app/executor/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Executor
|
||||
|
||||
Generates exact HTTP call descriptions for approved pending actions. It resolves generated tools, path parameters, headers, URL, JSON body, and cURL, but never sends the request to the Wed backend.
|
||||
1
backend/app/executor/__init__.py
Normal file
1
backend/app/executor/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Action execution services."""
|
||||
57
backend/app/executor/action_executor.py
Normal file
57
backend/app/executor/action_executor.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
from app.config import get_settings
|
||||
from app.domain.schemas import ActionStatus
|
||||
from app.infrastructure.database import PendingAction
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
"""Generates approved API calls without sending them to the Wed backend."""
|
||||
|
||||
def __init__(self, registry: ToolRegistry) -> None:
|
||||
self.registry = registry
|
||||
self.settings = get_settings()
|
||||
self.base_url = self.settings.wed_api_base_url.rstrip("/")
|
||||
|
||||
async def execute(self, session: Session, action: PendingAction) -> PendingAction:
|
||||
if action.status != ActionStatus.APPROVED:
|
||||
raise ValueError("Only approved actions can be converted into API calls.")
|
||||
tool = self.registry.get(action.tool)
|
||||
if not tool:
|
||||
raise ValueError(f"Unknown tool: {action.tool}")
|
||||
path = tool.path
|
||||
body = dict(action.payload)
|
||||
for param in tool.path_params:
|
||||
value = body.pop(param, None)
|
||||
if value is None:
|
||||
raise ValueError(f"Missing path parameter: {param}")
|
||||
path = path.replace(f"{{{param}}}", str(value))
|
||||
headers = {}
|
||||
if self.settings.wed_api_bearer_token:
|
||||
headers["Authorization"] = "Bearer <configured WED_API_BEARER_TOKEN>"
|
||||
action.execution_result = {
|
||||
"generated_only": True,
|
||||
"method": tool.method,
|
||||
"url": f"{self.base_url}{path}",
|
||||
"path": path,
|
||||
"headers": headers,
|
||||
"json": body,
|
||||
"curl": self._curl(tool.method, f"{self.base_url}{path}", headers, body),
|
||||
}
|
||||
session.add(action)
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
return action
|
||||
|
||||
def _curl(self, method: str, url: str, headers: dict[str, str], body: dict[str, Any]) -> str:
|
||||
import json
|
||||
import shlex
|
||||
|
||||
parts = ["curl", "-X", method, url, "-H", "Content-Type: application/json"]
|
||||
for key, value in headers.items():
|
||||
parts.extend(["-H", f"{key}: {value}"])
|
||||
parts.extend(["--data", json.dumps(body, default=str)])
|
||||
return " ".join(shlex.quote(part) for part in parts)
|
||||
3
backend/app/infrastructure/README.md
Normal file
3
backend/app/infrastructure/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Infrastructure
|
||||
|
||||
SQLAlchemy persistence, Redis Stream event publication, and runtime adapters. Local fallbacks keep tests and offline demos usable.
|
||||
1
backend/app/infrastructure/__init__.py
Normal file
1
backend/app/infrastructure/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Infrastructure adapters for persistence, queues, and external services."""
|
||||
80
backend/app/infrastructure/database.py
Normal file
80
backend/app/infrastructure/database.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Float, String, create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def json_type():
|
||||
return JSON()
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class Task(Base, TimestampMixin):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(240))
|
||||
assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending")
|
||||
|
||||
|
||||
class Note(Base, TimestampMixin):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
content: Mapped[str] = mapped_column(String(2000))
|
||||
category: Mapped[str] = mapped_column(String(40), default="general")
|
||||
|
||||
|
||||
class Reservation(Base, TimestampMixin):
|
||||
__tablename__ = "reservations"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
vendor: Mapped[str] = mapped_column(String(180))
|
||||
reservation_date: Mapped[datetime] = mapped_column(DateTime)
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending")
|
||||
|
||||
|
||||
class PendingAction(Base, TimestampMixin):
|
||||
__tablename__ = "pending_actions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
meeting_id: Mapped[str] = mapped_column(String(120), index=True)
|
||||
tool: Mapped[str] = mapped_column(String(120))
|
||||
intent: Mapped[str] = mapped_column(String(120))
|
||||
reasoning: Mapped[str] = mapped_column(String(1000))
|
||||
confidence: Mapped[float] = mapped_column(Float)
|
||||
payload: Mapped[dict] = mapped_column(json_type())
|
||||
extracted_entities: Mapped[dict] = mapped_column(json_type(), default=dict)
|
||||
validation: Mapped[dict] = mapped_column(json_type())
|
||||
policy: Mapped[dict] = mapped_column(json_type())
|
||||
status: Mapped[str] = mapped_column(String(40), default="pending_review")
|
||||
execution_result: Mapped[dict | None] = mapped_column(json_type(), nullable=True)
|
||||
|
||||
|
||||
engine = create_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
def get_session() -> Generator[Session, None, None]:
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
44
backend/app/infrastructure/events.py
Normal file
44
backend/app/infrastructure/events.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import redis
|
||||
|
||||
from app.config import get_settings
|
||||
from app.domain.schemas import EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventPublisher:
|
||||
"""Publishes orchestration events to Redis Streams with in-memory fallback."""
|
||||
|
||||
def __init__(self, redis_url: str | None = None) -> None:
|
||||
self.stream = "orchestrator.events"
|
||||
self._fallback: list[dict[str, Any]] = []
|
||||
self._redis = None
|
||||
try:
|
||||
self._redis = redis.Redis.from_url(redis_url or get_settings().redis_url, decode_responses=True)
|
||||
self._redis.ping()
|
||||
except Exception as exc: # pragma: no cover - depends on local infra
|
||||
self._redis = None
|
||||
logger.warning("Redis unavailable, using in-memory event log: %s", exc)
|
||||
|
||||
def publish(self, event_type: EventType, request_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
event = {
|
||||
"type": event_type.value,
|
||||
"request_id": request_id,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"payload": payload,
|
||||
}
|
||||
if self._redis:
|
||||
self._redis.xadd(self.stream, {"event": json.dumps(event, default=str)}, maxlen=500, approximate=True)
|
||||
self._fallback.append(event)
|
||||
return event
|
||||
|
||||
def recent(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
if self._redis:
|
||||
rows = self._redis.xrevrange(self.stream, count=limit)
|
||||
return [json.loads(fields["event"]) for _, fields in reversed(rows)]
|
||||
return self._fallback[-limit:]
|
||||
214
backend/app/infrastructure/soniox.py
Normal file
214
backend/app/infrastructure/soniox.py
Normal file
@@ -0,0 +1,214 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.dependencies import orchestrator
|
||||
from app.config import get_settings
|
||||
from app.domain.schemas import TranscriptChunk, TranscriptSegment, TranscriptionJobRead
|
||||
from app.infrastructure.database import SessionLocal
|
||||
|
||||
BASE_URL = "https://api.soniox.com/v1"
|
||||
|
||||
|
||||
class SonioxError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _message_from_response(response: httpx.Response, fallback: str) -> str:
|
||||
try:
|
||||
body = response.json()
|
||||
return str(body.get("message") or body.get("error_message") or fallback)
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _token_from_json(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"text": raw.get("text") or "",
|
||||
"start_ms": int(raw.get("start_ms") or 0),
|
||||
"end_ms": int(raw.get("end_ms") or 0),
|
||||
"confidence": float(raw.get("confidence") or 0),
|
||||
"speaker": str(raw.get("speaker") or "unknown"),
|
||||
}
|
||||
|
||||
|
||||
def _ends_sentence(text: str) -> bool:
|
||||
return text.rstrip().endswith((".", "?", "!"))
|
||||
|
||||
|
||||
def group_by_speaker(tokens: list[dict[str, Any]]) -> list[TranscriptSegment]:
|
||||
segments: list[TranscriptSegment] = []
|
||||
active_speaker: str | None = None
|
||||
buffer = ""
|
||||
start_ms = 0
|
||||
end_ms = 0
|
||||
|
||||
def flush() -> None:
|
||||
nonlocal buffer
|
||||
text = buffer.strip()
|
||||
if active_speaker is None or not text:
|
||||
buffer = ""
|
||||
return
|
||||
segments.append(
|
||||
TranscriptSegment(
|
||||
id=str(uuid4()),
|
||||
speaker=active_speaker,
|
||||
original_text=text,
|
||||
edited_text=text,
|
||||
start_ms=start_ms,
|
||||
end_ms=end_ms,
|
||||
)
|
||||
)
|
||||
buffer = ""
|
||||
|
||||
for token in tokens:
|
||||
speaker = token["speaker"] or "unknown"
|
||||
if active_speaker is not None and speaker != active_speaker:
|
||||
flush()
|
||||
if active_speaker != speaker:
|
||||
active_speaker = speaker
|
||||
start_ms = token["start_ms"]
|
||||
buffer += token["text"]
|
||||
end_ms = token["end_ms"]
|
||||
if _ends_sentence(token["text"]):
|
||||
flush()
|
||||
active_speaker = None
|
||||
|
||||
flush()
|
||||
return segments
|
||||
|
||||
|
||||
class TranscriptionJobs:
|
||||
"""In-memory Soniox job store for the demo runtime."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._jobs: dict[str, TranscriptionJobRead] = {}
|
||||
|
||||
def create(self) -> TranscriptionJobRead:
|
||||
job = TranscriptionJobRead(
|
||||
job_id=str(uuid4()),
|
||||
status="uploading",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
self._jobs[job.job_id] = job
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> TranscriptionJobRead | None:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def update(self, job_id: str, **changes: Any) -> TranscriptionJobRead:
|
||||
job = self._jobs[job_id].model_copy(update=changes)
|
||||
self._jobs[job_id] = job
|
||||
return job
|
||||
|
||||
|
||||
jobs = TranscriptionJobs()
|
||||
|
||||
|
||||
class SonioxTranscriptionService:
|
||||
def __init__(self) -> None:
|
||||
self.settings = get_settings()
|
||||
|
||||
async def process_upload(self, job_id: str, audio: bytes, filename: str, request_like: Any) -> None:
|
||||
try:
|
||||
if not self.settings.soniox_api_key:
|
||||
raise SonioxError("SONIOX_API_KEY is not configured.")
|
||||
file_id = await self._upload_file(audio, filename)
|
||||
jobs.update(job_id, status="queued")
|
||||
transcription_id = await self._create_transcription(file_id)
|
||||
jobs.update(job_id, status="processing", transcription_id=transcription_id)
|
||||
await self._wait_until_complete(transcription_id, job_id)
|
||||
segments = await self._fetch_segments(transcription_id)
|
||||
action_ids = await self._ingest_segments(segments, request_like)
|
||||
jobs.update(job_id, status="completed", segments=segments, generated_action_ids=action_ids)
|
||||
except Exception as exc:
|
||||
jobs.update(job_id, status="error", error=str(exc))
|
||||
|
||||
async def _upload_file(self, audio: bytes, filename: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/files",
|
||||
headers={"Authorization": f"Bearer {self.settings.soniox_api_key}"},
|
||||
files={"file": (filename, audio, "application/octet-stream")},
|
||||
)
|
||||
if not response.is_success:
|
||||
raise SonioxError(_message_from_response(response, "Soniox file upload failed."))
|
||||
data = response.json()
|
||||
file_id = str(data.get("id") or data.get("file_id") or "")
|
||||
if not file_id:
|
||||
raise SonioxError("Soniox did not return a file id.")
|
||||
return file_id
|
||||
|
||||
async def _create_transcription(self, file_id: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/transcriptions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.settings.soniox_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
content=json.dumps(
|
||||
{
|
||||
"model": self.settings.soniox_model,
|
||||
"file_id": file_id,
|
||||
"enable_speaker_diarization": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
if response.status_code != 201:
|
||||
raise SonioxError(_message_from_response(response, "Soniox transcription creation failed."))
|
||||
return str(response.json()["id"])
|
||||
|
||||
async def _wait_until_complete(self, transcription_id: str, job_id: str) -> None:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
for _ in range(self.settings.soniox_poll_attempts):
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/transcriptions/{transcription_id}",
|
||||
headers={"Authorization": f"Bearer {self.settings.soniox_api_key}"},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise SonioxError(_message_from_response(response, "Unable to read Soniox status."))
|
||||
data = response.json()
|
||||
status = data.get("status") or "processing"
|
||||
if status == "completed":
|
||||
return
|
||||
if status == "error":
|
||||
raise SonioxError(str(data.get("error_message") or "Soniox transcription failed."))
|
||||
jobs.update(job_id, status="processing")
|
||||
await asyncio.sleep(self.settings.soniox_poll_interval_seconds)
|
||||
raise SonioxError("Timed out waiting for Soniox transcription.")
|
||||
|
||||
async def _fetch_segments(self, transcription_id: str) -> list[TranscriptSegment]:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.get(
|
||||
f"{BASE_URL}/transcriptions/{transcription_id}/transcript",
|
||||
headers={"Authorization": f"Bearer {self.settings.soniox_api_key}"},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise SonioxError(_message_from_response(response, "Unable to download Soniox transcript."))
|
||||
tokens = [_token_from_json(item) for item in response.json().get("tokens", []) if isinstance(item, dict)]
|
||||
return group_by_speaker(tokens)
|
||||
|
||||
async def _ingest_segments(self, segments: list[TranscriptSegment], request_like: Any) -> list[int]:
|
||||
session: Session = SessionLocal()
|
||||
action_ids: list[int] = []
|
||||
try:
|
||||
for segment in segments:
|
||||
action = await orchestrator(request_like).ingest(
|
||||
session,
|
||||
TranscriptChunk(
|
||||
speaker=segment.speaker,
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
text=segment.edited_text,
|
||||
),
|
||||
)
|
||||
if action:
|
||||
action_ids.append(action.id)
|
||||
finally:
|
||||
session.close()
|
||||
return action_ids
|
||||
53
backend/app/main.py
Normal file
53
backend/app/main.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pythonjsonlogger import jsonlogger
|
||||
|
||||
from app.api.routes import router
|
||||
from app.config import get_settings
|
||||
from app.infrastructure.database import init_db
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s %(request_id)s %(latency_ms)s"))
|
||||
logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
configure_logging()
|
||||
settings = get_settings()
|
||||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(router)
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup() -> None:
|
||||
init_db()
|
||||
|
||||
@app.middleware("http")
|
||||
async def request_logging(request: Request, call_next):
|
||||
request_id = request.headers.get("x-request-id", str(uuid4()))
|
||||
start = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
latency_ms = round((time.perf_counter() - start) * 1000, 2)
|
||||
logging.getLogger("app.request").info(
|
||||
"request complete",
|
||||
extra={"request_id": request_id, "latency_ms": latency_ms, "path": request.url.path, "status_code": response.status_code},
|
||||
)
|
||||
response.headers["x-request-id"] = request_id
|
||||
return response
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
3
backend/app/memory/README.md
Normal file
3
backend/app/memory/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Memory
|
||||
|
||||
Context builder and semantic memory manager. Redis and Qdrant are represented behind replaceable service boundaries with deterministic local behavior for tests.
|
||||
1
backend/app/memory/__init__.py
Normal file
1
backend/app/memory/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Short- and long-term memory services."""
|
||||
33
backend/app/memory/context_builder.py
Normal file
33
backend/app/memory/context_builder.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from app.domain.schemas import TranscriptChunk
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Maintains a Redis-compatible meeting context; uses memory for offline tests."""
|
||||
|
||||
def __init__(self, window_size: int = 12) -> None:
|
||||
self.window_size = window_size
|
||||
self._transcript: deque[TranscriptChunk] = deque(maxlen=window_size)
|
||||
self._participants: set[str] = set()
|
||||
self._recent_decisions: deque[dict[str, Any]] = deque(maxlen=10)
|
||||
|
||||
def add_chunk(self, chunk: TranscriptChunk) -> None:
|
||||
self._transcript.append(chunk)
|
||||
self._participants.add(chunk.speaker)
|
||||
|
||||
def add_decision(self, decision: dict[str, Any]) -> None:
|
||||
self._recent_decisions.append(decision)
|
||||
|
||||
def snapshot(self, app_state: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"meeting": {"id": "demo-meeting", "title": "Wedding Planning Review"},
|
||||
"participants": sorted(self._participants),
|
||||
"transcript_window": [chunk.model_dump(mode="json") for chunk in self._transcript],
|
||||
"recent_ai_decisions": list(self._recent_decisions),
|
||||
"application_state": app_state or {},
|
||||
}
|
||||
|
||||
def transcript(self) -> list[TranscriptChunk]:
|
||||
return list(self._transcript)
|
||||
47
backend/app/memory/memory_manager.py
Normal file
47
backend/app/memory/memory_manager.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from hashlib import sha256
|
||||
from math import sqrt
|
||||
from typing import Any
|
||||
|
||||
|
||||
def embed_text(text: str, size: int = 32) -> list[float]:
|
||||
"""Deterministic local embedding used for offline demo and tests."""
|
||||
|
||||
buckets = [0.0] * size
|
||||
for word in text.lower().split():
|
||||
digest = sha256(word.encode()).digest()
|
||||
buckets[digest[0] % size] += 1.0
|
||||
norm = sqrt(sum(v * v for v in buckets)) or 1.0
|
||||
return [v / norm for v in buckets]
|
||||
|
||||
|
||||
class MemoryManager:
|
||||
"""Stores semantic memories behind a Qdrant-like interface with local fallback."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._items: list[dict[str, Any]] = []
|
||||
|
||||
def upsert(self, memory_type: str, text: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
item = {
|
||||
"id": sha256(f"{memory_type}:{text}".encode()).hexdigest()[:16],
|
||||
"type": memory_type,
|
||||
"text": text,
|
||||
"metadata": metadata or {},
|
||||
"embedding": embed_text(text),
|
||||
}
|
||||
self._items = [existing for existing in self._items if existing["id"] != item["id"]]
|
||||
self._items.append(item)
|
||||
return item
|
||||
|
||||
def retrieve(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||
query_vec = embed_text(query)
|
||||
|
||||
def score(item: dict[str, Any]) -> float:
|
||||
return sum(a * b for a, b in zip(query_vec, item["embedding"], strict=True))
|
||||
|
||||
ranked = sorted(self._items, key=score, reverse=True)
|
||||
return [{k: v for k, v in item.items() if k != "embedding"} | {"score": score(item)} for item in ranked[:limit]]
|
||||
|
||||
def seed_demo(self) -> None:
|
||||
self.upsert("customer_preference", "Customer prefers white flowers and a classic minimal arrangement.", {"customer": "Demo"})
|
||||
self.upsert("vendor", "Aegean Blooms is the preferred florist for white flower arrangements.", {"vendor": "Aegean Blooms"})
|
||||
self.upsert("meeting", "Previous meeting agreed to compare venues before reserving July dates.", {"meeting_id": "prior-1"})
|
||||
3
backend/app/planner/README.md
Normal file
3
backend/app/planner/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Planner
|
||||
|
||||
Hybrid planner implementation. Uses Anthropic Claude tool output when `ANTHROPIC_API_KEY` is configured and deterministic extraction otherwise. The default model is `claude-sonnet-4-6`.
|
||||
1
backend/app/planner/__init__.py
Normal file
1
backend/app/planner/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Planner implementations."""
|
||||
166
backend/app/planner/llm_planner.py
Normal file
166
backend/app/planner/llm_planner.py
Normal file
@@ -0,0 +1,166 @@
|
||||
import re
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from app.config import get_settings
|
||||
from app.domain.schemas import PlannerDecision, ToolDefinition, TranscriptChunk
|
||||
|
||||
|
||||
class HybridPlanner:
|
||||
"""Claude planner with deterministic fallback for offline demos."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.settings = get_settings()
|
||||
|
||||
async def plan(
|
||||
self,
|
||||
chunk: TranscriptChunk,
|
||||
context: dict[str, Any],
|
||||
memories: list[dict[str, Any]],
|
||||
tools: list[ToolDefinition],
|
||||
) -> PlannerDecision:
|
||||
if self.settings.anthropic_api_key:
|
||||
try:
|
||||
return await self._plan_with_claude(chunk, context, memories, tools)
|
||||
except Exception:
|
||||
return self._deterministic_plan(chunk)
|
||||
return self._deterministic_plan(chunk)
|
||||
|
||||
async def _plan_with_claude(
|
||||
self,
|
||||
chunk: TranscriptChunk,
|
||||
context: dict[str, Any],
|
||||
memories: list[dict[str, Any]],
|
||||
tools: list[ToolDefinition],
|
||||
) -> PlannerDecision:
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
client = AsyncAnthropic(api_key=self.settings.anthropic_api_key)
|
||||
response = await client.messages.create(
|
||||
model=self.settings.anthropic_model,
|
||||
max_tokens=1200,
|
||||
system=(
|
||||
"You are an event-driven meeting planner. Produce exactly one structured action plan "
|
||||
"or no_action. Never execute tools. Do not invent missing required fields."
|
||||
),
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(
|
||||
{
|
||||
"transcript_chunk": chunk.model_dump(mode="json"),
|
||||
"context": context,
|
||||
"memories": memories,
|
||||
"available_tools": [tool.model_dump() for tool in tools],
|
||||
"business_rules": [
|
||||
"All actions must start as pending review.",
|
||||
"Only select tools from available_tools.",
|
||||
"Do not include parameters that are not in the tool schema.",
|
||||
],
|
||||
},
|
||||
default=str,
|
||||
),
|
||||
}
|
||||
],
|
||||
tools=[
|
||||
{
|
||||
"name": "emit_plan",
|
||||
"description": "Emit the planner decision for the current transcript chunk.",
|
||||
"input_schema": PlannerDecision.model_json_schema(),
|
||||
}
|
||||
],
|
||||
tool_choice={"type": "tool", "name": "emit_plan"},
|
||||
)
|
||||
for block in response.content:
|
||||
if getattr(block, "type", None) == "tool_use" and getattr(block, "name", None) == "emit_plan":
|
||||
return PlannerDecision.model_validate(block.input)
|
||||
raise ValueError("Claude did not emit the required planner tool output.")
|
||||
|
||||
def _deterministic_plan(self, chunk: TranscriptChunk) -> PlannerDecision:
|
||||
text = chunk.text.strip()
|
||||
lowered = text.lower()
|
||||
if "task" in lowered or lowered.startswith("i'll ") or "i will " in lowered:
|
||||
assignee = self._extract_assignee(text) or chunk.speaker
|
||||
title = self._task_title(text)
|
||||
args: dict[str, Any] = {
|
||||
"title": title,
|
||||
"description": f"Follow-up captured from meeting transcript. Suggested owner: {assignee}.",
|
||||
"notes": text,
|
||||
"is_completed": False,
|
||||
}
|
||||
if "tomorrow" in lowered:
|
||||
args["due_date"] = (chunk.timestamp + timedelta(days=1)).isoformat()
|
||||
return PlannerDecision(
|
||||
intent="create_task",
|
||||
confidence=0.94,
|
||||
reasoning="The speaker described an explicit follow-up task.",
|
||||
tool="create_task",
|
||||
arguments=args,
|
||||
extracted_entities={"assignee": assignee, "title": title},
|
||||
)
|
||||
if "note" in lowered or "prefers" in lowered or "preference" in lowered:
|
||||
category = "preference" if "prefer" in lowered else "general"
|
||||
return PlannerDecision(
|
||||
intent="create_note",
|
||||
confidence=0.91,
|
||||
reasoning="The utterance captures information that should be preserved as a note.",
|
||||
tool="create_note",
|
||||
arguments={"content": text},
|
||||
extracted_entities={"category": category},
|
||||
)
|
||||
if "reserve" in lowered or "reservation" in lowered or "venue" in lowered:
|
||||
if not self.settings.wed_default_partner_id:
|
||||
return PlannerDecision(
|
||||
intent="no_action",
|
||||
confidence=0.65,
|
||||
reasoning="A reservation request was detected, but WED_DEFAULT_PARTNER_ID is required to target the Wed backend.",
|
||||
extracted_entities={"missing_configuration": "WED_DEFAULT_PARTNER_ID"},
|
||||
)
|
||||
date = self._extract_date(text, chunk.timestamp)
|
||||
event_type = "Wedding"
|
||||
match = re.search(r"reserve (?:the )?([a-zA-Z ]+?)(?: for| on|$)", text, re.I)
|
||||
if match:
|
||||
event_type = match.group(1).strip().title()
|
||||
return PlannerDecision(
|
||||
intent="create_guest_reservation",
|
||||
confidence=0.89,
|
||||
reasoning="The utterance requests a reservation that requires review before execution.",
|
||||
tool="create_guest_reservation",
|
||||
arguments={
|
||||
"partner_id": self.settings.wed_default_partner_id,
|
||||
"guest_first_name": self.settings.wed_guest_first_name,
|
||||
"guest_last_name": self.settings.wed_guest_last_name,
|
||||
"guest_email": self.settings.wed_guest_email,
|
||||
"guest_phone": self.settings.wed_guest_phone,
|
||||
"event_date": date.isoformat(),
|
||||
"details": text,
|
||||
"event_type": event_type,
|
||||
"interested_dates": date.date().isoformat(),
|
||||
},
|
||||
extracted_entities={"event_type": event_type, "event_date": date.isoformat()},
|
||||
)
|
||||
return PlannerDecision(intent="no_action", confidence=0.55, reasoning="No actionable backend intent was detected.")
|
||||
|
||||
def _extract_assignee(self, text: str) -> str | None:
|
||||
for pattern in [r"for ([A-Z][a-z]+) to", r"([A-Z][a-z]+) to"]:
|
||||
match = re.search(pattern, text)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
def _task_title(self, text: str) -> str:
|
||||
cleaned = re.sub(r"create a task for [A-Z][a-z]+ to ", "", text, flags=re.I)
|
||||
cleaned = re.sub(r"^i(?:'ll| will) ", "", cleaned, flags=re.I)
|
||||
cleaned = cleaned.rstrip(".")
|
||||
return cleaned[:1].upper() + cleaned[1:]
|
||||
|
||||
def _extract_date(self, text: str, fallback: datetime) -> datetime:
|
||||
lowered = text.lower()
|
||||
if "tomorrow" in lowered:
|
||||
return fallback + timedelta(days=1)
|
||||
match = re.search(r"(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})", lowered)
|
||||
if match:
|
||||
month = datetime.strptime(match.group(1), "%B").month
|
||||
return datetime(fallback.year, month, int(match.group(2)), 12, 0)
|
||||
return fallback + timedelta(days=7)
|
||||
41
backend/app/seed.py
Normal file
41
backend/app/seed.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.infrastructure.database import Note, Reservation, SessionLocal, Task, init_db
|
||||
from app.memory.memory_manager import MemoryManager
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
init_db()
|
||||
session = SessionLocal()
|
||||
try:
|
||||
if session.query(Task).count() == 0:
|
||||
session.add_all(
|
||||
[
|
||||
Task(title="Review venue shortlist", assignee="Maria", status="pending"),
|
||||
Task(title="Confirm catering tasting", assignee="Nikos", status="in_progress"),
|
||||
]
|
||||
)
|
||||
if session.query(Note).count() == 0:
|
||||
session.add_all(
|
||||
[
|
||||
Note(content="Customer prefers white flowers.", category="preference"),
|
||||
Note(content="Budget conversation should stay under 20,000 EUR.", category="budget"),
|
||||
]
|
||||
)
|
||||
if session.query(Reservation).count() == 0:
|
||||
session.add(
|
||||
Reservation(
|
||||
vendor="Aegean Garden Venue",
|
||||
reservation_date=datetime.utcnow() + timedelta(days=45),
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
memory = MemoryManager()
|
||||
memory.seed_demo()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed()
|
||||
25
backend/app/simulator.py
Normal file
25
backend/app/simulator.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import websockets
|
||||
|
||||
|
||||
TRANSCRIPT = [
|
||||
{"speaker": "Bride", "text": "Add a note that the customer prefers white flowers."},
|
||||
{"speaker": "Planner", "text": "Create a task for Maria to confirm the photographer."},
|
||||
{"speaker": "Groom", "text": "We should reserve the venue for July 15."},
|
||||
{"speaker": "Bride", "text": "I'll call the florist tomorrow."},
|
||||
]
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with websockets.connect("ws://localhost:9000/ws/transcript") as socket:
|
||||
for item in TRANSCRIPT:
|
||||
await socket.send(json.dumps({**item, "timestamp": datetime.utcnow().isoformat()}))
|
||||
print(await socket.recv())
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
3
backend/app/validation/README.md
Normal file
3
backend/app/validation/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Validation
|
||||
|
||||
Schema validation and policy enforcement for planner decisions before any action enters pending review.
|
||||
1
backend/app/validation/__init__.py
Normal file
1
backend/app/validation/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Validation and policy services."""
|
||||
35
backend/app/validation/action_validator.py
Normal file
35
backend/app/validation/action_validator.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.schemas import PlannerDecision, ValidationResult
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
class ActionValidator:
|
||||
def __init__(self, registry: ToolRegistry) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def validate(self, decision: PlannerDecision) -> ValidationResult:
|
||||
if decision.intent == "no_action":
|
||||
return ValidationResult(valid=False, errors=["No actionable intent detected."])
|
||||
if not decision.tool:
|
||||
return ValidationResult(valid=False, errors=["Planner did not select a tool."])
|
||||
tool = self.registry.get(decision.tool)
|
||||
if not tool:
|
||||
return ValidationResult(valid=False, errors=[f"Unknown tool: {decision.tool}"])
|
||||
try:
|
||||
Draft202012Validator(tool.schema, format_checker=FormatChecker()).validate(decision.arguments)
|
||||
except Exception as exc:
|
||||
return ValidationResult(valid=False, errors=[str(exc)])
|
||||
extra = set(decision.arguments) - set(tool.schema.get("properties", {}))
|
||||
if extra:
|
||||
return ValidationResult(valid=False, errors=[f"Hallucinated parameters: {sorted(extra)}"])
|
||||
try:
|
||||
self._validate_dates(decision.arguments)
|
||||
except ValidationError as exc:
|
||||
return ValidationResult(valid=False, errors=[str(exc)])
|
||||
return ValidationResult(valid=True)
|
||||
|
||||
def _validate_dates(self, args: dict) -> None:
|
||||
# JSON Schema catches shape; FastAPI/Pydantic validates API models at execution time.
|
||||
return None
|
||||
22
backend/app/validation/policy_engine.py
Normal file
22
backend/app/validation/policy_engine.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from app.config import get_settings
|
||||
from app.domain.schemas import PlannerDecision, PolicyResult
|
||||
|
||||
|
||||
class PolicyEngine:
|
||||
def __init__(self, existing_payloads: list[dict] | None = None) -> None:
|
||||
self.threshold = get_settings().planner_confidence_threshold
|
||||
self.existing_payloads = existing_payloads or []
|
||||
|
||||
def evaluate(self, decision: PlannerDecision) -> PolicyResult:
|
||||
reasons: list[str] = []
|
||||
if decision.confidence < self.threshold:
|
||||
reasons.append(f"Confidence {decision.confidence:.2f} is below threshold {self.threshold:.2f}.")
|
||||
if decision.arguments in self.existing_payloads:
|
||||
reasons.append("Duplicate pending action payload.")
|
||||
if decision.tool == "create_guest_reservation" and not decision.arguments.get("event_date"):
|
||||
reasons.append("Reservation requires a date.")
|
||||
if decision.tool == "create_guest_reservation" and not decision.arguments.get("partner_id"):
|
||||
reasons.append("Reservation requires a Wed partner_id.")
|
||||
if decision.tool == "create_task" and not decision.arguments.get("title"):
|
||||
reasons.append("Task requires a title.")
|
||||
return PolicyResult(allowed=not reasons, reasons=reasons)
|
||||
42
backend/pyproject.toml
Normal file
42
backend/pyproject.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=72.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ai-orchestrator-wedding-demo"
|
||||
version = "0.1.0"
|
||||
description = "Event-driven AI orchestrator demo for wedding planning meetings"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"alembic>=1.13.2",
|
||||
"fastapi>=0.115.0",
|
||||
"httpx>=0.27.0",
|
||||
"jsonschema>=4.23.0",
|
||||
"anthropic>=0.45.0",
|
||||
"psycopg[binary]>=3.2.1",
|
||||
"pydantic>=2.8.2",
|
||||
"pydantic-settings>=2.4.0",
|
||||
"python-multipart>=0.0.12",
|
||||
"python-json-logger>=2.0.7",
|
||||
"qdrant-client>=1.11.1",
|
||||
"redis>=5.0.8",
|
||||
"sqlalchemy>=2.0.32",
|
||||
"uvicorn[standard]>=0.30.6",
|
||||
"websockets>=13.0.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3.2",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"respx>=0.21.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
29
backend/tests/test_integration_flow.py
Normal file
29
backend/tests/test_integration_flow.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.infrastructure.database import PendingAction, SessionLocal, init_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_websocket_ingestion_creates_pending_action():
|
||||
init_db()
|
||||
session = SessionLocal()
|
||||
session.query(PendingAction).delete()
|
||||
session.commit()
|
||||
session.close()
|
||||
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws/transcript") as socket:
|
||||
socket.send_json(
|
||||
{
|
||||
"speaker": "Planner",
|
||||
"timestamp": "2026-06-26T12:00:00Z",
|
||||
"text": "Create a task for Maria to confirm the photographer.",
|
||||
}
|
||||
)
|
||||
response = socket.receive_json()
|
||||
|
||||
assert response["ok"] is True
|
||||
assert response["pending_action_id"] is not None
|
||||
|
||||
dashboard = client.get("/dashboard").json()
|
||||
assert any(action["tool"] == "create_task" for action in dashboard["pending_actions"])
|
||||
71
backend/tests/test_memory_executor.py
Normal file
71
backend/tests/test_memory_executor.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import pytest
|
||||
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
from app.domain.schemas import ActionStatus
|
||||
from app.domain.schemas import ToolDefinition
|
||||
from app.executor.action_executor import ActionExecutor
|
||||
from app.infrastructure.database import PendingAction
|
||||
from app.memory.memory_manager import MemoryManager
|
||||
|
||||
|
||||
def test_memory_retrieves_semantic_items():
|
||||
memory = MemoryManager()
|
||||
memory.upsert("preference", "Customer prefers white flowers")
|
||||
|
||||
results = memory.retrieve("white flower preference")
|
||||
|
||||
assert results[0]["type"] == "preference"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_rejects_unapproved_action():
|
||||
registry = ToolRegistry(
|
||||
[
|
||||
ToolDefinition(
|
||||
name="create_task",
|
||||
description="Create task",
|
||||
method="POST",
|
||||
path="/tasks",
|
||||
schema={"type": "object", "properties": {"title": {"type": "string"}}},
|
||||
)
|
||||
]
|
||||
)
|
||||
action = PendingAction(tool="create_task", status="pending_review", payload={"title": "x"})
|
||||
|
||||
with pytest.raises(ValueError, match="Only approved"):
|
||||
await ActionExecutor(registry).execute(None, action) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_generates_call_without_http_request():
|
||||
registry = ToolRegistry(
|
||||
[
|
||||
ToolDefinition(
|
||||
name="update_reservation",
|
||||
description="Update reservation",
|
||||
method="PATCH",
|
||||
path="/reservations/{reservation_id}",
|
||||
path_params=["reservation_id"],
|
||||
schema={"type": "object", "properties": {"reservation_id": {"type": "string"}, "status": {"type": "string"}}},
|
||||
)
|
||||
]
|
||||
)
|
||||
action = PendingAction(tool="update_reservation", status=ActionStatus.APPROVED, payload={"reservation_id": "abc", "status": "ACCEPTED"})
|
||||
|
||||
class FakeSession:
|
||||
def add(self, _action):
|
||||
return None
|
||||
|
||||
def commit(self):
|
||||
return None
|
||||
|
||||
def refresh(self, _action):
|
||||
return None
|
||||
|
||||
result = await ActionExecutor(registry).execute(FakeSession(), action) # type: ignore[arg-type]
|
||||
|
||||
assert result.status == ActionStatus.APPROVED
|
||||
assert result.execution_result["generated_only"] is True
|
||||
assert result.execution_result["method"] == "PATCH"
|
||||
assert result.execution_result["path"] == "/reservations/abc"
|
||||
assert result.execution_result["json"] == {"status": "ACCEPTED"}
|
||||
59
backend/tests/test_planner_validation_policy.py
Normal file
59
backend/tests/test_planner_validation_policy.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ai.tool_registry import ToolRegistry
|
||||
from app.domain.schemas import PlannerDecision, ToolDefinition, TranscriptChunk
|
||||
from app.planner.llm_planner import HybridPlanner
|
||||
from app.validation.action_validator import ActionValidator
|
||||
from app.validation.policy_engine import PolicyEngine
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_planner_extracts_task():
|
||||
planner = HybridPlanner()
|
||||
chunk = TranscriptChunk(speaker="Bride", timestamp=datetime(2026, 6, 26, 12, 0), text="Create a task for Maria to confirm the photographer.")
|
||||
|
||||
decision = await planner.plan(chunk, {}, [], [])
|
||||
|
||||
assert decision.tool == "create_task"
|
||||
assert decision.extracted_entities["assignee"] == "Maria"
|
||||
assert decision.arguments["is_completed"] is False
|
||||
assert "Suggested owner: Maria" in decision.arguments["description"]
|
||||
assert "photographer" in decision.arguments["title"].lower()
|
||||
|
||||
|
||||
def test_validator_rejects_hallucinated_parameters():
|
||||
registry = ToolRegistry(
|
||||
[
|
||||
ToolDefinition(
|
||||
name="create_task",
|
||||
description="Create task",
|
||||
method="POST",
|
||||
path="/tasks",
|
||||
schema={"type": "object", "required": ["title"], "properties": {"title": {"type": "string"}}},
|
||||
required=["title"],
|
||||
)
|
||||
]
|
||||
)
|
||||
decision = PlannerDecision(
|
||||
intent="create_task",
|
||||
confidence=0.9,
|
||||
reasoning="test",
|
||||
tool="create_task",
|
||||
arguments={"title": "Confirm photographer", "unexpected": True},
|
||||
)
|
||||
|
||||
result = ActionValidator(registry).validate(decision)
|
||||
|
||||
assert not result.valid
|
||||
assert "Hallucinated" in result.errors[0]
|
||||
|
||||
|
||||
def test_policy_rejects_low_confidence():
|
||||
decision = PlannerDecision(intent="create_note", confidence=0.2, reasoning="weak", tool="create_note", arguments={"content": "x"})
|
||||
|
||||
result = PolicyEngine().evaluate(decision)
|
||||
|
||||
assert not result.allowed
|
||||
assert "below threshold" in result.reasons[0]
|
||||
17
backend/tests/test_soniox_grouping.py
Normal file
17
backend/tests/test_soniox_grouping.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from app.infrastructure.soniox import group_by_speaker
|
||||
|
||||
|
||||
def test_group_by_speaker_flushes_on_sentence_and_speaker_change():
|
||||
segments = group_by_speaker(
|
||||
[
|
||||
{"speaker": "Bride", "text": "Create ", "start_ms": 0, "end_ms": 100},
|
||||
{"speaker": "Bride", "text": "a task.", "start_ms": 100, "end_ms": 500},
|
||||
{"speaker": "Planner", "text": "Add ", "start_ms": 600, "end_ms": 700},
|
||||
{"speaker": "Planner", "text": "a note.", "start_ms": 700, "end_ms": 1000},
|
||||
]
|
||||
)
|
||||
|
||||
assert len(segments) == 2
|
||||
assert segments[0].speaker == "Bride"
|
||||
assert segments[0].edited_text == "Create a task."
|
||||
assert segments[1].speaker == "Planner"
|
||||
63
backend/tests/test_tool_generation.py
Normal file
63
backend/tests/test_tool_generation.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from app.ai.tool_generator import OpenAPIToolGenerator
|
||||
|
||||
|
||||
def test_generates_tools_from_openapi_spec():
|
||||
spec = {
|
||||
"paths": {
|
||||
"/tasks": {
|
||||
"post": {
|
||||
"operationId": "create_task",
|
||||
"summary": "Create task",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {"$ref": "#/components/schemas/TaskCreate"}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"TaskCreate": {
|
||||
"type": "object",
|
||||
"required": ["title"],
|
||||
"properties": {"title": {"type": "string"}},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
tools = OpenAPIToolGenerator(spec).generate()
|
||||
|
||||
assert tools[0].name == "create_task"
|
||||
assert tools[0].required == ["title"]
|
||||
assert tools[0].path == "/tasks"
|
||||
|
||||
|
||||
def test_generates_path_params_for_patch_tools():
|
||||
spec = {
|
||||
"paths": {
|
||||
"/reservations/{reservation_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_reservation",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"status": {"type": "string"}},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tools = OpenAPIToolGenerator(spec).generate()
|
||||
|
||||
assert tools[0].path_params == ["reservation_id"]
|
||||
assert "reservation_id" in tools[0].schema["required"]
|
||||
Reference in New Issue
Block a user