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>
This commit is contained in:
Roland 2026-09-10 16:09:50 -06:00
parent faa3a03b37
commit cfce0a1224
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
10 changed files with 593 additions and 86 deletions

View file

@ -8,6 +8,7 @@ import asyncio
import mimetypes
import pathlib
from http import HTTPStatus
from typing import Optional
from sanic import Blueprint, Request
from sanic_ext import validate
@ -20,6 +21,7 @@ from modules.map import lib as map_lib
from modules.map import search as map_search
from modules.map.pins import get_map_pins_config
from modules.videos.channel.lib import search_channels_by_name
from modules.videos.models import Video
from modules.zim.models import Zims
from modules.docs.lib import _doc_response, _get_doc, _search_docs
from modules.inventory.common import get_inventory_configs
@ -34,6 +36,7 @@ from wrolpi.common import api_param_limiter, get_media_directory, get_relative_t
from wrolpi.downloader import download_manager
from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile, ValidationError
from wrolpi.files.lib import search_files, search_file_suggestion_count, HIDDEN_DIRECTORIES, HIDDEN_FILES
from wrolpi.files.models import FileGroup
from wrolpi.vars import DOCKERIZED, IS_RPI4
from wrolpi.version import __version__
from wrolpi.schema import JSONErrorResponse
@ -118,7 +121,7 @@ async def search_videos(_: Request, body: schema.AIVideoSearchRequest):
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_video(_: Request, file_group_id: int):
video, _prev, _next = videos_lib.get_video_for_app(file_group_id, skip_viewed=True)
result = lib.format_file_group(video, description_length=lib.DETAIL_DESCRIPTION_LENGTH)
result = lib.format_file_group(video, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
return json_response(result)
@ -195,20 +198,7 @@ async def search_archives(_: Request, body: schema.AIArchiveSearchRequest):
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_archive(request: Request, file_group_id: int):
archive = archive_lib.get_archive_by_file_group_id(request.ctx.session, file_group_id, skip_viewed=True)
result = lib.format_file_group(archive.file_group.__json__(),
description_length=lib.DETAIL_DESCRIPTION_LENGTH, include_url=True)
history = []
for snapshot in archive.history[:10]:
fg = snapshot.file_group.__json__()
history.append({k: v for k, v in dict(
id=fg.get('id'),
title=fg.get('title') or fg.get('name'),
published=fg.get('published_datetime'),
link=lib.wrolpi_link(fg),
).items() if v is not None})
if history:
result['history'] = history
return json_response(result)
return json_response(_archive_detail(archive))
@ai_bp.get('/archives/<file_group_id:int>/text')
@ -269,7 +259,7 @@ async def get_doc(request: Request, file_group_id: int):
file_group = response['file_group']
# Merge the Doc's details into the FileGroup dict so the lean formatter can use them.
file_group['doc'] = response['doc']
result = lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH)
result = lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
return json_response(result)
@ -372,36 +362,47 @@ async def list_collections(request: Request):
return json_response(dict(results=results, total=len(results)))
@ai_bp.get('/inventories')
@openapi.definition(
summary='List inventories',
description='List the inventories (food storage, emergency supplies, etc.). Use the slug with'
' get_inventory to read the items.',
)
@openapi.operation('list_inventories')
async def list_inventories(_: Request):
inventories = get_inventory_configs().all_inventories()
results = [{k: v for k, v in dict(
def _lean_inventories() -> list:
return [{k: v for k, v in dict(
slug=i.get('slug'),
name=i.get('name'),
type=i.get('type'),
item_count=len(i.get('items') or []),
).items() if v is not None} for i in inventories]
).items() if v is not None} for i in get_inventory_configs().all_inventories()]
def _inventory_by_slug(slug: str) -> dict:
inventory = get_inventory_configs().get_inventory(slug)
if inventory is None:
raise UnknownInventory(f'No inventory: {slug}')
return inventory
@ai_bp.get('/inventories')
@openapi.definition(
summary='Get inventories',
description='Without slug: list the inventories (food storage, emergency supplies, etc.) with item counts.'
' With slug: that inventory in full, every item and field.',
)
@openapi.operation('get_inventory')
@openapi.parameter('slug', str, 'query')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_inventory(request: Request):
if slug := request.args.get('slug'):
return json_response(dict(inventory=_inventory_by_slug(slug)))
results = _lean_inventories()
return json_response(dict(results=results, total=len(results)))
@ai_bp.get('/inventories/<slug:str>')
@openapi.definition(
summary='Get one inventory',
description='Get one inventory in full (its fields and every item) by its slug from list_inventories.',
description='Get one inventory in full (its fields and every item) by its slug.',
)
@openapi.operation('get_inventory')
@openapi.operation('get_inventory_items')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_inventory(_: Request, slug: str):
inventory = get_inventory_configs().get_inventory(slug)
if inventory is None:
raise UnknownInventory(f'No inventory: {slug}')
return json_response(dict(inventory=inventory))
async def get_inventory_items(_: Request, slug: str):
return json_response(dict(inventory=_inventory_by_slug(slug)))
# ---------------------------------------------------------------------------
@ -773,6 +774,213 @@ async def search_suggestions(request: Request, body: schema.AISearchSuggestionsR
))
# ---------------------------------------------------------------------------
# Consolidated, kind-generic endpoints. Small models carry every tool definition on every request,
# so one search / one detail / one reader replaces the per-kind trio.
# ---------------------------------------------------------------------------
FILE_KINDS = ('video', 'archive', 'doc')
# Name matches offered alongside search results so the model can narrow without another tool.
MATCHES_PER_GROUP = 3
def _archive_detail(archive) -> dict:
result = lib.format_file_group(archive.file_group.__json__(),
description_length=lib.DETAIL_DESCRIPTION_LENGTH, include_url=True, detail=True)
history = []
for snapshot in archive.history[:10]:
fg = snapshot.file_group.__json__()
history.append({k: v for k, v in dict(
id=fg.get('id'),
title=fg.get('title') or fg.get('name'),
published=lib.format_date(fg.get('published_datetime')),
link=lib.wrolpi_link(fg),
).items() if v is not None})
if history:
result['history'] = history
return result
def _video_detail(video: Video) -> dict:
fg = video.__json__()
try:
fg['video']['description'] = video.get_description()
except Exception as e:
logger.debug(f'Could not read description of {video}', exc_info=e)
result = lib.format_file_group(fg, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
result['has_captions'] = bool(video.caption_paths)
if video.have_comments:
result['has_comments'] = True
return result
def _doc_detail(session, file_group_id: int) -> dict:
response = _doc_response(_get_doc(session, file_group_id))
file_group = response['file_group']
file_group['doc'] = response['doc']
return lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
async def _resolve_channel_id(session, channel: str) -> Optional[int]:
"""A channel given as a numeric id or a (partial) name; None when nothing matches."""
channel = (channel or '').strip()
if not channel:
return None
if channel.isdigit():
return int(channel)
matches = await search_channels_by_name(session, channel, limit=1, order_by_video_count=True)
return matches[0].id if matches else None
@ai_bp.post('/files/search')
@openapi.definition(
summary='Search the library',
description='Search videos, archived web pages, and documents/ebooks by title and content. Omit search_str to'
' browse the newest items. kind narrows to video, archive, or doc. channel (a channel name from'
' the library list, or its id) applies to videos; domain (a site name) to archives; author and'
' subject to documents; a filter for another kind is ignored. Results carry an id for get_file'
' and read_content, and a WROLPi link. matches lists channels/domains/authors whose names fit'
' the term: use one to narrow when total is large.',
body=schema.AIFileSearchRequest,
)
@openapi.operation('search_files')
@openapi.response(HTTPStatus.OK, schema.AISearchResponse)
@validate(schema.AIFileSearchRequest)
async def search_files_endpoint(request: Request, body: schema.AIFileSearchRequest):
session = request.ctx.session
kind = (body.kind or '').strip().lower() or None
if kind and kind not in FILE_KINDS:
raise ValidationError(f'kind must be one of {", ".join(FILE_KINDS)}')
# A kind-specific filter implies the kind.
if not kind:
if body.channel:
kind = 'video'
elif body.domain:
kind = 'archive'
elif body.author or body.subject:
kind = 'doc'
limit = ai_limiter(body.limit)
offset = body.offset or 0
searched = bool(body.search_str)
narrowed = False
if kind == 'video':
channel_id = None
if body.channel:
channel_id = await _resolve_channel_id(session, body.channel)
if channel_id is None:
result = lib.format_file_groups([], 0, searched=True)
result['hint'] = (f'No channel matches "{body.channel}". Use the exact channel name from the'
' library list, or search without channel.')
return json_response(result)
narrowed = True
file_groups, total = videos_lib.search_videos(
search_str=body.search_str, offset=offset, limit=limit, channel_id=channel_id,
tag_names=body.tag_names, headline=True)
elif kind == 'archive':
narrowed = bool(body.domain)
file_groups, total = archive_lib.search_archives(
body.search_str, body.domain, limit, offset, None, body.tag_names, headline=True)
elif kind == 'doc':
narrowed = bool(body.author or body.subject)
file_groups, total = _search_docs(
search_str=body.search_str, author=body.author, subject=body.subject, limit=limit, offset=offset,
order_by='rank' if body.search_str else 'published_datetime', tag_names=body.tag_names)
else:
file_groups, total = search_files(body.search_str, limit, offset, tag_names=body.tag_names, headline=True)
result = lib.format_file_groups(file_groups, total, searched=searched)
if searched and not narrowed:
matches = dict()
channels = await search_channels_by_name(session, body.search_str, limit=MATCHES_PER_GROUP,
order_by_video_count=True)
if channels and kind in (None, 'video'):
matches['channels'] = [dict(id=i.id, name=i.name) for i in channels]
domains = await search_domains_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
if domains and kind in (None, 'archive'):
matches['domains'] = [dict(name=i['domain']) for i in domains]
if kind in (None, 'doc'):
authors = await search_authors_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
subjects = await search_subjects_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
if authors:
matches['authors'] = [dict(name=i['name']) for i in authors]
if subjects:
matches['subjects'] = [dict(name=i['name']) for i in subjects]
if matches:
result['matches'] = matches
return json_response(result)
@ai_bp.get('/files/<file_group_id:int>')
@openapi.definition(
summary='Get one file',
description='Details of one item by the id from search results, whatever its kind: title, link, date,'
' channel or author, tags, size, full description; for videos whether captions and comments'
' exist; for archived pages the source URL and earlier snapshots.',
)
@openapi.operation('get_file')
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_file(request: Request, file_group_id: int):
session = request.ctx.session
file_group = FileGroup.find_by_id(session, file_group_id)
if file_group.model == 'video':
return json_response(_video_detail(Video.find_by_file_group_id(session, file_group_id)))
if file_group.model == 'archive':
archive = archive_lib.get_archive_by_file_group_id(session, file_group_id, skip_viewed=True)
return json_response(_archive_detail(archive))
if file_group.model == 'doc':
return json_response(_doc_detail(session, file_group_id))
return json_response(lib.format_file_group(file_group.__json__(),
description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True))
CONTENT_PARTS = ('text', 'comments')
@ai_bp.get('/files/<file_group_id:int>/content')
@openapi.definition(
summary='Read a file\'s content',
description='Read what an item says, by its id: part=text gives a video\'s timestamped captions or an'
' archived page\'s plain text; part=comments gives a video\'s downloaded comments. Long content'
' is paged: request again with offset=next_offset to continue.',
)
@openapi.operation('read_content')
@openapi.parameter('part', str, 'query')
@openapi.parameter('offset', int, 'query')
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def read_content(request: Request, file_group_id: int):
part = (request.args.get('part') or 'text').strip().lower()
if part not in CONTENT_PARTS:
raise ValidationError(f'part must be one of {", ".join(CONTENT_PARTS)}')
session = request.ctx.session
file_group = FileGroup.find_by_id(session, file_group_id)
kind = file_group.model or 'file'
text = None
if kind == 'video':
video = Video.find_by_file_group_id(session, file_group_id)
if part == 'text':
text = lib.format_caption_chunks(video.get_caption_chunks())
else:
lines = []
for comment in video.get_comments() or []:
author = comment.get('author') or 'unknown'
if body := (comment.get('text') or '').strip():
lines.append(f'{author}: {body}')
text = '\n'.join(lines)
elif kind == 'archive' and part == 'text':
archive = archive_lib.get_archive_by_file_group_id(session, file_group_id, skip_viewed=True)
text = lib.read_archive_text(archive)
if text is None:
raise UnknownFile(f'This is a {kind}; it has no {part} to read')
return json_response(lib.paginate_text(text, _offset(request)))
# ---------------------------------------------------------------------------
# Manage endpoints — for the AI page's Manage tab, NOT part of the model's tool catalog.
# The agent loop excludes everything under /api/ai/manage/ when building tool definitions.

View file

@ -61,6 +61,12 @@ def build_library_context() -> str:
zim_titles.append(zim.path.name if zim.path else None)
lines.append(f'- {_capped_names(zim_titles, "Zim encyclopedias")}')
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)
try:
from modules.inventory.common import get_inventory_configs
inventories = [i.get('name') for i in get_inventory_configs().all_inventories()]

