lsdf commited on
Commit
9e6afbe
·
1 Parent(s): 0a34fa4

feat(optimizer): add diff highlighting

Browse files

Expose diff_mode + original snapshot in API, compute sentence-level diff on backend, and render/persist diff view in UI.

Made-with: Cursor

Files changed (4) hide show
  1. models.py +13 -0
  2. optimizer.py +98 -0
  3. static/js/app.js +154 -6
  4. templates/index.html +17 -0
models.py CHANGED
@@ -82,6 +82,12 @@ class OptimizerRequest(BaseModel):
82
  language: str = "en"
83
  target_title: str = ""
84
  competitor_titles: List[str] = Field(default_factory=list)
 
 
 
 
 
 
85
 
86
  api_key: str
87
  api_base_url: str = "https://api.deepseek.com/v1"
@@ -106,6 +112,13 @@ class OptimizerResponse(BaseModel):
106
  optimization_mode: str = "balanced"
107
  phrase_strategy_mode: str = "auto"
108
  bert_stage_target: float = 0.70
 
 
 
 
 
 
 
109
  error: str = ""
110
  stopped_early: bool = False
111
  stop_reason: str = ""
 
82
  language: str = "en"
83
  target_title: str = ""
84
  competitor_titles: List[str] = Field(default_factory=list)
85
+ # Base for highlighting what changed in this optimization run.
86
+ # - diff_from_input: compare with `target_text` passed in this request (snapshot before optimization)
87
+ # - diff_from_original: compare with `original_target_text` from the first snapshot in session
88
+ diff_mode: str = "diff_from_input" # diff_from_input | diff_from_original
89
+ original_target_text: Optional[str] = None
90
+ original_target_title: Optional[str] = None
91
 
92
  api_key: str
93
  api_base_url: str = "https://api.deepseek.com/v1"
 
112
  optimization_mode: str = "balanced"
113
  phrase_strategy_mode: str = "auto"
114
  bert_stage_target: float = 0.70
115
+ diff_mode: str = ""
116
+ # HTML with <mark class="diff-changed"> around changed parts.
117
+ diff_body_html: str = ""
118
+ diff_title_html: str = ""
119
+ # List of (type/from/to) blocks for "что именно поменять".
120
+ diff_changes: List[Dict[str, str]] = Field(default_factory=list)
121
+ diff_title_changes: List[Dict[str, str]] = Field(default_factory=list)
122
  error: str = ""
123
  stopped_early: bool = False
124
  stop_reason: str = ""
optimizer.py CHANGED
@@ -1,4 +1,6 @@
1
  import json
 
 
2
  import re
3
  from itertools import combinations
4
  from typing import Any, Dict, List, Optional, Tuple
@@ -53,6 +55,69 @@ def _split_sentences(text: str) -> List[str]:
53
  return parts
54
 
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  def _max_sentences_for_level(cascade_level: int, operation: str) -> int:
57
  if operation == "insert":
58
  return 2
