jeffsi-meet/deploy/meeting-intelligence/api/app/routes/summaries.py

252 lines
7.5 KiB
Python

"""
AI Summary routes.
"""
import json
from typing import Optional, List
import httpx
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
from pydantic import BaseModel
from ..config import settings
import structlog
log = structlog.get_logger()
router = APIRouter()
class ActionItem(BaseModel):
task: str
assignee: Optional[str] = None
due_date: Optional[str] = None
completed: bool = False
class Topic(BaseModel):
topic: str
duration_seconds: Optional[float] = None
relevance_score: Optional[float] = None
class SummaryResponse(BaseModel):
meeting_id: str
summary_text: str
key_points: List[str]
action_items: List[ActionItem]
decisions: List[str]
topics: List[Topic]
sentiment: Optional[str]
model_used: str
generated_at: str
class GenerateSummaryRequest(BaseModel):
force_regenerate: bool = False
# Summarization prompt template
SUMMARY_PROMPT = """You are analyzing a meeting transcript. Your task is to extract key information and provide a structured summary.
## Meeting Transcript:
{transcript}
## Instructions:
Analyze the transcript and extract the following information. Be concise and accurate.
Respond ONLY with a valid JSON object in this exact format (no markdown, no extra text):
{{
"summary": "A 2-3 sentence overview of what was discussed in the meeting",
"key_points": ["Point 1", "Point 2", "Point 3"],
"action_items": [
{{"task": "Description of task", "assignee": "Person name or null", "due_date": "Date or null"}}
],
"decisions": ["Decision 1", "Decision 2"],
"topics": [
{{"topic": "Topic name", "relevance_score": 0.9}}
],
"sentiment": "positive" or "neutral" or "negative" or "mixed"
}}
Remember:
- key_points: 3-5 most important points discussed
- action_items: Tasks that need to be done, with assignees if mentioned
- decisions: Any decisions or conclusions reached
- topics: Main themes discussed with relevance scores (0-1)
- sentiment: Overall tone of the meeting
"""
@router.get("/{meeting_id}/summary", response_model=SummaryResponse)
async def get_summary(request: Request, meeting_id: str):
"""Get AI-generated summary for a meeting."""
db = request.app.state.db
# Verify meeting exists
meeting = await db.get_meeting(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
summary = await db.get_summary(meeting_id)
if not summary:
raise HTTPException(
status_code=404,
detail="No summary available. Use POST to generate one."
)
return SummaryResponse(
meeting_id=meeting_id,
summary_text=summary["summary_text"],
key_points=summary["key_points"] or [],
action_items=[
ActionItem(**item) for item in (summary["action_items"] or [])
],
decisions=summary["decisions"] or [],
topics=[
Topic(**topic) for topic in (summary["topics"] or [])
],
sentiment=summary.get("sentiment"),
model_used=summary["model_used"],
generated_at=summary["generated_at"].isoformat()
)
@router.post("/{meeting_id}/summary", response_model=SummaryResponse)
async def generate_summary(
request: Request,
meeting_id: str,
body: GenerateSummaryRequest,
background_tasks: BackgroundTasks
):
"""Generate AI summary for a meeting."""
db = request.app.state.db
# Verify meeting exists
meeting = await db.get_meeting(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
# Check if summary already exists
if not body.force_regenerate:
existing = await db.get_summary(meeting_id)
if existing:
raise HTTPException(
status_code=409,
detail="Summary already exists. Set force_regenerate=true to regenerate."
)
# Get transcript
segments = await db.get_transcript(meeting_id)
if not segments:
raise HTTPException(
status_code=400,
detail="No transcript available for summarization"
)
# Format transcript for LLM
transcript_text = _format_transcript(segments)
# Generate summary using Ollama
summary_data = await _generate_summary_with_ollama(transcript_text)
# Save summary
await db.save_summary(
meeting_id=meeting_id,
summary_text=summary_data["summary"],
key_points=summary_data["key_points"],
action_items=summary_data["action_items"],
decisions=summary_data["decisions"],
topics=summary_data["topics"],
sentiment=summary_data["sentiment"],
model_used=settings.ollama_model
)
# Update meeting status
await db.update_meeting(meeting_id, status="ready")
# Get the saved summary
summary = await db.get_summary(meeting_id)
return SummaryResponse(
meeting_id=meeting_id,
summary_text=summary["summary_text"],
key_points=summary["key_points"] or [],
action_items=[
ActionItem(**item) for item in (summary["action_items"] or [])
],
decisions=summary["decisions"] or [],
topics=[
Topic(**topic) for topic in (summary["topics"] or [])
],
sentiment=summary.get("sentiment"),
model_used=summary["model_used"],
generated_at=summary["generated_at"].isoformat()
)
def _format_transcript(segments: list) -> str:
"""Format transcript segments for LLM processing."""
lines = []
current_speaker = None
for s in segments:
speaker = s.get("speaker_label") or "Speaker"
if speaker != current_speaker:
lines.append(f"\n[{speaker}]")
current_speaker = speaker
lines.append(s["text"])
return "\n".join(lines)
async def _generate_summary_with_ollama(transcript: str) -> dict:
"""Generate summary using Ollama."""
prompt = SUMMARY_PROMPT.format(transcript=transcript[:15000]) # Limit context
async with httpx.AsyncClient(timeout=120.0) as client:
try:
response = await client.post(
f"{settings.ollama_url}/api/generate",
json={
"model": settings.ollama_model,
"prompt": prompt,
"stream": False,
"format": "json"
}
)
response.raise_for_status()
result = response.json()
response_text = result.get("response", "")
# Parse JSON from response
summary_data = json.loads(response_text)
# Validate required fields
return {
"summary": summary_data.get("summary", "No summary generated"),
"key_points": summary_data.get("key_points", []),
"action_items": summary_data.get("action_items", []),
"decisions": summary_data.get("decisions", []),
"topics": summary_data.get("topics", []),
"sentiment": summary_data.get("sentiment", "neutral")
}
except httpx.HTTPError as e:
log.error("Ollama request failed", error=str(e))
raise HTTPException(
status_code=503,
detail=f"AI service unavailable: {str(e)}"
)
except json.JSONDecodeError as e:
log.error("Failed to parse Ollama response", error=str(e))
raise HTTPException(
status_code=500,
detail="Failed to parse AI response"
)