diff --git a/app/src/components/AiChat.test.js b/app/src/components/AiChat.test.js index 386206ee..d47488a4 100644 --- a/app/src/components/AiChat.test.js +++ b/app/src/components/AiChat.test.js @@ -66,8 +66,8 @@ describe('AiChat', () => { streamChat.mockImplementation(async ({mode, messages}, onEvent) => { expect(mode).toBe('research'); expect(messages[messages.length - 1]).toEqual({role: 'user', content: 'find canning'}); - onEvent('tool_call', {tool: 'search_videos', args: {search_str: 'canning'}}); - onEvent('tool_result', {tool: 'search_videos', success: true}); + onEvent('tool_call', {tool: 'search_files', args: {search_str: 'canning', kind: 'video'}}); + onEvent('tool_result', {tool: 'search_files', success: true}); onEvent('token', {content: 'One video: '}); onEvent('token', {content: '/videos/123'}); onEvent('done', {content: 'One video: /videos/123'}); @@ -80,7 +80,7 @@ describe('AiChat', () => { fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'find canning'}}); fireEvent.click(screen.getByText('Send')); - await waitFor(() => expect(screen.getByText(/using search videos/)).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText(/using search files/)).toBeInTheDocument()); await waitFor(() => expect(screen.getByRole('link', {name: '/videos/123'})).toBeInTheDocument()); expect(streamChat).toHaveBeenCalledTimes(1); }); diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js index 5f64ebd5..1a250838 100644 --- a/app/src/components/ThemeSamplePage.js +++ b/app/src/components/ThemeSamplePage.js @@ -728,15 +728,15 @@ export function ThemeSamplePage() { diff --git a/modules/ai/api.py b/modules/ai/api.py index 83201f08..8eca21cd 100644 --- a/modules/ai/api.py +++ b/modules/ai/api.py @@ -22,7 +22,6 @@ 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 from modules.inventory.errors import UnknownInventory @@ -35,7 +34,7 @@ from wrolpi.common import api_param_limiter, get_media_directory, get_relative_t wrol_mode_enabled 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.lib import search_files, HIDDEN_DIRECTORIES, HIDDEN_FILES from wrolpi.files.models import FileGroup from wrolpi.vars import DOCKERIZED, IS_RPI4 from wrolpi.version import __version__ @@ -64,205 +63,6 @@ def _offset(request: Request) -> int: return 0 -@ai_bp.post('/search') -@openapi.definition( - summary='Search all WROLPi content', - description='Search everything in the library at once: videos, archived web pages, ebooks, PDFs, and other' - ' files. search_str is optional: omit it to browse the newest items. Prefer the type-specific search' - ' (videos/archives/docs) when the user asks about one kind of content. When total is large,' - ' narrow the query instead of paging.', - body=schema.AISearchRequest, -) -@openapi.operation('search_all') -@openapi.response(HTTPStatus.OK, schema.AISearchResponse) -@validate(schema.AISearchRequest) -async def search_all(_: Request, body: schema.AISearchRequest): - file_groups, total = search_files( - body.search_str, - ai_limiter(body.limit), - body.offset or 0, - mimetypes=body.mimetypes, - tag_names=body.tag_names, - headline=True, - ) - return json_response(lib.format_file_groups(file_groups, total, searched=bool(body.search_str))) - - -@ai_bp.post('/videos/search') -@openapi.definition( - summary='Search videos', - description='Search downloaded videos (and audio) by title and captions. Returns lean results with a' - ' WROLPi link and a captions_link for each video. search_str is optional: omit it to browse the' - ' newest videos, or pass channel_id (from list_collections) to browse one channel.', - body=schema.AIVideoSearchRequest, -) -@openapi.operation('search_videos') -@openapi.response(HTTPStatus.OK, schema.AISearchResponse) -@validate(schema.AIVideoSearchRequest) -async def search_videos(_: Request, body: schema.AIVideoSearchRequest): - file_groups, total = videos_lib.search_videos( - search_str=body.search_str, - offset=body.offset or 0, - limit=ai_limiter(body.limit), - channel_id=body.channel_id, - tag_names=body.tag_names, - headline=True, - ) - return json_response(lib.format_file_groups(file_groups, total, searched=bool(body.search_str))) - - -@ai_bp.get('/videos/') -@openapi.definition( - summary='Get one video', - description='Get details about one video by its ID from search results: title, channel, description,' - ' duration, link, and a captions_link to read what is said in the video.', -) -@openapi.operation('get_video') -@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, detail=True) - return json_response(result) - - -@ai_bp.get('/videos//captions') -@openapi.definition( - summary='Get video captions', - description='Read the timestamped captions of a video by its ID; use this to learn what is said in a video' - ' without watching it. Long captions are paged: request again with offset=next_offset to' - ' continue reading.', -) -@openapi.operation('get_video_captions') -@openapi.parameter('offset', int, 'query') -@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) -@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -async def get_video_captions(request: Request, file_group_id: int): - video = videos_lib.get_video(file_group_id) - text = lib.format_caption_chunks(video.get_caption_chunks()) - return json_response(lib.paginate_text(text, _offset(request))) - - -@ai_bp.get('/videos//comments') -@openapi.definition( - summary='Get video comments', - description='Read the downloaded comments of a video by its ID. Long comment threads are paged:' - ' request again with offset=next_offset to continue reading.', -) -@openapi.operation('get_video_comments') -@openapi.parameter('offset', int, 'query') -@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) -@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -async def get_video_comments(request: Request, file_group_id: int): - video = videos_lib.get_video(file_group_id) - comments = video.get_comments() or [] - lines = [] - for comment in comments: - author = comment.get('author') or 'unknown' - text = (comment.get('text') or '').strip() - if text: - lines.append(f'{author}: {text}') - return json_response(lib.paginate_text('\n'.join(lines), _offset(request))) - - -@ai_bp.post('/archives/search') -@openapi.definition( - summary='Search archived web pages', - description='Search archived web pages (saved with SingleFile) by title and content. Returns lean results' - ' with a WROLPi link and a text_link to read each page. search_str is optional: omit it to browse' - ' the newest pages. Filter with domain (the exact name from list_collections) for one site.', - body=schema.AIArchiveSearchRequest, -) -@openapi.operation('search_archives') -@openapi.response(HTTPStatus.OK, schema.AISearchResponse) -@validate(schema.AIArchiveSearchRequest) -async def search_archives(_: Request, body: schema.AIArchiveSearchRequest): - file_groups, total = archive_lib.search_archives( - body.search_str, - body.domain, - ai_limiter(body.limit), - body.offset or 0, - None, # order: default (most recently published) - body.tag_names, - headline=True, - ) - return json_response(lib.format_file_groups(file_groups, total, searched=bool(body.search_str))) - - -@ai_bp.get('/archives/') -@openapi.definition( - summary='Get one archived web page', - description='Get details about one archived web page by its ID from search results: title, source URL,' - ' its WROLPi link, a text_link to read it, and earlier snapshots of the same page.', -) -@openapi.operation('get_archive') -@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) - return json_response(_archive_detail(archive)) - - -@ai_bp.get('/archives//text') -@openapi.definition( - summary='Read an archived web page', - description='Read the plain text content of an archived web page by its ID. Long pages are paged: request' - ' again with offset=next_offset to continue reading.', -) -@openapi.operation('get_archive_text') -@openapi.parameter('offset', int, 'query') -@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse) -@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -async def get_archive_text(request: Request, file_group_id: int): - archive = archive_lib.get_archive_by_file_group_id(request.ctx.session, file_group_id, skip_viewed=True) - text = lib.read_archive_text(archive) - if text is None: - raise UnknownFile(f'No readable text for archive {file_group_id}') - return json_response(lib.paginate_text(text, _offset(request))) - - -@ai_bp.post('/docs/search') -@openapi.definition( - summary='Search documents and ebooks', - description='Search documents (ebooks, PDFs, comics, office docs) by title, content, author, or subject.' - ' Returns lean results with a WROLPi link for each document. Provide at least one of' - ' search_str, author, subject, or tag_names.', - body=schema.AIDocSearchRequest, -) -@openapi.operation('search_docs') -@openapi.response(HTTPStatus.OK, schema.AISearchResponse) -@validate(schema.AIDocSearchRequest) -async def search_docs(_: Request, body: schema.AIDocSearchRequest): - file_groups, total = _search_docs( - search_str=body.search_str, - author=body.author, - subject=body.subject, - language=body.language, - mimetype=body.mimetype, - limit=ai_limiter(body.limit), - offset=body.offset or 0, - order_by='rank' if body.search_str else 'published_datetime', - tag_names=body.tag_names, - ) - return json_response(lib.format_file_groups(file_groups, total, searched=bool(body.search_str))) - - -@ai_bp.get('/docs/') -@openapi.definition( - summary='Get one document', - description='Get details about one document (ebook, PDF, comic, etc.) by its ID from search results:' - ' title, author, subject, description, and its WROLPi link.', -) -@openapi.operation('get_doc') -@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -async def get_doc(request: Request, file_group_id: int): - doc = _get_doc(request.ctx.session, file_group_id) - response = _doc_response(doc) - 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, detail=True) - return json_response(result) - - @ai_bp.get('/zims') @openapi.definition( summary='List Zim encyclopedias', @@ -289,9 +89,8 @@ async def list_zims(request: Request): @ai_bp.post('/zims/search') @openapi.definition( summary='Search Zim encyclopedias', - description='Search Zim encyclopedias (Wikipedia, etc.). Without zim_id this searches only the Zims that' - ' have search-by-default enabled; pass zim_id (from list_zims) to search one specific Zim.' - ' Use the returned link (or zim_id and path) to read a full entry.', + description='Search the Zim encyclopedias (Wikipedia, etc.) enabled for search; pass zim_id to search one.' + ' Read a result in full with get_zim_entry (zim_id and path).', body=schema.AIZimSearchRequest, ) @openapi.operation('search_zims') @@ -316,9 +115,8 @@ async def search_zims(request: Request, body: schema.AIZimSearchRequest): @ai_bp.get('/zims//entry') @openapi.definition( summary='Read a Zim entry', - description='Read one article from a Zim encyclopedia as plain text. Pass the path from Zim search' - ' results. Long articles are paged: request again with offset=next_offset to continue' - ' reading.', + description='Read one Zim article as plain text by zim_id and the path from search_zims. Paged: request' + ' again with offset=next_offset.', ) @openapi.operation('get_zim_entry') @openapi.parameter('path', str, 'query') @@ -340,8 +138,8 @@ async def get_zim_entry(request: Request, zim_id: int): @openapi.definition( summary='List collections', description='List collections in the library. Filter with kind: "channel" (video channels), "domain"' - ' (archived websites), or "playlist". Use a channel\'s id with search_videos, or a domain\'s' - ' name with search_archives.', + ' (archived websites), or "playlist". Use a channel\'s name or id, or a domain\'s name, as the' + ' channel or domain filter of search_files.', ) @openapi.operation('list_collections') @openapi.parameter('kind', str, 'query') @@ -394,17 +192,6 @@ async def get_inventory(request: Request): 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.', -) -@openapi.operation('get_inventory_items') -@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) -async def get_inventory_items(_: Request, slug: str): - return json_response(dict(inventory=_inventory_by_slug(slug))) - - # --------------------------------------------------------------------------- # Help endpoints (Help mode). Help markdown is searched in-process; see help_docs.py. # --------------------------------------------------------------------------- @@ -696,9 +483,8 @@ async def get_map_overview(request: Request): @ai_bp.get('/map/search') @openapi.definition( summary='Search places on the map', - description='Find towns, cities, and landmarks by name in the downloaded maps\' place index. Each result' - ' has coordinates and a WROLPi map link. Pass lat and lon to rank nearest first. Results are' - ' empty when no map has a search index (see get_map_overview).', + description='Find towns, cities, and landmarks by name in the downloaded maps. Each result has coordinates' + ' and a map link. Pass lat and lon to rank nearest first.', ) @openapi.operation('search_places') @openapi.parameter('q', str, 'query') @@ -730,50 +516,6 @@ async def search_places(request: Request): return json_response(dict(results=results, total=data.get('total', len(results)))) -@ai_bp.post('/search/suggestions') -@openapi.definition( - summary='Preview what a search would find', - description='Before searching, learn which channels, domains (archived sites), authors, and subjects match' - ' a term, and how many files and Zim entries a search would return. Use the channel id with' - ' search_videos, the domain name with search_archives, the author/subject with search_docs.' - ' tag_names narrows the file estimate to tagged items.', - body=schema.AISearchSuggestionsRequest, -) -@openapi.operation('search_suggestions') -@validate(schema.AISearchSuggestionsRequest) -async def search_suggestions(request: Request, body: schema.AISearchSuggestionsRequest): - if not body.search_str and not body.tag_names: - raise ValidationError('search_str or tag_names is required') - session = request.ctx.session - search_str = body.search_str or '' - - channels = await search_channels_by_name(session, search_str, order_by_video_count=True) - domains = await search_domains_by_name(session, search_str) - authors = await search_authors_by_name(session, search_str) - subjects = await search_subjects_by_name(session, search_str) - - counts = await search_file_suggestion_count(body.search_str, body.tag_names, []) - zims = [] - if body.search_str: - def estimate(): - with_counts = Zims.estimate(session, body.search_str) - return [dict(id=zim.id, title=lib.zim_metadata_dict(zim.zim_metadata).get('title') or zim.path.name, - estimate=count) for zim, count in with_counts.items()] - - try: - zims = await asyncio.to_thread(estimate) - except Exception as e: - logger.debug('AI suggestions could not estimate Zims', exc_info=e) - - return json_response(dict( - channels=[dict(id=i.id, name=i.name, link=f'/videos/channel/{i.id}/video') for i in channels], - domains=[dict(id=i['id'], name=i['domain']) for i in domains], - authors=authors, - subjects=subjects, - estimates=dict(file_groups=counts['file_groups'], file_groups_deep=counts['file_groups_deep'], zims=zims), - )) - - # --------------------------------------------------------------------------- # 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. @@ -835,12 +577,10 @@ async def _resolve_channel_id(session, channel: str) -> Optional[int]: @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.', + description='Search videos, archived web pages, and documents by title and content; omit search_str to' + ' browse the newest. kind: video, archive, or doc. channel (name or id) is for videos, domain for' + ' archives, author/subject for docs. Each result has an id for get_file/read_content and a link.' + ' matches names channels/domains/authors that fit the term: narrow with one when total is large.', body=schema.AIFileSearchRequest, ) @openapi.operation('search_files') @@ -917,9 +657,8 @@ async def search_files_endpoint(request: Request, body: schema.AIFileSearchReque @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.', + description='Details of one item by its id, any kind: title, link, date, channel/author, tags, size, full' + ' description; videos say whether captions/comments exist; archives include earlier snapshots.', ) @openapi.operation('get_file') @openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse) @@ -943,9 +682,8 @@ 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.', + description='Read an item by its id: part=text is a video\'s captions or an archived page\'s text;' + ' part=comments is a video\'s comments. Paged: request again with offset=next_offset.', ) @openapi.operation('read_content') @openapi.parameter('part', str, 'query') @@ -1063,11 +801,9 @@ def _resolve_media_path(relative: str | None, verb: str) -> pathlib.Path: @ai_bp.get('/files/list') @openapi.definition( summary='List a directory', - description='List the directories and files directly inside one directory of the media directory, by its' - ' relative path (omit path for the top level). Directories come first, then files, each with its' - ' relative path to pass to list_files or read_file. Big directories are paged: request again' - ' with offset=next_offset to continue. Use search when looking for content by topic; use this' - ' when the user asks what is in a folder or how files are organized.', + description='List one directory of the media directory by relative path (omit for the top level):' + ' directories first, then files, each with a path for list_files or read_file. Paged by offset.' + ' For "what is in this folder"; use search_files for content by topic.', ) @openapi.operation('list_files') @openapi.parameter('path', str, 'query') @@ -1120,9 +856,8 @@ async def list_files(request: Request): @ai_bp.get('/files/read') @openapi.definition( summary='Read a text file', - description='Read a plain-text file from the media directory by its relative path (from search result' - ' links, collection directories, or list_files). Only text files can be read. Long files are' - ' paged: request again with offset=next_offset to continue reading.', + description='Read a plain-text file from the media directory by its relative path (from list_files).' + ' Text files only. Paged: request again with offset=next_offset.', ) @openapi.operation('read_file') @openapi.parameter('path', str, 'query') diff --git a/modules/ai/lib.py b/modules/ai/lib.py index 5e47fc38..91e18e4d 100644 --- a/modules/ai/lib.py +++ b/modules/ai/lib.py @@ -158,7 +158,7 @@ def format_file_groups(file_groups: list, total: int, searched: bool = False) -> if searched and total == 0: # Small models give up on one empty search; steer the retry. result['hint'] = ('No matches. Names may be spelled differently: try fewer or different' - ' words, list_collections for exact channel/domain names, or omit' + ' words, use the exact channel/website names from the library list, or omit' ' search_str to browse the newest items.') return result diff --git a/modules/ai/modes.py b/modules/ai/modes.py index b3552a89..c8508902 100644 --- a/modules/ai/modes.py +++ b/modules/ai/modes.py @@ -29,39 +29,26 @@ MODES = { system_prompt=( f'{COMMON_PROMPT}' ' Your job is to be the librarian: find and summarize content from the user\'s library.' - ' Search first, then read captions, page text, or entries when the user wants detail.' - ' When the user asks about a channel, website, or their whole library, call' - ' list_collections first to learn the exact names (spellings often differ).' - ' list_collections only gives names — it never tells you what content exists. You have' - ' not summarized a channel until you have called search_videos with its channel_id (and' - ' NO search_str) and read the resulting titles.' - ' To browse everything of one kind (e.g. "summarize my archives"), call the search tool' - ' WITHOUT search_str — that lists the newest items.' - ' When a search finds nothing, do not give up: retry with fewer or different words, or' - ' browse with list_collections.' + ' Search first with search_files, then read captions, page text, or entries with read_content' + ' when the user wants detail. The library list above has the exact channel, website, and' + ' Zim names (spellings often differ): pass a channel or domain name to search_files directly.' + ' To summarize a channel or browse everything of one kind, call search_files WITHOUT search_str' + ' and with the channel, domain, or kind; that lists the newest items.' + ' When a search finds nothing, do not give up: retry with fewer or different words, or drop the' + ' filters. When it offers matches, narrow with one of them.' ' ALWAYS include the WROLPi link for every item you mention, exactly as the tools return it.' ' Never give external URLs. When a search returns a large total, narrow the query instead' ' of paging.' ), tools=( - 'search_all', - 'search_videos', - 'get_video', - 'get_video_captions', - 'search_archives', - 'get_archive_text', - 'search_docs', - 'get_doc', - 'list_zims', + 'search_files', + 'get_file', + 'read_content', 'search_zims', 'get_zim_entry', - 'list_collections', 'get_inventory', 'list_files', 'read_file', - 'list_tags', - 'search_suggestions', - 'get_map_overview', 'search_places', ), ), @@ -127,15 +114,11 @@ MODE_EXAMPLES = { ), 'research': _example( dict(role='user', content='Summarize the bakehouse channel'), - _tool_call('ex_res_1', 'list_collections', '{"kind": "channel"}'), + _tool_call('ex_res_1', 'search_files', '{"channel": "Bakehouse"}'), _tool_result('ex_res_1', - '{"results": [{"id": 7, "name": "Bakehouse", "kind": "channel",' - ' "link": "/videos/channel/7/video"}], "total": 1}'), - _tool_call('ex_res_2', 'search_videos', '{"channel_id": 7}'), - _tool_result('ex_res_2', '{"results": [{"id": 12, "kind": "video", "title": "Sourdough Basics",' - ' "link": "/videos/12", "channel": "Bakehouse", "duration": 900}], "total": 1}'), - dict(role='assistant', content='The Bakehouse channel (/videos/channel/7/video) has one video:\\n' + ' "link": "/videos/12", "channel": "Bakehouse", "duration": "15:00"}], "total": 1}'), + dict(role='assistant', content='The Bakehouse channel has one video:\\n' '1. **Sourdough Basics** (/videos/12) — a 15 minute introduction' ' to sourdough baking.'), ), diff --git a/modules/ai/schema.py b/modules/ai/schema.py index d20e18db..a5bbf996 100644 --- a/modules/ai/schema.py +++ b/modules/ai/schema.py @@ -2,45 +2,6 @@ import dataclasses from typing import List, Optional -@dataclasses.dataclass -class AISearchRequest: - search_str: Optional[str] = None - limit: int = 5 - offset: int = 0 - mimetypes: Optional[List[str]] = None - tag_names: Optional[List[str]] = None - - -@dataclasses.dataclass -class AIVideoSearchRequest: - search_str: Optional[str] = None - limit: int = 5 - offset: int = 0 - channel_id: Optional[int] = None - tag_names: Optional[List[str]] = None - - -@dataclasses.dataclass -class AIArchiveSearchRequest: - search_str: Optional[str] = None - limit: int = 5 - offset: int = 0 - domain: Optional[str] = None - tag_names: Optional[List[str]] = None - - -@dataclasses.dataclass -class AIDocSearchRequest: - search_str: Optional[str] = None - author: Optional[str] = None - subject: Optional[str] = None - language: Optional[str] = None - mimetype: Optional[str] = None - limit: int = 5 - offset: int = 0 - tag_names: Optional[List[str]] = None - - @dataclasses.dataclass class AIZimSearchRequest: search_str: Optional[str] = None @@ -85,12 +46,6 @@ class AIListFilesResponse: next_offset: Optional[int] = None -@dataclasses.dataclass -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 diff --git a/modules/ai/test/test_agent.py b/modules/ai/test/test_agent.py index f63e1526..15c12da8 100644 --- a/modules/ai/test/test_agent.py +++ b/modules/ai/test/test_agent.py @@ -36,17 +36,17 @@ async def test_tool_definitions_from_spec(tool_definitions): assert not any(t.path.startswith('/api/chat') for t in tools.values()) # Body, query, and path parameters all appear in the flat schema. - schema = tools['search_videos'].parameters_schema() + schema = tools['search_files'].parameters_schema() assert schema['properties']['search_str'] == {'type': 'string'} assert schema['properties']['tag_names'] == {'type': 'array', 'items': {'type': 'string'}} - schema = tools['get_video_captions'].parameters_schema() + schema = tools['read_content'].parameters_schema() assert schema['required'] == ['file_group_id'] assert schema['properties']['offset'] == {'type': 'integer'} # OpenAI tool format. - openai_tool = tools['search_all'].as_openai_tool() + openai_tool = tools['search_files'].as_openai_tool() assert openai_tool['type'] == 'function' - assert openai_tool['function']['name'] == 'search_all' + assert openai_tool['function']['name'] == 'search_files' def _endpoint_shim(async_client): @@ -70,8 +70,8 @@ async def test_execute_tool(async_client, tool_definitions, video_factory, test_ with _endpoint_shim(async_client): # A real search through the blueprint. - success, result = await agent.execute_tool(research_tools, 'search_videos', - dict(search_str='canning')) + success, result = await agent.execute_tool(research_tools, 'search_files', + dict(search_str='canning', kind='video')) assert success is True result = json.loads(result) assert result['total'] == 1 @@ -86,20 +86,20 @@ async def test_execute_tool(async_client, tool_definitions, video_factory, test_ assert success is False and 'Unknown tool' in error # Unknown argument names the accepted ones. - success, error = await agent.execute_tool(research_tools, 'search_videos', dict(query='x')) + success, error = await agent.execute_tool(research_tools, 'search_files', dict(query='x')) assert success is False and 'accepts' in error and 'search_str' in error # Missing required path parameter. - success, error = await agent.execute_tool(research_tools, 'get_video_captions', {}) + success, error = await agent.execute_tool(research_tools, 'read_content', {}) assert success is False and 'Missing required' in error # Wrong type. - success, error = await agent.execute_tool(research_tools, 'get_video_captions', + success, error = await agent.execute_tool(research_tools, 'read_content', dict(file_group_id='banana')) assert success is False and 'must be a integer' in error # An endpoint error comes back as a message, not an exception. - success, error = await agent.execute_tool(research_tools, 'get_video_captions', + success, error = await agent.execute_tool(research_tools, 'read_content', dict(file_group_id=123456)) assert success is False and 'error' in error.lower() @@ -129,7 +129,7 @@ async def test_execute_tool_result_truncated(async_client, tool_definitions, arc archive = test_session.query(Archive).one() with _endpoint_shim(async_client): - success, result = await agent.execute_tool(MODES['research']['tools'], 'get_archive_text', + success, result = await agent.execute_tool(MODES['research']['tools'], 'read_content', dict(file_group_id=archive.file_group_id)) assert success is True assert len(result) <= agent.TOOL_RESULT_MAX_CHARS + 100 diff --git a/modules/ai/test/test_api.py b/modules/ai/test/test_api.py index 093b352c..bc3cac1f 100644 --- a/modules/ai/test/test_api.py +++ b/modules/ai/test/test_api.py @@ -7,199 +7,6 @@ import pytest from modules.ai import lib -@pytest.mark.asyncio -async def test_ai_search_all(async_client, test_session, video_factory, archive_factory): - """Global search returns lean results of every kind with links and a total.""" - video_factory(title='canning tomatoes') - archive_factory(domain='example.com', title='canning peppers', contents='all about canning peppers') - test_session.commit() - - request, response = await async_client.post('/api/ai/search', content=json.dumps(dict(search_str='canning'))) - assert response.status_code == HTTPStatus.OK - assert response.json['total'] == 2 - kinds = {i['kind'] for i in response.json['results']} - assert kinds == {'video', 'archive'} - for result in response.json['results']: - assert result['link'] - assert result['id'] - - -@pytest.mark.asyncio -async def test_ai_search_all_limit(async_client, test_session, archive_factory): - """The limit is clamped and the total reports all matches so the model narrows, not pages.""" - for i in range(3): - archive_factory(domain='example.com', title=f'gardening {i}', contents='gardening guide') - test_session.commit() - - content = dict(search_str='gardening', limit=1) - request, response = await async_client.post('/api/ai/search', content=json.dumps(content)) - assert response.status_code == HTTPStatus.OK - assert len(response.json['results']) == 1 - assert response.json['total'] == 3 - - # An excessive limit is clamped to the maximum. - content = dict(search_str='gardening', limit=10_000) - request, response = await async_client.post('/api/ai/search', content=json.dumps(content)) - assert response.status_code == HTTPStatus.OK - - -@pytest.mark.asyncio -async def test_ai_search_videos(async_client, test_session, video_factory): - """Video search returns lean video results with captions links.""" - video_factory(title='sourdough starter', with_caption_file=True) - video_factory(title='unrelated') - test_session.commit() - - content = dict(search_str='sourdough') - request, response = await async_client.post('/api/ai/videos/search', content=json.dumps(content)) - assert response.status_code == HTTPStatus.OK - assert response.json['total'] == 1 - result = response.json['results'][0] - assert result['kind'] == 'video' - assert result['link'] == f'/videos/{result["id"]}' - assert 'captions_link' not in result # derivable from the id; kept out of listings - - -@pytest.mark.asyncio -async def test_ai_get_video(async_client, test_session, video_factory): - """A single video can be fetched by its FileGroup ID.""" - video = video_factory(title='wood stove install') - test_session.commit() - file_group_id = video.file_group_id - - request, response = await async_client.get(f'/api/ai/videos/{file_group_id}') - assert response.status_code == HTTPStatus.OK - assert response.json['id'] == file_group_id - assert response.json['kind'] == 'video' - assert response.json['link'] == f'/videos/{file_group_id}' - - # Fetching does not mark the video viewed. - test_session.expire_all() - assert video.file_group.viewed is None - - request, response = await async_client.get('/api/ai/videos/123456') - assert response.status_code == HTTPStatus.NOT_FOUND - - -@pytest.mark.asyncio -async def test_ai_get_video_captions(async_client, test_session, video_factory): - """Captions are returned as timestamped, paged text.""" - video = video_factory(title='captioned', with_caption_file=True) - test_session.commit() - - request, response = await async_client.get(f'/api/ai/videos/{video.file_group_id}/captions') - assert response.status_code == HTTPStatus.OK - assert response.json['content'].startswith('[00:00:0') - assert response.json['total_chars'] > 0 - - # An offset beyond the end returns an empty page, never an error. - offset = response.json['total_chars'] + 100 - request, response = await async_client.get(f'/api/ai/videos/{video.file_group_id}/captions?offset={offset}') - assert response.status_code == HTTPStatus.OK - assert response.json['content'] == '' - assert response.json['next_offset'] is None - - -@pytest.mark.asyncio -async def test_ai_search_archives(async_client, test_session, archive_factory): - """Archive search supports the domain filter and returns text links.""" - archive_factory(domain='example.com', title='pressure canning', contents='pressure canning guide') - archive_factory(domain='other.org', title='water bath canning', contents='water bath canning guide') - test_session.commit() - - content = dict(search_str='canning', domain='example.com') - request, response = await async_client.post('/api/ai/archives/search', content=json.dumps(content)) - assert response.status_code == HTTPStatus.OK - assert response.json['total'] == 1 - result = response.json['results'][0] - assert result['kind'] == 'archive' - assert 'text_link' not in result # derivable from the id; kept out of listings - - -@pytest.mark.asyncio -async def test_ai_get_archive_text(async_client, test_session, archive_factory): - """Archive text is served from the readability text file and paged.""" - contents = 'word ' * 2_000 # 10,000 chars, more than two pages. - archive = archive_factory(domain='example.com', title='long read', contents=contents) - test_session.commit() - file_group_id = archive.file_group_id - - request, response = await async_client.get(f'/api/ai/archives/{file_group_id}/text') - assert response.status_code == HTTPStatus.OK - assert len(response.json['content']) == lib.PAGE_SIZE - assert response.json['next_offset'] == lib.PAGE_SIZE - assert response.json['total_chars'] == len(contents) - - # The second page continues where the first ended. - request, response = await async_client.get( - f'/api/ai/archives/{file_group_id}/text?offset={response.json["next_offset"]}') - assert response.status_code == HTTPStatus.OK - assert response.json['content'] == contents[lib.PAGE_SIZE:2 * lib.PAGE_SIZE] - - request, response = await async_client.get('/api/ai/archives/123456/text') - assert response.status_code == HTTPStatus.NOT_FOUND - - -@pytest.mark.asyncio -async def test_ai_get_archive(async_client, test_session, archive_factory): - """A single archive can be fetched with its history of snapshots.""" - 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/archives/{archive.file_group_id}') - assert response.status_code == HTTPStatus.OK - assert response.json['kind'] == 'archive' - assert response.json['link'] == f'/archives/{archive.file_group_id}' - # History holds the OTHER snapshots of the URL, not the archive itself. - assert [i['title'] for i in response.json['history']] == ['first'] - - request, response = await async_client.get('/api/ai/archives/123456') - assert response.status_code == HTTPStatus.NOT_FOUND - - -@pytest.mark.asyncio -async def test_ai_get_video_comments(async_client, test_session, video_factory): - """Video comments render as paged author lines.""" - info_json = dict(duration=5, comments=[ - dict(author='alice', text='great video'), - dict(author='bob', text='thanks'), - ]) - video = video_factory(title='commented', with_info_json=info_json) - test_session.commit() - - request, response = await async_client.get(f'/api/ai/videos/{video.file_group_id}/comments') - assert response.status_code == HTTPStatus.OK - assert 'alice: great video' in response.json['content'] - assert 'bob: thanks' in response.json['content'] - - # No comments is an empty page, not an error. - no_comments = video_factory(title='quiet') - test_session.commit() - request, response = await async_client.get(f'/api/ai/videos/{no_comments.file_group_id}/comments') - assert response.status_code == HTTPStatus.OK - assert response.json['content'] == '' - - -@pytest.mark.asyncio -async def test_ai_search_docs(async_client, test_session, test_directory, example_epub, refresh_files): - """Docs can be searched and fetched with links.""" - await refresh_files() - - content = dict(search_str='WROLPi') - request, response = await async_client.post('/api/ai/docs/search', content=json.dumps(content)) - assert response.status_code == HTTPStatus.OK - assert response.json['total'] >= 1 - result = response.json['results'][0] - assert result['kind'] == 'doc' - assert result['link'] == f'/docs/{result["id"]}' - - request, response = await async_client.get(f'/api/ai/docs/{result["id"]}') - assert response.status_code == HTTPStatus.OK - assert response.json['id'] == result['id'] - assert response.json['kind'] == 'doc' - - @pytest.mark.asyncio async def test_ai_zims(async_client, test_session, test_zim): """Zims can be listed, searched, and their entries read as paged text.""" @@ -249,26 +56,6 @@ async def test_ai_list_collections(async_client, test_session, archive_factory): assert response.json['total'] == 0 -@pytest.mark.asyncio -async def test_ai_inventories(async_client, food_inventory_factory): - """Inventories are listed lean, and fetched in full by slug.""" - 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 - assert response.json['total'] >= 1 - lean = next(i for i in response.json['results'] if i['slug'] == slug) - assert lean['item_count'] == 1 - assert 'items' not in lean - - request, response = await async_client.get(f'/api/ai/inventories/{slug}') - assert response.status_code == HTTPStatus.OK - assert response.json['inventory']['items'][0]['name'] == 'rice' - - request, response = await async_client.get('/api/ai/inventories/no-such-inventory') - assert response.status_code == HTTPStatus.NOT_FOUND - - @pytest.mark.asyncio async def test_ai_read_file(async_client, test_directory): """Text files in the media directory can be read; everything else is refused.""" @@ -478,39 +265,6 @@ async def test_ai_map(async_client, test_session, test_directory, make_files_str assert response.status_code == HTTPStatus.BAD_REQUEST -@pytest.mark.asyncio -async def test_ai_search_suggestions(async_client, test_session, video_factory, archive_factory, channel_factory, - tag_factory): - """Suggestions name the channels/domains/authors/subjects matching a term and estimate result counts.""" - channel = channel_factory(name='Wild Cooking') - video_factory(channel_id=channel.id, title='cooking rice') - archive_factory(domain='cooking.example.com', title='cooking beans', contents='beans') - tag = await tag_factory('recipes') - test_session.commit() - - content = dict(search_str='cooking') - request, response = await async_client.post('/api/ai/search/suggestions', content=json.dumps(content)) - assert response.status_code == HTTPStatus.OK - ch, = response.json['channels'] - assert ch['name'] == 'Wild Cooking' and ch['id'] == channel.id and ch['link'].startswith('/videos/channel/') - dom, = response.json['domains'] - assert dom['name'] == 'cooking.example.com' - assert response.json['authors'] == [] and response.json['subjects'] == [] - assert response.json['estimates']['file_groups'] == 2 - assert 'file_groups_deep' in response.json['estimates'] - assert response.json['estimates']['zims'] == [] - - # Tag names narrow the estimate. - content = dict(search_str='cooking', tag_names=[tag.name]) - request, response = await async_client.post('/api/ai/search/suggestions', content=json.dumps(content)) - assert response.status_code == HTTPStatus.OK - assert response.json['estimates']['file_groups'] == 0 - - # Something to search for is required. - request, response = await async_client.post('/api/ai/search/suggestions', content=json.dumps({})) - assert response.status_code == HTTPStatus.BAD_REQUEST - - def test_ai_format_download_error_tail(): """Download errors are tracebacks; the model gets the end (the exception message), not the start.""" from modules.ai.api import _format_download, DOWNLOAD_ERROR_LENGTH @@ -578,11 +332,21 @@ async def test_ai_search_files_kinds(async_client, test_session, video_factory, 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. + # No filters at all browses the newest items. request, response = await async_client.post('/api/ai/files/search', content=json.dumps({})) - assert response.status_code == HTTPStatus.OK # browse newest + assert response.status_code == HTTPStatus.OK assert response.json['total'] == 3 + # The limit is clamped and total reports every match so the model narrows instead of paging. + data = await search(search_str='cooking', limit=1) + assert len(data['results']) == 1 and data['total'] == 3 + data = await search(search_str='cooking', limit=10_000) + assert len(data['results']) == 3 + + # An unknown kind is refused. + request, response = await async_client.post('/api/ai/files/search', content=json.dumps(dict(kind='song'))) + assert response.status_code == HTTPStatus.BAD_REQUEST + @pytest.mark.asyncio async def test_ai_search_files_docs(async_client, test_session, test_directory, example_epub, refresh_files): @@ -651,6 +415,20 @@ async def test_ai_read_content(async_client, test_session, video_factory, archiv 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') + assert response.json['total_chars'] > 0 + + # An offset beyond the end returns an empty page, never an error. + offset = response.json['total_chars'] + 100 + request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?offset={offset}') + assert response.status_code == HTTPStatus.OK + assert response.json['content'] == '' and response.json['next_offset'] is None + + # A video without comments is an empty page, not an error. + quiet = video_factory(title='quiet') + test_session.commit() + request, response = await async_client.get(f'/api/ai/files/{quiet.file_group_id}/content?part=comments') + assert response.status_code == HTTPStatus.OK + assert response.json['content'] == '' request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?part=comments') assert response.status_code == HTTPStatus.OK diff --git a/modules/ai/test/test_chat_api.py b/modules/ai/test/test_chat_api.py index a59ac901..9bfb753e 100644 --- a/modules/ai/test/test_chat_api.py +++ b/modules/ai/test/test_chat_api.py @@ -82,7 +82,7 @@ async def test_chat_tool_call_then_answer(async_client, ai_enabled, video_factor patch, calls = _llama_script( [dict(type='stop', tool_calls=[ - dict(id='call_1', name='search_videos', arguments='{"search_str": "canning"}')])], + dict(id='call_1', name='search_files', arguments='{"search_str": "canning", "kind": "video"}')])], [dict(type='token', content='Found '), dict(type='token', content='one video.'), dict(type='stop', tool_calls=[])], ) @@ -96,8 +96,8 @@ async def test_chat_tool_call_then_answer(async_client, ai_enabled, video_factor names = [e for e, _ in events if e != 'status'] assert names == ['tool_call', 'tool_result', 'token', 'token', 'done'] tool_call = dict(events)['tool_call'] - assert tool_call == dict(tool='search_videos', args=dict(search_str='canning')) - assert dict(events)['tool_result'] == dict(tool='search_videos', success=True) + assert tool_call == dict(tool='search_files', args=dict(search_str='canning', kind='video')) + assert dict(events)['tool_result'] == dict(tool='search_files', success=True) assert dict(events)['done'] == dict(content='Found one video.') # The second llama call saw the system prompt (with the live library map), the mode's diff --git a/modules/ai/test/test_mcp_parity.py b/modules/ai/test/test_mcp_parity.py index b934c74b..90f18003 100644 --- a/modules/ai/test/test_mcp_parity.py +++ b/modules/ai/test/test_mcp_parity.py @@ -18,7 +18,7 @@ EXPECTED_TOOLS = { 'get_doc', 'get_zim_entry', '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', + 'list_tags', 'list_downloads', 'get_map_overview', 'search_places', 'search_files', 'get_file', 'read_content', } diff --git a/scripts/ai_eval.py b/scripts/ai_eval.py index 7a2a9222..9a59714e 100755 --- a/scripts/ai_eval.py +++ b/scripts/ai_eval.py @@ -26,15 +26,17 @@ import requests requests.packages.urllib3.disable_warnings() # noqa Self-signed certs. -# (mode, question, expected first tools (any of), expect a WROLPi link in the answer) +# (mode, question, expected first tools (any of; None = answerable from context, no tool expected), +# expect a WROLPi link in the answer) QUESTIONS = [ ('help', 'How do I download videos?', {'search_help'}, True), ('help', 'What are Tags?', {'search_help'}, True), ('help', 'How do I add my own files to WROLPi?', {'search_help'}, True), - ('research', 'What are my three newest archives?', {'search_archives'}, True), - ('research', 'What video channels do I have?', {'list_collections', 'search_videos'}, False), - ('research', 'Find videos about cooking', {'search_videos', 'search_all'}, False), - ('research', 'Summarize my newest videos', {'search_videos'}, True), + ('research', 'What are my three newest archives?', {'search_files'}, True), + # The library map in the prompt already names the channels; no tool call is needed. + ('research', 'What video channels do I have?', None, False), + ('research', 'Find videos about cooking', {'search_files'}, False), + ('research', 'Summarize my newest videos', {'search_files'}, True), ('system', 'Is everything running?', {'get_system_status', 'list_services'}, False), ('system', 'How full are my drives?', {'get_system_status', 'list_disks'}, False), ('system', 'Why would downloads fail?', {'get_system_status', 'search_help'}, False), @@ -73,10 +75,11 @@ def score(result: dict, expected_tools: set, expect_link: bool) -> dict: answer = result.get('answer') or '' checks = { 'done': result.get('error') is None and bool(answer), - 'used_tool': bool(result['tools']), - 'right_tool': bool(result['tools']) and result['tools'][0] in expected_tools, 'no_external_urls': not EXTERNAL_URL.search(answer), } + if expected_tools is not None: + checks['used_tool'] = bool(result['tools']) + checks['right_tool'] = bool(result['tools']) and result['tools'][0] in expected_tools if expect_link: checks['has_link'] = bool(RELATIVE_LINK.search(answer)) return checks diff --git a/scripts/api_perf.py b/scripts/api_perf.py index 579aca57..863c75a3 100644 --- a/scripts/api_perf.py +++ b/scripts/api_perf.py @@ -198,14 +198,16 @@ CATALOG = [ dict(name='ai/collections', method='GET', path='/api/ai/collections'), dict(name='ai/inventories', method='GET', path='/api/ai/inventories'), dict(name='ai/zims', method='GET', path='/api/ai/zims'), - dict(name='ai/search', method='POST', path='/api/ai/search', body={'search_str': '{q}', 'limit': 5}), - dict(name='ai/videos/search', method='POST', path='/api/ai/videos/search', body={'search_str': '{q}', 'limit': 5}), - dict(name='ai/archives/search', method='POST', path='/api/ai/archives/search', - body={'search_str': '{q}', 'limit': 5}), - dict(name='ai/docs/search', method='POST', path='/api/ai/docs/search', body={'search_str': '{q}', 'limit': 5}), + dict(name='ai/files/search', method='POST', path='/api/ai/files/search', body={'search_str': '{q}', 'limit': 5}), + dict(name='ai/files/search video', method='POST', path='/api/ai/files/search', + body={'search_str': '{q}', 'kind': 'video', 'limit': 5}), + dict(name='ai/files/search archive', method='POST', path='/api/ai/files/search', + body={'search_str': '{q}', 'kind': 'archive', 'limit': 5}), + dict(name='ai/files/search doc', method='POST', path='/api/ai/files/search', + body={'search_str': '{q}', 'kind': 'doc', 'limit': 5}), dict(name='ai/zims/search', method='POST', path='/api/ai/zims/search', body={'search_str': '{q}', 'limit': 5}), - dict(name='ai/videos/', method='GET', path='/api/ai/videos/{video_id}', needs='video_id'), - dict(name='ai/archives/', method='GET', path='/api/ai/archives/{archive_id}', needs='archive_id'), + dict(name='ai/files/