diff --git a/backend/api/audio.py b/backend/api/audio.py new file mode 100644 index 0000000..a64f89c --- /dev/null +++ b/backend/api/audio.py @@ -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") diff --git a/backend/api/chat.py b/backend/api/chat.py index 4f43675..77d7948 100644 --- a/backend/api/chat.py +++ b/backend/api/chat.py @@ -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 has dedicated views for study artifacts and source management. Normal natural-language questions stay in chat, even if they mention words like history, summary, library, audio, quiz, or documents. Dedicated views open only from first-party UI controls or explicit slash commands, so you can safely answer ordinary questions without expecting the frontend to hijack them. + +Explicit slash commands (case-insensitive, optional text after the command): +- /quiz or /test → generates a real multiple-choice quiz from the knowledge base via POST /api/generate/quiz, rendered in the QuizWidget. +- /flashcards or /flashcard → generates real study flashcards from the knowledge base via POST /api/generate/flashcards, rendered in the FlashcardWidget. +- /audio, /listen, or /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 or /study-guide → opens the report view. +- /history → opens the history panel of past sessions. +- /tomes or /tome → opens the tome selector for grouping documents. +- /knowledge, /sources, or /docs → 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 Add source action. 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 how to access a routed feature, suggest the relevant slash command. Do not render generated quizzes, flashcards, reports, or audio scripts yourself unless the user asks for a plain chat answer instead.""" class ChatRequest(BaseModel): @@ -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, diff --git a/backend/api/generate.py b/backend/api/generate.py new file mode 100644 index 0000000..e7185cc --- /dev/null +++ b/backend/api/generate.py @@ -0,0 +1,479 @@ +"""Generation endpoints — produce structured artifacts (flashcards, quizzes) +grounded in the user's knowledge base.""" +from __future__ import annotations + +import json +import logging +import random +import re +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from config import SageConfig +from providers.base import Message +from providers.factory import create_provider +from skills.base import SkillContext +from skills.search_docs import SearchDocsSkill +from store.db import KnowledgeStore + +router = APIRouter(prefix="/api/generate", tags=["generate"]) +logger = logging.getLogger("sage.generate") + + +# ─────────────────────── Request schemas ─────────────────────── + + +class GenerateRequest(BaseModel): + topic: str | None = None + tome_id: str | None = None + count: int = 6 + provider: str | None = None + model: str | None = None + + +# ─────────────────────── Shared helpers ──────────────────────── + + +async def _gather_context( + topic: str | None, + tome_id: str | None, + store: KnowledgeStore, + config: SageConfig, + max_results: int, +) -> tuple[str, list[dict[str, Any]]]: + """Return (joined context text, list of source dicts). Falls back to + random sampling when there's no topic to drive semantic search.""" + if topic: + skill = SearchDocsSkill() + ctx = SkillContext( + store=store, provider=None, workspace=config.db_path.parent, + config=config, tome_id=tome_id, + ) + result = await skill.execute( + {"query": topic, "max_results": max_results}, ctx, + ) + rows = result.data.get("results", []) if result.data else [] + if rows: + sources = [ + { + "document_id": r["document_id"], + "document_title": r["document_title"], + "chunk_index": r["chunk_index"], + "similarity": r["similarity"], + } + for r in rows + ] + joined = "\n\n".join( + f'[{i + 1}] "{r["document_title"]}" (chunk {r["chunk_index"]})\n{r["content"]}' + for i, r in enumerate(rows) + ) + return joined, sources + + # No topic, or search returned nothing — fall back to random chunks. + if tome_id: + doc_ids = set(store.get_tome_document_ids(tome_id)) + chunks = [c for c in store.get_all_chunks_with_embeddings() if c.document_id in doc_ids] + else: + chunks = store.get_all_chunks_with_embeddings() + + if not chunks: + return "", [] + + sampled = random.sample(chunks, k=min(max_results, len(chunks))) + sources = [] + parts = [] + for i, chunk in enumerate(sampled, 1): + doc = store.get_document(chunk.document_id) + title = doc.title if doc else "Unknown" + sources.append({ + "document_id": chunk.document_id, + "document_title": title, + "chunk_index": chunk.chunk_index, + "similarity": None, + }) + parts.append(f'[{i}] "{title}" (chunk {chunk.chunk_index})\n{chunk.content}') + return "\n\n".join(parts), sources + + +_JSON_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL | re.IGNORECASE) + + +def _extract_json(text: str) -> Any: + """Pull a JSON object out of a model response that may be wrapped in + prose or code fences. Raises ValueError if nothing parses.""" + candidates: list[str] = [] + candidates.extend(m.group(1).strip() for m in _JSON_FENCE_RE.finditer(text)) + + stripped = text.strip() + if stripped: + candidates.append(stripped) + + for start_ch, end_ch in (("{", "}"), ("[", "]")): + first = text.find(start_ch) + last = text.rfind(end_ch) + if first != -1 and last > first: + candidates.append(text[first : last + 1]) + + for candidate in candidates: + try: + return json.loads(candidate) + except json.JSONDecodeError: + continue + raise ValueError("Could not parse JSON from model response") + + +async def _llm_json( + system_prompt: str, + user_prompt: str, + provider_name: str | None, + model: str | None, + config: SageConfig, +) -> Any: + provider_name = provider_name or config.get("providers.default", "ollama") + provider_config = config.provider_config(provider_name) + resolved_model = model or provider_config.get("default_model", "llama3.1:8b") + provider = create_provider(provider_name, provider_config) + + kwargs: dict[str, Any] = {} + if provider_name in ("openai", "deepseek"): + kwargs["response_format"] = {"type": "json_object"} + + messages = [ + Message(role="system", content=system_prompt), + Message(role="user", content=user_prompt), + ] + response = await provider.chat(messages=messages, tools=[], model=resolved_model, **kwargs) + text = getattr(response, "content", "") or "" + return _extract_json(text) + + +async def llm_text( + system_prompt: str, + user_prompt: str, + provider_name: str | None, + model: str | None, + config: SageConfig, +) -> tuple[str, str, str]: + """Call the configured provider with no tools, no JSON-mode. Returns + (content, resolved_provider_name, resolved_model).""" + provider_name = provider_name or config.get("providers.default", "ollama") + provider_config = config.provider_config(provider_name) + resolved_model = model or provider_config.get("default_model", "llama3.1:8b") + provider = create_provider(provider_name, provider_config) + + messages = [ + Message(role="system", content=system_prompt), + Message(role="user", content=user_prompt), + ] + response = await provider.chat(messages=messages, tools=[], model=resolved_model) + return (getattr(response, "content", "") or "", provider_name, resolved_model) + + +def _empty_kb_error() -> HTTPException: + return HTTPException( + status_code=400, + detail=( + "Your knowledge base is empty for this scope. Add documents first " + "via the upload button next to the command bar." + ), + ) + + +# ─────────────────────── Flashcards ──────────────────────────── + + +FLASHCARD_SYSTEM = """You write study flashcards grounded ONLY in the provided source excerpts. + +Rules: +- Each card must be answerable from the sources. Do not invent facts. +- "front" is a concise question or term (1 sentence). +- "back" is a tight, self-contained answer (1–3 sentences). +- Cover distinct ideas — do not repeat the same fact in different words. +- Output ONLY a JSON object of the form: {"cards": [{"front": "...", "back": "..."}, ...]} +- No commentary, no markdown fences.""" + + +@router.post("/flashcards") +async def generate_flashcards( + req: GenerateRequest, + store: KnowledgeStore = Depends(), + config: SageConfig = Depends(), +): + count = max(1, min(req.count, 20)) + context_text, sources = await _gather_context( + topic=req.topic, tome_id=req.tome_id, store=store, config=config, + max_results=max(count, 6), + ) + 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"Generate exactly {count} flashcards.\n\n" + f"Source excerpts:\n{context_text}" + ) + + try: + parsed = await _llm_json(FLASHCARD_SYSTEM, user_prompt, req.provider, req.model, config) + except ValueError as exc: + logger.exception("flashcard json parse failed") + raise HTTPException(status_code=502, detail=f"Model returned unparseable output: {exc}") + + raw_cards = parsed.get("cards") if isinstance(parsed, dict) else parsed + if not isinstance(raw_cards, list): + raise HTTPException(status_code=502, detail="Model output missing 'cards' array") + + cards = [] + for entry in raw_cards[:count]: + if not isinstance(entry, dict): + continue + front = str(entry.get("front", "")).strip() + back = str(entry.get("back", "")).strip() + if not front or not back: + continue + cards.append({"id": f"fc-{uuid.uuid4().hex[:8]}", "front": front, "back": back}) + + if not cards: + raise HTTPException(status_code=502, detail="Model produced no usable flashcards") + + return { + "cards": cards, + "topic": req.topic or "", + "sources": sources, + } + + +# ─────────────────────── Quiz ────────────────────────────────── + + +QUIZ_SYSTEM = """You write multiple-choice quiz questions grounded ONLY in the provided source excerpts. + +Rules: +- Each question must be answerable from the sources. Do not invent facts. +- Exactly 4 options per question, labeled "a", "b", "c", "d". +- Exactly one correct option per question. +- "explanation" should be 1–2 sentences explaining the correct answer using the sources. +- Distractors must be plausible but clearly wrong given the sources. +- Output ONLY a JSON object of the form: + {"questions": [ + {"question": "...", + "options": [{"id":"a","text":"..."},{"id":"b","text":"..."},{"id":"c","text":"..."},{"id":"d","text":"..."}], + "correctOptionId": "b", + "explanation": "..."} + ]} +- No commentary, no markdown fences.""" + + +@router.post("/quiz") +async def generate_quiz( + req: GenerateRequest, + store: KnowledgeStore = Depends(), + config: SageConfig = Depends(), +): + count = max(1, min(req.count, 15)) + context_text, sources = await _gather_context( + topic=req.topic, tome_id=req.tome_id, store=store, config=config, + max_results=max(count, 5), + ) + 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"Generate exactly {count} multiple-choice questions.\n\n" + f"Source excerpts:\n{context_text}" + ) + + try: + parsed = await _llm_json(QUIZ_SYSTEM, user_prompt, req.provider, req.model, config) + except ValueError as exc: + logger.exception("quiz json parse failed") + raise HTTPException(status_code=502, detail=f"Model returned unparseable output: {exc}") + + raw_questions = parsed.get("questions") if isinstance(parsed, dict) else parsed + if not isinstance(raw_questions, list): + raise HTTPException(status_code=502, detail="Model output missing 'questions' array") + + questions = [] + for entry in raw_questions[:count]: + if not isinstance(entry, dict): + continue + question_text = str(entry.get("question", "")).strip() + options_raw = entry.get("options") + correct_id = str(entry.get("correctOptionId", "")).strip().lower() + explanation = str(entry.get("explanation", "")).strip() + if not question_text or not isinstance(options_raw, list) or len(options_raw) < 2: + continue + + options = [] + for opt in options_raw: + if not isinstance(opt, dict): + continue + opt_id = str(opt.get("id", "")).strip().lower() + opt_text = str(opt.get("text", "")).strip() + if opt_id and opt_text: + options.append({"id": opt_id, "text": opt_text}) + if len(options) < 2 or not any(o["id"] == correct_id for o in options): + continue + + questions.append({ + "id": f"q-{uuid.uuid4().hex[:8]}", + "question": question_text, + "options": options, + "correctOptionId": correct_id, + "explanation": explanation, + }) + + if not questions: + raise HTTPException(status_code=502, detail="Model produced no usable questions") + + return { + "questions": questions, + "topic": req.topic or "", + "sources": sources, + } + + +# ─────────────────────── Report ──────────────────────────────── + + +REPORT_SYSTEM = """You write structured study reports grounded ONLY in the provided source excerpts. + +Rules: +- Every claim must be supported by the sources. Do not invent facts, numbers, or citations. +- Aim for 5–9 sections, each focused on one distinct idea. +- The first section MUST be "Executive Summary". +- Section content is GitHub-flavored markdown. You may use: + * paragraphs, **bold**, *italic*, blockquotes + * bullet and numbered lists + * tables (pipe syntax) + * fenced code blocks (with language) when sources include code + * LaTeX math: inline `$...$` and display `$$...$$` (escape backslashes as needed) +- Do NOT include the section title inside the section's content — only the body. +- Do NOT use h1 (#) headings. If you need a sub-heading inside a section, use h3 (###). +- Output ONLY a JSON object of the form: + { + "title": "Short report title (≤ 60 chars)", + "subtitle": "One-line description of the report's scope", + "sections": [ + {"title": "Executive Summary", "content": "markdown..."}, + {"title": "...", "content": "markdown..."}, + ... + ] + } +- No commentary, no markdown fences around the JSON.""" + + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def _slugify(text: str) -> str: + """Match rehype-slug / github-slugger output for typical ASCII headings.""" + slug = _SLUG_RE.sub("-", text.lower()).strip("-") + return slug or "section" + + +@router.post("/report") +async def generate_report( + req: GenerateRequest, + 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=10, + ) + 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: synthesize the most important ideas across the sources." + ) + user_prompt = ( + f"{topic_line}\n" + f"Write the report now as structured JSON.\n\n" + f"Source excerpts:\n{context_text}" + ) + + try: + parsed = await _llm_json(REPORT_SYSTEM, user_prompt, req.provider, req.model, config) + except ValueError as exc: + logger.exception("report json parse failed") + raise HTTPException(status_code=502, detail=f"Model returned unparseable output: {exc}") + + if not isinstance(parsed, dict): + raise HTTPException(status_code=502, detail="Model output was not a JSON object") + + title = str(parsed.get("title", "")).strip() or ( + f"Report: {req.topic.strip()}" if req.topic and req.topic.strip() else "Report" + ) + subtitle = str(parsed.get("subtitle", "")).strip() or None + raw_sections = parsed.get("sections") + if not isinstance(raw_sections, list) or not raw_sections: + raise HTTPException(status_code=502, detail="Model output missing 'sections' array") + + toc: list[dict[str, str]] = [] + content_parts: list[str] = [] + used_slugs: set[str] = set() + for entry in raw_sections: + if not isinstance(entry, dict): + continue + section_title = str(entry.get("title", "")).strip() + section_body = str(entry.get("content", "")).strip() + if not section_title or not section_body: + continue + base_slug = _slugify(section_title) + slug = base_slug + i = 1 + while slug in used_slugs: + i += 1 + slug = f"{base_slug}-{i}" + used_slugs.add(slug) + toc.append({"id": slug, "title": section_title}) + content_parts.append(f"## {section_title}\n\n{section_body}") + + if not toc: + raise HTTPException(status_code=502, detail="Model produced no usable sections") + + unique_doc_ids: set[str] = set() + for s in sources: + unique_doc_ids.add(s["document_id"]) + doc_count = len(unique_doc_ids) + + tome_name: str | None = None + if req.tome_id: + tome = store.get_tome(req.tome_id) + if tome: + tome_name = tome.name + + if doc_count: + if tome_name: + source_docs = f'Based on {doc_count} document{"s" if doc_count != 1 else ""} in "{tome_name}" tome' + else: + source_docs = f'Based on {doc_count} document{"s" if doc_count != 1 else ""} in your knowledge base' + else: + source_docs = None + + content = "\n\n".join(content_parts) + "\n\n---\n\n*Report generated by Sage*" + + return { + "title": title, + "subtitle": subtitle, + "sourceDocs": source_docs, + "toc": toc, + "content": content, + "sources": sources, + "topic": req.topic or "", + } diff --git a/backend/main.py b/backend/main.py index eed28c1..d8b2b1d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -24,7 +24,7 @@ def _get_arxiv_service(): from services.pdf import PDFService from services.summarizer import DeepSeekService -from api import chat, knowledge, skills as skills_api, tomes as tomes_api +from api import audio, chat, generate, knowledge, skills as skills_api, tomes as tomes_api from config import SageConfig from skills.read_document import ReadDocumentSkill from skills.registry import SkillRegistry @@ -57,6 +57,8 @@ def _get_arxiv_service(): app.include_router(chat.router) app.include_router(tomes_api.router) app.include_router(skills_api.router) +app.include_router(generate.router) +app.include_router(audio.router) # --- end Sage setup --- # Add CORS middleware with configurable origins @@ -308,6 +310,15 @@ def read_root(): return {"message": "Welcome to the Sage API!"} +@app.get("/api/config") +def get_config(config: SageConfig = Depends()): + """Expose the active provider/model so the UI can label which one's in use.""" + provider_name = config.get("providers.default", "ollama") + provider_config = config.provider_config(provider_name) + model = provider_config.get("default_model", "") + return {"provider": provider_name, "model": model} + + # Clear caches endpoint (for maintenance) @app.post("/api/clear-cache") def clear_cache(): diff --git a/backend/store/db.py b/backend/store/db.py index f75f2f0..2fab9f6 100644 --- a/backend/store/db.py +++ b/backend/store/db.py @@ -77,6 +77,18 @@ def __init__(self, db_path: Optional[Path] = None): self._init_schema() def _init_schema(self): + existing_tables = { + row[0] + for row in self.conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + if "documents" in existing_tables: + document_columns = { + row[1] for row in self.conn.execute("PRAGMA table_info(documents)") + } + if "content_hash" not in document_columns: + self.conn.execute("ALTER TABLE documents ADD COLUMN content_hash TEXT") self.conn.executescript(SCHEMA) self.conn.commit() @@ -231,6 +243,54 @@ def create_session(self, provider: str, model: str, self.conn.commit() return s + def list_sessions(self, limit: int = 100) -> list[dict]: + """List chat sessions with a derived title, tome name, and activity timestamps. + + Returns rows shaped for the History panel — newest activity first.""" + limit = max(1, min(limit, 500)) + rows = self.conn.execute( + """ + SELECT + s.id AS id, + s.tome_id AS tome_id, + s.provider AS provider, + s.model AS model, + s.created_at AS created_at, + t.name AS tome_name, + (SELECT content FROM messages + WHERE session_id = s.id AND role = 'user' + ORDER BY created_at ASC LIMIT 1) AS first_user_message, + (SELECT created_at FROM messages + WHERE session_id = s.id + ORDER BY created_at DESC LIMIT 1) AS last_message_at, + (SELECT COUNT(*) FROM messages + WHERE session_id = s.id) AS message_count + FROM sessions s + LEFT JOIN tomes t ON t.id = s.tome_id + ORDER BY COALESCE( + (SELECT created_at FROM messages + WHERE session_id = s.id ORDER BY created_at DESC LIMIT 1), + s.created_at + ) DESC + LIMIT ? + """, + (limit,), + ).fetchall() + return [ + { + "id": r["id"], + "tome_id": r["tome_id"], + "tome_name": r["tome_name"], + "provider": r["provider"], + "model": r["model"], + "created_at": r["created_at"], + "last_message_at": r["last_message_at"] or r["created_at"], + "first_user_message": r["first_user_message"], + "message_count": r["message_count"] or 0, + } + for r in rows + ] + def get_tome_session(self, tome_id: str) -> Optional[Session]: """Get the most recent session for a tome.""" row = self.conn.execute( diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 85f4ee5..3e96974 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from api import chat, knowledge, skills as skills_api, tomes as tomes_api +from api import audio, chat, generate, knowledge, skills as skills_api, tomes as tomes_api from config import SageConfig from skills.read_document import ReadDocumentSkill from skills.registry import SkillRegistry @@ -41,6 +41,8 @@ def create_test_app(db_path: Optional[Path] = None) -> tuple[FastAPI, KnowledgeS app.include_router(chat.router) app.include_router(tomes_api.router) app.include_router(skills_api.router) + app.include_router(generate.router) + app.include_router(audio.router) @app.get("/") def root(): diff --git a/backend/tests/test_chat_prompt.py b/backend/tests/test_chat_prompt.py new file mode 100644 index 0000000..b026e67 --- /dev/null +++ b/backend/tests/test_chat_prompt.py @@ -0,0 +1,21 @@ +"""Tests for Sage chat capability guidance.""" +from __future__ import annotations + +from api.chat import SYSTEM_PROMPT + + +def test_system_prompt_guides_explicit_slash_commands_only(): + prompt = SYSTEM_PROMPT.lower() + + assert "/quiz" in prompt + assert "/flashcards" in prompt + assert "/audio" in prompt + assert "/report" in prompt + assert "/history" in prompt + assert "/tomes" in prompt + assert "/sources" in prompt + + assert "routes prompts containing certain keywords" not in prompt + assert '"history" → opens' not in prompt + assert '"summary" → opens' not in prompt + assert "normal natural-language questions stay in chat" in prompt diff --git a/backend/tests/test_generation_api.py b/backend/tests/test_generation_api.py new file mode 100644 index 0000000..a635b51 --- /dev/null +++ b/backend/tests/test_generation_api.py @@ -0,0 +1,155 @@ +"""Tests for generated artifact and audio endpoints.""" +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from fastapi.testclient import TestClient + +from providers.base import AgentResponse +from store.models import Chunk, Document +from tests.test_app import create_test_app + + +class FakeProvider: + async def chat(self, messages, tools, model, stream=False, **kwargs): + prompt = messages[-1].content + if "flashcards" in prompt.lower(): + return AgentResponse(content=json.dumps({ + "cards": [{"front": "What is Sage?", "back": "A local-first knowledge agent."}] + })) + if "multiple-choice" in messages[0].content.lower(): + return AgentResponse(content=json.dumps({ + "questions": [{ + "question": "What is Sage?", + "options": [ + {"id": "a", "text": "A database"}, + {"id": "b", "text": "A local-first knowledge agent"}, + {"id": "c", "text": "A browser"}, + {"id": "d", "text": "A font"}, + ], + "correctOptionId": "b", + "explanation": "The source says Sage is a local-first knowledge agent.", + }] + })) + if "structured study reports" in messages[0].content.lower(): + return AgentResponse(content=json.dumps({ + "title": "Sage Overview", + "subtitle": "A grounded study report", + "sections": [ + { + "title": "Executive Summary", + "content": "Sage is a local-first knowledge agent for studying documents.", + }, + { + "title": "Grounding", + "content": "Generated artifacts should stay grounded in the knowledge base.", + }, + ], + })) + if "narration" in messages[0].content.lower(): + return AgentResponse(content="Sage is a local-first knowledge agent. It keeps answers grounded.") + return AgentResponse(content="{}") + + def list_models(self): + return ["fake-model"] + + +def client_with_seeded_kb(): + test_db = Path("/tmp/sage_test_generation_api.db") + test_db.unlink(missing_ok=True) + app, store = create_test_app(db_path=test_db) + doc = Document(title="Sage Notes") + store.add_document(doc) + store.add_chunks([ + Chunk( + document_id=doc.id, + chunk_index=0, + content="Sage is a local-first knowledge agent for studying your documents.", + embedding=b"fake-embedding", + ) + ]) + return app, store, test_db + + +def test_flashcards_endpoint_uses_fake_provider_and_returns_sources(): + app, store, test_db = client_with_seeded_kb() + try: + with patch("api.generate.create_provider", return_value=FakeProvider()): + with TestClient(app) as client: + resp = client.post("/api/generate/flashcards", json={"count": 1, "provider": "fake"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["cards"][0]["front"] == "What is Sage?" + assert data["sources"][0]["document_title"] == "Sage Notes" + finally: + store.close() + test_db.unlink(missing_ok=True) + + +def test_quiz_endpoint_uses_fake_provider_and_returns_contract_shape(): + app, store, test_db = client_with_seeded_kb() + try: + with patch("api.generate.create_provider", return_value=FakeProvider()): + with TestClient(app) as client: + resp = client.post("/api/generate/quiz", json={"count": 1, "provider": "fake"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["questions"][0]["question"] == "What is Sage?" + assert data["questions"][0]["correctOptionId"] == "b" + assert data["questions"][0]["options"] == [ + {"id": "a", "text": "A database"}, + {"id": "b", "text": "A local-first knowledge agent"}, + {"id": "c", "text": "A browser"}, + {"id": "d", "text": "A font"}, + ] + assert data["sources"][0]["document_title"] == "Sage Notes" + finally: + store.close() + test_db.unlink(missing_ok=True) + + +def test_report_endpoint_uses_fake_provider_and_returns_contract_shape(): + app, store, test_db = client_with_seeded_kb() + try: + with patch("api.generate.create_provider", return_value=FakeProvider()): + with TestClient(app) as client: + resp = client.post("/api/generate/report", json={"provider": "fake"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "Sage Overview" + assert data["subtitle"] == "A grounded study report" + assert data["toc"] == [ + {"id": "executive-summary", "title": "Executive Summary"}, + {"id": "grounding", "title": "Grounding"}, + ] + assert "## Executive Summary" in data["content"] + assert "Report generated by Sage" in data["content"] + assert data["sourceDocs"] == "Based on 1 document in your knowledge base" + assert data["sources"][0]["document_title"] == "Sage Notes" + finally: + store.close() + test_db.unlink(missing_ok=True) + + +def test_audio_endpoint_returns_browser_fallback_without_openai_tts(): + app, store, test_db = client_with_seeded_kb() + try: + with patch("api.generate.create_provider", return_value=FakeProvider()), \ + patch("api.audio._maybe_synthesize_openai_tts", return_value=(None, None)): + with TestClient(app) as client: + resp = client.post("/api/generate/audio", json={"provider": "fake"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["voice"] == "Browser" + assert data["audio_url"] is None + assert data["segments"] + assert "local-first knowledge agent" in data["script"] + finally: + store.close() + test_db.unlink(missing_ok=True) diff --git a/backend/tests/test_store.py b/backend/tests/test_store.py index 7eddc69..84313cf 100644 --- a/backend/tests/test_store.py +++ b/backend/tests/test_store.py @@ -1,10 +1,40 @@ """Tests for the KnowledgeStore — Tomes, documents, chunks, dedup, linking.""" import pytest +from store.db import KnowledgeStore from store.models import Document, Chunk, Tome, Session, Message class TestDocumentCRUD: + def test_existing_documents_table_gets_content_hash_column(self, tmp_path): + import sqlite3 + + db_path = tmp_path / "legacy.db" + conn = sqlite3.connect(db_path) + conn.execute( + """ + CREATE TABLE documents ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + source TEXT, + source_id TEXT, + doc_type TEXT, + metadata TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) + """ + ) + conn.commit() + conn.close() + + migrated = KnowledgeStore(db_path=db_path) + try: + columns = {row[1] for row in migrated.conn.execute("PRAGMA table_info(documents)")} + assert "content_hash" in columns + finally: + migrated.close() + def test_add_and_get(self, store): doc = Document(title="Test Paper", source="upload", doc_type="pdf", content_hash="abc123") store.add_document(doc) @@ -168,6 +198,24 @@ def test_dedup_prevents_duplicate_storage(self, store): class TestSessions: + def test_list_sessions_clamps_negative_limit(self, store): + for i in range(3): + session = store.create_session("ollama", "llama3.1:8b") + store.add_message(Message(session_id=session.id, role="user", content=f"hello {i}")) + + sessions = store.list_sessions(limit=-1) + + assert len(sessions) == 1 + + def test_list_sessions_clamps_large_limit(self, store): + for i in range(3): + session = store.create_session("ollama", "llama3.1:8b") + store.add_message(Message(session_id=session.id, role="user", content=f"hello {i}")) + + sessions = store.list_sessions(limit=10_000) + + assert len(sessions) == 3 + def test_create_session_with_tome(self, store): tome = store.create_tome("Tome") session = store.create_session("ollama", "llama3.1:8b", tome_id=tome.id) diff --git a/frontend/package.json b/frontend/package.json index 77759c4..d618159 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,9 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test:routing": "node scripts/test-command-routing.mjs", + "test:api-client": "node scripts/test-sage-api.mjs", + "test:smoke": "npm run test:routing && npm run test:api-client", "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build" diff --git a/frontend/scripts/test-command-routing.mjs b/frontend/scripts/test-command-routing.mjs new file mode 100644 index 0000000..e0a13c0 --- /dev/null +++ b/frontend/scripts/test-command-routing.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { detectView } from "../src/lib/command-routing.ts"; + +const routes = [ + ["/quiz", "quiz"], + ["/quiz attention mechanisms", "quiz"], + ["/flashcards transformers", "flashcards"], + ["/flashcard transformers", "flashcards"], + ["/audio chapter 2", "audio"], + ["/report diffusion notes", "report"], + ["/history", "history"], + ["/tomes", "tomes"], + ["/knowledge", "knowledge"], + ["/sources", "knowledge"], +]; + +for (const [input, expected] of routes) { + assert.equal(detectView(input), expected, `${input} should route to ${expected}`); +} + +const naturalLanguagePrompts = [ + "history of transformers", + "which library does this use", + "summary of my notes", + "can you quiz me conversationally first?", + "tell me about audio transformers", + "what collection did this paper come from?", + "show me documents about attention", +]; + +for (const input of naturalLanguagePrompts) { + assert.equal(detectView(input), null, `${input} should stay in chat`); +} + +console.log("command routing tests passed"); diff --git a/frontend/scripts/test-sage-api.mjs b/frontend/scripts/test-sage-api.mjs new file mode 100644 index 0000000..dabef9a --- /dev/null +++ b/frontend/scripts/test-sage-api.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("../src/lib/sage-api.ts", import.meta.url), "utf8"); +const apiFetchMatch = source.match(/async function apiFetch[\s\S]*?\n}\n/); +assert.ok(apiFetchMatch, "apiFetch function should exist"); + +const apiFetchSource = apiFetchMatch[0]; +const ensureIndex = apiFetchSource.indexOf("await ensureDesktopBackend()"); +const baseIndex = apiFetchSource.indexOf("const base = getApiBaseUrl()"); + +assert.ok(ensureIndex >= 0, "apiFetch should ensure the Tauri sidecar before requests"); +assert.ok(baseIndex >= 0, "apiFetch should resolve the API base URL"); +assert.ok( + ensureIndex < baseIndex, + "apiFetch should wait for the desktop sidecar before building and issuing the request", +); + +console.log("sage api sidecar readiness tests passed"); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 5bda21a..49bda4c 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,140 +1,429 @@ "use client"; import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import CommandBar from "@/components/CommandBar"; -import QuizWidget, { SAMPLE_QUESTIONS } from "@/components/QuizWidget"; -import FlashcardWidget, { SAMPLE_FLASHCARDS } from "@/components/FlashcardWidget"; -import AudioPlayerWidget, { SAMPLE_TRACK } from "@/components/AudioPlayerWidget"; -import ReportViewWidget, { SAMPLE_REPORT } from "@/components/ReportViewWidget"; +import type { FormEvent, KeyboardEvent, ReactNode } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import type { Transition, Variants } from "framer-motion"; +import AudioView from "@/components/AudioView"; +import ChatWidget from "@/components/ChatWidget"; +import FlashcardsView from "@/components/FlashcardsView"; import HistoryPanel from "@/components/HistoryPanel"; +import KnowledgeBaseWidget from "@/components/KnowledgeBaseWidget"; +import QuizView from "@/components/QuizView"; +import ReportView from "@/components/ReportView"; import TomeSelector from "@/components/TomeSelector"; -import ChatWidget from "@/components/ChatWidget"; +import { detectView } from "@/lib/command-routing"; +import type { RoutedView } from "@/lib/command-routing"; + +type ViewState = "idle" | "dashboard" | "chat" | RoutedView; -type ViewState = "idle" | "quiz" | "flashcards" | "audio" | "report" | "history" | "tomes" | "chat"; +const generatedViews = new Set(["quiz", "flashcards", "audio", "report"]); + +const capabilities: Array<{ label: string; icon: string; prompt?: string; view: ViewState }> = [ + { label: "Sources", icon: "▤", view: "knowledge" }, + { label: "Report", icon: "✎", prompt: "Generate a report for this Tome", view: "report" }, + { label: "Quiz", icon: "?", prompt: "Create a quiz for this Tome", view: "quiz" }, + { label: "Flashcards", icon: "◇", prompt: "Create flashcards for this Tome", view: "flashcards" }, + { label: "Audio", icon: "♪", prompt: "Generate an audio review for this Tome", view: "audio" }, + { label: "Chat", icon: "⌁", view: "chat" }, +]; export default function Home() { const [viewState, setViewState] = useState("idle"); + const [generationPrompt, setGenerationPrompt] = useState(""); + const [chatQuery, setChatQuery] = useState(""); const handleSubmit = (text: string) => { - const lower = text.toLowerCase().trim(); - - if (lower.includes("quiz")) { - setViewState("quiz"); - } else if (lower.includes("flashcard") || lower.includes("flash card")) { - setViewState("flashcards"); - } else if (lower.includes("audio") || lower.includes("listen") || lower.includes("podcast")) { - setViewState("audio"); - } else if (lower.includes("report") || lower.includes("study guide") || lower.includes("summary")) { - setViewState("report"); - } else if (lower.includes("history")) { - setViewState("history"); - } else if (lower.includes("tome") || lower.includes("collection") || lower.includes("library")) { - setViewState("tomes"); + const route = detectView(text); + if (route) { + if (generatedViews.has(route)) { + setGenerationPrompt(text); + } + setViewState(route); } else { + setChatQuery(text); setViewState("chat"); } }; + /** Called from inside ChatWidget. Returns true if it routed away from chat. */ + const handleChatCommand = (text: string): boolean => { + const route = detectView(text); + if (!route) return false; + if (generatedViews.has(route)) { + setGenerationPrompt(text); + } + setViewState(route); + return true; + }; + const handleBack = () => { setViewState("idle"); }; - const cardVariants = { + const cardVariants: Variants = { initial: { opacity: 0, y: 16, scale: 0.97 }, animate: { opacity: 1, y: 0, scale: 1 }, exit: { opacity: 0, y: -10, scale: 0.97 }, }; - const cardTransition = { duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }; + const cardTransition: Transition = { duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }; return ( -
- {/* Ambient glow */} -
- - {/* Command bar — always visible, shifts up when content appears */} -
+
+
+
+ + + + {viewState === "idle" && ( + + + + )} + + {viewState === "dashboard" && ( + + + + )} + + {viewState === "quiz" && ( + + + + )} + + {viewState === "flashcards" && ( + + + + )} + + {viewState === "audio" && ( + + + + )} + + {viewState === "report" && ( + + + + )} + + {viewState === "history" && ( + + + + )} + + {viewState === "tomes" && ( + + + + )} + + {viewState === "knowledge" && ( + + + + )} + + {viewState === "chat" && ( + + + + )} + +
+
+ ); +} + +function TopBar({ viewState, onNavigate }: { viewState: ViewState; onNavigate: (view: ViewState) => void }) { + return ( +
+ + + +
+ ); +} + +function TomeHome({ onSubmit, onNavigate }: { onSubmit: (text: string) => void; onNavigate: (view: ViewState) => void }) { + const [prompt, setPrompt] = useState(""); + + const submitPrompt = () => { + const trimmed = prompt.trim(); + if (!trimmed) return; + onSubmit(trimmed); + setPrompt(""); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + submitPrompt(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submitPrompt(); + } + }; + + return ( +
+
+
+ + Tome Home +
+ +

+ What should this Tome help with next? +

+

+ Ask questions, generate study materials, manage sources, or jump into focused work for this Tome. +

+ + + +
+