mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
- Added gender inference improvements in speaker_analysis.py, including handling of titles and diacritics. - Updated analyze_speakers function to include sample excerpts with context paragraphs. - Modified routes.py to skip suppressed speakers in the speaker roster. - Enhanced prepare.js to manage speaker samples and pronunciation previews more effectively. - Refined prepare_chapters.html and prepare_speakers.html templates for better navigation and user experience. - Added tests for speaker analysis to ensure proper handling of stopwords and threshold suppression.
715 lines
22 KiB
Python
715 lines
22 KiB
Python
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||
|
||
import unicodedata
|
||
|
||
_DIALOGUE_VERBS = (
|
||
"said",
|
||
"asked",
|
||
"replied",
|
||
"whispered",
|
||
"shouted",
|
||
"cried",
|
||
"muttered",
|
||
"answered",
|
||
"hissed",
|
||
"called",
|
||
"added",
|
||
"continued",
|
||
"insisted",
|
||
"remarked",
|
||
"yelled",
|
||
"breathed",
|
||
"murmured",
|
||
"exclaimed",
|
||
"explained",
|
||
"noted",
|
||
)
|
||
|
||
_VERB_PATTERN = "(?:" + "|".join(_DIALOGUE_VERBS) + ")"
|
||
_NAME_FRAGMENT = r"[A-ZÀ-ÖØ-Þ][\w'’\-]*"
|
||
_NAME_PATTERN = rf"{_NAME_FRAGMENT}(?:\s+{_NAME_FRAGMENT})*"
|
||
|
||
_COLON_PATTERN = re.compile(rf"^\s*({_NAME_PATTERN})\s*:\s*(.+)$")
|
||
_NAME_BEFORE_VERB = re.compile(rf"({_NAME_PATTERN})\s+{_VERB_PATTERN}\b", re.IGNORECASE)
|
||
_VERB_BEFORE_NAME = re.compile(rf"{_VERB_PATTERN}\s+({_NAME_PATTERN})", re.IGNORECASE)
|
||
_PRONOUN_PATTERN = re.compile(r"\b(?:he|she|they)\b", re.IGNORECASE)
|
||
_QUOTE_PATTERN = re.compile(r'["“”]([^"“”\\]*(?:\\.[^"“”\\]*)*)["”]')
|
||
_MALE_PRONOUN_PATTERN = re.compile(r"\b(?:he|him|his|himself)\b", re.IGNORECASE)
|
||
_FEMALE_PRONOUN_PATTERN = re.compile(r"\b(?:she|her|hers|herself)\b", re.IGNORECASE)
|
||
_PRONOUN_LABELS = {
|
||
"he",
|
||
"she",
|
||
"they",
|
||
"them",
|
||
"theirs",
|
||
"their",
|
||
"themselves",
|
||
"him",
|
||
"his",
|
||
"himself",
|
||
"her",
|
||
"hers",
|
||
"herself",
|
||
"we",
|
||
"us",
|
||
"our",
|
||
"ours",
|
||
"ourselves",
|
||
"i",
|
||
"me",
|
||
"my",
|
||
"mine",
|
||
"myself",
|
||
"you",
|
||
"your",
|
||
"yours",
|
||
"yourself",
|
||
"yourselves",
|
||
}
|
||
|
||
_CONFIDENCE_RANK = {"low": 1, "medium": 2, "high": 3}
|
||
|
||
_FEMALE_TITLE_HINTS = (
|
||
"madame",
|
||
"mme",
|
||
"madam",
|
||
"mrs",
|
||
"miss",
|
||
"ms",
|
||
"lady",
|
||
"countess",
|
||
"baroness",
|
||
"princess",
|
||
"queen",
|
||
"mademoiselle",
|
||
)
|
||
|
||
_MALE_TITLE_HINTS = (
|
||
"monsieur",
|
||
"m.",
|
||
"mr",
|
||
"sir",
|
||
"lord",
|
||
"count",
|
||
"baron",
|
||
"prince",
|
||
"king",
|
||
"abbé",
|
||
"abbe",
|
||
)
|
||
|
||
_MALE_TOKEN_WEIGHTS = {
|
||
"he": 1.0,
|
||
"him": 0.6,
|
||
"his": 0.75,
|
||
"himself": 1.0,
|
||
}
|
||
|
||
_FEMALE_TOKEN_WEIGHTS = {
|
||
"she": 1.0,
|
||
"her": 0.4,
|
||
"hers": 0.75,
|
||
"herself": 1.0,
|
||
}
|
||
|
||
_STOP_LABELS = {
|
||
"and",
|
||
"but",
|
||
"then",
|
||
"though",
|
||
"meanwhile",
|
||
"therefore",
|
||
"after",
|
||
"before",
|
||
"when",
|
||
"while",
|
||
"because",
|
||
"as",
|
||
"yet",
|
||
"nor",
|
||
"so",
|
||
"thus",
|
||
"suddenly",
|
||
"eventually",
|
||
"finally",
|
||
"until",
|
||
"unless",
|
||
}
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class SpeakerGuess:
|
||
speaker_id: str
|
||
label: str
|
||
count: int = 0
|
||
confidence: str = "low"
|
||
sample_quotes: List[Dict[str, str]] = field(default_factory=list)
|
||
suppressed: bool = False
|
||
gender: str = "unknown"
|
||
detected_gender: str = "unknown"
|
||
male_votes: int = 0
|
||
female_votes: int = 0
|
||
|
||
def register_occurrence(
|
||
self,
|
||
confidence: str,
|
||
text: str,
|
||
quote: Optional[str],
|
||
male_votes: int,
|
||
female_votes: int,
|
||
sample_excerpt: Optional[str] = None,
|
||
) -> None:
|
||
self.count += 1
|
||
if _CONFIDENCE_RANK.get(confidence, 0) > _CONFIDENCE_RANK.get(self.confidence, 0):
|
||
self.confidence = confidence
|
||
|
||
excerpt = sample_excerpt if sample_excerpt is not None else _build_excerpt(text, quote)
|
||
gender_hint = _format_gender_hint(male_votes, female_votes)
|
||
if excerpt:
|
||
payload = {"excerpt": excerpt, "gender_hint": gender_hint}
|
||
if payload not in self.sample_quotes:
|
||
self.sample_quotes.append(payload)
|
||
if len(self.sample_quotes) > 3:
|
||
self.sample_quotes = self.sample_quotes[:3]
|
||
|
||
if male_votes:
|
||
self.male_votes += male_votes
|
||
if female_votes:
|
||
self.female_votes += female_votes
|
||
self.detected_gender = _derive_gender(self.male_votes, self.female_votes, self.detected_gender)
|
||
if self.gender in {"unknown", "male", "female"}:
|
||
self.gender = _derive_gender(self.male_votes, self.female_votes, self.gender)
|
||
|
||
def as_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"id": self.speaker_id,
|
||
"label": self.label,
|
||
"count": self.count,
|
||
"confidence": self.confidence,
|
||
"sample_quotes": [dict(sample) for sample in self.sample_quotes],
|
||
"suppressed": self.suppressed,
|
||
"gender": self.gender,
|
||
"detected_gender": self.detected_gender,
|
||
}
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class SpeakerAnalysis:
|
||
assignments: Dict[str, str]
|
||
speakers: Dict[str, SpeakerGuess]
|
||
suppressed: List[str]
|
||
narrator: str = "narrator"
|
||
version: str = "1.0"
|
||
stats: Dict[str, Any] = field(default_factory=dict)
|
||
|
||
def to_dict(self) -> Dict[str, Any]:
|
||
return {
|
||
"version": self.version,
|
||
"narrator": self.narrator,
|
||
"assignments": dict(self.assignments),
|
||
"speakers": {speaker_id: guess.as_dict() for speaker_id, guess in self.speakers.items()},
|
||
"suppressed": list(self.suppressed),
|
||
"stats": dict(self.stats),
|
||
}
|
||
|
||
|
||
def analyze_speakers(
|
||
chapters: Sequence[Dict[str, Any]] | Iterable[Dict[str, Any]],
|
||
chunks: Sequence[Dict[str, Any]] | Iterable[Dict[str, Any]],
|
||
*,
|
||
threshold: int = 3,
|
||
max_speakers: int = 8,
|
||
) -> SpeakerAnalysis:
|
||
narrator_id = "narrator"
|
||
speaker_guesses: Dict[str, SpeakerGuess] = {
|
||
narrator_id: SpeakerGuess(speaker_id=narrator_id, label="Narrator", confidence="low")
|
||
}
|
||
label_index: Dict[str, str] = {"Narrator": narrator_id}
|
||
assignments: Dict[str, str] = {}
|
||
suppressed: List[str] = []
|
||
|
||
ordered_chunks = sorted(
|
||
(dict(chunk) for chunk in chunks),
|
||
key=lambda entry: (
|
||
_safe_int(entry.get("chapter_index")),
|
||
_safe_int(entry.get("chunk_index")),
|
||
),
|
||
)
|
||
last_explicit: Optional[str] = None
|
||
explicit_assignments = 0
|
||
unique_speakers: set[str] = set()
|
||
|
||
for index, chunk in enumerate(ordered_chunks):
|
||
chunk_id = str(chunk.get("id") or "")
|
||
text = _get_chunk_text(chunk)
|
||
speaker_id, confidence, quote = _infer_chunk_speaker(text, last_explicit)
|
||
if speaker_id is None:
|
||
speaker_id = last_explicit or narrator_id
|
||
confidence = "medium" if last_explicit else "low"
|
||
quote = quote or _extract_quote(text)
|
||
if speaker_id != narrator_id:
|
||
last_explicit = speaker_id
|
||
explicit_assignments += 1
|
||
|
||
if speaker_id in speaker_guesses:
|
||
record_id = speaker_id
|
||
guess = speaker_guesses[record_id]
|
||
label = guess.label
|
||
else:
|
||
label = _normalize_label(speaker_id)
|
||
record_id = label_index.get(label)
|
||
if record_id is None:
|
||
record_id = _dedupe_slug(_slugify(label), speaker_guesses)
|
||
label_index[label] = record_id
|
||
speaker_guesses[record_id] = SpeakerGuess(speaker_id=record_id, label=label)
|
||
guess = speaker_guesses[record_id]
|
||
assignments[chunk_id] = record_id
|
||
unique_speakers.add(record_id)
|
||
|
||
if record_id != narrator_id and record_id != speaker_id and speaker_id == last_explicit:
|
||
last_explicit = record_id
|
||
|
||
sample_excerpt = None
|
||
if record_id != narrator_id:
|
||
sample_excerpt = _select_sample_excerpt(ordered_chunks, index, guess.label, quote, confidence)
|
||
|
||
male_votes, female_votes = _count_gender_votes(text, guess.label)
|
||
|
||
guess.register_occurrence(confidence, text, quote, male_votes, female_votes, sample_excerpt)
|
||
|
||
active_speakers = [sid for sid in speaker_guesses if sid != narrator_id]
|
||
# Apply minimum occurrence threshold.
|
||
for speaker_id in list(active_speakers):
|
||
guess = speaker_guesses[speaker_id]
|
||
if guess.count < max(1, threshold):
|
||
guess.suppressed = True
|
||
suppressed.append(speaker_id)
|
||
_reassign(assignments, speaker_id, narrator_id)
|
||
active_speakers.remove(speaker_id)
|
||
|
||
# Apply maximum active speaker cap.
|
||
if max_speakers and len(active_speakers) > max_speakers:
|
||
active_speakers.sort(key=lambda sid: (-speaker_guesses[sid].count, sid))
|
||
for speaker_id in active_speakers[max_speakers:]:
|
||
guess = speaker_guesses[speaker_id]
|
||
guess.suppressed = True
|
||
suppressed.append(speaker_id)
|
||
_reassign(assignments, speaker_id, narrator_id)
|
||
active_speakers = active_speakers[:max_speakers]
|
||
|
||
narrator_guess = speaker_guesses[narrator_id]
|
||
narrator_guess.count = sum(1 for value in assignments.values() if value == narrator_id)
|
||
narrator_guess.confidence = "low"
|
||
|
||
stats = {
|
||
"total_chunks": len(ordered_chunks),
|
||
"explicit_chunks": explicit_assignments,
|
||
"active_speakers": len(active_speakers),
|
||
"unique_speakers": len(unique_speakers),
|
||
"suppressed": len(suppressed),
|
||
}
|
||
|
||
return SpeakerAnalysis(
|
||
assignments=assignments,
|
||
speakers=speaker_guesses,
|
||
suppressed=suppressed,
|
||
narrator=narrator_id,
|
||
stats=stats,
|
||
)
|
||
|
||
|
||
def _infer_chunk_speaker(text: str, last_explicit: Optional[str]) -> Tuple[Optional[str], str, Optional[str]]:
|
||
normalized = text.strip()
|
||
if not normalized:
|
||
return None, "low", None
|
||
|
||
colon_match = _COLON_PATTERN.match(normalized)
|
||
if colon_match:
|
||
raw_label = colon_match.group(1)
|
||
cleaned = _normalize_candidate_name(raw_label)
|
||
if cleaned is None:
|
||
return None, "low", colon_match.group(2).strip()
|
||
quote = colon_match.group(2).strip()
|
||
return cleaned, "high", quote
|
||
|
||
quote = _extract_quote(normalized)
|
||
if not quote:
|
||
return None, "low", None
|
||
|
||
before, after = _split_around_quote(normalized, quote)
|
||
|
||
candidate = _match_name_near_quote(before, after)
|
||
if candidate:
|
||
cleaned = _normalize_candidate_name(candidate)
|
||
if cleaned:
|
||
return cleaned, "high", quote
|
||
|
||
if last_explicit:
|
||
pronoun_after = _PRONOUN_PATTERN.search(after)
|
||
pronoun_before = _PRONOUN_PATTERN.search(before)
|
||
if pronoun_after or pronoun_before:
|
||
return last_explicit, "medium", quote
|
||
|
||
return None, "low", quote
|
||
|
||
|
||
def _split_around_quote(text: str, quote: str) -> Tuple[str, str]:
|
||
quote_index = text.find(quote)
|
||
if quote_index == -1:
|
||
return text, ""
|
||
before = text[:quote_index]
|
||
after = text[quote_index + len(quote) :]
|
||
return before, after
|
||
|
||
|
||
def _match_name_near_quote(before: str, after: str) -> Optional[str]:
|
||
trailing = before[-120:]
|
||
leading = after[:120]
|
||
|
||
match = _NAME_BEFORE_VERB.search(trailing)
|
||
if match:
|
||
name = match.group(1)
|
||
if _looks_like_name(name):
|
||
return name
|
||
|
||
match = re.search(rf"({_NAME_PATTERN})\s*,?\s*{_VERB_PATTERN}", leading, flags=re.IGNORECASE)
|
||
if match:
|
||
name = match.group(1)
|
||
if _looks_like_name(name):
|
||
return name
|
||
|
||
match = _VERB_BEFORE_NAME.search(leading)
|
||
if match:
|
||
name = match.group(1)
|
||
if _looks_like_name(name):
|
||
return name
|
||
|
||
return None
|
||
|
||
|
||
def _looks_like_name(value: str) -> bool:
|
||
normalized = _normalize_candidate_name(value)
|
||
if not normalized:
|
||
return False
|
||
parts = normalized.split()
|
||
if not parts:
|
||
return False
|
||
return all(part and part[0].isupper() for part in parts)
|
||
|
||
|
||
def _extract_quote(text: str) -> Optional[str]:
|
||
match = _QUOTE_PATTERN.search(text)
|
||
if not match:
|
||
return None
|
||
return match.group(0)
|
||
|
||
|
||
def _slugify(label: str) -> str:
|
||
slug = re.sub(r"[^a-z0-9]+", "_", label.lower()).strip("_")
|
||
return slug or "speaker"
|
||
|
||
|
||
def _dedupe_slug(slug: str, existing: Dict[str, SpeakerGuess]) -> str:
|
||
candidate = slug
|
||
index = 2
|
||
while candidate in existing:
|
||
candidate = f"{slug}_{index}"
|
||
index += 1
|
||
return candidate
|
||
|
||
|
||
def _normalize_label(label: str) -> str:
|
||
words = re.split(r"\s+", label.strip())
|
||
return " ".join(word.capitalize() for word in words if word)
|
||
|
||
|
||
def _safe_int(value: Any, default: int = 0) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
def _reassign(assignments: Dict[str, str], old: str, new: str) -> None:
|
||
for key, value in list(assignments.items()):
|
||
if value == old:
|
||
assignments[key] = new
|
||
|
||
|
||
def _strip_diacritics(value: str) -> str:
|
||
normalized = unicodedata.normalize("NFKD", value)
|
||
return "".join(char for char in normalized if not unicodedata.combining(char))
|
||
|
||
|
||
def _count_gender_votes(text: str, label: Optional[str]) -> Tuple[int, int]:
|
||
if not text:
|
||
return 0, 0
|
||
|
||
search_text = text
|
||
windows: List[Tuple[int, int]] = []
|
||
degrade_factor = 1.0
|
||
|
||
if label:
|
||
pattern = re.compile(re.escape(label), re.IGNORECASE)
|
||
matches = list(pattern.finditer(search_text))
|
||
if not matches:
|
||
alt_label = _strip_diacritics(label)
|
||
if alt_label and alt_label != label:
|
||
ascii_text = _strip_diacritics(search_text)
|
||
pattern_alt = re.compile(re.escape(alt_label), re.IGNORECASE)
|
||
windows = [match.span() for match in pattern_alt.finditer(ascii_text)]
|
||
# Map spans back roughly using proportional index
|
||
if windows:
|
||
mapped: List[Tuple[int, int]] = []
|
||
for start, end in windows:
|
||
start_idx = min(len(search_text) - 1, int(start * len(search_text) / max(len(ascii_text), 1)))
|
||
end_idx = min(len(search_text), int(end * len(search_text) / max(len(ascii_text), 1)))
|
||
mapped.append((start_idx, end_idx))
|
||
windows = mapped
|
||
else:
|
||
windows = [match.span() for match in matches]
|
||
|
||
if not windows:
|
||
windows = [(0, len(search_text))]
|
||
degrade_factor = 0.25
|
||
|
||
radius = 60
|
||
quote_spans: List[Tuple[int, int, str]] = []
|
||
for match in _QUOTE_PATTERN.finditer(search_text):
|
||
try:
|
||
content_start, content_end = match.span(1)
|
||
except IndexError:
|
||
content_start, content_end = match.span()
|
||
if content_start < content_end:
|
||
quote_spans.append((content_start, content_end, search_text[content_start:content_end]))
|
||
|
||
normalized_label = _normalize_candidate_name(label) if label else None
|
||
normalized_label_lower = normalized_label.lower() if normalized_label else None
|
||
|
||
def _window_weight(position: int) -> float:
|
||
for start, end in windows:
|
||
if position < start - radius or position > end + radius:
|
||
continue
|
||
if position >= end:
|
||
return 1.0
|
||
if position <= start:
|
||
return 0.2
|
||
return 1.0
|
||
return 0.0
|
||
|
||
def _quote_weight(position: int) -> float:
|
||
for start, end, content in quote_spans:
|
||
if position < start or position >= end:
|
||
continue
|
||
local_index = position - start
|
||
prefix = content[:local_index]
|
||
tail = prefix[-80:]
|
||
name_matches = list(re.finditer(_NAME_PATTERN, tail))
|
||
if name_matches:
|
||
last_name = _normalize_candidate_name(name_matches[-1].group(0))
|
||
if normalized_label_lower and last_name and last_name.lower() == normalized_label_lower:
|
||
return 0.6
|
||
return 0.05
|
||
if re.search(r"[.!?]\s", prefix):
|
||
return 0.2
|
||
if prefix.strip():
|
||
return 0.15
|
||
return 0.1
|
||
return 1.0
|
||
|
||
male_score = 0.0
|
||
for match in _MALE_PRONOUN_PATTERN.finditer(search_text):
|
||
base_weight = _window_weight(match.start())
|
||
if not base_weight:
|
||
continue
|
||
quote_modifier = _quote_weight(match.start())
|
||
weight = base_weight * quote_modifier
|
||
if not weight:
|
||
continue
|
||
token = match.group(0).lower()
|
||
male_score += _MALE_TOKEN_WEIGHTS.get(token, 0.6) * weight
|
||
|
||
female_score = 0.0
|
||
for match in _FEMALE_PRONOUN_PATTERN.finditer(search_text):
|
||
base_weight = _window_weight(match.start())
|
||
if not base_weight:
|
||
continue
|
||
quote_modifier = _quote_weight(match.start())
|
||
weight = base_weight * quote_modifier
|
||
if not weight:
|
||
continue
|
||
if quote_modifier >= 0.95:
|
||
weight = max(weight, 0.4)
|
||
token = match.group(0).lower()
|
||
female_score += _FEMALE_TOKEN_WEIGHTS.get(token, 0.4) * weight
|
||
|
||
for start, end in windows:
|
||
span_start = max(0, start - 40)
|
||
span_end = min(len(search_text), end + 40)
|
||
span_text = search_text[span_start:span_end].lower()
|
||
if any(title in span_text for title in _FEMALE_TITLE_HINTS):
|
||
female_score += 2.5
|
||
if any(title in span_text for title in _MALE_TITLE_HINTS):
|
||
male_score += 2.5
|
||
|
||
male_votes = int(round(male_score * degrade_factor))
|
||
female_votes = int(round(female_score * degrade_factor))
|
||
return male_votes, female_votes
|
||
|
||
|
||
def _derive_gender(male_votes: int, female_votes: int, current: str) -> str:
|
||
if male_votes == 0 and female_votes == 0:
|
||
return current if current != "unknown" else "unknown"
|
||
|
||
male_threshold = max(2, female_votes + 1)
|
||
female_threshold = max(2, male_votes + 1)
|
||
|
||
if male_votes >= male_threshold:
|
||
return "male"
|
||
if female_votes >= female_threshold:
|
||
return "female"
|
||
|
||
if current in {"male", "female"}:
|
||
return current
|
||
return "unknown"
|
||
|
||
|
||
def _get_chunk_text(chunk: Dict[str, Any]) -> str:
|
||
if not isinstance(chunk, dict):
|
||
return ""
|
||
value = chunk.get("normalized_text") or chunk.get("text") or ""
|
||
return str(value)
|
||
|
||
|
||
def _trim_paragraph(paragraph: str, limit: int = 600) -> str:
|
||
normalized = (paragraph or "").strip()
|
||
if not normalized:
|
||
return ""
|
||
if len(normalized) <= limit:
|
||
return normalized
|
||
return normalized[: limit - 1].rstrip() + "…"
|
||
|
||
|
||
def _compose_context_excerpt(before: str, current: str, after: str) -> str:
|
||
segments = []
|
||
for value in (before, current, after):
|
||
trimmed = _trim_paragraph(value)
|
||
if trimmed:
|
||
segments.append(trimmed)
|
||
return "\n\n".join(segments)
|
||
|
||
|
||
def _contains_dialogue_attribution(label: str, text: str, quote: Optional[str]) -> bool:
|
||
if not label or not text:
|
||
return False
|
||
escaped_label = re.escape(label)
|
||
direct_pattern = re.compile(rf"\b{escaped_label}\b\s+(?:{_VERB_PATTERN})\b", re.IGNORECASE)
|
||
reverse_pattern = re.compile(rf"(?:{_VERB_PATTERN})\s+\b{escaped_label}\b", re.IGNORECASE)
|
||
colon_pattern = re.compile(rf"^\s*{escaped_label}\s*:\s*", re.IGNORECASE)
|
||
|
||
if colon_pattern.search(text):
|
||
return True
|
||
if direct_pattern.search(text) or reverse_pattern.search(text):
|
||
return True
|
||
if quote:
|
||
before, after = _split_around_quote(text, quote)
|
||
if direct_pattern.search(before) or reverse_pattern.search(after):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _select_sample_excerpt(
|
||
chunks: Sequence[Dict[str, Any]],
|
||
index: int,
|
||
label: str,
|
||
quote: Optional[str],
|
||
confidence: str,
|
||
) -> Optional[str]:
|
||
if confidence != "high" or not label:
|
||
return None
|
||
if index < 0 or index >= len(chunks):
|
||
return None
|
||
current = _get_chunk_text(chunks[index])
|
||
if not current or not _contains_dialogue_attribution(label, current, quote):
|
||
return None
|
||
previous = _get_chunk_text(chunks[index - 1]) if index > 0 else ""
|
||
following = _get_chunk_text(chunks[index + 1]) if index + 1 < len(chunks) else ""
|
||
excerpt = _compose_context_excerpt(previous, current, following)
|
||
return excerpt or None
|
||
|
||
|
||
def _build_excerpt(text: str, quote: Optional[str]) -> str:
|
||
normalized = (text or "").strip()
|
||
if not normalized:
|
||
return ""
|
||
if quote:
|
||
location = normalized.find(quote)
|
||
if location != -1:
|
||
start = max(0, location - 120)
|
||
end = min(len(normalized), location + len(quote) + 120)
|
||
snippet = normalized[start:end].strip()
|
||
if start > 0:
|
||
snippet = "…" + snippet
|
||
if end < len(normalized):
|
||
snippet = snippet + "…"
|
||
return snippet
|
||
if len(normalized) > 240:
|
||
return normalized[:240].rstrip() + "…"
|
||
return normalized
|
||
|
||
|
||
def _format_gender_hint(male_votes: int, female_votes: int) -> str:
|
||
if male_votes and female_votes:
|
||
return "Context mentions both male and female pronouns."
|
||
if male_votes:
|
||
if male_votes >= 3:
|
||
return "Multiple male pronouns detected nearby."
|
||
return "Some male pronouns detected in the surrounding text."
|
||
if female_votes:
|
||
if female_votes >= 3:
|
||
return "Multiple female pronouns detected nearby."
|
||
return "Some female pronouns detected in the surrounding text."
|
||
return "No clear pronoun signal detected."
|
||
|
||
|
||
def _normalize_candidate_name(raw: str) -> Optional[str]:
|
||
if not raw:
|
||
return None
|
||
cleaned = raw.strip().strip('"“”\'’.,:;!')
|
||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||
if not cleaned:
|
||
return None
|
||
parts = cleaned.split()
|
||
filtered: List[str] = []
|
||
for part in parts:
|
||
if not part:
|
||
continue
|
||
if not filtered and part.lower() in _STOP_LABELS:
|
||
continue
|
||
filtered.append(part)
|
||
while filtered and filtered[-1].lower() in _STOP_LABELS:
|
||
filtered.pop()
|
||
if not filtered:
|
||
return None
|
||
if all(part.lower() in _STOP_LABELS for part in filtered):
|
||
return None
|
||
contiguous: List[str] = []
|
||
for part in filtered:
|
||
if part and part[0].isupper():
|
||
contiguous.append(part)
|
||
else:
|
||
break
|
||
if contiguous:
|
||
candidate = " ".join(contiguous)
|
||
else:
|
||
candidate = ""
|
||
if not candidate:
|
||
return None
|
||
lowered = candidate.lower()
|
||
if lowered in _PRONOUN_LABELS or lowered in _STOP_LABELS:
|
||
return None
|
||
return candidate |