From 85a95aa2d043970d194dcb90b69ddbd637a84d6a 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: Tue, 19 May 2026 10:15:17 +0300 Subject: [PATCH] =?UTF-8?q?feat(observer):=20parser=20v2=20=E2=80=94=20env?= =?UTF-8?q?ironment,=20task=5Fsize,=20prompt=5Fsignal=20extractors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/observer/STATUS.md | 4 +- tools/observer-transcript-parser.mjs | 81 +++++++++++++++++++- tools/observer-transcript-parser.test.mjs | 92 ++++++++++++++++++++++- 3 files changed, 171 insertions(+), 6 deletions(-) diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index dc1c2be7..c713de01 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-05-19T06:15:36.829Z +Last updated: 2026-05-19T07:10:08.281Z | Контролёр | Состояние | Детали | |---|---|---| @@ -11,7 +11,7 @@ Last updated: 2026-05-19T06:15:36.829Z ## Метрики (информационные, не алерты) -- Observer evidence: 7 episodes this month, 0 PII matches before filter +- Observer evidence: 10 episodes this month, 0 PII matches before filter - Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store). ## Алерт-индикаторы diff --git a/tools/observer-transcript-parser.mjs b/tools/observer-transcript-parser.mjs index 3f4369b5..c460049f 100644 --- a/tools/observer-transcript-parser.mjs +++ b/tools/observer-transcript-parser.mjs @@ -19,16 +19,19 @@ const SUPERPOWERS_PREFIX = 'superpowers:'; function parseLines(text) { const entries = []; + let broken = 0; + let total = 0; for (const line of String(text || '').split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; + total += 1; try { entries.push(JSON.parse(trimmed)); } catch { - // broken line — skip, never throw + broken += 1; // broken line — counted for parse_gap, never thrown } } - return entries; + return { entries, broken, total }; } // A genuine user prompt (turn boundary) — not a tool_result carrier message. @@ -96,6 +99,78 @@ function collectToolUse(entries) { return { skills, counts, errorCount }; } +const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit']); + +/** + * Deterministic environment factors for the turn that starts at turnStartIdx. + * economy_level / parallel_session are scanned from the stringified turn; + * model / post_compaction / session_turn from structural fields. + */ +export function extractEnvironment(allEntries, turnStartIdx) { + const turn = allEntries.slice(turnStartIdx); + const rawTurn = JSON.stringify(turn); + + const econ = rawTurn.match(/=== ECONOMY MODE:\s*(\d+)\s*%/); + const economy_level = econ ? Number(econ[1]) : null; + + let model = null; + for (const e of turn) { + if (e && e.message && e.message.model) { + model = e.message.model; + break; + } + } + + let post_compaction = false; + for (let i = 0; i < turnStartIdx && i < allEntries.length; i++) { + if (allEntries[i] && allEntries[i].isCompactSummary === true) { + post_compaction = true; + break; + } + } + + let session_turn = 0; + for (let i = 0; i <= turnStartIdx && i < allEntries.length; i++) { + if (isRealUserPrompt(allEntries[i])) session_turn += 1; + } + + const parallel_session = /параллельн|parallel session|чужой staged|foreign git index/i.test(rawTurn); + + return { economy_level, model, post_compaction, session_turn, parallel_session }; +} + +/** Task size: total tool calls + unique file paths touched (per spec §3, gap-resolution 2). */ +export function extractTaskSize(turn) { + let tool_calls = 0; + const files = new Set(); + 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 === 'tool_use') { + tool_calls += 1; + if (FILE_TOOLS.has(b.name) && b.input) { + const p = b.input.file_path || b.input.notebook_path; + if (p) files.add(String(p)); + } + } + } + } + return { tool_calls, files_touched: files.size, files: [...files] }; +} + +/** Classify the opening user-prompt sentiment (per spec §6 / gap-resolution 1). */ +export function classifyPromptSignal(text) { + const t = String(text || '').toLowerCase().trim(); + if (/не то\b|не так\b|переделай|отбой|\bстоп\b|почему ты|неверно|не верно|это не /.test(t)) { + return 'correction'; + } + if (/^(ок|окей|ok|спасибо|супер|отлично|готово|дальше|идеально)([,\s]|$)/.test(t)) { + return 'approval'; + } + if (classifyTask(t) !== 'other' && t.length > 15) return 'new_task'; + return 'neutral'; +} + /** * Parse a transcript JSONL string into observer episode fields. * @param {string} transcriptText - Raw JSONL transcript contents. @@ -103,7 +178,7 @@ function collectToolUse(entries) { * @returns {object} Episode with 5 mandatory fields + events. */ export function parseTranscript(transcriptText, fallbackSessionId = null) { - const entries = parseLines(transcriptText); + const { entries } = parseLines(transcriptText); const withSession = entries.find((e) => e && e.sessionId); const sessionId = diff --git a/tools/observer-transcript-parser.test.mjs b/tools/observer-transcript-parser.test.mjs index 5442a965..0b061b69 100644 --- a/tools/observer-transcript-parser.test.mjs +++ b/tools/observer-transcript-parser.test.mjs @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { parseTranscript } from './observer-transcript-parser.mjs'; +import { + parseTranscript, + extractEnvironment, + extractTaskSize, + classifyPromptSignal, +} from './observer-transcript-parser.mjs'; // Build a JSONL transcript string from entry objects. function jsonl(entries) { @@ -215,3 +220,88 @@ describe('parseTranscript', () => { } }); }); + +describe('extractEnvironment', () => { + it('reads economy_level from the ECONOMY MODE marker', () => { + const entries = [ + userPrompt('=== ECONOMY MODE: 0% (пользователь указал явно) ===\nfix it', '2026-05-19T10:00:00Z'), + ]; + expect(extractEnvironment(entries, 0).economy_level).toBe(0); + }); + + it('economy_level is null when no marker present', () => { + const entries = [userPrompt('just do it', '2026-05-19T10:00:00Z')]; + expect(extractEnvironment(entries, 0).economy_level).toBeNull(); + }); + + it('reads model from an assistant message', () => { + const entries = [ + userPrompt('go', '2026-05-19T10:00:00Z'), + { type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-7', content: [] }, timestamp: '2026-05-19T10:01:00Z', sessionId: 's1' }, + ]; + expect(extractEnvironment(entries, 0).model).toBe('claude-opus-4-7'); + }); + + it('post_compaction is true when an isCompactSummary entry precedes the turn', () => { + const entries = [ + { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'summary' }, timestamp: '2026-05-19T09:00:00Z' }, + userPrompt('the real turn', '2026-05-19T10:00:00Z'), + ]; + expect(extractEnvironment(entries, 1).post_compaction).toBe(true); + }); + + it('post_compaction is false with no compaction marker', () => { + const entries = [userPrompt('turn one', '2026-05-19T09:00:00Z'), userPrompt('turn two', '2026-05-19T10:00:00Z')]; + expect(extractEnvironment(entries, 1).post_compaction).toBe(false); + }); + + it('session_turn counts real user prompts up to and including the turn start', () => { + const entries = [ + userPrompt('one', '2026-05-19T09:00:00Z'), + userPrompt('two', '2026-05-19T09:30:00Z'), + userPrompt('three', '2026-05-19T10:00:00Z'), + ]; + expect(extractEnvironment(entries, 2).session_turn).toBe(3); + }); +}); + +describe('extractTaskSize', () => { + it('counts tool calls and unique file paths', () => { + const turn = [ + assistantTurn( + [ + { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: '/a.js' } }, + { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: '/a.js' } }, + { type: 'tool_use', id: 't3', name: 'Write', input: { file_path: '/b.js' } }, + { type: 'tool_use', id: 't4', name: 'Bash', input: {} }, + ], + '2026-05-19T10:01:00Z' + ), + ]; + const size = extractTaskSize(turn); + expect(size.tool_calls).toBe(4); + expect(size.files_touched).toBe(2); + expect(size.files.sort()).toEqual(['/a.js', '/b.js']); + }); + + it('returns zeros for an empty turn', () => { + expect(extractTaskSize([])).toEqual({ tool_calls: 0, files_touched: 0, files: [] }); + }); +}); + +describe('classifyPromptSignal', () => { + it('detects corrections', () => { + expect(classifyPromptSignal('не то, переделай')).toBe('correction'); + expect(classifyPromptSignal('почему ты это сделал')).toBe('correction'); + }); + it('detects approvals', () => { + expect(classifyPromptSignal('ок, спасибо')).toBe('approval'); + expect(classifyPromptSignal('готово, дальше')).toBe('approval'); + }); + it('detects a new task', () => { + expect(classifyPromptSignal('добавь новую фичу экспорта в CSV')).toBe('new_task'); + }); + it('falls back to neutral', () => { + expect(classifyPromptSignal('hmm')).toBe('neutral'); + }); +});