View file

@ -73,18 +73,43 @@ def wrolpi_link(fg: dict) -> Optional[str]:
def _truncate(text: Optional[str], length: int) -> Optional[str]:
if text and len(text) > length:
return text[:length] + '…'
return text
if not text:
return None
return text if len(text) <= length else text[:length] + '…'
def format_duration(seconds) -> Optional[str]:
"""Seconds as H:MM:SS (or M:SS), which a model reads more reliably than raw seconds."""
try:
seconds = int(seconds)
except (TypeError, ValueError):
return None
if seconds < 0:
return None
hours, rest = divmod(seconds, 3600)
minutes, secs = divmod(rest, 60)
return f'{hours}:{minutes:02d}:{secs:02d}' if hours else f'{minutes}:{secs:02d}'
def format_date(value) -> Optional[str]:
"""Only the date of a datetime (or ISO string); the time of day is noise for the model."""
if not value:
return None
if hasattr(value, 'date'):
return value.date().isoformat()
return str(value)[:10]
def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LENGTH,
include_url: bool = False) -> dict:
include_url: bool = False, detail: bool = False) -> dict:
"""Format a FileGroup JSON dict into the lean, flat shape the model consumes.
Empty fields are omitted so results stay compact on small contexts. The source URL is
omitted unless requested (archive detail): given both, small models present the familiar
external URL instead of the WROLPi link.
Listings (detail=False) carry only what the model needs to choose an item: id, kind, title,
link, date, channel/author, duration, tags, and the search headline. Details (detail=True)
add size, mimetype, and the longer description. Everything the model can derive from the id
(captions/text endpoints) is left out. Empty fields are omitted so results stay compact on
small contexts. The source URL is omitted unless requested (archive detail): given both,
small models present the familiar external URL instead of the WROLPi link.
"""
kind = file_group_kind(fg)
result = dict(
@ -92,13 +117,14 @@ def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LE
kind=kind,
title=fg.get('title') or fg.get('name'),
link=wrolpi_link(fg),
mimetype=fg.get('mimetype'),
size=fg.get('size'),
published=fg.get('published_datetime'),
published=format_date(fg.get('published_datetime')),
author=fg.get('author'),
tags=fg.get('tags') or None,
url=fg.get('url') if include_url else None,
)
if detail:
result['mimetype'] = fg.get('mimetype')
result['size'] = fg.get('size')
# FTS headline (content match context), if the search requested headlines.
headline = fg.get('d_headline') or fg.get('b_headline') or fg.get('c_headline')
@ -107,8 +133,7 @@ def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LE
video = fg.get('video')
if kind == 'video':
result['duration'] = fg.get('length')
result['captions_link'] = f'/api/ai/videos/{fg["id"]}/captions' if fg.get('id') else None
result['duration'] = format_duration(fg.get('length'))
if isinstance(video, dict):
channel = video.get('channel')
if isinstance(channel, dict):
@ -117,9 +142,6 @@ def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LE
result['channel_id'] = video['channel_id']
result['description'] = _truncate(video.get('description'), description_length)
if kind == 'archive' and fg.get('id'):
result['text_link'] = f'/api/ai/archives/{fg["id"]}/text'
if kind == 'doc':
doc = fg.get('doc') or {}
result['subject'] = doc.get('subject')

