chore(ai): P2 batch — prompt sync, perf, dedup, scope unification

Six P2 items from the AI Workbench audit:

#7 Prompt ↔ loop budget sync:
  System prompt now says "1 bis 5 Schritte pro Planungsrunde, bis zu 5
  Planungsrunden" — matches MAX_REASONING_LOOP_ITERATIONS. Cross-ref
  comment added to runner.ts.

#9 SceneHeader: useAgents() → useAgent(id):
  Only loads the single bound agent instead of the full agent list.
  Eliminates unnecessary Dexie churn on every scene header render.

#10 Unified scope filter:
  New scope-filter.ts with filterByScopeTagMap() (batch, sync) and
  filterByScopeAsync() (per-record). Both scope-context.ts (AI) and
  scene-scope.svelte.ts (UI) now import from the shared module —
  zero duplicated filter logic.

#11 Research dedup:
  Research input ID changed from `news-research-${Date.now()}` to
  `news-research-${mission.id}` — re-runs overwrite instead of
  appending duplicates.

#12 Kontext injection policy clarified:
  loadAgentKontextAsResolvedInput no longer falls back to the global
  singleton. Comment + code aligned: kontext injection is explicit
  (via input picker), not auto. Dead loadKontextAsResolvedInput
  kept for potential future opt-in auto-inject feature.

Audit doc updated with all items marked DONE.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 16:33:52 +02:00
parent a480393bfd
commit efc7641a60
6 changed files with 84 additions and 99 deletions

View file

