lsdf commited on
Commit
55a7978
·
1 Parent(s): 865a2ea

feat(optimizer): stage and goal selection controls

Browse files

Add per-stage enablement and manual/mixed goal overrides in API, backend optimizer pipeline, and UI with project save/load support.

Made-with: Cursor

Files changed (4) hide show
  1. models.py +9 -0
  2. optimizer.py +154 -8
  3. static/js/app.js +219 -0
  4. templates/index.html +18 -0
models.py CHANGED
@@ -99,6 +99,15 @@ class OptimizerRequest(BaseModel):
99
  optimization_mode: str = "balanced"
100
  phrase_strategy_mode: str = "auto" # auto | exact_preferred | distributed_preferred | ensemble
101
  bert_stage_target: float = 0.70
 
 
 
 
 
 
 
 
 
102
 
103
 
104
  class OptimizerResponse(BaseModel):
 
99
  optimization_mode: str = "balanced"
100
  phrase_strategy_mode: str = "auto" # auto | exact_preferred | distributed_preferred | ensemble
101
  bert_stage_target: float = 0.70
102
+ # Optional stage control. If empty -> default full pipeline order.
103
+ enabled_stages: List[str] = Field(default_factory=list) # bert|bm25|ngram|semantic|title
104
+ # Per-stage manual goal selection and custom additions.
105
+ # Example:
106
+ # {
107
+ # "bm25": {"mode":"mixed","selected":["canadian online casino"],"custom_add":["online casinos canada"]},
108
+ # "bert": {"mode":"manual","selected":["best payout casinos"],"custom_add":[]}
109
+ # }
110
+ stage_goal_overrides: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
111
 
112
 
113
  class OptimizerResponse(BaseModel):
optimizer.py CHANGED
@@ -31,6 +31,130 @@ STAGE_ORDER = ["bert", "bm25", "ngram", "semantic", "title"]
31
  NGRAM_ATTEMPTS_PER_TERM = 3
32
 
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  def _tokenize(text: str) -> List[str]:
35
  return [
36
  x
@@ -665,6 +789,7 @@ def _collect_optimization_goals(
665
  language: str,
666
  stage: str = "bert",
667
  bert_stage_target: float = BERT_TARGET_THRESHOLD,
 
668
  ) -> List[Dict[str, Any]]:
669
  goals: List[Dict[str, Any]] = []
670
  bert_details = analysis.get("bert_analysis", {}).get("detailed", []) or []
@@ -772,7 +897,8 @@ def _collect_optimization_goals(
772
  }
773
  )
774
 
775
- return [g for g in goals if g.get("type") == stage]
 
776
 
777
 
