From f54c82d68215dccdabbda1dfe00e01af0b4e9e43 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: Wed, 20 May 2026 13:20:45 +0300 Subject: [PATCH] feat(observer): opt-in reasoning-tag merges with heuristic primary_rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes brain-retro 2026-05-20 #11 — parseReasoningTag extracts opt-in HTML-comment from assistant text. Semicolon-separated values merged into heuristic-derived primary_rationale arrays via Set-dedupe. Conservative: tag is opt-in; heuristic still runs even when tag present (heuristic provides baseline, tag enriches). 5 new vitest tests, 309/309 GREEN. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/observer-transcript-parser.mjs | 51 ++++++++++++++++++----- tools/observer-transcript-parser.test.mjs | 48 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 7932dcb6..8880f512 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -532,6 +532,31 @@ export function parseRoutingTag(turn) { return null; } +const REASONING_TAG_RE = + //; + +/** + * Opt-in reasoning tag (Task 11). Claude may emit at most one such comment + * per turn to declare triggers / candidates / boundaries explicitly. Values + * are semicolon-separated. When present, parser merges them into the + * heuristic-derived arrays via Set-dedupe. + */ +export function parseReasoningTag(turn) { + for (const e of turn || []) { + const content = e && e.message && Array.isArray(e.message.content) ? e.message.content : []; + for (const b of content) { + if (b && b.type === 'text' && typeof b.text === 'string') { + const m = b.text.match(REASONING_TAG_RE); + if (m) { + const split = (s) => s.split(';').map((x) => x.trim()).filter(Boolean); + return { triggers: split(m[1]), candidates: split(m[2]), boundaries: split(m[3]) }; + } + } + } + } + return null; +} + /** Text of the last real user prompt — used by the Stop-hook routing-gate (Task 5). */ export function extractLastUserPromptText(transcriptText) { const { entries } = parseLines(transcriptText); @@ -615,17 +640,21 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) { environment: extractEnvironment(entries, start), task_size: extractTaskSize(turn), task_cost: extractTokenUsage(turn), - primary_rationale: { - step: 1, - node_chosen: skills.length > 0 ? skills[0] : 'direct', - triggers_matched: extractTriggers(turn), - candidates_considered: extractCandidates(turn), - boundaries_applied: extractBoundaries(turn), - hard_floor: usedSuperpowers - ? { invoked: true, rules: ['Pravila §12'] } - : { invoked: false, rules: [] }, - task_classification: classifyTask(prompt), - }, + primary_rationale: (() => { + const tag = parseReasoningTag(turn); + const merge = (heur, fromTag) => [...new Set([...heur, ...fromTag])]; + return { + step: 1, + node_chosen: skills.length > 0 ? skills[0] : 'direct', + triggers_matched: merge(extractTriggers(turn), tag ? tag.triggers : []), + candidates_considered: merge(extractCandidates(turn), tag ? tag.candidates : []), + boundaries_applied: merge(extractBoundaries(turn), tag ? tag.boundaries : []), + hard_floor: usedSuperpowers + ? { invoked: true, rules: ['Pravila §12'] } + : { invoked: false, rules: [] }, + task_classification: classifyTask(prompt), + }; + })(), events, }; } diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index ac4bd0f8..ab2cafc7 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -1327,3 +1327,51 @@ describe('classifyPromptSignal — extended dictionary (Task 9)', () => { expect(classifyPromptSignal('переделай это')).toBe('correction'); }); }); + +import { parseReasoningTag } from './observer-transcript-parser.mjs'; + +describe('parseReasoningTag (Task 11)', () => { + it('parses opt-in reasoning tag from assistant text', () => { + const turn = [{ message: { role: 'assistant', content: [ + { type: 'text', text: '\nAnswer.' } + ] } }]; + const tag = parseReasoningTag(turn); + expect(tag).toEqual({ + triggers: ['Pravila §12.2', 'ADR-011'], + candidates: ['brain-retro', 'brainstorming'], + boundaries: ['Pravila §16'], + }); + }); + it('returns null when no tag present', () => { + expect(parseReasoningTag([{ message: { role: 'assistant', content: [{ type: 'text', text: 'plain' }] } }])).toBeNull(); + }); + it('safe on null/empty turn', () => { + expect(parseReasoningTag(null)).toBeNull(); + expect(parseReasoningTag([])).toBeNull(); + }); + it('skips non-text blocks', () => { + const turn = [{ message: { role: 'assistant', content: [{ type: 'tool_use', name: 'Read' }] } }]; + expect(parseReasoningTag(turn)).toBeNull(); + }); +}); + +describe('parseTranscript — reasoning-tag merges with heuristic (Task 11)', () => { + it('merges tag triggers into heuristic triggers (deduped)', () => { + const transcript = [ + JSON.stringify({ sessionId: 's' }), + JSON.stringify({ type: 'user', message: { role: 'user', content: 'делай' }, uuid: 'u1', timestamp: '2026-05-20T00:00:00Z' }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [ + { type: 'text', text: 'Pravila §12.2 hard-rule\n' } + ] }, uuid: 'u2', timestamp: '2026-05-20T00:01:00Z' }), + ].join('\n'); + const ep = parseTranscript(transcript); + // heuristic finds 'Pravila §12.2' from text + tag adds 'ADR-011'; dedup + expect(ep.primary_rationale.triggers_matched).toContain('Pravila §12.2'); + expect(ep.primary_rationale.triggers_matched).toContain('ADR-011'); + expect(ep.primary_rationale.triggers_matched.filter((t) => t === 'Pravila §12.2')).toHaveLength(1); + // candidates: tag contributes 'brain-retro' (no numbered list in text) + expect(ep.primary_rationale.candidates_considered).toContain('brain-retro'); + // boundaries: heuristic empty (no marker in plain text), tag adds 'Pravila §16' + expect(ep.primary_rationale.boundaries_applied).toContain('Pravila §16'); + }); +});