Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions backend/api/audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
"""Audio generation endpoint — KB-grounded narration script + optional
OpenAI TTS synthesis. Frontend falls back to browser SpeechSynthesis when
no server-side audio file is produced."""
from __future__ import annotations

import logging
import os
import re
import uuid
from pathlib import Path
from typing import Any

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel

from api.generate import _empty_kb_error, _gather_context, llm_text
from config import SageConfig
from store.db import KnowledgeStore

router = APIRouter(tags=["audio"])
logger = logging.getLogger("sage.audio")

# Words per second — used to estimate segment timings and total duration
# when we can't probe the audio file. Roughly matches OpenAI TTS / typical
# browser SpeechSynthesis pacing.
WORDS_PER_SECOND = 2.5

AUDIO_CACHE_DIR = Path(os.getenv("SAGE_AUDIO_CACHE", "/tmp/sage_audio"))
AUDIO_CACHE_DIR.mkdir(parents=True, exist_ok=True)


# ─────────────────────── Schemas ───────────────────────────────


class AudioRequest(BaseModel):
topic: str | None = None
tome_id: str | None = None
voice: str | None = None # OpenAI TTS voice (alloy, echo, fable, onyx, nova, shimmer)
provider: str | None = None
model: str | None = None


# ─────────────────────── Script generation ─────────────────────


SCRIPT_SYSTEM = """You write spoken-style narration grounded ONLY in the provided source excerpts. The output will be read aloud by a text-to-speech engine.

Rules:
- Conversational, natural prose. Like a podcast host explaining a topic.
- No markdown, no bullet points, no code blocks, no headings, no citations.
- Do not say "in the sources" or "according to chunk 3" — just teach the content.
- 4–8 short paragraphs. Each paragraph 1–3 sentences.
- Roughly 200–350 words total.
- End with a brief closing sentence.
- If the sources are sparse, summarize what's there honestly without inventing facts."""


_SENTENCE_RE = re.compile(r"[^.!?\n]+[.!?]+|\S[^.!?\n]*", re.UNICODE)


def _split_into_segments(script: str) -> list[dict[str, Any]]:
"""Split the script into spoken sentences with estimated start/end times
proportional to word count."""
raw_sentences = [m.group(0).strip() for m in _SENTENCE_RE.finditer(script)]
raw_sentences = [s for s in raw_sentences if s]

if not raw_sentences:
return []

segments: list[dict[str, Any]] = []
cursor = 0.0
for i, text in enumerate(raw_sentences):
words = max(1, len(text.split()))
duration = max(1.2, words / WORDS_PER_SECOND)
segments.append({
"id": f"s{i + 1}",
"text": text,
"startTime": round(cursor, 2),
"endTime": round(cursor + duration, 2),
})
cursor += duration
return segments


# ─────────────────────── TTS synthesis ─────────────────────────


_OPENAI_TTS_VOICES = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"}


def _maybe_synthesize_openai_tts(
script: str, voice: str | None, config: SageConfig,
) -> tuple[str | None, str | None]:
"""If an OpenAI key is configured, synthesize the script to an MP3 on
disk and return (audio_id, voice_used). Otherwise return (None, None)
so the frontend can fall back to browser SpeechSynthesis."""
openai_cfg = config.provider_config("openai")
api_key = openai_cfg.get("api_key") or os.getenv("OPENAI_API_KEY")
if not api_key:
return None, None

selected_voice = (voice or "alloy").lower()
if selected_voice not in _OPENAI_TTS_VOICES:
selected_voice = "alloy"

try:
# Imported lazily so missing SDK never breaks the no-TTS path.
from openai import OpenAI

client = OpenAI(api_key=api_key, base_url=openai_cfg.get("base_url"))
audio_id = uuid.uuid4().hex
out_path = AUDIO_CACHE_DIR / f"{audio_id}.mp3"

with client.audio.speech.with_streaming_response.create(
model="tts-1",
voice=selected_voice,
input=script,
) as response:
response.stream_to_file(out_path)

return audio_id, selected_voice
except Exception as exc: # noqa: BLE001
logger.warning("OpenAI TTS synthesis failed, falling back: %s", exc)
return None, None


# ─────────────────────── Endpoints ─────────────────────────────


@router.post("/api/generate/audio")
async def generate_audio(
req: AudioRequest,
store: KnowledgeStore = Depends(),
config: SageConfig = Depends(),
):
context_text, sources = await _gather_context(
topic=req.topic, tome_id=req.tome_id, store=store, config=config,
max_results=8,
)
if not context_text:
raise _empty_kb_error()

topic_line = (
f"Topic focus: {req.topic.strip()}" if req.topic and req.topic.strip()
else "Topic focus: cover the most important ideas in the sources."
)
user_prompt = (
f"{topic_line}\n"
f"Write the narration now.\n\n"
f"Source excerpts:\n{context_text}"
)

