feat(observer): parser v2 — environment, task_size, prompt_signal extractors
This commit is contained in:
@@ -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).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user