Phases 2 to 8 of the AI assistant overhaul plus fixes from real-model chats: one tool registry with native tool calls, server-kept chat logs with prompt caching, an FTS5 library index with opt-in meaning search, keys bound to their address and a guard on every AI route, page tools and opt-in web search, saved chats with streaming, Try again, a context meter and compaction, chat images, delete/category/tag permissions with Undo, constraint and test cards, card fixes and previews, questions with answers to click, the derivation NOT toggle (schema v10, community catalog 0021), request fixes for the newest Claude models, and OpenAI's Responses API. Review fixes: Try again clears the old turn's fixes and answers, category Undo removes only what it made, a failed delete rolls back in order, not_matches patterns are checked, busy chats refuse a second request, and chat search ignores stored JSON.
201 lines
7.8 KiB
Python
201 lines
7.8 KiB
Python
"""Templates CRUD — reusable PromptAssembler template strings.
|
|
|
|
Mirrors wp_api/bundles.py. A template is a plain ``template_string``
|
|
($var tokens), an optional ``negative_template`` (the Assembler's negative
|
|
box, migration 019) plus library metadata (name/description/category/tags/
|
|
favorite). No children/color/payload_hash/version (templates can't
|
|
drift — see engine/db/migrations_sql/012_templates.sql).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sqlite3
|
|
|
|
from aiohttp import web
|
|
|
|
from engine.db.repositories import TemplateNotFound, TemplateRepository
|
|
from wp_api._helpers import db_session, json_error, json_ok
|
|
from wp_api._validators import validate_body_size, validate_meta
|
|
|
|
_NEW_ID = re.compile(r"[0-9a-f]{8}")
|
|
|
|
_UPDATABLE_FIELDS = (
|
|
"name", "description", "category_id", "tags",
|
|
"template_string", "is_favorite", "negative_template",
|
|
)
|
|
|
|
|
|
def _negative_template_error(body: dict) -> str | None:
|
|
"""`negative_template` (migration 019) is a string or null. Null means
|
|
"no negative saved": loading leaves the Assembler's negative box alone."""
|
|
if "negative_template" in body:
|
|
neg = body["negative_template"]
|
|
if neg is not None and not isinstance(neg, str):
|
|
return "negative_template must be a string or null"
|
|
return None
|
|
|
|
|
|
def _auto_suffix_template_name(repo: TemplateRepository, name: str) -> str:
|
|
existing = {t["name"] for t in repo.list()}
|
|
if name not in existing:
|
|
return name
|
|
candidate = f"{name} (copy)"
|
|
i = 2
|
|
while candidate in existing:
|
|
candidate = f"{name} (copy {i})"
|
|
i += 1
|
|
return candidate
|
|
|
|
|
|
async def list_templates(request: web.Request) -> web.Response:
|
|
category_id = request.query.get("category")
|
|
query = request.query.get("q")
|
|
favorites = request.query.get("favorites") == "1"
|
|
try:
|
|
limit = int(request.query["limit"]) if "limit" in request.query else None
|
|
offset = int(request.query.get("offset", 0))
|
|
except ValueError:
|
|
return json_error("limit/offset must be integers", status=400)
|
|
if limit is not None and limit < 0:
|
|
return json_error("limit must be non-negative", status=400)
|
|
if offset < 0:
|
|
return json_error("offset must be non-negative", status=400)
|
|
|
|
with db_session(request) as conn:
|
|
repo = TemplateRepository(conn)
|
|
items = repo.list(
|
|
category_id=category_id, query=query,
|
|
favorites_only=favorites, limit=limit, offset=offset,
|
|
)
|
|
total = repo.count(
|
|
category_id=category_id, query=query, favorites_only=favorites,
|
|
)
|
|
return json_ok({"items": items, "total": total})
|
|
|
|
|
|
async def create_template(request: web.Request) -> web.Response:
|
|
err = validate_body_size(request.content_length)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be a JSON object", status=400)
|
|
if "name" not in body or not str(body.get("name", "")).strip():
|
|
return json_error("missing field: name", status=400)
|
|
err = validate_meta(body) or _negative_template_error(body)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
try:
|
|
with db_session(request) as conn:
|
|
repo = TemplateRepository(conn)
|
|
# An optional `id` (8 hex): Undo on the AI chat's delete card
|
|
# brings a template back at the id it had.
|
|
given = body.get("id")
|
|
if given is not None:
|
|
if not isinstance(given, str) or not _NEW_ID.fullmatch(given):
|
|
return json_error("id must be 8 lowercase hex characters", status=400)
|
|
try:
|
|
repo.get(given)
|
|
return json_error(f"template {given} already exists", status=409)
|
|
except TemplateNotFound:
|
|
pass
|
|
unique_name = _auto_suffix_template_name(repo, body["name"])
|
|
row = repo.create(
|
|
id=given,
|
|
name=unique_name,
|
|
template_string=body.get("template_string", ""),
|
|
description=body.get("description", ""),
|
|
category_id=body.get("category_id"),
|
|
tags=body.get("tags", []),
|
|
is_favorite=bool(body.get("is_favorite", False)),
|
|
negative_template=body.get("negative_template"),
|
|
)
|
|
except ValueError as e:
|
|
return json_error(str(e), status=400)
|
|
except sqlite3.IntegrityError as e:
|
|
return json_error(f"foreign-key constraint failed: {e}", status=400)
|
|
return json_ok(row, status=201)
|
|
|
|
|
|
async def get_template(request: web.Request) -> web.Response:
|
|
template_id = request.match_info["id"]
|
|
with db_session(request) as conn:
|
|
try:
|
|
row = TemplateRepository(conn).get(template_id)
|
|
except TemplateNotFound:
|
|
return json_error(f"template {template_id!r} not found", status=404)
|
|
return json_ok(row)
|
|
|
|
|
|
async def update_template(request: web.Request) -> web.Response:
|
|
template_id = request.match_info["id"]
|
|
err = validate_body_size(request.content_length)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return json_error("invalid JSON body", status=400)
|
|
if not isinstance(body, dict):
|
|
return json_error("body must be a JSON object", status=400)
|
|
err = validate_meta(body) or _negative_template_error(body)
|
|
if err is not None:
|
|
return json_error(err, status=400)
|
|
patch = {k: body[k] for k in _UPDATABLE_FIELDS if k in body}
|
|
with db_session(request) as conn:
|
|
repo = TemplateRepository(conn)
|
|
if "name" in patch and isinstance(patch["name"], str):
|
|
others = {t["name"] for t in repo.list() if t["id"] != template_id}
|
|
if patch["name"] in others:
|
|
patch["name"] = _auto_suffix_template_name(repo, patch["name"])
|
|
try:
|
|
row = repo.update(template_id, **patch)
|
|
except TemplateNotFound:
|
|
return json_error(f"template {template_id!r} not found", status=404)
|
|
except sqlite3.IntegrityError as e:
|
|
return json_error(f"foreign-key constraint failed: {e}", status=400)
|
|
return json_ok(row)
|
|
|
|
|
|
async def delete_template(request: web.Request) -> web.Response:
|
|
template_id = request.match_info["id"]
|
|
with db_session(request) as conn:
|
|
try:
|
|
TemplateRepository(conn).delete(template_id)
|
|
except TemplateNotFound:
|
|
return json_error(f"template {template_id!r} not found", status=404)
|
|
return json_ok({"deleted": template_id})
|
|
|
|
|
|
async def toggle_favorite(request: web.Request) -> web.Response:
|
|
template_id = request.match_info["id"]
|
|
explicit: bool | None = None
|
|
if request.body_exists:
|
|
try:
|
|
body = await request.json()
|
|
if isinstance(body, dict) and "is_favorite" in body:
|
|
explicit = bool(body["is_favorite"])
|
|
except Exception:
|
|
pass
|
|
with db_session(request) as conn:
|
|
repo = TemplateRepository(conn)
|
|
try:
|
|
cur = repo.get(template_id)
|
|
except TemplateNotFound:
|
|
return json_error(f"template {template_id!r} not found", status=404)
|
|
new_state = explicit if explicit is not None else not bool(cur["is_favorite"])
|
|
row = repo.update(template_id, is_favorite=new_state)
|
|
return json_ok(row)
|
|
|
|
|
|
def register(router) -> None:
|
|
router.add_get("/wp/api/templates", list_templates)
|
|
router.add_post("/wp/api/templates", create_template)
|
|
router.add_get("/wp/api/templates/{id}", get_template)
|
|
router.add_put("/wp/api/templates/{id}", update_template)
|
|
router.add_delete("/wp/api/templates/{id}", delete_template)
|
|
router.add_post("/wp/api/templates/{id}/favorite", toggle_favorite)
|