@ -14,7 +14,7 @@
import { goto } from '$app/navigation';
import { TagSelector, type Tag } from '@mana/shared-ui';
import { useAllTags } from '@mana/shared-stores';
import { useAgents } from '$lib/data/ai/agents/queries';
import { useAgent } from '$lib/data/ai/agents/queries';
interface Props {
scene: WorkbenchScene | null;
@ -22,16 +22,14 @@
const { scene }: Props = $props();
const allTags = $derived(useAllTags());
const agents = $derived(useAgents());
// Only load the single bound agent, not the full agent list.
const boundAgent = $derived(scene?.viewingAsAgentId ? useAgent(scene.viewingAsAgentId) : null);
// Auto-infer scopeTagIds from bound agent if scene has no explicit override
const effectiveScopeTagIds = $derived.by(() => {
if (!scene) return [];
if (scene.scopeTagIds?.length) return scene.scopeTagIds;
if (scene.viewingAsAgentId) {
const agent = agents.value.find((a) => a.id === scene.viewingAsAgentId);
return agent?.scopeTagIds ?? [];
}
if (boundAgent?.value) return boundAgent.value.scopeTagIds ?? [];
return [];
});

View file

@ -57,6 +57,9 @@ const RESEARCH_TRIGGER = /\b(recherchier|research|news|finde|suche|aktuelle|neue
* planner returns zero steps (it considers this subtask done).
* 5 is generous for read-act-refine patterns ("list_notes → tag them")
* without running the LLM bill dry on stuck missions. */
/** Keep in sync with the planner system prompt in
* packages/shared-ai/src/planner/prompt.ts which tells the LLM
* "bis zu 5 Planungsrunden pro Iteration, 15 Schritte pro Runde". */
const MAX_REASONING_LOOP_ITERATIONS = 5;
/** Min interval between Dexie phaseDetail writes during streaming.
@ -616,10 +619,13 @@ async function loadKontextAsResolvedInput(): Promise<ResolvedInput | null> {
/** Load the agent-specific kontext doc. Falls back to null (caller
* may then fall back to the global singleton if desired). */
/** Load the agent-specific kontext doc. Returns null when the agent
* has no dedicated doc (does NOT fall back to the global singleton
* kontext injection is explicit via the input picker, not auto). */
async function loadAgentKontextAsResolvedInput(agentId: string): Promise<ResolvedInput | null> {
try {
const doc = await getAgentKontext(agentId);
if (!doc) return loadKontextAsResolvedInput(); // fallback to global
if (!doc) return null;
return {
id: doc.id,
module: 'kontext',
@ -726,7 +732,9 @@ async function runWebResearch(mission: Mission): Promise<WebResearchOutcome | nu
return {
input: {
id: `news-research-${Date.now()}`,
// Stable ID so re-running the same mission replaces the prior
// research input instead of appending duplicates.
id: `news-research-${mission.id}`,
module: 'news-research',
table: 'rssArticles',
title: 'News-Recherche (RSS) zu diesem Auftrag',

View file

@ -1,24 +1,22 @@
/**
* Scope filtering for AI tool execution.
* Ambient scope context for AI tool execution.
*
* Two modes:
* 1. **Explicit** (preferred): pass `scopeTagIds` directly to
* `filterByScopeExplicit()`. Race-safe because no shared state.
* 1. **Explicit** (preferred): call `filterByScopeExplicit()` with
* scope passed directly. Race-safe.
* 2. **Ambient** (convenience for auto-tools): `withAgentScope()`
* sets module-level state; `filterByScope()` reads it. Safe only
* when missions don't run concurrently the runner must serialize.
* sets module-level state; `filterByScope()` reads it.
*
* Callers that already have the scope (e.g. the reasoning loop itself)
* should use the explicit variant. Auto-tools that don't receive scope
* as a parameter use the ambient variant.
* Core filter logic lives in `./scope-filter.ts` (shared with scene-scope).
*/
import { filterByScopeAsync } from './scope-filter';
let currentScopeTagIds: readonly string[] | null = null;
/**
* Run `fn` with the given scope tag IDs as ambient context. Clears
* the scope when `fn` completes (or throws). NOT safe for concurrent
* calls use the mutex in runMission or serialize callers.
* Run `fn` with the given scope tag IDs as ambient context. NOT safe
* for concurrent calls the runner serializes via a mutex.
*/
export async function withAgentScope<T>(
scopeTagIds: readonly string[] | undefined,
@ -38,38 +36,13 @@ export function getAgentScopeTagIds(): readonly string[] | null {
return currentScopeTagIds;
}
/**
* Core filter: keep records whose tags overlap with `scopeTagIds`.
* Untagged records (tagIds=[]) always pass through (globally visible).
* When `scopeTagIds` is null/empty, returns all records (no filtering).
*
* This is the explicit, race-safe variant pass scope directly.
*/
export async function filterByScopeExplicit<T>(
records: T[],
scopeTagIds: readonly string[] | null | undefined,
getTagIdsForRecord: (record: T) => Promise<string[]>
): Promise<T[]> {
if (!scopeTagIds?.length) return records;
/** Explicit, race-safe variant — pass scope directly. */
export { filterByScopeAsync as filterByScopeExplicit } from './scope-filter';
const scopeSet = new Set(scopeTagIds);
const results: T[] = [];
for (const r of records) {
const tagIds = await getTagIdsForRecord(r);
if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) {
results.push(r);
}
}
return results;
}
/**
* Convenience wrapper: reads the ambient scope from `withAgentScope()`.
* Use this in auto-tools that don't receive scope explicitly.
*/
/** Ambient convenience — reads scope from withAgentScope(). */
export async function filterByScope<T>(
records: T[],
getTagIdsForRecord: (record: T) => Promise<string[]>
): Promise<T[]> {
return filterByScopeExplicit(records, currentScopeTagIds, getTagIdsForRecord);
return filterByScopeAsync(records, currentScopeTagIds, getTagIdsForRecord);
}

View file

@ -0,0 +1,46 @@
/**
* Pure scope-filter primitives shared between AI scope-context
* (ephemeral, per-mission) and scene-scope (long-lived, per-scene).
*
* Rule: untagged records (no tags) are always globally visible.
* Tagged records must match at least one scope tag.
*/
/**
* Synchronous batch filter using a pre-fetched tag map.
* Use when you've already called getTagIdsForMany().
*/
export function filterByScopeTagMap<T>(
records: T[],
scopeTagIds: readonly string[] | null | undefined,
getId: (record: T) => string,
tagMap: Map<string, string[]>
): T[] {
if (!scopeTagIds?.length) return records;
const scopeSet = new Set(scopeTagIds);
return records.filter((r) => {
const tagIds = tagMap.get(getId(r));
if (!tagIds || tagIds.length === 0) return true; // untagged = global
return tagIds.some((id) => scopeSet.has(id));
});
}
/**
* Async per-record filter for callers that don't have a pre-fetched map.
*/
export async function filterByScopeAsync<T>(
records: T[],
scopeTagIds: readonly string[] | null | undefined,
getTagIdsForRecord: (record: T) => Promise<string[]>
): Promise<T[]> {
if (!scopeTagIds?.length) return records;
const scopeSet = new Set(scopeTagIds);
const results: T[] = [];
for (const r of records) {
const tagIds = await getTagIdsForRecord(r);
if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) {
results.push(r);
}
}
return results;
}

View file

@ -2,80 +2,40 @@
* Reactive scene scope propagates the active scene's scopeTagIds to
* module queries so the UI can filter records by the scene's tags.
*
* Updated by the workbench layout whenever the active scene changes.
* Module queries read `currentScopeTagIds` and filter accordingly.
* Undefined = no filtering (everything visible).
*
* This is the UI-facing counterpart of the AI-facing `withAgentScope`
* in `data/ai/scope-context.ts`. Both use the same tag system; the AI
* scope is ephemeral (duration of a mission run), this one is long-
* lived (persists as long as the scene is active).
* Core filter logic lives in `$lib/data/ai/scope-filter.ts` (shared
* with the AI scope-context). This file only owns the reactive state
* + thin wrappers that read it.
*/
import { filterByScopeTagMap, filterByScopeAsync } from '$lib/data/ai/scope-filter';
/** Reactive scope state — set by workbench layout, read by module queries. */
let _scopeTagIds = $state<readonly string[] | undefined>(undefined);
/** Set the active scene's scope. Called from the workbench layout
* when the active scene changes. Pass undefined to clear. */
export function setSceneScopeTagIds(tagIds: readonly string[] | undefined): void {
_scopeTagIds = tagIds?.length ? tagIds : undefined;
}
/** Read the current scene scope. Returns undefined when no scope is
* active (= show everything). Module queries use this to filter. */
export function getSceneScopeTagIds(): readonly string[] | undefined {
return _scopeTagIds;
}
/**
* Utility: filter records by the active scene scope. Same semantics as
* `filterByScope` in AI scope-context: untagged records pass through,
* tagged records must match at least one scope tag.
*
* 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()
* Batch filter using a pre-fetched tag map. Preferred for list queries
* (1 Dexie call instead of N).
*/
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));
});
return filterByScopeTagMap(records, _scopeTagIds, getId, tagMap);
}
/**
* Legacy per-record variant. Prefer `filterBySceneScopeBatch` for lists.
*/
/** Legacy per-record variant. Prefer batch for lists. */
export async function filterBySceneScope<T>(
records: T[],
getTagIdsForRecord: (record: T) => Promise<string[]>
): Promise<T[]> {
const scope = _scopeTagIds;
if (!scope) return records;
const scopeSet = new Set(scope);
const results: T[] = [];
for (const r of records) {
const tagIds = await getTagIdsForRecord(r);
if (tagIds.length === 0 || tagIds.some((id) => scopeSet.has(id))) {
results.push(r);
}
}
return results;
return filterByScopeAsync(records, _scopeTagIds, getTagIdsForRecord);
}

View file

@ -40,7 +40,7 @@ function buildSystemPrompt(input: AiPlanInput): string {
return `Du bist eine KI, die im Auftrag des Nutzers an einer langlebigen Mission arbeitet.
Dein Job: aus dem aktuellen Mission-Kontext einen konkreten Plan ableiten 1 bis 10 Schritte, jeder ein Tool-Aufruf auf Nutzerdaten. Jeder Schritt MUSS eine Begründung haben (rationale), die der Nutzer in der Review-UI sieht.
Dein Job: aus dem aktuellen Mission-Kontext einen konkreten Plan ableiten 1 bis 5 Schritte pro Planungsrunde, jeder ein Tool-Aufruf auf Nutzerdaten. Es gibt bis zu 5 Planungsrunden pro Iteration. Jeder Schritt MUSS eine Begründung haben (rationale), die der Nutzer in der Review-UI sieht.
Wichtige Regeln:
1. Nutze NUR Tools aus der Liste unten. Unbekannte Tools Plan invalide.