try:
script, provider_used, model_used = await llm_text(
SCRIPT_SYSTEM, user_prompt, req.provider, req.model, config,
)
except Exception as exc: # noqa: BLE001
logger.exception("audio script generation failed")
raise HTTPException(status_code=502, detail=f"Failed to generate script: {exc}")

script = script.strip()
if not script:
raise HTTPException(status_code=502, detail="Model produced an empty script.")

segments = _split_into_segments(script)
estimated_duration = segments[-1]["endTime"] if segments else 0.0

audio_id, used_voice = _maybe_synthesize_openai_tts(script, req.voice, config)
audio_url = f"/api/audio/{audio_id}.mp3" if audio_id else None

voice_label = used_voice or "Browser"

title = (
f"Audio: {req.topic.strip()}" if req.topic and req.topic.strip()
else "Audio overview"
)

return {
"id": audio_id or f"local-{uuid.uuid4().hex[:8]}",
"title": title,
"voice": voice_label,
"provider": provider_used,
"model": model_used,
"script": script,
"segments": segments,
"duration": estimated_duration,
"audio_url": audio_url,
"sources": sources,
"topic": req.topic or "",
}


@router.get("/api/audio/{audio_id}.mp3")
async def get_audio_file(audio_id: str):
"""Serve a previously-synthesized TTS file."""
if not re.fullmatch(r"[a-f0-9\-]{8,}", audio_id):
raise HTTPException(status_code=400, detail="Invalid audio id.")
path = AUDIO_CACHE_DIR / f"{audio_id}.mp3"
if not path.exists():
raise HTTPException(status_code=404, detail="Audio file not found or expired.")
return FileResponse(path, media_type="audio/mpeg", filename=f"{audio_id}.mp3")
33 changes: 31 additions & 2 deletions backend/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,35 @@

router = APIRouter(prefix="/api/chat", tags=["chat"])

SYSTEM_PROMPT = """You are Sage, a helpful knowledge assistant. You have access to the user's personal knowledge base through tools. When answering questions:
SYSTEM_PROMPT = """You are Sage, a helpful knowledge assistant. You have access to the user's personal knowledge base through tools.

When answering questions:
1. Search the knowledge base first using search_docs
2. Read relevant documents if needed using read_document
3. Cite sources with numbered references like [1], [2]
4. Be concise but thorough
5. If nothing relevant is found, say so honestly

Always ground your answers in the user's actual documents."""
Always ground your answers in the user's actual documents.

# App capabilities you should know about

You run inside the Sage app. The app's frontend watches every message the user sends — to the command bar OR to you in chat — and routes prompts containing certain keywords to dedicated views instead of returning a chat reply. You should be aware of these so you can guide the user accurately when they ask "what can you do?" or how to access a feature. Do NOT try to render these artifacts yourself in markdown — tell the user the keyword to type and the app will open the right view.

Routing keywords (case-insensitive):
- "quiz", or whole-word "test" / "tests" / "testing" → generates a real multiple-choice quiz from the knowledge base via POST /api/generate/quiz, rendered in the QuizWidget.
- "flashcard" / "flash card" → generates real study flashcards from the knowledge base via POST /api/generate/flashcards, rendered in the FlashcardWidget.
- "audio", "listen", "podcast" → generates a spoken-style narration script from the knowledge base via POST /api/generate/audio. If an OpenAI API key is configured, the script is synthesized to an MP3 (OpenAI TTS) and played in an audio element; otherwise playback falls back to the browser's SpeechSynthesis voice. Transcript scrolls with the audio.
- "report", "study guide", "summary" → opens the report view (sample report for now).
- "history" → opens the history panel of past sessions.
- "tome", "collection", "library" → opens the tome selector for grouping documents.
- "knowledge", "kb", "docs", "documents", "knowledge base", "view/show/list documents" → opens the KnowledgeBaseWidget which lists every ingested document with detail + delete.

Other things the user can do in this app:
- Upload documents to the knowledge base using the circular upload button to the right of the command bar. It accepts pasted text or text-like files (.txt, .md, .csv, .json, .log). PDFs/DOCX/images are not yet supported via the UI.
- Anything ingested is chunked, embedded, and dedup'd by content hash; you can immediately search it via search_docs.

If the user asks for one of the routed features inside chat (e.g. "make me flashcards on attention"), the app will switch views automatically — you will not see the follow-up. So when that happens, you do not need to reply at all. If the user is asking *about* a feature rather than invoking it, explain it using the information above."""


class ChatRequest(BaseModel):
Expand All @@ -35,6 +55,15 @@ class ChatRequest(BaseModel):
model: str | None = None


@router.get("/sessions")
async def list_sessions(
limit: int = 100,
store: KnowledgeStore = Depends(),
):
"""Return recent chat sessions for the History panel."""
return {"sessions": store.list_sessions(limit=limit)}


@router.post("")
async def chat(
req: ChatRequest,
Expand Down
Loading