feat(router): prefilter 3 groups + manual override + anchor (phase 2 task 9)
Phase 2 Task 9 of LLM-first router overhaul. Spec §4.1 — adds prefilter() Layer 1
with 7-check chain: manual override → continuation (inheritance ≤30 min) →
acknowledgment → cancellation → short-conversation + anchor → micro → fall-through.
- tools/router-classifier.mjs: +export prefilter(prompt, { prevState, registry }).
Pure (no fs/exec/net). Imports INHERITANCE_MAX_AGE_MIN from router-config.mjs.
Constants: CONTINUATION_PATTERNS (13), ACKNOWLEDGMENT_PATTERNS (10),
CANCELLATION_PATTERNS (8), MANUAL_OVERRIDE_RE, ANCHOR_NOUNS (28),
ANCHOR_IMPERATIVES (10, fires only when length > 30), SKILL_ALIAS_MAP
(well-known superpower aliases for manual override without registry).
Existing classifyByRegex / classifyByLLM untouched — Task 10 extracts
them to a fallback module.
- tools/router-classifier.test.mjs: +8 prefilter tests covering all 7 checks
plus content-prompt fall-through.
Tests in worktree: 118/118 PASS (8 new prefilter + 110 existing).
This commit is contained in:
@@ -8,9 +8,16 @@
|
||||
*
|
||||
* Layer 2 (см. classifyByLLM): Sonnet с реестром в prompt'е.
|
||||
*
|
||||
* Phase 2 Task 9 adds `prefilter(prompt, { prevState, registry })` — Layer 1
|
||||
* 7-check chain per spec §4.1 (manual override / continuation / acknowledgment /
|
||||
* cancellation / short conv + anchor / micro / fall-through). Pure, registry
|
||||
* argument optional; INHERITANCE_MAX_AGE_MIN imported from router-config.mjs.
|
||||
*
|
||||
* Pure (Layer 1): read-only, никакого fs/exec/net. Caller передаёт registry.
|
||||
*/
|
||||
|
||||
import { INHERITANCE_MAX_AGE_MIN } from './router-config.mjs';
|
||||
|
||||
// Порядок ключей значим: detectTaskType возвращает первое совпадение.
|
||||
// Специфичные домены (marketing/security) идут ДО общего analysis, чтобы
|
||||
// «проверь пдн» ушло в security, а «проверь индекс» — в analysis.
|
||||
@@ -58,6 +65,161 @@ function detectMicro(prompt) {
|
||||
return MICRO_KEYWORDS.some((kw) => p.includes(kw));
|
||||
}
|
||||
|
||||
// ─── Prefilter constants (spec §4.1, Phase 2 Task 9) ────────────────────────
|
||||
|
||||
const CONTINUATION_PATTERNS = [
|
||||
'да', 'делай', 'давай', 'продолжай', 'дальше', 'ага', 'валяй',
|
||||
'поехали', 'утверждаю', 'одобряю', 'ок делай', 'хорошо делай', 'согласен делай',
|
||||
];
|
||||
|
||||
const ACKNOWLEDGMENT_PATTERNS = [
|
||||
'спасибо', 'понял', 'ок', 'хорошо', 'отлично', 'верно',
|
||||
'круто', 'годится', 'молодец', 'норм',
|
||||
];
|
||||
|
||||
const CANCELLATION_PATTERNS = [
|
||||
'стоп', 'нет', 'отмени', 'отбой', 'не надо',
|
||||
'забей', 'хватит', 'достаточно',
|
||||
];
|
||||
|
||||
const MANUAL_OVERRIDE_RE = /^(делай|сделай|используй|применя[йи]|запусти|вызови)\s+(через|с\s+помощью|skill|skill[оа]м)\s+([\w\-:]+)/i;
|
||||
|
||||
// 28 RU/EN nouns + 10 imperatives (spec §4.1 check 5). Список финализирован v2.2.
|
||||
const ANCHOR_NOUNS = [
|
||||
'аудит', 'баг', 'план', 'спека', 'фича', 'тест', 'миграция', 'endpoint', 'файл', 'функция',
|
||||
'класс', 'компонент', 'view', 'модель', 'биллинг', 'маркетинг', 'безопасность', 'пдн', 'регион',
|
||||
'портал', 'проект', 'сделка', 'лид', 'админка', 'база', 'схема', 'воронка', 'хук',
|
||||
];
|
||||
|
||||
const ANCHOR_IMPERATIVES = [
|
||||
'проанализируй', 'проверь', 'исправь', 'почини', 'создай', 'добавь',
|
||||
'удали', 'переименуй', 'улучши', 'расширь',
|
||||
];
|
||||
|
||||
// Hardcoded alias map for well-known superpower skills (manual override без
|
||||
// доступа к registry). Закрывает spec §4.1 «fuzzy matching для requested_node».
|
||||
const SKILL_ALIAS_MAP = {
|
||||
tdd: 'test-driven-development',
|
||||
'test-driven-development': 'test-driven-development',
|
||||
brainstorming: 'brainstorming',
|
||||
brainstorm: 'brainstorming',
|
||||
debugging: 'systematic-debugging',
|
||||
'systematic-debugging': 'systematic-debugging',
|
||||
debug: 'systematic-debugging',
|
||||
'writing-plans': 'writing-plans',
|
||||
plan: 'writing-plans',
|
||||
plans: 'writing-plans',
|
||||
'verification-before-completion': 'verification-before-completion',
|
||||
verify: 'verification-before-completion',
|
||||
parallel: 'dispatching-parallel-agents',
|
||||
'dispatching-parallel-agents': 'dispatching-parallel-agents',
|
||||
worktree: 'using-git-worktrees',
|
||||
'using-git-worktrees': 'using-git-worktrees',
|
||||
review: 'requesting-code-review',
|
||||
'requesting-code-review': 'requesting-code-review',
|
||||
};
|
||||
|
||||
function containsAnchor(prompt) {
|
||||
const p = lower(prompt);
|
||||
if (ANCHOR_NOUNS.some((a) => p.includes(a))) return true;
|
||||
if (prompt.length > 30 && ANCHOR_IMPERATIVES.some((a) => p.includes(a))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveNodeAlias(extracted, registry) {
|
||||
if (!extracted) return null;
|
||||
const norm = String(extracted).toLowerCase();
|
||||
if (SKILL_ALIAS_MAP[norm]) return SKILL_ALIAS_MAP[norm];
|
||||
if (registry?.nodes) {
|
||||
const exact = registry.nodes.find((n) => n.slug === norm);
|
||||
if (exact) return exact.slug;
|
||||
const fuzzy = registry.nodes.find((n) => {
|
||||
const slug = String(n.slug || '').toLowerCase();
|
||||
const name = String(n.name || '').toLowerCase();
|
||||
return (slug && (slug.includes(norm) || norm.includes(slug))) || (name && name.includes(norm));
|
||||
});
|
||||
if (fuzzy) return fuzzy.slug;
|
||||
}
|
||||
return `unknown_${extracted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefilter — Layer 1, 7-check chain (spec §4.1).
|
||||
* Pure: no fs/exec/net. Returns:
|
||||
* - { task_type, source, ... } on a positive match
|
||||
* - null when fall-through to Layer 2 (LLM classifier) is required
|
||||
*
|
||||
* @param {string} prompt — user prompt
|
||||
* @param {object} options
|
||||
* @param {object} [options.prevState] — `~/.claude/runtime/router-state-<session>.json`
|
||||
* for continuation/cancellation context. Schema: { classification, timestamp, task_id }.
|
||||
* @param {object} [options.registry] — node registry from loadRegistry() for fuzzy
|
||||
* slug resolution in manual override. Optional — falls back to SKILL_ALIAS_MAP.
|
||||
*/
|
||||
export function prefilter(prompt, { prevState, registry } = {}) {
|
||||
if (!prompt) return null;
|
||||
const raw = String(prompt);
|
||||
const p = raw.trim().toLowerCase();
|
||||
|
||||
// 1) Manual override
|
||||
const m = raw.match(MANUAL_OVERRIDE_RE);
|
||||
if (m) {
|
||||
return {
|
||||
task_type: 'manual_override',
|
||||
node: 'direct',
|
||||
source: 'prefilter',
|
||||
requested_node: resolveNodeAlias(m[3], registry),
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Continuation — inherit prev classification within 30 min
|
||||
if (CONTINUATION_PATTERNS.includes(p) && prevState?.classification && prevState.timestamp) {
|
||||
const ageMs = Date.now() - new Date(prevState.timestamp).getTime();
|
||||
const ageMin = ageMs / 60000;
|
||||
if (ageMin <= INHERITANCE_MAX_AGE_MIN) {
|
||||
return {
|
||||
task_type: prevState.classification.task_type,
|
||||
node: 'direct',
|
||||
source: 'prefilter_inherited',
|
||||
recommendedNode: prevState.classification.recommendedNode ?? null,
|
||||
inheritance: {
|
||||
inherited_from_task_id: prevState.task_id ?? null,
|
||||
inheritance_age_minutes: Math.round(ageMin),
|
||||
},
|
||||
};
|
||||
}
|
||||
// age > 30 → fall through to checks 3-7
|
||||
}
|
||||
|
||||
// 3) Acknowledgment
|
||||
if (ACKNOWLEDGMENT_PATTERNS.includes(p)) {
|
||||
return { task_type: 'conversation', node: 'direct', source: 'prefilter' };
|
||||
}
|
||||
|
||||
// 4) Cancellation
|
||||
if (CANCELLATION_PATTERNS.includes(p)) {
|
||||
return {
|
||||
task_type: 'conversation',
|
||||
node: 'direct',
|
||||
source: 'prefilter',
|
||||
previous_rejected: !!prevState?.task_id,
|
||||
};
|
||||
}
|
||||
|
||||
// 5) Short conversation + anchor protection (length < 15 && no anchor)
|
||||
if (raw.length < 15 && !containsAnchor(raw)) {
|
||||
return { task_type: 'conversation', node: 'direct', source: 'prefilter' };
|
||||
}
|
||||
|
||||
// 6) Micro
|
||||
if (detectMicro(raw)) {
|
||||
return { task_type: 'micro', node: 'direct', source: 'prefilter' };
|
||||
}
|
||||
|
||||
// 7) Fall-through to Layer 2 (LLM classifier)
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexible keyword matching: handles RU morphology by checking if
|
||||
* - prompt contains the keyword (exact), OR
|
||||
|
||||
@@ -1,5 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { classifyByRegex } from './router-classifier.mjs';
|
||||
import { classifyByRegex, prefilter } from './router-classifier.mjs';
|
||||
|
||||
describe('prefilter — Phase 2 Task 9 (spec §4.1, 7 checks)', () => {
|
||||
it('manual override has priority over continuation (delai cherez TDD)', () => {
|
||||
const r = prefilter('делай через TDD', { prevState: null });
|
||||
expect(r.task_type).toBe('manual_override');
|
||||
expect(r.source).toBe('prefilter');
|
||||
expect(r.requested_node).toContain('test-driven-development');
|
||||
});
|
||||
|
||||
it('continuation inherits classification within 30 min', () => {
|
||||
const prevState = {
|
||||
classification: { task_type: 'feature', recommendedNode: '#19' },
|
||||
timestamp: new Date().toISOString(),
|
||||
task_id: 'prev-abc',
|
||||
};
|
||||
const r = prefilter('делай', { prevState });
|
||||
expect(r.source).toBe('prefilter_inherited');
|
||||
expect(r.task_type).toBe('feature');
|
||||
expect(r.inheritance?.inherited_from_task_id).toBe('prev-abc');
|
||||
});
|
||||
|
||||
it('continuation falls through to short-conversation when prev state > 30 min', () => {
|
||||
const old = new Date(Date.now() - 31 * 60000).toISOString();
|
||||
const r = prefilter('делай', { prevState: { classification: { task_type: 'feature' }, timestamp: old } });
|
||||
expect(r.task_type).toBe('conversation');
|
||||
});
|
||||
|
||||
it('acknowledgment is plain conversation (spasibo)', () => {
|
||||
expect(prefilter('спасибо', {}).task_type).toBe('conversation');
|
||||
});
|
||||
|
||||
it('cancellation flags previous task rejected (net)', () => {
|
||||
expect(prefilter('нет', { prevState: { task_id: 'abc' } }).previous_rejected).toBe(true);
|
||||
});
|
||||
|
||||
it('anchor protection saves "делай аудит" from short-conversation → null fall through', () => {
|
||||
expect(prefilter('делай аудит', {})).toBeNull();
|
||||
});
|
||||
|
||||
it('micro keyword fires (poprav\' typo v stroke)', () => {
|
||||
expect(prefilter('поправь typo в строке', {}).task_type).toBe('micro');
|
||||
});
|
||||
|
||||
it('content prompt with anchor returns null (forwards to Layer 2)', () => {
|
||||
expect(prefilter('добавь endpoint для экспорта сделок', {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const fakeRegistry = {
|
||||
nodes: [
|
||||
|
||||
Reference in New Issue
Block a user