View file

@ -56,7 +56,6 @@ MODES = {
'search_zims',
'get_zim_entry',
'list_collections',
'list_inventories',
'get_inventory',
'list_files',
'read_file',

View file

@ -89,3 +89,16 @@ class AIListFilesResponse:
class AISearchSuggestionsRequest:
search_str: Optional[str] = None
tag_names: List[str] = dataclasses.field(default_factory=list)
@dataclasses.dataclass
class AIFileSearchRequest:
search_str: Optional[str] = None
kind: Optional[str] = None
channel: Optional[str] = None
domain: Optional[str] = None
author: Optional[str] = None
subject: Optional[str] = None
tag_names: Optional[List[str]] = None
limit: int = 5
offset: int = 0

View file

@ -57,7 +57,7 @@ async def test_ai_search_videos(async_client, test_session, video_factory):
result = response.json['results'][0]
assert result['kind'] == 'video'
assert result['link'] == f'/videos/{result["id"]}'
assert result['captions_link'] == f'/api/ai/videos/{result["id"]}/captions'
assert 'captions_link' not in result # derivable from the id; kept out of listings
@pytest.mark.asyncio
@ -113,7 +113,7 @@ async def test_ai_search_archives(async_client, test_session, archive_factory):
assert response.json['total'] == 1
result = response.json['results'][0]
assert result['kind'] == 'archive'
assert result['text_link'] == f'/api/ai/archives/{result["id"]}/text'
assert 'text_link' not in result # derivable from the id; kept out of listings
@pytest.mark.asyncio
@ -519,3 +519,176 @@ def test_ai_format_download_error_tail():
assert formatted['error'].endswith('ValueError: the real reason')
assert len(formatted['error']) == DOWNLOAD_ERROR_LENGTH + 1
assert 'error' not in _format_download(dict(id=2, url='https://example.com', status='new', error=None))
# ---------------------------------------------------------------------------
# Consolidated, kind-generic endpoints for small models.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ai_search_files_kinds(async_client, test_session, video_factory, archive_factory, channel_factory):
"""One search covers every kind; kind narrows it; channel accepts a name or an id."""
channel = channel_factory(name='Wild Cooking')
video_factory(channel_id=channel.id, title='cooking rice')
video_factory(title='cooking beans video')
archive_factory(domain='cooking.example.com', title='cooking beans', contents='beans')
test_session.commit()
async def search(**body):
request, response = await async_client.post('/api/ai/files/search', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK, response.json
return response.json
data = await search(search_str='cooking')
assert data['total'] == 3
assert {i['kind'] for i in data['results']} == {'video', 'archive'}
# The matching channel and domain are offered so the model can narrow without another tool.
assert [i['name'] for i in data['matches']['channels']] == ['Wild Cooking']
assert data['matches']['channels'][0]['id'] == channel.id
assert [i['name'] for i in data['matches']['domains']] == ['cooking.example.com']
data = await search(search_str='cooking', kind='video')
assert data['total'] == 2 and all(i['kind'] == 'video' for i in data['results'])
data = await search(search_str='cooking', kind='archive')
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
# channel by exact name, partial name, and numeric id; channel implies kind=video.
for channel_ref in ('Wild Cooking', 'wild', str(channel.id)):
data = await search(search_str='cooking', channel=channel_ref)
assert data['total'] == 1, channel_ref
assert data['results'][0]['title'] == 'cooking rice'
assert 'matches' not in data # already narrowed to one channel
# domain implies kind=archive.
data = await search(domain='cooking.example.com')
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
# A filter that does not apply to the kind is ignored, not fatal.
data = await search(search_str='cooking', kind='archive', channel='Wild Cooking')
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
# An unknown channel name gives no results and a hint, not an error.
data = await search(search_str='cooking', channel='No Such Channel')
assert data['total'] == 0 and data['results'] == []
assert 'channel' in data['hint'].lower()
# Listings are slim: ids, titles, links, kind; no links the model can derive.
result = data = (await search(search_str='cooking', kind='video'))['results'][0]
assert {'id', 'kind', 'title', 'link'} <= set(result)
assert 'captions_link' not in result and 'size' not in result and 'mimetype' not in result
# Something to search by is required.
request, response = await async_client.post('/api/ai/files/search', content=json.dumps({}))
assert response.status_code == HTTPStatus.OK # browse newest
assert response.json['total'] == 3
@pytest.mark.asyncio
async def test_ai_search_files_docs(async_client, test_session, test_directory, example_epub, refresh_files):
"""kind=doc searches documents; author/subject imply kind=doc."""
await refresh_files()
request, response = await async_client.post('/api/ai/files/search', content=json.dumps(dict(search_str='WROLPi', kind='doc')))
assert response.status_code == HTTPStatus.OK
assert response.json['total'] >= 1
doc = response.json['results'][0]
assert doc['kind'] == 'doc' and doc['link'] == f'/docs/{doc["id"]}'
# get_file returns the doc detail through the generic route.
request, response = await async_client.get(f'/api/ai/files/{doc["id"]}')
assert response.status_code == HTTPStatus.OK
assert response.json['kind'] == 'doc' and response.json['id'] == doc['id']
@pytest.mark.asyncio
async def test_ai_get_file(async_client, test_session, video_factory, archive_factory, make_files_structure, tag_factory):
"""get_file dispatches on the file's model and carries more than the listing row."""
tag = await tag_factory('food')
video = video_factory(title='wood stove install', with_caption_file=True)
video.file_group.add_tag(test_session, tag.id)
archive_factory(domain='example.com', url='https://example.com/a', title='first', contents='one')
archive = archive_factory(domain='example.com', url='https://example.com/a', title='second', contents='two')
test_session.commit()
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}')
assert response.status_code == HTTPStatus.OK
assert response.json['kind'] == 'video'
assert response.json['link'] == f'/videos/{video.file_group_id}'
assert response.json['has_captions'] is True
assert response.json['tags'] == ['food']
assert response.json['mimetype'].startswith('video/')
# Fetching does not mark the video viewed.
test_session.expire_all()
assert video.file_group.viewed is None
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}')
assert response.status_code == HTTPStatus.OK
assert response.json['kind'] == 'archive'
assert response.json['url'] == 'https://example.com/a'
assert [i['title'] for i in response.json['history']] == ['first']
# A plain file (no model) still resolves with a media link.
from wrolpi.files.lib import upsert_file
path, = make_files_structure({'photos/a.png': 'not really a png'})
fg = await upsert_file(path)
request, response = await async_client.get(f'/api/ai/files/{fg.id}')
assert response.status_code == HTTPStatus.OK
assert response.json['link'] == '/media/photos/a.png'
request, response = await async_client.get('/api/ai/files/123456')
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_ai_read_content(async_client, test_session, video_factory, archive_factory):
"""read_content serves captions/comments for videos and text for archives, paged."""
info_json = dict(duration=5, comments=[dict(author='alice', text='great video')])
video = video_factory(title='captioned', with_caption_file=True, with_info_json=info_json)
contents = 'word ' * 2_000
archive = archive_factory(domain='example.com', title='long read', contents=contents)
test_session.commit()
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content')
assert response.status_code == HTTPStatus.OK
assert response.json['content'].startswith('[00:00:0')
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?part=comments')
assert response.status_code == HTTPStatus.OK
assert 'alice: great video' in response.json['content']
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}/content')
assert response.status_code == HTTPStatus.OK
assert len(response.json['content']) == lib.PAGE_SIZE
assert response.json['next_offset'] == lib.PAGE_SIZE
request, response = await async_client.get(
f'/api/ai/files/{archive.file_group_id}/content?offset={response.json["next_offset"]}')
assert response.json['content'] == contents[lib.PAGE_SIZE:2 * lib.PAGE_SIZE]
# An archive has no comments; the error says what the file is.
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}/content?part=comments')
assert response.status_code == HTTPStatus.NOT_FOUND
assert 'archive' in response.json['error'].lower()
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?part=bogus')
assert response.status_code == HTTPStatus.BAD_REQUEST
request, response = await async_client.get('/api/ai/files/123456/content')
assert response.status_code == HTTPStatus.NOT_FOUND
@pytest.mark.asyncio
async def test_ai_get_inventory_optional_slug(async_client, food_inventory_factory):
"""Without a slug the inventories are listed lean; with one, the inventory is returned in full."""
slug = food_inventory_factory(items=[dict(name='rice', count=2)])
request, response = await async_client.get('/api/ai/inventories')
assert response.status_code == HTTPStatus.OK
lean = next(i for i in response.json['results'] if i['slug'] == slug)
assert lean['item_count'] == 1 and 'items' not in lean
request, response = await async_client.get(f'/api/ai/inventories?slug={slug}')
assert response.status_code == HTTPStatus.OK
assert response.json['inventory']['items'][0]['name'] == 'rice'
request, response = await async_client.get('/api/ai/inventories?slug=no-such-inventory')
assert response.status_code == HTTPStatus.NOT_FOUND

