Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 584beca22d | |||
| 17f3fa1946 | |||
| 314cc946d7 | |||
| 26793b1317 | |||
| 09eecbdd9a | |||
| e185edc9e1 | |||
| 9e2caeac48 | |||
| 2fe736aa6a | |||
| 6dd13b2e9d | |||
| ec457debb3 | |||
| 85f18e99b2 |
@@ -19,7 +19,7 @@ lembas info # paths + counts, useful when confused
|
||||
lembas secret-key # generate LEMBAS_SECRET_KEY
|
||||
lembas create-admin # create or promote an admin
|
||||
|
||||
pytest # 488 tests, ~29s
|
||||
pytest # 590 tests, ~35s
|
||||
# PLAN.md tracks what is and is not built
|
||||
ruff check . # lint (line length 100)
|
||||
python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons;
|
||||
@@ -81,6 +81,7 @@ src/lembas/
|
||||
admin_audio.py speech-to-text and text-to-speech endpoints
|
||||
admin_search.py web search provider and credentials
|
||||
admin_prompts.py the prompt fragment editor and its preview
|
||||
admin_suggestions.py the cards offered on the new-chat screen
|
||||
audio.py transcribe, speak, voice discovery
|
||||
library.py knowledge, notes, skills pages; memory CRUD
|
||||
files.py upload, serve, remove attachments
|
||||
@@ -99,6 +100,10 @@ src/lembas/
|
||||
fetch.py URL retrieval, HTML to text, the SSRF guard
|
||||
sharing.py one visibility rule for every library store
|
||||
prompts.py every injected prompt fragment, and {{variables}}
|
||||
metrics.py tokens, context percentage and tokens/second
|
||||
tokens.py the chars/4 estimate, for endpoints that report none
|
||||
compaction.py summarising the earlier turns of a long chat
|
||||
suggestions.py new-chat starting points, seeded once
|
||||
harness.py the operational prompt built from what a model has
|
||||
tools.py tool registry, schemas, streamed-call reassembly
|
||||
chat.py request building, endpoint resolution, titles
|
||||
@@ -213,8 +218,25 @@ NOT stop the reply -- that was the old behaviour and it cut answers off when
|
||||
the reader navigated away. Any route that creates an assistant placeholder must
|
||||
also call `generation.ensure()`.
|
||||
|
||||
**Stream frames carry whole blocks, not deltas.** Both `render` and `reasoning`
|
||||
send the complete text each time. That is what makes reattaching mid-reply
|
||||
**`ensure` attaches, `restart` replaces.** The registry is keyed on message id
|
||||
and finished generations linger `KEEP_FINISHED` so a follower arriving at the
|
||||
last moment still gets the final frames. `ensure` is idempotent because a page
|
||||
load finding an unfinished reply must attach rather than start a second one.
|
||||
Regeneration is the only caller that reuses a `Message` row, and therefore the
|
||||
only one for which idempotence is wrong -- it got the finished generation back,
|
||||
made no request, and left the browser reconnecting to a stream with nothing to
|
||||
say. It calls `restart`. `_persist` refuses to write when another generation
|
||||
owns the message, because a cancelled predecessor's `finally:` still runs.
|
||||
|
||||
**The row is written before `done` is set.** `_follow` breaks out the instant it
|
||||
sees that flag and re-renders the bubble from the database, so the row has to be
|
||||
authoritative first. The other order silently showed the previous turn's stored
|
||||
metrics.
|
||||
|
||||
**Stream frames carry whole blocks, not deltas.** `render`, `reasoning`,
|
||||
`metrics` and `status` all send the complete value each time, and every one of
|
||||
them is swapped with `innerHTML`. `reasoning` used `beforeend` and so repeated
|
||||
everything already shown on every frame. That is what makes reattaching mid-reply
|
||||
work: a follower arriving late has no earlier fragments to append to. It also
|
||||
means Markdown is re-rendered whole, which is required anyway -- a list or code
|
||||
fence is only correct once its context exists.
|
||||
@@ -490,5 +512,29 @@ flag, not a new code path. Its guidance is the same shape: a
|
||||
harness, on the admin page and in the preview without touching the assembler,
|
||||
the save handler or a template.
|
||||
|
||||
**Unknown is not zero.** `Model.context_length` of 0 means nobody has said how
|
||||
big the window is, which is different from "small". The context percentage is
|
||||
omitted rather than computed, and automatic compaction never fires. Token counts
|
||||
fall back to `services/tokens.py` -- four characters to a token -- and anything
|
||||
derived from an estimate is shown with a `~`. Compaction *does* act on an
|
||||
estimate, because a premature compaction costs one turn of answer quality rather
|
||||
than data: the messages are kept.
|
||||
|
||||
**Compaction hides turns, it does not delete them.** `Chat.compact_summary` plus
|
||||
`compacted_through_id` say how far it reached; the messages stay in the
|
||||
transcript behind a `<details>` divider and simply stop being part of the
|
||||
request. The summary is carried by a **user turn and an assistant turn**, not
|
||||
one: a leading `assistant` breaks templates that require the first non-system
|
||||
message to be `user`, and a lone leading `user` produces `user, user` whenever
|
||||
the kept history starts on a user turn -- which it always does, because the
|
||||
cutoff lands on a finished reply. `compacted_through_id` is a plain id, not a
|
||||
foreign key, because `migrations.py` compiles only the column type and a
|
||||
`REFERENCES` clause would exist on a fresh database and not on an upgraded one;
|
||||
`compaction.cutoff_message` validates it on every read instead.
|
||||
|
||||
**Compare message timestamps through `compaction.moment()`.** SQLite does not
|
||||
store the offset, so a row loaded from disk is naive while one still in the
|
||||
session's identity map keeps its tzinfo. Comparing the two raises.
|
||||
|
||||
No OCR: a scanned PDF is stored with an explanatory `extraction_error` rather
|
||||
than silently contributing nothing.
|
||||
|
||||
@@ -47,6 +47,13 @@ be a different project, not a refactor.
|
||||
- [x] **Unread indicator** — a green dot and a toast when a reply lands while
|
||||
you were elsewhere
|
||||
- [x] Folders, arbitrarily nested; deleting one keeps the chats inside it
|
||||
- [x] Per-reply metrics — tokens, context used as a percentage, tokens/second,
|
||||
live while streaming and kept afterwards. Estimated with a `~` when the
|
||||
endpoint reports no usage
|
||||
- [x] Compaction — a button, and automatically at a configurable percentage of
|
||||
the model's context. Summarised turns are kept and collapsed, not deleted
|
||||
- [x] Temporary chats — never listed, swept after a day, with a Keep button
|
||||
- [x] An admin-only request inspector beside the thread
|
||||
|
||||
### Tools
|
||||
- [x] **Tool calling** — one reply is a bounded loop of requests, not one
|
||||
@@ -126,6 +133,9 @@ be a different project, not a refactor.
|
||||
- [x] Defaults in code and overrides in the database, so improving a default
|
||||
still reaches an instance that never edited it
|
||||
|
||||
### Suggestions
|
||||
- [x] Admin-managed cards on the new-chat screen; three seeded once at startup
|
||||
|
||||
### Interface
|
||||
- [x] **Installable** — manifest, generated PWA icons, a service worker for the
|
||||
shell and a themed offline page. The worker deliberately never touches
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "lembas"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
+26
-3
@@ -14,7 +14,7 @@ from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Connection, Model, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import UNCHANGED_SENTINEL, decrypt, encrypt, mask
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, list_models
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, context_from, list_models
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -58,6 +58,7 @@ async def save_general(
|
||||
instance_name: str = Form("LLeMbas"),
|
||||
allow_signup: bool = Form(False),
|
||||
system_prompt: str = Form(""),
|
||||
compact_threshold: int = Form(95),
|
||||
) -> Response:
|
||||
"""Save instance settings.
|
||||
|
||||
@@ -70,6 +71,12 @@ async def save_general(
|
||||
"instance_name": instance_name.strip()[:120] or "LLeMbas",
|
||||
"allow_signup": allow_signup,
|
||||
"system_prompt": system_prompt.strip()[:8000],
|
||||
# 0 is "never"; anything else is clamped into a band where it can
|
||||
# do some good. 100 is useless -- you cannot compact after
|
||||
# overflowing -- and below 50 it fires while there is plenty left.
|
||||
"compact_threshold": (
|
||||
0 if compact_threshold <= 0 else min(max(compact_threshold, 50), 99)
|
||||
),
|
||||
},
|
||||
)
|
||||
log.info("registration %s by %s", "opened" if allow_signup else "closed", user.email)
|
||||
@@ -192,14 +199,30 @@ async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, s
|
||||
|
||||
# New models land after everything already ordered, rather than all at
|
||||
# position 0 where they would sort by id and shuffle the existing list.
|
||||
next_position = (db.scalar(select(func.coalesce(func.max(Model.position), -1))) or -1) + 1
|
||||
# No `or -1` after the coalesce: position 0 is falsy, so that idiom sent the
|
||||
# second discovered model back to 0 on top of the first.
|
||||
highest = db.scalar(select(func.coalesce(func.max(Model.position), -1)))
|
||||
next_position = int(highest if highest is not None else -1) + 1
|
||||
|
||||
for entry in discovered:
|
||||
model_id = str(entry["id"])[:300]
|
||||
seen.add(model_id)
|
||||
if model_id in existing:
|
||||
# A context length is filled in only when nobody has one yet. A
|
||||
# refresh must never overwrite a number an administrator typed --
|
||||
# they are usually correcting the endpoint.
|
||||
model = existing[model_id]
|
||||
if not model.context_length:
|
||||
model.context_length = context_from(entry)
|
||||
continue
|
||||
db.add(Model(connection_id=connection.id, model_id=model_id, position=next_position))
|
||||
db.add(
|
||||
Model(
|
||||
connection_id=connection.id,
|
||||
model_id=model_id,
|
||||
position=next_position,
|
||||
context_length=context_from(entry),
|
||||
)
|
||||
)
|
||||
next_position += 1
|
||||
|
||||
# Models that vanished upstream are dropped, so the picker never offers
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Request, Response, UploadFile, status
|
||||
@@ -12,6 +13,7 @@ from sqlalchemy.orm import Session as DBSession
|
||||
from lembas.api.deps import AdminUser, Db, RequiredUser
|
||||
from lembas.db.models import Connection, Group, Model
|
||||
from lembas.services import settings_store, uploads
|
||||
from lembas.services.llm.openai_client import MAX_CONTEXT
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -213,6 +215,7 @@ async def update_model(
|
||||
pinned: bool = Form(False),
|
||||
public: bool = Form(False),
|
||||
position: str = Form(""),
|
||||
context_length: str = Form(""),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
capability: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
@@ -221,6 +224,13 @@ async def update_model(
|
||||
model.display_name = display_name.strip()[:300]
|
||||
model.description = description.strip()[:2000]
|
||||
model.system_prompt = system_prompt.strip()[:8000]
|
||||
# A string, so an emptied field is distinguishable and junk can be ignored
|
||||
# rather than becoming a 422 -- the same shape `position` uses below.
|
||||
if context_length.strip():
|
||||
with contextlib.suppress(ValueError):
|
||||
model.context_length = min(max(int(context_length), 0), MAX_CONTEXT)
|
||||
else:
|
||||
model.context_length = 0
|
||||
model.enabled = enabled
|
||||
model.pinned = pinned
|
||||
model.public = public
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Administration for the cards offered on the new-chat screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Suggestion
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/suggestions", tags=["admin-suggestions"])
|
||||
|
||||
|
||||
def _suggestion(db: Db, suggestion_id: str) -> Suggestion:
|
||||
suggestion = db.get(Suggestion, suggestion_id)
|
||||
if suggestion is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That suggestion no longer exists.")
|
||||
return suggestion
|
||||
|
||||
|
||||
def _back(message: str = "") -> Response:
|
||||
target = f"/admin/suggestions?saved={message}" if message else "/admin/suggestions"
|
||||
return RedirectResponse(target, status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def suggestions_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
rows = suggestions_service.all_of_them(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/suggestions.html",
|
||||
{
|
||||
"suggestions": rows,
|
||||
"at_limit": len(rows) >= suggestions_service.MAX_SUGGESTIONS,
|
||||
"max_suggestions": suggestions_service.MAX_SUGGESTIONS,
|
||||
"max_shown": suggestions_service.MAX_SHOWN,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_suggestion(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
prompt: str = Form(""),
|
||||
) -> Response:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return _back("A suggestion needs a name.")
|
||||
if len(suggestions_service.all_of_them(db)) >= suggestions_service.MAX_SUGGESTIONS:
|
||||
return _back(f"That is already {suggestions_service.MAX_SUGGESTIONS}, which is plenty.")
|
||||
|
||||
suggestions_service.create(db, name=name, description=description, prompt=prompt)
|
||||
log.info("%s added suggestion %s", user.email, name)
|
||||
return _back(f"Added {name}.")
|
||||
|
||||
|
||||
# Registered before /{suggestion_id}: FastAPI matches in registration order, so
|
||||
# with the parameterised route first any literal segment added later would be
|
||||
# captured as an id. That has already been a bug once, in /admin/models.
|
||||
@router.post("/{suggestion_id}/delete")
|
||||
async def delete_suggestion(db: Db, user: AdminUser, suggestion_id: str) -> Response:
|
||||
suggestion = _suggestion(db, suggestion_id)
|
||||
name = suggestion.name
|
||||
db.delete(suggestion)
|
||||
db.commit()
|
||||
log.info("%s deleted suggestion %s", user.email, name)
|
||||
return _back(f"Deleted {name}.")
|
||||
|
||||
|
||||
@router.post("/{suggestion_id}")
|
||||
async def update_suggestion(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
suggestion_id: str,
|
||||
) -> Response:
|
||||
"""Save one row.
|
||||
|
||||
The raw form is read rather than declared parameters because `enabled` is a
|
||||
checkbox: FastAPI cannot tell an unticked box from an absent field, and an
|
||||
absent one is exactly what an unticked box sends.
|
||||
"""
|
||||
suggestion = _suggestion(db, suggestion_id)
|
||||
form = await request.form()
|
||||
|
||||
suggestion.name = (
|
||||
str(form.get("name") or "").strip()[: suggestions_service.MAX_NAME] or suggestion.name
|
||||
)
|
||||
suggestion.description = str(form.get("description") or "").strip()[
|
||||
: suggestions_service.MAX_DESCRIPTION
|
||||
]
|
||||
suggestion.prompt = str(form.get("prompt") or "").replace("\r\n", "\n")[
|
||||
: suggestions_service.MAX_PROMPT
|
||||
]
|
||||
suggestion.enabled = "enabled" in form
|
||||
|
||||
position = str(form.get("position") or "").strip()
|
||||
if position.isdigit():
|
||||
suggestion.position = min(max(int(position) - 1, 0), 999)
|
||||
|
||||
db.commit()
|
||||
log.info("%s updated suggestion %s", user.email, suggestion.name)
|
||||
return _back(f"Saved {suggestion.name}.")
|
||||
+230
-7
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||
@@ -18,9 +19,13 @@ from lembas.db.session import session_scope
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import files as files_service
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import sse
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.markdown import escape_text, render_markdown
|
||||
from lembas.web.templating import render, templates
|
||||
|
||||
@@ -28,6 +33,11 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/chats", tags=["chats"])
|
||||
|
||||
# Seconds of silence before a comment frame is sent to hold the connection open.
|
||||
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
|
||||
KEEPALIVE_AFTER = 15.0
|
||||
|
||||
|
||||
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||
chat = db.get(Chat, chat_id)
|
||||
# 404 rather than 403 for someone else's chat: whether a given id exists is
|
||||
@@ -37,7 +47,14 @@ def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||
return chat
|
||||
|
||||
|
||||
def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str = "") -> Chat:
|
||||
def _new_chat(
|
||||
db: DBSession,
|
||||
user: User,
|
||||
*,
|
||||
folder_id: str = "",
|
||||
model_id: str = "",
|
||||
temporary: bool = False,
|
||||
) -> Chat:
|
||||
"""Create a chat row, resolving which model it should use."""
|
||||
chosen = None
|
||||
if model_id:
|
||||
@@ -54,6 +71,7 @@ def _new_chat(db: DBSession, user: User, *, folder_id: str = "", model_id: str =
|
||||
folder_id=folder_id or None,
|
||||
model_id=chosen[0] if chosen else "",
|
||||
connection_id=chosen[1] if chosen else None,
|
||||
temporary=temporary,
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
@@ -68,6 +86,7 @@ async def start_chat(
|
||||
file_ids: list[str] = Form(default=[]),
|
||||
folder_id: str = Form(""),
|
||||
model_id: str = Form(""),
|
||||
temporary: bool = Form(False),
|
||||
) -> Response:
|
||||
"""Create a chat from its first message.
|
||||
|
||||
@@ -80,7 +99,9 @@ async def start_chat(
|
||||
if not content and not file_ids:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
chat = _new_chat(db, user, folder_id=folder_id, model_id=model_id)
|
||||
chat = _new_chat(
|
||||
db, user, folder_id=folder_id, model_id=model_id, temporary=temporary
|
||||
)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
if file_ids:
|
||||
@@ -100,6 +121,166 @@ async def start_chat(
|
||||
# /start when the first message is actually sent.
|
||||
|
||||
|
||||
@router.get("/{chat_id}/inspect")
|
||||
async def inspect_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
"""What this chat would send upstream right now.
|
||||
|
||||
Owner-checked *and* admin-checked, not admin alone. `permissions.resolve`
|
||||
giving an admin everything is about configuration, which they can grant
|
||||
themselves anyway; reading someone's conversation is a different act, which
|
||||
is why `sharing.visible_to` has no admin branch either. An inspector that
|
||||
could dump any user's transcript would be that branch under another name.
|
||||
|
||||
Rebuilt, not recorded. Recording every request would store a copy of the
|
||||
whole conversation against every message, which grows quadratically with
|
||||
chat length -- and the thing an administrator actually wants to see is what
|
||||
the current configuration produces. The panel says so in as many words.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN, "The inspector is restricted to administrators."
|
||||
)
|
||||
|
||||
offered = tools_service.enabled_tools(db, chat, user)
|
||||
payload = chat_service.build_request(db, chat, tools=offered, user=user)
|
||||
last = db.scalar(
|
||||
select(Message)
|
||||
.where(Message.chat_id == chat.id, Message.role == ROLE_ASSISTANT)
|
||||
.order_by(Message.created_at.desc())
|
||||
)
|
||||
|
||||
messages = payload.get("messages") or []
|
||||
system = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/_inspector_body.html",
|
||||
{
|
||||
"chat": chat,
|
||||
"system": system,
|
||||
"request_json": _pretty(_redact(payload)),
|
||||
"row": last,
|
||||
"metrics": metrics_service.from_message(last.usage_json if last else None),
|
||||
"tool_names": [
|
||||
(t.get("function") or {}).get("name", "") for t in offered
|
||||
],
|
||||
"model": chat_service.model_for(db, chat),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Roughly what a downscaled phone photo comes to as base64. The exact figure
|
||||
# does not matter; putting megabytes of it into the DOM does.
|
||||
_REDACTED_URI = "data:…base64 image omitted…"
|
||||
MAX_INSPECT_CHARS = 40_000
|
||||
|
||||
|
||||
def _redact(payload: dict) -> dict:
|
||||
"""Replace image data URIs before dumping.
|
||||
|
||||
Nothing else is hidden -- fidelity is the whole point of the panel, and API
|
||||
keys never appear because `build_request` returns a body, not headers.
|
||||
"""
|
||||
messages = []
|
||||
for message in payload.get("messages") or []:
|
||||
content = message.get("content")
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
parts.append({"type": "image_url", "image_url": {"url": _REDACTED_URI}})
|
||||
else:
|
||||
parts.append(part)
|
||||
message = {**message, "content": parts}
|
||||
messages.append(message)
|
||||
return {**payload, "messages": messages}
|
||||
|
||||
|
||||
def _pretty(payload: dict) -> str:
|
||||
text = json.dumps(payload, indent=2, ensure_ascii=False, default=str)
|
||||
if len(text) > MAX_INSPECT_CHARS:
|
||||
return text[:MAX_INSPECT_CHARS] + "\n… truncated"
|
||||
return text
|
||||
|
||||
|
||||
@router.post("/{chat_id}/compact")
|
||||
async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
"""Summarise the earlier turns and stop sending them.
|
||||
|
||||
No permission of its own: compaction changes only what one chat sends
|
||||
upstream, and gating it would mean answering "why can this user not tidy
|
||||
their own conversation".
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
unfinished = db.scalar(
|
||||
select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
|
||||
)
|
||||
if unfinished is not None:
|
||||
# Summarising a transcript that is still being written races
|
||||
# build_request. Queuing it is a state machine nobody asked for.
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, then compact."
|
||||
)
|
||||
|
||||
template = prompts_service.resolve(db, "task.compact")
|
||||
if not template.strip():
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT,
|
||||
"Compaction is turned off: its prompt is empty under Admin → Prompts.",
|
||||
)
|
||||
|
||||
upto = compaction_service.last_complete(db, chat)
|
||||
if upto is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "There is nothing here to summarise yet."
|
||||
)
|
||||
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
transcript = compaction_service.transcript(db, chat, upto=upto)
|
||||
previous = compaction_service.previous_summary_block(chat)
|
||||
|
||||
summary = await chat_service.summarise_for_compaction(
|
||||
endpoint,
|
||||
model_id,
|
||||
transcript=transcript,
|
||||
previous_summary=previous,
|
||||
template=template,
|
||||
)
|
||||
if not summary:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "The model returned no summary, so nothing changed."
|
||||
)
|
||||
|
||||
compaction_service.apply(chat, summary=summary, upto=upto)
|
||||
db.commit()
|
||||
log.info("chat %s compacted through %s", chat.id, upto.id)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{chat_id}/keep")
|
||||
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
"""Stop a temporary chat being temporary.
|
||||
|
||||
A conversation that turns out to matter has to have a way out; without one,
|
||||
the sweep destroys it a day later with no recourse, and people discover that
|
||||
exactly once.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
chat.temporary = False
|
||||
db.commit()
|
||||
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
# The sidebar has to gain a row and the topbar has to lose a badge; a full
|
||||
# refresh is one line against a handful of out-of-band fragments.
|
||||
response.headers["HX-Refresh"] = "true"
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/unread")
|
||||
async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
"""Dots for the sidebar, and a toast for anything newly arrived.
|
||||
@@ -113,7 +294,13 @@ async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
||||
"""
|
||||
chats = list(
|
||||
db.scalars(
|
||||
select(Chat).where(Chat.user_id == user.id, Chat.archived.is_(False))
|
||||
select(Chat).where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.archived.is_(False),
|
||||
# A temporary chat has no sidebar row, so a dot has nowhere to
|
||||
# land and the toast would name a chat nobody can navigate to.
|
||||
Chat.temporary.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -240,6 +427,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
generation = generation_service.ensure(chat_id, message_id)
|
||||
generation.followers += 1
|
||||
seen = -1
|
||||
last_frame = time.monotonic()
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -251,9 +439,20 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
yield sse.event("tools", _tool_activity(generation.tool_events))
|
||||
if generation.content:
|
||||
yield sse.event("render", render_markdown(generation.text))
|
||||
yield sse.event("metrics", _metrics_html(generation))
|
||||
yield sse.event("status", escape_text(generation.status))
|
||||
last_frame = time.monotonic()
|
||||
|
||||
if generation.done:
|
||||
break
|
||||
|
||||
# A reasoning model can think for a minute or more without emitting
|
||||
# anything, and an idle connection is what a proxy closes. The
|
||||
# comment frame keeps it open and is ignored by the browser.
|
||||
if time.monotonic() - last_frame > KEEPALIVE_AFTER:
|
||||
yield sse.KEEPALIVE
|
||||
last_frame = time.monotonic()
|
||||
|
||||
# Polling rather than per-follower wakeups: the producer already
|
||||
# works in RENDER_INTERVAL steps, so a short sleep is simpler and
|
||||
# cannot drop a notification.
|
||||
@@ -261,7 +460,7 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
finally:
|
||||
generation.followers = max(0, generation.followers - 1)
|
||||
|
||||
# The producer writes the message before marking itself done, so by here
|
||||
# The producer commits the message before marking itself done, so by here
|
||||
# the row is authoritative and the final bubble can be rendered from it.
|
||||
with session_scope() as db:
|
||||
message = db.get(Message, message_id)
|
||||
@@ -299,18 +498,32 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
yield sse.event("close", "")
|
||||
|
||||
|
||||
def _metrics_html(generation) -> str:
|
||||
"""The metric chips for a reply still being written.
|
||||
|
||||
Built from the same Metrics object the finished bubble uses, so the numbers
|
||||
do not jump when the stream ends -- the only thing that changes is that an
|
||||
estimate may have become exact.
|
||||
"""
|
||||
return templates.get_template("chat/_metrics.html").render(
|
||||
{"metrics": metrics_service.from_generation(generation)}
|
||||
)
|
||||
|
||||
|
||||
def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
||||
"""Everything chat/_thread.html needs to render the conversation."""
|
||||
messages = list(
|
||||
everything = list(
|
||||
db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at))
|
||||
)
|
||||
compacted, messages = compaction_service.split(db, chat, everything)
|
||||
return {
|
||||
"chat": chat,
|
||||
"user": user,
|
||||
"messages": messages,
|
||||
"compacted": compacted,
|
||||
"bodies": {
|
||||
m.id: render_markdown(m.content)
|
||||
for m in messages
|
||||
for m in everything
|
||||
if m.role == ROLE_ASSISTANT and m.content
|
||||
},
|
||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
|
||||
@@ -406,6 +619,14 @@ async def edit_message(
|
||||
discarded = _messages_after(db, message)
|
||||
for later in discarded:
|
||||
db.delete(later)
|
||||
|
||||
# A rewind to at or before the compaction boundary leaves that boundary
|
||||
# describing turns that no longer exist. There is no foreign key to null it
|
||||
# out on an upgraded database, so it is cleared here.
|
||||
cutoff = compaction_service.cutoff_message(db, chat)
|
||||
if cutoff is None or compaction_service.moment(message) <= compaction_service.moment(cutoff):
|
||||
compaction_service.reset(chat)
|
||||
|
||||
db.commit()
|
||||
|
||||
assistant = chat_service.create_message(
|
||||
@@ -592,7 +813,9 @@ async def regenerate(
|
||||
message.complete = False
|
||||
message.model_id = chat.model_id
|
||||
db.commit()
|
||||
generation_service.ensure(chat.id, message.id)
|
||||
# restart, not ensure: this is the one caller that reuses a Message row, and
|
||||
# the finished generation for it is still registered.
|
||||
generation_service.restart(chat.id, message.id)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
|
||||
+17
-4
@@ -12,7 +12,9 @@ from lembas.db.models import Chat, Folder, KnowledgeBase, Message, User
|
||||
from lembas.security import permissions
|
||||
from lembas.services import audio as audio_service
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.services.library import documents as documents_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
@@ -83,6 +85,7 @@ def sidebar_context(db: DBSession, user: User) -> dict:
|
||||
Chat.user_id == user.id,
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
Chat.temporary.is_(False),
|
||||
)
|
||||
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||
)
|
||||
@@ -164,11 +167,15 @@ async def offline(request: Request) -> Response:
|
||||
|
||||
|
||||
@router.get("/chat")
|
||||
async def chat_index(request: Request, db: Db, user: RequiredUser, model: str = ""):
|
||||
async def chat_index(
|
||||
request: Request, db: Db, user: RequiredUser, model: str = "", temporary: bool = False
|
||||
):
|
||||
"""A composer with no chat behind it yet.
|
||||
|
||||
`?model=` preselects one, which is how the pinned shortcuts work without
|
||||
creating a row for a chat that may never be sent.
|
||||
creating a row for a chat that may never be sent. `?temporary=1` is the
|
||||
same idea for the temporary flag: it lives in the URL rather than in
|
||||
JavaScript, so it survives a reload and can be bookmarked.
|
||||
"""
|
||||
context = _chat_context(db, user, None)
|
||||
|
||||
@@ -195,6 +202,8 @@ async def chat_index(request: Request, db: Db, user: RequiredUser, model: str =
|
||||
"bodies": {},
|
||||
**context,
|
||||
"current_model": preselected,
|
||||
"starting_temporary": temporary,
|
||||
"suggestions": suggestions_service.visible(db),
|
||||
**sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -212,18 +221,21 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
chat.unread_notified = False
|
||||
db.commit()
|
||||
|
||||
messages = list(
|
||||
everything = list(
|
||||
db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
)
|
||||
)
|
||||
# Summarised turns are kept and still rendered, behind a divider -- they
|
||||
# have only stopped being part of the request.
|
||||
compacted, messages = compaction_service.split(db, chat, everything)
|
||||
|
||||
# Markdown is rendered once here rather than in the template so the same
|
||||
# helper produces the page and the streamed final frame -- one code path,
|
||||
# no chance of the two disagreeing.
|
||||
bodies = {
|
||||
message.id: render_markdown(message.content)
|
||||
for message in messages
|
||||
for message in everything
|
||||
if message.role == "assistant" and message.content
|
||||
}
|
||||
|
||||
@@ -247,6 +259,7 @@ async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str
|
||||
{
|
||||
"chat": chat,
|
||||
"messages": messages,
|
||||
"compacted": compacted,
|
||||
"bodies": bodies,
|
||||
"inherited_prompt": inherited,
|
||||
"inherited_from": inherited_from,
|
||||
|
||||
@@ -41,6 +41,7 @@ from lembas.db.models.library import (
|
||||
chat_knowledge_bases,
|
||||
)
|
||||
from lembas.db.models.setting import Setting
|
||||
from lembas.db.models.suggestion import Suggestion
|
||||
from lembas.db.models.user import (
|
||||
ROLE_ADMIN,
|
||||
ROLE_PENDING,
|
||||
@@ -85,6 +86,7 @@ __all__ = [
|
||||
"Share",
|
||||
"Skill",
|
||||
"SkillRevision",
|
||||
"Suggestion",
|
||||
"User",
|
||||
"chat_knowledge_bases",
|
||||
"model_groups",
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
@@ -45,6 +46,24 @@ class Folder(UUIDPrimaryKey, Timestamps, Base):
|
||||
parent: Mapped[Folder | None] = relationship(back_populates="children", remote_side="Folder.id")
|
||||
chats: Mapped[list[Chat]] = relationship(back_populates="folder")
|
||||
|
||||
@property
|
||||
def visible_chats(self) -> list[Chat]:
|
||||
"""The chats in this folder that belong in the sidebar.
|
||||
|
||||
The relationship itself stays unfiltered -- back-population needs every
|
||||
row -- so the listing rule lives here rather than in the template, where
|
||||
the loop and the "Empty" check would have to agree by hand and already
|
||||
did not: archived chats have been showing inside folders since folders
|
||||
existed. The unfiled list has always filtered them (api/pages.py); the
|
||||
folder branch went through the relationship and filtered nothing.
|
||||
|
||||
Ordered like the unfiled list: pinned first, then most recently touched.
|
||||
"""
|
||||
kept = [chat for chat in self.chats if not chat.archived and not chat.temporary]
|
||||
kept.sort(key=lambda chat: chat.updated_at, reverse=True)
|
||||
kept.sort(key=lambda chat: not chat.pinned)
|
||||
return kept
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Folder {self.name}>"
|
||||
|
||||
@@ -77,12 +96,32 @@ class Chat(UUIDPrimaryKey, Timestamps, Base):
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Never listed in the sidebar, and swept a day after the last thing said in
|
||||
# it. A real row rather than something held in the browser, so a reload or a
|
||||
# dropped connection does not lose the conversation -- and `Keep` clears the
|
||||
# flag, because a temporary chat that turns out to matter must have a way
|
||||
# out. See services/chat.py:sweep_temporary.
|
||||
temporary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# A reply landed while nobody was watching this chat. Cleared when the chat
|
||||
# is next opened. `unread_notified` stops the same arrival being announced
|
||||
# on every poll.
|
||||
unread: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
unread_notified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
# --- Compaction ----------------------------------------------------------
|
||||
# A summary of the turns up to `compacted_through_id`, sent in their place.
|
||||
# The messages themselves are kept and still shown; they simply stop being
|
||||
# part of the request. See services/compaction.py.
|
||||
compact_summary: Mapped[str] = mapped_column(Text, default="")
|
||||
# A plain id, deliberately not a ForeignKey: db/migrations.py compiles only
|
||||
# the column type, so a REFERENCES clause would exist on a freshly created
|
||||
# database and not on an upgraded one, and a constraint half the fleet has
|
||||
# is worse than none. It is validated on every read instead -- the same
|
||||
# reasoning `model_id` above carries.
|
||||
compacted_through_id: Mapped[str | None] = mapped_column(String(32))
|
||||
compacted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
folder: Mapped[Folder | None] = relationship(back_populates="chats")
|
||||
messages: Mapped[list[Message]] = relationship(
|
||||
back_populates="chat",
|
||||
|
||||
@@ -114,6 +114,17 @@ class Model(UUIDPrimaryKey, Timestamps, Base):
|
||||
# Default sampling params applied to new chats using this model.
|
||||
params_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict)
|
||||
|
||||
# How many tokens this model can hold. 0 means unknown, which is what an
|
||||
# endpoint that does not advertise it leaves behind -- and unknown has to
|
||||
# stay tellable from "small", because the context percentage and automatic
|
||||
# compaction both refuse to act on a number nobody supplied.
|
||||
#
|
||||
# A column rather than a key in capabilities_json: that dict is rebuilt
|
||||
# wholesale from the submitted checkboxes on every save (api/admin_models.py),
|
||||
# so a number living in it would be destroyed the next time an administrator
|
||||
# ticked anything.
|
||||
context_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
connection: Mapped[Connection] = relationship(back_populates="models")
|
||||
groups: Mapped[list[Group]] = relationship(
|
||||
"Group", secondary=model_groups, back_populates="models"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Starting points offered on the new-chat screen."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from lembas.db.base import Base, Timestamps, UUIDPrimaryKey
|
||||
|
||||
|
||||
class Suggestion(UUIDPrimaryKey, Timestamps, Base):
|
||||
"""One card on the empty chat screen.
|
||||
|
||||
Instance-wide rather than per-user: these are what an administrator wants
|
||||
people to start with, the same way the instance system prompt is. There is
|
||||
no owner_id and therefore nothing for `sharing` to decide.
|
||||
"""
|
||||
|
||||
__tablename__ = "suggestions"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
description: Mapped[str] = mapped_column(String(300), default="")
|
||||
# What lands in the composer. Deliberately not sent on its own: it usually
|
||||
# ends mid-sentence, because a card is a starting point rather than a
|
||||
# question somebody already asked.
|
||||
prompt: Mapped[str] = mapped_column(Text, default="")
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Suggestion {self.name}>"
|
||||
@@ -18,6 +18,7 @@ from lembas.api import (
|
||||
admin_models,
|
||||
admin_prompts,
|
||||
admin_search,
|
||||
admin_suggestions,
|
||||
admin_users,
|
||||
audio,
|
||||
auth,
|
||||
@@ -62,14 +63,21 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
# disk forever. Cheap, and startup is the natural moment for it.
|
||||
try:
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services.chat import sweep_temporary
|
||||
from lembas.services.files import sweep_orphans
|
||||
from lembas.services.library.documents import sweep_unfiled
|
||||
from lembas.services.suggestions import seed_defaults as seed_suggestions
|
||||
|
||||
with session_scope() as db:
|
||||
sweep_orphans(db)
|
||||
# Documents that predate knowledge bases have nowhere to live until
|
||||
# this runs; see services/library/documents.py.
|
||||
sweep_unfiled(db)
|
||||
# Temporary chats older than a day. Startup only, like the sweeps
|
||||
# above it -- see services/chat.py:sweep_temporary.
|
||||
sweep_temporary(db)
|
||||
# Three starting points on the empty screen, written once ever.
|
||||
seed_suggestions(db)
|
||||
except Exception: # noqa: BLE001 - housekeeping must never block startup
|
||||
log.exception("orphaned upload sweep failed")
|
||||
|
||||
@@ -111,6 +119,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(admin_audio.router)
|
||||
app.include_router(admin_search.router)
|
||||
app.include_router(admin_prompts.router)
|
||||
app.include_router(admin_suggestions.router)
|
||||
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
|
||||
+107
-2
@@ -3,9 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
@@ -32,6 +33,9 @@ FORWARDED_PARAMS = frozenset(
|
||||
|
||||
MAX_TITLE_LENGTH = 60
|
||||
|
||||
# How long a temporary chat survives after the last thing said in it.
|
||||
TEMPORARY_LIFETIME = timedelta(hours=24)
|
||||
|
||||
|
||||
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
|
||||
"""Find the connection and model a chat should use.
|
||||
@@ -170,11 +174,31 @@ def build_messages(
|
||||
resolved, which is how the harness gets in front of the authored prompt
|
||||
without this function knowing anything about tools.
|
||||
"""
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
system = effective_system_prompt(db, chat) if system_prompt is None else system_prompt
|
||||
if system:
|
||||
payload.append({"role": ROLE_SYSTEM, "content": system})
|
||||
|
||||
# Compacted turns are replaced by a summary carried in two turns rather than
|
||||
# one. A leading `assistant` breaks templates that require the first
|
||||
# non-system message to be `user`; a lone leading `user` produces user, user
|
||||
# whenever the kept history starts on a user turn -- which it always does,
|
||||
# because the cutoff lands on a finished reply. The pair alternates
|
||||
# correctly in both directions and keeps exactly one system message.
|
||||
cutoff = compaction_service.cutoff_message(db, chat)
|
||||
if cutoff is not None:
|
||||
lead = prompts_service.resolve(db, "task.compact_lead").strip()
|
||||
ack = prompts_service.resolve(db, "task.compact_ack").strip()
|
||||
summary = chat.compact_summary.strip()
|
||||
payload.append(
|
||||
{"role": ROLE_USER, "content": f"{lead}\n\n{summary}" if lead else summary}
|
||||
)
|
||||
if ack:
|
||||
payload.append({"role": ROLE_ASSISTANT, "content": ack})
|
||||
|
||||
history = db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
).all()
|
||||
@@ -182,6 +206,10 @@ def build_messages(
|
||||
for message in history:
|
||||
if upto is not None and message.id == upto.id:
|
||||
break
|
||||
if cutoff is not None and compaction_service.moment(
|
||||
message
|
||||
) <= compaction_service.moment(cutoff):
|
||||
continue
|
||||
# Skip turns that failed or produced nothing -- but a message carrying
|
||||
# only an attachment has no text and must still be sent.
|
||||
if message.error:
|
||||
@@ -387,8 +415,85 @@ def create_message(
|
||||
return message
|
||||
|
||||
|
||||
async def summarise_for_compaction(
|
||||
endpoint: Endpoint,
|
||||
model_id: str,
|
||||
*,
|
||||
transcript: str,
|
||||
previous_summary: str,
|
||||
template: str,
|
||||
) -> str:
|
||||
"""Ask the model to summarise the earlier turns.
|
||||
|
||||
`template` is passed in for the same reason `generate_title`'s is: this runs
|
||||
after the generation's session has closed, and opening another one there is
|
||||
how you get a session that outlives its scope. An empty template means an
|
||||
administrator cleared the fragment, and nothing is asked of anyone.
|
||||
"""
|
||||
from lembas.services import prompts as prompts_service
|
||||
|
||||
if not template.strip() or not transcript.strip():
|
||||
return ""
|
||||
|
||||
prompt = prompts_service.substitute(
|
||||
template, {"transcript": transcript, "previous_summary": previous_summary}
|
||||
)
|
||||
raw = await complete(
|
||||
endpoint,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [{"role": ROLE_USER, "content": prompt}],
|
||||
"max_tokens": 1200,
|
||||
# Low, but not zero: this is recall, not invention.
|
||||
"temperature": 0.3,
|
||||
},
|
||||
)
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def sweep_temporary(db: DBSession, older_than: timedelta = TEMPORARY_LIFETIME) -> int:
|
||||
"""Delete temporary chats nobody has touched for a day.
|
||||
|
||||
Age is measured from the newest message rather than from the chat row's own
|
||||
timestamps. `created_at` would destroy a conversation still in use at hour
|
||||
23, and `updated_at` does not move when a message is inserted -- `onupdate`
|
||||
fires on an UPDATE of the chat, and adding a message is not one.
|
||||
|
||||
Startup only, like files.sweep_orphans beside it. A server that runs for a
|
||||
month sweeps once; that is the trade the existing sweep already makes, and a
|
||||
scheduler is a whole new concern for a single-worker application.
|
||||
"""
|
||||
cutoff = datetime.now(UTC) - older_than
|
||||
newest = (
|
||||
select(Message.chat_id, func.max(Message.created_at).label("last"))
|
||||
.group_by(Message.chat_id)
|
||||
.subquery()
|
||||
)
|
||||
stale = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.outerjoin(newest, newest.c.chat_id == Chat.id)
|
||||
.where(
|
||||
Chat.temporary.is_(True),
|
||||
func.coalesce(newest.c.last, Chat.created_at) < cutoff,
|
||||
)
|
||||
)
|
||||
)
|
||||
if not stale:
|
||||
return 0
|
||||
|
||||
files_service.remove_files_for_chats(db, [chat.id for chat in stale])
|
||||
for chat in stale:
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
log.info("swept %d temporary chat(s)", len(stale))
|
||||
return len(stale)
|
||||
|
||||
|
||||
def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]:
|
||||
query = select(Chat).where(Chat.user_id == user_id, Chat.archived.is_(False))
|
||||
query = select(Chat).where(
|
||||
Chat.user_id == user_id, Chat.archived.is_(False), Chat.temporary.is_(False)
|
||||
)
|
||||
if folder_id is not None:
|
||||
query = query.where(Chat.folder_id == folder_id)
|
||||
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Carrying a long conversation forward without carrying all of it.
|
||||
|
||||
Past a certain length every chat stops working: the window fills, and the only
|
||||
options are to lose the beginning or to start again. Compaction summarises the
|
||||
earlier turns and sends the summary in their place.
|
||||
|
||||
**The messages are kept.** They stay in the transcript, collapsed behind a
|
||||
divider, and simply stop being part of the request. A summary that turned out
|
||||
badly is then a bad turn rather than a lost conversation, which is what makes
|
||||
the button safe to press and automatic compaction safe to have at all.
|
||||
|
||||
**Stored on the Chat, not as a synthetic Message.** A synthetic row would need a
|
||||
role: `system` breaks the one-system-message rule the moment `build_messages`
|
||||
emits it beside the harness, and `user`/`assistant` makes it a turn people can
|
||||
edit, regenerate from and copy, indistinguishable from a real one in all four
|
||||
places a bubble is rendered. Worse, "editing rewinds, it does not branch" would
|
||||
silently delete it and leave no marker that compaction had ever happened.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import ROLE_ASSISTANT, Chat, Message
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import settings_store, tokens
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# What the summariser is shown. Past this the oldest turns are dropped with a
|
||||
# marker: a transcript that does not fit the window it is protecting is no use.
|
||||
MAX_TRANSCRIPT_CHARS = 24_000
|
||||
|
||||
# Settings key, in the GENERAL group. 0 turns automatic compaction off; the
|
||||
# button still works, because a person asking for it does not need a threshold.
|
||||
THRESHOLD_KEY = "compact_threshold"
|
||||
DEFAULT_THRESHOLD = 95
|
||||
|
||||
|
||||
def threshold(db: DBSession) -> int:
|
||||
value = settings_store.get(db, THRESHOLD_KEY)
|
||||
return int(value) if isinstance(value, (int, float)) else DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
def moment(message: Message) -> datetime:
|
||||
"""A message's timestamp, always comparable.
|
||||
|
||||
SQLite does not store the offset, so a row loaded from disk comes back naive
|
||||
while one still in the session's identity map keeps the tzinfo it was
|
||||
created with. Comparing the two raises, and every comparison here is between
|
||||
exactly those: a cutoff fetched by id against history loaded in bulk.
|
||||
`files.sweep_orphans` already normalises for the same reason.
|
||||
"""
|
||||
created = message.created_at
|
||||
return created if created.tzinfo is not None else created.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def cutoff_message(db: DBSession, chat: Chat) -> Message | None:
|
||||
"""The message compaction reached, or None if it never has.
|
||||
|
||||
There is no foreign key to null this out on an upgraded database, so the
|
||||
check is load-bearing rather than defensive: an id pointing at a message
|
||||
that has been deleted means the boundary no longer describes anything, and
|
||||
the chat has to read as uncompacted.
|
||||
"""
|
||||
if not chat.compact_summary or not chat.compacted_through_id:
|
||||
return None
|
||||
message = db.get(Message, chat.compacted_through_id)
|
||||
if message is None or message.chat_id != chat.id:
|
||||
return None
|
||||
return message
|
||||
|
||||
|
||||
def reset(chat: Chat) -> None:
|
||||
"""Forget that this chat was ever compacted."""
|
||||
chat.compact_summary = ""
|
||||
chat.compacted_through_id = None
|
||||
chat.compacted_at = None
|
||||
|
||||
|
||||
def apply(chat: Chat, *, summary: str, upto: Message) -> None:
|
||||
"""Record a summary and move the boundary. Caller commits."""
|
||||
chat.compact_summary = summary.strip()
|
||||
chat.compacted_through_id = upto.id
|
||||
chat.compacted_at = datetime.now(UTC)
|
||||
|
||||
|
||||
def split(
|
||||
db: DBSession, chat: Chat, messages: list[Message]
|
||||
) -> tuple[list[Message], list[Message]]:
|
||||
"""(summarised, live) -- what is behind the divider, and what is not."""
|
||||
cutoff = cutoff_message(db, chat)
|
||||
if cutoff is None:
|
||||
return [], list(messages)
|
||||
boundary = moment(cutoff)
|
||||
return (
|
||||
[m for m in messages if moment(m) <= boundary],
|
||||
[m for m in messages if moment(m) > boundary],
|
||||
)
|
||||
|
||||
|
||||
def last_complete(db: DBSession, chat: Chat) -> Message | None:
|
||||
"""The newest finished assistant turn: where compaction should stop.
|
||||
|
||||
Landing on a reply rather than a question means the kept history starts on a
|
||||
user turn, which is what every chat template expects.
|
||||
"""
|
||||
return db.scalar(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.chat_id == chat.id,
|
||||
Message.role == ROLE_ASSISTANT,
|
||||
Message.complete.is_(True),
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
)
|
||||
|
||||
|
||||
def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
|
||||
"""The turns to summarise, oldest first, as plain text.
|
||||
|
||||
Only the delta since the last compaction: the previous summary is supplied
|
||||
separately, and the instruction asks for one record, so each summary
|
||||
subsumes the one before it. Re-summarising the whole chat every time grows
|
||||
quadratically and eventually exceeds the very window this protects.
|
||||
"""
|
||||
previous = cutoff_message(db, chat)
|
||||
query = select(Message).where(
|
||||
Message.chat_id == chat.id,
|
||||
Message.created_at <= upto.created_at,
|
||||
Message.error == "",
|
||||
)
|
||||
if previous is not None:
|
||||
query = query.where(Message.created_at > previous.created_at)
|
||||
|
||||
lines: list[str] = []
|
||||
for message in db.scalars(query.order_by(Message.created_at)):
|
||||
body = message.content.strip()
|
||||
if not body:
|
||||
continue
|
||||
lines.append(f"{message.role}: {body}")
|
||||
|
||||
text = "\n\n".join(lines)
|
||||
if len(text) > MAX_TRANSCRIPT_CHARS:
|
||||
# Keep the most recent part: the older it is, the more likely the
|
||||
# previous summary already covers it.
|
||||
text = "[earlier turns omitted]\n\n" + text[-MAX_TRANSCRIPT_CHARS:]
|
||||
return text
|
||||
|
||||
|
||||
def previous_summary_block(chat: Chat) -> str:
|
||||
"""The earlier summary, headed, or "" on a first compaction.
|
||||
|
||||
Empty is fine to pass straight through: `prompts.substitute` drops a line
|
||||
that held a known variable and expanded to nothing, so the prompt does not
|
||||
end up with a hole where a heading was.
|
||||
"""
|
||||
if not chat.compact_summary.strip():
|
||||
return ""
|
||||
return "## Summary of even earlier turns\n\n" + chat.compact_summary.strip()
|
||||
|
||||
|
||||
def should_compact(db: DBSession, chat: Chat, *, pending: str = "") -> bool:
|
||||
"""Whether the next request should be summarised first.
|
||||
|
||||
Judged from the last reply's recorded usage plus an estimate of the new
|
||||
turn. True prompt_tokens are only knowable after a response, so a
|
||||
retrospective figure is the honest basis -- but on its own it is one turn
|
||||
stale, and fifty thousand characters pasted into the composer would overflow
|
||||
a window that measured 90% last time. The estimator covers only that delta.
|
||||
|
||||
Never fires when the model's context length is unknown. Acting on a number
|
||||
nobody supplied is exactly what the 0-means-unknown rule exists to prevent.
|
||||
"""
|
||||
limit = threshold(db)
|
||||
if limit <= 0:
|
||||
return False
|
||||
|
||||
last = last_complete(db, chat)
|
||||
if last is None:
|
||||
return False
|
||||
|
||||
usage = metrics_service.from_message(last.usage_json)
|
||||
if usage.context_limit <= 0 or usage.context_tokens <= 0:
|
||||
return False
|
||||
|
||||
projected = usage.context_tokens + tokens.estimate(pending)
|
||||
return projected >= usage.context_limit * limit / 100
|
||||
@@ -435,6 +435,26 @@ def claim(db: DBSession, *, ids: list[str], user_id: str, message_id: str) -> li
|
||||
return pending
|
||||
|
||||
|
||||
def remove_files_for_chats(db: DBSession, chat_ids: list[str]) -> int:
|
||||
"""Unlink the files belonging to these chats' attachments.
|
||||
|
||||
Deleting a Chat cascades to its Message and Attachment *rows* but leaves the
|
||||
files on disk -- only `sweep_orphans` unlinks anything, and it only looks at
|
||||
uploads that were never attached. Anything that deletes chats has to call
|
||||
this first, while the rows still say which files to remove.
|
||||
"""
|
||||
if not chat_ids:
|
||||
return 0
|
||||
|
||||
removed = 0
|
||||
for attachment in db.scalars(select(Attachment).where(Attachment.chat_id.in_(chat_ids))):
|
||||
path = stored_path(attachment.stored_name)
|
||||
if path is not None and path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def sweep_orphans(db: DBSession, older_than: timedelta = ORPHAN_AGE) -> int:
|
||||
"""Delete uploads that were never attached to a message.
|
||||
|
||||
|
||||
@@ -22,13 +22,19 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services import prompts as prompts_service
|
||||
from lembas.services import tokens
|
||||
from lembas.services import tools as tools_service
|
||||
from lembas.services.llm.openai_client import (
|
||||
LLMError,
|
||||
chunk_usage,
|
||||
delta_reasoning,
|
||||
delta_text,
|
||||
delta_tool_calls,
|
||||
@@ -64,6 +70,26 @@ class Generation:
|
||||
# live as the model works and kept on the message afterwards.
|
||||
tool_events: list[dict] = field(default_factory=list)
|
||||
|
||||
# --- What it cost --------------------------------------------------------
|
||||
# Prompt and completion are summed across tool rounds: what the reply cost.
|
||||
# context_tokens is overwritten each round with that round's prompt plus
|
||||
# completion, because a three-round reply pays for its prompt three times
|
||||
# but only ever occupies the window once.
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
context_tokens: int = 0
|
||||
context_limit: int = 0
|
||||
# Filled from the assembled request before the first chunk, so a follower
|
||||
# has a percentage to show while the reply is still being written -- real
|
||||
# usage only arrives in a single chunk at the very end.
|
||||
prompt_estimate: int = 0
|
||||
rounds: int = 0
|
||||
# time.monotonic() at the start. A field rather than a local in `_run`
|
||||
# because `_follow` is a different function that sees only this object, and
|
||||
# without it there is nothing to compute a live tokens/second against.
|
||||
started_at: float = 0.0
|
||||
elapsed_ms: int = 0
|
||||
|
||||
error: str = ""
|
||||
stopped: bool = False
|
||||
done: bool = False
|
||||
@@ -72,6 +98,10 @@ class Generation:
|
||||
# woken individually: with a 100ms cadence a short poll is simpler than
|
||||
# future bookkeeping, and cannot drop a wakeup.
|
||||
version: int = 0
|
||||
# What the reply is doing when it is not producing tokens. Shown in the
|
||||
# streaming bubble, because a silent multi-second pause before the first
|
||||
# token is what a hang looks like.
|
||||
status: str = ""
|
||||
# Number of browsers currently watching. Decides whether a finished reply
|
||||
# counts as unread.
|
||||
followers: int = 0
|
||||
@@ -120,18 +150,48 @@ def ensure(chat_id: str, message_id: str) -> Generation:
|
||||
|
||||
Idempotent, because more than one thing can ask for it: the route that
|
||||
created the message, and any page load that finds the message unfinished.
|
||||
|
||||
`_prune` runs first, not after the lookup. Below it, a stale entry could
|
||||
never expire: the early return is the only path a repeated id takes, so the
|
||||
sweep was unreachable for exactly the message that needed it.
|
||||
"""
|
||||
_prune()
|
||||
existing = _RUNNING.get(message_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
_prune()
|
||||
generation = Generation(chat_id=chat_id, message_id=message_id)
|
||||
_RUNNING[message_id] = generation
|
||||
_TASKS[message_id] = asyncio.create_task(_run(generation))
|
||||
return generation
|
||||
|
||||
|
||||
def restart(chat_id: str, message_id: str) -> Generation:
|
||||
"""Produce this reply again, discarding any finished attempt at it.
|
||||
|
||||
`ensure` is idempotent on purpose, and that is load-bearing: a page load
|
||||
finding an unfinished reply must attach to it rather than start a second
|
||||
one, and `_follow` calls it too. Regeneration is the one caller that means
|
||||
the opposite.
|
||||
|
||||
It is also the one caller that reuses an existing Message row -- blanked and
|
||||
marked incomplete -- rather than creating a new one. The finished Generation
|
||||
for that id is still in the registry, because finished ones linger
|
||||
KEEP_FINISHED so a follower arriving at the last moment still gets the final
|
||||
frames. `ensure` handed that one straight back: no request was made,
|
||||
`_follow` replayed the previous answer, and the `done` frame re-rendered a
|
||||
streaming shell because the row said incomplete. That was the reconnect loop,
|
||||
and the Send button stuck on Stop.
|
||||
"""
|
||||
previous = _RUNNING.pop(message_id, None)
|
||||
task = _TASKS.pop(message_id, None)
|
||||
if previous is not None and not previous.done:
|
||||
previous.cancel = True
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
return ensure(chat_id, message_id)
|
||||
|
||||
|
||||
async def shutdown() -> None:
|
||||
"""Stop every running generation, keeping what each has produced."""
|
||||
for task in list(_TASKS.values()):
|
||||
@@ -153,6 +213,7 @@ async def _run(generation: Generation) -> None:
|
||||
"""
|
||||
splitter = ReasoningSplitter()
|
||||
started = time.monotonic()
|
||||
generation.started_at = started
|
||||
reasoning_started: float | None = None
|
||||
question = ""
|
||||
endpoint = model_id = None
|
||||
@@ -160,6 +221,13 @@ async def _run(generation: Generation) -> None:
|
||||
title_prompt = ""
|
||||
|
||||
try:
|
||||
# Before the request is assembled, so build_request is called once and
|
||||
# what goes out is the compacted conversation -- there is no second
|
||||
# assembly path. Here rather than in post_message because that route's
|
||||
# whole contract is to return immediately, and a three-second
|
||||
# summarisation in front of it would break exactly that.
|
||||
await _maybe_compact(generation)
|
||||
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, generation.chat_id)
|
||||
message = db.get(Message, generation.message_id)
|
||||
@@ -182,13 +250,30 @@ async def _run(generation: Generation) -> None:
|
||||
title_prompt = prompts_service.resolve(db, "task.title")
|
||||
tool_context = tools_service.context_for(db, owner, chat)
|
||||
|
||||
model = chat_service.model_for(db, chat)
|
||||
generation.context_limit = model.context_length if model is not None else 0
|
||||
|
||||
generation.prompt_estimate = tokens.estimate_request(payload)
|
||||
|
||||
for round_number in range(tools_service.MAX_ROUNDS + 1):
|
||||
generation.rounds = round_number + 1
|
||||
accumulator = tools_service.ToolCallAccumulator()
|
||||
# Text the model produced in *this* round, needed separately from
|
||||
# generation.content when echoing the assistant turn back.
|
||||
round_text: list[str] = []
|
||||
|
||||
async for chunk in stream_chat(endpoint, payload):
|
||||
counts = chunk_usage(chunk)
|
||||
if counts is not None:
|
||||
generation.prompt_tokens += counts.get("prompt_tokens", 0)
|
||||
generation.completion_tokens += counts.get("completion_tokens", 0)
|
||||
# Overwritten, not summed: this round's prompt already
|
||||
# contains every earlier round.
|
||||
generation.context_tokens = counts.get("prompt_tokens", 0) + counts.get(
|
||||
"completion_tokens", 0
|
||||
)
|
||||
generation.touch()
|
||||
|
||||
thought = delta_reasoning(chunk)
|
||||
if thought:
|
||||
if reasoning_started is None:
|
||||
@@ -277,6 +362,17 @@ async def _run(generation: Generation) -> None:
|
||||
if reasoning_started is not None and not generation.reasoning_ms:
|
||||
generation.reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
|
||||
|
||||
generation.elapsed_ms = int((time.monotonic() - started) * 1000)
|
||||
if not generation.completion_tokens:
|
||||
# The endpoint reported nothing, so fall back to the estimate. Marked
|
||||
# as such everywhere it is shown -- four characters to a token is
|
||||
# wrong enough on code and CJK to be worth saying out loud.
|
||||
generation.completion_tokens = tokens.estimate(
|
||||
generation.text + generation.thinking
|
||||
)
|
||||
generation.prompt_tokens = generation.prompt_estimate
|
||||
generation.context_tokens = generation.prompt_tokens + generation.completion_tokens
|
||||
|
||||
# Naming the chat is a second, short completion, so it has to happen
|
||||
# here rather than in the synchronous persist step below. Best-effort:
|
||||
# a chat title is never worth surfacing an error for.
|
||||
@@ -295,10 +391,85 @@ async def _run(generation: Generation) -> None:
|
||||
)
|
||||
title = title or chat_service.fallback_title(question)
|
||||
|
||||
# Written *before* `done`, because `_follow` breaks out of its loop the
|
||||
# moment it sees that flag and immediately re-renders the bubble from
|
||||
# the row. The other order left a window in which the finished frame
|
||||
# showed the previous turn's stored values.
|
||||
_persist(generation, title, time.monotonic() - started)
|
||||
generation.done = True
|
||||
generation.finished_at = datetime.now(UTC)
|
||||
generation.touch()
|
||||
_persist(generation, title, time.monotonic() - started)
|
||||
|
||||
|
||||
async def _maybe_compact(generation: Generation) -> None:
|
||||
"""Summarise the earlier turns if the window is about to be full.
|
||||
|
||||
Never raises. A failed compaction logs and sends the uncompacted request,
|
||||
which either works or fails upstream with a message that says what actually
|
||||
happened -- refusing to answer because the summariser was unavailable would
|
||||
be a worse trade.
|
||||
|
||||
The awaited call is deliberately outside any session, the same shape titling
|
||||
uses: read everything needed, close, ask, reopen to write.
|
||||
"""
|
||||
try:
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, generation.chat_id)
|
||||
message = db.get(Message, generation.message_id)
|
||||
if chat is None or message is None:
|
||||
return
|
||||
|
||||
pending = _pending_text(db, message)
|
||||
if not compaction_service.should_compact(db, chat, pending=pending):
|
||||
return
|
||||
|
||||
template = prompts_service.resolve(db, "task.compact")
|
||||
upto = compaction_service.last_complete(db, chat)
|
||||
if not template.strip() or upto is None:
|
||||
return
|
||||
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
transcript = compaction_service.transcript(db, chat, upto=upto)
|
||||
previous = compaction_service.previous_summary_block(chat)
|
||||
upto_id = upto.id
|
||||
|
||||
generation.status = "Summarising earlier messages…"
|
||||
generation.touch()
|
||||
|
||||
summary = await chat_service.summarise_for_compaction(
|
||||
endpoint,
|
||||
model_id,
|
||||
transcript=transcript,
|
||||
previous_summary=previous,
|
||||
template=template,
|
||||
)
|
||||
if not summary:
|
||||
return
|
||||
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, generation.chat_id)
|
||||
upto = db.get(Message, upto_id)
|
||||
if chat is None or upto is None:
|
||||
return
|
||||
compaction_service.apply(chat, summary=summary, upto=upto)
|
||||
db.commit()
|
||||
log.info("chat %s compacted automatically through %s", chat.id, upto_id)
|
||||
except Exception: # noqa: BLE001 - the reply matters more than the tidy-up
|
||||
log.exception("automatic compaction failed for chat %s", generation.chat_id)
|
||||
finally:
|
||||
generation.status = ""
|
||||
generation.touch()
|
||||
|
||||
|
||||
def _pending_text(db, message: Message) -> str:
|
||||
"""The user turn this reply is answering, for the size estimate."""
|
||||
previous = db.scalars(
|
||||
select(Message)
|
||||
.where(Message.chat_id == message.chat_id, Message.created_at < message.created_at)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
return previous.content if previous is not None else ""
|
||||
|
||||
|
||||
def _question_from(payload: dict) -> str:
|
||||
@@ -319,7 +490,21 @@ def _question_from(payload: dict) -> str:
|
||||
|
||||
|
||||
def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
"""Write the finished reply, name the chat, and set the unread flag."""
|
||||
"""Write the finished reply, name the chat, and set the unread flag.
|
||||
|
||||
A generation another one has replaced may not write. A regeneration cancels
|
||||
its predecessor, whose `finally:` then runs this on the same row -- and it
|
||||
would overwrite the fresh reply with the abandoned one.
|
||||
|
||||
The test is "someone else owns this row now", not "this one is registered":
|
||||
an unregistered generation still writes, because that is a direct call
|
||||
rather than a superseded one.
|
||||
"""
|
||||
owner = _RUNNING.get(generation.message_id)
|
||||
if owner is not None and owner is not generation:
|
||||
log.debug("skipping persist for superseded generation %s", generation.message_id)
|
||||
return
|
||||
|
||||
try:
|
||||
with session_scope() as db:
|
||||
message = db.get(Message, generation.message_id)
|
||||
@@ -331,6 +516,9 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
message.reasoning = generation.thinking
|
||||
message.reasoning_ms = generation.reasoning_ms
|
||||
message.tool_calls_json = generation.tool_events
|
||||
message.usage_json = metrics_service.to_json(
|
||||
metrics_service.from_generation(generation)
|
||||
)
|
||||
message.error = generation.error
|
||||
message.stopped = generation.stopped
|
||||
message.complete = True
|
||||
@@ -340,8 +528,10 @@ def _persist(generation: Generation, title: str, elapsed: float) -> None:
|
||||
chat.title_generated = True
|
||||
|
||||
# Nobody watching when it landed, so it is news. The chat page
|
||||
# clears this when it is next opened.
|
||||
if generation.followers == 0:
|
||||
# clears this when it is next opened. Not for a temporary chat:
|
||||
# there is no sidebar row for the dot, and the toast would name a
|
||||
# chat nobody can navigate to.
|
||||
if generation.followers == 0 and not chat.temporary:
|
||||
chat.unread = True
|
||||
chat.unread_notified = False
|
||||
|
||||
@@ -364,5 +554,6 @@ __all__ = [
|
||||
"ensure",
|
||||
"get",
|
||||
"request_stop",
|
||||
"restart",
|
||||
"shutdown",
|
||||
]
|
||||
|
||||
@@ -155,6 +155,47 @@ async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
|
||||
return models
|
||||
|
||||
|
||||
# Where the runners that bother to advertise a context length put it. There is
|
||||
# no standard field, so this is a list of what the common ones actually emit.
|
||||
_CONTEXT_KEYS = ("context_length", "max_model_len", "context_window", "max_context_length")
|
||||
|
||||
# Below the first, the number is not a context length; above the second it is a
|
||||
# typo or a different unit. Either way, better to record nothing than a wrong
|
||||
# figure a percentage would then be computed from.
|
||||
MIN_CONTEXT = 256
|
||||
MAX_CONTEXT = 10_000_000
|
||||
|
||||
|
||||
def context_from(entry: dict[str, Any]) -> int:
|
||||
"""A model's context length as advertised by /v1/models, or 0 if it is not.
|
||||
|
||||
Strings are accepted because some servers quote the number, but only when
|
||||
they are digits alone -- "8192 tokens" is a label, not a measurement.
|
||||
"""
|
||||
candidates = [entry.get(key) for key in _CONTEXT_KEYS]
|
||||
meta = entry.get("meta")
|
||||
if isinstance(meta, dict):
|
||||
candidates += [meta.get("n_ctx"), *(meta.get(key) for key in _CONTEXT_KEYS)]
|
||||
|
||||
for value in candidates:
|
||||
if isinstance(value, bool):
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value.isdigit():
|
||||
continue
|
||||
value = int(value)
|
||||
if isinstance(value, int) and MIN_CONTEXT <= value <= MAX_CONTEXT:
|
||||
return value
|
||||
return 0
|
||||
|
||||
|
||||
# Endpoints that rejected `stream_options`, so it is asked for once per base URL
|
||||
# per process and then never again. Not persisted: it is a property of whatever
|
||||
# is running there now, and a restart is the right time to find out afresh.
|
||||
_NO_STREAM_OPTIONS: set[str] = set()
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
endpoint: Endpoint,
|
||||
payload: dict[str, Any],
|
||||
@@ -163,8 +204,41 @@ async def stream_chat(
|
||||
|
||||
Yields the raw upstream chunks; interpreting them is the caller's job. The
|
||||
terminating "[DONE]" sentinel is consumed here and not yielded.
|
||||
|
||||
`stream_options` asks for the final usage chunk, which is the only way to
|
||||
learn what a streamed reply actually cost. Not every server implements it,
|
||||
and an unknown key is a 400 from some of them -- the same hazard as sending
|
||||
a `tools` array to an endpoint without support. So it is asked for once,
|
||||
and an endpoint that refuses is remembered and never asked again. Retrying
|
||||
is safe because the status is checked before a single line is read: nothing
|
||||
has been yielded, so there is nothing to duplicate.
|
||||
"""
|
||||
wants_usage = endpoint.base_url not in _NO_STREAM_OPTIONS
|
||||
|
||||
try:
|
||||
async for chunk in _stream_once(endpoint, payload, usage=wants_usage):
|
||||
yield chunk
|
||||
except LLMError as exc:
|
||||
if not wants_usage or exc.status_code not in (400, 422):
|
||||
raise
|
||||
_NO_STREAM_OPTIONS.add(endpoint.base_url)
|
||||
log.info(
|
||||
"%s rejected stream_options; token counts will be estimated there",
|
||||
endpoint.base_url,
|
||||
)
|
||||
async for chunk in _stream_once(endpoint, payload, usage=False):
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _stream_once(
|
||||
endpoint: Endpoint,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
usage: bool,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
body = {**payload, "stream": True}
|
||||
if usage:
|
||||
body["stream_options"] = {"include_usage": True}
|
||||
|
||||
try:
|
||||
async with (
|
||||
@@ -283,6 +357,43 @@ def finish_reason(chunk: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def chunk_usage(chunk: dict[str, Any]) -> dict[str, int] | None:
|
||||
"""Token counts from a usage chunk, or None if this is not one.
|
||||
|
||||
A usage chunk carries `choices: []`, which is exactly the shape delta_text,
|
||||
delta_reasoning, delta_tool_calls and finish_reason all return early on --
|
||||
they have always tolerated it, so nothing else needs to change to let one
|
||||
through.
|
||||
|
||||
Fields are read defensively because "the endpoint returned something odd"
|
||||
must never be the reason a reply fails; a bad shape simply means no counts.
|
||||
"""
|
||||
try:
|
||||
raw = chunk.get("usage")
|
||||
except AttributeError:
|
||||
return None
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
|
||||
value = raw.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
continue
|
||||
if value >= 0:
|
||||
counts[key] = int(value)
|
||||
|
||||
# Some servers send a usage object of zeros on every chunk and the real
|
||||
# numbers only at the end. All-zero is indistinguishable from that, and
|
||||
# treating it as an answer would freeze the count at nothing.
|
||||
if not counts or not any(counts.values()):
|
||||
return None
|
||||
counts.setdefault(
|
||||
"total_tokens", counts.get("prompt_tokens", 0) + counts.get("completion_tokens", 0)
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def delta_text(chunk: dict[str, Any]) -> str:
|
||||
"""Pull the text out of one streamed chunk, tolerating provider variation."""
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""What a reply cost, how fast it arrived, and how full the window is.
|
||||
|
||||
One shape, built either from a generation still being written or from the row
|
||||
it left behind. That matters more than it looks: the finished bubble is
|
||||
re-rendered from the database the instant the stream ends, so if the live
|
||||
numbers and the stored ones came from different code they would visibly jump at
|
||||
exactly the moment the reader is looking at them. Here the only thing that
|
||||
changes when a reply finishes is that an estimate may become exact.
|
||||
|
||||
Nothing here is authoritative about tokens. `estimated` says which kind of
|
||||
number this is, and every surface that shows one has to say so too.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from lembas.services import tokens
|
||||
|
||||
# Where the context bar changes colour. Not thresholds anyone tunes: they mark
|
||||
# "worth noticing" and "about to be a problem", and the second is deliberately
|
||||
# below the default compaction threshold so the warning arrives first.
|
||||
WARNING_AT = 80
|
||||
DANGER_AT = 95
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Metrics:
|
||||
"""Token counts and timing for one reply."""
|
||||
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
# What the window holds after this turn: the last round's prompt plus its
|
||||
# completion. Distinct from prompt+completion summed over tool rounds, which
|
||||
# is what the reply *cost* -- a three-round reply pays for its prompt three
|
||||
# times but only ever occupies the window once.
|
||||
context_tokens: int = 0
|
||||
context_limit: int = 0
|
||||
estimated: bool = False
|
||||
elapsed_ms: int = 0
|
||||
rounds: int = 1
|
||||
|
||||
@property
|
||||
def percent(self) -> int:
|
||||
"""How full the window is, or 0 when nobody has said how big it is."""
|
||||
if self.context_limit <= 0 or self.context_tokens <= 0:
|
||||
return 0
|
||||
return min(100, round(self.context_tokens * 100 / self.context_limit))
|
||||
|
||||
@property
|
||||
def tokens_per_second(self) -> float:
|
||||
if self.elapsed_ms <= 0 or self.completion_tokens <= 0:
|
||||
return 0.0
|
||||
return self.completion_tokens / (self.elapsed_ms / 1000)
|
||||
|
||||
@property
|
||||
def pressure(self) -> str:
|
||||
""""", "warning" or "danger" -- the class the context chip takes."""
|
||||
percent = self.percent
|
||||
if not percent:
|
||||
return ""
|
||||
if percent >= DANGER_AT:
|
||||
return "danger"
|
||||
if percent >= WARNING_AT:
|
||||
return "warning"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def has_anything(self) -> bool:
|
||||
return bool(self.total_tokens or self.completion_tokens or self.elapsed_ms)
|
||||
|
||||
|
||||
def from_generation(generation: Any) -> Metrics:
|
||||
"""Metrics for a reply still being written.
|
||||
|
||||
Usage arrives in a single chunk at the very end, so mid-stream there is
|
||||
nothing to report and everything is estimated. The counts stop being
|
||||
estimates the moment that chunk lands, which is usually a beat before the
|
||||
bubble is replaced.
|
||||
"""
|
||||
import time
|
||||
|
||||
completion = generation.completion_tokens or tokens.estimate(
|
||||
generation.text + generation.thinking
|
||||
)
|
||||
prompt = generation.prompt_tokens or generation.prompt_estimate
|
||||
elapsed = generation.elapsed_ms or (
|
||||
int((time.monotonic() - generation.started_at) * 1000) if generation.started_at else 0
|
||||
)
|
||||
|
||||
return Metrics(
|
||||
prompt_tokens=prompt,
|
||||
completion_tokens=completion,
|
||||
total_tokens=prompt + completion,
|
||||
context_tokens=generation.context_tokens or (prompt + completion),
|
||||
context_limit=generation.context_limit,
|
||||
estimated=not (generation.prompt_tokens and generation.completion_tokens),
|
||||
elapsed_ms=elapsed,
|
||||
rounds=max(1, generation.rounds),
|
||||
)
|
||||
|
||||
|
||||
def from_message(usage_json: dict[str, Any] | None) -> Metrics:
|
||||
"""Metrics for a finished reply, read back off the row."""
|
||||
stored = usage_json or {}
|
||||
|
||||
def _int(key: str) -> int:
|
||||
value = stored.get(key)
|
||||
return int(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0
|
||||
|
||||
return Metrics(
|
||||
prompt_tokens=_int("prompt_tokens"),
|
||||
completion_tokens=_int("completion_tokens"),
|
||||
total_tokens=_int("total_tokens"),
|
||||
context_tokens=_int("context_tokens"),
|
||||
context_limit=_int("context_limit"),
|
||||
estimated=bool(stored.get("estimated")),
|
||||
elapsed_ms=_int("elapsed_ms"),
|
||||
rounds=max(1, _int("rounds")),
|
||||
)
|
||||
|
||||
|
||||
def to_json(metrics: Metrics) -> dict[str, Any]:
|
||||
"""The shape stored in Message.usage_json."""
|
||||
return {
|
||||
"prompt_tokens": metrics.prompt_tokens,
|
||||
"completion_tokens": metrics.completion_tokens,
|
||||
"total_tokens": metrics.total_tokens,
|
||||
"context_tokens": metrics.context_tokens,
|
||||
"context_limit": metrics.context_limit,
|
||||
"estimated": metrics.estimated,
|
||||
"elapsed_ms": metrics.elapsed_ms,
|
||||
"rounds": metrics.rounds,
|
||||
}
|
||||
@@ -156,6 +156,16 @@ VARIABLES: tuple[Variable, ...] = (
|
||||
),
|
||||
Variable("question", "Question", "The first message. Chat title task only."),
|
||||
Variable("answer", "Answer", "The first reply. Chat title task only."),
|
||||
Variable(
|
||||
"transcript",
|
||||
"Transcript",
|
||||
"The turns being summarised, oldest first. Compaction task only.",
|
||||
),
|
||||
Variable(
|
||||
"previous_summary",
|
||||
"Earlier summary",
|
||||
"The summary from a previous compaction, if there was one. Compaction task only.",
|
||||
),
|
||||
)
|
||||
|
||||
VARIABLE_NAMES = frozenset(variable.name for variable in VARIABLES)
|
||||
@@ -764,6 +774,77 @@ BUILTIN: tuple[Fragment, ...] = (
|
||||
"Assistant: {{answer}}"
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.compact",
|
||||
label="Compaction summary",
|
||||
group=GROUP_TASKS,
|
||||
order=410,
|
||||
variables=("transcript", "previous_summary"),
|
||||
hint="A separate one-message request, not part of any chat. Clear it to "
|
||||
"turn compaction off entirely: the button says so and nothing is "
|
||||
"summarised automatically.",
|
||||
default=(
|
||||
"Summarise the conversation below so it can be carried forward after the "
|
||||
"earlier turns are dropped from your context. This is a working record, "
|
||||
"not a report for a reader.\n"
|
||||
"\n"
|
||||
"Keep, under these headings and in this order:\n"
|
||||
"\n"
|
||||
"## What we are doing\n"
|
||||
"The goal, and where we have got to.\n"
|
||||
"\n"
|
||||
"## Decisions\n"
|
||||
"Anything settled, and why. A decision without its reason gets argued "
|
||||
"again.\n"
|
||||
"\n"
|
||||
"## Facts established\n"
|
||||
"Names, numbers, versions, file paths, URLs and identifiers, copied "
|
||||
"exactly. Do not round them, paraphrase them or reconstruct one from "
|
||||
"memory — if it is not in the transcript, leave it out.\n"
|
||||
"\n"
|
||||
"## Open threads\n"
|
||||
"What is unfinished, and what was about to happen next.\n"
|
||||
"\n"
|
||||
"Leave out pleasantries, retracted ideas and anything already superseded. "
|
||||
"Do not answer the conversation: you are recording it. Write in the "
|
||||
"language of the conversation, and stay under 500 words.\n"
|
||||
"\n"
|
||||
"{{previous_summary}}\n"
|
||||
"\n"
|
||||
"## Transcript\n"
|
||||
"\n"
|
||||
"{{transcript}}"
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.compact_lead",
|
||||
label="How a summary is introduced",
|
||||
group=GROUP_TASKS,
|
||||
order=420,
|
||||
hint="Sits in front of the summary, in the turn that replaces the "
|
||||
"messages no longer being sent. Without it a model reads the summary as "
|
||||
"something the person has just typed.",
|
||||
default=(
|
||||
"Here is a summary of the earlier part of this conversation. Those "
|
||||
"messages are no longer in your context. Treat this summary as an "
|
||||
"accurate record of them and rely on it rather than on what you can no "
|
||||
"longer see; if it does not cover something you need, say so instead of "
|
||||
"filling the gap."
|
||||
),
|
||||
),
|
||||
Fragment(
|
||||
key="task.compact_ack",
|
||||
label="The model's acknowledgement",
|
||||
group=GROUP_TASKS,
|
||||
order=430,
|
||||
hint="One assistant turn after the summary, so the conversation still "
|
||||
"alternates user, assistant, user. Several chat templates reject a "
|
||||
"history that does not.",
|
||||
default=(
|
||||
"Understood. I have the summary of the earlier turns and will carry on "
|
||||
"from there."
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
register_source(_builtin_source)
|
||||
|
||||
@@ -35,6 +35,12 @@ def _general_defaults() -> dict[str, Any]:
|
||||
# Applied to every chat that has no model or chat prompt of its
|
||||
# own. See services.chat.effective_system_prompt.
|
||||
"system_prompt": "",
|
||||
# Percentage of a model's context length at which the earlier turns are
|
||||
# summarised automatically. 0 turns it off; the Compact button still
|
||||
# works, because a person asking for it does not need a threshold.
|
||||
# Never fires for a model whose context_length is 0, since that is
|
||||
# "unknown" rather than "small". See services/compaction.py.
|
||||
"compact_threshold": 95,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""The cards offered on the new-chat screen.
|
||||
|
||||
A blank composer is the least helpful thing a chat client can show someone who
|
||||
has just installed one. These are three starting points an administrator can
|
||||
replace with their own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import Suggestion
|
||||
from lembas.services import settings_store
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# A short list stays a short list. Past a dozen it is a menu, and a menu on the
|
||||
# empty screen is a worse blank page than a blank page.
|
||||
MAX_SUGGESTIONS = 12
|
||||
MAX_SHOWN = 6
|
||||
|
||||
MAX_NAME = 120
|
||||
MAX_DESCRIPTION = 300
|
||||
MAX_PROMPT = 4000
|
||||
|
||||
# Each ends mid-sentence, so the caret lands exactly where the person has to
|
||||
# start typing. No Middle-earth flavour: this is functional UI.
|
||||
DEFAULTS: tuple[tuple[str, str, str], ...] = (
|
||||
(
|
||||
"Explain this",
|
||||
"Paste something confusing and get it back in plain language.",
|
||||
"Explain the following in plain language. Start with one sentence "
|
||||
"summarising it, then the details that actually matter, then anything I "
|
||||
"should watch out for. If I have not pasted anything yet, ask me for it "
|
||||
"rather than guessing.\n\n",
|
||||
),
|
||||
(
|
||||
"Draft a reply",
|
||||
"Turn a message you have received into an answer you can send.",
|
||||
"Help me reply to the message below. If the tone I want and the outcome "
|
||||
"I am after are not obvious from it, ask me before writing. Then give me "
|
||||
"a draft I could send as it stands.\n\n",
|
||||
),
|
||||
(
|
||||
"Find the flaw",
|
||||
"Have a plan argued with before you commit to it.",
|
||||
"I am going to describe a plan. Argue against it: what is most likely to "
|
||||
"go wrong, what am I assuming without evidence, and what would change "
|
||||
"your mind. Do not soften it, and do not agree just because I sound "
|
||||
"confident.\n\nMy plan: ",
|
||||
),
|
||||
)
|
||||
|
||||
# Guards the seed. Not "is the table empty", because an administrator who
|
||||
# deletes all three would get them back on every restart.
|
||||
SEEDED_KEY = "suggestions_seeded"
|
||||
|
||||
|
||||
def visible(db: DBSession) -> list[Suggestion]:
|
||||
"""What the new-chat screen shows, in order."""
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Suggestion)
|
||||
.where(Suggestion.enabled.is_(True))
|
||||
.order_by(Suggestion.position, Suggestion.name)
|
||||
.limit(MAX_SHOWN)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def all_of_them(db: DBSession) -> list[Suggestion]:
|
||||
"""Every suggestion, enabled or not, for the admin page."""
|
||||
return list(
|
||||
db.scalars(select(Suggestion).order_by(Suggestion.position, Suggestion.name))
|
||||
)
|
||||
|
||||
|
||||
def next_position(db: DBSession) -> int:
|
||||
"""New rows land at the end rather than at 0, where they would shuffle.
|
||||
|
||||
No `or -1` after the coalesce: position 0 is falsy, so that idiom sends the
|
||||
second row back to 0 on top of the first.
|
||||
"""
|
||||
highest = db.scalar(select(func.coalesce(func.max(Suggestion.position), -1)))
|
||||
return int(highest if highest is not None else -1) + 1
|
||||
|
||||
|
||||
def create(db: DBSession, *, name: str, description: str = "", prompt: str = "") -> Suggestion:
|
||||
suggestion = Suggestion(
|
||||
name=name.strip()[:MAX_NAME],
|
||||
description=description.strip()[:MAX_DESCRIPTION],
|
||||
prompt=prompt[:MAX_PROMPT],
|
||||
position=next_position(db),
|
||||
)
|
||||
db.add(suggestion)
|
||||
db.commit()
|
||||
return suggestion
|
||||
|
||||
|
||||
def seed_defaults(db: DBSession) -> int:
|
||||
"""Write the built-in suggestions, once ever.
|
||||
|
||||
Runs from the startup housekeeping block. The flag is what makes it once:
|
||||
checking whether the table is empty would restore all three every restart
|
||||
for anyone who decided they did not want them.
|
||||
"""
|
||||
if settings_store.get(db, SEEDED_KEY):
|
||||
return 0
|
||||
|
||||
for name, description, prompt in DEFAULTS:
|
||||
create(db, name=name, description=description, prompt=prompt)
|
||||
|
||||
settings_store.update(db, {SEEDED_KEY: True})
|
||||
log.info("seeded %d default suggestion(s)", len(DEFAULTS))
|
||||
return len(DEFAULTS)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""A rough token count, for when the endpoint does not give a real one.
|
||||
|
||||
Deliberately crude. Counting tokens properly means the model's own tokeniser,
|
||||
which means shipping one per model family and a dependency that has to be kept
|
||||
in step with them -- for a number that is displayed beside a `~` and used to
|
||||
decide when to summarise.
|
||||
|
||||
Four characters per token is the usual English approximation. It is optimistic
|
||||
on code and badly wrong on CJK, which is why anything derived from it is marked
|
||||
as an estimate everywhere it is shown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
CHARS_PER_TOKEN = 4
|
||||
|
||||
# What a message costs beyond its text: the role, the delimiters a chat template
|
||||
# wraps each turn in. Small, but a hundred-turn conversation is a hundred of them.
|
||||
PER_MESSAGE_OVERHEAD = 4
|
||||
|
||||
|
||||
def estimate(text: str) -> int:
|
||||
"""Roughly how many tokens a piece of text is."""
|
||||
if not text:
|
||||
return 0
|
||||
return max(1, round(len(text) / CHARS_PER_TOKEN))
|
||||
|
||||
|
||||
def estimate_content(content: Any) -> int:
|
||||
"""A message's content, whether it is a plain string or typed parts.
|
||||
|
||||
An image part contributes nothing: its cost depends on the model's tiling,
|
||||
and a number invented here would be worse than the omission.
|
||||
"""
|
||||
if isinstance(content, str):
|
||||
return estimate(content)
|
||||
if isinstance(content, list):
|
||||
return sum(
|
||||
estimate(part.get("text", ""))
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def estimate_messages(messages: list[dict[str, Any]]) -> int:
|
||||
total = 0
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
total += PER_MESSAGE_OVERHEAD + estimate_content(message.get("content"))
|
||||
# A tool result the model reads back is part of the window too.
|
||||
for call in message.get("tool_calls") or []:
|
||||
function = (call or {}).get("function") or {}
|
||||
total += estimate(str(function.get("name", "")))
|
||||
total += estimate(str(function.get("arguments", "")))
|
||||
return total
|
||||
|
||||
|
||||
def estimate_request(payload: dict[str, Any]) -> int:
|
||||
"""What a whole request body costs, tools included.
|
||||
|
||||
The tools array is sent on every request when tools are offered and is not
|
||||
small -- thirteen schemas is a meaningful slice of a short window, and
|
||||
leaving it out would make the percentage read low exactly when it matters.
|
||||
"""
|
||||
total = estimate_messages(payload.get("messages") or [])
|
||||
for tool in payload.get("tools") or []:
|
||||
function = (tool or {}).get("function") or {}
|
||||
total += estimate(str(function.get("name", "")))
|
||||
total += estimate(str(function.get("description", "")))
|
||||
total += estimate(str(function.get("parameters", "")))
|
||||
return total
|
||||
@@ -345,6 +345,7 @@ button, input, textarea, select {
|
||||
.badge--leaf { background: var(--leaf-soft); color: var(--leaf); }
|
||||
.badge--success { background: var(--success-soft); color: var(--success); }
|
||||
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.badge--warning { background: var(--warning-soft); color: var(--warning); }
|
||||
|
||||
/* --- Application shell ----------------------------------------------------- */
|
||||
.shell { display: flex; height: 100dvh; overflow: hidden; }
|
||||
@@ -419,6 +420,96 @@ button, input, textarea, select {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* The inspector, mirroring the sidebar on the other side of .shell. Hidden by
|
||||
the `hidden` attribute, which the rule at the top of this file forces to win. */
|
||||
.inspector {
|
||||
width: var(--inspector-width);
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--bg-sunken);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.inspector__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
height: var(--header-height);
|
||||
flex: none;
|
||||
padding: 0 var(--sp-3);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.inspector__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.inspector__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--sp-4);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.inspector__heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--sp-5) 0 var(--sp-2);
|
||||
}
|
||||
.inspector__note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
line-height: var(--leading-normal);
|
||||
margin: 0 0 var(--sp-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.inspector__facts {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--sp-1) var(--sp-3);
|
||||
font-size: var(--text-xs);
|
||||
margin: 0;
|
||||
}
|
||||
.inspector__facts dt { color: var(--ink-faint); }
|
||||
.inspector__facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
|
||||
.inspector__json {
|
||||
margin: 0 0 var(--sp-3);
|
||||
padding: var(--sp-2);
|
||||
max-height: 22rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 64rem) {
|
||||
.inspector {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
z-index: 40;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -626,7 +717,9 @@ button, input, textarea, select {
|
||||
z-index: 40;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.sidebar[data-collapsed="true"] { display: none; }
|
||||
/* Hiding it is the `hidden` attribute, forced to win at the top of this
|
||||
file. There used to be a `[data-collapsed="true"]` rule here that nothing
|
||||
ever set. */
|
||||
}
|
||||
|
||||
/* --- Toasts ----------------------------------------------------------------
|
||||
|
||||
@@ -167,6 +167,119 @@
|
||||
.reasoning__summary::-webkit-details-marker { display: none; }
|
||||
.reasoning__summary:hover { color: var(--ink); background: var(--surface-hover); }
|
||||
|
||||
/* --- Compacted turns -------------------------------------------------------
|
||||
Summarised messages, kept and readable but out of the way. Collapsed by
|
||||
default: the point of compacting was that they stopped mattering.
|
||||
*/
|
||||
.compacted {
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: color-mix(in srgb, var(--surface) 50%, transparent);
|
||||
}
|
||||
.compacted__summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
cursor: pointer;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.compacted__summary::-webkit-details-marker { display: none; }
|
||||
.compacted__summary:hover { color: var(--ink); }
|
||||
.compacted[open] .reasoning__chevron { transform: rotate(180deg); }
|
||||
|
||||
.compacted__note {
|
||||
margin: 0;
|
||||
padding: 0 var(--sp-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
.compacted__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
padding: var(--sp-4) var(--sp-3);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* --- Suggestions -----------------------------------------------------------
|
||||
Starting points on the empty screen. Cards rather than a list, because they
|
||||
are things to press.
|
||||
*/
|
||||
.suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: var(--sp-3);
|
||||
width: 100%;
|
||||
max-width: 40rem;
|
||||
margin-top: var(--sp-6);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-1);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
.suggestion:hover { background: var(--surface-hover); border-color: var(--border-strong); }
|
||||
.suggestion:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.suggestion__name { font-weight: 600; font-size: var(--text-sm); }
|
||||
.suggestion__note {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-muted);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
/* --- Metrics ---------------------------------------------------------------
|
||||
What a reply cost, under the bubble. Quiet by default: it is reference, not
|
||||
something to read every time.
|
||||
*/
|
||||
.msg__metrics {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.msg__metrics:empty { display: none; }
|
||||
|
||||
.metric { display: inline-flex; align-items: center; gap: var(--sp-1); cursor: default; }
|
||||
|
||||
.metric__bar {
|
||||
display: inline-block;
|
||||
width: 3rem;
|
||||
height: 0.3rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-active);
|
||||
overflow: hidden;
|
||||
}
|
||||
.metric__fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--ink-faint);
|
||||
transition: width var(--transition);
|
||||
}
|
||||
.metric--context.is-warning { color: var(--warning); }
|
||||
.metric--context.is-warning .metric__fill { background: var(--warning); }
|
||||
.metric--context.is-danger { color: var(--danger); }
|
||||
.metric--context.is-danger .metric__fill { background: var(--danger); }
|
||||
|
||||
.reasoning__icon { color: var(--leaf); flex: none; }
|
||||
.reasoning__label { flex: 1; font-style: italic; }
|
||||
|
||||
@@ -259,6 +372,9 @@
|
||||
}
|
||||
|
||||
/* --- Stop, notes and editing ---------------------------------------------- */
|
||||
.msg__status { font-size: var(--text-xs); color: var(--ink-faint); font-style: italic; }
|
||||
.msg__status:empty { display: none; }
|
||||
|
||||
.msg__waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
/* --- Layout ----------------------------------------------------------- */
|
||||
--sidebar-width: 17.5rem;
|
||||
--inspector-width: 24rem;
|
||||
--thread-max-width: 48rem;
|
||||
--header-height: 3.5rem;
|
||||
|
||||
|
||||
@@ -384,6 +384,21 @@
|
||||
return;
|
||||
}
|
||||
|
||||
/* A suggestion card fills the composer and stops there. It deliberately
|
||||
does not submit: the prompts end mid-sentence, because a card is a
|
||||
starting point rather than a question somebody already asked. */
|
||||
var suggestion = event.target.closest("[data-suggestion]");
|
||||
if (suggestion) {
|
||||
event.preventDefault();
|
||||
var input = document.querySelector("[data-composer-input]");
|
||||
if (!input) return;
|
||||
input.value = suggestion.dataset.suggestion;
|
||||
autosize(input);
|
||||
input.focus();
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Show/hide a panel by selector, so templates do not each carry their own
|
||||
inline toggle script. */
|
||||
var toggle = event.target.closest("[data-toggle]");
|
||||
|
||||
@@ -47,6 +47,11 @@
|
||||
{{ icon("sparkle", "icon--sm") }}
|
||||
<span class="nav-item__label">Prompts</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'suggestions' }}"
|
||||
href="/admin/suggestions">
|
||||
{{ icon("star", "icon--sm") }}
|
||||
<span class="nav-item__label">Suggestions</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>
|
||||
|
||||
@@ -41,6 +41,29 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Compaction</h2>
|
||||
<p class="card__lede">
|
||||
A long conversation eventually fills the model's context. When it gets
|
||||
close, the earlier turns are summarised and the summary is sent in their
|
||||
place. The messages themselves are kept and stay readable in the
|
||||
transcript — they simply stop being sent.
|
||||
</p>
|
||||
<div class="field">
|
||||
<label class="field__label" for="compact-threshold">Compact at</label>
|
||||
<input class="input" id="compact-threshold" name="compact_threshold" type="number"
|
||||
min="0" max="99" value="{{ values.compact_threshold }}">
|
||||
<p class="field__hint">
|
||||
Percent of the model's context length. <code>0</code> turns automatic
|
||||
compaction off; the button in each chat still works. Nothing happens for
|
||||
a model whose context length is unset under
|
||||
<a href="/admin/models">Models</a> — that is "unknown", not "small", and
|
||||
this will not act on a number nobody supplied. The wording of the
|
||||
summary is under <a href="/admin/prompts">Prompts</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
Registration
|
||||
|
||||
@@ -92,6 +92,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="context-length">Context length</label>
|
||||
<input class="input" id="context-length" name="context_length" type="number"
|
||||
min="0" step="1" placeholder="unknown"
|
||||
value="{{ model.context_length or '' }}">
|
||||
<p class="field__hint">
|
||||
How many tokens this model can hold, filled in from the endpoint where
|
||||
it says. Leave it empty if you do not know: the context percentage and
|
||||
automatic compaction both stay off rather than working from a guess.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description">Description</label>
|
||||
<textarea class="textarea" id="description" name="description" rows="2"
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "suggestions" %}
|
||||
|
||||
{% block title %}Suggestions - LLeMbas{% endblock %}
|
||||
{% block heading %}Suggestions{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Cards on the new-chat screen. Clicking one puts its prompt in the composer
|
||||
without sending it — the built-in ones deliberately end mid-sentence, so the
|
||||
caret lands where the person has to start typing. The first
|
||||
{{ max_shown }} enabled ones are shown, in this order.
|
||||
</p>
|
||||
|
||||
{% if saved %}
|
||||
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
{% for suggestion in suggestions %}
|
||||
<form class="card" method="post" action="/admin/suggestions/{{ suggestion.id }}">
|
||||
<div class="card__header">
|
||||
<h2 class="card__title">
|
||||
{{ suggestion.name }}
|
||||
{% if not suggestion.enabled %}<span class="badge">hidden</span>{% endif %}
|
||||
{% if loop.index > max_shown and suggestion.enabled %}
|
||||
<span class="badge badge--warning" title="Only the first {{ max_shown }} are shown">
|
||||
below the cut
|
||||
</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="/admin/suggestions/{{ suggestion.id }}/delete"
|
||||
data-confirm-button="Delete the suggestion “{{ suggestion.name }}”?"
|
||||
data-confirm-title="Delete suggestion">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid--2">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name-{{ suggestion.id }}">Name</label>
|
||||
<input class="input" id="name-{{ suggestion.id }}" name="name"
|
||||
value="{{ suggestion.name }}" maxlength="120">
|
||||
<p class="field__hint">The heading on the card.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="position-{{ suggestion.id }}">Position</label>
|
||||
<input class="input" id="position-{{ suggestion.id }}" name="position" type="number"
|
||||
min="1" value="{{ suggestion.position + 1 }}">
|
||||
<p class="field__hint">Order on the screen, lowest first.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="description-{{ suggestion.id }}">Description</label>
|
||||
<input class="input" id="description-{{ suggestion.id }}" name="description"
|
||||
value="{{ suggestion.description }}" maxlength="300">
|
||||
<p class="field__hint">One line under the name, saying what it is for.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="prompt-{{ suggestion.id }}">Prompt</label>
|
||||
<textarea class="textarea" id="prompt-{{ suggestion.id }}" name="prompt"
|
||||
rows="4">{{ suggestion.prompt }}</textarea>
|
||||
<p class="field__hint">
|
||||
Put in the composer, not sent. Ending it mid-sentence is usually right:
|
||||
the person still has to say what they are asking about.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true" {{ 'checked' if suggestion.enabled }}>
|
||||
<span>Show this one</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary btn--sm" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="empty">
|
||||
{{ icon("sparkle", "empty__mark") }}
|
||||
<h2 class="empty__title">No suggestions</h2>
|
||||
<p class="empty__text">The new-chat screen shows its empty state instead.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not at_limit %}
|
||||
<form class="card" method="post" action="/admin/suggestions">
|
||||
<h2 class="card__title">Add a suggestion</h2>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<input class="input" id="new-name" name="name" maxlength="120"
|
||||
placeholder="Summarise a document" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-description">Description</label>
|
||||
<input class="input" id="new-description" name="description" maxlength="300"
|
||||
placeholder="What it is for, in one line.">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-prompt">Prompt</label>
|
||||
<textarea class="textarea" id="new-prompt" name="prompt" rows="4"
|
||||
placeholder="What lands in the composer when the card is clicked."></textarea>
|
||||
</div>
|
||||
<div class="card__footer">
|
||||
<button class="btn btn--primary" type="submit">Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="admin-lede">
|
||||
{{ max_suggestions }} is the limit. Delete one to add another — past a dozen
|
||||
this is a menu, and a menu on the empty screen is a worse blank page than a
|
||||
blank page.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -50,6 +50,9 @@
|
||||
{% if not chat and current_model %}
|
||||
<input type="hidden" name="model_id" value="{{ current_model.model_id }}">
|
||||
{% endif %}
|
||||
{% if not chat and starting_temporary %}
|
||||
<input type="hidden" name="temporary" value="true">
|
||||
{% endif %}
|
||||
|
||||
<div class="composer__row">
|
||||
{% if can.get("files.upload") %}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The request inspector: a third child of .shell, opening and closing like the
|
||||
sidebar opposite it. Administrators only, and only on their own chats.
|
||||
|
||||
`intersect once` is what makes it lazy with no JavaScript: a hidden element
|
||||
never intersects the viewport, so the request fires the first time it is
|
||||
opened and never on a page load nobody looked at.
|
||||
#}
|
||||
<aside class="inspector" id="inspector" hidden aria-label="Request inspector">
|
||||
<div class="inspector__header">
|
||||
<h2 class="inspector__title">{{ icon("search", "icon--sm") }} Inspector</h2>
|
||||
<button class="btn btn--icon btn--sm" type="button" data-toggle="#inspector"
|
||||
aria-label="Close inspector">
|
||||
{{ icon("x", "icon--sm") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="inspector__body" id="inspector-body"
|
||||
hx-get="/api/chats/{{ chat.id }}/inspect"
|
||||
hx-trigger="intersect once" hx-target="this" hx-swap="innerHTML">
|
||||
<p class="inspector__note">Opening…</p>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,90 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
Everything is rendered through normal Jinja escaping and never `|safe`. This
|
||||
JSON is full of model output, search results and uploaded documents -- hard
|
||||
rule 6 applies here exactly as it does to a chat bubble.
|
||||
#}
|
||||
<p class="inspector__note">
|
||||
Rebuilt now against the current configuration. This is not a recording of the
|
||||
request that produced the last reply — if a prompt or a setting has changed
|
||||
since, this shows what would be sent today.
|
||||
</p>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--sm" type="button"
|
||||
hx-get="/api/chats/{{ chat.id }}/inspect"
|
||||
hx-target="#inspector-body" hx-swap="innerHTML">
|
||||
{{ icon("refresh", "icon--sm") }} Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 class="inspector__heading">This chat</h3>
|
||||
<dl class="inspector__facts">
|
||||
<dt>Model</dt><dd>{{ chat.model_id or "—" }}</dd>
|
||||
<dt>Context</dt>
|
||||
<dd>
|
||||
{% if model and model.context_length %}
|
||||
{{ model.context_length }} tokens
|
||||
{% else %}
|
||||
{# Shown here rather than only beside a reply, because "why is there no
|
||||
percentage" is a question people have before the first one. #}
|
||||
not set{% if model %} — <a href="/admin/models/{{ model.id }}/edit">set it</a>{% endif %}
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if chat.temporary %}<dt>Lifetime</dt><dd>temporary</dd>{% endif %}
|
||||
</dl>
|
||||
|
||||
<h3 class="inspector__heading">Last reply</h3>
|
||||
{% if row %}
|
||||
<dl class="inspector__facts">
|
||||
<dt>Tokens</dt>
|
||||
<dd>
|
||||
{% if metrics.has_anything %}
|
||||
{% if metrics.estimated %}~{% endif %}{{ metrics.prompt_tokens }} in,
|
||||
{% if metrics.estimated %}~{% endif %}{{ metrics.completion_tokens }} out
|
||||
{% if metrics.estimated %}<span class="badge">estimated</span>{% endif %}
|
||||
{% else %}
|
||||
not reported
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Elapsed</dt><dd>{{ metrics.elapsed_ms }} ms</dd>
|
||||
<dt>Thinking</dt><dd>{{ row.reasoning_ms }} ms</dd>
|
||||
<dt>Tool calls</dt><dd>{{ row.tool_calls_json | length }}</dd>
|
||||
<dt>Rounds</dt><dd>{{ metrics.rounds }}</dd>
|
||||
<dt>State</dt>
|
||||
<dd>
|
||||
{% if row.error %}<span class="badge badge--danger">error</span>
|
||||
{% elif row.stopped %}<span class="badge badge--warning">stopped</span>
|
||||
{% elif not row.complete %}<span class="badge">writing</span>
|
||||
{% else %}<span class="badge badge--success">complete</span>{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
{% if row.error %}
|
||||
<p class="inspector__note">{{ row.error }}</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="inspector__note">Nothing has been answered in this chat yet.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="inspector__heading">System message</h3>
|
||||
{% if system %}
|
||||
<pre class="inspector__json">{{ system }}</pre>
|
||||
{% else %}
|
||||
<p class="inspector__note">None is being sent.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="inspector__heading">
|
||||
Tools offered
|
||||
<span class="badge">{{ tool_names | length }}</span>
|
||||
</h3>
|
||||
{% if tool_names %}
|
||||
<p class="inspector__note mono">{{ tool_names | join(", ") }}</p>
|
||||
{% else %}
|
||||
<p class="inspector__note">
|
||||
None. A model has to be marked as supporting tools, the user needs the
|
||||
permission, and the tool itself has to be turned on.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h3 class="inspector__heading">Request body</h3>
|
||||
<pre class="inspector__json">{{ request_json }}</pre>
|
||||
@@ -98,7 +98,10 @@
|
||||
<span class="reasoning__label">Thinking…</span>
|
||||
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
||||
</summary>
|
||||
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
|
||||
{# innerHTML, not beforeend: the frame carries the whole block of
|
||||
thinking each time, exactly as `render` and `tools` do. Appending it
|
||||
repeated everything already shown, so the panel grew quadratically. #}
|
||||
<div class="reasoning__body" sse-swap="reasoning" hx-swap="innerHTML"></div>
|
||||
</details>
|
||||
|
||||
{# Tool activity as it happens. Empty until the model asks for something,
|
||||
@@ -116,7 +119,15 @@
|
||||
reply is being written, which is where the hand already is. #}
|
||||
<div class="msg__waiting">
|
||||
<span class="dots"><i></i><i></i><i></i></span>
|
||||
{# What the reply is doing when it is not producing tokens. A silent
|
||||
multi-second pause before the first token is what a hang looks
|
||||
like. #}
|
||||
<span class="msg__status" sse-swap="status" hx-swap="innerHTML"></span>
|
||||
</div>
|
||||
{# Counts as the reply is written. Everything is an estimate until the
|
||||
usage chunk lands at the very end, and the chips say so. #}
|
||||
<div class="msg__metrics" id="metrics-{{ message.id }}"
|
||||
sse-swap="metrics" hx-swap="innerHTML"></div>
|
||||
{% else %}
|
||||
{# Finished. Same order as the live view above -- thinking, then what it
|
||||
looked up, then the answer -- so a reply does not rearrange itself the
|
||||
@@ -174,6 +185,15 @@
|
||||
leave an empty box under the file. #}
|
||||
{% endif %}
|
||||
|
||||
{% if not streaming and message.role == "assistant" and message.usage_json %}
|
||||
{# Above the buttons, not among them: the actions row is things you press. #}
|
||||
<div class="msg__metrics" id="metrics-{{ message.id }}">
|
||||
{% with metrics = message.usage_json | metrics %}
|
||||
{% include "chat/_metrics.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not streaming %}
|
||||
<footer class="msg__actions">
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{#
|
||||
What a reply cost. Chips only, no wrapper: the same markup is swapped into
|
||||
the live bubble with innerHTML and rendered into the finished one, so the
|
||||
numbers cannot change shape when the stream ends.
|
||||
|
||||
A tilde means the endpoint reported no token counts and these were worked out
|
||||
at about four characters per token. Nothing here is ever shown as exact when
|
||||
it is not.
|
||||
#}
|
||||
{% if metrics.has_anything %}
|
||||
<span class="metric" title="{% if metrics.estimated %}Estimated: this endpoint reports no token counts.
|
||||
{% endif %}{{ metrics.prompt_tokens }} in, {{ metrics.completion_tokens }} out{% if metrics.rounds > 1 %}, over {{ metrics.rounds }} rounds of tool calls{% endif %}">
|
||||
{% if metrics.estimated %}~{% endif %}{{ metrics.total_tokens }} tokens
|
||||
</span>
|
||||
|
||||
{% if metrics.context_limit %}
|
||||
<span class="metric metric--context{{ ' is-' ~ metrics.pressure if metrics.pressure }}"
|
||||
title="{% if metrics.estimated %}Estimated. {% endif %}{{ metrics.context_tokens }} of {{ metrics.context_limit }} tokens of context used">
|
||||
<span class="metric__bar">
|
||||
{# A width is data, not a design value: it is the measurement itself. #}
|
||||
<span class="metric__fill" style="width: {{ metrics.percent }}%"></span>
|
||||
</span>
|
||||
{% if metrics.estimated %}~{% endif %}{{ metrics.percent }}%
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if metrics.tokens_per_second %}
|
||||
<span class="metric" title="{% if metrics.estimated %}Estimated. {% endif %}{{ metrics.completion_tokens }} tokens in {{ metrics.elapsed_ms }} ms">
|
||||
{% if metrics.estimated %}~{% endif %}{{ '%.1f' | format(metrics.tokens_per_second) }} tok/s
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,8 +1,38 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
The whole thread. Returned after a rewind, which changes an arbitrary number
|
||||
of messages at once -- replacing the lot is simpler and less error-prone than
|
||||
working out which individual bubbles to remove.
|
||||
The whole thread. Returned after a rewind or a compaction, either of which
|
||||
changes an arbitrary number of messages at once -- replacing the lot is
|
||||
simpler and less error-prone than working out which individual bubbles to
|
||||
remove. Also included by chat/index.html, so the conversation is described in
|
||||
exactly one place.
|
||||
|
||||
Deliberately has no root element: it is swapped with innerHTML into #thread,
|
||||
and an outerHTML swap would take the container with it.
|
||||
#}
|
||||
{% if compacted %}
|
||||
{# Summarised turns are kept and still readable -- they have only stopped being
|
||||
sent. A compaction that summarised badly is then a bad turn rather than a
|
||||
lost conversation. #}
|
||||
<details class="compacted">
|
||||
<summary class="compacted__summary">
|
||||
{{ icon("archive", "icon--sm") }}
|
||||
<span>{{ compacted | length }} earlier messages, summarised</span>
|
||||
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
||||
</summary>
|
||||
<p class="compacted__note">
|
||||
These are no longer sent to the model; a summary of them goes instead. They
|
||||
are kept here so nothing is lost.
|
||||
</p>
|
||||
<div class="compacted__body">
|
||||
{% for message in compacted %}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
|
||||
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
|
||||
aria-expanded="true" data-toggle="#sidebar">
|
||||
{{ icon("sidebar") }}
|
||||
</button>
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
</h1>
|
||||
|
||||
<div class="topbar__actions">
|
||||
{#
|
||||
A link, not a script: the flag lives in the URL, so it survives a
|
||||
reload and can be bookmarked. On an existing temporary chat the same
|
||||
corner explains what temporary means and offers the way out -- without
|
||||
one, a conversation that turns out to matter is destroyed a day later
|
||||
with no recourse.
|
||||
#}
|
||||
{% if chat and chat.temporary %}
|
||||
<span class="badge badge--warning"
|
||||
title="Not listed in the sidebar, and removed 24 hours after the last message.">
|
||||
Temporary
|
||||
</span>
|
||||
<button class="btn btn--sm" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/keep" hx-swap="none"
|
||||
title="Keep this chat and list it in the sidebar">
|
||||
{{ icon("pin", "icon--sm") }} Keep
|
||||
</button>
|
||||
{% elif not chat and can.get("chat.create") %}
|
||||
<a class="btn btn--icon {{ 'is-active' if starting_temporary }}"
|
||||
href="{{ '/chat' if starting_temporary else '/chat?temporary=1' }}"
|
||||
aria-label="Temporary chat"
|
||||
title="{% if starting_temporary %}Starting a temporary chat. Click to go back to a normal one.{% else %}Start a temporary chat: not listed in the sidebar, and removed after a day.{% endif %}">
|
||||
{{ icon("clock") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if models %}
|
||||
{% if can.get("chat.model_select") or not chat %}
|
||||
{% include "chat/_model_picker.html" %}
|
||||
@@ -39,6 +65,25 @@
|
||||
{{ icon("sliders") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if chat and messages %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Compact this chat"
|
||||
title="Summarise the earlier messages so they stop taking up context"
|
||||
hx-post="/api/chats/{{ chat.id }}/compact"
|
||||
hx-target="#thread" hx-swap="innerHTML"
|
||||
hx-confirm="Summarise everything before the last reply? The messages stay in the transcript; they just stop being sent to the model."
|
||||
data-confirm-title="Compact this chat"
|
||||
data-confirm-label="Compact">
|
||||
{{ icon("archive") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{% if chat and user.is_admin %}
|
||||
<button class="btn btn--icon" type="button" aria-label="Inspect this chat"
|
||||
title="Inspect this chat" aria-expanded="false" data-toggle="#inspector">
|
||||
{{ icon("search") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -158,21 +203,39 @@
|
||||
{{ mark(cls="empty__mark", uid="intro") }}
|
||||
<h2 class="empty__title">What would you ask?</h2>
|
||||
<p class="empty__text">Speak, friend, and enter.</p>
|
||||
|
||||
{# Only on a chat that does not exist yet. An empty chat someone
|
||||
opened on purpose already has a model and a prompt chosen. #}
|
||||
{% if not chat and suggestions %}
|
||||
<div class="suggestions">
|
||||
{% for suggestion in suggestions %}
|
||||
<button class="suggestion" type="button"
|
||||
data-suggestion="{{ suggestion.prompt }}">
|
||||
<span class="suggestion__name">{{ suggestion.name }}</span>
|
||||
{% if suggestion.description %}
|
||||
<span class="suggestion__note">{{ suggestion.description }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{# Markdown was rendered server-side in pages.py, keyed by message
|
||||
id, so this loop stays a lookup rather than a render. #}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{# The same include the rewind response returns, so the conversation
|
||||
is described in one place. Markdown was rendered server-side in
|
||||
pages.py, keyed by message id. #}
|
||||
{% include "chat/_thread.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "chat/_composer.html" %}
|
||||
{% endif %}
|
||||
</main>
|
||||
|
||||
{# A third child of .shell, mirroring the sidebar opposite it. #}
|
||||
{% if chat and user.is_admin %}
|
||||
{% include "chat/_inspector.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -30,17 +30,22 @@
|
||||
</div>
|
||||
|
||||
<div class="folder__contents" x-show="open" x-cloak>
|
||||
{# Bound once: the loop and the "Empty" check must be looking at the same
|
||||
list, or a folder holding only archived chats claims to be empty while
|
||||
showing them. #}
|
||||
{% set listed = folder.visible_chats %}
|
||||
|
||||
{% for child in folder.children %}
|
||||
{% with folder = child %}
|
||||
{% include "partials/_folder.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
{% for chat_item in folder.chats %}
|
||||
{% for chat_item in listed %}
|
||||
{% include "partials/_chat_link.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not folder.children and not folder.chats %}
|
||||
{% if not folder.children and not listed %}
|
||||
<p class="nav-empty">Empty</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
<rect x="3.5" y="4.5" width="17" height="4" rx="1.2"/>
|
||||
<path d="M5 8.5v9a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-9M10 12.5h4"/>
|
||||
</symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="8.5"/>
|
||||
<path d="M12 7.2V12l3.2 2"/>
|
||||
</symbol>
|
||||
<symbol id="i-warning" viewBox="0 0 24 24">
|
||||
<path d="M12 4.2 21 19.5H3Z"/>
|
||||
<path d="M12 10v4M12 16.8h.01"/>
|
||||
|
||||
@@ -11,6 +11,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from lembas import __version__
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import User
|
||||
from lembas.services import metrics as metrics_service
|
||||
from lembas.services.reasoning import format_duration
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
@@ -23,6 +24,10 @@ templates.env.lstrip_blocks = True
|
||||
# {{ message.reasoning_ms | duration }} -> "8 seconds"
|
||||
templates.env.filters["duration"] = format_duration
|
||||
|
||||
# {{ message.usage_json | metrics }} -> a Metrics, so the finished bubble reads
|
||||
# its numbers through the same object the live frames are built from.
|
||||
templates.env.filters["metrics"] = metrics_service.from_message
|
||||
|
||||
|
||||
def stable_hue(value: str) -> int:
|
||||
"""A deterministic 0-359 hue for a string.
|
||||
|
||||
@@ -314,6 +314,28 @@ def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, reg
|
||||
assert chat.folder_id is None
|
||||
|
||||
|
||||
def test_an_archived_chat_inside_a_folder_is_not_listed(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""Regression: the unfiled list has always filtered archived chats, but the
|
||||
folder branch went through the ORM relationship and filtered nothing, so an
|
||||
archived chat kept showing as long as it was filed."""
|
||||
_add_connection(db)
|
||||
client.post("/api/folders", data={"name": "Quests"})
|
||||
folder = db.scalar(select(Folder))
|
||||
|
||||
chat_id = make_chat()
|
||||
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id, "title": "Mount Doom"})
|
||||
chat = db.get(Chat, chat_id)
|
||||
chat.archived = True
|
||||
db.commit()
|
||||
|
||||
page = client.get("/chat").text
|
||||
assert "Mount Doom" not in page
|
||||
# And the folder must say so, rather than claiming to hold something.
|
||||
assert "Empty" in page
|
||||
|
||||
|
||||
def test_a_folder_cannot_be_moved_inside_itself(client: TestClient, db, registered):
|
||||
client.post("/api/folders", data={"name": "Outer"})
|
||||
folder = db.scalar(select(Folder))
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Compaction: what is summarised, what is sent, and when it happens by itself."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Chat, Connection, Message, Model
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import compaction as compaction_service
|
||||
from lembas.services import prompts, settings_store
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def chat(db, registered, make_chat) -> Chat:
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://x.test", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(
|
||||
Model(connection_id=connection.id, model_id="test-model", context_length=1000)
|
||||
)
|
||||
db.commit()
|
||||
return db.get(Chat, make_chat())
|
||||
|
||||
|
||||
def _exchange(db, chat: Chat, *, question: str, answer: str, minutes: int = 0) -> Message:
|
||||
"""One user turn and its reply, backdated so ordering is deterministic."""
|
||||
when = datetime.now(UTC) - timedelta(minutes=minutes)
|
||||
db.add(Message(chat_id=chat.id, role="user", content=question, created_at=when))
|
||||
reply = Message(
|
||||
chat_id=chat.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
created_at=when + timedelta(seconds=1),
|
||||
)
|
||||
db.add(reply)
|
||||
db.commit()
|
||||
return reply
|
||||
|
||||
|
||||
def _usage(reply: Message, *, context_tokens: int, limit: int = 1000) -> None:
|
||||
reply.usage_json = {
|
||||
"prompt_tokens": context_tokens,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": context_tokens,
|
||||
"context_tokens": context_tokens,
|
||||
"context_limit": limit,
|
||||
"estimated": False,
|
||||
"elapsed_ms": 10,
|
||||
"rounds": 1,
|
||||
}
|
||||
|
||||
|
||||
# --- The boundary -------------------------------------------------------------
|
||||
def test_an_uncompacted_chat_splits_into_nothing_and_everything(db, chat):
|
||||
_exchange(db, chat, question="one", answer="two")
|
||||
messages = list(db.scalars(select(Message).order_by(Message.created_at)))
|
||||
assert compaction_service.split(db, chat, messages) == ([], messages)
|
||||
|
||||
|
||||
def test_a_dangling_cutoff_reads_as_uncompacted(db, chat):
|
||||
"""There is no foreign key to null it out on an upgraded database, so the
|
||||
guard is load-bearing rather than defensive."""
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
compaction_service.apply(chat, summary="a summary", upto=reply)
|
||||
db.commit()
|
||||
|
||||
db.delete(reply)
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.cutoff_message(db, chat) is None
|
||||
assert compaction_service.split(db, chat, [])[0] == []
|
||||
|
||||
|
||||
def test_the_cutoff_lands_on_a_finished_reply(db, chat):
|
||||
"""So the kept history starts on a user turn, which is what every chat
|
||||
template expects."""
|
||||
_exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
db.add(Message(chat_id=chat.id, role="user", content="three"))
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.last_complete(db, chat).content == "two"
|
||||
|
||||
|
||||
# --- What gets sent -----------------------------------------------------------
|
||||
def test_the_summary_replaces_the_compacted_turns(db, chat):
|
||||
old = _exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
|
||||
_exchange(db, chat, question="how much?", answer="A bite.", minutes=1)
|
||||
compaction_service.apply(chat, summary="They asked about lembas.", upto=old)
|
||||
db.commit()
|
||||
|
||||
messages = chat_service.build_messages(db, chat)
|
||||
contents = [m["content"] for m in messages]
|
||||
|
||||
assert "what is lembas?" not in contents
|
||||
assert "how much?" in contents
|
||||
assert any("They asked about lembas." in c for c in contents)
|
||||
|
||||
|
||||
def test_the_summary_is_carried_by_a_user_and_an_assistant_turn(db, chat):
|
||||
"""A leading assistant turn breaks templates requiring the first non-system
|
||||
message to be user; a lone leading user turn produces user, user whenever the
|
||||
kept history starts on a user turn -- which it always does."""
|
||||
old = _exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
_exchange(db, chat, question="three", answer="four", minutes=1)
|
||||
compaction_service.apply(chat, summary="Summary.", upto=old)
|
||||
db.commit()
|
||||
|
||||
roles = [m["role"] for m in chat_service.build_messages(db, chat)]
|
||||
assert roles[:2] == ["user", "assistant"]
|
||||
# And it still alternates from there.
|
||||
assert roles == ["user", "assistant", "user", "assistant"]
|
||||
|
||||
|
||||
def test_there_is_still_exactly_one_system_message(db, chat):
|
||||
settings_store.update(db, {"system_prompt": "Speak as Gandalf."})
|
||||
old = _exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
compaction_service.apply(chat, summary="Summary.", upto=old)
|
||||
db.commit()
|
||||
|
||||
roles = [m["role"] for m in chat_service.build_messages(db, chat, system_prompt="S")]
|
||||
assert roles.count("system") == 1
|
||||
assert roles[0] == "system"
|
||||
|
||||
|
||||
def test_clearing_the_lead_fragment_still_sends_the_summary(db, chat):
|
||||
prompts.save(db, {"task.compact_lead": "", "task.compact_ack": ""})
|
||||
old = _exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
compaction_service.apply(chat, summary="Summary.", upto=old)
|
||||
db.commit()
|
||||
|
||||
messages = chat_service.build_messages(db, chat)
|
||||
assert messages[0] == {"role": "user", "content": "Summary."}
|
||||
|
||||
|
||||
# --- The transcript -----------------------------------------------------------
|
||||
def test_only_the_delta_is_summarised_the_second_time(db, chat):
|
||||
"""Re-summarising the whole chat grows quadratically and eventually exceeds
|
||||
the very window it is protecting."""
|
||||
first = _exchange(db, chat, question="the old part", answer="ok", minutes=20)
|
||||
compaction_service.apply(chat, summary="Earlier summary.", upto=first)
|
||||
db.commit()
|
||||
|
||||
second = _exchange(db, chat, question="the new part", answer="ok", minutes=5)
|
||||
transcript = compaction_service.transcript(db, chat, upto=second)
|
||||
|
||||
assert "the new part" in transcript
|
||||
assert "the old part" not in transcript
|
||||
assert "Earlier summary." in compaction_service.previous_summary_block(chat)
|
||||
|
||||
|
||||
def test_a_first_compaction_has_no_previous_summary(db, chat):
|
||||
assert compaction_service.previous_summary_block(chat) == ""
|
||||
|
||||
|
||||
def test_a_huge_transcript_is_trimmed_from_the_front(db, chat):
|
||||
reply = _exchange(db, chat, question="x" * 40_000, answer="ok", minutes=5)
|
||||
transcript = compaction_service.transcript(db, chat, upto=reply)
|
||||
|
||||
assert len(transcript) < 40_000
|
||||
assert transcript.startswith("[earlier turns omitted]")
|
||||
|
||||
|
||||
# --- The button ---------------------------------------------------------------
|
||||
def _summariser(text: str = "## What we are doing\n\nAsking about lembas."):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": text}}]})
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
async def test_compacting_summarises_and_hides_the_earlier_turns(
|
||||
client: TestClient, db, chat, mock_http
|
||||
):
|
||||
_exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
|
||||
mock_http(_summariser())
|
||||
|
||||
response = client.post(f"/api/chats/{chat.id}/compact")
|
||||
assert response.status_code == 200
|
||||
|
||||
db.refresh(chat)
|
||||
assert "Asking about lembas." in chat.compact_summary
|
||||
assert chat.compacted_through_id
|
||||
# The messages are still there, behind the divider.
|
||||
assert "earlier messages, summarised" in response.text
|
||||
assert db.scalar(select(Message).where(Message.content == "what is lembas?")) is not None
|
||||
|
||||
|
||||
async def test_compacting_while_a_reply_is_being_written_is_refused(
|
||||
client: TestClient, db, chat, mock_http
|
||||
):
|
||||
_exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
db.add(Message(chat_id=chat.id, role="assistant", content="", complete=False))
|
||||
db.commit()
|
||||
|
||||
response = client.post(f"/api/chats/{chat.id}/compact")
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
async def test_compaction_can_be_turned_off_by_clearing_its_prompt(
|
||||
client: TestClient, db, chat, mock_http
|
||||
):
|
||||
_exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
prompts.save(db, {"task.compact": ""})
|
||||
|
||||
response = client.post(f"/api/chats/{chat.id}/compact")
|
||||
assert response.status_code == 409
|
||||
assert "turned off" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_compacting_an_empty_chat_is_refused(client: TestClient, db, chat, mock_http):
|
||||
assert client.post(f"/api/chats/{chat.id}/compact").status_code == 409
|
||||
|
||||
|
||||
# --- Rewinding across the boundary --------------------------------------------
|
||||
def test_editing_at_or_before_the_cutoff_clears_the_compaction(
|
||||
client: TestClient, db, chat, mock_http
|
||||
):
|
||||
"""A rewind deletes everything after the edited message, so a boundary at or
|
||||
behind it no longer describes anything that exists."""
|
||||
first_reply = _exchange(db, chat, question="one", answer="two", minutes=20)
|
||||
_exchange(db, chat, question="three", answer="four", minutes=10)
|
||||
compaction_service.apply(chat, summary="Summary.", upto=first_reply)
|
||||
db.commit()
|
||||
|
||||
first_user = db.scalar(select(Message).where(Message.content == "one"))
|
||||
mock_http(_summariser())
|
||||
client.post(
|
||||
f"/api/chats/{chat.id}/messages/{first_user.id}/edit", data={"content": "one again"}
|
||||
)
|
||||
|
||||
db.refresh(chat)
|
||||
assert chat.compact_summary == ""
|
||||
assert chat.compacted_through_id is None
|
||||
|
||||
|
||||
# --- Automatic ----------------------------------------------------------------
|
||||
def test_it_fires_when_the_window_is_nearly_full(db, chat):
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
|
||||
_usage(reply, context_tokens=960)
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.should_compact(db, chat) is True
|
||||
|
||||
|
||||
def test_it_does_not_fire_with_room_to_spare(db, chat):
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
|
||||
_usage(reply, context_tokens=400)
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.should_compact(db, chat) is False
|
||||
|
||||
|
||||
def test_a_large_pending_turn_is_counted(db, chat):
|
||||
"""The recorded figure is one turn stale. Fifty thousand characters pasted
|
||||
into the composer overflow a window that measured 90% last time."""
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
|
||||
_usage(reply, context_tokens=900)
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.should_compact(db, chat) is False
|
||||
assert compaction_service.should_compact(db, chat, pending="x" * 400) is True
|
||||
|
||||
|
||||
def test_it_never_fires_without_a_context_length(db, chat):
|
||||
"""Acting on a number nobody supplied is exactly what 0-means-unknown is
|
||||
there to prevent."""
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
|
||||
_usage(reply, context_tokens=99_000, limit=0)
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.should_compact(db, chat) is False
|
||||
|
||||
|
||||
def test_a_threshold_of_zero_turns_it_off(db, chat):
|
||||
settings_store.update(db, {"compact_threshold": 0})
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
|
||||
_usage(reply, context_tokens=999)
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.should_compact(db, chat) is False
|
||||
|
||||
|
||||
def test_it_does_not_fire_on_the_first_turn(db, chat):
|
||||
assert compaction_service.should_compact(db, chat) is False
|
||||
|
||||
|
||||
def test_it_acts_on_estimated_counts_too(db, chat):
|
||||
"""A premature compaction costs one turn of answer quality, not data -- the
|
||||
messages are still there. That is what makes acting on an estimate safe."""
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=5)
|
||||
_usage(reply, context_tokens=960)
|
||||
reply.usage_json = {**reply.usage_json, "estimated": True}
|
||||
db.commit()
|
||||
|
||||
assert compaction_service.should_compact(db, chat) is True
|
||||
|
||||
|
||||
async def test_a_generation_compacts_before_it_asks(db, chat, mock_http):
|
||||
"""At the top of _run, so build_request is called once and what goes out is
|
||||
the compacted conversation."""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
reply = _exchange(db, chat, question="what is lembas?", answer="Waybread.", minutes=10)
|
||||
_usage(reply, context_tokens=980)
|
||||
db.commit()
|
||||
|
||||
pending = Message(chat_id=chat.id, role="user", content="more?")
|
||||
db.add(pending)
|
||||
db.commit()
|
||||
placeholder = Message(chat_id=chat.id, role="assistant", content="", complete=False)
|
||||
db.add(placeholder)
|
||||
db.commit()
|
||||
|
||||
sent: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
sent.append(body)
|
||||
if body.get("stream"):
|
||||
return httpx.Response(200, text="data: [DONE]\n\n")
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "A summary."}}]})
|
||||
|
||||
mock_http(handler)
|
||||
generation = generation_service.Generation(chat_id=chat.id, message_id=placeholder.id)
|
||||
generation_service._RUNNING[placeholder.id] = generation
|
||||
await generation_service._run(generation)
|
||||
generation_service._RUNNING.clear()
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(Chat, chat.id).compact_summary == "A summary."
|
||||
# The streamed request went out after compaction, carrying the summary.
|
||||
streamed = next(b for b in sent if b.get("stream"))
|
||||
assert any("A summary." in str(m.get("content")) for m in streamed["messages"])
|
||||
assert not any(m.get("content") == "what is lembas?" for m in streamed["messages"])
|
||||
|
||||
|
||||
async def test_a_failed_compaction_still_sends_the_reply(db, chat, mock_http):
|
||||
"""Refusing to answer because the summariser was unavailable is a worse
|
||||
trade than sending the uncompacted request."""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
reply = _exchange(db, chat, question="one", answer="two", minutes=10)
|
||||
_usage(reply, context_tokens=980)
|
||||
db.commit()
|
||||
placeholder = Message(chat_id=chat.id, role="assistant", content="", complete=False)
|
||||
db.add(placeholder)
|
||||
db.commit()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if json.loads(request.content).get("stream"):
|
||||
return httpx.Response(
|
||||
200, text='data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n'
|
||||
)
|
||||
return httpx.Response(500, json={"error": {"message": "no"}})
|
||||
|
||||
mock_http(handler)
|
||||
generation = generation_service.Generation(chat_id=chat.id, message_id=placeholder.id)
|
||||
generation_service._RUNNING[placeholder.id] = generation
|
||||
await generation_service._run(generation)
|
||||
generation_service._RUNNING.clear()
|
||||
|
||||
assert generation.text == "hi"
|
||||
assert not generation.error
|
||||
@@ -0,0 +1,144 @@
|
||||
"""The admin request inspector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Chat, Connection, Message, Model, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
|
||||
def _connection(db, **model_kwargs) -> None:
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="test-model", **model_kwargs))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_user(client: TestClient, db, registered) -> User:
|
||||
"""A second, non-admin account, left signed in."""
|
||||
client.post("/auth/logout", follow_redirects=False)
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
return db.scalar(select(User).where(User.email == "sam@shire.test"))
|
||||
|
||||
|
||||
# --- Access -------------------------------------------------------------------
|
||||
def test_an_admin_sees_the_toggle(client: TestClient, db, registered, make_chat):
|
||||
_connection(db)
|
||||
page = client.get(f"/chat/{make_chat()}").text
|
||||
assert 'data-toggle="#inspector"' in page
|
||||
assert 'id="inspector"' in page
|
||||
|
||||
|
||||
def test_a_plain_user_has_no_inspector(client: TestClient, db, plain_user, make_chat):
|
||||
_connection(db)
|
||||
chat_id = make_chat(email="sam@shire.test")
|
||||
page = client.get(f"/chat/{chat_id}").text
|
||||
assert 'data-toggle="#inspector"' not in page
|
||||
assert 'id="inspector"' not in page
|
||||
|
||||
|
||||
def test_a_plain_user_is_refused_the_endpoint(client: TestClient, db, plain_user, make_chat):
|
||||
"""Owning the chat is not enough. Reading a conversation is a different act
|
||||
from configuring the instance, which is why sharing has no admin branch
|
||||
either -- and this must not become one by another name."""
|
||||
_connection(db)
|
||||
chat_id = make_chat(email="sam@shire.test")
|
||||
assert client.get(f"/api/chats/{chat_id}/inspect").status_code == 403
|
||||
|
||||
|
||||
def test_an_admin_cannot_inspect_someone_elses_chat(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
other = User(name="Sam", email="s@shire.test", password_hash="x")
|
||||
db.add(other)
|
||||
db.commit()
|
||||
db.get(Chat, chat_id).user_id = other.id
|
||||
db.commit()
|
||||
|
||||
assert client.get(f"/api/chats/{chat_id}/inspect").status_code == 404
|
||||
|
||||
|
||||
# --- What it shows ------------------------------------------------------------
|
||||
def test_it_says_it_is_rebuilt_not_recorded(client: TestClient, db, registered, make_chat):
|
||||
_connection(db)
|
||||
body = client.get(f"/api/chats/{make_chat()}/inspect").text
|
||||
assert "not a recording" in body
|
||||
|
||||
|
||||
def test_it_shows_the_system_message_and_the_request(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
settings_store.update(db, {"system_prompt": "Speak as Gandalf."})
|
||||
_connection(db, context_length=8192)
|
||||
chat_id = make_chat()
|
||||
db.add(Message(chat_id=chat_id, role="user", content="what is lembas?"))
|
||||
db.commit()
|
||||
|
||||
body = client.get(f"/api/chats/{chat_id}/inspect").text
|
||||
assert "Speak as Gandalf." in body
|
||||
assert "test-model" in body
|
||||
assert "what is lembas?" in body
|
||||
assert "8192 tokens" in body
|
||||
|
||||
|
||||
def test_model_output_is_escaped(client: TestClient, db, registered, make_chat):
|
||||
"""The JSON is full of model output and search results. Hard rule 6 applies
|
||||
here exactly as it does to a chat bubble."""
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
db.add(Message(chat_id=chat_id, role="assistant", content="<script>alert(1)</script>"))
|
||||
db.commit()
|
||||
|
||||
body = client.get(f"/api/chats/{chat_id}/inspect").text
|
||||
assert "<script>alert(1)</script>" not in body
|
||||
assert "<script>" in body
|
||||
|
||||
|
||||
def test_an_image_is_not_dumped_into_the_dom(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""A phone photo is megabytes of base64. Fidelity is the point of the panel,
|
||||
but not that much of it."""
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from lembas.db.models import Attachment
|
||||
|
||||
_connection(db, capabilities_json={"vision": True})
|
||||
chat_id = make_chat()
|
||||
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (80, 60), "red").save(buffer, format="PNG")
|
||||
client.post("/api/files", files={"file": ("p.png", buffer.getvalue(), "image/png")})
|
||||
attachment = db.scalar(select(Attachment))
|
||||
|
||||
message = Message(chat_id=chat_id, role="user", content="look")
|
||||
db.add(message)
|
||||
db.commit()
|
||||
attachment.message_id = message.id
|
||||
attachment.chat_id = chat_id
|
||||
db.commit()
|
||||
|
||||
body = client.get(f"/api/chats/{chat_id}/inspect").text
|
||||
assert "base64 image omitted" in body
|
||||
assert "iVBORw0" not in body
|
||||
|
||||
|
||||
def test_a_chat_with_no_reply_yet_says_so(client: TestClient, db, registered, make_chat):
|
||||
_connection(db)
|
||||
assert "Nothing has been answered" in client.get(f"/api/chats/{make_chat()}/inspect").text
|
||||
@@ -0,0 +1,452 @@
|
||||
"""How full the context is, what a reply cost, and how fast it arrived."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Connection, Model
|
||||
from lembas.services import metrics, tokens
|
||||
from lembas.services.crypto import encrypt
|
||||
from lembas.services.llm.openai_client import chunk_usage, context_from
|
||||
|
||||
|
||||
def _model(db, **kwargs) -> Model:
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
model = Model(connection_id=connection.id, model_id="test-model", **kwargs)
|
||||
db.add(model)
|
||||
db.commit()
|
||||
return model
|
||||
|
||||
|
||||
# --- Reading a context length off /v1/models ---------------------------------
|
||||
def test_context_length_is_read_from_any_of_the_spellings():
|
||||
assert context_from({"id": "m", "context_length": 8192}) == 8192
|
||||
assert context_from({"id": "m", "max_model_len": 32768}) == 32768
|
||||
assert context_from({"id": "m", "context_window": 4096}) == 4096
|
||||
assert context_from({"id": "m", "meta": {"n_ctx": 2048}}) == 2048
|
||||
|
||||
|
||||
def test_a_quoted_number_is_accepted_but_a_label_is_not():
|
||||
"""Some servers quote it. "8192 tokens" is a label, not a measurement."""
|
||||
assert context_from({"id": "m", "context_length": "8192"}) == 8192
|
||||
assert context_from({"id": "m", "context_length": "8192 tokens"}) == 0
|
||||
|
||||
|
||||
def test_an_absent_or_implausible_context_length_is_zero():
|
||||
assert context_from({"id": "m"}) == 0
|
||||
assert context_from({"id": "m", "context_length": 0}) == 0
|
||||
assert context_from({"id": "m", "context_length": 64}) == 0
|
||||
assert context_from({"id": "m", "context_length": 10**9}) == 0
|
||||
# True is an int in Python, and it is not a context length.
|
||||
assert context_from({"id": "m", "context_length": True}) == 0
|
||||
|
||||
|
||||
# --- Discovery ---------------------------------------------------------------
|
||||
async def test_discovery_fills_in_a_context_length(client: TestClient, db, registered, mock_http):
|
||||
import httpx
|
||||
|
||||
mock_http(
|
||||
lambda _r: httpx.Response(
|
||||
200, json={"data": [{"id": "big-model", "context_length": 16384}]}
|
||||
)
|
||||
)
|
||||
client.post(
|
||||
"/admin/connections",
|
||||
data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
model = db.scalar(select(Model).where(Model.model_id == "big-model"))
|
||||
assert model.context_length == 16384
|
||||
|
||||
|
||||
async def test_discovery_never_overwrites_a_number_an_admin_typed(
|
||||
client: TestClient, db, registered, mock_http
|
||||
):
|
||||
"""A refresh must not undo a correction. Administrators set this precisely
|
||||
because the endpoint was wrong or silent."""
|
||||
import httpx
|
||||
|
||||
mock_http(
|
||||
lambda _r: httpx.Response(200, json={"data": [{"id": "m", "context_length": 4096}]})
|
||||
)
|
||||
client.post(
|
||||
"/admin/connections",
|
||||
data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
model = db.scalar(select(Model).where(Model.model_id == "m"))
|
||||
model.context_length = 131072
|
||||
db.commit()
|
||||
|
||||
connection = db.scalar(select(Connection))
|
||||
client.post(f"/admin/connections/{connection.id}/refresh", follow_redirects=False)
|
||||
|
||||
db.refresh(model)
|
||||
assert model.context_length == 131072
|
||||
|
||||
|
||||
# --- The admin field ---------------------------------------------------------
|
||||
def test_an_admin_can_set_and_clear_the_context_length(client: TestClient, db, registered):
|
||||
model = _model(db)
|
||||
client.post(
|
||||
f"/admin/models/{model.id}",
|
||||
data={"context_length": "8192", "position": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(model)
|
||||
assert model.context_length == 8192
|
||||
|
||||
client.post(
|
||||
f"/admin/models/{model.id}", data={"context_length": "", "position": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(model)
|
||||
assert model.context_length == 0
|
||||
|
||||
|
||||
def test_junk_in_the_context_length_field_is_ignored_not_a_500(
|
||||
client: TestClient, db, registered
|
||||
):
|
||||
model = _model(db, context_length=4096)
|
||||
response = client.post(
|
||||
f"/admin/models/{model.id}",
|
||||
data={"context_length": "eight thousand", "position": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code == 303
|
||||
db.refresh(model)
|
||||
assert model.context_length == 4096
|
||||
|
||||
|
||||
# --- Usage off the wire -------------------------------------------------------
|
||||
def test_usage_is_read_from_a_usage_chunk():
|
||||
chunk = {
|
||||
"choices": [],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 20, "total_tokens": 120},
|
||||
}
|
||||
assert chunk_usage(chunk) == {
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 120,
|
||||
}
|
||||
|
||||
|
||||
def test_a_missing_total_is_worked_out():
|
||||
chunk = {"choices": [], "usage": {"prompt_tokens": 100, "completion_tokens": 20}}
|
||||
assert chunk_usage(chunk)["total_tokens"] == 120
|
||||
|
||||
|
||||
def test_an_ordinary_chunk_carries_no_usage():
|
||||
assert chunk_usage({"choices": [{"delta": {"content": "hi"}}]}) is None
|
||||
assert chunk_usage({}) is None
|
||||
assert chunk_usage({"usage": "lots"}) is None
|
||||
|
||||
|
||||
def test_an_all_zero_usage_object_is_not_an_answer():
|
||||
"""Some servers attach zeros to every chunk and the real numbers only at the
|
||||
end. Believing the zeros freezes the count at nothing."""
|
||||
chunk = {"choices": [], "usage": {"prompt_tokens": 0, "completion_tokens": 0}}
|
||||
assert chunk_usage(chunk) is None
|
||||
|
||||
|
||||
def test_the_other_accessors_still_ignore_a_usage_chunk():
|
||||
"""They return early on `choices: []`, which is exactly the shape of one.
|
||||
That is what lets a usage chunk through the loop untouched."""
|
||||
from lembas.services.llm.openai_client import (
|
||||
delta_reasoning,
|
||||
delta_text,
|
||||
delta_tool_calls,
|
||||
finish_reason,
|
||||
)
|
||||
|
||||
chunk = {"choices": [], "usage": {"prompt_tokens": 1, "completion_tokens": 1}}
|
||||
assert delta_text(chunk) == ""
|
||||
assert delta_reasoning(chunk) == ""
|
||||
assert delta_tool_calls(chunk) == []
|
||||
assert finish_reason(chunk) == ""
|
||||
|
||||
|
||||
async def test_stream_options_is_asked_for(mock_http):
|
||||
import json as json_module
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.services.llm.openai_client import Endpoint, stream_chat
|
||||
|
||||
seen: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(json_module.loads(request.content))
|
||||
return httpx.Response(200, text="data: [DONE]\n\n")
|
||||
|
||||
mock_http(handler)
|
||||
async for _ in stream_chat(Endpoint("http://ask.test", "", {}), {"model": "m"}):
|
||||
pass
|
||||
|
||||
assert seen[0]["stream_options"] == {"include_usage": True}
|
||||
|
||||
|
||||
async def test_an_endpoint_that_rejects_stream_options_is_asked_once(mock_http):
|
||||
"""A 400 for an unknown key is the same hazard as sending `tools` to an
|
||||
endpoint without support. Retry without it, then stop asking."""
|
||||
import json as json_module
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.services.llm.openai_client import (
|
||||
_NO_STREAM_OPTIONS,
|
||||
Endpoint,
|
||||
stream_chat,
|
||||
)
|
||||
|
||||
_NO_STREAM_OPTIONS.discard("http://fussy.test")
|
||||
seen: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json_module.loads(request.content)
|
||||
seen.append(body)
|
||||
if "stream_options" in body:
|
||||
return httpx.Response(400, json={"error": {"message": "unknown field"}})
|
||||
reply = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\ndata: [DONE]\n\n'
|
||||
return httpx.Response(200, text=reply)
|
||||
|
||||
mock_http(handler)
|
||||
endpoint = Endpoint("http://fussy.test", "", {})
|
||||
|
||||
text = [c async for c in stream_chat(endpoint, {"model": "m"})]
|
||||
assert text, "the retry should have produced the reply"
|
||||
assert len(seen) == 2
|
||||
|
||||
# Second reply: it already knows, so one request and no stream_options.
|
||||
async for _ in stream_chat(endpoint, {"model": "m"}):
|
||||
pass
|
||||
assert len(seen) == 3
|
||||
assert "stream_options" not in seen[2]
|
||||
_NO_STREAM_OPTIONS.discard("http://fussy.test")
|
||||
|
||||
|
||||
# --- The estimate -------------------------------------------------------------
|
||||
def test_the_estimate_is_about_four_characters_a_token():
|
||||
assert tokens.estimate("") == 0
|
||||
assert tokens.estimate("x" * 400) == 100
|
||||
|
||||
|
||||
def test_typed_content_parts_are_counted_and_images_are_not():
|
||||
"""An image's cost depends on the model's tiling. A number invented here
|
||||
would be worse than the omission."""
|
||||
content = [
|
||||
{"type": "text", "text": "x" * 40},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA" * 500}},
|
||||
]
|
||||
assert tokens.estimate_content(content) == 10
|
||||
|
||||
|
||||
def test_a_request_estimate_includes_the_tools_array():
|
||||
"""Thirteen schemas is a meaningful slice of a short window; leaving them
|
||||
out would read low exactly when it matters."""
|
||||
payload = {
|
||||
"messages": [{"role": "user", "content": "x" * 40}],
|
||||
"tools": [
|
||||
{"function": {"name": "web_search", "description": "y" * 400, "parameters": {}}}
|
||||
],
|
||||
}
|
||||
assert tokens.estimate_request(payload) > tokens.estimate_request(
|
||||
{"messages": payload["messages"]}
|
||||
)
|
||||
|
||||
|
||||
# --- The Metrics object -------------------------------------------------------
|
||||
def test_the_percentage_is_zero_when_nobody_said_how_big_the_window_is():
|
||||
"""Unknown must stay tellable from small. A percentage of an unknown total
|
||||
is a made-up number in a place people trust numbers."""
|
||||
assert metrics.Metrics(context_tokens=5000, context_limit=0).percent == 0
|
||||
assert metrics.Metrics(context_tokens=5000, context_limit=10000).percent == 50
|
||||
|
||||
|
||||
def test_pressure_marks_the_bar_only_when_it_is_worth_noticing():
|
||||
assert metrics.Metrics(context_tokens=50, context_limit=100).pressure == ""
|
||||
assert metrics.Metrics(context_tokens=85, context_limit=100).pressure == "warning"
|
||||
assert metrics.Metrics(context_tokens=96, context_limit=100).pressure == "danger"
|
||||
assert metrics.Metrics(context_tokens=0, context_limit=0).pressure == ""
|
||||
|
||||
|
||||
def test_speed_needs_both_a_count_and_a_clock():
|
||||
assert metrics.Metrics(completion_tokens=100, elapsed_ms=2000).tokens_per_second == 50.0
|
||||
assert metrics.Metrics(completion_tokens=100, elapsed_ms=0).tokens_per_second == 0.0
|
||||
assert metrics.Metrics(completion_tokens=0, elapsed_ms=2000).tokens_per_second == 0.0
|
||||
|
||||
|
||||
def test_metrics_survive_a_round_trip_through_the_row():
|
||||
original = metrics.Metrics(
|
||||
prompt_tokens=100,
|
||||
completion_tokens=20,
|
||||
total_tokens=120,
|
||||
context_tokens=120,
|
||||
context_limit=8192,
|
||||
estimated=True,
|
||||
elapsed_ms=1500,
|
||||
rounds=2,
|
||||
)
|
||||
assert metrics.from_message(metrics.to_json(original)) == original
|
||||
|
||||
|
||||
def test_a_row_with_no_usage_reads_as_nothing_rather_than_failing():
|
||||
assert metrics.from_message(None).has_anything is False
|
||||
assert metrics.from_message({}).has_anything is False
|
||||
assert metrics.from_message({"prompt_tokens": "lots"}).prompt_tokens == 0
|
||||
|
||||
|
||||
# --- Through a generation -----------------------------------------------------
|
||||
def _chunks(*frames: str) -> str:
|
||||
return "".join(f"data: {frame}\n\n" for frame in frames) + "data: [DONE]\n\n"
|
||||
|
||||
|
||||
async def test_usage_is_summed_across_tool_rounds(db, registered, make_chat, monkeypatch):
|
||||
"""Prompt and completion are what the reply cost; context_tokens is what the
|
||||
window holds. A three-round reply pays for its prompt three times and only
|
||||
ever occupies the window once."""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
generation = generation_service.Generation(chat_id="c", message_id="m")
|
||||
for prompt_tokens, completion_tokens in ((100, 10), (250, 20)):
|
||||
counts = {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens}
|
||||
generation.prompt_tokens += counts["prompt_tokens"]
|
||||
generation.completion_tokens += counts["completion_tokens"]
|
||||
generation.context_tokens = prompt_tokens + completion_tokens
|
||||
|
||||
assert generation.prompt_tokens == 350
|
||||
assert generation.completion_tokens == 30
|
||||
assert generation.context_tokens == 270
|
||||
|
||||
|
||||
async def test_a_reply_records_what_it_cost(client: TestClient, db, registered, mock_http):
|
||||
import httpx
|
||||
|
||||
from lembas.db.models import Message
|
||||
|
||||
def handler(_request: httpx.Request) -> httpx.Response:
|
||||
if _request.url.path.endswith("/models"):
|
||||
return httpx.Response(200, json={"data": [{"id": "m", "context_length": 1000}]})
|
||||
return httpx.Response(
|
||||
200,
|
||||
text=_chunks(
|
||||
'{"choices":[{"delta":{"content":"Waybread."}}]}',
|
||||
'{"choices":[],"usage":{"prompt_tokens":120,"completion_tokens":8}}',
|
||||
),
|
||||
)
|
||||
|
||||
mock_http(handler)
|
||||
client.post(
|
||||
"/admin/connections",
|
||||
data={"name": "Local", "base_url": "http://x.test", "api_key": ""},
|
||||
follow_redirects=False,
|
||||
)
|
||||
client.post("/api/chats/start", data={"content": "what is lembas?"})
|
||||
reply = db.scalar(select(Message).where(Message.role == "assistant"))
|
||||
|
||||
# Following the stream is what runs the generation to completion.
|
||||
with client.stream(
|
||||
"GET", f"/api/chats/{reply.chat_id}/messages/{reply.id}/stream"
|
||||
) as response:
|
||||
list(response.iter_lines())
|
||||
|
||||
db.refresh(reply)
|
||||
assert reply.usage_json["prompt_tokens"] == 120
|
||||
assert reply.usage_json["completion_tokens"] == 8
|
||||
assert reply.usage_json["context_limit"] == 1000
|
||||
assert reply.usage_json["estimated"] is False
|
||||
assert reply.usage_json["elapsed_ms"] >= 0
|
||||
|
||||
|
||||
def test_the_chips_render_from_a_stored_row(client: TestClient, db, registered, make_chat):
|
||||
from lembas.db.models import Message
|
||||
|
||||
_model(db)
|
||||
chat_id = make_chat()
|
||||
db.add(
|
||||
Message(
|
||||
chat_id=chat_id,
|
||||
role="assistant",
|
||||
content="Waybread.",
|
||||
usage_json={
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 120,
|
||||
"context_tokens": 120,
|
||||
"context_limit": 1000,
|
||||
"estimated": False,
|
||||
"elapsed_ms": 2000,
|
||||
"rounds": 1,
|
||||
},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
page = client.get(f"/chat/{chat_id}").text
|
||||
assert "120 tokens" in page
|
||||
assert "12%" in page
|
||||
assert "10.0 tok/s" in page
|
||||
assert "~" not in page.split("msg__metrics")[1][:400]
|
||||
|
||||
|
||||
def test_an_estimated_row_says_so(client: TestClient, db, registered, make_chat):
|
||||
from lembas.db.models import Message
|
||||
|
||||
_model(db)
|
||||
chat_id = make_chat()
|
||||
db.add(
|
||||
Message(
|
||||
chat_id=chat_id,
|
||||
role="assistant",
|
||||
content="Waybread.",
|
||||
usage_json={
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 120,
|
||||
"context_tokens": 120,
|
||||
"context_limit": 1000,
|
||||
"estimated": True,
|
||||
"elapsed_ms": 2000,
|
||||
"rounds": 1,
|
||||
},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
page = client.get(f"/chat/{chat_id}").text
|
||||
assert "~120 tokens" in page
|
||||
assert "reports no token counts" in page
|
||||
|
||||
|
||||
def test_no_context_length_means_no_percentage(client: TestClient, db, registered, make_chat):
|
||||
from lembas.db.models import Message
|
||||
|
||||
_model(db)
|
||||
chat_id = make_chat()
|
||||
db.add(
|
||||
Message(
|
||||
chat_id=chat_id,
|
||||
role="assistant",
|
||||
content="Waybread.",
|
||||
usage_json={
|
||||
"prompt_tokens": 100,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 120,
|
||||
"context_tokens": 120,
|
||||
"context_limit": 0,
|
||||
"estimated": False,
|
||||
"elapsed_ms": 2000,
|
||||
"rounds": 1,
|
||||
},
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
page = client.get(f"/chat/{chat_id}").text
|
||||
assert "120 tokens" in page
|
||||
assert "metric--context" not in page
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Regenerating a reply, and the registry rules that make it work.
|
||||
|
||||
Regeneration is the one caller that reuses a Message row rather than creating a
|
||||
new one, which is why it is the one caller `ensure` was wrong for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Chat, Connection, Message, Model
|
||||
from lembas.services import generation as generation_service
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def empty_registry():
|
||||
"""The registry is module state. A test that leaves an entry behind changes
|
||||
what the next one sees."""
|
||||
yield
|
||||
generation_service._RUNNING.clear()
|
||||
generation_service._TASKS.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_upstream(monkeypatch):
|
||||
"""Replace the producer, so a test can watch the registry without a server.
|
||||
|
||||
Records the generations it was asked to run.
|
||||
"""
|
||||
started: list = []
|
||||
|
||||
async def _fake_run(generation):
|
||||
started.append(generation)
|
||||
|
||||
monkeypatch.setattr(generation_service, "_run", _fake_run)
|
||||
return started
|
||||
|
||||
|
||||
def _connection(db) -> Connection:
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="test-model"))
|
||||
db.commit()
|
||||
return connection
|
||||
|
||||
|
||||
def _reply(db, chat_id: str, *, complete: bool = True) -> Message:
|
||||
db.add(Message(chat_id=chat_id, role="user", content="what is lembas?"))
|
||||
reply = Message(chat_id=chat_id, role="assistant", content="Waybread.", complete=complete)
|
||||
db.add(reply)
|
||||
db.commit()
|
||||
return reply
|
||||
|
||||
|
||||
def _finished(chat_id: str, message_id: str, *, text: str = "old") -> generation_service.Generation:
|
||||
"""A generation in the state a just-completed reply leaves behind."""
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=message_id)
|
||||
generation.content.append(text)
|
||||
generation.done = True
|
||||
generation.finished_at = datetime.now(UTC)
|
||||
generation_service._RUNNING[message_id] = generation
|
||||
return generation
|
||||
|
||||
|
||||
# --- The bug -----------------------------------------------------------------
|
||||
def test_regenerate_starts_a_fresh_generation(
|
||||
client: TestClient, db, registered, make_chat, no_upstream
|
||||
):
|
||||
"""The bug: `ensure` handed back the finished generation still sitting in
|
||||
the registry, so no request was ever made and the browser reconnected to a
|
||||
stream that had nothing left to say."""
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id)
|
||||
stale = _finished(chat_id, reply.id)
|
||||
|
||||
response = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
|
||||
assert response.status_code == 200
|
||||
|
||||
fresh = generation_service.get(reply.id)
|
||||
assert fresh is not stale
|
||||
assert not fresh.done
|
||||
assert fresh.text == ""
|
||||
assert no_upstream == [fresh]
|
||||
|
||||
|
||||
def test_regenerate_blanks_the_row_and_returns_a_streaming_shell(
|
||||
client: TestClient, db, registered, make_chat, no_upstream
|
||||
):
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id)
|
||||
|
||||
body = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate").text
|
||||
assert "sse-connect=" in body
|
||||
assert 'sse-swap="render"' in body
|
||||
|
||||
db.expire_all()
|
||||
row = db.get(Message, reply.id)
|
||||
assert row.complete is False
|
||||
assert row.content == ""
|
||||
|
||||
|
||||
def test_regenerating_twice_in_a_row_works(
|
||||
client: TestClient, db, registered, make_chat, no_upstream
|
||||
):
|
||||
"""The reported symptom was that it worked once, sometimes."""
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id)
|
||||
|
||||
client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
|
||||
first = generation_service.get(reply.id)
|
||||
first.done = True
|
||||
first.finished_at = datetime.now(UTC)
|
||||
|
||||
client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
|
||||
assert generation_service.get(reply.id) is not first
|
||||
assert len(no_upstream) == 2
|
||||
|
||||
|
||||
def test_regenerating_someone_elses_reply_is_not_found(
|
||||
client: TestClient, db, registered, make_chat, no_upstream
|
||||
):
|
||||
from lembas.db.models import User
|
||||
from lembas.security.passwords import hash_password
|
||||
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id)
|
||||
|
||||
someone_else = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
|
||||
db.add(someone_else)
|
||||
db.commit()
|
||||
db.scalar(select(Chat).where(Chat.id == chat_id)).user_id = someone_else.id
|
||||
db.commit()
|
||||
|
||||
response = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# --- Registry rules ----------------------------------------------------------
|
||||
async def test_ensure_still_attaches_to_an_unfinished_reply(db, no_upstream):
|
||||
"""Idempotence is load-bearing: a page load that finds an unfinished reply
|
||||
must attach to it, not start a second one."""
|
||||
first = generation_service.ensure("chat", "message")
|
||||
assert generation_service.ensure("chat", "message") is first
|
||||
await asyncio.sleep(0) # let the scheduled task actually start
|
||||
assert len(no_upstream) == 1
|
||||
|
||||
|
||||
async def test_prune_expires_a_finished_generation_before_the_lookup(db, no_upstream):
|
||||
"""`_prune` used to sit below the early return, where it could never reach
|
||||
the one entry that needed it."""
|
||||
stale = _finished("chat", "message")
|
||||
stale.finished_at = datetime.now(UTC) - generation_service.KEEP_FINISHED - timedelta(minutes=1)
|
||||
|
||||
assert generation_service.ensure("chat", "message") is not stale
|
||||
|
||||
|
||||
def test_a_superseded_generation_does_not_write_the_row(db, registered, make_chat):
|
||||
"""A cancelled predecessor's `finally:` runs _persist on the same message,
|
||||
and it must not overwrite the reply that replaced it."""
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id)
|
||||
|
||||
abandoned = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
|
||||
abandoned.content.append("the abandoned attempt")
|
||||
current = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
|
||||
current.content.append("the reply that replaced it")
|
||||
generation_service._RUNNING[reply.id] = current
|
||||
|
||||
generation_service._persist(abandoned, "", 0.0)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(Message, reply.id).content == "Waybread."
|
||||
|
||||
|
||||
async def test_restart_cancels_a_generation_still_running(db, no_upstream):
|
||||
live = generation_service.ensure("chat", "message")
|
||||
generation_service.restart("chat", "message")
|
||||
assert live.cancel is True
|
||||
assert generation_service.get("message") is not live
|
||||
|
||||
|
||||
# --- Ordering ----------------------------------------------------------------
|
||||
async def test_the_reply_is_persisted_before_it_is_marked_done(
|
||||
db, registered, make_chat, monkeypatch
|
||||
):
|
||||
"""`_follow` breaks out the instant it sees `done` and re-renders the bubble
|
||||
from the row, so the row has to be right first."""
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id, complete=False)
|
||||
seen: list[bool] = []
|
||||
|
||||
def _record(generation, title, elapsed):
|
||||
seen.append(generation.done)
|
||||
|
||||
monkeypatch.setattr(generation_service, "_persist", _record)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
|
||||
generation_service._RUNNING[reply.id] = generation
|
||||
# No connection row, so resolve_endpoint fails and _run goes straight to
|
||||
# its finally: which is the part under test.
|
||||
await generation_service._run(generation)
|
||||
|
||||
assert seen == [False]
|
||||
assert generation.done is True
|
||||
|
||||
|
||||
# --- The streaming shell ------------------------------------------------------
|
||||
def test_live_reasoning_is_replaced_not_appended(
|
||||
client: TestClient, db, registered, make_chat, no_upstream
|
||||
):
|
||||
"""The frame carries the whole block each time. Appending it repeated
|
||||
everything already shown, so the panel grew quadratically."""
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id)
|
||||
|
||||
body = client.post(f"/api/chats/{chat_id}/messages/{reply.id}/regenerate").text
|
||||
line = next(line for line in body.splitlines() if 'sse-swap="reasoning"' in line)
|
||||
assert 'hx-swap="innerHTML"' in line
|
||||
assert "beforeend" not in line
|
||||
|
||||
|
||||
async def test_a_silent_generation_gets_a_keepalive(db, registered, make_chat, monkeypatch):
|
||||
"""A model thinking for a minute emits nothing, and an idle connection is
|
||||
what a proxy closes."""
|
||||
from lembas.api import chats as chats_api
|
||||
|
||||
chat_id = make_chat()
|
||||
reply = _reply(db, chat_id, complete=False)
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
|
||||
generation_service._RUNNING[reply.id] = generation
|
||||
monkeypatch.setattr(chats_api, "KEEPALIVE_AFTER", 0.0)
|
||||
|
||||
frames: list[str] = []
|
||||
stream = chats_api._follow(chat_id, reply.id).__aiter__()
|
||||
|
||||
async def _finish():
|
||||
await asyncio.sleep(0.05)
|
||||
generation.done = True
|
||||
|
||||
task = asyncio.create_task(_finish())
|
||||
async for frame in stream:
|
||||
frames.append(frame)
|
||||
if frame.startswith("event: close"):
|
||||
break
|
||||
await task
|
||||
|
||||
assert any(frame == ": keepalive\n\n" for frame in frames)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Prompt suggestions: seeding, administration, and the cards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Connection, Model, Suggestion, User
|
||||
from lembas.services import settings_store
|
||||
from lembas.services import suggestions as suggestions_service
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean(client: TestClient, db, registered):
|
||||
"""Start from no suggestions.
|
||||
|
||||
The app's lifespan seeds the three defaults, so any test that goes through
|
||||
`client` already has them. Tests about seeding want that; tests about the
|
||||
admin screen want to count their own rows.
|
||||
"""
|
||||
for suggestion in suggestions_service.all_of_them(db):
|
||||
db.delete(suggestion)
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plain_user(client: TestClient, db, registered) -> User:
|
||||
client.post("/auth/logout", follow_redirects=False)
|
||||
client.post(
|
||||
"/auth/register",
|
||||
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
return db.scalar(select(User).where(User.email == "sam@shire.test"))
|
||||
|
||||
|
||||
def _connection(db) -> None:
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="test-model"))
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- Seeding ------------------------------------------------------------------
|
||||
def test_seeding_writes_the_defaults_once(db):
|
||||
assert suggestions_service.seed_defaults(db) == len(suggestions_service.DEFAULTS)
|
||||
assert suggestions_service.seed_defaults(db) == 0
|
||||
assert len(suggestions_service.all_of_them(db)) == len(suggestions_service.DEFAULTS)
|
||||
|
||||
|
||||
def test_deleting_every_suggestion_does_not_bring_them_back(db):
|
||||
"""The guard is a flag, not "is the table empty". Otherwise an administrator
|
||||
who decided against them gets them back on every restart."""
|
||||
suggestions_service.seed_defaults(db)
|
||||
for suggestion in suggestions_service.all_of_them(db):
|
||||
db.delete(suggestion)
|
||||
db.commit()
|
||||
|
||||
assert suggestions_service.seed_defaults(db) == 0
|
||||
assert suggestions_service.all_of_them(db) == []
|
||||
|
||||
|
||||
def test_the_seed_flag_lives_in_settings(db):
|
||||
suggestions_service.seed_defaults(db)
|
||||
assert settings_store.get(db, suggestions_service.SEEDED_KEY) is True
|
||||
|
||||
|
||||
# --- What is shown ------------------------------------------------------------
|
||||
def test_only_enabled_ones_are_shown(db):
|
||||
suggestions_service.create(db, name="Shown", prompt="a")
|
||||
hidden = suggestions_service.create(db, name="Hidden", prompt="b")
|
||||
hidden.enabled = False
|
||||
db.commit()
|
||||
|
||||
assert [s.name for s in suggestions_service.visible(db)] == ["Shown"]
|
||||
|
||||
|
||||
def test_position_decides_the_order(db):
|
||||
first = suggestions_service.create(db, name="Zebra", prompt="a")
|
||||
second = suggestions_service.create(db, name="Antelope", prompt="b")
|
||||
first.position, second.position = 5, 1
|
||||
db.commit()
|
||||
|
||||
assert [s.name for s in suggestions_service.visible(db)] == ["Antelope", "Zebra"]
|
||||
|
||||
|
||||
def test_no_more_than_the_cap_is_shown(db):
|
||||
for index in range(suggestions_service.MAX_SHOWN + 3):
|
||||
suggestions_service.create(db, name=f"One {index}", prompt="x")
|
||||
assert len(suggestions_service.visible(db)) == suggestions_service.MAX_SHOWN
|
||||
|
||||
|
||||
def test_new_rows_land_at_the_end(db):
|
||||
suggestions_service.create(db, name="First", prompt="a")
|
||||
assert suggestions_service.create(db, name="Second", prompt="b").position == 1
|
||||
|
||||
|
||||
# --- The cards ----------------------------------------------------------------
|
||||
def test_the_cards_appear_on_the_new_chat_screen(client: TestClient, db, registered):
|
||||
_connection(db)
|
||||
suggestions_service.create(db, name="Explain this", description="Plain language.", prompt="Go ")
|
||||
|
||||
page = client.get("/chat").text
|
||||
assert "Explain this" in page
|
||||
assert "Plain language." in page
|
||||
assert 'data-suggestion="Go "' in page
|
||||
|
||||
|
||||
def test_the_cards_do_not_appear_in_an_existing_empty_chat(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""A chat someone opened on purpose already has a model and a prompt
|
||||
chosen; the cards are for the screen where nothing has been decided."""
|
||||
_connection(db)
|
||||
suggestions_service.create(db, name="Explain this", prompt="Go ")
|
||||
chat_id = make_chat()
|
||||
|
||||
assert "data-suggestion" not in client.get(f"/chat/{chat_id}").text
|
||||
|
||||
|
||||
def test_a_prompt_with_quotes_is_escaped_in_the_attribute(client: TestClient, db, registered):
|
||||
_connection(db)
|
||||
suggestions_service.create(db, name="Tricky", prompt='say "<script>alert(1)</script>"')
|
||||
|
||||
page = client.get("/chat").text
|
||||
assert "<script>alert(1)</script>" not in page
|
||||
assert "<script>" in page
|
||||
|
||||
|
||||
# --- Administration -----------------------------------------------------------
|
||||
def test_the_admin_page_is_refused_to_a_plain_user(client: TestClient, plain_user):
|
||||
assert client.get("/admin/suggestions").status_code == 403
|
||||
assert client.post("/admin/suggestions", data={"name": "x"}).status_code == 403
|
||||
|
||||
|
||||
def test_an_admin_can_create_edit_and_delete(client: TestClient, db, registered, clean):
|
||||
client.post(
|
||||
"/admin/suggestions",
|
||||
data={"name": "Explain this", "description": "Plain language.", "prompt": "Go "},
|
||||
follow_redirects=False,
|
||||
)
|
||||
suggestion = db.scalar(select(Suggestion))
|
||||
assert suggestion.name == "Explain this"
|
||||
assert suggestion.enabled is True
|
||||
|
||||
client.post(
|
||||
f"/admin/suggestions/{suggestion.id}",
|
||||
data={"name": "Renamed", "description": "", "prompt": "New ", "position": "3"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
db.refresh(suggestion)
|
||||
assert suggestion.name == "Renamed"
|
||||
assert suggestion.prompt == "New "
|
||||
# An unticked checkbox is simply absent from the post, which is the signal.
|
||||
assert suggestion.enabled is False
|
||||
assert suggestion.position == 2
|
||||
|
||||
client.post(f"/admin/suggestions/{suggestion.id}/delete", follow_redirects=False)
|
||||
assert db.scalar(select(Suggestion)) is None
|
||||
|
||||
|
||||
def test_a_nameless_suggestion_is_refused(client: TestClient, db, registered, clean):
|
||||
client.post("/admin/suggestions", data={"name": " "}, follow_redirects=False)
|
||||
assert db.scalar(select(Suggestion)) is None
|
||||
|
||||
|
||||
def test_the_limit_is_enforced(client: TestClient, db, registered, clean):
|
||||
for index in range(suggestions_service.MAX_SUGGESTIONS):
|
||||
suggestions_service.create(db, name=f"One {index}", prompt="x")
|
||||
|
||||
client.post("/admin/suggestions", data={"name": "One too many"}, follow_redirects=False)
|
||||
assert len(suggestions_service.all_of_them(db)) == suggestions_service.MAX_SUGGESTIONS
|
||||
|
||||
|
||||
def test_editing_something_that_is_gone_is_a_404(client: TestClient, registered):
|
||||
assert client.post("/admin/suggestions/nope", data={"name": "x"}).status_code == 404
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Temporary chats: hidden from the sidebar, and swept a day later."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from lembas.db.models import Chat, Connection, Folder, Message, Model
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services.crypto import encrypt
|
||||
|
||||
|
||||
def _connection(db) -> Connection:
|
||||
connection = Connection(
|
||||
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
db.add(Model(connection_id=connection.id, model_id="test-model"))
|
||||
db.commit()
|
||||
return connection
|
||||
|
||||
|
||||
def _temporary(db, chat_id: str, *, title: str = "Passing thought") -> Chat:
|
||||
chat = db.get(Chat, chat_id)
|
||||
chat.temporary = True
|
||||
chat.title = title
|
||||
db.commit()
|
||||
return chat
|
||||
|
||||
|
||||
# --- Hidden ------------------------------------------------------------------
|
||||
def test_a_temporary_chat_is_not_in_the_sidebar(client: TestClient, db, registered, make_chat):
|
||||
_connection(db)
|
||||
_temporary(db, make_chat())
|
||||
assert "Passing thought" not in client.get("/chat").text
|
||||
|
||||
|
||||
def test_a_temporary_chat_inside_a_folder_is_not_listed(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""The folder branch renders through the relationship, which is why this
|
||||
needs asserting separately from the unfiled list."""
|
||||
_connection(db)
|
||||
client.post("/api/folders", data={"name": "Quests"})
|
||||
folder = db.scalar(select(Folder))
|
||||
|
||||
chat_id = make_chat()
|
||||
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
|
||||
_temporary(db, chat_id)
|
||||
|
||||
page = client.get("/chat").text
|
||||
assert "Passing thought" not in page
|
||||
assert "Empty" in page
|
||||
|
||||
|
||||
def test_a_temporary_chat_gets_no_unread_dot(client: TestClient, db, registered, make_chat):
|
||||
"""There is no sidebar row for the dot, and the toast would name a chat
|
||||
nobody can navigate to."""
|
||||
_connection(db)
|
||||
chat = _temporary(db, make_chat())
|
||||
chat.unread = True
|
||||
db.commit()
|
||||
|
||||
response = client.get("/api/chats/unread")
|
||||
assert chat.id not in response.text
|
||||
assert "HX-Trigger" not in response.headers
|
||||
|
||||
|
||||
def test_user_chats_excludes_temporary_ones(db, registered, make_chat, user_id):
|
||||
_connection(db)
|
||||
_temporary(db, make_chat())
|
||||
assert chat_service.user_chats(db, user_id) == []
|
||||
|
||||
|
||||
def test_a_finished_temporary_reply_is_not_unread(db, registered, make_chat):
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
_temporary(db, chat_id)
|
||||
reply = Message(chat_id=chat_id, role="assistant", content="", complete=False)
|
||||
db.add(reply)
|
||||
db.commit()
|
||||
|
||||
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
|
||||
generation.content.append("Waybread.")
|
||||
generation_service._persist(generation, "", 0.0)
|
||||
|
||||
db.expire_all()
|
||||
assert db.get(Chat, chat_id).unread is False
|
||||
|
||||
|
||||
# --- Starting one -------------------------------------------------------------
|
||||
def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered):
|
||||
_connection(db)
|
||||
assert 'name="temporary"' not in client.get("/chat").text
|
||||
assert 'name="temporary"' in client.get("/chat?temporary=1").text
|
||||
|
||||
|
||||
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
|
||||
_connection(db)
|
||||
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
|
||||
assert db.scalar(select(Chat)).temporary is True
|
||||
|
||||
|
||||
def test_starting_an_ordinary_chat_does_not(client: TestClient, db, registered):
|
||||
_connection(db)
|
||||
client.post("/api/chats/start", data={"content": "hello"})
|
||||
assert db.scalar(select(Chat)).temporary is False
|
||||
|
||||
|
||||
# --- Keeping one --------------------------------------------------------------
|
||||
def test_keeping_a_chat_clears_the_flag(client: TestClient, db, registered, make_chat):
|
||||
_connection(db)
|
||||
chat = _temporary(db, make_chat())
|
||||
|
||||
response = client.post(f"/api/chats/{chat.id}/keep")
|
||||
assert response.status_code == 204
|
||||
assert response.headers["HX-Refresh"] == "true"
|
||||
|
||||
db.refresh(chat)
|
||||
assert chat.temporary is False
|
||||
assert "Passing thought" in client.get("/chat").text
|
||||
|
||||
|
||||
def test_keeping_someone_elses_chat_is_not_found(client: TestClient, db, registered, make_chat):
|
||||
from lembas.db.models import User
|
||||
from lembas.security.passwords import hash_password
|
||||
|
||||
_connection(db)
|
||||
chat = _temporary(db, make_chat())
|
||||
other = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
|
||||
db.add(other)
|
||||
db.commit()
|
||||
chat.user_id = other.id
|
||||
db.commit()
|
||||
|
||||
assert client.post(f"/api/chats/{chat.id}/keep").status_code == 404
|
||||
|
||||
|
||||
# --- The sweep ----------------------------------------------------------------
|
||||
def _aged(db, chat_id: str, hours: float) -> None:
|
||||
"""Backdate the chat's newest message, which is what the sweep measures."""
|
||||
when = datetime.now(UTC) - timedelta(hours=hours)
|
||||
message = Message(chat_id=chat_id, role="user", content="hello", created_at=when)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_the_sweep_removes_a_stale_temporary_chat(db, registered, make_chat):
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
_temporary(db, chat_id)
|
||||
_aged(db, chat_id, 25)
|
||||
|
||||
assert chat_service.sweep_temporary(db) == 1
|
||||
assert db.get(Chat, chat_id) is None
|
||||
|
||||
|
||||
def test_the_sweep_keeps_one_still_in_use(db, registered, make_chat):
|
||||
"""Age is measured from the newest message. A conversation still going at
|
||||
hour 23 must not vanish mid-sentence."""
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
_temporary(db, chat_id)
|
||||
_aged(db, chat_id, 40)
|
||||
_aged(db, chat_id, 0.5)
|
||||
|
||||
assert chat_service.sweep_temporary(db) == 0
|
||||
assert db.get(Chat, chat_id) is not None
|
||||
|
||||
|
||||
def test_the_sweep_leaves_ordinary_chats_alone(db, registered, make_chat):
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
_aged(db, chat_id, 500)
|
||||
|
||||
assert chat_service.sweep_temporary(db) == 0
|
||||
assert db.get(Chat, chat_id) is not None
|
||||
|
||||
|
||||
def test_a_temporary_chat_with_no_messages_ages_from_its_own_row(db, registered, make_chat):
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
chat = _temporary(db, chat_id)
|
||||
chat.created_at = datetime.now(UTC) - timedelta(hours=30)
|
||||
db.commit()
|
||||
|
||||
assert chat_service.sweep_temporary(db) == 1
|
||||
|
||||
|
||||
def test_the_sweep_unlinks_the_files_too(client: TestClient, db, registered, make_chat):
|
||||
"""Deleting a chat cascades the rows but leaves the files on disk. Anything
|
||||
that deletes chats has to remove them while the rows still say which."""
|
||||
from lembas.db.models import Attachment
|
||||
from lembas.services.files import stored_path
|
||||
|
||||
_connection(db)
|
||||
chat_id = make_chat()
|
||||
client.post("/api/files", files={"file": ("notes.txt", b"some words", "text/plain")})
|
||||
attachment = db.scalar(select(Attachment))
|
||||
attachment.chat_id = chat_id
|
||||
message = Message(chat_id=chat_id, role="user", content="look")
|
||||
db.add(message)
|
||||
db.commit()
|
||||
attachment.message_id = message.id
|
||||
db.commit()
|
||||
|
||||
path = stored_path(attachment.stored_name)
|
||||
assert path is not None and path.exists()
|
||||
|
||||
_temporary(db, chat_id)
|
||||
db.query(Message).filter(Message.id == message.id).update(
|
||||
{"created_at": datetime.now(UTC) - timedelta(hours=30)}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
chat_service.sweep_temporary(db)
|
||||
assert not path.exists()
|
||||
Reference in New Issue
Block a user