from __future__ import annotations import re from collections.abc import Sequence from .schema import Clearing, SituationPlan _WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]{2,}") _DUPLICATE_WORD_PATTERN = re.compile(r"[A-Za-z][A-Za-z'-]*") _DUPLICATE_FIELDS = ("scene_intro", "narration", "reflection", "spell") _ARC_ORDER = ("arrive", "steady", "widen", "step", "carry") _ABSTRACT_STOCK_PHRASES = ( "leave space around the worry", "more than one future remains possible", "the whole path is already settled", "the whole path is settled", "keep your own pace", "return to what is known", "stay with what is known", "treating its forecast as a settled fact", "what deserves attention now", "when the worry grows loud", ) _PRACTICAL_ACTION_PATTERN = re.compile( r"\b(?:could|might|can|consider|try|one option is(?: to)?|" r"(?:one|a) small step is(?: to)?)\b" r"(?:\W+\w+){0,5}\W+" r"(?:ask|break|check|choos(?:e|ing)|compar(?:e|ing)|contact|" r"focus|identif(?:y|ying)|list|look|not(?:e|ing)|pick|practic(?:e|ing)|" r"read|review|schedul(?:e|ing)|start|stud(?:y|ying)|talk|test|" r"tackl(?:e|ing)|revisit|writ(?:e|ing))\b", re.IGNORECASE, ) _NUMBER_WORDS = { "zero", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", } _DATE_WORDS = { "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", "january", "february", "march", "april", "june", "july", "august", "september", "october", "november", "december", } _UNSUPPORTED_DETAIL_PHRASES = { "application", "applications", "cafe", "coffee break", "coffee breaks", "company", "cover letter", "cover letters", "hiring manager", "hiring managers", "interview", "interviews", "mentor", "old boss", "team meeting", "team meetings", } _ACTION_FORMS = { "apply": {"applied", "apply"}, "ask": {"asked", "ask"}, "book": {"booked", "book"}, "complete": {"completed", "complete"}, "draft": {"drafted", "draft"}, "finish": {"finished", "finish"}, "fix": {"fixed", "fix"}, "include": {"included", "include"}, "make": {"made", "make"}, "meet": {"met", "meet"}, "notice": {"noticed", "notice"}, "practice": {"practiced", "practised", "practice", "practise"}, "prepare": {"prepared", "prepare"}, "remember": {"remembered", "remember"}, "research": {"researched", "research"}, "revise": {"revised", "revise"}, "schedule": {"scheduled", "schedule"}, "send": {"sent", "send"}, "show": {"showed", "shown", "show"}, "speak": {"spoke", "spoken", "speak"}, "start": {"started", "start"}, "talk": {"talked", "talk"}, "write": {"wrote", "written", "write"}, } _ACTION_LOOKUP = {form: root for root, forms in _ACTION_FORMS.items() for form in forms} _PAST_ACTION_LOOKUP = { form: root for root, forms in _ACTION_FORMS.items() for form in forms if form not in {root, "practise"} } _PERFECT_CLAIM_PATTERN = re.compile( r"\byou(?:'ve| have| had)\s+(?:already\s+)?" r"(?P" + "|".join(sorted(_ACTION_LOOKUP, key=len, reverse=True)) + r")\b", re.IGNORECASE, ) _SIMPLE_PAST_CLAIM_PATTERN = re.compile( r"\byou\s+(?P" + "|".join(sorted(_PAST_ACTION_LOOKUP, key=len, reverse=True)) + r")\b", re.IGNORECASE, ) _DIRECT_CLAIM_PATTERN = re.compile( r"(?:^|[.!?]\s+)[\"'“”]?\s*you\s+(?P[a-z][a-z'-]*)\b", re.IGNORECASE, ) _NON_BIOGRAPHICAL_YOU_VERBS = { "are", "can", "could", "deserve", "do", "don't", "fear", "feel", "hope", "know", "matter", "may", "might", "need", "seem", "should", "want", "wonder", "would", "worry", } _STOP_WORDS = { "about", "after", "again", "and", "are", "because", "before", "being", "but", "can", "for", "from", "have", "help", "into", "need", "new", "next", "starting", "staying", "that", "the", "their", "them", "this", "with", "you", "your", } def extract_situation_terms(situation: str) -> set[str]: return { token.casefold() for token in _WORD_PATTERN.findall(situation) if token.casefold() not in _STOP_WORDS } def groundedness_score(line: str, situation: str) -> int: situation_terms = extract_situation_terms(situation) line_terms = {token.casefold() for token in _WORD_PATTERN.findall(line)} return len(situation_terms & line_terms) def is_situation_grounded(line: str, situation: str) -> bool: return groundedness_score(line, situation) >= 1 def _normalized_phrase(text: str) -> str: return " ".join(_DUPLICATE_WORD_PATTERN.findall(text.casefold())) def source_phrase_in_situation(source_phrase: str, situation: str) -> bool: source = _normalized_phrase(source_phrase) return bool(source) and source in _normalized_phrase(situation) def invalid_fact_anchor_indices( plan: SituationPlan, situation: str, ) -> list[int]: return [ index for index, anchor in enumerate(plan.fact_anchors) if not source_phrase_in_situation(anchor.source_phrase, situation) ] def _number_tokens(text: str) -> set[str]: return { token for token in re.findall(r"\b(?:\d+|[a-z]+)\b", text.casefold()) if token.isdigit() or token in _NUMBER_WORDS } def _date_tokens(text: str) -> set[str]: tokens = set(re.findall(r"\b[a-z]+\b", text.casefold())) result = tokens & _DATE_WORDS result.update(re.findall(r"\b\d{1,2}(?::\d{2})?\s*(?:a\.?m\.?|p\.?m\.?)\b", text.casefold())) return result def _action_roots(text: str) -> set[str]: tokens = set(re.findall(r"\b[a-z]+\b", text.casefold())) return {root for form, root in _ACTION_LOOKUP.items() if form in tokens} def _match_is_in_question(text: str, start: int) -> bool: sentence_start = max( text.rfind(".", 0, start), text.rfind("!", 0, start), text.rfind("?", 0, start), ) sentence_end_candidates = [ position for punctuation in ".!?" if (position := text.find(punctuation, start)) >= 0 ] if not sentence_end_candidates: return False sentence_end = min(sentence_end_candidates) return text[sentence_start + 1 : sentence_end + 1].strip().endswith("?") def unsupported_specificity(text: str, situation: str) -> set[str]: """Find concrete claims in generated prose that the user did not provide.""" issues: set[str] = set() if _number_tokens(text) - _number_tokens(situation): issues.add("invented_number") if _date_tokens(text) - _date_tokens(situation): issues.add("invented_date") normalized_text = _normalized_phrase(text) normalized_situation = _normalized_phrase(situation) if any( phrase in normalized_text and phrase not in normalized_situation for phrase in _UNSUPPORTED_DETAIL_PHRASES ): issues.add("unsupported_detail") situation_actions = _action_roots(situation) past_claims = ( (_PERFECT_CLAIM_PATTERN, _ACTION_LOOKUP), (_SIMPLE_PAST_CLAIM_PATTERN, _PAST_ACTION_LOOKUP), ) for pattern, lookup in past_claims: for match in pattern.finditer(text): if _match_is_in_question(text, match.start()): continue root = lookup[match.group("verb").casefold()] if root not in situation_actions: issues.add("unsupported_past_claim") break if "unsupported_past_claim" in issues: break situation_tokens = set(re.findall(r"\b[a-z][a-z'-]*\b", situation.casefold())) for match in _DIRECT_CLAIM_PATTERN.finditer(text): verb = match.group("verb").casefold() if verb not in _NON_BIOGRAPHICAL_YOU_VERBS and verb not in situation_tokens: issues.add("unsupported_direct_claim") break return issues def content_quality_issues(clearing: Clearing) -> set[str]: """Report stock abstraction and missing practical help in a clearing.""" issues: set[str] = set() normalized = _normalized_phrase( " ".join((clearing.scene_intro, clearing.narration, clearing.reflection)) ) if any(phrase in normalized for phrase in _ABSTRACT_STOCK_PHRASES): issues.add("abstract_language") if clearing.arc_role == "step" and ( "abstract_language" in issues or not _PRACTICAL_ACTION_PATTERN.search(clearing.narration) ): issues.add("missing_practical_step") return issues def repeated_source_phrase_indices( clearings: Sequence[Clearing], *, minimum_words: int = 5, ) -> set[int]: """Flag later narrations that repeat the same long source phrase.""" seen: set[str] = set() repeated: set[int] = set() for index, clearing in enumerate(clearings): source = _normalized_phrase(clearing.source_phrase) if len(source.split()) < minimum_words: continue if source not in _normalized_phrase(clearing.narration): continue if source in seen: repeated.add(index) seen.add(source) return repeated def valid_arc_indices(clearings: Sequence[Clearing]) -> list[int]: """Return the first clearing for each arc role in narrative order.""" by_role: dict[str, int] = {} for index, clearing in enumerate(clearings): by_role.setdefault(clearing.arc_role, index) return [by_role[role] for role in _ARC_ORDER if role in by_role] def _normalized_tokens(text: str) -> set[str]: return {token.casefold().strip("'") for token in _DUPLICATE_WORD_PATTERN.findall(text)} def _token_containment(left: str, right: str) -> float: left_tokens = _normalized_tokens(left) right_tokens = _normalized_tokens(right) if not left_tokens or not right_tokens: return 0 return len(left_tokens & right_tokens) / min(len(left_tokens), len(right_tokens)) def duplicate_fields( clearings: Sequence[Clearing], indices: Sequence[int] | None = None, *, threshold: float = 0.78, ) -> dict[int, list[str]]: """Report fields in later clearings that substantially repeat an earlier one.""" candidate_indices = list(indices) if indices is not None else list(range(len(clearings))) duplicates: dict[int, list[str]] = {} for position, index in enumerate(candidate_indices): for prior_index in candidate_indices[:position]: for field in _DUPLICATE_FIELDS: if field in duplicates.get(index, []): continue left = getattr(clearings[prior_index], field) right = getattr(clearings[index], field) if _token_containment(left, right) >= threshold: duplicates.setdefault(index, []).append(field) return duplicates def distinct_indices( clearings: Sequence[Clearing], indices: Sequence[int], *, threshold: float = 0.78, ) -> list[int]: """Keep the earliest clearing from each group of repetitive prose.""" selected: list[int] = [] for index in indices: if not duplicate_fields( clearings, [*selected, index], threshold=threshold, ).get(index): selected.append(index) return selected