778
  def _per_goal_budget(
@@ -832,9 +958,12 @@ def _estimate_total_loop_budget(
832
  max_iterations: int,
833
  candidates_per_iteration: int,
834
  bert_stage_target: float,
 
 
835
  ) -> int:
836
  total = 0
837
- for st in STAGE_ORDER:
 
838
  for g in _collect_optimization_goals(
839
  analysis,
840
  semantic,
@@ -842,6 +971,7 @@ def _estimate_total_loop_budget(
842
  language,
843
  stage=st,
844
  bert_stage_target=bert_stage_target,
 
845
  ):
846
  ei, _ = _per_goal_budget(g, max_iterations, candidates_per_iteration, bert_stage_target)
847
  total += ei
@@ -1745,6 +1875,18 @@ def optimize_text(
1745
  phrase_strategy_mode = "auto"
1746
  bert_stage_target = float(request_data.get("bert_stage_target", BERT_TARGET_THRESHOLD) or BERT_TARGET_THRESHOLD)
1747
  bert_stage_target = max(0.0, min(1.0, bert_stage_target))
 
 
 
 
 
 
 
 
 
 
 
 
1748
 
1749
  baseline_analysis = _build_analysis_snapshot(
1750
  target_text, competitors, keywords, language, target_title, competitor_titles
@@ -1764,9 +1906,10 @@ def optimize_text(
1764
  language,
1765
  stage=st,
1766
  bert_stage_target=bert_stage_target,
 
1767
  )
1768
  )
1769
- for st in STAGE_ORDER
1770
  }
1771
  ngram_row_count = int(baseline_goal_counts.get("ngram", 0))
1772
  total_loop_steps = _estimate_total_loop_budget(
@@ -1777,6 +1920,8 @@ def optimize_text(
1777
  max_iterations,
1778
  candidates_per_iteration,
1779
  bert_stage_target,
 
 
1780
  )
1781
 
1782
  current_text = target_text
@@ -1849,7 +1994,7 @@ def optimize_text(
1849
  total_steps=total_loop_steps,
1850
  max_iterations_setting=max_iterations,
1851
  ngram_targets=ngram_row_count,
1852
- stages_order=list(STAGE_ORDER),
1853
  )
1854
 
1855
  seen_candidate_rewrites = set()
@@ -1873,16 +2018,16 @@ def optimize_text(
1873
  )
1874
  return _pack_result(stopped_early=True, stop_reason="user_cancelled")
1875
 
1876
- while stage_idx < len(STAGE_ORDER) and _is_stage_complete(
1877
- STAGE_ORDER[stage_idx], current_metrics, bert_stage_target=bert_stage_target
1878
  ):
1879
  stage_idx += 1
1880
  stage_no_progress_steps = 0
1881
- if stage_idx >= len(STAGE_ORDER):
1882
  logs.append({"step": step + 1, "status": "stopped", "reason": "All optimization stages completed."})
1883
  break
1884
 
1885
- active_stage = STAGE_ORDER[stage_idx]
1886
  goals_for_stage = _collect_optimization_goals(
1887
  current_analysis,
1888
  current_semantic,
@@ -1890,6 +2035,7 @@ def optimize_text(
1890
  language,
1891
  stage=active_stage,
1892
  bert_stage_target=bert_stage_target,
 
1893
  )
1894
  state = stage_goal_cursor.get(active_stage) or {"goal_index": 0, "attempt_count": 0}
1895
  goal_index = int(state.get("goal_index", 0))
 
31
  NGRAM_ATTEMPTS_PER_TERM = 3
32
 
33
 
34
+ def _normalize_stage_name(v: Any) -> str:
35
+ s = str(v or "").strip().lower()
36
+ return s if s in STAGE_ORDER else ""
37
+
38
+
39
+ def _goal_label_canonical(goal: Dict[str, Any]) -> str:
40
+ t = str(goal.get("type", "") or "").strip().lower()
41
+ if t == "bm25":
42
+ term = str(goal.get("bm25_word", "") or "").strip().lower()
43
+ if term:
44
+ return term
45
+ label = str(goal.get("label", "") or "").strip().lower()
46
+ if label.startswith("reduce spam:"):
47
+ return label.replace("reduce spam:", "", 1).strip()
48
+ return label
49
+ return str(goal.get("label", "") or "").strip().lower()
50
+
51
+
52
+ def _build_custom_goal(stage: str, value: str, language: str) -> Optional[Dict[str, Any]]:
53
+ raw = str(value or "").strip()
54
+ if not raw:
55
+ return None
56
+ if stage == "bert":
57
+ return {
58
+ "type": "bert",
59
+ "label": raw,
60
+ "focus_terms": _filter_stopwords(_tokenize(raw), language)[:6],
61
+ "avoid_terms": [],
62
+ "bert_phrase_score": 0.0,
63
+ "bert_target": float(BERT_TARGET_THRESHOLD),
64
+ }
65
+ if stage == "bm25":
66
+ return {
67
+ "type": "bm25",
68
+ "label": f"reduce spam: {raw}",
69
+ "focus_terms": [],
70
+ "avoid_terms": [raw],
71
+ "bm25_count": 2,
72
+ "bm25_word": raw,
73
+ }
74
+ if stage == "ngram":
75
+ return {
76
+ "type": "ngram",
77
+ "label": raw,
78
+ "focus_terms": [raw],
79
+ "avoid_terms": [],
80
+ "ngram_target_count": 0.0,
81
+ "ngram_comp_avg": 1.0,
82
+ "ngram_tolerance_pct": 0.5,
83
+ "ngram_lower_bound": 0.5,
84
+ "ngram_upper_bound": 1.5,
85
+ "ngram_direction": "increase",
86
+ "ngram_rank_index": 0,
87
+ "ngram_candidates_total": 1,
88
+ }
89
+ if stage == "semantic":
90
+ return {
91
+ "type": "semantic",
92
+ "label": raw,
93
+ "focus_terms": [raw],
94
+ "avoid_terms": [],
95
+ "semantic_gap": float(SEMANTIC_GAP_MIN_ABS),
96
+ }
97
+ if stage == "title":
98
+ return {
99
+ "type": "title",
100
+ "label": "title alignment",
101
+ "focus_terms": _filter_stopwords(_tokenize(raw), language)[:8],
102
+ "avoid_terms": [],
103
+ "title_bert_score": 0.0,
104
+ "title_target": float(TITLE_TARGET_THRESHOLD),
105
+ }
106
+ return None
107
+
108
+
109
+ def _apply_stage_goal_override(
110
+ goals: List[Dict[str, Any]],
111
+ stage: str,
112
+ language: str,
113
+ stage_goal_overrides: Optional[Dict[str, Any]],
114
+ ) -> List[Dict[str, Any]]:
115
+ ov_all = stage_goal_overrides or {}
116
+ ov = ov_all.get(stage) if isinstance(ov_all, dict) else None
117
+ if not isinstance(ov, dict):
118
+ return goals
119
+
120
+ mode = str(ov.get("mode", "auto") or "auto").strip().lower()
121
+ if mode not in {"auto", "manual", "mixed"}:
122
+ mode = "auto"
123
+
124
+ selected_raw = ov.get("selected") or []
125
+ custom_raw = ov.get("custom_add") or []
126
+ selected_set = {
127
+ str(x or "").strip().lower()
128
+ for x in selected_raw
129
+ if str(x or "").strip()
130
+ }
131
+
132
+ custom_goals: List[Dict[str, Any]] = []
133
+ for item in custom_raw:
134
+ g = _build_custom_goal(stage, str(item or ""), language)
135
+ if g:
136
+ custom_goals.append(g)
137
+
138
+ if mode == "auto":
139
+ out = list(goals)
140
+ else:
141
+ out = []
142
+ for g in goals:
143
+ if _goal_label_canonical(g) in selected_set:
144
+ out.append(g)
145
+
146
+ if mode in {"manual", "mixed"}:
147
+ out.extend(custom_goals)
148
+
149
+ # Deduplicate by canonical goal label to keep deterministic cursor behavior.
150
+ dedup: Dict[str, Dict[str, Any]] = {}
151
+ for g in out:
152
+ key = _goal_label_canonical(g)
153
+ if key and key not in dedup:
154
+ dedup[key] = g
155
+ return list(dedup.values())
156
+
157
+
158
  def _tokenize(text: str) -> List[str]:
159
  return [
160
  x
 
789
  language: str,
790
  stage: str = "bert",
791
  bert_stage_target: float = BERT_TARGET_THRESHOLD,
792
+ stage_goal_overrides: Optional[Dict[str, Any]] = None,
793
  ) -> List[Dict[str, Any]]:
794
  goals: List[Dict[str, Any]] = []
795
  bert_details = analysis.get("bert_analysis", {}).get("detailed", []) or []
 
897
  }
898
  )
899
 
900
+ stage_goals = [g for g in goals if g.get("type") == stage]
901
+ return _apply_stage_goal_override(stage_goals, stage, language, stage_goal_overrides)
902
 
903
 
904
  def _per_goal_budget(
 
958
  max_iterations: int,
959
  candidates_per_iteration: int,
960
  bert_stage_target: float,
961
+ active_stage_order: Optional[List[str]] = None,
962
+ stage_goal_overrides: Optional[Dict[str, Any]] = None,
963
  ) -> int:
964
  total = 0
965
+ stages = active_stage_order or list(STAGE_ORDER)
966
+ for st in stages:
967
  for g in _collect_optimization_goals(
968
  analysis,
969
  semantic,
 
971
  language,
972
  stage=st,
973
  bert_stage_target=bert_stage_target,
974
+ stage_goal_overrides=stage_goal_overrides,
975
  ):
976
  ei, _ = _per_goal_budget(g, max_iterations, candidates_per_iteration, bert_stage_target)
977
  total += ei
 
1875
  phrase_strategy_mode = "auto"
1876
  bert_stage_target = float(request_data.get("bert_stage_target", BERT_TARGET_THRESHOLD) or BERT_TARGET_THRESHOLD)
1877
  bert_stage_target = max(0.0, min(1.0, bert_stage_target))
1878
+ stage_goal_overrides = request_data.get("stage_goal_overrides") or {}
1879
+ if not isinstance(stage_goal_overrides, dict):
1880
+ stage_goal_overrides = {}
1881
+ req_enabled_stages = request_data.get("enabled_stages") or []
1882
+ active_stage_order: List[str] = []
1883
+ if isinstance(req_enabled_stages, list):
1884
+ for x in req_enabled_stages:
1885
+ st = _normalize_stage_name(x)
1886
+ if st and st not in active_stage_order:
1887
+ active_stage_order.append(st)
1888
+ if not active_stage_order:
1889
+ active_stage_order = list(STAGE_ORDER)
1890
 
1891
  baseline_analysis = _build_analysis_snapshot(
1892
  target_text, competitors, keywords, language, target_title, competitor_titles
 
1906
  language,
1907
  stage=st,
1908
  bert_stage_target=bert_stage_target,
1909
+ stage_goal_overrides=stage_goal_overrides,
1910
  )
1911
  )
1912
+ for st in active_stage_order
1913
  }
