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:
Дмитрий
2026-05-25 11:07:08 +03:00
parent 2fe4e1c4bc
commit 41deac7bc8
2 changed files with 210 additions and 1 deletions
+162
View File
@@ -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
+48 -1
View File
@@ -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: [