mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-18 05:09:39 +02:00
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:
parent
a480393bfd
commit
efc7641a60
6 changed files with 84 additions and 99 deletions
|
|
@ -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 [];
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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, 1–5 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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
46
apps/mana/apps/web/src/lib/data/ai/scope-filter.ts
Normal file
46
apps/mana/apps/web/src/lib/data/ai/scope-filter.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue