Files
Dumitru Picalau 657dc07831 feat(ai): assistant overhaul: tool registry, server-kept chats, search index, security, page tools, web search, saved chats, images, permissions, derivation NOT (schema 10), OpenAI Responses API (#64)
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.
2026-10-14 14:56:12 +02:00

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)