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 };