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/