1914
  ngram_row_count = int(baseline_goal_counts.get("ngram", 0))
1915
  total_loop_steps = _estimate_total_loop_budget(
 
1920
  max_iterations,
1921
  candidates_per_iteration,
1922
  bert_stage_target,
1923
+ active_stage_order=active_stage_order,
1924
+ stage_goal_overrides=stage_goal_overrides,
1925
  )
1926
 
1927
  current_text = target_text
 
1994
  total_steps=total_loop_steps,
1995
  max_iterations_setting=max_iterations,
1996
  ngram_targets=ngram_row_count,
1997
+ stages_order=list(active_stage_order),
1998
  )
1999
 
2000
  seen_candidate_rewrites = set()
 
2018
  )
2019
  return _pack_result(stopped_early=True, stop_reason="user_cancelled")
2020
 
2021
+ while stage_idx < len(active_stage_order) and _is_stage_complete(
2022
+ active_stage_order[stage_idx], current_metrics, bert_stage_target=bert_stage_target
2023
  ):
2024
  stage_idx += 1
2025
  stage_no_progress_steps = 0
2026
+ if stage_idx >= len(active_stage_order):
2027
  logs.append({"step": step + 1, "status": "stopped", "reason": "All optimization stages completed."})
2028
  break
2029
 
2030
+ active_stage = active_stage_order[stage_idx]
2031
  goals_for_stage = _collect_optimization_goals(
2032
  current_analysis,
2033
  current_semantic,
 
2035
  language,
2036
  stage=active_stage,
2037
  bert_stage_target=bert_stage_target,
2038
+ stage_goal_overrides=stage_goal_overrides,
2039
  )
