mathly / mathly2 /prompter.py
wzender's picture
Claude
Prompter on the developer screen (add-prompter) (#8)
bc052e5 unverified
Raw History Blame Contribute Delete
5.24 kB
"""The developer's prompter (ืคืจื•ืžืคื˜ืจ, change add-prompter, PRD DEV-2): saved texts on every device, and on request the AI's outline.
Developers only, each seeing only their own texts (up to 200; a title up to 120 characters, the text up to 100,000). A text may have an
outline (ืจืืฉื™ ืคืจืงื™ื): the AI rewrites it once, on request, as the same slides with two to four very short topic reminders each; it is kept
with the text and can be edited. Changing the text without sending an outline drops the outline, so a stale one is never shown. The
full-screen prompter itself is static/prompter.js (ported as is from mathly1).
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from . import ai
from .status import developer
log = logging.getLogger("mathly2.prompter")
router = APIRouter(prefix="/api/prompter")
MAX_TITLE, MAX_BODY, MAX_TEXTS, MAX_OUTLINE = 120, 100_000, 200, ai.OUTLINE_MAX
UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
class TextBody(BaseModel):
title: str = ""
body: str
outline: str | None = None # sent: replaces the outline ("" removes it); not sent: kept only if the text did not change
def _clean(body: TextBody) -> dict:
if not body.body.strip():
raise HTTPException(422, "ื”ื˜ืงืกื˜ ืจื™ืง")
if len(body.body) > MAX_BODY:
raise HTTPException(413, f"ื”ื˜ืงืกื˜ ืืจื•ืš ืžื“ื™ (ืขื“ {MAX_BODY:,} ืชื•ื•ื™ื)")
title = " ".join(body.title.split()) or " ".join(body.body.strip().splitlines()[0].split())
return {"title": title[:MAX_TITLE], "body": body.body}
def _outline(value: str | None) -> str | None:
if value is not None and len(value) > MAX_OUTLINE:
raise HTTPException(413, f"ืจืืฉื™ ื”ืคืจืงื™ื ืืจื•ื›ื™ื ืžื“ื™ (ืขื“ {MAX_OUTLINE:,} ืชื•ื•ื™ื)")
return value if value and value.strip() else None
def _shown(row: dict) -> dict:
return {"id": row["id"], "title": row["title"], "body": row["body"], "outline": row.get("outline"),
"updated_at": row["updated_at"].isoformat() if row.get("updated_at") else None}
def _mine(request: Request, user: dict, text_id: str) -> dict:
row = request.app.state.store.get_prompter_text(user["id"], text_id) if UUID.match(text_id) else None
if row is None:
raise HTTPException(404, "ื”ื˜ืงืกื˜ ืœื ื ืžืฆื")
return row
@router.get("/texts")
def list_texts(request: Request, user: dict = Depends(developer)) -> dict:
return {"texts": [_shown(t) for t in request.app.state.store.prompter_texts(user["id"])]}
@router.post("/texts", status_code=201)
def add_text(body: TextBody, request: Request, user: dict = Depends(developer)) -> dict:
store, row = request.app.state.store, _clean(body)
if len(store.prompter_texts(user["id"])) >= MAX_TEXTS:
raise HTTPException(409, f"ืืคืฉืจ ืœืฉืžื•ืจ ืขื“ {MAX_TEXTS} ื˜ืงืกื˜ื™ื. ืžื—ืงื• ื˜ืงืกื˜ ื™ืฉืŸ ื•ื ืกื• ืฉื•ื‘.")
made = store.add_prompter_text(user["id"], row["title"], row["body"])
if body.outline is not None and _outline(body.outline):
made = store.save_prompter_text(user["id"], made["id"], touch=False, outline=_outline(body.outline))
return {"text": _shown(made)}
@router.put("/texts/{text_id}")
def save_text(text_id: str, body: TextBody, request: Request, user: dict = Depends(developer)) -> dict:
old = _mine(request, user, text_id)
fields = _clean(body)
if "outline" in body.model_fields_set:
fields["outline"] = _outline(body.outline)
elif old["body"] != fields["body"]:
fields["outline"] = None # written from the old text: it goes when the text changes
return {"text": _shown(request.app.state.store.save_prompter_text(user["id"], text_id, **fields))}
@router.delete("/texts/{text_id}", status_code=204)
def delete_text(text_id: str, request: Request, user: dict = Depends(developer)) -> None:
_mine(request, user, text_id)
request.app.state.store.delete_prompter_text(user["id"], text_id)
@router.post("/texts/{text_id}/outline")
def make_outline(text_id: str, request: Request, user: dict = Depends(developer)) -> dict:
"""The AI writes (or rewrites) the text's outline; it is saved with the text. The text's date is left alone: the text did not change."""
row = _mine(request, user, text_id)
try:
result = request.app.state.ai.outline(row["body"])
except ai.AIFailure as exc:
what, todo = ai.FAILURES.get(exc.reason, ai.FAILURES["provider_error"])
log.warning("prompter outline failed: %s (%s)", exc.reason, exc.detail)
raise HTTPException(503, f"{what}. {todo}") from None
try:
if result.get("usage"):
request.app.state.store.add_ai_usage(ai.usage_row(result["usage"], "prompter", None, user["id"]))
except Exception as exc: # recording the cost never fails the outline
log.warning("prompter outline: usage not recorded: %s", type(exc).__name__)
return {"text": _shown(request.app.state.store.save_prompter_text(user["id"], text_id, touch=False, outline=result["outline"]))}