ceda265a5d
Баг: границы спанов метились предсказанным номером хода (turnCount+1 в prompt-hook), который уезжает под гейт-петлёй (coverage-хук вставляет служебные ходы, Claude Code очередит промпт). Итог — служебный ход принимался за реальную просьбу (фантомный «Ход 5» в тетради + ложные скрытые вопросы про coverage). Корень: терялся структурный ярлычок isMeta (служебное vs владелец), который уже есть в транскрипте. Теперь: - parseLastExchange читает entry.isMeta -> userIsMeta; - buildRawRecord пишет метку meta=1 в заголовок служебного хода; - realBoundariesFromRaw определяет границы СТРУКТУРНО (meta=1; фолбэк по тексту) — это ОСНОВНОЙ источник; ненадёжный realPromptTurns/prompt-hook-механизм убран; - разбор одного спана вынесен в общий distillSpan (stop-хук и пересборка из сырья). Свод секретаря зелёный (143 теста). Живая пересборка дела на реальной модели дала чистую тетрадь: Шаги по реальным промптам, гейт-шум не плодит скрытые вопросы. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
4.4 KiB
JavaScript
86 lines
4.4 KiB
JavaScript
// Чистый разбор хвоста стенограммы: последний обмен (user + assistant + действия).
|
||
// Схема сверена с observer-transcript-parser: entry.message.role / entry.message.content
|
||
// (строка или массив блоков text/tool_use{name,input}).
|
||
|
||
function parseLines(text) {
|
||
const entries = [];
|
||
for (const line of String(text || '').split(/\r?\n/)) {
|
||
const t = line.trim();
|
||
if (!t) continue;
|
||
try { entries.push(JSON.parse(t)); } catch { /* битую строку пропускаем */ }
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
// Настоящий промпт пользователя (НЕ tool_result): content — строка или массив с text-блоком.
|
||
// В формате Anthropic tool_result — это сообщения role:user, их пропускаем, иначе теряются
|
||
// и настоящий промпт, и все действия ассистента до него.
|
||
function isRealUserPrompt(msg) {
|
||
if (!msg || msg.role !== 'user') return false;
|
||
const c = msg.content;
|
||
if (typeof c === 'string') return true;
|
||
if (Array.isArray(c)) return c.some((b) => b && b.type === 'text');
|
||
return false;
|
||
}
|
||
|
||
// Текст результата инструмента: строка как есть; массив блоков → склейка text-блоков.
|
||
// Без обрезки: секретарь должен видеть ПОЛНОЕ содержимое (линзы ловят ошибки/пропуски).
|
||
function resultText(content) {
|
||
if (typeof content === 'string') return content;
|
||
if (Array.isArray(content)) {
|
||
return content.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
||
.map((b) => b.text).join('\n');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/** Последний обмен из стенограммы: { user, assistant, actions:[{tool,input,result?}] }.
|
||
* result привязывается к действию по tool_use.id === tool_result.tool_use_id (усечён до предела);
|
||
* без совпадения действие остаётся прежней формы {tool,input} — без ключа result. */
|
||
export function parseLastExchange(transcriptText) {
|
||
const entries = parseLines(transcriptText);
|
||
let u = -1;
|
||
for (let i = entries.length - 1; i >= 0; i--) {
|
||
if (entries[i] && isRealUserPrompt(entries[i].message)) { u = i; break; }
|
||
}
|
||
const userContent = u >= 0 ? entries[u].message.content : '';
|
||
const user = typeof userContent === 'string'
|
||
? userContent
|
||
: (Array.isArray(userContent)
|
||
? userContent.filter((b) => b && b.type === 'text').map((b) => b.text).join('\n')
|
||
: '');
|
||
// Структурный ярлычок: служебное сообщение (гейт-фидбек / загрузка навыка / контекст) помечено
|
||
// isMeta:true на самой записи транскрипта. Реальная просьба владельца — без него. Это честный
|
||
// разделитель «хозяин vs служебное» (не угадывание по тексту/номеру хода).
|
||
const userIsMeta = u >= 0 && entries[u].isMeta === true;
|
||
|
||
let assistant = '';
|
||
const raw = []; // {id, tool, input} — вызовы инструментов
|
||
const results = {}; // tool_use_id -> текст результата (из tool_result в сообщениях role:user)
|
||
for (let i = u + 1; i < entries.length; i++) {
|
||
const m = entries[i] && entries[i].message;
|
||
if (!m) continue;
|
||
const c = m.content;
|
||
if (m.role === 'assistant') {
|
||
if (Array.isArray(c)) {
|
||
for (const b of c) {
|
||
if (b && b.type === 'text' && b.text) assistant += (assistant ? '\n' : '') + b.text;
|
||
if (b && b.type === 'tool_use') raw.push({ id: b.id, tool: b.name, input: JSON.stringify(b.input ?? {}) });
|
||
}
|
||
} else if (typeof c === 'string') {
|
||
assistant += (assistant ? '\n' : '') + c;
|
||
}
|
||
} else if (m.role === 'user' && Array.isArray(c)) {
|
||
for (const b of c) {
|
||
if (b && b.type === 'tool_result' && b.tool_use_id != null) results[b.tool_use_id] = resultText(b.content);
|
||
}
|
||
}
|
||
}
|
||
const actions = raw.map((a) => {
|
||
const out = { tool: a.tool, input: a.input };
|
||
if (a.id != null && results[a.id] != null) out.result = String(results[a.id] ?? '');
|
||
return out;
|
||
});
|
||
return { user, assistant, actions, userIsMeta };
|
||
}
|