2040
  state = stage_goal_cursor.get(active_stage) or {"goal_index": 0, "attempt_count": 0}
2041
  goal_index = int(state.get("goal_index", 0))
static/js/app.js CHANGED
@@ -70,6 +70,182 @@
70
  }
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  function optimizerLogAppend(line) {
74
  const el = document.getElementById('optimizerRunLog');
75
  if (!el) return;
@@ -325,6 +501,7 @@
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
  // В проекте всегда держим оригинал, даже если снимок ещё не создавался.
@@ -355,6 +532,8 @@
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,
@@ -414,6 +593,7 @@
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');
@@ -449,6 +629,13 @@
449
  } catch (e) {
450
  // ignore
451
  }
 
 
 
 
 
 
 
452
  }
453
 
454
  function applyProjectData(project) {
@@ -477,6 +664,21 @@
477
  document.getElementById('optimizerBertStageTarget').value = nv(inp.optimizer_bert_stage_target, 0.70);
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 {
@@ -539,6 +741,7 @@
539
 
540
  if (currentData) renderResults(currentData);
541
  if (semanticData) renderSemanticResults(semanticData);
 
542
  renderActionSummary(currentData, semanticData);
543
  renderOptimizerResults(optimizerData);
544
  }
@@ -603,6 +806,7 @@
603
  renderResults(data);
604
  optimizerData = null;
605
  renderOptimizerResults(null);
 
606
 
607
  } catch (error) {
608
  alert("Ошибка: " + error.message);
@@ -642,6 +846,7 @@
642
  if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
643
  semanticData = await response.json();
644
  renderSemanticResults(semanticData);
 
645
  renderActionSummary(currentData, semanticData);
646
  } catch (error) {
647
  alert("Ошибка Semantic Core: " + error.message);
@@ -902,6 +1107,11 @@
902
  originalTargetText = snap.body;
903
  originalTargetTitle = snap.title;
904
  }
 
 
 
 
 
905
 
