feat(observer): heuristic reasoning capture in primary_rationale

Closes brain-retro 2026-05-20 #6 — extractTriggers/Candidates/Boundaries
scan assistant.text for Pravila §N / ADR-N / PSR_v1 RX / routing-off-phase
LN / hard-floor + numbered/bulleted lists (≥2). Populates previously-
always-empty primary_rationale arrays.

Conservative-broad: false positives accepted (mention ≠ application);
/brain-retro determines applied validity. Phase 2 agent-judge out of scope.

19 new tests, 282/282 GREEN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-20 13:06:00 +03:00
parent 52728dfc12
commit 0663479bb8
2 changed files with 174 additions and 3 deletions
+74 -3
View File
@@ -290,6 +290,77 @@ export function extractTokenUsage(turn) {
* For each AskUserQuestion toolUseResult in the turn, emit one event per question.
* answer_kind: 'option' (exact label match), 'custom' (free-text), 'no_answer' (missing/empty).
*/
/** Collect concatenated text from all assistant text blocks in the turn. */
function assistantTextOfTurn(turn) {
const parts = [];
for (const e of turn || []) {
if (!e || !e.message || e.message.role !== 'assistant') continue;
const content = Array.isArray(e.message.content) ? e.message.content : [];
for (const b of content) {
if (b && b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
}
}
return parts.join('\n');
}
const TRIGGER_PATTERNS = [
/\bPravila\s+§\d+(?:\.\d+)?/g,
/\bADR-\d+/g,
/\bPSR_v1\s+R\d+(?:\.\d+)?/g,
/\brouting-off-phase\s+L\d+/g,
/\bL\d+\s+chain/g,
/\bhard-(?:floor|rule)\b/gi,
];
/** Heuristic triggers from assistant text. Conservative-broad — false positives OK. */
export function extractTriggers(turn) {
const text = assistantTextOfTurn(turn);
const out = new Set();
for (const re of TRIGGER_PATTERNS) {
const matches = text.match(re);
if (matches) for (const m of matches) {
const norm = /^L\d+\s+chain$/.test(m) ? `routing-off-phase ${m.split(/\s+/)[0]}` : m;
out.add(norm);
}
}
return [...out];
}
const CANDIDATE_NUMBERED_RE = /^\s*\d+[.\)]\s+([^\n]+)$/gm;
const CANDIDATE_BULLET_RE = /^\s*[-*]\s+([^\n]+)$/gm;
/** Heuristic candidates: ≥2 numbered (preferred) or bulleted items. */
export function extractCandidates(turn) {
const text = assistantTextOfTurn(turn);
const numbered = [...text.matchAll(CANDIDATE_NUMBERED_RE)].map((m) => m[1].trim());
if (numbered.length >= 2) return numbered;
const bulleted = [...text.matchAll(CANDIDATE_BULLET_RE)].map((m) => m[1].trim());
if (bulleted.length >= 2) return bulleted;
return [];
}
const BOUNDARY_PATTERNS = [
/\bADR-\d+(?:\s+§\d+(?:\.\d+)?)?/g,
/\bPSR_v1\s+R\d+(?:\.\d+)?/g,
/\bPravila\s+§\d+(?:\.\d+)?/g,
/\brouting-off-phase\s+L\d+/g,
/\bL\d+\s+chain/g,
];
/** Heuristic boundaries — overlaps with triggers, dedup per-array only. */
export function extractBoundaries(turn) {
const text = assistantTextOfTurn(turn);
const out = new Set();
for (const re of BOUNDARY_PATTERNS) {
const matches = text.match(re);
if (matches) for (const m of matches) {
const norm = /^L\d+\s+chain$/.test(m) ? `routing-off-phase ${m.split(/\s+/)[0]}` : m;
out.add(norm);
}
}
return [...out];
}
export function extractAskUserQuestionEvents(turn) {
const events = [];
for (const e of turn || []) {
@@ -528,9 +599,9 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) {
primary_rationale: {
step: 1,
node_chosen: skills.length > 0 ? skills[0] : 'direct',
triggers_matched: [],
candidates_considered: [],
boundaries_applied: [],
triggers_matched: extractTriggers(turn),
candidates_considered: extractCandidates(turn),
boundaries_applied: extractBoundaries(turn),
hard_floor: usedSuperpowers
? { invoked: true, rules: ['Pravila §12'] }
: { invoked: false, rules: [] },