-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement IntentExtractor service (GIT-98) #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ import { TrailerService } from '../services/TrailerService'; | |
| import { EventBus } from '../events/EventBus'; | ||
| import { createLogger } from '../logging/factory'; | ||
| import { createLLMClient } from '../llm/LLMClientFactory'; | ||
| import { IntentExtractor } from '../llm/IntentExtractor'; | ||
| import { AgentResolver } from '../services/AgentResolver'; | ||
| import { HookConfigLoader } from '../services/HookConfigLoader'; | ||
|
|
||
|
|
@@ -110,6 +111,22 @@ export function createContainer(options?: IContainerOptions): AwilixContainer<IC | |
| return createLLMClient() ?? null; | ||
| }).singleton(), | ||
|
|
||
| intentExtractor: asFunction(() => { | ||
| // Intent extraction requires an API key. Return null for graceful degradation. | ||
| const apiKey = process.env.ANTHROPIC_API_KEY; | ||
| if (!apiKey) { | ||
| return null; | ||
| } | ||
| try { | ||
| return new IntentExtractor({ | ||
| apiKey, | ||
| logger: container.cradle.logger, | ||
| }); | ||
| } catch { | ||
| return null; | ||
| } | ||
| }).singleton(), | ||
|
Comment on lines
+114
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check for usages of intentTimeout/minWords/extractIntent across the codebase
rg -n --type=ts '\bintentTimeout\b|\bminWords\b|\bextractIntent\b' | head -100Repository: TonyCasey/git-mem Length of output: 1197 🏁 Script executed: #!/bin/bash
# Also search for IntentExtractor class definition and usage
rg -n --type=ts 'class IntentExtractor|IntentExtractor(' | head -50Repository: TonyCasey/git-mem Length of output: 187 🏁 Script executed: #!/bin/bash
# Search for promptSubmit config references
rg -n --type=ts 'promptSubmit' | head -50Repository: TonyCasey/git-mem Length of output: 3445 🏁 Script executed: #!/bin/bash
# Check the IntentExtractor class definition to see what parameters it accepts
cat -n src/infrastructure/llm/IntentExtractor.ts | head -60Repository: TonyCasey/git-mem Length of output: 2777 🏁 Script executed: #!/bin/bash
# Check where IntentExtractor is instantiated - look for "new IntentExtractor"
rg -n --type=ts 'new IntentExtractor' -A5Repository: TonyCasey/git-mem Length of output: 441 🏁 Script executed: #!/bin/bash
# Check the container.ts file around the intentExtractor registration
cat -n src/infrastructure/di/container.ts | sed -n '100,150p'Repository: TonyCasey/git-mem Length of output: 2305 🏁 Script executed: #!/bin/bash
# Check how intentExtractor is used in the hooks layer
rg -n --type=ts 'intentExtractor|extractIntent' src/hooks --context=3Repository: TonyCasey/git-mem Length of output: 436 🏁 Script executed: #!/bin/bash
# Check if the hook config is passed to the DI container
rg -n --type=ts 'hookConfig|HookConfig' src/infrastructure/di --context=2Repository: TonyCasey/git-mem Length of output: 2030 🏁 Script executed: #!/bin/bash
# Check the entire container.ts file to see what options are passed in
cat -n src/infrastructure/di/container.ts | head -50Repository: TonyCasey/git-mem Length of output: 2798 🏁 Script executed: #!/bin/bash
# Check where intentExtractor is actually used (dependencies)
rg -n --type=ts 'intentExtractor|IIntentExtractor' src/application --context=3Repository: TonyCasey/git-mem Length of output: 43 🏁 Script executed: #!/bin/bash
# Check PromptSubmitHandler to see how it uses intentExtractor
cat -n src/application/handlers/PromptSubmitHandler.ts | head -100Repository: TonyCasey/git-mem Length of output: 2447 🏁 Script executed: #!/bin/bash
# Check if hookConfig is somehow passed to PromptSubmitHandler
rg -n --type=ts 'PromptSubmitHandler' src --context=5Repository: TonyCasey/git-mem Length of output: 7080 🏁 Script executed: #!/bin/bash
# Check if intentExtractor is actually used/injected anywhere in the codebase
rg -n --type=ts 'intentExtractor' src --context=2 | grep -v 'asFunction\|singleton\|test'Repository: TonyCasey/git-mem Length of output: 639 🏁 Script executed: #!/bin/bash
# Check if the config values are actually read and used anywhere
rg -n --type=ts 'intentTimeout|minWords' src/infrastructure/di --context=3Repository: TonyCasey/git-mem Length of output: 43 🏁 Script executed: #!/bin/bash
# Check all services that depend on intentExtractor in the DI container
cat -n src/infrastructure/di/container.ts | sed -n '50,150p'Repository: TonyCasey/git-mem Length of output: 4454 🏁 Script executed: #!/bin/bash
# Search for any service that has intentExtractor as a constructor dependency
rg -n --type=ts 'intentExtractor|IIntentExtractor' src/application src/infrastructure/services --context=3Repository: TonyCasey/git-mem Length of output: 43 🏁 Script executed: #!/bin/bash
# Check if intentExtractor is ever retrieved from the container after registration
rg -n --type=ts 'container.cradle.intentExtractor' srcRepository: TonyCasey/git-mem Length of output: 43 Wire intentTimeout and minWords from hook config to IntentExtractor, or remove unused registration.
🤖 Prompt for AI Agents |
||
|
|
||
| // ── Application services ───────────────────────────────────── | ||
|
|
||
| // GitTriageService constructor uses `git` not `gitClient`, so | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| /** | ||
| * IntentExtractor | ||
| * | ||
| * Infrastructure implementation of IIntentExtractor using the Anthropic SDK. | ||
| * Extracts searchable keywords from user prompts for memory retrieval. | ||
| * Uses Claude Haiku for fast, cheap keyword extraction. | ||
| */ | ||
|
|
||
| import Anthropic from '@anthropic-ai/sdk'; | ||
| import type { | ||
| IIntentExtractor, | ||
| IIntentExtractorInput, | ||
| IIntentExtractorResult, | ||
| } from '../../domain/interfaces/IIntentExtractor'; | ||
| import type { ILogger } from '../../domain/interfaces/ILogger'; | ||
| import { LLMError } from '../../domain/errors/LLMError'; | ||
|
|
||
| export interface IIntentExtractorOptions { | ||
| /** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var. */ | ||
| readonly apiKey?: string; | ||
| /** Timeout in ms for LLM call. Default: 3000. */ | ||
| readonly timeout?: number; | ||
| /** Minimum word count to trigger extraction. Default: 5. */ | ||
| readonly minWords?: number; | ||
| /** Logger for debug output. */ | ||
| readonly logger?: ILogger; | ||
| } | ||
|
|
||
| const HAIKU_MODEL = 'claude-haiku-4-5-20251001'; | ||
| const MAX_TOKENS = 100; // Keywords are short | ||
|
|
||
| const SYSTEM_PROMPT = `Extract searchable keywords from this user prompt. | ||
| Return ONLY a comma-separated list of: file names, class names, function names, issue IDs (e.g., GIT-95), and technical concepts. | ||
| No articles, no verbs, no summaries — just the nouns/identifiers that would appear in code or documentation. | ||
| If the prompt is a simple confirmation or has no extractable keywords, respond with "SKIP".`; | ||
|
|
||
| /** Pattern for simple confirmations that should skip extraction. */ | ||
| const CONFIRMATION_PATTERN = /^(yes|no|ok|okay|go|sure|proceed|continue|done|y|n|yep|nope|thanks|thank you|\d+)$/i; | ||
|
|
||
| export class IntentExtractor implements IIntentExtractor { | ||
| private readonly client: Anthropic; | ||
| private readonly timeout: number; | ||
| private readonly minWords: number; | ||
| private readonly logger?: ILogger; | ||
|
|
||
| constructor(options: IIntentExtractorOptions) { | ||
| const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; | ||
| if (!apiKey) { | ||
| throw new LLMError('Anthropic API key required for IntentExtractor'); | ||
| } | ||
|
Comment on lines
+48
to
+50
|
||
|
|
||
| this.client = new Anthropic({ apiKey }); | ||
| this.timeout = options.timeout ?? 3000; | ||
| this.minWords = options.minWords ?? 5; | ||
| this.logger = options.logger; | ||
|
Comment on lines
+46
to
+55
|
||
| } | ||
|
|
||
| async extract(input: IIntentExtractorInput): Promise<IIntentExtractorResult> { | ||
| const prompt = input.prompt.trim(); | ||
|
|
||
| // Check minimum words | ||
| const words = prompt.split(/\s+/).filter(w => w.length > 0); | ||
| if (words.length < this.minWords) { | ||
| this.logger?.debug('Intent extraction skipped: too short', { wordCount: words.length }); | ||
| return { intent: null, skipped: true, reason: 'too_short' }; | ||
| } | ||
|
|
||
| // Check confirmation patterns | ||
| if (CONFIRMATION_PATTERN.test(prompt)) { | ||
| this.logger?.debug('Intent extraction skipped: confirmation'); | ||
| return { intent: null, skipped: true, reason: 'confirmation' }; | ||
| } | ||
|
|
||
| // Extract via LLM with timeout | ||
| try { | ||
| const intent = await this.extractWithTimeout(prompt); | ||
|
|
||
| if (!intent || intent.toUpperCase() === 'SKIP') { | ||
| this.logger?.debug('Intent extraction: LLM returned SKIP'); | ||
| return { intent: null, skipped: true, reason: 'llm_skip' }; | ||
| } | ||
|
|
||
| this.logger?.debug('Intent extracted', { intent }); | ||
| return { intent, skipped: false }; | ||
| } catch (error) { | ||
| const isTimeout = error instanceof Error && error.message.includes('timed out'); | ||
| this.logger?.warn('Intent extraction failed', { | ||
| error: error instanceof Error ? error.message : String(error), | ||
| isTimeout, | ||
| }); | ||
| return { | ||
| intent: null, | ||
| skipped: true, | ||
| reason: isTimeout ? 'timeout' : 'error', | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Call LLM with timeout enforcement. | ||
| * Clears timeout after Promise.race to avoid lingering timers. | ||
| */ | ||
| private async extractWithTimeout(prompt: string): Promise<string | null> { | ||
| let timeoutId: ReturnType<typeof setTimeout> | undefined; | ||
|
|
||
| const timeoutPromise = new Promise<never>((_, reject) => { | ||
| timeoutId = setTimeout( | ||
| () => reject(new LLMError('Intent extraction timed out')), | ||
| this.timeout, | ||
| ); | ||
| }); | ||
|
|
||
| const extractPromise = this.callLLM(prompt); | ||
|
|
||
| try { | ||
| return await Promise.race([extractPromise, timeoutPromise]); | ||
| } finally { | ||
| if (timeoutId !== undefined) { | ||
| clearTimeout(timeoutId); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Make the actual LLM call. | ||
| */ | ||
| private async callLLM(prompt: string): Promise<string | null> { | ||
| const response = await this.client.messages.create({ | ||
| model: HAIKU_MODEL, | ||
| max_tokens: MAX_TOKENS, | ||
| system: SYSTEM_PROMPT, | ||
| messages: [{ role: 'user', content: `User prompt: "${prompt}"` }], | ||
| }); | ||
|
|
||
| const text = response.content | ||
| .filter((block): block is Anthropic.TextBlock => block.type === 'text') | ||
| .map(block => block.text) | ||
| .join('') | ||
| .trim(); | ||
|
|
||
| return text || null; | ||
| } | ||
| } | ||
|
Comment on lines
+40
to
+143
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing documentation constraint. Following the pattern established for enrichTimeout (line 58 in IHookConfig.ts: "Must be under hook timeout (10s)"), this field should include the same constraint documentation since it's also used within the hook's 10-second hard timeout.