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 17995c1275
commit 2c8c274850
22 changed files with 2138 additions and 147 deletions
@@ -43,6 +43,10 @@
{{ icon("globe", "icon--sm") }}
<span class="nav-item__label">Web search</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'prompts' }}" href="/admin/prompts">
{{ icon("sparkle", "icon--sm") }}
<span class="nav-item__label">Prompts</span>
</a>
<a class="nav-item {{ 'is-active' if section == 'users' }}" href="/admin/users">
{{ icon("user", "icon--sm") }}
<span class="nav-item__label">Users</span>
@@ -0,0 +1,46 @@
{#
One editable fragment. Also the swap target for "Use default", which is why
the whole card carries the id rather than just the textarea.
#}
{% set field_id = "frag-" ~ fragment.key | replace(".", "-") %}
<section class="card" id="{{ field_id }}">
<div class="card__header">
<h2 class="card__title">
{{ fragment.label }}
{% if overridden %}<span class="badge badge--leaf">edited</span>{% endif %}
{% for family in fragment.families %}<span class="badge">{{ family }}</span>{% endfor %}
{% if fragment.when_tools %}<span class="badge">with tools</span>{% endif %}
</h2>
<button class="btn btn--sm" type="button"
hx-post="/admin/prompts/default"
hx-vals='{"key": "{{ fragment.key }}"}'
hx-target="#{{ field_id }}" hx-swap="outerHTML"
hx-confirm="Put the built-in wording back in this box? Your edit is lost, but nothing is saved until you press Save settings.">
Use default
</button>
</div>
{% if fragment.hint %}<p class="card__lede">{{ fragment.hint }}</p>{% endif %}
<div class="field">
<label class="field__label visually-hidden" for="{{ field_id }}-text">
{{ fragment.label }}
</label>
<textarea class="textarea" id="{{ field_id }}-text" name="prompt.{{ fragment.key }}"
rows="4" spellcheck="true">{{ value }}</textarea>
<p class="field__hint">
<code>{{ fragment.key }}</code>
{% for name in fragment.variables %}
<code>&lbrace;&lbrace;{{ name }}&rbrace;&rbrace;</code>
{% endfor %}
{% if fragment.requires %}
— only appears when
{% for name in fragment.requires %}
<code>&lbrace;&lbrace;{{ name }}&rbrace;&rbrace;</code>{% if not loop.last %} and {% endif %}
{% endfor %}
has something in it.
{% endif %}
Leave the box empty to leave this out of the prompt entirely.
</p>
</div>
</section>
@@ -0,0 +1,41 @@
{#
The assembled system message.
Rendered with ordinary escaping and never `|safe`: it carries memory text a
model wrote and skill descriptions from items other people shared. Hard rule 6
applies here as much as anywhere.
#}
{% if harness_chars > limit %}
<div class="alert alert--warning">
<span>
The preamble is {{ harness_chars }} characters and the cap is {{ limit }}. Everything
past the cap is cut off before it reaches the model.
</span>
</div>
{% endif %}
<pre class="prompt-preview"><code>{{ system }}</code></pre>
<p class="field__hint">
{{ harness_chars }} of {{ limit }} characters before the authored prompt.
{% if authored %}
The instance prompt is shown below the line; a model or chat prompt would
replace it, never stack with it.
{% else %}
No instance prompt is set, so nothing follows the preamble.
{% endif %}
</p>
{% if title_prompt %}
<h3 class="admin-section-title">Chat title request</h3>
<p class="card__lede">
Sent on its own after the first reply, not as part of any conversation.
</p>
<pre class="prompt-preview"><code>{{ title_prompt }}</code></pre>
{% else %}
<h3 class="admin-section-title">Chat title request</h3>
<p class="card__lede">
Empty, so no model is asked to name a chat. Chats are named from the first
thing said in them.
</p>
{% endif %}
+180
View File
@@ -0,0 +1,180 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon %}
{% set section = "prompts" %}
{% block title %}Prompts - LLeMbas{% endblock %}
{% block heading %}Prompts{% endblock %}
{% block admin_content %}
<p class="admin-lede">
Everything LLeMbas puts in front of a model on its own: what day it is, how to
use each tool, what it has been asked to remember. These sit above whichever
system prompt was authored for the instance, the model or the chat — that
prompt still wins where the two disagree. Clear a box to leave that piece out
altogether; press <strong>Use default</strong> to put the built-in wording back.
</p>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>Prompts saved.</span></div>
{% endif %}
<section class="card">
<h2 class="card__title">Variables</h2>
<p class="card__lede">
Write these in double braces. Anything in double braces that is not on this
list is left exactly as you typed it — variable names are lowercase letters,
digits and underscores, so <code>{"total": 1}</code> and <code>${PATH}</code>
are never mistaken for one. A variable with nothing in it takes its whole
line with it, so a section never appears empty.
</p>
<div class="ref-list">
{% for variable in variables %}
<div class="ref-row">
<code>&lbrace;&lbrace;{{ variable.name }}&rbrace;&rbrace;</code>
<span>{{ variable.description }}</span>
<span class="ref-row__value">
{% if variable.name not in resolved %}
<em>only in the title request</em>
{% elif resolved[variable.name] %}
{{ resolved[variable.name] | truncate(80) }}
{% else %}
<em>empty</em>
{% endif %}
</span>
</div>
{% endfor %}
</div>
</section>
{#
The preview posts both the controls and the editing form, so what it shows is
the text in the boxes rather than the text last saved. Triggers live on this
wrapper and the swap is innerHTML, so the element carrying `load` is never
replaced -- an outerHTML swap would re-fire it and loop forever.
#}
<section class="card">
<h2 class="card__title">Preview</h2>
<p class="card__lede">
The whole system message, assembled from what is in the boxes below —
including changes you have not saved yet. Your own memories and skills are
used, because a preview against invented ones cannot tell you whether it
reads well against what is actually there.
</p>
<div class="grid grid--2" id="preview-controls">
<div class="field">
<label class="field__label" for="preview-model">Answering model</label>
<select class="select" id="preview-model" name="preview_model">
{% for model in models %}
<option value="{{ model.label }}">{{ model.label }}</option>
{% else %}
<option value="">No models configured</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field__label" for="preview-bases">Knowledge bases in scope</label>
<input class="input" id="preview-bases" name="preview_bases" value="Contracts, Recipes">
<p class="field__hint">Empty means a chat that can see everything.</p>
</div>
<div class="field">
<label class="field__label" for="preview-documents">Attached files</label>
<input class="input" id="preview-documents" name="preview_documents"
value="{{ sample_documents }}">
</div>
<div class="field">
<span class="field__label">Tools offered</span>
{% for family in families %}
<label class="checkbox">
<input type="checkbox" name="preview_family" value="{{ family }}" checked>
<span>{{ family }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="btn-row">
<button class="btn btn--sm" type="button" id="preview-refresh">
{{ icon("refresh", "icon--sm") }} Refresh
</button>
</div>
<div id="prompt-preview"
hx-post="/admin/prompts/preview"
hx-include="#prompt-form, #preview-controls"
hx-target="#prompt-preview" hx-swap="innerHTML"
hx-trigger="load, change from:#preview-controls, click from:#preview-refresh,
keyup changed delay:700ms from:#prompt-form"></div>
</section>
<form method="post" action="/admin/prompts" id="prompt-form">
<div class="tabs">
<div class="tabs__bar" role="tablist">
{% for key, label, fragments in groups %}
<input class="visually-hidden" type="radio" name="prompts-tab"
id="tab-{{ key }}" {{ 'checked' if loop.first }}>
<label class="tabs__tab" for="tab-{{ key }}">{{ label }}</label>
{% endfor %}
</div>
<div class="tabs__body">
{% for key, label, fragments in groups %}
<section class="tabs__panel" data-tab="tab-{{ key }}">
{% for fragment in fragments %}
{% with value = values[fragment.key], overridden = fragment.key in overridden %}
{% include "admin/_prompt_field.html" %}
{% endwith %}
{% endfor %}
</section>
{% endfor %}
</div>
</div>
<section class="card">
<h2 class="card__title">Length</h2>
<div class="field">
<label class="field__label" for="max-harness-chars">Preamble character cap</label>
<input class="input" id="max-harness-chars" name="max_harness_chars" type="number"
min="0" max="100000" value="{{ max_harness_chars }}">
<p class="field__hint">
Everything above is cut off past this. <code>0</code> means the built-in
{{ default_harness_chars }}. It is a backstop against a large skill index
or memory list quietly eating the context window, not a budget to tune.
</p>
</div>
</section>
<div class="form-actions">
<button class="btn btn--primary" type="submit">Save settings</button>
{#
data-confirm-button, not data-confirm: this button acts on its own through
formaction, and confirming the whole form would also catch plain Save.
#}
<button class="btn" type="submit" formaction="/admin/prompts/reset"
data-confirm-button="Put every prompt back to its built-in wording? Everything you have edited here is lost."
data-confirm-title="Restore defaults" data-confirm-label="Restore">
Restore all defaults
</button>
</div>
</form>
<section class="card">
<h2 class="card__title">Tool descriptions</h2>
<p class="card__lede">
Defined in code — part of the schema sent to the endpoint alongside the
prompt, not guidance layered on top of it. They are statements of fact about
what each tool does, so they change when the tool does; editing them here
would let the text quietly become a lie. A custom tool's description will be
editable, because a custom tool is a row rather than a function.
</p>
<div class="ref-list">
{% for tool in registry %}
<div class="ref-row">
<code>{{ tool.name }}</code>
<span>{{ tool.description }}</span>
<span class="ref-row__value">{{ tool.family }}</span>
</div>
{% endfor %}
</div>
</section>
{% endblock %}