feat(core): add command, intent, and routing primitives
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionIndexer, tokenize } from './indexer.js';
|
||||
|
||||
describe('session indexer', () => {
|
||||
it('tokenizes text with stopword filtering', () => {
|
||||
const tokens = tokenize('Deploy the backend service to production and verify logs');
|
||||
expect(tokens).toContain('deploy');
|
||||
expect(tokens).toContain('backend');
|
||||
expect(tokens).toContain('service');
|
||||
expect(tokens).not.toContain('the');
|
||||
});
|
||||
|
||||
it('extracts top keywords and topics', () => {
|
||||
const indexer = new SessionIndexer({ maxKeywords: 5 });
|
||||
const metadata = indexer.indexText('deploy deploy backend service backend api release');
|
||||
|
||||
expect(metadata.keywords.length).toBeLessThanOrEqual(5);
|
||||
expect(metadata.keywords).toContain('deploy');
|
||||
expect(metadata.topics.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface HistoryMetadata {
|
||||
keywords: string[];
|
||||
topics: string[];
|
||||
}
|
||||
|
||||
export interface HistoryIndexerConfig {
|
||||
maxKeywords: number;
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he', 'in', 'is', 'it', 'its',
|
||||
'of', 'on', 'or', 'that', 'the', 'to', 'was', 'were', 'will', 'with', 'you', 'your', 'we', 'this',
|
||||
'they', 'their', 'our', 'but', 'not', 'can', 'just', 'into', 'about', 'after', 'before', 'than', 'then',
|
||||
]);
|
||||
|
||||
export function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(token => token.length >= 3 && !STOPWORDS.has(token));
|
||||
}
|
||||
|
||||
export class SessionIndexer {
|
||||
private readonly maxKeywords: number;
|
||||
|
||||
constructor(config: HistoryIndexerConfig) {
|
||||
this.maxKeywords = config.maxKeywords;
|
||||
}
|
||||
|
||||
indexText(text: string): HistoryMetadata {
|
||||
const tokens = tokenize(text);
|
||||
const frequencies = new Map<string, number>();
|
||||
|
||||
for (const token of tokens) {
|
||||
frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sorted = Array.from(frequencies.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, this.maxKeywords)
|
||||
.map(([token]) => token);
|
||||
|
||||
return {
|
||||
keywords: sorted,
|
||||
topics: sorted.slice(0, Math.min(3, sorted.length)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionStore } from './store.js';
|
||||
import { SessionIndexer } from './indexer.js';
|
||||
import { SessionSearch } from './search.js';
|
||||
import { unlinkSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('SessionSearch', () => {
|
||||
const dbPath = join(tmpdir(), 'flynn-test-search.db');
|
||||
let store: SessionStore;
|
||||
let search: SessionSearch;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new SessionStore(dbPath);
|
||||
const indexer = new SessionIndexer({ maxKeywords: 8 });
|
||||
store.addMessage('session:a', { role: 'user', content: 'deploy backend service' }, indexer.indexText('deploy backend service'));
|
||||
store.addMessage('session:a', { role: 'assistant', content: 'backend deployment completed' }, indexer.indexText('backend deployment completed'));
|
||||
store.addMessage('session:b', { role: 'user', content: 'buy groceries tonight' }, indexer.indexText('buy groceries tonight'));
|
||||
|
||||
search = new SessionSearch(store, {
|
||||
limit: 10,
|
||||
minScore: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
if (existsSync(dbPath)) {
|
||||
unlinkSync(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ranked results for overlapping keywords', () => {
|
||||
const results = search.search('deploy backend');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].sessionId).toBe('session:a');
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('supports session-specific filtering', () => {
|
||||
const results = search.search('deploy', { sessionId: 'session:b' });
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SessionStore } from './store.js';
|
||||
import { tokenize } from './indexer.js';
|
||||
|
||||
export interface HistorySearchResult {
|
||||
sessionId: string;
|
||||
messageId: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
score: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface HistorySearchConfig {
|
||||
limit: number;
|
||||
minScore: number;
|
||||
}
|
||||
|
||||
export class SessionSearch {
|
||||
private readonly store: SessionStore;
|
||||
private readonly config: HistorySearchConfig;
|
||||
|
||||
constructor(store: SessionStore, config: HistorySearchConfig) {
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
search(query: string, opts?: { limit?: number; sessionId?: string }): HistorySearchResult[] {
|
||||
const queryTokens = new Set(tokenize(query));
|
||||
if (queryTokens.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = opts?.sessionId
|
||||
? this.store.getMessagesWithMetadata(opts.sessionId)
|
||||
: this.store.getAllMessagesWithMetadata();
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const results: HistorySearchResult[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const keywords = row.metadata?.keywords ?? [];
|
||||
if (keywords.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapCount = keywords.filter(keyword => queryTokens.has(keyword)).length;
|
||||
if (overlapCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapScore = overlapCount / queryTokens.size;
|
||||
const ageSeconds = Math.max(0, now - row.createdAt);
|
||||
const recencyScore = Math.max(0, 1 - ageSeconds / (30 * 24 * 3600)) * 0.2;
|
||||
const totalScore = overlapScore + recencyScore;
|
||||
|
||||
if (totalScore < this.config.minScore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
sessionId: row.sessionId,
|
||||
messageId: row.id,
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
score: totalScore,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score || b.createdAt - a.createdAt);
|
||||
return results.slice(0, opts?.limit ?? this.config.limit);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user