View file

@ -9,10 +9,11 @@ from modules.ai import context
@pytest.mark.asyncio
async def test_build_library_context(async_client, test_session, video_factory, archive_factory,
test_zim, food_inventory_factory):
test_zim, food_inventory_factory, tag_factory):
"""The map carries today's date, counts, and exact names."""
from wrolpi.collections.models import Collection
test_session.add(Collection(name='Talking Sasquach', kind='channel'))
await tag_factory('Food Preservation')
video_factory(title='a video')
archive_factory(domain='example.com', title='a page', contents='contents')
food_inventory_factory(name='Pantry')
@ -25,6 +26,7 @@ async def test_build_library_context(async_client, test_session, video_factory,
assert 'example.com' in text
assert 'WROLPi example ZIM' in text
assert 'Pantry' in text
assert 'Tags (1): Food Preservation' in text
assert 'user spellings may differ' in text

View file

@ -53,15 +53,21 @@ def test_format_file_group_video():
assert result['id'] == 42
assert result['kind'] == 'video'
assert result['link'] == '/videos/42'
assert result['captions_link'] == '/api/ai/videos/42/captions'
assert result['channel'] == 'Homesteading'
assert result['duration'] == 4021
assert result['duration'] == '1:07:01'
assert result['published'] == '2024-01-02'
assert result['headline'] == 'pressure <b>canning</b>'
assert result['tags'] == ['food']
# Listing descriptions are truncated.
assert len(result['description']) == lib.LISTING_DESCRIPTION_LENGTH + 1
# Empty fields are omitted.
assert 'author' not in result and 'url' not in result
# Listings leave out what the model can derive or does not need to choose an item.
assert 'captions_link' not in result and 'size' not in result and 'mimetype' not in result
# Details add them back.
detail = lib.format_file_group(fg, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
assert detail['size'] == 1000 and detail['mimetype'] == 'video/mp4'
assert len(detail['description']) == 500
def test_format_file_group_archive():
@ -69,7 +75,7 @@ def test_format_file_group_archive():
result = lib.format_file_group(fg)
assert result['kind'] == 'archive'
assert result['link'] == '/archives/8'
assert result['text_link'] == '/api/ai/archives/8/text'
assert 'text_link' not in result
# Listings omit the source URL so small models present the WROLPi link instead.
assert 'url' not in result
assert lib.format_file_group(fg, include_url=True)['url'] == 'https://example.com/a'
@ -109,6 +115,17 @@ def test_html_to_text():
assert 'evil' not in text and 'menu' not in text and '.x{}' not in text
def test_format_duration_and_date():
assert lib.format_duration(59) == '0:59'
assert lib.format_duration(692) == '11:32'
assert lib.format_duration(4021) == '1:07:01'
assert lib.format_duration(None) is None and lib.format_duration('x') is None
import datetime
assert lib.format_date(datetime.datetime(2024, 1, 2, 13, 4, 5)) == '2024-01-02'
assert lib.format_date('2024-01-02T13:04:05+00:00') == '2024-01-02'
assert lib.format_date(None) is None
def test_format_caption_chunks():
chunks = [dict(start_seconds=0, text='hello'), dict(start_seconds=3671, text='goodbye')]
text = lib.format_caption_chunks(chunks)

View file

@ -19,6 +19,7 @@ EXPECTED_TOOLS = {
'list_collections', 'list_zim_files', 'list_files',
'get_statistics', 'get_inventory', 'get_inventory_items', 'get_status',
'list_tags', 'search_suggestions', 'list_downloads', 'get_map_overview', 'search_places',
'search_files', 'get_file', 'read_content',
}

View file

@ -44,6 +44,8 @@ _FIELD_LABELS = (
('duration', 'Duration'),
('published', 'Published'),
('channel', 'Channel'),
('has_captions', 'Has captions'),
('has_comments', 'Has comments'),
('author', 'Author'),
('subject', 'Subject'),
('language', 'Language'),
@ -54,9 +56,9 @@ _FIELD_LABELS = (
)
_KIND_HINTS = {
'video': 'use this for get_video, get_video_captions, get_video_comments',
'archive': 'use this for get_archive, get_archive_text',
'doc': 'use this for get_doc',
'video': 'use this for get_file, read_content (captions/comments)',
'archive': 'use this for get_file, read_content (page text)',
'doc': 'use this for get_file',
}
@ -84,6 +86,10 @@ def _render_result(item: dict) -> str:
parts.append(f'{label}: {value}')
if tags := item.get('tags'):
parts.append(f'Tags: {", ".join(map(str, tags))}')
if history := item.get('history'):
parts.append(f'Earlier snapshots ({len(history)}):')
for snapshot in history:
parts.append(f' - {snapshot.get("title", "Untitled")} (ID: {snapshot.get("id")}, {snapshot.get("published", "?")})')
return '\n'.join(parts)
@ -95,6 +101,13 @@ def _render_results(data: dict) -> str:
text = '\n\n'.join(sections)
if (total := data.get('total')) is not None:
text += f'\n\nTotal matching: {total}'
if matches := data.get('matches'):
text += '\n\nNames matching the term (use to narrow the search):'
for channel in matches.get('channels') or []:
text += f"\n channel: {channel.get('name')} (ID: {channel.get('id')})"
for group in ('domains', 'authors', 'subjects'):
for item in matches.get(group) or []:
text += f"\n {group[:-1]}: {item.get('name')}"
return text
@ -165,11 +178,14 @@ async def search(
tag_names: Filter by tag names.
"""
body = {"search_str": query, "limit": limit, "offset": offset}
if mimetypes:
body["mimetypes"] = mimetypes
if tag_names:
body["tag_names"] = tag_names
return _render_results(await api_post("/api/ai/search", json=body))
# mimetypes are no longer a filter; a kind is. Map the common prefixes.
if mimetypes:
kinds = {("video" if m.startswith("video/") else "archive" if m == "text/html" else "doc") for m in mimetypes}
if len(kinds) == 1:
body["kind"] = kinds.pop()
return _render_results(await api_post("/api/ai/files/search", json=body))
@mcp.tool(annotations={"readOnlyHint": True})
@ -189,12 +205,12 @@ async def search_videos(
channel_id: Filter to a specific channel by ID.
tag_names: Filter by tag names.
"""
body = {"search_str": query, "limit": limit, "offset": offset}
body = {"search_str": query, "limit": limit, "offset": offset, "kind": "video"}
if channel_id is not None:
body["channel_id"] = channel_id
body["channel"] = str(channel_id)
if tag_names:
body["tag_names"] = tag_names
return _render_results(await api_post("/api/ai/videos/search", json=body))
return _render_results(await api_post("/api/ai/files/search", json=body))
@mcp.tool(annotations={"readOnlyHint": True})
@ -214,12 +230,12 @@ async def search_archives(
domain: Filter to a specific domain (e.g. "example.com").
tag_names: Filter by tag names.
"""
body = {"search_str": query, "limit": limit, "offset": offset}
body = {"search_str": query, "limit": limit, "offset": offset, "kind": "archive"}
if domain:
body["domain"] = domain
if tag_names:
body["tag_names"] = tag_names
return _render_results(await api_post("/api/ai/archives/search", json=body))
return _render_results(await api_post("/api/ai/files/search", json=body))
@mcp.tool(annotations={"readOnlyHint": True})
@ -247,20 +263,17 @@ async def search_docs(
offset: Pagination offset.
tag_names: Filter by tag names.
"""
body = {"limit": limit, "offset": offset}
body = {"limit": limit, "offset": offset, "kind": "doc"}
if query:
body["search_str"] = query
if author:
body["author"] = author
if subject:
body["subject"] = subject
if language:
body["language"] = language
if mimetype:
body["mimetype"] = mimetype
if tag_names:
body["tag_names"] = tag_names
return _render_results(await api_post("/api/ai/docs/search", json=body))
# language and mimetype are accepted for compatibility; the consolidated search does not filter on them.
return _render_results(await api_post("/api/ai/files/search", json=body))
@mcp.tool(annotations={"readOnlyHint": True})
@ -270,7 +283,7 @@ async def get_doc(file_group_id: int) -> str:
Args:
file_group_id: The top-level ID from search results (this is the FileGroup ID).
"""
return _render_result(await api_get(f"/api/ai/docs/{file_group_id}"))
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
@mcp.tool(annotations={"readOnlyHint": True})
@ -331,7 +344,7 @@ async def get_video(file_group_id: int) -> str:
Args:
file_group_id: The FileGroup ID from search results.
"""
return _render_result(await api_get(f"/api/ai/videos/{file_group_id}"))
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
@mcp.tool(annotations={"readOnlyHint": True})
@ -341,7 +354,7 @@ async def get_video_captions(file_group_id: int) -> str:
Args:
file_group_id: The FileGroup ID from search results.
"""
text = await _read_paged(f"/api/ai/videos/{file_group_id}/captions")
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": "text"})
return text or "No captions available for this video."
@ -352,7 +365,7 @@ async def get_video_comments(file_group_id: int) -> str:
Args:
file_group_id: The FileGroup ID from search results.
"""
text = await _read_paged(f"/api/ai/videos/{file_group_id}/comments")
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": "comments"})
return text or "No comments available for this video."
@ -363,13 +376,7 @@ async def get_archive(file_group_id: int) -> str:
Args:
file_group_id: The FileGroup ID from search results.
"""
data = await api_get(f"/api/ai/archives/{file_group_id}")
parts = [_render_result(data)]
if history := data.get("history"):
parts.append(f"\nArchive history ({len(history)} snapshots):")
for snapshot in history:
parts.append(f" - {snapshot.get('title', 'Untitled')} (ID: {snapshot.get('id')})")
return "\n".join(parts)
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
@mcp.tool(annotations={"readOnlyHint": True})
@ -382,7 +389,7 @@ async def get_archive_text(file_group_id: int) -> str:
file_group_id: The FileGroup ID from search results.
"""
try:
text = await _read_paged(f"/api/ai/archives/{file_group_id}/text")
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": "text"})
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"Could not retrieve text content for archive {file_group_id}. Try get_archive for metadata."
@ -415,6 +422,65 @@ async def get_zim_entry(zim_id: int, entry_path: str) -> str:
return f"LINK: {link}\n\n{text}"
# ---------------------------------------------------------------------------
# Kind-generic tools (the compact set the built-in assistant uses)
# ---------------------------------------------------------------------------
@mcp.tool(annotations={"readOnlyHint": True})
async def search_files(
query: str | None = None,
kind: str | None = None,
channel: str | None = None,
domain: str | None = None,
author: str | None = None,
subject: str | None = None,
tag_names: list[str] | None = None,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> str:
"""Search the whole library (videos, archived web pages, documents/ebooks) in one call.
Args:
query: Text to search for in titles and content. Omit to browse the newest items.
kind: Narrow to "video", "archive", or "doc".
channel: A video channel name (or id); implies kind=video.
domain: An archived site name, e.g. "example.com"; implies kind=archive.
author: Document author (partial match); implies kind=doc.
subject: Document subject (partial match); implies kind=doc.
tag_names: Only items with these tags.
limit: Maximum results.
offset: Pagination offset.
"""
body = {k: v for k, v in dict(search_str=query, kind=kind, channel=channel, domain=domain, author=author,
subject=subject, tag_names=tag_names, limit=limit, offset=offset).items()
if v not in (None, [], "")}
return _render_results(await api_post("/api/ai/files/search", json=body))
@mcp.tool(annotations={"readOnlyHint": True})
async def get_file(file_group_id: int) -> str:
"""Get details about one item of any kind (video, archived page, document) by its ID from search results."""
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
@mcp.tool(annotations={"readOnlyHint": True})
async def read_content(file_group_id: int, part: str = "text") -> str:
"""Read what an item says: a video's captions or an archived page's text (part="text"), or a video's
comments (part="comments").
Args:
file_group_id: The ID from search results.
part: "text" (default) or "comments".
"""
try:
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": part})
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return f"No {part} available for item {file_group_id}. Try get_file for its details."
raise
return text or f"No {part} available for item {file_group_id}."
# ---------------------------------------------------------------------------
# Browsing / listing tools
# ---------------------------------------------------------------------------
@ -689,7 +755,7 @@ async def get_inventory_items(inventory_slug: str) -> str:
Args:
inventory_slug: The inventory slug (from get_inventory results).
"""
data = await api_get(f"/api/ai/inventories/{inventory_slug}")
data = await api_get("/api/ai/inventories", params={"slug": inventory_slug})
return json.dumps(data, indent=2, default=str)