"""The prompter's saved texts (ניהול ← פרומפטר, static/prompter.js), kept with the account so they are on every device. For developers only (the management.access permission, like the rest of ניהול). Each account sees only its own texts. They are kept in the Supabase table `math_prompter_texts`, read and written with the server's secret key. A text may also have an outline (ראשי פרקים): for a speaker who talks from reminders rather than reading aloud, Claude rewrites the text as the same slides with two to four very short topic reminders each. It is written once, on request, kept with the text, and can be edited like the text. Changing the text without sending an outline drops the outline, so a stale one is never shown. """ from __future__ import annotations import json import logging import os import re import urllib.parse from datetime import datetime, timezone from fastapi import Body, Depends, HTTPException from fastapi.responses import JSONResponse from . import ai_usage from .access import has_permission from .auth import AuthUnavailable log = logging.getLogger("mathly.prompter") MIGRATION = "supabase/migrations/20261106130000_math_prompter_texts.sql" OUTLINE_MIGRATION = "supabase/migrations/20261107130000_math_prompter_outline.sql" TABLE = "/rest/v1/math_prompter_texts" MAX_TITLE, MAX_BODY, MAX_TEXTS, MAX_OUTLINE = 120, 100_000, 200, 20_000 UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") FIELDS = "id,title,body,outline,updated_at" OUTLINE_MODEL = "claude-sonnet-5-5" OUTLINE_SCHEMA = { "type": "object", "additionalProperties": False, "required": ["sections"], "properties": {"sections": {"type": "array", "items": { "type": "object", "additionalProperties": False, "required": ["part", "heading", "time", "reminders"], "properties": {"part": {"type": "string"}, "heading": {"type": "string"}, "time": {"type": "string"}, "reminders": {"type": "array", "items": {"type": "string"}}}}}}, } OUTLINE_SYSTEM = """You turn a talk's full script into speaker reminders for a teleprompter. The speaker does not read the script aloud; they glance at a few words per slide to remember what to talk about, and speak freely. The script is in "}]) except anthropic.APIError as exc: reason, detail = failure_reason(exc) log.warning("prompter outline failed: %s (%s)", reason, detail) raise HTTPException(503, "outline_unavailable") from exc try: if response.stop_reason != "end_turn": raise ValueError(response.stop_reason) sections = json.loads(next(b.text for b in response.content if b.type == "text"))["sections"] outline = outline_text(sections) if not outline: raise ValueError("empty") except (ValueError, KeyError, TypeError, StopIteration) as exc: log.warning("prompter outline was not usable: %s", exc) raise HTTPException(503, "outline_unavailable") from exc return outline, ai_usage.usage_of(response) def install_prompter_routes(app, signed_in): """GET/POST /api/prompter/texts, PUT/DELETE /api/prompter/texts/{id}: the developer's own saved texts, and POST /api/prompter/texts/{id}/outline: Claude writes (or rewrites) the text's outline and it is saved with it.""" def developer(user=Depends(signed_in)) -> str: if not user or not user.get("id"): raise HTTPException(401, "sign_in_required") if not has_permission(user, "management.access"): raise HTTPException(403, "developers_only") return user["id"] def admin(path: str, method: str = "GET", body=None, prefer: str = "return=representation"): try: return app.state.auth._admin(path, method, body, prefer=prefer) except AuthUnavailable as exc: if "outline" in str(exc) and "answered 404" not in str(exc): # before the outline migration: texts work as before, only outlines are unavailable log.warning("the math_prompter_texts.outline column is missing; apply %s", OUTLINE_MIGRATION) if isinstance(body, dict) and body.get("outline"): raise HTTPException(503, "outline_unavailable") from exc if isinstance(body, dict): body = {k: v for k, v in body.items() if k != "outline"} return admin(path.replace(FIELDS, FIELDS.replace(",outline", "")), method, body, prefer) if "answered 404" in str(exc): log.warning("the math_prompter_texts table is missing; apply %s", MIGRATION) else: log.error("prompter texts unavailable: %s", exc) raise HTTPException(503, "prompter_unavailable") from exc def one(text_id: str, user: str) -> str: if not UUID.match(text_id): raise HTTPException(404, "not_found") return urllib.parse.urlencode({"id": f"eq.{text_id}", "user_id": f"eq.{user}"}) def now() -> str: return datetime.now(timezone.utc).isoformat() @app.get("/api/prompter/texts") def list_texts(user: str = Depends(developer)): rows = admin(f"{TABLE}?select={FIELDS}&order=updated_at.desc&limit={MAX_TEXTS}&" + urllib.parse.urlencode({"user_id": f"eq.{user}"})) or [] return JSONResponse({"texts": rows}, headers={"Cache-Control": "no-store"}) @app.post("/api/prompter/texts") def add_text(body: dict = Body(...), user: str = Depends(developer)): row = clean(body) have = admin(f"{TABLE}?select=id&limit={MAX_TEXTS}&" + urllib.parse.urlencode({"user_id": f"eq.{user}"})) or [] if len(have) >= MAX_TEXTS: raise HTTPException(409, "too_many_texts") rows = admin(f"{TABLE}?select={FIELDS}", "POST", {**row, "user_id": user, "updated_at": now()}) or [] return {"text": rows[0] if rows else None} @app.put("/api/prompter/texts/{text_id}") def save_text(text_id: str, body: dict = Body(...), user: str = Depends(developer)): row = clean(body) if "outline" in body: row["outline"] = clean_outline(body["outline"]) else: # the outline was written from the old text: it goes when the text changes old = admin(f"{TABLE}?select=body&" + one(text_id, user)) or [] if not old: raise HTTPException(404, "not_found") if old[0]["body"] != row["body"]: row["outline"] = None rows = admin(f"{TABLE}?select={FIELDS}&" + one(text_id, user), "PATCH", {**row, "updated_at": now()}) or [] if not rows: raise HTTPException(404, "not_found") return {"text": rows[0]} @app.post("/api/prompter/texts/{text_id}/outline") def make_outline(text_id: str, user: str = Depends(developer)): rows = admin(f"{TABLE}?select=body&" + one(text_id, user)) or [] if not rows: raise HTTPException(404, "not_found") outliner = getattr(app.state, "prompter_outliner", None) if outliner is None: key = os.environ.get("ANTHROPIC_API_KEY", "").strip() or os.environ.get("CLAUDE_API_KEY", "").strip() if not key: raise HTTPException(503, "outline_unavailable") outliner = Outliner(key, os.environ.get("PROMPTER_MODEL", "").strip() or OUTLINE_MODEL) outline, usage = outliner(rows[0]["body"]) ai_usage.record(app.state.auth, user, "prompter", usage) # updated_at is left alone: the text itself did not change saved = admin(f"{TABLE}?select={FIELDS}&" + one(text_id, user), "PATCH", {"outline": outline}) or [] if not saved: raise HTTPException(404, "not_found") return {"text": saved[0]} @app.delete("/api/prompter/texts/{text_id}") def delete_text(text_id: str, user: str = Depends(developer)): admin(f"{TABLE}?" + one(text_id, user), "DELETE", prefer="return=minimal") return {"ok": True}