Files
DumiFlex 87dd6ab12c feat(ai): add an "About my library" note every AI request reads (#195)
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.
2026-10-29 14:08:34 +01:00

908 lines
40 KiB
Python

"""Editor panels: wildcard draft + edit, derivation, text and constraint
drafts, describe and organize, and the Suggest button of the "About my
library" note. Nothing here writes to the library."""
from __future__ import annotations
from typing import Any
from aiohttp import web
from engine.ai import about as ai_about
from engine.ai import chat as ai_chat
from engine.ai import constraint as ai_constraint
from engine.ai import derivation as ai_derivation
from engine.ai import describe as ai_describe
from engine.ai import followup as ai_followup
from engine.ai import lang as ai_lang
from engine.ai import library_vars, text_items
from engine.ai import note as ai_note
from engine.ai import organize as ai_organize
from engine.ai import refs as ai_refs
from engine.ai import safety as ai_safety
from engine.ai import wildcard as ai_wildcard
from engine.ai import wildcard_edit as ai_edit
from engine.db.repositories import (
BundleRepository,
CategoryRepository,
ModuleNotFound,
ModuleRepository,
TemplateRepository,
)
from wp_api import (
_ai_client,
_ai_lang,
)
from wp_api._ai_common import (
_MAX_IMAGE_BODY_BYTES,
_ask,
_body,
_enabled_cfg,
_instruction,
_ref_index,
_request_images,
_str_list,
_turns,
_Web,
_wildcards,
)
from wp_api._helpers import db_session, json_error, json_ok
async def post_wildcard_draft(request: web.Request) -> web.Response:
"""Body: {instruction, count?, wildcard: {name?, var_binding?, payload}}.
`payload` is the editor's current draft (options, sub_categories,
tag_groups), so the model sees unsaved edits too.
"""
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
images = await _request_images(cfg, body)
if isinstance(images, web.Response):
return images
webs = _Web(cfg, body)
instruction = body.get("instruction")
if not isinstance(instruction, str) or not instruction.strip():
return json_error("say what to add", status=400)
if len(instruction) > ai_wildcard.MAX_INSTRUCTION_LEN:
return json_error("that request is too long", status=400)
count = body.get("count", 10)
if isinstance(count, bool) or not isinstance(count, int):
return json_error("count must be a whole number", status=400)
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
# A request in another language: the checks read its English
# restatement, and with `count_auto` (the panel's How many box is
# neither set by hand nor read from English words) its count.
frame = await _ai_lang.frame(cfg, ai_followup.first_instruction(turns, instruction))
if body.get("count_auto") is True and not turns and ai_lang.count(frame):
count = ai_lang.count(frame)
count = max(1, min(ai_wildcard.MAX_COUNT, count))
check = ai_lang.check_text(frame, ai_followup.first_instruction(turns, instruction))
# A few more than wanted: some come back as repeats of the list (or of
# each other) and are dropped, and "3 options" should still land 3.
asked = ai_wildcard.over_ask(count)
wc = _got if isinstance(_got := body.get("wildcard"), dict) else {}
payload = _got if isinstance(_got := wc.get("payload"), dict) else {}
options = _got if isinstance(_got := payload.get("options"), list) else []
existing_texts = [
o["value"].strip() for o in options
if isinstance(o, dict) and isinstance(o.get("value"), str) and o["value"].strip()
][:5000]
tags = _str_list(payload.get("sub_categories"), limit=500)
raw_groups = _got if isinstance(_got := payload.get("tag_groups"), dict) else {}
axes = {
str(k): _str_list(v, limit=200) for k, v in list(raw_groups.items())[:50]
}
raw_kinds = payload.get("tag_group_kinds") \
if isinstance(payload.get("tag_group_kinds"), dict) else {}
kinds = {str(k): "accepts" for k, v in raw_kinds.items() if v == "accepts" and k in axes}
name = _got if isinstance(_got := wc.get("name"), str) else ""
var = _got if isinstance(_got := wc.get("var_binding"), str) else ""
description, labels = ai_about.clean(wc.get("description"), wc.get("tags"))
self_id = _got if isinstance(_got := wc.get("id"), str) else None
refs = _ref_index(_wildcards(request), self_id, payload, name)
existing_texts = [ai_refs.display_text(t, refs) for t in existing_texts]
# Each with its tags, so the model tags new options the same way
# instead of inventing its own scheme (bench W3: three runs, three).
tagged = [
_str_list(o.get("sub_categories"), limit=20) for o in options
if isinstance(o, dict) and isinstance(o.get("value"), str) and o["value"].strip()
][:5000]
existing_texts = [t + (f" [tags {', '.join(g)}]" if g else "")
for t, g in zip(existing_texts, tagged, strict=True)]
first = ai_wildcard.build_user_prompt(kinds=kinds,
instruction=ai_followup.first_instruction(turns, instruction), count=asked,
name=name[:200], var_binding=var[:100],
existing_options=existing_texts, tags=tags, axes=axes,
description=description, labels=labels, refs_text=refs.describe(exclude=self_id),
)
ask: dict[str, Any] = dict(
cfg=cfg, first=first, turns=turns, instruction=instruction,
system=webs.system(ai_wildcard.draft_prompt()),
schema=webs.schema(ai_wildcard.DRAFT_SCHEMA),
schema_name="wildcard_options",
images=images or None,
quick=True, max_tokens=ai_wildcard.answer_tokens(asked),
)
def compiled(raw: Any) -> dict[str, Any]:
return ai_wildcard.compile_options(
raw,
existing_payload={
"options": [o for o in options if isinstance(o, dict)][:5000],
"sub_categories": tags,
},
limit=count, refs=refs, self_id=self_id,
# A refine is held to the first request too ("with negatives"),
# and one in another language to its restatement as well.
request=" ".join(dict.fromkeys(
(ai_followup.first_instruction(turns, instruction), instruction, check))),
labels=labels, own_name=f"{name} {var}",
)
try:
answer = await _ask(extra=webs.note, **ask)
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
answer, research = webs.take(answer)
try:
result = compiled(answer)
except ValueError as e:
return json_error(
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
status=502,
)
asked_words = " ".join(dict.fromkeys((instruction, check)))
asked_first = " ".join(dict.fromkeys(
(ai_followup.first_instruction(turns, instruction), check)))
real_opts = [o for o in options if isinstance(o, dict)]
def problems(res: dict[str, Any]) -> list[str]:
return [x for x in (
ai_wildcard.bleed_note(name, var, res["options"], asked_words),
ai_wildcard.untagged_note(asked_first, res["options"], real_opts)) if x]
notes = problems(result)
if notes:
# One re-ask with what the code found; the first answer stands when
# the second is no better.
try:
again, _ = webs.take(await _ask(extra="\n\n".join(
x for x in (webs.note, *notes) if x), **ask))
better = compiled(again)
if len(better["options"]) >= len(result["options"]) and \
len(problems(better)) < len(notes):
answer, result = again, better
except (_ai_client.AIError, ValueError):
pass
if ai_wildcard.untagged_note(asked_first, result["options"], real_opts):
result.setdefault("warnings", []).append(
"You asked for tags, but no option came back tagged; tag them, or Try again")
groups, group_notes = ai_wildcard.new_groups(
answer.get("axes") if isinstance(answer, dict) else None,
existing_payload={"sub_categories": tags, "tag_groups": axes, "tag_group_kinds": kinds},
new_tags=result["new_tags"], options=result["options"],
)
result["groups"] = groups
result["skipped"] += [{"text": "tag groups", "reason": n} for n in group_notes]
result["model"] = cfg["model"]
result["answer"] = answer
result["research"] = research
result["count"] = count
return json_ok(result)
async def post_wildcard_edit(request: web.Request) -> web.Response:
"""Body: {action, instruction, wildcard: {name?, var_binding?, payload}}.
`payload.options` are the rows to edit (the whole list, or the editor's
selection), each with its real `id`. The model sees them numbered 1..N
and the answer is mapped back, so every change names a real option.
"""
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
images = await _request_images(cfg, body)
if isinstance(images, web.Response):
return images
webs = _Web(cfg, body)
action = body.get("action")
if action not in ai_edit.ACTIONS:
return json_error("action must be remove, rewrite, retag or reweight", status=400)
instruction = body.get("instruction")
if not isinstance(instruction, str) or not instruction.strip():
return json_error("say what to change", status=400)
if len(instruction) > ai_wildcard.MAX_INSTRUCTION_LEN:
return json_error("that request is too long", status=400)
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
# A request in another language: the checks read its restatement.
frame = await _ai_lang.frame(cfg, instruction)
check = ai_lang.check_text(frame, instruction)
wc = _got if isinstance(_got := body.get("wildcard"), dict) else {}
payload = _got if isinstance(_got := wc.get("payload"), dict) else {}
raw_opts = _got if isinstance(_got := payload.get("options"), list) else []
rows: list[dict] = []
real_ids: dict[str, str] = {}
for o in raw_opts:
if not isinstance(o, dict) or o.get("is_null") is True:
continue
value = o.get("value")
oid = o.get("id")
if not isinstance(value, str) or not value.strip() or not isinstance(oid, str):
continue
short = str(len(rows) + 1)
real_ids[short] = oid
weight = o.get("weight", 1)
if isinstance(weight, bool) or not isinstance(weight, (int, float)):
weight = 1
rows.append({
"id": short,
"value": value.strip(),
"weight": weight,
"sub_categories": _str_list(o.get("sub_categories"), limit=50),
})
if not rows:
return json_error("there are no options to edit", status=400)
if len(rows) > ai_edit.MAX_EDIT_OPTIONS:
return json_error(
f"that's {len(rows)} options; select up to {ai_edit.MAX_EDIT_OPTIONS} and try again",
status=400,
)
tags = _str_list(payload.get("sub_categories"), limit=500)
raw_groups = _got if isinstance(_got := payload.get("tag_groups"), dict) else {}
axes = {str(k): _str_list(v, limit=200) for k, v in list(raw_groups.items())[:50]}
name = _got if isinstance(_got := wc.get("name"), str) else ""
var = _got if isinstance(_got := wc.get("var_binding"), str) else ""
description, labels = ai_about.clean(wc.get("description"), wc.get("tags"))
self_id = _got if isinstance(_got := wc.get("id"), str) else None
refs = _ref_index(_wildcards(request), self_id, payload, name)
known_tag = _known_tag() if action == "rewrite" and ai_edit.wants_booru(check) \
else None
first = ai_edit.build_edit_prompt(
action=action, instruction=ai_followup.first_instruction(turns, instruction),
name=name[:200], var_binding=var[:100],
options=rows, tags=tags, axes=axes, description=description, labels=labels,
refs=refs,
)
ask: dict[str, Any] = dict(
cfg=cfg, first=first, turns=turns, instruction=instruction,
system=webs.system(ai_edit.system_prompt(action)),
schema=webs.schema(ai_edit.schema_for(action, [r["id"] for r in rows])),
schema_name=f"wildcard_{action}",
# Edits should be faithful to the request, not creative.
temperature=0.3,
images=images or None,
quick=True, max_tokens=ai_edit.answer_tokens(action, len(rows)),
)
try:
answer = await _ask(extra=webs.note, **ask)
nudged = ai_edit.answered_nothing(action, answer, check)
if nudged:
# The base 9B answered "tag each by …" with an empty list every
# time (panel eval 2026-10-21): the system prompt's "only list
# the options you change" read as "change nothing". One re-ask.
answer = await _ask(extra="\n\n".join(x for x in (webs.note, ai_edit.EMPTY_NUDGE)
if x), **ask)
missing = [] if nudged else ai_edit.left_out(action, answer, check,
[r["id"] for r in rows])
if missing:
# One re-ask in all: the empty answer's, or this one.
again = await _ask(extra="\n\n".join(
x for x in (webs.note, ai_edit.left_out_nudge(missing)) if x), **ask)
# The second answer stands only when it covers more.
if isinstance(again, dict) and again.get("options") and len(ai_edit.left_out(
action, again, check, [r["id"] for r in rows])) < len(missing):
answer = again
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
answer, research = webs.take(answer)
try:
result = ai_edit.compile_edit(action, answer, options=rows, registry=tags,
refs=refs, self_id=self_id, labels=labels,
instruction=check, frame=frame, known_tag=known_tag)
except ValueError as e:
return json_error(
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
status=502,
)
if action == "remove" and len(rows) >= 3 and len(result["changes"]) == len(rows):
# Bench W8: "drop anything that could not exist before 1900" removed
# all 12 outfits. One re-ask; the second answer stands when it keeps
# some, else the draft says so.
try:
again, _ = webs.take(await _ask(extra="\n\n".join(
x for x in (webs.note, ai_edit.ALL_REMOVED_NUDGE) if x), **ask))
retry = ai_edit.compile_edit(action, again, options=rows, registry=tags,
refs=refs, self_id=self_id, labels=labels,
instruction=check, frame=frame)
if 0 < len(retry["changes"]) < len(rows):
answer, result = again, retry
except (_ai_client.AIError, ValueError):
pass
if len(result["changes"]) == len(rows):
result["warnings"] = ["This removes every option; check the reasons before "
"you apply it"]
if not result["changes"] and result["skipped"] and all(
x["reason"] == "not an option in the list" for x in result["skipped"]):
# Small models write prose where the option number goes: every change
# is dropped, which used to come back as a silent empty list.
return json_error(
"the model's answer couldn't be used (it named no option by its number). "
"Try again, or a larger model.", status=502)
for c in result["changes"]:
c["option_id"] = real_ids[c.pop("id")]
result["action"] = action
result["model"] = cfg["model"]
result["answer"] = answer
result["research"] = research
return json_ok(result)
def _known_tag() -> Any:
"""The installed tag list's exact lookup, or None when there is none."""
from wp_api import tags as _tags
idx = _tags._get_index()
return idx.has if idx is not None else None
async def post_derivation_draft(request: web.Request) -> web.Response:
"""Body: {instruction, derivation: {name?, rules?}}.
`rules` are the editor's current rules (unsaved edits included); the
model is told not to repeat them, and new rule ids avoid theirs. The
variables it may use come from the library.
"""
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
images = await _request_images(cfg, body)
if isinstance(images, web.Response):
return images
instruction = _instruction(body, "say what the rules should do")
if isinstance(instruction, web.Response):
return instruction
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
drv = _got if isinstance(_got := body.get("derivation"), dict) else {}
rules = [r for r in drv.get("rules") or [] if isinstance(r, dict)][:200]
name = _got if isinstance(_got := drv.get("name"), str) else ""
with db_session(request) as conn:
modules = ModuleRepository(conn).list()
templates = TemplateRepository(conn).list()
catalog = library_vars.build_catalog(modules)
drv_id = _got if isinstance(_got := drv.get("id"), str) else None
reads = library_vars.read_vars([m for m in modules if m.get("id") != drv_id], templates) \
| library_vars.read_vars([{"type": "derivation", "payload": {"rules": rules}}])
refs = ai_refs.RefIndex([m for m in modules if m.get("type") == "wildcard"])
focus = [
n for line in (ai_derivation.describe_rule(r) for r in rules)
for n in library_vars.var_names_in(line)
]
description, labels = ai_about.clean(drv.get("description"), drv.get("tags"))
first = ai_derivation.build_prompt(
instruction=ai_followup.first_instruction(turns, instruction), name=name[:200],
description=description, labels=labels,
catalog_text=library_vars.describe_for(
# The first request only: follow-ups replay this prompt as it was.
catalog, " ".join([ai_followup.first_instruction(turns, instruction), name,
description]), focus=focus),
existing_rules=[ai_derivation.describe_rule(r) for r in rules],
refs_text=refs.describe(), numbered=bool(rules),
)
try:
answer = await _ask(
cfg, first=first, turns=turns, instruction=instruction,
system=ai_derivation.SYSTEM_PROMPT + (
"\n\n" + ai_derivation.EDIT_NOTE if rules else ""),
schema=ai_derivation.EDIT_SCHEMA if rules else ai_derivation.SCHEMA,
schema_name="derivation_rules", temperature=0.3,
images=images or None, quick=True,
)
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
asked = ai_followup.first_instruction(turns, instruction)
try:
result = ai_derivation.compile_rules(
answer, catalog=catalog,
taken_ids={r["id"] for r in rules if isinstance(r.get("id"), str)}, refs=refs,
existing=rules, request=" ".join(dict.fromkeys((asked, instruction))),
read_vars=reads,
)
except ValueError as e:
return json_error(
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
status=502,
)
rules_out = [x["rule"] for x in result["rules"]]
gone = ai_derivation.missing_else(await _ai_lang.check_text(cfg, instruction), rules_out)
if gone:
# Re-test on d6ac847 (9B): the catch-all was dropped in 2 of 3 drafts.
# One short second ask for the ELSE alone (verify, then repair).
added = await _repair_else(cfg, instruction, result, catalog, refs)
if added:
for x in result["rules"]:
if isinstance(x["rule"].get("else"), dict):
x["summary"] = ai_derivation.describe_rule(x["rule"])
result["warnings"] = [*result.get("warnings", []),
f"Added the catch-all from your request: else {added}"]
else:
result["skipped"] = [*result["skipped"], {"text": "a catch-all", "reason": gone}]
result["model"] = cfg["model"]
result["answer"] = answer
return json_ok(result)
async def _repair_else(cfg: dict[str, Any], instruction: str, result: dict[str, Any],
catalog: dict[str, Any], refs: Any) -> str:
"""Ask the model for the missing ELSE only and attach it; "" when that
fails too (the draft then carries the warning)."""
rules = [x["rule"] for x in result["rules"]]
targets = sorted({str(a.get("target_var")) for r in rules
for c in [*(r.get("branches") or []), r.get("else")] if isinstance(c, dict)
for a in [c.get("action"), *(c.get("extra_actions") or [])]
if isinstance(a, dict) and isinstance(a.get("target_var"), str)})
if not targets:
return ""
try:
answer = await _ai_client.complete_json(
cfg, system=ai_derivation.ELSE_SYSTEM,
user=ai_derivation.else_prompt(instruction, rules),
schema=ai_derivation.else_schema(targets), schema_name="derivation_else",
temperature=0.2, max_tokens=512, quick=True)
except _ai_client.AIError:
return ""
return ai_derivation.attach_else(answer, rules, catalog, refs)
def _catalog_size(kind: str) -> tuple[int, int]:
"""How much of the library a text panel sees, (variables in full,
values each): fixed values read no variable, so a few for the style and
the rest by name."""
if kind == "fixed_values":
return text_items.FIXED_CATALOG_VARS, text_items.FIXED_CATALOG_VALUES
return library_vars.MAX_VARS_FOCUSED, library_vars.MAX_VALUES_SHOWN
_TEXT_KINDS = {
"fixed_values": (text_items.FIXED_SYSTEM, text_items.FIXED_SCHEMA, "fixed_values"),
"combine": (text_items.COMBINE_SYSTEM, text_items.COMBINE_SCHEMA, "combine"),
"template": (text_items.TEMPLATE_SYSTEM, text_items.TEMPLATE_SCHEMA, "prompt_template"),
}
async def post_text_draft(request: web.Request) -> web.Response:
"""Body: {kind: "fixed_values"|"combine"|"template", instruction,
history?, item: {id?, name, description?, tags?, payload? (fixed values,
combine), template_string?, negative_template? (template)}}.
The editor panels for fixed values, combines and prompt templates. The
answer is compiled against the editor's item as it stands: fixed values
come back as the whole value list (a name that exists is rewritten in
place), a combine or template as its new text.
"""
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
images = await _request_images(cfg, body)
if isinstance(images, web.Response):
return images
webs = _Web(cfg, body)
kind = body.get("kind")
if kind not in _TEXT_KINDS:
return json_error("kind must be fixed_values, combine or template", status=400)
instruction = _instruction(body, "say what to write")
if isinstance(instruction, web.Response):
return instruction
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
item = _got if isinstance(_got := body.get("item"), dict) else {}
payload = _got if isinstance(_got := item.get("payload"), dict) else {}
name = _got if isinstance(_got := item.get("name"), str) else ""
out_var = ""
if kind == "fixed_values":
current = "\n".join(
f"${v.get('name')} = {v.get('value')}"
+ (f" (negative: {v['negative']})" if v.get("negative") else "")
for v in payload.get("values") or [] if isinstance(v, dict))
elif kind == "combine":
out_var = _got if isinstance(_got := payload.get("output_var"), str) else ""
out_var = out_var.strip().lstrip("$")
if not out_var:
return json_error("name the variable the combine sets first", status=400)
current = str(payload.get("template") or "")
if payload.get("negative"):
current += f"\nNegative: {payload['negative']}"
else:
current = str(item.get("template_string") or "")
if item.get("negative_template"):
current += f"\nNegative: {item['negative_template']}"
with db_session(request) as conn:
modules = ModuleRepository(conn).list()
item_id = _got if isinstance(_got := item.get("id"), str) else None
catalog = library_vars.build_catalog([m for m in modules if m.get("id") != item_id])
description, labels = ai_about.clean(item.get("description"), item.get("tags"))
asked = ai_followup.first_instruction(turns, instruction)
# Both asks: a refine is held to the first request too.
# A request in another language: the checks read its restatement.
check = await _ai_lang.check_text(cfg, instruction)
held_to = " ".join(dict.fromkeys((asked, instruction, check)))
first = text_items.build_prompt(
kind=kind, instruction=asked,
name=name[:200], description=description,
catalog_text=library_vars.describe_for(
text_items.prompt_catalog(kind, catalog, asked),
" ".join([asked, name, description, current[:2000], out_var]),
keep=_catalog_size(kind)[0], values=_catalog_size(kind)[1]),
labels=labels, current=current[:4000], out_var=out_var,
)
system, schema, schema_name = _TEXT_KINDS[kind]
ask: dict[str, Any] = dict(
cfg=cfg, first=first, turns=turns, instruction=instruction,
system=webs.system(system), schema=webs.schema(schema), schema_name=schema_name,
temperature=0.5, images=images or None, quick=True,
max_tokens=text_items.answer_tokens(kind))
try:
try:
answer = await _ask(extra=webs.note, **ask)
except _ai_client.AIError as e:
# A model that loops on the same words hits the limit; one more
# sample usually doesn't.
if e.message != _ai_client.CUT_OFF_MESSAGE:
raise
answer = await _ask(extra=webs.note, **ask)
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
answer, research = webs.take(answer)
def problems(raw: Any) -> list[str]:
unset = text_items.left_out(kind, raw, check)
names = ", ".join(f"${v}" for v in unset)
notes = [(f"Set {names} too: the request asks for it. Answer with the whole list again."
if kind == "fixed_values" else
f"Read {names} too: the request asks for it. Answer with the whole text "
"again.")] if unset else []
return notes + text_items.recheck_notes(kind, raw, held_to)
notes = problems(answer)
if notes:
# One re-ask naming what the code found; the first answer stands
# when the second misses no less of the request.
try:
again, _ = webs.take(await _ask(
extra="\n\n".join(x for x in (webs.note, *notes) if x), **ask))
if len(problems(again)) < len(notes):
answer = again
except _ai_client.AIError:
pass
had = text_items.reads(str(payload.get("template") or "") if kind == "combine"
else str(item.get("template_string") or ""))
try:
if kind == "fixed_values":
result = text_items.compile_fixed(answer, existing=payload, request=held_to)
elif kind == "combine":
result = text_items.compile_combine(answer, out_var=out_var, catalog=catalog,
existing=payload, request=held_to)
else:
result = text_items.compile_template(answer, catalog=catalog, request=held_to)
if kind != "fixed_values":
body = result["payload"].get("template") or result["payload"].get("template_string")
gone = text_items.missing_asked(str(body or ""), check)
if gone:
result["warnings"] = [*result["warnings"], gone]
extra = text_items.unasked(str(body or ""), check,
text_items.reads(str(body or "")), had)
if extra:
result["warnings"] = [*result["warnings"], extra]
result["samples"] = text_items.samples(str(body or ""), catalog,
seed=f"{name}:{instruction}",
texts=text_items.sample_texts(modules, item_id))
result["before"] = {
"template": str(payload.get("template") or "") if kind == "combine"
else str(item.get("template_string") or ""),
"negative": str(payload.get("negative") or "") if kind == "combine"
else str(item.get("negative_template") or ""),
}
except ValueError as e:
return json_error(
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
status=502,
)
result["model"] = cfg["model"]
result["answer"] = answer
result["research"] = research
return json_ok(result)
async def post_constraint_draft(request: web.Request) -> web.Response:
"""Body: {instruction, constraint: {source_wildcard_id, target_wildcard_id,
matrix?, exceptions?}}.
Both wildcards are read from the library (the constraint editor links
saved wildcards). `matrix` / `exceptions` are the editor's current rules,
so the answer can say which pairs it would replace.
"""
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
images = await _request_images(cfg, body)
if isinstance(images, web.Response):
return images
instruction = _instruction(body, "say what the rules should do")
if isinstance(instruction, web.Response):
return instruction
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
cons = _got if isinstance(_got := body.get("constraint"), dict) else {}
src_id, tgt_id = cons.get("source_wildcard_id"), cons.get("target_wildcard_id")
if not isinstance(src_id, str) or not isinstance(tgt_id, str) or not src_id or not tgt_id:
return json_error("pick the source and target wildcards first", status=400)
try:
with db_session(request) as conn:
repo = ModuleRepository(conn)
src_row, tgt_row = repo.get(src_id), repo.get(tgt_id)
except ModuleNotFound:
return json_error("one of the wildcards isn't in the library any more", status=404)
if src_row.get("type") != "wildcard" or tgt_row.get("type") != "wildcard":
return json_error("source and target must be wildcards", status=400)
source = ai_constraint.side_view(src_row, "S")
target = ai_constraint.side_view(tgt_row, "T")
if not source["options"] or not target["options"]:
return json_error("both wildcards need options first", status=400)
existing = {
"matrix": _got if isinstance(_got := cons.get("matrix"), dict) else {},
"exceptions": [e for e in cons.get("exceptions") or [] if isinstance(e, dict)][:2000],
}
description, labels = ai_about.clean(cons.get("description"), cons.get("tags"))
cname = _got if isinstance(_got := cons.get("name"), str) else ""
first = ai_constraint.build_prompt(
instruction=ai_followup.first_instruction(turns, instruction), source=source, target=target,
name=cname[:200], description=description, labels=labels,
existing=ai_constraint.describe_existing(existing),
)
try:
answer = await _ask(
cfg, first=first, turns=turns, instruction=instruction,
system=ai_constraint.SYSTEM_PROMPT,
schema=ai_constraint.SCHEMA, schema_name="constraint_rules", temperature=0.3,
images=images or None, quick=True,
)
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
try:
result = ai_constraint.compile_rules(
answer, source=source, target=target, existing=existing,
)
except ValueError as e:
return json_error(
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
status=502,
)
result["model"] = cfg["model"]
result["answer"] = answer
return json_ok(result)
async def post_describe(request: web.Request) -> web.Response:
"""Body: {kind, name?, description?, tags?, payload, hint?}.
`payload` is what the editor holds now (a module payload, a bundle's
children, a template's text). The model also sees the library's tags so
it reuses them.
"""
cfg = _enabled_cfg()
if isinstance(cfg, web.Response):
return cfg
body = await _body(request)
if isinstance(body, web.Response):
return body
kind = body.get("kind")
if kind not in ai_describe.KINDS:
return json_error("unknown kind", status=400)
name = _got if isinstance(_got := body.get("name"), str) else ""
description, tags = ai_about.clean(body.get("description"), body.get("tags"))
hint = _got if isinstance(_got := body.get("hint"), str) else ""
if len(hint) > ai_wildcard.MAX_INSTRUCTION_LEN:
return json_error("that request is too long", status=400)
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
with db_session(request) as conn:
modules = ModuleRepository(conn).list()
bundles = BundleRepository(conn).list()
names = {m["id"]: m.get("name") or "" for m in modules if isinstance(m.get("id"), str)}
library = ai_describe.library_tags([*modules, *bundles])
first = ai_describe.build_prompt(
kind=kind, name=name[:200], description=description, tags=tags,
summary=ai_describe.summarize(kind, body.get("payload"), names=names),
library=library, hint=ai_followup.first_instruction(turns, hint),
)
try:
answer = await _ask(
cfg, first=first, turns=turns, instruction=hint,
system=ai_describe.SYSTEM_PROMPT,
schema=ai_describe.SCHEMA, schema_name="describe", temperature=0.4,
quick=True,
)
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
try:
result = ai_describe.compile_answer(answer, current=tags, library=library)
except ValueError as e:
return json_error(
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
status=502,
)
result["model"] = cfg["model"]
result["answer"] = answer
return json_ok(result)
async def post_organize(request: web.Request) -> web.Response:
"""Body: {items: [{kind: "module"|"bundle", id}], hint?, history?}.
The items are read from the library, so the model sees what is saved.
Changes come back keyed by kind + id for the manager to review.
"""
cfg = _enabled_cfg()
if isinstance(cfg, web.Response):
return cfg
body = await _body(request)
if isinstance(body, web.Response):
return body
hint = _got if isinstance(_got := body.get("instruction"), str) else ""
if len(hint) > ai_wildcard.MAX_INSTRUCTION_LEN:
return json_error("that request is too long", status=400)
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
wanted = _got if isinstance(_got := body.get("items"), list) else []
refs: list[tuple[str, str]] = []
for w in wanted:
if isinstance(w, dict) and w.get("kind") in ("module", "bundle") \
and isinstance(w.get("id"), str) and (w["kind"], w["id"]) not in refs:
refs.append((w["kind"], w["id"]))
if not refs:
return json_error("select some modules or bundles first", status=400)
if len(refs) > ai_organize.MAX_ITEMS:
return json_error(
f"that's {len(refs)} items; select up to {ai_organize.MAX_ITEMS} and try again",
status=400,
)
with db_session(request) as conn:
modules = ModuleRepository(conn).list()
bundles = BundleRepository(conn).list()
cats = CategoryRepository(conn).list()
by_ref = {("module", m["id"]): m for m in modules}
by_ref.update({("bundle", b["id"]): b for b in bundles})
cat_names = {c["id"]: c["name"] for c in cats}
names = {m["id"]: m.get("name") or "" for m in modules if isinstance(m.get("id"), str)}
items: list[dict[str, Any]] = []
real: dict[str, tuple[str, str]] = {}
for ref in refs:
row = by_ref.get(ref)
if row is None:
continue
short = str(len(items) + 1)
real[short] = ref
items.append(ai_organize.item_view(
row, ref[0], short, category=cat_names.get(row.get("category_id") or "", ""),
names=names,
))
if not items:
return json_error("none of those items are in the library any more", status=404)
categories = ai_organize.library_categories(cats)
library = ai_describe.library_tags([*modules, *bundles])
first = ai_organize.build_prompt(
items=items, categories=categories, library=library,
hint=ai_followup.first_instruction(turns, hint),
)
try:
answer = await _ask(
cfg, first=first, turns=turns, instruction=hint,
system=ai_organize.SYSTEM_PROMPT,
schema=ai_organize.schema_for([it["id"] for it in items]),
schema_name="organize", temperature=0.3, quick=True,
)
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
try:
result = ai_organize.compile_answer(
answer, items=items, categories=categories, library=library,
request=" ".join(dict.fromkeys((ai_followup.first_instruction(turns, hint), hint))),
)
except ValueError as e:
return json_error(
f"the model's answer couldn't be used ({e}). Try again, or a larger model.",
status=502,
)
cat_ids = {c["name"].lower(): c["id"] for c in cats}
by_short = {it["id"]: it for it in items}
for c in result["changes"]:
it = by_short[c["id"]]
c["kind"], c["id"] = real[c.pop("id")]
c["name"] = it["name"]
c["type"] = it["type"]
c["before"] = {"category": it["category"], "tags": it["tags"], "rating": it["rating"]}
if c["category"]:
c["category"]["id"] = cat_ids.get(c["category"]["name"].lower())
result["count"] = len(items)
result["model"] = cfg["model"]
result["answer"] = answer
return json_ok(result)
#: Items the Suggest button's library summary lists (newest first); the
#: counts, tags and habits above them cover the rest.
_NOTE_ITEMS = 60
async def post_library_note(request: web.Request) -> web.Response:
"""Body: {current?}. A first draft of the "About my library" note from
the library summary (`engine/ai/note.py`). `current` is what the
Settings box holds now, saved or not. Nothing is saved: the user edits
the draft and saves it with the rest of Settings."""
cfg = _enabled_cfg()
if isinstance(cfg, web.Response):
return cfg
body = await _body(request)
if isinstance(body, web.Response):
return body
current = _got if isinstance(_got := body.get("current"), str) else ""
with db_session(request) as conn:
modules = ModuleRepository(conn).list()
lib = ai_chat.Library(
modules, BundleRepository(conn).list(),
CategoryRepository(conn).list(), TemplateRepository(conn).list(),
)
if not lib.modules and not lib.bundles and not lib.templates:
return json_error("the library is empty, so there is nothing to describe yet",
status=400)
try:
answer = await _ai_client.complete_json(
cfg, system=ai_note.SUGGEST_SYSTEM,
user=ai_note.suggest_prompt(ai_safety.strip_invisible(
lib.overview(limit=_NOTE_ITEMS)), current, ai_note.samples(modules)),
schema=ai_note.SUGGEST_SCHEMA, schema_name="library_note",
temperature=0.3, quick=True,
)
except _ai_client.AIError as e:
return json_error(e.message, status=e.status)
note = ai_note.suggested(answer)
if not note:
return json_error("the model wrote no note. Try again, or a larger model.", status=502)
return json_ok({"note": note, "model": cfg["model"]})