lsdf's picture
Единая логика сводки и авто-целей оптимизатора (buildSummaryData)
f6881cf
let currentData = null;
let semanticData = null;
let optimizerData = null;
let semanticTermSortBy = 'target_weight';
let semanticTermSortDir = 'desc';
let availableUserAgents = [];
const PROJECT_SCHEMA_VERSION = 2;
/** Без операторов ?? / ?. — совместимость с SES lockdown на Hugging Face Spaces */
function nv(v, d) {
return (v !== undefined && v !== null) ? v : d;
}
let optimizerStreamJobId = null;
const OPT_DIFF_ORIG_BODY_KEY = 'optimizerOriginalTargetText_v1';
const OPT_DIFF_ORIG_TITLE_KEY = 'optimizerOriginalTargetTitle_v1';
function getOptimizerDiffModeValue() {
const el = document.getElementById('optimizerDiffMode');
const v = el && el.value ? String(el.value) : 'diff_from_input';
return v === 'diff_from_original' ? 'diff_from_original' : 'diff_from_input';
}
function loadOptimizerOriginalSnapshot() {
try {
const body = localStorage.getItem(OPT_DIFF_ORIG_BODY_KEY);
const title = localStorage.getItem(OPT_DIFF_ORIG_TITLE_KEY);
return { body: body, title: title };
} catch (e) {
return { body: null, title: null };
}
}
function ensureOptimizerOriginalSnapshot() {
// Создаём снимок “оригинала” при первом запуске в режиме diff_from_original.
const snapshot = loadOptimizerOriginalSnapshot();
let body = snapshot.body;
let title = snapshot.title;
const currentBody = (document.getElementById('targetText').value || '');
const currentTitle = (document.getElementById('targetTitle').value || '');
try {
if (body === null) {
localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, currentBody);
body = currentBody;
}
if (title === null) {
localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, currentTitle);
title = currentTitle;
}
} catch (e) {
// Best-effort: если localStorage недоступен, просто вернём то, что сейчас в форме.
body = currentBody;
title = currentTitle;
}
return { body: body, title: title };
}
function resetOptimizerDiffOriginal() {
const currentBody = (document.getElementById('targetText').value || '');
const currentTitle = (document.getElementById('targetTitle').value || '');
try {
localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, currentBody);
localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, currentTitle);
alert('Оригинал для сравнения обновлён.');
} catch (e) {
alert('Не удалось обновить снимок в localStorage. Проверьте настройки браузера.');
}
}
const OPT_STAGE_ORDER = ['bert', 'bm25', 'ngram', 'semantic', 'title'];
const OPT_STAGE_LABELS = {
bert: 'BERT',
bm25: 'BM25',
ngram: 'N-gram',
semantic: 'Semantic',
title: 'Title'
};
/** Порог Title BERT в сводке (совпадает с текстом рекомендаций). */
const SUMMARY_TITLE_BERT_THRESHOLD = 0.65;
/** Стоп-слова для Semantic Core в сводке и авто-целях оптимизатора. */
const SUMMARY_STOP_WORDS = {
en: new Set(['a', 'an', 'and', 'or', 'the', 'to', 'of', 'for', 'in', 'on', 'at', 'by', 'with', 'from', 'as', 'is', 'are', 'be', 'was', 'were']),
ru: new Set(['и', 'или', 'в', 'во', 'на', 'по', 'с', 'со', 'к', 'ко', 'для', 'из', 'за', 'что', 'это', 'как', 'а', 'но', 'у', 'о', 'от']),
de: new Set(['und', 'oder', 'der', 'die', 'das', 'zu', 'von', 'mit', 'für', 'in', 'auf', 'ist', 'sind']),
es: new Set(['y', 'o', 'el', 'la', 'los', 'las', 'de', 'del', 'en', 'con', 'para', 'por', 'es', 'son']),
it: new Set(['e', 'o', 'il', 'lo', 'la', 'i', 'gli', 'le', 'di', 'del', 'in', 'con', 'per', 'da', 'è', 'sono']),
pl: new Set(['i', 'oraz', 'lub', 'w', 'na', 'z', 'ze', 'do', 'od', 'po', 'dla', 'to', 'jest', 'są']),
pt: new Set(['e', 'ou', 'o', 'a', 'os', 'as', 'de', 'do', 'da', 'em', 'no', 'na', 'com', 'para', 'por', 'é', 'são'])
};
let optimizerStageGoalAutoCache = {};
function _escHtml(v) {
return String(v == null ? '' : v)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _uniqStrList(arr) {
const out = [];
const seen = {};
(arr || []).forEach((x) => {
const v = String(x || '').trim();
if (!v) return;
const k = v.toLowerCase();
if (seen[k]) return;
seen[k] = true;
out.push(v);
});
return out;
}
/**
* Единая логика «Сводки» и авто-целей оптимизатора: те же пороги, фильтры и сортировки.
* Возвращает диагностику, карточки рекомендаций и списки целей по стадиям.
*/
function buildSummaryData(analysisData, semData) {
const emptyGoals = { bert: [], bm25: [], ngram: [], semantic: [], title: [] };
const bertThreshold = Number(document.getElementById('optimizerBertStageTarget').value || 0.70);
if (!analysisData) {
return { diagnostics: [], recommendations: [], optimizerAutoGoals: emptyGoals, bertThreshold: bertThreshold };
}
const recommendations = [];
const diagnostics = [];
let semanticGapsForGoals = [];
const wcComp2 = analysisData.word_counts && analysisData.word_counts.competitors;
const competitorCount = Array.isArray(wcComp2) ? wcComp2.length : 0;
const minCompetitorSignal = competitorCount <= 1 ? 1 : 2;
const keywordsRaw = (document.getElementById('keywordsInput').value || '')
.split('\n')
.map(function (v) { return v.trim(); })
.filter(Boolean);
const bertDetails = analysisData.bert_analysis && analysisData.bert_analysis.detailed
? analysisData.bert_analysis.detailed
: [];
const lowBert = bertDetails.filter(function (i) {
return Number(i.my_max_score || 0) < bertThreshold;
});
diagnostics.push({
metric: 'BERT',
value: String(lowBert.length) + '/' + String(bertDetails.length || 0) + ' ниже ' + String(bertThreshold),
status: lowBert.length > 0 ? 'warning' : 'ok'
});
if (lowBert.length > 0) {
const topPhrases = lowBert
.slice()
.sort(function (a, b) { return Number(a.my_max_score || 0) - Number(b.my_max_score || 0); })
.slice(0, 8)
.map(function (i) {
return {
phrase: i.phrase,
my: Number(i.my_max_score || 0),
comp: Number(i.comp_max_score || 0)
};
});
recommendations.push({
priority: 'high',
title: 'Поднять BERT-релевантность по слабым ключам',
why: 'Найдено ' + String(lowBert.length) + ' ключей с релевантностью ниже ' + String(bertThreshold) + '.',
action: 'Усилить смысловое раскрытие этих фраз в тексте (не только точные вхождения, но и контекст).',
details_table: {
headers: ['Ключевая фраза', 'My score', 'Best Comp'],
rows: topPhrases.map(function (x) { return [x.phrase, x.my, x.comp]; })
}
});
}
const bm25 = Array.isArray(analysisData.bm25_recommendations) ? analysisData.bm25_recommendations : [];
const bm25Remove = bm25.filter(function (r) { return r.action === 'remove'; });
diagnostics.push({
metric: 'BM25',
value: 'REMOVE-рекомендаций: ' + String(bm25Remove.length),
status: bm25Remove.length >= 4 ? 'warning' : 'ok'
});
if (bm25Remove.length >= 4) {
const topSpam = bm25Remove
.slice()
.sort(function (a, b) { return Number(b.count || 0) - Number(a.count || 0); })
.slice(0, 10)
.map(function (r) { return { word: r.word, count: Number(r.count || 0) }; });
recommendations.push({
priority: 'high',
title: 'Снизить спамность ключевых вхождений',
why: 'BM25 дал ' + String(bm25Remove.length) + ' рекомендаций на сокращение (порог риска: 4+).',
action: 'Сократить частоту этих терминов и перераспределить формулировки в пользу естественных синонимов.',
details_table: {
headers: ['Термин', 'Сократить на'],
rows: topSpam.map(function (x) { return [x.word, x.count]; })
}
});
}
const ngramSignals = [];
const ngramStats = analysisData.ngram_stats || {};
const kwUnigrams = new Set();
keywordsRaw.forEach(function (kw) {
String(kw || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
.split(/\s+/)
.map(function (v) { return v.trim(); })
.filter(function (v) { return v.length >= 2; })
.forEach(function (t) { kwUnigrams.add(t); });
});
const isOutsideTolerance = function (targetCount, compAvg) {
if (compAvg <= 0) return false;
const tol = compAvg >= 4 ? 0.20 : 0.50;
return targetCount < compAvg * (1 - tol) || targetCount > compAvg * (1 + tol);
};
const isEligibleNgram = function (ngram, compOcc) {
const toks = String(ngram || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
.split(/\s+/)
.map(function (v) { return v.trim(); })
.filter(function (v) { return v.length >= 2; });
if (!toks.length) return false;
if (competitorCount > 1) {
if (compOcc < 2) return false;
if (toks.length >= 2) return true;
return kwUnigrams.has(toks[0]);
}
return compOcc >= 1;
};
Object.values(ngramStats).forEach(function (bucket) {
(Array.isArray(bucket) ? bucket : []).forEach(function (item) {
const compOcc = Number(item.comp_occurrence || 0);
const targetCount = Number(item.target_count || 0);
const compAvg = Number(item.competitor_avg || 0);
if (!isEligibleNgram(item.ngram, compOcc)) return;
if (!isOutsideTolerance(targetCount, compAvg)) return;
const devRatio = compAvg > 0 ? Math.abs(targetCount - compAvg) / compAvg : 0;
const direction = targetCount < compAvg ? 'under' : 'over';
ngramSignals.push({
ngram: item.ngram,
compOcc: compOcc,
targetCount: targetCount,
compAvg: compAvg,
devRatio: devRatio,
direction: direction
});
});
});
const underrepresentedNgrams = ngramSignals.filter(function (x) { return x.direction === 'under'; });
const overrepresentedNgrams = ngramSignals.filter(function (x) { return x.direction === 'over'; });
diagnostics.push({
metric: 'N-граммы',
value: 'Сигналов: ' + String(ngramSignals.length) + ' (недобор: ' + String(underrepresentedNgrams.length) + ', перебор: ' + String(overrepresentedNgrams.length) + ')',
status: ngramSignals.length > 0 ? 'warning' : 'ok'
});
if (underrepresentedNgrams.length > 0) {
const topSignals = underrepresentedNgrams
.slice()
.sort(function (a, b) {
return (b.devRatio - a.devRatio) || (b.compOcc - a.compOcc) || (b.compAvg - a.compAvg);
})
.slice(0, 10)
.map(function (x) {
return {
ngram: x.ngram,
target: x.targetCount,
avg: x.compAvg,
freq: String(x.compOcc)
};
});
recommendations.push({
priority: 'medium',
title: 'Добавить недопредставленные слова/фразы',
why: competitorCount <= 1
? 'В режиме 1x1 у конкурента эти n-граммы встречаются заметно чаще (или у вас отсутствуют).'
: 'У 2+ конкурентов эти n-граммы встречаются заметно чаще (или у вас отсутствуют).',
action: competitorCount <= 1
? 'Добавить релевантные вхождения для выравнивания с единственным конкурентом.'
: 'Добавить релевантные вхождения в естественные абзацы (особенно сигналы с K=3+).',
details_table: {
headers: ['N-грамма', 'My', 'Avg K', 'Freq(K)'],
rows: topSignals.map(function (x) { return [x.ngram, x.target, x.avg, x.freq]; })
}
});
}
if (overrepresentedNgrams.length > 0) {
const topOver = overrepresentedNgrams
.slice()
.sort(function (a, b) {
return (b.devRatio - a.devRatio) || (b.targetCount - a.targetCount);
})
.slice(0, 10)
.map(function (x) {
return {
ngram: x.ngram,
target: x.targetCount,
avg: x.compAvg,
freq: String(x.compOcc)
};
});
recommendations.push({
priority: 'low',
title: 'Переизбыточные n-граммы (инфо)',
why: 'Эти n-граммы встречаются у вас заметно чаще среднего по конкурентам.',
action: 'Информационный блок: снижение частоты регулируется через рекомендации BM25 (раздел спамности), а не через блок недопредставленности.',
details_table: {
headers: ['N-грамма', 'My', 'Avg K', 'Freq(K)'],
rows: topOver.map(function (x) { return [x.ngram, x.target, x.avg, x.freq]; })
}
});
}
const titleBert = analysisData.title_analysis && analysisData.title_analysis.bert
? Number(analysisData.title_analysis.bert.target_score || 0)
: null;
diagnostics.push({
metric: 'Title BERT',
value: titleBert === null ? 'нет данных' : titleBert.toFixed(3),
status: (titleBert !== null && titleBert < SUMMARY_TITLE_BERT_THRESHOLD) ? 'warning' : 'ok'
});
if (titleBert !== null && titleBert < SUMMARY_TITLE_BERT_THRESHOLD) {
recommendations.push({
priority: 'high',
title: 'Переписать Title под ключевой интент',
why: 'Семантическая релевантность Title = ' + titleBert.toFixed(3) + ' (< ' + String(SUMMARY_TITLE_BERT_THRESHOLD) + ').',
action: 'Уточнить формулировку Title: добавить главные ключевые слова и намерение пользователя без переспама.',
details: []
});
}
if (semData && semData.comparison && Array.isArray(semData.comparison.term_power_table)) {
const table = semData.comparison.term_power_table;
const _lsel = document.getElementById('languageSelect');
const lang = ((_lsel && _lsel.value) || 'en').toLowerCase();
const stopWords = SUMMARY_STOP_WORDS[lang] || SUMMARY_STOP_WORDS.en;
const tokenize = function (text) {
return String(text || '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
.split(/\s+/)
.map(function (v) { return v.trim(); })
.filter(function (v) { return v.length >= 2; });
};
const filterStopWords = function (tokens) {
return tokens.filter(function (t) { return !stopWords.has(t); });
};
const keywordTermSet = new Set();
keywordsRaw.forEach(function (kw) {
const rawTokens = tokenize(kw);
const tokens = filterStopWords(rawTokens);
if (tokens.length === 0) return;
tokens.forEach(function (t) { keywordTermSet.add(t); });
for (let n = 2; n <= 3; n++) {
if (tokens.length < n) continue;
for (let i = 0; i <= tokens.length - n; i++) {
keywordTermSet.add(tokens.slice(i, i + n).join(' '));
}
}
});
const keywordTerms = Array.from(keywordTermSet);
const byTerm = new Map(table.map(function (row) { return [String(row.term || '').toLowerCase(), row]; }));
const buildGap = function (term, row) {
const targetWeight = Number(row.target_weight || 0);
const compAvgWeight = Number(row.competitor_avg_weight || 0);
const gap = compAvgWeight - targetWeight;
return {
term: term,
targetWeight: targetWeight,
compAvgWeight: compAvgWeight,
gap: gap,
compOcc: Number(row.comp_occurrence || 0),
compTotal: Number(row.comp_total || 0)
};
};
const keywordSemanticGaps = keywordTerms
.map(function (term) {
const row = byTerm.get(term);
if (!row) return null;
return buildGap(term, row);
})
.filter(Boolean)
.filter(function (x) { return x.gap > 0 && x.compOcc >= minCompetitorSignal; })
.sort(function (a, b) { return (b.gap - a.gap) || (b.compOcc - a.compOcc); });
const otherSemanticGaps = table
.map(function (row) {
const term = String(row.term || '').toLowerCase();
if (!term || keywordTermSet.has(term)) return null;
const tokens = tokenize(term);
if (tokens.length === 0) return null;
if (tokens.every(function (t) { return stopWords.has(t); })) return null;
return buildGap(term, row);
})
.filter(Boolean)
.filter(function (x) { return x.gap > 0 && x.compOcc >= minCompetitorSignal; })
.sort(function (a, b) { return (b.gap - a.gap) || (b.compAvgWeight - a.compAvgWeight); });
const semanticGaps = keywordSemanticGaps.concat(otherSemanticGaps);
semanticGapsForGoals = semanticGaps;
diagnostics.push({
metric: 'Semantic Core',
value: 'Ключевых n-грамм с отставанием: ' + String(keywordSemanticGaps.length) + ' (порог K>=' + String(minCompetitorSignal) + ')',
status: semanticGaps.length > 0 ? 'warning' : 'ok'
});
if (semanticGaps.length > 0) {
const topSemantic = semanticGaps
.slice(0, 10)
.map(function (x) {
return {
source: keywordTermSet.has(x.term) ? 'KW' : 'TOP',
term: x.term,
target: x.targetWeight,
avg: x.compAvgWeight,
freq: String(x.compOcc) + '/' + String(x.compTotal)
};
});
recommendations.push({
priority: 'high',
title: 'Усилить смысловой вес ключевых терминов (слова + n-граммы)',
why: competitorCount <= 1
? 'В режиме 1x1 ключевые слова/bi- и tri-граммы из ваших фраз слабее, чем у конкурента.'
: 'По Semantic Core ключевые слова/bi- и tri-граммы из ваших фраз слабее, чем в среднем у конкурентов.',
action: 'Сначала усилить [KW]-термины в смысловых узлах текста, затем добрать [TOP]-термины с высоким разрывом.',
details_table: {
headers: ['Тип', 'Термин', 'My вес', 'Avg K', 'Freq'],
rows: topSemantic.map(function (x) { return [x.source, x.term, x.target, x.avg, x.freq]; })
}
});
}
} else {
diagnostics.push({
metric: 'Semantic Core',
value: 'нет данных (не запускался)',
status: 'pending'
});
recommendations.push({
priority: 'medium',
title: 'Запустить Semantic Core для итоговой оценки',
why: 'Без графовых весов нельзя рассчитать разрыв по смысловой мощности ключевых слов.',
action: 'Запустите вкладку Semantic Core и затем обновите сводку.',
details: []
});
}
const nu = underrepresentedNgrams
.slice()
.sort(function (a, b) {
return (b.devRatio - a.devRatio) || (b.compOcc - a.compOcc) || (b.compAvg - a.compAvg);
})
.slice(0, 10);
const no = overrepresentedNgrams
.slice()
.sort(function (a, b) {
return (b.devRatio - a.devRatio) || (b.targetCount - a.targetCount);
})
.slice(0, 10);
const optimizerAutoGoals = {
bert: _uniqStrList(
lowBert
.slice()
.sort(function (a, b) { return Number(a.my_max_score || 0) - Number(b.my_max_score || 0); })
.map(function (i) { return String(i.phrase || '').trim(); })
),
bm25: bm25Remove.length >= 4
? _uniqStrList(
bm25Remove
.slice()
.sort(function (a, b) { return Number(b.count || 0) - Number(a.count || 0); })
.slice(0, 10)
.map(function (r) { return String(r.word || '').trim(); })
)
: [],
ngram: _uniqStrList(
nu.map(function (x) { return String(x.ngram || '').trim(); }).concat(
no.map(function (x) { return String(x.ngram || '').trim(); })
)
),
semantic: semanticGapsForGoals.slice(0, 10).map(function (x) { return x.term; }),
title: (titleBert !== null && titleBert < SUMMARY_TITLE_BERT_THRESHOLD)
? _uniqStrList(keywordsRaw).slice(0, 20)
: []
};
return {
diagnostics: diagnostics,
recommendations: recommendations,
optimizerAutoGoals: optimizerAutoGoals,
bertThreshold: bertThreshold
};
}
function _buildOptimizerAutoGoals() {
if (!currentData) {
return { bert: [], bm25: [], ngram: [], semantic: [], title: [] };
}
return buildSummaryData(currentData, semanticData).optimizerAutoGoals;
}
function _getOptimizerStageConfigFromUI() {
const enabled = [];
const overrides = {};
OPT_STAGE_ORDER.forEach((stage) => {
const enEl = document.getElementById('optStage' + stage.charAt(0).toUpperCase() + stage.slice(1));
if (enEl && enEl.checked) enabled.push(stage);
const modeEl = document.getElementById('optStageMode-' + stage);
const mode = modeEl ? String(modeEl.value || 'auto') : 'auto';
const selectedEls = document.querySelectorAll('.opt-goal-check[data-stage="' + stage + '"]');
const selected = [];
for (let i = 0; i < selectedEls.length; i++) {
if (selectedEls[i].checked) selected.push(String(selectedEls[i].value || '').trim());
}
const customEl = document.getElementById('optStageCustom-' + stage);
const customAdd = customEl
? _uniqStrList(String(customEl.value || '').split('\n').map((x) => x.trim()).filter(Boolean))
: [];
overrides[stage] = {
mode: (mode === 'manual' || mode === 'mixed') ? mode : 'auto',
selected: _uniqStrList(selected),
custom_add: customAdd
};
});
return { enabled_stages: enabled, stage_goal_overrides: overrides };
}
function optimizerSelectAllStages(flag) {
OPT_STAGE_ORDER.forEach((stage) => {
const enEl = document.getElementById('optStage' + stage.charAt(0).toUpperCase() + stage.slice(1));
if (enEl) enEl.checked = !!flag;
});
}
function _renderOptimizerStageGoalConfig(savedCfg) {
const wrap = document.getElementById('optimizerStageGoalConfigContainer');
if (!wrap) return;
const cfg = savedCfg || {};
const html = OPT_STAGE_ORDER.map((stage) => {
const auto = optimizerStageGoalAutoCache[stage] || [];
const stCfg = cfg[stage] || {};
const mode = (stCfg.mode === 'manual' || stCfg.mode === 'mixed') ? stCfg.mode : 'auto';
const selectedSaved = {};
(stCfg.selected || []).forEach((v) => { selectedSaved[String(v || '').toLowerCase()] = true; });
const rows = auto.map((goal, idx) => {
const g = String(goal || '').trim();
const key = g.toLowerCase();
const checked = selectedSaved[key] ? 'checked' : '';
return `<div class="form-check">
<input class="form-check-input opt-goal-check" type="checkbox" data-stage="${stage}" id="optGoal-${stage}-${idx}" value="${_escHtml(g)}" ${checked}>
<label class="form-check-label small" for="optGoal-${stage}-${idx}">${_escHtml(g)}</label>
</div>`;
}).join('');
const customText = Array.isArray(stCfg.custom_add) ? stCfg.custom_add.join('\n') : '';
return `<div class="border rounded p-2 mb-2 bg-white">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong class="small">${_escHtml(OPT_STAGE_LABELS[stage] || stage)}</strong>
<span class="small text-muted">Авто-целей: ${auto.length}</span>
</div>
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small text-muted mb-1">Режим целей</label>
<select id="optStageMode-${stage}" class="form-select form-select-sm">
<option value="auto" ${mode === 'auto' ? 'selected' : ''}>Auto</option>
<option value="manual" ${mode === 'manual' ? 'selected' : ''}>Manual</option>
<option value="mixed" ${mode === 'mixed' ? 'selected' : ''}>Mixed</option>
</select>
</div>
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Авто-цели стадии</label>
<div class="border rounded p-2" style="max-height:120px;overflow:auto;">
${rows || '<div class="small text-muted">Нет авто-целей. Добавьте вручную.</div>'}
</div>
</div>
<div class="col-md-4">
<label class="form-label small text-muted mb-1">Custom цели (по строкам)</label>
<textarea id="optStageCustom-${stage}" class="form-control form-control-sm" rows="4" placeholder="Добавьте свои цели">${_escHtml(customText)}</textarea>
</div>
</div>
</div>`;
}).join('');
wrap.innerHTML = html;
}
function refreshOptimizerStageGoalConfig(savedOverrides) {
const currentUiCfg = _getOptimizerStageConfigFromUI().stage_goal_overrides;
optimizerStageGoalAutoCache = _buildOptimizerAutoGoals();
_renderOptimizerStageGoalConfig(savedOverrides || currentUiCfg || {});
}
function optimizerLogAppend(line) {
const el = document.getElementById('optimizerRunLog');
if (!el) return;
const t = new Date().toISOString().split('T')[1].split('.')[0];
el.textContent += '[' + t + '] ' + line + '\n';
el.scrollTop = el.scrollHeight;
}
function optimizerRunUiClear() {
const log = document.getElementById('optimizerRunLog');
if (log) log.textContent = '';
const bar = document.getElementById('optimizerProgressBar');
if (bar) {
bar.style.width = '0%';
bar.setAttribute('aria-valuenow', '0');
}
const elp = document.getElementById('optimizerElapsed');
if (elp) elp.textContent = '';
}
function applyOptimizerStreamEvent(data) {
if (!data || typeof data !== 'object') return;
const bar = document.getElementById('optimizerProgressBar');
const ev = data.event;
if (ev === 'preparing') {
if (bar) {
bar.style.width = '2%';
bar.setAttribute('aria-valuenow', '2');
}
optimizerLogAppend('Подготовка: ' + (data.message || data.phase || ''));
return;
}
if (ev === 'started') {
if (bar) {
bar.style.width = '5%';
bar.setAttribute('aria-valuenow', '5');
}
optimizerLogAppend('Старт: всего шагов ' + (data.total_steps || '?') + ', n-gram целей: ' + (data.ngram_targets != null ? data.ngram_targets : '?'));
return;
}
if (ev === 'step_start') {
const tot = Math.max(1, data.total_steps || 1);
const pct = Math.min(95, Math.round(((data.step - 1) / tot) * 90 + 5));
if (bar) {
bar.style.width = pct + '%';
bar.setAttribute('aria-valuenow', String(pct));
}
optimizerLogAppend('Шаг ' + data.step + '/' + tot + ' | ' + (data.active_stage || '') + ' | ' + (data.goal_type || '') + (data.goal_label ? ': ' + data.goal_label : '') + (data.score != null ? ' | score ' + data.score : ''));
return;
}
if (ev === 'llm_call') {
optimizerLogAppend('LLM: кандидат ' + data.candidate_index + ', окно ' + data.span_trial + '/' + data.span_trials + ', стратегия ' + (data.strategy || ''));
}
}
async function requestStopOptimizer() {
if (!optimizerStreamJobId) return;
optimizerLogAppend('Остановка запрошена, ждём частичный результат с сервера…');
try {
await fetch('/api/v1/optimizer/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: optimizerStreamJobId })
});
} catch (e) {
console.warn(e);
}
}
// --- ФУНКЦИИ ИНТЕРФЕЙСА ---
document.getElementById('targetTitle').addEventListener('input', function() {
const len = this.value.length;
const counter = document.getElementById('titleCharCount');
counter.textContent = len;
counter.className = len > 60 ? 'text-danger fw-bold' : (len > 50 ? 'text-warning' : 'text-muted');
});
function addCompetitorTitleField() {
const div = document.createElement('div');
div.innerHTML = '<input type="text" class="form-control mb-2" placeholder="Ещё Title конкурента..." maxlength="200">';
document.getElementById('competitorTitlesList').appendChild(div);
}
function addCompetitorField() {
const div = document.createElement('div');
div.innerHTML = '<textarea class="form-control mb-2 competitor-input" rows="3" placeholder="Ещё конкурент..."></textarea>';
document.getElementById('competitorsList').appendChild(div);
}
function collectCompetitorTexts() {
const compInputs = document.querySelectorAll('#competitorsList textarea');
const competitors = [];
compInputs.forEach(input => {
if (input.value.trim() !== '') competitors.push(input.value);
});
return competitors;
}
function collectCompetitorTitles() {
const compTitleInputs = document.querySelectorAll('#competitorTitlesList input');
const competitorTitles = [];
compTitleInputs.forEach(input => {
if (input.value.trim() !== '') competitorTitles.push(input.value);
});
return competitorTitles;
}
function ensureCompetitorFieldCount(count) {
const safeCount = Math.max(1, count || 1);
const competitorsList = document.getElementById('competitorsList');
competitorsList.innerHTML = '';
for (let i = 0; i < safeCount; i++) {
const textarea = document.createElement('textarea');
textarea.className = 'form-control mb-2' + (i > 0 ? ' competitor-input' : '');
textarea.rows = 3;
textarea.placeholder = i === 0 ? 'Текст конкурента 1...' : 'Ещё конкурент...';
competitorsList.appendChild(textarea);
}
const compTitlesList = document.getElementById('competitorTitlesList');
compTitlesList.innerHTML = '';
for (let i = 0; i < safeCount; i++) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control mb-2';
input.placeholder = i === 0 ? 'Title конкурента 1...' : 'Ещё Title конкурента...';
input.maxLength = 200;
compTitlesList.appendChild(input);
}
}
async function loadUserAgentOptions() {
const select = document.getElementById('urlUserAgentSelect');
if (!select) return;
const currentValue = select.value || 'chrome_desktop';
try {
const response = await fetch('/api/v1/url/user-agents');
if (!response.ok) throw new Error(response.statusText);
const data = await response.json();
availableUserAgents = Array.isArray(data.user_agents) ? data.user_agents : [];
if (availableUserAgents.length === 0) return;
select.innerHTML = availableUserAgents
.map(ua => `<option value="${ua.key}">${ua.name}</option>`)
.join('');
if ([...select.options].some(o => o.value === currentValue)) {
select.value = currentValue;
} else if ([...select.options].some(o => o.value === 'chrome_desktop')) {
select.value = 'chrome_desktop';
}
} catch (err) {
console.warn('Failed to load user-agent presets', err);
}
}
async function fetchUrlPayload(url) {
const userAgent = document.getElementById('urlUserAgentSelect').value || 'chrome_desktop';
const response = await fetch('/api/v1/url/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: url,
user_agent: userAgent,
timeout_seconds: 20
})
});
if (!response.ok) {
throw new Error('Ошибка сервера: ' + response.statusText);
}
const data = await response.json();
if (!data.ok) {
throw new Error(data.error || 'Не удалось извлечь текст по URL.');
}
return data;
}
async function fetchTargetFromUrl() {
const input = document.getElementById('targetUrlInput');
const url = (input.value || '').trim();
if (!url) {
alert('Введите URL для Target.');
return;
}
document.getElementById('loader').style.display = 'block';
try {
const data = await fetchUrlPayload(url);
document.getElementById('targetText').value = data.text || '';
document.getElementById('targetTitle').value = data.title || '';
const len = (data.title || '').length;
const counter = document.getElementById('titleCharCount');
counter.textContent = len;
counter.className = len > 60 ? 'text-danger fw-bold' : (len > 50 ? 'text-warning' : 'text-muted');
alert('Target успешно загружен по URL.');
} catch (error) {
alert('Ошибка загрузки Target URL: ' + error.message);
console.error(error);
} finally {
document.getElementById('loader').style.display = 'none';
}
}
async function fetchCompetitorsFromUrls() {
const urlsRaw = (document.getElementById('competitorUrlsInput').value || '').trim();
if (!urlsRaw) {
alert('Введите хотя бы один URL конкурента (по одному в строке).');
return;
}
const urls = urlsRaw.split('\n').map(u => u.trim()).filter(Boolean);
if (urls.length === 0) {
alert('Не найдено корректных URL.');
return;
}
document.getElementById('loader').style.display = 'block';
const results = [];
const errors = [];
try {
for (let i = 0; i < urls.length; i++) {
const url = urls[i];
try {
const parsed = await fetchUrlPayload(url);
results.push(parsed);
} catch (err) {
errors.push(`K${i + 1}: ${err.message}`);
}
}
if (results.length > 0) {
ensureCompetitorFieldCount(results.length);
const textAreas = document.querySelectorAll('#competitorsList textarea');
const titleInputs = document.querySelectorAll('#competitorTitlesList input');
results.forEach((doc, idx) => {
if (textAreas[idx]) textAreas[idx].value = doc.text || '';
if (titleInputs[idx]) titleInputs[idx].value = doc.title || '';
});
}
if (errors.length > 0) {
alert(`Частично загружено. Успешно: ${results.length}/${urls.length}\n` + errors.join('\n'));
} else {
alert(`Конкуренты успешно загружены: ${results.length}/${urls.length}`);
}
} catch (error) {
alert('Ошибка загрузки URL конкурентов: ' + error.message);
console.error(error);
} finally {
document.getElementById('loader').style.display = 'none';
}
}
function saveProject() {
const diffMode = getOptimizerDiffModeValue();
const origSnap = loadOptimizerOriginalSnapshot();
const stageCfg = _getOptimizerStageConfigFromUI();
const curBody = document.getElementById('targetText').value || '';
const curTitle = document.getElementById('targetTitle').value || '';
// В проекте всегда держим оригинал, даже если снимок ещё не создавался.
const origBody = (origSnap && origSnap.body !== null) ? origSnap.body : curBody;
const origTitle = (origSnap && origSnap.title !== null) ? origSnap.title : curTitle;
const projectData = {
app: "seo-ai-editor",
schema_version: PROJECT_SCHEMA_VERSION,
saved_at: new Date().toISOString(),
inputs: {
language: document.getElementById('languageSelect').value,
target_url: document.getElementById('targetUrlInput').value,
competitor_urls: document.getElementById('competitorUrlsInput').value,
fetch_user_agent: document.getElementById('urlUserAgentSelect').value,
target_text: curBody,
keywords: document.getElementById('keywordsInput').value,
competitors: collectCompetitorTexts(),
target_title: curTitle,
competitor_titles: collectCompetitorTitles(),
semantic_threshold: Number(document.getElementById('semanticThreshold').value || 50),
semantic_compression: Number(document.getElementById('semanticCompression').value || 0.1),
semantic_query: document.getElementById('semanticQueryInput').value,
optimizer_base_url: document.getElementById('optimizerBaseUrl').value,
optimizer_model: document.getElementById('optimizerModel').value,
optimizer_iterations: Number(document.getElementById('optimizerIterations').value || 2),
optimizer_candidates: Number(document.getElementById('optimizerCandidates').value || 2),
optimizer_temperature: Number(document.getElementById('optimizerTemp').value || 0.25),
optimizer_bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
optimizer_mode: document.getElementById('optimizerMode').value,
optimizer_phrase_strategy: document.getElementById('optimizerPhraseStrategy').value,
optimizer_enabled_stages: stageCfg.enabled_stages,
optimizer_stage_goal_overrides: stageCfg.stage_goal_overrides,
// Diff highlight settings (for persistence across sessions)
optimizer_diff_mode: diffMode,
optimizer_original_target_text: origBody,
optimizer_original_target_title: origTitle
},
state: {
analysis_result: currentData,
semantic_result: semanticData,
optimizer_result: optimizerData
}
};
const blob = new Blob([JSON.stringify(projectData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
a.href = url;
a.download = `seo_ai_project_${stamp}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
function loadProject() {
const input = document.getElementById('projectFileInput');
input.value = '';
input.click();
}
function clearProject() {
const ok = confirm("Сбросить текущий проект? Все несохраненные данные будут потеряны.");
if (!ok) return;
// Inputs
document.getElementById('languageSelect').value = 'ru';
document.getElementById('targetUrlInput').value = '';
document.getElementById('competitorUrlsInput').value = '';
document.getElementById('urlUserAgentSelect').value = 'chrome_desktop';
document.getElementById('targetText').value = '';
document.getElementById('keywordsInput').value = '';
document.getElementById('targetTitle').value = '';
document.getElementById('titleCharCount').textContent = '0';
document.getElementById('titleCharCount').className = 'text-muted';
document.getElementById('semanticThreshold').value = 50;
document.getElementById('semanticCompression').value = 0.1;
document.getElementById('semanticQueryInput').value = '';
document.getElementById('optimizerApiKey').value = '';
document.getElementById('optimizerBaseUrl').value = 'https://api.deepseek.com/v1';
document.getElementById('optimizerModel').value = 'deepseek-chat';
document.getElementById('optimizerIterations').value = 2;
document.getElementById('optimizerCandidates').value = 2;
document.getElementById('optimizerTemp').value = 0.25;
document.getElementById('optimizerBertStageTarget').value = 0.70;
document.getElementById('optimizerMode').value = 'balanced';
document.getElementById('optimizerPhraseStrategy').value = 'auto';
const diffSel = document.getElementById('optimizerDiffMode');
if (diffSel) diffSel.value = 'diff_from_input';
optimizerSelectAllStages(true);
// Competitor text fields
const competitorsList = document.getElementById('competitorsList');
competitorsList.innerHTML = '<textarea class="form-control mb-2" rows="3" placeholder="Текст конкурента 1..."></textarea>';
// Competitor title fields
const compTitlesList = document.getElementById('competitorTitlesList');
compTitlesList.innerHTML = '<input type="text" class="form-control mb-2" placeholder="Title конкурента 1..." maxlength="200">';
// Clear state
currentData = null;
semanticData = null;
optimizerData = null;
// Reset result blocks
document.getElementById('generalStats').innerHTML = '';
document.getElementById('bertResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Нажмите "Анализировать", чтобы увидеть результаты.</div>';
document.getElementById('bm25TableBody').innerHTML = '';
const bm25Msg = document.getElementById('bm25EmptyMsg');
bm25Msg.style.display = 'block';
bm25Msg.textContent = 'Нет критических рекомендаций.';
document.getElementById('ngramTableBody').innerHTML = '';
document.getElementById('titleResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Заполните поле "Ваш Title" и нажмите "Анализировать".</div>';
document.getElementById('semanticDocSelect').innerHTML = '<option value="target">Мой текст</option>';
document.getElementById('semanticResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Нажмите "Запустить Semantic Core", чтобы построить граф и разметку.</div>';
document.getElementById('summaryResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите анализ, чтобы увидеть итоговые рекомендации.</div>';
document.getElementById('optimizerResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>';
// Чтобы режим "оригинал" не сравнивался с прошлым проектом.
try {
localStorage.removeItem(OPT_DIFF_ORIG_BODY_KEY);
localStorage.removeItem(OPT_DIFF_ORIG_TITLE_KEY);
} catch (e) {
// ignore
}
refreshOptimizerStageGoalConfig({
bert: { mode: 'auto', selected: [], custom_add: [] },
bm25: { mode: 'auto', selected: [], custom_add: [] },
ngram: { mode: 'auto', selected: [], custom_add: [] },
semantic: { mode: 'auto', selected: [], custom_add: [] },
title: { mode: 'auto', selected: [], custom_add: [] }
});
}
function applyProjectData(project) {
if (!project || !project.inputs) {
throw new Error("Некорректный формат файла проекта.");
}
const inp = project.inputs;
// Base fields
document.getElementById('languageSelect').value = inp.language || 'ru';
document.getElementById('targetUrlInput').value = inp.target_url || '';
document.getElementById('competitorUrlsInput').value = inp.competitor_urls || '';
document.getElementById('urlUserAgentSelect').value = inp.fetch_user_agent || 'chrome_desktop';
document.getElementById('targetText').value = inp.target_text || '';
document.getElementById('keywordsInput').value = inp.keywords || '';
document.getElementById('targetTitle').value = inp.target_title || '';
document.getElementById('semanticThreshold').value = nv(inp.semantic_threshold, 50);
document.getElementById('semanticCompression').value = nv(inp.semantic_compression, 0.1);
document.getElementById('semanticQueryInput').value = inp.semantic_query || '';
document.getElementById('optimizerBaseUrl').value = inp.optimizer_base_url || 'https://api.deepseek.com/v1';
document.getElementById('optimizerModel').value = inp.optimizer_model || 'deepseek-chat';
document.getElementById('optimizerIterations').value = nv(inp.optimizer_iterations, 2);
document.getElementById('optimizerCandidates').value = nv(inp.optimizer_candidates, 2);
document.getElementById('optimizerTemp').value = nv(inp.optimizer_temperature, 0.25);
document.getElementById('optimizerBertStageTarget').value = nv(inp.optimizer_bert_stage_target, 0.70);
document.getElementById('optimizerMode').value = inp.optimizer_mode || 'balanced';
document.getElementById('optimizerPhraseStrategy').value = inp.optimizer_phrase_strategy || 'auto';
optimizerSelectAllStages(true);
const savedEnabledStages = Array.isArray(inp.optimizer_enabled_stages) ? inp.optimizer_enabled_stages : [];
if (savedEnabledStages.length > 0) {
optimizerSelectAllStages(false);
savedEnabledStages.forEach((st) => {
const stage = String(st || '').trim().toLowerCase();
const id = 'optStage' + stage.charAt(0).toUpperCase() + stage.slice(1);
const el = document.getElementById(id);
if (el) el.checked = true;
});
}
const savedOverrides = inp.optimizer_stage_goal_overrides && typeof inp.optimizer_stage_goal_overrides === 'object'
? inp.optimizer_stage_goal_overrides
: {};
refreshOptimizerStageGoalConfig(savedOverrides);
// Restore diff-mode and original snapshot from project (if present).
try {
const savedMode = String(nv(inp.optimizer_diff_mode, '') || '').trim();
const mode = (savedMode === 'diff_from_original') ? 'diff_from_original' : 'diff_from_input';
const diffSel = document.getElementById('optimizerDiffMode');
if (diffSel) diffSel.value = mode;
const savedOrigBody = nv(inp.optimizer_original_target_text, null);
const savedOrigTitle = nv(inp.optimizer_original_target_title, null);
if (savedOrigBody !== null || savedOrigTitle !== null) {
localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, String(savedOrigBody || ''));
localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, String(savedOrigTitle || ''));
} else {
// If project has no snapshot, clear any leftovers.
localStorage.removeItem(OPT_DIFF_ORIG_BODY_KEY);
localStorage.removeItem(OPT_DIFF_ORIG_TITLE_KEY);
}
} catch (e) {
// ignore
}
// Title character counter refresh
const titleLen = (inp.target_title || '').length;
const counter = document.getElementById('titleCharCount');
counter.textContent = titleLen;
counter.className = titleLen > 60 ? 'text-danger fw-bold' : (titleLen > 50 ? 'text-warning' : 'text-muted');
// Competitor texts
const competitorsList = document.getElementById('competitorsList');
competitorsList.innerHTML = '';
const compTexts = Array.isArray(inp.competitors) && inp.competitors.length > 0 ? inp.competitors : [''];
compTexts.forEach((txt, idx) => {
const textarea = document.createElement('textarea');
textarea.className = 'form-control mb-2' + (idx > 0 ? ' competitor-input' : '');
textarea.rows = 3;
textarea.placeholder = idx === 0 ? 'Текст конкурента 1...' : 'Ещё конкурент...';
textarea.value = txt || '';
competitorsList.appendChild(textarea);
});
// Competitor titles
const compTitlesList = document.getElementById('competitorTitlesList');
compTitlesList.innerHTML = '';
const compTitles = Array.isArray(inp.competitor_titles) && inp.competitor_titles.length > 0 ? inp.competitor_titles : [''];
compTitles.forEach((ttl, idx) => {
const titleInput = document.createElement('input');
titleInput.type = 'text';
titleInput.className = 'form-control mb-2';
titleInput.placeholder = idx === 0 ? 'Title конкурента 1...' : 'Ещё Title конкурента...';
titleInput.maxLength = 200;
titleInput.value = ttl || '';
compTitlesList.appendChild(titleInput);
});
// Restore cached analysis results if present
currentData = project.state && project.state.analysis_result ? project.state.analysis_result : null;
semanticData = project.state && project.state.semantic_result ? project.state.semantic_result : null;
optimizerData = project.state && project.state.optimizer_result ? project.state.optimizer_result : null;
if (currentData) renderResults(currentData);
if (semanticData) renderSemanticResults(semanticData);
refreshOptimizerStageGoalConfig(savedOverrides);
renderActionSummary(currentData, semanticData);
renderOptimizerResults(optimizerData);
}
document.getElementById('projectFileInput').addEventListener('change', function(e) {
const file = e.target.files && e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
try {
const text = evt.target.result;
const project = JSON.parse(text);
applyProjectData(project);
alert("Проект успешно загружен.");
} catch (err) {
alert("Не удалось загрузить проект: " + err.message);
console.error(err);
}
};
reader.readAsText(file, 'utf-8');
});
async function runAnalysis() {
// Сбор данных
const targetText = document.getElementById('targetText').value;
const lang = document.getElementById('languageSelect').value;
const keywordsRaw = document.getElementById('keywordsInput').value.split('\n').filter(k => k.trim() !== '');
// Сбор конкурентов
const competitors = collectCompetitorTexts();
if(!targetText) { alert("Введите ваш текст!"); return; }
// Title fields
const targetTitle = document.getElementById('targetTitle').value;
const competitorTitles = collectCompetitorTitles();
// UI Loading
document.getElementById('loader').style.display = 'block';
const payload = {
target_text: targetText,
competitors: competitors,
keywords: keywordsRaw,
language: lang,
target_title: targetTitle,
competitor_titles: competitorTitles
};
try {
const response = await fetch('/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
const data = await response.json();
currentData = data;
renderResults(data);
optimizerData = null;
renderOptimizerResults(null);
refreshOptimizerStageGoalConfig();
} catch (error) {
alert("Ошибка: " + error.message);
console.error(error);
} finally {
document.getElementById('loader').style.display = 'none';
}
}
async function runSemanticAnalysis() {
const targetText = document.getElementById('targetText').value;
const lang = document.getElementById('languageSelect').value;
const threshold = Number(document.getElementById('semanticThreshold').value || 50);
const compression = Number(document.getElementById('semanticCompression').value || 0.1);
const competitors = collectCompetitorTexts();
if (!targetText.trim()) {
alert("Введите ваш текст, чтобы запустить Semantic Core.");
return;
}
document.getElementById('loader').style.display = 'block';
const payload = {
text: targetText,
competitors: competitors,
language: lang,
threshold: Math.max(1, Math.min(100, threshold)),
compression_ratio: Math.max(0.05, Math.min(0.8, compression))
};
try {
const response = await fetch('/api/v1/semantic/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
semanticData = await response.json();
renderSemanticResults(semanticData);
refreshOptimizerStageGoalConfig();
renderActionSummary(currentData, semanticData);
} catch (error) {
alert("Ошибка Semantic Core: " + error.message);
console.error(error);
} finally {
document.getElementById('loader').style.display = 'none';
}
}
function renderOptimizerResults(data) {
const container = document.getElementById('optimizerResultsContainer');
if (!container) return;
if (!data) {
container.innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>';
return;
}
if (!data.ok) {
container.innerHTML = `<div class="alert alert-danger">Ошибка оптимизации: ${data.error || 'unknown'}</div>`;
return;
}
const earlyBanner = data.stopped_early
? `<div class="alert alert-warning mb-3"><strong>Остановлено вручную.</strong> Ниже — частичный результат на момент остановки. Шагов применено: <strong>${nv(data.applied_changes, 0)}</strong>.</div>`
: '';
const safeHtml = (v) => String(nv(v, ''))
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const optTitle = getOptimizedTitleFromResult(data);
const titleBanner = optTitle
? `<div class="alert alert-light border mb-3"><strong>Title после оптимизации:</strong><br><code class="small">${safeHtml(optTitle)}</code><div class="small text-muted mt-1">Кнопка «Применить в Target» подставит этот текст в поле Title.</div></div>`
: '';
const diffModeLabel = data.diff_mode === 'diff_from_original' ? 'Оригинал (снимок)' : 'Входной текст';
const diffChanges = Array.isArray(data.diff_changes) ? data.diff_changes : [];
const diffChangesRows = diffChanges
.slice(0, 50)
.map((c) => {
const kind = c.type || '-';
const fromTxt = c.from ? safeHtml(c.from) : '-';
const toTxt = c.to ? safeHtml(c.to) : '-';
return `<tr>
<td>${safeHtml(kind)}</td>
<td><div style="max-width: 420px; white-space: normal;">${fromTxt}</div></td>
<td><div style="max-width: 420px; white-space: normal;">${toTxt}</div></td>
</tr>`;
}).join('');
const diffCard = data.diff_body_html
? `<div class="stat-card mt-3">
<h6 class="card-title">Подсветка изменений в Target</h6>
<div class="small mb-2 text-muted">Режим: <strong>${safeHtml(diffModeLabel)}</strong></div>
<div class="border rounded p-3" style="background:#fff; line-height: 1.8; word-break: break-word;">${data.diff_body_html}</div>
<div class="mt-3">
<div class="small fw-semibold text-muted mb-1">Фрагменты (что было / что стало)</div>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr><th>Тип</th><th>Было</th><th>Стало</th></tr>
</thead>
<tbody>
${diffChangesRows || '<tr><td colspan="3" class="text-center text-muted">Изменений не найдено.</td></tr>'}
</tbody>
</table>
</div>
</div>
</div>`
: '';
const base = data.baseline_metrics || {};
const fin = data.final_metrics || {};
const rows = [
['Composite score', base.score, fin.score],
['BERT низких ключей', base.bert_low_count, fin.bert_low_count],
['BM25 remove', base.bm25_remove_count, fin.bm25_remove_count],
['N-gram signals', base.ngram_signal_count, fin.ngram_signal_count],
['Title BERT', nv(base.title_bert_score, '-'), nv(fin.title_bert_score, '-')],
['Semantic gaps', base.semantic_gap_count, fin.semantic_gap_count],
].map(r => `<tr><td>${r[0]}</td><td>${r[1]}</td><td>${r[2]}</td></tr>`).join('');
const iterRows = (data.iterations || []).map(it => {
const before = it.metrics_before ? it.metrics_before.score : '-';
const after = it.metrics_after ? it.metrics_after.score : '-';
const baseline = nv(it.current_score, before);
const reason = it.reason || ((it.status === 'rejected' && it.candidates) ? 'all candidates rejected by constraints' : '-');
const stage = (it.stage || (it.goal && it.goal.type) || '-');
const advanced = it.advanced_to_stage ? ` → ${it.advanced_to_stage}` : '';
return `<tr>
<td>${it.step}</td>
<td>${it.status}</td>
<td>${stage}${advanced}</td>
<td>${it.goal ? (it.goal.type + ': ' + (it.goal.label || '')) : '-'}</td>
<td>L${it.cascade_level || '-'} / ${it.operation || '-'}</td>
<td>${baseline}</td>
<td>${before}</td>
<td>${after}</td>
<td>${nv(it.delta_score, '-')}</td>
<td>${reason}</td>
</tr>`;
}).join('');
const iterationDebugHtml = (data.iterations || []).map(it => {
const candidates = Array.isArray(it.candidates) ? it.candidates : [];
const candidateRows = candidates.map(c => {
const reasons = Array.isArray(c.invalid_reasons) ? c.invalid_reasons.join(', ') : '';
const sentAfter = c.sentence_after ? safeHtml(c.sentence_after) : '-';
const strategy = c.phrase_strategy_used || (c.llm_prompt_debug && c.llm_prompt_debug.phrase_strategy_mode) || '-';
const relBefore = nv(c.chunk_relevance_before, '-');
const relAfter = nv(c.chunk_relevance_after, '-');
const termDiff = c.term_diff ? safeHtml(JSON.stringify(c.term_diff)) : '-';
const metricDelta = c.metrics_delta ? safeHtml(JSON.stringify(c.metrics_delta)) : '-';
const promptDbg = c.llm_prompt_debug ? safeHtml(JSON.stringify(c.llm_prompt_debug, null, 2)) : '-';
const rationale = c.llm_rationale ? safeHtml(c.llm_rationale) : '-';
return `
<tr>
<td>${nv(c.candidate_index, '-')}</td>
<td>${safeHtml(strategy)}</td>
<td>${c.valid ? 'yes' : 'no'}</td>
<td>${c.goal_improved ? 'yes' : 'no'}</td>
<td>${nv(c.bert_phrase_delta, '-')}</td>
<td>${c.local_chunk_improved ? 'yes' : 'no'}</td>
<td>${nv(c.chunk_goal_delta, '-')}</td>
<td>${relBefore}${relAfter}</td>
<td>${nv(c.delta_score, '-')}</td>
<td>${nv(c.candidate_score, '-')}</td>
<td>
<div>${safeHtml(reasons || c.error || '-')}</div>
<div class="small text-muted mt-1">term diff: ${termDiff}</div>
<div class="small text-muted mt-1">metric Δ: ${metricDelta}</div>
</td>
<td>
<div style="max-width: 520px; white-space: normal;">${sentAfter}</div>
<div class="small text-muted mt-1">rationale: ${rationale}</div>
<details class="mt-1">
<summary class="small">LLM input</summary>
<pre class="small mb-0" style="white-space: pre-wrap;">${promptDbg}</pre>
</details>
</td>
</tr>
`;
}).join('');
const sentBefore = it.sentence_before ? safeHtml(it.sentence_before) : '-';
const sentAfterChosen = it.sentence_after
? safeHtml(it.sentence_after)
: (it.best_candidate_sentence_after ? safeHtml(it.best_candidate_sentence_after) : '-');
return `
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-2">
<div><strong>Шаг ${it.step}</strong> — ${safeHtml(it.status || '-')}</div>
<span class="badge bg-secondary">${safeHtml(it.goal ? (it.goal.type + ': ' + (it.goal.label || '')) : '-')}</span>
</div>
<div class="small mb-2"><strong>Этап:</strong> ${safeHtml(it.stage || (it.goal ? it.goal.type : '-') || '-')}</div>
<div class="small mb-2"><strong>Каскад:</strong> L${safeHtml(it.cascade_level || '-')} / ${safeHtml(it.operation || '-')}</div>
<div class="small mb-2"><strong>Причина:</strong> ${safeHtml(it.reason || '-')}</div>
${it.escalated_to_level ? `<div class="small mb-2 text-warning"><strong>Эскалация:</strong> переход на L${safeHtml(it.escalated_to_level)}</div>` : ''}
${it.advanced_to_stage ? `<div class="small mb-2 text-info"><strong>Смена этапа:</strong> переход на ${safeHtml(it.advanced_to_stage)}</div>` : ''}
<div class="small mb-2"><strong>Исходное предложение:</strong><br><span class="text-muted">${sentBefore}</span></div>
<div class="small mb-2"><strong>Выбранный вариант:</strong><br><span class="text-muted">${sentAfterChosen}</span></div>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th>#cand</th><th>strategy</th><th>valid</th><th>goal+</th><th>bert Δ</th><th>local+</th><th>chunk Δ</th><th>rel b→a</th><th>Δ</th><th>score</th><th>reject reason/error</th><th>кандидат правки</th>
</tr>
</thead>
<tbody>${candidateRows || '<tr><td colspan="12" class="text-center text-muted">Нет кандидатов</td></tr>'}</tbody>
</table>
</div>
</div>
</div>`;
}).join('');
container.innerHTML = earlyBanner + titleBanner + diffCard + `
<div class="stat-card">
<h6 class="card-title">Результат оптимизации</h6>
<div class="small mb-2">Применено правок: <strong>${data.applied_changes || 0}</strong></div>
<div class="small mb-2">
Режим: <strong>${data.optimization_mode || 'balanced'}</strong>
· Phrase Strategy: <strong>${data.phrase_strategy_mode || 'auto'}</strong>
· BERT target A-stage: <strong>${nv(data.bert_stage_target, 0.7)}</strong>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light"><tr><th>Метрика</th><th>До</th><th>После</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
</div>
<div class="stat-card">
<h6 class="card-title">Лог итераций</h6>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead><tr><th>#</th><th>Статус</th><th>Этап</th><th>Цель</th><th>Каскад</th><th>Baseline шага</th><th>Score до</th><th>Score после</th><th>Δ</th><th>Причина/комментарий</th></tr></thead>
<tbody>${iterRows || '<tr><td colspan="10" class="text-muted text-center">Нет данных</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="stat-card">
<h6 class="card-title">Подробный debug-лог итераций</h6>
${iterationDebugHtml || '<div class="text-muted">Нет данных.</div>'}
</div>`;
}
function getOptimizedTitleFromResult(data) {
if (!data) return '';
var t = (data.optimized_title || '').trim();
if (t) return t;
var fm = data.final_metrics || {};
t = (fm.resolved_title || '').trim();
return t || '';
}
function applyOptimizedText() {
if (!optimizerData || !optimizerData.ok || !optimizerData.optimized_text) {
alert('Нет результата оптимизации для применения.');
return;
}
document.getElementById('targetText').value = optimizerData.optimized_text;
var ot = getOptimizedTitleFromResult(optimizerData);
var titleEl = document.getElementById('targetTitle');
if (titleEl && ot) {
titleEl.value = ot;
try {
titleEl.dispatchEvent(new Event('input', { bubbles: true }));
} catch (e) { /* ignore */ }
}
alert(
ot
? 'Подставлены Target и Title. Рекомендуется заново запустить анализ.'
: 'Оптимизированный текст подставлен в поле Target (Title не менялся или не найден в ответе). Рекомендуется заново запустить анализ.'
);
}
async function runLlmOptimization() {
if (!currentData) {
alert('Сначала выполните основной анализ текста.');
return;
}
const apiKey = (document.getElementById('optimizerApiKey').value || '').trim();
if (!apiKey) {
alert('Введите API key для LLM.');
return;
}
const diffMode = getOptimizerDiffModeValue();
let originalTargetText = null;
let originalTargetTitle = null;
if (diffMode === 'diff_from_original') {
const snap = ensureOptimizerOriginalSnapshot();
originalTargetText = snap.body;
originalTargetTitle = snap.title;
}
const stageCfg = _getOptimizerStageConfigFromUI();
if (!stageCfg.enabled_stages || stageCfg.enabled_stages.length === 0) {
alert('Выберите хотя бы одну стадию оптимизации.');
return;
}
const payload = {
target_text: document.getElementById('targetText').value || '',
competitors: collectCompetitorTexts(),
keywords: (document.getElementById('keywordsInput').value || '').split('\n').map(v => v.trim()).filter(Boolean),
language: document.getElementById('languageSelect').value || 'en',
target_title: document.getElementById('targetTitle').value || '',
competitor_titles: collectCompetitorTitles(),
api_key: apiKey,
api_base_url: (document.getElementById('optimizerBaseUrl').value || '').trim(),
model: (document.getElementById('optimizerModel').value || '').trim(),
max_iterations: Number(document.getElementById('optimizerIterations').value || 2),
candidates_per_iteration: Number(document.getElementById('optimizerCandidates').value || 2),
temperature: Number(document.getElementById('optimizerTemp').value || 0.25),
bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
optimization_mode: document.getElementById('optimizerMode').value || 'balanced',
phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto',
enabled_stages: stageCfg.enabled_stages,
stage_goal_overrides: stageCfg.stage_goal_overrides,
diff_mode: diffMode,
original_target_text: originalTargetText,
original_target_title: originalTargetTitle
};
const runBtn = document.getElementById('btnRunLlmOpt');
const stopBtn = document.getElementById('btnStopLlmOpt');
const panel = document.getElementById('optimizerProgressPanel');
optimizerStreamJobId = null;
if (runBtn) runBtn.disabled = true;
if (stopBtn) stopBtn.disabled = false;
if (panel) panel.classList.remove('d-none');
optimizerRunUiClear();
optimizerLogAppend('Соединение с сервером (SSE)…');
const t0 = Date.now();
const tick = setInterval(function () {
const el = document.getElementById('optimizerElapsed');
if (el) el.textContent = 'Прошло: ' + Math.floor((Date.now() - t0) / 1000) + ' с';
}, 1000);
try {
const response = await fetch('/api/v1/optimizer/run-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Ошибка сервера: ' + response.statusText);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalResult = null;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
for (;;) {
const sep = buffer.indexOf('\n\n');
if (sep < 0) break;
const raw = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const rawLines = raw.split('\n');
for (let li = 0; li < rawLines.length; li++) {
const line = rawLines[li];
if (line.indexOf('data: ') !== 0) continue;
let parsed;
try {
parsed = JSON.parse(line.slice(6));
} catch (e) {
continue;
}
if (parsed.job_id) optimizerStreamJobId = parsed.job_id;
if (parsed.event === 'complete') {
finalResult = parsed.result;
} else if (parsed.event === 'error') {
throw new Error(parsed.error || 'Ошибка оптимизатора');
} else {
applyOptimizerStreamEvent(parsed);
}
}
}
}
if (!finalResult) throw new Error('Поток завершился без результата');
optimizerData = finalResult;
renderOptimizerResults(optimizerData);
optimizerLogAppend('Готово.');
if (optimizerData.stopped_early) {
alert('Остановлено: показан частичный результат, можно «Применить в Target».');
}
} catch (error) {
alert('Ошибка LLM оптимизации: ' + error.message);
console.error(error);
} finally {
clearInterval(tick);
optimizerStreamJobId = null;
if (runBtn) runBtn.disabled = false;
if (stopBtn) stopBtn.disabled = true;
if (panel) panel.classList.add('d-none');
}
}
async function runSemanticSearch() {
const query = document.getElementById('semanticQueryInput').value;
const lang = document.getElementById('languageSelect').value;
const container = document.getElementById('semanticSearchResults');
const selectedDocKey = document.getElementById('semanticDocSelect').value;
if (!query.trim()) {
alert("Введите запрос для смыслового поиска.");
return;
}
if (!semanticData) {
alert("Сначала запустите Semantic Core.");
return;
}
let docText = "";
if (selectedDocKey === "target") {
docText = semanticData.target ? (semanticData.target.text || "") : "";
} else {
const compId = Number(selectedDocKey.replace("comp_", ""));
const compDoc = (semanticData.competitors || []).find(c => c.id === compId);
docText = compDoc ? (compDoc.text || "") : "";
}
if (!docText.trim()) {
alert("Не найден текст выбранного документа.");
return;
}
const payload = {
query_text: query,
text: docText,
language: lang,
top_n: 20
};
try {
const response = await fetch('/api/v1/semantic/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
const data = await response.json();
if (!container) return;
if (!data.results || data.results.length === 0) {
container.innerHTML = '<div class="text-muted small">Совпадений не найдено.</div>';
return;
}
const badges = data.results
.map(r => `<span class="badge bg-secondary me-1 mb-1">${r.lemma}: ${r.score}</span>`)
.join('');
container.innerHTML = badges;
} catch (error) {
alert("Ошибка semantic search: " + error.message);
console.error(error);
}
}
function setSemanticTermSortBy(value) {
semanticTermSortBy = value || 'target_weight';
if (semanticData) renderSemanticResults(semanticData);
}
function toggleSemanticTermSortDir() {
semanticTermSortDir = semanticTermSortDir === 'desc' ? 'asc' : 'desc';
if (semanticData) renderSemanticResults(semanticData);
}
function renderSemanticResults(data) {
const container = document.getElementById('semanticResultsContainer');
const docSelect = document.getElementById('semanticDocSelect');
if (!data || !data.target) {
container.innerHTML = '<div class="text-center text-muted py-5">Нет данных.</div>';
return;
}
// Обновляем список документов (target + competitors)
const prevValue = docSelect.value;
let optionsHtml = '<option value="target">Мой текст</option>';
(data.competitors || []).forEach((c, idx) => {
optionsHtml += `<option value="comp_${c.id}">K${idx + 1}: ${c.name}</option>`;
});
docSelect.innerHTML = optionsHtml;
if ([...docSelect.options].some(o => o.value === prevValue)) {
docSelect.value = prevValue;
}
// Выбираем документ для рендера
const selectedDocKey = docSelect.value || "target";
let docData = data.target;
if (selectedDocKey !== "target") {
const compId = Number(selectedDocKey.replace("comp_", ""));
const found = (data.competitors || []).find(c => c.id === compId);
if (found) docData = found;
}
const nodes = (docData.graph && docData.graph.nodes) ? docData.graph.nodes : [];
const links = (docData.graph && docData.graph.links) ? docData.graph.links : [];
const topKeywords = docData.top_keywords || [];
const summary = docData.summary || [];
const markup = docData.markup_text || [];
const topNodesRows = nodes
.slice()
.sort((a, b) => (b.weight || 0) - (a.weight || 0))
.slice(0, 20)
.map(n => `<tr><td>${n.label}</td><td class="text-center">${n.weight}</td><td class="text-center">${n.frequency}</td></tr>`)
.join('');
const topLinksRows = links
.slice()
.sort((a, b) => (b.weight || 0) - (a.weight || 0))
.slice(0, 30)
.map(l => `<tr><td>${l.source}</td><td>${l.target}</td><td class="text-center">${Number(l.weight).toFixed(1)}</td></tr>`)
.join('');
const keywordsHtml = topKeywords.length > 0
? topKeywords.map(k => `<span class="badge bg-dark me-1 mb-1">${k.lemma}: ${k.weight}</span>`).join('')
: '<span class="text-muted">Нет данных</span>';
const summaryHtml = summary.length > 0
? summary.map(s => `<li class="list-group-item"><span class="badge bg-primary me-2">${s.score}</span>${s.text}</li>`).join('')
: '<li class="list-group-item text-muted">Реферат пуст.</li>';
const markupHtml = markup.map(sent => {
const sentBlocks = (sent.blocks || []).map(b => {
if (b.is_link) {
const lemmas = (b.lemmas || []).join(", ");
return `<span class="text-success fw-bold text-decoration-underline" title="lemmas: ${lemmas}; weight: ${b.weight}">${b.text}</span>`;
}
return `<span>${b.text}</span>`;
}).join('');
return `<div class="mb-2">${sentBlocks}</div>`;
}).join('');
let compareHtml = '';
if (data.comparison) {
compareHtml = `
<div class="stat-card">
<h6 class="card-title">Сравнение с конкурентами</h6>
<div class="row">
<div class="col-md-4"><strong>Target nodes:</strong> ${data.comparison.target_nodes}</div>
<div class="col-md-4"><strong>Avg Comp nodes:</strong> ${data.comparison.avg_comp_nodes}</div>
<div class="col-md-4"><strong>Кол-во конкурентов:</strong> ${data.comparison.num_competitors}</div>
</div>
<div class="row mt-2">
<div class="col-md-4"><strong>Target links:</strong> ${data.comparison.target_links}</div>
<div class="col-md-4"><strong>Avg Comp links:</strong> ${data.comparison.avg_comp_links}</div>
<div class="col-md-4"><strong>Текущий документ:</strong> ${docData.name || '—'}</div>
</div>
</div>`;
}
// Сравнительная таблица весов терминов (слова + фразы)
const powerTable = data.comparison && data.comparison.term_power_table ? data.comparison.term_power_table : [];
const powerCompTotal = data.comparison ? (data.comparison.num_competitors || 0) : 0;
let powerCompHeaders = '';
for (let i = 0; i < powerCompTotal; i++) {
powerCompHeaders += `<th>K${i + 1}</th>`;
}
const sortedPowerTable = powerTable.slice().sort((a, b) => {
const dir = semanticTermSortDir === 'asc' ? 1 : -1;
if (semanticTermSortBy === 'target_weight') {
const diff = (Number(a.target_weight) || 0) - (Number(b.target_weight) || 0);
if (diff !== 0) return diff * dir;
} else if (semanticTermSortBy === 'competitor_avg_weight') {
const diff = (Number(a.competitor_avg_weight) || 0) - (Number(b.competitor_avg_weight) || 0);
if (diff !== 0) return diff * dir;
} else if (semanticTermSortBy === 'freq') {
const aOcc = Number(a.comp_occurrence) || 0;
const bOcc = Number(b.comp_occurrence) || 0;
const aTotal = Math.max(Number(a.comp_total) || 0, 1);
const bTotal = Math.max(Number(b.comp_total) || 0, 1);
const aRatio = aOcc / aTotal;
const bRatio = bOcc / bTotal;
const ratioDiff = aRatio - bRatio;
if (ratioDiff !== 0) return ratioDiff * dir;
const occDiff = aOcc - bOcc;
if (occDiff !== 0) return occDiff * dir;
}
// Предсказуемый тай-брейк: по алфавиту
return String(a.term || '').localeCompare(String(b.term || ''), 'ru', { sensitivity: 'base', numeric: true });
});
const powerRows = sortedPowerTable.map(item => {
let compWeightCells = '';
(item.competitor_weights || []).forEach(w => {
const cls = w > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25';
compWeightCells += `<td class="${cls}">${w}</td>`;
});
const freqText = powerCompTotal > 0
? `${item.comp_occurrence}<span class="text-muted small">/${item.comp_total}</span>`
: '-';
const termBadge = item.term_type === 'phrase'
? '<span class="badge bg-info text-dark ms-1">phrase</span>'
: '';
return `
<tr>
<td>${item.term}${termBadge}</td>
<td class="fw-bold">${item.target_weight}</td>
<td class="table-light fw-bold">${item.competitor_avg_weight}</td>
<td class="table-secondary fw-bold text-center">${freqText}</td>
${compWeightCells}
</tr>`;
}).join('');
container.innerHTML = `
${compareHtml}
<div class="stat-card">
<h6 class="card-title">Сравнительная таблица мощных терминов</h6>
<p class="text-muted small mb-2">Все термины анализируемых текстов (слова + фразы): вес в моем тексте, средний вес у конкурентов, и встречаемость по конкурентам (X/Y).</p>
<div class="d-flex gap-2 align-items-center mb-2">
<label for="semanticTermSortBy" class="small text-muted mb-0">Сортировка:</label>
<select id="semanticTermSortBy" class="form-select form-select-sm" style="max-width: 220px;" onchange="setSemanticTermSortBy(this.value)">
<option value="target_weight" ${semanticTermSortBy === 'target_weight' ? 'selected' : ''}>Мой вес</option>
<option value="competitor_avg_weight" ${semanticTermSortBy === 'competitor_avg_weight' ? 'selected' : ''}>Avg K</option>
<option value="freq" ${semanticTermSortBy === 'freq' ? 'selected' : ''}>Freq (X/Y)</option>
</select>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="toggleSemanticTermSortDir()">
${semanticTermSortDir === 'desc' ? 'По убыванию' : 'По возрастанию'}
</button>
</div>
<div class="scrollable-table">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Термин</th>
<th>Мой вес</th>
<th class="table-light">Avg K</th>
<th class="table-secondary">Freq</th>
${powerCompHeaders}
</tr>
</thead>
<tbody>
${powerRows || '<tr><td colspan="' + (4 + powerCompTotal) + '" class="text-center text-muted">Нет данных</td></tr>'}
</tbody>
</table>
</div>
</div>
<div class="stat-card">
<h6 class="card-title">Граф (сводка)</h6>
<div class="row">
<div class="col-md-4"><strong>Узлы:</strong> ${nodes.length}</div>
<div class="col-md-4"><strong>Связи:</strong> ${links.length}</div>
<div class="col-md-4"><strong>Ключевые понятия:</strong> ${topKeywords.length}</div>
</div>
</div>
<div class="stat-card">
<h6 class="card-title">Top Keywords</h6>
<div>${keywordsHtml}</div>
</div>
<div class="stat-card">
<h6 class="card-title">Окно 1: Узлы и связи (табличный режим)</h6>
<div class="row">
<div class="col-md-6">
<div class="scrollable-table">
<table class="table table-sm table-hover">
<thead><tr><th>Node</th><th class="text-center">Weight</th><th class="text-center">Freq</th></tr></thead>
<tbody>${topNodesRows || '<tr><td colspan="3" class="text-center text-muted">Нет данных</td></tr>'}</tbody>
</table>
</div>
</div>
<div class="col-md-6">
<div class="scrollable-table">
<table class="table table-sm table-hover">
<thead><tr><th>From</th><th>To</th><th class="text-center">P(B|A)</th></tr></thead>
<tbody>${topLinksRows || '<tr><td colspan="3" class="text-center text-muted">Нет данных</td></tr>'}</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="stat-card">
<h6 class="card-title">Окно 3: Разметка текста</h6>
<div class="border rounded p-3" style="line-height: 1.8;">${markupHtml || '<span class="text-muted">Нет данных</span>'}</div>
</div>
<div class="stat-card">
<h6 class="card-title">Реферат</h6>
<ul class="list-group">${summaryHtml}</ul>
</div>
`;
}
function renderActionSummary(analysisData, semData) {
const container = document.getElementById('summaryResultsContainer');
if (!container) return;
if (!analysisData) {
container.innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ (`Анализировать`), чтобы собрать сводку.</div>';
return;
}
const built = buildSummaryData(analysisData, semData);
const recommendations = built.recommendations;
const diagnostics = built.diagnostics;
const bertThreshold = built.bertThreshold;
const priorityRank = { high: 3, medium: 2, low: 1 };
const sortedRecs = recommendations
.slice()
.sort((a, b) => (priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0));
const recCards = sortedRecs.length > 0
? sortedRecs.map((r, idx) => {
const badge = r.priority === 'high'
? 'bg-danger'
: (r.priority === 'medium' ? 'bg-warning text-dark' : 'bg-secondary');
const detailsTableHtml = (r.details_table && Array.isArray(r.details_table.rows) && r.details_table.rows.length > 0)
? `
<div class="table-responsive mt-2">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>${(r.details_table.headers || []).map(h => `<th>${h}</th>`).join('')}</tr>
</thead>
<tbody>
${r.details_table.rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}
</tbody>
</table>
</div>`
: '';
const detailHtml = (r.details && r.details.length > 0)
? `<div class="small text-muted mt-2">${r.details.slice(0, 8).join(' • ')}</div>`
: '';
return `
<div class="card mb-3 border-0 shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<h6 class="mb-0">${idx + 1}. ${r.title}</h6>
<span class="badge ${badge}">${r.priority.toUpperCase()}</span>
</div>
<div class="small mb-1"><strong>Почему:</strong> ${r.why}</div>
<div class="small"><strong>Что сделать:</strong> ${r.action}</div>
${detailsTableHtml}
${detailHtml}
</div>
</div>`;
}).join('')
: '<div class="alert alert-success mb-0">Критичных сигналов не найдено. Поддерживайте текущий баланс.</div>';
const diagRows = diagnostics.map(d => {
const badge = d.status === 'ok'
? 'bg-success'
: (d.status === 'warning' ? 'bg-warning text-dark' : 'bg-secondary');
const label = d.status === 'ok' ? 'OK' : (d.status === 'warning' ? 'Внимание' : 'Ожидает');
return `<tr><td>${d.metric}</td><td>${d.value}</td><td><span class="badge ${badge}">${label}</span></td></tr>`;
}).join('');
container.innerHTML = `
<div class="stat-card">
<h5 class="card-title mb-3">Итоговые рекомендации (что сделать в первую очередь)</h5>
<p class="text-muted small mb-3">Сводка и авто-цели оптимизатора используют одни правила: BERT &lt; ${bertThreshold}, BM25 remove ≥ 4, n-граммы по допускам (±20% при Avg≥4, ±50% при Avg&lt;4) с фильтром K для multi-competitor, Title BERT &lt; ${SUMMARY_TITLE_BERT_THRESHOLD}, Semantic Core — разрыв весов (KW затем TOP), порог частоты K как в диагностике (1×1: K≥1, иначе K≥2).</p>
${recCards}
</div>
<div class="stat-card">
<h6 class="card-title mb-2">Диагностика по модулям</h6>
<table class="table table-sm table-hover mb-0">
<thead><tr><th>Модуль</th><th>Метрика</th><th>Статус</th></tr></thead>
<tbody>${diagRows}</tbody>
</table>
</div>
`;
}
function renderResults(data) {
// 0. General Stats Render (Word Count Dual Mode)
const statsContainer = document.getElementById('generalStats');
statsContainer.innerHTML = '';
if (data.word_counts) {
const myTotal = data.word_counts.target.total;
const mySig = data.word_counts.target.significant;
const avgTotal = data.word_counts.avg.total;
const avgSig = data.word_counts.avg.significant;
// Цвет зависит от ОБЩЕГО количества
let totalColor = 'text-dark';
if (avgTotal > 0) {
if (myTotal < avgTotal * 0.8) totalColor = 'text-danger';
else if (myTotal > avgTotal * 1.2) totalColor = 'text-success';
}
// HTML для конкурентов
let compsHtml = '';
data.word_counts.competitors.forEach((c, idx) => {
compsHtml += `
<div class="px-3 border-end text-center">
<small class="text-muted d-block mb-1">K${idx+1}</small>
<div class="fw-bold">${c.total}</div>
<div class="text-muted small" style="font-size: 0.75em;">(${c.significant})</div>
</div>`;
});
const html = `
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-3">
<div class="row align-items-center">
<!-- Мой результат -->
<div class="col-md-4 border-end">
<h6 class="text-uppercase text-muted small fw-bold mb-2">Мой текст</h6>
<div class="d-flex align-items-baseline">
<span class="display-6 fw-bold ${totalColor} me-2">${myTotal}</span>
<span class="text-muted">слов</span>
</div>
<div class="text-muted small">
Значимых: <strong>${mySig}</strong>
</div>
</div>
<!-- Среднее -->
<div class="col-md-3 border-end text-center">
<h6 class="text-uppercase text-muted small fw-bold mb-2">Среднее (Рынок)</h6>
<div class="h3 fw-bold mb-0">${avgTotal}</div>
<div class="text-muted small">Значимых: ${avgSig}</div>
</div>
<!-- Конкуренты -->
<div class="col-md-5">
<h6 class="text-uppercase text-muted small fw-bold mb-2 text-center">Детализация</h6>
<div class="d-flex justify-content-center align-items-center">
${compsHtml}
</div>
</div>
</div>
</div>
</div>`;
statsContainer.innerHTML = html;
}
// 1. BERT Render (ИСПРАВЛЕННЫЙ ПОД НОВУЮ СТРУКТУРУ)
const bertContainer = document.getElementById('bertResultsContainer');
bertContainer.innerHTML = '';
// Получаем объект данных. Теперь это объект, а не массив!
const bertData = data.bert_analysis;
// Проверяем, есть ли поле detailed (список фраз)
if (!bertData || !bertData.detailed || bertData.detailed.length === 0) {
bertContainer.innerHTML = '<div class="alert alert-warning">Добавьте ключевые фразы для анализа.</div>';
} else {
// А. Рендерим ГЛОБАЛЬНЫЙ СЧЕТ (Global Score)
if (bertData.global_scores && bertData.global_scores.length > 0) {
let globalHtml = '<div class="card mb-4 border-primary"><div class="card-body">';
globalHtml += '<h6 class="card-title text-primary fw-bold mb-3">🏆 Общий рейтинг релевантности (Global Score)</h6>';
bertData.global_scores.forEach(gs => {
const scorePct = Math.round(gs.score * 100);
const isMe = gs.is_me;
const barColor = isMe ? 'bg-primary' : 'bg-secondary';
const rowBg = isMe ? 'bg-light border-start border-primary border-3' : '';
const nameLabel = isMe ? `<strong>${gs.name} (Вы)</strong>` : gs.name;
globalHtml += `
<div class="d-flex align-items-center mb-2 p-2 rounded ${rowBg}">
<div style="width: 150px;">${nameLabel}</div>
<div class="flex-grow-1 mx-3">
<div class="progress" style="height: 20px;">
<div class="progress-bar ${barColor}" role="progressbar" style="width: ${scorePct}%">${scorePct}%</div>
</div>
</div>
<div class="fw-bold">${gs.score}</div>
</div>`;
});
globalHtml += '</div></div>';
bertContainer.insertAdjacentHTML('beforeend', globalHtml);
}
// Б. Рендерим ДЕТАЛИЗАЦИЮ (Аккордеоны)
// Итерируемся именно по .detailed, так как это массив
bertData.detailed.forEach((item, index) => {
let badgeClass = 'bg-secondary';
if(item.status === 'good') badgeClass = 'bg-success';
if(item.status === 'warning') badgeClass = 'bg-warning text-dark';
if(item.status === 'bad') badgeClass = 'bg-danger';
const collapseId = `collapseBert${index}`;
// Мои чанки
const myChunksHtml = item.my_top_chunks.map(c =>
`<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom">
<div class="small me-2">"${c.text}"</div>
<span class="badge bg-primary rounded-pill opacity-75">${c.score}</span>
</li>`
).join('');
// Чанки конкурентов (С АТРИБУЦИЕЙ ИСТОЧНИКА)
const compChunksHtml = (item.comp_top_chunks && item.comp_top_chunks.length > 0)
? item.comp_top_chunks.map(c =>
`<li class="list-group-item d-flex justify-content-between align-items-start border-0 border-bottom list-group-item-light">
<div class="me-2">
<span class="badge bg-secondary mb-1" style="font-size: 0.7em;">${c.source}</span>
<div class="small text-muted">"${c.text}"</div>
</div>
<span class="badge bg-dark rounded-pill opacity-50">${c.score}</span>
</li>`
).join('')
: '<li class="list-group-item text-muted small border-0">Нет данных</li>';
const html = `
<div class="card mb-3 border">
<div class="card-header bg-white d-flex justify-content-between align-items-center" style="cursor: pointer;" data-bs-toggle="collapse" data-bs-target="#${collapseId}">
<div>
<div class="fw-bold text-dark">${item.phrase}</div>
<div class="text-muted small">
My: <b>${item.my_max_score}</b> vs Best Comp: <b>${item.comp_max_score}</b>
</div>
</div>
<span class="badge ${badgeClass}">${item.status.toUpperCase()}</span>
</div>
<div id="${collapseId}" class="collapse">
<div class="card-body bg-light">
<p class="small mb-3"><strong>Совет:</strong> ${item.recommendation}</p>
<div class="row">
<div class="col-md-6">
<h6 class="small fw-bold text-primary">Мой текст</h6>
<ul class="list-group shadow-sm mb-3">${myChunksHtml || '<li class="list-group-item small">Нет вхождений</li>'}</ul>
</div>
<div class="col-md-6 border-start">
<h6 class="small fw-bold text-secondary">Лучшее у конкурентов</h6>
<ul class="list-group shadow-sm">${compChunksHtml}</ul>
</div>
</div>
</div>
</div>
</div>`;
bertContainer.insertAdjacentHTML('beforeend', html);
});
}
// 2. BM25 Render (ОБНОВЛЕННЫЙ v2 - Полный список)
const bm25Body = document.getElementById('bm25TableBody');
bm25Body.innerHTML = '';
const bm25Msg = document.getElementById('bm25EmptyMsg');
// Теперь мы ожидаем, что список не пуст, если были введены ключи
if (data.bm25_recommendations && data.bm25_recommendations.length > 0) {
bm25Msg.style.display = 'none';
data.bm25_recommendations.forEach(item => {
let colorClass = '';
let actionText = '';
let countText = '';
let rowBg = '';
// Определяем стили в зависимости от действия
if (item.action === 'add') {
colorClass = 'text-success';
actionText = 'ДОБАВИТЬ';
countText = `+${item.count}`;
rowBg = 'table-success'; // Легкая зеленая подсветка всей строки (Bootstrap класс)
// Но лучше не красить всю строку, чтобы не рябило, покрасим только текст действия
rowBg = '';
} else if (item.action === 'remove') {
colorClass = 'text-danger';
actionText = 'УБРАТЬ';
countText = `-${item.count}`;
} else {
colorClass = 'text-muted'; // Серый цвет
actionText = 'НОРМА'; // Или OK
countText = '<span class="text-muted">-</span>';
}
// Жирный шрифт для важных действий
const weight = item.action === 'ok' ? 'fw-normal' : 'fw-bold';
const row = `
<tr>
<td class="fw-bold text-dark">${item.word}</td>
<td class="${colorClass} ${weight}">${actionText}</td>
<td class="${colorClass} ${weight}">${countText}</td>
<td>${item.my_score}</td>
<td>${item.avg_comp_score}</td>
</tr>`;
bm25Body.insertAdjacentHTML('beforeend', row);
});
} else {
// Если список пуст (например, не ввели ключевые слова)
bm25Msg.style.display = 'block';
bm25Msg.textContent = "Введите ключевые фразы для расчета BM25.";
}
// 3. N-grams
showNgramTable('unigrams');
// 4. Title
renderTitleResults(data);
// 5. Сводка
renderActionSummary(data, semanticData);
}
function showNgramTable(type) {
if(!currentData) return;
document.querySelectorAll('#ngrams .btn').forEach(b => {
if(type === 'unigrams' && b.innerText.includes('1')) b.classList.add('active');
else if(type === 'bigrams' && b.innerText.includes('2')) b.classList.add('active');
else if(type === 'trigrams' && b.innerText.includes('3')) b.classList.add('active');
else b.classList.remove('active');
});
const table = document.querySelector('#ngrams table');
const thead = table.querySelector('thead');
const tbody = document.getElementById('ngramTableBody');
tbody.innerHTML = '';
thead.innerHTML = '';
const list = currentData.ngram_stats[type];
let numCompetitors = 0;
if (list && list.length > 0 && list[0].competitor_detailed) {
numCompetitors = list[0].competitor_detailed.length;
}
// ЗАГОЛОВОК
let headerRow = '<tr>';
headerRow += '<th style="width: 35%;">Фраза</th>';
headerRow += '<th>Target</th>';
headerRow += '<th class="table-light">AVG</th>';
// НОВАЯ КОЛОНКА
headerRow += '<th class="table-secondary" title="Встречаемость у конкурентов">Freq</th>';
for (let i = 0; i < numCompetitors; i++) {
headerRow += `<th>K${i + 1}</th>`;
}
headerRow += '</tr>';
thead.innerHTML = headerRow;
// ТЕЛО
if(list && list.length > 0) {
list.forEach(item => {
let rowClass = "";
let countClass = "";
let icon = "";
if (item.target_count === 0 && item.competitor_avg > 0) {
rowClass = "table-warning";
countClass = "text-danger fw-bold";
icon = "⚠️";
} else if (item.target_count > 0 && item.competitor_avg === 0) {
countClass = "text-success";
}
let compCells = "";
if (item.competitor_detailed) {
item.competitor_detailed.forEach(count => {
const style = count > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25';
compCells += `<td class="${style}">${count}</td>`;
});
}
// ЗНАЧЕНИЕ ДЛЯ НОВОЙ КОЛОНКИ (Например: "3/5")
const freqText = numCompetitors > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numCompetitors}</span>` : '-';
const row = `
<tr class="${rowClass}">
<td>${item.ngram} ${icon}</td>
<td class="fw-bold ${countClass} border-end">${item.target_count}</td>
<td class="table-light fw-bold border-end">${item.competitor_avg}</td>
<!-- НОВАЯ ЯЧЕЙКА -->
<td class="table-secondary fw-bold text-center border-end">${freqText}</td>
${compCells}
</tr>`;
tbody.insertAdjacentHTML('beforeend', row);
});
} else {
tbody.innerHTML = `<tr><td colspan="${4 + numCompetitors}" class="text-center text-muted">Нет данных</td></tr>`;
}
}
function renderTitleResults(data) {
const container = document.getElementById('titleResultsContainer');
if (!data.title_analysis || !data.title_analysis.target_title) {
container.innerHTML = '<div class="text-center text-muted py-5">Заполните поле "Ваш Title" и нажмите "Анализировать".</div>';
return;
}
const ta = data.title_analysis;
let html = '';
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escapeRegex = (value) => String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const keywordTerms = (() => {
const terms = new Set();
(ta.keyword_coverage || []).forEach(kw => {
const phrase = String(kw.phrase || '').trim();
if (!phrase) return;
terms.add(phrase.toLowerCase());
phrase.split(/\s+/).forEach(w => {
const word = w.trim().toLowerCase();
if (word.length >= 2) terms.add(word);
});
});
return Array.from(terms).sort((a, b) => b.length - a.length);
})();
const highlightTitleKeywords = (titleText) => {
const source = String(titleText || '');
if (!source) return '';
if (!keywordTerms.length) return escapeHtml(source);
const pattern = new RegExp(`(${keywordTerms.map(escapeRegex).join('|')})`, 'gi');
let out = '';
let last = 0;
let match;
while ((match = pattern.exec(source)) !== null) {
const start = match.index;
const value = match[0];
out += escapeHtml(source.slice(last, start));
out += `<mark class="px-1 rounded bg-warning-subtle border border-warning-subtle">${escapeHtml(value)}</mark>`;
last = start + value.length;
if (value.length === 0) pattern.lastIndex += 1;
}
out += escapeHtml(source.slice(last));
return out;
};
// === BLOCK 1: Length ===
const len = ta.length;
let charBadge = 'bg-success';
let charHint = 'Оптимально';
if (len.status === 'too_long') { charBadge = 'bg-danger'; charHint = 'Слишком длинный (>60)'; }
else if (len.status === 'too_short') { charBadge = 'bg-warning text-dark'; charHint = 'Слишком короткий (<30)'; }
let compLenRows = '';
// Показываем и ваш Title, и конкурентов в одной таблице.
compLenRows += `
<tr class="table-light">
<td class="fw-bold">Мой Title</td>
<td title="${escapeHtml(ta.target_title)}">${highlightTitleKeywords(ta.target_title)}</td>
<td class="text-center fw-bold">${len.target_chars}</td>
<td class="text-center fw-bold">${len.target_words.total}</td>
</tr>`;
if (ta.competitor_titles && ta.competitor_titles.length > 0 && len.comp_data) {
ta.competitor_titles.forEach((ct, idx) => {
const cd = len.comp_data[idx];
if (!cd) return;
compLenRows += `
<tr>
<td class="fw-bold">K${idx+1}</td>
<td class="text-truncate" style="max-width: 420px;" title="${escapeHtml(ct)}">${highlightTitleKeywords(ct)}</td>
<td class="text-center">${cd.chars}</td>
<td class="text-center">${cd.words.total}</td>
</tr>`;
});
}
html += `
<div class="stat-card">
<h5 class="card-title mb-3">Длина Title</h5>
<div class="d-flex align-items-center mb-3">
<span class="display-6 fw-bold me-3">${len.target_chars}</span>
<div>
<span class="badge ${charBadge}">${charHint}</span>
<div class="text-muted small mt-1">Слов: ${len.target_words.total} (значимых: ${len.target_words.significant})</div>
</div>
</div>
<table class="table table-sm table-bordered">
<thead><tr><th style="width:120px;">Источник</th><th>Title</th><th class="text-center">Символы</th><th class="text-center">Слова</th></tr></thead>
<tbody>${compLenRows}</tbody>
${ta.competitor_titles && ta.competitor_titles.length > 0 ? `<tfoot><tr class="table-secondary fw-bold"><td colspan="2">Среднее по конкурентам</td><td class="text-center">${len.avg_chars}</td><td class="text-center">${len.avg_words_total}</td></tr></tfoot>` : ''}
</table>
<div class="small text-muted mt-2">Подсветка в Title показывает найденные слова и фразы из блока ключевых фраз.</div>
</div>`;
// === BLOCK 2: Keyword Coverage ===
if (ta.keyword_coverage && ta.keyword_coverage.length > 0) {
let coverageRows = '';
ta.keyword_coverage.forEach(kw => {
let badge = 'bg-danger';
let label = 'Нет';
if (kw.exact_match) { badge = 'bg-success'; label = 'Точное'; }
else if (kw.word_coverage >= 50) { badge = 'bg-warning text-dark'; label = 'Частично'; }
const missingHtml = kw.words_missing.length > 0
? `<span class="text-danger small">${kw.words_missing.join(', ')}</span>`
: '<span class="text-success small">-</span>';
const compPresence = kw.comp_total > 0 ? `${kw.comp_presence}/${kw.comp_total}` : '-';
coverageRows += `
<tr>
<td class="fw-bold">${kw.phrase}</td>
<td class="text-center"><span class="badge ${badge}">${label}</span></td>
<td class="text-center">${kw.word_coverage}%</td>
<td>${missingHtml}</td>
<td class="text-center">${compPresence}</td>
</tr>`;
});
html += `
<div class="stat-card">
<h5 class="card-title mb-3">Покрытие ключевых фраз</h5>
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Фраза</th>
<th class="text-center">Вхождение</th>
<th class="text-center">Покрытие</th>
<th>Отсутствуют</th>
<th class="text-center">У конк.</th>
</tr>
</thead>
<tbody>${coverageRows}</tbody>
</table>
</div>`;
}
// === BLOCK 3: Title N-grams ===
if (ta.ngrams) {
['unigrams', 'bigrams'].forEach(type => {
const list = ta.ngrams[type];
if (!list || list.length === 0) return;
const typeName = type === 'unigrams' ? '1 слово' : '2 слова';
let numComp = 0;
if (list[0].competitor_detailed) numComp = list[0].competitor_detailed.length;
let tHead = '<tr><th>Слово</th><th>Target</th><th class="table-light">AVG</th><th class="table-secondary">Freq</th>';
for (let i = 0; i < numComp; i++) tHead += `<th>K${i+1}</th>`;
tHead += '</tr>';
let tBody = '';
list.forEach(item => {
let rc = '';
let cc = '';
if (item.target_count === 0 && item.competitor_avg > 0) { rc = 'table-warning'; cc = 'text-danger fw-bold'; }
else if (item.target_count > 0 && item.competitor_avg === 0) { cc = 'text-success'; }
let cells = '';
if (item.competitor_detailed) {
item.competitor_detailed.forEach(cnt => {
const s = cnt > 0 ? 'fw-bold text-dark' : 'text-muted text-opacity-25';
cells += `<td class="${s}">${cnt}</td>`;
});
}
const freq = numComp > 0 ? `${item.comp_occurrence}<span class="text-muted small">/${numComp}</span>` : '-';
tBody += `
<tr class="${rc}">
<td>${item.ngram}</td>
<td class="fw-bold ${cc} border-end">${item.target_count}</td>
<td class="table-light fw-bold border-end">${item.competitor_avg}</td>
<td class="table-secondary fw-bold text-center border-end">${freq}</td>
${cells}
</tr>`;
});
html += `
<div class="stat-card">
<h6 class="card-title">N-граммы Title (${typeName})</h6>
<div class="scrollable-table">
<table class="table table-sm">
<thead>${tHead}</thead>
<tbody>${tBody}</tbody>
</table>
</div>
</div>`;
});
}
// === BLOCK 4: BERT Semantic ===
if (ta.bert && ta.bert.per_keyword && ta.bert.per_keyword.length > 0) {
let bertRows = '';
ta.bert.per_keyword.forEach(kw => {
const compScores = kw.comp_scores.map((s, i) => `<td>${s}</td>`).join('');
const best = Math.max(...kw.comp_scores, 0);
let cls = '';
if (kw.target_score >= best - 0.05) cls = 'text-success';
else if (kw.target_score < best - 0.15) cls = 'text-danger';
else cls = 'text-warning';
bertRows += `
<tr>
<td class="fw-bold">${kw.keyword}</td>
<td class="fw-bold ${cls} border-end">${kw.target_score}</td>
${compScores}
</tr>`;
});
let compHeaders = '';
if (ta.bert.comp_scores) {
ta.bert.comp_scores.forEach((s, i) => { compHeaders += `<th>K${i+1}</th>`; });
}
html += `
<div class="stat-card">
<h5 class="card-title mb-3">Семантика Title (BERT)</h5>
<div class="d-flex align-items-baseline mb-3">
<span class="h4 fw-bold me-2">Мой: ${ta.bert.target_score}</span>
${ta.bert.comp_scores ? ta.bert.comp_scores.map((s,i) => `<span class="badge bg-secondary me-1">K${i+1}: ${s}</span>`).join('') : ''}
</div>
<table class="table table-sm table-hover">
<thead><tr><th>Ключ</th><th class="border-end">Target</th>${compHeaders}</tr></thead>
<tbody>${bertRows}</tbody>
</table>
</div>`;
}
container.innerHTML = html;
}
const _optimizerBertTargetEl = document.getElementById('optimizerBertStageTarget');
if (_optimizerBertTargetEl) {
_optimizerBertTargetEl.addEventListener('input', function () {
if (currentData) {
renderActionSummary(currentData, semanticData);
refreshOptimizerStageGoalConfig();
}
});
}
loadUserAgentOptions();
refreshOptimizerStageGoalConfig({
bert: { mode: 'auto', selected: [], custom_add: [] },
bm25: { mode: 'auto', selected: [], custom_add: [] },
ngram: { mode: 'auto', selected: [], custom_add: [] },
semantic: { mode: 'auto', selected: [], custom_add: [] },
title: { mode: 'auto', selected: [], custom_add: [] }
});