diff --git "a/optimizer.py" "b/optimizer.py"
deleted file mode 100644--- "a/optimizer.py"
+++ /dev/null
@@ -1,3089 +0,0 @@
-import json
-import difflib
-import html as html_lib
-import re
-from itertools import combinations
-from typing import Any, Dict, List, Optional, Tuple
-
-import requests
-
-import logic
-import nlp_processor
-import semantic_graph
-
-
-STOP_WORDS = {
- "en": {"a", "an", "and", "or", "the", "to", "of", "for", "in", "on", "at", "by", "with", "from", "as", "is", "are", "be", "was", "were"},
- "ru": {"и", "или", "в", "во", "на", "по", "с", "со", "к", "ко", "для", "из", "за", "что", "это", "как", "а", "но", "у", "о", "от"},
- "de": {"und", "oder", "der", "die", "das", "zu", "von", "mit", "fur", "in", "auf", "ist", "sind"},
- "es": {"y", "o", "el", "la", "los", "las", "de", "del", "en", "con", "para", "por", "es", "son"},
- "it": {"e", "o", "il", "lo", "la", "i", "gli", "le", "di", "del", "in", "con", "per", "da", "e", "sono"},
- "pl": {"i", "oraz", "lub", "w", "na", "z", "ze", "do", "od", "po", "dla", "to", "jest", "sa"},
- "pt": {"e", "ou", "o", "a", "os", "as", "de", "do", "da", "em", "no", "na", "com", "para", "por", "e", "sao"},
-}
-
-BERT_TARGET_THRESHOLD = 0.7
-BERT_GOAL_DELTA_MIN = 0.005
-TITLE_TARGET_THRESHOLD = 0.65
-SEMANTIC_GAP_TOLERANCE_PCT = 0.15
-SEMANTIC_GAP_MIN_ABS = 3.0
-STAGE_ORDER = ["bert", "bm25", "ngram", "semantic", "title"]
-NGRAM_ATTEMPTS_PER_TERM = 3
-
-
-def _normalize_stage_name(v: Any) -> str:
- s = str(v or "").strip().lower()
- return s if s in STAGE_ORDER else ""
-
-
-def _goal_label_canonical(goal: Dict[str, Any]) -> str:
- t = str(goal.get("type", "") or "").strip().lower()
- if t == "bm25":
- term = str(goal.get("bm25_word", "") or "").strip().lower()
- if term:
- return term
- label = str(goal.get("label", "") or "").strip().lower()
- if label.startswith("reduce spam:"):
- return label.replace("reduce spam:", "", 1).strip()
- return label
- return str(goal.get("label", "") or "").strip().lower()
-
-
-def _build_custom_goal(stage: str, value: str, language: str) -> Optional[Dict[str, Any]]:
- raw = str(value or "").strip()
- if not raw:
- return None
- if stage == "bert":
- return {
- "type": "bert",
- "label": raw,
- "focus_terms": _filter_stopwords(_tokenize(raw), language)[:6],
- "avoid_terms": [],
- "bert_phrase_score": 0.0,
- "bert_target": float(BERT_TARGET_THRESHOLD),
- }
- if stage == "bm25":
- return {
- "type": "bm25",
- "label": f"reduce spam: {raw}",
- "focus_terms": [],
- "avoid_terms": [raw],
- "bm25_count": 2,
- "bm25_word": raw,
- }
- if stage == "ngram":
- return {
- "type": "ngram",
- "label": raw,
- "focus_terms": [raw],
- "avoid_terms": [],
- "ngram_target_count": 0.0,
- "ngram_comp_avg": 1.0,
- "ngram_tolerance_pct": 0.5,
- "ngram_lower_bound": 0.5,
- "ngram_upper_bound": 1.5,
- "ngram_direction": "increase",
- "ngram_rank_index": 0,
- "ngram_candidates_total": 1,
- }
- if stage == "semantic":
- return {
- "type": "semantic",
- "label": raw,
- "focus_terms": [raw],
- "avoid_terms": [],
- "semantic_gap": float(SEMANTIC_GAP_MIN_ABS),
- }
- if stage == "title":
- return {
- "type": "title",
- "label": "title alignment",
- "focus_terms": _filter_stopwords(_tokenize(raw), language)[:8],
- "avoid_terms": [],
- "title_bert_score": 0.0,
- "title_target": float(TITLE_TARGET_THRESHOLD),
- }
- return None
-
-
-def _apply_stage_goal_override(
- goals: List[Dict[str, Any]],
- stage: str,
- language: str,
- stage_goal_overrides: Optional[Dict[str, Any]],
-) -> List[Dict[str, Any]]:
- ov_all = stage_goal_overrides or {}
- ov = ov_all.get(stage) if isinstance(ov_all, dict) else None
- if not isinstance(ov, dict):
- return goals
-
- mode = str(ov.get("mode", "auto") or "auto").strip().lower()
- if mode not in {"auto", "manual", "mixed"}:
- mode = "auto"
-
- selected_raw = ov.get("selected") or []
- custom_raw = ov.get("custom_add") or []
- selected_set = {
- str(x or "").strip().lower()
- for x in selected_raw
- if str(x or "").strip()
- }
-
- custom_goals: List[Dict[str, Any]] = []
- for item in custom_raw:
- g = _build_custom_goal(stage, str(item or ""), language)
- if g:
- custom_goals.append(g)
-
- if mode == "auto":
- out = list(goals)
- else:
- out = []
- for g in goals:
- if _goal_label_canonical(g) in selected_set:
- out.append(g)
-
- if mode in {"manual", "mixed"}:
- out.extend(custom_goals)
-
- # Deduplicate by canonical goal label to keep deterministic cursor behavior.
- dedup: Dict[str, Dict[str, Any]] = {}
- for g in out:
- key = _goal_label_canonical(g)
- if key and key not in dedup:
- dedup[key] = g
- return list(dedup.values())
-
-
-def _tokenize(text: str) -> List[str]:
- return [
- x
- for x in re.sub(r"[^\w\s-]+", " ", (text or "").lower(), flags=re.UNICODE).split()
- if len(x) >= 2
- ]
-
-
-def _tokenize_ngrams_strict(text: str) -> List[str]:
- """
- Tokenizer for strict n-gram matching.
- В отличие от _tokenize() здесь НЕ отбрасываем короткие токены (например, "a"),
- чтобы совпадать с теми же n-граммами, которые генерирует логика BM25.
- """
- return [
- x
- for x in re.sub(r"[^\w\s-]+", " ", (text or "").lower(), flags=re.UNICODE).split()
- if x
- ]
-
-
-def _count_term_ngrams_strict(text: str, term: str) -> int:
- """
- Strict count of exact token n-gram occurrences (overlapping allowed).
- Считает вхождения term как последовательности токенов.
- """
- text_tokens = _tokenize_ngrams_strict(text)
- term_tokens = _tokenize_ngrams_strict(term)
- m = len(term_tokens)
- if m == 0 or not text_tokens or len(text_tokens) < m:
- return 0
-
- cnt = 0
- # Sliding window over token sequence
- for i in range(0, len(text_tokens) - m + 1):
- if text_tokens[i : i + m] == term_tokens:
- cnt += 1
- return cnt
-
-
-def _filter_stopwords(tokens: List[str], language: str) -> List[str]:
- stop = STOP_WORDS.get(language, STOP_WORDS["en"])
- return [t for t in tokens if t not in stop]
-
-
-def _split_sentences(text: str) -> List[str]:
- text = (text or "").strip()
- if not text:
- return []
- parts = re.split(r"(?<=[\.\!\?])\s+", text)
- parts = [p.strip() for p in parts if p.strip()]
- if len(parts) <= 1:
- parts = [p.strip() for p in re.split(r"\n+", text) if p.strip()]
- return parts
-
-
-def _escape_html(v: Any) -> str:
- return html_lib.escape(str(v or ""), quote=True)
-
-
-def _diff_sentences_html(before_text: str, after_text: str) -> Tuple[str, List[Dict[str, str]]]:
- """
- sentence-level diff for UI highlighting.
- Возвращает:
- - html для отображения ТОЛЬКО after_text (optimized),
- - список блоков что было/что стало (для "что именно менять").
- """
- before_sents = _split_sentences(before_text)
- after_sents = _split_sentences(after_text)
-
- matcher = difflib.SequenceMatcher(None, before_sents, after_sents, autojunk=False)
- parts: List[str] = []
- changes: List[Dict[str, str]] = []
-
- for tag, i1, i2, j1, j2 in matcher.get_opcodes():
- if tag == "equal":
- for j in range(j1, j2):
- parts.append(_escape_html(after_sents[j]))
- elif tag == "replace":
- from_txt = " ".join(before_sents[i1:i2]).strip()
- to_txt = " ".join(after_sents[j1:j2]).strip()
- changes.append({"type": "replace", "from": from_txt, "to": to_txt})
- for j in range(j1, j2):
- parts.append(
- f'{_escape_html(after_sents[j])}'
- )
- elif tag == "insert":
- to_txt = " ".join(after_sents[j1:j2]).strip()
- changes.append({"type": "insert", "from": "", "to": to_txt})
- for j in range(j1, j2):
- parts.append(
- f'{_escape_html(after_sents[j])}'
- )
- elif tag == "delete":
- from_txt = " ".join(before_sents[i1:i2]).strip()
- changes.append({"type": "delete", "from": from_txt, "to": ""})
- else:
- # Defensive: should not happen.
- for j in range(j1, j2):
- parts.append(_escape_html(after_sents[j]))
-
- diff_html = " ".join(parts).strip()
- return diff_html, changes
-
-
-def _diff_title_html(before_title: str, after_title: str) -> Tuple[str, List[Dict[str, str]]]:
- before_t = (before_title or "").strip()
- after_t = (after_title or "").strip()
- if before_t == after_t:
- return "", []
- if not after_t:
- # Title removed: show nothing in UI, but keep "from/to" for debug.
- return "", [{"type": "delete", "from": before_t, "to": ""}]
- return (
- f'{_escape_html(after_t)}',
- [{"type": "replace", "from": before_t, "to": after_t}],
- )
-
-
-def _max_sentences_for_level(cascade_level: int, operation: str) -> int:
- if operation == "insert":
- return 2
- if cascade_level <= 1:
- return 2
- if cascade_level == 2:
- return 3
- return 4
-
-
-def _phrase_strategy_variants(mode: str, goal_type: str, goal_label: str) -> List[str]:
- normalized = (mode or "auto").strip().lower()
- if normalized == "ensemble":
- if goal_type != "bert":
- return ["auto"]
- phrase_len = len(_tokenize(goal_label or ""))
- if phrase_len >= 3:
- return ["distributed_preferred", "auto", "exact_preferred"]
- return ["auto", "exact_preferred", "distributed_preferred"]
- if normalized in {"auto", "exact_preferred", "distributed_preferred"}:
- return [normalized]
- return ["auto"]
-
-
-def _build_phrase_strategy_plan(mode: str, goal_type: str, goal_label: str, count: int) -> List[str]:
- variants = _phrase_strategy_variants(mode, goal_type, goal_label)
- if count <= 0:
- return []
- return [variants[i % len(variants)] for i in range(count)]
-
-
-def _validate_title_candidate(edited_text: str) -> List[str]:
- """Guardrails for HTML
plain text (not body sentences)."""
- reasons: List[str] = []
- text = (edited_text or "").strip()
- if not text:
- reasons.append("empty_title")
- return reasons
- if "\n" in text or "\r" in text:
- reasons.append("title_multiline")
- if len(text) > 90:
- reasons.append("title_too_long>90")
- if len(text) < 8:
- reasons.append("title_too_short")
- if re.search(r"<[^>]+>", text):
- reasons.append("title_contains_html_tags")
- return reasons
-
-
-def _validate_candidate_text(
- edited_text: str,
- cascade_level: int,
- operation: str,
- goal_label: str = "",
- focus_terms: Optional[List[str]] = None,
-) -> List[str]:
- reasons: List[str] = []
- text = (edited_text or "").strip()
- if not text:
- reasons.append("empty_candidate")
- return reasons
-
- sentence_count = len(_split_sentences(text))
- max_sent = _max_sentences_for_level(cascade_level, operation)
- if sentence_count > max_sent:
- reasons.append(f"too_many_sentences>{max_sent}")
-
- # Heuristic quality checks: duplicated words/entities and obvious malformed token joins.
- if re.search(r"\b([A-Za-z][A-Za-z0-9-]{1,})\s+\1\b", text, flags=re.IGNORECASE):
- reasons.append("duplicated_entity_or_word")
- # Catch broken lowercase+Camel join artifacts like "likemBit", but allow brand CamelCase like "RedDogCasino".
- if re.search(r"\b[a-z]{6,}[A-Z][a-z]+\b", text):
- reasons.append("suspicious_token_join")
-
- # Anti-stuffing checks for BERT phrase goals.
- focus_terms = focus_terms or []
- phrase = (goal_label or "").strip().lower()
- normalized = re.sub(r"\s+", " ", text.lower())
-
- if phrase:
- phrase_occurrences = normalized.count(phrase)
- phrase_token_count = len(_tokenize(phrase))
- # For long goal phrases, repeated exact matches are usually unnatural.
- if phrase_token_count >= 3 and phrase_occurrences > 1:
- reasons.append("exact_phrase_stuffing")
- elif phrase_occurrences > 2:
- reasons.append("exact_phrase_stuffing")
-
- for term in focus_terms:
- tok = (term or "").strip().lower()
- if not tok:
- continue
- term_occurrences = len(re.findall(rf"\b{re.escape(tok)}\b", normalized))
- if term_occurrences > 3:
- reasons.append("focus_term_overuse")
- break
-
- return reasons
-
-
-def _build_analysis_snapshot(
- target_text: str,
- competitors: List[str],
- keywords: List[str],
- language: str,
- target_title: str,
- competitor_titles: List[str],
-) -> Dict[str, Any]:
- wc_target = logic.count_words(target_text, language)
- wc_comp = [logic.count_words(t, language) for t in competitors]
- if wc_comp:
- avg_total = sum(c["total"] for c in wc_comp) / len(wc_comp)
- avg_sig = sum(c["significant"] for c in wc_comp) / len(wc_comp)
- else:
- avg_total = 0
- avg_sig = 0
-
- ngram_stats = logic.calculate_ngram_stats(target_text, competitors, language)
- key_phrases, _ = logic.parse_keywords(keywords, language)
- bm25 = logic.calculate_bm25_recommendations(target_text, competitors, keywords, language)
- bert = logic.perform_bert_analysis(target_text, competitors, key_phrases, language)
-
- title_data = {}
- if (target_title or "").strip():
- title_data = logic.analyze_title(target_title, competitor_titles, keywords, language)
-
- return {
- "word_counts": {
- "target": wc_target,
- "competitors": wc_comp,
- "avg": {"total": round(avg_total), "significant": round(avg_sig)},
- },
- "ngram_stats": ngram_stats,
- "bm25_recommendations": bm25,
- "bert_analysis": bert,
- "title_analysis": title_data,
- }
-
-
-def _build_semantic_snapshot(
- target_text: str,
- competitors: List[str],
- language: str,
-) -> Dict[str, Any]:
- def _build_doc(text: str, doc_id: int) -> Dict[str, Any]:
- sentences_data = nlp_processor.preprocess_text(text, language)
- graph, word_weights = semantic_graph.build_semantic_graph(sentences_data, lang=language)
- graph_data = semantic_graph.get_graph_data_for_frontend(graph)
- return {
- "id": doc_id,
- "text": text,
- "word_weights": word_weights,
- "stats": {
- "nodes": len(graph_data.get("nodes", [])),
- "links": len(graph_data.get("links", [])),
- },
- }
-
- target_doc = _build_doc(target_text, 0)
- comp_docs = []
- for idx, c in enumerate([x for x in competitors if (x or "").strip()]):
- comp_docs.append(_build_doc(c, idx + 1))
-
- num_comp = len(comp_docs)
- target_weights = target_doc["word_weights"]
- all_terms = set(target_weights.keys())
- for c in comp_docs:
- all_terms.update(c["word_weights"].keys())
-
- term_power_table = []
- for term in all_terms:
- target_weight = int(target_weights.get(term, 0))
- comp_weights = [int(c["word_weights"].get(term, 0)) for c in comp_docs]
- comp_avg = round(sum(comp_weights) / max(1, num_comp), 2)
- comp_occ = sum(1 for w in comp_weights if w > 0)
- term_power_table.append(
- {
- "term": term,
- "target_weight": target_weight,
- "competitor_avg_weight": comp_avg,
- "comp_occurrence": comp_occ,
- "comp_total": num_comp,
- }
- )
- return {"comparison": {"term_power_table": term_power_table, "num_competitors": num_comp}}
-
-
-def _is_semantic_gap(target_weight: float, competitor_avg_weight: float) -> bool:
- # Gap is significant only when competitor is meaningfully above target:
- # - relative margin above tolerance band (15%)
- # - and absolute margin to avoid micro-noise around normalized weights
- if competitor_avg_weight <= 0:
- return False
- abs_gap = competitor_avg_weight - target_weight
- rel_threshold = target_weight * (1.0 + SEMANTIC_GAP_TOLERANCE_PCT)
- return (competitor_avg_weight > rel_threshold) and (abs_gap >= SEMANTIC_GAP_MIN_ABS)
-
-
-def _ngram_tolerance_pct(competitor_avg: float) -> float:
- # User rule:
- # - avg >= 4 -> +/-20%
- # - avg < 4 -> +/-50%
- return 0.20 if competitor_avg >= 4.0 else 0.50
-
-
-def _is_ngram_outside_tolerance(target_count: float, competitor_avg: float) -> bool:
- if competitor_avg <= 0:
- return False
- tol = _ngram_tolerance_pct(competitor_avg)
- low = competitor_avg * (1.0 - tol)
- high = competitor_avg * (1.0 + tol)
- return target_count < low or target_count > high
-
-
-def _ngram_deviation_ratio(target_count: float, competitor_avg: float) -> float:
- if competitor_avg <= 0:
- return 0.0
- return abs(target_count - competitor_avg) / max(competitor_avg, 1e-6)
-
-
-def _keyword_unigram_set(keywords: List[str], language: str) -> set:
- out = set()
- for kw in keywords:
- toks = _filter_stopwords(_tokenize(kw), language)
- for t in toks:
- out.add(t)
- return out
-
-
-def _is_ngram_stage_candidate(
- ngram_label: str,
- comp_occurrence: int,
- competitor_count: int,
- keyword_unigrams: set,
-) -> bool:
- ngram = (ngram_label or "").strip().lower()
- if not ngram:
- return False
- tokens = _tokenize(ngram)
- n = len(tokens)
- if competitor_count > 1:
- if comp_occurrence < 2:
- return False
- if n >= 2:
- # For multi-competitor mode, bi/tri-grams with K>=2 are always candidates.
- return True
- # Unigrams are candidates only if they belong to key phrases.
- return n == 1 and tokens[0] in keyword_unigrams
- # Single-competitor mode: keep broader eligibility.
- return comp_occurrence >= 1
-
-
-def _chunk_ngram_count(text: str, ngram_label: str, language: str) -> int:
- toks = _filter_stopwords(_tokenize(text), language)
- phrase_toks = _filter_stopwords(_tokenize(ngram_label), language)
- if not toks or not phrase_toks:
- return 0
- n = len(phrase_toks)
- if n == 1:
- term = phrase_toks[0]
- return sum(1 for t in toks if t == term)
- count = 0
- for i in range(0, max(0, len(toks) - n + 1)):
- if toks[i : i + n] == phrase_toks:
- count += 1
- return count
-
-
-def _build_ngram_stage_rows(
- analysis: Dict[str, Any],
- keywords: List[str],
- language: str,
-) -> List[Tuple[str, float, float, float, int, float]]:
- """
- Eligible underrepresented n-gram targets for the ngram stage.
- Each row: (ngram_label, target_count, comp_avg, tolerance_pct, comp_occurrence, dev_ratio).
-
- Priority (user policy): maximize competitor coverage Freq(K), then Avg(K), then
- how far below the band the target is (larger deviation first).
- Only terms with My=0 or outside the tolerance band below average are included.
- """
- ngram_rows: List[Tuple[str, float, float, float, int, float]] = []
- ngram_stats = analysis.get("ngram_stats", {}) or {}
- competitor_count = len((analysis.get("word_counts", {}) or {}).get("competitors", []) or [])
- keyword_unigrams = _keyword_unigram_set(keywords, language)
- for _bucket_name, bucket in ngram_stats.items():
- if not isinstance(bucket, list):
- continue
- for item in bucket:
- ngram_label = str(item.get("ngram", "")).strip()
- if not ngram_label:
- continue
- target = float(item.get("target_count", 0))
- comp_avg = float(item.get("competitor_avg", 0))
- comp_occ = int(item.get("comp_occurrence", 0))
- if not _is_ngram_stage_candidate(ngram_label, comp_occ, competitor_count, keyword_unigrams):
- continue
- if not _is_ngram_outside_tolerance(target, comp_avg):
- continue
- if target >= comp_avg:
- continue
- tol = _ngram_tolerance_pct(comp_avg)
- dev_ratio = _ngram_deviation_ratio(target, comp_avg)
- ngram_rows.append((ngram_label, target, comp_avg, tol, comp_occ, dev_ratio))
- ngram_rows.sort(key=lambda x: (x[4], x[2], x[5]), reverse=True)
- return ngram_rows
-
-
-def _score_ngram_candidate_window(window_sentences: List[str], goal_label: str, language: str) -> float:
- """Heuristic: good place to add phrase — low local duplication, topical proximity, not boilerplate."""
- chunk = " ".join(s for s in window_sentences if s).strip()
- if not chunk:
- return -1e6
- phrase_count = float(_chunk_ngram_count(chunk, goal_label, language))
- noise_n = sum(1 for s in window_sentences if _is_noise_like_sentence(s))
- noise_frac = noise_n / max(1, len(window_sentences))
- phrase_tokens = [t.lower() for t in _filter_stopwords(_tokenize(goal_label), language) if t]
- chunk_l = chunk.lower()
- unigram_hits = sum(1 for t in phrase_tokens if t and len(t) > 1 and t in chunk_l)
- rel_proxy = unigram_hits / max(1, len(phrase_tokens)) if phrase_tokens else 0.0
- return (
- -3.0 * phrase_count
- + 2.2 * rel_proxy
- - 4.0 * noise_frac
- + min(len(chunk) / 1200.0, 0.35)
- )
-
-
-def _rank_ngram_overlap_sentence_indices(
- sentences: List[str],
- goal_label: str,
- language: str,
-) -> List[int]:
- """
- Slide overlapping multi-sentence windows over the document; each sentence gets the
- best score among windows that contain it. Order sentences by that score (desc).
- """
- n = len(sentences)
- if n <= 0:
- return [0]
- if n == 1:
- return [0]
- # 2–4 sentences per window, stride 1 for strong overlap.
- w = min(4, max(2, n))
- best: List[float] = [-1e9] * n
- for start in range(0, n - w + 1):
- win = sentences[start : start + w]
- sc = _score_ngram_candidate_window(win, goal_label, language)
- for j in range(start, start + w):
- if sc > best[j]:
- best[j] = sc
- center = (n - 1) / 2.0
- scored_idx = [(i, best[i], -abs(i - center)) for i in range(n)]
- scored_idx.sort(key=lambda t: (t[1], t[2]), reverse=True)
- return [t[0] for t in scored_idx]
-
-
-def _compute_metrics(
- analysis: Dict[str, Any],
- semantic: Dict[str, Any],
- keywords: List[str],
- language: str,
- bert_stage_target: float = BERT_TARGET_THRESHOLD,
-) -> Dict[str, Any]:
- competitor_count = len(analysis.get("word_counts", {}).get("competitors", []))
- min_signal = 1 if competitor_count <= 1 else 2
-
- bert_details = analysis.get("bert_analysis", {}).get("detailed", []) or []
- bert_low = [d for d in bert_details if float(d.get("my_max_score", 0)) < float(bert_stage_target)]
- bert_phrase_scores = {
- str(d.get("phrase", "")).strip().lower(): float(d.get("my_max_score", 0) or 0.0)
- for d in bert_details
- if str(d.get("phrase", "")).strip()
- }
-
- bm25_remove = [x for x in (analysis.get("bm25_recommendations") or []) if x.get("action") == "remove"]
- bm25_remove_count = len(bm25_remove)
-
- ngram_signal_count = 0
- ngram_gap_sum = 0.0
- keyword_unigrams = _keyword_unigram_set(keywords, language)
- ngrams = analysis.get("ngram_stats", {}) or {}
- for bucket in ngrams.values():
- if not isinstance(bucket, list):
- continue
- for item in bucket:
- comp_occ = int(item.get("comp_occurrence", 0))
- ngram_label = str(item.get("ngram", ""))
- if not _is_ngram_stage_candidate(ngram_label, comp_occ, competitor_count, keyword_unigrams):
- continue
- target = float(item.get("target_count", 0))
- comp_avg = float(item.get("competitor_avg", 0))
- if _is_ngram_outside_tolerance(target, comp_avg):
- ngram_signal_count += 1
- ngram_gap_sum += _ngram_deviation_ratio(target, comp_avg)
-
- title_score = None
- title_bert = analysis.get("title_analysis", {}).get("bert", {})
- if title_bert and title_bert.get("target_score") is not None:
- title_score = float(title_bert.get("target_score", 0))
-
- keyword_terms = set()
- for kw in keywords:
- tokens = _filter_stopwords(_tokenize(kw), language)
- for t in tokens:
- keyword_terms.add(t)
- for n in (2, 3):
- for i in range(0, max(0, len(tokens) - n + 1)):
- keyword_terms.add(" ".join(tokens[i : i + n]))
-
- table = semantic.get("comparison", {}).get("term_power_table", []) or []
- by_term = {str(r.get("term", "")).lower(): r for r in table}
- semantic_gap_count = 0
- semantic_gap_sum = 0.0
- semantic_gap_terms: List[Dict[str, Any]] = []
- for term in keyword_terms:
- row = by_term.get(term)
- if not row:
- continue
- target_w = float(row.get("target_weight", 0))
- comp_w = float(row.get("competitor_avg_weight", 0))
- gap = comp_w - target_w
- if int(row.get("comp_occurrence", 0)) >= min_signal and _is_semantic_gap(target_w, comp_w):
- semantic_gap_count += 1
- semantic_gap_sum += gap
- base = max(1.0, target_w)
- semantic_gap_terms.append(
- {
- "term": term,
- "target_weight": round(target_w, 2),
- "competitor_avg_weight": round(comp_w, 2),
- "gap": round(gap, 2),
- "gap_pct_of_target": round(gap / base, 4),
- "comp_occurrence": int(row.get("comp_occurrence", 0)),
- "comp_total": int(row.get("comp_total", 0)),
- }
- )
-
- # Composite score (0..100)
- w_bert, w_bm25, w_ng, w_title, w_sem = 30, 20, 15, 10, 25
- bert_comp = 1.0 - (len(bert_low) / max(1, len(bert_details)))
- bm25_comp = 1.0 if bm25_remove_count <= 3 else max(0.0, 1.0 - ((bm25_remove_count - 3) / 10.0))
- ng_comp = max(0.0, 1.0 - (ngram_signal_count / 15.0))
- title_comp = 1.0 if title_score is None else min(1.0, max(0.0, title_score / 0.65))
- sem_comp = max(0.0, 1.0 - (semantic_gap_count / 20.0))
-
- weighted = (
- w_bert * bert_comp
- + w_bm25 * bm25_comp
- + w_ng * ng_comp
- + w_title * title_comp
- + w_sem * sem_comp
- )
- total_w = w_bert + w_bm25 + w_ng + w_title + w_sem
- score = round((weighted / total_w) * 100.0, 2)
-
- resolved_title = ""
- _td = analysis.get("title_analysis") or {}
- if isinstance(_td, dict) and (_td.get("target_title") or "").strip():
- resolved_title = str(_td.get("target_title")).strip()
-
- return {
- "score": score,
- "competitor_count": competitor_count,
- "min_competitor_signal": min_signal,
- "bert_low_count": len(bert_low),
- "bert_total_keywords": len(bert_details),
- "bert_phrase_scores": bert_phrase_scores,
- "bm25_remove_count": bm25_remove_count,
- "ngram_signal_count": ngram_signal_count,
- "ngram_gap_sum": round(ngram_gap_sum, 4),
- "title_bert_score": title_score,
- "resolved_title": resolved_title,
- "semantic_gap_count": semantic_gap_count,
- "semantic_gap_sum": round(semantic_gap_sum, 4),
- "semantic_gap_terms": sorted(
- semantic_gap_terms,
- key=lambda x: (x.get("gap", 0), x.get("gap_pct_of_target", 0)),
- reverse=True,
- )[:20],
- }
-
-
-def _choose_optimization_goal(
- analysis: Dict[str, Any],
- semantic: Dict[str, Any],
- keywords: List[str],
- language: str,
- stage: str = "bert",
- bert_stage_target: float = BERT_TARGET_THRESHOLD,
- stage_cursor: int = 0,
-) -> Dict[str, Any]:
- goals = _collect_optimization_goals(
- analysis=analysis,
- semantic=semantic,
- keywords=keywords,
- language=language,
- stage=stage,
- bert_stage_target=bert_stage_target,
- )
- if not goals:
- return {"type": "none", "label": "no-op", "focus_terms": [], "avoid_terms": []}
- pick = max(0, int(stage_cursor))
- if pick >= len(goals):
- return {"type": "none", "label": "no-op", "focus_terms": [], "avoid_terms": []}
- return goals[pick]
-
-
-def _collect_optimization_goals(
- analysis: Dict[str, Any],
- semantic: Dict[str, Any],
- keywords: List[str],
- language: str,
- stage: str = "bert",
- bert_stage_target: float = BERT_TARGET_THRESHOLD,
- stage_goal_overrides: Optional[Dict[str, Any]] = None,
-) -> List[Dict[str, Any]]:
- goals: List[Dict[str, Any]] = []
- bert_details = analysis.get("bert_analysis", {}).get("detailed", []) or []
- low_bert = [x for x in bert_details if float(x.get("my_max_score", 0)) < float(bert_stage_target)]
- if low_bert:
- for row in sorted(low_bert, key=lambda x: float(x.get("my_max_score", 0))):
- phrase = str(row.get("phrase", "")).strip()
- if not phrase:
- continue
- focus_terms = _filter_stopwords(_tokenize(phrase), language)[:4]
- goals.append(
- {
- "type": "bert",
- "label": phrase,
- "focus_terms": focus_terms,
- "avoid_terms": [],
- "bert_phrase_score": float(row.get("my_max_score", 0) or 0.0),
- "bert_target": float(bert_stage_target),
- }
- )
-
- bm25_remove = [x for x in (analysis.get("bm25_recommendations") or []) if x.get("action") == "remove"]
- if len(bm25_remove) >= 4:
- for row in sorted(bm25_remove, key=lambda r: int(r.get("count", 0)), reverse=True)[:8]:
- word = str(row.get("word", "")).strip()
- if not word:
- continue
- goals.append(
- {
- "type": "bm25",
- "label": f"reduce spam: {word}",
- "focus_terms": [],
- "avoid_terms": [word],
- "bm25_count": int(row.get("count", 0) or 0),
- "bm25_word": word,
- }
- )
-
- # Semantic keyword gaps
- lang_stop = STOP_WORDS.get(language, STOP_WORDS["en"])
- keyword_terms = set()
- for kw in keywords:
- toks = [t for t in _tokenize(kw) if t not in lang_stop]
- keyword_terms.update(toks)
- for n in (2, 3):
- for i in range(0, max(0, len(toks) - n + 1)):
- keyword_terms.add(" ".join(toks[i : i + n]))
- table = semantic.get("comparison", {}).get("term_power_table", []) or []
- candidate_rows: List[Tuple[str, float]] = []
- for row in table:
- term = str(row.get("term", "")).lower()
- if term not in keyword_terms:
- continue
- target_w = float(row.get("target_weight", 0))
- comp_w = float(row.get("competitor_avg_weight", 0))
- gap = comp_w - target_w
- if _is_semantic_gap(target_w, comp_w):
- candidate_rows.append((term, gap))
- if candidate_rows:
- for term, gap in sorted(candidate_rows, key=lambda x: x[1], reverse=True)[:12]:
- goals.append(
- {
- "type": "semantic",
- "label": term,
- "focus_terms": [term],
- "avoid_terms": [],
- "semantic_gap": float(gap),
- }
- )
-
- # N-gram balancing (toward competitor average with tolerance policy).
- ngram_rows = _build_ngram_stage_rows(analysis, keywords, language)
- if ngram_rows:
- for rank, (label, target, comp_avg, tol, _, _) in enumerate(ngram_rows):
- goals.append({
- "type": "ngram",
- "label": label,
- "focus_terms": [label],
- "avoid_terms": [],
- "ngram_target_count": target,
- "ngram_comp_avg": comp_avg,
- "ngram_tolerance_pct": tol,
- "ngram_lower_bound": round(comp_avg * (1.0 - tol), 3),
- "ngram_upper_bound": round(comp_avg * (1.0 + tol), 3),
- "ngram_direction": "increase" if target < comp_avg else "decrease",
- "ngram_rank_index": rank,
- "ngram_candidates_total": len(ngram_rows),
- })
-
- title_bert = analysis.get("title_analysis", {}).get("bert", {}) or {}
- title_target_score = title_bert.get("target_score")
- if (
- keywords
- and title_target_score is not None
- and float(title_target_score) < TITLE_TARGET_THRESHOLD
- ):
- goals.append(
- {
- "type": "title",
- "label": "title alignment",
- "focus_terms": _filter_stopwords(_tokenize(" ".join(keywords[:8])), language)[:8],
- "avoid_terms": [],
- "title_bert_score": float(title_target_score) if title_target_score is not None else None,
- "title_target": float(TITLE_TARGET_THRESHOLD),
- }
- )
-
- stage_goals = [g for g in goals if g.get("type") == stage]
- return _apply_stage_goal_override(stage_goals, stage, language, stage_goal_overrides)
-
-
-def _per_goal_budget(
- goal: Dict[str, Any],
- max_iterations: int,
- candidates_per_iteration: int,
- bert_stage_target: float,
-) -> Tuple[int, int]:
- """
- Scale per-goal iteration and candidate budgets by how far the metric is from its target.
- Returns (effective_max_iterations_for_this_goal, effective_candidates_per_iteration).
- """
- t = str(goal.get("type", "") or "")
- raw = 0.0
-
- if t == "bert":
- sc = float(goal.get("bert_phrase_score", 0.0) or 0.0)
- tgt = float(goal.get("bert_target", bert_stage_target) or bert_stage_target)
- raw = max(0.0, (tgt - sc) / max(tgt, 1e-6))
- elif t == "ngram":
- ca = float(goal.get("ngram_comp_avg", 0.0) or 0.0)
- tc = float(goal.get("ngram_target_count", 0.0) or 0.0)
- if str(goal.get("ngram_direction", "increase")) == "increase":
- need = max(0.0, ca - tc)
- raw = min(1.0, need / max(ca, 1e-6))
- else:
- need = max(0.0, tc - ca)
- raw = min(1.0, need / max(tc, 1e-6))
- elif t == "semantic":
- gap = float(goal.get("semantic_gap", 0.0) or 0.0)
- raw = min(1.0, gap / max(SEMANTIC_GAP_MIN_ABS * 4.0, 1e-6))
- elif t == "bm25":
- c = int(goal.get("bm25_count", 0) or 0)
- raw = min(1.0, max(0, c - 1) / 8.0)
- elif t == "title":
- ts = goal.get("title_bert_score")
- if ts is None:
- raw = 0.5
- else:
- tgt = float(goal.get("title_target", TITLE_TARGET_THRESHOLD) or TITLE_TARGET_THRESHOLD)
- raw = max(0.0, (tgt - float(ts)) / max(tgt, 1e-6))
- else:
- raw = 0.0
-
- iter_mult = 1.0 + 2.0 * min(1.0, raw)
- cand_mult = 1.0 + 1.0 * min(1.0, raw)
- eff_iter = max(1, min(int(round(max_iterations * iter_mult)), max_iterations * 3))
- eff_cand = max(1, min(int(round(candidates_per_iteration * cand_mult)), 5))
- return eff_iter, eff_cand
-
-
-def _estimate_total_loop_budget(
- analysis: Dict[str, Any],
- semantic: Dict[str, Any],
- keywords: List[str],
- language: str,
- max_iterations: int,
- candidates_per_iteration: int,
- bert_stage_target: float,
- active_stage_order: Optional[List[str]] = None,
- stage_goal_overrides: Optional[Dict[str, Any]] = None,
-) -> int:
- total = 0
- stages = active_stage_order or list(STAGE_ORDER)
- for st in stages:
- for g in _collect_optimization_goals(
- analysis,
- semantic,
- keywords,
- language,
- stage=st,
- bert_stage_target=bert_stage_target,
- stage_goal_overrides=stage_goal_overrides,
- ):
- ei, _ = _per_goal_budget(g, max_iterations, candidates_per_iteration, bert_stage_target)
- total += ei
- return min(480, max(1, total))
-
-
-def _choose_sentence_idx(sentences: List[str], focus_terms: List[str], avoid_terms: List[str], language: str) -> int:
- if not sentences:
- return 0
- stop = STOP_WORDS.get(language, STOP_WORDS["en"])
- focus = [x for x in focus_terms if x and x not in stop]
-
- if avoid_terms:
- best_idx, best_score = 0, -1.0
- for i, s in enumerate(sentences):
- lower = s.lower()
- score = sum(lower.count(t.lower()) for t in avoid_terms if t)
- if score > best_score:
- best_idx, best_score = i, score
- return best_idx
-
- if focus:
- best_idx, best_score = 0, -1.0
- for i, s in enumerate(sentences):
- lower = s.lower()
- score = sum(lower.count(t.lower()) for t in focus)
- if score > best_score:
- best_idx, best_score = i, score
- return best_idx
-
- return min(2, len(sentences) - 1)
-
-
-def _rank_sentence_indices(
- sentences: List[str],
- focus_terms: List[str],
- avoid_terms: List[str],
- language: str,
- goal_type: str = "",
- goal_label: str = "",
-) -> List[int]:
- if not sentences:
- return [0]
- stop = STOP_WORDS.get(language, STOP_WORDS["en"])
- focus = [x for x in focus_terms if x and x not in stop]
- goal_phrase = (goal_label or "").strip().lower()
- avoid = [x for x in avoid_terms if x]
- center = (len(sentences) - 1) / 2.0
-
- # N-gram stage: overlapping sentence windows — pick spans where insertion is natural
- # while document-level phrase count remains the primary optimization signal.
- if goal_type == "ngram" and (goal_label or "").strip():
- return _rank_ngram_overlap_sentence_indices(sentences, str(goal_label).strip(), language)
-
- # For BERT optimization prefer natural prose chunks over list/menu/noisy blocks.
- candidate_indices = list(range(len(sentences)))
- if goal_type == "bert":
- non_noise = [i for i, s in enumerate(sentences) if not _is_noise_like_sentence(s)]
- if non_noise:
- candidate_indices = non_noise
-
- scored: List[Tuple[int, float, int]] = []
- for idx in candidate_indices:
- s = sentences[idx]
- lower = s.lower()
- # For bm25 "remove" goals we must select spans that actually contain the exact n-gram term.
- # Otherwise the LLM can "optimize around" the goal without decreasing the real BM25 remove metric.
- if goal_type == "bm25" and avoid and not focus_terms:
- bm25_term = avoid[0]
- bm25_count = _count_term_ngrams_strict(s, bm25_term)
- focus_score = 0
- avoid_score = float(bm25_count)
- chunk_rel = float(bm25_count)
- else:
- focus_score = sum(lower.count(t.lower()) for t in focus)
- avoid_score = sum(lower.count(t.lower()) for t in avoid)
- chunk_rel = _chunk_goal_relevance(s, goal_type, goal_label, focus_terms, language)
- noise_penalty = 1.0 if _is_noise_like_sentence(s) else 0.0
-
- # For BERT goals, do not over-focus on existing occurrences only:
- # prioritize semantically relevant chunks where phrase/terms may still be underrepresented.
- if goal_type == "bert":
- tokenized = _filter_stopwords(_tokenize(lower), language)
- token_set = set(tokenized)
- core_terms = [t.lower() for t in focus if t]
- core_hits = sum(1 for t in core_terms if t in token_set)
- coverage = (core_hits / max(1, len(core_terms))) if core_terms else 0.0
- phrase_present = 1.0 if (goal_phrase and goal_phrase in lower) else 0.0
- # Boost candidates where semantic context is relevant but explicit core terms are not saturated yet.
- missing_term_boost = (1.0 - coverage) * 1.4 if chunk_rel >= 0.18 else 0.0
- phrase_absent_boost = 0.35 if (goal_phrase and not phrase_present and chunk_rel >= 0.2) else 0.0
- score = (
- (chunk_rel * 5.0)
- + (focus_score * 1.2)
- + (missing_term_boost + phrase_absent_boost)
- + (avoid_score * 1.5)
- - (noise_penalty * 3.0)
- - (abs(idx - center) * 0.05)
- )
- else:
- # Prefer semantically relevant and lexical matches; push noisy headers/CTA lower.
- score = (chunk_rel * 4.0) + (focus_score * 3.0) + (avoid_score * 2.0) - (noise_penalty * 3.0) - (abs(idx - center) * 0.05)
- scored.append((idx, score, len(s)))
-
- scored.sort(key=lambda x: (x[1], -x[2]), reverse=True)
- ordered = [idx for idx, _, _ in scored]
-
- if not ordered:
- ordered = list(range(len(sentences)))
- return ordered
-
-
-def _span_variants_for_level(cascade_level: int) -> List[Tuple[str, int, int]]:
- # (operation, left_radius, right_radius)
- if cascade_level <= 1:
- return [("rewrite", 0, 0), ("rewrite", 0, 1), ("rewrite", 1, 0)]
- if cascade_level == 2:
- return [("rewrite", 1, 1), ("rewrite", 0, 2), ("rewrite", 2, 0), ("rewrite", 1, 2), ("rewrite", 2, 1)]
- if cascade_level == 3:
- return [("insert", 0, 0), ("insert", 0, 0), ("insert", 0, 0), ("rewrite", 1, 1)]
- return [("rewrite", 2, 2), ("rewrite", 1, 3), ("rewrite", 3, 1), ("rewrite", 2, 3), ("rewrite", 3, 2)]
-
-
-def _choose_edit_span(
- sentences: List[str],
- goal: Dict[str, Any],
- language: str,
- cascade_level: int,
- attempt_cursor: int,
-) -> Tuple[str, int, int, int, int]:
- ranked = _rank_sentence_indices(
- sentences,
- goal.get("focus_terms", []) or [],
- goal.get("avoid_terms", []) or [],
- language,
- str(goal.get("type", "") or ""),
- str(goal.get("label", "") or ""),
- )
- variants = _span_variants_for_level(cascade_level)
- total = max(1, len(ranked) * len(variants))
- pick = attempt_cursor % total
- sent_pick = pick % len(ranked)
- variant_pick = (pick // len(ranked)) % len(variants)
-
- sent_idx = ranked[sent_pick]
- operation, left_radius, right_radius = variants[variant_pick]
-
- if operation == "insert":
- span_start = sent_idx
- span_end = sent_idx
- else:
- span_start = max(0, sent_idx - left_radius)
- span_end = min(len(sentences) - 1, sent_idx + right_radius)
-
- return operation, span_start, span_end, sent_idx, variant_pick
-
-
-def _portfolio_span_trials(goal_type: str, cascade_level: int, eff_candidates: int) -> int:
- """Try more distinct spans for hard local goals without exploding candidate count."""
- t = (goal_type or "").strip().lower()
- base = 2 if cascade_level <= 2 else 3
- if t in {"bert", "ngram"}:
- base += 1
- if cascade_level >= 3 and t in {"bert", "ngram", "semantic"}:
- base += 1
- return max(1, min(5, max(base, min(3, int(eff_candidates or 1)))))
-
-
-def _local_prefilter_reasons(
- *,
- goal_type: str,
- focus_terms: List[str],
- edited_text: str,
- chunk_delta: float,
- before_rel: float,
- after_rel: float,
-) -> List[str]:
- """
- Cheap local gate before the expensive full document metric rebuild.
- It rejects only candidates that cannot plausibly help the current goal.
- """
- t = (goal_type or "").strip().lower()
- reasons: List[str] = []
- if t == "bm25" and chunk_delta <= 0:
- reasons.append("local_bm25_term_not_reduced")
- elif t == "ngram" and chunk_delta <= 0:
- reasons.append("local_ngram_not_closer_to_target")
- elif t == "bert" and after_rel < (before_rel - 0.003):
- reasons.append("local_bert_relevance_dropped")
- elif t == "semantic":
- edited_l = (edited_text or "").lower()
- has_focus = any(str(t).strip().lower() in edited_l for t in (focus_terms or []) if str(t).strip())
- if chunk_delta <= 0 and not has_focus:
- reasons.append("local_semantic_focus_missing")
- return reasons
-
-
-def _is_noise_like_sentence(text: str) -> bool:
- s = (text or "").strip()
- if not s:
- return True
- lower = s.lower()
- tokens = _tokenize(lower)
- if len(tokens) <= 2:
- return True
- if len(tokens) <= 8 and re.search(r"\b(play|explore|best|top|contact|login|signup|casino)\b", lower):
- return True
- if len(s) <= 90 and re.fullmatch(r"[A-Z0-9\s\-\|\:\.]+", s):
- return True
- if re.search(r"\b(best|top)\b.{0,20}\b(alternative|alternatives|casino|casinos)\b", lower) and len(tokens) <= 12:
- return True
- return False
-
-
-def _chunk_goal_relevance(
- text: str,
- goal_type: str,
- goal_label: str,
- focus_terms: List[str],
- language: str,
- goal_meta: Optional[Dict[str, Any]] = None,
-) -> float:
- chunk = (text or "").strip()
- if not chunk:
- return 0.0
- if goal_type == "bert" and (goal_label or "").strip():
- try:
- model = logic.get_bert_model()
- embeddings = model.encode([goal_label.strip(), chunk], convert_to_tensor=True)
- return float(logic.util.cos_sim(embeddings[0:1], embeddings[1:2])[0][0].item())
- except Exception:
- pass
- if goal_type == "ngram" and (goal_label or "").strip():
- return float(_chunk_ngram_count(chunk, goal_label, language))
-
- # Lexical fallback for non-BERT goals or if embedding scoring is unavailable.
- toks = _filter_stopwords(_tokenize(chunk), language)
- if not toks:
- return 0.0
- focus = [f.lower() for f in (focus_terms or []) if f]
- if not focus:
- return 0.0
- overlap = 0.0
- token_str = " ".join(toks)
- for f in focus:
- if " " in f:
- overlap += 1.0 if f in token_str else 0.0
- else:
- overlap += 1.0 if f in toks else 0.0
- return overlap / max(1.0, float(len(focus)))
-
-
-def _chunk_goal_delta(
- before_text: str,
- after_text: str,
- goal_type: str,
- goal_label: str,
- focus_terms: List[str],
- language: str,
- goal_meta: Optional[Dict[str, Any]] = None,
-) -> float:
- # BM25 "remove spam" must be judged by strict decrease of the exact n-gram term count.
- # Иначе LLM может улучшать семантику рядом, но не уменьшать реальный BM25 remove metric.
- if goal_type == "bm25":
- term = None
- avoid = []
- focus = []
- if isinstance(goal_meta, dict):
- term = goal_meta.get("bm25_word")
- avoid = goal_meta.get("avoid_terms") or []
- focus = goal_meta.get("focus_terms") or []
- # Fallback: try to infer from avoid_terms
- if not term and avoid:
- term = avoid[0]
- if not term:
- return 0.0
-
- before_count = _count_term_ngrams_strict(before_text or "", str(term))
- after_count = _count_term_ngrams_strict(after_text or "", str(term))
-
- # For now bm25 goals are "remove" (focus_terms is empty, avoid_terms contains term).
- # Keep the sign direction explicit so add-mode can be introduced later safely.
- if avoid and not focus:
- return round(float(before_count - after_count), 4)
- # If ever we add bm25 "add" goals with focus terms, then increase is good.
- return round(float(after_count - before_count), 4)
-
- before_rel = _chunk_goal_relevance(before_text, goal_type, goal_label, focus_terms, language, goal_meta)
- after_rel = _chunk_goal_relevance(after_text, goal_type, goal_label, focus_terms, language, goal_meta)
- if goal_type == "ngram":
- target_avg = float((goal_meta or {}).get("ngram_comp_avg", 0.0))
- if target_avg > 0:
- # Positive delta means closer to competitor average regardless of direction.
- before_dist = abs(before_rel - target_avg)
- after_dist = abs(after_rel - target_avg)
- return round(before_dist - after_dist, 4)
- return round(after_rel - before_rel, 4)
-
-
-def _min_chunk_delta(goal_type: str) -> float:
- if goal_type == "bert":
- return 0.01
- if goal_type == "title":
- return 0.005
- if goal_type == "ngram":
- # Require at least one occurrence-equivalent movement toward target zone.
- return 0.5
- return 0.05
-
-
-def _chunk_relevance_pair(
- before_text: str,
- after_text: str,
- goal_type: str,
- goal_label: str,
- focus_terms: List[str],
- language: str,
- goal_meta: Optional[Dict[str, Any]] = None,
-) -> Tuple[float, float]:
- before_rel = _chunk_goal_relevance(before_text, goal_type, goal_label, focus_terms, language, goal_meta)
- after_rel = _chunk_goal_relevance(after_text, goal_type, goal_label, focus_terms, language, goal_meta)
- return round(before_rel, 4), round(after_rel, 4)
-
-
-def _term_diff(before_text: str, after_text: str, language: str) -> Dict[str, List[str]]:
- before_tokens = _filter_stopwords(_tokenize(before_text), language)
- after_tokens = _filter_stopwords(_tokenize(after_text), language)
- before_set = set(before_tokens)
- after_set = set(after_tokens)
- removed = sorted(list(before_set - after_set))[:12]
- added = sorted(list(after_set - before_set))[:12]
- return {"added_terms": added, "removed_terms": removed}
-
-
-def _edits_conflict(a: Dict[str, Any], b: Dict[str, Any]) -> bool:
- if a.get("operation") == "title_rewrite" or b.get("operation") == "title_rewrite":
- return True
- if a.get("operation") == "insert" or b.get("operation") == "insert":
- return int(a.get("span_start", 0)) == int(b.get("span_start", 0))
- a0, a1 = int(a.get("span_start", 0)), int(a.get("span_end", 0))
- b0, b1 = int(b.get("span_start", 0)), int(b.get("span_end", 0))
- return not (a1 < b0 or b1 < a0)
-
-
-def _apply_edits_to_sentences(sentences: List[str], edits: List[Dict[str, Any]]) -> List[str]:
- updated = list(sentences)
- ordered = sorted(
- edits,
- key=lambda e: (int(e.get("span_start", 0)), int(e.get("span_end", 0))),
- reverse=True,
- )
- for edit in ordered:
- op = str(edit.get("operation", "rewrite"))
- start = int(edit.get("span_start", 0))
- end = int(edit.get("span_end", start))
- text = str(edit.get("edited_text", "")).strip()
- if not text:
- continue
- if op == "insert":
- updated = _insert_after(updated, end, text)
- else:
- updated = _replace_span(updated, start, end, text)
- return updated
-
-
-def _metrics_delta(prev_metrics: Dict[str, Any], next_metrics: Dict[str, Any]) -> Dict[str, Any]:
- keys = [
- "score",
- "bert_low_count",
- "bm25_remove_count",
- "ngram_signal_count",
- "ngram_gap_sum",
- "semantic_gap_count",
- "semantic_gap_sum",
- ]
- out: Dict[str, Any] = {}
- for k in keys:
- pv = prev_metrics.get(k)
- nv = next_metrics.get(k)
- if pv is None or nv is None:
- continue
- out[k] = round(float(nv) - float(pv), 4)
- pv_t = prev_metrics.get("title_bert_score")
- nv_t = next_metrics.get("title_bert_score")
- if pv_t is not None and nv_t is not None:
- out["title_bert_score"] = round(float(nv_t) - float(pv_t), 4)
- prev_terms = {str(x.get("term", "")).lower() for x in (prev_metrics.get("semantic_gap_terms") or []) if x.get("term")}
- next_terms = {str(x.get("term", "")).lower() for x in (next_metrics.get("semantic_gap_terms") or []) if x.get("term")}
- if prev_terms or next_terms:
- out["semantic_gap_terms_added"] = sorted(list(next_terms - prev_terms))[:12]
- out["semantic_gap_terms_removed"] = sorted(list(prev_terms - next_terms))[:12]
- return out
-
-
-def _non_conflicting_edit_combos(candidates: List[Dict[str, Any]], min_size: int = 2, max_size: int = 4) -> List[List[Dict[str, Any]]]:
- if not candidates:
- return []
- n = len(candidates)
- combos: List[List[Dict[str, Any]]] = []
- for r in range(max(2, min_size), min(max_size, n) + 1):
- for idxs in combinations(range(n), r):
- combo = [candidates[i] for i in idxs]
- conflict = False
- for i in range(len(combo)):
- for j in range(i + 1, len(combo)):
- e1 = combo[i].get("edit_payload")
- e2 = combo[j].get("edit_payload")
- if not e1 or not e2:
- conflict = True
- break
- if _edits_conflict(e1, e2):
- conflict = True
- break
- if conflict:
- break
- if not conflict:
- combos.append(combo)
- return combos
-
-
-def _extract_json_object(text: str) -> Optional[Dict[str, Any]]:
- raw = (text or "").strip()
- if not raw:
- return None
- try:
- return json.loads(raw)
- except Exception:
- pass
- m = re.search(r"\{[\s\S]*\}", raw)
- if not m:
- return None
- try:
- return json.loads(m.group(0))
- except Exception:
- return None
-
-
-def _llm_edit_chunk(
- *,
- api_key: str,
- base_url: str,
- model: str,
- language: str,
- full_text: str,
- chunk_text: str,
- operation: str,
- context_before: str,
- context_after: str,
- cascade_level: int,
- goal_type: str,
- goal_label: str,
- focus_terms: List[str],
- avoid_terms: List[str],
- temperature: float,
- phrase_strategy_mode: str = "auto",
-) -> Dict[str, Any]:
- # Title optimization must never touch body text; see optimize_text branch for goal.type == "title".
- if str(goal_type or "").strip().lower() == "title":
- raise ValueError(
- "Internal error: goal_type 'title' must be handled via _llm_edit_title(), not _llm_edit_chunk(). "
- "If you see this on a server, deploy the current optimizer (title-only LLM path)."
- )
- endpoint = base_url.rstrip("/") + "/chat/completions"
- op = operation if operation in {"rewrite", "insert"} else "rewrite"
- system_msg = (
- "You are a semantic-vector optimizer for SEO tasks. "
- "Your task is to improve chunk relevance to the focus terms/goal phrase with minimal local edits. "
- "Preserve narrative flow, factual tone, and language. "
- "Return strict JSON only: {\"edited_text\": \"...\", \"rationale\": \"...\"}. "
- "Do not rewrite the whole text. Never change topic or introduce unrelated entities."
- )
- op_instruction = (
- "Rewrite the provided chunk only."
- if op == "rewrite"
- else "Create a short bridge chunk (1-2 sentences) to insert after the chunk."
- )
- max_sent = _max_sentences_for_level(cascade_level, op)
- phrase_tokens = _filter_stopwords(_tokenize(goal_label or ""), language)
- phrase_len = len(phrase_tokens)
- strategy_mode = (phrase_strategy_mode or "auto").strip().lower()
- if strategy_mode not in {"auto", "exact_preferred", "distributed_preferred", "ensemble"}:
- strategy_mode = "auto"
- if strategy_mode == "exact_preferred":
- phrase_strategy = (
- "Prefer one natural exact phrase mention when grammatically correct; otherwise use distributed core-term coverage."
- )
- elif strategy_mode == "distributed_preferred":
- phrase_strategy = (
- "Prefer distributed semantic coverage: spread core terms/lemmas naturally and avoid exact phrase unless absolutely natural."
- )
- elif phrase_len >= 3:
- phrase_strategy = (
- "Prefer distributed semantic coverage for long phrases: naturally spread core terms/lemmas across the local paragraph. "
- "Use exact phrase only if it is grammatically natural."
- )
- elif phrase_len == 2:
- phrase_strategy = (
- "For two-term goals, use either one natural exact phrase or distributed use of both terms without repetition."
- )
- else:
- phrase_strategy = (
- "For single-term goals, improve relevance using natural lexical variants and nearby semantic anchors."
- )
- user_msg = (
- f"Language: {language}\n"
- f"Operation: {op}\n"
- f"Cascade level: L{cascade_level}\n"
- f"Goal: {goal_type} ({goal_label})\n"
- f"Goal token count (without stopwords): {phrase_len}\n"
- f"Instruction: {op_instruction}\n"
- f"Must preserve overall narrative and style.\n"
- "Text must be grammatically correct and natural for native readers.\n"
- "Keep edits tightly local to the provided chunk and immediate context only.\n"
- "Edit must be substantive (not just synonyms) and should increase relevance to the goal phrase.\n"
- "Do not change the sentence subject/entity focus unless absolutely required by grammar.\n"
- f"Phrase strategy: {phrase_strategy}\n"
- f"Focus terms to strengthen: {', '.join(focus_terms) if focus_terms else '-'}\n"
- f"Terms to de-emphasize/avoid overuse: {', '.join(avoid_terms) if avoid_terms else '-'}\n\n"
- f"Chunk to edit/expand:\n{chunk_text}\n\n"
- f"Prev context:\n{context_before}\n\n"
- f"Next context:\n{context_after}\n\n"
- "Constraints:\n"
- "1) Keep text concise and locally coherent.\n"
- "2) Keep local coherence with surrounding text.\n"
- f"3) Max {max_sent} sentence(s) in edited_text.\n"
- "4) Keep key named entities from the original chunk unchanged when possible.\n"
- "5) For BERT goals, prioritize semantic alignment over exact phrase repetition.\n"
- "6) If exact phrase sounds unnatural, do NOT force it; use grammatically correct distributed wording.\n"
- "7) Exact phrase may appear at most once, and only when it reads naturally.\n"
- "8) Avoid repeating the same focus term more than needed; no stuffing.\n"
- "9) For rewrite: preserve original meaning sentence-by-sentence while improving relevance.\n"
- "10) Provide rationale in one short sentence.\n"
- "11) Only output JSON object."
- )
- payload = {
- "model": model,
- "temperature": float(max(0.0, min(1.2, temperature))),
- "messages": [
- {"role": "system", "content": system_msg},
- {"role": "user", "content": user_msg},
- ],
- "response_format": {"type": "json_object"},
- }
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
- response = requests.post(endpoint, headers=headers, json=payload, timeout=60)
- response.raise_for_status()
- data = response.json()
- content = (
- data.get("choices", [{}])[0]
- .get("message", {})
- .get("content", "")
- )
- parsed = _extract_json_object(content)
- edited = ""
- rationale = ""
- if parsed:
- edited = str(parsed.get("edited_text") or parsed.get("revised_sentence") or parsed.get("rewrite") or "").strip()
- rationale = str(parsed.get("rationale") or parsed.get("why") or "").strip()
- if not edited:
- raise ValueError("LLM returned invalid JSON edit payload.")
- return {
- "edited_text": edited,
- "rationale": rationale,
- "prompt_debug": {
- "operation": op,
- "cascade_level": cascade_level,
- "goal_type": goal_type,
- "goal_label": goal_label,
- "focus_terms": focus_terms,
- "avoid_terms": avoid_terms,
- "phrase_strategy_mode": strategy_mode,
- "goal_token_count": phrase_len,
- "phrase_strategy": phrase_strategy,
- "max_sentences": max_sent,
- "chunk_text": chunk_text,
- "context_before": context_before,
- "context_after": context_after,
- "temperature": float(max(0.0, min(1.2, temperature))),
- },
- }
-
-
-def _llm_edit_title(
- *,
- api_key: str,
- base_url: str,
- model: str,
- language: str,
- current_title: str,
- body_excerpt: str,
- competitor_title_hint: str,
- focus_terms: List[str],
- avoid_terms: List[str],
- keywords: List[str],
- cascade_level: int,
- temperature: float,
- phrase_strategy_mode: str = "auto",
-) -> Dict[str, Any]:
- """
- Rewrite only the page plain text. Backend metric: mean BERT cos-sim(title, each keyword).
- """
- endpoint = base_url.rstrip("/") + "/chat/completions"
- strategy_mode = (phrase_strategy_mode or "auto").strip().lower()
- if strategy_mode not in {"auto", "exact_preferred", "distributed_preferred", "ensemble"}:
- strategy_mode = "auto"
- phrase_tokens = _filter_stopwords(_tokenize(" ".join(keywords[:6])), language)
- phrase_len = len(phrase_tokens)
- if strategy_mode == "exact_preferred":
- phrase_strategy = (
- "Prefer one natural exact phrase from the keyword list when it fits; otherwise distribute core terms."
- )
- elif strategy_mode == "distributed_preferred":
- phrase_strategy = "Prefer natural distribution of core terms; avoid stiff exact-match phrasing."
- elif phrase_len >= 3:
- phrase_strategy = (
- "Spread core lemmas across the title; exact multi-word match only if it reads like a real title."
- )
- elif phrase_len == 2:
- phrase_strategy = "Use both core ideas in one short title line without repetition."
- else:
- phrase_strategy = "Keep the title specific and aligned with the keyword theme using natural wording."
-
- system_msg = (
- "You optimize HTML tag text for SEO. "
- "Output JSON only: {\"edited_text\": \"...\", \"rationale\": \"...\"}. "
- "edited_text must be the new title line ONLY: plain text, no tags, no quotes wrapping the whole title. "
- "Do not change the article body. Preserve the primary brand/site name from the current title when present."
- )
- kw_line = ", ".join(k.strip() for k in (keywords or [])[:24] if k.strip()) or "-"
- user_msg = (
- f"Language: {language}\n"
- f"Cascade level: L{cascade_level}\n"
- f"Task: Improve semantic alignment of the TITLE with these keywords (average embedding similarity should rise).\n"
- f"Keywords (priority): {kw_line}\n"
- f"Focus terms: {', '.join(focus_terms) if focus_terms else '-'}\n"
- f"De-emphasize: {', '.join(avoid_terms) if avoid_terms else '-'}\n"
- f"Phrase strategy: {phrase_strategy}\n\n"
- f"Current title:\n{current_title.strip()}\n\n"
- f"Page content excerpt (context only; do not paste into title):\n{(body_excerpt or '')[:900]}\n\n"
- f"{competitor_title_hint}\n\n"
- "Constraints:\n"
- "1) Length about 35–60 characters when possible; hard max 88 characters.\n"
- "2) One line; no line breaks; title case or sentence case per language norms.\n"
- "3) Natural human phrasing; no keyword stuffing; each important term at most once unless grammar needs it.\n"
- "4) Do not add unrelated entities or claims.\n"
- "5) Rationale: one short sentence.\n"
- )
- payload = {
- "model": model,
- "temperature": float(max(0.0, min(1.2, temperature))),
- "messages": [
- {"role": "system", "content": system_msg},
- {"role": "user", "content": user_msg},
- ],
- "response_format": {"type": "json_object"},
- }
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
- response = requests.post(endpoint, headers=headers, json=payload, timeout=60)
- response.raise_for_status()
- data = response.json()
- content = (
- data.get("choices", [{}])[0]
- .get("message", {})
- .get("content", "")
- )
- parsed = _extract_json_object(content)
- edited = ""
- rationale = ""
- if parsed:
- edited = str(parsed.get("edited_text") or parsed.get("title") or "").strip()
- rationale = str(parsed.get("rationale") or "").strip()
- if not edited:
- raise ValueError("LLM returned invalid JSON title payload.")
- return {
- "edited_text": edited,
- "rationale": rationale,
- "prompt_debug": {
- "operation": "title_rewrite",
- "cascade_level": cascade_level,
- "goal_type": "title",
- "goal_label": "title alignment",
- "focus_terms": focus_terms,
- "avoid_terms": avoid_terms,
- "phrase_strategy_mode": strategy_mode,
- "phrase_strategy": phrase_strategy,
- "chunk_text": current_title.strip(),
- "context_before": (body_excerpt or "")[:500],
- "context_after": competitor_title_hint[:500] if competitor_title_hint else "",
- "temperature": float(max(0.0, min(1.2, temperature))),
- },
- }
-
-
-def _replace_span(sentences: List[str], start_idx: int, end_idx: int, replacement_text: str) -> List[str]:
- replacement = _split_sentences(replacement_text)
- if not replacement:
- replacement = [replacement_text.strip()]
- return sentences[:start_idx] + replacement + sentences[end_idx + 1 :]
-
-
-def _insert_after(sentences: List[str], after_idx: int, inserted_text: str) -> List[str]:
- insertion = _split_sentences(inserted_text)
- if not insertion:
- insertion = [inserted_text.strip()]
- return sentences[: after_idx + 1] + insertion + sentences[after_idx + 1 :]
-
-
-def _goal_improved(
- goal_type: str,
- goal_label: str,
- prev_metrics: Dict[str, Any],
- next_metrics: Dict[str, Any],
-) -> bool:
- if goal_type == "bert":
- key = (goal_label or "").strip().lower()
- prev_phrase = float((prev_metrics.get("bert_phrase_scores") or {}).get(key, 0.0))
- next_phrase = float((next_metrics.get("bert_phrase_scores") or {}).get(key, 0.0))
- # Accept smaller but real phrase-level gains to accumulate progress toward threshold (e.g. 0.51 -> 0.70).
- return (next_phrase - prev_phrase) >= BERT_GOAL_DELTA_MIN or next_metrics["bert_low_count"] < prev_metrics["bert_low_count"]
- if goal_type == "bm25":
- return next_metrics["bm25_remove_count"] < prev_metrics["bm25_remove_count"]
- if goal_type == "semantic":
- return next_metrics["semantic_gap_count"] < prev_metrics["semantic_gap_count"]
- if goal_type == "ngram":
- return (
- next_metrics["ngram_signal_count"] < prev_metrics["ngram_signal_count"]
- or float(next_metrics.get("ngram_gap_sum", 0.0)) < float(prev_metrics.get("ngram_gap_sum", 0.0))
- )
- if goal_type == "title":
- pt = prev_metrics.get("title_bert_score")
- nt = next_metrics.get("title_bert_score")
- if pt is None or nt is None:
- return False
- return float(nt) > float(pt) or float(nt) >= TITLE_TARGET_THRESHOLD
- return next_metrics["score"] > prev_metrics["score"]
-
-
-def _bert_phrase_delta(goal_label: str, prev_metrics: Dict[str, Any], next_metrics: Dict[str, Any]) -> float:
- key = (goal_label or "").strip().lower()
- prev_phrase = float((prev_metrics.get("bert_phrase_scores") or {}).get(key, 0.0))
- next_phrase = float((next_metrics.get("bert_phrase_scores") or {}).get(key, 0.0))
- return round(next_phrase - prev_phrase, 4)
-
-
-def _safe_delta(prev_metrics: Dict[str, Any], next_metrics: Dict[str, Any], key: str) -> float:
- try:
- return float(next_metrics.get(key, 0.0)) - float(prev_metrics.get(key, 0.0))
- except Exception:
- return 0.0
-
-
-def _stage_primary_progress(stage: str, prev_metrics: Dict[str, Any], next_metrics: Dict[str, Any]) -> bool:
- if stage == "bert":
- prev_low = int(prev_metrics.get("bert_low_count", 0))
- next_low = int(next_metrics.get("bert_low_count", 0))
- if next_low < prev_low:
- return True
- prev_max = max([0.0] + [float(v) for v in (prev_metrics.get("bert_phrase_scores") or {}).values()])
- next_max = max([0.0] + [float(v) for v in (next_metrics.get("bert_phrase_scores") or {}).values()])
- return (next_max - prev_max) >= BERT_GOAL_DELTA_MIN
- if stage == "bm25":
- return int(next_metrics.get("bm25_remove_count", 0)) < int(prev_metrics.get("bm25_remove_count", 0))
- if stage == "semantic":
- return (
- int(next_metrics.get("semantic_gap_count", 0)) < int(prev_metrics.get("semantic_gap_count", 0))
- or float(next_metrics.get("semantic_gap_sum", 0.0)) < float(prev_metrics.get("semantic_gap_sum", 0.0))
- )
- if stage == "ngram":
- return (
- int(next_metrics.get("ngram_signal_count", 0)) < int(prev_metrics.get("ngram_signal_count", 0))
- or float(next_metrics.get("ngram_gap_sum", 0.0)) < float(prev_metrics.get("ngram_gap_sum", 0.0))
- )
- if stage == "title":
- pv = prev_metrics.get("title_bert_score")
- nv = next_metrics.get("title_bert_score")
- if pv is None or nv is None:
- return False
- return float(nv) > float(pv)
- return False
-
-
-def _is_stage_complete(stage: str, metrics: Dict[str, Any], bert_stage_target: float = BERT_TARGET_THRESHOLD) -> bool:
- if stage == "bert":
- # Complete only when all tracked BERT phrase scores meet the threshold.
- # This enforces per-phrase target behavior (no early exit on one strong phrase).
- scores = [float(v) for v in (metrics.get("bert_phrase_scores") or {}).values()]
- if not scores:
- return True
- return min(scores) >= float(bert_stage_target)
- if stage == "bm25":
- return int(metrics.get("bm25_remove_count", 0)) <= 3
- if stage == "semantic":
- return int(metrics.get("semantic_gap_count", 0)) <= 0
- if stage == "ngram":
- return int(metrics.get("ngram_signal_count", 0)) <= 0
- if stage == "title":
- score = metrics.get("title_bert_score")
- return (score is None) or (float(score) >= TITLE_TARGET_THRESHOLD)
- return True
-
-
-def _advance_ngram_term_cursor(cursor_state: Dict[str, Dict[str, int]], stage_key: str) -> None:
- state = cursor_state.get(stage_key) or {"term_index": 0, "attempt_count": 0}
- attempts = int(state.get("attempt_count", 0)) + 1
- term_index = int(state.get("term_index", 0))
- if attempts >= NGRAM_ATTEMPTS_PER_TERM:
- term_index += 1
- attempts = 0
- cursor_state[stage_key] = {"term_index": term_index, "attempt_count": attempts}
-
-
-def _candidate_utility(
- *,
- prev_metrics: Dict[str, Any],
- next_metrics: Dict[str, Any],
- goal_type: str,
- goal_label: str,
- bert_phrase_delta: float,
- chunk_goal_delta: float,
- local_chunk_improved: bool,
-) -> float:
- score_delta = _safe_delta(prev_metrics, next_metrics, "score")
- bm25_delta = _safe_delta(prev_metrics, next_metrics, "bm25_remove_count")
- bert_low_delta = _safe_delta(prev_metrics, next_metrics, "bert_low_count")
- ngram_delta = _safe_delta(prev_metrics, next_metrics, "ngram_signal_count")
- sem_gap_delta = _safe_delta(prev_metrics, next_metrics, "semantic_gap_count")
- title_delta = _safe_delta(prev_metrics, next_metrics, "title_bert_score")
-
- # Dynamic emphasis:
- # - if target phrase is still far from threshold, prioritize phrase-level BERT gains
- # - but keep non-BERT regressions as penalties to preserve future optimization capacity
- key = (goal_label or "").strip().lower()
- prev_phrase = float((prev_metrics.get("bert_phrase_scores") or {}).get(key, 0.0))
- bert_push_mode = (goal_type == "bert" and prev_phrase < BERT_TARGET_THRESHOLD)
-
- w_phrase = 7.5 if bert_push_mode else 3.0
- w_chunk = 1.6 if bert_push_mode else 1.0
- w_score = 1.0
-
- utility = (
- (w_phrase * float(bert_phrase_delta))
- + (w_chunk * float(chunk_goal_delta))
- + (w_score * float(score_delta))
- )
- if local_chunk_improved:
- utility += 0.3
-
- # Cross-metric guardrails as soft penalties (in addition to hard validity checks).
- utility -= max(0.0, bm25_delta) * 1.8
- utility -= max(0.0, bert_low_delta) * 2.4
- utility -= max(0.0, ngram_delta) * 0.6
- utility -= max(0.0, sem_gap_delta) * 1.5
- utility += min(0.0, title_delta) * 1.2
- if goal_type == "title":
- utility += max(0.0, title_delta) * 5.0
- return round(float(utility), 4)
-
-
-def _is_candidate_valid(
- prev_metrics: Dict[str, Any],
- next_metrics: Dict[str, Any],
- goal_type: str,
- goal_label: str,
- optimization_mode: str,
-) -> Tuple[bool, List[str], bool]:
- mode = (optimization_mode or "balanced").lower()
- if mode not in {"conservative", "balanced", "aggressive"}:
- mode = "balanced"
-
- cfg = {
- "conservative": {"max_score_drop": 0.0, "max_title_drop": 0.02},
- "balanced": {"max_score_drop": 1.0, "max_title_drop": 0.03},
- "aggressive": {"max_score_drop": 2.0, "max_title_drop": 0.05},
- }[mode]
-
- reasons = []
- score_drop = float(prev_metrics["score"]) - float(next_metrics["score"])
- if score_drop > cfg["max_score_drop"]:
- reasons.append(f"score_drop>{cfg['max_score_drop']}")
-
- # Hard regressions in critical counters.
- if next_metrics["bm25_remove_count"] > prev_metrics["bm25_remove_count"] + (1 if mode == "aggressive" else 0):
- reasons.append("bm25_remove_regression")
- if next_metrics["bert_low_count"] > prev_metrics["bert_low_count"] + (1 if mode == "aggressive" else 0):
- reasons.append("bert_low_regression")
- if next_metrics["semantic_gap_count"] > prev_metrics["semantic_gap_count"] + (1 if mode == "aggressive" else 0):
- reasons.append("semantic_gap_regression")
-
- prev_title = prev_metrics.get("title_bert_score")
- next_title = next_metrics.get("title_bert_score")
- if goal_type != "title":
- if prev_title is not None and next_title is not None and next_title < (prev_title - cfg["max_title_drop"]):
- reasons.append("title_bert_drop")
-
- improved = _goal_improved(goal_type, goal_label, prev_metrics, next_metrics)
-
- # In conservative mode require explicit goal improvement.
- if mode == "conservative" and not improved:
- reasons.append("goal_not_improved")
-
- return (len(reasons) == 0), reasons, improved
-
-
-def optimize_text(
- request_data: Dict[str, Any],
- progress_callback: Optional[Any] = None,
- cancel_event: Optional[Any] = None,
-) -> Dict[str, Any]:
- target_text = str(request_data.get("target_text", "")).strip()
- competitors = [str(x) for x in (request_data.get("competitors") or []) if str(x).strip()]
- keywords = [str(x) for x in (request_data.get("keywords") or []) if str(x).strip()]
- language = str(request_data.get("language", "en")).strip() or "en"
- target_title = str(request_data.get("target_title", "") or "")
- competitor_titles = [str(x) for x in (request_data.get("competitor_titles") or [])]
-
- diff_mode_used = str(request_data.get("diff_mode", "diff_from_input") or "diff_from_input").strip().lower()
- if diff_mode_used not in {"diff_from_input", "diff_from_original"}:
- diff_mode_used = "diff_from_input"
- diff_base_text = target_text
- diff_base_title = target_title
- if diff_mode_used == "diff_from_original":
- diff_base_text = str(request_data.get("original_target_text") or target_text or "").strip()
- diff_base_title = str(request_data.get("original_target_title") or target_title or "").strip()
- else:
- diff_base_text = (target_text or "").strip()
- diff_base_title = (target_title or "").strip()
-
- api_key = str(request_data.get("api_key", "")).strip()
- if not api_key:
- raise ValueError("API key is required.")
- base_url = str(request_data.get("api_base_url", "https://api.deepseek.com/v1")).strip() or "https://api.deepseek.com/v1"
- model = str(request_data.get("model", "deepseek-chat")).strip() or "deepseek-chat"
- max_iterations = int(request_data.get("max_iterations", 2) or 2)
- max_iterations = max(1, min(8, max_iterations))
- candidates_per_iteration = int(request_data.get("candidates_per_iteration", 2) or 2)
- candidates_per_iteration = max(1, min(5, candidates_per_iteration))
- temperature = float(request_data.get("temperature", 0.25) or 0.25)
- optimization_mode = str(request_data.get("optimization_mode", "balanced") or "balanced")
- phrase_strategy_mode = str(request_data.get("phrase_strategy_mode", "auto") or "auto").strip().lower()
- if phrase_strategy_mode not in {"auto", "exact_preferred", "distributed_preferred", "ensemble"}:
- phrase_strategy_mode = "auto"
- bert_stage_target = float(request_data.get("bert_stage_target", BERT_TARGET_THRESHOLD) or BERT_TARGET_THRESHOLD)
- bert_stage_target = max(0.0, min(1.0, bert_stage_target))
- stage_goal_overrides = request_data.get("stage_goal_overrides") or {}
- if not isinstance(stage_goal_overrides, dict):
- stage_goal_overrides = {}
- req_enabled_stages = request_data.get("enabled_stages") or []
- active_stage_order: List[str] = []
- if isinstance(req_enabled_stages, list):
- for x in req_enabled_stages:
- st = _normalize_stage_name(x)
- if st and st not in active_stage_order:
- active_stage_order.append(st)
- if not active_stage_order:
- active_stage_order = list(STAGE_ORDER)
-
- baseline_analysis = _build_analysis_snapshot(
- target_text, competitors, keywords, language, target_title, competitor_titles
- )
- baseline_semantic = _build_semantic_snapshot(target_text, competitors, language)
- baseline_metrics = _compute_metrics(
- baseline_analysis, baseline_semantic, keywords, language, bert_stage_target=bert_stage_target
- )
-
- # Per-goal iteration budget scales with deficit; total loop steps = sum(effective iters per goal).
- baseline_goal_counts = {
- st: len(
- _collect_optimization_goals(
- baseline_analysis,
- baseline_semantic,
- keywords,
- language,
- stage=st,
- bert_stage_target=bert_stage_target,
- stage_goal_overrides=stage_goal_overrides,
- )
- )
- for st in active_stage_order
- }
- ngram_row_count = int(baseline_goal_counts.get("ngram", 0))
- total_loop_steps = _estimate_total_loop_budget(
- baseline_analysis,
- baseline_semantic,
- keywords,
- language,
- max_iterations,
- candidates_per_iteration,
- bert_stage_target,
- active_stage_order=active_stage_order,
- stage_goal_overrides=stage_goal_overrides,
- )
-
- current_text = target_text
- current_title = (target_title or "").strip()
- current_analysis = baseline_analysis
- current_semantic = baseline_semantic
- current_metrics = baseline_metrics
- logs: List[Dict[str, Any]] = []
- applied_changes = 0
-
- def _emit(ev: str, **kwargs: Any) -> None:
- if progress_callback:
- try:
- progress_callback({"event": ev, **kwargs})
- except Exception:
- pass
-
- def _cancelled() -> bool:
- return cancel_event is not None and getattr(cancel_event, "is_set", lambda: False)()
-
- candidate_eval_cache: Dict[Tuple[str, str], Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]] = {}
-
- def _evaluate_candidate_state(body_text: str, title_text: str) -> Tuple[Dict[str, Any], Dict[str, Any], Dict[str, Any]]:
- key = ((body_text or "").strip(), (title_text or "").strip())
- cached = candidate_eval_cache.get(key)
- if cached is not None:
- return cached
- analysis = _build_analysis_snapshot(key[0], competitors, keywords, language, key[1], competitor_titles)
- semantic = _build_semantic_snapshot(key[0], competitors, language)
- metrics = _compute_metrics(analysis, semantic, keywords, language, bert_stage_target=bert_stage_target)
- candidate_eval_cache[key] = (analysis, semantic, metrics)
- return analysis, semantic, metrics
-
- def _pack_result(stopped_early: bool = False, stop_reason: str = "") -> Dict[str, Any]:
- # Title string must match what last metrics used (title_analysis.target_title), not only the mutable var.
- _ta = (current_analysis or {}).get("title_analysis") or {}
- _ot = ""
- if isinstance(_ta, dict) and (_ta.get("target_title") or "").strip():
- _ot = str(_ta.get("target_title")).strip()
- if not _ot:
- _ot = (current_title or "").strip()
- if not _ot:
- _ot = (target_title or "").strip()
-
- diff_body_html = ""
- diff_changes: List[Dict[str, str]] = []
- if (diff_base_text or "").strip() != (current_text or "").strip():
- dh, dc = _diff_sentences_html(diff_base_text or "", current_text or "")
- if dc:
- diff_body_html = dh
- diff_changes = dc
-
- diff_title_html = ""
- diff_title_changes: List[Dict[str, str]] = []
- if (diff_base_title or "").strip() != (_ot or "").strip():
- dth, dtc = _diff_title_html(diff_base_title or "", _ot or "")
- if dtc:
- diff_title_html = dth
- diff_title_changes = dtc
- return {
- "ok": True,
- "optimized_text": current_text,
- "optimized_title": _ot,
- "baseline_metrics": baseline_metrics,
- "final_metrics": current_metrics,
- "iterations": logs,
- "applied_changes": applied_changes,
- "optimization_mode": optimization_mode,
- "phrase_strategy_mode": phrase_strategy_mode,
- "bert_stage_target": round(bert_stage_target, 4),
- "diff_mode": diff_mode_used,
- "diff_body_html": diff_body_html,
- "diff_title_html": diff_title_html,
- "diff_changes": diff_changes,
- "diff_title_changes": diff_title_changes,
- "stopped_early": stopped_early,
- "stop_reason": stop_reason,
- }
-
- _emit("preparing", message="Подготовка", phase="baseline")
- _emit(
- "started",
- total_steps=total_loop_steps,
- max_iterations_setting=max_iterations,
- ngram_targets=ngram_row_count,
- stages_order=list(active_stage_order),
- )
-
- seen_candidate_rewrites = set()
- cascade_level = 1
- consecutive_failures = 0
- goal_attempt_cursor: Dict[str, int] = {}
- attempted_spans = set()
- queued_candidates: List[Dict[str, Any]] = []
- stage_idx = 0
- stage_no_progress_steps = 0
- stage_goal_cursor: Dict[str, Dict[str, int]] = {}
-
- for step in range(total_loop_steps):
- if _cancelled():
- logs.append(
- {
- "step": step + 1,
- "status": "stopped",
- "reason": "Остановка пользователем (сохранён текущий текст и метрики).",
- }
- )
- return _pack_result(stopped_early=True, stop_reason="user_cancelled")
-
- while stage_idx < len(active_stage_order) and _is_stage_complete(
- active_stage_order[stage_idx], current_metrics, bert_stage_target=bert_stage_target
- ):
- stage_idx += 1
- stage_no_progress_steps = 0
- if stage_idx >= len(active_stage_order):
- logs.append({"step": step + 1, "status": "stopped", "reason": "All optimization stages completed."})
- break
-
- active_stage = active_stage_order[stage_idx]
- goals_for_stage = _collect_optimization_goals(
- current_analysis,
- current_semantic,
- keywords,
- language,
- stage=active_stage,
- bert_stage_target=bert_stage_target,
- stage_goal_overrides=stage_goal_overrides,
- )
- state = stage_goal_cursor.get(active_stage) or {"goal_index": 0, "attempt_count": 0}
- goal_index = int(state.get("goal_index", 0))
- attempt_count = int(state.get("attempt_count", 0))
-
- # Advance across goals that exhausted per-goal iteration budget (scaled by deficit).
- while goal_index < len(goals_for_stage):
- g_try = goals_for_stage[goal_index]
- eff_max_iter, _ = _per_goal_budget(g_try, max_iterations, candidates_per_iteration, bert_stage_target)
- if attempt_count < eff_max_iter:
- break
- goal_index += 1
- attempt_count = 0
-
- if goal_index >= len(goals_for_stage):
- stage_idx += 1
- stage_no_progress_steps = 0
- logs.append(
- {
- "step": step + 1,
- "status": "stage_skipped",
- "stage": active_stage,
- "reason": f"All goals exhausted for stage '{active_stage}' (per-goal iteration budget).",
- }
- )
- stage_goal_cursor[active_stage] = {"goal_index": goal_index, "attempt_count": attempt_count}
- continue
-
- goal = goals_for_stage[goal_index]
- eff_max_iter, eff_cand = _per_goal_budget(goal, max_iterations, candidates_per_iteration, bert_stage_target)
- attempt_count += 1
- stage_goal_cursor[active_stage] = {"goal_index": goal_index, "attempt_count": attempt_count}
- if goal["type"] == "none":
- stage_idx += 1
- stage_no_progress_steps = 0
- logs.append(
- {
- "step": step + 1,
- "status": "stage_skipped",
- "stage": active_stage,
- "reason": f"No actionable goals for stage '{active_stage}', moving to next stage.",
- }
- )
- continue
-
- _emit(
- "step_start",
- step=step + 1,
- total_steps=total_loop_steps,
- active_stage=active_stage,
- goal_type=goal.get("type"),
- goal_label=goal.get("label"),
- score=current_metrics.get("score"),
- goal_budget_iter=eff_max_iter,
- goal_budget_candidates=eff_cand,
- )
-
- goal_key = f"{goal.get('type', '')}:{goal.get('label', '')}".strip().lower()
- base_attempt_cursor = int(goal_attempt_cursor.get(goal_key, 0))
- candidates: List[Dict[str, Any]] = []
- chosen_spans: List[Dict[str, Any]] = []
- candidate_idx = 0
- span_trials_eff = 1
-
- if goal.get("type") == "title":
- if not current_title:
- logs.append(
- {
- "step": step + 1,
- "status": "stage_skipped",
- "stage": active_stage,
- "reason": "Поле Title пустое — этап title пропущен.",
- }
- )
- stage_idx += 1
- continue
-
- chosen_spans = [
- {
- "operation": "title_rewrite",
- "span_start": 0,
- "span_end": 0,
- "sentence_index": 0,
- "span_variant": 0,
- "sentence_before": current_title,
- }
- ]
- original_span_text = current_title
- body_excerpt = (current_text or "").strip()[:1200]
- comp_ctx = " | ".join(t.strip() for t in competitor_titles[:4] if t.strip())
- competitor_title_hint = (
- f"Примеры title конкурентов (только стиль/длина): {comp_ctx}" if comp_ctx else ""
- )
- strategy_plan = _build_phrase_strategy_plan(
- phrase_strategy_mode,
- "title",
- str(goal.get("label", "")),
- eff_cand,
- )
- for strategy_variant in strategy_plan:
- candidate_idx += 1
- temp = min(1.1, max(0.0, temperature + (candidate_idx - 1) * 0.07))
- if _cancelled():
- logs.append(
- {
- "step": step + 1,
- "status": "stopped",
- "reason": "Остановка пользователем перед запросом к LLM.",
- }
- )
- return _pack_result(stopped_early=True, stop_reason="user_cancelled")
- _emit(
- "llm_call",
- candidate_index=candidate_idx,
- span_trial=1,
- span_trials=1,
- strategy=strategy_variant,
- )
- try:
- llm_result = _llm_edit_title(
- api_key=api_key,
- base_url=base_url,
- model=model,
- language=language,
- current_title=current_title,
- body_excerpt=body_excerpt,
- competitor_title_hint=competitor_title_hint,
- focus_terms=goal.get("focus_terms", []) or [],
- avoid_terms=goal.get("avoid_terms", []) or [],
- keywords=keywords,
- cascade_level=cascade_level,
- temperature=temp,
- phrase_strategy_mode=strategy_variant,
- )
- edited_text = str((llm_result or {}).get("edited_text", "")).strip()
- llm_rationale = str((llm_result or {}).get("rationale", "")).strip()
- prompt_debug = (llm_result or {}).get("prompt_debug", {})
- if not edited_text or edited_text == original_span_text.strip():
- continue
-
- quality_issues = _validate_title_candidate(edited_text)
- cand_analysis, cand_semantic, cand_metrics = _evaluate_candidate_state(current_text, edited_text)
- before_rel = float(current_metrics.get("title_bert_score") or 0.0)
- after_rel = float(cand_metrics.get("title_bert_score") or 0.0)
- chunk_delta = round(after_rel - before_rel, 4)
- local_chunk_improved = chunk_delta >= _min_chunk_delta("title")
- bert_phrase_delta = 0.0
- valid, invalid_reasons, goal_improved = _is_candidate_valid(
- current_metrics, cand_metrics, goal["type"], goal["label"], optimization_mode
- )
- delta_score = round(cand_metrics["score"] - current_metrics["score"], 3)
- candidate_utility = _candidate_utility(
- prev_metrics=current_metrics,
- next_metrics=cand_metrics,
- goal_type=str(goal.get("type", "")),
- goal_label=str(goal.get("label", "")),
- bert_phrase_delta=bert_phrase_delta,
- chunk_goal_delta=chunk_delta,
- local_chunk_improved=local_chunk_improved,
- )
- md = _metrics_delta(current_metrics, cand_metrics)
-
- if quality_issues:
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "error": "quality_validation_failed",
- "valid": False,
- "goal_improved": False,
- "local_chunk_improved": local_chunk_improved,
- "chunk_goal_delta": chunk_delta,
- "invalid_reasons": quality_issues,
- "delta_score": -999.0,
- "candidate_score": None,
- "sentence_after": edited_text,
- "chunk_relevance_before": before_rel,
- "chunk_relevance_after": after_rel,
- "term_diff": _term_diff(original_span_text, edited_text, language),
- "llm_prompt_debug": prompt_debug,
- "llm_rationale": llm_rationale,
- "operation": "title_rewrite",
- "sentence_index": 0,
- "span_start": 0,
- "span_end": 0,
- "span_variant": 0,
- "phrase_strategy_used": strategy_variant,
- "sentence_before": original_span_text,
- }
- )
- continue
-
- candidate_key = ("title_rewrite", 0, 0, edited_text.strip().lower())
- if candidate_key in seen_candidate_rewrites:
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "error": "duplicate_candidate_rewrite",
- "valid": False,
- "goal_improved": False,
- "local_chunk_improved": local_chunk_improved,
- "chunk_goal_delta": chunk_delta,
- "invalid_reasons": ["duplicate_candidate_rewrite"],
- "delta_score": -999.0,
- "candidate_score": None,
- "chunk_relevance_before": before_rel,
- "chunk_relevance_after": after_rel,
- "term_diff": _term_diff(original_span_text, edited_text, language),
- "llm_prompt_debug": prompt_debug,
- "llm_rationale": llm_rationale,
- "operation": "title_rewrite",
- "sentence_index": 0,
- "span_start": 0,
- "span_end": 0,
- "span_variant": 0,
- "phrase_strategy_used": strategy_variant,
- "sentence_before": original_span_text,
- }
- )
- continue
- seen_candidate_rewrites.add(candidate_key)
-
- candidate_text = current_text
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "sentence_before": original_span_text,
- "sentence_after": edited_text,
- "operation": "title_rewrite",
- "sentence_index": 0,
- "span_start": 0,
- "span_end": 0,
- "span_variant": 0,
- "phrase_strategy_used": strategy_variant,
- "text": candidate_text,
- "new_title": edited_text,
- "analysis": cand_analysis,
- "semantic": cand_semantic,
- "metrics": cand_metrics,
- "valid": valid,
- "goal_improved": goal_improved,
- "bert_phrase_delta": bert_phrase_delta,
- "local_chunk_improved": local_chunk_improved,
- "chunk_goal_delta": chunk_delta,
- "chunk_relevance_before": before_rel,
- "chunk_relevance_after": after_rel,
- "term_diff": _term_diff(original_span_text, edited_text, language),
- "llm_prompt_debug": prompt_debug,
- "llm_rationale": llm_rationale,
- "invalid_reasons": invalid_reasons,
- "delta_score": delta_score,
- "candidate_score": cand_metrics.get("score"),
- "candidate_utility": candidate_utility,
- "metrics_delta": md,
- "edit_payload": {
- "operation": "title_rewrite",
- "span_start": 0,
- "span_end": 0,
- "edited_text": edited_text,
- },
- }
- )
- except Exception as e:
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "error": str(e),
- "valid": False,
- "goal_improved": False,
- "local_chunk_improved": False,
- "chunk_goal_delta": -999.0,
- "invalid_reasons": [str(e)],
- "delta_score": -999.0,
- "candidate_score": None,
- "llm_prompt_debug": {
- "operation": "title_rewrite",
- "cascade_level": cascade_level,
- "goal_type": goal.get("type"),
- "goal_label": goal.get("label"),
- "phrase_strategy_mode": strategy_variant,
- },
- "llm_rationale": "",
- "operation": "title_rewrite",
- "sentence_index": 0,
- "span_start": 0,
- "span_end": 0,
- "span_variant": 0,
- "phrase_strategy_used": strategy_variant,
- "sentence_before": current_title,
- }
- )
-
- else:
- sentences = _split_sentences(current_text)
- if not sentences:
- logs.append({"step": step + 1, "status": "stopped", "reason": "No sentences available for editing."})
- break
-
- span_trials = _portfolio_span_trials(str(goal.get("type", "")), cascade_level, eff_cand)
- local_candidates = max(eff_cand, span_trials)
- if cascade_level > 2:
- local_candidates = min(6, max(local_candidates, eff_cand + 1))
- span_trials_eff = span_trials
-
- for st in range(span_trials):
- attempt_cursor = base_attempt_cursor + st
- operation, span_start, span_end, sent_idx, span_variant = _choose_edit_span(
- sentences, goal, language, cascade_level, attempt_cursor
- )
- max_span_retries = max(1, len(sentences) * 4)
- retries = 0
- while retries < max_span_retries:
- span_key = (goal_key, cascade_level, operation, span_start, span_end)
- if span_key not in attempted_spans:
- attempted_spans.add(span_key)
- break
- attempt_cursor += 1
- operation, span_start, span_end, sent_idx, span_variant = _choose_edit_span(
- sentences, goal, language, cascade_level, attempt_cursor
- )
- retries += 1
-
- original_span_text = " ".join(sentences[span_start : span_end + 1]).strip()
- context_before = " ".join(sentences[max(0, span_start - 2) : span_start]).strip()
- context_after = " ".join(sentences[span_end + 1 : min(len(sentences), span_end + 3)]).strip()
- chosen_spans.append(
- {
- "operation": operation,
- "span_start": span_start,
- "span_end": span_end,
- "sentence_index": sent_idx,
- "span_variant": span_variant,
- "sentence_before": original_span_text,
- }
- )
-
- per_span_candidates = max(1, local_candidates // span_trials)
- strategy_plan = _build_phrase_strategy_plan(
- phrase_strategy_mode,
- str(goal.get("type", "")),
- str(goal.get("label", "")),
- per_span_candidates,
- )
- for strategy_variant in strategy_plan:
- candidate_idx += 1
- temp = min(1.1, max(0.0, temperature + (candidate_idx - 1) * 0.07))
- if _cancelled():
- logs.append(
- {
- "step": step + 1,
- "status": "stopped",
- "reason": "Остановка пользователем перед запросом к LLM.",
- }
- )
- return _pack_result(stopped_early=True, stop_reason="user_cancelled")
- _emit(
- "llm_call",
- candidate_index=candidate_idx,
- span_trial=st + 1,
- span_trials=span_trials,
- strategy=strategy_variant,
- )
- try:
- llm_result = _llm_edit_chunk(
- api_key=api_key,
- base_url=base_url,
- model=model,
- language=language,
- full_text=current_text,
- chunk_text=original_span_text,
- operation=operation,
- context_before=context_before,
- context_after=context_after,
- cascade_level=cascade_level,
- goal_type=goal["type"],
- goal_label=goal["label"],
- focus_terms=goal["focus_terms"],
- avoid_terms=goal["avoid_terms"],
- temperature=temp,
- phrase_strategy_mode=strategy_variant,
- )
- edited_text = str((llm_result or {}).get("edited_text", "")).strip()
- llm_rationale = str((llm_result or {}).get("rationale", "")).strip()
- prompt_debug = (llm_result or {}).get("prompt_debug", {})
- if not edited_text or edited_text == original_span_text:
- continue
-
- quality_issues = _validate_candidate_text(
- edited_text,
- cascade_level,
- operation,
- goal_label=goal.get("label", ""),
- focus_terms=goal.get("focus_terms", []) or [],
- )
- before_rel, after_rel = _chunk_relevance_pair(
- original_span_text,
- edited_text,
- goal["type"],
- goal["label"],
- goal.get("focus_terms", []) or [],
- language,
- goal,
- )
- chunk_delta = _chunk_goal_delta(
- original_span_text,
- edited_text,
- goal["type"],
- goal["label"],
- goal.get("focus_terms", []) or [],
- language,
- goal,
- )
- local_chunk_improved = chunk_delta >= _min_chunk_delta(goal["type"])
-
- if quality_issues:
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "error": "quality_validation_failed",
- "valid": False,
- "goal_improved": False,
- "local_chunk_improved": local_chunk_improved,
- "chunk_goal_delta": chunk_delta,
- "invalid_reasons": quality_issues,
- "delta_score": -999.0,
- "candidate_score": None,
- "sentence_after": edited_text,
- "chunk_relevance_before": before_rel,
- "chunk_relevance_after": after_rel,
- "term_diff": _term_diff(original_span_text, edited_text, language),
- "llm_prompt_debug": prompt_debug,
- "llm_rationale": llm_rationale,
- "operation": operation,
- "sentence_index": sent_idx,
- "span_start": span_start,
- "span_end": span_end,
- "span_variant": span_variant,
- "phrase_strategy_used": strategy_variant,
- "sentence_before": original_span_text,
- }
- )
- continue
-
- prefilter_reasons = _local_prefilter_reasons(
- goal_type=str(goal.get("type", "")),
- focus_terms=goal.get("focus_terms", []) or [],
- edited_text=edited_text,
- chunk_delta=chunk_delta,
- before_rel=before_rel,
- after_rel=after_rel,
- )
- if prefilter_reasons:
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "error": "local_prefilter_rejected",
- "valid": False,
- "goal_improved": False,
- "local_chunk_improved": local_chunk_improved,
- "chunk_goal_delta": chunk_delta,
- "invalid_reasons": prefilter_reasons,
- "delta_score": -999.0,
- "candidate_score": None,
- "sentence_after": edited_text,
- "chunk_relevance_before": before_rel,
- "chunk_relevance_after": after_rel,
- "term_diff": _term_diff(original_span_text, edited_text, language),
- "llm_prompt_debug": prompt_debug,
- "llm_rationale": llm_rationale,
- "operation": operation,
- "sentence_index": sent_idx,
- "span_start": span_start,
- "span_end": span_end,
- "span_variant": span_variant,
- "phrase_strategy_used": strategy_variant,
- "sentence_before": original_span_text,
- }
- )
- continue
-
- candidate_key = (operation, span_start, span_end, edited_text.strip().lower())
- if candidate_key in seen_candidate_rewrites:
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "error": "duplicate_candidate_rewrite",
- "valid": False,
- "goal_improved": False,
- "local_chunk_improved": local_chunk_improved,
- "chunk_goal_delta": chunk_delta,
- "invalid_reasons": ["duplicate_candidate_rewrite"],
- "delta_score": -999.0,
- "candidate_score": None,
- "chunk_relevance_before": before_rel,
- "chunk_relevance_after": after_rel,
- "term_diff": _term_diff(original_span_text, edited_text, language),
- "llm_prompt_debug": prompt_debug,
- "llm_rationale": llm_rationale,
- "operation": operation,
- "sentence_index": sent_idx,
- "span_start": span_start,
- "span_end": span_end,
- "span_variant": span_variant,
- "phrase_strategy_used": strategy_variant,
- "sentence_before": original_span_text,
- }
- )
- continue
- seen_candidate_rewrites.add(candidate_key)
-
- if operation == "insert":
- candidate_sentences = _insert_after(sentences, span_end, edited_text)
- else:
- candidate_sentences = _replace_span(sentences, span_start, span_end, edited_text)
- candidate_text = " ".join(candidate_sentences).strip()
-
- cand_analysis, cand_semantic, cand_metrics = _evaluate_candidate_state(candidate_text, current_title)
- valid, invalid_reasons, goal_improved = _is_candidate_valid(
- current_metrics, cand_metrics, goal["type"], goal["label"], optimization_mode
- )
- delta_score = round(cand_metrics["score"] - current_metrics["score"], 3)
- bert_phrase_delta = _bert_phrase_delta(goal["label"], current_metrics, cand_metrics) if goal.get("type") == "bert" else 0.0
- candidate_utility = _candidate_utility(
- prev_metrics=current_metrics,
- next_metrics=cand_metrics,
- goal_type=str(goal.get("type", "")),
- goal_label=str(goal.get("label", "")),
- bert_phrase_delta=bert_phrase_delta,
- chunk_goal_delta=chunk_delta,
- local_chunk_improved=local_chunk_improved,
- )
- md = _metrics_delta(current_metrics, cand_metrics)
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "sentence_before": original_span_text,
- "sentence_after": edited_text,
- "operation": operation,
- "sentence_index": sent_idx,
- "span_start": span_start,
- "span_end": span_end,
- "span_variant": span_variant,
- "phrase_strategy_used": strategy_variant,
- "text": candidate_text,
- "analysis": cand_analysis,
- "semantic": cand_semantic,
- "metrics": cand_metrics,
- "valid": valid,
- "goal_improved": goal_improved,
- "bert_phrase_delta": bert_phrase_delta,
- "local_chunk_improved": local_chunk_improved,
- "chunk_goal_delta": chunk_delta,
- "chunk_relevance_before": before_rel,
- "chunk_relevance_after": after_rel,
- "term_diff": _term_diff(original_span_text, edited_text, language),
- "llm_prompt_debug": prompt_debug,
- "llm_rationale": llm_rationale,
- "invalid_reasons": invalid_reasons,
- "delta_score": delta_score,
- "candidate_score": cand_metrics.get("score"),
- "candidate_utility": candidate_utility,
- "metrics_delta": md,
- "edit_payload": {
- "operation": operation,
- "span_start": span_start,
- "span_end": span_end,
- "edited_text": edited_text,
- },
- }
- )
- except Exception as e:
- candidates.append(
- {
- "candidate_index": candidate_idx,
- "error": str(e),
- "valid": False,
- "goal_improved": False,
- "local_chunk_improved": False,
- "chunk_goal_delta": -999.0,
- "invalid_reasons": [str(e)],
- "delta_score": -999.0,
- "candidate_score": None,
- "llm_prompt_debug": {
- "operation": operation,
- "cascade_level": cascade_level,
- "goal_type": goal.get("type"),
- "goal_label": goal.get("label"),
- "phrase_strategy_mode": strategy_variant,
- },
- "llm_rationale": "",
- "operation": operation,
- "sentence_index": sent_idx,
- "span_start": span_start,
- "span_end": span_end,
- "span_variant": span_variant,
- "phrase_strategy_used": strategy_variant,
- "sentence_before": original_span_text,
- }
- )
-
- goal_attempt_cursor[goal_key] = base_attempt_cursor + span_trials_eff
- primary_span = chosen_spans[0] if chosen_spans else {"operation": "-", "span_start": 0, "span_end": 0, "sentence_index": 0, "span_variant": 0, "sentence_before": ""}
-
- valid_raw_candidates = [c for c in candidates if c.get("valid")]
- valid_candidates = [
- c
- for c in valid_raw_candidates
- if c.get("valid")
- and (
- c.get("goal_improved")
- or (goal.get("type") == "bert" and float(c.get("bert_phrase_delta") or 0.0) > 0.0)
- or float(c.get("candidate_score") or -1) > float(current_metrics["score"])
- or float(c.get("candidate_utility") or -999.0) > 0.0
- )
- and (
- goal.get("type") != "bert"
- or float(c.get("bert_phrase_delta") or 0.0) > 0.0
- or c.get("local_chunk_improved")
- )
- ]
- if not valid_candidates:
- # Local-first accumulation mode:
- # if we have guardrail-valid candidates that improve chunk relevance,
- # apply the strongest local edit immediately and continue optimizing next chunks.
- local_progress_candidates = [
- c
- for c in valid_raw_candidates
- if c.get("local_chunk_improved")
- ]
- if local_progress_candidates:
- best_local = sorted(
- local_progress_candidates,
- key=lambda c: (
- float(c.get("candidate_utility") or -999.0),
- float(c.get("chunk_goal_delta") or 0.0),
- float(c.get("bert_phrase_delta") or 0.0),
- float(c.get("candidate_score") or -999.0),
- ),
- reverse=True,
- )[0]
-
- prev_metrics = current_metrics
- current_text = best_local["text"]
- if str(best_local.get("operation") or "") == "title_rewrite":
- nt = (best_local.get("new_title") or best_local.get("sentence_after") or "").strip()
- if nt:
- current_title = nt
- current_analysis = best_local["analysis"]
- current_semantic = best_local["semantic"]
- current_metrics = best_local["metrics"]
- progressed_stage = _stage_primary_progress(active_stage, prev_metrics, current_metrics)
- if progressed_stage:
- stage_no_progress_steps = 0
- else:
- stage_no_progress_steps += 1
- applied_changes += 1
- queued_candidates = []
-
- logs.append(
- {
- "step": step + 1,
- "status": "applied_local_progress",
- "stage": active_stage,
- "goal": goal,
- "cascade_level": cascade_level,
- "operation": best_local.get("operation"),
- "sentence_index": best_local.get("sentence_index"),
- "span_start": best_local.get("span_start"),
- "span_end": best_local.get("span_end"),
- "span_variant": best_local.get("span_variant"),
- "sentence_before": best_local.get("sentence_before"),
- "sentence_after": best_local.get("sentence_after"),
- "reason": "Applied best local-improvement candidate despite no immediate global gain.",
- "current_score": prev_metrics.get("score"),
- "metrics_before": prev_metrics,
- "metrics_after": current_metrics,
- "delta_score": round(float(current_metrics.get("score", 0)) - float(prev_metrics.get("score", 0)), 3),
- "chosen_candidate_index": best_local.get("candidate_index"),
- "chosen_chunk_goal_delta": best_local.get("chunk_goal_delta"),
- "chosen_bert_phrase_delta": best_local.get("bert_phrase_delta"),
- "chosen_candidate_utility": best_local.get("candidate_utility"),
- "chosen_metrics_delta": best_local.get("metrics_delta"),
- "candidates": [
- {
- "candidate_index": c.get("candidate_index"),
- "valid": c.get("valid", False),
- "goal_improved": c.get("goal_improved", False),
- "bert_phrase_delta": c.get("bert_phrase_delta"),
- "local_chunk_improved": c.get("local_chunk_improved", False),
- "chunk_goal_delta": c.get("chunk_goal_delta"),
- "chunk_relevance_before": c.get("chunk_relevance_before"),
- "chunk_relevance_after": c.get("chunk_relevance_after"),
- "term_diff": c.get("term_diff"),
- "llm_prompt_debug": c.get("llm_prompt_debug"),
- "llm_rationale": c.get("llm_rationale"),
- "metrics_delta": c.get("metrics_delta"),
- "candidate_utility": c.get("candidate_utility"),
- "invalid_reasons": c.get("invalid_reasons", []),
- "delta_score": c.get("delta_score"),
- "candidate_score": c.get("candidate_score"),
- "sentence_after": c.get("sentence_after"),
- "error": c.get("error"),
- }
- for c in candidates
- ],
- }
- )
- consecutive_failures = 0
- cascade_level = 1
- goal_attempt_cursor[goal_key] = base_attempt_cursor + 1
- continue
-
- local_pool = [
- c
- for c in candidates
- if c.get("local_chunk_improved")
- and c.get("edit_payload")
- and c.get("candidate_score") is not None
- and goal.get("type") != "title"
- ]
- local_pool.sort(
- key=lambda c: (
- float(c.get("candidate_utility") or -999.0),
- float(c.get("chunk_goal_delta") or -999.0),
- float(c.get("candidate_score") or -999.0),
- ),
- reverse=True,
- )
- for c in local_pool[:4]:
- queue_key = (
- goal_key,
- c.get("operation"),
- c.get("span_start"),
- c.get("span_end"),
- str((c.get("sentence_after") or "")).strip().lower(),
- )
- if not any(x.get("queue_key") == queue_key for x in queued_candidates):
- queued_candidates.append({"queue_key": queue_key, "candidate": c})
-
- batch_applied = False
- batch_info: Dict[str, Any] = {}
- if len(queued_candidates) >= 2 and goal.get("type") != "title":
- pool = [x["candidate"] for x in queued_candidates[:6]]
- combos = _non_conflicting_edit_combos(pool, min_size=2, max_size=4)
- best_batch: Optional[Dict[str, Any]] = None
- prev_metrics = current_metrics
- for combo in combos:
- edits = [c.get("edit_payload") for c in combo if c.get("edit_payload")]
- if len(edits) != len(combo):
- continue
- batch_sentences = _apply_edits_to_sentences(sentences, edits)
- batch_text = " ".join(batch_sentences).strip()
- batch_analysis, batch_semantic, batch_metrics = _evaluate_candidate_state(batch_text, current_title)
- b_valid, b_reasons, b_goal = _is_candidate_valid(
- current_metrics, batch_metrics, goal["type"], goal["label"], optimization_mode
- )
- b_delta = round(batch_metrics["score"] - current_metrics["score"], 3)
- local_sum = sum(float(c.get("chunk_goal_delta") or 0.0) for c in combo)
- if not (b_valid and (b_goal or b_delta > 0)):
- continue
- if goal.get("type") == "bert" and local_sum < (_min_chunk_delta("bert") * len(combo)):
- continue
- cand = {
- "combo": combo,
- "batch_text": batch_text,
- "batch_analysis": batch_analysis,
- "batch_semantic": batch_semantic,
- "batch_metrics": batch_metrics,
- "b_delta": b_delta,
- "local_sum": local_sum,
- "b_reasons": b_reasons,
- "b_goal": b_goal,
- }
- if best_batch is None or (
- cand["b_delta"],
- cand["local_sum"],
- len(cand["combo"]),
- ) > (
- best_batch["b_delta"],
- best_batch["local_sum"],
- len(best_batch["combo"]),
- ):
- best_batch = cand
- if best_batch:
- current_text = best_batch["batch_text"]
- current_analysis = best_batch["batch_analysis"]
- current_semantic = best_batch["batch_semantic"]
- current_metrics = best_batch["batch_metrics"]
- progressed_stage = _stage_primary_progress(active_stage, prev_metrics, current_metrics)
- if progressed_stage:
- stage_no_progress_steps = 0
- else:
- stage_no_progress_steps += 1
- applied_changes += 1
- batch_applied = True
- batch_info = {
- "status": "applied_batch",
- "batch_candidate_ids": [c.get("candidate_index") for c in best_batch["combo"]],
- "batch_size": len(best_batch["combo"]),
- "batch_local_chunk_delta_sum": round(best_batch["local_sum"], 4),
- "delta_score": best_batch["b_delta"],
- "metrics_before": prev_metrics,
- "metrics_after": best_batch["batch_metrics"],
- "metrics_delta": _metrics_delta(prev_metrics, best_batch["batch_metrics"]),
- }
- queued_candidates = []
- consecutive_failures = 0
- cascade_level = 1
- goal_attempt_cursor[goal_key] = 0
- if not batch_applied:
- batch_info = {
- "status": "batch_rejected",
- "reason": "Queued local improvements could not pass global constraints together.",
- }
-
- if batch_applied:
- logs.append(
- {
- "step": step + 1,
- "status": "applied_batch",
- "stage": active_stage,
- "goal": goal,
- "cascade_level": cascade_level,
- "operation": "batch",
- "sentence_index": primary_span.get("sentence_index"),
- "span_start": primary_span.get("span_start"),
- "span_end": primary_span.get("span_end"),
- "span_variant": primary_span.get("span_variant"),
- "sentence_before": primary_span.get("sentence_before"),
- "current_score": (batch_info.get("metrics_before") or {}).get("score"),
- "metrics_before": batch_info.get("metrics_before"),
- "metrics_after": current_metrics,
- "reason": "Applied queued local-improvement edits as a batch.",
- "batch_info": batch_info,
- "candidates": [
- {
- "candidate_index": c.get("candidate_index"),
- "valid": c.get("valid", False),
- "goal_improved": c.get("goal_improved", False),
- "bert_phrase_delta": c.get("bert_phrase_delta"),
- "local_chunk_improved": c.get("local_chunk_improved", False),
- "chunk_goal_delta": c.get("chunk_goal_delta"),
- "chunk_relevance_before": c.get("chunk_relevance_before"),
- "chunk_relevance_after": c.get("chunk_relevance_after"),
- "term_diff": c.get("term_diff"),
- "llm_prompt_debug": c.get("llm_prompt_debug"),
- "llm_rationale": c.get("llm_rationale"),
- "metrics_delta": c.get("metrics_delta"),
- "candidate_utility": c.get("candidate_utility"),
- "invalid_reasons": c.get("invalid_reasons", []),
- "delta_score": c.get("delta_score"),
- "candidate_score": c.get("candidate_score"),
- "sentence_after": c.get("sentence_after"),
- "error": c.get("error"),
- }
- for c in candidates
- ],
- }
- )
- continue
-
- logs.append(
- {
- "step": step + 1,
- "status": "rejected",
- "stage": active_stage,
- "goal": goal,
- "cascade_level": cascade_level,
- "operation": primary_span.get("operation"),
- "sentence_index": primary_span.get("sentence_index"),
- "span_start": primary_span.get("span_start"),
- "span_end": primary_span.get("span_end"),
- "span_variant": primary_span.get("span_variant"),
- "sentence_before": primary_span.get("sentence_before"),
- "current_score": current_metrics["score"],
- "reason": (
- "No valid candidate satisfied constraints."
- if not valid_raw_candidates
- else "Valid candidates existed but none improved goal or total score."
- ),
- "valid_candidates_count": len(valid_raw_candidates),
- "promotable_candidates_count": len(valid_candidates),
- "queued_local_candidates": len(local_pool),
- "queued_total": len(queued_candidates),
- "batch_info": batch_info if batch_info else None,
- "candidates": [
- {
- "candidate_index": c.get("candidate_index"),
- "valid": c.get("valid", False),
- "goal_improved": c.get("goal_improved", False),
- "bert_phrase_delta": c.get("bert_phrase_delta"),
- "local_chunk_improved": c.get("local_chunk_improved", False),
- "chunk_goal_delta": c.get("chunk_goal_delta"),
- "chunk_relevance_before": c.get("chunk_relevance_before"),
- "chunk_relevance_after": c.get("chunk_relevance_after"),
- "term_diff": c.get("term_diff"),
- "llm_prompt_debug": c.get("llm_prompt_debug"),
- "llm_rationale": c.get("llm_rationale"),
- "metrics_delta": c.get("metrics_delta"),
- "candidate_utility": c.get("candidate_utility"),
- "invalid_reasons": c.get("invalid_reasons", []),
- "delta_score": c.get("delta_score"),
- "candidate_score": c.get("candidate_score"),
- "sentence_after": c.get("sentence_after"),
- "error": c.get("error"),
- }
- for c in candidates
- ],
- }
- )
- stage_no_progress_steps += 1
- # Stage transition is controlled by per-stage iteration budget and completion checks.
- consecutive_failures += 1
- if consecutive_failures >= 2 and cascade_level < 4:
- cascade_level += 1
- consecutive_failures = 0
- logs[-1]["escalated_to_level"] = cascade_level
- continue
-
- best = sorted(
- valid_candidates,
- key=lambda c: (
- 1 if c.get("goal_improved") else 0,
- float(c.get("candidate_utility") or -999.0),
- float(c.get("bert_phrase_delta") or 0.0),
- float(c.get("chunk_goal_delta") or 0.0),
- c["metrics"]["score"],
- ),
- reverse=True,
- )[0]
-
- prev_metrics = current_metrics
- current_text = best["text"]
- if str(best.get("operation") or "") == "title_rewrite":
- nt = (best.get("new_title") or best.get("sentence_after") or "").strip()
- if nt:
- current_title = nt
- current_analysis = best["analysis"]
- current_semantic = best["semantic"]
- current_metrics = best["metrics"]
- progressed_stage = _stage_primary_progress(active_stage, prev_metrics, current_metrics)
- if progressed_stage:
- stage_no_progress_steps = 0
- else:
- stage_no_progress_steps += 1
- applied_changes += 1
- queued_candidates = []
-
- logs.append(
- {
- "step": step + 1,
- "status": "applied",
- "stage": active_stage,
- "goal": goal,
- "cascade_level": cascade_level,
- "operation": best.get("operation"),
- "sentence_index": best.get("sentence_index"),
- "span_start": best.get("span_start"),
- "span_end": best.get("span_end"),
- "span_variant": best.get("span_variant"),
- "sentence_before": best.get("sentence_before"),
- "sentence_after": best["sentence_after"],
- "current_score": prev_metrics["score"],
- "metrics_before": prev_metrics,
- "metrics_after": current_metrics,
- "delta_score": round(current_metrics["score"] - prev_metrics["score"], 3),
- "chosen_candidate_index": best.get("candidate_index"),
- "chosen_candidate_utility": best.get("candidate_utility"),
- "candidates": [
- {
- "candidate_index": c.get("candidate_index"),
- "valid": c.get("valid", False),
- "goal_improved": c.get("goal_improved", False),
- "bert_phrase_delta": c.get("bert_phrase_delta"),
- "local_chunk_improved": c.get("local_chunk_improved", False),
- "chunk_goal_delta": c.get("chunk_goal_delta"),
- "chunk_relevance_before": c.get("chunk_relevance_before"),
- "chunk_relevance_after": c.get("chunk_relevance_after"),
- "term_diff": c.get("term_diff"),
- "llm_prompt_debug": c.get("llm_prompt_debug"),
- "llm_rationale": c.get("llm_rationale"),
- "metrics_delta": c.get("metrics_delta"),
- "candidate_utility": c.get("candidate_utility"),
- "invalid_reasons": c.get("invalid_reasons", []),
- "delta_score": c.get("delta_score"),
- "candidate_score": c.get("candidate_score"),
- "sentence_after": c.get("sentence_after"),
- "error": c.get("error"),
- }
- for c in candidates
- ],
- }
- )
- # After successful edit, return to cheapest level and reset failure streak.
- consecutive_failures = 0
- cascade_level = 1
- goal_attempt_cursor[goal_key] = 0
-
- return _pack_result()