File size: 5,240 Bytes
bc052e5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
"""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"]))}