Files
WhatIDo/diff.js
T
dev 54cf5bbfa4 feat(audit): показывать изменения текста записи по шагам
При сохранении записи журнала (PUT /api/entries/:id) сравнивается
состояние до и после, и в audit_log пишется не только факт правки,
но и сами изменения: пословный дифф текста, статистика добавленных
и удалённых слов, а также смена ФИО, группы и темы модуля.

- diff.js: пословный LCS-дифф без зависимостей, обрезка больших
  текстов, сборка изменений по полям записи, облегчённый target
  для списка аудита
- source правки: manual / ai / ai_manual / ai_revert; журнал шлёт
  edit_source, сервер доверяет явному значению и определяет источник
  по description_ai как запасной вариант
- те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check)
  и отката к оригиналу (entry.ai.revert)
- GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный
  target, чтобы не грузить килобайты текста на каждую строку
- Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой
  удалённого и добавленного текста, «было/стало» для полей
- auth.login теперь пишет user_id, иначе колонка «Кто» показывала
  «система»
- diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
2026-09-27 11:29:17 +03:00

216 lines
6.6 KiB
JavaScript

const MAX_CELLS = 400000;
const MAX_DIFF_CHARS = 6000;
const MAX_SEGMENTS = 80;
const FIELD_LABELS = {
student_name: 'ФИО ученика',
group_id: 'Группа',
module_id: 'Тема модуля',
description: 'Текст работы'
};
const SIMPLE_FIELDS = ['student_name', 'group_id', 'module_id'];
const NAME_FIELDS = { group_id: 'group_name', module_id: 'module_name' };
function asText(v) {
if (v === null || v === undefined) return '';
return typeof v === 'string' ? v : String(v);
}
function tokenize(text) {
return asText(text).split(/(\s+)/).filter(t => t.length > 0);
}
function compact(segments) {
const out = [];
for (const seg of segments) {
if (!seg.text) continue;
const last = out[out.length - 1];
if (last && last.type === seg.type) last.text += seg.text;
else out.push({ type: seg.type, text: seg.text });
}
return out;
}
function lcsSegments(a, b) {
const n = a.length;
const m = b.length;
if (!n) return m ? [{ type: 'add', text: b.join('') }] : [];
if (!m) return [{ type: 'del', text: a.join('') }];
const w = m + 1;
const dp = new Int32Array((n + 1) * w);
for (let i = n - 1; i >= 0; i--) {
const rowBase = i * w;
const nextBase = (i + 1) * w;
for (let j = m - 1; j >= 0; j--) {
dp[rowBase + j] = a[i] === b[j]
? dp[nextBase + j + 1] + 1
: Math.max(dp[nextBase + j], dp[rowBase + j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { out.push({ type: 'eq', text: a[i] }); i++; j++; }
else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) { out.push({ type: 'del', text: a[i] }); i++; }
else { out.push({ type: 'add', text: b[j] }); j++; }
}
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
return out;
}
function anchoredDiff(a, b) {
let head = 0;
while (head < a.length && head < b.length && a[head] === b[head]) head++;
let tailA = a.length;
let tailB = b.length;
while (tailA > head && tailB > head && a[tailA - 1] === b[tailB - 1]) { tailA--; tailB--; }
const out = head ? [{ type: 'eq', text: a.slice(0, head).join('') }] : [];
const midA = a.slice(head, tailA);
const midB = b.slice(head, tailB);
if (midA.length * midB.length <= MAX_CELLS) {
out.push(...lcsSegments(midA, midB));
} else {
if (midA.length) out.push({ type: 'del', text: midA.join('') });
if (midB.length) out.push({ type: 'add', text: midB.join('') });
}
if (tailA < a.length) out.push({ type: 'eq', text: a.slice(tailA).join('') });
return out;
}
function capSegments(segments) {
const out = [];
let chars = 0;
let truncated = false;
for (const seg of segments) {
const room = MAX_DIFF_CHARS - chars;
if (out.length >= MAX_SEGMENTS || room <= 0) { truncated = true; break; }
if (seg.text.length > room) {
out.push({ type: seg.type, text: seg.text.slice(0, room) });
chars += room;
truncated = true;
break;
}
out.push({ type: seg.type, text: seg.text });
chars += seg.text.length;
}
if (truncated) out.push({ type: 'eq', text: '…' });
return { segments: out, truncated };
}
function countWords(text) {
const t = text.trim();
return t ? t.split(/\s+/).length : 0;
}
function diffStats(segments, before, after) {
let addedChars = 0;
let removedChars = 0;
let addedWords = 0;
let removedWords = 0;
for (const seg of segments) {
if (seg.type === 'add') { addedChars += seg.text.length; addedWords += countWords(seg.text); }
else if (seg.type === 'del') { removedChars += seg.text.length; removedWords += countWords(seg.text); }
}
return {
added_chars: addedChars,
removed_chars: removedChars,
added_words: addedWords,
removed_words: removedWords,
chars_before: before.length,
chars_after: after.length
};
}
function textDiff(beforeRaw, afterRaw) {
const before = asText(beforeRaw);
const after = asText(afterRaw);
if (before === after) {
return { changed: false, segments: [], truncated: false, stats: diffStats([], before, after) };
}
const a = tokenize(before);
const b = tokenize(after);
const raw = a.length * b.length <= MAX_CELLS ? lcsSegments(a, b) : anchoredDiff(a, b);
const full = compact(raw);
const capped = capSegments(full);
return {
changed: true,
segments: capped.segments,
truncated: capped.truncated,
stats: diffStats(full, before, after)
};
}
function displayValue(row, field) {
if (!row) return null;
const value = row[field];
const name = row[NAME_FIELDS[field]];
if (value === null || value === undefined || value === '') return name ? `— (${name})` : null;
if (name) return `${value} · ${name}`;
return String(value);
}
function buildEntryDiff(before, after) {
const changes = [];
for (const field of SIMPLE_FIELDS) {
const prev = displayValue(before, field);
const next = displayValue(after, field);
if (prev !== next) changes.push({ field, label: FIELD_LABELS[field], before: prev, after: next });
}
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
if (beforeText !== afterText) {
const d = textDiff(beforeText, afterText);
changes.push({
field: 'description',
label: FIELD_LABELS.description,
stats: d.stats,
diff: d.segments,
truncated: d.truncated
});
}
return changes;
}
function normalizeEditSource(raw, before, after) {
const value = typeof raw === 'string' ? raw.trim() : '';
const beforeText = asText(before && before.description);
const afterText = asText(after && after.description);
const aiText = asText(after && after.description_ai);
const textChanged = beforeText !== afterText;
if (!textChanged) return 'manual';
if (value === 'ai' || value === 'ai_manual') return value;
if (aiText && afterText === aiText) return 'ai';
return 'manual';
}
function summarizeChanges(changes) {
if (!Array.isArray(changes)) return [];
return changes.map(change => {
const item = { field: change.field, label: change.label || change.field };
if ('before' in change) item.before = change.before;
if ('after' in change) item.after = change.after;
if (change.stats) item.stats = change.stats;
if (change.truncated) item.truncated = true;
return item;
});
}
function stripDiffs(target) {
if (!target || typeof target !== 'object' || Array.isArray(target)) return target;
if (!Array.isArray(target.changes)) return target;
return { ...target, changes: summarizeChanges(target.changes) };
}
module.exports = {
FIELD_LABELS,
textDiff,
buildEntryDiff,
normalizeEditSource,
summarizeChanges,
stripDiffs
};