mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
ai: kind-generic search_files / get_file / read_content
Small models carry every tool definition on every request, and the
per-kind trios (four searches, three getters, three readers) cost a
quarter of an 8k context while giving the model near-duplicate choices.
Three generic endpoints replace them:
- POST files/search: one search over videos, archives, and docs with an
optional kind; channel (name or id), domain, author, subject each
imply their kind and are ignored for other kinds. When the term also
names channels/domains/authors, they are returned as `matches` so the
model can narrow without a second tool. Channels resolve by name, so
the library map already in the prompt is enough: no list call first.
- GET files/{id}: detail by id whatever the kind; videos now report
has_captions/has_comments and the full description, archives keep
their snapshot history.
- GET files/{id}/content?part=text|comments: captions, page text, or
comments, paged; a wrong part for the kind says what the file is.
Inventories take an optional slug on GET /inventories (one tool lists
and fetches). Listings are slimmed: no derivable captions/text links,
no size/mimetype, duration as H:MM:SS, published as a date. The
library map gains the tag names.
wrolpi_mcp keeps every existing tool name; the wrappers now call the
generic routes, and search_files/get_file/read_content are exposed too.
The old per-kind endpoints remain until the modes switch over.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
faa3a03b37
commit
cfce0a1224
10 changed files with 593 additions and 86 deletions
|
|
@ -8,6 +8,7 @@ import asyncio
|
|||
import mimetypes
|
||||
import pathlib
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from sanic import Blueprint, Request
|
||||
from sanic_ext import validate
|
||||
|
|
@ -20,6 +21,7 @@ from modules.map import lib as map_lib
|
|||
from modules.map import search as map_search
|
||||
from modules.map.pins import get_map_pins_config
|
||||
from modules.videos.channel.lib import search_channels_by_name
|
||||
from modules.videos.models import Video
|
||||
from modules.zim.models import Zims
|
||||
from modules.docs.lib import _doc_response, _get_doc, _search_docs
|
||||
from modules.inventory.common import get_inventory_configs
|
||||
|
|
@ -34,6 +36,7 @@ from wrolpi.common import api_param_limiter, get_media_directory, get_relative_t
|
|||
from wrolpi.downloader import download_manager
|
||||
from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile, ValidationError
|
||||
from wrolpi.files.lib import search_files, search_file_suggestion_count, HIDDEN_DIRECTORIES, HIDDEN_FILES
|
||||
from wrolpi.files.models import FileGroup
|
||||
from wrolpi.vars import DOCKERIZED, IS_RPI4
|
||||
from wrolpi.version import __version__
|
||||
from wrolpi.schema import JSONErrorResponse
|
||||
|
|
@ -118,7 +121,7 @@ async def search_videos(_: Request, body: schema.AIVideoSearchRequest):
|
|||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def get_video(_: Request, file_group_id: int):
|
||||
video, _prev, _next = videos_lib.get_video_for_app(file_group_id, skip_viewed=True)
|
||||
result = lib.format_file_group(video, description_length=lib.DETAIL_DESCRIPTION_LENGTH)
|
||||
result = lib.format_file_group(video, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
|
||||
return json_response(result)
|
||||
|
||||
|
||||
|
|
@ -195,20 +198,7 @@ async def search_archives(_: Request, body: schema.AIArchiveSearchRequest):
|
|||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def get_archive(request: Request, file_group_id: int):
|
||||
archive = archive_lib.get_archive_by_file_group_id(request.ctx.session, file_group_id, skip_viewed=True)
|
||||
result = lib.format_file_group(archive.file_group.__json__(),
|
||||
description_length=lib.DETAIL_DESCRIPTION_LENGTH, include_url=True)
|
||||
history = []
|
||||
for snapshot in archive.history[:10]:
|
||||
fg = snapshot.file_group.__json__()
|
||||
history.append({k: v for k, v in dict(
|
||||
id=fg.get('id'),
|
||||
title=fg.get('title') or fg.get('name'),
|
||||
published=fg.get('published_datetime'),
|
||||
link=lib.wrolpi_link(fg),
|
||||
).items() if v is not None})
|
||||
if history:
|
||||
result['history'] = history
|
||||
return json_response(result)
|
||||
return json_response(_archive_detail(archive))
|
||||
|
||||
|
||||
@ai_bp.get('/archives/<file_group_id:int>/text')
|
||||
|
|
@ -269,7 +259,7 @@ async def get_doc(request: Request, file_group_id: int):
|
|||
file_group = response['file_group']
|
||||
# Merge the Doc's details into the FileGroup dict so the lean formatter can use them.
|
||||
file_group['doc'] = response['doc']
|
||||
result = lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH)
|
||||
result = lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
|
||||
return json_response(result)
|
||||
|
||||
|
||||
|
|
@ -372,36 +362,47 @@ async def list_collections(request: Request):
|
|||
return json_response(dict(results=results, total=len(results)))
|
||||
|
||||
|
||||
@ai_bp.get('/inventories')
|
||||
@openapi.definition(
|
||||
summary='List inventories',
|
||||
description='List the inventories (food storage, emergency supplies, etc.). Use the slug with'
|
||||
' get_inventory to read the items.',
|
||||
)
|
||||
@openapi.operation('list_inventories')
|
||||
async def list_inventories(_: Request):
|
||||
inventories = get_inventory_configs().all_inventories()
|
||||
results = [{k: v for k, v in dict(
|
||||
def _lean_inventories() -> list:
|
||||
return [{k: v for k, v in dict(
|
||||
slug=i.get('slug'),
|
||||
name=i.get('name'),
|
||||
type=i.get('type'),
|
||||
item_count=len(i.get('items') or []),
|
||||
).items() if v is not None} for i in inventories]
|
||||
).items() if v is not None} for i in get_inventory_configs().all_inventories()]
|
||||
|
||||
|
||||
def _inventory_by_slug(slug: str) -> dict:
|
||||
inventory = get_inventory_configs().get_inventory(slug)
|
||||
if inventory is None:
|
||||
raise UnknownInventory(f'No inventory: {slug}')
|
||||
return inventory
|
||||
|
||||
|
||||
@ai_bp.get('/inventories')
|
||||
@openapi.definition(
|
||||
summary='Get inventories',
|
||||
description='Without slug: list the inventories (food storage, emergency supplies, etc.) with item counts.'
|
||||
' With slug: that inventory in full, every item and field.',
|
||||
)
|
||||
@openapi.operation('get_inventory')
|
||||
@openapi.parameter('slug', str, 'query')
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def get_inventory(request: Request):
|
||||
if slug := request.args.get('slug'):
|
||||
return json_response(dict(inventory=_inventory_by_slug(slug)))
|
||||
results = _lean_inventories()
|
||||
return json_response(dict(results=results, total=len(results)))
|
||||
|
||||
|
||||
@ai_bp.get('/inventories/<slug:str>')
|
||||
@openapi.definition(
|
||||
summary='Get one inventory',
|
||||
description='Get one inventory in full (its fields and every item) by its slug from list_inventories.',
|
||||
description='Get one inventory in full (its fields and every item) by its slug.',
|
||||
)
|
||||
@openapi.operation('get_inventory')
|
||||
@openapi.operation('get_inventory_items')
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def get_inventory(_: Request, slug: str):
|
||||
inventory = get_inventory_configs().get_inventory(slug)
|
||||
if inventory is None:
|
||||
raise UnknownInventory(f'No inventory: {slug}')
|
||||
return json_response(dict(inventory=inventory))
|
||||
async def get_inventory_items(_: Request, slug: str):
|
||||
return json_response(dict(inventory=_inventory_by_slug(slug)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -773,6 +774,213 @@ async def search_suggestions(request: Request, body: schema.AISearchSuggestionsR
|
|||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consolidated, kind-generic endpoints. Small models carry every tool definition on every request,
|
||||
# so one search / one detail / one reader replaces the per-kind trio.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FILE_KINDS = ('video', 'archive', 'doc')
|
||||
# Name matches offered alongside search results so the model can narrow without another tool.
|
||||
MATCHES_PER_GROUP = 3
|
||||
|
||||
|
||||
def _archive_detail(archive) -> dict:
|
||||
result = lib.format_file_group(archive.file_group.__json__(),
|
||||
description_length=lib.DETAIL_DESCRIPTION_LENGTH, include_url=True, detail=True)
|
||||
history = []
|
||||
for snapshot in archive.history[:10]:
|
||||
fg = snapshot.file_group.__json__()
|
||||
history.append({k: v for k, v in dict(
|
||||
id=fg.get('id'),
|
||||
title=fg.get('title') or fg.get('name'),
|
||||
published=lib.format_date(fg.get('published_datetime')),
|
||||
link=lib.wrolpi_link(fg),
|
||||
).items() if v is not None})
|
||||
if history:
|
||||
result['history'] = history
|
||||
return result
|
||||
|
||||
|
||||
def _video_detail(video: Video) -> dict:
|
||||
fg = video.__json__()
|
||||
try:
|
||||
fg['video']['description'] = video.get_description()
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not read description of {video}', exc_info=e)
|
||||
result = lib.format_file_group(fg, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
|
||||
result['has_captions'] = bool(video.caption_paths)
|
||||
if video.have_comments:
|
||||
result['has_comments'] = True
|
||||
return result
|
||||
|
||||
|
||||
def _doc_detail(session, file_group_id: int) -> dict:
|
||||
response = _doc_response(_get_doc(session, file_group_id))
|
||||
file_group = response['file_group']
|
||||
file_group['doc'] = response['doc']
|
||||
return lib.format_file_group(file_group, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
|
||||
|
||||
|
||||
async def _resolve_channel_id(session, channel: str) -> Optional[int]:
|
||||
"""A channel given as a numeric id or a (partial) name; None when nothing matches."""
|
||||
channel = (channel or '').strip()
|
||||
if not channel:
|
||||
return None
|
||||
if channel.isdigit():
|
||||
return int(channel)
|
||||
matches = await search_channels_by_name(session, channel, limit=1, order_by_video_count=True)
|
||||
return matches[0].id if matches else None
|
||||
|
||||
|
||||
@ai_bp.post('/files/search')
|
||||
@openapi.definition(
|
||||
summary='Search the library',
|
||||
description='Search videos, archived web pages, and documents/ebooks by title and content. Omit search_str to'
|
||||
' browse the newest items. kind narrows to video, archive, or doc. channel (a channel name from'
|
||||
' the library list, or its id) applies to videos; domain (a site name) to archives; author and'
|
||||
' subject to documents; a filter for another kind is ignored. Results carry an id for get_file'
|
||||
' and read_content, and a WROLPi link. matches lists channels/domains/authors whose names fit'
|
||||
' the term: use one to narrow when total is large.',
|
||||
body=schema.AIFileSearchRequest,
|
||||
)
|
||||
@openapi.operation('search_files')
|
||||
@openapi.response(HTTPStatus.OK, schema.AISearchResponse)
|
||||
@validate(schema.AIFileSearchRequest)
|
||||
async def search_files_endpoint(request: Request, body: schema.AIFileSearchRequest):
|
||||
session = request.ctx.session
|
||||
kind = (body.kind or '').strip().lower() or None
|
||||
if kind and kind not in FILE_KINDS:
|
||||
raise ValidationError(f'kind must be one of {", ".join(FILE_KINDS)}')
|
||||
# A kind-specific filter implies the kind.
|
||||
if not kind:
|
||||
if body.channel:
|
||||
kind = 'video'
|
||||
elif body.domain:
|
||||
kind = 'archive'
|
||||
elif body.author or body.subject:
|
||||
kind = 'doc'
|
||||
|
||||
limit = ai_limiter(body.limit)
|
||||
offset = body.offset or 0
|
||||
searched = bool(body.search_str)
|
||||
narrowed = False
|
||||
|
||||
if kind == 'video':
|
||||
channel_id = None
|
||||
if body.channel:
|
||||
channel_id = await _resolve_channel_id(session, body.channel)
|
||||
if channel_id is None:
|
||||
result = lib.format_file_groups([], 0, searched=True)
|
||||
result['hint'] = (f'No channel matches "{body.channel}". Use the exact channel name from the'
|
||||
' library list, or search without channel.')
|
||||
return json_response(result)
|
||||
narrowed = True
|
||||
file_groups, total = videos_lib.search_videos(
|
||||
search_str=body.search_str, offset=offset, limit=limit, channel_id=channel_id,
|
||||
tag_names=body.tag_names, headline=True)
|
||||
elif kind == 'archive':
|
||||
narrowed = bool(body.domain)
|
||||
file_groups, total = archive_lib.search_archives(
|
||||
body.search_str, body.domain, limit, offset, None, body.tag_names, headline=True)
|
||||
elif kind == 'doc':
|
||||
narrowed = bool(body.author or body.subject)
|
||||
file_groups, total = _search_docs(
|
||||
search_str=body.search_str, author=body.author, subject=body.subject, limit=limit, offset=offset,
|
||||
order_by='rank' if body.search_str else 'published_datetime', tag_names=body.tag_names)
|
||||
else:
|
||||
file_groups, total = search_files(body.search_str, limit, offset, tag_names=body.tag_names, headline=True)
|
||||
|
||||
result = lib.format_file_groups(file_groups, total, searched=searched)
|
||||
|
||||
if searched and not narrowed:
|
||||
matches = dict()
|
||||
channels = await search_channels_by_name(session, body.search_str, limit=MATCHES_PER_GROUP,
|
||||
order_by_video_count=True)
|
||||
if channels and kind in (None, 'video'):
|
||||
matches['channels'] = [dict(id=i.id, name=i.name) for i in channels]
|
||||
domains = await search_domains_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
|
||||
if domains and kind in (None, 'archive'):
|
||||
matches['domains'] = [dict(name=i['domain']) for i in domains]
|
||||
if kind in (None, 'doc'):
|
||||
authors = await search_authors_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
|
||||
subjects = await search_subjects_by_name(session, body.search_str, limit=MATCHES_PER_GROUP)
|
||||
if authors:
|
||||
matches['authors'] = [dict(name=i['name']) for i in authors]
|
||||
if subjects:
|
||||
matches['subjects'] = [dict(name=i['name']) for i in subjects]
|
||||
if matches:
|
||||
result['matches'] = matches
|
||||
|
||||
return json_response(result)
|
||||
|
||||
|
||||
@ai_bp.get('/files/<file_group_id:int>')
|
||||
@openapi.definition(
|
||||
summary='Get one file',
|
||||
description='Details of one item by the id from search results, whatever its kind: title, link, date,'
|
||||
' channel or author, tags, size, full description; for videos whether captions and comments'
|
||||
' exist; for archived pages the source URL and earlier snapshots.',
|
||||
)
|
||||
@openapi.operation('get_file')
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def get_file(request: Request, file_group_id: int):
|
||||
session = request.ctx.session
|
||||
file_group = FileGroup.find_by_id(session, file_group_id)
|
||||
if file_group.model == 'video':
|
||||
return json_response(_video_detail(Video.find_by_file_group_id(session, file_group_id)))
|
||||
if file_group.model == 'archive':
|
||||
archive = archive_lib.get_archive_by_file_group_id(session, file_group_id, skip_viewed=True)
|
||||
return json_response(_archive_detail(archive))
|
||||
if file_group.model == 'doc':
|
||||
return json_response(_doc_detail(session, file_group_id))
|
||||
return json_response(lib.format_file_group(file_group.__json__(),
|
||||
description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True))
|
||||
|
||||
|
||||
CONTENT_PARTS = ('text', 'comments')
|
||||
|
||||
|
||||
@ai_bp.get('/files/<file_group_id:int>/content')
|
||||
@openapi.definition(
|
||||
summary='Read a file\'s content',
|
||||
description='Read what an item says, by its id: part=text gives a video\'s timestamped captions or an'
|
||||
' archived page\'s plain text; part=comments gives a video\'s downloaded comments. Long content'
|
||||
' is paged: request again with offset=next_offset to continue.',
|
||||
)
|
||||
@openapi.operation('read_content')
|
||||
@openapi.parameter('part', str, 'query')
|
||||
@openapi.parameter('offset', int, 'query')
|
||||
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def read_content(request: Request, file_group_id: int):
|
||||
part = (request.args.get('part') or 'text').strip().lower()
|
||||
if part not in CONTENT_PARTS:
|
||||
raise ValidationError(f'part must be one of {", ".join(CONTENT_PARTS)}')
|
||||
session = request.ctx.session
|
||||
file_group = FileGroup.find_by_id(session, file_group_id)
|
||||
kind = file_group.model or 'file'
|
||||
|
||||
text = None
|
||||
if kind == 'video':
|
||||
video = Video.find_by_file_group_id(session, file_group_id)
|
||||
if part == 'text':
|
||||
text = lib.format_caption_chunks(video.get_caption_chunks())
|
||||
else:
|
||||
lines = []
|
||||
for comment in video.get_comments() or []:
|
||||
author = comment.get('author') or 'unknown'
|
||||
if body := (comment.get('text') or '').strip():
|
||||
lines.append(f'{author}: {body}')
|
||||
text = '\n'.join(lines)
|
||||
elif kind == 'archive' and part == 'text':
|
||||
archive = archive_lib.get_archive_by_file_group_id(session, file_group_id, skip_viewed=True)
|
||||
text = lib.read_archive_text(archive)
|
||||
|
||||
if text is None:
|
||||
raise UnknownFile(f'This is a {kind}; it has no {part} to read')
|
||||
return json_response(lib.paginate_text(text, _offset(request)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manage endpoints — for the AI page's Manage tab, NOT part of the model's tool catalog.
|
||||
# The agent loop excludes everything under /api/ai/manage/ when building tool definitions.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ def build_library_context() -> str:
|
|||
zim_titles.append(zim.path.name if zim.path else None)
|
||||
lines.append(f'- {_capped_names(zim_titles, "Zim encyclopedias")}')
|
||||
|
||||
try:
|
||||
from wrolpi import tags
|
||||
lines.append(f'- {_capped_names([i["name"] for i in tags.get_tags()], "Tags")}')
|
||||
except Exception as e:
|
||||
logger.debug('Library context could not list tags', exc_info=e)
|
||||
|
||||
try:
|
||||
from modules.inventory.common import get_inventory_configs
|
||||
inventories = [i.get('name') for i in get_inventory_configs().all_inventories()]
|
||||
|
|
|
|||
|
|
@ -73,18 +73,43 @@ def wrolpi_link(fg: dict) -> Optional[str]:
|
|||
|
||||
|
||||
def _truncate(text: Optional[str], length: int) -> Optional[str]:
|
||||
if text and len(text) > length:
|
||||
return text[:length] + '…'
|
||||
return text
|
||||
if not text:
|
||||
return None
|
||||
return text if len(text) <= length else text[:length] + '…'
|
||||
|
||||
|
||||
def format_duration(seconds) -> Optional[str]:
|
||||
"""Seconds as H:MM:SS (or M:SS), which a model reads more reliably than raw seconds."""
|
||||
try:
|
||||
seconds = int(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if seconds < 0:
|
||||
return None
|
||||
hours, rest = divmod(seconds, 3600)
|
||||
minutes, secs = divmod(rest, 60)
|
||||
return f'{hours}:{minutes:02d}:{secs:02d}' if hours else f'{minutes}:{secs:02d}'
|
||||
|
||||
|
||||
def format_date(value) -> Optional[str]:
|
||||
"""Only the date of a datetime (or ISO string); the time of day is noise for the model."""
|
||||
if not value:
|
||||
return None
|
||||
if hasattr(value, 'date'):
|
||||
return value.date().isoformat()
|
||||
return str(value)[:10]
|
||||
|
||||
|
||||
def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LENGTH,
|
||||
include_url: bool = False) -> dict:
|
||||
include_url: bool = False, detail: bool = False) -> dict:
|
||||
"""Format a FileGroup JSON dict into the lean, flat shape the model consumes.
|
||||
|
||||
Empty fields are omitted so results stay compact on small contexts. The source URL is
|
||||
omitted unless requested (archive detail): given both, small models present the familiar
|
||||
external URL instead of the WROLPi link.
|
||||
Listings (detail=False) carry only what the model needs to choose an item: id, kind, title,
|
||||
link, date, channel/author, duration, tags, and the search headline. Details (detail=True)
|
||||
add size, mimetype, and the longer description. Everything the model can derive from the id
|
||||
(captions/text endpoints) is left out. Empty fields are omitted so results stay compact on
|
||||
small contexts. The source URL is omitted unless requested (archive detail): given both,
|
||||
small models present the familiar external URL instead of the WROLPi link.
|
||||
"""
|
||||
kind = file_group_kind(fg)
|
||||
result = dict(
|
||||
|
|
@ -92,13 +117,14 @@ def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LE
|
|||
kind=kind,
|
||||
title=fg.get('title') or fg.get('name'),
|
||||
link=wrolpi_link(fg),
|
||||
mimetype=fg.get('mimetype'),
|
||||
size=fg.get('size'),
|
||||
published=fg.get('published_datetime'),
|
||||
published=format_date(fg.get('published_datetime')),
|
||||
author=fg.get('author'),
|
||||
tags=fg.get('tags') or None,
|
||||
url=fg.get('url') if include_url else None,
|
||||
)
|
||||
if detail:
|
||||
result['mimetype'] = fg.get('mimetype')
|
||||
result['size'] = fg.get('size')
|
||||
|
||||
# FTS headline (content match context), if the search requested headlines.
|
||||
headline = fg.get('d_headline') or fg.get('b_headline') or fg.get('c_headline')
|
||||
|
|
@ -107,8 +133,7 @@ def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LE
|
|||
|
||||
video = fg.get('video')
|
||||
if kind == 'video':
|
||||
result['duration'] = fg.get('length')
|
||||
result['captions_link'] = f'/api/ai/videos/{fg["id"]}/captions' if fg.get('id') else None
|
||||
result['duration'] = format_duration(fg.get('length'))
|
||||
if isinstance(video, dict):
|
||||
channel = video.get('channel')
|
||||
if isinstance(channel, dict):
|
||||
|
|
@ -117,9 +142,6 @@ def format_file_group(fg: dict, description_length: int = LISTING_DESCRIPTION_LE
|
|||
result['channel_id'] = video['channel_id']
|
||||
result['description'] = _truncate(video.get('description'), description_length)
|
||||
|
||||
if kind == 'archive' and fg.get('id'):
|
||||
result['text_link'] = f'/api/ai/archives/{fg["id"]}/text'
|
||||
|
||||
if kind == 'doc':
|
||||
doc = fg.get('doc') or {}
|
||||
result['subject'] = doc.get('subject')
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ MODES = {
|
|||
'search_zims',
|
||||
'get_zim_entry',
|
||||
'list_collections',
|
||||
'list_inventories',
|
||||
'get_inventory',
|
||||
'list_files',
|
||||
'read_file',
|
||||
|
|
|
|||
|
|
@ -89,3 +89,16 @@ class AIListFilesResponse:
|
|||
class AISearchSuggestionsRequest:
|
||||
search_str: Optional[str] = None
|
||||
tag_names: List[str] = dataclasses.field(default_factory=list)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AIFileSearchRequest:
|
||||
search_str: Optional[str] = None
|
||||
kind: Optional[str] = None
|
||||
channel: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
tag_names: Optional[List[str]] = None
|
||||
limit: int = 5
|
||||
offset: int = 0
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ async def test_ai_search_videos(async_client, test_session, video_factory):
|
|||
result = response.json['results'][0]
|
||||
assert result['kind'] == 'video'
|
||||
assert result['link'] == f'/videos/{result["id"]}'
|
||||
assert result['captions_link'] == f'/api/ai/videos/{result["id"]}/captions'
|
||||
assert 'captions_link' not in result # derivable from the id; kept out of listings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -113,7 +113,7 @@ async def test_ai_search_archives(async_client, test_session, archive_factory):
|
|||
assert response.json['total'] == 1
|
||||
result = response.json['results'][0]
|
||||
assert result['kind'] == 'archive'
|
||||
assert result['text_link'] == f'/api/ai/archives/{result["id"]}/text'
|
||||
assert 'text_link' not in result # derivable from the id; kept out of listings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -519,3 +519,176 @@ def test_ai_format_download_error_tail():
|
|||
assert formatted['error'].endswith('ValueError: the real reason')
|
||||
assert len(formatted['error']) == DOWNLOAD_ERROR_LENGTH + 1
|
||||
assert 'error' not in _format_download(dict(id=2, url='https://example.com', status='new', error=None))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Consolidated, kind-generic endpoints for small models.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_search_files_kinds(async_client, test_session, video_factory, archive_factory, channel_factory):
|
||||
"""One search covers every kind; kind narrows it; channel accepts a name or an id."""
|
||||
channel = channel_factory(name='Wild Cooking')
|
||||
video_factory(channel_id=channel.id, title='cooking rice')
|
||||
video_factory(title='cooking beans video')
|
||||
archive_factory(domain='cooking.example.com', title='cooking beans', contents='beans')
|
||||
test_session.commit()
|
||||
|
||||
async def search(**body):
|
||||
request, response = await async_client.post('/api/ai/files/search', content=json.dumps(body))
|
||||
assert response.status_code == HTTPStatus.OK, response.json
|
||||
return response.json
|
||||
|
||||
data = await search(search_str='cooking')
|
||||
assert data['total'] == 3
|
||||
assert {i['kind'] for i in data['results']} == {'video', 'archive'}
|
||||
# The matching channel and domain are offered so the model can narrow without another tool.
|
||||
assert [i['name'] for i in data['matches']['channels']] == ['Wild Cooking']
|
||||
assert data['matches']['channels'][0]['id'] == channel.id
|
||||
assert [i['name'] for i in data['matches']['domains']] == ['cooking.example.com']
|
||||
|
||||
data = await search(search_str='cooking', kind='video')
|
||||
assert data['total'] == 2 and all(i['kind'] == 'video' for i in data['results'])
|
||||
|
||||
data = await search(search_str='cooking', kind='archive')
|
||||
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
|
||||
|
||||
# channel by exact name, partial name, and numeric id; channel implies kind=video.
|
||||
for channel_ref in ('Wild Cooking', 'wild', str(channel.id)):
|
||||
data = await search(search_str='cooking', channel=channel_ref)
|
||||
assert data['total'] == 1, channel_ref
|
||||
assert data['results'][0]['title'] == 'cooking rice'
|
||||
assert 'matches' not in data # already narrowed to one channel
|
||||
|
||||
# domain implies kind=archive.
|
||||
data = await search(domain='cooking.example.com')
|
||||
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
|
||||
|
||||
# A filter that does not apply to the kind is ignored, not fatal.
|
||||
data = await search(search_str='cooking', kind='archive', channel='Wild Cooking')
|
||||
assert data['total'] == 1 and data['results'][0]['kind'] == 'archive'
|
||||
|
||||
# An unknown channel name gives no results and a hint, not an error.
|
||||
data = await search(search_str='cooking', channel='No Such Channel')
|
||||
assert data['total'] == 0 and data['results'] == []
|
||||
assert 'channel' in data['hint'].lower()
|
||||
|
||||
# Listings are slim: ids, titles, links, kind; no links the model can derive.
|
||||
result = data = (await search(search_str='cooking', kind='video'))['results'][0]
|
||||
assert {'id', 'kind', 'title', 'link'} <= set(result)
|
||||
assert 'captions_link' not in result and 'size' not in result and 'mimetype' not in result
|
||||
|
||||
# Something to search by is required.
|
||||
request, response = await async_client.post('/api/ai/files/search', content=json.dumps({}))
|
||||
assert response.status_code == HTTPStatus.OK # browse newest
|
||||
assert response.json['total'] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_search_files_docs(async_client, test_session, test_directory, example_epub, refresh_files):
|
||||
"""kind=doc searches documents; author/subject imply kind=doc."""
|
||||
await refresh_files()
|
||||
request, response = await async_client.post('/api/ai/files/search', content=json.dumps(dict(search_str='WROLPi', kind='doc')))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] >= 1
|
||||
doc = response.json['results'][0]
|
||||
assert doc['kind'] == 'doc' and doc['link'] == f'/docs/{doc["id"]}'
|
||||
|
||||
# get_file returns the doc detail through the generic route.
|
||||
request, response = await async_client.get(f'/api/ai/files/{doc["id"]}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['kind'] == 'doc' and response.json['id'] == doc['id']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_get_file(async_client, test_session, video_factory, archive_factory, make_files_structure, tag_factory):
|
||||
"""get_file dispatches on the file's model and carries more than the listing row."""
|
||||
tag = await tag_factory('food')
|
||||
video = video_factory(title='wood stove install', with_caption_file=True)
|
||||
video.file_group.add_tag(test_session, tag.id)
|
||||
archive_factory(domain='example.com', url='https://example.com/a', title='first', contents='one')
|
||||
archive = archive_factory(domain='example.com', url='https://example.com/a', title='second', contents='two')
|
||||
test_session.commit()
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['kind'] == 'video'
|
||||
assert response.json['link'] == f'/videos/{video.file_group_id}'
|
||||
assert response.json['has_captions'] is True
|
||||
assert response.json['tags'] == ['food']
|
||||
assert response.json['mimetype'].startswith('video/')
|
||||
# Fetching does not mark the video viewed.
|
||||
test_session.expire_all()
|
||||
assert video.file_group.viewed is None
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['kind'] == 'archive'
|
||||
assert response.json['url'] == 'https://example.com/a'
|
||||
assert [i['title'] for i in response.json['history']] == ['first']
|
||||
|
||||
# A plain file (no model) still resolves with a media link.
|
||||
from wrolpi.files.lib import upsert_file
|
||||
path, = make_files_structure({'photos/a.png': 'not really a png'})
|
||||
fg = await upsert_file(path)
|
||||
request, response = await async_client.get(f'/api/ai/files/{fg.id}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['link'] == '/media/photos/a.png'
|
||||
|
||||
request, response = await async_client.get('/api/ai/files/123456')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_read_content(async_client, test_session, video_factory, archive_factory):
|
||||
"""read_content serves captions/comments for videos and text for archives, paged."""
|
||||
info_json = dict(duration=5, comments=[dict(author='alice', text='great video')])
|
||||
video = video_factory(title='captioned', with_caption_file=True, with_info_json=info_json)
|
||||
contents = 'word ' * 2_000
|
||||
archive = archive_factory(domain='example.com', title='long read', contents=contents)
|
||||
test_session.commit()
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['content'].startswith('[00:00:0')
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?part=comments')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert 'alice: great video' in response.json['content']
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}/content')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert len(response.json['content']) == lib.PAGE_SIZE
|
||||
assert response.json['next_offset'] == lib.PAGE_SIZE
|
||||
request, response = await async_client.get(
|
||||
f'/api/ai/files/{archive.file_group_id}/content?offset={response.json["next_offset"]}')
|
||||
assert response.json['content'] == contents[lib.PAGE_SIZE:2 * lib.PAGE_SIZE]
|
||||
|
||||
# An archive has no comments; the error says what the file is.
|
||||
request, response = await async_client.get(f'/api/ai/files/{archive.file_group_id}/content?part=comments')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
assert 'archive' in response.json['error'].lower()
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/files/{video.file_group_id}/content?part=bogus')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
request, response = await async_client.get('/api/ai/files/123456/content')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_get_inventory_optional_slug(async_client, food_inventory_factory):
|
||||
"""Without a slug the inventories are listed lean; with one, the inventory is returned in full."""
|
||||
slug = food_inventory_factory(items=[dict(name='rice', count=2)])
|
||||
|
||||
request, response = await async_client.get('/api/ai/inventories')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
lean = next(i for i in response.json['results'] if i['slug'] == slug)
|
||||
assert lean['item_count'] == 1 and 'items' not in lean
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/inventories?slug={slug}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['inventory']['items'][0]['name'] == 'rice'
|
||||
|
||||
request, response = await async_client.get('/api/ai/inventories?slug=no-such-inventory')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ from modules.ai import context
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_library_context(async_client, test_session, video_factory, archive_factory,
|
||||
test_zim, food_inventory_factory):
|
||||
test_zim, food_inventory_factory, tag_factory):
|
||||
"""The map carries today's date, counts, and exact names."""
|
||||
from wrolpi.collections.models import Collection
|
||||
test_session.add(Collection(name='Talking Sasquach', kind='channel'))
|
||||
await tag_factory('Food Preservation')
|
||||
video_factory(title='a video')
|
||||
archive_factory(domain='example.com', title='a page', contents='contents')
|
||||
food_inventory_factory(name='Pantry')
|
||||
|
|
@ -25,6 +26,7 @@ async def test_build_library_context(async_client, test_session, video_factory,
|
|||
assert 'example.com' in text
|
||||
assert 'WROLPi example ZIM' in text
|
||||
assert 'Pantry' in text
|
||||
assert 'Tags (1): Food Preservation' in text
|
||||
assert 'user spellings may differ' in text
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -53,15 +53,21 @@ def test_format_file_group_video():
|
|||
assert result['id'] == 42
|
||||
assert result['kind'] == 'video'
|
||||
assert result['link'] == '/videos/42'
|
||||
assert result['captions_link'] == '/api/ai/videos/42/captions'
|
||||
assert result['channel'] == 'Homesteading'
|
||||
assert result['duration'] == 4021
|
||||
assert result['duration'] == '1:07:01'
|
||||
assert result['published'] == '2024-01-02'
|
||||
assert result['headline'] == 'pressure <b>canning</b>'
|
||||
assert result['tags'] == ['food']
|
||||
# Listing descriptions are truncated.
|
||||
assert len(result['description']) == lib.LISTING_DESCRIPTION_LENGTH + 1
|
||||
# Empty fields are omitted.
|
||||
assert 'author' not in result and 'url' not in result
|
||||
# Listings leave out what the model can derive or does not need to choose an item.
|
||||
assert 'captions_link' not in result and 'size' not in result and 'mimetype' not in result
|
||||
# Details add them back.
|
||||
detail = lib.format_file_group(fg, description_length=lib.DETAIL_DESCRIPTION_LENGTH, detail=True)
|
||||
assert detail['size'] == 1000 and detail['mimetype'] == 'video/mp4'
|
||||
assert len(detail['description']) == 500
|
||||
|
||||
|
||||
def test_format_file_group_archive():
|
||||
|
|
@ -69,7 +75,7 @@ def test_format_file_group_archive():
|
|||
result = lib.format_file_group(fg)
|
||||
assert result['kind'] == 'archive'
|
||||
assert result['link'] == '/archives/8'
|
||||
assert result['text_link'] == '/api/ai/archives/8/text'
|
||||
assert 'text_link' not in result
|
||||
# Listings omit the source URL so small models present the WROLPi link instead.
|
||||
assert 'url' not in result
|
||||
assert lib.format_file_group(fg, include_url=True)['url'] == 'https://example.com/a'
|
||||
|
|
@ -109,6 +115,17 @@ def test_html_to_text():
|
|||
assert 'evil' not in text and 'menu' not in text and '.x{}' not in text
|
||||
|
||||
|
||||
def test_format_duration_and_date():
|
||||
assert lib.format_duration(59) == '0:59'
|
||||
assert lib.format_duration(692) == '11:32'
|
||||
assert lib.format_duration(4021) == '1:07:01'
|
||||
assert lib.format_duration(None) is None and lib.format_duration('x') is None
|
||||
import datetime
|
||||
assert lib.format_date(datetime.datetime(2024, 1, 2, 13, 4, 5)) == '2024-01-02'
|
||||
assert lib.format_date('2024-01-02T13:04:05+00:00') == '2024-01-02'
|
||||
assert lib.format_date(None) is None
|
||||
|
||||
|
||||
def test_format_caption_chunks():
|
||||
chunks = [dict(start_seconds=0, text='hello'), dict(start_seconds=3671, text='goodbye')]
|
||||
text = lib.format_caption_chunks(chunks)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ EXPECTED_TOOLS = {
|
|||
'list_collections', 'list_zim_files', 'list_files',
|
||||
'get_statistics', 'get_inventory', 'get_inventory_items', 'get_status',
|
||||
'list_tags', 'search_suggestions', 'list_downloads', 'get_map_overview', 'search_places',
|
||||
'search_files', 'get_file', 'read_content',
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ _FIELD_LABELS = (
|
|||
('duration', 'Duration'),
|
||||
('published', 'Published'),
|
||||
('channel', 'Channel'),
|
||||
('has_captions', 'Has captions'),
|
||||
('has_comments', 'Has comments'),
|
||||
('author', 'Author'),
|
||||
('subject', 'Subject'),
|
||||
('language', 'Language'),
|
||||
|
|
@ -54,9 +56,9 @@ _FIELD_LABELS = (
|
|||
)
|
||||
|
||||
_KIND_HINTS = {
|
||||
'video': 'use this for get_video, get_video_captions, get_video_comments',
|
||||
'archive': 'use this for get_archive, get_archive_text',
|
||||
'doc': 'use this for get_doc',
|
||||
'video': 'use this for get_file, read_content (captions/comments)',
|
||||
'archive': 'use this for get_file, read_content (page text)',
|
||||
'doc': 'use this for get_file',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -84,6 +86,10 @@ def _render_result(item: dict) -> str:
|
|||
parts.append(f'{label}: {value}')
|
||||
if tags := item.get('tags'):
|
||||
parts.append(f'Tags: {", ".join(map(str, tags))}')
|
||||
if history := item.get('history'):
|
||||
parts.append(f'Earlier snapshots ({len(history)}):')
|
||||
for snapshot in history:
|
||||
parts.append(f' - {snapshot.get("title", "Untitled")} (ID: {snapshot.get("id")}, {snapshot.get("published", "?")})')
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
|
|
@ -95,6 +101,13 @@ def _render_results(data: dict) -> str:
|
|||
text = '\n\n'.join(sections)
|
||||
if (total := data.get('total')) is not None:
|
||||
text += f'\n\nTotal matching: {total}'
|
||||
if matches := data.get('matches'):
|
||||
text += '\n\nNames matching the term (use to narrow the search):'
|
||||
for channel in matches.get('channels') or []:
|
||||
text += f"\n channel: {channel.get('name')} (ID: {channel.get('id')})"
|
||||
for group in ('domains', 'authors', 'subjects'):
|
||||
for item in matches.get(group) or []:
|
||||
text += f"\n {group[:-1]}: {item.get('name')}"
|
||||
return text
|
||||
|
||||
|
||||
|
|
@ -165,11 +178,14 @@ async def search(
|
|||
tag_names: Filter by tag names.
|
||||
"""
|
||||
body = {"search_str": query, "limit": limit, "offset": offset}
|
||||
if mimetypes:
|
||||
body["mimetypes"] = mimetypes
|
||||
if tag_names:
|
||||
body["tag_names"] = tag_names
|
||||
return _render_results(await api_post("/api/ai/search", json=body))
|
||||
# mimetypes are no longer a filter; a kind is. Map the common prefixes.
|
||||
if mimetypes:
|
||||
kinds = {("video" if m.startswith("video/") else "archive" if m == "text/html" else "doc") for m in mimetypes}
|
||||
if len(kinds) == 1:
|
||||
body["kind"] = kinds.pop()
|
||||
return _render_results(await api_post("/api/ai/files/search", json=body))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
|
|
@ -189,12 +205,12 @@ async def search_videos(
|
|||
channel_id: Filter to a specific channel by ID.
|
||||
tag_names: Filter by tag names.
|
||||
"""
|
||||
body = {"search_str": query, "limit": limit, "offset": offset}
|
||||
body = {"search_str": query, "limit": limit, "offset": offset, "kind": "video"}
|
||||
if channel_id is not None:
|
||||
body["channel_id"] = channel_id
|
||||
body["channel"] = str(channel_id)
|
||||
if tag_names:
|
||||
body["tag_names"] = tag_names
|
||||
return _render_results(await api_post("/api/ai/videos/search", json=body))
|
||||
return _render_results(await api_post("/api/ai/files/search", json=body))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
|
|
@ -214,12 +230,12 @@ async def search_archives(
|
|||
domain: Filter to a specific domain (e.g. "example.com").
|
||||
tag_names: Filter by tag names.
|
||||
"""
|
||||
body = {"search_str": query, "limit": limit, "offset": offset}
|
||||
body = {"search_str": query, "limit": limit, "offset": offset, "kind": "archive"}
|
||||
if domain:
|
||||
body["domain"] = domain
|
||||
if tag_names:
|
||||
body["tag_names"] = tag_names
|
||||
return _render_results(await api_post("/api/ai/archives/search", json=body))
|
||||
return _render_results(await api_post("/api/ai/files/search", json=body))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
|
|
@ -247,20 +263,17 @@ async def search_docs(
|
|||
offset: Pagination offset.
|
||||
tag_names: Filter by tag names.
|
||||
"""
|
||||
body = {"limit": limit, "offset": offset}
|
||||
body = {"limit": limit, "offset": offset, "kind": "doc"}
|
||||
if query:
|
||||
body["search_str"] = query
|
||||
if author:
|
||||
body["author"] = author
|
||||
if subject:
|
||||
body["subject"] = subject
|
||||
if language:
|
||||
body["language"] = language
|
||||
if mimetype:
|
||||
body["mimetype"] = mimetype
|
||||
if tag_names:
|
||||
body["tag_names"] = tag_names
|
||||
return _render_results(await api_post("/api/ai/docs/search", json=body))
|
||||
# language and mimetype are accepted for compatibility; the consolidated search does not filter on them.
|
||||
return _render_results(await api_post("/api/ai/files/search", json=body))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
|
|
@ -270,7 +283,7 @@ async def get_doc(file_group_id: int) -> str:
|
|||
Args:
|
||||
file_group_id: The top-level ID from search results (this is the FileGroup ID).
|
||||
"""
|
||||
return _render_result(await api_get(f"/api/ai/docs/{file_group_id}"))
|
||||
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
|
|
@ -331,7 +344,7 @@ async def get_video(file_group_id: int) -> str:
|
|||
Args:
|
||||
file_group_id: The FileGroup ID from search results.
|
||||
"""
|
||||
return _render_result(await api_get(f"/api/ai/videos/{file_group_id}"))
|
||||
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
|
|
@ -341,7 +354,7 @@ async def get_video_captions(file_group_id: int) -> str:
|
|||
Args:
|
||||
file_group_id: The FileGroup ID from search results.
|
||||
"""
|
||||
text = await _read_paged(f"/api/ai/videos/{file_group_id}/captions")
|
||||
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": "text"})
|
||||
return text or "No captions available for this video."
|
||||
|
||||
|
||||
|
|
@ -352,7 +365,7 @@ async def get_video_comments(file_group_id: int) -> str:
|
|||
Args:
|
||||
file_group_id: The FileGroup ID from search results.
|
||||
"""
|
||||
text = await _read_paged(f"/api/ai/videos/{file_group_id}/comments")
|
||||
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": "comments"})
|
||||
return text or "No comments available for this video."
|
||||
|
||||
|
||||
|
|
@ -363,13 +376,7 @@ async def get_archive(file_group_id: int) -> str:
|
|||
Args:
|
||||
file_group_id: The FileGroup ID from search results.
|
||||
"""
|
||||
data = await api_get(f"/api/ai/archives/{file_group_id}")
|
||||
parts = [_render_result(data)]
|
||||
if history := data.get("history"):
|
||||
parts.append(f"\nArchive history ({len(history)} snapshots):")
|
||||
for snapshot in history:
|
||||
parts.append(f" - {snapshot.get('title', 'Untitled')} (ID: {snapshot.get('id')})")
|
||||
return "\n".join(parts)
|
||||
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
|
|
@ -382,7 +389,7 @@ async def get_archive_text(file_group_id: int) -> str:
|
|||
file_group_id: The FileGroup ID from search results.
|
||||
"""
|
||||
try:
|
||||
text = await _read_paged(f"/api/ai/archives/{file_group_id}/text")
|
||||
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": "text"})
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return f"Could not retrieve text content for archive {file_group_id}. Try get_archive for metadata."
|
||||
|
|
@ -415,6 +422,65 @@ async def get_zim_entry(zim_id: int, entry_path: str) -> str:
|
|||
return f"LINK: {link}\n\n{text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kind-generic tools (the compact set the built-in assistant uses)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def search_files(
|
||||
query: str | None = None,
|
||||
kind: str | None = None,
|
||||
channel: str | None = None,
|
||||
domain: str | None = None,
|
||||
author: str | None = None,
|
||||
subject: str | None = None,
|
||||
tag_names: list[str] | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
"""Search the whole library (videos, archived web pages, documents/ebooks) in one call.
|
||||
|
||||
Args:
|
||||
query: Text to search for in titles and content. Omit to browse the newest items.
|
||||
kind: Narrow to "video", "archive", or "doc".
|
||||
channel: A video channel name (or id); implies kind=video.
|
||||
domain: An archived site name, e.g. "example.com"; implies kind=archive.
|
||||
author: Document author (partial match); implies kind=doc.
|
||||
subject: Document subject (partial match); implies kind=doc.
|
||||
tag_names: Only items with these tags.
|
||||
limit: Maximum results.
|
||||
offset: Pagination offset.
|
||||
"""
|
||||
body = {k: v for k, v in dict(search_str=query, kind=kind, channel=channel, domain=domain, author=author,
|
||||
subject=subject, tag_names=tag_names, limit=limit, offset=offset).items()
|
||||
if v not in (None, [], "")}
|
||||
return _render_results(await api_post("/api/ai/files/search", json=body))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def get_file(file_group_id: int) -> str:
|
||||
"""Get details about one item of any kind (video, archived page, document) by its ID from search results."""
|
||||
return _render_result(await api_get(f"/api/ai/files/{file_group_id}"))
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def read_content(file_group_id: int, part: str = "text") -> str:
|
||||
"""Read what an item says: a video's captions or an archived page's text (part="text"), or a video's
|
||||
comments (part="comments").
|
||||
|
||||
Args:
|
||||
file_group_id: The ID from search results.
|
||||
part: "text" (default) or "comments".
|
||||
"""
|
||||
try:
|
||||
text = await _read_paged(f"/api/ai/files/{file_group_id}/content", params={"part": part})
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return f"No {part} available for item {file_group_id}. Try get_file for its details."
|
||||
raise
|
||||
return text or f"No {part} available for item {file_group_id}."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browsing / listing tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -689,7 +755,7 @@ async def get_inventory_items(inventory_slug: str) -> str:
|
|||
Args:
|
||||
inventory_slug: The inventory slug (from get_inventory results).
|
||||
"""
|
||||
data = await api_get(f"/api/ai/inventories/{inventory_slug}")
|
||||
data = await api_get("/api/ai/inventories", params={"slug": inventory_slug})
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue