"""Safer varied structural fallbacks when paraphrase cannot change wording.""" from __future__ import annotations from app.config import ENGINE_SPLIT_LONG, ENGINE_SPLIT_MIN_WORDS from app.engine.paraphrase import sufficiently_changed from app.engine.plan import build_plan from app.engine.rewrite import generate_candidates, reorder_quality_ok from app.engine.split import try_split_coordinated_verbs, try_split_long_sentence from app.engine.templates import ( try_because_front, try_by_agent_front, try_complex_clause_swap, try_copula_np_invert, try_for_purpose_front, try_in_both_front, try_in_pp_front, try_such_as_front, try_unfront_opener, try_when_clause_front, ) from app.engine.voice import active_to_passive, passive_to_active _DEDICATED = frozenset( { "for_purpose_front", "in_pp_front", "copula_np_invert", "such_as_front", "when_clause_front", "by_agent_front", "in_both_front", "split_long", "split_coordinated_verbs", # Voice flips move the agent by design, so the reorder-quality check # (written for slot moves) does not apply to them. "passive_to_active", "active_to_passive", } ) def structural_fallback_candidates( text: str, *, min_confidence: float = 0.35, include_plan: bool = True, ) -> list[tuple[str, str, float]]: """Return (template_id, rewritten, confidence) reorder options. Prefers voice/clause moves over fixed clefts. Never invents new wording. Set ``include_plan`` false when the caller already holds the plan's slot templates; rebuilding them here costs a second parse for no new options. """ source = (text or "").strip() if not source: return [] results: list[tuple[str, str, float]] = [] seen: set[str] = set() def _add(template_id: str, candidate: str | None, confidence: float) -> None: if not candidate: return cleaned = candidate.strip() key = cleaned.lower().rstrip(".!?") if not cleaned or key in seen: return if key == source.lower().rstrip(".!?"): return # Clause splits keep most words; require a real boundary change instead # of the stricter paraphrase divergence gate. if template_id in {"split_long", "split_coordinated_verbs"}: src_stops = sum(source.count(mark) for mark in ".!?") cand_stops = sum(cleaned.count(mark) for mark in ".!?") if cand_stops <= src_stops: return elif not sufficiently_changed(source, cleaned): return if template_id not in _DEDICATED and not reorder_quality_ok(source, cleaned): return seen.add(key) results.append((template_id, cleaned, confidence)) _add("when_clause_front", try_when_clause_front(source), 0.80) _add("because_front", try_because_front(source), 0.78) _add("for_purpose_front", try_for_purpose_front(source), 0.76) _add("in_both_front", try_in_both_front(source), 0.75) _add("in_pp_front", try_in_pp_front(source), 0.74) _add("complex_clause_swap", try_complex_clause_swap(source), 0.74) _add("unfront_opener", try_unfront_opener(source), 0.77) _add("by_agent_front", try_by_agent_front(source), 0.73) _add("copula_np_invert", try_copula_np_invert(source), 0.72) _add("such_as_front", try_such_as_front(source), 0.70) if ENGINE_SPLIT_LONG: _add( "split_coordinated_verbs", try_split_coordinated_verbs(source, min_words=ENGINE_SPLIT_MIN_WORDS), 0.71, ) _add( "split_long", try_split_long_sentence(source, min_words=ENGINE_SPLIT_MIN_WORDS), 0.69, ) # Voice flips restructure the whole clause, so they rank above slot moves. _add("passive_to_active", passive_to_active(source), 0.79) _add("active_to_passive", active_to_passive(source), 0.68) if include_plan: plan = build_plan(source, min_confidence=min_confidence) if plan.safe: for template_id, candidate, confidence in generate_candidates(plan): _add(template_id, candidate, confidence) results.sort(key=lambda item: -item[2]) return results