From d391a3649d78299d3b2b67f89b3f29f61fe88a46 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Apr 2026 16:54:51 -0700 Subject: [PATCH 1/2] Improve benchmark retrieval controls Add request-time retrieval and extraction configuration needed for AtomicBench experiments. Improve LoCoMo-targeted extraction, temporal packaging, literal-list protection, and query constraints. Refactor changed paths to satisfy the staged fallow audit. --- .env.example | 15 + src/app/runtime-config-route-snapshot.ts | 45 +++ src/app/runtime-container.ts | 53 +--- src/config.ts | 40 +++ src/routes/memories.ts | 155 +++++----- src/routes/memory-response-formatters.ts | 3 + src/schemas/response-scalars.ts | 23 ++ src/schemas/responses.ts | 99 +------ src/schemas/search-response-parts.ts | 75 +++++ .../__tests__/extraction-cache.test.ts | 2 +- src/services/__tests__/extraction.test.ts | 66 ++++- .../__tests__/literal-list-protection.test.ts | 81 ++++++ .../observation-date-extraction.test.ts | 63 ++++ .../__tests__/quick-extraction.test.ts | 17 +- .../quoted-entity-extraction.test.ts | 87 ++++++ .../__tests__/retrieval-format.test.ts | 44 ++- .../__tests__/retrieval-policy.test.ts | 22 ++ .../__tests__/retrieval-trace.test.ts | 11 + src/services/__tests__/session-date.test.ts | 27 ++ .../__tests__/supplemental-extraction.test.ts | 15 + .../temporal-query-constraints.test.ts | 63 ++++ src/services/chunked-extraction.ts | 11 +- src/services/consensus-extraction.ts | 42 ++- src/services/content-detection.ts | 4 +- src/services/extraction-cache.ts | 12 +- src/services/extraction.ts | 130 +++++++-- src/services/ingest-post-write.ts | 13 +- src/services/literal-list-protection.ts | 191 ++++++++++++ src/services/memory-ingest.ts | 26 +- src/services/memory-service-types.ts | 2 + src/services/observation-date-extraction.ts | 70 +++++ src/services/query-keyword-matches.ts | 8 + src/services/quick-extraction.ts | 97 +++++-- src/services/quoted-entity-extraction.ts | 217 ++++++++++++++ src/services/relative-temporal.ts | 8 + src/services/retrieval-format.ts | 114 +++++++- src/services/retrieval-policy.ts | 33 ++- src/services/retrieval-trace.ts | 11 +- src/services/search-pipeline.ts | 274 +++++++++++++----- src/services/session-date.ts | 26 ++ src/services/subject-aware-ranking.ts | 6 +- src/services/supplemental-extraction.ts | 19 +- src/services/temporal-query-constraints.ts | 148 ++++++++++ 43 files changed, 2083 insertions(+), 385 deletions(-) create mode 100644 src/app/runtime-config-route-snapshot.ts create mode 100644 src/schemas/response-scalars.ts create mode 100644 src/schemas/search-response-parts.ts create mode 100644 src/services/__tests__/literal-list-protection.test.ts create mode 100644 src/services/__tests__/observation-date-extraction.test.ts create mode 100644 src/services/__tests__/quoted-entity-extraction.test.ts create mode 100644 src/services/__tests__/session-date.test.ts create mode 100644 src/services/__tests__/temporal-query-constraints.test.ts create mode 100644 src/services/literal-list-protection.ts create mode 100644 src/services/observation-date-extraction.ts create mode 100644 src/services/query-keyword-matches.ts create mode 100644 src/services/quoted-entity-extraction.ts create mode 100644 src/services/session-date.ts create mode 100644 src/services/temporal-query-constraints.ts diff --git a/.env.example b/.env.example index d954765..ed17b5b 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,21 @@ EMBEDDING_DIMENSIONS=1024 # See https://docs.atomicmemory.ai/platform/consuming-core. # CORE_RUNTIME_CONFIG_MUTATION_ENABLED=false +# --- Internal retrieval tuning --- +# Defaults mirror the balanced adaptive policy. These are experimental knobs +# for benchmark sweeps and should not be treated as stable product config. +# ADAPTIVE_SIMPLE_LIMIT=5 +# ADAPTIVE_MEDIUM_LIMIT=5 +# ADAPTIVE_COMPLEX_LIMIT=8 +# ADAPTIVE_MULTI_HOP_LIMIT=12 +# ADAPTIVE_AGGREGATION_LIMIT=25 +# LITERAL_LIST_PROTECTION_ENABLED=false +# LITERAL_LIST_PROTECTION_MAX_PROTECTED=3 +# OBSERVATION_DATE_EXTRACTION_ENABLED=false +# QUOTED_ENTITY_EXTRACTION_ENABLED=false +# TEMPORAL_QUERY_CONSTRAINT_ENABLED=false +# TEMPORAL_QUERY_CONSTRAINT_BOOST=2 + # --- Railway --- # On Railway, DATABASE_URL is injected by the Postgres plugin. # Set OPENAI_API_KEY in Railway service variables. diff --git a/src/app/runtime-config-route-snapshot.ts b/src/app/runtime-config-route-snapshot.ts new file mode 100644 index 0000000..17891ea --- /dev/null +++ b/src/app/runtime-config-route-snapshot.ts @@ -0,0 +1,45 @@ +/** + * Shared runtime-config route snapshot shape and formatter. + * + * Both the composed runtime container and the legacy route module need the + * same public config subset. Keeping the projection here prevents drift in + * `/v1/memories/health` and `/v1/memories/config` responses. + */ + +import type { EmbeddingProviderName, LLMProviderName, RuntimeConfig } from '../config.js'; + +export interface RuntimeConfigRouteSnapshot { + retrievalProfile: string; + embeddingProvider: EmbeddingProviderName; + embeddingModel: string; + llmProvider: LLMProviderName; + llmModel: string; + clarificationConflictThreshold: number; + maxSearchResults: number; + hybridSearchEnabled: boolean; + iterativeRetrievalEnabled: boolean; + entityGraphEnabled: boolean; + crossEncoderEnabled: boolean; + agenticRetrievalEnabled: boolean; + repairLoopEnabled: boolean; + runtimeConfigMutationEnabled: boolean; +} + +export function readRuntimeConfigRouteSnapshot(config: RuntimeConfig): RuntimeConfigRouteSnapshot { + return { + retrievalProfile: config.retrievalProfile, + embeddingProvider: config.embeddingProvider, + embeddingModel: config.embeddingModel, + llmProvider: config.llmProvider, + llmModel: config.llmModel, + clarificationConflictThreshold: config.clarificationConflictThreshold, + maxSearchResults: config.maxSearchResults, + hybridSearchEnabled: config.hybridSearchEnabled, + iterativeRetrievalEnabled: config.iterativeRetrievalEnabled, + entityGraphEnabled: config.entityGraphEnabled, + crossEncoderEnabled: config.crossEncoderEnabled, + agenticRetrievalEnabled: config.agenticRetrievalEnabled, + repairLoopEnabled: config.repairLoopEnabled, + runtimeConfigMutationEnabled: config.runtimeConfigMutationEnabled, + }; +} diff --git a/src/app/runtime-container.ts b/src/app/runtime-container.ts index c3c9ebe..bbd730c 100644 --- a/src/app/runtime-container.ts +++ b/src/app/runtime-container.ts @@ -28,6 +28,10 @@ import type { RetrievalProfile } from '../services/retrieval-profiles.js'; import { MemoryService } from '../services/memory-service.js'; import { initEmbedding } from '../services/embedding.js'; import { initLlm } from '../services/llm.js'; +import { + readRuntimeConfigRouteSnapshot, + type RuntimeConfigRouteSnapshot, +} from './runtime-config-route-snapshot.js'; /** * Explicit runtime configuration subset currently needed by the runtime @@ -62,6 +66,11 @@ import { initLlm } from '../services/llm.js'; */ export interface CoreRuntimeConfig { adaptiveRetrievalEnabled: boolean; + adaptiveSimpleLimit: number; + adaptiveMediumLimit: number; + adaptiveComplexLimit: number; + adaptiveMultiHopLimit: number; + adaptiveAggregationLimit: number; agenticRetrievalEnabled: boolean; auditLoggingEnabled: boolean; consensusMinMemories: number; @@ -79,6 +88,8 @@ export interface CoreRuntimeConfig { linkExpansionEnabled: boolean; linkExpansionMax: number; linkSimilarityThreshold: number; + literalListProtectionEnabled: boolean; + literalListProtectionMaxProtected: number; maxSearchResults: number; mmrEnabled: boolean; mmrLambda: number; @@ -98,6 +109,8 @@ export interface CoreRuntimeConfig { rerankSkipMinGap: number; rerankSkipTopSimilarity: number; retrievalProfileSettings: RetrievalProfile; + temporalQueryConstraintBoost: number; + temporalQueryConstraintEnabled: boolean; } /** Repositories constructed by the runtime container. */ @@ -116,28 +129,7 @@ export interface CoreRuntimeServices { } export interface CoreRuntimeConfigRouteAdapter { - current: () => { - retrievalProfile: string; - embeddingProvider: import('../config.js').EmbeddingProviderName; - embeddingModel: string; - llmProvider: import('../config.js').LLMProviderName; - llmModel: string; - clarificationConflictThreshold: number; - maxSearchResults: number; - hybridSearchEnabled: boolean; - iterativeRetrievalEnabled: boolean; - entityGraphEnabled: boolean; - crossEncoderEnabled: boolean; - agenticRetrievalEnabled: boolean; - repairLoopEnabled: boolean; - /** - * Startup-validated flag for whether PUT /v1/memories/config should mutate - * runtime config. Production deploys leave this false; dev/test toggles - * it on via the CORE_RUNTIME_CONFIG_MUTATION_ENABLED env var. Routes - * read this snapshot — never re-check env at request time. - */ - runtimeConfigMutationEnabled: boolean; - }; + current: () => RuntimeConfigRouteSnapshot; update: (updates: { similarityThreshold?: number; audnCandidateThreshold?: number; @@ -224,22 +216,7 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime { config, configRouteAdapter: { current() { - return { - retrievalProfile: config.retrievalProfile, - embeddingProvider: config.embeddingProvider, - embeddingModel: config.embeddingModel, - llmProvider: config.llmProvider, - llmModel: config.llmModel, - clarificationConflictThreshold: config.clarificationConflictThreshold, - maxSearchResults: config.maxSearchResults, - hybridSearchEnabled: config.hybridSearchEnabled, - iterativeRetrievalEnabled: config.iterativeRetrievalEnabled, - entityGraphEnabled: config.entityGraphEnabled, - crossEncoderEnabled: config.crossEncoderEnabled, - agenticRetrievalEnabled: config.agenticRetrievalEnabled, - repairLoopEnabled: config.repairLoopEnabled, - runtimeConfigMutationEnabled: config.runtimeConfigMutationEnabled, - }; + return readRuntimeConfigRouteSnapshot(config); }, update(updates) { return updateRuntimeConfig(updates); diff --git a/src/config.ts b/src/config.ts index 7d4fcce..2010bba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,11 @@ export interface RuntimeConfig { crossAgentCandidateThreshold: number; clarificationConflictThreshold: number; adaptiveRetrievalEnabled: boolean; + adaptiveSimpleLimit: number; + adaptiveMediumLimit: number; + adaptiveComplexLimit: number; + adaptiveMultiHopLimit: number; + adaptiveAggregationLimit: number; repairLoopEnabled: boolean; hybridSearchEnabled: boolean; repairLoopMinSimilarity: number; @@ -70,6 +75,8 @@ export interface RuntimeConfig { chunkOverlapTurns: number; consensusExtractionEnabled: boolean; consensusExtractionRuns: number; + observationDateExtractionEnabled: boolean; + quotedEntityExtractionEnabled: boolean; entropyGateEnabled: boolean; entropyGateThreshold: number; entropyGateAlpha: number; @@ -110,6 +117,10 @@ export interface RuntimeConfig { agenticRetrievalEnabled: boolean; rerankSkipTopSimilarity: number; rerankSkipMinGap: number; + literalListProtectionEnabled: boolean; + literalListProtectionMaxProtected: number; + temporalQueryConstraintEnabled: boolean; + temporalQueryConstraintBoost: number; deferredAudnEnabled: boolean; deferredAudnBatchSize: number; compositeGroupingEnabled: boolean; @@ -201,6 +212,16 @@ function parseLlmSeed(value: string | undefined): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } +function parsePositiveIntEnv(name: string, fallback: number): number { + const raw = optionalEnv(name); + if (!raw) return fallback; + const parsed = parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + throw new Error(`${name} must be a positive integer`); + } + return parsed; +} + function parseVectorBackend(value: string | undefined): VectorBackendName { if (!value) return 'pgvector'; if (value === 'pgvector' || value === 'ruvector-mock' || value === 'zvec-mock') return value; @@ -235,6 +256,11 @@ export const config: RuntimeConfig = { crossAgentCandidateThreshold: parseFloat(optionalEnv('CROSS_AGENT_CANDIDATE_THRESHOLD') ?? '0.75'), clarificationConflictThreshold: 0.8, adaptiveRetrievalEnabled: (process.env.ADAPTIVE_RETRIEVAL_ENABLED ?? String(retrievalProfileSettings.adaptiveRetrievalEnabled)) === 'true', + adaptiveSimpleLimit: parsePositiveIntEnv('ADAPTIVE_SIMPLE_LIMIT', 5), + adaptiveMediumLimit: parsePositiveIntEnv('ADAPTIVE_MEDIUM_LIMIT', 5), + adaptiveComplexLimit: parsePositiveIntEnv('ADAPTIVE_COMPLEX_LIMIT', 8), + adaptiveMultiHopLimit: parsePositiveIntEnv('ADAPTIVE_MULTI_HOP_LIMIT', 12), + adaptiveAggregationLimit: parsePositiveIntEnv('ADAPTIVE_AGGREGATION_LIMIT', 25), repairLoopEnabled: (process.env.REPAIR_LOOP_ENABLED ?? String(retrievalProfileSettings.repairLoopEnabled)) === 'true', hybridSearchEnabled: (process.env.HYBRID_SEARCH_ENABLED ?? String(retrievalProfileSettings.hybridSearchEnabled)) === 'true', repairLoopMinSimilarity: parseFloat(process.env.REPAIR_LOOP_MIN_SIMILARITY ?? String(retrievalProfileSettings.repairLoopMinSimilarity)), @@ -286,6 +312,8 @@ export const config: RuntimeConfig = { chunkOverlapTurns: parseInt(optionalEnv('CHUNK_OVERLAP_TURNS') ?? '1', 10), consensusExtractionEnabled: (optionalEnv('CONSENSUS_EXTRACTION_ENABLED') ?? 'false') === 'true', consensusExtractionRuns: parseInt(optionalEnv('CONSENSUS_EXTRACTION_RUNS') ?? '3', 10), + observationDateExtractionEnabled: (optionalEnv('OBSERVATION_DATE_EXTRACTION_ENABLED') ?? 'false') === 'true', + quotedEntityExtractionEnabled: (optionalEnv('QUOTED_ENTITY_EXTRACTION_ENABLED') ?? 'false') === 'true', entropyGateEnabled: (optionalEnv('ENTROPY_GATE_ENABLED') ?? 'false') === 'true', entropyGateThreshold: parseFloat(optionalEnv('ENTROPY_GATE_THRESHOLD') ?? '0.35'), entropyGateAlpha: parseFloat(optionalEnv('ENTROPY_GATE_ALPHA') ?? '0.5'), @@ -326,6 +354,10 @@ export const config: RuntimeConfig = { agenticRetrievalEnabled: (optionalEnv('AGENTIC_RETRIEVAL_ENABLED') ?? 'false') === 'true', rerankSkipTopSimilarity: parseFloat(optionalEnv('RERANK_SKIP_TOP_SIMILARITY') ?? '0.85'), rerankSkipMinGap: parseFloat(optionalEnv('RERANK_SKIP_MIN_GAP') ?? '0.05'), + literalListProtectionEnabled: (optionalEnv('LITERAL_LIST_PROTECTION_ENABLED') ?? 'false') === 'true', + literalListProtectionMaxProtected: parsePositiveIntEnv('LITERAL_LIST_PROTECTION_MAX_PROTECTED', 3), + temporalQueryConstraintEnabled: (optionalEnv('TEMPORAL_QUERY_CONSTRAINT_ENABLED') ?? 'false') === 'true', + temporalQueryConstraintBoost: parseFloat(optionalEnv('TEMPORAL_QUERY_CONSTRAINT_BOOST') ?? '2'), deferredAudnEnabled: (optionalEnv('DEFERRED_AUDN_ENABLED') ?? 'false') === 'true', deferredAudnBatchSize: parseInt(optionalEnv('DEFERRED_AUDN_BATCH_SIZE') ?? '20', 10), compositeGroupingEnabled: (optionalEnv('COMPOSITE_GROUPING_ENABLED') ?? 'true') === 'true', @@ -413,6 +445,9 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [ // Repair loop tuning 'repairLoopMinSimilarity', 'repairSkipSimilarity', 'repairDeltaThreshold', 'repairConfidenceFloor', + // Adaptive retrieval tuning + 'adaptiveSimpleLimit', 'adaptiveMediumLimit', 'adaptiveComplexLimit', + 'adaptiveMultiHopLimit', 'adaptiveAggregationLimit', // MMR 'mmrEnabled', 'mmrLambda', // Link expansion @@ -428,6 +463,7 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [ 'extractionCacheEnabled', 'embeddingCacheEnabled', 'chunkedExtractionEnabled', 'chunkSizeTurns', 'chunkOverlapTurns', 'consensusExtractionEnabled', 'consensusExtractionRuns', + 'observationDateExtractionEnabled', 'quotedEntityExtractionEnabled', 'entropyGateEnabled', 'entropyGateThreshold', 'entropyGateAlpha', // Affinity clustering 'affinityClusteringThreshold', 'affinityClusteringMinSize', @@ -449,6 +485,10 @@ export const INTERNAL_POLICY_CONFIG_FIELDS = [ 'queryAugmentationMinSimilarity', // Rerank tuning 'rerankSkipTopSimilarity', 'rerankSkipMinGap', + // Literal/list answer selection + 'literalListProtectionEnabled', 'literalListProtectionMaxProtected', + // Temporal query selection + 'temporalQueryConstraintEnabled', 'temporalQueryConstraintBoost', // Fast AUDN 'fastAudnEnabled', 'fastAudnDuplicateThreshold', // Observation / deferred diff --git a/src/routes/memories.ts b/src/routes/memories.ts index 2fefb23..4e407a7 100644 --- a/src/routes/memories.ts +++ b/src/routes/memories.ts @@ -12,7 +12,11 @@ */ import { Router, type Request, type Response } from 'express'; -import { config, updateRuntimeConfig, type EmbeddingProviderName, type LLMProviderName, type RuntimeConfig } from '../config.js'; +import { config, updateRuntimeConfig, type RuntimeConfig } from '../config.js'; +import { + readRuntimeConfigRouteSnapshot as projectRuntimeConfigRouteSnapshot, + type RuntimeConfigRouteSnapshot, +} from '../app/runtime-config-route-snapshot.js'; import { MemoryService, type RetrievalResult } from '../services/memory-service.js'; import type { MemoryScope, MemoryServiceDeps, RetrievalObservability } from '../services/memory-service-types.js'; import { @@ -72,23 +76,6 @@ interface RuntimeConfigRouteAdapter { update(updates: RuntimeConfigRouteUpdates): string[]; } -interface RuntimeConfigRouteSnapshot { - retrievalProfile: string; - embeddingProvider: EmbeddingProviderName; - embeddingModel: string; - llmProvider: LLMProviderName; - llmModel: string; - clarificationConflictThreshold: number; - maxSearchResults: number; - hybridSearchEnabled: boolean; - iterativeRetrievalEnabled: boolean; - entityGraphEnabled: boolean; - crossEncoderEnabled: boolean; - agenticRetrievalEnabled: boolean; - repairLoopEnabled: boolean; - runtimeConfigMutationEnabled: boolean; -} - interface RuntimeConfigRouteUpdates { similarityThreshold?: number; audnCandidateThreshold?: number; @@ -96,6 +83,24 @@ interface RuntimeConfigRouteUpdates { maxSearchResults?: number; } +interface IngestRequestContext { + body: IngestBody; + effectiveConfig: MemoryServiceDeps['config'] | undefined; +} + +interface SearchRequestContext { + body: SearchBody; + effectiveConfig: MemoryServiceDeps['config'] | undefined; + scope: MemoryScope; + requestLimit: number | undefined; +} + +interface MemoryByIdRouteQuery { + userId: string; + workspaceId: string | undefined; + agentId: string | undefined; +} + const STARTUP_ONLY_CONFIG_FIELDS = ['embedding_provider', 'embedding_model', 'llm_provider', 'llm_model'] as const; const defaultRuntimeConfigRouteAdapter: RuntimeConfigRouteAdapter = { @@ -153,12 +158,7 @@ function registerCors(router: Router): void { function registerIngestRoute(router: Router, service: MemoryService): void { router.post('/ingest', validateBody(IngestBodySchema), async (req: Request, res: Response) => { try { - const body = req.body as IngestBody; - const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); - const result = body.workspace - ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace, undefined, effectiveConfig) - : await service.ingest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, undefined, effectiveConfig); - res.json(formatIngestResponse(result)); + await handleIngestRequest(service, req, res, 'full'); } catch (err) { handleRouteError(res, 'POST /v1/memories/ingest', err); } @@ -168,20 +168,42 @@ function registerIngestRoute(router: Router, service: MemoryService): void { function registerQuickIngestRoute(router: Router, service: MemoryService): void { router.post('/ingest/quick', validateBody(IngestBodySchema), async (req: Request, res: Response) => { try { - const body = req.body as IngestBody; - const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); - const result = body.workspace - ? await service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace, undefined, effectiveConfig) - : body.skipExtraction - ? await service.storeVerbatim(body.userId, body.conversation, body.sourceSite, body.sourceUrl, effectiveConfig) - : await service.quickIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, undefined, effectiveConfig); - res.json(formatIngestResponse(result)); + await handleIngestRequest(service, req, res, 'quick'); } catch (err) { handleRouteError(res, 'POST /v1/memories/ingest/quick', err); } }); } +async function handleIngestRequest( + service: MemoryService, + req: Request, + res: Response, + mode: 'full' | 'quick', +): Promise { + const { body, effectiveConfig } = readIngestRequest(req, res); + const result = await runIngest(service, body, effectiveConfig, mode); + res.json(formatIngestResponse(result)); +} + +async function runIngest( + service: MemoryService, + body: IngestBody, + effectiveConfig: MemoryServiceDeps['config'] | undefined, + mode: 'full' | 'quick', +) { + if (body.workspace) { + return service.workspaceIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, body.workspace, undefined, effectiveConfig); + } + if (mode === 'full') { + return service.ingest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, undefined, effectiveConfig); + } + if (body.skipExtraction) { + return service.storeVerbatim(body.userId, body.conversation, body.sourceSite, body.sourceUrl, effectiveConfig); + } + return service.quickIngest(body.userId, body.conversation, body.sourceSite, body.sourceUrl, undefined, effectiveConfig); +} + /** * Resolve scope + clamped limit using the *effective* maxSearchResults — i.e. * the post-override value when a `config_override` was carried, or the startup @@ -197,6 +219,26 @@ function resolveSearchPreamble(body: SearchBody, maxSearchResults: number) { return { scope, requestLimit }; } +function readIngestRequest(req: Request, res: Response): IngestRequestContext { + const body = req.body as IngestBody; + return { + body, + effectiveConfig: applyRequestConfigOverride(res, body.configOverride), + }; +} + +function readSearchRequest( + req: Request, + res: Response, + configRouteAdapter: RuntimeConfigRouteAdapter, +): SearchRequestContext { + const body = req.body as SearchBody; + const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); + const maxSearchResults = effectiveConfig?.maxSearchResults ?? configRouteAdapter.current().maxSearchResults; + const { scope, requestLimit } = resolveSearchPreamble(body, maxSearchResults); + return { body, effectiveConfig, scope, requestLimit }; +} + function registerSearchRoute( router: Router, service: MemoryService, @@ -204,10 +246,7 @@ function registerSearchRoute( ): void { router.post('/search', validateBody(SearchBodySchema), async (req: Request, res: Response) => { try { - const body = req.body as SearchBody; - const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); - const maxSearchResults = effectiveConfig?.maxSearchResults ?? configRouteAdapter.current().maxSearchResults; - const { scope, requestLimit } = resolveSearchPreamble(body, maxSearchResults); + const { body, effectiveConfig, scope, requestLimit } = readSearchRequest(req, res, configRouteAdapter); const retrievalOptions: { retrievalMode?: SearchBody['retrievalMode']; tokenBudget?: SearchBody['tokenBudget']; skipRepairLoop?: boolean } = { retrievalMode: body.retrievalMode, tokenBudget: body.tokenBudget, @@ -239,10 +278,7 @@ function registerFastSearchRoute( ): void { router.post('/search/fast', validateBody(SearchBodySchema), async (req: Request, res: Response) => { try { - const body = req.body as SearchBody; - const effectiveConfig = applyRequestConfigOverride(res, body.configOverride); - const maxSearchResults = effectiveConfig?.maxSearchResults ?? configRouteAdapter.current().maxSearchResults; - const { scope, requestLimit } = resolveSearchPreamble(body, maxSearchResults); + const { body, effectiveConfig, scope, requestLimit } = readSearchRequest(req, res, configRouteAdapter); const result = await service.scopedSearch(scope, body.query, { fast: true, sourceSite: body.sourceSite, @@ -493,12 +529,7 @@ function registerGetRoute(router: Router, service: MemoryService): void { validateQuery(MemoryByIdQuerySchema), async (req: Request, res: Response) => { try { - const { id: memoryId } = req.params as unknown as { id: string }; - const q = req.query as unknown as { - userId: string; - workspaceId: string | undefined; - agentId: string | undefined; - }; + const { memoryId, q } = readMemoryByIdRequest(req); const memory = q.workspaceId ? await service.scopedGet( { kind: 'workspace', userId: q.userId, workspaceId: q.workspaceId, agentId: q.agentId! }, @@ -524,12 +555,7 @@ function registerDeleteRoute(router: Router, service: MemoryService): void { validateQuery(MemoryByIdQuerySchema), async (req: Request, res: Response) => { try { - const { id: memoryId } = req.params as unknown as { id: string }; - const q = req.query as unknown as { - userId: string; - workspaceId: string | undefined; - agentId: string | undefined; - }; + const { memoryId, q } = readMemoryByIdRequest(req); if (q.workspaceId) { const deleted = await service.scopedDelete( { kind: 'workspace', userId: q.userId, workspaceId: q.workspaceId, agentId: q.agentId! }, @@ -550,6 +576,14 @@ function registerDeleteRoute(router: Router, service: MemoryService): void { ); } +function readMemoryByIdRequest(req: Request): { memoryId: string; q: MemoryByIdRouteQuery } { + const { id: memoryId } = req.params as unknown as { id: string }; + return { + memoryId, + q: req.query as unknown as MemoryByIdRouteQuery, + }; +} + function registerAuditSummaryRoute(router: Router, service: MemoryService): void { router.get('/audit/summary', validateQuery(UserIdQuerySchema), async (req: Request, res: Response) => { try { @@ -676,22 +710,7 @@ function applyCorsHeaders(req: Request, res: Response): void { function readRuntimeConfigRouteSnapshot(): RuntimeConfigRouteSnapshot { - return { - retrievalProfile: config.retrievalProfile, - embeddingProvider: config.embeddingProvider, - embeddingModel: config.embeddingModel, - llmProvider: config.llmProvider, - llmModel: config.llmModel, - clarificationConflictThreshold: config.clarificationConflictThreshold, - maxSearchResults: config.maxSearchResults, - hybridSearchEnabled: config.hybridSearchEnabled, - iterativeRetrievalEnabled: config.iterativeRetrievalEnabled, - entityGraphEnabled: config.entityGraphEnabled, - crossEncoderEnabled: config.crossEncoderEnabled, - agenticRetrievalEnabled: config.agenticRetrievalEnabled, - repairLoopEnabled: config.repairLoopEnabled, - runtimeConfigMutationEnabled: config.runtimeConfigMutationEnabled, - }; + return projectRuntimeConfigRouteSnapshot(config); } function toSnakeCase(camel: string): string { diff --git a/src/routes/memory-response-formatters.ts b/src/routes/memory-response-formatters.ts index 3d2b8ec..e4b9799 100644 --- a/src/routes/memory-response-formatters.ts +++ b/src/routes/memory-response-formatters.ts @@ -177,6 +177,9 @@ function formatRetrievalTrace(summary: RetrievalTraceSummary) { candidate_count: summary.candidateCount, query_text: summary.queryText, skip_repair: summary.skipRepair, + ...(summary.traceId ? { trace_id: summary.traceId } : {}), + ...(summary.stageCount !== undefined ? { stage_count: summary.stageCount } : {}), + ...(summary.stageNames ? { stage_names: summary.stageNames } : {}), }; } diff --git a/src/schemas/response-scalars.ts b/src/schemas/response-scalars.ts new file mode 100644 index 0000000..6c197f9 --- /dev/null +++ b/src/schemas/response-scalars.ts @@ -0,0 +1,23 @@ +/** + * Shared scalar schemas for HTTP response validation. + * + * These preprocessors match Express serialization behavior so the development + * response validator can run before `JSON.stringify` converts values. + */ + +import { z } from './zod-setup'; + +export const IsoDateString = z.preprocess( + (value) => (value instanceof Date ? value.toISOString() : value), + z.string(), +); + +export const IsoDateStringOrNull = z.preprocess( + (value) => (value instanceof Date ? value.toISOString() : value), + z.string().nullable(), +); + +export const NumberOrNaN = z.preprocess( + (value) => (typeof value === 'number' && Number.isNaN(value) ? null : value), + z.number().nullable(), +); diff --git a/src/schemas/responses.ts b/src/schemas/responses.ts index 1900389..d303901 100644 --- a/src/schemas/responses.ts +++ b/src/schemas/responses.ts @@ -13,34 +13,14 @@ */ import { z } from './zod-setup'; - -/** - * ISO date-time string on the wire. Accepts `Date` at validation time - * so `validateResponse` middleware can run before Express serializes - * the body — Express's JSON.stringify converts Date → ISO string, - * matching the outer `z.string()` the OpenAPI spec documents. - */ -const IsoDateString = z.preprocess( - (v) => (v instanceof Date ? v.toISOString() : v), - z.string(), -); - -/** Same pattern but nullable (schema exports nullable string). */ -const IsoDateStringOrNull = z.preprocess( - (v) => (v instanceof Date ? v.toISOString() : v), - z.string().nullable(), -); - -/** - * Float that may be NaN on the JS side. `JSON.stringify(NaN)` emits - * `null`, so the wire shape is `number | null` — the schema reflects - * that, and the preprocess converts NaN so the validator (which runs - * before serialization) matches. - */ -const NumberOrNaN = z.preprocess( - (v) => (typeof v === 'number' && Number.isNaN(v) ? null : v), - z.number().nullable(), -); +import { IsoDateString, IsoDateStringOrNull } from './response-scalars.js'; +import { + ConsensusResponseSchema, + LessonCheckSchema, + ObservabilityResponseSchema, + SearchMemoryItemSchema, + TierAssignmentSchema, +} from './search-response-parts.js'; // --------------------------------------------------------------------------- // Shared sub-schemas @@ -88,69 +68,6 @@ const MemoryRowSchema = z.object({ visibility: z.enum(['agent_only', 'restricted', 'workspace']).nullable().optional(), }).passthrough().openapi({ description: 'Full memory row as emitted by core.' }); -const SearchMemoryItemSchema = z.object({ - id: z.string(), - content: z.string(), - similarity: NumberOrNaN.optional(), - score: NumberOrNaN.optional(), - importance: NumberOrNaN.optional(), - source_site: z.string().optional(), - created_at: IsoDateString.optional(), -}).openapi({ description: 'Projected memory record in a search result.' }); - -const TierAssignmentSchema = z.object({ - memory_id: z.string(), - tier: z.string(), - estimated_tokens: z.number(), -}); - -const LessonCheckSchema = z.object({ - safe: z.boolean(), - warnings: z.array(z.unknown()), - highest_severity: z.string(), - matched_count: z.number(), -}); - -const ConsensusResponseSchema = z.object({ - original_count: z.number(), - filtered_count: z.number(), - removed_count: z.number(), - removed_memory_ids: z.array(z.string()), -}); - -const RetrievalTraceSchema = z.object({ - candidate_ids: z.array(z.string()), - candidate_count: z.number(), - query_text: z.string(), - skip_repair: z.boolean(), -}); - -const PackagingTraceSchema = z.object({ - package_type: z.enum(['subject-pack', 'timeline-pack', 'tiered']), - included_ids: z.array(z.string()), - dropped_ids: z.array(z.string()), - evidence_roles: z.record(z.string(), z.enum(['primary', 'supporting', 'historical', 'contextual'])), - episode_count: z.number(), - date_count: z.number(), - has_current_marker: z.boolean(), - has_conflict_block: z.boolean(), - token_cost: z.number(), -}); - -const AssemblyTraceSchema = z.object({ - final_ids: z.array(z.string()), - final_token_cost: z.number(), - token_budget: z.number().nullable(), - primary_evidence_position: z.number().nullable(), - blocks: z.array(z.string()), -}); - -const ObservabilityResponseSchema = z.object({ - retrieval: RetrievalTraceSchema.optional(), - packaging: PackagingTraceSchema.optional(), - assembly: AssemblyTraceSchema.optional(), -}).openapi({ description: 'Retrieval pipeline trace summaries.' }); - const ClusterCandidateSchema = z.object({ member_ids: z.array(z.string()), member_contents: z.array(z.string()), diff --git a/src/schemas/search-response-parts.ts b/src/schemas/search-response-parts.ts new file mode 100644 index 0000000..a564d61 --- /dev/null +++ b/src/schemas/search-response-parts.ts @@ -0,0 +1,75 @@ +/** + * Shared Zod schema fragments for memory search responses. + * + * Keeping these pieces outside `responses.ts` prevents the route-wide schema + * catalog from accumulating every search-specific observability detail. + */ + +import { z } from './zod-setup'; +import { IsoDateString, NumberOrNaN } from './response-scalars.js'; + +export const SearchMemoryItemSchema = z.object({ + id: z.string(), + content: z.string(), + similarity: NumberOrNaN.optional(), + score: NumberOrNaN.optional(), + importance: NumberOrNaN.optional(), + source_site: z.string().optional(), + created_at: IsoDateString.optional(), +}).openapi({ description: 'Projected memory record in a search result.' }); + +export const TierAssignmentSchema = z.object({ + memory_id: z.string(), + tier: z.string(), + estimated_tokens: z.number(), +}); + +export const LessonCheckSchema = z.object({ + safe: z.boolean(), + warnings: z.array(z.unknown()), + highest_severity: z.string(), + matched_count: z.number(), +}); + +export const ConsensusResponseSchema = z.object({ + original_count: z.number(), + filtered_count: z.number(), + removed_count: z.number(), + removed_memory_ids: z.array(z.string()), +}); + +const RetrievalTraceSchema = z.object({ + candidate_ids: z.array(z.string()), + candidate_count: z.number(), + query_text: z.string(), + skip_repair: z.boolean(), + trace_id: z.string().optional(), + stage_count: z.number().optional(), + stage_names: z.array(z.string()).optional(), +}); + +const PackagingTraceSchema = z.object({ + package_type: z.enum(['subject-pack', 'timeline-pack', 'tiered']), + included_ids: z.array(z.string()), + dropped_ids: z.array(z.string()), + evidence_roles: z.record(z.string(), z.enum(['primary', 'supporting', 'historical', 'contextual'])), + episode_count: z.number(), + date_count: z.number(), + has_current_marker: z.boolean(), + has_conflict_block: z.boolean(), + token_cost: z.number(), +}); + +const AssemblyTraceSchema = z.object({ + final_ids: z.array(z.string()), + final_token_cost: z.number(), + token_budget: z.number().nullable(), + primary_evidence_position: z.number().nullable(), + blocks: z.array(z.string()), +}); + +export const ObservabilityResponseSchema = z.object({ + retrieval: RetrievalTraceSchema.optional(), + packaging: PackagingTraceSchema.optional(), + assembly: AssemblyTraceSchema.optional(), +}).openapi({ description: 'Retrieval pipeline trace summaries.' }); diff --git a/src/services/__tests__/extraction-cache.test.ts b/src/services/__tests__/extraction-cache.test.ts index b487ea1..5e020ec 100644 --- a/src/services/__tests__/extraction-cache.test.ts +++ b/src/services/__tests__/extraction-cache.test.ts @@ -51,7 +51,7 @@ describe('cachedExtractFacts', () => { const result = await cachedExtractFacts('some conversation'); expect(mockExtractFacts).toHaveBeenCalledOnce(); - expect(mockExtractFacts).toHaveBeenCalledWith('some conversation'); + expect(mockExtractFacts).toHaveBeenCalledWith('some conversation', {}); expect(result).toEqual(SAMPLE_FACTS); }); diff --git a/src/services/__tests__/extraction.test.ts b/src/services/__tests__/extraction.test.ts index 28ebc1c..a56f356 100644 --- a/src/services/__tests__/extraction.test.ts +++ b/src/services/__tests__/extraction.test.ts @@ -4,7 +4,7 @@ * and default decision construction — all without LLM calls. */ -import { describe, it, expect, vi } from 'vitest'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; /** Mock llm and fact-normalization to avoid config.ts env var requirements. */ vi.mock('../llm.js', () => ({ llm: { chat: vi.fn() } })); @@ -12,9 +12,11 @@ vi.mock('../fact-normalization.js', () => ({ normalizeExtractedFacts: (facts: unknown[]) => facts, })); +const { llm } = await import('../llm.js'); const { normalizeAction, defaultDecision, + resolveAUDN, normalizeConfidence, inferConflictConfidence, generateFallbackHeadline, @@ -24,6 +26,11 @@ const { } = await import('../extraction.js'); type AUDNAction = Awaited>; +const mockLlmChat = vi.mocked(llm.chat); + +beforeEach(() => { + mockLlmChat.mockReset(); +}); describe('normalizeAction', () => { it('passes through valid uppercase actions', () => { @@ -76,6 +83,63 @@ describe('defaultDecision', () => { }); }); +describe('resolveAUDN', () => { + it('parses fenced Anthropic JSON responses', async () => { + mockLlmChat.mockResolvedValueOnce(`\`\`\`json +{ + "action": "NOOP", + "target_memory_id": "11111111-1111-4111-8111-111111111111", + "updated_content": null, + "clarification_note": null, + "contradiction_confidence": null +} +\`\`\``); + + const decision = await resolveAUDN('User likes Vite.', [{ + id: '11111111-1111-4111-8111-111111111111', + content: 'User likes Vite.', + similarity: 0.99, + }]); + + expect(decision.action).toBe('NOOP'); + expect(decision.targetMemoryId).toBe('11111111-1111-4111-8111-111111111111'); + }); + + it('extracts JSON when the model includes prose around the object', async () => { + mockLlmChat.mockResolvedValueOnce(`The answer is: +{ + "action": "ADD", + "target_memory_id": null, + "updated_content": null, + "clarification_note": null, + "contradiction_confidence": null +} +This is final.`); + + const decision = await resolveAUDN('User uses Supabase.', []); + + expect(decision).toEqual(defaultDecision()); + }); + + it('requests a larger AUDN output budget for Anthropic-compatible models', async () => { + mockLlmChat.mockResolvedValueOnce(JSON.stringify({ + action: 'ADD', + target_memory_id: null, + updated_content: null, + clarification_note: null, + contradiction_confidence: null, + })); + + await resolveAUDN('User uses Tailwind.', []); + + expect(mockLlmChat).toHaveBeenLastCalledWith(expect.any(Array), expect.objectContaining({ + jsonMode: true, + maxTokens: 2048, + temperature: 0, + })); + }); +}); + describe('normalizeConfidence', () => { it('clamps valid numeric values to [0, 1]', () => { expect(normalizeConfidence(0.5, 'ADD', 'some fact')).toBe(0.5); diff --git a/src/services/__tests__/literal-list-protection.test.ts b/src/services/__tests__/literal-list-protection.test.ts new file mode 100644 index 0000000..0eb2489 --- /dev/null +++ b/src/services/__tests__/literal-list-protection.test.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for literal/list answer protection. + * + * These tests cover the default-off reranking guard used by targeted + * LoCoMo-style list questions where exact named answers can be dropped by MMR. + */ + +import { describe, expect, it } from 'vitest'; +import { protectLiteralListAnswerCandidates } from '../literal-list-protection.js'; +import { createSearchResult } from './test-fixtures.js'; + +function buildResult(id: string, content: string, score: number) { + return createSearchResult({ + id, + content, + user_id: 'u1', + memory_type: 'fact', + network: 'experience', + created_at: new Date('2023-07-06T00:00:00.000Z'), + last_accessed_at: new Date('2023-07-06T00:00:00.000Z'), + score, + }); +} + +describe('protectLiteralListAnswerCandidates', () => { + it('protects named pet-answer memories for pet-name questions', () => { + const result = protectLiteralListAnswerCandidates( + "What are Melanie's pets' names?", + [ + buildResult('generic', 'Melanie likes animals and enjoys caring for them.', 0.9), + buildResult('bailey', 'We got another cat named Bailey too.', 0.4), + ], + 3, + ); + + expect(result.protectedIds).toEqual(['bailey']); + expect(result.reasons).toContain('named-entity'); + expect(result.reasons).toContain('pet-domain'); + expect(result.results.find((item) => item.id === 'bailey')?.score).toBeGreaterThan(4); + }); + + it('protects quoted music-answer memories for artist-list questions', () => { + const result = protectLiteralListAnswerCandidates( + 'What musical artists or bands has Melanie seen in concert?', + [ + buildResult('playlist', 'Melanie has been listening to upbeat music lately.', 0.8), + buildResult('summer', '"Summer Sounds" played an awesome pop song.', 0.5), + ], + 3, + ); + + expect(result.protectedIds).toEqual(['summer']); + expect(result.reasons).toContain('quoted-title'); + expect(result.reasons).toContain('music-domain'); + expect(result.reasons).toContain('performance-event'); + }); + + it('prefers seen-live performer evidence over quoted song preferences', () => { + const result = protectLiteralListAnswerCandidates( + 'What musical artists or bands has Melanie seen?', + [ + buildResult('song', 'Melanie enjoys modern music, specifically Ed Sheeran song "Perfect".', 0.9), + buildResult('summer', '"Summer Sounds"- The playing an awesome pop song got everyone dancing.', 0.4), + ], + 1, + ); + + expect(result.protectedIds).toEqual(['summer']); + }); + + it('does nothing when the protection budget is zero', () => { + const result = protectLiteralListAnswerCandidates( + "What are Melanie's pets' names?", + [buildResult('bailey', 'We got another cat named Bailey too.', 0.4)], + 0, + ); + + expect(result.protectedIds).toEqual([]); + expect(result.protectedFingerprints).toEqual([]); + }); +}); diff --git a/src/services/__tests__/observation-date-extraction.test.ts b/src/services/__tests__/observation-date-extraction.test.ts new file mode 100644 index 0000000..2849a0a --- /dev/null +++ b/src/services/__tests__/observation-date-extraction.test.ts @@ -0,0 +1,63 @@ +/** + * Tests for observation-date extraction helpers. + */ + +import { describe, expect, it } from 'vitest'; +import { + applyObservationDateAnchors, + buildExtractionUserMessage, +} from '../observation-date-extraction.js'; +import type { ExtractedFact } from '../extraction.js'; + +function fact(text: string): ExtractedFact { + return { + fact: text, + headline: 'Temporal fact', + importance: 0.7, + type: 'knowledge', + keywords: [], + entities: [], + relations: [], + }; +} + +describe('buildExtractionUserMessage', () => { + it('keeps the existing prompt shape when observation date extraction is off', () => { + const message = buildExtractionUserMessage('[Session date: 2023-08-14]\nUser: I went last Friday.'); + + expect(message).toBe('Conversation to extract from:\n[Session date: 2023-08-14]\nUser: I went last Friday.'); + }); + + it('adds an explicit observation timestamp when enabled and available', () => { + const message = buildExtractionUserMessage( + '[Session date: 2023-08-14T14:24:00.000Z]\nUser: I went last Friday.', + { observationDateExtractionEnabled: true }, + ); + + expect(message).toContain('Observation timestamp: 2023-08-14T14:24:00.000Z'); + expect(message).toContain('resolve relative dates'); + expect(message).toContain('Conversation to extract from:'); + }); +}); + +describe('applyObservationDateAnchors', () => { + it('annotates relative dates in extracted facts when enabled', () => { + const [anchored] = applyObservationDateAnchors( + [fact('Caroline went to the adoption meeting last Friday.')], + '[Session date: 2023-07-15T13:51:00.000Z]\nCaroline: I went last Friday.', + { observationDateExtractionEnabled: true }, + ); + + expect(anchored?.fact).toContain('last Friday (on July 14, 2023)'); + expect(anchored?.keywords).toContain('2023-07-14'); + }); + + it('does not annotate when the flag is off', () => { + const [anchored] = applyObservationDateAnchors( + [fact('Caroline went to the adoption meeting last Friday.')], + '[Session date: 2023-07-15T13:51:00.000Z]\nCaroline: I went last Friday.', + ); + + expect(anchored?.fact).toBe('Caroline went to the adoption meeting last Friday.'); + }); +}); diff --git a/src/services/__tests__/quick-extraction.test.ts b/src/services/__tests__/quick-extraction.test.ts index 39250b1..c1cc62f 100644 --- a/src/services/__tests__/quick-extraction.test.ts +++ b/src/services/__tests__/quick-extraction.test.ts @@ -26,13 +26,14 @@ describe('quickExtractFacts', () => { it('annotates relative temporal phrases with explicit anchors', () => { const facts = quickExtractFacts( - '[Session date: 2023-01-20]\nUser: Lost my job as a banker yesterday. Unfortunately I also lost my job at Door Dash this month. I plan to perform at a nearby festival next month. Started hitting the gym last week.', + '[Session date: 2023-01-20]\nUser: Lost my job as a banker yesterday. Unfortunately I also lost my job at Door Dash this month. I plan to perform at a nearby festival next month. Started hitting the gym last week. I attended a workshop last month.', ); expect(facts.some((fact) => fact.fact.includes('yesterday (on January 19, 2023)'))).toBe(true); expect(facts.some((fact) => fact.fact.includes('this month (in January 2023)'))).toBe(true); expect(facts.some((fact) => fact.fact.includes('next month (in February 2023)'))).toBe(true); expect(facts.some((fact) => fact.fact.includes('last week (around January 13, 2023)'))).toBe(true); + expect(facts.some((fact) => fact.fact.includes('last month (in December 2022)'))).toBe(true); }); it('preserves advisor and backup-plan detail in a multi-sentence turn', () => { @@ -98,4 +99,18 @@ describe('quickExtractFacts', () => { expect(facts.some((fact) => fact.fact.includes('Last Friday (on July 14, 2023)'))).toBe(true); expect(facts.some((fact) => fact.fact.includes('Shia Labeouf'))).toBe(true); }); + + it('captures terse LoCoMo duration and medical event turns', () => { + const facts = quickExtractFacts( + [ + '[Session date: 2023-05-24]', + 'Nate: I like having some of these little turtles around to keep me calm.', + "Nate: I've had them for 3 years now and they bring me tons of joy!", + "Sam: Thanks, Evan. Appreciate the offer, but had a check-up with my doctor a few days ago and, yikes, the weight wasn't great.", + ].join('\n'), + ); + + expect(facts.some((fact) => fact.fact.includes('Nate has had the turtles for 3 years now'))).toBe(true); + expect(facts.some((fact) => fact.fact.includes('Sam had a check-up with Sam\'s doctor a few days ago'))).toBe(true); + }); }); diff --git a/src/services/__tests__/quoted-entity-extraction.test.ts b/src/services/__tests__/quoted-entity-extraction.test.ts new file mode 100644 index 0000000..4bd426d --- /dev/null +++ b/src/services/__tests__/quoted-entity-extraction.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for deterministic quoted-entity extraction. + * + * Guards exact quoted titles and performer/event facts used by benchmark + * extraction-normalization experiments. + */ + +import { describe, expect, it } from 'vitest'; +import { extractQuotedEntityFacts, mergeQuotedEntityFacts } from '../quoted-entity-extraction.js'; +import type { ExtractedFact } from '../extraction.js'; + +function baseFact(fact: string): ExtractedFact { + return { + fact, + headline: fact, + importance: 0.6, + type: 'knowledge', + keywords: [], + entities: [], + relations: [], + }; +} + +describe('extractQuotedEntityFacts', () => { + it('extracts exact quoted book titles with speaker and session date', () => { + const facts = extractQuotedEntityFacts( + '[Session date: 2023-07-06]\nMelanie: I loved reading "Charlotte\'s Web" as a kid.', + ); + + expect(facts.map((fact) => fact.fact)).toContain( + 'As of July 6 2023, Melanie read "Charlotte\'s Web".', + ); + }); + + it('extracts leading quoted performer names as seen music events', () => { + const facts = extractQuotedEntityFacts( + '[Session date: 2023-09-20]\nMelanie: "Summer Sounds"- The playing an awesome pop song got everyone dancing and singing.', + ); + + expect(facts.map((fact) => fact.fact)).toContain( + 'As of September 20 2023, Melanie saw "Summer Sounds" perform music.', + ); + }); + + it('extracts named concert performers from event facts', () => { + const facts = extractQuotedEntityFacts( + "[Session date: 2023-08-14]\nMelanie: We celebrated my daughter's birthday at a Matt Patterson concert.", + ); + + expect(facts.map((fact) => fact.fact)).toContain( + 'As of August 14 2023, Melanie saw "Matt Patterson" perform music.', + ); + }); + + it('does not treat quoted song preferences as seen-live events', () => { + const facts = extractQuotedEntityFacts( + '[Session date: 2023-08-14]\nMelanie: I enjoy Ed Sheeran song "Perfect".', + ); + + expect(facts.map((fact) => fact.fact)).not.toContain( + 'As of August 14 2023, Melanie saw "Perfect" perform music.', + ); + }); + + it('extracts pronoun-linked recommendation letter writers', () => { + const facts = extractQuotedEntityFacts( + "[Session date: 2026-01-20]\nuser: My advisor Dr. Chen at MSR has been really supportive. She's writing my main recommendation letter.", + ); + + expect(facts.map((fact) => fact.fact)).toContain( + "As of January 20 2026, Dr. Chen is writing user's main recommendation letter.", + ); + }); +}); + +describe('mergeQuotedEntityFacts', () => { + it('adds clearer event facts alongside weak existing title mentions', () => { + const merged = mergeQuotedEntityFacts( + [baseFact('"Summer Sounds"- The playing an awesome pop song got everyone dancing.')], + '[Session date: 2023-09-20]\nMelanie: "Summer Sounds"- The playing an awesome pop song got everyone dancing.', + ); + + expect(merged.map((fact) => fact.fact)).toContain( + 'As of September 20 2023, Melanie saw "Summer Sounds" perform music.', + ); + }); +}); diff --git a/src/services/__tests__/retrieval-format.test.ts b/src/services/__tests__/retrieval-format.test.ts index 8a3a161..e3c6836 100644 --- a/src/services/__tests__/retrieval-format.test.ts +++ b/src/services/__tests__/retrieval-format.test.ts @@ -14,7 +14,14 @@ vi.mock('../../config.js', () => ({ config: mockConfig, })); -const { buildCitations, computePackagingSignal, formatInjection, formatSimpleInjection, formatTieredInjection } = await import('../retrieval-format.js'); +const { + buildCitations, + buildInjection, + computePackagingSignal, + formatInjection, + formatSimpleInjection, + formatTieredInjection, +} = await import('../retrieval-format.js'); function makeResult(overrides: Partial = {}) { return createSearchResult({ @@ -200,6 +207,24 @@ describe('formatTieredInjection', () => { expect(result).not.toContain(' { + const memories = [ + makeResult({ id: 'met', content: 'James met Samantha.', created_at: new Date('2022-08-10T00:00:00Z') }), + makeResult({ id: 'move', content: 'James and Samantha decided to move in.', created_at: new Date('2022-10-31T00:00:00Z') }), + ]; + const assignments = [ + { memoryId: 'met', tier: 'L2' as const, estimatedTokens: 5 }, + { memoryId: 'move', tier: 'L2' as const, estimatedTokens: 5 }, + ]; + const result = formatTieredInjection(memories, assignments); + + expect(result).toContain('Timeline:'); + expect(result).toContain('2022-08-10 → 2022-10-31: ~3 months'); + expect(result).toContain('Key temporal evidence:'); + expect(result).toContain('- 2022-08-10: James met Samantha.'); + expect(result).toContain('- 2022-10-31: James and Samantha decided to move in.'); + }); }); describe('formatSimpleInjection', () => { @@ -256,6 +281,23 @@ describe('formatSimpleInjection', () => { }); }); +describe('buildInjection query-term visibility', () => { + it('promotes a compressed memory when L0 hides an exact query term', () => { + const result = buildInjection([ + makeResult({ + id: 'workshop', + content: 'Caroline attended an LGBTQ+ counseling workshop for therapists. '.repeat(12), + summary: 'Caroline attended LGBTQ+ counseling...', + overview: 'Caroline attended an LGBTQ+ counseling workshop for therapists.', + score: 0.4, + }), + ], 'What workshop did Caroline attend recently?', 'tiered', 35); + + expect(result.injectionText).toContain('[L1]'); + expect(result.injectionText).toContain('workshop'); + }); +}); + describe('computePackagingSignal', () => { it('returns zeros for empty input', () => { const signal = computePackagingSignal([]); diff --git a/src/services/__tests__/retrieval-policy.test.ts b/src/services/__tests__/retrieval-policy.test.ts index bb20e85..e0f2ebd 100644 --- a/src/services/__tests__/retrieval-policy.test.ts +++ b/src/services/__tests__/retrieval-policy.test.ts @@ -36,6 +36,11 @@ const retrievalProfileSettings: RetrievalProfile = { const mockConfig = { adaptiveRetrievalEnabled: true, + adaptiveSimpleLimit: 5, + adaptiveMediumLimit: 5, + adaptiveComplexLimit: 8, + adaptiveMultiHopLimit: 12, + adaptiveAggregationLimit: 25, maxSearchResults: 10, repairLoopEnabled: true, repairLoopMinSimilarity: 0.3, @@ -129,6 +134,23 @@ describe('resolveSearchLimit', () => { expect(limit).toBeGreaterThan(mockConfig.maxSearchResults); }); + it('uses configured adaptive limits when no explicit limit is provided', () => { + mockConfig.adaptiveSimpleLimit = 7; + mockConfig.adaptiveComplexLimit = 11; + + expect(resolveSearchLimit('what is TypeScript?', undefined, mockConfig)).toBe(7); + expect(resolveSearchLimit('how did the architecture change', undefined, mockConfig)).toBe(10); + + mockConfig.adaptiveSimpleLimit = 5; + mockConfig.adaptiveComplexLimit = 8; + }); + + it('uses configured aggregation limit without maxSearchResults clamp', () => { + mockConfig.adaptiveAggregationLimit = 30; + expect(resolveSearchLimit('How many model kits have I bought?', undefined, mockConfig)).toBe(30); + mockConfig.adaptiveAggregationLimit = 25; + }); + it('detects "how many" as aggregation', () => { expect(resolveSearchLimit('How many times did I mention yoga?', undefined, mockConfig)) .toBe(AGGREGATION_QUERY_LIMIT); diff --git a/src/services/__tests__/retrieval-trace.test.ts b/src/services/__tests__/retrieval-trace.test.ts index 5f55878..d9b6d13 100644 --- a/src/services/__tests__/retrieval-trace.test.ts +++ b/src/services/__tests__/retrieval-trace.test.ts @@ -46,9 +46,20 @@ describe('TraceCollector', () => { const results = [makeResult('m1', 0.95, 0.88, 'Alice likes cats')]; trace.stage('initial', results, { candidateDepth: 15 }); + trace.setRetrievalSummary({ + candidateIds: ['m1'], + candidateCount: 1, + queryText: 'test query', + skipRepair: false, + }); trace.finalize(results); expect(writeFileSpy).toHaveBeenCalledOnce(); + expect(trace.getRetrievalSummary()).toMatchObject({ + traceId: expect.stringMatching(/^trace-/), + stageCount: 2, + stageNames: ['initial', 'final'], + }); const output = getWrittenTrace(); expect(output.query).toBe('test query'); expect(output.userId).toBe('user-1'); diff --git a/src/services/__tests__/session-date.test.ts b/src/services/__tests__/session-date.test.ts new file mode 100644 index 0000000..6fb9aa4 --- /dev/null +++ b/src/services/__tests__/session-date.test.ts @@ -0,0 +1,27 @@ +/** + * Unit tests for transcript session-date parsing. + */ + +import { describe, expect, it } from 'vitest'; +import { extractSessionTimestamp, parseSessionDate, resolveSessionDate } from '../session-date.js'; + +describe('session-date helpers', () => { + it('extracts the first-line session timestamp', () => { + const timestamp = extractSessionTimestamp('[Session date: 2023-08-14T10:00:00Z]\nUser: hello'); + + expect(timestamp).toBe('2023-08-14T10:00:00Z'); + }); + + it('parses valid session dates', () => { + const parsed = parseSessionDate('[Session date: 2023-08-14]\nUser: hello'); + + expect(parsed?.toISOString()).toBe('2023-08-14T00:00:00.000Z'); + }); + + it('prefers explicit timestamps over transcript headers', () => { + const explicit = new Date('2026-01-01T00:00:00.000Z'); + const resolved = resolveSessionDate(explicit, '[Session date: 2023-08-14]\nUser: hello'); + + expect(resolved).toBe(explicit); + }); +}); diff --git a/src/services/__tests__/supplemental-extraction.test.ts b/src/services/__tests__/supplemental-extraction.test.ts index 17b9fc3..f105a62 100644 --- a/src/services/__tests__/supplemental-extraction.test.ts +++ b/src/services/__tests__/supplemental-extraction.test.ts @@ -118,4 +118,19 @@ describe('mergeSupplementalFacts', () => { expect(merged.some((fact) => fact.fact.includes('analytics tools'))).toBe(true); expect(merged.some((fact) => fact.fact.includes('social media accounts'))).toBe(true); }); + + it('keeps LoCoMo temporal duration and doctor facts without named entities', () => { + const merged = mergeSupplementalFacts( + [], + [ + '[Session date: 2023-05-24]', + 'Nate: I like having some of these little turtles around to keep me calm.', + "Nate: I've had them for 3 years now and they bring me tons of joy!", + "Sam: Thanks, Evan. Appreciate the offer, but had a check-up with my doctor a few days ago and, yikes, the weight wasn't great.", + ].join('\n'), + ); + + expect(merged.some((fact) => fact.fact.includes('Nate has had the turtles for 3 years now'))).toBe(true); + expect(merged.some((fact) => fact.fact.includes('Sam had a check-up with Sam\'s doctor a few days ago'))).toBe(true); + }); }); diff --git a/src/services/__tests__/temporal-query-constraints.test.ts b/src/services/__tests__/temporal-query-constraints.test.ts new file mode 100644 index 0000000..0b38764 --- /dev/null +++ b/src/services/__tests__/temporal-query-constraints.test.ts @@ -0,0 +1,63 @@ +/** + * Unit tests for explicit month/date query constraint ranking. + */ + +import { describe, expect, it } from 'vitest'; +import { applyTemporalQueryConstraints } from '../temporal-query-constraints.js'; +import { createSearchResult } from './test-fixtures.js'; + +function result(id: string, content: string, createdAt: string, score: number) { + return createSearchResult({ + id, + content, + score, + created_at: new Date(createdAt), + observed_at: new Date(createdAt), + }); +} + +describe('applyTemporalQueryConstraints', () => { + it('boosts and protects candidates matching an explicit month and query keywords', () => { + const ranked = applyTemporalQueryConstraints( + 'What did Caroline do for the pride parade in August?', + [ + result('october', 'Caroline went to the October pride parade.', '2023-10-13T00:00:00.000Z', 0.9), + result('august', 'Caroline volunteered at the pride parade.', '2023-08-19T00:00:00.000Z', 0.3), + ], + 2, + ); + + expect(ranked.constraints).toEqual(['august']); + expect(ranked.protectedIds).toEqual(['august']); + expect(ranked.results[0]?.id).toBe('august'); + }); + + it('does not boost month matches that lack query keyword support', () => { + const ranked = applyTemporalQueryConstraints( + 'What did Caroline do for the pride parade in August?', + [ + result('beach', 'Caroline visited the beach in August.', '2023-08-19T00:00:00.000Z', 0.9), + result('parade', 'Caroline mentioned the pride parade.', '2023-10-13T00:00:00.000Z', 0.8), + ], + 2, + ); + + expect(ranked.protectedIds).toEqual([]); + expect(ranked.results[0]?.id).toBe('beach'); + }); + + it('honors explicit month-year constraints', () => { + const ranked = applyTemporalQueryConstraints( + 'What changed in August 2024?', + [ + result('old', 'The project changed in August 2023.', '2023-08-01T00:00:00.000Z', 0.9), + result('new', 'The project changed during planning.', '2024-08-01T00:00:00.000Z', 0.2), + ], + 2, + ); + + expect(ranked.constraints).toEqual(['august 2024']); + expect(ranked.protectedIds).toEqual(['new']); + expect(ranked.results[0]?.id).toBe('new'); + }); +}); diff --git a/src/services/chunked-extraction.ts b/src/services/chunked-extraction.ts index 12391a7..fbe37bc 100644 --- a/src/services/chunked-extraction.ts +++ b/src/services/chunked-extraction.ts @@ -10,7 +10,7 @@ */ import { config } from '../config.js'; -import { extractFacts, type ExtractedFact } from './extraction.js'; +import { extractFacts, type ExtractionOptions, type ExtractedFact } from './extraction.js'; import { cachedExtractFacts } from './extraction-cache.js'; import { cosineSimilarity, embedText } from './embedding.js'; @@ -65,6 +65,7 @@ function chunkConversation( */ export async function chunkedExtractFacts( conversationText: string, + options: ExtractionOptions = {}, ): Promise { const chunks = chunkConversation( conversationText, @@ -74,16 +75,16 @@ export async function chunkedExtractFacts( if (chunks.length <= 1) { return config.extractionCacheEnabled - ? cachedExtractFacts(conversationText) - : extractFacts(conversationText); + ? cachedExtractFacts(conversationText, options) + : extractFacts(conversationText, options); } // Extract facts from each chunk const allFacts: ExtractedFact[] = []; for (const chunk of chunks) { const facts = config.extractionCacheEnabled - ? await cachedExtractFacts(chunk) - : await extractFacts(chunk); + ? await cachedExtractFacts(chunk, options) + : await extractFacts(chunk, options); allFacts.push(...facts); } diff --git a/src/services/consensus-extraction.ts b/src/services/consensus-extraction.ts index af2f6f4..41ed91f 100644 --- a/src/services/consensus-extraction.ts +++ b/src/services/consensus-extraction.ts @@ -16,6 +16,7 @@ import { cachedExtractFacts } from './extraction-cache.js'; import { chunkedExtractFacts } from './chunked-extraction.js'; import { cosineSimilarity, embedText } from './embedding.js'; import { classifyNetwork } from './memory-network.js'; +import { mergeQuotedEntityFacts } from './quoted-entity-extraction.js'; const SIMILARITY_THRESHOLD = 0.90; @@ -28,6 +29,8 @@ export interface ConsensusExtractionConfig { consensusExtractionEnabled: boolean; consensusExtractionRuns: number; chunkedExtractionEnabled: boolean; + observationDateExtractionEnabled: boolean; + quotedEntityExtractionEnabled: boolean; } interface FactWithEmbedding { @@ -49,35 +52,56 @@ export async function consensusExtractFacts( config: ConsensusExtractionConfig, ): Promise { if (!config.consensusExtractionEnabled) { - return config.chunkedExtractionEnabled - ? chunkedExtractFacts(conversationText) - : cachedExtractFacts(conversationText); + const options = buildExtractionOptions(config); + const facts = await (config.chunkedExtractionEnabled + ? chunkedExtractFacts(conversationText, options) + : cachedExtractFacts(conversationText, options)); + return applyOptionalQuotedEntityExtraction(facts, conversationText, config); } - const allRunFacts = await runMultipleExtractions(conversationText, config.consensusExtractionRuns); + const allRunFacts = await runMultipleExtractions(conversationText, config); const mode = (process.env.CONSENSUS_MODE || 'consensus').toLowerCase(); if (mode === 'union') { const unique = await deduplicateFacts(allRunFacts.flat()); - return applyNetworkClassification(unique); + return applyNetworkClassification(applyOptionalQuotedEntityExtraction(unique, conversationText, config)); } const stableFacts = await filterByMajorityVote(allRunFacts); - return applyNetworkClassification(stableFacts); + return applyNetworkClassification(applyOptionalQuotedEntityExtraction(stableFacts, conversationText, config)); +} + +function applyOptionalQuotedEntityExtraction( + facts: ExtractedFact[], + conversationText: string, + config: Pick, +): ExtractedFact[] { + return config.quotedEntityExtractionEnabled + ? mergeQuotedEntityFacts(facts, conversationText) + : facts; } /** Run extractFacts() N times to get independent LLM samples. */ async function runMultipleExtractions( conversationText: string, - runs: number, + config: Pick, ): Promise { const allRunFacts: ExtractedFact[][] = []; - for (let i = 0; i < runs; i++) { - allRunFacts.push(await extractFacts(conversationText)); + const options = buildExtractionOptions(config); + for (let i = 0; i < config.consensusExtractionRuns; i++) { + allRunFacts.push(await extractFacts(conversationText, options)); } return allRunFacts; } +function buildExtractionOptions( + config: Pick, +) { + return { + observationDateExtractionEnabled: config.observationDateExtractionEnabled, + }; +} + /** Keep only facts from run[0] that appear in a majority of all runs. */ async function filterByMajorityVote( allRunFacts: ExtractedFact[][], diff --git a/src/services/content-detection.ts b/src/services/content-detection.ts index 5b4c76b..c480b7b 100644 --- a/src/services/content-detection.ts +++ b/src/services/content-detection.ts @@ -20,10 +20,10 @@ export const ENTITY_PATTERNS: Array<{ pattern: RegExp; type: ExtractedEntity['ty export const QUOTED_TEXT_PATTERN = /["""][^"""]{2,}["""]/; export const LITERAL_DETAIL_PATTERN = - /\b(?:necklace|book|books|song|songs|music|musicians|fan|painting|paintings|photo|poster|posters|library|store|decor|furniture|flooring|pet|pets|cat|cats|dog|dogs|guinea pig|workshop|poetry reading|sign|slipper|bowl)\b/i; + /\b(?:necklace|book|books|song|songs|music|musicians|fan|painting|paintings|photo|poster|posters|library|store|decor|furniture|flooring|pet|pets|cat|cats|dog|dogs|guinea pig|turtle|turtles|snake|snakes|workshop|poetry reading|sign|slipper|bowl)\b/i; export const EVENT_DETAIL_PATTERN = - /\b(?:accepted|interview|internship|mentor(?:ed|ing)?|network(?:ing)?|social media|competition|investor(?:s)?|fashion editors|analytics tools|video presentation|website|collaborat(?:e|ion)|dance class|Shia Labeouf|trip|paris|rome)\b/i; + /\b(?:accepted|interview|internship|mentor(?:ed|ing)?|network(?:ing)?|social media|competition|investor(?:s)?|fashion editors|analytics tools|video presentation|website|collaborat(?:e|ion)|dance class|Shia Labeouf|trip|travel(?:ed|ling)?|retreat|phuket|doctor|doc|check-up|appointment|blog|car mods?|restor(?:e|ed|ing|ation)|paris|rome)\b/i; /** Check whether text contains any known entity pattern. */ export function hasStandaloneEntity(sentence: string): boolean { diff --git a/src/services/extraction-cache.ts b/src/services/extraction-cache.ts index 32a03a7..36fd2e5 100644 --- a/src/services/extraction-cache.ts +++ b/src/services/extraction-cache.ts @@ -13,6 +13,7 @@ import { extractFacts, resolveAUDN, type AUDNDecision, + type ExtractionOptions, type ExtractedFact, type ExistingMemory, } from './extraction.js'; @@ -38,15 +39,18 @@ function writeCache(filePath: string, value: T): void { renameSync(tmpPath, filePath); } -export async function cachedExtractFacts(conversationText: string): Promise { - if (!config.extractionCacheEnabled) return extractFacts(conversationText); +export async function cachedExtractFacts( + conversationText: string, + options: ExtractionOptions = {}, +): Promise { + if (!config.extractionCacheEnabled) return extractFacts(conversationText, options); - const key = `extract-${hashInput([conversationText])}`; + const key = `extract-${hashInput([conversationText, JSON.stringify(options)])}`; const filePath = cacheFilePath(key); const cached = readCache(filePath); if (cached) return cached; - const result = await extractFacts(conversationText); + const result = await extractFacts(conversationText, options); writeCache(filePath, result); return result; } diff --git a/src/services/extraction.ts b/src/services/extraction.ts index e0fecea..3ce6730 100644 --- a/src/services/extraction.ts +++ b/src/services/extraction.ts @@ -12,6 +12,16 @@ import { timed, timedSync } from './timing.js'; import { normalizeExtractedFacts } from './fact-normalization.js'; import { enrichExtractedFacts } from './extraction-enrichment.js'; import { mergeSupplementalFacts } from './supplemental-extraction.js'; +import { + applyObservationDateAnchors, + buildExtractionUserMessage, + type ExtractionOptions, +} from './observation-date-extraction.js'; + +const EXTRACTION_MAX_TOKENS = 4096; +const AUDN_MAX_TOKENS = 2048; + +export type { ExtractionOptions }; /** Strip markdown code fences (```json ... ```) that some LLMs wrap around JSON output. */ function stripJsonFences(raw: string): string { @@ -30,27 +40,85 @@ function stripJsonFences(raw: string): string { return trimmed; } +/** Return the first complete JSON object, tolerating prose before/after it. */ +function extractFirstJsonObject(raw: string): string { + const cleaned = stripJsonFences(raw); + if (isValidJson(cleaned)) return cleaned; + + const start = cleaned.indexOf('{'); + if (start < 0) return cleaned; + + const end = findBalancedJsonObjectEnd(cleaned, start); + return end < 0 ? cleaned : cleaned.slice(start, end + 1); +} + +function isValidJson(value: string): boolean { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +} + +interface JsonScanState { + depth: number; + inString: boolean; + escaped: boolean; +} + +function findBalancedJsonObjectEnd(input: string, start: number): number { + const state: JsonScanState = { depth: 0, inString: false, escaped: false }; + for (let i = start; i < input.length; i++) { + if (advanceJsonScanState(state, input[i]!)) return i; + } + return -1; +} + +function advanceJsonScanState(state: JsonScanState, char: string): boolean { + if (state.escaped) { + state.escaped = false; + return false; + } + if (char === '\\') { + state.escaped = state.inString; + return false; + } + if (char === '"') { + state.inString = !state.inString; + return false; + } + if (state.inString) return false; + return updateJsonDepth(state, char); +} + +function updateJsonDepth(state: JsonScanState, char: string): boolean { + if (char === '{') state.depth++; + if (char === '}') state.depth--; + return state.depth === 0; +} + /** * Attempts to recover a valid JSON object from truncated LLM output. * Finds the last complete object boundary, closes unterminated strings/arrays/objects, * and wraps in the expected `{"memories": [...]}` structure if needed. */ function repairTruncatedJson(raw: string): string | null { - // Find last complete JSON object (closing brace that isn't mid-string) const lastBrace = raw.lastIndexOf('}'); if (lastBrace <= 0) return null; - let candidate = raw.slice(0, lastBrace + 1); - // Remove trailing commas before ] or } - candidate = candidate.replace(/,\s*\]/, ']').replace(/,\s*\}/, '}'); + const candidate = removeTrailingJsonCommas(raw.slice(0, lastBrace + 1)); + if (isValidJson(candidate)) return candidate; - try { - JSON.parse(candidate); - return candidate; - } catch { - // Truncation may leave unterminated strings — try closing them - } + const repaired = closeAtLastCompleteArrayEntry(candidate); + return repaired && isValidJson(repaired) ? repaired : null; +} + +function removeTrailingJsonCommas(value: string): string { + return value.replace(/,\s*\]/, ']').replace(/,\s*\}/, '}'); +} +function closeAtLastCompleteArrayEntry(candidate: string): string | null { // Walk backwards to find the last complete array entry boundary // Look for `},` or `}]` patterns that mark a complete object in the memories array const lastCompleteEntry = candidate.lastIndexOf('},'); @@ -58,20 +126,17 @@ function repairTruncatedJson(raw: string): string | null { const cutPoint = Math.max(lastCompleteEntry, lastArrayClose); if (cutPoint <= 0) return null; - // Slice up to and including the last complete entry, then close the structure - let truncated = candidate.slice(0, cutPoint + 1); - // Close any open arrays and objects - const openBrackets = (truncated.match(/\[/g) || []).length - (truncated.match(/\]/g) || []).length; - const openBraces = (truncated.match(/\{/g) || []).length - (truncated.match(/\}/g) || []).length; - for (let i = 0; i < openBrackets; i++) truncated += ']'; - for (let i = 0; i < openBraces; i++) truncated += '}'; + return closeOpenJsonContainers(candidate.slice(0, cutPoint + 1)); +} - try { - JSON.parse(truncated); - return truncated; - } catch { - return null; - } +function closeOpenJsonContainers(value: string): string { + const openBrackets = Math.max(0, countMatches(value, '[') - countMatches(value, ']')); + const openBraces = Math.max(0, countMatches(value, '{') - countMatches(value, '}')); + return value + ']'.repeat(openBrackets) + '}'.repeat(openBraces); +} + +function countMatches(value: string, char: string): number { + return [...value].filter((candidate) => candidate === char).length; } export interface ExtractedEntity { @@ -235,13 +300,16 @@ OUTPUT FORMAT (JSON): If no extractable facts exist, return: {"memories": []}`; -export async function extractFacts(conversationText: string): Promise { +export async function extractFacts( + conversationText: string, + options: ExtractionOptions = {}, +): Promise { const content = await timed('ingest.extract.llm', () => withCostStage('extract', () => llm.chat( [ { role: 'system', content: EXTRACTION_PROMPT }, - { role: 'user', content: `Conversation to extract from:\n${conversationText}` }, + { role: 'user', content: buildExtractionUserMessage(conversationText, options) }, ], - { temperature: 0, jsonMode: true }, + { temperature: 0, jsonMode: true, maxTokens: EXTRACTION_MAX_TOKENS }, ))); if (!content) return []; @@ -251,7 +319,8 @@ export async function extractFacts(conversationText: string): Promise { const normalized: ExtractedFact[] = rawFacts.map((m) => normalizeRawFact(m)); - const baseFacts = enrichExtractedFacts(normalizeExtractedFacts(normalized)); + const anchoredFacts = applyObservationDateAnchors(normalized, conversationText, options); + const baseFacts = enrichExtractedFacts(normalizeExtractedFacts(anchoredFacts)); return mergeSupplementalFacts(baseFacts, conversationText); }); } @@ -458,6 +527,8 @@ OUTPUT FORMAT (JSON): "clarification_note": null | "description of the conflict for CLARIFY action", "contradiction_confidence": null | 0.0-1.0 } + +Return only the JSON object. Do not wrap it in markdown fences. Do not explain your reasoning. `; export async function resolveAUDN( @@ -473,14 +544,14 @@ export async function resolveAUDN( { role: 'system', content: AUDN_PROMPT }, { role: 'user', content: `NEW FACT: ${newFact}\n\nEXISTING MEMORIES:\n${memoriesBlock}` }, ], - { temperature: 0, jsonMode: true }, + { temperature: 0, jsonMode: true, maxTokens: AUDN_MAX_TOKENS }, ); if (!content) { return defaultDecision(); } - const cleanedAudn = stripJsonFences(content); + const cleanedAudn = extractFirstJsonObject(content); let parsed: Record; try { parsed = JSON.parse(cleanedAudn) as Record; @@ -674,4 +745,3 @@ export function generateFallbackHeadline(fact: string): string { if (words.length <= HEADLINE_MAX_WORDS) return fact; return words.slice(0, HEADLINE_MAX_WORDS).join(' ') + '...'; } - diff --git a/src/services/ingest-post-write.ts b/src/services/ingest-post-write.ts index 31000f3..ba13a8a 100644 --- a/src/services/ingest-post-write.ts +++ b/src/services/ingest-post-write.ts @@ -66,7 +66,16 @@ export async function runPostWriteProcessors( let compositesCreated = 0; if (ctx.compositesEnabled && ctx.storedFacts.length >= deps.config.compositeMinClusterSize) { compositesCreated = await timed(`${ctx.timingPrefix}.composites`, () => - generateAndStoreComposites(deps, userId, ctx.storedFacts, ctx.embeddingCache, ctx.sourceSite, ctx.sourceUrl, ctx.episodeId), + generateAndStoreComposites( + deps, + userId, + ctx.storedFacts, + ctx.embeddingCache, + ctx.sourceSite, + ctx.sourceUrl, + ctx.episodeId, + ctx.sessionTimestamp, + ), ); } @@ -82,6 +91,7 @@ async function generateAndStoreComposites( sourceSite: string, sourceUrl: string, episodeId: string, + sessionTimestamp?: Date, ): Promise { const memberNamespaceMap = new Map(); const compositeInputs: CompositeInput[] = storedFacts @@ -117,6 +127,7 @@ async function generateAndStoreComposites( summary: composite.headline, overview: composite.overview, trustScore: 1.0, + createdAt: sessionTimestamp, namespace: namespace ?? undefined, metadata: { memberMemoryIds: composite.memberMemoryIds, diff --git a/src/services/literal-list-protection.ts b/src/services/literal-list-protection.ts new file mode 100644 index 0000000..36ceb1b --- /dev/null +++ b/src/services/literal-list-protection.ts @@ -0,0 +1,191 @@ +/** + * Protect literal list-answer candidates from late-stage diversity selection. + * + * LoCoMo-style questions such as "What are Melanie's pets' names?" and + * "What musical artists has Melanie seen?" often need short memories that + * contain the exact named answer. These memories can be less semantically broad + * than neighboring context, so MMR may drop them unless we mark high-signal + * candidates as protected before final selection. + */ + +import type { SearchResult } from '../db/repository-types.js'; +import { buildTemporalFingerprint } from './temporal-fingerprint.js'; +import { isLiteralDetailQuery } from './literal-query-expansion.js'; + +const MIN_SIGNAL_SCORE = 3; +const PROTECTED_SCORE_BONUS = 4; + +const PET_TERMS = ['pet', 'pets', 'cat', 'cats', 'dog', 'dogs']; +const MUSIC_TERMS = ['artist', 'artists', 'band', 'bands', 'music', 'musical', 'concert', 'song', 'songs']; +const BOOK_TERMS = ['book', 'books', 'title', 'read', 'reading']; +const NAME_TERMS = ['name', 'names', 'called', 'named']; +const SEEN_EVENT_TERMS = ['seen', 'saw', 'attended', 'concert', 'show']; +const PERFORMANCE_TERMS = ['played', 'playing', 'dancing', 'singing', 'live', 'stage', 'show', 'concert']; +const ATTENDANCE_TERMS = ['attended', 'saw', 'seen', 'concert', 'show']; + +export interface LiteralListProtectionResult { + protectedFingerprints: string[]; + protectedIds: string[]; + reasons: string[]; + results: SearchResult[]; +} + +interface CandidateSignal { + result: SearchResult; + score: number; + reasons: string[]; +} + +export function protectLiteralListAnswerCandidates( + query: string, + candidates: SearchResult[], + maxProtected: number, +): LiteralListProtectionResult { + if (maxProtected <= 0 || !isLiteralDetailQuery(query)) { + return emptyProtection(candidates); + } + + const intent = classifyListIntent(query); + if (!intent.hasListIntent) { + return emptyProtection(candidates); + } + + const protectedCandidates = candidates + .map((candidate) => scoreCandidate(candidate, intent)) + .filter((candidate) => candidate.score >= MIN_SIGNAL_SCORE) + .sort((left, right) => right.score - left.score) + .slice(0, maxProtected); + + return { + protectedFingerprints: protectedCandidates.map((item) => buildTemporalFingerprint(item.result.content)), + protectedIds: protectedCandidates.map((item) => item.result.id), + reasons: [...new Set(protectedCandidates.flatMap((item) => item.reasons))], + results: boostProtectedCandidates(candidates, protectedCandidates), + }; +} + +interface ListIntent { + hasListIntent: boolean; + wantsNames: boolean; + wantsPets: boolean; + wantsMusic: boolean; + wantsBooks: boolean; + wantsSeenEvent: boolean; +} + +function classifyListIntent(query: string): ListIntent { + const lower = query.toLowerCase(); + return { + hasListIntent: containsAny(lower, NAME_TERMS) || containsAny(lower, PET_TERMS) + || containsAny(lower, MUSIC_TERMS) || containsAny(lower, BOOK_TERMS), + wantsNames: containsAny(lower, NAME_TERMS), + wantsPets: containsAny(lower, PET_TERMS), + wantsMusic: containsAny(lower, MUSIC_TERMS), + wantsBooks: containsAny(lower, BOOK_TERMS), + wantsSeenEvent: containsAny(lower, SEEN_EVENT_TERMS), + }; +} + +function scoreCandidate(result: SearchResult, intent: ListIntent): CandidateSignal { + const content = result.content; + const lower = content.toLowerCase(); + const hasQuoted = hasQuotedTitle(content); + const hasLeadingQuote = hasLeadingQuotedTitle(content); + const hasAttendance = containsAny(lower, ATTENDANCE_TERMS); + const reasons: string[] = []; + + const score = scoreNamedEntity(content, intent, reasons) + + scoreQuotedOrAttendance(intent, hasQuoted, hasLeadingQuote, hasAttendance, reasons) + + scoreDomainTerms(lower, intent, reasons) + + scorePerformanceEvent(lower, intent, hasLeadingQuote, reasons); + + return { result, score, reasons }; +} + +function scoreNamedEntity(content: string, intent: ListIntent, reasons: string[]): number { + if (!(intent.wantsPets || intent.wantsNames)) return 0; + if (!/\bnamed\s+[A-Z][A-Za-z'-]+/.test(content)) return 0; + reasons.push('named-entity'); + return 3; +} + +function scoreQuotedOrAttendance( + intent: ListIntent, + hasQuoted: boolean, + hasLeadingQuote: boolean, + hasAttendance: boolean, + reasons: string[], +): number { + const seenEventScore = scoreSeenMusicEvent(intent, hasLeadingQuote, hasAttendance, reasons); + if (seenEventScore > 0) return seenEventScore; + if ((intent.wantsMusic || intent.wantsBooks) && hasQuoted) { + reasons.push('quoted-title'); + return 3; + } + return 0; +} + +function scoreSeenMusicEvent( + intent: ListIntent, + hasLeadingQuote: boolean, + hasAttendance: boolean, + reasons: string[], +): number { + if (!intent.wantsMusic || !intent.wantsSeenEvent) return 0; + if (hasLeadingQuote) { + reasons.push('quoted-title'); + return 4; + } + if (!hasAttendance) return 0; + reasons.push('attendance-event'); + return 3; +} + +function scoreDomainTerms(lower: string, intent: ListIntent, reasons: string[]): number { + let score = 0; + score += addSignal(intent.wantsPets && containsAny(lower, PET_TERMS), reasons, 'pet-domain', 1.5); + score += addSignal(intent.wantsMusic && containsAny(lower, MUSIC_TERMS), reasons, 'music-domain', 1.5); + score += addSignal(intent.wantsBooks && containsAny(lower, BOOK_TERMS), reasons, 'book-domain', 1.5); + return score; +} + +function scorePerformanceEvent(lower: string, intent: ListIntent, hasLeadingQuote: boolean, reasons: string[]): number { + const hasPerformanceSignal = intent.wantsMusic && intent.wantsSeenEvent + && hasLeadingQuote && containsAny(lower, PERFORMANCE_TERMS); + return addSignal(hasPerformanceSignal, reasons, 'performance-event', 2.5); +} + +function addSignal(enabled: boolean, reasons: string[], reason: string, score: number): number { + if (!enabled) return 0; + reasons.push(reason); + return score; +} + +function hasQuotedTitle(content: string): boolean { + return /["'“‘][A-Z][^"'”’]{2,}["'”’]/.test(content); +} + +function hasLeadingQuotedTitle(content: string): boolean { + return /^\s*["'“‘][A-Z][^"'”’]{2,}["'”’]/.test(content); +} + +function containsAny(content: string, terms: string[]): boolean { + return terms.some((term) => new RegExp(`\\b${term}\\b`).test(content)); +} + +function boostProtectedCandidates( + candidates: SearchResult[], + protectedCandidates: CandidateSignal[], +): SearchResult[] { + const protectedIds = new Set(protectedCandidates.map((item) => item.result.id)); + return candidates.map((candidate) => { + if (!protectedIds.has(candidate.id)) { + return candidate; + } + return { ...candidate, score: candidate.score + PROTECTED_SCORE_BONUS }; + }); +} + +function emptyProtection(results: SearchResult[]): LiteralListProtectionResult { + return { protectedFingerprints: [], protectedIds: [], reasons: [], results }; +} diff --git a/src/services/memory-ingest.ts b/src/services/memory-ingest.ts index 06234b0..1324c46 100644 --- a/src/services/memory-ingest.ts +++ b/src/services/memory-ingest.ts @@ -10,6 +10,7 @@ import { assessWriteSecurity } from './write-security.js'; import { timed } from './timing.js'; import { runPostWriteProcessors } from './ingest-post-write.js'; import { processFactThroughPipeline } from './ingest-fact-pipeline.js'; +import { resolveSessionDate } from './session-date.js'; import type { WorkspaceContext } from '../db/repository-types.js'; import type { IngestResult, @@ -69,6 +70,7 @@ export async function performIngest( sessionTimestamp?: Date, ): Promise { const ingestStart = performance.now(); + const logicalSessionTimestamp = resolveSessionDate(sessionTimestamp, conversationText); const episodeId = await timed('ingest.store-episode', () => deps.stores.episode.storeEpisode({ userId, content: conversationText, sourceSite, sourceUrl })); const facts = await timed('ingest.extract', () => consensusExtractFacts(conversationText, deps.config)); const acc = createIngestAccumulator(); @@ -79,7 +81,7 @@ export async function performIngest( for (const fact of facts) { const result = await timed('ingest.fact', () => processFactThroughPipeline( deps, userId, fact, sourceSite, sourceUrl, episodeId, - { entropyGate: true, fullAudn: true, supersededTargets, entropyCtx, logicalTimestamp: sessionTimestamp, timingPrefix: 'ingest' }, + { entropyGate: true, fullAudn: true, supersededTargets, entropyCtx, logicalTimestamp: logicalSessionTimestamp, timingPrefix: 'ingest' }, )); accumulateFactResult(acc, result); if (result.memoryId) storedFacts.push({ memoryId: result.memoryId, fact }); @@ -88,7 +90,7 @@ export async function performIngest( const postWrite = await runPostWriteProcessors(deps, userId, { episodeId, sourceSite, sourceUrl, storedFacts, memoryIds: acc.memoryIds, embeddingCache: acc.embeddingCache, - sessionTimestamp, compositesEnabled: deps.config.compositeGroupingEnabled, + sessionTimestamp: logicalSessionTimestamp, compositesEnabled: deps.config.compositeGroupingEnabled, timingPrefix: 'ingest', }); @@ -109,6 +111,7 @@ export async function performQuickIngest( sessionTimestamp?: Date, ): Promise { const ingestStart = performance.now(); + const logicalSessionTimestamp = resolveSessionDate(sessionTimestamp, conversationText); const episodeId = await deps.stores.episode.storeEpisode({ userId, content: conversationText, sourceSite, sourceUrl }); const facts = timed('quick-ingest.extract', () => Promise.resolve(quickExtractFacts(conversationText))); const extractedFacts = await facts; @@ -117,7 +120,7 @@ export async function performQuickIngest( for (const fact of extractedFacts) { const result = await timed('quick-ingest.fact', () => processFactThroughPipeline( deps, userId, fact, sourceSite, sourceUrl, episodeId, - { entropyGate: false, fullAudn: false, supersededTargets: new Set(), entropyCtx: { seenEntities: new Set(), previousEmbedding: null }, logicalTimestamp: sessionTimestamp, timingPrefix: 'quick-ingest' }, + { entropyGate: false, fullAudn: false, supersededTargets: new Set(), entropyCtx: { seenEntities: new Set(), previousEmbedding: null }, logicalTimestamp: logicalSessionTimestamp, timingPrefix: 'quick-ingest' }, )); accumulateFactResult(acc, result); } @@ -125,7 +128,7 @@ export async function performQuickIngest( const postWrite = await runPostWriteProcessors(deps, userId, { episodeId, sourceSite, sourceUrl, storedFacts: [], memoryIds: acc.memoryIds, embeddingCache: acc.embeddingCache, - sessionTimestamp, compositesEnabled: false, + sessionTimestamp: logicalSessionTimestamp, compositesEnabled: false, timingPrefix: 'quick-ingest', }); @@ -191,6 +194,7 @@ export async function performWorkspaceIngest( sessionTimestamp?: Date, ): Promise { const ingestStart = performance.now(); + const logicalSessionTimestamp = resolveSessionDate(sessionTimestamp, conversationText); const episodeId = await timed('ws-ingest.store-episode', () => deps.stores.episode.storeEpisode({ userId, content: conversationText, sourceSite, sourceUrl, @@ -205,7 +209,15 @@ export async function performWorkspaceIngest( for (const fact of facts) { const result = await timed('ws-ingest.fact', () => processFactThroughPipeline(deps, userId, fact, sourceSite, sourceUrl, episodeId, - { workspace, entropyGate: false, fullAudn: true, supersededTargets, entropyCtx, timingPrefix: 'ws-ingest' }), + { + workspace, + entropyGate: false, + fullAudn: true, + supersededTargets, + entropyCtx, + logicalTimestamp: logicalSessionTimestamp, + timingPrefix: 'ws-ingest', + }), ); accumulateFactResult(acc, result); } @@ -213,12 +225,10 @@ export async function performWorkspaceIngest( const postWrite = await runPostWriteProcessors(deps, userId, { episodeId, sourceSite, sourceUrl, storedFacts: [], memoryIds: acc.memoryIds, embeddingCache: acc.embeddingCache, - sessionTimestamp, compositesEnabled: false, + sessionTimestamp: logicalSessionTimestamp, compositesEnabled: false, timingPrefix: 'ws-ingest', }); console.log(`[timing] ws-ingest.total: ${(performance.now() - ingestStart).toFixed(1)}ms (${facts.length} facts, workspace=${workspace.workspaceId})`); return buildIngestResult(episodeId, facts.length, acc, postWrite.linksCreated, 0); } - - diff --git a/src/services/memory-service-types.ts b/src/services/memory-service-types.ts index 188f372..765ac4d 100644 --- a/src/services/memory-service-types.ts +++ b/src/services/memory-service-types.ts @@ -205,6 +205,8 @@ export interface IngestRuntimeConfig { compositeMinClusterSize: number; consensusExtractionEnabled: boolean; consensusExtractionRuns: number; + observationDateExtractionEnabled: boolean; + quotedEntityExtractionEnabled: boolean; entityGraphEnabled: boolean; entropyGateAlpha: number; entropyGateEnabled: boolean; diff --git a/src/services/observation-date-extraction.ts b/src/services/observation-date-extraction.ts new file mode 100644 index 0000000..7529b2d --- /dev/null +++ b/src/services/observation-date-extraction.ts @@ -0,0 +1,70 @@ +/** + * Observation-date extraction helpers. + * + * Keeps benchmark-only temporal prompt and post-processing behavior behind one + * default-off option so relative-date experiments can run by configuration. + */ + +import type { ExtractedFact } from './extraction.js'; +import { + annotateRelativeTemporalText, + extractRelativeTemporalAnchors, +} from './relative-temporal.js'; +import { extractSessionTimestamp, parseSessionDate } from './session-date.js'; + +export interface ExtractionOptions { + observationDateExtractionEnabled?: boolean; +} + +export function buildExtractionUserMessage( + conversationText: string, + options: ExtractionOptions = {}, +): string { + if (!options.observationDateExtractionEnabled) { + return `Conversation to extract from:\n${conversationText}`; + } + const observationTimestamp = extractObservationTimestamp(conversationText); + if (!observationTimestamp) { + return `Conversation to extract from:\n${conversationText}`; + } + return [ + `Observation timestamp: ${observationTimestamp}`, + 'Use this timestamp to resolve relative dates in the conversation.', + 'For relative phrases such as "last Friday", include the resolved absolute date in extracted facts when possible.', + '', + `Conversation to extract from:\n${conversationText}`, + ].join('\n'); +} + +export function applyObservationDateAnchors( + facts: ExtractedFact[], + conversationText: string, + options: ExtractionOptions = {}, +): ExtractedFact[] { + if (!options.observationDateExtractionEnabled) return facts; + const observationDate = parseObservationDate(conversationText); + if (!observationDate) return facts; + + return facts.map((fact) => annotateFact(fact, observationDate)); +} + +function annotateFact(fact: ExtractedFact, observationDate: Date): ExtractedFact { + const annotatedFact = annotateRelativeTemporalText(fact.fact, observationDate); + if (annotatedFact === fact.fact) return fact; + + const anchorKeywords = extractRelativeTemporalAnchors(fact.fact, observationDate) + .map((anchor) => anchor.eventDate); + return { + ...fact, + fact: annotatedFact, + keywords: [...new Set([...fact.keywords, ...anchorKeywords])], + }; +} + +function parseObservationDate(conversationText: string): Date | null { + return parseSessionDate(conversationText); +} + +function extractObservationTimestamp(conversationText: string): string | null { + return extractSessionTimestamp(conversationText); +} diff --git a/src/services/query-keyword-matches.ts b/src/services/query-keyword-matches.ts new file mode 100644 index 0000000..3c56ee3 --- /dev/null +++ b/src/services/query-keyword-matches.ts @@ -0,0 +1,8 @@ +/** + * Shared query keyword matching utilities for retrieval-time reranking. + */ + +export function countKeywordMatches(content: string, keywords: string[]): number { + const lower = content.toLowerCase(); + return keywords.filter((keyword) => lower.includes(keyword)).length; +} diff --git a/src/services/quick-extraction.ts b/src/services/quick-extraction.ts index 66ac54b..1517c3d 100644 --- a/src/services/quick-extraction.ts +++ b/src/services/quick-extraction.ts @@ -47,7 +47,7 @@ const MONTH_NAMES = [ ]; const SPEAKER_PREFIX_PATTERN = /^[A-Z][A-Za-z0-9' -]{1,40}:\s*/; const IMPLICIT_FIRST_PERSON_EVENT_PATTERN = - /^(?:started|starting|built|building|developed|developing|created|creating|launched|launching|opened|opening|accepted|receiv(?:ed|ing)|got|went|attended|visited|reading|posted|hosting|working|looking|planning|taking|took)\b/i; + /^(?:started|starting|built|building|developed|developing|created|creating|launched|launching|opened|opening|accepted|receiv(?:ed|ing)|got|had|went|attended|visited|reading|posted|hosting|working|looking|planning|taking|took)\b/i; /** Patterns that indicate a user is stating a fact about themselves. */ const FIRST_PERSON_PATTERNS = [ @@ -56,6 +56,7 @@ const FIRST_PERSON_PATTERNS = [ /\bwe\s+(?:use|used|have|had|built|created|switched|moved|started|decided|chose|plan|are|were)\b/i, /\bI['']m\s+(?:a|an|the|from|based|working|building|using|looking|trying|planning|learning|studying|interested|responsible|currently)\b/i, /\bI['']ve\s+(?:been|had|used|tried|built|worked|lived|started|finished|switched|decided)\b/i, + /\b(?:had|got)\s+(?:a\s+)?(?:check-up|doctor['’]?s appointment|doc['’]?s appointment)\b/i, /\bLet['’]?s\s+(?:create|collaborate|get together|make|work)\b/i, /\bI\s+should\b/i, ]; @@ -74,6 +75,12 @@ interface TurnEntry { source: 'user' | 'assistant'; } +interface TurnState { + currentTurn: string; + currentSpeaker: string | null; + currentSource: 'user' | 'assistant'; +} + /** * Split conversation into turns, returning user turns and fact-bearing * assistant turns. Generic assistant chatter (acknowledgments, clarifying @@ -82,35 +89,12 @@ interface TurnEntry { function extractFactBearingTurns(text: string): TurnEntry[] { const lines = text.split('\n'); const turns: TurnEntry[] = []; - let currentTurn = ''; - let currentSpeaker: string | null = null; - let currentSource: 'user' | 'assistant' = 'user'; + const state: TurnState = { currentTurn: '', currentSpeaker: null, currentSource: 'user' }; for (const line of lines) { - const trimmed = line.trim(); - if (SESSION_DATE_PATTERN.test(trimmed)) { - continue; - } - if (/^(?:User|Human|Me):/i.test(trimmed)) { - pushTurn(turns, currentTurn, currentSpeaker, currentSource); - currentTurn = trimmed.replace(/^(?:User|Human|Me):\s*/i, ''); - currentSpeaker = null; - currentSource = 'user'; - } else if (/^(?:Assistant|AI|Bot|Claude|ChatGPT|GPT):/i.test(trimmed)) { - pushTurn(turns, currentTurn, currentSpeaker, currentSource); - currentTurn = trimmed.replace(/^(?:Assistant|AI|Bot|Claude|ChatGPT|GPT):\s*/i, ''); - currentSpeaker = null; - currentSource = 'assistant'; - } else if (SPEAKER_PREFIX_PATTERN.test(trimmed)) { - pushTurn(turns, currentTurn, currentSpeaker, currentSource); - currentTurn = trimmed; - currentSpeaker = trimmed.match(/^([A-Z][A-Za-z0-9' -]{1,40}):/)?.[1] ?? null; - currentSource = 'user'; - } else { - currentTurn += '\n' + trimmed; - } + applyTurnLine(turns, state, line.trim()); } - pushTurn(turns, currentTurn, currentSpeaker, currentSource); + pushTurn(turns, state.currentTurn, state.currentSpeaker, state.currentSource); // If no turn markers found, treat entire text as user input if (turns.length === 0 && text.trim()) { @@ -120,6 +104,35 @@ function extractFactBearingTurns(text: string): TurnEntry[] { return turns; } +function applyTurnLine(turns: TurnEntry[], state: TurnState, trimmed: string): void { + if (SESSION_DATE_PATTERN.test(trimmed)) return; + const speakerTurn = parseSpeakerTurn(trimmed); + if (!speakerTurn) { + state.currentTurn += '\n' + trimmed; + return; + } + + pushTurn(turns, state.currentTurn, state.currentSpeaker, state.currentSource); + state.currentTurn = speakerTurn.text; + state.currentSpeaker = speakerTurn.speaker; + state.currentSource = speakerTurn.source; +} + +function parseSpeakerTurn(trimmed: string): TurnEntry | null { + if (/^(?:User|Human|Me):/i.test(trimmed)) { + return { speaker: null, text: trimmed.replace(/^(?:User|Human|Me):\s*/i, ''), source: 'user' }; + } + if (/^(?:Assistant|AI|Bot|Claude|ChatGPT|GPT):/i.test(trimmed)) { + return { speaker: null, text: trimmed.replace(/^(?:Assistant|AI|Bot|Claude|ChatGPT|GPT):\s*/i, ''), source: 'assistant' }; + } + if (!SPEAKER_PREFIX_PATTERN.test(trimmed)) return null; + return { + speaker: trimmed.match(/^([A-Z][A-Za-z0-9' -]{1,40}):/)?.[1] ?? null, + text: trimmed, + source: 'user', + }; +} + function pushTurn( turns: TurnEntry[], text: string, @@ -398,7 +411,7 @@ export function quickExtractFacts(conversationText: string): ExtractedFact[] { const seenFacts = new Set(); for (const turn of turns) { - extractFactsFromTurn(turn, sessionDate, sessionDateValue, seenFacts, facts); + extractFactsFromTurn(turn, conversationText, sessionDate, sessionDateValue, seenFacts, facts); } return enrichExtractedFacts(facts); @@ -407,6 +420,7 @@ export function quickExtractFacts(conversationText: string): ExtractedFact[] { /** Extract facts from a single turn's sentences and add to the accumulator. */ function extractFactsFromTurn( turn: { text: string; source: string; speaker: string | null }, + contextText: string, sessionDate: string | null, sessionDateValue: Date | null, seenFacts: Set, @@ -420,7 +434,7 @@ function extractFactsFromTurn( for (const sentence of candidates) { const fact = processSentence(sentence, isAssistant, turn.speaker, sessionDate, sessionDateValue, seenFacts); - if (fact) facts.push(fact); + if (fact) facts.push(resolveContextualObjectReference(fact, contextText)); } } @@ -461,7 +475,11 @@ function applySpeakerSubject(sentence: string, speaker: string | null): string { if (!speaker) { return sentence; } - return sentence + const impliedSpeaker = sentence.replace( + /^(?:Appreciate[^,]{0,80},\s+but\s+)?had\b/i, + `${speaker} had`, + ); + return impliedSpeaker .replace(/\bI['’]d\b/g, `${speaker} would`) .replace(/\bI['’]ll\b/g, `${speaker} will`) .replace(/\bI['’]ve\b/g, `${speaker} has`) @@ -469,3 +487,22 @@ function applySpeakerSubject(sentence: string, speaker: string | null): string { .replace(/\bI\b/g, speaker) .replace(/\bmy\b/gi, `${speaker}'s`); } + +function resolveContextualObjectReference(fact: ExtractedFact, turnText: string): ExtractedFact { + if (!/\bhad them for\b/i.test(fact.fact)) { + return fact; + } + const object = findContextualObject(turnText); + if (!object) { + return fact; + } + return { + ...fact, + fact: fact.fact.replace(/\bhad them for\b/i, `had the ${object} for`), + }; +} + +function findContextualObject(text: string): string | null { + const match = text.match(/\b(turtles|snakes|dogs|cats|pets)\b/i); + return match ? match[1].toLowerCase() : null; +} diff --git a/src/services/quoted-entity-extraction.ts b/src/services/quoted-entity-extraction.ts new file mode 100644 index 0000000..22a82e6 --- /dev/null +++ b/src/services/quoted-entity-extraction.ts @@ -0,0 +1,217 @@ +/** + * Deterministic quoted-entity extraction for exact titles and event names. + * + * This supplements LLM extraction when exact quoted titles or performers are + * text-visible but the generated fact weakens the relation. It intentionally + * does not infer image-only text or unseen metadata. + */ + +import type { ExtractedEntity, ExtractedFact } from './extraction.js'; + +const SESSION_DATE_PATTERN = /^\[Session date:\s*(\d{4})-(\d{2})-(\d{2})\]/im; +const SPEAKER_LINE_PATTERN = /^([A-Za-z][A-Za-z0-9' -]{1,40}):\s*(.+)$/; +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + +interface Turn { + speaker: string; + text: string; +} + +export function mergeQuotedEntityFacts( + existingFacts: ExtractedFact[], + conversationText: string, +): ExtractedFact[] { + const supplemental = extractQuotedEntityFacts(conversationText); + if (supplemental.length === 0) return existingFacts; + + const byFact = new Map(existingFacts.map((fact) => [normalize(fact.fact), fact])); + for (const fact of supplemental) { + if (!byFact.has(normalize(fact.fact))) { + byFact.set(normalize(fact.fact), fact); + } + } + return [...byFact.values()]; +} + +export function extractQuotedEntityFacts(conversationText: string): ExtractedFact[] { + const sessionDate = parseSessionDate(conversationText); + const facts = parseTurns(conversationText) + .flatMap((turn) => extractFactsFromTurn(turn, sessionDate)); + return dedupeFacts(facts); +} + +function extractFactsFromTurn(turn: Turn, sessionDate: string | null): ExtractedFact[] { + return [ + ...extractBookTitleFacts(turn, sessionDate), + ...extractPerformerEventFacts(turn, sessionDate), + ...extractRecommendationLetterFacts(turn, sessionDate), + ]; +} + +function extractBookTitleFacts(turn: Turn, sessionDate: string | null): ExtractedFact[] { + if (!/\b(?:book|books|read|reading)\b/i.test(turn.text)) return []; + return extractQuotedValues(turn.text).map((title) => { + const isFavorite = /\bfavou?rite\b/i.test(turn.text); + const relation = isFavorite ? 'favorite childhood book was' : 'read'; + const fact = `${subjectPrefix(sessionDate, turn.speaker)} ${relation} "${title}".`; + return buildFact(fact, title, 'concept', ['book', title], isFavorite ? 'preference' : 'knowledge'); + }); +} + +function extractPerformerEventFacts(turn: Turn, sessionDate: string | null): ExtractedFact[] { + const facts: ExtractedFact[] = []; + const leadingQuoted = turn.text.match(/^\s*["'“‘]([^"'”’]{2,80})["'”’]\s*[-:]/); + if (leadingQuoted && hasPerformanceSignal(turn.text)) { + const performer = leadingQuoted[1]!.trim(); + facts.push(buildPerformerFact(sessionDate, turn.speaker, performer)); + } + + for (const performer of extractNamedConcertPerformers(turn.text)) { + facts.push(buildPerformerFact(sessionDate, turn.speaker, performer)); + } + return facts; +} + +function extractRecommendationLetterFacts(turn: Turn, sessionDate: string | null): ExtractedFact[] { + if (!/\brecommendation letter\b/i.test(turn.text)) return []; + if (!/\b(?:writing|write|wrote|agreed to write)\b/i.test(turn.text)) return []; + + const writer = extractRecommendationWriter(turn.text); + if (!writer) return []; + + return [ + buildFact( + `${subjectPrefix(sessionDate, writer)} is writing ${possessiveSubject(turn.speaker)} main recommendation letter.`, + writer, + 'person', + ['recommendation letter', writer], + 'knowledge', + ), + ]; +} + +function extractRecommendationWriter(text: string): string | null { + const direct = text.match( + /\b(?Dr\.?\s+[A-Z][A-Za-z'’.-]+|[A-Z][A-Za-z'’.-]+(?:\s+[A-Z][A-Za-z'’.-]+){0,3})\s+(?:is|'s|will be|agreed to)\s+(?:writing|write)\s+(?:my|their|the user's)?\s*(?:main\s+)?recommendation letter\b/i, + ); + if (direct?.groups?.writer) return normalizePersonName(direct.groups.writer); + + const pronoun = text.match( + /\b(?Dr\.?\s+[A-Z][A-Za-z'’.-]+|[A-Z][A-Za-z'’.-]+(?:\s+[A-Z][A-Za-z'’.-]+){0,3})\b[^.]{0,120}\.\s*(?:she|he|they)\s*(?:'s| is| are| will be)?\s+(?:writing|write)\s+(?:my|their|the user's)?\s*(?:main\s+)?recommendation letter\b/i, + ); + return pronoun?.groups?.writer ? normalizePersonName(pronoun.groups.writer) : null; +} + +function buildPerformerFact( + sessionDate: string | null, + speaker: string, + performer: string, +): ExtractedFact { + return buildFact( + `${subjectPrefix(sessionDate, speaker)} saw "${performer}" perform music.`, + performer, + 'concept', + ['artist', 'band', 'music', performer], + 'knowledge', + ); +} + +function extractNamedConcertPerformers(text: string): string[] { + const performers: string[] = []; + const patterns = [ + /\bat\s+(?:a\s+)?([A-Z][A-Za-z'’.-]+(?:\s+[A-Z][A-Za-z'’.-]+){0,4})\s+concert\b/g, + /\bconcert\s+(?:featuring|with)\s+([A-Z][A-Za-z'’.-]+(?:\s+[A-Z][A-Za-z'’.-]+){0,4})\b/g, + ]; + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + performers.push(stripTrailingWords(match[1]!)); + } + } + return performers.filter(Boolean); +} + +function hasPerformanceSignal(text: string): boolean { + return /\b(?:played|playing|performed|performing|concert|show|stage|song|songs|dancing|singing)\b/i.test(text); +} + +function extractQuotedValues(text: string): string[] { + const values: string[] = []; + collectQuotedValues(values, text, /"([^"]{2,80})"/g); + collectQuotedValues(values, text, /“([^”]{2,80})”/g); + collectQuotedValues(values, text, /'([^']{2,80})'/g); + collectQuotedValues(values, text, /‘([^’]{2,80})’/g); + return values; +} + +function collectQuotedValues(values: string[], text: string, pattern: RegExp): void { + for (const match of text.matchAll(pattern)) { + values.push(match[1]!.trim()); + } +} + +function parseTurns(conversationText: string): Turn[] { + return conversationText + .split('\n') + .map((line) => line.trim()) + .map((line) => line.match(SPEAKER_LINE_PATTERN)) + .filter((match): match is RegExpMatchArray => match !== null) + .map((match) => ({ speaker: match[1]!, text: match[2]! })); +} + +function parseSessionDate(conversationText: string): string | null { + const match = conversationText.match(SESSION_DATE_PATTERN); + if (!match) return null; + const month = MONTH_NAMES[Number(match[2]) - 1]; + return month ? `${month} ${Number(match[3])} ${match[1]}` : null; +} + +function subjectPrefix(sessionDate: string | null, speaker: string): string { + const subject = speaker || 'user'; + return sessionDate ? `As of ${sessionDate}, ${subject}` : subject; +} + +function possessiveSubject(speaker: string): string { + return /^user$/i.test(speaker) ? "user's" : `${speaker}'s`; +} + +function buildFact( + fact: string, + entityName: string, + entityType: ExtractedEntity['type'], + keywords: string[], + type: ExtractedFact['type'], +): ExtractedFact { + return { + fact, + headline: fact.split(/\s+/).slice(0, 10).join(' '), + importance: 0.7, + type, + keywords, + entities: [{ name: entityName, type: entityType }], + relations: [], + }; +} + +function stripTrailingWords(text: string): string { + return text.replace(/\s+(?:last|yesterday|today|tomorrow)$/i, '').trim(); +} + +function normalizePersonName(text: string): string { + return text + .replace(/^(?:my|the user's|user's)\s+(?:advisor|mentor|professor)\s+/i, '') + .replace(/\bDr\s+/i, 'Dr. ') + .replace(/\s+/g, ' ') + .trim(); +} + +function dedupeFacts(facts: ExtractedFact[]): ExtractedFact[] { + const unique = new Map(facts.map((fact) => [normalize(fact.fact), fact])); + return [...unique.values()]; +} + +function normalize(text: string): string { + return text.toLowerCase().replace(/\s+/g, ' ').trim(); +} diff --git a/src/services/relative-temporal.ts b/src/services/relative-temporal.ts index baf7796..3f126f0 100644 --- a/src/services/relative-temporal.ts +++ b/src/services/relative-temporal.ts @@ -68,6 +68,14 @@ const RELATIVE_PATTERNS: RelativePattern[] = [ phrase: 'this month', }), }, + { + regex: /\blast month\b/gi, + resolve: (recordedDate) => ({ + eventDate: formatIsoMonth(shiftMonths(recordedDate, -1)), + precision: 'month', + phrase: 'last month', + }), + }, { regex: /\bnext month\b/gi, resolve: (recordedDate) => ({ diff --git a/src/services/retrieval-format.ts b/src/services/retrieval-format.ts index 6f223d9..7512796 100644 --- a/src/services/retrieval-format.ts +++ b/src/services/retrieval-format.ts @@ -11,7 +11,11 @@ import { config } from '../config.js'; import type { SearchResult } from '../db/memory-repository.js'; import type { ContextTier, TierAssignment } from './tiered-loading.js'; -import { assignTiers as assignTierBudgets, getContentAtTier } from './tiered-loading.js'; +import { + assignTiers as assignTierBudgets, + estimateTokens, + getContentAtTier, +} from './tiered-loading.js'; import { isAnswerBearing, sortBySessionPriority } from './session-packaging.js'; import { deduplicateCompositeMembersHard } from './composite-dedup.js'; import { prefersAbstractAwareRetrieval } from './abstract-query-policy.js'; @@ -181,8 +185,12 @@ function buildTemporalSummary(sortedMemories: SearchResult[]): string { const last = uniqueDates[uniqueDates.length - 1]; const totalDays = Math.round((last.getTime() - first.getTime()) / 86400000); const totalLine = `Total span: ${formatDateLabel(first)} to ${formatDateLabel(last)} (${formatDuration(totalDays)})`; + const evidenceLines = buildTemporalEvidenceLines(sortedMemories, uniqueDates); + const evidenceBlock = evidenceLines.length > 0 + ? `\nKey temporal evidence:\n${evidenceLines.join('\n')}` + : ''; - return `Timeline:\n${gaps.join('\n')}\n${totalLine}`; + return `Timeline:\n${gaps.join('\n')}\n${totalLine}${evidenceBlock}`; } function getUniqueDates(memories: SearchResult[]): Date[] { @@ -210,6 +218,30 @@ function formatDuration(days: number): string { return `~${months} month${months !== 1 ? 's' : ''} (${days} days)`; } +function buildTemporalEvidenceLines( + memories: SearchResult[], + dates: Date[], +): string[] { + return dates + .slice(0, 4) + .map((date) => buildTemporalEvidenceLine(memories, date)) + .filter((line): line is string => line !== null); +} + +function buildTemporalEvidenceLine(memories: SearchResult[], date: Date): string | null { + const key = formatDateLabel(date); + const sameDate = memories.filter((memory) => formatDateLabel(memory.created_at) === key); + const selected = sameDate.find((memory) => isAnswerBearing(memory.content)) ?? sameDate[0]; + if (!selected) return null; + return `- ${key}: ${truncateTemporalEvidence(selected.content)}`; +} + +function truncateTemporalEvidence(content: string): string { + const normalized = content.replace(/\s+/g, ' ').trim(); + if (normalized.length <= 180) return normalized; + return `${normalized.slice(0, 177)}...`; +} + export function formatInjection( memories: SearchResult[], options: RetrievalFormatOptions = {}, @@ -291,8 +323,10 @@ export function formatTieredInjection( .filter((a) => a.tier !== 'L2') .map((a) => a.memoryId) .join(','); - if (!expandableIds) return lines.join('\n'); - return `${lines.join('\n')}\nExpandable IDs: ${expandableIds}`; + const sections = expandableIds + ? [lines.join('\n'), `Expandable IDs: ${expandableIds}`] + : [lines.join('\n')]; + return appendTemporalSummary(sections, memories); } function formatTieredLine(memory: SearchResult, tier: ContextTier): string { @@ -312,6 +346,11 @@ function formatAge(date: Date): string { } const DEFAULT_INJECTION_TOKEN_BUDGET = 2000; +const QUERY_TERM_MIN_LENGTH = 4; +const QUERY_TERM_STOP_WORDS = new Set([ + 'what', 'when', 'where', 'which', 'with', 'from', 'that', 'this', + 'recently', 'attend', 'attended', 'does', 'have', 'has', 'did', +]); export interface InjectionBuildResult { injectionText: string; @@ -344,14 +383,73 @@ export function buildInjection( const forceRichTopHit = prefersAbstractAwareRetrieval(mode, query); const result = assignTierBudgets(deduplicated, budget, { forceRichTopHit }); - const expandIds = result.assignments + const assignments = preserveQueryTermVisibility(deduplicated, result.assignments, query, budget); + const expandIds = assignments .filter((a) => a.tier !== 'L2') .map((a) => a.memoryId); return { - injectionText: formatTieredInjection(deduplicated, result.assignments), - tierAssignments: result.assignments, + injectionText: formatTieredInjection(deduplicated, assignments), + tierAssignments: assignments, expandIds: expandIds.length > 0 ? expandIds : undefined, - estimatedContextTokens: result.totalTokens, + estimatedContextTokens: sumAssignmentTokens(assignments), }; } + +function preserveQueryTermVisibility( + memories: SearchResult[], + assignments: TierAssignment[], + query: string, + tokenBudget: number, +): TierAssignment[] { + const terms = extractQueryVisibilityTerms(query); + if (terms.length === 0) return assignments; + + const nextAssignments = assignments.map((assignment) => ({ ...assignment })); + let remaining = tokenBudget - sumAssignmentTokens(nextAssignments); + for (const memory of memories) { + const index = nextAssignments.findIndex((assignment) => assignment.memoryId === memory.id); + if (index === -1 || nextAssignments[index].tier === 'L2') continue; + const upgraded = chooseVisibleTier(memory, nextAssignments[index], terms, remaining); + if (!upgraded) continue; + remaining -= upgraded.estimatedTokens - nextAssignments[index].estimatedTokens; + nextAssignments[index] = upgraded; + } + return nextAssignments; +} + +function chooseVisibleTier( + memory: SearchResult, + assignment: TierAssignment, + terms: string[], + remainingBudget: number, +): TierAssignment | null { + const current = getContentAtTier(memory, assignment.tier).toLowerCase(); + const missingTerms = terms.filter((term) => !current.includes(term) && memory.content.toLowerCase().includes(term)); + if (missingTerms.length === 0) return null; + + for (const tier of ['L1', 'L2'] as const) { + const content = getContentAtTier(memory, tier).toLowerCase(); + const revealsTerm = missingTerms.some((term) => content.includes(term)); + const tokens = estimateTokens(content); + const extra = tokens - assignment.estimatedTokens; + if (revealsTerm && extra <= remainingBudget) { + return { memoryId: memory.id, tier, estimatedTokens: tokens }; + } + } + return null; +} + +function extractQueryVisibilityTerms(query: string): string[] { + const terms = query + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((term) => term.length >= QUERY_TERM_MIN_LENGTH) + .filter((term) => !QUERY_TERM_STOP_WORDS.has(term)); + return [...new Set(terms)]; +} + +function sumAssignmentTokens(assignments: Array<{ estimatedTokens: number }>): number { + return assignments.reduce((sum, assignment) => sum + assignment.estimatedTokens, 0); +} diff --git a/src/services/retrieval-policy.ts b/src/services/retrieval-policy.ts index a853151..a34d796 100644 --- a/src/services/retrieval-policy.ts +++ b/src/services/retrieval-policy.ts @@ -12,6 +12,17 @@ const COMPLEX_QUERY_LIMIT = 8; const MULTI_HOP_QUERY_LIMIT = 12; export const AGGREGATION_QUERY_LIMIT = 25; +type AdaptiveLimitConfig = Pick< + CoreRuntimeConfig, + | 'adaptiveRetrievalEnabled' + | 'maxSearchResults' + | 'adaptiveSimpleLimit' + | 'adaptiveMediumLimit' + | 'adaptiveComplexLimit' + | 'adaptiveMultiHopLimit' + | 'adaptiveAggregationLimit' +>; + /** Hard ceiling for aggregation queries (prevents runaway candidate pools). */ const AGGREGATION_HARD_CAP = 50; @@ -64,7 +75,7 @@ export interface ResolvedLimit { export function resolveSearchLimit( query: string, requestedLimit: number | undefined, - runtimeConfig: Pick, + runtimeConfig: AdaptiveLimitConfig, ): number { return resolveSearchLimitDetailed(query, requestedLimit, runtimeConfig).limit; } @@ -72,7 +83,7 @@ export function resolveSearchLimit( export function resolveSearchLimitDetailed( query: string, requestedLimit: number | undefined, - runtimeConfig: Pick, + runtimeConfig: AdaptiveLimitConfig, ): ResolvedLimit { if (requestedLimit !== undefined) { return { limit: clampLimit(requestedLimit, runtimeConfig.maxSearchResults), classification: { limit: requestedLimit, label: 'medium' } }; @@ -80,7 +91,7 @@ export function resolveSearchLimitDetailed( if (!runtimeConfig.adaptiveRetrievalEnabled) { return { limit: clampLimit(runtimeConfig.maxSearchResults, runtimeConfig.maxSearchResults), classification: { limit: runtimeConfig.maxSearchResults, label: 'medium' } }; } - const classification = classifyQueryDetailed(query); + const classification = applyConfiguredLimit(classifyQueryDetailed(query), runtimeConfig); // Aggregation queries bypass the normal maxSearchResults clamp to improve // recall for count/sum/list-all questions spanning many sessions. const limit = classification.label === 'aggregation' @@ -92,7 +103,7 @@ export function resolveSearchLimitDetailed( export function shouldRunRepairLoop( query: string, memories: SearchResult[], - runtimeConfig: Pick, + runtimeConfig: Pick & AdaptiveLimitConfig, ): boolean { if (!runtimeConfig.repairLoopEnabled) return false; // Selective repair: only escalate queries where the rewrite improves retrieval. @@ -186,6 +197,20 @@ export interface QueryClassification { matchedMarker?: string; } +function applyConfiguredLimit( + classification: QueryClassification, + runtimeConfig: AdaptiveLimitConfig, +): QueryClassification { + const limits: Record = { + simple: runtimeConfig.adaptiveSimpleLimit, + medium: runtimeConfig.adaptiveMediumLimit, + complex: runtimeConfig.adaptiveComplexLimit, + 'multi-hop': runtimeConfig.adaptiveMultiHopLimit, + aggregation: runtimeConfig.adaptiveAggregationLimit, + }; + return { ...classification, limit: limits[classification.label] }; +} + function classifyQueryComplexity(query: string): number { return classifyQueryDetailed(query).limit; } diff --git a/src/services/retrieval-trace.ts b/src/services/retrieval-trace.ts index 5aee404..fb48694 100644 --- a/src/services/retrieval-trace.ts +++ b/src/services/retrieval-trace.ts @@ -53,6 +53,9 @@ export interface RetrievalTraceSummary { candidateCount: number; queryText: string; skipRepair: boolean; + traceId?: string; + stageCount?: number; + stageNames?: string[]; } export type PackagingType = 'subject-pack' | 'timeline-pack' | 'tiered'; @@ -157,7 +160,13 @@ export class TraceCollector { } getRetrievalSummary(): RetrievalTraceSummary | undefined { - return this.retrieval; + if (!this.retrieval) return undefined; + return { + ...this.retrieval, + traceId: this.traceId, + stageCount: this.stages.length, + stageNames: this.stages.map((stage) => stage.name), + }; } getPackagingSummary(): PackagingTraceSummary | undefined { diff --git a/src/services/search-pipeline.ts b/src/services/search-pipeline.ts index 81e9b00..1c4d445 100644 --- a/src/services/search-pipeline.ts +++ b/src/services/search-pipeline.ts @@ -36,6 +36,8 @@ import { DEFAULT_RRF_K, weightedRRF } from './rrf-fusion.js'; import { applyIterativeRetrieval } from './iterative-retrieval.js'; import { applyCurrentStateRanking } from './current-state-ranking.js'; import { applyConcisenessPenalty } from './conciseness-preference.js'; +import { protectLiteralListAnswerCandidates } from './literal-list-protection.js'; +import { applyTemporalQueryConstraints } from './temporal-query-constraints.js'; const TEMPORAL_NEIGHBOR_WINDOW_MINUTES = 30; const SEMANTIC_RRF_WEIGHT = 1.2; @@ -45,6 +47,11 @@ const KEYWORD_RRF_WEIGHT = 1.0; export type SearchPipelineRuntimeConfig = Pick< CoreRuntimeConfig, | 'adaptiveRetrievalEnabled' + | 'adaptiveSimpleLimit' + | 'adaptiveMediumLimit' + | 'adaptiveComplexLimit' + | 'adaptiveMultiHopLimit' + | 'adaptiveAggregationLimit' | 'agenticRetrievalEnabled' | 'crossEncoderDtype' | 'crossEncoderEnabled' @@ -57,6 +64,8 @@ export type SearchPipelineRuntimeConfig = Pick< | 'linkExpansionEnabled' | 'linkExpansionMax' | 'linkSimilarityThreshold' + | 'literalListProtectionEnabled' + | 'literalListProtectionMaxProtected' | 'maxSearchResults' | 'mmrEnabled' | 'mmrLambda' @@ -74,6 +83,8 @@ export type SearchPipelineRuntimeConfig = Pick< | 'rerankSkipMinGap' | 'rerankSkipTopSimilarity' | 'retrievalProfileSettings' + | 'temporalQueryConstraintBoost' + | 'temporalQueryConstraintEnabled' >; /** * Decide whether to auto-skip cross-encoder reranking. @@ -677,90 +688,211 @@ async function applyExpansionAndReranking( skipReranking?: boolean, policyConfig: SearchPipelineRuntimeConfig = config, ): Promise { - // Cross-encoder reranking: re-score candidates before MMR - let candidates = results; - let protectedFingerprints = [...temporalAnchorFingerprints]; + const reranked = await applyCrossEncoderStage(query, results, skipReranking, trace, policyConfig); + const ranked = applyRankingProtectionStages( + query, + reranked, + temporalAnchorFingerprints, + trace, + policyConfig, + ); + + return selectAndExpandCandidates( + stores, + userId, + ranked.candidates, + queryEmbedding, + limit, + referenceTime, + ranked.protectedFingerprints, + trace, + policyConfig, + ); +} + +interface RankedCandidateState { + candidates: SearchResult[]; + protectedFingerprints: string[]; +} + +async function applyCrossEncoderStage( + query: string, + results: SearchResult[], + skipReranking: boolean | undefined, + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): Promise { const shouldSkipRerank = skipReranking || shouldAutoSkipReranking(results, policyConfig); - if (policyConfig.crossEncoderEnabled && !shouldSkipRerank) { - const rerankerConfig = { - crossEncoderModel: policyConfig.crossEncoderModel, - crossEncoderDtype: policyConfig.crossEncoderDtype, - }; - candidates = await rerankCandidates(query, results, rerankerConfig); - trace.stage('cross-encoder', candidates, { - model: rerankerConfig.crossEncoderModel, - dtype: rerankerConfig.crossEncoderDtype, - }); - } else if (policyConfig.crossEncoderEnabled && shouldSkipRerank) { + if (!policyConfig.crossEncoderEnabled) return results; + if (shouldSkipRerank) { console.log(`[reranker] Skipped: ${skipReranking ? 'explicit' : 'auto-skip (high-confidence results)'}`); + return results; } - const subjectRanked = applySubjectAwareRanking(query, candidates); - if (subjectRanked.subjects.length > 0) { - candidates = subjectRanked.results; - protectedFingerprints = [...protectedFingerprints, ...subjectRanked.protectedFingerprints]; - trace.stage('subject-aware-ranking', candidates, { - subjects: subjectRanked.subjects, - keywords: subjectRanked.keywords, - protected: subjectRanked.protectedFingerprints.length, - }); - } - const currentStateRanked = applyCurrentStateRanking(query, candidates); + + const rerankerConfig = { + crossEncoderModel: policyConfig.crossEncoderModel, + crossEncoderDtype: policyConfig.crossEncoderDtype, + }; + const candidates = await rerankCandidates(query, results, rerankerConfig); + trace.stage('cross-encoder', candidates, { + model: rerankerConfig.crossEncoderModel, + dtype: rerankerConfig.crossEncoderDtype, + }); + return candidates; +} + +function applyRankingProtectionStages( + query: string, + candidates: SearchResult[], + temporalAnchorFingerprints: string[], + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): RankedCandidateState { + let state = applySubjectRankingStage(query, candidates, temporalAnchorFingerprints, trace); + state = applyLiteralProtectionStage(query, state, trace, policyConfig); + state = applyTemporalConstraintStage(query, state, trace, policyConfig); + + const currentStateRanked = applyCurrentStateRanking(query, state.candidates); if (currentStateRanked.triggered) { - candidates = currentStateRanked.results; - trace.stage('current-state-ranking', candidates, {}); + trace.stage('current-state-ranking', currentStateRanked.results, {}); + state = { ...state, candidates: currentStateRanked.results }; } - candidates = applyConcisenessPenalty(candidates); + return { ...state, candidates: applyConcisenessPenalty(state.candidates) }; +} +function applySubjectRankingStage( + query: string, + candidates: SearchResult[], + protectedFingerprints: string[], + trace: TraceCollector, +): RankedCandidateState { + const subjectRanked = applySubjectAwareRanking(query, candidates); + if (subjectRanked.subjects.length === 0) return { candidates, protectedFingerprints }; + + trace.stage('subject-aware-ranking', subjectRanked.results, { + subjects: subjectRanked.subjects, + keywords: subjectRanked.keywords, + protected: subjectRanked.protectedFingerprints.length, + }); + return { + candidates: subjectRanked.results, + protectedFingerprints: [...protectedFingerprints, ...subjectRanked.protectedFingerprints], + }; +} + +function applyLiteralProtectionStage( + query: string, + state: RankedCandidateState, + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): RankedCandidateState { + if (!policyConfig.literalListProtectionEnabled) return state; + const literalProtected = protectLiteralListAnswerCandidates( + query, + state.candidates, + policyConfig.literalListProtectionMaxProtected, + ); + trace.stage('literal-list-protection', literalProtected.results, { + protected: literalProtected.protectedFingerprints.length, + protected_ids: literalProtected.protectedIds, + reasons: literalProtected.reasons, + }); + return { + candidates: literalProtected.results, + protectedFingerprints: [...state.protectedFingerprints, ...literalProtected.protectedFingerprints], + }; +} + +function applyTemporalConstraintStage( + query: string, + state: RankedCandidateState, + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): RankedCandidateState { + if (!policyConfig.temporalQueryConstraintEnabled) return state; + const constrained = applyTemporalQueryConstraints(query, state.candidates, policyConfig.temporalQueryConstraintBoost); + trace.stage('temporal-query-constraints', constrained.results, { + constraints: constrained.constraints, + protected: constrained.protectedFingerprints.length, + protected_ids: constrained.protectedIds, + boost: policyConfig.temporalQueryConstraintBoost, + }); + return { + candidates: constrained.results, + protectedFingerprints: [...state.protectedFingerprints, ...constrained.protectedFingerprints], + }; +} + +async function selectAndExpandCandidates( + stores: SearchPipelineStores, + userId: string, + candidates: SearchResult[], + queryEmbedding: number[], + limit: number, + referenceTime: Date | undefined, + protectedFingerprints: string[], + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): Promise { if (policyConfig.linkExpansionBeforeMMR && policyConfig.linkExpansionEnabled && policyConfig.mmrEnabled) { - const preExpanded = await expandWithLinks( - stores, - userId, - candidates.slice(0, limit), - queryEmbedding, - referenceTime, - policyConfig, - ); - trace.stage('link-expansion', preExpanded, { order: 'before-mmr' }); - const selected = preserveProtectedResults( - applyMMR(preExpanded, queryEmbedding, limit, policyConfig.mmrLambda), - preExpanded, - protectedFingerprints, - limit, - ); - trace.stage('mmr', selected, { lambda: policyConfig.mmrLambda }); - return selected; + return selectWithPreMmrExpansion(stores, userId, candidates, queryEmbedding, limit, referenceTime, protectedFingerprints, trace, policyConfig); } - if (policyConfig.mmrEnabled) { - const mmrResults = preserveProtectedResults( - applyMMR(candidates, queryEmbedding, limit, policyConfig.mmrLambda), - candidates, - protectedFingerprints, - limit, - ); - trace.stage('mmr', mmrResults, { lambda: policyConfig.mmrLambda }); - const expanded = await expandWithLinks( - stores, - userId, - mmrResults, - queryEmbedding, - referenceTime, - policyConfig, - ); - trace.stage('link-expansion', expanded, { order: 'after-mmr' }); - return expanded; + return selectWithMmrThenExpand(stores, userId, candidates, queryEmbedding, limit, referenceTime, protectedFingerprints, trace, policyConfig); } + return selectWithoutMmr(stores, userId, candidates, queryEmbedding, limit, referenceTime, protectedFingerprints, trace, policyConfig); +} +async function selectWithPreMmrExpansion( + stores: SearchPipelineStores, + userId: string, + candidates: SearchResult[], + queryEmbedding: number[], + limit: number, + referenceTime: Date | undefined, + protectedFingerprints: string[], + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): Promise { + const preExpanded = await expandWithLinks(stores, userId, candidates.slice(0, limit), queryEmbedding, referenceTime, policyConfig); + trace.stage('link-expansion', preExpanded, { order: 'before-mmr' }); + const selected = preserveProtectedResults(applyMMR(preExpanded, queryEmbedding, limit, policyConfig.mmrLambda), preExpanded, protectedFingerprints, limit); + trace.stage('mmr', selected, { lambda: policyConfig.mmrLambda }); + return selected; +} + +async function selectWithMmrThenExpand( + stores: SearchPipelineStores, + userId: string, + candidates: SearchResult[], + queryEmbedding: number[], + limit: number, + referenceTime: Date | undefined, + protectedFingerprints: string[], + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): Promise { + const mmrResults = preserveProtectedResults(applyMMR(candidates, queryEmbedding, limit, policyConfig.mmrLambda), candidates, protectedFingerprints, limit); + trace.stage('mmr', mmrResults, { lambda: policyConfig.mmrLambda }); + const expanded = await expandWithLinks(stores, userId, mmrResults, queryEmbedding, referenceTime, policyConfig); + trace.stage('link-expansion', expanded, { order: 'after-mmr' }); + return expanded; +} + +async function selectWithoutMmr( + stores: SearchPipelineStores, + userId: string, + candidates: SearchResult[], + queryEmbedding: number[], + limit: number, + referenceTime: Date | undefined, + protectedFingerprints: string[], + trace: TraceCollector, + policyConfig: SearchPipelineRuntimeConfig, +): Promise { const sliced = preserveProtectedResults(candidates.slice(0, limit), candidates, protectedFingerprints, limit); - const expanded = await expandWithLinks( - stores, - userId, - sliced, - queryEmbedding, - referenceTime, - policyConfig, - ); + const expanded = await expandWithLinks(stores, userId, sliced, queryEmbedding, referenceTime, policyConfig); trace.stage('link-expansion', expanded, { order: 'no-mmr' }); return expanded; } diff --git a/src/services/session-date.ts b/src/services/session-date.ts new file mode 100644 index 0000000..0d3a28a --- /dev/null +++ b/src/services/session-date.ts @@ -0,0 +1,26 @@ +/** + * Shared parser for transcript-level session dates. + * + * Benchmark and SDK callers can include a first-line header: + * `[Session date: ...]`. Core uses it as the logical observation timestamp + * for extraction, storage backdating, and context packaging. + */ + +const SESSION_DATE_PATTERN = /^\[Session date:\s*([^\]]+)\]/i; + +export function extractSessionTimestamp(conversationText: string): string | null { + const firstLine = conversationText.split('\n', 1)[0] ?? ''; + const match = firstLine.match(SESSION_DATE_PATTERN); + return match?.[1]?.trim() || null; +} + +export function parseSessionDate(conversationText: string): Date | null { + const timestamp = extractSessionTimestamp(conversationText); + if (!timestamp) return null; + const parsed = new Date(timestamp); + return Number.isNaN(parsed.getTime()) ? null : parsed; +} + +export function resolveSessionDate(explicitTimestamp: Date | undefined, conversationText: string): Date | undefined { + return explicitTimestamp ?? parseSessionDate(conversationText) ?? undefined; +} diff --git a/src/services/subject-aware-ranking.ts b/src/services/subject-aware-ranking.ts index 2ae4795..2d28410 100644 --- a/src/services/subject-aware-ranking.ts +++ b/src/services/subject-aware-ranking.ts @@ -8,6 +8,7 @@ import type { SearchResult } from '../db/repository-types.js'; import type { SearchStore } from '../db/stores.js'; import { buildTemporalFingerprint } from './temporal-fingerprint.js'; import { fetchAndBoostKeywordCandidates } from './keyword-expansion.js'; +import { countKeywordMatches } from './query-keyword-matches.js'; const MONTH_NAMES = new Set([ 'January', 'February', 'March', 'April', 'May', 'June', @@ -150,11 +151,6 @@ function extractQueryCandidates(query: string): string[] { }); } -function countKeywordMatches(content: string, keywords: string[]): number { - const lower = content.toLowerCase(); - return keywords.filter((keyword) => lower.includes(keyword)).length; -} - function buildProtectedFingerprints(scoredResults: ScoredSubjectCandidate[]): string[] { return scoredResults .filter((item) => item.hasRequestedSubject && item.keywordMatches > 0) diff --git a/src/services/supplemental-extraction.ts b/src/services/supplemental-extraction.ts index bc92966..d1ebbdd 100644 --- a/src/services/supplemental-extraction.ts +++ b/src/services/supplemental-extraction.ts @@ -10,12 +10,12 @@ import { quickExtractFacts } from './quick-extraction.js'; import { containsRelativeTemporalPhrase } from './relative-temporal.js'; const LITERAL_DETAIL_PATTERN = - /\b(?:necklace|book|books|song|songs|music|musicians|fan|painting|paintings|photo|poster|posters|library|store|decor|furniture|flooring|pet|pets|cat|cats|dog|dogs|guinea pig|workshop|poetry reading|sign|slipper|bowl)\b/i; + /\b(?:necklace|book|books|song|songs|music|musicians|fan|painting|paintings|photo|poster|posters|library|store|decor|furniture|flooring|pet|pets|cat|cats|dog|dogs|guinea pig|turtle|turtles|snake|snakes|workshop|poetry reading|sign|slipper|bowl)\b/i; const QUOTED_TEXT_PATTERN = /["“”][^"“”]{2,}["“”]/; const TEMPORAL_DETAIL_PATTERN = - /\b(last year|last month|last week|last [a-z]+|today|tomorrow|first|second|before|after|deadline|deadlines|timeline|relative to|months later|weeks later)\b/i; + /\b(last year|last month|last week|last [a-z]+|today|tomorrow|first|second|before|after|deadline|deadlines|timeline|relative to|months later|weeks later|few days ago|for \d+ years?|for three years?|for two years?|for four years?|for five years?)\b/i; const EVENT_DETAIL_PATTERN = - /\b(?:accepted|interview|internship|mentor(?:ed|ing)?|network(?:ing)?|social media|competition|investor(?:s)?|fashion editors|analytics tools|video presentation|website|collaborat(?:e|ion)|dance class|Shia Labeouf)\b/i; + /\b(?:accepted|interview|internship|mentor(?:ed|ing)?|network(?:ing)?|social media|competition|investor(?:s)?|fashion editors|analytics tools|video presentation|website|collaborat(?:e|ion)|dance class|Shia Labeouf|trip|travel(?:ed|ling)?|retreat|phuket|doctor|doc|check-up|appointment|blog|car mods?|restor(?:e|ed|ing|ation))\b/i; export function mergeSupplementalFacts( primaryFacts: ExtractedFact[], @@ -69,9 +69,16 @@ function shouldIncludeSupplementalFact( return false; } - return shapeMatches.every( - (fact) => !hasRelativeTemporalDetail(fact.fact) && !hasLiteralDetail(fact.fact) && !hasEventDetail(fact.fact), - ); + if (candidateAddsTemporalDetail) { + return shapeMatches.every((fact) => !hasRelativeTemporalDetail(fact.fact)); + } + if (candidateAddsLiteralDetail) { + return shapeMatches.every((fact) => !hasLiteralDetail(fact.fact)); + } + if (candidateAddsEventDetail) { + return shapeMatches.every((fact) => !hasEventDetail(fact.fact)); + } + return false; } function findUpgradeableFactIndex( diff --git a/src/services/temporal-query-constraints.ts b/src/services/temporal-query-constraints.ts new file mode 100644 index 0000000..b0c1887 --- /dev/null +++ b/src/services/temporal-query-constraints.ts @@ -0,0 +1,148 @@ +/** + * Query-time temporal constraint ranking for explicit month/date questions. + * + * This is intentionally narrow: it only reacts when the user states a month + * constraint in the query, then boosts/protects candidates whose content or + * observation timestamp matches that month. It complements temporal ordering + * expansion, which handles "before/after/when" phrasing. + */ + +import type { SearchResult } from '../db/repository-types.js'; +import { buildTemporalFingerprint } from './temporal-fingerprint.js'; +import { countKeywordMatches } from './query-keyword-matches.js'; + +const MONTHS = [ + 'january', 'february', 'march', 'april', 'may', 'june', + 'july', 'august', 'september', 'october', 'november', 'december', +] as const; + +const QUERY_STOP_WORDS = new Set([ + 'what', 'when', 'where', 'which', 'who', 'whom', 'whose', 'why', 'how', + 'did', 'does', 'do', 'was', 'were', 'is', 'are', 'the', 'a', 'an', 'and', + 'or', 'to', 'of', 'in', 'for', 'on', 'with', 'about', 'user', 'their', + 'they', 'them', 'there', 'happen', 'happened', 'event', 'events', +]); + +const MAX_PROTECTED_TEMPORAL_CONSTRAINTS = 3; + +export interface TemporalConstraintRankingResult { + constraints: string[]; + protectedFingerprints: string[]; + protectedIds: string[]; + results: SearchResult[]; +} + +interface TemporalQueryConstraint { + monthIndex: number; + monthName: string; + year?: number; +} + +interface ScoredTemporalCandidate { + result: SearchResult; + matched: boolean; +} + +export function applyTemporalQueryConstraints( + query: string, + results: SearchResult[], + boost: number, +): TemporalConstraintRankingResult { + const constraints = extractTemporalConstraints(query); + if (constraints.length === 0 || boost <= 0) { + return emptyResult(results); + } + + const keywords = extractQueryKeywords(query); + const scored = results + .map((result) => scoreTemporalCandidate(result, constraints, keywords, boost)) + .sort((left, right) => right.result.score - left.result.score); + const protectedCandidates = scored.filter((item) => item.matched).slice(0, MAX_PROTECTED_TEMPORAL_CONSTRAINTS); + + return { + constraints: constraints.map(formatConstraint), + protectedFingerprints: protectedCandidates.map((item) => buildTemporalFingerprint(item.result.content)), + protectedIds: protectedCandidates.map((item) => item.result.id), + results: scored.map((item) => item.result), + }; +} + +function extractTemporalConstraints(query: string): TemporalQueryConstraint[] { + const lower = query.toLowerCase(); + const constraints: TemporalQueryConstraint[] = []; + for (let index = 0; index < MONTHS.length; index++) { + const monthName = MONTHS[index]; + const pattern = new RegExp(`\\b${monthName}\\b(?:\\s+(\\d{4}))?`, 'i'); + const match = lower.match(pattern); + if (!match) continue; + const year = match[1] ? parseInt(match[1], 10) : undefined; + constraints.push({ monthIndex: index, monthName, year }); + } + return constraints; +} + +function scoreTemporalCandidate( + result: SearchResult, + constraints: TemporalQueryConstraint[], + keywords: string[], + boost: number, +): ScoredTemporalCandidate { + if (!matchesAnyConstraint(result, constraints) || !hasKeywordSupport(result.content, keywords)) { + return { result, matched: false }; + } + + const keywordBoost = countKeywordMatches(result.content, keywords) * 0.1; + return { + result: { ...result, score: result.score + boost + keywordBoost }, + matched: true, + }; +} + +function matchesAnyConstraint(result: SearchResult, constraints: TemporalQueryConstraint[]): boolean { + return constraints.some((constraint) => ( + contentMatchesConstraint(result.content, constraint) + || dateMatchesConstraint(result.created_at, constraint) + || dateMatchesConstraint(result.observed_at, constraint) + )); +} + +function contentMatchesConstraint(content: string, constraint: TemporalQueryConstraint): boolean { + const lower = content.toLowerCase(); + if (!lower.includes(constraint.monthName)) return false; + if (constraint.year === undefined) return true; + return lower.includes(String(constraint.year)); +} + +function dateMatchesConstraint(date: Date, constraint: TemporalQueryConstraint): boolean { + if (date.getUTCMonth() !== constraint.monthIndex) return false; + if (constraint.year === undefined) return true; + return date.getUTCFullYear() === constraint.year; +} + +function hasKeywordSupport(content: string, keywords: string[]): boolean { + if (keywords.length === 0) return true; + const requiredMatches = Math.min(2, keywords.length); + return countKeywordMatches(content, keywords) >= requiredMatches; +} + +function extractQueryKeywords(query: string): string[] { + const words = query + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((word) => word.length > 2) + .filter((word) => !QUERY_STOP_WORDS.has(word)) + .filter((word) => !MONTHS.includes(word as typeof MONTHS[number])) + .filter((word) => !/^\d{4}$/.test(word)); + return [...new Set(words)]; +} + +function formatConstraint(constraint: TemporalQueryConstraint): string { + return constraint.year === undefined + ? constraint.monthName + : `${constraint.monthName} ${constraint.year}`; +} + +function emptyResult(results: SearchResult[]): TemporalConstraintRankingResult { + return { constraints: [], protectedFingerprints: [], protectedIds: [], results }; +} From 3de9aa80eba0f431fbfdb4a7527338524e693bad Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Apr 2026 17:23:32 -0700 Subject: [PATCH 2/2] chore(openapi): regenerate spec for retrieval observability fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's check:openapi gate caught that openapi.{json,yaml} were stale after the search-response schema gained `trace_id`, `stage_count`, and `stage_names` under `observability.retrieval`. Regenerated via `npm run generate:openapi` to match the Zod schemas in src/schemas/. No source change — generated artifacts only. Co-Authored-By: Claude Opus 4.7 (1M context) --- openapi.json | 24 ++++++++++++++++++++++++ openapi.yaml | 16 ++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/openapi.json b/openapi.json index 276f54a..ca3ac43 100644 --- a/openapi.json +++ b/openapi.json @@ -3802,6 +3802,18 @@ }, "skip_repair": { "type": "boolean" + }, + "stage_count": { + "type": "number" + }, + "stage_names": { + "items": { + "type": "string" + }, + "type": "array" + }, + "trace_id": { + "type": "string" } }, "required": [ @@ -4316,6 +4328,18 @@ }, "skip_repair": { "type": "boolean" + }, + "stage_count": { + "type": "number" + }, + "stage_names": { + "items": { + "type": "string" + }, + "type": "array" + }, + "trace_id": { + "type": "string" } }, "required": [ diff --git a/openapi.yaml b/openapi.yaml index 7e32d0b..cc3d244 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -2558,6 +2558,14 @@ paths: type: string skip_repair: type: boolean + stage_count: + type: number + stage_names: + items: + type: string + type: array + trace_id: + type: string required: - candidate_ids - candidate_count @@ -2906,6 +2914,14 @@ paths: type: string skip_repair: type: boolean + stage_count: + type: number + stage_names: + items: + type: string + type: array + trace_id: + type: string required: - candidate_ids - candidate_count