A short note (up to 600 characters) in Settings > AI assistant > About my library. The chat, every Draft with AI panel and the pack builder read it after their own instructions, and it wins over how existing items are written. A chat keeps the note it opened with, so an edit never changes an open chat's prompt. Suggest drafts a note from the library summary and sample option text for the user to edit; nothing is saved until Save. The note is stored with the shared AI settings (ai.json), not per model, and a note that asks for a minor or non-consent in a sexual context is refused like a pack request.
792 lines
37 KiB
Python
792 lines
37 KiB
Python
"""Pack builder routes: plan, change, search, review, item, check,
|
||
install, and bundle split."""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json as _json
|
||
import re
|
||
from typing import Any
|
||
|
||
from aiohttp import web
|
||
|
||
from engine.ai import adult as ai_adult
|
||
from engine.ai import budget as ai_budget
|
||
from engine.ai import bundle_split as ai_split
|
||
from engine.ai import followup as ai_followup
|
||
from engine.ai import lang as ai_lang
|
||
from engine.ai import pack as ai_pack
|
||
from engine.ai import pack_tidy
|
||
from engine.ai import patterns as ai_patterns
|
||
from engine.ai import safety as ai_safety
|
||
from engine.ai import vocab as ai_vocab
|
||
from engine.ai import wildcard as ai_wildcard
|
||
from engine.ai.pack import recommend as pack_recommend
|
||
from engine.db.repositories import (
|
||
BundleRepository,
|
||
ModuleRepository,
|
||
TemplateRepository,
|
||
)
|
||
from engine.importer import commit_import
|
||
from wp_api import (
|
||
_ai_client,
|
||
_ai_lang,
|
||
_ai_search,
|
||
_ai_vocab,
|
||
)
|
||
from wp_api._ai_common import (
|
||
_MAX_IMAGE_BODY_BYTES,
|
||
_MAX_PACK_BODY_BYTES,
|
||
_ask,
|
||
_body,
|
||
_enabled_cfg,
|
||
_instruction,
|
||
_request_images,
|
||
_turns,
|
||
_with_note,
|
||
)
|
||
from wp_api._ai_context import (
|
||
_vocab_kinds,
|
||
_window,
|
||
)
|
||
from wp_api._helpers import db_session, json_error, json_ok
|
||
from wp_api._validators import validate_wildcard_name
|
||
|
||
|
||
def _reusable(request: web.Request) -> list[dict[str, Any]]:
|
||
"""Library rows a pack may reuse or point at: wildcards and fixed values."""
|
||
with db_session(request) as conn:
|
||
return [m for m in ModuleRepository(conn).list()
|
||
if m.get("type") in ai_pack.REUSABLE]
|
||
|
||
|
||
def _pack_base(request: web.Request, body: dict[str, Any],
|
||
library: list[dict[str, Any]]) -> dict[str, Any] | None | web.Response:
|
||
""""Extend with AI": the bundle named by ``bundle_id``, read fresh on
|
||
every request (None without one)."""
|
||
bid = body.get("bundle_id")
|
||
if bid is None:
|
||
return None
|
||
if not isinstance(bid, str) or not bid:
|
||
return json_error("bundle_id must be a bundle id", status=400)
|
||
with db_session(request) as conn:
|
||
bundles = BundleRepository(conn).list()
|
||
bundle = next((b for b in bundles if b["id"] == bid), None)
|
||
if bundle is None:
|
||
return json_error("that bundle no longer exists", status=404)
|
||
return ai_pack.bundle_base(bundle, bundles, library)
|
||
|
||
|
||
def _canvas(body: dict[str, Any]) -> list[dict[str, Any]] | None:
|
||
"""The canvas variables the manager sent (``canvas``), or None when it
|
||
sent none (a plan sent back keeps the ones it had)."""
|
||
raw = body.get("canvas")
|
||
return raw if isinstance(raw, list) else None
|
||
|
||
|
||
async def _check_request(cfg: dict[str, Any], body: dict[str, Any]) -> str:
|
||
"""The request the manager sends back with a plan, as the code checks
|
||
read it: a request in another language becomes its English
|
||
restatement (`engine/ai/lang.py`, asked once and kept) when the plan
|
||
itself is written in English, so its words are matched against the
|
||
plan's. Returns the user's own words, for what the model reads."""
|
||
raw = body.get("request")
|
||
if not isinstance(raw, str) or not raw.strip():
|
||
return ""
|
||
english = await _ai_lang.check_text(cfg, raw)
|
||
body["request"] = ai_lang.for_content(raw, english, body.get("plan"))
|
||
return raw
|
||
|
||
|
||
async def _english_parts(cfg: dict[str, Any], *parts: str) -> str:
|
||
"""``parts`` joined, each in its English restatement when it isn't
|
||
English."""
|
||
return " ".join([await _ai_lang.check_text(cfg, p) if p else p for p in parts])
|
||
|
||
|
||
def _pack_body(
|
||
body: dict[str, Any], library: list[dict[str, Any]] | None = None,
|
||
base: dict[str, Any] | None = None,
|
||
) -> tuple[dict[str, Any], dict[str, Any]] | web.Response:
|
||
"""The plan and built items the manager sends back, checked again."""
|
||
raw = body.get("plan")
|
||
try:
|
||
if isinstance(raw, dict) and isinstance(raw.get("graph"), dict):
|
||
# A graph plan: its items are compiled from the graph again, and
|
||
# keep their keys and ids through ``previous``.
|
||
asked = _got if isinstance(_got := body.get("request"), str) else ""
|
||
plan = ai_pack.compile_graph_plan(raw, library=library, base=base,
|
||
canvas=_canvas(body), previous=raw,
|
||
settings=raw.get("settings"), request=asked)
|
||
else:
|
||
plan = ai_pack.compile_plan(raw, library=library, base=base, canvas=_canvas(body))
|
||
except ai_pack.PlanError as e:
|
||
return json_error(f"the plan can't be used: {e}", status=400)
|
||
return plan, ai_pack.parse_built(plan, body.get("built"))
|
||
|
||
|
||
def _cut_off_twice(message: str) -> str:
|
||
"""The error for an answer cut off at the output limit after the retry
|
||
asked for less: what to try next, in the pack builder's terms."""
|
||
if message != _ai_client.CUT_OFF_MESSAGE:
|
||
return message
|
||
return ("the model's answer ran past its output limit twice, even when asked for less. "
|
||
"Rebuild it with a note asking for fewer options or shorter text, or raise the "
|
||
"output limit in Settings › AI assistant.")
|
||
|
||
|
||
async def _repair_refs(cfg: dict[str, Any], plan: dict[str, Any], **kw: Any) -> dict[str, Any]:
|
||
"""One repair call for the relations a graph plan dropped, on an enum
|
||
of what the graph declares; a failed call keeps the plan as it is."""
|
||
g = {**plan["graph"], "bad_refs": plan["bad_refs"]}
|
||
try:
|
||
fix = await _ai_client.complete_json(
|
||
cfg, system=ai_pack.GRAPH_SYSTEM, user=ai_pack.refs_prompt(g),
|
||
schema=ai_pack.refs_schema(g), schema_name="pack_refs", temperature=0.2)
|
||
return ai_pack.compile_graph_plan(ai_pack.with_refs(g, fix), previous=plan, **kw)
|
||
except (_ai_client.AIError, ai_pack.PlanError):
|
||
return plan
|
||
|
||
|
||
async def post_pack_plan(request: web.Request) -> web.Response:
|
||
"""Body: {instruction, history?, sample?, settings?, decisions?,
|
||
previous?, images?}. An outline of the pack: title, questions,
|
||
assumptions, sections and the items to build, each with a reserved
|
||
module id. ``decisions`` are questions already answered (never asked
|
||
again); ``previous`` is the plan being refined (its items keep their
|
||
keys and ids, and hand edits replace the model's last answer)."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request, limit=_MAX_IMAGE_BODY_BYTES)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
instruction = _instruction(body, "describe the pack first")
|
||
if isinstance(instruction, web.Response):
|
||
return instruction
|
||
refused = ai_adult.refused_request(instruction)
|
||
if refused:
|
||
return json_error(refused, status=400)
|
||
turns = _turns(body)
|
||
if isinstance(turns, web.Response):
|
||
return turns
|
||
images = await _request_images(cfg, body)
|
||
if isinstance(images, web.Response):
|
||
return images
|
||
sample = _got if isinstance(_got := body.get("sample"), str) else ""
|
||
settings = ai_pack.parse_settings(body.get("settings"))
|
||
decisions = ai_pack.parse_decisions(body.get("decisions"))
|
||
with db_session(request) as conn:
|
||
modules = ModuleRepository(conn).list()
|
||
lib_note = ai_patterns.note(ai_patterns.summarize(
|
||
modules, BundleRepository(conn).list(), TemplateRepository(conn).list()),
|
||
reuse=False)
|
||
library = [m for m in modules if m.get("type") in ai_pack.REUSABLE]
|
||
base = _pack_base(request, body, library)
|
||
if isinstance(base, web.Response):
|
||
return base
|
||
previous = _got if isinstance(_got := body.get("previous"), dict) else None
|
||
canvas = _canvas(body) or []
|
||
if turns and previous is not None:
|
||
# The user may have edited the plan by hand: the model's last answer
|
||
# is replayed as the plan they see now.
|
||
try:
|
||
if isinstance(previous.get("graph"), dict):
|
||
shown = ai_pack.compile_graph_plan(previous, library=library, base=base,
|
||
canvas=canvas, previous=previous,
|
||
settings=settings)
|
||
turns[-1]["answer"] = ai_pack.graph_answer(shown)
|
||
else:
|
||
shown = ai_pack.compile_plan(previous, library=library, base=base,
|
||
canvas=canvas)
|
||
turns[-1]["answer"] = ai_pack.plan_answer(shown)
|
||
except ai_pack.PlanError:
|
||
previous = None
|
||
web_on = _ai_search.enabled(cfg)
|
||
searched = [q for q in (body.get("searched") or []) if isinstance(q, str)][:12] \
|
||
if isinstance(body.get("searched"), list) else []
|
||
# A follow-up sends the images again only when they changed (or the
|
||
# user asked), so the first request's prompt keeps the count it had.
|
||
first_images = len(images)
|
||
if turns and isinstance(body.get("image_count"), int) \
|
||
and not isinstance(body.get("image_count"), bool):
|
||
first_images = max(0, min(int(body["image_count"]), 8))
|
||
kinds = await _vocab_kinds(body)
|
||
first_text = ai_followup.first_instruction(turns, instruction)
|
||
loras = ai_vocab.loras_note(_ai_vocab.lookup, kinds,
|
||
re.findall(r"[a-z]{3,}", f"{first_text} {sample}".lower()))
|
||
graph = settings["planner"] == "graph"
|
||
first = ai_pack.plan_prompt(
|
||
first_text, library_note=lib_note,
|
||
sample=sample, settings=settings, decisions=[] if turns else decisions,
|
||
library=library, images=first_images, web=web_on, loras=loras, graph=graph)
|
||
if base is not None:
|
||
first = ai_pack.base_note(base) + "\n\n" + first
|
||
follow = ai_pack.refine_text(instruction, decisions, settings, body.get("web"),
|
||
searched, graph=graph) if turns else instruction
|
||
canvas_line = ai_pack.canvas_note(ai_pack.canvas_vars(canvas))
|
||
if canvas_line:
|
||
if turns:
|
||
follow += "\n\n" + canvas_line
|
||
else:
|
||
first += "\n\n" + canvas_line
|
||
if turns and first_images and not images:
|
||
follow += ("\n\nThe reference images aren't attached again; keep what you took "
|
||
"from them unless asked otherwise.")
|
||
if turns and previous is not None and ai_pack.locks_note(previous):
|
||
follow += "\n\n" + ai_pack.locks_note(previous)
|
||
answer: Any = None
|
||
plan: Any = None # set by the loop, which returns when it can't
|
||
retry = ""
|
||
# The user's own words. A decision's answer is not one of them: picked
|
||
# from the planner's own choices, it reads "a woman", "Generic big city"
|
||
# or "your call", and those came back as request gaps at fix level in
|
||
# every flow of the Sonnet walkthrough (2026-10-21), costing a Fix all
|
||
# round each. The planner reads the decisions in every request anyway.
|
||
raw_asked = " ".join([first_text, ai_pack.own_request(instruction), sample])
|
||
asked = raw_asked
|
||
# A request in another language: its English restatement, for a plan
|
||
# the planner writes in English (`_asked_for`).
|
||
english = await _english_parts(cfg, first_text, ai_pack.own_request(instruction))
|
||
english = f"{english} {sample}" if sample else english
|
||
|
||
def _asked_for(answer: Any) -> str:
|
||
return ai_lang.for_content(raw_asked, english, answer)
|
||
|
||
for attempt in range(2):
|
||
try:
|
||
answer = await _ask(
|
||
cfg, first=first, turns=turns, instruction=follow, extra=retry,
|
||
system=ai_pack.GRAPH_SYSTEM if graph else ai_pack.PLAN_SYSTEM,
|
||
schema=ai_pack.GRAPH_SCHEMA if graph else ai_pack.PLAN_SCHEMA,
|
||
schema_name="pack_graph" if graph else "pack_plan",
|
||
temperature=0.4 if not retry else 0.2,
|
||
images=images or None,
|
||
image_note=False, # plan_prompt says how to use them
|
||
partial=True, # a cut-off plan keeps the items it finished
|
||
)
|
||
asked = _asked_for(answer)
|
||
if graph:
|
||
plan = ai_pack.compile_graph_plan(answer, library=library, previous=previous,
|
||
decisions=decisions, settings=settings,
|
||
base=base, canvas=canvas, request=asked)
|
||
if plan["bad_refs"]:
|
||
plan = await _repair_refs(cfg, plan, library=library, decisions=decisions,
|
||
settings=settings, base=base, canvas=canvas,
|
||
request=asked)
|
||
else:
|
||
plan = ai_pack.compile_plan(answer, library=library, previous=previous,
|
||
decisions=decisions, settings=settings, base=base,
|
||
canvas=canvas, request=asked)
|
||
break
|
||
except _ai_client.AIError as e:
|
||
return json_error(e.message, status=e.status)
|
||
except ai_pack.PlanError as e:
|
||
if isinstance(answer, dict) and answer.get(_ai_client.CUT_OFF):
|
||
if attempt:
|
||
return json_error(_ai_client.CUT_OFF_MESSAGE, status=502)
|
||
# A plan that ran into the output limit is most often a
|
||
# model repeating itself: one more sample, asked for less.
|
||
retry = ai_pack.CUT_OFF_NOTE
|
||
continue
|
||
if attempt:
|
||
return json_error(
|
||
f"the model's plan couldn't be used ({e}). Try again, or a larger model.",
|
||
status=502,
|
||
)
|
||
# A small model now and then answers a plan with no wildcards
|
||
# or no template: asked once more with what was wrong.
|
||
retry = ai_pack.plan_retry_note(str(e))
|
||
temps = ai_pack.extra_temperatures(settings["drafts"], plan, asked, library) \
|
||
if graph and not turns else ()
|
||
if temps:
|
||
# More drafts of the first plan (always two with "three", one when
|
||
# the first scores below 0 with "auto"): the code keeps the draft
|
||
# that fits the request best (`drafts.pick_draft`); a failed draft
|
||
# is skipped.
|
||
drafts = [(answer, plan)]
|
||
# The images went once: the other drafts read what the first took
|
||
# from them instead of reading the images again.
|
||
seen = ai_pack.images_seen(plan) if images else ""
|
||
for t in temps:
|
||
try:
|
||
a = await _ask(cfg, first=first, turns=turns, instruction=follow, extra=seen,
|
||
system=ai_pack.GRAPH_SYSTEM, schema=ai_pack.GRAPH_SCHEMA,
|
||
schema_name="pack_graph", temperature=t,
|
||
images=None, image_note=False, partial=True)
|
||
if isinstance(a, dict) and a.get(_ai_client.CUT_OFF):
|
||
continue
|
||
p = ai_pack.compile_graph_plan(a, library=library, previous=previous,
|
||
decisions=decisions, settings=settings,
|
||
base=base, canvas=canvas, request=asked)
|
||
if p["bad_refs"]:
|
||
p = await _repair_refs(cfg, p, library=library, decisions=decisions,
|
||
settings=settings, base=base, canvas=canvas,
|
||
request=asked)
|
||
drafts.append((a, p))
|
||
except (_ai_client.AIError, ai_pack.PlanError):
|
||
continue
|
||
best, scores = ai_pack.pick_draft([p for _, p in drafts], asked, library)
|
||
answer, plan = drafts[best]
|
||
line = ai_pack.draft_line(best, scores)
|
||
if line:
|
||
plan["applied"] = [*plan.get("applied", []), line]
|
||
if not web_on:
|
||
plan["research"] = []
|
||
plan["research"] = [q for q in plan["research"]
|
||
if not any(q.lower() == s.lower() for s in searched)]
|
||
if not images and not first_images:
|
||
plan["from_images"] = []
|
||
if isinstance(answer, dict):
|
||
answer.pop(_ai_client.CUT_OFF, None) # never replayed to the model
|
||
thought = _ai_client.last_thinking()
|
||
return json_ok({"plan": plan, "model": cfg["model"], "answer": answer,
|
||
**({"thinking": thought} if thought else {}),
|
||
"review": ai_pack.review_plan(plan, library, request=asked),
|
||
"web": web_on,
|
||
"context": await _plan_context(cfg, first, turns, follow, answer,
|
||
len(images), graph=graph)})
|
||
|
||
|
||
async def _plan_context(cfg: dict[str, Any], first: str, turns: list[dict[str, Any]],
|
||
follow: str, answer: Any, images: int, *,
|
||
graph: bool = False) -> dict[str, Any]:
|
||
"""The pack plan's meter, like the chat's: about how much of the model's
|
||
window the last request and its answer took (every refine sends the
|
||
whole conversation again, so it grows)."""
|
||
history, user = ai_followup.conversation(first, turns, follow)
|
||
chars = ai_budget.prompt_chars(ai_pack.GRAPH_SYSTEM if graph else ai_pack.PLAN_SYSTEM,
|
||
history, user,
|
||
ai_pack.GRAPH_SCHEMA if graph else ai_pack.PLAN_SCHEMA)
|
||
chars += len(_json.dumps(answer, ensure_ascii=False)) if answer is not None else 0
|
||
return {"used": ai_budget.tokens(chars) + 1000 * images,
|
||
"window": await _window(cfg, {}), "estimated": True,
|
||
"about": not cfg.get("local")}
|
||
|
||
|
||
async def post_pack_search(request: web.Request) -> web.Response:
|
||
"""Body: {queries}. Runs the searches the pack planner or a Draft with
|
||
AI panel asked for, after the user allowed them. Returns {results: [{query,
|
||
text, rows: [{title, url, snippet}], error?}]}; the text is labelled as
|
||
untrusted for the model, the rows are what the Pack builder lets the user
|
||
pick from."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
if not _ai_search.enabled(cfg):
|
||
return json_error("web search is off (Settings › AI assistant › Web search)",
|
||
status=400)
|
||
raw = body.get("queries")
|
||
queries = [q.strip()[:120] for q in raw if isinstance(q, str) and q.strip()] \
|
||
if isinstance(raw, list) else []
|
||
if not queries or len(queries) > ai_pack.MAX_RESEARCH:
|
||
return json_error(f"send 1 to {ai_pack.MAX_RESEARCH} searches", status=400)
|
||
results = []
|
||
for q in queries:
|
||
# The rows let the page show what each search found and let the
|
||
# user pick what goes to the planner (Dumi 2026-10-21: "use what
|
||
# the searches found" trusted results nobody had seen).
|
||
try:
|
||
rows = await _ai_search.search_rows(cfg, q)
|
||
results.append({"query": q, "text": "web_search: " + _ai_search.rows_text(q, rows),
|
||
"rows": rows})
|
||
except _ai_client.AIError as e:
|
||
results.append({"query": q, "text": f"web_search: error: {e.message}", "rows": [],
|
||
"error": e.message})
|
||
return json_ok({"results": results})
|
||
|
||
|
||
async def post_pack_recommend(request: web.Request) -> web.Response:
|
||
"""Body: {request, suggested?}. The pack settings the request, the
|
||
assistant's model and the chat's suggestion (``suggested``, from a plan
|
||
card) point to: ``{settings, reasons}``. No model call, unless the request isn't
|
||
in English (`engine/ai/lang.py`)."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
text = body.get("request")
|
||
text = text[:ai_wildcard.MAX_INSTRUCTION_LEN] if isinstance(text, str) else ""
|
||
# A request in another language: read in its English restatement (one
|
||
# small model call, kept for the plan that follows).
|
||
text = await _ai_lang.check_text(cfg, text)
|
||
return json_ok(pack_recommend.recommend(text, cfg, body.get("suggested")))
|
||
|
||
|
||
async def post_pack_change(request: web.Request) -> web.Response:
|
||
"""Body: {plan, key?, instruction}. One item rewritten by the model as
|
||
the user asks (or, with no key, items added); the rest of the plan
|
||
stays as it is. Returns {plan, review}."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request, limit=_MAX_PACK_BODY_BYTES)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
await _check_request(cfg, body)
|
||
instruction = _instruction(body, "say what to change")
|
||
if isinstance(instruction, web.Response):
|
||
return instruction
|
||
refused = ai_adult.refused_request(instruction)
|
||
if refused:
|
||
return json_error(refused, status=400)
|
||
library = _reusable(request)
|
||
base = _pack_base(request, body, library)
|
||
if isinstance(base, web.Response):
|
||
return base
|
||
parsed = _pack_body(body, library, base)
|
||
if isinstance(parsed, web.Response):
|
||
return parsed
|
||
plan, _built = parsed
|
||
key = _got if isinstance(_got := body.get("key"), str) else ""
|
||
if key and key not in {i["key"] for i in plan["items"]}:
|
||
return json_error("no such item in the plan", status=400)
|
||
asked = _got if isinstance(_got := body.get("request"), str) else ""
|
||
try:
|
||
if isinstance(plan.get("graph"), dict):
|
||
# A graph plan: one part on its own schema, merged by the code.
|
||
answer = await _ai_client.complete_json(
|
||
cfg, system=_with_note(cfg, ai_pack.GRAPH_CHANGE_SYSTEM),
|
||
user=ai_pack.graph_change_prompt(plan, key, instruction),
|
||
schema=ai_pack.change_schema(plan, key), schema_name="pack_graph_change",
|
||
temperature=0.4,
|
||
)
|
||
new = ai_pack.apply_graph_change(plan, key, answer, library=library, base=base,
|
||
canvas=_canvas(body), request=asked,
|
||
instruction=instruction)
|
||
if new["bad_refs"]:
|
||
new = await _repair_refs(cfg, new, library=library, base=base,
|
||
canvas=_canvas(body), request=asked)
|
||
else:
|
||
answer = await _ai_client.complete_json(
|
||
cfg, system=_with_note(cfg, ai_pack.CHANGE_SYSTEM),
|
||
user=ai_pack.change_prompt(plan, key, instruction),
|
||
schema=ai_pack.CHANGE_SCHEMA, schema_name="pack_change", temperature=0.4,
|
||
)
|
||
new = ai_pack.apply_change(plan, key, answer, library, base)
|
||
except _ai_client.AIError as e:
|
||
return json_error(e.message, status=e.status)
|
||
except ai_pack.PlanError as e:
|
||
return json_error(f"the model's change couldn't be used ({e}). Try again.",
|
||
status=502)
|
||
return json_ok({"plan": new, "answer": answer, "model": cfg["model"],
|
||
"review": ai_pack.review_plan(new, library, request=asked)})
|
||
|
||
|
||
async def post_pack_images(request: web.Request) -> web.Response:
|
||
"""Body: {plan, images, note?, request?}. Reference images added to a
|
||
graph plan after it was made: one narrow call says what they add
|
||
(slots, fixed values, from_images lines), merged into the graph by the
|
||
code instead of a whole re-plan. Returns {plan, answer, model, review}."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request, limit=_MAX_PACK_BODY_BYTES)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
await _check_request(cfg, body)
|
||
images = await _request_images(cfg, body)
|
||
if isinstance(images, web.Response):
|
||
return images
|
||
if not images:
|
||
return json_error("no images were sent", status=400)
|
||
library = _reusable(request)
|
||
base = _pack_base(request, body, library)
|
||
if isinstance(base, web.Response):
|
||
return base
|
||
parsed = _pack_body(body, library, base)
|
||
if isinstance(parsed, web.Response):
|
||
return parsed
|
||
plan, _built = parsed
|
||
if not isinstance(plan.get("graph"), dict):
|
||
return json_error("only a graph plan reads images this way", status=400)
|
||
note = _got if isinstance(_got := body.get("note"), str) else ""
|
||
asked = _got if isinstance(_got := body.get("request"), str) else ""
|
||
try:
|
||
answer = await _ai_client.complete_json(
|
||
cfg, system=_with_note(cfg, ai_pack.IMAGES_SYSTEM),
|
||
user=ai_pack.images_prompt(plan, note),
|
||
schema=ai_pack.IMAGES_SCHEMA, schema_name="pack_images", temperature=0.3,
|
||
images=images,
|
||
)
|
||
new = ai_pack.apply_images(plan, answer, library=library, base=base,
|
||
canvas=_canvas(body), request=asked)
|
||
except _ai_client.AIError as e:
|
||
return json_error(e.message, status=e.status)
|
||
except ai_pack.PlanError as e:
|
||
return json_error(f"the model's answer couldn't be used ({e}). Try again.",
|
||
status=502)
|
||
return json_ok({"plan": new, "answer": answer, "model": cfg["model"],
|
||
"review": ai_pack.review_plan(new, library, request=asked)})
|
||
|
||
|
||
async def post_pack_review(request: web.Request) -> web.Response:
|
||
"""Body: {plan, ai?, request?}. The plan checked against the guide
|
||
before it is built; with ``ai`` the model reviews it too. Returns
|
||
{findings, note, plan}: ``plan`` is the plan as the server reads it
|
||
(hand-added items get their keys and reserved ids)."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request, limit=_MAX_PACK_BODY_BYTES)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
own_words = await _check_request(cfg, body)
|
||
library = _reusable(request)
|
||
base = _pack_base(request, body, library)
|
||
if isinstance(base, web.Response):
|
||
return base
|
||
parsed = _pack_body(body, library, base)
|
||
if isinstance(parsed, web.Response):
|
||
return parsed
|
||
plan, _built = parsed
|
||
asked = _got if isinstance(_got := body.get("request"), str) else ""
|
||
findings = ai_pack.review_plan(plan, library, request=asked)
|
||
if body.get("ai") is True:
|
||
raw = body.get("recheck")
|
||
recheck = [ai_safety.strip_invisible(t).strip()[:300] for t in raw[:ai_pack.MAX_RECHECK]
|
||
if isinstance(t, str) and t.strip()] if isinstance(raw, list) else None
|
||
ask = {"system": _with_note(cfg, ai_pack.review_system(plan)),
|
||
"user": ai_pack.review_prompt(plan, findings, own_words, recheck),
|
||
"schema": ai_pack.review_schema(plan), "schema_name": "pack_review",
|
||
"temperature": 0.3}
|
||
try:
|
||
try:
|
||
answer = await _ai_client.complete_json(cfg, **ask)
|
||
except _ai_client.AIError as e:
|
||
# Cut off at the output limit: once more, asked for less.
|
||
if e.message != _ai_client.CUT_OFF_MESSAGE:
|
||
raise
|
||
answer = await _ai_client.complete_json(
|
||
cfg, **{**ask, "user": ask["user"] + "\n\n" + ai_pack.REVIEW_CUT_OFF_NOTE})
|
||
except _ai_client.AIError as e:
|
||
return json_error(_cut_off_twice(e.message), status=e.status)
|
||
findings += ai_pack.compile_review(answer, plan, findings, recheck)
|
||
return json_ok({"findings": findings, "note": ai_pack.review_note(findings),
|
||
"plan": plan})
|
||
|
||
|
||
async def post_pack_item(request: web.Request) -> web.Response:
|
||
"""Body: {plan, built, key, note?}. Builds one item with the model.
|
||
A rebuild keeps the item's id, so constraints keep pointing at it."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request, limit=_MAX_PACK_BODY_BYTES)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
await _check_request(cfg, body)
|
||
library = _reusable(request)
|
||
base = _pack_base(request, body, library)
|
||
if isinstance(base, web.Response):
|
||
return base
|
||
parsed = _pack_body(body, library, base)
|
||
if isinstance(parsed, web.Response):
|
||
return parsed
|
||
plan, built = parsed
|
||
key = body.get("key")
|
||
note = _got if isinstance(_got := body.get("note"), str) else ""
|
||
if not isinstance(key, str) or key not in {i["key"] for i in plan["items"]}:
|
||
# The plan is compiled again from what the browser sent; an item
|
||
# that compile no longer makes (a rule whose slot went) can't be
|
||
# built (9B bench P11: "no such item in the plan" on a constraint).
|
||
sent = _got if isinstance(_got := body.get("plan"), dict) else {}
|
||
items = _got if isinstance(_got := sent.get("items"), list) else []
|
||
name = next((str(i.get("name") or "") for i in items
|
||
if isinstance(i, dict) and i.get("key") == key), "")
|
||
return json_error((f"{name or 'This item'} isn't in the plan any more: the plan changed "
|
||
"when it was read back. Go back to the plan and build again, or skip "
|
||
"it"), status=400)
|
||
if next(i for i in plan["items"] if i["key"] == key)["kind"] == "library":
|
||
try:
|
||
out = ai_pack.library_item(plan, key, library)
|
||
except ai_pack.PlanError as e:
|
||
return json_error(str(e), status=400)
|
||
return json_ok({**out, "repair": "", "model": cfg["model"]})
|
||
if ai_pack.is_wired(plan, key):
|
||
# The graph wires this item: no model call.
|
||
try:
|
||
wired = ai_pack.wired_payload(plan, key, built)
|
||
except (ai_pack.PlanError, ValueError) as e:
|
||
return json_error(str(e), status=400)
|
||
item = next(i for i in plan["items"] if i["key"] == key)
|
||
return json_ok({"key": key, "id": item["id"], "kind": wired["kind"],
|
||
"payload": wired["payload"], "preview": wired["preview"],
|
||
"warnings": wired["warnings"], "repair": "", "model": cfg["model"],
|
||
"wired": True})
|
||
kinds = await _vocab_kinds(body)
|
||
try:
|
||
req = ai_pack.item_request(plan, key, built, note[:1500], library=library)
|
||
except ai_pack.PlanError as e:
|
||
return json_error(str(e), status=400)
|
||
req["system"] = _with_note(cfg, req["system"])
|
||
vocab_line = ai_pack.item_vocab_note(plan, key, _ai_vocab.lookup, kinds)
|
||
if vocab_line:
|
||
req["user"] += "\n\n" + vocab_line
|
||
# The template's second try: when the model fails again, a plain one
|
||
# (every pick in order) keeps the pack rendering.
|
||
last_try = next(i for i in plan["items"] if i["key"] == key)["kind"] == "template" \
|
||
and note.startswith("your last answer failed")
|
||
old = built.get(key)
|
||
try:
|
||
try:
|
||
answer = await _ai_client.complete_json(cfg, **req)
|
||
except _ai_client.AIError as e:
|
||
# An answer that ran into the output limit is most often a
|
||
# model repeating itself: one more sample, asked for less
|
||
# (9B bench P6/P10/Z19: the error told the user to raise the
|
||
# context, which a retry fixes).
|
||
if e.message != _ai_client.CUT_OFF_MESSAGE:
|
||
raise
|
||
answer = await _ai_client.complete_json(
|
||
cfg, **{**req, "user": req["user"] + "\n\n" + ai_pack.ITEM_CUT_OFF_NOTE})
|
||
out = ai_pack.compile_item(
|
||
plan, key, built, answer, taken={b["id"] for b in built.values()},
|
||
item_id=old["id"] if old else "", library=library,
|
||
)
|
||
except (_ai_client.AIError, ValueError) as e:
|
||
why = e.message if isinstance(e, _ai_client.AIError) else str(e)
|
||
if last_try:
|
||
try:
|
||
out = pack_tidy.fallback_item(plan, key, built, why, library=library)
|
||
except ValueError as e2:
|
||
return json_error(f"the model's answer couldn't be used ({e2})", status=502)
|
||
elif isinstance(e, _ai_client.AIError):
|
||
return json_error(_cut_off_twice(e.message), status=e.status)
|
||
else:
|
||
return json_error(
|
||
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
|
||
status=502,
|
||
)
|
||
grounded, spelled = ai_pack.ground_item(plan, out["kind"], out["payload"],
|
||
_ai_vocab.lookup, kinds)
|
||
if spelled:
|
||
out = {**out, "payload": grounded, "preview": ai_pack.item_preview(out["kind"], grounded),
|
||
"warnings": [*out["warnings"], spelled]}
|
||
checked = ai_pack.tag_check_note(plan, out["kind"], out["payload"], _ai_vocab.lookup,
|
||
kinds)
|
||
if checked:
|
||
out["warnings"] = [*out["warnings"], checked]
|
||
out["model"] = cfg["model"]
|
||
thought = _ai_client.last_thinking()
|
||
if thought:
|
||
out["thinking"] = thought
|
||
return json_ok(out)
|
||
|
||
|
||
async def post_pack_check(request: web.Request) -> web.Response:
|
||
"""Body: {plan, built}. Runs the pack on the engine; no model call."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request, limit=_MAX_PACK_BODY_BYTES)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
await _check_request(cfg, body)
|
||
library = _reusable(request)
|
||
base = _pack_base(request, body, library)
|
||
if isinstance(base, web.Response):
|
||
return base
|
||
parsed = _pack_body(body, library, base)
|
||
if isinstance(parsed, web.Response):
|
||
return parsed
|
||
plan, built = parsed
|
||
if not built:
|
||
return json_error("nothing is built yet", status=400)
|
||
loop = asyncio.get_running_loop()
|
||
|
||
def run() -> dict[str, Any]:
|
||
# Fixes that need no model (words said twice, picks another wildcard
|
||
# makes) go back to the browser with the report checked on them.
|
||
tidied_built, tidied = pack_tidy.tidy(plan, built, library=library, base=base)
|
||
report = ai_pack.check_pack(plan, tidied_built, library=library, base=base)
|
||
return {**report, "tidied": tidied}
|
||
|
||
return json_ok(await loop.run_in_executor(None, run))
|
||
|
||
|
||
async def post_pack_install(request: web.Request) -> web.Response:
|
||
"""Body: {plan, built, bundle_id?}. One import commit: the modules, the
|
||
template and a bundle, tagged as one collection; with ``bundle_id`` the
|
||
new items go into that bundle instead (it is replaced, so the same undo
|
||
puts it back). Returns the import's undo id."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request, limit=_MAX_PACK_BODY_BYTES)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
await _check_request(cfg, body)
|
||
library = _reusable(request)
|
||
base = _pack_base(request, body, library)
|
||
if isinstance(base, web.Response):
|
||
return base
|
||
parsed = _pack_body(body, library, base)
|
||
if isinstance(parsed, web.Response):
|
||
return parsed
|
||
plan, built = parsed
|
||
if not any(i["key"] in built for i in plan["items"]
|
||
if i["kind"] != "template" and not i.get("existing")):
|
||
return json_error("nothing is built yet", status=400)
|
||
for i in plan["items"]:
|
||
if i["kind"] == "wildcard" and i["key"] in built:
|
||
err = validate_wildcard_name(i["name"])
|
||
if err is not None:
|
||
return json_error(f"{i['name']}: {err}", status=400)
|
||
with db_session(request) as conn:
|
||
def ids(table: str) -> set[str]:
|
||
return {r[0] for r in conn.execute(f"SELECT id FROM {table};")} # noqa: S608
|
||
taken = ids("modules") | ids("bundles")
|
||
if base is None:
|
||
ops = ai_pack.install_ops(plan, built, taken=taken,
|
||
template_ids=ids("templates"), bundle_ids=ids("bundles"))
|
||
else:
|
||
ops = ai_pack.extend_ops(plan, built, base, taken=taken,
|
||
template_ids=ids("templates"), bundle_ids=ids("bundles"))
|
||
result = commit_import(conn, {"adds": ops["adds"], "replaces": ops["replaces"]})
|
||
if not result.get("ok"):
|
||
return json_error(result.get("error", "install failed"), status=400)
|
||
return json_ok({
|
||
"undo_entry_id": result["undo_id"], "ids": ops["ids"],
|
||
"bundle_id": ops["bundle_id"], "sub_bundles": ops["sub_bundles"],
|
||
"template_id": ops["template_id"], "tag": ops["tag"], "notes": ops["notes"],
|
||
})
|
||
|
||
|
||
async def post_bundle_split(request: web.Request) -> web.Response:
|
||
"""Body: {name, children, instruction?}. The bundle editor's children
|
||
(unsaved edits included) grouped into sub-bundles by the model.
|
||
Returns {groups: [{name, about, start, end}]}: runs of neighbouring
|
||
children, so the run order never changes. Nothing is saved."""
|
||
cfg = _enabled_cfg()
|
||
if isinstance(cfg, web.Response):
|
||
return cfg
|
||
body = await _body(request)
|
||
if isinstance(body, web.Response):
|
||
return body
|
||
children = body.get("children")
|
||
if not isinstance(children, list) or not all(isinstance(c, dict) for c in children):
|
||
return json_error("children must be a list", status=400)
|
||
if len(children) < 3:
|
||
return json_error("a bundle needs at least 3 children to split", status=400)
|
||
if len(children) > ai_split.MAX_CHILDREN:
|
||
return json_error(f"at most {ai_split.MAX_CHILDREN} children", status=400)
|
||
name = _got if isinstance(_got := body.get("name"), str) else ""
|
||
instruction = _got if isinstance(_got := body.get("instruction"), str) else ""
|
||
try:
|
||
answer = await _ai_client.complete_json(
|
||
cfg, system=ai_split.SYSTEM,
|
||
user=ai_split.prompt(name, children, instruction[:1000]),
|
||
schema=ai_split.SCHEMA, schema_name="bundle_split", temperature=0.3,
|
||
)
|
||
groups = ai_split.compile_groups(answer, children)
|
||
except _ai_client.AIError as e:
|
||
return json_error(e.message, status=e.status)
|
||
except ai_split.SplitError as e:
|
||
return json_error(str(e), status=502)
|
||
return json_ok({"groups": groups, "model": cfg["model"]})
|