Compare commits

..

3 Commits

Author SHA1 Message Date
5bf9dda0d2 feat: implement export endpoint for transcription sessions (Phase 3E)
- Add POST /transcribe/sessions/{id}/export endpoint
- Generate SRT (SubRip subtitle format) with timestamps
- Generate TXT (plain text with [HH:MM:SS,mmm] timestamps)
- Generate JSON (structured data: session, segments, summaries, canvas events)
- Return as FileResponse download with Content-Disposition headers
- Filenames include sanitized session title + date
- No API keys stored or logged during export
2026-05-20 22:25:36 +00:00
5e5ac52771 Phase 3B: Implement pluggable LLM client for summary generation
- Create llm_client.py with 5 provider implementations (Anthropic, OpenAI, Ollama, OpenRouter, Google)
- Add build_prompt() helper to construct system/user prompts from templates
- Wire up POST /transcribe/sessions/{id}/summaries endpoint to call LLM client
- Return generated content + token counts (input_tokens, output_tokens)
- API keys passed per-request, never stored or logged
- Uses prompt templates from prompts.py based on summary_type
2026-05-20 22:20:19 +00:00
f4aa28005d feat(cis): implement /database/timetables/current-period endpoint with Neo4j query
- Query Neo4j for Academic/Registration periods where now() is between start_time and end_time
- Return period_id, event_type, event_label, start_time, end_time
- Handles missing teacher or Neo4j connection gracefully
2026-05-20 22:06:46 +00:00
3 changed files with 652 additions and 53 deletions

View File

