feat(ai): add Library AI tools (create / rate / status / list)
Some checks failed
CD Mac Mini / Detect Changes (push) Has been cancelled
CI / Detect Changes (push) Has been cancelled
CI / Validate (push) Has been cancelled
CI / Auth flow integration test (push) Has been cancelled
Docker Validate / Validate Dockerfiles (push) Has been cancelled
Mirror to Forgejo / Push to Forgejo (push) Has been cancelled
CD Mac Mini / Deploy (push) Has been cancelled
CI / Build mana-auth (push) Has been cancelled
CI / Build mana-search (push) Has been cancelled
CI / Build mana-sync (push) Has been cancelled
CI / Build mana-notify (push) Has been cancelled
CI / Build mana-api-gateway (push) Has been cancelled
CI / Build mana-crawler (push) Has been cancelled
CI / Build mana-media (push) Has been cancelled
CI / Build mana-credits (push) Has been cancelled
CI / Build mana-web (push) Has been cancelled
CI / Build chat-backend (push) Has been cancelled
CI / Build chat-web (push) Has been cancelled
CI / Build todo-backend (push) Has been cancelled
CI / Build todo-web (push) Has been cancelled
CI / Build calendar-backend (push) Has been cancelled
CI / Build calendar-web (push) Has been cancelled
CI / Build clock-web (push) Has been cancelled
CI / Build contacts-backend (push) Has been cancelled
CI / Build contacts-web (push) Has been cancelled
CI / Build presi-web (push) Has been cancelled
CI / Build storage-backend (push) Has been cancelled
CI / Build storage-web (push) Has been cancelled
CI / Build telegram-stats-bot (push) Has been cancelled
CI / Build food-backend (push) Has been cancelled
CI / Build food-web (push) Has been cancelled
CI / Build skilltree-web (push) Has been cancelled
Docker Validate / Build calendar-web (push) Has been cancelled
Docker Validate / Build quotes-web (push) Has been cancelled
Docker Validate / Build todo-backend (push) Has been cancelled
Docker Validate / Build todo-web (push) Has been cancelled
Docker Validate / Build mana-auth (push) Has been cancelled
Docker Validate / Build mana-sync (push) Has been cancelled
Docker Validate / Build mana-media (push) Has been cancelled

Library module had no AI tool coverage post the M1 skeleton. Adds
four tools so the agent can curate the reading/watch list alongside
other modules:

- create_library_entry (propose) — books/movies/series/comics with
  creators, year, status, rating, tags, genres. Default status
  "planned" covers the most common flow ("add to watchlist").
- update_library_entry_status (propose) — status transitions
  planned → active → completed (also paused / dropped). Auto-
  stamps startedAt/completedAt on the matching transitions so the
  existing Dexie projections (streaks, progress) fire correctly.
- rate_library_entry (propose) — 1-5 stars, thin wrapper over the
  store's rate() method.
- list_library_entries (auto) — id/kind/title/status/rating/year,
  filterable by kind + status.

