Files
DumiFlex-ComfyUI-Wildcard-P…/wp_api/_ai_drafts.py
T
DumiFlex f59a69c60c fix(ai): read negations and consent in the safety guard, keep ratings in step
The minor guard cut "adult woman, no children, no minors" and let
non-consent wording through. It now masks negated phrases (NegEx-style,
comparatives excluded, sexual words still read raw), adds a non-consent
line, knows minor words in nine more languages, and leaves adult body
words like "petite" alone. Ratings: chat-made items tagged #nsfw are
rated NSFW, chat and Organize no longer lower NSFW or drop #nsfw unasked,
chat search filters by rating, packs spell rating tags one way and flag
a prompt with two. A template draft asked for a youth negative gets one.

Bench critiques #30 and #38.
2026-10-27 02:48:10 +01:00

777 lines
34 KiB
Python

"""Editor panels: wildcard draft + edit, derivation, text and constraint
drafts, describe and organize. 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 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 library_vars, text_items
from engine.ai import organize as ai_organize
from engine.ai import refs as ai_refs
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,
)
from wp_api import (
_ai_client,
)
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)
count = max(1, min(ai_wildcard.MAX_COUNT, count))
# 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)
turns = _turns(body)
if isinstance(turns, web.Response):
return turns
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").
request=" ".join(dict.fromkeys(
(ai_followup.first_instruction(turns, instruction), instruction))),
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,
)
note = ai_wildcard.bleed_note(name, var, result["options"], instruction)
if note:
# 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, note) if x), **ask))
better = compiled(again)
if len(better["options"]) >= len(result["options"]) and not \
ai_wildcard.bleed_note(name, var, better["options"], instruction):
answer, result = again, better
except (_ai_client.AIError, ValueError):
pass
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
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
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)
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, instruction)
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, instruction,
[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, instruction, [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=instruction)
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 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)
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()
catalog = library_vars.build_catalog(modules)
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(),
)
try:
answer = await _ask(
cfg, first=first, turns=turns, instruction=instruction,
system=ai_derivation.SYSTEM_PROMPT,
schema=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)
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,
)
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(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"))
first = text_items.build_prompt(
kind=kind, instruction=ai_followup.first_instruction(turns, instruction),
name=name[:200], description=description,
catalog_text=library_vars.describe_for(
catalog, " ".join([ai_followup.first_instruction(turns, instruction), 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)
unset = text_items.left_out(kind, answer, instruction)
if unset:
# One re-ask naming what the request asked for; the first answer
# stands when the second leaves out no less of it.
names = ", ".join(f"${v}" for v in unset)
note = (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.")
try:
again, _ = webs.take(await _ask(
extra="\n\n".join(x for x in (webs.note, note) if x), **ask))
if len(text_items.left_out(kind, again, instruction)) < len(unset):
answer = again
except _ai_client.AIError:
pass
try:
if kind == "fixed_values":
result = text_items.compile_fixed(answer, existing=payload)
elif kind == "combine":
result = text_items.compile_combine(answer, out_var=out_var, catalog=catalog,
existing=payload)
else:
result = text_items.compile_template(answer, catalog=catalog, request=" ".join(
dict.fromkeys((ai_followup.first_instruction(turns, instruction), instruction))))
if kind != "fixed_values":
body = result["payload"].get("template") or result["payload"].get("template_string")
gone = text_items.missing_asked(str(body or ""), instruction)
if gone:
result["warnings"] = [*result["warnings"], gone]
extra = text_items.unasked(str(body or ""), instruction,
text_items.reads(str(body or "")))
if extra:
result["warnings"] = [*result["warnings"], extra]
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)