"""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