diff --git a/modules/ai/api.py b/modules/ai/api.py index 6962b7c6..83201f08 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -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//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/') @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/') +@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//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. diff --git a/modules/ai/context.py b/modules/ai/context.py index 90ccfbd7..9ba391aa 100644 --- a/modules/ai/context.py +++ b/modules/ai/context.py @@ -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()] diff --git a/modules/ai/lib.py b/modules/ai/lib.py index dbd648fd..5e47fc38 100644 --- a/modules/ai/lib.py +++ b/modules/ai/lib.py @@ -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') diff --git a/modules/ai/modes.py b/modules/ai/modes.py index b6ae7763..b3552a89 100644 --- a/modules/ai/modes.py +++ b/modules/ai/modes.py @@ -56,7 +56,6 @@ MODES = { 'search_zims', 'get_zim_entry', 'list_collections', - 'list_inventories', 'get_inventory', 'list_files', 'read_file', diff --git a/modules/ai/schema.py b/modules/ai/schema.py index bc5a0f38..d20e18db 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -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 diff --git a/modules/ai/test/test_api.py b/modules/ai/test/test_api.py index 0c648f9f..093b352c 100644 --- a/modules/ai/test/test_api.py +++ b/modules/ai/test/test_api.py @@ -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 diff --git a/modules/ai/test/test_context.py b/modules/ai/test/test_context.py index cf3a5fc4..38993590 100644 --- a/modules/ai/test/test_context.py +++ b/modules/ai/test/test_context.py @@ -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 diff --git a/modules/ai/test/test_lib.py b/modules/ai/test/test_lib.py index fb74f687..dce9fb1b 100644 --- a/modules/ai/test/test_lib.py +++ b/modules/ai/test/test_lib.py @@ -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 canning' 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) diff --git a/modules/ai/test/test_mcp_parity.py b/modules/ai/test/test_mcp_parity.py index ac770f60..b934c74b 100644 --- a/modules/ai/test/test_mcp_parity.py +++ b/modules/ai/test/test_mcp_parity.py @@ -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', } diff --git a/wrolpi_mcp/server.py b/wrolpi_mcp/server.py index f1abf158..e76c3cc5 100644 --- a/wrolpi_mcp/server.py +++ b/wrolpi_mcp/server.py @@ -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)