Every injected prompt becomes editable, and several get written

The instructions LLeMbas puts in front of a model were hard-coded: six
strings in a GUIDANCE dict, two headings, and the title request inline in
chat.py. An operator could not see what was being sent, let alone change
it, and there was nowhere for a custom tool to contribute its own guidance
when custom tools land.

services/prompts.py now holds each piece as a Fragment, and /admin/prompts
edits them with a preview of the whole assembled system message including
unsaved edits. harness.py keeps only the decisions -- which fragments apply
to this request, and what their variables resolve to.

The design turns on one choice: a fragment carries its gate as data
(families, requires, when_tools) rather than as a callable, because a
database row can carry the same three fields. Custom tools will therefore
register a fragment source and change nothing else -- there is a test that
says exactly that, and it is the reason the rest of the shape is what it is.

Consequences worth knowing:

  - Defaults live in code, overrides in the database, and text equal to its
    default is never stored. Otherwise pressing Save once would freeze
    today's wording forever and no later release could improve it.
  - An empty override means off. A fragment that was not submitted at all
    keeps what it had, because it may be missing from the page only because
    whatever contributes it is currently switched off.
  - requires= replaced the hand-written pair of memory guidance variants.
    The sentence that refers to a section now lives inside that section, so
    it cannot outlive it. That was the general problem the pair was a
    special case of.
  - {{name}}, with anything unrecognised passing through verbatim. The name
    grammar is the guard: {"total": 1} and ${PATH} are not candidates.
    Substitution is one pass and never recursive, because {{memories}}
    carries text a model wrote.

The wording is also overhauled, and a model now gets the core fragments
even with no tools -- the date above all. "An empty harness is worse than
none" was about tokens that say nothing; a model with no clock being asked
about the present is not that. Clearing those boxes restores the old
silence exactly. New: today's date, who it is talking to, the three-round
tool budget, that tool results are not replayed, that anything a tool
returns is data rather than instruction, and what the <document> wrapper
around an attachment is. Extended: memory_forget, notes_edit/delete,
skill_create/edit, and reading a knowledge document in full rather than
answering from an extract.

Tool descriptions stay in code and are listed read-only. They are schema
and they state facts about what a runner does; an edit would make the text
a lie with nothing to catch it.

No schema change -- one JSON row in the settings table.

