mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Compare commits
26 commits
master
...
ai-preview
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b3c6510b4 | ||
|
|
168f194512 | ||
|
|
1b172ddda5 | ||
|
|
e4d996013a | ||
|
|
85ce1d0172 | ||
|
|
c5ca3dae78 | ||
|
|
6cbc48c78e | ||
|
|
6b6f057c86 | ||
|
|
6e8e11b2b9 | ||
|
|
40b63b6a28 | ||
|
|
ced78e181f | ||
|
|
82e11cbaba | ||
|
|
642cfa29ee | ||
|
|
6ca2fc954c | ||
|
|
9cb1414d7a | ||
|
|
bf52b24b98 | ||
|
|
ebbea75e26 | ||
|
|
4a37d95fab | ||
|
|
0347c2901b | ||
|
|
f86058ee8a | ||
|
|
485a9703eb | ||
|
|
41bd8f7c45 | ||
|
|
c6e0f18d5e | ||
|
|
85604c80fe | ||
|
|
b07672b976 | ||
|
|
d77eaa9ecd |
67 changed files with 6364 additions and 10 deletions
|
|
@ -31,6 +31,7 @@ import {ZimRoute} from "./components/Zim";
|
|||
import {PlaylistsRoute} from "./components/Playlists";
|
||||
import {DocsRoute} from "./components/Docs";
|
||||
import {FlasherRoute} from "./components/Flasher";
|
||||
import {AiRoute} from "./components/Ai";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import {KeyboardShortcutsProvider} from "./components/KeyboardShortcutsProvider";
|
||||
|
||||
|
|
@ -116,6 +117,7 @@ const router = createBrowserRouter(createRoutesFromElements(<Route
|
|||
<Route path='playlists/*' element={<ErrorBoundary><PlaylistsRoute/></ErrorBoundary>}/>
|
||||
<Route path='files/*' element={<ErrorBoundary><FilesRoute/></ErrorBoundary>}/>
|
||||
<Route path='flasher/*' element={<ErrorBoundary><FlasherRoute/></ErrorBoundary>}/>
|
||||
<Route path='ai/*' element={<ErrorBoundary><AiRoute/></ErrorBoundary>}/>
|
||||
</Route>));
|
||||
|
||||
export default function App() {
|
||||
|
|
|
|||
|
|
@ -2438,3 +2438,33 @@ export async function setPlaylistTag(playlistId, tagName, directory) {
|
|||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
throw Error(message);
|
||||
}
|
||||
|
||||
// --- AI assistant ---
|
||||
|
||||
export async function getAiCatalog() {
|
||||
const response = await apiGet(`${API_URI}/ai/manage/catalog`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
const message = await getErrorMessage(response, 'Failed to fetch AI catalog.');
|
||||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
}
|
||||
|
||||
export async function postAiSettings(settings) {
|
||||
const response = await apiPost(`${API_URI}/ai/manage/settings`, settings);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
const message = await getErrorMessage(response, 'Failed to save AI settings.');
|
||||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
throw Error(message);
|
||||
}
|
||||
|
||||
export async function getChatModes() {
|
||||
const response = await apiGet(`${API_URI}/chat/modes`);
|
||||
if (response.ok) {
|
||||
return (await response.json())['modes'];
|
||||
}
|
||||
const message = await getErrorMessage(response, 'Failed to fetch chat modes.');
|
||||
toast({type: 'error', title: 'Error', description: message, time: 5000});
|
||||
}
|
||||
|
|
|
|||
216
app/src/components/Ai.js
Normal file
216
app/src/components/Ai.js
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, {useContext, useEffect, useState} from 'react';
|
||||
import {Route, Routes} from 'react-router';
|
||||
|
||||
import {getAiCatalog, postAiSettings, postDownload} from '../api';
|
||||
import {getServiceStatus, startService, stopService} from '../api/controller';
|
||||
import {AiChatProvider} from '../contexts/AiChatContext';
|
||||
import {StatusContext} from '../contexts/contexts';
|
||||
import {AiChat} from './AiChat';
|
||||
import {APIButton, humanFileSize, InfoMessage, PageContainer, TabLinks, useTitle, WROLModeMessage} from './Common';
|
||||
import {SortableTable} from './SortableTable';
|
||||
import {Button, Divider, Group, Header, NumberInput, Select, Table, Text, Toggle, toast} from './ui';
|
||||
import {Downloaders} from './Vars';
|
||||
|
||||
// The Controller's name for llama-server: the compose service in docker, the systemd unit natively.
|
||||
export const llmServiceName = (dockerized) => dockerized ? 'llm' : 'wrolpi-llm';
|
||||
|
||||
export function AiModelRow({model, onDownload}) {
|
||||
const {name, tier, size, description, downloaded, active} = model;
|
||||
|
||||
return <Table.Row>
|
||||
<Table.Cell>
|
||||
<Text fw={active ? 700 : undefined}>{name}{active ? ' (active)' : ''}</Text>
|
||||
{description && <Text size='sm' c='dimmed'>{description}</Text>}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{tier}</Table.Cell>
|
||||
<Table.Cell>{humanFileSize(size)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{downloaded
|
||||
? <Button size='xs' icon='check' disabled role='save'>
|
||||
{tier === 'custom' ? 'On disk' : 'Downloaded'}
|
||||
</Button>
|
||||
: <APIButton size='xs' icon='download' color='violet' onClick={() => onDownload(model)}>
|
||||
Download
|
||||
</APIButton>}
|
||||
</Table.Cell>
|
||||
</Table.Row>;
|
||||
}
|
||||
|
||||
export function ManageAi() {
|
||||
const [catalog, setCatalog] = useState(null);
|
||||
const [serviceStatus, setServiceStatus] = useState(null);
|
||||
const [pendingSave, setPendingSave] = useState(false);
|
||||
const {status} = useContext(StatusContext);
|
||||
const serviceName = llmServiceName(status?.dockerized);
|
||||
|
||||
const fetchCatalog = async () => {
|
||||
try {
|
||||
const data = await getAiCatalog();
|
||||
if (data) {
|
||||
setCatalog(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchService = async () => {
|
||||
try {
|
||||
setServiceStatus(await getServiceStatus(serviceName));
|
||||
} catch (e) {
|
||||
// The service is unknown until AI is installed; not an error worth toasting.
|
||||
setServiceStatus(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCatalog();
|
||||
fetchService();
|
||||
const interval = setInterval(() => {
|
||||
fetchCatalog();
|
||||
fetchService();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serviceName]);
|
||||
|
||||
const handleDownload = async (model) => {
|
||||
await postDownload({
|
||||
urls: [model.url],
|
||||
downloader: Downloaders.AiModel,
|
||||
destination: 'ai/models',
|
||||
});
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Download started',
|
||||
description: 'Monitor progress on the Downloads page.',
|
||||
time: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const saveSettings = async (settings) => {
|
||||
setPendingSave(true);
|
||||
try {
|
||||
await postAiSettings(settings);
|
||||
await fetchCatalog();
|
||||
} finally {
|
||||
setPendingSave(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServiceButton = async () => {
|
||||
if (serviceStatus?.status === 'running') {
|
||||
await stopService(serviceName);
|
||||
} else {
|
||||
await startService(serviceName);
|
||||
}
|
||||
await fetchService();
|
||||
};
|
||||
|
||||
if (!catalog) {
|
||||
return <WROLModeMessage content='Cannot modify AI'/>;
|
||||
}
|
||||
|
||||
const {models, recommended_tier, total_ram, slow_hardware, disk_usage, enabled, active_model} = catalog;
|
||||
const downloadedModels = models.filter(i => i.downloaded);
|
||||
|
||||
const modelHeaders = [
|
||||
{key: 'name', text: 'Model', sortBy: i => i.name.toLowerCase()},
|
||||
{key: 'tier', text: 'Tier', sortBy: i => i.tier},
|
||||
{key: 'size', text: 'Size', sortBy: i => i.size || 0},
|
||||
{key: 'actions', text: '', sortBy: null},
|
||||
];
|
||||
|
||||
return <>
|
||||
<WROLModeMessage content='Cannot modify AI'/>
|
||||
|
||||
{slow_hardware && <InfoMessage storageName='hint_ai_slow_hardware'>
|
||||
This hardware will run the assistant slowly. Answers may take minutes; the smallest
|
||||
model is recommended.
|
||||
</InfoMessage>}
|
||||
|
||||
<Header as='h3'>AI Assistant</Header>
|
||||
<Group gap='lg'>
|
||||
<Toggle
|
||||
label='Enabled'
|
||||
checked={enabled === true}
|
||||
disabled={pendingSave || !active_model}
|
||||
onChange={e => saveSettings({enabled: e.target.checked})}
|
||||
/>
|
||||
<Select
|
||||
label='Active Model'
|
||||
data={downloadedModels.map(i => ({value: i.name, label: i.name}))}
|
||||
value={active_model || null}
|
||||
placeholder={downloadedModels.length ? 'Select a model' : 'Download a model below'}
|
||||
disabled={pendingSave || !downloadedModels.length}
|
||||
onChange={value => value && saveSettings({active_model: value})}
|
||||
/>
|
||||
<NumberInput
|
||||
label='Idle unload (minutes)'
|
||||
min={1}
|
||||
value={catalog.idle_unload_minutes}
|
||||
disabled={pendingSave}
|
||||
onChange={value => value && saveSettings({idle_unload_minutes: value})}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{enabled && <Group gap='xs' style={{marginTop: '1em'}}>
|
||||
<Text>Service: {serviceStatus?.status || 'unknown'}</Text>
|
||||
<APIButton
|
||||
size='xs'
|
||||
color={serviceStatus?.status === 'running' ? undefined : 'violet'}
|
||||
role={serviceStatus?.status === 'running' ? 'danger' : undefined}
|
||||
onClick={handleServiceButton}
|
||||
>
|
||||
{serviceStatus?.status === 'running' ? 'Stop' : 'Start'}
|
||||
</APIButton>
|
||||
</Group>}
|
||||
|
||||
<Divider/>
|
||||
|
||||
<Header as='h3'>Model Catalog</Header>
|
||||
<Text size='sm'>
|
||||
This device has {humanFileSize(total_ram)} of RAM;
|
||||
the <b>{recommended_tier}</b> tier is recommended.
|
||||
Models use {humanFileSize(disk_usage)} in <b>ai/models/</b>.
|
||||
</Text>
|
||||
<SortableTable
|
||||
tableHeaders={modelHeaders}
|
||||
data={models}
|
||||
rowFunc={(model) => <AiModelRow key={model.name} model={model} onDownload={handleDownload}/>}
|
||||
rowKey='name'
|
||||
defaultSortColumn='size'
|
||||
/>
|
||||
|
||||
<InfoMessage storageName='hint_ai_models'>
|
||||
<p>The AI assistant runs entirely on this WROLPi — no internet is needed after a model
|
||||
is downloaded. Download a model, select it, then enable the assistant. Monitor
|
||||
download progress on the <b>Downloads</b> page.</p>
|
||||
<p>You can also copy your own GGUF file into <b>ai/models/</b>; it will appear here as a
|
||||
<b>custom</b> model the next time this page loads. Use a plain ASCII file name without
|
||||
<b>#</b>, quotes, or <b>..</b>. The model must support tool calling (Qwen3, Llama 3.x,
|
||||
and Mistral instruct models do). Selecting a custom model resets the context size to the
|
||||
default; set <b>context_size</b> in <b>ai.yaml</b> to override it.</p>
|
||||
</InfoMessage>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function AiRoute() {
|
||||
useTitle('AI');
|
||||
|
||||
const links = [
|
||||
{text: 'Chat', to: '/ai', key: 'chat', end: true},
|
||||
{text: 'Manage', to: '/ai/manage', key: 'manage'},
|
||||
];
|
||||
|
||||
// The provider wraps both tabs so the conversation survives visiting Manage.
|
||||
return <AiChatProvider>
|
||||
<PageContainer>
|
||||
<TabLinks links={links}/>
|
||||
<Routes>
|
||||
<Route path='/' exact element={<AiChat/>}/>
|
||||
<Route path='manage' exact element={<ManageAi/>}/>
|
||||
</Routes>
|
||||
</PageContainer>
|
||||
</AiChatProvider>;
|
||||
}
|
||||
130
app/src/components/Ai.test.js
Normal file
130
app/src/components/Ai.test.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import React from 'react';
|
||||
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
|
||||
import {render} from '../test-utils';
|
||||
import {AiModelRow, ManageAi, llmServiceName} from './Ai';
|
||||
import {Table} from './ui';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
getAiCatalog: jest.fn(),
|
||||
postAiSettings: jest.fn().mockResolvedValue({}),
|
||||
postDownload: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.mock('../api/controller', () => ({
|
||||
getServiceStatus: jest.fn().mockResolvedValue({name: 'wrolpi-llm', status: 'stopped'}),
|
||||
startService: jest.fn().mockResolvedValue({}),
|
||||
stopService: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const {getAiCatalog, postAiSettings, postDownload} = require('../api');
|
||||
|
||||
const catalogFixture = (overrides = {}) => ({
|
||||
models: [
|
||||
{
|
||||
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
|
||||
url: 'https://cdn.example.com/ai/Qwen3-1.7B-Q4_K_M.gguf',
|
||||
description: 'Small model.', downloaded: true, active: true,
|
||||
},
|
||||
{
|
||||
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
|
||||
url: 'https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
|
||||
description: 'Medium model.', downloaded: false, active: false,
|
||||
},
|
||||
],
|
||||
catalog_source: 'bundled',
|
||||
total_ram: 8 * 1024 ** 3,
|
||||
recommended_tier: 'medium',
|
||||
slow_hardware: false,
|
||||
models_directory: '/media/wrolpi/ai/models',
|
||||
disk_usage: 1_200_000_000,
|
||||
enabled: false,
|
||||
active_model: 'Qwen3-1.7B-Q4_K_M.gguf',
|
||||
idle_unload_minutes: 15,
|
||||
context_size: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('llmServiceName', () => {
|
||||
it('is the compose service in docker, the systemd unit natively', () => {
|
||||
expect(llmServiceName(true)).toBe('llm');
|
||||
expect(llmServiceName(false)).toBe('wrolpi-llm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiModelRow', () => {
|
||||
const renderRow = (model, onDownload = jest.fn()) => render(
|
||||
<Table><Table.Body><AiModelRow model={model} onDownload={onDownload}/></Table.Body></Table>);
|
||||
|
||||
it('shows a downloaded, active model without a download button', () => {
|
||||
renderRow(catalogFixture().models[0]);
|
||||
expect(screen.getByText(/Qwen3-1.7B-Q4_K_M.gguf \(active\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Downloaded')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Download')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers a download for a model that is not downloaded', async () => {
|
||||
const onDownload = jest.fn();
|
||||
renderRow(catalogFixture().models[1], onDownload);
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
await waitFor(() => expect(onDownload).toHaveBeenCalled());
|
||||
expect(onDownload.mock.calls[0][0].name).toBe('Qwen3-4B-Instruct-2507-Q4_K_M.gguf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ManageAi', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getAiCatalog.mockResolvedValue(catalogFixture());
|
||||
});
|
||||
|
||||
it('renders the catalog with the RAM recommendation', async () => {
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText(/tier is recommended/)).toBeInTheDocument());
|
||||
// The active model appears in both the Select and its catalog row.
|
||||
expect(screen.getAllByText(/Qwen3-1.7B-Q4_K_M.gguf/).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText(/of RAM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('starts a model download with the ai_model downloader', async () => {
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText('Download')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
await waitFor(() => expect(postDownload).toHaveBeenCalled());
|
||||
expect(postDownload).toHaveBeenCalledWith({
|
||||
urls: ['https://cdn.example.com/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf'],
|
||||
downloader: 'ai_model',
|
||||
destination: 'ai/models',
|
||||
});
|
||||
});
|
||||
|
||||
it('saves the enabled toggle', async () => {
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByLabelText('Enabled')).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByLabelText('Enabled'));
|
||||
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({enabled: true}));
|
||||
});
|
||||
|
||||
it('lists a custom GGUF from ai/models in the Active Model dropdown', async () => {
|
||||
const custom = {
|
||||
name: 'My-Custom-Model-Q8_0.gguf', tier: 'custom', size: 3_000_000_000, url: null,
|
||||
description: 'Custom model found in ai/models/. Not from the WROLPi catalog.',
|
||||
downloaded: true, active: false,
|
||||
};
|
||||
const fixture = catalogFixture();
|
||||
getAiCatalog.mockResolvedValue({...fixture, models: [...fixture.models, custom]});
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText('On disk')).toBeInTheDocument());
|
||||
expect(screen.getByRole('cell', {name: 'custom'})).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('textbox', {name: 'Active Model'}));
|
||||
const option = await screen.findByRole('option', {name: 'My-Custom-Model-Q8_0.gguf'});
|
||||
fireEvent.click(option);
|
||||
await waitFor(() => expect(postAiSettings).toHaveBeenCalledWith({active_model: 'My-Custom-Model-Q8_0.gguf'}));
|
||||
});
|
||||
|
||||
it('shows the slow-hardware warning on a Pi 4', async () => {
|
||||
getAiCatalog.mockResolvedValue(catalogFixture({slow_hardware: true}));
|
||||
render(<ManageAi/>);
|
||||
await waitFor(() => expect(screen.getByText(/run the assistant slowly/)).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
154
app/src/components/AiChat.js
Normal file
154
app/src/components/AiChat.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Link} from 'react-router';
|
||||
|
||||
import {getAiCatalog, getChatModes} from '../api';
|
||||
import {useAiChat} from '../contexts/AiChatContext';
|
||||
import {InfoMessage} from './Common';
|
||||
import {Button, Group, Icon, Loader, Text, TextInput} from './ui';
|
||||
|
||||
const MODE_LABELS = {help: 'Help', research: 'Research', system: 'System'};
|
||||
const MODE_DESCRIPTIONS = {
|
||||
help: 'Learn how to use WROLPi',
|
||||
research: 'Find and summarize library content',
|
||||
system: 'Troubleshoot this WROLPi',
|
||||
};
|
||||
|
||||
// Render an assistant message with WROLPi links clickable. The model only ever returns
|
||||
// relative links (/videos/123), which the server computed — never external URLs.
|
||||
export function LinkifiedText({content}) {
|
||||
const parts = String(content || '').split(/(\/[a-zA-Z0-9_\-./?=&%#]+)/g);
|
||||
return <span style={{whiteSpace: 'pre-wrap', overflowWrap: 'anywhere'}}>
|
||||
{parts.map((part, i) => part.startsWith('/') && part.length > 1
|
||||
? <Link key={i} to={part}>{part}</Link>
|
||||
: part)}
|
||||
</span>;
|
||||
}
|
||||
|
||||
function ToolActivity({activity}) {
|
||||
if (!activity || !activity.length) {
|
||||
return null;
|
||||
}
|
||||
return <div>
|
||||
{activity.map((item, i) => <Text key={i} size='sm' c='dimmed'>
|
||||
{item.pending
|
||||
? <Loader size='xs'/>
|
||||
: <Icon name={item.success ? 'check' : 'warning circle'}/>}
|
||||
{' '}using {String(item.tool || '').replace(/_/g, ' ')}
|
||||
</Text>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function ChatMessage({message}) {
|
||||
if (message.role === 'user') {
|
||||
return <div style={{textAlign: 'right', margin: '0.5em 0'}}>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '0.5em 0.8em', borderRadius: 8,
|
||||
background: 'var(--panel)', maxWidth: '85%', textAlign: 'left',
|
||||
}}>{message.content}</span>
|
||||
</div>;
|
||||
}
|
||||
return <div style={{margin: '0.5em 0'}}>
|
||||
{message.status && <Text size='sm' c='dimmed'>{message.status}</Text>}
|
||||
<ToolActivity activity={message.activity}/>
|
||||
{message.content && <LinkifiedText content={message.content}/>}
|
||||
{message.streaming && !message.content && !message.activity?.length && <Loader size='xs'/>}
|
||||
{message.error && <Text style={{color: 'var(--danger)'}}>{message.error}</Text>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function AiChat() {
|
||||
const {mode, setMode, messages, sending, send, stop, disabledCode} = useAiChat();
|
||||
const [modes, setModes] = useState([]);
|
||||
const [slowHardware, setSlowHardware] = useState(false);
|
||||
const [aiEnabled, setAiEnabled] = useState(null);
|
||||
const [input, setInput] = useState('');
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
getChatModes().then(i => i && setModes(i)).catch(console.error);
|
||||
getAiCatalog().then(catalog => {
|
||||
if (catalog) {
|
||||
setSlowHardware(catalog.slow_hardware === true);
|
||||
setAiEnabled(Boolean(catalog.enabled && catalog.active_model));
|
||||
}
|
||||
}).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView?.({behavior: 'smooth'});
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async (content) => {
|
||||
setInput('');
|
||||
await send(content);
|
||||
};
|
||||
|
||||
const notReady = aiEnabled === false || disabledCode === 'AI_DISABLED';
|
||||
if (notReady) {
|
||||
return <InfoMessage>
|
||||
The AI assistant is not ready. Download a model and enable it on
|
||||
the <Link to='/ai/manage'>Manage</Link> tab.
|
||||
</InfoMessage>;
|
||||
}
|
||||
|
||||
const suggestions = (modes.find(i => i.name === mode) || {}).suggestions || [];
|
||||
|
||||
return <>
|
||||
{slowHardware && <InfoMessage storageName='hint_ai_chat_slow'>
|
||||
This hardware runs the assistant slowly — answers can take minutes.
|
||||
</InfoMessage>}
|
||||
|
||||
<Group gap='xs'>
|
||||
{(modes.length ? modes.map(i => i.name) : Object.keys(MODE_LABELS)).map(name =>
|
||||
<Button
|
||||
key={name}
|
||||
size='sm'
|
||||
color={mode === name ? 'violet' : undefined}
|
||||
aria-pressed={mode === name}
|
||||
onClick={() => setMode(name)}
|
||||
>
|
||||
{MODE_LABELS[name] || name}
|
||||
</Button>)}
|
||||
{mode && <Text size='sm' c='dimmed'>{MODE_DESCRIPTIONS[mode]}</Text>}
|
||||
</Group>
|
||||
|
||||
{!mode && <Text style={{marginTop: '1em'}}>
|
||||
Pick a mode to start: <b>Help</b> teaches WROLPi, <b>Research</b> searches your
|
||||
library, <b>System</b> troubleshoots this device.
|
||||
</Text>}
|
||||
|
||||
{mode && <>
|
||||
<div style={{minHeight: '40vh', margin: '1em 0'}}>
|
||||
{messages.length === 0 && <Group gap='xs'>
|
||||
{suggestions.map(suggestion =>
|
||||
<Button key={suggestion} size='xs' onClick={() => handleSend(suggestion)}>
|
||||
{suggestion}
|
||||
</Button>)}
|
||||
</Group>}
|
||||
{messages.map((message, i) => <ChatMessage key={i} message={message}/>)}
|
||||
<div ref={bottomRef}/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault();
|
||||
if (!sending) {
|
||||
handleSend(input.trim());
|
||||
}
|
||||
}}>
|
||||
<Group gap='xs' wrap='nowrap'>
|
||||
<TextInput
|
||||
style={{flexGrow: 1}}
|
||||
placeholder='Ask the assistant…'
|
||||
value={input}
|
||||
disabled={sending}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
aria-label='Chat message'
|
||||
/>
|
||||
{sending
|
||||
? <Button role='cancel' onClick={stop}>Stop</Button>
|
||||
: <Button type='submit' color='violet' disabled={!input.trim()}>Send</Button>}
|
||||
</Group>
|
||||
</form>
|
||||
</>}
|
||||
</>;
|
||||
}
|
||||
121
app/src/components/AiChat.test.js
Normal file
121
app/src/components/AiChat.test.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React from 'react';
|
||||
import {fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
|
||||
import {render} from '../test-utils';
|
||||
import {AiChatProvider} from '../contexts/AiChatContext';
|
||||
import {parseSSEBlock} from '../sse';
|
||||
import {AiChat, LinkifiedText} from './AiChat';
|
||||
|
||||
jest.mock('../api', () => ({
|
||||
getChatModes: jest.fn().mockResolvedValue([
|
||||
{name: 'help', suggestions: ['How do I download videos?']},
|
||||
{name: 'research', suggestions: ['Find videos about canning']},
|
||||
{name: 'system', suggestions: ['Why is my drive full?']},
|
||||
]),
|
||||
getAiCatalog: jest.fn().mockResolvedValue({
|
||||
enabled: true, active_model: 'test.gguf', slow_hardware: false,
|
||||
}),
|
||||
}));
|
||||
jest.mock('../sse', () => ({
|
||||
...jest.requireActual('../sse'),
|
||||
streamChat: jest.fn(),
|
||||
}));
|
||||
|
||||
const {getAiCatalog, getChatModes} = require('../api');
|
||||
const {streamChat} = require('../sse');
|
||||
|
||||
const renderChat = () => render(<AiChatProvider><AiChat/></AiChatProvider>);
|
||||
|
||||
describe('parseSSEBlock', () => {
|
||||
it('parses an event and its JSON data', () => {
|
||||
expect(parseSSEBlock('event: token\ndata: {"content": "hi"}'))
|
||||
.toEqual({event: 'token', data: {content: 'hi'}});
|
||||
expect(parseSSEBlock('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LinkifiedText', () => {
|
||||
it('makes relative WROLPi links clickable', () => {
|
||||
render(<LinkifiedText content='See /videos/123 for details'/>);
|
||||
const link = screen.getByRole('link', {name: '/videos/123'});
|
||||
expect(link).toHaveAttribute('href', '/videos/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiChat', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getChatModes.mockResolvedValue([
|
||||
{name: 'help', suggestions: ['How do I download videos?']},
|
||||
{name: 'research', suggestions: ['Find videos about canning']},
|
||||
{name: 'system', suggestions: ['Why is my drive full?']},
|
||||
]);
|
||||
getAiCatalog.mockResolvedValue({enabled: true, active_model: 'test.gguf', slow_hardware: false});
|
||||
});
|
||||
|
||||
it('starts with the mode picker; picking a mode shows its suggestion chips', async () => {
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
|
||||
expect(screen.getByText(/Pick a mode/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
|
||||
await waitFor(() => expect(screen.getByText('Find videos about canning')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('streams tokens and tool activity into the conversation', async () => {
|
||||
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_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'});
|
||||
});
|
||||
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Research'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Research'}));
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'find canning'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/using search files/)).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole('link', {name: '/videos/123'})).toBeInTheDocument());
|
||||
expect(streamChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('switching modes clears the conversation', async () => {
|
||||
streamChat.mockImplementation(async (_, onEvent) => {
|
||||
onEvent('done', {content: 'An answer.'});
|
||||
});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('An answer.')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'System'}));
|
||||
await waitFor(() => expect(screen.queryByText('An answer.')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('shows an error event without crashing the conversation', async () => {
|
||||
streamChat.mockImplementation(async (_, onEvent) => {
|
||||
onEvent('error', {message: 'The AI failed to answer.'});
|
||||
});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByRole('button', {name: 'Help'})).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Help'}));
|
||||
fireEvent.change(screen.getByLabelText('Chat message'), {target: {value: 'hello'}});
|
||||
fireEvent.click(screen.getByText('Send'));
|
||||
await waitFor(() => expect(screen.getByText('The AI failed to answer.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('points at the Manage tab when AI is not enabled', async () => {
|
||||
getAiCatalog.mockResolvedValue({enabled: false, active_model: '', slow_hardware: false});
|
||||
renderChat();
|
||||
await waitFor(() => expect(screen.getByText(/not ready/)).toBeInTheDocument());
|
||||
expect(screen.getByRole('link', {name: 'Manage'})).toHaveAttribute('href', '/ai/manage');
|
||||
});
|
||||
});
|
||||
|
|
@ -61,6 +61,7 @@ const allLinks = [
|
|||
{text: 'Playlists', to: '/playlists', key: 'playlists'},
|
||||
{text: 'Zim', to: '/zim', key: 'zim'},
|
||||
{text: 'Inventory', to: '/inventory', key: 'inventory'},
|
||||
{text: 'AI', to: '/ai', key: 'ai'},
|
||||
// No `end` on these two: a tab stays current for the pages beneath it, and `end` would
|
||||
// drop the mark the moment either grows a sub-page.
|
||||
{to: '/more/calculators', text: 'Calculators', key: 'calculators'},
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import {
|
|||
} from './ui';
|
||||
import {paletteColorNames} from '../themes/mantine';
|
||||
import {contrastingColor} from './Common';
|
||||
import {AiModelRow} from './Ai';
|
||||
import {ChatMessage} from './AiChat';
|
||||
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
|
||||
import {IconMenu2} from '@tabler/icons-react';
|
||||
|
||||
|
|
@ -725,6 +727,51 @@ export function ThemeSamplePage() {
|
|||
</Table>
|
||||
</Section>
|
||||
|
||||
<Section label='AI model catalog'>
|
||||
{/* The real AiModelRow from the AI page's Manage tab, in both of its states. */}
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Model</Table.HeaderCell>
|
||||
<Table.HeaderCell>Tier</Table.HeaderCell>
|
||||
<Table.HeaderCell>Size</Table.HeaderCell>
|
||||
<Table.HeaderCell/>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
<AiModelRow model={{
|
||||
name: 'Qwen3-1.7B-Q4_K_M.gguf', tier: 'small', size: 1_200_000_000,
|
||||
description: 'The default assistant for 4GB devices (Pi 4/5).',
|
||||
downloaded: true, active: true,
|
||||
}} onDownload={() => {}}/>
|
||||
<AiModelRow model={{
|
||||
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf', tier: 'medium', size: 2_600_000_000,
|
||||
description: 'The default assistant for 8GB+ devices.',
|
||||
downloaded: false, active: false,
|
||||
}} onDownload={() => {}}/>
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</Section>
|
||||
|
||||
<Section label='AI chat'>
|
||||
{/* The real ChatMessage from the AI Chat tab, in each of its states. */}
|
||||
<ChatMessage message={{role: 'user', content: 'Find videos about canning'}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant',
|
||||
content: 'I found one video: Pressure Canning Basics — /videos/123',
|
||||
activity: [{tool: 'search_files', pending: false, success: true}],
|
||||
}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant', content: '',
|
||||
activity: [{tool: 'search_files', pending: true}], streaming: true,
|
||||
}}/>
|
||||
<ChatMessage message={{
|
||||
role: 'assistant', content: '',
|
||||
activity: [{tool: 'read_content', pending: false, success: false}],
|
||||
error: 'The AI failed to answer. Check the logs.',
|
||||
}}/>
|
||||
</Section>
|
||||
|
||||
<Section label='Form controls'>
|
||||
<Panel>
|
||||
<div style={{display: 'grid', gridTemplateColumns: '2fr 1fr 1fr', gap: 12}}>
|
||||
|
|
|
|||
|
|
@ -194,6 +194,7 @@ export function audioContainerForCodec(codec) {
|
|||
}
|
||||
|
||||
export const Downloaders = {
|
||||
AiModel: 'ai_model',
|
||||
Archive: 'archive',
|
||||
File: 'file',
|
||||
KiwixCatalog: 'kiwix_catalog',
|
||||
|
|
|
|||
110
app/src/contexts/AiChatContext.js
Normal file
110
app/src/contexts/AiChatContext.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Conversation state for the AI Chat tab.
|
||||
*
|
||||
* A Context (not props) because the conversation must survive switching between the Chat and
|
||||
* Manage tabs, and the chips/mode picker/input are separate components. Browser-only by
|
||||
* design: v1 keeps no server-side chat history.
|
||||
*
|
||||
* The context itself is exported so tests can wrap components with the real provider instead
|
||||
* of mocking the module (see FileWorkerStatusContext.js for why).
|
||||
*/
|
||||
import React, {createContext, useContext, useRef, useState} from 'react';
|
||||
|
||||
import {streamChat} from '../sse';
|
||||
|
||||
export const AiChatContext = createContext(null);
|
||||
|
||||
export function AiChatProvider({children}) {
|
||||
const [mode, setModeState] = useState(null);
|
||||
// [{role: 'user'|'assistant', content, activity: [{tool, success}], error, streaming}]
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [disabledCode, setDisabledCode] = useState(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// Switching modes starts a fresh conversation: small contexts cannot carry a Help
|
||||
// conversation into Research mode.
|
||||
const setMode = (newMode) => {
|
||||
stop();
|
||||
setModeState(newMode);
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
setSending(false);
|
||||
};
|
||||
|
||||
const updateLast = (updater) => {
|
||||
setMessages(previous => {
|
||||
const next = [...previous];
|
||||
next[next.length - 1] = updater(next[next.length - 1]);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const send = async (content) => {
|
||||
if (!content || !mode || sending) {
|
||||
return;
|
||||
}
|
||||
const history = messages
|
||||
.filter(i => !i.error && i.content)
|
||||
.map(i => ({role: i.role, content: i.content}));
|
||||
const userMessage = {role: 'user', content};
|
||||
setMessages(previous => [...previous, userMessage,
|
||||
{role: 'assistant', content: '', activity: [], streaming: true}]);
|
||||
setSending(true);
|
||||
setDisabledCode(null);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
await streamChat({mode, messages: [...history, userMessage]}, (event, data) => {
|
||||
if (event === 'token') {
|
||||
updateLast(m => ({...m, content: m.content + (data?.content || '')}));
|
||||
} else if (event === 'status') {
|
||||
updateLast(m => ({...m, status: data?.message}));
|
||||
} else if (event === 'tool_call') {
|
||||
updateLast(m => ({...m, activity: [...m.activity, {tool: data?.tool, pending: true}]}));
|
||||
} else if (event === 'tool_result') {
|
||||
updateLast(m => ({
|
||||
...m,
|
||||
activity: m.activity.map((a, i) =>
|
||||
i === m.activity.length - 1 ? {...a, pending: false, success: data?.success} : a),
|
||||
}));
|
||||
} else if (event === 'done') {
|
||||
updateLast(m => ({...m, content: data?.content ?? m.content, streaming: false, status: null}));
|
||||
} else if (event === 'error') {
|
||||
updateLast(m => ({...m, error: data?.message, streaming: false, status: null}));
|
||||
}
|
||||
}, controller.signal);
|
||||
updateLast(m => ({...m, streaming: false}));
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
updateLast(m => ({...m, streaming: false, error: 'Stopped.'}));
|
||||
} else {
|
||||
if (e.code === 'AI_DISABLED') {
|
||||
setDisabledCode(e.code);
|
||||
}
|
||||
updateLast(m => ({...m, streaming: false, error: e.message}));
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const value = {mode, setMode, messages, sending, send, stop, disabledCode};
|
||||
return <AiChatContext.Provider value={value}>{children}</AiChatContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAiChat() {
|
||||
const context = useContext(AiChatContext);
|
||||
if (!context) {
|
||||
throw new Error('useAiChat must be used within AiChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
71
app/src/sse.js
Normal file
71
app/src/sse.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// The app's only streaming client. `apiCall` is JSON-only with a 60-second timeout, which is
|
||||
// wrong for a chat answer that streams for minutes — so the AI chat uses fetch + a stream
|
||||
// reader, parsing Server-Sent Events.
|
||||
import {API_URI} from './components/Vars';
|
||||
|
||||
// Parse one SSE block ("event: name\ndata: {...}") into {event, data}.
|
||||
export function parseSSEBlock(block) {
|
||||
let event = null;
|
||||
let data = null;
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
try {
|
||||
data = JSON.parse(line.slice(5).trim());
|
||||
} catch (e) {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return event ? {event, data} : null;
|
||||
}
|
||||
|
||||
/*
|
||||
* POST /api/chat and stream its SSE events to `onEvent(event, data)`.
|
||||
*
|
||||
* Events: status, token, tool_call, tool_result, done, error (see modules/ai/chat.py).
|
||||
* Throws on a non-OK response (e.g. AI disabled) with the API's message; abort with `signal`.
|
||||
*/
|
||||
export async function streamChat({mode, messages}, onEvent, signal = undefined) {
|
||||
const response = await fetch(`${API_URI}/chat/`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({mode, messages}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `Chat failed: HTTP ${response.status}`;
|
||||
let code = null;
|
||||
try {
|
||||
const content = await response.json();
|
||||
message = content.summary || content.message || message;
|
||||
code = content.code;
|
||||
} catch (e) {
|
||||
// Not JSON; keep the HTTP message.
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, {stream: true});
|
||||
let index;
|
||||
while ((index = buffer.indexOf('\n\n')) >= 0) {
|
||||
const block = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 2);
|
||||
const parsed = parseSSEBlock(block);
|
||||
if (parsed) {
|
||||
onEvent(parsed.event, parsed.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from modules.ai.conftest import * # noqa
|
||||
from modules.archive.conftest import * # noqa
|
||||
from modules.inventory.conftest import * # noqa
|
||||
from modules.videos.conftest import * # noqa
|
||||
|
|
|
|||
|
|
@ -78,6 +78,16 @@ DEFAULT_CONFIG = {
|
|||
"use_https": True,
|
||||
"description": "Kiwix/Zim server",
|
||||
},
|
||||
{
|
||||
"name": "wrolpi-llm",
|
||||
"systemd_name": "wrolpi-llm",
|
||||
"port": 11435,
|
||||
"viewable": False,
|
||||
"use_https": False,
|
||||
"description": "AI assistant (llama-server)",
|
||||
# On-demand: started by the Controller when AI is enabled, stopped by the API when idle.
|
||||
"kind": "persistent",
|
||||
},
|
||||
# System services
|
||||
{
|
||||
"name": "caddy",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ except ImportError:
|
|||
CONTAINER_PREFIX = os.environ.get("COMPOSE_PROJECT_NAME", "wrolpi")
|
||||
|
||||
# Containers that should not have a "View" button (non-HTTP services)
|
||||
NON_VIEWABLE_CONTAINERS = {"redis", "memcached", "samba"}
|
||||
NON_VIEWABLE_CONTAINERS = {"redis", "memcached", "samba", "llm"}
|
||||
|
||||
# Containers that should use HTTPS
|
||||
HTTPS_CONTAINERS = {"web"}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class TestManagedServicesConfig:
|
|||
def test_managed_services_count(self):
|
||||
"""Should have expected number of managed services."""
|
||||
services = DEFAULT_CONFIG["managed_services"]
|
||||
assert len(services) == 11
|
||||
assert len(services) == 12
|
||||
|
||||
@pytest.mark.parametrize("name,expected", [
|
||||
("wrolpi-api",
|
||||
|
|
@ -68,6 +68,8 @@ class TestManagedServicesConfig:
|
|||
("wrolpi-app-dev", {"port": 3000, "viewable": False, "show_only_when_running": True}),
|
||||
("caddy", {"port": 443}),
|
||||
("wrolpi-upgrade", {"port": None, "viewable": False, "show_only_when_running": True}),
|
||||
("wrolpi-llm",
|
||||
{"systemd_name": "wrolpi-llm", "port": 11435, "viewable": False, "kind": "persistent"}),
|
||||
])
|
||||
def test_service_config(self, name, expected):
|
||||
"""Should have service configured with expected values."""
|
||||
|
|
|
|||
|
|
@ -143,6 +143,9 @@ cp /opt/wrolpi/wrolpi/blobs/* /opt/wrolpi-blobs/
|
|||
# systemd.
|
||||
/opt/wrolpi/wrolpi/scripts/install_map_tools.sh
|
||||
|
||||
# Bake in llama.cpp (local AI) so a fresh install does not compile it on first upgrade.
|
||||
/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh
|
||||
|
||||
# Install WROLPi Help.
|
||||
/opt/wrolpi/scripts/install_help_service.sh
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ services:
|
|||
# Sanic 25.x wants 'spawn' but our code uses 'fork' for multiprocessing.
|
||||
# This tells Sanic to accept the existing start method.
|
||||
- SANIC_START_METHOD_SET=1
|
||||
# llama-server runs in the llm container (native installs use 127.0.0.1).
|
||||
- LLAMA_URL=http://llm:11435
|
||||
volumes:
|
||||
- './main.py:/opt/wrolpi/main.py'
|
||||
- './wrolpi:/opt/wrolpi/wrolpi'
|
||||
|
|
@ -49,6 +51,8 @@ services:
|
|||
- './scripts:/opt/wrolpi/scripts'
|
||||
- './test:/opt/wrolpi/test'
|
||||
- './test:/media/wrolpi'
|
||||
# Help markdown for the AI help search (native installs read /opt/wrolpi-help directly).
|
||||
- './docker/help/wrolpi-help/docs:/opt/wrolpi-help/docs:ro'
|
||||
- './alembic:/opt/wrolpi/alembic'
|
||||
- './alembic.ini:/opt/wrolpi/alembic.ini'
|
||||
ports:
|
||||
|
|
@ -145,6 +149,21 @@ services:
|
|||
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:80' ]
|
||||
interval: 1m
|
||||
|
||||
llm: # llama-server (local AI) - started/stopped by the controller on demand; the API stops it when idle.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/llm/Dockerfile
|
||||
restart: "no"
|
||||
depends_on:
|
||||
controller:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${MEDIA_DIRECTORY:-./test}:/media/wrolpi
|
||||
- './docker/llm/entrypoint.sh:/entrypoint.sh'
|
||||
healthcheck:
|
||||
test: [ 'CMD-SHELL', 'curl http://127.0.0.1:11435/health' ]
|
||||
interval: 1m
|
||||
|
||||
samba: # Samba file sharing - started/stopped by the controller based on configured shares.
|
||||
build:
|
||||
context: .
|
||||
|
|
|
|||
19
docker/llm/Dockerfile
Normal file
19
docker/llm/Dockerfile
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
FROM debian:13
|
||||
ENV DOCKER=true
|
||||
|
||||
RUN apt-get update && apt-get install -y curl ca-certificates
|
||||
|
||||
# Build llama-server from the pinned release (single source of truth with native installs).
|
||||
COPY wrolpi/scripts/install_llama_cpp.sh /tmp/install_llama_cpp.sh
|
||||
RUN /tmp/install_llama_cpp.sh && rm /tmp/install_llama_cpp.sh \
|
||||
&& apt-get remove -y build-essential cmake && apt-get autoremove -y \
|
||||
# llama-server needs the OpenMP runtime, which the build-tool cleanup just removed.
|
||||
&& apt-get install -y libgomp1 && apt-get clean
|
||||
|
||||
# The same startup scripts the native install uses.
|
||||
COPY scripts/start_llama_server.sh /opt/wrolpi/scripts/start_llama_server.sh
|
||||
COPY wrolpi/scripts/read_config_value.sh /opt/wrolpi/wrolpi/scripts/read_config_value.sh
|
||||
|
||||
COPY docker/llm/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
5
docker/llm/entrypoint.sh
Executable file
5
docker/llm/entrypoint.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
#! /usr/bin/env bash
|
||||
# Bind all interfaces so the api container can reach llama-server on the internal docker network.
|
||||
# The container publishes no host ports and is not proxied by Caddy.
|
||||
export LLAMA_HOST=0.0.0.0
|
||||
exec /opt/wrolpi/scripts/start_llama_server.sh
|
||||
|
|
@ -51,6 +51,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
# AI chat streams Server-Sent Events; never buffer them.
|
||||
handle /api/chat* {
|
||||
reverse_proxy api:8081 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Main API.
|
||||
handle /api/* {
|
||||
reverse_proxy api:8081
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
# AI chat streams Server-Sent Events; never buffer them.
|
||||
handle /api/chat* {
|
||||
reverse_proxy 0.0.0.0:8081 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Main API.
|
||||
handle /api/* {
|
||||
reverse_proxy 0.0.0.0:8081
|
||||
|
|
|
|||
17
etc/raspberrypios/wrolpi-llm.service
Normal file
17
etc/raspberrypios/wrolpi-llm.service
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[Unit]
|
||||
Description=WROLPi AI (llama-server)
|
||||
PartOf=wrolpi.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/opt/wrolpi/scripts/start_llama_server.sh
|
||||
Group=wrolpi
|
||||
User=wrolpi
|
||||
WorkingDirectory=/opt/wrolpi/
|
||||
Restart=on-failure
|
||||
# The lowest priority of any WROLPi service; inference must never starve the API.
|
||||
Nice=15
|
||||
|
||||
# On-demand service: deliberately NOT enabled at boot and NOT wanted by wrolpi.target.
|
||||
# The Controller starts it when the user enables AI; the API stops it when idle.
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
4
main.py
4
main.py
|
|
@ -289,6 +289,10 @@ async def start_single_tasks(app: Sanic):
|
|||
from wrolpi.downloader import get_download_cache_config
|
||||
get_download_cache_config().import_config()
|
||||
logger.debug('download cache config imported')
|
||||
with log_and_suppress(Exception, message='Failed to import ai config'):
|
||||
from modules.ai.config import get_ai_config
|
||||
get_ai_config().import_config()
|
||||
logger.debug('ai config imported')
|
||||
|
||||
logger.debug('start_single_tasks waiting for db...')
|
||||
async with flags.db_up.wait_for():
|
||||
|
|
|
|||
3
modules/ai/__init__.py
Normal file
3
modules/ai/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from modules.ai.api import ai_bp # noqa
|
||||
from modules.ai.downloader import ModelDownloader # noqa Instantiation registers the downloader.
|
||||
from modules.ai import service # noqa Import registers the idle-unload perpetual worker.
|
||||
236
modules/ai/agent.py
Normal file
236
modules/ai/agent.py
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
"""Tool definitions and tool execution for the agent loop.
|
||||
|
||||
The /api/ai blueprint IS the tool catalog: tool definitions are generated from the blueprint's
|
||||
own OpenAPI operations (in-process — the spec endpoint is not proxied by Caddy), so there is no
|
||||
separate registry to drift. operationIds are the tool names; @openapi.description strings are
|
||||
the tool descriptions; @validate dataclasses and @openapi.parameter declarations become the
|
||||
JSON-schema parameters."""
|
||||
import dataclasses
|
||||
import json
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
from sanic_ext.extensions.openapi.builders import OperationStore, SpecificationBuilder
|
||||
from sanic_ext.utils.route import get_all_routes
|
||||
|
||||
from wrolpi.api_utils import api_app
|
||||
from wrolpi.common import aiohttp_get, aiohttp_post, logger
|
||||
from wrolpi.vars import API_PORT
|
||||
|
||||
from .lib import PAGE_SIZE
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
AI_PATH_PREFIX = '/api/ai/'
|
||||
# Manage endpoints are for the Manage tab, never the model.
|
||||
EXCLUDED_PREFIX = '/api/ai/manage'
|
||||
|
||||
# Tool results are capped so one result cannot blow a small context; content endpoints page.
|
||||
TOOL_RESULT_MAX_CHARS = PAGE_SIZE + 1_000
|
||||
|
||||
_TYPE_MAP = {'integer': int, 'number': float, 'boolean': bool, 'string': str}
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ToolDefinition:
|
||||
name: str
|
||||
description: str
|
||||
method: str # 'get' or 'post'
|
||||
path: str # e.g. '/api/ai/videos/{file_group_id}/captions'
|
||||
path_params: Dict[str, dict] # name -> JSON schema
|
||||
query_params: Dict[str, dict]
|
||||
body_params: Dict[str, dict]
|
||||
|
||||
def parameters_schema(self) -> dict:
|
||||
"""One flat JSON-schema object; the executor routes each arg to its place."""
|
||||
properties = {**self.body_params, **self.query_params, **self.path_params}
|
||||
required = sorted(self.path_params)
|
||||
schema = dict(type='object', properties=properties)
|
||||
if required:
|
||||
schema['required'] = required
|
||||
return schema
|
||||
|
||||
def as_openai_tool(self) -> dict:
|
||||
return dict(type='function', function=dict(
|
||||
name=self.name,
|
||||
description=self.description,
|
||||
parameters=self.parameters_schema(),
|
||||
))
|
||||
|
||||
|
||||
def _build_ai_spec() -> dict:
|
||||
"""Populate and serialize the OpenAPI spec for the AI blueprint.
|
||||
|
||||
Replicates sanic-ext's build_spec startup hook (sanic_ext/extensions/openapi/blueprint.py) for
|
||||
just our routes, so tool definitions work identically in production and under pytest."""
|
||||
spec = SpecificationBuilder()
|
||||
store = OperationStore()
|
||||
for uri, route_name, route_parameters, method_handlers, host in get_all_routes(api_app, '/docs'):
|
||||
if not uri.startswith(AI_PATH_PREFIX):
|
||||
continue
|
||||
for method, handler in method_handlers:
|
||||
if method in ('OPTIONS', 'HEAD', 'TRACE'):
|
||||
continue
|
||||
if handler not in store and (func := getattr(handler, '__func__', None)) and func in store:
|
||||
handler = func
|
||||
operation = store[handler]
|
||||
if operation._exclude:
|
||||
continue
|
||||
operation._default['operationId'] = f'{method.lower()}~{route_name}'
|
||||
for parameter in route_parameters:
|
||||
if not any(p.fields['name'] == parameter.name for p in operation.parameters):
|
||||
operation.parameter(parameter.name, parameter.cast, 'path')
|
||||
operation._app = api_app
|
||||
spec.operation(uri, method, operation)
|
||||
return spec.build(api_app).serialize()
|
||||
|
||||
|
||||
def _resolve_ref(spec: dict, schema: Optional[dict]) -> dict:
|
||||
if not schema:
|
||||
return {}
|
||||
if ref := schema.get('$ref'):
|
||||
name = ref.rsplit('/', 1)[-1]
|
||||
return (spec.get('components', {}).get('schemas', {}) or {}).get(name, {})
|
||||
return schema
|
||||
|
||||
|
||||
def _clean_property(schema: dict) -> dict:
|
||||
"""A lean JSON-schema property for the model: type (+items), nothing else."""
|
||||
schema = schema or {}
|
||||
cleaned = {}
|
||||
if type_ := schema.get('type'):
|
||||
cleaned['type'] = type_
|
||||
if items := schema.get('items'):
|
||||
cleaned['items'] = _clean_property(items)
|
||||
return cleaned or {'type': 'string'}
|
||||
|
||||
|
||||
_TOOL_DEFINITIONS: Optional[Dict[str, ToolDefinition]] = None
|
||||
|
||||
|
||||
def get_ai_tool_definitions(refresh: bool = False) -> Dict[str, ToolDefinition]:
|
||||
"""All tools the agent loop can ever execute, keyed by name. Built lazily, cached.
|
||||
|
||||
This map is also the hard allowlist: the executor can only reach /api/ai endpoints that
|
||||
appear here, and manage endpoints never do."""
|
||||
global _TOOL_DEFINITIONS
|
||||
if _TOOL_DEFINITIONS is not None and not refresh:
|
||||
return _TOOL_DEFINITIONS
|
||||
|
||||
spec = _build_ai_spec()
|
||||
tools = {}
|
||||
for path, operations in (spec.get('paths') or {}).items():
|
||||
if not path.startswith(AI_PATH_PREFIX) or path.startswith(EXCLUDED_PREFIX):
|
||||
continue
|
||||
for method, operation in operations.items():
|
||||
if method not in ('get', 'post'):
|
||||
continue
|
||||
name = operation.get('operationId')
|
||||
description = operation.get('description') or operation.get('summary') or ''
|
||||
if not name or '~' in name:
|
||||
# Every tool endpoint must declare a deliberate @openapi.operation name.
|
||||
logger.warning(f'AI endpoint without an operation name is not a tool: {method} {path}')
|
||||
continue
|
||||
|
||||
path_params, query_params = {}, {}
|
||||
for parameter in operation.get('parameters') or []:
|
||||
cleaned = _clean_property(parameter.get('schema'))
|
||||
if parameter.get('in') == 'path':
|
||||
path_params[parameter['name']] = cleaned
|
||||
elif parameter.get('in') == 'query':
|
||||
query_params[parameter['name']] = cleaned
|
||||
|
||||
body_params = {}
|
||||
content = (operation.get('requestBody') or {}).get('content') or {}
|
||||
# sanic-ext serializes dataclass bodies under '*/*'.
|
||||
body_schema = (content.get('application/json') or content.get('*/*') or {}).get('schema')
|
||||
body_schema = _resolve_ref(spec, body_schema)
|
||||
for prop_name, prop_schema in (body_schema.get('properties') or {}).items():
|
||||
body_params[prop_name] = _clean_property(_resolve_ref(spec, prop_schema))
|
||||
|
||||
tools[name] = ToolDefinition(
|
||||
name=name, description=description, method=method, path=path,
|
||||
path_params=path_params, query_params=query_params, body_params=body_params,
|
||||
)
|
||||
|
||||
_TOOL_DEFINITIONS = tools
|
||||
return tools
|
||||
|
||||
|
||||
def get_mode_tools(mode_tools: tuple) -> List[dict]:
|
||||
"""OpenAI-format tool definitions for one mode's allowlist."""
|
||||
definitions = get_ai_tool_definitions()
|
||||
return [definitions[name].as_openai_tool() for name in mode_tools if name in definitions]
|
||||
|
||||
|
||||
async def call_ai_endpoint(method: str, path: str, query: dict, body: Optional[dict]) -> Tuple[int, dict]:
|
||||
"""Execute one blueprint endpoint over localhost. Patched in tests."""
|
||||
url = f'http://127.0.0.1:{API_PORT}{path}'
|
||||
if query:
|
||||
url = f'{url}?{urlencode(query)}'
|
||||
if method == 'post':
|
||||
async with aiohttp_post(url, json_=body or {}, timeout=60) as response:
|
||||
return response.status, await response.json()
|
||||
async with aiohttp_get(url, timeout=60) as response:
|
||||
return response.status, await response.json()
|
||||
|
||||
|
||||
def _validate_args(tool: ToolDefinition, args: dict) -> Optional[str]:
|
||||
"""Return a crisp, instructive error string for the model, or None when the args are usable."""
|
||||
known = {**tool.path_params, **tool.query_params, **tool.body_params}
|
||||
if unknown := sorted(set(args) - set(known)):
|
||||
return f'Unknown arguments {unknown}. {tool.name} accepts: {sorted(known)}.'
|
||||
if missing := sorted(set(tool.path_params) - set(args)):
|
||||
return f'Missing required arguments {missing} for {tool.name}.'
|
||||
for name, value in args.items():
|
||||
expected = _TYPE_MAP.get(known[name].get('type'))
|
||||
if expected in (int, float) and value is not None and not isinstance(value, (int, float)) \
|
||||
and not (isinstance(value, str) and value.lstrip('-').isdigit()):
|
||||
return f'Argument {name} must be a {known[name]["type"]}, got {value!r}.'
|
||||
# Path parameters land in the request URL; refuse anything that could escape the
|
||||
# endpoint's own path (the allowlist must hold even against a crafted value).
|
||||
if name in tool.path_params and isinstance(value, str):
|
||||
segments = value.split('/')
|
||||
if value.startswith('/') or '' in segments or '.' in segments or '..' in segments:
|
||||
return f'Argument {name} must not contain path traversal.'
|
||||
return None
|
||||
|
||||
|
||||
async def execute_tool(mode_tools: tuple, name: str, args: dict) -> Tuple[bool, str]:
|
||||
"""Validate and execute one tool call. Returns (success, result text for the model).
|
||||
|
||||
Every failure is a plain-language error string — small models retry well on crisp errors."""
|
||||
definitions = get_ai_tool_definitions()
|
||||
if name not in definitions or name not in mode_tools:
|
||||
available = sorted(set(mode_tools) & set(definitions))
|
||||
return False, f'Unknown tool {name!r}. Available tools: {available}.'
|
||||
tool = definitions[name]
|
||||
|
||||
args = args or {}
|
||||
if error := _validate_args(tool, args):
|
||||
return False, error
|
||||
|
||||
path = tool.path
|
||||
for param in tool.path_params:
|
||||
# Encode everything but '/' (help-doc slugs contain it); traversal was rejected above.
|
||||
path = path.replace(f'{{{param}}}', quote(str(args[param]), safe='/'))
|
||||
# The substituted path must still be inside the endpoint the tool declares.
|
||||
static_prefix = tool.path.split('{', 1)[0]
|
||||
if not path.startswith(static_prefix) or '..' in path.split('/'):
|
||||
return False, f'Arguments produced an invalid path for {tool.name}.'
|
||||
query = {k: v for k, v in args.items() if k in tool.query_params and v is not None}
|
||||
body = {k: v for k, v in args.items() if k in tool.body_params} if tool.method == 'post' else None
|
||||
|
||||
try:
|
||||
status, response = await call_ai_endpoint(tool.method, path, query, body)
|
||||
except Exception as e:
|
||||
logger.error(f'Tool {name} failed', exc_info=e)
|
||||
return False, f'Tool {name} failed to execute; try different arguments or another tool.'
|
||||
|
||||
text = json.dumps(response, default=str)
|
||||
if len(text) > TOOL_RESULT_MAX_CHARS:
|
||||
text = text[:TOOL_RESULT_MAX_CHARS] + '… (result truncated; use limit/offset to get less at a time)'
|
||||
if status >= 400:
|
||||
message = response.get('message') or response.get('error') or f'HTTP {status}'
|
||||
return False, f'{tool.name} returned an error: {message}'
|
||||
return True, text
|
||||
911
modules/ai/api.py
Normal file
911
modules/ai/api.py
Normal file
|
|
@ -0,0 +1,911 @@
|
|||
"""The /api/ai blueprint — read-only, LLM-shaped endpoints.
|
||||
|
||||
This blueprint IS the AI tool catalog: the agent loop generates its tool definitions from these
|
||||
endpoints' OpenAPI operations, and the external MCP server proxies them. Every endpoint is
|
||||
read-only; descriptions are written for the model, not for humans.
|
||||
"""
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import pathlib
|
||||
from http import HTTPStatus
|
||||
from typing import Optional
|
||||
|
||||
from sanic import Blueprint, Request
|
||||
from sanic_ext import validate
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
|
||||
from modules.archive import lib as archive_lib
|
||||
from modules.archive.lib import search_domains_by_name
|
||||
from modules.docs.lib import search_authors_by_name, search_subjects_by_name
|
||||
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.docs.lib import _doc_response, _get_doc, _search_docs
|
||||
from modules.inventory.common import get_inventory_configs
|
||||
from modules.inventory.errors import UnknownInventory
|
||||
from modules.videos.video import lib as videos_lib
|
||||
from modules.zim import lib as zim_lib
|
||||
from wrolpi import flags, tags
|
||||
from wrolpi.api_utils import json_response
|
||||
from wrolpi.collections.lib import search_collections
|
||||
from wrolpi.common import api_param_limiter, get_media_directory, get_relative_to_media_directory, logger, \
|
||||
wrol_mode_enabled
|
||||
from wrolpi.downloader import download_manager
|
||||
from wrolpi.errors import InvalidFile, SearchEmpty, UnknownFile, ValidationError
|
||||
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__
|
||||
from wrolpi.schema import JSONErrorResponse
|
||||
from . import catalog, help_docs, lib, schema
|
||||
from .config import get_ai_config
|
||||
from .controller_client import controller_get
|
||||
from .errors import ControllerUnavailable
|
||||
|
||||
ai_bp = Blueprint('AI', url_prefix='/api/ai')
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
ai_limiter = api_param_limiter(25, default=5)
|
||||
|
||||
# Refuse to serve files this large through the text reader; real content is paged anyway.
|
||||
MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
|
||||
# Directory listings are paged by entry count so one big channel can't fill the model's context.
|
||||
LIST_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def _offset(request: Request) -> int:
|
||||
try:
|
||||
return max(int(request.args.get('offset', 0)), 0)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
@ai_bp.get('/zims')
|
||||
@openapi.definition(
|
||||
summary='List Zim encyclopedias',
|
||||
description='List the Zim encyclopedias in the library (Wikipedia, Wiktionary, etc.). Returns the zim_id'
|
||||
' needed to search a specific Zim or read one of its entries.',
|
||||
)
|
||||
@openapi.operation('list_zims')
|
||||
async def list_zims(request: Request):
|
||||
zims = zim_lib.get_zims(request.ctx.session)
|
||||
results = []
|
||||
for zim in zims:
|
||||
metadata = lib.zim_metadata_dict(zim.zim_metadata)
|
||||
results.append({k: v for k, v in dict(
|
||||
id=zim.id,
|
||||
title=metadata.get('title'),
|
||||
creator=metadata.get('creator'),
|
||||
description=metadata.get('description'),
|
||||
size=zim.file_group.size if zim.file_group else None,
|
||||
auto_search=zim.auto_search,
|
||||
).items() if v is not None})
|
||||
return json_response(dict(results=results, total=len(results)))
|
||||
|
||||
|
||||
@ai_bp.post('/zims/search')
|
||||
@openapi.definition(
|
||||
summary='Search Zim encyclopedias',
|
||||
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')
|
||||
@openapi.response(HTTPStatus.OK, schema.AISearchResponse)
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
@validate(schema.AIZimSearchRequest)
|
||||
async def search_zims(request: Request, body: schema.AIZimSearchRequest):
|
||||
if not body.search_str:
|
||||
raise SearchEmpty()
|
||||
limit = ai_limiter(body.limit)
|
||||
offset = body.offset or 0
|
||||
if body.zim_id:
|
||||
result = zim_lib.headline_zim(request.ctx.session, body.search_str, body.zim_id, offset=offset, limit=limit)
|
||||
for entry in result.get('search') or []:
|
||||
entry['zim_id'] = body.zim_id
|
||||
zim_results = [result]
|
||||
else:
|
||||
zim_results = zim_lib.search_all_zims(request.ctx.session, body.search_str, offset=offset, limit=limit)
|
||||
return json_response(lib.format_zim_search(zim_results))
|
||||
|
||||
|
||||
@ai_bp.get('/zims/<zim_id:int>/entry')
|
||||
@openapi.definition(
|
||||
summary='Read a Zim entry',
|
||||
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')
|
||||
@openapi.parameter('offset', int, 'query')
|
||||
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def get_zim_entry(request: Request, zim_id: int):
|
||||
path = request.args.get('path')
|
||||
if not path:
|
||||
raise InvalidFile('path query parameter is required')
|
||||
entry = zim_lib.get_entry(path, zim_id)
|
||||
html = bytes(entry.get_item().content).decode('UTF-8', errors='replace')
|
||||
result = lib.paginate_text(lib.html_to_text(html), _offset(request))
|
||||
result['link'] = lib.zim_entry_link(zim_id, path)
|
||||
return json_response(result)
|
||||
|
||||
|
||||
@ai_bp.get('/collections')
|
||||
@openapi.definition(
|
||||
summary='List collections',
|
||||
description='List collections in the library, paged. Filter with kind: "channel" (video channels),'
|
||||
' "domain" (archived websites), or "playlist"; search_str matches names. 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')
|
||||
@openapi.parameter('search_str', str, 'query')
|
||||
@openapi.parameter('limit', int, 'query')
|
||||
@openapi.parameter('offset', int, 'query')
|
||||
async def list_collections(request: Request):
|
||||
kind = request.args.get('kind')
|
||||
search_str = request.args.get('search_str') or None
|
||||
try:
|
||||
limit = ai_limiter(int(request.args.get('limit', 0)) or None)
|
||||
except ValueError:
|
||||
raise ValidationError('limit must be an integer')
|
||||
offset = _offset(request)
|
||||
collections = search_collections(request.ctx.session, kind=kind, search_str=search_str)
|
||||
results = []
|
||||
for collection in collections[offset:offset + limit]:
|
||||
# Channels have a browsable page; a real link stops the model inventing one.
|
||||
link = f"/videos/channel/{collection['id']}/video" if collection.get('kind') == 'channel' else None
|
||||
results.append({k: v for k, v in dict(
|
||||
id=collection.get('id'),
|
||||
name=collection.get('name'),
|
||||
kind=collection.get('kind'),
|
||||
directory=collection.get('directory'),
|
||||
link=link,
|
||||
).items() if v is not None})
|
||||
next_offset = offset + limit if offset + limit < len(collections) else None
|
||||
return json_response(dict(results=results, total=len(collections), next_offset=next_offset))
|
||||
|
||||
|
||||
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 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)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Help endpoints (Help mode). Help markdown is searched in-process; see help_docs.py.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ai_bp.post('/help/search')
|
||||
@openapi.definition(
|
||||
summary='Search the WROLPi help documentation',
|
||||
description='Search the WROLPi help documentation for how to use and repair WROLPi. Returns matching'
|
||||
' help pages with a snippet; read a full page by passing its slug to get_help_doc. Links are pages on the'
|
||||
' WROLPi help site.',
|
||||
body=schema.AIHelpSearchRequest,
|
||||
)
|
||||
@openapi.operation('search_help')
|
||||
@openapi.response(HTTPStatus.OK, schema.AISearchResponse)
|
||||
@validate(schema.AIHelpSearchRequest)
|
||||
async def search_help(_: Request, body: schema.AIHelpSearchRequest):
|
||||
if not body.search_str:
|
||||
raise SearchEmpty()
|
||||
if not help_docs.get_help_docs_directory():
|
||||
return json_response(dict(results=[], total=0, message='Help documentation is not installed.'))
|
||||
results, total = help_docs.search_help(body.search_str, ai_limiter(body.limit))
|
||||
return json_response(dict(results=results, total=total))
|
||||
|
||||
|
||||
@ai_bp.get('/help/<slug:path>')
|
||||
@openapi.definition(
|
||||
summary='Read a help page',
|
||||
description='Read one WROLPi help page as markdown by its doc slug from search_help results. Long'
|
||||
' pages are paged: request again with offset=next_offset to continue reading.',
|
||||
)
|
||||
@openapi.operation('get_help_doc')
|
||||
@openapi.parameter('offset', int, 'query')
|
||||
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def get_help_doc(request: Request, slug: str):
|
||||
doc = help_docs.get_help_doc(slug)
|
||||
if not doc:
|
||||
raise UnknownFile(f'No such help doc: {slug}')
|
||||
result = lib.paginate_text(doc.body, _offset(request))
|
||||
result['title'] = doc.title
|
||||
result['link'] = help_docs.help_doc_link(doc.slug)
|
||||
return json_response(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System endpoints (System mode). The Controller is proxied so the model sees one API; every
|
||||
# proxy degrades gracefully — a Controller outage becomes an entry in `errors`, never a 500.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The lean subset of the Controller's cached status that is useful for diagnosis; the full blob
|
||||
# (processes, iostat, disk bandwidth) is too big for small model contexts.
|
||||
SYSTEM_STATUS_KEYS = ('cpu', 'memory', 'load', 'drives', 'network', 'uptime', 'hotspot', 'throttle', 'is_rpi')
|
||||
|
||||
log_lines_limiter = api_param_limiter(1_000, default=100)
|
||||
|
||||
|
||||
@ai_bp.get('/status')
|
||||
@openapi.definition(
|
||||
summary='Get WROLPi system status',
|
||||
description='Get an aggregate of the whole system in one call: WROLPi version, mode flags, download'
|
||||
' summary, CPU/memory/load/disk usage, and the state of every WROLPi service. Start'
|
||||
' troubleshooting here. Any part that could not be gathered is listed in errors.',
|
||||
)
|
||||
@openapi.operation('get_system_status')
|
||||
async def get_system_status(_: Request):
|
||||
errors = []
|
||||
ret = dict(
|
||||
version=__version__.strip(),
|
||||
dockerized=DOCKERIZED,
|
||||
wrol_mode=wrol_mode_enabled(),
|
||||
flags=flags.get_flags(),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
try:
|
||||
ret['downloads'] = download_manager.get_summary()
|
||||
except Exception as e:
|
||||
logger.debug('AI status could not get download summary', exc_info=e)
|
||||
errors.append('Could not get download summary')
|
||||
|
||||
try:
|
||||
status, stats = await controller_get('/api/stats')
|
||||
if status == HTTPStatus.OK:
|
||||
ret['system'] = {k: v for k, v in stats.items() if k in SYSTEM_STATUS_KEYS}
|
||||
else:
|
||||
errors.append(f'Controller stats returned HTTP {status}')
|
||||
except Exception as e:
|
||||
logger.debug('AI status could not reach Controller stats', exc_info=e)
|
||||
errors.append('Could not reach the Controller for system stats')
|
||||
|
||||
try:
|
||||
ret['services'] = await _lean_services()
|
||||
except Exception as e:
|
||||
logger.debug('AI status could not get services', exc_info=e)
|
||||
errors.append('Could not get service statuses')
|
||||
|
||||
return json_response(ret)
|
||||
|
||||
|
||||
async def _lean_services() -> list:
|
||||
status, services = await controller_get('/api/services')
|
||||
if status != HTTPStatus.OK or not isinstance(services, list):
|
||||
raise ControllerUnavailable(f'Controller services returned HTTP {status}')
|
||||
return [{k: v for k, v in dict(
|
||||
name=i.get('name'),
|
||||
status=i.get('status'),
|
||||
enabled=i.get('enabled'),
|
||||
port=i.get('port'),
|
||||
description=i.get('description') or None,
|
||||
).items() if v is not None} for i in services]
|
||||
|
||||
|
||||
@ai_bp.get('/services')
|
||||
@openapi.definition(
|
||||
summary='List WROLPi services',
|
||||
description='List every WROLPi service with its state (running, stopped, failed) and port. Use the'
|
||||
' name with get_service_logs to read a failing service\'s logs.',
|
||||
)
|
||||
@openapi.operation('list_services')
|
||||
@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse)
|
||||
async def ai_list_services(_: Request):
|
||||
services = await _lean_services()
|
||||
return json_response(dict(results=services, total=len(services)))
|
||||
|
||||
|
||||
@ai_bp.get('/services/<name:str>/logs')
|
||||
@openapi.definition(
|
||||
summary='Read service logs',
|
||||
description='Read the most recent log lines of one WROLPi service by its name from list_services.'
|
||||
' Pass lines to read more history (at most 1000).',
|
||||
)
|
||||
@openapi.operation('get_service_logs')
|
||||
@openapi.parameter('lines', int, 'query')
|
||||
@openapi.parameter('since', str, 'query')
|
||||
@openapi.response(HTTPStatus.BAD_GATEWAY, JSONErrorResponse)
|
||||
async def ai_get_service_logs(request: Request, name: str):
|
||||
try:
|
||||
lines = log_lines_limiter(int(request.args.get('lines', 100)))
|
||||
except ValueError:
|
||||
lines = 100
|
||||
status, body = await controller_get(f'/api/services/{name}/logs',
|
||||
params=dict(lines=max(lines, 1), since=request.args.get('since')))
|
||||
if status != HTTPStatus.OK:
|
||||
raise ControllerUnavailable(f'Controller logs returned HTTP {status}: {body.get("detail")}')
|
||||
return json_response(dict(service=body.get('service', name), lines=body.get('lines'), logs=body.get('logs', '')))
|
||||
|
||||
|
||||
@ai_bp.get('/disks')
|
||||
@openapi.definition(
|
||||
summary='Get disks and drive health',
|
||||
description='List the disks and partitions (size, filesystem, where mounted) and SMART drive-health'
|
||||
' status. Use this to diagnose full or failing drives. Parts that could not be gathered'
|
||||
' are listed in errors.',
|
||||
)
|
||||
@openapi.operation('list_disks')
|
||||
async def ai_list_disks(_: Request):
|
||||
errors = []
|
||||
ret = dict(errors=errors)
|
||||
|
||||
try:
|
||||
status, disks = await controller_get('/api/disks')
|
||||
if status == HTTPStatus.OK:
|
||||
ret['disks'] = disks
|
||||
else:
|
||||
errors.append(f'Controller disks returned HTTP {status}: {disks.get("detail")}')
|
||||
except Exception as e:
|
||||
logger.debug('AI disks could not reach Controller', exc_info=e)
|
||||
errors.append('Could not reach the Controller for disks')
|
||||
|
||||
try:
|
||||
status, smart = await controller_get('/api/disks/smart')
|
||||
if status == HTTPStatus.OK:
|
||||
ret['smart'] = smart
|
||||
else:
|
||||
errors.append(f'Controller SMART returned HTTP {status}: {smart.get("detail")}')
|
||||
except Exception as e:
|
||||
logger.debug('AI disks could not reach Controller SMART', exc_info=e)
|
||||
errors.append('Could not reach the Controller for SMART status')
|
||||
|
||||
return json_response(ret)
|
||||
|
||||
|
||||
# Errors on downloads can be long tracebacks; the model only needs the gist.
|
||||
DOWNLOAD_ERROR_LENGTH = 300
|
||||
MAP_LINK_ZOOM = 12
|
||||
|
||||
|
||||
def _map_link(lat, lon) -> str:
|
||||
return f'/map?lat={lat}&lon={lon}&z={MAP_LINK_ZOOM}'
|
||||
|
||||
|
||||
@ai_bp.get('/tags')
|
||||
@openapi.definition(
|
||||
summary='List tags',
|
||||
description='List every tag in the library with how many files, Zim entries, channels, and domains carry'
|
||||
' it, plus the most recently used tag names. Use a tag name in the tag_names filter of the'
|
||||
' search tools. Users tag things they care about, so tags are a good map of their interests.',
|
||||
)
|
||||
@openapi.operation('list_tags')
|
||||
async def list_tags(_: Request):
|
||||
results = [dict(
|
||||
name=i['name'],
|
||||
file_groups=i['file_group_count'],
|
||||
zim_entries=i['zim_entry_count'],
|
||||
channels=i['channel_count'],
|
||||
domains=i['domain_count'],
|
||||
) for i in tags.get_tags()]
|
||||
return json_response(dict(results=results, recent=tags.get_recent_tags(), total=len(results)))
|
||||
|
||||
|
||||
def _format_download(download: dict) -> dict:
|
||||
error = download.get('error')
|
||||
if error and len(error) > DOWNLOAD_ERROR_LENGTH:
|
||||
# Errors are usually tracebacks; the exception message at the end is the useful part.
|
||||
error = '…' + error[-DOWNLOAD_ERROR_LENGTH:]
|
||||
destination = download.get('destination')
|
||||
return {k: v for k, v in dict(
|
||||
id=download.get('id'),
|
||||
url=download.get('url'),
|
||||
status=download.get('status'),
|
||||
downloader=download.get('downloader'),
|
||||
sub_downloader=download.get('sub_downloader'),
|
||||
frequency=download.get('frequency'),
|
||||
destination=str(get_relative_to_media_directory(destination)) if destination else None,
|
||||
collection_id=download.get('collection_id'),
|
||||
tag_names=download.get('tag_names') or None,
|
||||
next_download=download.get('next_download'),
|
||||
last_successful_download=download.get('last_successful_download'),
|
||||
error=error,
|
||||
).items() if v is not None}
|
||||
|
||||
|
||||
@ai_bp.get('/downloads')
|
||||
@openapi.definition(
|
||||
summary='List downloads',
|
||||
description='Read the download queue: a summary (pending count, whether downloading is disabled or'
|
||||
' stopped, daily limit), the recurring downloads (channels, feeds; frequency is in seconds),'
|
||||
' and the newest one-time downloads. Filter with status: new, pending, failed, deferred, or'
|
||||
' complete. Use this when the user asks what is downloading, why a download failed, or what'
|
||||
' is scheduled. You cannot start, stop, or retry downloads.',
|
||||
)
|
||||
@openapi.operation('list_downloads')
|
||||
@openapi.parameter('status', str, 'query')
|
||||
@openapi.parameter('limit', int, 'query')
|
||||
async def list_downloads(request: Request):
|
||||
status = request.args.get('status')
|
||||
try:
|
||||
limit = ai_limiter(int(request.args.get('limit', 0)) or None)
|
||||
except ValueError:
|
||||
raise ValidationError('limit must be an integer')
|
||||
summary = download_manager.get_summary()
|
||||
data = download_manager.get_fe_downloads()
|
||||
|
||||
def select(downloads: list) -> list:
|
||||
if status:
|
||||
downloads = [i for i in downloads if i.get('status') == status]
|
||||
return [_format_download(i) for i in downloads[:limit]]
|
||||
|
||||
return json_response(dict(
|
||||
summary=summary,
|
||||
recurring=select(data['recurring_downloads']),
|
||||
once=select(data['once_downloads']),
|
||||
pending_once=data.get('pending_once_downloads', 0),
|
||||
))
|
||||
|
||||
|
||||
@ai_bp.get('/map')
|
||||
@openapi.definition(
|
||||
summary='Get map overview',
|
||||
description='What maps this WROLPi has: the downloaded map files (regions) and whether each has a place'
|
||||
' search index, the map regions subscribed for updates, and the user\'s saved pins with a'
|
||||
' link to each. Use search_places to look up a town or landmark.',
|
||||
)
|
||||
@openapi.operation('get_map_overview')
|
||||
async def get_map_overview(request: Request):
|
||||
files = [dict(name=i['name'], path=i['path'], size=i['size'], has_search_index=i['has_search_index'])
|
||||
for i in map_lib.get_pmtiles_files()]
|
||||
pins = [dict(id=i.get('id'), label=i.get('label'), lat=i.get('lat'), lon=i.get('lon'),
|
||||
link=_map_link(i.get('lat'), i.get('lon')))
|
||||
for i in get_map_pins_config().pins]
|
||||
return json_response(dict(
|
||||
files=files,
|
||||
subscriptions=map_lib.get_map_subscriptions(request.ctx.session),
|
||||
pins=pins,
|
||||
search_indexes=map_search.get_search_status(),
|
||||
))
|
||||
|
||||
|
||||
@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. 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')
|
||||
@openapi.parameter('limit', int, 'query')
|
||||
@openapi.parameter('offset', int, 'query')
|
||||
@openapi.parameter('lat', float, 'query')
|
||||
@openapi.parameter('lon', float, 'query')
|
||||
async def search_places(request: Request):
|
||||
q = (request.args.get('q') or '').strip()
|
||||
if not q:
|
||||
raise ValidationError('q query parameter is required')
|
||||
try:
|
||||
limit = ai_limiter(int(request.args.get('limit', 0)) or None)
|
||||
lat = float(request.args.get('lat')) if request.args.get('lat') is not None else None
|
||||
lon = float(request.args.get('lon')) if request.args.get('lon') is not None else None
|
||||
except ValueError:
|
||||
raise ValidationError('limit, lat, and lon must be numbers')
|
||||
data = await asyncio.to_thread(map_search.search_places, q, limit=limit, offset=_offset(request),
|
||||
lat=lat, lon=lon)
|
||||
results = [{k: v for k, v in dict(
|
||||
name=i.get('name'),
|
||||
kind=i.get('kind_detail') or i.get('kind'),
|
||||
region=i.get('region'),
|
||||
population=i.get('population'),
|
||||
lat=i.get('lat'),
|
||||
lon=i.get('lon'),
|
||||
link=_map_link(i.get('lat'), i.get('lon')),
|
||||
).items() if v is not None} for i in data.get('results') or []]
|
||||
return json_response(dict(results=results, total=data.get('total', len(results))))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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 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')
|
||||
@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 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)
|
||||
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 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')
|
||||
@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.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ai_bp.get('/manage/catalog')
|
||||
@openapi.description('The AI model catalog, download/active state, and the recommended tier for this'
|
||||
' device. For the Manage tab, not the model.')
|
||||
async def manage_catalog(_: Request):
|
||||
models, source = await catalog.get_models_catalog()
|
||||
models_directory = catalog.get_models_directory()
|
||||
on_disk = {i.name: i for i in models_directory.glob('*.gguf')} if models_directory.is_dir() else dict()
|
||||
disk_usage = sum(i.stat().st_size for i in models_directory.glob('*')) if models_directory.is_dir() else 0
|
||||
|
||||
config = get_ai_config()
|
||||
models = [dict(i, downloaded=i['name'] in on_disk, active=i['name'] == config.active_model)
|
||||
for i in models]
|
||||
# Any GGUF the user copied into ai/models themselves is a "custom" model. It is discovered
|
||||
# here on every request (no file refresh) so it appears in the Active Model dropdown.
|
||||
catalog_names = {i['name'] for i in models}
|
||||
for name, path in sorted(on_disk.items()):
|
||||
# Skip names start_llama_server.sh could not load (see is_loadable_model_name).
|
||||
if name not in catalog_names and path.is_file() and catalog.is_loadable_model_name(name):
|
||||
models.append(dict(
|
||||
name=name,
|
||||
tier='custom',
|
||||
url=None,
|
||||
size=path.stat().st_size,
|
||||
description='Custom model found in ai/models/. Not from the WROLPi catalog.',
|
||||
downloaded=True,
|
||||
active=name == config.active_model,
|
||||
))
|
||||
|
||||
total_ram = catalog.get_total_ram_bytes()
|
||||
return json_response(dict(
|
||||
models=models,
|
||||
catalog_source=source,
|
||||
total_ram=total_ram,
|
||||
recommended_tier=catalog.recommend_tier(total_ram),
|
||||
slow_hardware=IS_RPI4,
|
||||
models_directory=str(models_directory),
|
||||
disk_usage=disk_usage,
|
||||
enabled=config.enabled,
|
||||
active_model=config.active_model,
|
||||
idle_unload_minutes=config.idle_unload_minutes,
|
||||
context_size=config.context_size,
|
||||
))
|
||||
|
||||
|
||||
@ai_bp.post('/manage/settings')
|
||||
@openapi.definition(
|
||||
description='Update the AI settings (ai.yaml). For the Manage tab, not the model.',
|
||||
body=schema.AIManageSettingsRequest,
|
||||
)
|
||||
@validate(schema.AIManageSettingsRequest)
|
||||
async def manage_settings(_: Request, body: schema.AIManageSettingsRequest):
|
||||
config = get_ai_config()
|
||||
if body.active_model is not None:
|
||||
if body.active_model and not (catalog.get_models_directory() / body.active_model).is_file():
|
||||
raise ValidationError(f'Model is not downloaded: {body.active_model}')
|
||||
if body.active_model and not catalog.is_loadable_model_name(body.active_model):
|
||||
raise ValidationError(f'Model file name cannot be loaded by llama-server; rename it to plain ASCII'
|
||||
f' without #, quotes, or "..": {body.active_model}')
|
||||
config.active_model = body.active_model
|
||||
# Each model has its own context default (the 4B runs 16k, the small tier 8k); selecting
|
||||
# a model adopts it unless this request also sets context_size explicitly. A custom model
|
||||
# has no catalog default, so the previous model's explicit value is cleared rather than
|
||||
# carried over.
|
||||
if body.context_size is None:
|
||||
config.context_size = catalog.get_model_default_context(body.active_model)
|
||||
if body.enabled is not None:
|
||||
config.enabled = body.enabled
|
||||
if body.idle_unload_minutes is not None:
|
||||
if body.idle_unload_minutes < 1:
|
||||
raise ValidationError('idle_unload_minutes must be at least 1')
|
||||
config.idle_unload_minutes = body.idle_unload_minutes
|
||||
if body.context_size is not None:
|
||||
config.context_size = body.context_size
|
||||
return json_response(dict(
|
||||
enabled=config.enabled,
|
||||
active_model=config.active_model,
|
||||
idle_unload_minutes=config.idle_unload_minutes,
|
||||
context_size=config.context_size,
|
||||
))
|
||||
|
||||
|
||||
def _resolve_media_path(relative: str | None, verb: str) -> pathlib.Path:
|
||||
"""Resolve a relative path inside the media directory, refusing escapes and the config directory."""
|
||||
media_directory = get_media_directory().resolve()
|
||||
path = (media_directory / (relative or '').strip('/')).resolve()
|
||||
if path != media_directory and not str(path).startswith(f'{media_directory}/'):
|
||||
raise InvalidFile(f'Cannot {verb} outside the media directory')
|
||||
# The config directory can contain secrets (Wi-Fi credentials, API keys); never serve it.
|
||||
config_directory = media_directory / 'config'
|
||||
if path == config_directory or str(path).startswith(f'{config_directory}/'):
|
||||
raise InvalidFile(f'Cannot {verb} config files')
|
||||
return path
|
||||
|
||||
|
||||
@ai_bp.get('/files/list')
|
||||
@openapi.definition(
|
||||
summary='List a directory',
|
||||
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')
|
||||
@openapi.parameter('offset', int, 'query')
|
||||
@openapi.response(HTTPStatus.OK, schema.AIListFilesResponse)
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def list_files(request: Request):
|
||||
relative = request.args.get('path') or ''
|
||||
media_directory = get_media_directory().resolve()
|
||||
directory = _resolve_media_path(relative, 'list')
|
||||
if not directory.is_dir():
|
||||
raise UnknownFile(f'No such directory: {relative}')
|
||||
|
||||
def relative_path(path: pathlib.Path) -> str:
|
||||
rel = str(path.relative_to(media_directory))
|
||||
return f'{rel}/' if path.is_dir() else rel
|
||||
|
||||
directories, files = [], []
|
||||
for child in directory.iterdir():
|
||||
if child.is_dir():
|
||||
# The config directory is never served (see _resolve_media_path), so don't advertise it.
|
||||
if child.name not in HIDDEN_DIRECTORIES and child != media_directory / 'config':
|
||||
directories.append(child)
|
||||
elif child.name not in HIDDEN_FILES:
|
||||
files.append(child)
|
||||
directories.sort(key=lambda i: i.name.lower())
|
||||
files.sort(key=lambda i: i.name.lower())
|
||||
|
||||
entries = [dict(name=i.name, path=relative_path(i)) for i in directories]
|
||||
for file in files:
|
||||
try:
|
||||
size = file.stat().st_size
|
||||
except OSError:
|
||||
size = None
|
||||
entries.append(dict(name=file.name, path=relative_path(file), size=size,
|
||||
mimetype=mimetypes.guess_type(file.name)[0]))
|
||||
|
||||
offset = _offset(request)
|
||||
page = entries[offset:offset + LIST_PAGE_SIZE]
|
||||
next_offset = offset + LIST_PAGE_SIZE if offset + LIST_PAGE_SIZE < len(entries) else None
|
||||
return json_response(dict(
|
||||
path=relative_path(directory) if directory != media_directory else '',
|
||||
directories=[i for i in page if 'size' not in i],
|
||||
files=[i for i in page if 'size' in i],
|
||||
total=len(entries),
|
||||
next_offset=next_offset,
|
||||
))
|
||||
|
||||
|
||||
@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 list_files).'
|
||||
' Text files only. Paged: request again with offset=next_offset.',
|
||||
)
|
||||
@openapi.operation('read_file')
|
||||
@openapi.parameter('path', str, 'query')
|
||||
@openapi.parameter('offset', int, 'query')
|
||||
@openapi.response(HTTPStatus.OK, schema.AIPagedTextResponse)
|
||||
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
|
||||
async def read_file(request: Request):
|
||||
relative = request.args.get('path')
|
||||
if not relative:
|
||||
raise InvalidFile('path query parameter is required')
|
||||
|
||||
path = _resolve_media_path(relative, 'read')
|
||||
if not path.is_file():
|
||||
raise UnknownFile(f'No such file: {relative}')
|
||||
if path.stat().st_size > MAX_TEXT_FILE_SIZE:
|
||||
raise InvalidFile('File is too large to read')
|
||||
|
||||
data = path.read_bytes()
|
||||
if b'\x00' in data[:8192]:
|
||||
raise InvalidFile('Not a text file')
|
||||
return json_response(lib.paginate_text(data.decode('UTF-8', errors='replace'), _offset(request)))
|
||||
145
modules/ai/catalog.py
Normal file
145
modules/ai/catalog.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""The AI model catalog.
|
||||
|
||||
The bundled AI_MODELS constant is the offline fallback (like the map module's MAP_REGIONS);
|
||||
the CDN manifest at ai/models.json is fetched at browse/download time and GPG-verified with the
|
||||
committed WROLPi public key. The release process refreshes AI_MODELS from the published manifest.
|
||||
"""
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import psutil
|
||||
|
||||
from wrolpi.common import aiohttp_get, get_media_directory, logger, verify_gpg_signature
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
CDN = 'https://wrolpi.nyc3.cdn.digitaloceanspaces.com'
|
||||
MODELS_MANIFEST_URL = f'{CDN}/ai/models.json'
|
||||
|
||||
# Sizes are bytes; sha256 values match the published manifest (wrolpi/scripts/publish_ai_models.py
|
||||
# refreshes both). The meta4 sidecar (GPG-verified at download time) is what enforces the hash.
|
||||
AI_MODELS = [
|
||||
dict(
|
||||
name='Qwen3-1.7B-Q4_K_M.gguf',
|
||||
tier='small',
|
||||
url=f'{CDN}/ai/Qwen3-1.7B-Q4_K_M.gguf',
|
||||
sha256='b139949c5bd74937ad8ed8c8cf3d9ffb1e99c866c823204dc42c0d91fa181897',
|
||||
size=1_107_409_472,
|
||||
min_ram_gb=4,
|
||||
default_context=8_192,
|
||||
license='Apache 2.0',
|
||||
description='The default assistant for 4GB devices (Pi 4/5). Best tool-calling per byte.',
|
||||
),
|
||||
dict(
|
||||
name='Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
|
||||
tier='medium',
|
||||
url=f'{CDN}/ai/Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
|
||||
sha256='3605803b982cb64aead44f6c1b2ae36e3acdb41d8e46c8a94c6533bc4c67e597',
|
||||
size=2_497_281_120,
|
||||
min_ram_gb=8,
|
||||
default_context=16_384,
|
||||
license='Apache 2.0',
|
||||
description='The default assistant for 8GB+ devices. A standout small tool-caller.',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_models_directory() -> Path:
|
||||
"""Where GGUF model files are stored. Always inside the media directory."""
|
||||
return get_media_directory() / 'ai/models'
|
||||
|
||||
|
||||
# Printable ASCII only, minus the characters the shell config reader cannot round-trip.
|
||||
# start_llama_server.sh reads active_model back out of ai.yaml with read_config_value.sh: PyYAML
|
||||
# escapes non-ASCII (\xE9), the reader strips " #..." before unquoting, and quotes/backslashes
|
||||
# survive as literal characters. The start script's traversal guard also rejects any ".." run.
|
||||
_UNLOADABLE_CHARS = set('#"\'\\/')
|
||||
|
||||
|
||||
def is_loadable_model_name(name: str) -> bool:
|
||||
"""True when llama-server can be started with this file name from ai/models.
|
||||
|
||||
Catalog names always pass; this exists for GGUFs the user copies in by hand."""
|
||||
if not name or not name.endswith('.gguf') or name != name.strip():
|
||||
return False
|
||||
if '..' in name or any(c in _UNLOADABLE_CHARS for c in name):
|
||||
return False
|
||||
return all(0x20 <= ord(c) < 0x7F for c in name)
|
||||
|
||||
|
||||
async def fetch_models_manifest(url: str = None) -> dict:
|
||||
"""Fetch the models manifest from the CDN and verify its GPG signature (map-manifest pattern)."""
|
||||
url = url or MODELS_MANIFEST_URL
|
||||
sig_url = f'{url}.sig'
|
||||
|
||||
async with aiohttp_get(url, timeout=30) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f'Failed to fetch models manifest: HTTP {response.status}')
|
||||
manifest_bytes = await response.content.read()
|
||||
|
||||
async with aiohttp_get(sig_url, timeout=30) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f'Failed to fetch models manifest signature: HTTP {response.status}')
|
||||
signature_bytes = await response.content.read()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manifest_path = Path(tmpdir) / 'models.json'
|
||||
sig_path = Path(tmpdir) / 'models.json.sig'
|
||||
manifest_path.write_bytes(manifest_bytes)
|
||||
sig_path.write_bytes(signature_bytes)
|
||||
|
||||
if not await verify_gpg_signature(manifest_path, sig_path):
|
||||
raise RuntimeError('Models manifest GPG signature verification failed')
|
||||
|
||||
logger.info('Models manifest GPG signature verified successfully')
|
||||
return json.loads(manifest_bytes)
|
||||
|
||||
|
||||
async def get_models_catalog() -> (list, str):
|
||||
"""The list of downloadable models, and where it came from ('cdn' or 'bundled').
|
||||
|
||||
The CDN manifest wins when reachable; the bundled constant keeps the Manage tab working
|
||||
offline."""
|
||||
try:
|
||||
manifest = await fetch_models_manifest()
|
||||
models = manifest.get('models') or []
|
||||
if models:
|
||||
return models, 'cdn'
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not fetch models manifest, using bundled catalog: {e}')
|
||||
return AI_MODELS, 'bundled'
|
||||
|
||||
|
||||
def get_total_ram_bytes() -> int:
|
||||
return psutil.virtual_memory().total
|
||||
|
||||
|
||||
def recommend_tier(total_ram_bytes: int = None) -> str:
|
||||
"""Recommend a model tier for this device's RAM. Other tiers stay selectable with a warning."""
|
||||
total_ram_bytes = total_ram_bytes if total_ram_bytes is not None else get_total_ram_bytes()
|
||||
gb = total_ram_bytes / 1024 ** 3
|
||||
if gb < 6:
|
||||
return 'small'
|
||||
if gb < 24:
|
||||
return 'medium'
|
||||
return 'large'
|
||||
|
||||
|
||||
def get_model_default_context(name: str) -> Optional[int]:
|
||||
"""The bundled catalog's default context size for a model, if known."""
|
||||
for model in AI_MODELS:
|
||||
if model['name'] == name:
|
||||
return model.get('default_context')
|
||||
return None
|
||||
|
||||
|
||||
def get_effective_context_size() -> int:
|
||||
"""The context llama-server runs with: the explicit ai.yaml value, else the active model's
|
||||
catalog default, else the conservative small-tier default. start_llama_server.sh reads the
|
||||
explicit value; manage_settings writes the model default there on model selection so the
|
||||
script and this function agree."""
|
||||
from modules.ai.config import get_ai_config
|
||||
config = get_ai_config()
|
||||
return config.context_size or get_model_default_context(config.active_model) or 8_192
|
||||
256
modules/ai/chat.py
Normal file
256
modules/ai/chat.py
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
"""POST /api/chat — the agent loop, streamed over SSE.
|
||||
|
||||
Events (each `event: <name>` with a JSON `data:` line):
|
||||
status {message} e.g. "Loading the model…"
|
||||
token {content} one streamed answer fragment
|
||||
tool_call {tool, args} the model is using a tool
|
||||
tool_result {tool, success} the tool finished
|
||||
done {content} the complete final answer
|
||||
error {message}
|
||||
|
||||
The loop: build the mode's system prompt and tool definitions -> stream from llama-server ->
|
||||
execute tool calls against /api/ai only (the mode allowlist is enforced server-side) -> repeat,
|
||||
bounded by ai.yaml's max_tool_calls -> stream the final answer.
|
||||
|
||||
A streaming response outlives the request middleware, so nothing here may use
|
||||
request.ctx.session; tools execute through the blueprint, which manages its own sessions."""
|
||||
import dataclasses
|
||||
import json
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from typing import AsyncGenerator, List, Optional
|
||||
|
||||
from sanic import Blueprint, Request
|
||||
from sanic_ext import validate
|
||||
from sanic_ext.extensions.openapi import openapi
|
||||
|
||||
from wrolpi.common import aiohttp_post, logger
|
||||
from wrolpi.errors import APIError, ValidationError
|
||||
|
||||
from . import agent, service
|
||||
from .catalog import get_effective_context_size
|
||||
from .config import get_ai_config
|
||||
from .context import get_library_context
|
||||
from .modes import MODE_EXAMPLES, MODE_SUGGESTIONS, MODES
|
||||
|
||||
chat_bp = Blueprint('AIChat', url_prefix='/api/chat')
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
# The whole conversation turn (all tool calls and answers) must finish within this.
|
||||
CHAT_TIMEOUT_SECONDS = 600
|
||||
FALLBACK_MESSAGE = "I couldn't complete that request. Try rephrasing, or a more specific question."
|
||||
# Tokens kept free for the model's reply when budgeting the prompt.
|
||||
REPLY_RESERVE_TOKENS = 768
|
||||
CONTEXT_FULL_MESSAGE = ('This conversation no longer fits the model\'s context.'
|
||||
' Start a new conversation and ask a more specific question.')
|
||||
|
||||
|
||||
def estimate_tokens(messages: List[dict], tools: List[dict]) -> int:
|
||||
"""A deliberate overestimate (~4 chars/token) of what one completion request costs."""
|
||||
return (len(json.dumps(messages)) + len(json.dumps(tools, default=str))) // 4
|
||||
|
||||
|
||||
def trim_history(history: List[dict], fixed: List[dict], tools: List[dict], budget: int) -> List[dict]:
|
||||
"""Drop the oldest conversation turns until fixed + history fits the budget.
|
||||
|
||||
`fixed` (system prompt, library map, few-shot example, and the current user message) is never
|
||||
trimmed; old turns are the least valuable tokens in the request."""
|
||||
history = list(history)
|
||||
while history and estimate_tokens(fixed + history, tools) > budget:
|
||||
history.pop(0)
|
||||
return history
|
||||
|
||||
|
||||
class AIDisabled(APIError):
|
||||
code = 'AI_DISABLED'
|
||||
summary = 'AI is not enabled. Enable it and select a model on the AI page.'
|
||||
status_code = HTTPStatus.CONFLICT
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ChatRequest:
|
||||
mode: str = ''
|
||||
messages: List[dict] = dataclasses.field(default_factory=list)
|
||||
model: Optional[str] = None # reserved; llama-server runs the single active model
|
||||
|
||||
|
||||
@chat_bp.get('/modes')
|
||||
@openapi.description('The available chat modes with their suggestion chips.')
|
||||
async def get_modes(_: Request):
|
||||
from wrolpi.api_utils import json_response
|
||||
modes = [dict(name=name, suggestions=MODE_SUGGESTIONS.get(name, [])) for name in MODES]
|
||||
return json_response(dict(modes=modes))
|
||||
|
||||
|
||||
async def llama_chat_stream(messages: List[dict], tools: List[dict]) -> AsyncGenerator[dict, None]:
|
||||
"""Stream one /v1/chat/completions call from llama-server.
|
||||
|
||||
Yields {'type': 'token', 'content': str} for answer fragments, then exactly one
|
||||
{'type': 'stop', 'tool_calls': [...]} where tool_calls is empty for a final answer.
|
||||
Patched in tests."""
|
||||
config = get_ai_config()
|
||||
body = dict(
|
||||
messages=messages,
|
||||
stream=True,
|
||||
# Low temperature: tool calls from small models are far more reliable near 0.2-0.3.
|
||||
temperature=config.temperature,
|
||||
# Reuse the KV cache for the stable prefix (system prompt + example); on slow CPUs
|
||||
# re-processing that prefix is most of the latency.
|
||||
cache_prompt=True,
|
||||
)
|
||||
if tools:
|
||||
body['tools'] = tools
|
||||
|
||||
tool_calls = {} # index -> {id, name, arguments}
|
||||
async with aiohttp_post(f'{service.LLAMA_URL}/v1/chat/completions', json_=body,
|
||||
timeout=CHAT_TIMEOUT_SECONDS) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f'llama-server returned HTTP {response.status}')
|
||||
async for raw_line in response.content:
|
||||
line = raw_line.decode('UTF-8', errors='replace').strip()
|
||||
if not line.startswith('data:'):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == '[DONE]':
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
choice = (chunk.get('choices') or [{}])[0]
|
||||
delta = choice.get('delta') or {}
|
||||
if content := delta.get('content'):
|
||||
yield dict(type='token', content=content)
|
||||
for tool_call in delta.get('tool_calls') or []:
|
||||
index = tool_call.get('index', 0)
|
||||
entry = tool_calls.setdefault(index, dict(id=None, name='', arguments=''))
|
||||
if tool_call.get('id'):
|
||||
entry['id'] = tool_call['id']
|
||||
function = tool_call.get('function') or {}
|
||||
if function.get('name'):
|
||||
entry['name'] = function['name']
|
||||
if function.get('arguments'):
|
||||
entry['arguments'] += function['arguments']
|
||||
|
||||
yield dict(type='stop', tool_calls=[tool_calls[i] for i in sorted(tool_calls)])
|
||||
|
||||
|
||||
async def _send_event(response, event: str, data: dict):
|
||||
await response.send(f'event: {event}\ndata: {json.dumps(data, default=str)}\n\n')
|
||||
|
||||
|
||||
@chat_bp.post('/')
|
||||
@openapi.definition(
|
||||
summary='Chat with the local AI assistant (SSE stream)',
|
||||
body=ChatRequest,
|
||||
)
|
||||
@validate(ChatRequest)
|
||||
async def post_chat(request: Request, body: ChatRequest):
|
||||
if body.mode not in MODES:
|
||||
raise ValidationError(f'Unknown mode: {body.mode!r}. Modes: {sorted(MODES)}')
|
||||
if not body.messages:
|
||||
raise ValidationError('messages must not be empty')
|
||||
config = get_ai_config()
|
||||
if not config.enabled or not config.active_model:
|
||||
raise AIDisabled()
|
||||
|
||||
mode = MODES[body.mode]
|
||||
tools = agent.get_mode_tools(mode['tools'])
|
||||
max_tool_calls = config.max_tool_calls
|
||||
deadline = time.time() + CHAT_TIMEOUT_SECONDS
|
||||
|
||||
response = await request.respond(content_type='text/event-stream', headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
# Ask proxies not to buffer the stream.
|
||||
'X-Accel-Buffering': 'no',
|
||||
})
|
||||
|
||||
try:
|
||||
if not await service.llama_healthy():
|
||||
await _send_event(response, 'status', dict(message='Loading the model…'))
|
||||
if not await service.ensure_llama_running():
|
||||
await _send_event(response, 'error', dict(message='The AI service could not be started.'))
|
||||
return
|
||||
|
||||
# System prompt + the live library map, so exact names, counts, and today's date are in
|
||||
# context before the first token; then the mode's worked example, then the conversation.
|
||||
system_content = mode['system_prompt']
|
||||
if library_context := get_library_context():
|
||||
system_content += f'\n\n{library_context}'
|
||||
fixed = [dict(role='system', content=system_content)]
|
||||
fixed += MODE_EXAMPLES.get(body.mode, [])
|
||||
|
||||
history = [dict(role=m.get('role', 'user'), content=m.get('content', '')) for m in body.messages]
|
||||
# The current user message is never trimmed away.
|
||||
current, history = history[-1], history[:-1]
|
||||
fixed.append(current)
|
||||
|
||||
budget = get_effective_context_size() - REPLY_RESERVE_TOKENS
|
||||
history = trim_history(history, fixed, tools, budget)
|
||||
if estimate_tokens(fixed + history, tools) > budget:
|
||||
await _send_event(response, 'error', dict(message=CONTEXT_FULL_MESSAGE))
|
||||
return
|
||||
messages = fixed[:-1] + history + [current]
|
||||
|
||||
final_answer = []
|
||||
for _ in range(max_tool_calls + 1):
|
||||
if time.time() > deadline:
|
||||
await _send_event(response, 'error', dict(message=FALLBACK_MESSAGE))
|
||||
return
|
||||
|
||||
# Tool results grow the request; refuse to overflow the model's context mid-loop.
|
||||
if estimate_tokens(messages, tools) > budget:
|
||||
await _send_event(response, 'error', dict(message=CONTEXT_FULL_MESSAGE))
|
||||
return
|
||||
|
||||
service.record_llm_activity()
|
||||
# Always send something before the model call: on slow CPUs the first token can be
|
||||
# minutes away, and a silent stream looks dead to the UI and to proxies.
|
||||
await _send_event(response, 'status', dict(message='Thinking…'))
|
||||
tool_calls = []
|
||||
final_answer = []
|
||||
async for event in llama_chat_stream(messages, tools):
|
||||
if event['type'] == 'token':
|
||||
final_answer.append(event['content'])
|
||||
await _send_event(response, 'token', dict(content=event['content']))
|
||||
elif event['type'] == 'stop':
|
||||
tool_calls = event['tool_calls']
|
||||
|
||||
if not tool_calls:
|
||||
# A final answer.
|
||||
content = ''.join(final_answer).strip()
|
||||
await _send_event(response, 'done', dict(content=content or FALLBACK_MESSAGE))
|
||||
return
|
||||
|
||||
# Execute the tool calls, then loop for the model's next step.
|
||||
messages.append(dict(role='assistant', content=''.join(final_answer) or None, tool_calls=[
|
||||
dict(id=i['id'] or f'call_{n}', type='function',
|
||||
function=dict(name=i['name'], arguments=i['arguments']))
|
||||
for n, i in enumerate(tool_calls)
|
||||
]))
|
||||
for n, tool_call in enumerate(tool_calls):
|
||||
name = tool_call['name']
|
||||
try:
|
||||
args = json.loads(tool_call['arguments']) if tool_call['arguments'] else {}
|
||||
except ValueError:
|
||||
args = None
|
||||
await _send_event(response, 'tool_call', dict(tool=name, args=args))
|
||||
if args is None:
|
||||
success, result = False, 'The tool arguments were not valid JSON. Try again.'
|
||||
else:
|
||||
success, result = await agent.execute_tool(mode['tools'], name, args)
|
||||
await _send_event(response, 'tool_result', dict(tool=name, success=success))
|
||||
messages.append(dict(role='tool', tool_call_id=tool_call['id'] or f'call_{n}',
|
||||
content=result))
|
||||
|
||||
# Tool-call budget exhausted without a final answer.
|
||||
await _send_event(response, 'error', dict(message=FALLBACK_MESSAGE))
|
||||
except Exception as e:
|
||||
logger.error('Chat failed', exc_info=e)
|
||||
try:
|
||||
await _send_event(response, 'error', dict(message='The AI failed to answer. Check the logs.'))
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
await response.eof()
|
||||
112
modules/ai/config.py
Normal file
112
modules/ai/config.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""The ai.yaml config — the source of truth for the AI assistant's state.
|
||||
|
||||
AI is opt-in: nothing is downloaded and llama-server does not run until the user enables it on
|
||||
the Manage tab. YAML-only (no database)."""
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from wrolpi.common import ConfigFile
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIConfigValidator:
|
||||
version: int = None
|
||||
enabled: bool = None
|
||||
active_model: str = None
|
||||
idle_unload_minutes: int = None
|
||||
context_size: Optional[int] = None
|
||||
max_tool_calls: int = None
|
||||
temperature: float = None
|
||||
|
||||
|
||||
class AIConfig(ConfigFile):
|
||||
file_name = 'ai.yaml'
|
||||
default_config = dict(
|
||||
version=0,
|
||||
enabled=False,
|
||||
active_model='', # a GGUF file name in <media>/ai/models/
|
||||
idle_unload_minutes=15,
|
||||
context_size=None, # None uses the active model's default from the catalog.
|
||||
max_tool_calls=6,
|
||||
temperature=0.3, # Low: tool-calling small models are far more reliable near 0.2-0.3.
|
||||
)
|
||||
validator = AIConfigValidator
|
||||
|
||||
def import_config(self, file=None, send_events=False):
|
||||
super().import_config(file, send_events)
|
||||
# YAML-only config (no database); a successful read is a successful import.
|
||||
self.successful_import = True
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._config['enabled']
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, value: bool):
|
||||
self.update({'enabled': bool(value)})
|
||||
|
||||
@property
|
||||
def active_model(self) -> str:
|
||||
return self._config['active_model']
|
||||
|
||||
@active_model.setter
|
||||
def active_model(self, value: str):
|
||||
self.update({'active_model': value or ''})
|
||||
|
||||
@property
|
||||
def idle_unload_minutes(self) -> int:
|
||||
return self._config['idle_unload_minutes']
|
||||
|
||||
@idle_unload_minutes.setter
|
||||
def idle_unload_minutes(self, value: int):
|
||||
self.update({'idle_unload_minutes': int(value)})
|
||||
|
||||
@property
|
||||
def context_size(self) -> Optional[int]:
|
||||
return self._config['context_size']
|
||||
|
||||
@context_size.setter
|
||||
def context_size(self, value: Optional[int]):
|
||||
self.update({'context_size': int(value) if value else None})
|
||||
|
||||
@property
|
||||
def max_tool_calls(self) -> int:
|
||||
return self._config['max_tool_calls']
|
||||
|
||||
@max_tool_calls.setter
|
||||
def max_tool_calls(self, value: int):
|
||||
self.update({'max_tool_calls': int(value)})
|
||||
|
||||
@property
|
||||
def temperature(self) -> float:
|
||||
# .get(): configs imported before this field existed lack the key.
|
||||
return self._config.get('temperature', 0.3)
|
||||
|
||||
@temperature.setter
|
||||
def temperature(self, value: float):
|
||||
self.update({'temperature': float(value)})
|
||||
|
||||
|
||||
AI_CONFIG: AIConfig = AIConfig()
|
||||
|
||||
# Test override (see get_ai_config).
|
||||
TEST_AI_CONFIG: Optional[AIConfig] = None
|
||||
|
||||
|
||||
def get_ai_config() -> AIConfig:
|
||||
global TEST_AI_CONFIG
|
||||
if isinstance(TEST_AI_CONFIG, ConfigFile):
|
||||
return TEST_AI_CONFIG
|
||||
return AI_CONFIG
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ai_config_context():
|
||||
"""Used to create a test config."""
|
||||
global TEST_AI_CONFIG
|
||||
TEST_AI_CONFIG = AIConfig()
|
||||
try:
|
||||
yield TEST_AI_CONFIG
|
||||
finally:
|
||||
TEST_AI_CONFIG = None
|
||||
17
modules/ai/conftest.py
Normal file
17
modules/ai/conftest.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# AI module test fixtures. The docs conftest is not star-imported by the root conftest, but the
|
||||
# AI endpoints cover docs; import its fixtures so AI tests can use doc_factory.
|
||||
import pytest
|
||||
|
||||
from modules.ai.config import ai_config_context
|
||||
from modules.docs.conftest import * # noqa
|
||||
from wrolpi.api_utils import api_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_ai_config(test_directory, async_client):
|
||||
"""An isolated ai.yaml config for one test."""
|
||||
(test_directory / 'config').mkdir(exist_ok=True)
|
||||
config_path = test_directory / 'config/ai.yaml'
|
||||
with ai_config_context() as config:
|
||||
config.initialize(api_app.shared_ctx.ai_config)
|
||||
yield config_path
|
||||
91
modules/ai/context.py
Normal file
91
modules/ai/context.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""The library map: a compact, live overview of this WROLPi injected into every chat.
|
||||
|
||||
Small models answer from what is in context. Without this block the model must spend a tool
|
||||
round-trip (minutes on slow hardware) just to learn what channels exist — and it still misread
|
||||
spellings. With it, exact names, counts, and today's date are in front of the model before the
|
||||
first token. Kept to a few hundred tokens: names are capped and the block is cached briefly."""
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from wrolpi.common import logger
|
||||
from wrolpi.dates import now
|
||||
from wrolpi.db import get_db_session
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
# At most this many names per category; a huge library lists the rest as a count.
|
||||
MAX_NAMES = 30
|
||||
CACHE_SECONDS = 60
|
||||
|
||||
_cache = dict(text=None, expires=0.0)
|
||||
|
||||
|
||||
def _capped_names(names: List[str], label: str) -> str:
|
||||
names = [i for i in names if i]
|
||||
if not names:
|
||||
return f'{label}: none'
|
||||
listed = ', '.join(sorted(names)[:MAX_NAMES])
|
||||
extra = f' (and {len(names) - MAX_NAMES} more)' if len(names) > MAX_NAMES else ''
|
||||
return f'{label} ({len(names)}): {listed}{extra}'
|
||||
|
||||
|
||||
def build_library_context() -> str:
|
||||
"""Build the library overview. Every query here must stay cheap (counts and name lists)."""
|
||||
from modules.archive.models import Archive
|
||||
from modules.docs.models import Doc
|
||||
from modules.videos.models import Video
|
||||
from modules.zim.models import Zim
|
||||
from wrolpi.collections.models import Collection
|
||||
|
||||
lines = [f'Today is {now().strftime("%A, %B %d, %Y")}.', "The user's library contains:"]
|
||||
with get_db_session() as session:
|
||||
counts = {model.__name__: session.query(func.count(model.id)).scalar() or 0
|
||||
for model in (Video, Archive, Doc)}
|
||||
lines.append(f'- {counts["Video"]} videos, {counts["Archive"]} archived web pages,'
|
||||
f' {counts["Doc"]} documents/ebooks.')
|
||||
|
||||
collections = session.query(Collection.kind, Collection.name).all()
|
||||
channels = [name for kind, name in collections if kind == 'channel']
|
||||
domains = [name for kind, name in collections if kind == 'domain']
|
||||
lines.append(f'- {_capped_names(channels, "Video channels")}')
|
||||
lines.append(f'- {_capped_names(domains, "Archived websites")}')
|
||||
|
||||
zims = session.query(Zim).all()
|
||||
zim_titles = []
|
||||
for zim in zims:
|
||||
try:
|
||||
zim_titles.append(zim.zim_metadata.title)
|
||||
except Exception:
|
||||
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()]
|
||||
lines.append(f'- {_capped_names(inventories, "Inventories")}')
|
||||
except Exception as e:
|
||||
logger.debug('Library context could not list inventories', exc_info=e)
|
||||
|
||||
lines.append('Use the exact names above; user spellings may differ.')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def get_library_context() -> Optional[str]:
|
||||
"""The cached library overview; None when it cannot be built (chat proceeds without it)."""
|
||||
if _cache['text'] is not None and time.time() < _cache['expires']:
|
||||
return _cache['text']
|
||||
try:
|
||||
text = build_library_context()
|
||||
except Exception as e:
|
||||
logger.warning('Could not build the library context', exc_info=e)
|
||||
return _cache['text']
|
||||
_cache.update(text=text, expires=time.time() + CACHE_SECONDS)
|
||||
return text
|
||||
37
modules/ai/controller_client.py
Normal file
37
modules/ai/controller_client.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""One helper for API -> Controller requests.
|
||||
|
||||
Existing call sites disagree on the base URL (wrolpi/common.py uses http://127.0.0.1, the zim
|
||||
module uses the docker service name); this module resolves it once: the docker service name when
|
||||
DOCKERIZED, else localhost, overridable with the CONTROLLER_URL environment variable.
|
||||
"""
|
||||
import os
|
||||
from typing import Tuple
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from wrolpi.common import aiohttp_get, aiohttp_post, logger
|
||||
from wrolpi.vars import DOCKERIZED
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
CONTROLLER_URL = os.environ.get('CONTROLLER_URL') or ('http://controller' if DOCKERIZED else 'http://127.0.0.1')
|
||||
|
||||
|
||||
async def controller_get(path: str, params: dict = None, timeout: int = 10) -> Tuple[int, dict]:
|
||||
"""GET a Controller endpoint, returning (status, JSON body). Raises on connection errors."""
|
||||
url = f'{CONTROLLER_URL}{path}'
|
||||
if params:
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
if params:
|
||||
url = f'{url}?{urlencode(params)}'
|
||||
async with aiohttp_get(url, timeout=timeout) as response:
|
||||
return response.status, await response.json()
|
||||
|
||||
|
||||
async def controller_post(path: str, json_: dict = None, timeout: int = 30) -> Tuple[int, dict]:
|
||||
"""POST a Controller endpoint, returning (status, JSON body). Raises on connection errors."""
|
||||
async with aiohttp_post(f'{CONTROLLER_URL}{path}', json_=json_ or {}, timeout=timeout) as response:
|
||||
try:
|
||||
body = await response.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
return response.status, body
|
||||
120
modules/ai/downloader.py
Normal file
120
modules/ai/downloader.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Downloads GGUF model files from the WROLPi CDN into <media>/ai/models/.
|
||||
|
||||
Follows the map-extract pattern: the per-file .meta4 sidecar is fetched once and its hash
|
||||
signature GPG-verified, then the same bytes are handed to aria2c so the hash that was verified
|
||||
is the hash that is enforced. Downloads are resumable and respect the media_mounted rules via
|
||||
the download manager."""
|
||||
import pathlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from modules.ai.catalog import get_models_directory
|
||||
from modules.map.downloader import verify_meta4_signature
|
||||
from wrolpi.common import logger
|
||||
from wrolpi.downloader import Download, DownloadContext, Downloader, DownloadResult
|
||||
from wrolpi.events import Events
|
||||
|
||||
__all__ = ['ModelDownloader', 'PreparedModel', 'ExecutedModel', 'ai_model_downloader']
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreparedModel:
|
||||
"""Plan produced by ModelDownloader.prepare_download."""
|
||||
name: str
|
||||
models_directory: pathlib.Path
|
||||
output_path: pathlib.Path
|
||||
tmp_path: pathlib.Path
|
||||
already_done: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutedModel:
|
||||
"""Output of ModelDownloader.execute_download."""
|
||||
name: str
|
||||
output_path: pathlib.Path
|
||||
skipped: bool = False
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ModelDownloader(Downloader):
|
||||
"""Downloads one GGUF model file via aria2c with GPG-verified hash checking."""
|
||||
|
||||
name = 'ai_model'
|
||||
listable = False
|
||||
|
||||
def prepare_download(self, session: Session, download: Download) -> PreparedModel:
|
||||
name = download.url.rstrip('/').rsplit('/', 1)[-1]
|
||||
models_directory = get_models_directory()
|
||||
output_path = models_directory / name
|
||||
tmp_path = models_directory / f'{name}.tmp'
|
||||
|
||||
if not name.endswith('.gguf'):
|
||||
return PreparedModel(name=name, models_directory=models_directory, output_path=output_path,
|
||||
tmp_path=tmp_path, error=f'Not a GGUF model URL: {download.url}')
|
||||
|
||||
prepared = PreparedModel(name=name, models_directory=models_directory, output_path=output_path,
|
||||
tmp_path=tmp_path)
|
||||
if output_path.is_file():
|
||||
logger.info(f'{name} already exists, skipping download')
|
||||
prepared.already_done = True
|
||||
return prepared
|
||||
|
||||
models_directory.mkdir(parents=True, exist_ok=True)
|
||||
# Clean up any leftover temp file; aria2c resumes its own .aria2 checkpoints.
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return prepared
|
||||
|
||||
async def execute_download(self, prepared: PreparedModel, ctx: DownloadContext,
|
||||
download: Download = None) -> ExecutedModel:
|
||||
if prepared.error:
|
||||
return ExecutedModel(name=prepared.name, output_path=prepared.output_path, error=prepared.error)
|
||||
if prepared.already_done:
|
||||
return ExecutedModel(name=prepared.name, output_path=prepared.output_path, skipped=True)
|
||||
|
||||
url = download.url
|
||||
|
||||
# Fetch the meta4 once, GPG-verify the hash signature before downloading.
|
||||
meta4_contents = await self.get_meta4_contents(url)
|
||||
if not meta4_contents:
|
||||
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
|
||||
error=f'No meta4 available for {prepared.name}; refusing an unverified download')
|
||||
if not await verify_meta4_signature(meta4_contents):
|
||||
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
|
||||
error=f'meta4 hash signature verification failed for {prepared.name}')
|
||||
logger.info(f'meta4 signature verified for {prepared.name}')
|
||||
|
||||
downloaded_path = None
|
||||
try:
|
||||
downloaded_path = await self.download_file(
|
||||
download, url, prepared.models_directory,
|
||||
check_for_meta4=False, concurrent=1, meta4_xml=meta4_contents,
|
||||
ctx=ctx,
|
||||
)
|
||||
downloaded_path.rename(prepared.tmp_path)
|
||||
except Exception as e:
|
||||
logger.error(f'Model download failed for {prepared.name}: {e}')
|
||||
prepared.tmp_path.unlink(missing_ok=True)
|
||||
if downloaded_path:
|
||||
downloaded_path.unlink(missing_ok=True)
|
||||
return ExecutedModel(name=prepared.name, output_path=prepared.output_path,
|
||||
error=f'Download failed: {str(e)[:500]}')
|
||||
|
||||
# Replace atomically.
|
||||
prepared.tmp_path.rename(prepared.output_path)
|
||||
return ExecutedModel(name=prepared.name, output_path=prepared.output_path)
|
||||
|
||||
def finalize_download(self, session: Session, download: Download, executed: ExecutedModel) -> DownloadResult:
|
||||
if executed.error:
|
||||
return DownloadResult(success=False, error=executed.error)
|
||||
if not executed.skipped:
|
||||
Events.send_user_notify(f'AI model downloaded: {executed.name}', url='/ai/manage')
|
||||
return DownloadResult(success=True, location='/ai/manage')
|
||||
|
||||
|
||||
# Instantiating registers with the download manager.
|
||||
ai_model_downloader = ModelDownloader()
|
||||
9
modules/ai/errors.py
Normal file
9
modules/ai/errors.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from http import HTTPStatus
|
||||
|
||||
from wrolpi.errors import APIError
|
||||
|
||||
|
||||
class ControllerUnavailable(APIError):
|
||||
code = 'CONTROLLER_UNAVAILABLE'
|
||||
summary = 'The Controller did not answer, or answered with an error.'
|
||||
status_code = HTTPStatus.BAD_GATEWAY
|
||||
152
modules/ai/help_docs.py
Normal file
152
modules/ai/help_docs.py
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
"""In-memory search over the WROLPi help markdown.
|
||||
|
||||
The help docs are a small corpus (dozens of markdown files) served by mkdocs from the wrolpi-help
|
||||
repository. They live outside the media directory and get no FileGroups, so they are searched
|
||||
in-process: files are parsed on demand and cached by mtime. No database, no index to build.
|
||||
"""
|
||||
import dataclasses
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from wrolpi.common import logger
|
||||
from wrolpi.vars import PROJECT_DIR
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
# Native installs clone wrolpi-help to /opt/wrolpi-help; development uses the git submodule.
|
||||
# The docker api container gets a read-only bind mount at the native location.
|
||||
DEFAULT_HELP_DIRS = (
|
||||
pathlib.Path('/opt/wrolpi-help/docs'),
|
||||
PROJECT_DIR / 'docker/help/wrolpi-help/docs',
|
||||
)
|
||||
|
||||
SNIPPET_LENGTH = 200
|
||||
|
||||
|
||||
def get_help_docs_directory() -> Optional[pathlib.Path]:
|
||||
"""The directory containing the help markdown, or None when help is not installed."""
|
||||
if directory := os.environ.get('HELP_DOCS_DIR'):
|
||||
directory = pathlib.Path(directory)
|
||||
return directory if directory.is_dir() else None
|
||||
for directory in DEFAULT_HELP_DIRS:
|
||||
if directory.is_dir():
|
||||
return directory
|
||||
return None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class HelpDoc:
|
||||
slug: str # relative path without .md, e.g. "system/logs"
|
||||
title: str
|
||||
headings: List[str]
|
||||
body: str
|
||||
mtime: float
|
||||
|
||||
|
||||
# slug -> HelpDoc, invalidated per-file by mtime.
|
||||
_CACHE: Dict[str, HelpDoc] = {}
|
||||
|
||||
|
||||
def _parse_doc(slug: str, path: pathlib.Path) -> HelpDoc:
|
||||
body = path.read_text(errors='replace')
|
||||
title = None
|
||||
headings = []
|
||||
for line in body.splitlines():
|
||||
if match := re.match(r'^(#{1,6})\s+(.+)', line):
|
||||
heading = match.group(2).strip()
|
||||
headings.append(heading)
|
||||
if title is None and match.group(1) == '#':
|
||||
title = heading
|
||||
return HelpDoc(slug=slug, title=title or slug, headings=headings, body=body, mtime=path.stat().st_mtime)
|
||||
|
||||
|
||||
def load_help_docs() -> List[HelpDoc]:
|
||||
"""Load every help markdown file, reusing cached parses of unchanged files."""
|
||||
directory = get_help_docs_directory()
|
||||
if not directory or not directory.is_dir():
|
||||
return []
|
||||
|
||||
docs = []
|
||||
seen = set()
|
||||
for path in sorted(directory.rglob('*.md')):
|
||||
slug = str(path.relative_to(directory)).removesuffix('.md')
|
||||
seen.add(slug)
|
||||
try:
|
||||
cached = _CACHE.get(slug)
|
||||
if not cached or cached.mtime != path.stat().st_mtime:
|
||||
_CACHE[slug] = _parse_doc(slug, path)
|
||||
docs.append(_CACHE[slug])
|
||||
except OSError as e:
|
||||
logger.warning(f'Could not read help doc {path}', exc_info=e)
|
||||
# Drop deleted files from the cache.
|
||||
for slug in set(_CACHE) - seen:
|
||||
del _CACHE[slug]
|
||||
return docs
|
||||
|
||||
|
||||
def help_doc_link(slug: str) -> str:
|
||||
"""The mkdocs URL path of a doc; the help server (port 8086) serves index.md at /."""
|
||||
if slug == 'index':
|
||||
return '/'
|
||||
slug = slug.removesuffix('/index')
|
||||
return f'/{slug}/'
|
||||
|
||||
|
||||
def _tokenize(text: str) -> List[str]:
|
||||
return [i for i in re.split(r'\W+', text.lower()) if len(i) >= 2]
|
||||
|
||||
|
||||
def _snippet(body: str, tokens: List[str]) -> Optional[str]:
|
||||
"""A little context around the first token match in the body."""
|
||||
lower = body.lower()
|
||||
position = min((p for token in tokens if (p := lower.find(token)) >= 0), default=-1)
|
||||
if position < 0:
|
||||
return None
|
||||
start = max(position - SNIPPET_LENGTH // 4, 0)
|
||||
snippet = body[start:start + SNIPPET_LENGTH]
|
||||
snippet = re.sub(r'\s+', ' ', snippet).strip()
|
||||
return f'…{snippet}…'
|
||||
|
||||
|
||||
def search_help(search_str: str, limit: int = 5) -> (List[dict], int):
|
||||
"""Score every help doc against the query; title matches beat heading matches beat body matches."""
|
||||
tokens = _tokenize(search_str or '')
|
||||
if not tokens:
|
||||
return []
|
||||
|
||||
scored = []
|
||||
for doc in load_help_docs():
|
||||
title = doc.title.lower()
|
||||
headings = ' '.join(doc.headings).lower()
|
||||
body = doc.body.lower()
|
||||
score = 0
|
||||
for token in tokens:
|
||||
score += 10 * title.count(token)
|
||||
score += 4 * headings.count(token)
|
||||
score += body.count(token)
|
||||
if score:
|
||||
scored.append((score, doc))
|
||||
|
||||
scored.sort(key=lambda i: (-i[0], i[1].slug))
|
||||
results = []
|
||||
for score, doc in scored[:limit]:
|
||||
result = dict(
|
||||
slug=doc.slug,
|
||||
title=doc.title,
|
||||
link=help_doc_link(doc.slug),
|
||||
snippet=_snippet(doc.body, tokens),
|
||||
)
|
||||
results.append({k: v for k, v in result.items() if v is not None})
|
||||
return results, len(scored)
|
||||
|
||||
|
||||
def get_help_doc(slug: str) -> Optional[HelpDoc]:
|
||||
"""Fetch one help doc by its slug. Returns None when it (or the help directory) is missing."""
|
||||
if '..' in slug.split('/'):
|
||||
return None
|
||||
for doc in load_help_docs():
|
||||
if doc.slug == slug:
|
||||
return doc
|
||||
return None
|
||||
250
modules/ai/lib.py
Normal file
250
modules/ai/lib.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""Formatting helpers for the /api/ai blueprint — the LLM tool catalog.
|
||||
|
||||
Results are lean, flat JSON with server-computed links so the model never constructs URLs.
|
||||
Links are relative to the WROLPi host (e.g. /videos/123); the caller (frontend, MCP client)
|
||||
prefixes its own base URL.
|
||||
"""
|
||||
import pathlib
|
||||
from typing import Optional
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from wrolpi.common import get_relative_to_media_directory, logger
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
# Big content (captions, archive text, zim entries, help docs) is paged in chunks of this many
|
||||
# characters, never silently truncated. Sized for small (8k-token) model contexts.
|
||||
PAGE_SIZE = 4_000
|
||||
# Description length in a detail response; descriptions in search listings are shorter.
|
||||
DETAIL_DESCRIPTION_LENGTH = 1_000
|
||||
LISTING_DESCRIPTION_LENGTH = 300
|
||||
|
||||
|
||||
def _relative_path(path) -> Optional[str]:
|
||||
"""Return the media-relative string of a path from a FileGroup JSON dict, if possible."""
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return str(get_relative_to_media_directory(path))
|
||||
except Exception:
|
||||
return str(path)
|
||||
|
||||
|
||||
def file_group_kind(fg: dict) -> str:
|
||||
"""Categorize a FileGroup JSON dict for the model: video/archive/doc/image/file."""
|
||||
mimetype = fg.get('mimetype') or ''
|
||||
if fg.get('model') == 'video' or isinstance(fg.get('video'), dict):
|
||||
return 'video'
|
||||
if fg.get('model') == 'archive' or isinstance(fg.get('archive'), dict):
|
||||
return 'archive'
|
||||
if fg.get('model') == 'doc':
|
||||
return 'doc'
|
||||
if mimetype.startswith('image/'):
|
||||
return 'image'
|
||||
return 'file'
|
||||
|
||||
|
||||
def wrolpi_link(fg: dict) -> Optional[str]:
|
||||
"""Build the relative WROLPi UI link for a FileGroup JSON dict.
|
||||
|
||||
Videos/docs/archives deep-link by FileGroup ID; epubs open in the reader; everything else is
|
||||
served directly from /media/ or /download/.
|
||||
"""
|
||||
fg_id = fg.get('id')
|
||||
kind = file_group_kind(fg)
|
||||
if kind == 'video' and fg_id:
|
||||
return f'/videos/{fg_id}'
|
||||
if kind == 'doc' and fg_id:
|
||||
return f'/docs/{fg_id}'
|
||||
if kind == 'archive' and fg_id:
|
||||
return f'/archives/{fg_id}'
|
||||
|
||||
mimetype = fg.get('mimetype') or ''
|
||||
primary_path = _relative_path(fg.get('primary_path'))
|
||||
if not primary_path:
|
||||
return None
|
||||
if mimetype.startswith('application/epub'):
|
||||
return f'/epub/epub.html?url=/download/{primary_path}'
|
||||
if mimetype.startswith('application/x-mobipocket-ebook'):
|
||||
return f'/download/{primary_path}'
|
||||
# Caddy serves any media file at /media/{path}.
|
||||
return f'/media/{primary_path}'
|
||||
|
||||
|
||||
def _truncate(text: Optional[str], length: int) -> Optional[str]:
|
||||
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, detail: bool = False) -> dict:
|
||||
"""Format a FileGroup JSON dict into the lean, flat shape the model consumes.
|
||||
|
||||
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(
|
||||
id=fg.get('id'),
|
||||
kind=kind,
|
||||
title=fg.get('title') or fg.get('name'),
|
||||
link=wrolpi_link(fg),
|
||||
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')
|
||||
if headline:
|
||||
result['headline'] = headline
|
||||
|
||||
video = fg.get('video')
|
||||
if kind == 'video':
|
||||
result['duration'] = format_duration(fg.get('length'))
|
||||
if isinstance(video, dict):
|
||||
channel = video.get('channel')
|
||||
if isinstance(channel, dict):
|
||||
result['channel'] = channel.get('name') or channel.get('id')
|
||||
elif video.get('channel_id'):
|
||||
result['channel_id'] = video['channel_id']
|
||||
result['description'] = _truncate(video.get('description'), description_length)
|
||||
|
||||
if kind == 'doc':
|
||||
doc = fg.get('doc') or {}
|
||||
result['subject'] = doc.get('subject')
|
||||
result['language'] = doc.get('language')
|
||||
result['pages'] = doc.get('page_count')
|
||||
result['description'] = _truncate(doc.get('description'), description_length)
|
||||
|
||||
return {k: v for k, v in result.items() if v is not None}
|
||||
|
||||
|
||||
def format_file_groups(file_groups: list, total: int, searched: bool = False) -> dict:
|
||||
"""Format search results: lean items plus the total so the model narrows instead of paging."""
|
||||
result = dict(results=[format_file_group(i) for i in file_groups], total=total)
|
||||
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, use the exact channel/website names from the library list, or omit'
|
||||
' search_str to browse the newest items.')
|
||||
return result
|
||||
|
||||
|
||||
def paginate_text(text: Optional[str], offset: int = 0) -> dict:
|
||||
"""Page big content: ~PAGE_SIZE chars plus next_offset/total_chars, never silent truncation."""
|
||||
text = text or ''
|
||||
offset = max(int(offset or 0), 0)
|
||||
end = offset + PAGE_SIZE
|
||||
next_offset = end if end < len(text) else None
|
||||
return dict(content=text[offset:end], next_offset=next_offset, total_chars=len(text))
|
||||
|
||||
|
||||
def html_to_text(html: str) -> str:
|
||||
"""Strip HTML down to clean plain text for the model. Pagination handles size."""
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
for tag in soup(['script', 'style', 'nav', 'footer', 'header']):
|
||||
tag.decompose()
|
||||
|
||||
text = soup.get_text(separator='\n', strip=True)
|
||||
|
||||
# Collapse multiple blank lines.
|
||||
lines = []
|
||||
prev_blank = False
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
if not prev_blank:
|
||||
lines.append('')
|
||||
prev_blank = True
|
||||
else:
|
||||
lines.append(stripped)
|
||||
prev_blank = False
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def format_caption_chunks(chunks: Optional[list]) -> str:
|
||||
"""Render caption chunks ({start_seconds, text}) as timestamped lines."""
|
||||
if not chunks:
|
||||
return ''
|
||||
lines = []
|
||||
for chunk in chunks:
|
||||
minutes, seconds = divmod(int(chunk.get('start_seconds') or 0), 60)
|
||||
hours, minutes = divmod(minutes, 60)
|
||||
lines.append(f'[{hours:02d}:{minutes:02d}:{seconds:02d}] {chunk.get("text") or ""}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def zim_entry_link(zim_id: int, path: str) -> str:
|
||||
return f'/api/zim/{zim_id}/entry/{path}'
|
||||
|
||||
|
||||
def zim_metadata_dict(metadata) -> dict:
|
||||
"""Zim lib functions return a ZimMetadata object; normalize it to a dict."""
|
||||
if metadata is None:
|
||||
return {}
|
||||
return metadata if isinstance(metadata, dict) else metadata.__json__()
|
||||
|
||||
|
||||
def format_zim_search(zim_results: list) -> dict:
|
||||
"""Flatten per-Zim search results into lean entries with links."""
|
||||
results = []
|
||||
total = 0
|
||||
for zim_result in zim_results:
|
||||
metadata = zim_metadata_dict(zim_result.get('metadata'))
|
||||
zim_title = metadata.get('title')
|
||||
total += zim_result.get('estimate') or 0
|
||||
for entry in zim_result.get('search') or []:
|
||||
zim_id = entry.get('zim_id')
|
||||
path = entry.get('path')
|
||||
result = dict(
|
||||
zim_id=zim_id,
|
||||
zim=zim_title,
|
||||
path=path,
|
||||
title=entry.get('title'),
|
||||
headline=entry.get('headline'),
|
||||
link=zim_entry_link(zim_id, path) if zim_id and path else None,
|
||||
)
|
||||
results.append({k: v for k, v in result.items() if v is not None})
|
||||
return dict(results=results, total=total)
|
||||
|
||||
|
||||
def read_archive_text(archive) -> Optional[str]:
|
||||
"""Return the readable plain text of an Archive: readability text, else stripped singlefile."""
|
||||
if (path := archive.readability_txt_path) and pathlib.Path(path).is_file():
|
||||
return pathlib.Path(path).read_text(errors='replace')
|
||||
if (path := archive.singlefile_path) and pathlib.Path(path).is_file():
|
||||
return html_to_text(pathlib.Path(path).read_text(errors='replace'))
|
||||
return None
|
||||
144
modules/ai/modes.py
Normal file
144
modules/ai/modes.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""Chat modes: a mode is a system prompt plus a tool allowlist, enforced server-side in the agent
|
||||
loop — the model cannot opt out. Tool names are the /api/ai blueprint's operationIds."""
|
||||
|
||||
COMMON_PROMPT = (
|
||||
'You are the WROLPi assistant, running locally on the user\'s own offline WROLPi server.'
|
||||
' WROLPi is an off-grid digital library: videos, archived web pages, ebooks, Zim encyclopedias,'
|
||||
' maps, and inventories. You have no internet access.'
|
||||
' Answer briefly and plainly. Use the provided tools to find real information before answering;'
|
||||
' never invent library content or system state.'
|
||||
)
|
||||
|
||||
MODES = {
|
||||
'help': dict(
|
||||
system_prompt=(
|
||||
f'{COMMON_PROMPT}'
|
||||
' Your job is to teach the user how to use WROLPi.'
|
||||
' You do NOT know how WROLPi works from memory: before answering ANY question, you MUST'
|
||||
' first call search_help, then read the matching page with get_help_doc, and answer only'
|
||||
' from what the docs actually say. Always include the link of every help page you used.'
|
||||
' If the docs do not cover the question, say so.'
|
||||
),
|
||||
tools=(
|
||||
'search_help',
|
||||
'get_help_doc',
|
||||
'get_system_status',
|
||||
),
|
||||
),
|
||||
'research': dict(
|
||||
system_prompt=(
|
||||
f'{COMMON_PROMPT}'
|
||||
' Your job is to be the librarian: find and summarize content from the user\'s library.'
|
||||
' 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_files',
|
||||
'get_file',
|
||||
'read_content',
|
||||
'search_zims',
|
||||
'get_zim_entry',
|
||||
'get_inventory',
|
||||
'list_files',
|
||||
'read_file',
|
||||
'search_places',
|
||||
),
|
||||
),
|
||||
'system': dict(
|
||||
system_prompt=(
|
||||
f'{COMMON_PROMPT}'
|
||||
' Your job is to troubleshoot the user\'s WROLPi.'
|
||||
' You never know the current state of this system from memory or from example'
|
||||
' conversations: EVERY answer about services, disks, or health starts with a fresh'
|
||||
' get_system_status call. Read a failing service\'s logs with get_service_logs;'
|
||||
' check drives with list_disks.'
|
||||
' You CANNOT execute commands or change anything — you can only read.'
|
||||
' Only suggest commands that appear in the help documentation: find them with search_help,'
|
||||
' quote them exactly, and include the help page link.'
|
||||
),
|
||||
tools=(
|
||||
'get_system_status',
|
||||
'list_services',
|
||||
'get_service_logs',
|
||||
'list_disks',
|
||||
'search_help',
|
||||
'get_help_doc',
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _example(*messages) -> list:
|
||||
"""A compact worked exchange (user -> tool call -> tool result -> answer) prepended to every
|
||||
conversation. Small models imitate far better than they obey; one example per mode is what
|
||||
stops them answering from memory or quitting after the first tool call."""
|
||||
return list(messages)
|
||||
|
||||
|
||||
def _tool_call(call_id: str, name: str, arguments: str) -> dict:
|
||||
return dict(role='assistant', content=None, tool_calls=[
|
||||
dict(id=call_id, type='function', function=dict(name=name, arguments=arguments))])
|
||||
|
||||
|
||||
def _tool_result(call_id: str, content: str) -> dict:
|
||||
return dict(role='tool', tool_call_id=call_id, content=content)
|
||||
|
||||
|
||||
MODE_EXAMPLES = {
|
||||
# The example's content must be TRUE (from the real help docs): when a user asks about the
|
||||
# example's topic, a small model may answer straight from it without searching.
|
||||
'help': _example(
|
||||
dict(role='user', content='Why does my browser warn about the certificate?'),
|
||||
_tool_call('ex_help_1', 'search_help', '{"search_str": "certificate"}'),
|
||||
_tool_result('ex_help_1',
|
||||
'{"results": [{"slug": "system/certificates", "title": "HTTPS Certificates",'
|
||||
' "link": "/system/certificates/",'
|
||||
' "snippet": "…WROLPi generates its own Root CA certificate…"}], "total": 1}'),
|
||||
_tool_call('ex_help_2', 'get_help_doc', '{"slug": "system/certificates"}'),
|
||||
_tool_result('ex_help_2',
|
||||
'{"content": "# HTTPS Certificates\\nWROLPi is offline, so it cannot use a public'
|
||||
' certificate authority. It generates its own Root CA certificate which you install'
|
||||
' once on each device that connects to it.", "next_offset": null, "total_chars": 205}'),
|
||||
dict(role='assistant', content='WROLPi is offline, so it cannot use a public certificate'
|
||||
' authority; it signs its own certificate with a Root CA that you'
|
||||
' install once on each device. The HTTPS Certificates help page'
|
||||
' explains how: /system/certificates/'),
|
||||
),
|
||||
'research': _example(
|
||||
dict(role='user', content='Summarize the bakehouse channel'),
|
||||
_tool_call('ex_res_1', 'search_files', '{"channel": "Bakehouse"}'),
|
||||
_tool_result('ex_res_1',
|
||||
'{"results": [{"id": 12, "kind": "video", "title": "Sourdough Basics",'
|
||||
' "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.'),
|
||||
),
|
||||
# No invented failures in the example: the model carries them into real diagnoses (it kept
|
||||
# investigating a fictional failed service the first version of this example described).
|
||||
'system': _example(
|
||||
dict(role='user', content='Is everything running?'),
|
||||
_tool_call('ex_sys_1', 'get_system_status', '{}'),
|
||||
_tool_result('ex_sys_1',
|
||||
'{"version": "0.28", "wrol_mode": false, "services": [{"name": "wrolpi-api",'
|
||||
' "status": "running"}, {"name": "wrolpi-app", "status": "running"}], "errors": []}'),
|
||||
dict(role='assistant', content='The status shows both services (wrolpi-api, wrolpi-app)'
|
||||
' running on version 0.28, with no errors reported.'),
|
||||
),
|
||||
}
|
||||
|
||||
# Suggestion chips shown by the Chat tab; small models do much better when the opening message
|
||||
# matches what the mode's prompt was tuned for.
|
||||
MODE_SUGGESTIONS = {
|
||||
'help': ['How do I download videos?', 'How do I add my own files?', 'What are Tags?'],
|
||||
'research': ['Find videos about canning', 'What do I have about gardening?', 'Summarize my newest archives'],
|
||||
'system': ['Why is my drive full?', 'Is everything running?', 'Why do downloads fail?'],
|
||||
}
|
||||
59
modules/ai/schema.py
Normal file
59
modules/ai/schema.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import dataclasses
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AIZimSearchRequest:
|
||||
search_str: Optional[str] = None
|
||||
zim_id: Optional[int] = None
|
||||
limit: int = 5
|
||||
offset: int = 0
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AIHelpSearchRequest:
|
||||
search_str: Optional[str] = None
|
||||
limit: int = 5
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AIManageSettingsRequest:
|
||||
enabled: Optional[bool] = None
|
||||
active_model: Optional[str] = None
|
||||
idle_unload_minutes: Optional[int] = None
|
||||
context_size: Optional[int] = None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AISearchResponse:
|
||||
results: list = dataclasses.field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AIPagedTextResponse:
|
||||
content: str = ''
|
||||
next_offset: Optional[int] = None
|
||||
total_chars: int = 0
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AIListFilesResponse:
|
||||
path: str = ''
|
||||
directories: list = dataclasses.field(default_factory=list)
|
||||
files: list = dataclasses.field(default_factory=list)
|
||||
total: int = 0
|
||||
next_offset: Optional[int] = None
|
||||
|
||||
|
||||
@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
|
||||
112
modules/ai/service.py
Normal file
112
modules/ai/service.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""llama-server lifecycle: on-demand start and idle unload.
|
||||
|
||||
The API owns the policy (this module); the Controller executes the mechanism through its
|
||||
existing service start/stop endpoints — it runs as root natively and manages the `llm`
|
||||
container in docker, so no sudoers changes are needed.
|
||||
|
||||
Idle unload is mandatory on small devices: a swapped-to-death Pi is worse than a slow first
|
||||
token. The chat handler records activity; the perpetual worker stops llama-server after the
|
||||
configured idle minutes."""
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
|
||||
from modules.ai.config import get_ai_config
|
||||
from modules.ai.controller_client import controller_post
|
||||
from wrolpi.api_utils import api_app, perpetual_signal
|
||||
from wrolpi.common import aiohttp_get, logger
|
||||
from wrolpi.vars import DOCKERIZED
|
||||
|
||||
logger = logger.getChild(__name__)
|
||||
|
||||
LLAMA_URL = os.environ.get('LLAMA_URL', 'http://127.0.0.1:11435')
|
||||
# The Controller's name for the service: the systemd unit natively, the compose service in docker.
|
||||
LLM_SERVICE_NAME = 'llm' if DOCKERIZED else 'wrolpi-llm'
|
||||
|
||||
# How long to wait for llama-server to answer /health after a cold start (model load included).
|
||||
STARTUP_TIMEOUT_SECONDS = 90
|
||||
|
||||
|
||||
def record_llm_activity():
|
||||
"""Note that the LLM was just used; postpones the idle unload."""
|
||||
try:
|
||||
api_app.shared_ctx.ai_llm_last_request.value = time.time()
|
||||
except AttributeError:
|
||||
# Shared contexts are absent in some unit tests.
|
||||
pass
|
||||
|
||||
|
||||
def get_last_llm_activity() -> float:
|
||||
try:
|
||||
return api_app.shared_ctx.ai_llm_last_request.value
|
||||
except AttributeError:
|
||||
return 0.0
|
||||
|
||||
|
||||
async def llama_healthy() -> bool:
|
||||
try:
|
||||
async with aiohttp_get(f'{LLAMA_URL}/health', timeout=5) as response:
|
||||
return response.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def start_llm_service() -> bool:
|
||||
status, body = await controller_post(f'/api/services/{LLM_SERVICE_NAME}/start')
|
||||
if status != 200:
|
||||
logger.error(f'Controller failed to start {LLM_SERVICE_NAME}: HTTP {status} {body}')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def stop_llm_service() -> bool:
|
||||
status, body = await controller_post(f'/api/services/{LLM_SERVICE_NAME}/stop')
|
||||
if status != 200:
|
||||
logger.error(f'Controller failed to stop {LLM_SERVICE_NAME}: HTTP {status} {body}')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def ensure_llama_running(timeout: int = STARTUP_TIMEOUT_SECONDS) -> bool:
|
||||
"""Start llama-server on demand and wait for it to answer /health.
|
||||
|
||||
Returns True when llama-server is up. Cold-start latency (service start + model load) is
|
||||
expected and acceptable."""
|
||||
if await llama_healthy():
|
||||
record_llm_activity()
|
||||
return True
|
||||
|
||||
logger.info(f'Asking the Controller to start {LLM_SERVICE_NAME}')
|
||||
if not await start_llm_service():
|
||||
return False
|
||||
|
||||
started = time.time()
|
||||
while time.time() - started < timeout:
|
||||
if await llama_healthy():
|
||||
record_llm_activity()
|
||||
return True
|
||||
await asyncio.sleep(1)
|
||||
logger.error(f'llama-server did not become healthy within {timeout}s')
|
||||
return False
|
||||
|
||||
|
||||
@perpetual_signal(sleep=60)
|
||||
async def perpetual_llm_idle_unload():
|
||||
"""Stop llama-server after the configured idle minutes. Only acts after it was used."""
|
||||
try:
|
||||
last_activity = get_last_llm_activity()
|
||||
if not last_activity:
|
||||
return
|
||||
config = get_ai_config()
|
||||
idle_seconds = config.idle_unload_minutes * 60
|
||||
if time.time() - last_activity < idle_seconds:
|
||||
return
|
||||
if not await llama_healthy():
|
||||
# Already stopped (or crashed); nothing to unload.
|
||||
api_app.shared_ctx.ai_llm_last_request.value = 0.0
|
||||
return
|
||||
logger.info(f'Stopping {LLM_SERVICE_NAME} after {config.idle_unload_minutes} idle minutes')
|
||||
if await stop_llm_service():
|
||||
api_app.shared_ctx.ai_llm_last_request.value = 0.0
|
||||
except Exception as e:
|
||||
logger.error('LLM idle unload worker failed', exc_info=e)
|
||||
0
modules/ai/test/__init__.py
Normal file
0
modules/ai/test/__init__.py
Normal file
135
modules/ai/test/test_agent.py
Normal file
135
modules/ai/test/test_agent.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"""Tests for tool-definition generation and tool execution."""
|
||||
import json
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.ai import agent
|
||||
from modules.ai.modes import MODES
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def tool_definitions(async_client):
|
||||
# Any request finalizes the router, which the spec builder requires.
|
||||
await async_client.get('/api/ai/zims')
|
||||
return agent.get_ai_tool_definitions(refresh=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_definitions_from_spec(tool_definitions):
|
||||
"""Tool definitions are generated from the blueprint's own OpenAPI operations."""
|
||||
tools = tool_definitions
|
||||
# Every tool any mode allowlists must exist — this guards allowlists against drift.
|
||||
for mode_name, mode in MODES.items():
|
||||
for tool_name in mode['tools']:
|
||||
assert tool_name in tools, f'Mode {mode_name} allowlists missing tool {tool_name}'
|
||||
|
||||
# Every tool has a model-facing description.
|
||||
for name, tool in tools.items():
|
||||
assert tool.description, f'Tool {name} has no description'
|
||||
|
||||
# Manage endpoints are never tools.
|
||||
assert not any(t.path.startswith('/api/ai/manage') for t in tools.values())
|
||||
assert 'manage_catalog' not in tools and 'manage_settings' not in tools
|
||||
# The chat endpoint itself is not a tool.
|
||||
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_files'].parameters_schema()
|
||||
assert schema['properties']['search_str'] == {'type': 'string'}
|
||||
assert schema['properties']['tag_names'] == {'type': 'array', 'items': {'type': 'string'}}
|
||||
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_files'].as_openai_tool()
|
||||
assert openai_tool['type'] == 'function'
|
||||
assert openai_tool['function']['name'] == 'search_files'
|
||||
|
||||
|
||||
def _endpoint_shim(async_client):
|
||||
async def shim(method, path, query, body):
|
||||
url = f'{path}?{urlencode(query)}' if query else path
|
||||
if method == 'post':
|
||||
request, response = await async_client.post(url, content=json.dumps(body or {}))
|
||||
else:
|
||||
request, response = await async_client.get(url)
|
||||
return response.status_code, response.json
|
||||
|
||||
return mock.patch('modules.ai.agent.call_ai_endpoint', side_effect=shim)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_tool(async_client, tool_definitions, video_factory, test_session):
|
||||
"""Tools execute against the blueprint; every failure is an instructive error string."""
|
||||
video_factory(title='pressure canning')
|
||||
test_session.commit()
|
||||
research_tools = MODES['research']['tools']
|
||||
|
||||
with _endpoint_shim(async_client):
|
||||
# A real search through the blueprint.
|
||||
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
|
||||
|
||||
# Unknown tool.
|
||||
success, error = await agent.execute_tool(research_tools, 'delete_everything', {})
|
||||
assert success is False and 'Unknown tool' in error
|
||||
|
||||
# Mode allowlist: research mode cannot read service logs even though the tool exists.
|
||||
success, error = await agent.execute_tool(research_tools, 'get_service_logs',
|
||||
dict(name='wrolpi-api'))
|
||||
assert success is False and 'Unknown tool' in error
|
||||
|
||||
# Unknown argument names the accepted ones.
|
||||
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, 'read_content', {})
|
||||
assert success is False and 'Missing required' in error
|
||||
|
||||
# Wrong type.
|
||||
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, 'read_content',
|
||||
dict(file_group_id=123456))
|
||||
assert success is False and 'error' in error.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_tool_path_traversal(async_client, tool_definitions):
|
||||
"""A crafted path parameter cannot escape the tool's endpoint (allowlist bypass)."""
|
||||
help_tools = MODES['help']['tools']
|
||||
with _endpoint_shim(async_client):
|
||||
for slug in ('../../manage/catalog', '../manage/settings', '/etc/passwd',
|
||||
'a/../../files/refresh', 'a//b', 'a/./b'):
|
||||
success, error = await agent.execute_tool(help_tools, 'get_help_doc', dict(slug=slug))
|
||||
assert success is False, f'traversal not refused: {slug}'
|
||||
assert 'traversal' in error or 'invalid path' in error
|
||||
|
||||
# Legitimate nested slugs keep working (404 from the endpoint is fine, no traversal error).
|
||||
success, error = await agent.execute_tool(help_tools, 'get_help_doc', dict(slug='system/logs'))
|
||||
assert 'traversal' not in error and 'invalid path' not in (error or '')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_tool_result_truncated(async_client, tool_definitions, archive_factory, test_session):
|
||||
"""A huge tool result is capped with a paging hint."""
|
||||
archive_factory(domain='example.com', title='big page', contents='word ' * 5_000)
|
||||
test_session.commit()
|
||||
from modules.archive.models import Archive
|
||||
archive = test_session.query(Archive).one()
|
||||
|
||||
with _endpoint_shim(async_client):
|
||||
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
|
||||
485
modules/ai/test/test_api.py
Normal file
485
modules/ai/test/test_api.py
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
"""Tests for the /api/ai blueprint — the LLM tool catalog."""
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.ai import lib
|
||||
|
||||
|
||||
@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."""
|
||||
request, response = await async_client.get('/api/ai/zims')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] == 1
|
||||
zim_id = response.json['results'][0]['id']
|
||||
|
||||
# Search one specific Zim.
|
||||
content = dict(search_str='item', zim_id=zim_id)
|
||||
request, response = await async_client.post('/api/ai/zims/search', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] >= 1
|
||||
result = response.json['results'][0]
|
||||
assert result['zim_id'] == zim_id
|
||||
assert result['link'] == f'/api/zim/{zim_id}/entry/{result["path"]}'
|
||||
|
||||
# An empty search is a clear error for the model.
|
||||
request, response = await async_client.post('/api/ai/zims/search', content=json.dumps(dict()))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Read the entry the search found.
|
||||
request, response = await async_client.get(f'/api/ai/zims/{zim_id}/entry?path={result["path"]}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['content']
|
||||
assert response.json['link'] == f'/api/zim/{zim_id}/entry/{result["path"]}'
|
||||
|
||||
# The path is required.
|
||||
request, response = await async_client.get(f'/api/ai/zims/{zim_id}/entry')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_collections(async_client, test_session, archive_factory, channel_factory):
|
||||
"""Collections are listed paged, filtered by kind, and searchable by name."""
|
||||
archive_factory(domain='example.com', title='a page', contents='contents')
|
||||
for name in ('Alpha Cooking', 'Beta Cooking', 'Gamma Woodwork'):
|
||||
channel_factory(name=name)
|
||||
test_session.commit()
|
||||
|
||||
request, response = await async_client.get('/api/ai/collections?kind=domain')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] == 1
|
||||
assert response.json['results'][0]['name'] == 'example.com'
|
||||
assert response.json['results'][0]['kind'] == 'domain'
|
||||
assert response.json['next_offset'] is None
|
||||
|
||||
# Paged, with the full total so the model knows how many there are.
|
||||
request, response = await async_client.get('/api/ai/collections?kind=channel&limit=2')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] == 3 and len(response.json['results']) == 2
|
||||
assert response.json['next_offset'] == 2
|
||||
request, response = await async_client.get('/api/ai/collections?kind=channel&limit=2&offset=2')
|
||||
assert [i['name'] for i in response.json['results']] == ['Gamma Woodwork']
|
||||
assert response.json['next_offset'] is None
|
||||
|
||||
# Name search.
|
||||
request, response = await async_client.get('/api/ai/collections?search_str=cooking')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert sorted(i['name'] for i in response.json['results']) == ['Alpha Cooking', 'Beta Cooking']
|
||||
|
||||
|
||||
@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."""
|
||||
(test_directory / 'notes').mkdir()
|
||||
(test_directory / 'notes/todo.txt').write_text('buy more rice\n' * 400) # > one page
|
||||
|
||||
request, response = await async_client.get('/api/ai/files/read?path=notes/todo.txt')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['content'].startswith('buy more rice')
|
||||
assert response.json['next_offset'] == lib.PAGE_SIZE
|
||||
|
||||
# Missing path parameter.
|
||||
request, response = await async_client.get('/api/ai/files/read')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Missing file.
|
||||
request, response = await async_client.get('/api/ai/files/read?path=notes/nope.txt')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
# Binary files are refused.
|
||||
(test_directory / 'notes/blob.bin').write_bytes(b'\x00\x01\x02')
|
||||
request, response = await async_client.get('/api/ai/files/read?path=notes/blob.bin')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_read_file_refuses_escapes(async_client, test_directory):
|
||||
"""Reads outside the media directory, and of config files, are refused."""
|
||||
# Path traversal.
|
||||
request, response = await async_client.get('/api/ai/files/read?path=../secrets.txt')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# The config directory can contain secrets.
|
||||
(test_directory / 'config').mkdir(exist_ok=True)
|
||||
(test_directory / 'config/wrolpi.yaml').write_text('secret: hunter2')
|
||||
request, response = await async_client.get('/api/ai/files/read?path=config/wrolpi.yaml')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_files(async_client, test_directory):
|
||||
"""Directories in the media directory can be listed: directories first, then files, both sorted."""
|
||||
(test_directory / 'videos/channel').mkdir(parents=True)
|
||||
(test_directory / 'videos/channel/b.mp4').write_bytes(b'x' * 10)
|
||||
(test_directory / 'videos/channel/a.mp4').write_bytes(b'x' * 20)
|
||||
(test_directory / 'videos/channel/a.en.vtt').write_text('WEBVTT')
|
||||
(test_directory / 'videos/channel/.DS_Store').write_bytes(b'\x00') # macOS junk is hidden
|
||||
(test_directory / 'videos/other').mkdir()
|
||||
(test_directory / 'notes.txt').write_text('hi')
|
||||
(test_directory / 'lost+found').mkdir() # hidden
|
||||
(test_directory / 'config').mkdir(exist_ok=True) # hidden: may hold secrets
|
||||
|
||||
# The root is listed when no path is given.
|
||||
request, response = await async_client.get('/api/ai/files/list')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['path'] == ''
|
||||
assert [i['path'] for i in response.json['directories']] == ['videos/']
|
||||
assert [i['path'] for i in response.json['files']] == ['notes.txt']
|
||||
assert response.json['total'] == 2
|
||||
assert response.json['next_offset'] is None
|
||||
|
||||
request, response = await async_client.get('/api/ai/files/list?path=videos/channel')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['path'] == 'videos/channel/'
|
||||
assert response.json['directories'] == []
|
||||
assert [(i['path'], i['size']) for i in response.json['files']] == [
|
||||
('videos/channel/a.en.vtt', 6),
|
||||
('videos/channel/a.mp4', 20),
|
||||
('videos/channel/b.mp4', 10),
|
||||
]
|
||||
assert response.json['files'][1]['mimetype'] == 'video/mp4'
|
||||
|
||||
# Trailing slashes and leading slashes are tolerated.
|
||||
request, response = await async_client.get('/api/ai/files/list?path=/videos/')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert [i['path'] for i in response.json['directories']] == ['videos/channel/', 'videos/other/']
|
||||
assert response.json['files'] == []
|
||||
|
||||
# Missing directory, and a file rather than a directory.
|
||||
request, response = await async_client.get('/api/ai/files/list?path=nope')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
request, response = await async_client.get('/api/ai/files/list?path=notes.txt')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_files_paged(async_client, test_directory):
|
||||
"""Big directories are paged so a single listing cannot blow the model's context."""
|
||||
from modules.ai.api import LIST_PAGE_SIZE
|
||||
(test_directory / 'big').mkdir()
|
||||
for i in range(LIST_PAGE_SIZE + 5):
|
||||
(test_directory / f'big/{i:04d}.txt').write_text('x')
|
||||
|
||||
request, response = await async_client.get('/api/ai/files/list?path=big')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert len(response.json['files']) == LIST_PAGE_SIZE
|
||||
assert response.json['total'] == LIST_PAGE_SIZE + 5
|
||||
assert response.json['next_offset'] == LIST_PAGE_SIZE
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/files/list?path=big&offset={LIST_PAGE_SIZE}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert [i['name'] for i in response.json['files']] == [f'{i:04d}.txt' for i in range(LIST_PAGE_SIZE, LIST_PAGE_SIZE + 5)]
|
||||
assert response.json['next_offset'] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_files_refuses_escapes(async_client, test_directory):
|
||||
"""Listings outside the media directory, and inside the config directory, are refused."""
|
||||
request, response = await async_client.get('/api/ai/files/list?path=../')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
(test_directory / 'config').mkdir(exist_ok=True)
|
||||
request, response = await async_client.get('/api/ai/files/list?path=config')
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_tags(async_client, test_session, tag_factory, video_factory):
|
||||
"""Tags are listed with what they are applied to, plus the recently used names."""
|
||||
one = await tag_factory('one')
|
||||
await tag_factory('two')
|
||||
video = video_factory(title='tagged video')
|
||||
video.file_group.add_tag(test_session, one.id)
|
||||
test_session.commit()
|
||||
|
||||
request, response = await async_client.get('/api/ai/tags')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] == 2
|
||||
assert [i['name'] for i in response.json['results']] == ['one', 'two']
|
||||
assert response.json['results'][0]['file_groups'] == 1
|
||||
assert response.json['results'][1]['file_groups'] == 0
|
||||
assert response.json['recent'] == ['one']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_downloads(async_client, test_session, test_download_manager, test_downloader,
|
||||
test_directory):
|
||||
"""The download queue is summarized with lean, relative-path entries; status can filter."""
|
||||
test_download_manager.create_download(test_session, 'https://example.com/once', test_downloader.name,
|
||||
destination=test_directory / 'archive/example.com')
|
||||
test_download_manager.recurring_download(test_session, 'https://example.com/feed', 86400,
|
||||
test_downloader.name)
|
||||
test_session.commit()
|
||||
|
||||
request, response = await async_client.get('/api/ai/downloads')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['summary']['recurring'] == 1
|
||||
assert 'disabled' in response.json['summary']
|
||||
once, = response.json['once']
|
||||
assert once['url'] == 'https://example.com/once'
|
||||
assert once['destination'] == 'archive/example.com'
|
||||
assert once['status'] == 'new'
|
||||
assert once['downloader'] == test_downloader.name
|
||||
recurring, = response.json['recurring']
|
||||
assert recurring['url'] == 'https://example.com/feed'
|
||||
assert recurring['frequency'] == 86400
|
||||
|
||||
# Filter by status.
|
||||
request, response = await async_client.get('/api/ai/downloads?status=complete')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['once'] == [] and response.json['recurring'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_map(async_client, test_session, test_directory, make_files_structure):
|
||||
"""The map overview reports map files, subscriptions, pins, and search indexes; places can be searched."""
|
||||
from modules.map.pins import get_map_pins_config
|
||||
from modules.map.test.test_search import _create_test_search_db
|
||||
|
||||
make_files_structure(['map/oregon.pmtiles'])
|
||||
_create_test_search_db(test_directory / 'map/oregon.search.db', [
|
||||
('Portland', 'city', 45.5, -122.6, 6, 'places', 'city', 650000, 'Oregon'),
|
||||
('Port Orford', 'town', 42.7, -124.5, 9, 'places', 'town', 1100, 'Oregon'),
|
||||
])
|
||||
get_map_pins_config().add_pin(45.1, -122.2, 'Home')
|
||||
|
||||
request, response = await async_client.get('/api/ai/map')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
files, = response.json['files']
|
||||
assert files['name'] == 'oregon.pmtiles' and files['has_search_index'] is True
|
||||
assert response.json['subscriptions'] == []
|
||||
pin, = response.json['pins']
|
||||
assert pin['label'] == 'Home' and pin['link'] == '/map?lat=45.1&lon=-122.2&z=12'
|
||||
|
||||
request, response = await async_client.get('/api/ai/map/search?q=port')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] == 2
|
||||
first = response.json['results'][0]
|
||||
assert first['name'] == 'Portland'
|
||||
assert first['region'] == 'Oregon'
|
||||
assert first['link'] == '/map?lat=45.5&lon=-122.6&z=12'
|
||||
|
||||
# Proximity ranking within an importance tier: equal min_zoom, nearest first.
|
||||
_create_test_search_db(test_directory / 'map/coast.search.db', [
|
||||
('Port A', 'town', 42.0, -124.0, 9, 'places', 'town', 100, 'Oregon'),
|
||||
('Port B', 'town', 44.0, -124.0, 9, 'places', 'town', 100, 'Oregon'),
|
||||
])
|
||||
request, response = await async_client.get('/api/ai/map/search?q=port%20b&lat=44.0&lon=-124.0')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['results'][0]['name'] == 'Port B'
|
||||
request, response = await async_client.get('/api/ai/map/search?q=port&lat=42.0&lon=-124.0&limit=2')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert len(response.json['results']) == 2 and response.json['total'] == 4
|
||||
|
||||
# q is required.
|
||||
request, response = await async_client.get('/api/ai/map/search')
|
||||
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
|
||||
error = 'Traceback (most recent call last):\n' + (' File "x.py"\n' * 100) + 'ValueError: the real reason'
|
||||
formatted = _format_download(dict(id=1, url='https://example.com', status='failed', error=error))
|
||||
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
|
||||
|
||||
# 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
|
||||
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):
|
||||
"""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')
|
||||
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
|
||||
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
|
||||
250
modules/ai/test/test_chat_api.py
Normal file
250
modules/ai/test/test_chat_api.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""End-to-end tests for /api/chat: SSE framing, the agent loop, and its guardrails.
|
||||
|
||||
llama-server is scripted (llama_chat_stream is patched); tool calls execute against the real
|
||||
/api/ai blueprint through the test client."""
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.ai.config import get_ai_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ai_enabled(async_client, test_ai_config, test_directory):
|
||||
"""AI enabled with a (fake) downloaded model, llama-server 'healthy'."""
|
||||
models_dir = test_directory / 'ai/models'
|
||||
models_dir.mkdir(parents=True)
|
||||
(models_dir / 'test.gguf').write_bytes(b'GGUF')
|
||||
config = get_ai_config()
|
||||
config.active_model = 'test.gguf'
|
||||
config.enabled = True
|
||||
|
||||
with mock.patch('modules.ai.service.llama_healthy', return_value=True), \
|
||||
mock.patch('modules.ai.chat.service.ensure_llama_running', return_value=True):
|
||||
yield config
|
||||
|
||||
|
||||
def _llama_script(*turns):
|
||||
"""Patch llama_chat_stream with scripted turns.
|
||||
|
||||
Each turn is a list of events ({'type': 'token'|'stop', ...}); turn N answers call N."""
|
||||
calls = dict(count=0, messages=[])
|
||||
|
||||
def factory(messages, tools):
|
||||
calls['messages'].append(messages)
|
||||
turn = turns[min(calls['count'], len(turns) - 1)]
|
||||
calls['count'] += 1
|
||||
|
||||
async def generator():
|
||||
for event in turn:
|
||||
yield event
|
||||
|
||||
return generator()
|
||||
|
||||
return mock.patch('modules.ai.chat.llama_chat_stream', side_effect=factory), calls
|
||||
|
||||
|
||||
def _endpoint_shim(async_client):
|
||||
async def shim(method, path, query, body):
|
||||
url = f'{path}?{urlencode(query)}' if query else path
|
||||
if method == 'post':
|
||||
request, response = await async_client.post(url, content=json.dumps(body or {}))
|
||||
else:
|
||||
request, response = await async_client.get(url)
|
||||
return response.status_code, response.json
|
||||
|
||||
return mock.patch('modules.ai.agent.call_ai_endpoint', side_effect=shim)
|
||||
|
||||
|
||||
def _parse_sse(text):
|
||||
"""[(event, data_dict), ...] from an SSE body."""
|
||||
events = []
|
||||
for block in text.split('\n\n'):
|
||||
event = data = None
|
||||
for line in block.splitlines():
|
||||
if line.startswith('event:'):
|
||||
event = line[6:].strip()
|
||||
elif line.startswith('data:'):
|
||||
data = json.loads(line[5:].strip())
|
||||
if event:
|
||||
events.append((event, data))
|
||||
return events
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_tool_call_then_answer(async_client, ai_enabled, video_factory, test_session):
|
||||
"""The loop executes a tool call against the blueprint, then streams the final answer."""
|
||||
video_factory(title='pressure canning')
|
||||
test_session.commit()
|
||||
|
||||
patch, calls = _llama_script(
|
||||
[dict(type='stop', tool_calls=[
|
||||
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=[])],
|
||||
)
|
||||
body = dict(mode='research', messages=[dict(role='user', content='Find videos about canning')])
|
||||
with patch, _endpoint_shim(async_client):
|
||||
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
events = _parse_sse(response.text)
|
||||
# A status event precedes every model call so slow CPUs never leave the stream silent.
|
||||
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_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
|
||||
# worked example, the user message, and the tool result.
|
||||
second_call_messages = calls['messages'][1]
|
||||
assert second_call_messages[0]['role'] == 'system'
|
||||
assert 'librarian' in second_call_messages[0]['content']
|
||||
assert 'Today is' in second_call_messages[0]['content']
|
||||
assert "library contains" in second_call_messages[0]['content']
|
||||
example = [m for m in second_call_messages if m.get('content') and 'Bakehouse' in str(m.get('content'))]
|
||||
assert example, 'the research few-shot example is in context'
|
||||
assert second_call_messages[-1]['role'] == 'tool'
|
||||
assert 'pressure canning' in second_call_messages[-1]['content']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_plain_answer(async_client, ai_enabled):
|
||||
"""A toolless answer streams tokens then done."""
|
||||
patch, _ = _llama_script(
|
||||
[dict(type='token', content='Hello!'), dict(type='stop', tool_calls=[])],
|
||||
)
|
||||
body = dict(mode='help', messages=[dict(role='user', content='hi')])
|
||||
with patch:
|
||||
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
|
||||
events = [e for e in _parse_sse(response.text) if e[0] != 'status']
|
||||
assert events == [('token', dict(content='Hello!')), ('done', dict(content='Hello!'))]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_tool_loop_bounded(async_client, ai_enabled):
|
||||
"""A model that never stops calling tools hits max_tool_calls and gets the fallback."""
|
||||
get_ai_config().max_tool_calls = 2
|
||||
patch, calls = _llama_script(
|
||||
[dict(type='stop', tool_calls=[dict(id='x', name='search_help', arguments='{"search_str": "a"}')])],
|
||||
)
|
||||
body = dict(mode='help', messages=[dict(role='user', content='loop forever')])
|
||||
with patch, _endpoint_shim(async_client):
|
||||
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
|
||||
events = _parse_sse(response.text)
|
||||
assert events[-1][0] == 'error'
|
||||
assert calls['count'] == 3 # initial + max_tool_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_invalid_tool_args(async_client, ai_enabled):
|
||||
"""Bad tool JSON and disallowed tools come back to the model as errors, not crashes."""
|
||||
patch, calls = _llama_script(
|
||||
[dict(type='stop', tool_calls=[dict(id='x', name='search_help', arguments='{not json')])],
|
||||
[dict(type='stop', tool_calls=[dict(id='y', name='list_disks', arguments='{}')])],
|
||||
[dict(type='token', content='Sorry.'), dict(type='stop', tool_calls=[])],
|
||||
)
|
||||
body = dict(mode='help', messages=[dict(role='user', content='hi')])
|
||||
with patch, _endpoint_shim(async_client):
|
||||
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
|
||||
events = _parse_sse(response.text)
|
||||
results = [d for e, d in events if e == 'tool_result']
|
||||
assert results[0]['success'] is False # invalid JSON arguments
|
||||
assert results[1]['success'] is False # list_disks is not a help-mode tool
|
||||
assert events[-1] == ('done', dict(content='Sorry.'))
|
||||
# The model saw instructive errors (skip the few-shot example's tool messages).
|
||||
tool_messages = [m for m in calls['messages'][-1]
|
||||
if m['role'] == 'tool' and not str(m.get('tool_call_id', '')).startswith('ex_')]
|
||||
assert 'not valid JSON' in tool_messages[0]['content']
|
||||
assert 'Unknown tool' in tool_messages[1]['content']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_validation(async_client, ai_enabled):
|
||||
"""Bad requests are refused before the stream starts."""
|
||||
request, response = await async_client.post(
|
||||
'/api/chat/', content=json.dumps(dict(mode='nope', messages=[dict(role='user', content='x')])))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
request, response = await async_client.post(
|
||||
'/api/chat/', content=json.dumps(dict(mode='help', messages=[])))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_disabled(async_client, test_ai_config):
|
||||
"""Chat refuses when AI is disabled or no model is selected."""
|
||||
body = dict(mode='help', messages=[dict(role='user', content='x')])
|
||||
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
|
||||
assert response.status_code == HTTPStatus.CONFLICT
|
||||
assert response.json['code'] == 'AI_DISABLED'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_modes_endpoint(async_client):
|
||||
"""The Chat tab can list modes and their suggestion chips."""
|
||||
request, response = await async_client.get('/api/chat/modes')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
modes = {m['name']: m for m in response.json['modes']}
|
||||
assert set(modes) == {'help', 'research', 'system'}
|
||||
assert modes['research']['suggestions']
|
||||
|
||||
|
||||
def test_trim_history():
|
||||
"""Old turns are dropped first; fixed messages and the current turn are never trimmed."""
|
||||
from modules.ai import chat
|
||||
fixed = [dict(role='system', content='s' * 400), dict(role='user', content='current')]
|
||||
history = [dict(role='user', content=f'old {i} ' + 'x' * 400) for i in range(10)]
|
||||
|
||||
# A huge budget keeps everything.
|
||||
assert chat.trim_history(history, fixed, [], budget=10_000) == history
|
||||
|
||||
# A small budget drops the OLDEST turns.
|
||||
trimmed = chat.trim_history(history, fixed, [], budget=400)
|
||||
assert len(trimmed) < len(history)
|
||||
assert trimmed == history[len(history) - len(trimmed):]
|
||||
|
||||
# A budget the fixed messages alone exceed empties the history without crashing.
|
||||
assert chat.trim_history(history, fixed, [], budget=1) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_context_full(async_client, ai_enabled):
|
||||
"""A conversation that cannot fit the model context gets a clear error, not a bad answer."""
|
||||
from modules.ai import chat
|
||||
get_ai_config().context_size = 1 # far below the reply reserve
|
||||
|
||||
patch, calls = _llama_script([dict(type='token', content='hi'), dict(type='stop', tool_calls=[])])
|
||||
body = dict(mode='help', messages=[dict(role='user', content='hello')])
|
||||
with patch:
|
||||
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
|
||||
events = _parse_sse(response.text)
|
||||
assert events[-1][0] == 'error'
|
||||
assert 'no longer fits' in events[-1][1]['message']
|
||||
assert calls['count'] == 0 # the model was never called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_history_trimmed(async_client, ai_enabled):
|
||||
"""Old turns are dropped to fit the context; the current question always survives."""
|
||||
get_ai_config().context_size = 4_096
|
||||
|
||||
patch, calls = _llama_script([dict(type='token', content='ok'), dict(type='stop', tool_calls=[])])
|
||||
old_turns = []
|
||||
for i in range(6):
|
||||
old_turns += [dict(role='user', content=f'old question {i} ' + 'pad ' * 500),
|
||||
dict(role='assistant', content=f'old answer {i} ' + 'pad ' * 500)]
|
||||
body = dict(mode='help', messages=old_turns + [dict(role='user', content='the current question')])
|
||||
with patch:
|
||||
request, response = await async_client.post('/api/chat/', content=json.dumps(body))
|
||||
assert _parse_sse(response.text)[-1][0] == 'done'
|
||||
|
||||
sent = calls['messages'][0]
|
||||
contents = [str(m.get('content')) for m in sent]
|
||||
assert any('the current question' in c for c in contents)
|
||||
assert not any('old question 0' in c for c in contents) # oldest turn was trimmed
|
||||
287
modules/ai/test/test_config_catalog.py
Normal file
287
modules/ai/test/test_config_catalog.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""Tests for the ai.yaml config, the model catalog, and the GGUF downloader."""
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from modules.ai import catalog
|
||||
from modules.ai.config import get_ai_config
|
||||
from modules.ai.downloader import ai_model_downloader
|
||||
from wrolpi.downloader import Download
|
||||
|
||||
|
||||
def test_ai_config_defaults_and_update(test_ai_config):
|
||||
"""The config has safe defaults (AI off) and round-trips values."""
|
||||
config = get_ai_config()
|
||||
assert config.enabled is False
|
||||
assert config.active_model == ''
|
||||
assert config.idle_unload_minutes == 15
|
||||
assert config.context_size is None
|
||||
assert config.max_tool_calls == 6
|
||||
assert config.temperature == 0.3
|
||||
|
||||
config.enabled = True
|
||||
config.active_model = 'Qwen3-1.7B-Q4_K_M.gguf'
|
||||
config.context_size = 8_192
|
||||
assert get_ai_config().enabled is True
|
||||
assert get_ai_config().active_model == 'Qwen3-1.7B-Q4_K_M.gguf'
|
||||
assert get_ai_config().context_size == 8_192
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_config_import(test_ai_config):
|
||||
"""A hand-edited ai.yaml is the source of truth on import."""
|
||||
test_ai_config.write_text(yaml.dump(dict(
|
||||
version=1, enabled=True, active_model='custom.gguf', idle_unload_minutes=5,
|
||||
context_size=None, max_tool_calls=4,
|
||||
)))
|
||||
config = get_ai_config()
|
||||
config.import_config(test_ai_config)
|
||||
assert config.enabled is True
|
||||
assert config.active_model == 'custom.gguf'
|
||||
assert config.idle_unload_minutes == 5
|
||||
assert config.max_tool_calls == 4
|
||||
assert config.successful_import is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_models_catalog_fallback(async_client):
|
||||
"""The bundled catalog is served when the CDN is unreachable."""
|
||||
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
|
||||
models, source = await catalog.get_models_catalog()
|
||||
assert source == 'bundled'
|
||||
assert models == catalog.AI_MODELS
|
||||
|
||||
manifest = dict(version=1, models=[dict(name='new.gguf', tier='small')])
|
||||
with mock.patch('modules.ai.catalog.fetch_models_manifest', return_value=manifest):
|
||||
models, source = await catalog.get_models_catalog()
|
||||
assert source == 'cdn'
|
||||
assert models[0]['name'] == 'new.gguf'
|
||||
|
||||
|
||||
def test_recommend_tier():
|
||||
gb = 1024 ** 3
|
||||
assert catalog.recommend_tier(4 * gb) == 'small'
|
||||
assert catalog.recommend_tier(8 * gb) == 'medium'
|
||||
assert catalog.recommend_tier(32 * gb) == 'large'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_downloader(test_session, test_directory, test_download_manager):
|
||||
"""The model downloader verifies the meta4 signature, stages, and lands in <media>/ai/models."""
|
||||
url = 'https://example.com/ai/Test-Model-Q4_K_M.gguf'
|
||||
download = Download(url=url, downloader='ai_model')
|
||||
|
||||
prepared = ai_model_downloader.prepare_download(test_session, download)
|
||||
assert prepared.error is None
|
||||
assert prepared.output_path == test_directory / 'ai/models/Test-Model-Q4_K_M.gguf'
|
||||
assert prepared.models_directory.is_dir()
|
||||
|
||||
meta4 = b'<metalink>fake</metalink>'
|
||||
|
||||
async def fake_download_file(download_, url_, directory, **kwargs):
|
||||
assert kwargs['meta4_xml'] == meta4
|
||||
path = directory / 'Test-Model-Q4_K_M.gguf'
|
||||
path.write_bytes(b'GGUF fake weights')
|
||||
return path
|
||||
|
||||
with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=meta4), \
|
||||
mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=True), \
|
||||
mock.patch.object(ai_model_downloader, 'download_file', side_effect=fake_download_file):
|
||||
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
|
||||
assert executed.error is None
|
||||
assert prepared.output_path.is_file()
|
||||
|
||||
result = ai_model_downloader.finalize_download(test_session, download, executed)
|
||||
assert result.success is True
|
||||
|
||||
# A second download of the same model is skipped.
|
||||
prepared = ai_model_downloader.prepare_download(test_session, download)
|
||||
assert prepared.already_done is True
|
||||
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
|
||||
assert executed.skipped is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_downloader_refuses_unverified(test_session, test_directory, test_download_manager):
|
||||
"""No meta4, or a bad signature, refuses the download."""
|
||||
url = 'https://example.com/ai/Test-Model.gguf'
|
||||
download = Download(url=url, downloader='ai_model')
|
||||
prepared = ai_model_downloader.prepare_download(test_session, download)
|
||||
|
||||
with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=None):
|
||||
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
|
||||
assert 'refusing an unverified download' in executed.error
|
||||
|
||||
with mock.patch.object(ai_model_downloader, 'get_meta4_contents', return_value=b'<m/>'), \
|
||||
mock.patch('modules.ai.downloader.verify_meta4_signature', return_value=False):
|
||||
executed = await ai_model_downloader.execute_download(prepared, ctx=None, download=download)
|
||||
assert 'signature verification failed' in executed.error
|
||||
assert not prepared.output_path.exists()
|
||||
|
||||
# Not a GGUF URL is refused in prepare.
|
||||
bad = ai_model_downloader.prepare_download(test_session, Download(url='https://example.com/x.zip'))
|
||||
assert 'Not a GGUF model URL' in bad.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_manage_endpoints(async_client, test_directory, test_ai_config):
|
||||
"""The Manage tab's catalog and settings endpoints."""
|
||||
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
|
||||
request, response = await async_client.get('/api/ai/manage/catalog')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['catalog_source'] == 'bundled'
|
||||
assert response.json['enabled'] is False
|
||||
assert response.json['recommended_tier'] in ('small', 'medium', 'large')
|
||||
assert all(i['downloaded'] is False for i in response.json['models'])
|
||||
|
||||
# Selecting a model that is not downloaded is refused.
|
||||
content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf')
|
||||
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
# Once the file exists it can be selected, and shows downloaded/active in the catalog.
|
||||
models_dir = test_directory / 'ai/models'
|
||||
models_dir.mkdir(parents=True)
|
||||
(models_dir / 'Qwen3-1.7B-Q4_K_M.gguf').write_bytes(b'GGUF')
|
||||
content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf', enabled=True, idle_unload_minutes=5)
|
||||
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['active_model'] == 'Qwen3-1.7B-Q4_K_M.gguf'
|
||||
assert response.json['enabled'] is True
|
||||
|
||||
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
|
||||
request, response = await async_client.get('/api/ai/manage/catalog')
|
||||
model = next(i for i in response.json['models'] if i['name'] == 'Qwen3-1.7B-Q4_K_M.gguf')
|
||||
assert model['downloaded'] is True and model['active'] is True
|
||||
assert response.json['disk_usage'] > 0
|
||||
|
||||
# Bad idle minutes.
|
||||
request, response = await async_client.post('/api/ai/manage/settings',
|
||||
content=json.dumps(dict(idle_unload_minutes=0)))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
def test_effective_context_size(test_ai_config):
|
||||
"""Explicit ai.yaml value wins; else the active model's catalog default; else the small default."""
|
||||
config = get_ai_config()
|
||||
assert catalog.get_effective_context_size() == 8_192 # no model, no override
|
||||
|
||||
config.active_model = 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf'
|
||||
assert catalog.get_effective_context_size() == 16_384 # the 4B's catalog default
|
||||
|
||||
config.context_size = 4_096
|
||||
assert catalog.get_effective_context_size() == 4_096 # explicit override wins
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_settings_adopts_model_context(async_client, test_directory, test_ai_config):
|
||||
"""Selecting a model writes its catalog context default unless the request sets one."""
|
||||
models_dir = test_directory / 'ai/models'
|
||||
models_dir.mkdir(parents=True)
|
||||
(models_dir / 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf').write_bytes(b'GGUF')
|
||||
|
||||
content = dict(active_model='Qwen3-4B-Instruct-2507-Q4_K_M.gguf')
|
||||
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['context_size'] == 16_384
|
||||
|
||||
# An explicit context_size in the same request is respected.
|
||||
content = dict(active_model='Qwen3-4B-Instruct-2507-Q4_K_M.gguf', context_size=4_096)
|
||||
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
|
||||
assert response.json['context_size'] == 4_096
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_catalog_lists_custom_models(async_client, test_directory, test_ai_config):
|
||||
"""A GGUF the user copied into ai/models (not from the catalog) is listed as a custom model so it
|
||||
can be selected in the Manage tab. It is discovered on every request; no file refresh needed."""
|
||||
models_dir = test_directory / 'ai/models'
|
||||
models_dir.mkdir(parents=True)
|
||||
(models_dir / 'My-Custom-Model-Q8_0.gguf').write_bytes(b'GGUF' * 10)
|
||||
(models_dir / 'Qwen3-1.7B-Q4_K_M.gguf').write_bytes(b'GGUF')
|
||||
(models_dir / 'partial.gguf.tmp').write_bytes(b'x') # an in-progress download is not a model
|
||||
|
||||
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
|
||||
request, response = await async_client.get('/api/ai/manage/catalog')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
names = [i['name'] for i in response.json['models']]
|
||||
assert names.count('Qwen3-1.7B-Q4_K_M.gguf') == 1, 'catalog model must not be duplicated'
|
||||
assert 'partial.gguf.tmp' not in names
|
||||
|
||||
custom = next(i for i in response.json['models'] if i['name'] == 'My-Custom-Model-Q8_0.gguf')
|
||||
assert custom['tier'] == 'custom'
|
||||
assert custom['downloaded'] is True
|
||||
assert custom['active'] is False
|
||||
assert custom['size'] == 40
|
||||
assert custom['url'] is None
|
||||
|
||||
# Selecting a catalog model first stores its context default in ai.yaml ...
|
||||
content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf')
|
||||
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['context_size'] == 8_192
|
||||
|
||||
# ... and switching to the custom model clears it, since a custom model has no catalog
|
||||
# default; it must not silently keep the previous model's value.
|
||||
content = dict(active_model='My-Custom-Model-Q8_0.gguf')
|
||||
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['active_model'] == 'My-Custom-Model-Q8_0.gguf'
|
||||
assert response.json['context_size'] is None
|
||||
assert catalog.get_effective_context_size() == 8_192 # the conservative default
|
||||
|
||||
# An explicit context_size in the same request still wins for a custom model.
|
||||
content = dict(active_model='My-Custom-Model-Q8_0.gguf', context_size=4_096)
|
||||
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
|
||||
assert response.json['context_size'] == 4_096
|
||||
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
|
||||
request, response = await async_client.get('/api/ai/manage/catalog')
|
||||
custom = next(i for i in response.json['models'] if i['name'] == 'My-Custom-Model-Q8_0.gguf')
|
||||
assert custom['active'] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,loadable', [
|
||||
('My-Custom-Model-Q8_0.gguf', True),
|
||||
('model (v2) [q4].gguf', True), # shell-safe once quoted by PyYAML
|
||||
('café.gguf', False), # PyYAML dumps \xE9; read_config_value.sh prints the escape literally
|
||||
('模型-Q4.gguf', False),
|
||||
('model #1.gguf', False), # the reader strips " #..." before unquoting
|
||||
('foo..bar.gguf', False), # start_llama_server.sh's traversal guard rejects any ".."
|
||||
('it\'s.gguf', False),
|
||||
('say "hi".gguf', False),
|
||||
('back\\slash.gguf', False),
|
||||
(' padded.gguf', False),
|
||||
('model.bin', False),
|
||||
])
|
||||
def test_is_loadable_model_name(name, loadable):
|
||||
"""Only names start_llama_server.sh can read back out of ai.yaml are loadable."""
|
||||
assert catalog.is_loadable_model_name(name) is loadable
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manage_catalog_skips_unloadable_custom_models(async_client, test_directory, test_ai_config):
|
||||
"""A copied-in GGUF whose name cannot round-trip through ai.yaml and the shell reader is neither
|
||||
listed nor accepted, so the UI never reports an activation that llama-server would refuse."""
|
||||
models_dir = test_directory / 'ai/models'
|
||||
models_dir.mkdir(parents=True)
|
||||
(models_dir / 'café.gguf').write_bytes(b'GGUF')
|
||||
(models_dir / 'model #1.gguf').write_bytes(b'GGUF')
|
||||
(models_dir / 'foo..bar.gguf').write_bytes(b'GGUF')
|
||||
(models_dir / 'Fine-Model.gguf').write_bytes(b'GGUF')
|
||||
(models_dir / 'a-directory.gguf').mkdir()
|
||||
|
||||
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
|
||||
request, response = await async_client.get('/api/ai/manage/catalog')
|
||||
names = [i['name'] for i in response.json['models']]
|
||||
assert 'Fine-Model.gguf' in names
|
||||
for bad in ('café.gguf', 'model #1.gguf', 'foo..bar.gguf', 'a-directory.gguf'):
|
||||
assert bad not in names
|
||||
|
||||
for bad in ('café.gguf', 'model #1.gguf', 'foo..bar.gguf'):
|
||||
request, response = await async_client.post('/api/ai/manage/settings',
|
||||
content=json.dumps(dict(active_model=bad)))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, bad
|
||||
assert 'cannot be loaded' in response.json['error']
|
||||
62
modules/ai/test/test_context.py
Normal file
62
modules/ai/test/test_context.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Tests for the library map injected into every chat."""
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
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, 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')
|
||||
test_session.commit()
|
||||
|
||||
text = context.build_library_context()
|
||||
assert 'Today is' in text
|
||||
assert '1 videos, 1 archived web pages' in text
|
||||
assert 'Talking Sasquach' in text
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_context_caps_names(async_client, test_session, archive_factory):
|
||||
"""A huge library lists at most MAX_NAMES names per category, plus a count of the rest."""
|
||||
for i in range(context.MAX_NAMES + 5):
|
||||
archive_factory(domain=f'site{i:02}.com', title=f'page {i}', contents='x')
|
||||
test_session.commit()
|
||||
|
||||
text = context.build_library_context()
|
||||
assert f'Archived websites ({context.MAX_NAMES + 5}):' in text
|
||||
assert 'and 5 more' in text
|
||||
listed = [i for i in range(context.MAX_NAMES + 5) if f'site{i:02}.com' in text]
|
||||
assert len(listed) == context.MAX_NAMES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_context_cache_and_degradation(async_client, test_session):
|
||||
"""The map is cached briefly; a build failure serves the stale copy rather than crashing chat."""
|
||||
context._cache.update(text=None, expires=0.0)
|
||||
first = context.get_library_context()
|
||||
assert first and 'Today is' in first
|
||||
|
||||
with mock.patch('modules.ai.context.build_library_context', side_effect=RuntimeError('db down')) as build:
|
||||
# Cached: the builder is not called.
|
||||
assert context.get_library_context() == first
|
||||
build.assert_not_called()
|
||||
|
||||
# Expired + failing builder: the stale copy is served.
|
||||
context._cache['expires'] = time.time() - 1
|
||||
assert context.get_library_context() == first
|
||||
build.assert_called_once()
|
||||
114
modules/ai/test/test_help_docs.py
Normal file
114
modules/ai/test/test_help_docs.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Tests for the help-docs search (Help mode). Uses a fixture corpus, never the real submodule."""
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.ai import help_docs, lib
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def help_corpus(test_directory, monkeypatch):
|
||||
"""A small help-docs corpus in a temporary directory."""
|
||||
docs = test_directory / 'help-docs'
|
||||
(docs / 'system').mkdir(parents=True)
|
||||
(docs / 'modules').mkdir()
|
||||
(docs / 'index.md').write_text('# Home\n\nWelcome to WROLPi help.\n')
|
||||
(docs / 'system/logs.md').write_text(
|
||||
'# Logs\n\n## Reading logs\n\nUse journalctl to read service logs on a WROLPi.\n')
|
||||
(docs / 'modules/videos.md').write_text(
|
||||
'# Videos\n\n## Downloading\n\nVideos are downloaded with yt-dlp into channels.\n'
|
||||
+ ('More text about videos. ' * 400)) # > one page
|
||||
monkeypatch.setenv('HELP_DOCS_DIR', str(docs))
|
||||
help_docs._CACHE.clear()
|
||||
try:
|
||||
yield docs
|
||||
finally:
|
||||
help_docs._CACHE.clear()
|
||||
|
||||
|
||||
def test_search_help_scoring(help_corpus):
|
||||
"""Title matches outrank body matches; snippets show context."""
|
||||
results, total = help_docs.search_help('logs')
|
||||
assert total == 1
|
||||
assert results[0]['slug'] == 'system/logs'
|
||||
assert results[0]['title'] == 'Logs'
|
||||
assert results[0]['link'] == '/system/logs/'
|
||||
assert 'journalctl' in results[0]['snippet'] or 'Logs' in results[0]['snippet']
|
||||
|
||||
# 'videos' matches the videos doc far better than any other.
|
||||
results, _ = help_docs.search_help('downloading videos')
|
||||
assert results[0]['slug'] == 'modules/videos'
|
||||
|
||||
# No matches.
|
||||
results, total = help_docs.search_help('xyzzy')
|
||||
assert results == [] and total == 0
|
||||
|
||||
|
||||
def test_help_doc_cache_invalidation(help_corpus):
|
||||
"""Edited files are re-parsed; deleted files leave the corpus."""
|
||||
results, _ = help_docs.search_help('welcome')
|
||||
assert results[0]['slug'] == 'index'
|
||||
assert results[0]['link'] == '/'
|
||||
|
||||
(help_corpus / 'index.md').write_text('# Home\n\nGreetings, traveler.\n')
|
||||
import os
|
||||
os.utime(help_corpus / 'index.md', (0, 0)) # force a distinct mtime
|
||||
results, total = help_docs.search_help('welcome')
|
||||
assert total == 0
|
||||
results, _ = help_docs.search_help('greetings')
|
||||
assert results[0]['slug'] == 'index'
|
||||
|
||||
(help_corpus / 'system/logs.md').unlink()
|
||||
_, total = help_docs.search_help('logs')
|
||||
assert total == 0
|
||||
|
||||
|
||||
def test_get_help_doc_refuses_traversal(help_corpus):
|
||||
assert help_docs.get_help_doc('../secrets') is None
|
||||
assert help_docs.get_help_doc('system/logs') is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_help_endpoints(async_client, help_corpus):
|
||||
"""Help can be searched and pages read as paged markdown."""
|
||||
content = dict(search_str='logs')
|
||||
request, response = await async_client.post('/api/ai/help/search', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] == 1
|
||||
result = response.json['results'][0]
|
||||
assert result['slug'] == 'system/logs'
|
||||
|
||||
request, response = await async_client.get(f'/api/ai/help/{result["slug"]}')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['title'] == 'Logs'
|
||||
assert 'journalctl' in response.json['content']
|
||||
|
||||
# Long pages are paged.
|
||||
request, response = await async_client.get('/api/ai/help/modules/videos')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['next_offset'] == lib.PAGE_SIZE
|
||||
|
||||
# Unknown page.
|
||||
request, response = await async_client.get('/api/ai/help/no/such/page')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
|
||||
# Empty search is a clear error.
|
||||
request, response = await async_client.post('/api/ai/help/search', content=json.dumps(dict()))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_help_not_installed(async_client, monkeypatch, test_directory):
|
||||
"""A missing help directory returns empty results with a message, never a 500."""
|
||||
monkeypatch.setenv('HELP_DOCS_DIR', str(test_directory / 'nope'))
|
||||
help_docs._CACHE.clear()
|
||||
|
||||
content = dict(search_str='logs')
|
||||
request, response = await async_client.post('/api/ai/help/search', content=json.dumps(content))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['results'] == []
|
||||
assert 'not installed' in response.json['message']
|
||||
|
||||
request, response = await async_client.get('/api/ai/help/system/logs')
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND
|
||||
149
modules/ai/test/test_lib.py
Normal file
149
modules/ai/test/test_lib.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Tests for the /api/ai formatting helpers."""
|
||||
from modules.ai import lib
|
||||
|
||||
|
||||
def test_wrolpi_link_video():
|
||||
fg = dict(id=123, model='video', video=dict(), mimetype='video/mp4')
|
||||
assert lib.wrolpi_link(fg) == '/videos/123'
|
||||
|
||||
|
||||
def test_wrolpi_link_doc():
|
||||
fg = dict(id=5, model='doc', mimetype='application/pdf')
|
||||
assert lib.wrolpi_link(fg) == '/docs/5'
|
||||
|
||||
|
||||
def test_wrolpi_link_archive():
|
||||
fg = dict(id=7, model='archive', mimetype='text/html')
|
||||
assert lib.wrolpi_link(fg) == '/archives/7'
|
||||
|
||||
|
||||
def test_wrolpi_link_epub(test_directory):
|
||||
fg = dict(id=9, mimetype='application/epub+zip', primary_path=test_directory / 'books/foo.epub')
|
||||
assert lib.wrolpi_link(fg) == '/epub/epub.html?url=/download/books/foo.epub'
|
||||
|
||||
|
||||
def test_wrolpi_link_mobi(test_directory):
|
||||
fg = dict(id=9, mimetype='application/x-mobipocket-ebook', primary_path=test_directory / 'books/foo.mobi')
|
||||
assert lib.wrolpi_link(fg) == '/download/books/foo.mobi'
|
||||
|
||||
|
||||
def test_wrolpi_link_media_fallback(test_directory):
|
||||
fg = dict(id=2, mimetype='image/png', primary_path=test_directory / 'photos/a.png')
|
||||
assert lib.wrolpi_link(fg) == '/media/photos/a.png'
|
||||
|
||||
|
||||
def test_wrolpi_link_no_path():
|
||||
assert lib.wrolpi_link(dict(mimetype='image/png')) is None
|
||||
|
||||
|
||||
def test_format_file_group_video():
|
||||
fg = dict(
|
||||
id=42,
|
||||
model='video',
|
||||
title='Canning Basics',
|
||||
mimetype='video/mp4',
|
||||
size=1000,
|
||||
length=4021,
|
||||
published_datetime='2024-01-02',
|
||||
tags=['food'],
|
||||
video=dict(channel=dict(id=1, name='Homesteading'), description='x' * 500),
|
||||
d_headline='pressure <b>canning</b>',
|
||||
)
|
||||
result = lib.format_file_group(fg)
|
||||
assert result['id'] == 42
|
||||
assert result['kind'] == 'video'
|
||||
assert result['link'] == '/videos/42'
|
||||
assert result['channel'] == 'Homesteading'
|
||||
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():
|
||||
fg = dict(id=8, model='archive', title='Some Page', mimetype='text/html', url='https://example.com/a')
|
||||
result = lib.format_file_group(fg)
|
||||
assert result['kind'] == 'archive'
|
||||
assert result['link'] == '/archives/8'
|
||||
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'
|
||||
|
||||
|
||||
def test_format_file_groups_total():
|
||||
result = lib.format_file_groups([dict(id=1, model='archive')], 55)
|
||||
assert result['total'] == 55
|
||||
assert len(result['results']) == 1
|
||||
|
||||
|
||||
def test_paginate_text():
|
||||
text = 'a' * (lib.PAGE_SIZE + 100)
|
||||
page = lib.paginate_text(text, 0)
|
||||
assert len(page['content']) == lib.PAGE_SIZE
|
||||
assert page['next_offset'] == lib.PAGE_SIZE
|
||||
assert page['total_chars'] == lib.PAGE_SIZE + 100
|
||||
|
||||
page = lib.paginate_text(text, page['next_offset'])
|
||||
assert len(page['content']) == 100
|
||||
assert page['next_offset'] is None
|
||||
|
||||
# Exact boundary has no next page.
|
||||
page = lib.paginate_text('a' * lib.PAGE_SIZE, 0)
|
||||
assert page['next_offset'] is None
|
||||
|
||||
# Empty text.
|
||||
page = lib.paginate_text(None, 0)
|
||||
assert page == dict(content='', next_offset=None, total_chars=0)
|
||||
|
||||
|
||||
def test_html_to_text():
|
||||
html = '<html><head><style>.x{}</style><script>evil()</script></head>' \
|
||||
'<body><nav>menu</nav><p>Hello</p><p></p><p>World</p></body></html>'
|
||||
text = lib.html_to_text(html)
|
||||
assert 'Hello' in text and 'World' in 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)
|
||||
assert text == '[00:00:00] hello\n[01:01:11] goodbye'
|
||||
assert lib.format_caption_chunks(None) == ''
|
||||
|
||||
|
||||
def test_format_zim_search():
|
||||
zim_results = [dict(
|
||||
metadata=dict(title='Test Zim'),
|
||||
estimate=2,
|
||||
search=[
|
||||
dict(zim_id=1, path='one', title='One', headline='the first', rank=1),
|
||||
dict(zim_id=1, path='two', title='Two', headline=None, rank=1),
|
||||
],
|
||||
)]
|
||||
result = lib.format_zim_search(zim_results)
|
||||
assert result['total'] == 2
|
||||
assert result['results'][0]['link'] == '/api/zim/1/entry/one'
|
||||
assert result['results'][0]['zim'] == 'Test Zim'
|
||||
assert 'headline' not in result['results'][1]
|
||||
39
modules/ai/test/test_mcp_parity.py
Normal file
39
modules/ai/test/test_mcp_parity.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Guards for the wrolpi_mcp thin proxy.
|
||||
|
||||
The MCP server's venv (with the `mcp` package) is separate from the API's, so these are source
|
||||
scans: the tool set is deliberate (no per-kind duplicates of the generic tools), and the proxy
|
||||
must only reach the tested /api/ai blueprint (plus the whole-API statistics read)."""
|
||||
import re
|
||||
|
||||
from wrolpi.vars import PROJECT_DIR
|
||||
|
||||
MCP_SERVER = PROJECT_DIR / 'wrolpi_mcp/server.py'
|
||||
|
||||
# The MCP mirrors the compact catalog the built-in assistant uses, plus the read-only listings
|
||||
# (collections, zims, tags, downloads, map, statistics, status) that only external clients need.
|
||||
EXPECTED_TOOLS = {
|
||||
'search_files', 'get_file', 'read_content',
|
||||
'search_zim', 'get_zim_entry', 'list_zim_files',
|
||||
'list_collections', 'list_tags', 'list_files', 'read_file',
|
||||
'list_downloads', 'get_map_overview', 'search_places',
|
||||
'get_statistics', 'get_inventory', 'get_status',
|
||||
}
|
||||
|
||||
|
||||
def test_mcp_tool_set():
|
||||
text = MCP_SERVER.read_text()
|
||||
tools = set(re.findall(r'@mcp\.tool[^\n]*\)\nasync def (\w+)\(', text))
|
||||
assert tools == EXPECTED_TOOLS
|
||||
|
||||
|
||||
def test_mcp_is_a_thin_proxy():
|
||||
"""The MCP calls only /api/ai (and the raw statistics read); the formatting layer is gone."""
|
||||
text = MCP_SERVER.read_text()
|
||||
endpoints = set(re.findall(r'["\'](/api/[a-z_/{}]+)', text))
|
||||
allowed_non_ai = {'/api/statistics'}
|
||||
offenders = {e for e in endpoints if not e.startswith('/api/ai') and e not in allowed_non_ai}
|
||||
assert not offenders, f'MCP tools must proxy /api/ai, found: {sorted(offenders)}'
|
||||
|
||||
# The formatting/truncation layer moved into modules/ai; it must not come back here.
|
||||
for name in ('_wrolpi_link', '_fmt_file_group', 'html_to_text', 'BeautifulSoup'):
|
||||
assert name not in text, f'{name} belongs in modules/ai, not the MCP proxy'
|
||||
78
modules/ai/test/test_service.py
Normal file
78
modules/ai/test/test_service.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""Tests for the llama-server lifecycle: on-demand start and idle unload."""
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from modules.ai import service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_llama_running_already_healthy(async_client):
|
||||
"""A healthy llama-server is used as-is and activity is recorded."""
|
||||
with mock.patch('modules.ai.service.llama_healthy', return_value=True), \
|
||||
mock.patch('modules.ai.service.start_llm_service') as start:
|
||||
assert await service.ensure_llama_running() is True
|
||||
start.assert_not_called()
|
||||
assert service.get_last_llm_activity() > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_llama_running_cold_start(async_client):
|
||||
"""An unhealthy llama-server is started via the Controller and polled until healthy."""
|
||||
health = iter([False, False, True])
|
||||
|
||||
async def fake_healthy():
|
||||
return next(health)
|
||||
|
||||
with mock.patch('modules.ai.service.llama_healthy', side_effect=fake_healthy), \
|
||||
mock.patch('modules.ai.service.start_llm_service', return_value=True) as start, \
|
||||
mock.patch('modules.ai.service.asyncio.sleep'):
|
||||
assert await service.ensure_llama_running(timeout=10) is True
|
||||
start.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_llama_running_start_fails(async_client):
|
||||
"""A Controller start failure is reported as False, not an exception."""
|
||||
with mock.patch('modules.ai.service.llama_healthy', return_value=False), \
|
||||
mock.patch('modules.ai.service.start_llm_service', return_value=False):
|
||||
assert await service.ensure_llama_running(timeout=1) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_unload(async_client, test_ai_config):
|
||||
"""llama-server is stopped only after the configured idle minutes."""
|
||||
from wrolpi.api_utils import api_app
|
||||
from modules.ai.config import get_ai_config
|
||||
|
||||
get_ai_config().idle_unload_minutes = 10
|
||||
|
||||
# Never used: nothing happens.
|
||||
api_app.shared_ctx.ai_llm_last_request.value = 0.0
|
||||
with mock.patch('modules.ai.service.stop_llm_service') as stop:
|
||||
await service.perpetual_llm_idle_unload()
|
||||
stop.assert_not_called()
|
||||
|
||||
# Recently used: not stopped.
|
||||
api_app.shared_ctx.ai_llm_last_request.value = time.time()
|
||||
with mock.patch('modules.ai.service.stop_llm_service') as stop, \
|
||||
mock.patch('modules.ai.service.llama_healthy', return_value=True):
|
||||
await service.perpetual_llm_idle_unload()
|
||||
stop.assert_not_called()
|
||||
|
||||
# Idle past the threshold: stopped, and the activity marker resets.
|
||||
api_app.shared_ctx.ai_llm_last_request.value = time.time() - 11 * 60
|
||||
with mock.patch('modules.ai.service.stop_llm_service', return_value=True) as stop, \
|
||||
mock.patch('modules.ai.service.llama_healthy', return_value=True):
|
||||
await service.perpetual_llm_idle_unload()
|
||||
stop.assert_called_once()
|
||||
assert api_app.shared_ctx.ai_llm_last_request.value == 0.0
|
||||
|
||||
# Idle but already stopped: the marker resets without a stop call.
|
||||
api_app.shared_ctx.ai_llm_last_request.value = time.time() - 11 * 60
|
||||
with mock.patch('modules.ai.service.stop_llm_service') as stop, \
|
||||
mock.patch('modules.ai.service.llama_healthy', return_value=False):
|
||||
await service.perpetual_llm_idle_unload()
|
||||
stop.assert_not_called()
|
||||
assert api_app.shared_ctx.ai_llm_last_request.value == 0.0
|
||||
130
modules/ai/test/test_system_api.py
Normal file
130
modules/ai/test/test_system_api.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Tests for the /api/ai system endpoints (System mode)."""
|
||||
from http import HTTPStatus
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
CONTROLLER_STATS = dict(
|
||||
cpu=dict(percent=12.5),
|
||||
memory=dict(total=8_000_000_000, used=2_000_000_000),
|
||||
load=dict(minute_1=0.5),
|
||||
drives=[dict(mount='/media/wrolpi', percent=41)],
|
||||
# Keys the lean status must NOT forward (too big for small contexts).
|
||||
processes=[dict(pid=1, name='huge')],
|
||||
iostat=dict(huge=True),
|
||||
)
|
||||
|
||||
CONTROLLER_SERVICES = [
|
||||
dict(name='wrolpi-api', status='running', enabled=True, port=8081, description='API', viewable=True),
|
||||
dict(name='wrolpi-kiwix', status='failed', enabled=True, port=9085, description='', view_path='/'),
|
||||
]
|
||||
|
||||
|
||||
def _mock_controller(responses: dict):
|
||||
"""Patch controller_get to answer from {path: (status, body)}."""
|
||||
|
||||
async def fake_controller_get(path, params=None, timeout=10):
|
||||
if path not in responses:
|
||||
raise ConnectionError(f'no controller response for {path}')
|
||||
return responses[path]
|
||||
|
||||
return mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_system_status(async_client, test_session):
|
||||
"""The aggregate status contains API state, lean Controller stats, and services."""
|
||||
responses = {
|
||||
'/api/stats': (200, CONTROLLER_STATS),
|
||||
'/api/services': (200, CONTROLLER_SERVICES),
|
||||
}
|
||||
with _mock_controller(responses):
|
||||
request, response = await async_client.get('/api/ai/status')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['version']
|
||||
assert response.json['wrol_mode'] is False
|
||||
assert isinstance(response.json['flags'], dict)
|
||||
# Only the lean subset of Controller stats is forwarded.
|
||||
assert response.json['system']['cpu'] == dict(percent=12.5)
|
||||
assert 'processes' not in response.json['system']
|
||||
assert 'iostat' not in response.json['system']
|
||||
# Services are lean: no view_path/viewable noise, empty description omitted.
|
||||
services = {i['name']: i for i in response.json['services']}
|
||||
assert services['wrolpi-kiwix']['status'] == 'failed'
|
||||
assert 'viewable' not in services['wrolpi-api']
|
||||
assert 'description' not in services['wrolpi-kiwix']
|
||||
assert response.json['errors'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_system_status_controller_down(async_client, test_session):
|
||||
"""A Controller outage degrades to errors entries, never a 500."""
|
||||
with _mock_controller({}):
|
||||
request, response = await async_client.get('/api/ai/status')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['version']
|
||||
assert any('system stats' in i for i in response.json['errors'])
|
||||
assert any('service statuses' in i for i in response.json['errors'])
|
||||
assert 'system' not in response.json
|
||||
assert 'services' not in response.json
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_services(async_client, test_session):
|
||||
"""Services are listed lean; a Controller failure is a clear 502."""
|
||||
with _mock_controller({'/api/services': (200, CONTROLLER_SERVICES)}):
|
||||
request, response = await async_client.get('/api/ai/services')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['total'] == 2
|
||||
assert response.json['results'][0]['name'] == 'wrolpi-api'
|
||||
|
||||
with _mock_controller({'/api/services': (500, dict(error='boom'))}):
|
||||
request, response = await async_client.get('/api/ai/services')
|
||||
assert response.status_code == HTTPStatus.BAD_GATEWAY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_get_service_logs(async_client, test_session):
|
||||
"""Logs are proxied with the lines param clamped."""
|
||||
captured = {}
|
||||
|
||||
async def fake_controller_get(path, params=None, timeout=10):
|
||||
captured['path'] = path
|
||||
captured['params'] = params
|
||||
return 200, dict(service='wrolpi-kiwix', lines=params['lines'], logs='line1\nline2')
|
||||
|
||||
with mock.patch('modules.ai.api.controller_get', side_effect=fake_controller_get):
|
||||
request, response = await async_client.get('/api/ai/services/wrolpi-kiwix/logs?lines=999999')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert captured['path'] == '/api/services/wrolpi-kiwix/logs'
|
||||
assert captured['params']['lines'] == 1_000 # clamped
|
||||
assert response.json['logs'] == 'line1\nline2'
|
||||
|
||||
# Unknown service: the Controller's error becomes a 502 with its detail.
|
||||
with _mock_controller({'/api/services/nope/logs': (404, dict(detail='Service not found'))}):
|
||||
request, response = await async_client.get('/api/ai/services/nope/logs')
|
||||
assert response.status_code == HTTPStatus.BAD_GATEWAY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ai_list_disks(async_client, test_session):
|
||||
"""Disks and SMART are aggregated; partial failures land in errors."""
|
||||
disks = [dict(name='sda1', path='/dev/sda1', size='500G', fstype='ext4', mountpoint='/media/wrolpi')]
|
||||
responses = {
|
||||
'/api/disks': (200, disks),
|
||||
'/api/disks/smart': (200, dict(available=True, drives=[dict(name='sda', assessment='PASS')])),
|
||||
}
|
||||
with _mock_controller(responses):
|
||||
request, response = await async_client.get('/api/ai/disks')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['disks'] == disks
|
||||
assert response.json['smart']['available'] is True
|
||||
assert response.json['errors'] == []
|
||||
|
||||
# SMART unavailable (e.g. docker) is an error entry, not a failure.
|
||||
with _mock_controller({'/api/disks': (200, disks)}):
|
||||
request, response = await async_client.get('/api/ai/disks')
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json['disks'] == disks
|
||||
assert 'smart' not in response.json
|
||||
assert len(response.json['errors']) == 1
|
||||
|
|
@ -45,6 +45,9 @@ git config --global --add safe.directory /opt/wrolpi
|
|||
# CPU-heavy step on weak hardware.
|
||||
/opt/wrolpi/wrolpi/scripts/install_map_tools.sh
|
||||
|
||||
# Bake in llama.cpp (local AI) so a fresh Pi does not compile it on first upgrade.
|
||||
/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh
|
||||
|
||||
# Install Python requirements. Try multiple times because pypi may stop responding.
|
||||
python3 -m venv /opt/wrolpi/venv
|
||||
/opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt ||
|
||||
|
|
|
|||
116
scripts/ai_eval.py
Executable file
116
scripts/ai_eval.py
Executable file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Evaluate the AI assistant against a live WROLPi.
|
||||
|
||||
Prompt and mode changes have so far been validated by anecdote; this scores every answer on
|
||||
mechanical properties that do not need a judge:
|
||||
|
||||
- the stream finished with `done` (no error, no timeout)
|
||||
- at least one tool was called, and the FIRST tool was one the question expects
|
||||
- the answer contains a relative WROLPi link when one is expected
|
||||
- the answer contains no external URLs (the modes forbid them)
|
||||
|
||||
Run it before and after any prompt/tool change:
|
||||
|
||||
python3 scripts/ai_eval.py --base https://localhost:8443 [--mode research] [--timeout 600]
|
||||
|
||||
Exit code 1 when any question errors out; the score table is the real output. Requires only
|
||||
`requests` (in WROLPi's requirements). The suite reads the library map's world: questions are
|
||||
library-agnostic (browse/status/help) so they work on any WROLPi with some content."""
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
requests.packages.urllib3.disable_warnings() # noqa Self-signed certs.
|
||||
|
||||
# (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_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),
|
||||
]
|
||||
|
||||
EXTERNAL_URL = re.compile(r'https?://(?!localhost|127\.0\.0\.1)')
|
||||
RELATIVE_LINK = re.compile(r'(^|[\s(\[])/(videos|archives|docs|epub|media|download|system|modules)[/\w]')
|
||||
|
||||
|
||||
def run_question(base: str, mode: str, question: str, timeout: int) -> dict:
|
||||
started = time.time()
|
||||
tools, answer, error = [], None, None
|
||||
try:
|
||||
response = requests.post(f'{base}/api/chat/', verify=False, stream=True, timeout=timeout,
|
||||
json=dict(mode=mode, messages=[dict(role='user', content=question)]))
|
||||
if response.status_code != 200:
|
||||
return dict(error=f'HTTP {response.status_code}', tools=tools, elapsed=time.time() - started)
|
||||
event = None
|
||||
for raw_line in response.iter_lines(decode_unicode=True):
|
||||
if raw_line.startswith('event:'):
|
||||
event = raw_line[6:].strip()
|
||||
elif raw_line.startswith('data:'):
|
||||
data = json.loads(raw_line[5:].strip())
|
||||
if event == 'tool_call':
|
||||
tools.append(data.get('tool'))
|
||||
elif event == 'done':
|
||||
answer = data.get('content')
|
||||
elif event == 'error':
|
||||
error = data.get('message')
|
||||
except Exception as e:
|
||||
error = str(e)[:120]
|
||||
return dict(answer=answer, error=error, tools=tools, elapsed=time.time() - started)
|
||||
|
||||
|
||||
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),
|
||||
'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
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--base', default='https://localhost:8443')
|
||||
parser.add_argument('--mode', help='Only run one mode')
|
||||
parser.add_argument('--timeout', type=int, default=600)
|
||||
args = parser.parse_args()
|
||||
|
||||
questions = [q for q in QUESTIONS if not args.mode or q[0] == args.mode]
|
||||
passed = total = errors = 0
|
||||
for mode, question, expected_tools, expect_link in questions:
|
||||
result = run_question(args.base, mode, question, args.timeout)
|
||||
checks = score(result, expected_tools, expect_link)
|
||||
passed += sum(checks.values())
|
||||
total += len(checks)
|
||||
if result.get('error'):
|
||||
errors += 1
|
||||
flags = ' '.join(f'{name}={"Y" if ok else "N"}' for name, ok in checks.items())
|
||||
print(f'[{mode:8}] {result["elapsed"]:6.1f}s {flags} {question}')
|
||||
if not all(checks.values()):
|
||||
print(f' tools={result["tools"]}')
|
||||
detail = result.get('error') or (result.get('answer') or '')[:200]
|
||||
print(f' {detail}')
|
||||
|
||||
print(f'\nScore: {passed}/{total} checks passed across {len(questions)} questions; {errors} hard errors.')
|
||||
return 1 if errors else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -197,14 +197,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/<id>', method='GET', path='/api/ai/videos/{video_id}', needs='video_id'),
|
||||
dict(name='ai/archives/<id>', method='GET', path='/api/ai/archives/{archive_id}', needs='archive_id'),
|
||||
dict(name='ai/files/<video id>', method='GET', path='/api/ai/files/{video_id}', needs='video_id'),
|
||||
dict(name='ai/files/<archive id>', method='GET', path='/api/ai/files/{archive_id}', needs='archive_id'),
|
||||
dict(name='chat/modes', method='GET', path='/api/chat/modes'),
|
||||
]
|
||||
|
||||
|
|
|
|||
56
scripts/start_llama_server.sh
Executable file
56
scripts/start_llama_server.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#! /usr/bin/env bash
|
||||
# Start llama-server (WROLPi's local AI inference runtime) with the model selected in ai.yaml.
|
||||
#
|
||||
# AI is opt-in: this refuses to start unless ai.yaml enables it and the active model file exists
|
||||
# in the media directory (which also covers the unmounted-media case). Started/stopped on demand
|
||||
# by the Controller; the API's idle-unload worker stops it after inactivity.
|
||||
set -u
|
||||
|
||||
MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}"
|
||||
PROJECT_DIR="${PROJECT_DIR:-/opt/wrolpi}"
|
||||
AI_CONFIG="${MEDIA_DIRECTORY}/config/ai.yaml"
|
||||
# Docker overrides the host so the api container can reach this container.
|
||||
LLAMA_HOST="${LLAMA_HOST:-127.0.0.1}"
|
||||
LLAMA_PORT="${LLAMA_PORT:-11435}"
|
||||
|
||||
read_ai_config() {
|
||||
MEDIA_DIRECTORY="${MEDIA_DIRECTORY}" WROLPI_CONFIG_FILE="${AI_CONFIG}" \
|
||||
"${PROJECT_DIR}/wrolpi/scripts/read_config_value.sh" "$@"
|
||||
}
|
||||
|
||||
[ ! -d "${MEDIA_DIRECTORY}" ] && echo "Cannot start llama-server without media directory mounted" && exit 1
|
||||
[ ! -f "${AI_CONFIG}" ] && echo "Cannot start llama-server without ${AI_CONFIG}" && exit 1
|
||||
|
||||
ENABLED="$(read_ai_config enabled false)"
|
||||
if [ "${ENABLED}" != "true" ] && [ "${ENABLED}" != "True" ]; then
|
||||
echo "AI is not enabled in ${AI_CONFIG}; refusing to start"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTIVE_MODEL="$(read_ai_config active_model)"
|
||||
[ -z "${ACTIVE_MODEL}" ] && echo "No active_model in ${AI_CONFIG}; refusing to start" && exit 1
|
||||
# Defense in depth: the model must be a bare file name inside the models directory.
|
||||
case "${ACTIVE_MODEL}" in
|
||||
*/*|*..*)
|
||||
echo "Ignoring unsafe active_model '${ACTIVE_MODEL}'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
MODEL="${MEDIA_DIRECTORY}/ai/models/${ACTIVE_MODEL}"
|
||||
[ ! -f "${MODEL}" ] && echo "Model file does not exist: ${MODEL}" && exit 1
|
||||
|
||||
CONTEXT_SIZE="$(read_ai_config context_size 8192)"
|
||||
case "${CONTEXT_SIZE}" in
|
||||
''|*[!0-9]*) CONTEXT_SIZE=8192 ;;
|
||||
esac
|
||||
|
||||
# --jinja enables tool-call chat templates; mmap is llama.cpp's default so cold RAM cost is low.
|
||||
# Localhost only on native installs -- never exposed through Caddy.
|
||||
# --parallel 1: WROLPi is single-user; one slot gets the whole context.
|
||||
exec llama-server \
|
||||
--host "${LLAMA_HOST}" \
|
||||
--port "${LLAMA_PORT}" \
|
||||
--jinja \
|
||||
--parallel 1 \
|
||||
-c "${CONTEXT_SIZE}" \
|
||||
-m "${MODEL}"
|
||||
|
|
@ -258,6 +258,10 @@ rm -f /home/wrolpi/.pgpass
|
|||
# Migrate from old Leaflet/renderd/Apache2 map stack to MapLibre GL JS + PMTiles.
|
||||
/opt/wrolpi/scripts/install_protomaps.sh || echo "Protomaps migration failed."
|
||||
|
||||
# Install llama.cpp for the local AI assistant (version-guarded no-op once installed;
|
||||
# the first build takes several minutes on a Raspberry Pi).
|
||||
/opt/wrolpi/wrolpi/scripts/install_llama_cpp.sh || echo "llama.cpp install failed, continuing..."
|
||||
|
||||
# Download map fonts blob if missing.
|
||||
MAP_FONTS_BLOB=/opt/wrolpi-blobs/map-fonts.tar.gz
|
||||
if [[ ! -f ${MAP_FONTS_BLOB} || ! -s ${MAP_FONTS_BLOB} ]]; then
|
||||
|
|
|
|||
|
|
@ -1173,6 +1173,10 @@ def get_all_configs() -> Dict[str, ConfigFile]:
|
|||
if playlists_config := get_playlists_config():
|
||||
all_configs[playlists_config.file_name] = playlists_config
|
||||
|
||||
from modules.ai.config import get_ai_config
|
||||
if ai_config := get_ai_config():
|
||||
all_configs[ai_config.file_name] = ai_config
|
||||
|
||||
return all_configs
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ def attach_shared_contexts(app: Sanic):
|
|||
app.shared_ctx.download_cache_config = manager.dict()
|
||||
app.shared_ctx.map_pins_config = manager.dict()
|
||||
app.shared_ctx.flasher_config = manager.dict()
|
||||
app.shared_ctx.ai_config = manager.dict()
|
||||
# Shared dicts.
|
||||
app.shared_ctx.uploaded_files = manager.dict()
|
||||
app.shared_ctx.status = manager.dict()
|
||||
|
|
@ -57,6 +58,9 @@ def attach_shared_contexts(app: Sanic):
|
|||
app.shared_ctx.download_manager_disabled.set()
|
||||
app.shared_ctx.download_manager_stopped.set()
|
||||
|
||||
# Monotonic-ish timestamp of the last chat/LLM request; the AI idle-unload worker reads it.
|
||||
app.shared_ctx.ai_llm_last_request = multiprocessing.Value(ctypes.c_double, 0.0)
|
||||
|
||||
app.shared_ctx.single_tasks_started = multiprocessing.Event()
|
||||
app.shared_ctx.flags_initialized = multiprocessing.Event()
|
||||
|
||||
|
|
@ -126,6 +130,7 @@ def reset_shared_contexts(app: Sanic):
|
|||
app.shared_ctx.domains_config.clear()
|
||||
app.shared_ctx.archive_downloader_config.clear()
|
||||
app.shared_ctx.download_cache_config.clear()
|
||||
app.shared_ctx.ai_config.clear()
|
||||
# Shared dicts.
|
||||
app.shared_ctx.uploaded_files.clear()
|
||||
app.shared_ctx.status.clear()
|
||||
|
|
@ -313,3 +318,9 @@ def initialize_configs_contexts(app: Sanic):
|
|||
FLASHER_CONFIG.initialize(app.shared_ctx.flasher_config)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize in-memory flasher config: {e}')
|
||||
|
||||
try:
|
||||
from modules.ai.config import AI_CONFIG
|
||||
AI_CONFIG.initialize(app.shared_ctx.ai_config)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize in-memory ai config: {e}')
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ from sanic_ext.extensions.openapi import openapi
|
|||
from vininfo import Vin
|
||||
from vininfo.details._base import VinDetails
|
||||
|
||||
from modules.ai.api import ai_bp
|
||||
from modules.ai.chat import chat_bp
|
||||
from modules.archive.api import archive_bp
|
||||
from modules.docs.api import docs_bp
|
||||
from modules.flasher.api import flasher_bp
|
||||
|
|
@ -49,11 +51,16 @@ api_app.config.FALLBACK_ERROR_FORMAT = 'json'
|
|||
# Caddy is the single proxy in front of the API; trust its X-Forwarded-For so request.remote_addr
|
||||
# is the browser's address (used by the speed test).
|
||||
api_app.config.PROXIES_COUNT = 1
|
||||
# The AI chat streams for as long as CHAT_TIMEOUT_SECONDS (its own hard ceiling); Sanic's 60s
|
||||
# default killed the stream before a slow CPU produced its first token.
|
||||
api_app.config.RESPONSE_TIMEOUT = 660
|
||||
|
||||
api_bp = Blueprint('RootAPI', url_prefix='/api')
|
||||
|
||||
# Blueprints order here defines what order they are displayed in OpenAPI Docs.
|
||||
api_app.blueprint(api_bp)
|
||||
api_app.blueprint(ai_bp)
|
||||
api_app.blueprint(chat_bp)
|
||||
api_app.blueprint(archive_bp)
|
||||
api_app.blueprint(collection_bp) # Unified collection endpoints
|
||||
api_app.blueprint(config_bp)
|
||||
|
|
|
|||
88
wrolpi/scripts/install_llama_cpp.sh
Executable file
88
wrolpi/scripts/install_llama_cpp.sh
Executable file
|
|
@ -0,0 +1,88 @@
|
|||
#!/usr/bin/env bash
|
||||
# Install llama.cpp's llama-server, WROLPi's local AI inference runtime.
|
||||
#
|
||||
# This is the single source of truth, called by every place that provisions a
|
||||
# WROLPi so a version bump only has to be made here:
|
||||
# - scripts/upgrade.sh (on-device upgrade)
|
||||
# - pi-gen/stage2/04-wrolpi/03-run-chroot.sh (Raspberry Pi image build)
|
||||
# - debian-live-config/config/hooks/normal/9999-wrolpi.hook.chroot (Debian Live ISO build)
|
||||
# - docker/llm/Dockerfile (Docker llm container)
|
||||
#
|
||||
# Built from the SHA-pinned source tarball (llama.cpp ships no generic Linux
|
||||
# binaries for ARM). The build takes several minutes on a Raspberry Pi; the
|
||||
# version marker makes re-runs no-ops until the pinned version changes.
|
||||
# Run as root: installs into /usr/local/bin and apt-installs build dependencies.
|
||||
set -e
|
||||
|
||||
LLAMA_CPP_VERSION="v0.3.0"
|
||||
# SHA256 of the source tarball -- arch-independent, one value covers every build.
|
||||
LLAMA_CPP_SHA256="d94c02d86db22d68692f6bb5b3854763d5091e52142868dc7251995517c666d1"
|
||||
|
||||
# llama-server's version output tracks build numbers, not release tags, so the
|
||||
# installed release is recorded in a marker file. The +dl suffix marks the
|
||||
# multi-variant CPU build; bumping it forces a rebuild of older installs.
|
||||
MARKER_DIR=/usr/local/share/wrolpi
|
||||
MARKER="${MARKER_DIR}/llama_cpp_version"
|
||||
MARKER_VALUE="${LLAMA_CPP_VERSION}+dl1"
|
||||
|
||||
# llama-server and its dlopen'd CPU-variant backends live together here; the
|
||||
# backend loader scans the real executable's directory.
|
||||
INSTALL_DIR=/opt/llama-cpp
|
||||
|
||||
install_llama_cpp() {
|
||||
if [ -f "${MARKER}" ] && [ "$(cat "${MARKER}")" = "${MARKER_VALUE}" ] \
|
||||
&& command -v llama-server &>/dev/null; then
|
||||
echo "llama.cpp ${LLAMA_CPP_VERSION} already installed"
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Building llama.cpp ${LLAMA_CPP_VERSION}..."
|
||||
apt-get install -y build-essential cmake
|
||||
|
||||
local build_dir
|
||||
build_dir=$(mktemp -d)
|
||||
curl -fsSL "https://github.com/ggml-org/llama.cpp/archive/refs/tags/${LLAMA_CPP_VERSION}.tar.gz" \
|
||||
-o /tmp/llama_cpp.tar.gz
|
||||
echo "${LLAMA_CPP_SHA256} /tmp/llama_cpp.tar.gz" | sha256sum -c -
|
||||
tar -xz -C "${build_dir}" --strip-components=1 -f /tmp/llama_cpp.tar.gz
|
||||
rm -f /tmp/llama_cpp.tar.gz
|
||||
|
||||
# GGML_NATIVE=OFF: an image built in a chroot/QEMU (or a docker image moved
|
||||
# between machines) must not bake in the build host's CPU features. On
|
||||
# x86_64 the baseline still assumes AVX2, which SIGILLs on older CPUs
|
||||
# (AMD FX, early Atom) -- so build every CPU variant with runtime dispatch,
|
||||
# exactly like llama.cpp's official release binaries.
|
||||
local variant_flags=""
|
||||
if [ "$(uname -m)" = "x86_64" ]; then
|
||||
variant_flags="-DGGML_BACKEND_DL=ON -DGGML_CPU_ALL_VARIANTS=ON"
|
||||
fi
|
||||
(cd "${build_dir}" &&
|
||||
cmake -B build \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DCMAKE_BUILD_RPATH_USE_ORIGIN=ON \
|
||||
${variant_flags} \
|
||||
-DLLAMA_CURL=OFF \
|
||||
-DLLAMA_BUILD_TESTS=OFF \
|
||||
-DLLAMA_BUILD_EXAMPLES=OFF \
|
||||
-DLLAMA_BUILD_SERVER=ON &&
|
||||
cmake --build build -j"$(nproc)")
|
||||
|
||||
# Install the binary and every built library (CPU-variant backends included)
|
||||
# together; the backend loader scans the executable's real directory.
|
||||
rm -rf "${INSTALL_DIR}"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
cp "${build_dir}"/build/bin/llama-server "${INSTALL_DIR}/"
|
||||
cp "${build_dir}"/build/bin/*.so "${INSTALL_DIR}/" 2>/dev/null || true
|
||||
find "${build_dir}/build" -name 'lib*.so*' -exec cp {} "${INSTALL_DIR}/" \; 2>/dev/null || true
|
||||
chmod 0755 "${INSTALL_DIR}"/llama-server
|
||||
ln -sf "${INSTALL_DIR}/llama-server" /usr/local/bin/llama-server
|
||||
rm -rf "${build_dir}"
|
||||
|
||||
mkdir -p "${MARKER_DIR}"
|
||||
echo "${MARKER_VALUE}" > "${MARKER}"
|
||||
LD_LIBRARY_PATH="${INSTALL_DIR}" llama-server --version || true
|
||||
echo "llama.cpp ${LLAMA_CPP_VERSION} installed"
|
||||
}
|
||||
|
||||
install_llama_cpp
|
||||
95
wrolpi/scripts/publish_ai_models.py
Normal file
95
wrolpi/scripts/publish_ai_models.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Publish the AI model catalog to the WROLPi CDN.
|
||||
|
||||
For every model in modules/ai/catalog.py AI_MODELS this expects the GGUF file in MODELS_DIR,
|
||||
then produces and uploads (the same trust chain the maps use):
|
||||
- <model>.gguf.meta4 Metalink4 with size, sha-256, and a GPG signature of the hash
|
||||
(aria2c enforces the hash; WROLPi GPG-verifies it before download)
|
||||
- ai/models.json the manifest, with the real sha256/size values
|
||||
- ai/models.json.sig detached GPG signature of the manifest
|
||||
|
||||
Run on a machine with the roland@learningselfreliance.com secret key and an s3cmd configured
|
||||
for the wrolpi Spaces bucket:
|
||||
|
||||
python3 wrolpi/scripts/publish_ai_models.py <models_dir> [--upload]
|
||||
|
||||
Afterwards, copy the printed sha256/size values into AI_MODELS (the bundled offline catalog).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
PROJECT_DIR = pathlib.Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_DIR))
|
||||
|
||||
from modules.ai.catalog import AI_MODELS, MODELS_MANIFEST_URL # noqa: E402
|
||||
from wrolpi.scripts.build_map_regions import generate_meta4, sha256_file, sign_hash # noqa: E402
|
||||
|
||||
BUCKET = 's3://wrolpi/ai'
|
||||
GPG_UID = 'roland@learningselfreliance.com'
|
||||
|
||||
|
||||
def sign_file(path: pathlib.Path) -> pathlib.Path:
|
||||
sig_path = path.with_suffix(path.suffix + '.sig')
|
||||
sig_path.unlink(missing_ok=True)
|
||||
subprocess.run(['gpg', '--batch', '-u', GPG_UID, '--detach-sign', '--armor',
|
||||
'-o', str(sig_path), str(path)], check=True)
|
||||
return sig_path
|
||||
|
||||
|
||||
def upload(path: pathlib.Path):
|
||||
subprocess.run(['s3cmd', 'put', '--acl-public', str(path), f'{BUCKET}/{path.name}'], check=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('models_dir', type=pathlib.Path)
|
||||
parser.add_argument('--upload', action='store_true', help='Upload to the CDN with s3cmd')
|
||||
args = parser.parse_args()
|
||||
|
||||
manifest_models = []
|
||||
artifacts = []
|
||||
for model in AI_MODELS:
|
||||
path = args.models_dir / model['name']
|
||||
if not path.is_file():
|
||||
print(f'MISSING: {path} — download it first', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
size = path.stat().st_size
|
||||
print(f'Hashing {model["name"]} ({size:,} bytes)...')
|
||||
sha256 = sha256_file(str(path))
|
||||
signature = sign_hash(sha256)
|
||||
meta4_path = pathlib.Path(f'{path}.meta4')
|
||||
meta4_path.write_text(generate_meta4(model['name'], model['url'], sha256, size, signature))
|
||||
print(f' sha256 {sha256}')
|
||||
|
||||
manifest_models.append(dict(model, sha256=sha256, size=size,
|
||||
meta4_url=f'{model["url"]}.meta4'))
|
||||
artifacts += [path, meta4_path]
|
||||
|
||||
manifest_path = args.models_dir / 'models.json'
|
||||
manifest_path.write_text(json.dumps(dict(version=1, models=manifest_models), indent=2) + '\n')
|
||||
sig_path = sign_file(manifest_path)
|
||||
artifacts += [manifest_path, sig_path]
|
||||
print(f'Manifest written: {manifest_path} (URL: {MODELS_MANIFEST_URL})')
|
||||
|
||||
if args.upload:
|
||||
for path in artifacts:
|
||||
print(f'Uploading {path.name}...')
|
||||
upload(path)
|
||||
print('Upload complete.')
|
||||
else:
|
||||
print('\nDry run (no --upload). Artifacts ready:')
|
||||
for path in artifacts:
|
||||
print(f' {path}')
|
||||
|
||||
print('\nCopy these real values into modules/ai/catalog.py AI_MODELS:')
|
||||
for model in manifest_models:
|
||||
print(f' {model["name"]}: sha256={model["sha256"]} size={model["size"]}')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
|
@ -20,7 +20,8 @@ KEY="${1:?Usage: read_config_value.sh KEY [DEFAULT]}"
|
|||
DEFAULT="${2:-}"
|
||||
|
||||
MEDIA_DIRECTORY="${MEDIA_DIRECTORY:-/media/wrolpi}"
|
||||
CONFIG="${MEDIA_DIRECTORY}/config/wrolpi.yaml"
|
||||
# Another top-level config file (e.g. ai.yaml) may be selected with WROLPI_CONFIG_FILE.
|
||||
CONFIG="${WROLPI_CONFIG_FILE:-${MEDIA_DIRECTORY}/config/wrolpi.yaml}"
|
||||
|
||||
value=""
|
||||
if [ -f "${CONFIG}" ]; then
|
||||
|
|
|
|||
1
wrolpi_mcp/__init__.py
Normal file
1
wrolpi_mcp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""WROLPi MCP Server - Exposes WROLPi content to LLMs via the Model Context Protocol."""
|
||||
13
wrolpi_mcp/__main__.py
Normal file
13
wrolpi_mcp/__main__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"""Entry point: python -m wrolpi_mcp, or python wrolpi_mcp/__main__.py"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure the project root is on sys.path so wrolpi_mcp is importable
|
||||
# regardless of the working directory or how this script is invoked.
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
from wrolpi_mcp.server import mcp
|
||||
|
||||
mcp.run(transport="stdio")
|
||||
28
wrolpi_mcp/client.py
Normal file
28
wrolpi_mcp/client.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""HTTP client for the WROLPi API."""
|
||||
import httpx
|
||||
|
||||
from wrolpi_mcp.config import API_BASE_URL, VERIFY_TLS
|
||||
|
||||
|
||||
async def api_get(path: str, params: dict | None = None) -> dict:
|
||||
"""GET request to the WROLPi API. Returns parsed JSON."""
|
||||
async with httpx.AsyncClient(base_url=API_BASE_URL, verify=VERIFY_TLS, timeout=30.0) as client:
|
||||
resp = await client.get(path, params=params)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def api_post(path: str, json: dict | None = None) -> dict:
|
||||
"""POST request to the WROLPi API. Returns parsed JSON."""
|
||||
async with httpx.AsyncClient(base_url=API_BASE_URL, verify=VERIFY_TLS, timeout=30.0) as client:
|
||||
resp = await client.post(path, json=json or {})
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def api_get_text(path: str) -> str:
|
||||
"""GET request that returns raw text/HTML."""
|
||||
async with httpx.AsyncClient(base_url=API_BASE_URL, verify=VERIFY_TLS, timeout=30.0) as client:
|
||||
resp = await client.get(path)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
11
wrolpi_mcp/config.py
Normal file
11
wrolpi_mcp/config.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Configuration for the WROLPi MCP server."""
|
||||
import os
|
||||
|
||||
# WROLPi API base URL. Override with WROLPI_API_URL environment variable.
|
||||
API_BASE_URL = os.environ.get("WROLPI_API_URL", "https://localhost:8443")
|
||||
|
||||
# Whether to verify TLS certificates (WROLPi uses self-signed certs locally).
|
||||
VERIFY_TLS = os.environ.get("WROLPI_VERIFY_TLS", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
# Default search result limit.
|
||||
DEFAULT_LIMIT = 10
|
||||
2
wrolpi_mcp/requirements.txt
Normal file
2
wrolpi_mcp/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mcp[cli]>=1.26.0
|
||||
httpx>=0.27.0
|
||||
552
wrolpi_mcp/server.py
Normal file
552
wrolpi_mcp/server.py
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
"""WROLPi MCP Server — exposes WROLPi content to LLMs via the Model Context Protocol.
|
||||
|
||||
A thin proxy: every tool is one call to the WROLPi API's /api/ai blueprint, which owns (and
|
||||
tests) the lean result shapes, links, and paging. External clients (Claude etc.) and WROLPi's
|
||||
local assistant share the same compact, kind-generic tools."""
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import httpx
|
||||
|
||||
from wrolpi_mcp.client import api_get, api_post
|
||||
from wrolpi_mcp.config import API_BASE_URL, DEFAULT_LIMIT
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# All logging must go to stderr in stdio transport mode.
|
||||
logging.basicConfig(stream=sys.stderr, level=logging.INFO)
|
||||
logger = logging.getLogger("wrolpi_mcp")
|
||||
|
||||
mcp = FastMCP(
|
||||
"WROLPi",
|
||||
instructions=(
|
||||
"WROLPi is an offline digital library containing videos, archived web pages, "
|
||||
"ebooks, Zim encyclopedias (Wikipedia, etc.), maps, and documents. "
|
||||
"Use search_files to find content of any kind, get_file for details, and read_content "
|
||||
"for captions, page text, or comments. "
|
||||
"IMPORTANT: When presenting results to the user, ALWAYS include the 'WROLPi Link' "
|
||||
"for every item. Never use the 'Source URL' — only use the local WROLPi Link. "
|
||||
"When calling tools, use the top-level ID (FileGroup ID)."
|
||||
),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rendering helpers. /api/ai returns lean flat JSON with relative links; these render it as
|
||||
# text and prefix the WROLPi base URL.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Big content (captions, page text, zim entries) is read page by page up to this many chars.
|
||||
MAX_CONTENT_CHARS = 50_000
|
||||
|
||||
_FIELD_LABELS = (
|
||||
('mimetype', 'Type'),
|
||||
('size', 'Size'),
|
||||
('duration', 'Duration'),
|
||||
('published', 'Published'),
|
||||
('channel', 'Channel'),
|
||||
('has_captions', 'Has captions'),
|
||||
('has_comments', 'Has comments'),
|
||||
('author', 'Author'),
|
||||
('subject', 'Subject'),
|
||||
('language', 'Language'),
|
||||
('pages', 'Pages'),
|
||||
('url', 'Source URL'),
|
||||
('headline', 'Headline'),
|
||||
('description', 'Description'),
|
||||
)
|
||||
|
||||
_KIND_HINTS = {
|
||||
'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',
|
||||
}
|
||||
|
||||
|
||||
def _link(relative: str | None) -> str | None:
|
||||
return f'{API_BASE_URL.rstrip("/")}{relative}' if relative else None
|
||||
|
||||
|
||||
def _render_result(item: dict) -> str:
|
||||
"""One lean /api/ai result as concise text. The link goes first so the LLM can't drop it."""
|
||||
parts = []
|
||||
title = item.get('title')
|
||||
link = _link(item.get('link'))
|
||||
if title and link:
|
||||
parts.append(f'Title: {title} — LINK: {link}')
|
||||
elif title:
|
||||
parts.append(f'Title: {title}')
|
||||
elif link:
|
||||
parts.append(f'LINK: {link}')
|
||||
|
||||
if item.get('id'):
|
||||
hint = _KIND_HINTS.get(item.get('kind'))
|
||||
parts.append(f'ID: {item["id"]}' + (f' ({hint})' if hint else ''))
|
||||
for key, label in _FIELD_LABELS:
|
||||
if value := item.get(key):
|
||||
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)
|
||||
|
||||
|
||||
def _render_results(data: dict) -> str:
|
||||
results = data.get('results') or []
|
||||
if not results:
|
||||
return 'No results found.'
|
||||
sections = [f'--- Result {i} ---\n{_render_result(item)}' for i, item in enumerate(results, 1)]
|
||||
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
|
||||
|
||||
|
||||
async def _read_paged(path: str, params: dict | None = None, max_chars: int = MAX_CONTENT_CHARS) -> str:
|
||||
"""Read a paged /api/ai text endpoint until it ends or max_chars is reached."""
|
||||
params = dict(params or {})
|
||||
chunks = []
|
||||
read = 0
|
||||
offset = 0
|
||||
while True:
|
||||
data = await api_get(path, params={**params, 'offset': offset})
|
||||
chunks.append(data.get('content') or '')
|
||||
read += len(chunks[-1])
|
||||
offset = data.get('next_offset')
|
||||
if offset is None or read >= max_chars:
|
||||
break
|
||||
text = ''.join(chunks)
|
||||
if offset is not None:
|
||||
text += '\n\n[Truncated]'
|
||||
return text
|
||||
|
||||
|
||||
def _render_zim_entries(data: dict) -> str:
|
||||
results = data.get('results') or []
|
||||
if not results:
|
||||
return 'No results found.'
|
||||
sections = []
|
||||
for i, entry in enumerate(results, 1):
|
||||
parts = [f'--- Result {i} ---']
|
||||
title = entry.get('title')
|
||||
link = _link(entry.get('link'))
|
||||
if title and link:
|
||||
parts.append(f'Title: {title} — LINK: {link}')
|
||||
elif title:
|
||||
parts.append(f'Title: {title}')
|
||||
if entry.get('zim'):
|
||||
parts.append(f'Zim: {entry["zim"]}')
|
||||
if entry.get('zim_id') is not None:
|
||||
parts.append(f'Zim ID: {entry["zim_id"]} | Path: {entry.get("path", "")}')
|
||||
if entry.get('headline'):
|
||||
parts.append(f'Headline: {entry["headline"]}')
|
||||
sections.append('\n'.join(parts))
|
||||
text = '\n\n'.join(sections)
|
||||
if (total := data.get('total')) is not None:
|
||||
text += f'\n\nTotal matching: {total}'
|
||||
return text
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def search_zim(
|
||||
query: str,
|
||||
zim_id: int | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
"""Search the Zim encyclopedias (Wikipedia, Wiktionary, etc.).
|
||||
|
||||
Without zim_id this searches the Zims that have 'search by default' enabled; pass a zim_id
|
||||
(from list_zim_files) to search one specific Zim. Read a result with get_zim_entry.
|
||||
|
||||
Args:
|
||||
query: Text to search for.
|
||||
zim_id: ID of one Zim file to search; omit to search the default Zims.
|
||||
limit: Maximum results (per Zim when searching several).
|
||||
offset: Pagination offset.
|
||||
"""
|
||||
body = {"search_str": query, "limit": limit, "offset": offset}
|
||||
if zim_id is not None:
|
||||
body["zim_id"] = zim_id
|
||||
try:
|
||||
data = await api_post("/api/ai/zims/search", json=body)
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return f"Zim ID {zim_id} not found. Use list_zim_files to find valid Zim IDs."
|
||||
raise
|
||||
return _render_zim_entries(data)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def get_zim_entry(zim_id: int, entry_path: str) -> str:
|
||||
"""Read a specific article/entry from a Zim file (e.g. a Wikipedia article).
|
||||
|
||||
Use search_zim first to find entry paths.
|
||||
|
||||
Args:
|
||||
zim_id: ID of the Zim file.
|
||||
entry_path: Path to the entry within the Zim file (from search results).
|
||||
"""
|
||||
try:
|
||||
first = await api_get(f"/api/ai/zims/{zim_id}/entry", params={"path": entry_path})
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return f"Zim ID {zim_id} not found. Use list_zim_files to find valid Zim IDs."
|
||||
raise
|
||||
text = first.get("content") or ""
|
||||
if first.get("next_offset") is not None:
|
||||
text += await _read_paged(f"/api/ai/zims/{zim_id}/entry",
|
||||
params={"path": entry_path, "offset": first["next_offset"]},
|
||||
max_chars=MAX_CONTENT_CHARS - len(text))
|
||||
link = _link(first.get("link"))
|
||||
return f"LINK: {link}\n\n{text}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Library tools (the same 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def list_collections(
|
||||
kind: str | None = None,
|
||||
query: str | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> str:
|
||||
"""List collections (video channels, archived domains, playlists) in the library, paged.
|
||||
|
||||
Args:
|
||||
kind: Filter by collection kind: "channel", "domain", "playlist", or None for all.
|
||||
query: Match collection names containing this text.
|
||||
limit: Maximum results.
|
||||
offset: Pagination offset (from a previous listing's "next offset").
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
if kind:
|
||||
params["kind"] = kind
|
||||
if query:
|
||||
params["search_str"] = query
|
||||
data = await api_get("/api/ai/collections", params=params)
|
||||
collections = data.get("results") or []
|
||||
if not collections:
|
||||
return "No collections found."
|
||||
lines = []
|
||||
for collection in collections:
|
||||
parts = [f"ID: {collection.get('id')} | {collection.get('name', 'Unnamed')} ({collection.get('kind', '?')})"]
|
||||
if collection.get("directory"):
|
||||
parts.append(f" Directory: {collection['directory']}")
|
||||
lines.append("\n".join(parts))
|
||||
lines.append(f"\nTotal: {data.get('total', len(collections))}")
|
||||
if (next_offset := data.get("next_offset")) is not None:
|
||||
lines.append(f"More available: call list_collections again with offset={next_offset}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def list_files(path: str = "", offset: int = 0) -> str:
|
||||
"""List the directories and files inside one directory of the WROLPi media directory.
|
||||
|
||||
Use this to explore how the library is organized on disk (e.g. "videos/", "archive/", a channel's
|
||||
directory). Directories are listed first, then files. Every entry's relative path can be passed
|
||||
back to list_files to descend. Large directories are paged; call again with the returned offset.
|
||||
|
||||
Args:
|
||||
path: Directory relative to the media directory (e.g. "videos/SomeChannel"). Empty for the top level.
|
||||
offset: Number of entries to skip (from a previous listing's "next offset").
|
||||
"""
|
||||
try:
|
||||
data = await api_get("/api/ai/files/list", params={"path": path, "offset": offset})
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
return f"No such directory: {path or '/'}"
|
||||
raise
|
||||
lines = [f"Directory: {data.get('path') or '/'}"]
|
||||
for directory in data.get("directories") or []:
|
||||
lines.append(f" [dir] {directory['path']}")
|
||||
for file in data.get("files") or []:
|
||||
size = file.get("size")
|
||||
size_text = f"{size / (1024 * 1024):,.1f} MB" if size and size >= 1024 * 1024 else f"{size or 0:,} B"
|
||||
mimetype = file.get("mimetype") or "unknown"
|
||||
lines.append(f" [file] {file['path']} ({mimetype}, {size_text})")
|
||||
if not (data.get("directories") or data.get("files")):
|
||||
lines.append(" (empty)")
|
||||
lines.append(f"\nTotal entries: {data.get('total', 0)}")
|
||||
if (next_offset := data.get("next_offset")) is not None:
|
||||
lines.append(f"More entries available: call list_files again with offset={next_offset}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def read_file(path: str) -> str:
|
||||
"""Read a plain-text file from the media directory by its relative path (from list_files).
|
||||
|
||||
Args:
|
||||
path: File path relative to the media directory, e.g. "notes/todo.txt". Text files only.
|
||||
"""
|
||||
try:
|
||||
text = await _read_paged("/api/ai/files/read", params={"path": path})
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code in (400, 404):
|
||||
detail = ""
|
||||
try:
|
||||
detail = e.response.json().get("error") or ""
|
||||
except Exception:
|
||||
pass
|
||||
return f"Could not read {path}: {detail or 'not a readable text file'}"
|
||||
raise
|
||||
return text or f"{path} is empty."
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def list_zim_files() -> str:
|
||||
"""List all available Zim encyclopedias (Wikipedia, Wiktionary, etc.).
|
||||
|
||||
Returns Zim IDs needed for search_zim and get_zim_entry.
|
||||
"""
|
||||
data = await api_get("/api/ai/zims")
|
||||
zims = data.get("results") or []
|
||||
if not zims:
|
||||
return "No Zim files found."
|
||||
lines = []
|
||||
for zim in zims:
|
||||
size_mb = (zim.get("size") or 0) / (1024 * 1024)
|
||||
parts = [
|
||||
f"ID: {zim.get('id')} | {zim.get('title', 'Unknown')}",
|
||||
f" Creator: {zim.get('creator', 'Unknown')}",
|
||||
f" Description: {zim.get('description', 'N/A')}",
|
||||
f" Size: {size_mb:,.0f} MB",
|
||||
]
|
||||
lines.append("\n".join(parts))
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def list_tags() -> str:
|
||||
"""List every tag in the library with how many files, Zim entries, channels, and domains carry it.
|
||||
|
||||
Tag names can be passed as tag_names to the search tools. Users tag what they care about, so this
|
||||
is a good map of their interests. Also returns the most recently used tag names.
|
||||
"""
|
||||
data = await api_get("/api/ai/tags")
|
||||
results = data.get("results") or []
|
||||
if not results:
|
||||
return "No tags found."
|
||||
lines = []
|
||||
for tag in results:
|
||||
counts = ", ".join(f"{tag.get(k, 0)} {k.replace('_', ' ')}" for k in
|
||||
("file_groups", "zim_entries", "channels", "domains") if tag.get(k))
|
||||
lines.append(f" {tag['name']}" + (f" ({counts})" if counts else " (unused)"))
|
||||
text = f"Tags ({data.get('total', len(results))}):\n" + "\n".join(lines)
|
||||
if recent := data.get("recent"):
|
||||
text += f"\n\nRecently used: {', '.join(recent)}"
|
||||
return text
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def list_downloads(status: str | None = None, limit: int = DEFAULT_LIMIT) -> str:
|
||||
"""Read the WROLPi download queue: summary, recurring downloads (channels, feeds), and one-time downloads.
|
||||
|
||||
Use this when the user asks what is downloading, what failed and why, or what is scheduled.
|
||||
Downloads cannot be started, stopped, or retried from here.
|
||||
|
||||
Args:
|
||||
status: Only show downloads with this status: new, pending, failed, deferred, or complete.
|
||||
limit: Maximum recurring and one-time downloads to show (each).
|
||||
"""
|
||||
params = {"limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
data = await api_get("/api/ai/downloads", params=params)
|
||||
summary = data.get("summary") or {}
|
||||
lines = ["Summary: " + ", ".join(f"{k}={v}" for k, v in summary.items())]
|
||||
|
||||
def render(download: dict) -> str:
|
||||
parts = [f" [{download.get('status')}] {download.get('url')} (ID: {download.get('id')},"
|
||||
f" {download.get('downloader')})"]
|
||||
if download.get("frequency"):
|
||||
parts.append(f" every {download['frequency']}s, next: {download.get('next_download') or 'unscheduled'}")
|
||||
if download.get("destination"):
|
||||
parts.append(f" destination: {download['destination']}")
|
||||
if download.get("tag_names"):
|
||||
parts.append(f" tags: {', '.join(download['tag_names'])}")
|
||||
if download.get("error"):
|
||||
parts.append(f" error: {download['error']}")
|
||||
return "\n".join(parts)
|
||||
|
||||
recurring = data.get("recurring") or []
|
||||
once = data.get("once") or []
|
||||
lines.append(f"\nRecurring downloads ({len(recurring)} shown):")
|
||||
lines.extend(render(i) for i in recurring) if recurring else lines.append(" none")
|
||||
lines.append(f"\nOne-time downloads ({len(once)} shown, {data.get('pending_once', 0)} pending):")
|
||||
lines.extend(render(i) for i in once) if once else lines.append(" none")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def get_map_overview() -> str:
|
||||
"""Describe the maps on this WROLPi: downloaded map regions, whether each has a place-search index,
|
||||
subscribed regions, and the user's saved pins (with links)."""
|
||||
data = await api_get("/api/ai/map")
|
||||
lines = ["Map files:"]
|
||||
files = data.get("files") or []
|
||||
for file in files:
|
||||
size_gb = (file.get("size") or 0) / (1024 ** 3)
|
||||
index = "searchable" if file.get("has_search_index") else "no search index"
|
||||
lines.append(f" {file['name']} ({size_gb:,.2f} GB, {index})")
|
||||
if not files:
|
||||
lines.append(" none")
|
||||
subscriptions = data.get("subscriptions") or []
|
||||
names = [i.get("name") or i.get("region") if isinstance(i, dict) else str(i) for i in subscriptions]
|
||||
lines.append(f"\nSubscribed regions: {', '.join(names) if names else 'none'}")
|
||||
lines.append("\nPins:")
|
||||
pins = data.get("pins") or []
|
||||
for pin in pins:
|
||||
lines.append(f" {pin.get('label') or 'Unlabeled'} ({pin.get('lat')}, {pin.get('lon')})"
|
||||
f" LINK: {_link(pin.get('link'))}")
|
||||
if not pins:
|
||||
lines.append(" none")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def search_places(
|
||||
query: str,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
lat: float | None = None,
|
||||
lon: float | None = None,
|
||||
) -> str:
|
||||
"""Find towns, cities, and landmarks by name in the downloaded maps. Each result links to the WROLPi map.
|
||||
|
||||
Args:
|
||||
query: Place name (prefix match), e.g. "Portland".
|
||||
limit: Maximum results.
|
||||
offset: Pagination offset.
|
||||
lat: Latitude to rank nearby places first.
|
||||
lon: Longitude to rank nearby places first.
|
||||
"""
|
||||
params = {"q": query, "limit": limit, "offset": offset}
|
||||
if lat is not None and lon is not None:
|
||||
params.update(lat=lat, lon=lon)
|
||||
data = await api_get("/api/ai/map/search", params=params)
|
||||
results = data.get("results") or []
|
||||
if not results:
|
||||
return "No places found. The maps may have no search index (see get_map_overview)."
|
||||
lines = []
|
||||
for i, place in enumerate(results, 1):
|
||||
detail = ", ".join(str(place[k]) for k in ("kind", "region") if place.get(k))
|
||||
if place.get("population"):
|
||||
detail += f", pop. {place['population']:,}"
|
||||
lines.append(f"{i}. {place['name']} ({detail}) at {place.get('lat')}, {place.get('lon')}"
|
||||
f" LINK: {_link(place.get('link'))}")
|
||||
lines.append(f"\nTotal matching: {data.get('total', len(results))}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def get_statistics() -> str:
|
||||
"""Get an overview of what content is stored in the WROLPi library.
|
||||
|
||||
Returns counts and sizes for videos, archives, ebooks, Zim files, and other content.
|
||||
"""
|
||||
data = await api_get("/api/statistics")
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def get_inventory(inventory_slug: str | None = None) -> str:
|
||||
"""List the inventories (emergency supplies, food storage, etc.), or read one in full.
|
||||
|
||||
Args:
|
||||
inventory_slug: The slug of one inventory to read with every item; omit to list them all.
|
||||
"""
|
||||
if inventory_slug:
|
||||
data = await api_get("/api/ai/inventories", params={"slug": inventory_slug})
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
data = await api_get("/api/ai/inventories")
|
||||
inventories = data.get("results") or []
|
||||
if not inventories:
|
||||
return "No inventories found."
|
||||
lines = []
|
||||
for inventory in inventories:
|
||||
parts = [f"Slug: {inventory.get('slug')} | {inventory.get('name', 'Unnamed')}"]
|
||||
if inventory.get("item_count") is not None:
|
||||
parts.append(f" Items: {inventory['item_count']}")
|
||||
lines.append("\n".join(parts))
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool(annotations={"readOnlyHint": True})
|
||||
async def get_status() -> str:
|
||||
"""Get WROLPi system status (version, services, CPU, disks, flags)."""
|
||||
data = await api_get("/api/ai/status")
|
||||
return json.dumps(data, indent=2, default=str)
|
||||
Loading…
Add table
Add a link
Reference in a new issue