@ -1,53 +1,365 @@
"""Pluggable LLM client for transcription summaries.
Phase 1: Stub implementation returns TODO string.
Phase 3: Wire up Anthropic, OpenAI, and Ollama providers.
Phase 3: Full implementation with Anthropic, OpenAI, Ollama, OpenRouter, and Google providers.
"""
import os
from typing import Optional
import json
import logging
from typing import Optional, Dict, Any
import aiohttp
from modules.transcription.prompts import PROMPT_TEMPLATES
logger = logging.getLogger(__name__)
# Default models per provider
DEFAULT_MODELS: Dict[str, str] = {
"anthropic": "claude-sonnet-4-6",
"openai": "gpt-4o",
"ollama": "llama3",
"openrouter": "anthropic/claude-sonnet-4-6",
"google": "gemini-2.0-flash",
}
# Timeout for LLM calls (seconds)
LLM_TIMEOUT = 120
class LLMCallResult:
"""Result from an LLM call, containing content and token usage."""
def __init__(self, content: str, input_tokens: Optional[int] = None,
output_tokens: Optional[int] = None, raw_response: Optional[Dict[str, Any]] = None):
self.content = content
self.input_tokens = input_tokens
self.output_tokens = output_tokens
self.raw_response = raw_response
def to_dict(self) -> Dict[str, Any]:
return {
"content": self.content,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
}
async def call_llm(
provider: str,
model: str,
api_key: str,
system_prompt: str,
user_message: str,
) -> str:
model: Optional[str] = None,
api_key: str = "",
system_prompt: str = "",
user_message: str = "",
) -> LLMCallResult:
"""Call an LLM to generate a summary.
Phase 1 stub returns a TODO string.
Phase 3 will implement actual provider routing.
Routes to the appropriate provider implementation.
Args:
provider: 'anthropic', 'openai', 'ollama', 'openrouter', 'google'
model: Model name (e.g. 'claude-sonnet-4-6', 'gpt-4o', 'llama3')
api_key: User's API key (from localStorage, passed per-request)
model: Model name (falls back to provider default if None)
api_key: User's API key (from frontend, passed per-request)
system_prompt: System prompt template (already filled with transcript)
user_message: User message content
Returns:
LLM-generated summary text
LLMCallResult with generated summary text and token counts
Raises:
ValueError: If provider is not supported
Exception: If the API call fails
"""
# Phase 1 stub — TODO: implement in Phase 3
return f"[TODO: Implement LLM call for provider={provider}, model={model}]"
provider = provider.lower().strip()
if model is None:
model = DEFAULT_MODELS.get(provider, "")
dispatch = {
"anthropic": call_anthropic,
"openai": call_openai,
"ollama": call_ollama,
"openrouter": call_openrouter,
"google": call_google,
}
if provider not in dispatch:
raise ValueError(
f"Unsupported provider: {provider}. "
f"Supported: {', '.join(dispatch.keys())}"
)
logger.info(f"Calling LLM provider={provider} model={model}")
result = await dispatch[provider](
api_key=api_key, model=model,
system_prompt=system_prompt, user_message=user_message,
)
logger.info(f"LLM call complete: provider={provider} tokens_in={result.input_tokens} tokens_out={result.output_tokens}")
return result
async def call_anthropic(api_key: str, model: str, system_prompt: str, user_message: str) -> str:
"""Call Anthropic Claude API."""
# Phase 3 implementation placeholder
return f"[TODO: Anthropic call — model={model}]"
# ---------------------------------------------------------------------------
# Provider implementations
# ---------------------------------------------------------------------------
async def call_anthropic(
api_key: str, model: str, system_prompt: str, user_message: str,
) -> LLMCallResult:
"""Call Anthropic Claude API (messages v2)."""
url = "https://api.anthropic.com/v1/messages"
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
payload = {
"model": model,
"max_tokens": 4096,
"system": system_prompt,
"messages": [{"role": "user", "content": user_message}],
}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT)) as resp:
if resp.status != 200:
body = await resp.text()
logger.error(f"Anthropic API error ({resp.status}): {body}")
raise Exception(f"Anthropic API error {resp.status}: {body}")
data = await resp.json()
# Extract content blocks
content_parts = []
for block in data.get("content", []):
if block.get("type") == "text":
content_parts.append(block["text"])
content = "\n".join(content_parts)
# Token counts from response
usage = data.get("usage", {})
input_tokens = usage.get("input_tokens") or usage.get("input_tokens")
output_tokens = usage.get("output_tokens") or usage.get("output_tokens")
# Anthropic v2 uses input_tokens/output_tokens; fall back to input_tokens/input_tokens
if not input_tokens:
input_tokens = usage.get("input_tokens")
if not output_tokens:
output_tokens = usage.get("output_tokens")
return LLMCallResult(
content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
raw_response=data,
)
async def call_openai(api_key: str, model: str, system_prompt: str, user_message: str) -> str:
"""Call OpenAI API."""
# Phase 3 implementation placeholder
return f"[TODO: OpenAI call — model={model}]"
async def call_openai(
api_key: str, model: str, system_prompt: str, user_message: str,
) -> LLMCallResult:
"""Call OpenAI Chat Completions API."""
url = "https://api.openai.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"max_tokens": 4096,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT)) as resp:
if resp.status != 200:
body = await resp.text()
logger.error(f"OpenAI API error ({resp.status}): {body}")
raise Exception(f"OpenAI API error {resp.status}: {body}")
data = await resp.json()
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
usage = data.get("usage", {})
return LLMCallResult(
content=content,
input_tokens=usage.get("prompt_tokens"),
output_tokens=usage.get("completion_tokens"),
raw_response=data,
)
async def call_ollama(api_key: str, model: str, system_prompt: str, user_message: str) -> str:
"""Call local Ollama instance."""
# Phase 3 implementation placeholder
ollama_url = os.getenv("OLLAMA_URL", "https://ollama.kevlarai.com")
return f"[TODO: Ollama call — url={ollama_url}, model={model}]"
async def call_ollama(
api_key: str, model: str, system_prompt: str, user_message: str,
) -> LLMCallResult:
"""Call local Ollama instance (generate endpoint)."""
ollama_url = os.getenv("OLLAMA_URL", "http://localhost:11434")
url = f"{ollama_url}/api/generate"
# Ollama uses a single prompt with system instructions prepended
full_prompt = f"{system_prompt}\n\n{user_message}"
payload = {
"model": model,
"prompt": full_prompt,
"stream": False,
}
headers = {"Content-Type": "application/json"}
# Ollama may not need an API key; include if set
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT)) as resp:
if resp.status != 200:
body = await resp.text()
logger.error(f"Ollama API error ({resp.status}): {body}")
raise Exception(f"Ollama API error {resp.status}: {body}")
data = await resp.json()
content = data.get("response", "")
# Ollama reports total_tokens; split into input/output heuristically
total = data.get("total_tokens", 0)
prompt_tokens = data.get("prompt_eval_count", None)
eval_count = data.get("eval_count", None)
return LLMCallResult(
content=content,
input_tokens=prompt_tokens,
output_tokens=eval_count,
raw_response=data,
)
async def call_openrouter(
api_key: str, model: str, system_prompt: str, user_message: str,
) -> LLMCallResult:
"""Call OpenRouter API (OpenAI-compatible chat completions)."""
url = "https://openrouter.ai/api/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": os.getenv("APP_URL", "https://classroom-copilot.example.com"),
"X-Title": "Classroom Copilot",
}
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_message},
],
"max_tokens": 4096,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT)) as resp:
if resp.status != 200:
body = await resp.text()
logger.error(f"OpenRouter API error ({resp.status}): {body}")
raise Exception(f"OpenRouter API error {resp.status}: {body}")
data = await resp.json()
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "")
usage = data.get("usage", {})
return LLMCallResult(
content=content,
input_tokens=usage.get("prompt_tokens"),
output_tokens=usage.get("completion_tokens"),
raw_response=data,
)
async def call_google(
api_key: str, model: str, system_prompt: str, user_message: str,
) -> LLMCallResult:
"""Call Google Gemini API (generateContent)."""
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
payload = {
"contents": [
{
"role": "user",
"parts": [{"text": user_message}],
}
],
"system_instruction": {
"parts": [{"text": system_prompt}],
},
"generationConfig": {
"maxOutputTokens": 4096,
},
}
headers = {"Content-Type": "application/json"}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=LLM_TIMEOUT)) as resp:
if resp.status != 200:
body = await resp.text()
logger.error(f"Google Gemini API error ({resp.status}): {body}")
raise Exception(f"Google Gemini API error {resp.status}: {body}")
data = await resp.json()
# Extract text from candidates
candidates = data.get("candidates", [])
if candidates:
content_parts = candidates[0].get("content", {}).get("parts", [])
content = "\n".join(p.get("text", "") for p in content_parts)
else:
content = ""
# Token usage from usage_metadata
usage = data.get("usageMetadata", {})
return LLMCallResult(
content=content,
input_tokens=usage.get("promptTokenCount"),
output_tokens=usage.get("candidatesTokenCount"),
raw_response=data,
)
# ---------------------------------------------------------------------------
# Helper: build prompt from template
# ---------------------------------------------------------------------------
def build_prompt(summary_type: str, transcript: str) -> tuple[str, str]:
"""Build system + user prompt from template and transcript.
Args:
summary_type: One of 'full_lesson', 'questions_asked', 'teaching_style',
'key_moments', 'segment'
transcript: The full (or segment) transcript text
Returns:
(system_prompt, user_message) tuple
"""
template = PROMPT_TEMPLATES.get(summary_type, PROMPT_TEMPLATES["full_lesson"])
# The template has {transcript} placeholder — fill it in
filled = template.format(transcript=transcript)
# Split into system and user: everything before "Transcript:" is the system prompt,
# everything from "Transcript:" onward is the user message.
transcript_marker = "\n\nTranscript:\n"
if transcript_marker in filled:
system_prompt, user_message = filled.split(transcript_marker, 1)
user_message = "Transcript:\n" + user_message
else:
system_prompt = "You are an expert educational analyst."
user_message = filled
return system_prompt, user_message

View File

@ -162,9 +162,15 @@ async def process_worker_timetable(file_content, user_node_data, worker_node_dat
finally:
logging.info(f"Closing driver for {worker_node_data['worker_db_name']}")
driver.close_driver(neo_driver)
@router.get("/current-period")
async def get_current_period(user_id: str = ""):
# Phase 1: return stub — TODO: implement Neo4j query in Phase 2
"""Get the current active timetable period for a teacher.
Queries Neo4j for Academic or Registration periods where now() falls
between start_time and end_time for the given teacher's uuid_string.
"""
if not user_id:
return {
"period_id": None,
"event_type": None,
@ -172,3 +178,68 @@ async def get_current_period(user_id: str = ""):
"start_time": None,
"end_time": None,
}
# The user_id from Supabase JWT maps to the TeacherNode's uuid_string
teacher_uuid = user_id
# Try to find the current period across all known databases
# First, try to get the user's database name from the UserNode
neo_driver = driver.get_driver()
if neo_driver is None:
logging.error("Failed to connect to Neo4j for current-period query")
return {
"period_id": None,
"event_type": None,
"event_label": None,
"start_time": None,
"end_time": None,
}
try:
# Query for the current period
# Look for Teacher nodes with matching uuid_string that have relationships to Academic/Registration periods
query = """
MATCH (t:Teacher {uuid_string: $teacher_uuid})-[:HAS_PERIOD]->(p:AcademicPeriod|RegistrationPeriod)
WHERE p.start_time <= datetime() AND p.end_time >= datetime()
RETURN p.uuid_string AS period_id,
p.name AS event_label,
p.start_time AS start_time,
p.end_time AS end_time,
CASE WHEN p:AcademicPeriod THEN 'lesson' ELSE 'registration' END AS event_type
ORDER BY p.start_time ASC
LIMIT 1
"""
with neo_driver.session() as session:
result = session.run(query, teacher_uuid=teacher_uuid)
record = result.single()
if record:
logging.info(f"Found current period for teacher {teacher_uuid}: {record['event_label']}")
return {
"period_id": record["period_id"],
"event_type": record["event_type"],
"event_label": record["event_label"],
"start_time": str(record["start_time"]) if record["start_time"] else None,
"end_time": str(record["end_time"]) if record["end_time"] else None,
}
else:
logging.info(f"No current period found for teacher {teacher_uuid}")
return {
"period_id": None,
"event_type": None,
"event_label": None,
"start_time": None,
"end_time": None,
}
except Exception as e:
logging.error(f"Error querying current period: {str(e)}")
return {
"period_id": None,
"event_type": None,
"event_label": None,
"start_time": None,
"end_time": None,
}
finally:
driver.close_driver(neo_driver)

View File

@ -1,8 +1,13 @@
"""Transcription sessions router — CRUD endpoints for transcription sessions and segments."""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from typing import Optional, List
from datetime import datetime
import io
import json
import tempfile
import os
from modules.auth.supabase_bearer import SupabaseBearer
from modules.transcription.models import (
@ -16,6 +21,10 @@ from modules.transcription.models import (
SummaryResponse,
ExportFormat,
)
from modules.transcription.llm_client import call_llm, build_prompt
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@ -31,6 +40,116 @@ def get_user_id(credentials=Depends(SupabaseBearer())) -> str:
return credentials.get("sub", credentials.get("user_id", ""))
def seconds_to_srt_timestamp(seconds: float) -> str:
"""Convert seconds to SRT timestamp format: HH:MM:SS,mmm"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def generate_srt(segments: List[dict]) -> str:
"""Generate SRT (SubRip subtitle) content from segments."""
srt_entries = []
for idx, seg in enumerate(segments, start=1):
start_sec = float(seg.get("start_seconds", 0))
end_sec = float(seg.get("end_seconds", 0))
text = seg.get("text", "").strip()
if not text:
continue
start_ts = seconds_to_srt_timestamp(start_sec)
end_ts = seconds_to_srt_timestamp(end_sec)
# Clean text for SRT (no line breaks within a subtitle block)
clean_text = text.replace("\n", " ").strip()
srt_entries.append(f"{idx}\n{start_ts} --> {end_ts}\n{clean_text}")
return "\n\n".join(srt_entries) + "\n" if srt_entries else ""
def generate_txt(segments: List[dict]) -> str:
"""Generate plain text transcript with timestamps from segments."""
lines = []
for seg in segments:
start_sec = float(seg.get("start_seconds", 0))
text = seg.get("text", "").strip()
if not text:
continue
ts = seconds_to_srt_timestamp(start_sec)
lines.append(f"[{ts}] {text}")
return "\n".join(lines) + "\n" if lines else ""
def generate_json_export(session: dict, segments: List[dict],
summaries: List[dict],
canvas_events: List[dict]) -> str:
"""Generate structured JSON export with segments, metadata, and canvas events."""
# Build clean segment list (exclude internal DB fields)
clean_segments = []
for seg in segments:
clean_segments.append({
"sequence_index": seg.get("sequence_index"),
"text": seg.get("text", ""),
"start_seconds": float(seg.get("start_seconds", 0)),
"end_seconds": float(seg.get("end_seconds", 0)),
"is_final": seg.get("is_final", True),
"speaker_label": seg.get("speaker_label"),
"keyword_matches": seg.get("keyword_matches"),
})
# Build clean summary list
clean_summaries = []
for s in summaries:
clean_summaries.append({
"id": s.get("id"),
"summary_type": s.get("summary_type"),
"content": s.get("content", ""),
"llm_provider": s.get("llm_provider"),
"llm_model": s.get("llm_model"),
"created_at": s.get("created_at"),
})
# Build clean canvas events list
clean_events = []
for ev in canvas_events:
clean_events.append({
"id": ev.get("id"),
"event_type": ev.get("event_type"),
"session_elapsed_seconds": float(ev.get("session_elapsed_seconds", 0)) if ev.get("session_elapsed_seconds") else None,
"timestamp": ev.get("timestamp"),
"event_payload": ev.get("event_payload", {}),
})
export_data = {
"session": {
"id": session.get("id"),
"title": session.get("title"),
"canvas_type": session.get("canvas_type"),
"started_at": session.get("started_at"),
"ended_at": session.get("ended_at"),
"duration_seconds": session.get("duration_seconds"),
"timetable_period_id": session.get("timetable_period_id"),
"timetable_event_type": session.get("timetable_event_type"),
"timetable_event_label": session.get("timetable_event_label"),
"auto_tagged": session.get("auto_tagged", False),
"llm_provider": session.get("llm_provider"),
"llm_model": session.get("llm_model"),
"word_count": session.get("word_count", 0),
"segment_count": session.get("segment_count", 0),
},
"segments": clean_segments,
"summaries": clean_summaries,
"canvas_events": clean_events,
}
return json.dumps(export_data, indent=2, default=str)
def sanitize_filename(name: str) -> str:
"""Remove or replace characters that are unsafe in filenames."""
safe = "".join(c if c.isalnum() or c in " _-." else "_" for c in name)
return safe[:100] if safe else "export"
@router.post("/sessions", response_model=TranscriptionSessionResponse)
async def create_session(
session_data: TranscriptionSessionCreate,
@ -211,28 +330,86 @@ async def generate_summary(
summary_request: SummaryGenerateRequest,
user_id: str = Depends(get_user_id),
):
"""Generate a summary for a session (Phase 1 stub)."""
"""Generate a summary for a session using the specified LLM provider.
Phase 3: Full implementation calls the pluggable LLM client with
prompt templates from prompts.py. API key is passed per-request and
never stored or logged.
"""
supabase = get_supabase_client()
# Verify session exists and user owns it
session_check = supabase.supabase.table("transcription_sessions").select("id").eq("id", session_id).eq("user_id", user_id).execute()
session_check = supabase.supabase.table("transcription_sessions").select("*").eq("id", session_id).eq("user_id", user_id).execute()
if not session_check.data:
raise HTTPException(status_code=404, detail="Session not found")
# Phase 1 stub: TODO implement LLM call in Phase 3
content = "[TODO: Generate summary via LLM — provider={}, model={}]".format(
summary_request.provider, summary_request.model
session = session_check.data[0]
# Build transcript from segments (or use segment range)
segments_query = supabase.supabase.table("transcription_segments").select("*").eq("session_id", session_id).order("sequence_index")
segments_result = segments_query.execute()
if not segments_result.data:
raise HTTPException(status_code=400, detail="No segments found for this session")
# Apply segment range filter if specified
segments = segments_result.data
if summary_request.segment_range and len(summary_request.segment_range) == 2:
start_idx, end_idx = summary_request.segment_range[0], summary_request.segment_range[1]
if start_idx is not None and end_idx is not None:
segments = segments[start_idx:end_idx]
elif start_idx is not None:
segments = segments[start_idx:]
elif end_idx is not None:
segments = segments[:end_idx]
# Build full transcript text from segments
transcript_parts = [s["text"] for s in segments if s.get("text")]
transcript = "\n".join(transcript_parts)
if not transcript.strip():
raise HTTPException(status_code=400, detail="Transcript is empty — cannot generate summary")
# Build prompt from template
system_prompt, user_message = build_prompt(summary_request.summary_type, transcript)
# Call the LLM client
try:
llm_result = await call_llm(
provider=summary_request.provider,
model=summary_request.model,
api_key=summary_request.api_key,
system_prompt=system_prompt,
user_message=user_message,
)
except Exception as e:
logger.error(f"LLM call failed: {e}")
raise HTTPException(status_code=502, detail=f"LLM generation failed: {str(e)}")
# Determine segment range for storage
seg_start = None
seg_end = None
if summary_request.segment_range and len(summary_request.segment_range) == 2:
seg_start = summary_request.segment_range[0]
seg_end = summary_request.segment_range[1]
# Build the prompt that was used (for audit trail)
prompt_used = f"{system_prompt}\n\n{user_message}" if summary_request.summary_type != "segment" else user_message
# Save summary to database
summary_data = {
"session_id": session_id,
"user_id": user_id,
"summary_type": summary_request.summary_type,
"content": content,
"content": llm_result.content,
"prompt_used": prompt_used,
"llm_provider": summary_request.provider,
"llm_model": summary_request.model,
"input_tokens": llm_result.input_tokens,
"output_tokens": llm_result.output_tokens,
"segment_range_start": seg_start,
"segment_range_end": seg_end,
}
result = supabase.supabase.table("transcription_summaries").insert(summary_data).execute()
@ -268,26 +445,65 @@ async def export_session(
export_format: ExportFormat,
user_id: str = Depends(get_user_id),
):
"""Export session as SRT, TXT, or JSON (Phase 1 stub)."""
"""Export session as SRT, TXT, or JSON file download.
Phase 3E: Full implementation generates properly formatted files
and returns them as downloadable responses. API keys are never stored
or logged during export.
"""
supabase = get_supabase_client()
# Verify ownership
session_check = supabase.supabase.table("transcription_sessions").select("id").eq("id", session_id).eq("user_id", user_id).execute()
session_check = supabase.supabase.table("transcription_sessions").select("*").eq("id", session_id).eq("user_id", user_id).execute()
if not session_check.data:
raise HTTPException(status_code=404, detail="Session not found")
session = session_check.data[0]
# Get segments
segments_result = supabase.supabase.table("transcription_segments").select("*").eq("session_id", session_id).order("sequence_index").execute()
segments = segments_result.data
if export_format.format == "srt":
# Phase 1 stub — implement in Phase 3
return {"format": "srt", "content": "[TODO: Generate SRT from segments]"}
elif export_format.format == "txt":
text = "\n".join(s["text"] for s in segments)
return {"format": "txt", "content": text}
elif export_format.format == "json":
return {"format": "json", "content": {"segments": segments}}
# Get summaries (for JSON export)
summaries_result = supabase.supabase.table("transcription_summaries").select("*").eq("session_id", session_id).execute()
summaries = summaries_result.data
# Get canvas events (for JSON export)
canvas_result = supabase.supabase.table("canvas_events").select("*").eq("session_id", session_id).order("timestamp").execute()
canvas_events = canvas_result.data
fmt = export_format.format.lower()
if fmt == "srt":
content = generate_srt(segments)
filename = f"{sanitize_filename(session.get('title', session_id))}_{session.get('started_at', 'export')[:10]}.srt"
return FileResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/x-subrip",
filename=filename,
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
)
elif fmt == "txt":
content = generate_txt(segments)
filename = f"{sanitize_filename(session.get('title', session_id))}_{session.get('started_at', 'export')[:10]}.txt"
return FileResponse(
io.BytesIO(content.encode("utf-8")),
media_type="text/plain",
filename=filename,
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
)
elif fmt == "json":
content = generate_json_export(session, segments, summaries, canvas_events)
filename = f"{sanitize_filename(session.get('title', session_id))}_{session.get('started_at', 'export')[:10]}.json"
return FileResponse(
io.BytesIO(content.encode("utf-8")),
media_type="application/json",
filename=filename,
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{filename}"},
)
else:
raise HTTPException(status_code=400, detail=f"Unsupported format: {export_format.format}")