| 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<verb>" + "|".join(sorted(_ACTION_LOOKUP, key=len, reverse=True)) + r")\b", |
| re.IGNORECASE, |
| ) |
| _SIMPLE_PAST_CLAIM_PATTERN = re.compile( |
| r"\byou\s+(?P<verb>" |
| + "|".join(sorted(_PAST_ACTION_LOOKUP, key=len, reverse=True)) |
| + r")\b", |
| re.IGNORECASE, |
| ) |
| _DIRECT_CLAIM_PATTERN = re.compile( |
| r"(?:^|[.!?]\s+)[\"'“”]?\s*you\s+(?P<verb>[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 |
|
|