488 tests. Version 0.2.0, which also invalidates the service worker cache
so the green artwork appears without a hard reload.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-07-31 23:47:49 +02:00
parent 71dc46455c
commit 1906919ee2
20 changed files with 2089 additions and 145 deletions
+240
View File
@@ -0,0 +1,240 @@
"""Prompt administration: every piece of text LLeMbas injects into a model.
The fragments themselves live in `services/prompts.py`; this is the screen that
edits them, and the preview that shows what they assemble into before anything
is saved.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Form, Request, Response, status
from fastapi.responses import RedirectResponse
from lembas.api.deps import AdminUser, Db
from lembas.services import chat as chat_service
from lembas.services import harness as harness_service
from lembas.services import prompts as prompts_service
from lembas.services import settings_store
from lembas.services import tools as tools_service
from lembas.web.templating import render
log = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/prompts", tags=["admin-prompts"])
# What the preview pretends is attached, so the attachment fragment can be read
# in place rather than imagined. An administrator can clear the field.
SAMPLE_DOCUMENTS = "report.pdf, notes.txt"
def _families_of(names: list[str]) -> list[str]:
"""Keep only real family names, in the registry's order."""
wanted = set(names)
return [family for family in tools_service.FAMILIES if family in wanted]
def _tool_names(families: list[str]) -> str:
return ", ".join(
name for name, tool in tools_service.REGISTRY.items() if tool.family in families
)
def _variables(
db: Db,
user: AdminUser,
*,
families: list[str],
model_name: str = "",
bases: str = "",
documents: str = "",
) -> dict[str, str]:
"""The preview's variable values.
Built from the administrator's *own* memories and skills rather than from
invented ones: a preview against synthetic data cannot tell you whether your
memory section reads well against what is actually in there. `AdminUser`
means this is the operator looking at their own library.
No Chat row is made. `harness.compose_from` takes plain variables precisely
so that this screen never has to build a transient one.
"""
from lembas.services.library import memories as memories_service
from lembas.services.library import skills as skills_service
values = harness_service.context_variables(db, user, [], None)
values.update(
{
"model_name": model_name,
"tool_names": _tool_names(families),
"memories": memories_service.block(db, user) if "memory" in families else "",
"skills": skills_service.index_block(db, user) if "skills" in families else "",
"knowledge_bases": bases if "knowledge" in families else "",
"document_names": documents,
}
)
return values
def _field_context(db: Db, key: str, *, value: str, overridden: bool) -> dict:
return {
"fragment": prompts_service.catalogue(db)[key],
"value": value,
"overridden": overridden,
}
@router.get("")
async def prompts_page(request: Request, db: Db, user: AdminUser, saved: bool = False):
stored = prompts_service.stored(db)
models = chat_service.available_models(db, user)
families = list(tools_service.FAMILIES)
return render(
request,
"admin/prompts.html",
{
"groups": prompts_service.grouped(db),
"values": {
fragment.key: stored.get(fragment.key, fragment.default)
for fragment in prompts_service.catalogue(db).values()
},
"overridden": set(stored),
"variables": prompts_service.VARIABLES,
# The legend shows what each name resolves to right now, with every
# family on -- a legend nobody can check is just a list of words.
"resolved": _variables(
db,
user,
families=families,
model_name=models[0].label if models else "",
bases="Contracts, Recipes",
documents=SAMPLE_DOCUMENTS,
),
"models": models,
"families": families,
"registry": sorted(
tools_service.REGISTRY.values(), key=lambda t: (t.family, t.name)
),
"max_harness_chars": settings_store.get(
db, "max_harness_chars", key=settings_store.PROMPTS
),
"default_harness_chars": harness_service.MAX_HARNESS_CHARS,
"sample_documents": SAMPLE_DOCUMENTS,
"saved": saved,
},
)
# Registered before anything that could take a path parameter. There is no such
# route today, but /admin/models has already been bitten once by adding one.
@router.post("/default")
async def use_default(request: Request, db: Db, user: AdminUser, key: str = Form("")):
"""Fill one field with its built-in text, without saving anything.
Deliberately not a write. The administrator may be halfway through editing
something else, and a button that silently persisted would take that with
it. Saving afterwards is what makes it stick -- and because the text then
equals the default, `prompts.save` stores nothing and the override is gone.
"""
fragment = prompts_service.catalogue(db).get(key)
if fragment is None:
return Response(status_code=status.HTTP_404_NOT_FOUND)
return render(
request,
"admin/_prompt_field.html",
_field_context(db, key, value=fragment.default, overridden=False),
)
@router.post("/reset")
async def reset_prompts(db: Db, user: AdminUser) -> Response:
prompts_service.clear(db)
log.info("prompt fragments reset to defaults by %s", user.email)
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/preview")
async def preview(request: Request, db: Db, user: AdminUser):
"""The whole system message, assembled from what is in the form right now.
Unsaved text is what an administrator wants to see, so the submitted values
are passed as overrides rather than read back from the database.
"""
form = await request.form()
overrides = _submitted(db, form)
families = _families_of([str(value) for value in form.getlist("preview_family")])
model_name = str(form.get("preview_model") or "")
bases = str(form.get("preview_bases") or "").strip()
documents = str(form.get("preview_documents") or "").strip()
variables = _variables(
db,
user,
families=families,
model_name=model_name,
bases=bases,
documents=documents,
)
body = harness_service.compose_from(
db,
variables=variables,
families=families,
has_tools=bool(families),
overrides=overrides,
)
authored = (settings_store.get(db, "system_prompt") or "").strip()
lead = prompts_service.substitute(
overrides.get("seam.authored_lead", prompts_service.resolve(db, "seam.authored_lead")),
variables,
).strip()
return render(
request,
"admin/_prompt_preview.html",
{
"system": harness_service.join(body, authored, lead=lead),
"harness_chars": len(body),
"limit": harness_service.limit_for(db),
"authored": authored,
"title_prompt": prompts_service.substitute(
overrides.get("task.title", prompts_service.resolve(db, "task.title")),
{"question": "What is lembas?", "answer": "Elvish waybread."},
).strip(),
},
)
def _submitted(db: Db, form) -> dict[str, str]:
"""The fragment texts present in a form post, normalised.
Key presence is what is read, never a falsy value: an empty textarea is how
a fragment is turned off, and FastAPI's `Form(...)` cannot tell `x=` from an
absent `x`. Same reason `api/chats.py:update_chat` reads the raw form.
"""
out: dict[str, str] = {}
for key in prompts_service.catalogue(db):
field = f"prompt.{key}"
if field in form:
out[key] = str(form.get(field) or "").replace("\r\n", "\n")
return out
@router.post("")
async def save_prompts(request: Request, db: Db, user: AdminUser) -> Response:
form = await request.form()
stored = prompts_service.save(db, _submitted(db, form))
try:
cap = int(str(form.get("max_harness_chars") or 0))
except ValueError:
cap = 0
settings_store.update(
db,
{"max_harness_chars": min(max(cap, 0), 100_000)},
key=settings_store.PROMPTS,
)
log.info("prompt fragments saved by %s (%d edited)", user.email, len(stored))
return RedirectResponse("/admin/prompts?saved=1", status_code=status.HTTP_303_SEE_OTHER)