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 обновлены
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user