/
deka
/
buffer
Обзор
Документация
Войти
/
deka
/
buffer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
dev
src/converters/goal-diff.js
173 строки
8 KB
Maksim Ratnikov
feat: сверка версий цели вместо вкладки review
29 июл 2026, 11:28
29 июл 2026, 11:28
74bebb7
Код
Авторство
О чём код?
// Сверка двух версий goal-template.v1: что добавилось, что изменилось и — главное — что // исчезло. LLM в режиме обновления возвращает документ целиком, поэтому потеря куска данных // выглядит как обычный ответ; поймать её может только детерминированное сравнение. // // diffGoals(previous, next) -> { changes, summary } const ID_KEYS = [ "id", "source_ref_id", "metric_id", "period_id", "quote_id", "rule_id", "set_id", "item_id", "mention_id", "criterion_id", "requirement_id", "obstacle_id", "relation_id", "outcome_id" ]; const LABEL_KEYS = ["title", "label", "name", "question", "raw_text", "raw_name", "area", "role", "url", "value"]; const SECTION_LABEL = { goal_identity: "Паспорт", source_refs: "Источники", source_coverage: "Покрытие источников", goal_meaning: "Смысл цели", timeline: "Сроки", metrics: "Метрики", standards_and_requirements: "Стандарты и требования", applicability: "Применимость", entity_mentions: "Упоминания сущностей", responsibility: "Ответственные", execution: "Действия", implementation_details: "Детали реализации", completion: "Закрытие", knowledge: "Знания", obstacles: "Блокеры", goal_relations: "Связи целей", outcomes: "Результаты" }; // Элементы массивов сопоставляются по стабильному идентификатору из схемы, а когда его нет — // по человекочитаемому полю. Иначе перестановка элементов читалась бы как «всё удалено и заново создано». export function itemKey(item, index) { if (item === null || typeof item !== "object") return `#${index}:${String(item)}`; for (const key of ID_KEYS) { if (typeof item[key] === "string" && item[key]) return `${key}=${item[key]}`; } for (const key of LABEL_KEYS) { if (typeof item[key] === "string" && item[key].trim()) return `${key}=${item[key].trim().slice(0, 80)}`; } return `#${index}`; } function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); } function subjectOf(value, fallback) { if (isPlainObject(value)) { for (const key of LABEL_KEYS) { if (typeof value[key] === "string" && value[key].trim()) return value[key].trim(); } } if (typeof value === "string" && value.trim()) return value.trim(); return fallback; } function shortValue(value) { if (value === undefined) return ""; if (value === null) return "null"; if (typeof value === "string") return value; if (typeof value === "number" || typeof value === "boolean") return String(value); if (Array.isArray(value)) return `[${value.length} элементов]`; if (isPlainObject(value)) { const subject = subjectOf(value, ""); return subject || `{${Object.keys(value).length} полей}`; } return String(value); } // Статус провенанса меняется чаще всего и меняется осмысленно, поэтому он выносится отдельно. function statusOf(value) { return isPlainObject(value) && isPlainObject(value.state) ? value.state.status : undefined; } export function diffGoals(previous, next) { const changes = []; function record(kind, path, section, subject, before, after, note) { changes.push({ kind, path, section: SECTION_LABEL[section] ?? section, subject, before, after, note }); } function walk(before, after, path, section) { if (Array.isArray(before) || Array.isArray(after)) { const beforeList = Array.isArray(before) ? before : []; const afterList = Array.isArray(after) ? after : []; const beforeMap = new Map(beforeList.map((item, index) => [itemKey(item, index), item])); const afterMap = new Map(afterList.map((item, index) => [itemKey(item, index), item])); for (const [key, item] of beforeMap) { if (!afterMap.has(key)) { record("removed", `${path}/${key}`, section, subjectOf(item, key), shortValue(item), "", "элемент исчез из документа"); } } for (const [key, item] of afterMap) { if (!beforeMap.has(key)) { record("added", `${path}/${key}`, section, subjectOf(item, key), "", shortValue(item)); } else { walk(beforeMap.get(key), item, `${path}/${key}`, section); } } return; } if (isPlainObject(before) || isPlainObject(after)) { const beforeObject = isPlainObject(before) ? before : {}; const afterObject = isPlainObject(after) ? after : {}; const beforeStatus = statusOf(before); const afterStatus = statusOf(after); if (beforeStatus && afterStatus && beforeStatus !== afterStatus) { record("status", `${path}/state/status`, section, subjectOf(after, path), beforeStatus, afterStatus); } for (const key of new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)])) { if (key === "state" && (beforeStatus || afterStatus)) { // статус уже учтён выше; остальные поля state (источники, заметки) не шумим continue; } walk(beforeObject[key], afterObject[key], `${path}/${key}`, section); } return; } if (before === undefined && after !== undefined) { record("added", path, section, path.split("/").filter(Boolean).slice(-2).join(" · "), "", shortValue(after)); return; } if (before !== undefined && after === undefined) { record("removed", path, section, path.split("/").filter(Boolean).slice(-2).join(" · "), shortValue(before), "", "значение исчезло"); return; } if (before !== after) { record("changed", path, section, path.split("/").filter(Boolean).slice(-2).join(" · "), shortValue(before), shortValue(after)); } } const sections = new Set([...Object.keys(previous ?? {}), ...Object.keys(next ?? {})]); for (const section of sections) { if (section.startsWith("$") || section === "schema_version" || section === "schema_minor_version") continue; walk(previous?.[section], next?.[section], `/${section}`, section); } const summary = { added: changes.filter((change) => change.kind === "added").length, changed: changes.filter((change) => change.kind === "changed").length, removed: changes.filter((change) => change.kind === "removed").length, status: changes.filter((change) => change.kind === "status").length, sourcesBefore: (previous?.source_refs ?? []).length, sourcesAfter: (next?.source_refs ?? []).length }; summary.newSources = (next?.source_refs ?? []) .filter((source) => !(previous?.source_refs ?? []).some((old) => old.id === source.id)) .map((source) => `${source.id}: ${source.title}`); // Идентификаторы источников обязаны быть стабильными: если старый id исчез или сменил // название, провенанс всех полей, которые на него ссылались, становится ложным. summary.brokenSources = (previous?.source_refs ?? []) .filter((old) => { const same = (next?.source_refs ?? []).find((source) => source.id === old.id); return !same || same.title !== old.title; }) .map((old) => `${old.id}: ${old.title}`); return { changes, summary }; } // Потери и переписанные источники — то, ради чего сверка и делается. export function riskyChanges(diff) { return diff.changes.filter((change) => change.kind === "removed"); }