mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +02:00
fix(ai): P1 batch — N+1 queries, vault-locked, debug hardening, timeout
Four P1 fixes from the AI Workbench audit: #3 N+1 junction queries → batch lookups: - TagLinkOps gains getTagIdsForMany(entityIds) — single where(field).anyOf(ids).toArray() instead of N calls. - filterBySceneScopeBatch() uses a pre-fetched Map<id, tagId[]>. - All 4 module queries (notes, todo, contacts, calendar) migrated. - 500 notes now = 2 Dexie queries (records + junctions) instead of 501. #4 Vault-locked detection in readLocalNote: - Catches VaultLockedError from decryptRecords. - Throws descriptive "Vault ist gesperrt" instead of returning null. - Tools surface it as a clear error to the planner ("bitte Vault entsperren") instead of "Notiz nicht gefunden". #5 Debug log hardening: - Resolved-input content truncated to 500 chars before storage. - Time-based purge: entries older than 7 days auto-deleted. - Reduces privacy exposure if device is stolen/profile synced. #6 Timeout 90s → 180s: - 5 LLM calls on slow models (Ollama/GPU) regularly hit 90s. - 180s gives comfortable headroom for the reasoning loop. Audit doc updated with status markers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
93358ed002
commit
a480393bfd
9 changed files with 131 additions and 22 deletions
|
|
@ -19,6 +19,12 @@ import type { ResolvedInput } from './planner/types';
|
|||
const TABLE = '_aiDebugLog';
|
||||
const STORAGE_KEY = 'mana.ai.debug';
|
||||
const MAX_ENTRIES = 50;
|
||||
/** Auto-purge entries older than this to limit exposure of decrypted
|
||||
* content in the local-only table. */
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
/** Max chars per resolved-input content stored in debug. Longer content
|
||||
* is truncated to reduce the privacy surface if the device is stolen. */
|
||||
const INPUT_CONTENT_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* Captured by `aiPlanTask` and passed back via the planner output so the
|
||||
|
|
@ -83,11 +89,28 @@ export function setAiDebugEnabled(enabled: boolean): void {
|
|||
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
/** Persist one debug entry + trim oldest if over cap. Idempotent on
|
||||
* iterationId — re-running an iteration overwrites the prior capture. */
|
||||
/** Truncate resolved-input content before persisting so the debug
|
||||
* table doesn't store full decrypted note/kontext bodies at rest. */
|
||||
function sanitizeForStorage(entry: AiDebugEntry): AiDebugEntry {
|
||||
return {
|
||||
...entry,
|
||||
resolvedInputs: entry.resolvedInputs.map((inp) => ({
|
||||
...inp,
|
||||
content:
|
||||
inp.content.length > INPUT_CONTENT_LIMIT
|
||||
? inp.content.slice(0, INPUT_CONTENT_LIMIT) + '\n… (truncated for privacy)'
|
||||
: inp.content,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist one debug entry + trim oldest if over cap + purge old entries.
|
||||
* Idempotent on iterationId — re-running an iteration overwrites prior. */
|
||||
export async function recordAiDebug(entry: AiDebugEntry): Promise<void> {
|
||||
try {
|
||||
await db.table<AiDebugEntry>(TABLE).put(entry);
|
||||
await db.table<AiDebugEntry>(TABLE).put(sanitizeForStorage(entry));
|
||||
|
||||
// Count-based cap
|
||||
const total = await db.table<AiDebugEntry>(TABLE).count();
|
||||
if (total > MAX_ENTRIES) {
|
||||
const overflow = total - MAX_ENTRIES;
|
||||
|
|
@ -100,6 +123,17 @@ export async function recordAiDebug(entry: AiDebugEntry): Promise<void> {
|
|||
await db.table<AiDebugEntry>(TABLE).bulkDelete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// Time-based purge: drop entries older than MAX_AGE_MS
|
||||
const cutoff = new Date(Date.now() - MAX_AGE_MS).toISOString();
|
||||
const expired = await db
|
||||
.table<AiDebugEntry>(TABLE)
|
||||
.where('capturedAt')
|
||||
.below(cutoff)
|
||||
.primaryKeys();
|
||||
if (expired.length) {
|
||||
await db.table<AiDebugEntry>(TABLE).bulkDelete(expired);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[AiDebug] persist failed:', err);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,11 @@ const KONTEXT_SINGLETON_ID = 'singleton';
|
|||
* and finalises the iteration as failed. 90 s is comfortable for a
|
||||
* cloud-tier model but short enough that a wedged backend doesn't sit
|
||||
* in `running` indefinitely. */
|
||||
const ITERATION_TIMEOUT_MS = 90_000;
|
||||
/** 180s gives the reasoning loop (up to 5 LLM calls) enough headroom
|
||||
* even on slow models. Each call can take 10–30s on Ollama/GPU with
|
||||
* network latency; the old 90s limit regularly timed out during the
|
||||
* second loop round. */
|
||||
const ITERATION_TIMEOUT_MS = 180_000;
|
||||
|
||||
class CancelledError extends Error {
|
||||
constructor(reason: string) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { filterBySceneScope } from '$lib/stores/scene-scope.svelte';
|
||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||
import { eventTagOps } from './stores/tags.svelte';
|
||||
import type { LocalCalendar, LocalEvent, Calendar, CalendarEvent } from './types';
|
||||
import { timeBlockToCalendarEvent } from './types';
|
||||
|
|
@ -67,10 +67,12 @@ export function useAllCalendarItems() {
|
|||
eventsById.set(e.id, e);
|
||||
}
|
||||
|
||||
// Scene scope filter: only calendar-sourced blocks are tag-filterable.
|
||||
const scopedBlocks = await filterBySceneScope(decryptedBlocks, async (b) =>
|
||||
b.sourceModule === 'calendar' && b.sourceId ? eventTagOps.getTagIds(b.sourceId) : []
|
||||
);
|
||||
// Scene scope filter: batch-fetch event tags for calendar-sourced blocks.
|
||||
const calendarSourceIds = decryptedBlocks
|
||||
.filter((b) => b.sourceModule === 'calendar' && b.sourceId)
|
||||
.map((b) => b.sourceId);
|
||||
const tagMap = await eventTagOps.getTagIdsForMany(calendarSourceIds);
|
||||
const scopedBlocks = filterBySceneScopeBatch(decryptedBlocks, (b) => b.sourceId ?? '', tagMap);
|
||||
|
||||
// Convert to CalendarEvent, joining event data for calendar blocks
|
||||
return scopedBlocks.map((block) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { filterBySceneScope } from '$lib/stores/scene-scope.svelte';
|
||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||
import { contactTagOps } from './stores/tags.svelte';
|
||||
import type { LocalContact, Contact, SortField, ContactFilter } from './types';
|
||||
|
||||
|
|
@ -57,7 +57,8 @@ export function useAllContacts() {
|
|||
(c) => !c.deletedAt
|
||||
);
|
||||
const decrypted = await decryptRecords('contacts', visible);
|
||||
const scoped = await filterBySceneScope(decrypted, (c) => contactTagOps.getTagIds(c.id));
|
||||
const tagMap = await contactTagOps.getTagIdsForMany(decrypted.map((c) => c.id));
|
||||
const scoped = filterBySceneScopeBatch(decrypted, (c) => c.id, tagMap);
|
||||
return scoped.map(toContact);
|
||||
}, []);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { filterBySceneScope } from '$lib/stores/scene-scope.svelte';
|
||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||
import { noteTagOps } from './stores/tags.svelte';
|
||||
import type { LocalNote, Note } from './types';
|
||||
|
||||
|
|
@ -51,7 +51,8 @@ export function useAllNotes() {
|
|||
// Locked vault returns the blobs untouched so the UI can render
|
||||
// a "🔒" placeholder where title/content would be.
|
||||
const decrypted = await decryptRecords('notes', visible);
|
||||
const scoped = await filterBySceneScope(decrypted, (n) => noteTagOps.getTagIds(n.id));
|
||||
const tagMap = await noteTagOps.getTagIdsForMany(decrypted.map((n) => n.id));
|
||||
const scoped = filterBySceneScopeBatch(decrypted, (n) => n.id, tagMap);
|
||||
return scoped.map(toNote).sort((a, b) => {
|
||||
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
|
||||
return b.updatedAt.localeCompare(a.updatedAt);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type { ModuleTool } from '$lib/data/tools/types';
|
|||
import { notesStore } from './stores/notes.svelte';
|
||||
import { noteTagOps } from './stores/tags.svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
|
||||
import { filterByScope } from '$lib/data/ai/scope-context';
|
||||
import type { LocalNote } from './types';
|
||||
|
||||
|
|
@ -25,11 +25,23 @@ function excerptOf(content: string, max = 140): string {
|
|||
return flat.length <= max ? flat : flat.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
/** Read + decrypt a single note. Throws a descriptive error when the
|
||||
* vault is locked instead of returning null (which callers can't
|
||||
* distinguish from "note doesn't exist"). */
|
||||
async function readLocalNote(id: string): Promise<LocalNote | null> {
|
||||
const local = await db.table<LocalNote>('notes').get(id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
const [decrypted] = await decryptRecords('notes', [local]);
|
||||
return decrypted ?? null;
|
||||
try {
|
||||
const [decrypted] = await decryptRecords('notes', [local]);
|
||||
return decrypted ?? null;
|
||||
} catch (err) {
|
||||
if (err instanceof VaultLockedError) {
|
||||
throw new Error(
|
||||
`Vault ist gesperrt — Notiz ${id} kann nicht entschlüsselt werden. Bitte Vault entsperren.`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export const notesTools: ModuleTool[] = [
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { filterBySceneScope } from '$lib/stores/scene-scope.svelte';
|
||||
import { taskTagTable } from './collections';
|
||||
import { filterBySceneScopeBatch } from '$lib/stores/scene-scope.svelte';
|
||||
import type {
|
||||
LocalTask,
|
||||
LocalBoardView,
|
||||
|
|
@ -48,10 +47,18 @@ export function useAllTasks() {
|
|||
const locals = await db.table<LocalTask>('tasks').orderBy('order').toArray();
|
||||
const visible = locals.filter((t) => !t.deletedAt);
|
||||
const decrypted = await decryptRecords('tasks', visible);
|
||||
const scoped = await filterBySceneScope(decrypted, async (t) => {
|
||||
const links = await taskTagTable.where('taskId').equals(t.id).toArray();
|
||||
return links.filter((l) => !l.deletedAt).map((l) => l.tagId);
|
||||
});
|
||||
// Batch tag lookup: 1 query instead of N
|
||||
const ids = decrypted.map((t) => t.id);
|
||||
const allLinks =
|
||||
ids.length > 0 ? await db.table('taskLabels').where('taskId').anyOf(ids).toArray() : [];
|
||||
const tagMap = new Map<string, string[]>();
|
||||
for (const l of allLinks) {
|
||||
if (l.deletedAt) continue;
|
||||
const arr = tagMap.get(l.taskId as string);
|
||||
if (arr) arr.push(l.tagId as string);
|
||||
else tagMap.set(l.taskId as string, [l.tagId as string]);
|
||||
}
|
||||
const scoped = filterBySceneScopeBatch(decrypted, (t) => t.id, tagMap);
|
||||
return scoped.map(toTask);
|
||||
}, [] as Task[]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,33 @@ export function getSceneScopeTagIds(): readonly string[] | undefined {
|
|||
* Synchronous if tagIds are already loaded; async variant for
|
||||
* junction-table lookups (same signature as the AI version).
|
||||
*/
|
||||
/**
|
||||
* Filter records by the active scene scope using a pre-fetched tag map.
|
||||
* Pass the result of `tagOps.getTagIdsForMany(ids)` to avoid N+1 queries.
|
||||
*
|
||||
* @param getId - extract the record's ID (used as key into tagMap)
|
||||
* @param tagMap - Map<entityId, tagId[]> from getTagIdsForMany()
|
||||
*/
|
||||
export function filterBySceneScopeBatch<T>(
|
||||
records: T[],
|
||||
getId: (record: T) => string,
|
||||
tagMap: Map<string, string[]>
|
||||
): T[] {
|
||||
const scope = _scopeTagIds;
|
||||
if (!scope) return records;
|
||||
|
||||
const scopeSet = new Set(scope);
|
||||
return records.filter((r) => {
|
||||
const tagIds = tagMap.get(getId(r));
|
||||
// Untagged records (not in map or empty) are globally visible
|
||||
if (!tagIds || tagIds.length === 0) return true;
|
||||
return tagIds.some((id) => scopeSet.has(id));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy per-record variant. Prefer `filterBySceneScopeBatch` for lists.
|
||||
*/
|
||||
export async function filterBySceneScope<T>(
|
||||
records: T[],
|
||||
getTagIdsForRecord: (record: T) => Promise<string[]>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,13 @@ export interface TagLinkOpsConfig {
|
|||
export interface TagLinkOps {
|
||||
/** Get all tag IDs linked to an entity */
|
||||
getTagIds(entityId: string): Promise<string[]>;
|
||||
/**
|
||||
* Batch variant: fetch tag IDs for many entities in ONE Dexie query.
|
||||
* Returns a Map<entityId, tagId[]>. Entities with no tags are absent
|
||||
* from the map (not present with empty array). Use this in list views
|
||||
* and scope filters to avoid N+1 queries.
|
||||
*/
|
||||
getTagIdsForMany(entityIds: string[]): Promise<Map<string, string[]>>;
|
||||
/** Add a tag to an entity (no-op if already linked) */
|
||||
addTag(entityId: string, tagId: string): Promise<void>;
|
||||
/** Remove a tag from an entity (soft-delete) */
|
||||
|
|
@ -66,6 +73,20 @@ export function createTagLinkOps(config: TagLinkOpsConfig): TagLinkOps {
|
|||
return links.map((l) => l.tagId);
|
||||
},
|
||||
|
||||
async getTagIdsForMany(entityIds: string[]): Promise<Map<string, string[]>> {
|
||||
if (entityIds.length === 0) return new Map();
|
||||
const all = await config.table().where(entityIdField).anyOf(entityIds).toArray();
|
||||
const active = all.filter((r) => !r.deletedAt);
|
||||
const result = new Map<string, string[]>();
|
||||
for (const link of active) {
|
||||
const eid = link[entityIdField] as string;
|
||||
const arr = result.get(eid);
|
||||
if (arr) arr.push(link.tagId);
|
||||
else result.set(eid, [link.tagId]);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async addTag(entityId: string, tagId: string): Promise<void> {
|
||||
const existing = await getActive(entityId);
|
||||
if (existing.some((l) => l.tagId === tagId)) return;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue