Implement AI orchestration wedding demo

This commit is contained in:
pelpanagiotis
2026-06-27 13:50:11 +03:00
commit 55d4fe95b4
67 changed files with 4736 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
# Infrastructure
SQLAlchemy persistence, Redis Stream event publication, and runtime adapters. Local fallbacks keep tests and offline demos usable.

View File

@@ -0,0 +1 @@
"""Infrastructure adapters for persistence, queues, and external services."""

View 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()

View 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:]

View 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