diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 73664aa0..224e0d0e 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -18,6 +18,7 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { readRouterState, extractRouterFields } from './observer-state-enricher.mjs'; import { homedir } from 'node:os'; import { detectChoiceProvenance, detectAskUserQuestionChoice } from './observer-choice-detector.mjs'; import { loadChainMap, chainsFor } from './observer-chain-detector.mjs'; @@ -751,13 +752,17 @@ function extractLastAssistantContent(entries, turnStartIdx) { * @param {string|null} fallbackSessionId - Used when the transcript has no sessionId. * @returns {object} v2 episode. */ -export function parseTranscript(transcriptText, fallbackSessionId = null) { +export function parseTranscript(transcriptText, fallbackSessionId = null, options = {}) { const { entries, broken, total } = parseLines(transcriptText); const withSession = entries.find((e) => e && e.sessionId); const sessionId = (withSession && withSession.sessionId) || fallbackSessionId || `unknown-${Date.now()}`; + const routerStateBaseDir = options.routerStateBaseDir; + const routerState = readRouterState(sessionId, routerStateBaseDir ? { baseDir: routerStateBaseDir } : {}); + const routerFields = extractRouterFields(routerState); + const start = findTurnStart(entries); const turn = entries.slice(start); @@ -809,6 +814,10 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) { primary_rationale: (() => { const tag = parseReasoningTag(turn); const merge = (heur, fromTag) => [...new Set([...heur, ...fromTag])]; + const classifMapNode = + skills.length === 0 + ? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy()) + : null; return { step: 1, node_chosen: skills.length > 0 ? skills[0] : 'direct', @@ -820,10 +829,10 @@ export function parseTranscript(transcriptText, fallbackSessionId = null) { ? { invoked: true, rules: ['Pravila §12'] } : { invoked: false, rules: [] }, task_classification: classifyTask(prompt), - recommended_node: - skills.length === 0 - ? recommendNode(classifyTask(prompt), getClassificationMap(), getDormancy()) - : null, + recommended_node: routerFields.recommended_node !== null ? routerFields.recommended_node : classifMapNode, + recommended_chain: routerFields.recommended_chain, + chain_progress: routerFields.chain_progress, + chain_completed: routerFields.chain_completed, }; })(), events, diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 51f323fc..ba9abbd5 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -1,4 +1,7 @@ import { describe, it, expect } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { parseTranscript, extractEnvironment, @@ -1655,3 +1658,59 @@ describe('parseTranscript v3 fields', () => { expect(typeof hookEvent.scripts).toBe('object'); }); }); + +describe('parseTranscript — router-state enrichment (Task 3)', () => { + function makeTranscript(sessionId) { + return [ + JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'добавь новый endpoint /api/bar' }, + timestamp: '2026-05-24T10:00:00Z', + uuid: 'u-t3-1', + sessionId, + }), + JSON.stringify({ + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'делаю' }] }, + timestamp: '2026-05-24T10:00:01Z', + uuid: 'u-t3-2', + sessionId, + }), + ].join('\n'); + } + + it('enriches primary_rationale from router-state file when present', () => { + const dir = mkdtempSync(join(tmpdir(), 'router-state-test-')); + const sessionId = 'test-session-t3-enrich'; + const state = { + classification: { recommendedNode: '#42', recommendedChain: 'L13' }, + chainProgress: ['step-a', 'step-b'], + chainCompleted: false, + }; + writeFileSync(join(dir, `router-state-${sessionId}.json`), JSON.stringify(state)); + try { + const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir }); + expect(ep.primary_rationale.recommended_node).toBe('#42'); + expect(ep.primary_rationale.recommended_chain).toBe('L13'); + expect(ep.primary_rationale.chain_progress).toEqual(['step-a', 'step-b']); + expect(ep.primary_rationale.chain_completed).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('falls back gracefully when router-state file is absent', () => { + const dir = mkdtempSync(join(tmpdir(), 'router-state-test-')); + const sessionId = 'test-session-t3-missing'; + try { + const ep = parseTranscript(makeTranscript(sessionId), sessionId, { routerStateBaseDir: dir }); + // recommended_node falls back to classification-map result (direct episode → feature → #19) + expect(ep.primary_rationale.recommended_node).toBe('#19'); + expect(ep.primary_rationale.recommended_chain).toBeNull(); + expect(ep.primary_rationale.chain_progress).toEqual([]); + expect(ep.primary_rationale.chain_completed).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});