4c78215e31
Six things, all found by using the thing rather than by reading it. The scope menu only appeared once a chat existed, on the reasoning that there was no row to post to. True, and the wrong conclusion: the harness puts a tool's guidance in front of the model the moment the tool is offered, so the menu could not be reached until after the model had been told how to keep notes and handed the tools to do it -- and switching it off then does not un-send that turn. It is on the new-chat screen now and writes nothing: `_scope_context` builds a stand-in Chat, which is `draft.as_chat`'s trick again, and the switches ride along with the first message. Checked means on and a browser submits only the ticked boxes, so every gate also renders a hidden input naming it and `start_chat` subtracts one list from the other; inverting the control would read backwards under a menu that says everything is on unless you say otherwise. Only the off ones are written, because absent means on and one representation of it is what keeps "why is this off?" to a single answer. Nothing is validated against the offered set, since scope_json narrows after every gate -- naming a gate that was never offered switches off something that was not on. Then the scheduling instructions, audited against a 4B model on this machine rather than against my own reading of them. Ten realistic requests, ten compiled, twice over -- so the prompt is sound. What was not sound was `describe`, which built a phrase by joining fragments and read "Every the 1st at 09:00" for the commonest monthly schedule there is, and "Every of January" for a month with no day. That string is the whole of what somebody sees before approving a schedule and the whole of what the model is told about its own chat, so a phrase nobody can parse is a review step nobody performs. It reads as English now, collapses Monday-to-Friday to "every weekday" and seven days to "every day", and every case in the test is a rule that model actually produced. The one mistake it made was naming Wednesday for "every other tuesday", so the weekday numbering is spelled out rather than left as "0-6, Monday is 0": getting that wrong is the error here that still looks like a working schedule. Roughly one call in six also came back empty -- a local runner swapping models under the request will do that -- so an unusable reply is asked for once more before giving up. Not on an LLMError: an endpoint that refused will refuse again, and the reader is better served by the form than by waiting twice for the same answer. Canvas asked for a typed path, which was the last control in the application expecting somebody to remember an absolute path on another machine -- the same complaint the folder page's directory field answered with a picker. /browse takes pick=file and the same fragment makes files buttons, because a second copy of that listing is a second place for the path arithmetic to be got subtly differently. The button carries data-canvas-open rather than an hx-post since the path is not known until the dialog closes, and ui.js posts it through htmx.ajax so the response lands in the panel exactly as every other canvas action's does. The key is `agent:<path>`, so a file opened by hand and one opened by the model are one tab rather than two spellings of it. The tabs already existed and already closed; they now square off at the bottom and the active one takes the body's background, so which is selected is structural rather than a tint nobody can see in a theme they did not choose. Highlighting was already there for every language named and is checked for fifteen of them. Three smaller ones. Tabs kept their scroll position, so switching from a long panel to a short one left the browser clamping to that panel's bottom: the end of it above a screen of nothing, which reads as a page that failed to load. Nothing in CSS can reset a scroll position. The sidebar's footer and the composer sit either side of one vertical edge and were both content-sized, so their top borders met it at different heights and read as one line that had been broken -- `--footer-height` is a calc of the pieces the footer is built from, applied as a min-height to both, which is exactly what `--header-height` already does at the top of the shell. And "Add a workflow" sat flush against the list it adds to, stated as an adjacency because `.btn-row` is right to carry no margin everywhere else it appears. Both pieces of JavaScript were driven under a DOM stub before committing, which is how the tab listener's delegation and the canvas button's six behaviours were checked at all -- `node --check` parses a file that does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
9.5 KiB
Python
236 lines
9.5 KiB
Python
"""Turning "remind me every Monday to check the build" into a schedule.
|
|
|
|
One request, once, when a schedule is created. It does two things a person
|
|
should not have to do by hand: work out the recurrence, and rewrite the
|
|
description into something that reads sensibly with **no conversation around
|
|
it** — because that is how it will be read, days later, by a model that was not
|
|
present when it was typed.
|
|
|
|
Three rules hold this up:
|
|
|
|
- **The rule goes through `rule.validate` and nothing else.** That function is
|
|
total and clamping, and this is the reason it had to be: what arrives here is
|
|
model output that becomes a *timer*. There is one normaliser, shared with the
|
|
manual form, so there cannot be two ideas of what a legal schedule is.
|
|
- **A compile that fails is not an error.** It hands back what it could work out
|
|
and the caller shows the manual form with the reader's own words in it. A
|
|
model that answers in prose must never quietly produce a schedule that never
|
|
fires.
|
|
- **Clearing `task.schedule_compile` switches off the compiling, not the
|
|
feature.** That is what makes "an empty override means off" safe here, and it
|
|
is only safe because the manual form exists. `task.compact` set the precedent
|
|
that clearing a fragment kills a feature, so this one says otherwise in its
|
|
own hint.
|
|
|
|
Deliberately no `response_format`. Several local endpoints reject unknown
|
|
parameters outright, and this is exactly the `apply_effort` lesson: a request
|
|
that 400s here would be the compile silently switching itself off.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
|
|
from lembas.db.models import TARGET_CHAT, TARGETS, Chat, User
|
|
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
|
from lembas.services.reasoning import strip_reasoning
|
|
from lembas.services.schedule import clock
|
|
from lembas.services.schedule import rule as rule_service
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Enough for a small model that thinks before answering. The title lesson
|
|
# applies: too small is not a shorter answer, it is no answer, because the
|
|
# thinking consumes the budget and content comes back empty.
|
|
MAX_TOKENS = 900
|
|
MAX_REQUEST_CHARS = 2000
|
|
|
|
_FENCE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Compiled:
|
|
"""What the compile worked out. `ok` is False when the reader must finish
|
|
the job by hand -- the fields are still filled in as far as they went."""
|
|
|
|
ok: bool = False
|
|
title: str = ""
|
|
instruction: str = ""
|
|
target: str = TARGET_CHAT
|
|
rule: dict = field(default_factory=dict)
|
|
reason: str = ""
|
|
|
|
|
|
def _payload(raw: str) -> dict:
|
|
"""The first JSON object in a reply, however it was wrapped.
|
|
|
|
Lenient for the reason `tools.parse_arguments` is: a small model sends
|
|
something close to the shape rather than the shape, and refusing it costs a
|
|
whole round trip to end up showing the manual form anyway.
|
|
"""
|
|
text = (raw or "").strip()
|
|
fenced = _FENCE.search(text)
|
|
if fenced:
|
|
text = fenced.group(1).strip()
|
|
if not text.startswith("{"):
|
|
start, end = text.find("{"), text.rfind("}")
|
|
if start == -1 or end <= start:
|
|
return {}
|
|
text = text[start : end + 1]
|
|
try:
|
|
parsed = json.loads(text)
|
|
except (ValueError, TypeError):
|
|
return {}
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
|
|
|
|
def render_prompt(template: str, *, request: str, user: User | None) -> str:
|
|
"""Fill the fragment in. Separate so a test can read what was asked."""
|
|
from lembas.services import prompts as prompts_service
|
|
|
|
zone = clock.zone_for(user)
|
|
now = datetime.now(tz=UTC).astimezone(zone)
|
|
return prompts_service.substitute(
|
|
template,
|
|
{
|
|
"request": request[:MAX_REQUEST_CHARS],
|
|
"now": now.strftime("%A %-d %B %Y, %H:%M"),
|
|
"timezone": clock.name_for(user) or str(clock.server_zone()),
|
|
"targets": ", ".join(TARGETS),
|
|
},
|
|
)
|
|
|
|
|
|
async def compile_request(
|
|
endpoint: Endpoint,
|
|
model_id: str,
|
|
request: str,
|
|
*,
|
|
template: str,
|
|
user: User | None = None,
|
|
) -> Compiled:
|
|
"""Work a plain-language request into a schedule.
|
|
|
|
Never raises. Every failure -- a cleared fragment, an endpoint that is down,
|
|
prose instead of JSON, a rule that normalises to nothing -- comes back as
|
|
`ok=False` with whatever was salvageable, and the route shows the manual form.
|
|
"""
|
|
plain = (request or "").strip()
|
|
if not plain:
|
|
return Compiled(reason="Say what you want to happen.")
|
|
if not template.strip():
|
|
# An administrator cleared the fragment. That switches off the
|
|
# *compiling*: the reader fills the form in themselves, with their own
|
|
# words already in it.
|
|
return Compiled(instruction=plain, title=plain[:80], reason="")
|
|
|
|
prompt = render_prompt(template, request=plain, user=user)
|
|
body = {
|
|
"model": model_id,
|
|
"messages": [{"role": "user", "content": prompt}],
|
|
"max_tokens": MAX_TOKENS,
|
|
"temperature": 0.2,
|
|
}
|
|
|
|
# Asked twice before giving up, and only when the *reply* was unusable.
|
|
# Measured against a 4B model on this machine: the prompt itself is sound --
|
|
# ten realistic requests compiled ten times over, twice -- but roughly one
|
|
# call in six came back empty or truncated, which a local runner swapping
|
|
# models under the request will do. One retry costs a second on a screen
|
|
# somebody is already waiting at, and turns "fill this in yourself" from
|
|
# something seen regularly into something seen rarely.
|
|
#
|
|
# Deliberately not retried on an LLMError: an endpoint that refused the
|
|
# connection will refuse it again, and the reader is better served by the
|
|
# form than by waiting twice for the same answer.
|
|
payload: dict = {}
|
|
for attempt in range(2):
|
|
try:
|
|
raw = await complete(endpoint, body)
|
|
except LLMError as exc:
|
|
log.info("schedule compile failed: %s", exc)
|
|
return Compiled(
|
|
instruction=plain,
|
|
title=plain[:80],
|
|
reason="The model could not be reached, so fill this in yourself.",
|
|
)
|
|
# A model that thinks inline puts its reasoning in `content`, which is
|
|
# the field `complete` hands back verbatim -- the trap auto-titling hit.
|
|
answered, _ = strip_reasoning(raw)
|
|
payload = _payload(answered)
|
|
if payload:
|
|
break
|
|
log.info("schedule compile produced no JSON (attempt %s)", attempt + 1)
|
|
|
|
if not payload:
|
|
return Compiled(
|
|
instruction=plain,
|
|
title=plain[:80],
|
|
reason="The model did not answer with a schedule, so fill this in yourself.",
|
|
)
|
|
|
|
raw_rule = payload.get("schedule") or payload.get("rule") or {}
|
|
if isinstance(raw_rule, dict):
|
|
# A model asked for "every six hours" writes `{"every": {"hours": 6}}`
|
|
# and nothing else, which is the natural reading and cannot fire: a
|
|
# timer measures from a start, and `rule.py` has no clock to invent one.
|
|
# Filled in here, exactly as the manual form's `_rule_from_form` does,
|
|
# so the two paths agree about what a startless timer means. A model
|
|
# that puts `start` at the top level instead is read the same way rather
|
|
# than being told its schedule means nothing.
|
|
raw_rule = dict(raw_rule)
|
|
if raw_rule.get("every") and not raw_rule.get("start"):
|
|
raw_rule["start"] = payload.get("start") or datetime.now(tz=UTC).isoformat()
|
|
clean = rule_service.validate(raw_rule)
|
|
title = str(payload.get("title") or "").strip() or plain[:80]
|
|
instruction = str(payload.get("instruction") or "").strip() or plain
|
|
target = str(payload.get("target") or TARGET_CHAT)
|
|
if target not in TARGETS:
|
|
target = TARGET_CHAT
|
|
|
|
if not clean:
|
|
return Compiled(
|
|
title=title,
|
|
instruction=instruction,
|
|
target=target,
|
|
reason="The model could not work out when this should run — say when below.",
|
|
)
|
|
if rule_service.next_after(clean, datetime.now(tz=UTC), zone=clock.zone_for(user)) is None:
|
|
# Normalised, but with nothing left to fire. Refused for the same reason
|
|
# `schedules.create` refuses it: a schedule that can never run looks
|
|
# exactly like a working one on every screen it appears on.
|
|
return Compiled(
|
|
title=title,
|
|
instruction=instruction,
|
|
target=target,
|
|
rule=clean,
|
|
reason="That time has already passed — say when it should run.",
|
|
)
|
|
|
|
return Compiled(ok=True, title=title, instruction=instruction, target=target, rule=clean)
|
|
|
|
|
|
def endpoint_for(db, user: User) -> tuple[Endpoint, str] | None:
|
|
"""A connection and model to compile with, or None if there is none.
|
|
|
|
Built on a throwaway `Chat` that is never added to a session, exactly as
|
|
`agent/draft.py` does: `resolve_endpoint` reads `model_id` and
|
|
`connection_id` and nothing else, so it works unchanged and did not have to
|
|
learn what a compile is.
|
|
"""
|
|
from lembas.services import chat as chat_service
|
|
|
|
models = chat_service.available_models(db, user)
|
|
if not models:
|
|
return None
|
|
chosen = next((m for m in models if m.pinned), models[0])
|
|
stand_in = Chat(user_id=user.id, model_id=chosen.model_id, connection_id=chosen.connection_id)
|
|
try:
|
|
return chat_service.resolve_endpoint(db, stand_in)
|
|
except LLMError:
|
|
return None
|