@@ -1585,6 +1650,18 @@ def optimize_text(
1585
  target_title = str(request_data.get("target_title", "") or "")
1586
  competitor_titles = [str(x) for x in (request_data.get("competitor_titles") or [])]
1587
 
 
 
 
 
 
 
 
 
 
 
 
 
1588
  api_key = str(request_data.get("api_key", "")).strip()
1589
  if not api_key:
1590
  raise ValueError("API key is required.")
@@ -1663,6 +1740,22 @@ def optimize_text(
1663
  _ot = (current_title or "").strip()
1664
  if not _ot:
1665
  _ot = (target_title or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1666
  return {
1667
  "ok": True,
1668
  "optimized_text": current_text,
@@ -1674,6 +1767,11 @@ def optimize_text(
1674
  "optimization_mode": optimization_mode,
1675
  "phrase_strategy_mode": phrase_strategy_mode,
1676
  "bert_stage_target": round(bert_stage_target, 4),
 
 
 
 
 
1677
  "stopped_early": stopped_early,
1678
  "stop_reason": stop_reason,
1679
  }
 
1
  import json
2
+ import difflib
3
+ import html as html_lib
4
  import re
5
  from itertools import combinations
6
  from typing import Any, Dict, List, Optional, Tuple
 
55
  return parts
56
 
57
 
58
+ def _escape_html(v: Any) -> str:
59
+ return html_lib.escape(str(v or ""), quote=True)
60
+
61
+
62
+ def _diff_sentences_html(before_text: str, after_text: str) -> Tuple[str, List[Dict[str, str]]]:
63
+ """
64
+ sentence-level diff for UI highlighting.
65
+ Возвращает:
66
+ - html для отображения ТОЛЬКО after_text (optimized),
67
+ - список блоков что было/что стало (для "что именно менять").
68
+ """
69
+ before_sents = _split_sentences(before_text)
70
+ after_sents = _split_sentences(after_text)
71
+
72
+ matcher = difflib.SequenceMatcher(None, before_sents, after_sents, autojunk=False)
73
+ parts: List[str] = []
74
+ changes: List[Dict[str, str]] = []
75
+
76
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
77
+ if tag == "equal":
78
+ for j in range(j1, j2):
79
+ parts.append(_escape_html(after_sents[j]))
80
+ elif tag == "replace":
81
+ from_txt = " ".join(before_sents[i1:i2]).strip()
82
+ to_txt = " ".join(after_sents[j1:j2]).strip()
83
+ changes.append({"type": "replace", "from": from_txt, "to": to_txt})
84
+ for j in range(j1, j2):
85
+ parts.append(
86
+ f'<mark class="diff-changed" data-diff-kind="replace">{_escape_html(after_sents[j])}</mark>'
87
+ )
88
+ elif tag == "insert":
89
+ to_txt = " ".join(after_sents[j1:j2]).strip()
90
+ changes.append({"type": "insert", "from": "", "to": to_txt})
91
+ for j in range(j1, j2):
92
+ parts.append(
93
+ f'<mark class="diff-changed" data-diff-kind="insert">{_escape_html(after_sents[j])}</mark>'
94
+ )
95
+ elif tag == "delete":
96
+ from_txt = " ".join(before_sents[i1:i2]).strip()
97
+ changes.append({"type": "delete", "from": from_txt, "to": ""})
98
+ else:
99
+ # Defensive: should not happen.
100
+ for j in range(j1, j2):
101
+ parts.append(_escape_html(after_sents[j]))
102
+
103
+ diff_html = " ".join(parts).strip()
104
+ return diff_html, changes
105
+
106
+
107
+ def _diff_title_html(before_title: str, after_title: str) -> Tuple[str, List[Dict[str, str]]]:
108
+ before_t = (before_title or "").strip()
109
+ after_t = (after_title or "").strip()
110
+ if before_t == after_t:
111
+ return "", []
112
+ if not after_t:
113
+ # Title removed: show nothing in UI, but keep "from/to" for debug.
114
+ return "", [{"type": "delete", "from": before_t, "to": ""}]
115
+ return (
116
+ f'<mark class="diff-changed" data-diff-kind="replace">{_escape_html(after_t)}</mark>',
117
+ [{"type": "replace", "from": before_t, "to": after_t}],
118
+ )
119
+
120
+
121
  def _max_sentences_for_level(cascade_level: int, operation: str) -> int:
122
  if operation == "insert":
123
  return 2
 
1650
  target_title = str(request_data.get("target_title", "") or "")
1651
  competitor_titles = [str(x) for x in (request_data.get("competitor_titles") or [])]
1652
 
1653
+ diff_mode_used = str(request_data.get("diff_mode", "diff_from_input") or "diff_from_input").strip().lower()
1654
+ if diff_mode_used not in {"diff_from_input", "diff_from_original"}:
1655
+ diff_mode_used = "diff_from_input"
1656
+ diff_base_text = target_text
1657
+ diff_base_title = target_title
1658
+ if diff_mode_used == "diff_from_original":
1659
+ diff_base_text = str(request_data.get("original_target_text") or target_text or "").strip()
1660
+ diff_base_title = str(request_data.get("original_target_title") or target_title or "").strip()
1661
+ else:
1662
+ diff_base_text = (target_text or "").strip()
1663
+ diff_base_title = (target_title or "").strip()
1664
+
1665
  api_key = str(request_data.get("api_key", "")).strip()
1666
  if not api_key:
1667
  raise ValueError("API key is required.")
 
1740
  _ot = (current_title or "").strip()
1741
  if not _ot:
1742
  _ot = (target_title or "").strip()
1743
+
1744
+ diff_body_html = ""
1745
+ diff_changes: List[Dict[str, str]] = []
1746
+ if (diff_base_text or "").strip() != (current_text or "").strip():
1747
+ dh, dc = _diff_sentences_html(diff_base_text or "", current_text or "")
1748
+ if dc:
1749
+ diff_body_html = dh
1750
+ diff_changes = dc
1751
+
1752
+ diff_title_html = ""
1753
+ diff_title_changes: List[Dict[str, str]] = []
1754
+ if (diff_base_title or "").strip() != (_ot or "").strip():
1755
+ dth, dtc = _diff_title_html(diff_base_title or "", _ot or "")
1756
+ if dtc:
1757
+ diff_title_html = dth
1758
+ diff_title_changes = dtc
1759
  return {
1760
  "ok": True,
1761
  "optimized_text": current_text,
 
1767
  "optimization_mode": optimization_mode,
1768
  "phrase_strategy_mode": phrase_strategy_mode,
1769
  "bert_stage_target": round(bert_stage_target, 4),
1770
+ "diff_mode": diff_mode_used,
1771
+ "diff_body_html": diff_body_html,
1772
+ "diff_title_html": diff_title_html,
1773
+ "diff_changes": diff_changes,
1774
+ "diff_title_changes": diff_title_changes,
1775
  "stopped_early": stopped_early,
1776
  "stop_reason": stop_reason,
1777
  }
static/js/app.js CHANGED
@@ -4,7 +4,7 @@
4
  let semanticTermSortBy = 'target_weight';
5
  let semanticTermSortDir = 'desc';
6
  let availableUserAgents = [];
7
- const PROJECT_SCHEMA_VERSION = 1;
8
 
9
  /** Без операторов ?? / ?. — совместимость с SES lockdown на Hugging Face Spaces */
10
  function nv(v, d) {
@@ -13,6 +13,63 @@
13
 
14
  let optimizerStreamJobId = null;
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  function optimizerLogAppend(line) {
17
  const el = document.getElementById('optimizerRunLog');
18
  if (!el) return;
@@ -266,6 +323,13 @@
266
  }
267
 
268
  function saveProject() {
 
 
 
 
 
 
 
269
  const projectData = {
270
  app: "seo-ai-editor",
271
  schema_version: PROJECT_SCHEMA_VERSION,
@@ -275,10 +339,10 @@
275
  target_url: document.getElementById('targetUrlInput').value,
276
  competitor_urls: document.getElementById('competitorUrlsInput').value,
277
  fetch_user_agent: document.getElementById('urlUserAgentSelect').value,
278
- target_text: document.getElementById('targetText').value,
279
  keywords: document.getElementById('keywordsInput').value,
280
  competitors: collectCompetitorTexts(),
281
- target_title: document.getElementById('targetTitle').value,
282
  competitor_titles: collectCompetitorTitles(),
283
  semantic_threshold: Number(document.getElementById('semanticThreshold').value || 50),
284
  semantic_compression: Number(document.getElementById('semanticCompression').value || 0.1),
@@ -290,7 +354,12 @@
290
  optimizer_temperature: Number(document.getElementById('optimizerTemp').value || 0.25),
291
  optimizer_bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
292
  optimizer_mode: document.getElementById('optimizerMode').value,
293
- optimizer_phrase_strategy: document.getElementById('optimizerPhraseStrategy').value
 
 
 
 
 
294
  },
295
  state: {
296
  analysis_result: currentData,
@@ -343,6 +412,8 @@
343
  document.getElementById('optimizerBertStageTarget').value = 0.70;
344
  document.getElementById('optimizerMode').value = 'balanced';
345
  document.getElementById('optimizerPhraseStrategy').value = 'auto';
 
 
346
 
347
  // Competitor text fields
348
  const competitorsList = document.getElementById('competitorsList');
@@ -370,6 +441,14 @@
370
  document.getElementById('semanticResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Нажмите "Запустить Semantic Core", чтобы построить граф и разметку.</div>';
371
  document.getElementById('summaryResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите анализ, чтобы увидеть итоговые рекомендации.</div>';
372
  document.getElementById('optimizerResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>';
 
 
 
 
 
 
 
 
373
  }
374
 
375
  function applyProjectData(project) {
@@ -399,6 +478,27 @@
399
  document.getElementById('optimizerMode').value = inp.optimizer_mode || 'balanced';
400
  document.getElementById('optimizerPhraseStrategy').value = inp.optimizer_phrase_strategy || 'auto';
401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  // Title character counter refresh
403
  const titleLen = (inp.target_title || '').length;
404
  const counter = document.getElementById('titleCharCount');
@@ -579,6 +679,42 @@
579
  ? `<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>`
580
  : '';
581
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
582
  const base = data.baseline_metrics || {};
583
  const fin = data.final_metrics || {};
584
  const rows = [
@@ -685,7 +821,7 @@
685
  </div>`;
686
  }).join('');
687
 
688
- container.innerHTML = earlyBanner + titleBanner + `
689
  <div class="stat-card">
690
  <h6 class="card-title">Результат оптимизации</h6>
691
  <div class="small mb-2">Применено правок: <strong>${data.applied_changes || 0}</strong></div>
@@ -773,7 +909,10 @@
773
  temperature: Number(document.getElementById('optimizerTemp').value || 0.25),
774
  bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
775
  optimization_mode: document.getElementById('optimizerMode').value || 'balanced',
776
- phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto'
 
 
 
777
  };
778
 
779
  const runBtn = document.getElementById('btnRunLlmOpt');
@@ -786,6 +925,15 @@
786
  optimizerRunUiClear();
787
  optimizerLogAppend('Соединение с сервером (SSE)…');
788
 
 
 
 
 
 
 
 
 
 
789
  const t0 = Date.now();
790
  const tick = setInterval(function () {
791
  const el = document.getElementById('optimizerElapsed');
 
4
  let semanticTermSortBy = 'target_weight';
5
  let semanticTermSortDir = 'desc';
6
  let availableUserAgents = [];
7
+ const PROJECT_SCHEMA_VERSION = 2;
8
 
9
  /** Без операторов ?? / ?. — совместимость с SES lockdown на Hugging Face Spaces */
10
  function nv(v, d) {
 
13
 
14
  let optimizerStreamJobId = null;
15
 
16
+ const OPT_DIFF_ORIG_BODY_KEY = 'optimizerOriginalTargetText_v1';
17
+ const OPT_DIFF_ORIG_TITLE_KEY = 'optimizerOriginalTargetTitle_v1';
18
+
19
+ function getOptimizerDiffModeValue() {
20
+ const el = document.getElementById('optimizerDiffMode');
21
+ const v = el && el.value ? String(el.value) : 'diff_from_input';
22
+ return v === 'diff_from_original' ? 'diff_from_original' : 'diff_from_input';
23
+ }
24
+
25
+ function loadOptimizerOriginalSnapshot() {
26
+ try {
27
+ const body = localStorage.getItem(OPT_DIFF_ORIG_BODY_KEY);
28
+ const title = localStorage.getItem(OPT_DIFF_ORIG_TITLE_KEY);
29
+ return { body: body, title: title };
30
+ } catch (e) {
31
+ return { body: null, title: null };
32
+ }
33
+ }
34
+
35
+ function ensureOptimizerOriginalSnapshot() {
36
+ // Создаём снимок “оригинала” при первом запуске в режиме diff_from_original.
37
+ const snapshot = loadOptimizerOriginalSnapshot();
38
+ let body = snapshot.body;
39
+ let title = snapshot.title;
40
+
41
+ const currentBody = (document.getElementById('targetText').value || '');
42
+ const currentTitle = (document.getElementById('targetTitle').value || '');
43
+
44
+ try {
45
+ if (body === null) {
46
+ localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, currentBody);
47
+ body = currentBody;
48
+ }
49
+ if (title === null) {
50
+ localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, currentTitle);
51
+ title = currentTitle;
52
+ }
53
+ } catch (e) {
54
+ // Best-effort: если localStorage недоступен, просто вернём то, что сейчас в форме.
55
+ body = currentBody;
56
+ title = currentTitle;
57
+ }
58
+ return { body: body, title: title };
59
+ }
60
+
61
+ function resetOptimizerDiffOriginal() {
62
+ const currentBody = (document.getElementById('targetText').value || '');
63
+ const currentTitle = (document.getElementById('targetTitle').value || '');
64
+ try {
65
+ localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, currentBody);
66
+ localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, currentTitle);
67
+ alert('Оригинал для сравнения обновлён.');
68
+ } catch (e) {
69
+ alert('Не удалось обновить снимок в localStorage. Проверьте настройки браузера.');
70
+ }
71
+ }
72
+
73
  function optimizerLogAppend(line) {
74
  const el = document.getElementById('optimizerRunLog');
75
  if (!el) return;
 
323
  }
324
 
325
  function saveProject() {
326
+ const diffMode = getOptimizerDiffModeValue();
327
+ const origSnap = loadOptimizerOriginalSnapshot();
328
+ const curBody = document.getElementById('targetText').value || '';
329
+ const curTitle = document.getElementById('targetTitle').value || '';
330
+ // В проекте всегда держим оригинал, даже если снимок ещё не создавался.
331
+ const origBody = (origSnap && origSnap.body !== null) ? origSnap.body : curBody;
332
+ const origTitle = (origSnap && origSnap.title !== null) ? origSnap.title : curTitle;
333
  const projectData = {
334
  app: "seo-ai-editor",
335
  schema_version: PROJECT_SCHEMA_VERSION,
 
339
  target_url: document.getElementById('targetUrlInput').value,
340
  competitor_urls: document.getElementById('competitorUrlsInput').value,
341
  fetch_user_agent: document.getElementById('urlUserAgentSelect').value,
342
+ target_text: curBody,
343
  keywords: document.getElementById('keywordsInput').value,
344
  competitors: collectCompetitorTexts(),
345
+ target_title: curTitle,
346
  competitor_titles: collectCompetitorTitles(),
347
  semantic_threshold: Number(document.getElementById('semanticThreshold').value || 50),
348
  semantic_compression: Number(document.getElementById('semanticCompression').value || 0.1),
 
354
  optimizer_temperature: Number(document.getElementById('optimizerTemp').value || 0.25),
355
  optimizer_bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
356
  optimizer_mode: document.getElementById('optimizerMode').value,
357
+ optimizer_phrase_strategy: document.getElementById('optimizerPhraseStrategy').value,
358
+
359
+ // Diff highlight settings (for persistence across sessions)
360
+ optimizer_diff_mode: diffMode,
361
+ optimizer_original_target_text: origBody,
362
+ optimizer_original_target_title: origTitle
363
  },
364
  state: {
365
  analysis_result: currentData,
 
412
  document.getElementById('optimizerBertStageTarget').value = 0.70;
413
  document.getElementById('optimizerMode').value = 'balanced';
414
  document.getElementById('optimizerPhraseStrategy').value = 'auto';
415
+ const diffSel = document.getElementById('optimizerDiffMode');
416
+ if (diffSel) diffSel.value = 'diff_from_input';
417
 
418
  // Competitor text fields
419
  const competitorsList = document.getElementById('competitorsList');
 
441
  document.getElementById('semanticResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Нажмите "Запустить Semantic Core", чтобы построить граф и разметку.</div>';
442
  document.getElementById('summaryResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите анализ, чтобы увидеть итоговые рекомендации.</div>';
443
  document.getElementById('optimizerResultsContainer').innerHTML = '<div class="text-center text-muted py-5">Запустите основной анализ и затем оптимизацию.</div>';
444
+
445
+ // Чтобы режим "оригинал" не сравнивался с прошлым проектом.
446
+ try {
447
+ localStorage.removeItem(OPT_DIFF_ORIG_BODY_KEY);
448
+ localStorage.removeItem(OPT_DIFF_ORIG_TITLE_KEY);
449
+ } catch (e) {
450
+ // ignore
451
+ }
452
  }
453
 
454
  function applyProjectData(project) {
 
478
  document.getElementById('optimizerMode').value = inp.optimizer_mode || 'balanced';
479
  document.getElementById('optimizerPhraseStrategy').value = inp.optimizer_phrase_strategy || 'auto';
480
 
481
+ // Restore diff-mode and original snapshot from project (if present).
482
+ try {
483
+ const savedMode = String(nv(inp.optimizer_diff_mode, '') || '').trim();
484
+ const mode = (savedMode === 'diff_from_original') ? 'diff_from_original' : 'diff_from_input';
485
+ const diffSel = document.getElementById('optimizerDiffMode');
486
+ if (diffSel) diffSel.value = mode;
487
+
488
+ const savedOrigBody = nv(inp.optimizer_original_target_text, null);
489
+ const savedOrigTitle = nv(inp.optimizer_original_target_title, null);
490
+ if (savedOrigBody !== null || savedOrigTitle !== null) {
491
+ localStorage.setItem(OPT_DIFF_ORIG_BODY_KEY, String(savedOrigBody || ''));
492
+ localStorage.setItem(OPT_DIFF_ORIG_TITLE_KEY, String(savedOrigTitle || ''));
493
+ } else {
494
+ // If project has no snapshot, clear any leftovers.
495
+ localStorage.removeItem(OPT_DIFF_ORIG_BODY_KEY);
496
+ localStorage.removeItem(OPT_DIFF_ORIG_TITLE_KEY);
497
+ }
498
+ } catch (e) {
499
+ // ignore
500
+ }
501
+
502
  // Title character counter refresh
503
  const titleLen = (inp.target_title || '').length;
504
  const counter = document.getElementById('titleCharCount');
 
679
  ? `<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>`
680
  : '';
681
 
682
+ const diffModeLabel = data.diff_mode === 'diff_from_original' ? 'Оригинал (снимок)' : 'Входной текст';
683
+ const diffChanges = Array.isArray(data.diff_changes) ? data.diff_changes : [];
684
+ const diffChangesRows = diffChanges
685
+ .slice(0, 50)
686
+ .map((c) => {
687
+ const kind = c.type || '-';
688
+ const fromTxt = c.from ? safeHtml(c.from) : '-';
689
+ const toTxt = c.to ? safeHtml(c.to) : '-';
690
+ return `<tr>
691
+ <td>${safeHtml(kind)}</td>
692
+ <td><div style="max-width: 420px; white-space: normal;">${fromTxt}</div></td>
693
+ <td><div style="max-width: 420px; white-space: normal;">${toTxt}</div></td>
694
+ </tr>`;
695
+ }).join('');
696
+
697
+ const diffCard = data.diff_body_html
698
+ ? `<div class="stat-card mt-3">
699
+ <h6 class="card-title">Подсветка изменений в Target</h6>
700
+ <div class="small mb-2 text-muted">Режим: <strong>${safeHtml(diffModeLabel)}</strong></div>
701
+ <div class="border rounded p-3" style="background:#fff; line-height: 1.8; word-break: break-word;">${data.diff_body_html}</div>
702
+ <div class="mt-3">
703
+ <div class="small fw-semibold text-muted mb-1">Фрагменты (что было / что стало)</div>
704
+ <div class="table-responsive">
705
+ <table class="table table-sm table-bordered mb-0">
706
+ <thead class="table-light">
707
+ <tr><th>Тип</th><th>Было</th><th>Стало</th></tr>
708
+ </thead>
709
+ <tbody>
710
+ ${diffChangesRows || '<tr><td colspan="3" class="text-center text-muted">Изменений не найдено.</td></tr>'}
711
+ </tbody>
712
+ </table>
713
+ </div>
714
+ </div>
715
+ </div>`
716
+ : '';
717
+
718
  const base = data.baseline_metrics || {};
719
  const fin = data.final_metrics || {};
720
  const rows = [
 
821
  </div>`;
822
  }).join('');
823
 
824
+ container.innerHTML = earlyBanner + titleBanner + diffCard + `
825
  <div class="stat-card">
826
  <h6 class="card-title">Результат оптимизации</h6>
827
  <div class="small mb-2">Применено правок: <strong>${data.applied_changes || 0}</strong></div>
 
909
  temperature: Number(document.getElementById('optimizerTemp').value || 0.25),
910
  bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
911
  optimization_mode: document.getElementById('optimizerMode').value || 'balanced',
912
+ phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto',
913
+ diff_mode: diffMode,
914
+ original_target_text: originalTargetText,
915
+ original_target_title: originalTargetTitle
916
  };
917
 
918
  const runBtn = document.getElementById('btnRunLlmOpt');
 
925
  optimizerRunUiClear();
926
  optimizerLogAppend('Соединение с сервером (SSE)…');
927
 
928
+ const diffMode = getOptimizerDiffModeValue();
929
+ let originalTargetText = null;
930
+ let originalTargetTitle = null;
931
+ if (diffMode === 'diff_from_original') {
932
+ const snap = ensureOptimizerOriginalSnapshot();
933
+ originalTargetText = snap.body;
934
+ originalTargetTitle = snap.title;
935
+ }
936
+
937
  const t0 = Date.now();
938
  const tick = setInterval(function () {
939
  const el = document.getElementById('optimizerElapsed');
templates/index.html CHANGED
@@ -9,6 +9,8 @@
9
  body { background-color: #f8f9fa; }
10
  .editor-box { min-height: 300px; font-family: 'Georgia', serif; font-size: 1.1rem; }
11
  .stat-card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); padding: 20px; margin-bottom: 20px; }
 
 
12
  .loading-overlay {
13
  display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
14
  background: rgba(255,255,255,0.8); z-index: 9999; text-align: center; padding-top: 20%;
@@ -328,6 +330,21 @@
328
  <div class="form-text">Выбор стратегии остается у пользователя.</div>
329
  </div>
330
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  <div class="d-flex gap-2 mt-3 flex-wrap align-items-center">
332
  <button type="button" class="btn btn-dark" id="btnRunLlmOpt" onclick="runLlmOptimization()">Запустить оптимизацию</button>
333
  <button type="button" class="btn btn-outline-danger" id="btnStopLlmOpt" disabled onclick="requestStopOptimizer()">Остановить</button>
 
9
  body { background-color: #f8f9fa; }
10
  .editor-box { min-height: 300px; font-family: 'Georgia', serif; font-size: 1.1rem; }
11
  .stat-card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); padding: 20px; margin-bottom: 20px; }
12
+ .diff-changed { background-color: #fff3cd; padding: 0 2px; border-radius: 4px; }
13
+ .diff-unchanged { }
14
  .loading-overlay {
15
  display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
16
  background: rgba(255,255,255,0.8); z-index: 9999; text-align: center; padding-top: 20%;
 
330
  <div class="form-text">Выбор стратегии остается у пользователя.</div>
331
  </div>
332
  </div>
333
+ <div class="row g-2 mt-1">
334
+ <div class="col-md-6">
335
+ <label class="form-label small text-muted mb-1">Подсветка изменений (Target)</label>
336
+ <select id="optimizerDiffMode" class="form-select">
337
+ <option value="diff_from_input" selected>Относительно текста перед запуском</option>
338
+ <option value="diff_from_original">Относительно оригинала (снимок)</option>
339
+ </select>
340
+ <div class="form-text">
341
+ Если выбран режим «оригинал», при первом запуске автоматически сохраняется снимок текущего `Target`.
342
+ </div>
343
+ </div>
344
+ <div class="col-md-3 d-grid align-items-end">
345
+ <button type="button" class="btn btn-outline-secondary" onclick="resetOptimizerDiffOriginal()">Сбросить оригинал</button>
346
+ </div>
347
+ </div>
348
  <div class="d-flex gap-2 mt-3 flex-wrap align-items-center">
349
  <button type="button" class="btn btn-dark" id="btnRunLlmOpt" onclick="runLlmOptimization()">Запустить оптимизацию</button>
350
  <button type="button" class="btn btn-outline-danger" id="btnStopLlmOpt" disabled onclick="requestStopOptimizer()">Остановить</button>