def _resolve_target_in_paragraph( para_text: str, target_text: str, before_anchor: str, after_anchor: str, ) -> Optional[ResolveResult]: para_map = _build_char_map(para_text) # --- Layer 1: Exact match --- exact_positions = _find_all(para_text, target_text) if len(exact_positions) == 1: return ResolveResult( target_text=target_text, before_anchor=before_anchor, after_anchor=after_anchor, strategy="exact", confidence=1.0, paragraph_id="", ) if len(exact_positions) > 1: if before_anchor or after_anchor: for pos in exact_positions: if before_anchor and para_text[:pos].endswith(before_anchor): return ResolveResult( target_text=target_text, before_anchor=before_anchor, after_anchor=after_anchor, strategy="exact", confidence=1.0, paragraph_id="", ) if after_anchor and para_text[pos + len(target_text):].startswith(after_anchor): return ResolveResult( target_text=target_text, before_anchor=before_anchor, after_anchor=after_anchor, strategy="exact", confidence=1.0, paragraph_id="", ) # --- Layer 2: Whitespace-insensitive match --- norm_target = _normalize_for_match(target_text) if norm_target: norm_positions = _find_all(para_map.normalized, norm_target) # If no exact case match, try case-insensitive if not norm_positions: norm_lower = norm_target.lower() para_lower = para_map.normalized.lower() norm_positions_ci = _find_all(para_lower, norm_lower) if norm_positions_ci and len(norm_positions_ci) == 1: norm_positions = norm_positions_ci if len(norm_positions) == 1: ns = norm_positions[0] ne = ns + len(norm_target) orig_start, orig_end = _map_norm_range_to_orig(para_map, ns, ne) resolved = para_text[orig_start:orig_end] return ResolveResult( target_text=resolved, before_anchor="", after_anchor="", strategy="whitespace_normalized", confidence=0.95, paragraph_id="", was_resolved=True, ) if len(norm_positions) > 1: # --- Layer 3: Anchor-aware normalized match --- norm_before = _normalize_for_match(before_anchor) if before_anchor else "" norm_after = _normalize_for_match(after_anchor) if after_anchor else "" best_idx: Optional[int] = None best_score = -1 for pos in norm_positions: end_pos = pos + len(norm_target) score = 0 if norm_before: prefix = para_map.normalized[max(0, pos - len(norm_before)):pos] if prefix.lower().endswith(norm_before.lower()): score += 2 elif prefix[-len(norm_before):].lower() == norm_before[-len(prefix):].lower() if len(prefix) >= len(norm_before) else prefix.lower() == norm_before[-len(prefix):].lower(): score += 1 if norm_after: suffix_start = end_pos suffix = para_map.normalized[suffix_start:suffix_start + len(norm_after)] if suffix.lower().startswith(norm_after.lower()): score += 2 elif suffix[:len(norm_after)].lower() == norm_after[:len(suffix)].lower() if len(suffix) >= len(norm_after) else suffix.lower() == norm_after[:len(suffix)].lower(): score += 1 if score > best_score: best_score = score best_idx = pos if best_idx is not None and best_score > 0: ns = best_idx ne = ns + len(norm_target) orig_start, orig_end = _map_norm_range_to_orig(para_map, ns, ne) resolved = para_text[orig_start:orig_end] return ResolveResult( target_text=resolved, before_anchor="", after_anchor="", strategy="anchor_normalized", confidence=0.8 if best_score >= 3 else 0.65, paragraph_id="", was_resolved=True, ) # --- Layer 4: Sentence-level fallback --- sentences = _sentence_tokenize(para_text) target_bigrams = _char_bigrams(target_text) if target_bigrams and sentences: scored: list[tuple[float, int, int]] = [] for s_start, s_end in sentences: sent = para_text[s_start:s_end] sent_bigrams = _char_bigrams(sent) score = _bigram_containment(target_bigrams, sent_bigrams) scored.append((score, s_start, s_end)) scored.sort(key=lambda x: x[0], reverse=True) if scored: best = scored[0] runner_up = scored[1][0] if len(scored) > 1 else 0.0 if best[0] >= 0.70 and best[0] - runner_up > 0.10: resolved = para_text[best[1]:best[2]] return ResolveResult( target_text=resolved, before_anchor="", after_anchor="", strategy="sentence_fallback", confidence=round(best[0], 2), paragraph_id="", was_resolved=True, ) return None