Coverage table in apps/mana/CLAUDE.md updated (+library, +invoices
row that wasn't listed). Total now 67 tools / 21 modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-20 21:23:19 +02:00
parent ea1c9c1364
commit 8e677c9066
4 changed files with 305 additions and 1 deletions

View file

@ -210,7 +210,7 @@ The companion is a **second actor** that works alongside the human in every modu
- **Scene lens** — workbench scenes can bind to an agent via `scene.viewingAsAgentId` (context menu → "An Agent binden…"). Pure UI lens, not a data-scope change. `SceneAppBar` shows the agent avatar on bound scene tabs.
- **Workbench timeline**`/ai-workbench` renders every AI-attributed event grouped by mission iteration with per-**agent** filter, per-module, per-mission. Each bucket header shows agent avatar + name + mission title. Per-bucket **Revert button** undoes the iteration's writes via `data/ai/revert/` (TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first). Separate **"Datenzugriff"** tab exposes the server-side decrypt audit (for missions with Key-Grants).
### Tool Coverage (59 tools, 19 modules)
### Tool Coverage (67 tools, 21 modules)
Agents interact with the app through tools — each one either auto (executes silently during reasoning) or propose (creates a Proposal card the user must approve). Source of truth: `AI_TOOL_CATALOG` in `@mana/shared-ai/src/tools/schemas.ts` — both webapp policy (`src/lib/data/ai/policy.ts`) and server-side planner (`services/mana-ai/src/planner/tools.ts`) derive from it automatically, so drift is structurally impossible.
@ -235,6 +235,8 @@ Agents interact with the app through tools — each one either auto (executes si
| finance | `add_transaction` | `get_month_summary`, `list_transactions` |
| times | `start_timer`, `stop_timer` | `get_time_stats`, `get_timer_status`, `list_projects` |
| wetter | — | `get_weather`, `get_rain_forecast` |
| invoices | `create_invoice`, `mark_invoice_paid` | `list_invoices`, `get_invoice_stats` |
| library | `create_library_entry`, `update_library_entry_status`, `rate_library_entry` | `list_library_entries` |
**Server-side web-research**: mana-ai calls mana-api's `/api/v1/news-research/discover` + `/search` directly before the planner prompt is built (pre-planning injection). Missions with research-keyword objectives get real article URLs + excerpts injected as a synthetic ResolvedInput. See `services/mana-ai/src/planner/news-research-client.ts`.

View file

@ -42,6 +42,7 @@ import { wishesTools } from '$lib/modules/wishes/tools';
import { wetterTools } from '$lib/modules/wetter/tools';
import { quizTools } from '$lib/modules/quiz/tools';
import { invoicesTools } from '$lib/modules/invoices/tools';
import { libraryTools } from '$lib/modules/library/tools';
let initialized = false;
@ -85,5 +86,6 @@ export function initTools(): void {
registerTools(wetterTools);
registerTools(quizTools);
registerTools(invoicesTools);
registerTools(libraryTools);
initialized = true;
}

View file

@ -0,0 +1,193 @@
/**
* Library tools AI-accessible CRUD over the encrypted library table.
*
* Propose:
* - create_library_entry new book/movie/series/comic
* - update_library_entry_status planned active completed
* - rate_library_entry 1-5 stars
*
* Auto:
* - list_library_entries filtered by kind + status
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { libraryEntriesStore } from './stores/entries.svelte';
import { db } from '$lib/data/database';
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
import { toLibraryEntry } from './queries';
import type { LocalLibraryEntry, LibraryKind, LibraryStatus } from './types';
const KINDS = ['book', 'movie', 'series', 'comic'] as const;
const STATUSES = ['planned', 'active', 'completed', 'paused', 'dropped'] as const;
function splitList(raw: unknown): string[] | undefined {
if (typeof raw !== 'string') return undefined;
const parts = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return parts.length > 0 ? parts : undefined;
}
function nowIso(): string {
return new Date().toISOString();
}
export const libraryTools: ModuleTool[] = [
{
name: 'create_library_entry',
module: 'library',
description: 'Erstellt einen neuen Bibliotheks-Eintrag',
parameters: [
{ name: 'kind', type: 'string', description: 'Art', required: true },
{ name: 'title', type: 'string', description: 'Titel', required: true },
{ name: 'creators', type: 'string', description: 'Autor/Regie, CSV', required: false },
{ name: 'year', type: 'number', description: 'Jahr', required: false },
{ name: 'status', type: 'string', description: 'Status', required: false },
{ name: 'rating', type: 'number', description: 'Bewertung 1-5', required: false },
{ name: 'tags', type: 'string', description: 'Tags CSV', required: false },
{ name: 'genres', type: 'string', description: 'Genres CSV', required: false },
],
async execute(params) {
const kind = params.kind as LibraryKind;
if (!KINDS.includes(kind)) {
return { success: false, message: `Unbekannte Art: ${kind}` };
}
const status = (params.status as LibraryStatus | undefined) ?? 'planned';
if (!STATUSES.includes(status)) {
return { success: false, message: `Unbekannter Status: ${status}` };
}
const title = String(params.title ?? '').trim();
if (!title) return { success: false, message: 'title darf nicht leer sein' };
const ratingNum = typeof params.rating === 'number' ? params.rating : null;
if (ratingNum !== null && (ratingNum < 1 || ratingNum > 5)) {
return { success: false, message: 'rating muss zwischen 1 und 5 liegen' };
}
const entry = await libraryEntriesStore.createEntry({
kind,
title,
creators: splitList(params.creators),
year: typeof params.year === 'number' ? params.year : null,
status,
rating: ratingNum,
tags: splitList(params.tags),
genres: splitList(params.genres),
});
return {
success: true,
data: { entryId: entry.id, kind: entry.kind, title: entry.title },
message: `${entry.kind} "${entry.title}" angelegt`,
};
},
},
{
name: 'update_library_entry_status',
module: 'library',
description:
'Aendert den Status eines Eintrags. Setzt bei active / completed automatisch die passenden Zeitstempel.',
parameters: [
{ name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true },
{ name: 'status', type: 'string', description: 'Neuer Status', required: true },
],
async execute(params) {
const entryId = String(params.entryId ?? '');
const status = params.status as LibraryStatus;
if (!STATUSES.includes(status)) {
return { success: false, message: `Unbekannter Status: ${status}` };
}
const existing = await db.table<LocalLibraryEntry>('libraryEntries').get(entryId);
if (!existing || existing.deletedAt) {
return { success: false, message: `Eintrag ${entryId} nicht gefunden` };
}
const patch: Partial<LocalLibraryEntry> = { status };
if (status === 'active' && !existing.startedAt) patch.startedAt = nowIso();
if (status === 'completed' && !existing.completedAt) patch.completedAt = nowIso();
await libraryEntriesStore.updateEntry(entryId, patch);
return {
success: true,
data: { entryId, status },
message: `Status auf "${status}" gesetzt`,
};
},
},
{
name: 'rate_library_entry',
module: 'library',
description: 'Setzt die Bewertung (1-5) eines Eintrags',
parameters: [
{ name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true },
{ name: 'rating', type: 'number', description: 'Bewertung 1-5', required: true },
],
async execute(params) {
const entryId = String(params.entryId ?? '');
const rating = params.rating as number;
if (typeof rating !== 'number' || rating < 1 || rating > 5) {
return { success: false, message: 'rating muss zwischen 1 und 5 liegen' };
}
await libraryEntriesStore.rate(entryId, rating);
return {
success: true,
data: { entryId, rating },
message: `Bewertung ${rating}/5 gesetzt`,
};
},
},
{
name: 'list_library_entries',
module: 'library',
description:
'Listet Bibliotheks-Eintraege (id, kind, title, status, rating). Filterbar nach kind und status.',
parameters: [
{ name: 'kind', type: 'string', description: 'Nur eine Art', required: false },
{ name: 'status', type: 'string', description: 'Nur einen Status', required: false },
{ name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false },
],
async execute(params) {
const kindFilter = params.kind as LibraryKind | undefined;
const statusFilter = params.status as LibraryStatus | undefined;
const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100);
try {
const all = await db.table<LocalLibraryEntry>('libraryEntries').toArray();
const visible = all.filter((e) => !e.deletedAt);
const decrypted = await decryptRecords('libraryEntries', visible);
const rows = decrypted
.map(toLibraryEntry)
.filter((e) => (kindFilter ? e.kind === kindFilter : true))
.filter((e) => (statusFilter ? e.status === statusFilter : true))
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, limit)
.map((e) => ({
id: e.id,
kind: e.kind,
title: e.title,
status: e.status,
rating: e.rating,
year: e.year,
}));
return {
success: true,
data: { entries: rows, total: rows.length },
message: `${rows.length} Eintrag(e) gelistet`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return {
success: false,
message: 'Vault ist gesperrt — Bibliothek kann nicht entschlüsselt werden',
};
}
throw err;
}
},
},
];

View file

@ -1216,6 +1216,113 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
defaultPolicy: 'auto',
parameters: [],
},
// ── Library ───────────────────────────────────────────────
{
name: 'create_library_entry',
module: 'library',
description:
'Erstellt einen neuen Eintrag in der Bibliothek (Buch, Film, Serie oder Comic). Default-Status ist "planned" falls nicht anders angegeben.',
defaultPolicy: 'propose',
parameters: [
{
name: 'kind',
type: 'string',
description: 'Art des Eintrags',
required: true,
enum: ['book', 'movie', 'series', 'comic'],
},
{ name: 'title', type: 'string', description: 'Titel', required: true },
{
name: 'creators',
type: 'string',
description: 'Autor/Regisseur/Creator, mehrere durch Komma trennen',
required: false,
},
{ name: 'year', type: 'number', description: 'Erscheinungsjahr', required: false },
{
name: 'status',
type: 'string',
description: 'Anfangsstatus',
required: false,
enum: ['planned', 'active', 'completed', 'paused', 'dropped'],
},
{
name: 'rating',
type: 'number',
description: 'Bewertung 1-5 (nur bei completed sinnvoll)',
required: false,
},
{
name: 'tags',
type: 'string',
description: 'Tags durch Komma getrennt',
required: false,
},
{
name: 'genres',
type: 'string',
description: 'Genres durch Komma getrennt',
required: false,
},
],
},
{
name: 'update_library_entry_status',
module: 'library',
description:
'Aendert den Status eines Bibliotheks-Eintrags (planned/active/completed/paused/dropped). Setzt beim Wechsel auf "active" automatisch startedAt, bei "completed" completedAt.',
defaultPolicy: 'propose',
parameters: [
{ name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true },
{
name: 'status',
type: 'string',
description: 'Neuer Status',
required: true,
enum: ['planned', 'active', 'completed', 'paused', 'dropped'],
},
],
},
{
name: 'rate_library_entry',
module: 'library',
description: 'Setzt die Bewertung (1-5) eines Bibliotheks-Eintrags.',
defaultPolicy: 'propose',
parameters: [
{ name: 'entryId', type: 'string', description: 'ID des Eintrags', required: true },
{ name: 'rating', type: 'number', description: 'Bewertung 1 bis 5', required: true },
],
},
{
name: 'list_library_entries',
module: 'library',
description:
'Listet Bibliotheks-Eintraege (id, kind, title, status, rating). Optional nach Art und Status filterbar.',
defaultPolicy: 'auto',
parameters: [
{
name: 'kind',
type: 'string',
description: 'Nur eine Art zeigen',
required: false,
enum: ['book', 'movie', 'series', 'comic'],
},
{
name: 'status',
type: 'string',
description: 'Nur einen Status zeigen',
required: false,
enum: ['planned', 'active', 'completed', 'paused', 'dropped'],
},
{
name: 'limit',
type: 'number',
description: 'Maximale Anzahl (Standard 30)',
required: false,
},
],
},
];
// ═══════════════════════════════════════════════════════════════