fix(observer): parser candidates_considered — whitelist filter
extractCandidates грузила в primary_rationale.candidates_considered ЛЮБОЙ нумерованный/маркированный список из ассистентского текста — без семантического фильтра. В topе оказывались куски прозы («Hard-floor работает только для §12 Superpowers …»), шаги процедуры («1. Hard-floor check, 2. Классификация …»), фрагменты кода (regex-паттерны) — не имена узлов реестра. Фикс: при загрузке модуля собираю KNOWN_NODES из tools/observer-known-nodes.txt + ключей observer-chain-map.json + сентинела «direct». После regex-извлечения item нормализуется (срезаются **/`/_/* обвязки + хвостовая пунктуация) и проверяется по: точное имя в реестре ИЛИ #NN (Tooling ID) ИЛИ plugin:skill форма. Если после фильтра <2 элементов — return []. Opt-in <!-- reasoning --> тег остаётся authoritative и идёт мимо фильтра. Триггеры/границы не трогал — их regex уже узкий (Pravila §N / ADR-N / PSR_v1 RN / L-цепочки). Repro-кейсы из живого episodes-2026-05.jsonl добавлены в тесты: prose-bullets, procedure-steps, code-snippet bullets, mixed list, single survivor.
This commit is contained in:
@@ -1143,21 +1143,74 @@ describe('reasoning capture heuristics (Task 6)', () => {
|
||||
});
|
||||
|
||||
describe('extractCandidates', () => {
|
||||
it('extracts numbered options (≥2)', () => {
|
||||
const c = extractCandidates(mkTurn('1. brainstorming\n2. subagent-driven\n3. direct'));
|
||||
expect(c).toContain('brainstorming');
|
||||
expect(c.length).toBeGreaterThanOrEqual(2);
|
||||
// Only items that look like router-node identifiers are accepted:
|
||||
// - a known node from tools/observer-known-nodes.txt
|
||||
// - a key from tools/observer-chain-map.json (e.g. superpowers:brainstorming)
|
||||
// - a tooling ID matching ^#\d+$ (Прил. Н)
|
||||
// - the sentinel "direct"
|
||||
// Free-form prose bullets / numbered procedure steps / code snippets are rejected.
|
||||
it('extracts numbered options that are known node names', () => {
|
||||
const c = extractCandidates(
|
||||
mkTurn('1. brainstorming\n2. subagent-driven-development\n3. direct')
|
||||
);
|
||||
expect(c).toEqual(['brainstorming', 'subagent-driven-development', 'direct']);
|
||||
});
|
||||
it('extracts bullets when no numbered', () => {
|
||||
expect(extractCandidates(mkTurn('- A\n- B\n- C')).length).toBeGreaterThanOrEqual(2);
|
||||
it('accepts namespaced plugin:skill form from the chain map', () => {
|
||||
const c = extractCandidates(
|
||||
mkTurn('1. superpowers:brainstorming\n2. claude-md-management:claude-md-improver')
|
||||
);
|
||||
expect(c).toEqual(['superpowers:brainstorming', 'claude-md-management:claude-md-improver']);
|
||||
});
|
||||
it('prefers numbered over bullets', () => {
|
||||
const c = extractCandidates(mkTurn('1. X\n2. Y\n- A\n- B'));
|
||||
expect(c).toContain('X');
|
||||
expect(c).toContain('Y');
|
||||
it('accepts tooling IDs like #25 / #74', () => {
|
||||
expect(extractCandidates(mkTurn('1. #25\n2. #74'))).toEqual(['#25', '#74']);
|
||||
});
|
||||
it('returns empty when single item', () => {
|
||||
expect(extractCandidates(mkTurn('1. only one'))).toEqual([]);
|
||||
it('strips simple markdown wrappers before checking', () => {
|
||||
const c = extractCandidates(
|
||||
mkTurn('1. **brainstorming**\n2. `writing-plans`\n3. discovery-interview')
|
||||
);
|
||||
expect(c).toEqual(['brainstorming', 'writing-plans', 'discovery-interview']);
|
||||
});
|
||||
it('drops free-form prose bullets even when ≥2 are present', () => {
|
||||
// Repro from docs/observer/episodes-2026-05.jsonl: analysis bullets with
|
||||
// bold-prefix sentence text were going straight into candidates_considered.
|
||||
const text =
|
||||
'- **Hard-floor работает только для §12 Superpowers** (14 раз). §14/§15 в журнале не оставили следов.\n' +
|
||||
'- **На feature/planning я не ищу триггеры** — 0% матча.\n' +
|
||||
'- **Метка `regulated` врёт** в 79% случаев — нет настоящего применения границ.';
|
||||
expect(extractCandidates(mkTurn(text))).toEqual([]);
|
||||
});
|
||||
it('drops numbered procedure-step text (real episode repro)', () => {
|
||||
const text =
|
||||
'1. **Hard-floor check** — Pravila §12 (Superpowers) / §14 (Queen) / §15.\n' +
|
||||
'2. **Классификация** — определяю тип задачи (feature/bugfix/planning).\n' +
|
||||
'3. **Trigger-based node selection** — по реестру Tooling Прил. Н §4.X.\n' +
|
||||
'4. **Canonical chain check** — смотрю L1-L15.\n' +
|
||||
'5. **Execution** — иду делать.';
|
||||
expect(extractCandidates(mkTurn(text))).toEqual([]);
|
||||
});
|
||||
it('drops code-snippet bullets (regex patterns etc.)', () => {
|
||||
const text =
|
||||
'- regex `(?:^|[\\s\\"\\\'])(tools\\/[\\w-]+\\.(?:mjs|py|sh))` → имя файла.\n' +
|
||||
'- fallback `inline:<sha256(command).slice(0,16)>` — стабильно.';
|
||||
expect(extractCandidates(mkTurn(text))).toEqual([]);
|
||||
});
|
||||
it('filters a mixed list down to just the real nodes', () => {
|
||||
const text =
|
||||
'1. brainstorming\n' +
|
||||
'2. resolver + tests\n' +
|
||||
'3. discovery-interview\n' +
|
||||
'4. parser extension + tests + smoke';
|
||||
expect(extractCandidates(mkTurn(text))).toEqual(['brainstorming', 'discovery-interview']);
|
||||
});
|
||||
it('returns empty when only one real node survives the filter', () => {
|
||||
// ≥2 raw items but only 1 known-node → not enough signal, drop.
|
||||
expect(extractCandidates(mkTurn('1. brainstorming\n2. некий текст'))).toEqual([]);
|
||||
});
|
||||
it('prefers numbered over bullets when both lists contain known nodes', () => {
|
||||
const c = extractCandidates(
|
||||
mkTurn('1. brainstorming\n2. writing-plans\n- discovery-interview\n- regression')
|
||||
);
|
||||
expect(c).toEqual(['brainstorming', 'writing-plans']);
|
||||
});
|
||||
it('returns empty for prose', () => {
|
||||
expect(extractCandidates(mkTurn('просто текст'))).toEqual([]);
|
||||
|
||||
Reference in New Issue
Block a user