906
  const payload = {
907
  target_text: document.getElementById('targetText').value || '',
@@ -919,6 +1129,8 @@
919
  bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
920
  optimization_mode: document.getElementById('optimizerMode').value || 'balanced',
921
  phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto',
 
 
922
  diff_mode: diffMode,
923
  original_target_text: originalTargetText,
924
  original_target_title: originalTargetTitle
@@ -2245,3 +2457,10 @@
2245
  }
2246
 
2247
  loadUserAgentOptions();
 
 
 
 
 
 
 
 
70
  }
71
  }
72
 
73
+ const OPT_STAGE_ORDER = ['bert', 'bm25', 'ngram', 'semantic', 'title'];
74
+ const OPT_STAGE_LABELS = {
75
+ bert: 'BERT',
76
+ bm25: 'BM25',
77
+ ngram: 'N-gram',
78
+ semantic: 'Semantic',
79
+ title: 'Title'
80
+ };
81
+ let optimizerStageGoalAutoCache = {};
82
+
83
+ function _escHtml(v) {
84
+ return String(v == null ? '' : v)
85
+ .replace(/&/g, '&amp;')
86
+ .replace(/</g, '&lt;')
87
+ .replace(/>/g, '&gt;')
88
+ .replace(/"/g, '&quot;')
89
+ .replace(/'/g, '&#39;');
90
+ }
91
+
92
+ function _uniqStrList(arr) {
93
+ const out = [];
94
+ const seen = {};
95
+ (arr || []).forEach((x) => {
96
+ const v = String(x || '').trim();
97
+ if (!v) return;
98
+ const k = v.toLowerCase();
99
+ if (seen[k]) return;
100
+ seen[k] = true;
101
+ out.push(v);
102
+ });
103
+ return out;
104
+ }
105
+
106
+ function _buildOptimizerAutoGoals() {
107
+ const auto = { bert: [], bm25: [], ngram: [], semantic: [], title: [] };
108
+ const bertTarget = Number(document.getElementById('optimizerBertStageTarget').value || 0.70);
109
+
110
+ if (currentData && currentData.bert_analysis && Array.isArray(currentData.bert_analysis.detailed)) {
111
+ auto.bert = _uniqStrList(
112
+ currentData.bert_analysis.detailed
113
+ .filter((r) => Number(r.my_max_score || 0) < bertTarget)
114
+ .map((r) => String(r.phrase || '').trim())
115
+ );
116
+ }
117
+
118
+ if (currentData && Array.isArray(currentData.bm25_recommendations)) {
119
+ auto.bm25 = _uniqStrList(
120
+ currentData.bm25_recommendations
121
+ .filter((r) => String(r.action || '') === 'remove')
122
+ .sort((a, b) => Number(b.count || 0) - Number(a.count || 0))
123
+ .slice(0, 40)
124
+ .map((r) => String(r.word || '').trim())
125
+ );
126
+ }
127
+
128
+ if (currentData && currentData.ngram_stats) {
129
+ const nrows = [];
130
+ ['unigrams', 'bigrams', 'trigrams'].forEach((k) => {
131
+ const rows = currentData.ngram_stats[k];
132
+ if (!Array.isArray(rows)) return;
133
+ rows.forEach((row) => {
134
+ const t = Number(row.target_count || 0);
135
+ const c = Number(row.competitor_avg || 0);
136
+ if (Math.abs(t - c) >= 1 && (t > 0 || c > 0)) nrows.push(String(row.ngram || '').trim());
137
+ });
138
+ });
139
+ auto.ngram = _uniqStrList(nrows).slice(0, 60);
140
+ }
141
+
142
+ if (semanticData && semanticData.comparison && Array.isArray(semanticData.comparison.term_power_table)) {
143
+ auto.semantic = _uniqStrList(
144
+ semanticData.comparison.term_power_table
145
+ .filter((r) => Number(r.competitor_avg_weight || 0) > Number(r.target_weight || 0))
146
+ .sort((a, b) => (Number(b.competitor_avg_weight || 0) - Number(b.target_weight || 0)) - (Number(a.competitor_avg_weight || 0) - Number(a.target_weight || 0)))
147
+ .slice(0, 40)
148
+ .map((r) => String(r.term || '').trim())
149
+ );
150
+ }
151
+
152
+ auto.title = _uniqStrList((document.getElementById('keywordsInput').value || '').split('\n')).slice(0, 20);
153
+ return auto;
154
+ }
155
+
156
+ function _getOptimizerStageConfigFromUI() {
157
+ const enabled = [];
158
+ const overrides = {};
159
+ OPT_STAGE_ORDER.forEach((stage) => {
160
+ const enEl = document.getElementById('optStage' + stage.charAt(0).toUpperCase() + stage.slice(1));
161
+ if (enEl && enEl.checked) enabled.push(stage);
162
+
163
+ const modeEl = document.getElementById('optStageMode-' + stage);
164
+ const mode = modeEl ? String(modeEl.value || 'auto') : 'auto';
165
+ const selectedEls = document.querySelectorAll('.opt-goal-check[data-stage="' + stage + '"]');
166
+ const selected = [];
167
+ for (let i = 0; i < selectedEls.length; i++) {
168
+ if (selectedEls[i].checked) selected.push(String(selectedEls[i].value || '').trim());
169
+ }
170
+ const customEl = document.getElementById('optStageCustom-' + stage);
171
+ const customAdd = customEl
172
+ ? _uniqStrList(String(customEl.value || '').split('\n').map((x) => x.trim()).filter(Boolean))
173
+ : [];
174
+
175
+ overrides[stage] = {
176
+ mode: (mode === 'manual' || mode === 'mixed') ? mode : 'auto',
177
+ selected: _uniqStrList(selected),
178
+ custom_add: customAdd
179
+ };
180
+ });
181
+ return { enabled_stages: enabled, stage_goal_overrides: overrides };
182
+ }
183
+
184
+ function optimizerSelectAllStages(flag) {
185
+ OPT_STAGE_ORDER.forEach((stage) => {
186
+ const enEl = document.getElementById('optStage' + stage.charAt(0).toUpperCase() + stage.slice(1));
187
+ if (enEl) enEl.checked = !!flag;
188
+ });
189
+ }
190
+
191
+ function _renderOptimizerStageGoalConfig(savedCfg) {
192
+ const wrap = document.getElementById('optimizerStageGoalConfigContainer');
193
+ if (!wrap) return;
194
+
195
+ const cfg = savedCfg || {};
196
+ const html = OPT_STAGE_ORDER.map((stage) => {
197
+ const auto = optimizerStageGoalAutoCache[stage] || [];
198
+ const stCfg = cfg[stage] || {};
199
+ const mode = (stCfg.mode === 'manual' || stCfg.mode === 'mixed') ? stCfg.mode : 'auto';
200
+ const selectedSaved = {};
201
+ (stCfg.selected || []).forEach((v) => { selectedSaved[String(v || '').toLowerCase()] = true; });
202
+ const rows = auto.map((goal, idx) => {
203
+ const g = String(goal || '').trim();
204
+ const key = g.toLowerCase();
205
+ const checked = selectedSaved[key] ? 'checked' : '';
206
+ return `<div class="form-check">
207
+ <input class="form-check-input opt-goal-check" type="checkbox" data-stage="${stage}" id="optGoal-${stage}-${idx}" value="${_escHtml(g)}" ${checked}>
208
+ <label class="form-check-label small" for="optGoal-${stage}-${idx}">${_escHtml(g)}</label>
209
+ </div>`;
210
+ }).join('');
211
+ const customText = Array.isArray(stCfg.custom_add) ? stCfg.custom_add.join('\n') : '';
212
+ return `<div class="border rounded p-2 mb-2 bg-white">
213
+ <div class="d-flex justify-content-between align-items-center mb-1">
214
+ <strong class="small">${_escHtml(OPT_STAGE_LABELS[stage] || stage)}</strong>
215
+ <span class="small text-muted">Авто-целей: ${auto.length}</span>
216
+ </div>
217
+ <div class="row g-2">
218
+ <div class="col-md-3">
219
+ <label class="form-label small text-muted mb-1">Режим целей</label>
220
+ <select id="optStageMode-${stage}" class="form-select form-select-sm">
221
+ <option value="auto" ${mode === 'auto' ? 'selected' : ''}>Auto</option>
222
+ <option value="manual" ${mode === 'manual' ? 'selected' : ''}>Manual</option>
223
+ <option value="mixed" ${mode === 'mixed' ? 'selected' : ''}>Mixed</option>
224
+ </select>
225
+ </div>
226
+ <div class="col-md-5">
227
+ <label class="form-label small text-muted mb-1">Авто-цели стадии</label>
228
+ <div class="border rounded p-2" style="max-height:120px;overflow:auto;">
229
+ ${rows || '<div class="small text-muted">Нет авто-целей. Добавьте вручную.</div>'}
230
+ </div>
231
+ </div>
232
+ <div class="col-md-4">
233
+ <label class="form-label small text-muted mb-1">Custom цели (по строкам)</label>
234
+ <textarea id="optStageCustom-${stage}" class="form-control form-control-sm" rows="4" placeholder="Добавьте свои цели">${_escHtml(customText)}</textarea>
235
+ </div>
236
+ </div>
237
+ </div>`;
238
+ }).join('');
239
+
240
+ wrap.innerHTML = html;
241
+ }
242
+
243
+ function refreshOptimizerStageGoalConfig(savedOverrides) {
244
+ const currentUiCfg = _getOptimizerStageConfigFromUI().stage_goal_overrides;
245
+ optimizerStageGoalAutoCache = _buildOptimizerAutoGoals();
246
+ _renderOptimizerStageGoalConfig(savedOverrides || currentUiCfg || {});
247
+ }
248
+
249
  function optimizerLogAppend(line) {
250
  const el = document.getElementById('optimizerRunLog');
251
  if (!el) return;
 
501
  function saveProject() {
502
  const diffMode = getOptimizerDiffModeValue();
503
  const origSnap = loadOptimizerOriginalSnapshot();
504
+ const stageCfg = _getOptimizerStageConfigFromUI();
505
  const curBody = document.getElementById('targetText').value || '';
506
  const curTitle = document.getElementById('targetTitle').value || '';
507
  // В проекте всегда держим оригинал, даже если снимок ещё не создавался.
 
532
  optimizer_bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
533
  optimizer_mode: document.getElementById('optimizerMode').value,
534
  optimizer_phrase_strategy: document.getElementById('optimizerPhraseStrategy').value,
535
+ optimizer_enabled_stages: stageCfg.enabled_stages,
536
+ optimizer_stage_goal_overrides: stageCfg.stage_goal_overrides,
537
 
538
  // Diff highlight settings (for persistence across sessions)
539
  optimizer_diff_mode: diffMode,
 
593
  document.getElementById('optimizerPhraseStrategy').value = 'auto';
594
  const diffSel = document.getElementById('optimizerDiffMode');
595
  if (diffSel) diffSel.value = 'diff_from_input';
596
+ optimizerSelectAllStages(true);
597
 
598
  // Competitor text fields
599
  const competitorsList = document.getElementById('competitorsList');
 
629
  } catch (e) {
630
  // ignore
631
  }
632
+ refreshOptimizerStageGoalConfig({
633
+ bert: { mode: 'auto', selected: [], custom_add: [] },
634
+ bm25: { mode: 'auto', selected: [], custom_add: [] },
635
+ ngram: { mode: 'auto', selected: [], custom_add: [] },
636
+ semantic: { mode: 'auto', selected: [], custom_add: [] },
637
+ title: { mode: 'auto', selected: [], custom_add: [] }
638
+ });
639
  }
640
 
641
  function applyProjectData(project) {
 
664
  document.getElementById('optimizerBertStageTarget').value = nv(inp.optimizer_bert_stage_target, 0.70);
665
  document.getElementById('optimizerMode').value = inp.optimizer_mode || 'balanced';
666
  document.getElementById('optimizerPhraseStrategy').value = inp.optimizer_phrase_strategy || 'auto';
667
+ optimizerSelectAllStages(true);
668
+ const savedEnabledStages = Array.isArray(inp.optimizer_enabled_stages) ? inp.optimizer_enabled_stages : [];
669
+ if (savedEnabledStages.length > 0) {
670
+ optimizerSelectAllStages(false);
671
+ savedEnabledStages.forEach((st) => {
672
+ const stage = String(st || '').trim().toLowerCase();
673
+ const id = 'optStage' + stage.charAt(0).toUpperCase() + stage.slice(1);
674
+ const el = document.getElementById(id);
675
+ if (el) el.checked = true;
676
+ });
677
+ }
678
+ const savedOverrides = inp.optimizer_stage_goal_overrides && typeof inp.optimizer_stage_goal_overrides === 'object'
679
+ ? inp.optimizer_stage_goal_overrides
680
+ : {};
681
+ refreshOptimizerStageGoalConfig(savedOverrides);
682
 
683
  // Restore diff-mode and original snapshot from project (if present).
684
  try {
 
741
 
742
  if (currentData) renderResults(currentData);
743
  if (semanticData) renderSemanticResults(semanticData);
744
+ refreshOptimizerStageGoalConfig(savedOverrides);
745
  renderActionSummary(currentData, semanticData);
746
  renderOptimizerResults(optimizerData);
747
  }
 
806
  renderResults(data);
807
  optimizerData = null;
808
  renderOptimizerResults(null);
809
+ refreshOptimizerStageGoalConfig();
810
 
811
  } catch (error) {
812
  alert("Ошибка: " + error.message);
 
846
  if (!response.ok) throw new Error("Ошибка сервера: " + response.statusText);
847
  semanticData = await response.json();
848
  renderSemanticResults(semanticData);
849
+ refreshOptimizerStageGoalConfig();
850
  renderActionSummary(currentData, semanticData);
851
  } catch (error) {
852
  alert("Ошибка Semantic Core: " + error.message);
 
1107
  originalTargetText = snap.body;
1108
  originalTargetTitle = snap.title;
1109
  }
1110
+ const stageCfg = _getOptimizerStageConfigFromUI();
1111
+ if (!stageCfg.enabled_stages || stageCfg.enabled_stages.length === 0) {
1112
+ alert('Выберите хотя бы одну стадию оптимизации.');
1113
+ return;
1114
+ }
1115
 
1116
  const payload = {
1117
  target_text: document.getElementById('targetText').value || '',
 
1129
  bert_stage_target: Number(document.getElementById('optimizerBertStageTarget').value || 0.70),
1130
  optimization_mode: document.getElementById('optimizerMode').value || 'balanced',
1131
  phrase_strategy_mode: document.getElementById('optimizerPhraseStrategy').value || 'auto',
1132
+ enabled_stages: stageCfg.enabled_stages,
1133
+ stage_goal_overrides: stageCfg.stage_goal_overrides,
1134
  diff_mode: diffMode,
1135
  original_target_text: originalTargetText,
1136
  original_target_title: originalTargetTitle
 
2457
  }
2458
 
2459
  loadUserAgentOptions();
2460
+ refreshOptimizerStageGoalConfig({
2461
+ bert: { mode: 'auto', selected: [], custom_add: [] },
2462
+ bm25: { mode: 'auto', selected: [], custom_add: [] },
2463
+ ngram: { mode: 'auto', selected: [], custom_add: [] },
2464
+ semantic: { mode: 'auto', selected: [], custom_add: [] },
2465
+ title: { mode: 'auto', selected: [], custom_add: [] }
2466
+ });
templates/index.html CHANGED
@@ -331,6 +331,24 @@
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">
 
331
  </div>
332
  </div>
333
  <div class="row g-2 mt-1">
334
+ <div class="col-12">
335
+ <div class="border rounded p-2 bg-light">
336
+ <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
337
+ <span class="small fw-semibold text-secondary">Стадии и цели оптимизатора</span>
338
+ <button type="button" class="btn btn-sm btn-outline-secondary" onclick="optimizerSelectAllStages(true)">Все</button>
339
+ <button type="button" class="btn btn-sm btn-outline-secondary" onclick="optimizerSelectAllStages(false)">Снять</button>
340
+ <button type="button" class="btn btn-sm btn-outline-primary" onclick="refreshOptimizerStageGoalConfig()">Обновить авто-цели</button>
341
+ </div>
342
+ <div class="row g-2 mb-2">
343
+ <div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageBert" data-stage="bert" checked><label class="form-check-label small" for="optStageBert">BERT</label></div></div>
344
+ <div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageBm25" data-stage="bm25" checked><label class="form-check-label small" for="optStageBm25">BM25</label></div></div>
345
+ <div class="col-md-2"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageNgram" data-stage="ngram" checked><label class="form-check-label small" for="optStageNgram">N-gram</label></div></div>
346
+ <div class="col-md-3"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageSemantic" data-stage="semantic" checked><label class="form-check-label small" for="optStageSemantic">Semantic</label></div></div>
347
+ <div class="col-md-3"><div class="form-check"><input class="form-check-input optimizer-stage-enabled" type="checkbox" id="optStageTitle" data-stage="title" checked><label class="form-check-label small" for="optStageTitle">Title</label></div></div>
348
+ </div>
349
+ <div id="optimizerStageGoalConfigContainer"></div>
350
+ </div>
351
+ </div>
352
  <div class="col-md-6">
353
  <label class="form-label small text-muted mb-1">Подсветка изменений (Target)</label>
354
  <select id="optimizerDiffMode" class="form-select">