ai: ground answers in a live library map, budget the context
Response-quality work, all aimed at giving the model enough context to
answer accurately:
- Every chat's system prompt now carries a live library map (today's
date, counts, and the exact names of channels, domains, zims, and
inventories, capped and cached 60s). Exact spellings are in context
before the first token, and the list_collections round-trip — minutes
on slow hardware — is usually unnecessary.
- The request is budgeted against the model's real context: oldest
turns are trimmed first, the current question is never dropped, and
an over-budget turn gets a clear "start a new conversation" error
instead of a silently truncated prompt. cache_prompt reuses the KV
cache for the stable prefix, which is most of the latency on a Pi.
- context_size plumbing fixed: selecting a model adopts its catalog
default (the 4B was running at 8k instead of 16k), and llama-server
runs --parallel 1 so the single user gets the whole context.
- temperature is configurable in ai.yaml (default 0.3 — small models
call tools far more reliably near 0.2-0.3 than at server defaults).
- One worked tool-use example per mode is prepended to every
conversation; small models imitate far better than they obey.
- scripts/ai_eval.py scores the assistant against a live WROLPi on
mechanical checks (finished, called the right tool first, included a
WROLPi link, no external URLs) so prompt changes stop being validated
by anecdote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 16:48:19 -06:00
|
|
|
"""The library map: a compact, live overview of this WROLPi injected into every chat.
|
|
|
|
|
|
|
|
|
|
Small models answer from what is in context. Without this block the model must spend a tool
|
|
|
|
|
round-trip (minutes on slow hardware) just to learn what channels exist — and it still misread
|
|
|
|
|
spellings. With it, exact names, counts, and today's date are in front of the model before the
|
|
|
|
|
first token. Kept to a few hundred tokens: names are capped and the block is cached briefly."""
|
|
|
|
|
import time
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import func
|
|
|
|
|
|
|
|
|
|
from wrolpi.common import logger
|
|
|
|
|
from wrolpi.dates import now
|
|
|
|
|
from wrolpi.db import get_db_session
|
|
|
|
|
|
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
|
|
|
|
|
|
# At most this many names per category; a huge library lists the rest as a count.
|
|
|
|
|
MAX_NAMES = 30
|
|
|
|
|
CACHE_SECONDS = 60
|
|
|
|
|
|
|
|
|
|
_cache = dict(text=None, expires=0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _capped_names(names: List[str], label: str) -> str:
|
|
|
|
|
names = [i for i in names if i]
|
|
|
|
|
if not names:
|
|
|
|
|
return f'{label}: none'
|
|
|
|
|
listed = ', '.join(sorted(names)[:MAX_NAMES])
|
|
|
|
|
extra = f' (and {len(names) - MAX_NAMES} more)' if len(names) > MAX_NAMES else ''
|
|
|
|
|
return f'{label} ({len(names)}): {listed}{extra}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_library_context() -> str:
|
|
|
|
|
"""Build the library overview. Every query here must stay cheap (counts and name lists)."""
|
|
|
|
|
from modules.archive.models import Archive
|
|
|
|
|
from modules.docs.models import Doc
|
|
|
|
|
from modules.videos.models import Video
|
|
|
|
|
from modules.zim.models import Zim
|
|
|
|
|
from wrolpi.collections.models import Collection
|
|
|
|
|
|
|
|
|
|
lines = [f'Today is {now().strftime("%A, %B %d, %Y")}.', "The user's library contains:"]
|
|
|
|
|
with get_db_session() as session:
|
|
|
|
|
counts = {model.__name__: session.query(func.count(model.id)).scalar() or 0
|
|
|
|
|
for model in (Video, Archive, Doc)}
|
|
|
|
|
lines.append(f'- {counts["Video"]} videos, {counts["Archive"]} archived web pages,'
|
|
|
|
|
f' {counts["Doc"]} documents/ebooks.')
|
|
|
|
|
|
|
|
|
|
collections = session.query(Collection.kind, Collection.name).all()
|
|
|
|
|
channels = [name for kind, name in collections if kind == 'channel']
|
|
|
|
|
domains = [name for kind, name in collections if kind == 'domain']
|
|
|
|
|
lines.append(f'- {_capped_names(channels, "Video channels")}')
|
|
|
|
|
lines.append(f'- {_capped_names(domains, "Archived websites")}')
|
|
|
|
|
|
|
|
|
|
zims = session.query(Zim).all()
|
|
|
|
|
zim_titles = []
|
|
|
|
|
for zim in zims:
|
|
|
|
|
try:
|
|
|
|
|
zim_titles.append(zim.zim_metadata.title)
|
|
|
|
|
except Exception:
|
|
|
|
|
zim_titles.append(zim.path.name if zim.path else None)
|
|
|
|
|
lines.append(f'- {_capped_names(zim_titles, "Zim encyclopedias")}')
|
|
|
|
|
|
ai: kind-generic search_files / get_file / read_content
Small models carry every tool definition on every request, and the
per-kind trios (four searches, three getters, three readers) cost a
quarter of an 8k context while giving the model near-duplicate choices.
Three generic endpoints replace them:
- POST files/search: one search over videos, archives, and docs with an
optional kind; channel (name or id), domain, author, subject each
imply their kind and are ignored for other kinds. When the term also
names channels/domains/authors, they are returned as `matches` so the
model can narrow without a second tool. Channels resolve by name, so
the library map already in the prompt is enough: no list call first.
- GET files/{id}: detail by id whatever the kind; videos now report
has_captions/has_comments and the full description, archives keep
their snapshot history.
- GET files/{id}/content?part=text|comments: captions, page text, or
comments, paged; a wrong part for the kind says what the file is.
Inventories take an optional slug on GET /inventories (one tool lists
and fetches). Listings are slimmed: no derivable captions/text links,
no size/mimetype, duration as H:MM:SS, published as a date. The
library map gains the tag names.
wrolpi_mcp keeps every existing tool name; the wrappers now call the
generic routes, and search_files/get_file/read_content are exposed too.
The old per-kind endpoints remain until the modes switch over.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:09:50 -06:00
|
|
|
try:
|
|
|
|
|
from wrolpi import tags
|
|
|
|
|
lines.append(f'- {_capped_names([i["name"] for i in tags.get_tags()], "Tags")}')
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug('Library context could not list tags', exc_info=e)
|
|
|
|
|
|
ai: ground answers in a live library map, budget the context
Response-quality work, all aimed at giving the model enough context to
answer accurately:
- Every chat's system prompt now carries a live library map (today's
date, counts, and the exact names of channels, domains, zims, and
inventories, capped and cached 60s). Exact spellings are in context
before the first token, and the list_collections round-trip — minutes
on slow hardware — is usually unnecessary.
- The request is budgeted against the model's real context: oldest
turns are trimmed first, the current question is never dropped, and
an over-budget turn gets a clear "start a new conversation" error
instead of a silently truncated prompt. cache_prompt reuses the KV
cache for the stable prefix, which is most of the latency on a Pi.
- context_size plumbing fixed: selecting a model adopts its catalog
default (the 4B was running at 8k instead of 16k), and llama-server
runs --parallel 1 so the single user gets the whole context.
- temperature is configurable in ai.yaml (default 0.3 — small models
call tools far more reliably near 0.2-0.3 than at server defaults).
- One worked tool-use example per mode is prepended to every
conversation; small models imitate far better than they obey.
- scripts/ai_eval.py scores the assistant against a live WROLPi on
mechanical checks (finished, called the right tool first, included a
WROLPi link, no external URLs) so prompt changes stop being validated
by anecdote.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 16:48:19 -06:00
|
|
|
try:
|
|
|
|
|
from modules.inventory.common import get_inventory_configs
|
|
|
|
|
inventories = [i.get('name') for i in get_inventory_configs().all_inventories()]
|
|
|
|
|
lines.append(f'- {_capped_names(inventories, "Inventories")}')
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.debug('Library context could not list inventories', exc_info=e)
|
|
|
|
|
|
|
|
|
|
lines.append('Use the exact names above; user spellings may differ.')
|
|
|
|
|
return '\n'.join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_library_context() -> Optional[str]:
|
|
|
|
|
"""The cached library overview; None when it cannot be built (chat proceeds without it)."""
|
|
|
|
|
if _cache['text'] is not None and time.time() < _cache['expires']:
|
|
|
|
|
return _cache['text']
|
|
|
|
|
try:
|
|
|
|
|
text = build_library_context()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning('Could not build the library context', exc_info=e)
|
|
|
|
|
return _cache['text']
|
|
|
|
|
_cache.update(text=text, expires=time.time() + CACHE_SECONDS)
|
|
|
|
|
return text
|