При сохранении записи журнала (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 обновлены
216 lines
6.6 KiB
JavaScript
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
|
|
};
|