diff --git a/src/services/__tests__/extraction-enrichment.test.ts b/src/services/__tests__/extraction-enrichment.test.ts index d648105..aa6d47f 100644 --- a/src/services/__tests__/extraction-enrichment.test.ts +++ b/src/services/__tests__/extraction-enrichment.test.ts @@ -106,6 +106,46 @@ describe('instruction tagging (EXP-05)', () => { expect(detectInstructionFact('User favorite color is blue.')).toBe(false); }); + // EXP-05 H-310 — BEAM soft-imperative additions. + // BEAM users phrase instructions softer than the original strict + // imperatives. These positive cases mirror the surface form that the + // extraction pipeline emits (third person "user prefers / wants / ..."). + const BEAM_INSTRUCTION_PHRASES = [ + 'User prefers simple, minimal dependencies to keep the app lightweight.', + 'User wants the dashboard API response time to stay under 250ms.', + 'User would like all responses to include explicit version numbers.', + "I'd like the assistant to suggest only lightweight libraries.", + 'Please respond in formal English when discussing security topics.', + 'Assistant should always include unit tests in code examples.', + 'Assistant should never log passwords or session tokens.', + 'User preference is to use TypeScript over JavaScript for new files.', + 'User has a preference for syntax-highlighted code snippets in discussions.', + ]; + + for (const phrase of BEAM_INSTRUCTION_PHRASES) { + it(`H-310: detects BEAM soft-imperative in: "${phrase}"`, () => { + expect(detectInstructionFact(phrase)).toBe(true); + }); + } + + // False-positive prevention: phrasings that LOOK directive but are + // actually questions or negations and must NOT be tagged. + const BEAM_FALSE_POSITIVE_PHRASES = [ + // Question prefixes — "wants to know" is a query, not a standing rule. + 'User wants to know how Flask sessions work.', + "I'd like to know which Python version is currently installed.", + 'User would like to ask about the difference between SQLite and Postgres.', + // Negations — "prefers not to" is not a standing rule we should boost. + 'User prefers not to use heavy frameworks for this project.', + 'User does not prefer dark mode in the IDE.', + ]; + + for (const phrase of BEAM_FALSE_POSITIVE_PHRASES) { + it(`H-310: suppresses false positive: "${phrase}"`, () => { + expect(detectInstructionFact(phrase)).toBe(false); + }); + } + it('tags fact metadata with fact_role=instruction and floors importance', () => { const fact = buildFact('Always respond to me in formal English.', []); diff --git a/src/services/extraction-enrichment.ts b/src/services/extraction-enrichment.ts index 0018791..fc3dd5b 100644 --- a/src/services/extraction-enrichment.ts +++ b/src/services/extraction-enrichment.ts @@ -8,6 +8,7 @@ import type { ExtractedEntity, ExtractedFact, ExtractedRelation } from './extraction.js'; import { dedupeEntities } from './entity-dedup.js'; import { inferEventAnchorFacts, type EventAnchorOptions } from './event-anchor-facts.js'; +import { matchesInstructionMarker } from './instruction-markers.js'; export type EnrichmentOptions = EventAnchorOptions; @@ -20,27 +21,12 @@ const KNOWS_MARKERS = ['advisor', 'career advice from', 'supportive', 'recommend const STUDIES_MARKERS = ['focus on', 'studying', 'research', 'paper on']; /** - * Imperative phrasing markers that flag an extracted fact as an explicit - * instruction. Detection is intentionally conservative — false positives on - * assistant tutorial content are acceptable since extraction prefixes those - * with "Assistant mentioned:". When a marker hits, the fact gains - * `metadata.fact_role: 'instruction'` and its importance is floored to 0.95 - * so lifecycle decay does not evict it. + * Imperative-phrase detection lives in `instruction-markers.ts` so it can + * be extended (BEAM soft imperatives, H-310) without bloating this file. + * When a marker hits, the fact gains `metadata.fact_role: 'instruction'` + * and its importance is floored to INSTRUCTION_IMPORTANCE_FLOOR so + * lifecycle decay does not evict it. */ -const INSTRUCTION_MARKERS = [ - 'always ', - 'never ', - 'from now on', - 'please remember', - 'make sure to', - "don't forget", - 'do not forget', - 'every time', - 'whenever you', - 'going forward', - 'in the future', - 'remember to', -] as const; /** Floor for instruction-tagged importance — see plan EXP-05 risks (2). */ const INSTRUCTION_IMPORTANCE_FLOOR = 0.95; @@ -123,8 +109,7 @@ function applyInstructionTagging(fact: ExtractedFact): ExtractedFact { /** Returns true when the fact text contains an imperative instruction marker. */ export function detectInstructionFact(text: string): boolean { - const lower = ` ${text.toLowerCase()} `; - return INSTRUCTION_MARKERS.some((marker) => lower.includes(marker)); + return matchesInstructionMarker(text); } function shouldAddSelfEntity(text: string): boolean { diff --git a/src/services/instruction-markers.ts b/src/services/instruction-markers.ts new file mode 100644 index 0000000..21495b6 --- /dev/null +++ b/src/services/instruction-markers.ts @@ -0,0 +1,109 @@ +/** + * Instruction-marker phrase list for EXP-05 (H-310). + * + * Detects facts that express a user-supplied directive, preference, or + * standing instruction. Two layers: + * + * 1. INSTRUCTION_MARKERS — case-insensitive substrings that flag the fact. + * 2. FALSE_POSITIVE_PATTERNS — subsequences that disqualify a hit + * (e.g. "wants to know" is a question prefix, not a directive). + * + * Both lists are intentionally additive on top of the original strict + * imperatives ("always X / never Y / from now on / going forward / make + * sure to / ...") so existing behavior is preserved. The expansion + * targets BEAM-style soft imperatives that the Stage-7 v1 dryrun missed + * (only 15/2000 facts tagged — see plan H-310). + * + * Detection runs over the post-extraction fact form, which is third + * person ("user prefers X", "user wants Y") — so the markers match that + * surface form rather than first-person ("I prefer X"). Both forms are + * supported by relying on the unanchored substring "prefer" / "want". + */ + +/** + * Phrases that flag an extracted fact as an explicit instruction or + * preference. Matched as case-insensitive substrings against the fact + * text padded with spaces on either side. + * + * Ordering note: the list MUST keep all original strict imperatives at + * the top so that existing behavior is preserved verbatim. + */ +export const INSTRUCTION_MARKERS = [ + // --- Original strict imperatives (preserved verbatim from EXP-05 v1) --- + 'always ', + 'never ', + 'from now on', + 'please remember', + 'make sure to', + "don't forget", + 'do not forget', + 'every time', + 'whenever you', + 'going forward', + 'in the future', + 'remember to', + // --- BEAM soft-imperative additions (H-310) --- + // Preferences (PF questions) + 'prefer ', + 'prefers ', + 'preference is', + 'preference for', + // First/third-person desires that read as standing requests + ' want ', + ' wants ', + "i'd like", + 'would like', + // Standing-rule phrasings users issue mid-conversation + 'please ', + 'should always', + 'should never', + 'must always', + // Inclusion/format directives + 'always include', + 'always use', + 'use only', +] as const; + +/** + * Disqualifying sub-phrases. If any of these appears in the lower-cased + * fact text, the instruction tag is suppressed even when an + * INSTRUCTION_MARKERS entry matched. These cover cases where the + * marker word is part of a question or hedge rather than a directive. + * + * Examples blocked: + * - "user wants to know" → question prefix, not a directive + * - "user would like to know" / "would like to ask" → question prefix + * - "user prefers not to" → negation; expressed as not-doing, not a rule + * - "user does not prefer" → negation + * - "i want to know" → question, surfaces post-extraction unchanged + */ +export const FALSE_POSITIVE_PATTERNS: readonly string[] = [ + 'want to know', + 'wants to know', + 'wanted to know', + 'would like to know', + 'would like to ask', + "i'd like to know", + "i'd like to ask", + 'prefer not to', + 'prefers not to', + 'does not prefer', + 'do not prefer', +] as const; + +/** + * Returns true when the fact text contains at least one instruction + * marker AND is not blocked by a false-positive pattern. + * + * The leading/trailing space pad lets us match word-boundary-like + * patterns ("always ", " want ") without resorting to regex. The pad + * also makes "wants" not match a marker that requires "want " with a + * trailing space — only " wants " (the dedicated marker) matches. + */ +export function matchesInstructionMarker(text: string): boolean { + const lower = ` ${text.toLowerCase()} `; + if (FALSE_POSITIVE_PATTERNS.some((pattern) => lower.includes(pattern))) { + return false; + } + return INSTRUCTION_MARKERS.some((marker) => lower.includes(marker)); +}