From 41deac7bc8a0490c461d2a1d8f2d9ce380fb56f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 11:07:08 +0300 Subject: [PATCH] feat(router): prefilter 3 groups + manual override + anchor (phase 2 task 9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- tools/router-classifier.mjs | 162 +++++++++++++++++++++++++++++++ tools/router-classifier.test.mjs | 49 +++++++++- 2 files changed, 210 insertions(+), 1 deletion(-) diff --git a/tools/router-classifier.mjs b/tools/router-classifier.mjs index 5dacae4a..bac5f61a 100644 --- a/tools/router-classifier.mjs +++ b/tools/router-classifier.mjs @@ -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-.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 diff --git a/tools/router-classifier.test.mjs b/tools/router-classifier.test.mjs index 0f8e959e..d5b54c3c 100644 --- a/tools/router-classifier.test.mjs +++ b/tools/router-classifier.test.mjs @@ -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: [