wuenlp_tools.models.sentiment.harrymotions_prompting

  1from __future__ import annotations
  2
  3import json
  4import os
  5import re
  6import time
  7from collections import Counter
  8from concurrent.futures import ThreadPoolExecutor, as_completed
  9from dataclasses import dataclass, field
 10from enum import Enum
 11from itertools import combinations
 12from typing import Iterable
 13
 14from loguru import logger
 15from pydantic import BaseModel
 16from tqdm import tqdm
 17from wuenlp.impl.UIMANLPStructs import UIMADocument
 18from wuenlp.impl.uima import UIMACharacter, UIMACharacterReference, UIMAInteraction, UIMASentence, UIMASpan
 19
 20from wuenlp_tools.pipeline import PipelineProcessor, PipelineStep
 21from wuenlp_tools.utils.prompting import LLM, LLMArchitecture, LLMCompletion, gemini3flash
 22
 23
 24class HarryMotionsMarkerMode(str, Enum):
 25    NO_IND = "no_ind"
 26    ROLE = "role"
 27    ENTITY = "entity"
 28    MROLE = "mrole"
 29    MENTITY = "mentity"
 30
 31
 32class HarryMotionsPairingMode(str, Enum):
 33    ALL_PAIRS = "all_pairs"
 34    STAR = "star"  # top-1 character (by mention count) vs each other selected character in window
 35
 36
 37@dataclass(frozen=True)
 38class HarryMotionsPromptConfig:
 39    """HM prompting settings.
 40
 41    ``n_classes`` selects the label space: ``2``, ``5``, or ``8`` for discrete
 42    emotion labels, or ``0`` for a continuous polarity score in ``[-1, 1]``
 43    (negative → neutral → positive affect between the two characters).
 44    """
 45
 46    directed: bool = False
 47    n_classes: int = 2
 48    marker_mode: HarryMotionsMarkerMode = HarryMotionsMarkerMode.ENTITY
 49    model: LLMArchitecture = field(default_factory=lambda: gemini3flash)
 50
 51
 52@dataclass(frozen=True)
 53class HarryMotionsSamplingConfig:
 54    # Context extraction around selected mentions (kept unchanged).
 55    window_size: int = 10
 56    # Interaction extraction windows over sentences.
 57    window_sentences: int = 5
 58    overlapping_windows: bool = True
 59    top_k_characters: int = 10
 60    max_pairs: int | None = None
 61    workers: int = 8
 62    pairing_mode: HarryMotionsPairingMode = HarryMotionsPairingMode.ALL_PAIRS
 63
 64
 65class LabelResponse(BaseModel):
 66    label: str
 67
 68
 69class SentimentScalarResponse(BaseModel):
 70    """Structured LLM output for ``n_classes == 0`` (continuous polarity)."""
 71
 72    sentiment: float
 73
 74
 75MARKER_TOKEN_RE = re.compile(r"(?<!\S)(TARG|EXP)-(\S+?)-\1(?!\S)")
 76
 77_TRANSIENT_LLM_ERRORS = frozenset({
 78    "APITimeoutError",
 79    "RateLimitError",
 80    "APIConnectionError",
 81    "InternalServerError",
 82})
 83
 84
 85def _is_transient_llm_error(exc: Exception) -> bool:
 86    return type(exc).__name__ in _TRANSIENT_LLM_ERRORS
 87
 88
 89def _harrymotions_max_llm_retries() -> int:
 90    raw = os.getenv("WUENLP_HARRYMOTIONS_MAX_LLM_RETRIES", "8").strip()
 91    try:
 92        return max(1, int(raw))
 93    except ValueError:
 94        logger.warning(
 95            "Invalid WUENLP_HARRYMOTIONS_MAX_LLM_RETRIES={!r}; using 8",
 96            raw,
 97        )
 98        return 8
 99
100
101def _invoke_llm_resilient(llm: LLM, prompt: str) -> LLMCompletion:
102    max_retries = _harrymotions_max_llm_retries()
103    delay_s = 5.0
104    attempt = 0
105    last_exc: Exception | None = None
106    while attempt < max_retries:
107        attempt += 1
108        try:
109            return llm.with_reasoning(prompt)
110        except Exception as exc:
111            if not _is_transient_llm_error(exc):
112                raise
113            last_exc = exc
114            if attempt >= max_retries:
115                break
116            logger.warning(
117                "HarryMotions LLM transient error (attempt {}/{}), retry in {:.0f}s: {}: {}",
118                attempt,
119                max_retries,
120                delay_s,
121                type(exc).__name__,
122                exc,
123            )
124            time.sleep(delay_s)
125            delay_s = min(delay_s * 1.5, 120.0)
126    raise RuntimeError(
127        f"HarryMotions LLM failed after {max_retries} transient-error retries"
128    ) from last_exc
129
130
131@dataclass(frozen=True)
132class HarryMotionsPrediction:
133    label: str
134    reason: str | None = None
135
136
137def _supported_n_classes() -> tuple[int, ...]:
138    return (0, 2, 5, 8)
139
140
141def _labels_for_setting(directed: bool, n_classes: int) -> list[str]:
142    if n_classes == 0:
143        raise ValueError("Continuous mode (n_classes=0) does not use discrete labels.")
144    if n_classes not in (2, 5, 8):
145        raise ValueError(f"Unsupported class count: {n_classes}")
146    if n_classes == 8:
147        base = ["anger", "anticipation", "disgust", "fear", "joy", "sadness", "surprise", "trust"]
148    elif n_classes == 5:
149        base = ["anger", "fear", "joy", "sadness", "surprise"]
150    else:
151        base = ["neg", "pos"]
152    if directed:
153        return sorted([f"{b}_L" for b in base] + [f"{b}_R" for b in base])
154    return sorted(base)
155
156
157def _validate_sample_mode(sample: str, marker_mode: HarryMotionsMarkerMode) -> None:
158    has_role = bool(re.search(r"(?:TARG|EXP)-[\s\S]+?-(?:TARG|EXP)", sample))
159    has_ent = bool(re.search(r"ENT-[\s\S]+?-ENT", sample))
160    has_mrole = "experiencer_object" in sample or "target_object" in sample
161    has_mentity = "entity_object" in sample
162
163    if marker_mode == HarryMotionsMarkerMode.ROLE:
164        if not re.search(r"TARG-[\s\S]+?-TARG", sample) or not re.search(r"EXP-[\s\S]+?-EXP", sample):
165            raise ValueError("ROLE mode requires both TARG-...-TARG and EXP-...-EXP markers.")
166        if has_ent or has_mrole or has_mentity:
167            raise ValueError("ROLE mode sample contains incompatible marker style.")
168    elif marker_mode == HarryMotionsMarkerMode.ENTITY:
169        if not has_ent:
170            raise ValueError("ENTITY mode requires ENT-...-ENT markers.")
171        if has_role or has_mrole or has_mentity:
172            raise ValueError("ENTITY mode sample contains incompatible marker style.")
173    elif marker_mode == HarryMotionsMarkerMode.NO_IND:
174        if has_role or has_ent or has_mrole or has_mentity:
175            raise ValueError("NO_IND mode must not contain marker tokens/placeholders.")
176    elif marker_mode == HarryMotionsMarkerMode.MROLE:
177        if "experiencer_object" not in sample or "target_object" not in sample:
178            raise ValueError("MROLE mode requires experiencer_object and target_object placeholders.")
179        if has_role or has_ent or has_mentity:
180            raise ValueError("MROLE mode sample contains incompatible marker style.")
181    elif marker_mode == HarryMotionsMarkerMode.MENTITY:
182        if "entity_object" not in sample:
183            raise ValueError("MENTITY mode requires entity_object placeholder.")
184        if has_role or has_ent or has_mrole:
185            raise ValueError("MENTITY mode sample contains incompatible marker style.")
186
187
188def _apply_indicator(text: str, indicator: HarryMotionsMarkerMode) -> str:
189    def replace_tagged(in_text: str, left_tag: str, right_tag: str, repl: str) -> str:
190        return re.sub(fr"{left_tag}-([\s\S]+?)-{right_tag}", repl, in_text)
191
192    if indicator == HarryMotionsMarkerMode.NO_IND:
193        out = replace_tagged(text, "TARG", "TARG", r"\1")
194        out = replace_tagged(out, "EXP", "EXP", r"\1")
195        return out
196    if indicator == HarryMotionsMarkerMode.ROLE:
197        return text
198    if indicator == HarryMotionsMarkerMode.ENTITY:
199        out = replace_tagged(text, "TARG", "TARG", r"ENT-\1-ENT")
200        out = replace_tagged(out, "EXP", "EXP", r"ENT-\1-ENT")
201        return out
202    if indicator == HarryMotionsMarkerMode.MROLE:
203        out = re.sub(r"TARG-[\S\s]+?-TARG", "target_object", text)
204        out = re.sub(r"EXP-[\S\s]+?-EXP", "experiencer_object", out)
205        return out
206    if indicator == HarryMotionsMarkerMode.MENTITY:
207        out = re.sub(r"TARG-[\S\s]+?-TARG", "entity_object", text)
208        out = re.sub(r"EXP-[\S\s]+?-EXP", "entity_object", out)
209        return out
210    raise ValueError(f"Unknown indicator mode: {indicator}")
211
212
213def _marker_hint(indicator: HarryMotionsMarkerMode) -> str:
214    if indicator == HarryMotionsMarkerMode.ROLE:
215        return (
216            "Characters are marked in the text with their roles: TARG-Name-TARG (target) "
217            "and EXP-Name-EXP (experiencer). The character names inside the markers are preserved."
218        )
219    if indicator == HarryMotionsMarkerMode.ENTITY:
220        return (
221            "Characters are marked in the text as ENT-Name-ENT. The character names are preserved, "
222            "but role markers are replaced with a generic ENT label."
223        )
224    if indicator == HarryMotionsMarkerMode.MROLE:
225        return (
226            "Character names have been removed. Two generic placeholders appear: "
227            "'experiencer_object' and 'target_object'."
228        )
229    if indicator == HarryMotionsMarkerMode.MENTITY:
230        return (
231            "Both character names and role information have been removed. The placeholder "
232            "'entity_object' is used for every character position."
233        )
234    return ""
235
236
237def _direction_block(cfg: HarryMotionsPromptConfig) -> str:
238    if not cfg.directed:
239        hint = _marker_hint(cfg.marker_mode)
240        return (
241            "All labels are undirected: they describe the overall emotion between the two characters "
242            "without distinguishing who feels it toward whom.\n\n" + hint if hint else
243            "All labels are undirected: they describe the overall emotion between the two characters "
244            "without distinguishing who feels it toward whom."
245        )
246
247    common_lr = (
248        "- Labels ending in _L: the first-mentioned character is the experiencer. They feel the emotion toward "
249        "the second-mentioned character.\n"
250        "- Labels ending in _R: the second-mentioned character is the experiencer. They feel the emotion toward "
251        "the first-mentioned character."
252    )
253
254    if cfg.marker_mode == HarryMotionsMarkerMode.ROLE:
255        return (
256            "Labels are directional. Characters are marked in the text with role markers:\n"
257            "- EXP-Name-EXP = experiencer\n"
258            "- TARG-Name-TARG = target\n\n"
259            "- Labels ending in _L: the first-mentioned character is the experiencer (EXP appears first).\n"
260            "- Labels ending in _R: the second-mentioned character is the experiencer (EXP appears second)."
261        )
262    if cfg.marker_mode == HarryMotionsMarkerMode.ENTITY:
263        return (
264            "Labels are directional. Characters are marked in the text as ENT-Name-ENT. "
265            "The character names are preserved, but role markers are replaced with a generic ENT label.\n"
266            + common_lr
267        )
268    if cfg.marker_mode == HarryMotionsMarkerMode.MROLE:
269        return (
270            "Labels are directional. Character names have been removed and replaced by placeholders "
271            "'experiencer_object' and 'target_object'.\n"
272            + common_lr
273        )
274    if cfg.marker_mode == HarryMotionsMarkerMode.MENTITY:
275        return (
276            "Labels are directional. Both names and role markers are removed. "
277            "The placeholder 'entity_object' marks each character position.\n"
278            + common_lr
279        )
280    return "Labels are directional. Two characters appear in the text.\n" + common_lr
281
282
283def _continuous_undirected_intro(cfg: HarryMotionsPromptConfig) -> str:
284    marker = _marker_hint(cfg.marker_mode)
285    base = (
286        "Score the emotional polarity **between** the two characters on a continuous scale from -1 to 1:\n"
287        "- **-1** strongly negative affect, tension, or hostility between them\n"
288        "- **0** neutral or no clear valenced tie\n"
289        "- **+1** strongly positive warmth, trust, or goodwill between them\n"
290        "The score is **undirected**: it does not say who feels what toward whom; it summarizes the "
291        "overall relational tone in the passage."
292    )
293    return base + ("\n\n" + marker if marker else "")
294
295
296def _continuous_directed_intro(cfg: HarryMotionsPromptConfig) -> str:
297    marker = _marker_hint(cfg.marker_mode)
298    base = (
299        "Score **directed** emotional polarity: how the **first-mentioned** character appears to feel "
300        "toward or about the **second-mentioned** character, on a scale from -1 to 1:\n"
301        "- **-1** strongly negative (cold, hostile, resentful, fearful toward them)\n"
302        "- **0** neutral or unclear valence\n"
303        "- **+1** strongly positive (warm, trusting, fond, grateful toward them)\n"
304        "Use the order in which the characters appear in the marked text (first vs second)."
305    )
306    return base + ("\n\n" + marker if marker else "")
307
308
309def _build_llm_continuous(cfg: HarryMotionsPromptConfig) -> LLM:
310    dir_intro = _continuous_directed_intro(cfg) if cfg.directed else _continuous_undirected_intro(cfg)
311    system_prompt = (
312        "You are an emotion annotator for short literary excerpts. "
313        "Each input describes an interaction between two characters.\n\n"
314        f"{dir_intro}\n\n"
315        "Output format:\n"
316        "- Return a JSON object with exactly one numeric field `sentiment` (float) in [-1.0, 1.0].\n"
317        "- Do not add other fields or commentary."
318    )
319    return LLM(
320        model=cfg.model,
321        system_prompt=system_prompt,
322        cache_maxsize=0,
323        output_format=SentimentScalarResponse,
324    )
325
326
327def _build_llm(cfg: HarryMotionsPromptConfig, label_names: list[str]) -> LLM:
328    system_prompt = (
329        "You are an emotion classifier for short pieces of literary text. "
330        "Each input text contains an interaction between two characters.\n\n"
331        f"{_direction_block(cfg)}\n\n"
332        "Your task:\n"
333        "- Read the input text.\n"
334        "- Choose exactly one label from the following list that best matches the emotion expressed "
335        f"between the two characters:\n  [{', '.join(label_names)}]\n\n"
336        "Output format:\n"
337        "- Return a JSON object with a single field 'label', whose value is exactly one of the allowed labels.\n"
338        "- Do not include any additional fields or explanations."
339    )
340    return LLM(model=cfg.model, system_prompt=system_prompt, cache_maxsize=0, output_format=LabelResponse)
341
342
343def _build_fallback_llm(primary: LLM, cfg: HarryMotionsPromptConfig) -> LLM:
344    return LLM(
345        model=cfg.model,
346        system_prompt=primary.system_prompt,
347        cache_maxsize=0,
348        output_format=None,
349        json=cfg.model.provider in ("ollama", "local"),
350    )
351
352
353def _parse_label(output: LabelResponse | dict | str, label_names: list[str]) -> str:
354    allowed = {l.lower(): l for l in label_names}
355    raw = output.label if isinstance(output, LabelResponse) else output.get("label", "") if isinstance(output, dict) else str(output)
356    candidate = raw.strip()
357    # Handle responses like `H{"label":"neg"}` or any surrounding text.
358    json_like = re.search(r"\{[\s\S]*\}", candidate)
359    if json_like:
360        fragment = json_like.group(0)
361        try:
362            parsed = json.loads(fragment)
363            if isinstance(parsed, dict) and isinstance(parsed.get("label"), str):
364                candidate = parsed["label"].strip()
365        except Exception:
366            # Fallback: regex extraction from json-like fragment.
367            m = re.search(r'"label"\s*:\s*"([^"]+)"', fragment)
368            if m:
369                candidate = m.group(1).strip()
370
371    if candidate.lower() in allowed:
372        return allowed[candidate.lower()]
373    token = re.split(r"[\s,;:]+", candidate)[0].strip(" \"'`[]{}()")
374    if token.lower() in allowed:
375        return allowed[token.lower()]
376    for k, v in allowed.items():
377        if k in candidate.lower():
378            return v
379    raise ValueError(f"Could not parse a valid label from output: {raw!r}")
380
381
382def _parse_sentiment(output: SentimentScalarResponse | dict | str) -> float:
383    if isinstance(output, SentimentScalarResponse):
384        return max(-1.0, min(1.0, float(output.sentiment)))
385    if isinstance(output, dict):
386        v = output.get("sentiment", output.get("polarity", 0.0))
387        try:
388            return max(-1.0, min(1.0, float(v)))
389        except (TypeError, ValueError):
390            pass
391    candidate = str(output).strip()
392    json_like = re.search(r"\{[\s\S]*\}", candidate)
393    if json_like:
394        fragment = json_like.group(0)
395        try:
396            parsed = json.loads(fragment)
397            if isinstance(parsed, dict):
398                for key in ("sentiment", "polarity"):
399                    val = parsed.get(key)
400                    if isinstance(val, (int, float, str)):
401                        try:
402                            return max(-1.0, min(1.0, float(val)))
403                        except (TypeError, ValueError):
404                            continue
405        except Exception:
406            m = re.search(
407                r'"(?:sentiment|polarity)"\s*:\s*([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)',
408                fragment,
409            )
410            if m:
411                return max(-1.0, min(1.0, float(m.group(1))))
412    m = re.search(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", candidate)
413    if m:
414        return max(-1.0, min(1.0, float(m.group(0))))
415    raise ValueError(f"Could not parse sentiment from output: {output!r}")
416
417
418def classify_harrymotions_continuous(
419    sample_text: str,
420    cfg: HarryMotionsPromptConfig,
421    *,
422    llm: LLM | None = None,
423    fallback_llm: LLM | None = None,
424) -> HarryMotionsPrediction:
425    primary = llm or _build_llm_continuous(cfg)
426    fallback = fallback_llm or _build_fallback_llm(primary, cfg)
427    try:
428        completion = _invoke_llm_resilient(primary, sample_text)
429        return HarryMotionsPrediction(
430            label=str(_parse_sentiment(completion.content)),
431            reason=completion.reasoning,
432        )
433    except Exception as exc:
434        logger.warning(
435            "Structured sentiment parsing failed; retrying with raw text output. "
436            f"Reason: {type(exc).__name__}: {exc}"
437        )
438        last_exc: Exception | None = None
439        for attempt in range(10):
440            prompt = sample_text if attempt == 0 else (
441                sample_text + '\n\nReturn only a JSON object like {"sentiment": <float between -1 and 1>}.'
442            )
443            try:
444                completion = _invoke_llm_resilient(fallback, prompt)
445                return HarryMotionsPrediction(
446                    label=str(_parse_sentiment(completion.content)),
447                    reason=completion.reasoning,
448                )
449            except Exception as inner_exc:
450                last_exc = inner_exc
451        raise RuntimeError(
452            "Could not classify HarryMotions continuous sample after retries."
453        ) from last_exc
454
455
456def classify_harrymotions_sample(
457    sample_text: str,
458    cfg: HarryMotionsPromptConfig,
459    *,
460    llm: LLM | None = None,
461    fallback_llm: LLM | None = None,
462) -> HarryMotionsPrediction:
463    if cfg.n_classes not in _supported_n_classes():
464        raise ValueError(f"Unsupported n_classes={cfg.n_classes}; expected one of {_supported_n_classes()}.")
465    _validate_sample_mode(sample_text, cfg.marker_mode)
466    if cfg.n_classes == 0:
467        return classify_harrymotions_continuous(
468            sample_text, cfg, llm=llm, fallback_llm=fallback_llm,
469        )
470    labels = _labels_for_setting(cfg.directed, cfg.n_classes)
471    primary = llm or _build_llm(cfg, labels)
472    fallback = fallback_llm or _build_fallback_llm(primary, cfg)
473    try:
474        completion = _invoke_llm_resilient(primary, sample_text)
475        return HarryMotionsPrediction(
476            label=_parse_label(completion.content, labels),
477            reason=completion.reasoning,
478        )
479    except Exception as exc:
480        logger.warning(
481            "Structured label parsing failed; retrying with raw text output. "
482            f"Reason: {type(exc).__name__}: {exc}"
483        )
484        last_exc: Exception | None = None
485        for attempt in range(10):
486            prompt = sample_text if attempt == 0 else (
487                sample_text
488                + "\n\nReturn only a JSON object like {\"label\":\"<one_allowed_label>\"}."
489            )
490            try:
491                completion = _invoke_llm_resilient(fallback, prompt)
492                return HarryMotionsPrediction(
493                    label=_parse_label(completion.content, labels),
494                    reason=completion.reasoning,
495                )
496            except Exception as inner_exc:
497                last_exc = inner_exc
498        raise RuntimeError(
499            "Could not classify HarryMotions discrete sample after retries."
500        ) from last_exc
501
502
503def _character_key(character: UIMACharacter | None) -> str | None:
504    if character is None:
505        return None
506    try:
507        return str(character.id)
508    except Exception:
509        return None
510
511
512def _ref_character_key(ref: UIMACharacterReference) -> str | None:
513    try:
514        return _character_key(ref.referred_entity)
515    except Exception:
516        return None
517
518
519def _reference_preference(ref: UIMACharacterReference) -> tuple[int, int]:
520    """
521    Lower tuple is better:
522    - prefer longest reference span in sentence
523    - tie-break by earlier mention
524    """
525    length_rank = -(int(ref.end) - int(ref.begin))
526    return length_rank, int(ref.begin)
527
528
529def _extract_window_text(doc: UIMADocument, left: UIMACharacterReference, right: UIMACharacterReference, window_size: int) -> tuple[str, int]:
530    try:
531        tokens = list(doc.tokens)
532        left_tok = int(left.token_begin_within)
533        right_tok = int(right.token_begin_within)
534        lo = max(0, min(left_tok, right_tok) - window_size)
535        hi = min(len(tokens) - 1, max(left_tok, right_tok) + window_size)
536        start = int(tokens[lo].begin)
537        end = int(tokens[hi].end)
538        return doc.text[start:end], start
539    except Exception:
540        start = max(0, min(int(left.begin), int(right.begin)) - 300)
541        end = min(len(doc.text), max(int(left.end), int(right.end)) + 300)
542        return doc.text[start:end], start
543
544
545def _insert_role_markers(sample: str, sample_offset: int, exp_ref: UIMACharacterReference, targ_ref: UIMACharacterReference) -> str:
546    edits = [
547        (int(exp_ref.begin) - sample_offset, int(exp_ref.end) - sample_offset, "EXP"),
548        (int(targ_ref.begin) - sample_offset, int(targ_ref.end) - sample_offset, "TARG"),
549    ]
550    out = sample
551    for b, e, tag in sorted(edits, key=lambda x: x[0], reverse=True):
552        if b < 0 or e > len(out) or b >= e:
553            raise ValueError("Mention boundaries are outside extracted sample window.")
554        mention = out[b:e]
555        out = out[:b] + f"{tag}-{mention}-{tag}" + out[e:]
556    return out
557
558
559def _pair_distance_tokens(a: UIMACharacterReference, b: UIMACharacterReference) -> int | None:
560    try:
561        return abs(int(a.token_begin_within) - int(b.token_begin_within))
562    except Exception:
563        return None
564
565
566def _append_interaction_pair(
567        pairs: list[tuple[UIMACharacterReference, UIMACharacterReference]],
568        left: UIMACharacterReference,
569        right: UIMACharacterReference,
570        *,
571        directed: bool,
572        max_pairs: int | None,
573        yielded: int,
574) -> tuple[list[tuple[UIMACharacterReference, UIMACharacterReference]], int, bool]:
575    if _ref_character_key(left) == _ref_character_key(right):
576        return pairs, yielded, False
577    if directed:
578        pairs.append((left, right))
579        yielded += 1
580        if max_pairs is not None and yielded >= max_pairs:
581            return pairs, yielded, True
582        pairs.append((right, left))
583        yielded += 1
584        if max_pairs is not None and yielded >= max_pairs:
585            return pairs, yielded, True
586    else:
587        pairs.append((left, right))
588        yielded += 1
589        if max_pairs is not None and yielded >= max_pairs:
590            return pairs, yielded, True
591    return pairs, yielded, False
592
593
594def _iter_interaction_pairs(
595        doc: UIMADocument,
596        sampling: HarryMotionsSamplingConfig,
597        directed: bool,
598        selected_characters: list[UIMACharacter] | None = None,
599) -> Iterable[tuple[UIMACharacterReference, UIMACharacterReference]]:
600    refs = [r for r in doc.character_references if _ref_character_key(r) is not None]
601    if not refs:
602        return []
603
604    counts = Counter(_ref_character_key(r) for r in refs)
605    counts.pop(None, None)
606    if selected_characters:
607        selected = {_character_key(c) for c in selected_characters}
608        selected.discard(None)
609    else:
610        selected = {key for key, _ in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:sampling.top_k_characters]}
611
612    ranked_selected = sorted(selected, key=lambda k: (-counts.get(k, 0), k))
613    hub_key = ranked_selected[0] if ranked_selected else None
614
615    sentences = list(doc.sentences)
616    if not sentences:
617        return []
618
619    window_sentences = max(1, int(sampling.window_sentences))
620    step = 1 if sampling.overlapping_windows else window_sentences
621
622    yielded = 0
623    pairs: list[tuple[UIMACharacterReference, UIMACharacterReference]] = []
624    # Generate sentence windows; if text is shorter than window, use one full window.
625    starts = [0] if len(sentences) <= window_sentences else list(range(0, len(sentences) - window_sentences + 1, step))
626    for start_idx in starts:
627        end_idx = min(len(sentences), start_idx + window_sentences)
628        window_refs: list[UIMACharacterReference] = []
629        for sentence in sentences[start_idx:end_idx]:
630            window_refs.extend([r for r in sentence.covered(UIMACharacterReference) if _ref_character_key(r) in selected])
631
632        representative_ref: dict[str, UIMACharacterReference] = {}
633        for ref in window_refs:
634            cid = _ref_character_key(ref)
635            if cid is None:
636                continue
637            if cid not in representative_ref or _reference_preference(ref) < _reference_preference(representative_ref[cid]):
638                representative_ref[cid] = ref
639
640        if sampling.pairing_mode == HarryMotionsPairingMode.STAR:
641            if hub_key is None or hub_key not in representative_ref:
642                continue
643            hub_ref = representative_ref[hub_key]
644            for cid, other_ref in representative_ref.items():
645                if cid == hub_key:
646                    continue
647                pairs, yielded, stop = _append_interaction_pair(
648                    pairs, hub_ref, other_ref, directed=directed, max_pairs=sampling.max_pairs, yielded=yielded,
649                )
650                if stop:
651                    return pairs
652        else:
653            ordered_refs = sorted(representative_ref.values(), key=lambda r: int(r.begin))
654            for left, right in combinations(ordered_refs, 2):
655                pairs, yielded, stop = _append_interaction_pair(
656                    pairs, left, right, directed=directed, max_pairs=sampling.max_pairs, yielded=yielded,
657                )
658                if stop:
659                    return pairs
660    return pairs
661
662
663def _sentiment_for_2class(label: str) -> float | None:
664    if label.startswith("pos"):
665        return 1.0
666    if label.startswith("neg"):
667        return -1.0
668    return None
669
670
671def _setting_id(cfg: HarryMotionsPromptConfig) -> str:
672    return f"{cfg.n_classes}cl_{'dir' if cfg.directed else 'undir'}_{cfg.marker_mode.value}"
673
674
675def _setting_feature(cfg: HarryMotionsPromptConfig, suffix: str) -> str:
676    return f"harrymotions_{_setting_id(cfg)}_{suffix}"
677
678
679@dataclass
680class HarryMotionsPromptingProcessor(PipelineProcessor):
681    prompt_config: HarryMotionsPromptConfig = field(default_factory=lambda: HarryMotionsPromptConfig(False, 2, HarryMotionsMarkerMode.ENTITY))
682    sampling_config: HarryMotionsSamplingConfig = field(default_factory=HarryMotionsSamplingConfig)
683    _llm: LLM | None = field(default=None, init=False, repr=False)
684    _fallback_llm: LLM | None = field(default=None, init=False, repr=False)
685
686    def _ensure_llms(self) -> tuple[LLM, LLM]:
687        if self._llm is not None and self._fallback_llm is not None:
688            return self._llm, self._fallback_llm
689        if self.prompt_config.n_classes == 0:
690            self._llm = _build_llm_continuous(self.prompt_config)
691        else:
692            labels = _labels_for_setting(self.prompt_config.directed, self.prompt_config.n_classes)
693            self._llm = _build_llm(self.prompt_config, labels)
694        self._fallback_llm = _build_fallback_llm(self._llm, self.prompt_config)
695        return self._llm, self._fallback_llm
696
697    def __call__(self, doc: UIMADocument, unit_type: type[UIMASpan] | None, overwrite: bool = False, **kwargs) -> UIMADocument:
698        label_key = _setting_feature(self.prompt_config, "label")
699        sentiment_polarity_key = _setting_feature(self.prompt_config, "sentiment_polarity")
700        continuous = self.prompt_config.n_classes == 0
701        prediction_key = sentiment_polarity_key if continuous else label_key
702        setting_key = _setting_feature(self.prompt_config, "setting")
703        marker_mode_key = _setting_feature(self.prompt_config, "marker_mode")
704        directed_key = _setting_feature(self.prompt_config, "directed")
705        window_size_key = _setting_feature(self.prompt_config, "window_size")
706        model_key = _setting_feature(self.prompt_config, "model")
707        sentiment_bin_key = _setting_feature(self.prompt_config, "sentiment_bin")
708        reason_key = _setting_feature(self.prompt_config, "reason")
709
710        interactions = list(doc.interactions)
711        targets: list[tuple[UIMAInteraction, UIMACharacterReference, UIMACharacterReference]] = []
712        if interactions:
713            if not overwrite and all(prediction_key in inter.additional_features for inter in interactions):
714                logger.info(f"All existing interactions already contain '{prediction_key}'. Skipping.")
715                return doc
716            for inter in interactions:
717                if not overwrite and prediction_key in inter.additional_features:
718                    continue
719                try:
720                    left = inter.ne1
721                    right = inter.ne2
722                except Exception:
723                    logger.warning("Skipping interaction without NE1/NE2 references.")
724                    continue
725                targets.append((inter, left, right))
726        else:
727            selected_characters = kwargs.get("characters")
728            if selected_characters is not None and not isinstance(selected_characters, list):
729                raise ValueError("characters must be a list[UIMACharacter] when provided.")
730            if selected_characters is not None and any(not isinstance(c, UIMACharacter) for c in selected_characters):
731                raise ValueError("characters must contain only UIMACharacter instances.")
732            pairs = list(_iter_interaction_pairs(
733                doc,
734                self.sampling_config,
735                directed=self.prompt_config.directed,
736                selected_characters=selected_characters,
737            ))
738            if not pairs:
739                logger.warning("No eligible character mention pairs found for HarryMotions prompting.")
740                return doc
741            for left, right in pairs:
742                inter: UIMAInteraction = doc.create_anno(UIMAInteraction, min(int(left.begin), int(right.begin)),
743                                                         max(int(left.end), int(right.end)), add_to_document=True)
744                inter.ne1 = left
745                inter.ne2 = right
746                targets.append((inter, left, right))
747
748        llm, fallback_llm = self._ensure_llms()
749
750        def _classify_single(inter: UIMAInteraction, left: UIMACharacterReference, right: UIMACharacterReference) -> tuple[UIMAInteraction, HarryMotionsPrediction]:
751            base, offset = _extract_window_text(doc, left, right, self.sampling_config.window_size)
752            role_text = _insert_role_markers(base, offset, exp_ref=left, targ_ref=right)
753            sample_text = _apply_indicator(role_text, self.prompt_config.marker_mode)
754            try:
755                prediction = classify_harrymotions_sample(
756                    sample_text, self.prompt_config, llm=llm, fallback_llm=fallback_llm,
757                )
758            except ValueError as exc:
759                logger.warning(f"Skipping invalid sample for interaction due to marker validation/parsing issue: {exc}")
760                prediction = HarryMotionsPrediction(label="")
761            return inter, prediction
762
763        workers = max(1, int(self.sampling_config.workers))
764        classified: list[tuple[UIMAInteraction, HarryMotionsPrediction]] = []
765        if workers == 1 or len(targets) <= 1:
766            for inter, left, right in tqdm(targets):
767                classified.append(_classify_single(inter, left, right))
768        else:
769            with ThreadPoolExecutor(max_workers=workers) as executor:
770                futures = [executor.submit(_classify_single, inter, left, right) for inter, left, right in targets]
771                for future in tqdm(as_completed(futures), total=len(futures)):
772                    classified.append(future.result())
773
774        for inter, prediction in classified:
775            inter.additional_features[setting_key] = _setting_id(self.prompt_config)
776            inter.additional_features[marker_mode_key] = self.prompt_config.marker_mode.value
777            inter.additional_features[directed_key] = self.prompt_config.directed
778            inter.additional_features[window_size_key] = self.sampling_config.window_size
779            inter.additional_features[model_key] = self.prompt_config.model.name
780            if prediction.reason:
781                inter.additional_features[reason_key] = prediction.reason
782
783            if continuous:
784                try:
785                    polarity = float(prediction.label) if prediction.label else 0.0
786                except (TypeError, ValueError):
787                    polarity = 0.0
788                polarity = max(-1.0, min(1.0, polarity))
789                inter.additional_features[sentiment_polarity_key] = polarity
790                inter.sentiment = polarity
791                inter.additional_features[sentiment_bin_key] = polarity
792            else:
793                inter.additional_features[label_key] = prediction.label
794                if self.prompt_config.n_classes == 2:
795                    sentiment = _sentiment_for_2class(prediction.label)
796                    if sentiment is None:
797                        sentiment = 0.0
798                    inter.sentiment = sentiment
799                    inter.additional_features[sentiment_bin_key] = sentiment
800
801        return doc
802
803
804def HarryMotionsPromptingStep(
805        prompt_config: HarryMotionsPromptConfig | None = None,
806        sampling_config: HarryMotionsSamplingConfig | None = None,
807        name: str = "HarryMotions Prompting",
808) -> PipelineStep[HarryMotionsPromptingProcessor]:
809    if prompt_config is None:
810        prompt_config = HarryMotionsPromptConfig(False, 2, HarryMotionsMarkerMode.ENTITY)
811    processor = HarryMotionsPromptingProcessor(
812        prompt_config=prompt_config,
813        sampling_config=sampling_config or HarryMotionsSamplingConfig(),
814    )
815    return PipelineStep(
816        name=name,
817        processor=processor,
818        unit_type=None,
819        modified_types=[UIMAInteraction],
820        added_additional_features=[
821            "harrymotions_*",
822        ],
823    )
824
825
826def HarryMotionsEntity2clStep(
827        sampling_config: HarryMotionsSamplingConfig | None = None,
828        name: str = "HarryMotions Entity 2cl Prompting",
829) -> PipelineStep[HarryMotionsPromptingProcessor]:
830    return HarryMotionsPromptingStep(
831        prompt_config=HarryMotionsPromptConfig(False, 2, HarryMotionsMarkerMode.ENTITY),
832        sampling_config=sampling_config,
833        name=name,
834    )
835
836
837def HarryMotionsEntity5clStep(
838        sampling_config: HarryMotionsSamplingConfig | None = None,
839        name: str = "HarryMotions Entity 5cl Prompting",
840) -> PipelineStep[HarryMotionsPromptingProcessor]:
841    return HarryMotionsPromptingStep(
842        prompt_config=HarryMotionsPromptConfig(False, 5, HarryMotionsMarkerMode.ENTITY),
843        sampling_config=sampling_config,
844        name=name,
845    )
846
847
848def HarryMotionsEntity8clStep(
849        sampling_config: HarryMotionsSamplingConfig | None = None,
850        name: str = "HarryMotions Entity 8cl Prompting",
851) -> PipelineStep[HarryMotionsPromptingProcessor]:
852    return HarryMotionsPromptingStep(
853        prompt_config=HarryMotionsPromptConfig(False, 8, HarryMotionsMarkerMode.ENTITY),
854        sampling_config=sampling_config,
855        name=name,
856    )
class HarryMotionsMarkerMode(builtins.str, enum.Enum):
25class HarryMotionsMarkerMode(str, Enum):
26    NO_IND = "no_ind"
27    ROLE = "role"
28    ENTITY = "entity"
29    MROLE = "mrole"
30    MENTITY = "mentity"

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

NO_IND = <HarryMotionsMarkerMode.NO_IND: 'no_ind'>
ROLE = <HarryMotionsMarkerMode.ROLE: 'role'>
ENTITY = <HarryMotionsMarkerMode.ENTITY: 'entity'>
MROLE = <HarryMotionsMarkerMode.MROLE: 'mrole'>
MENTITY = <HarryMotionsMarkerMode.MENTITY: 'mentity'>
class HarryMotionsPairingMode(builtins.str, enum.Enum):
33class HarryMotionsPairingMode(str, Enum):
34    ALL_PAIRS = "all_pairs"
35    STAR = "star"  # top-1 character (by mention count) vs each other selected character in window

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

ALL_PAIRS = <HarryMotionsPairingMode.ALL_PAIRS: 'all_pairs'>
STAR = <HarryMotionsPairingMode.STAR: 'star'>
@dataclass(frozen=True)
class HarryMotionsPromptConfig:
38@dataclass(frozen=True)
39class HarryMotionsPromptConfig:
40    """HM prompting settings.
41
42    ``n_classes`` selects the label space: ``2``, ``5``, or ``8`` for discrete
43    emotion labels, or ``0`` for a continuous polarity score in ``[-1, 1]``
44    (negative → neutral → positive affect between the two characters).
45    """
46
47    directed: bool = False
48    n_classes: int = 2
49    marker_mode: HarryMotionsMarkerMode = HarryMotionsMarkerMode.ENTITY
50    model: LLMArchitecture = field(default_factory=lambda: gemini3flash)

HM prompting settings.

n_classes selects the label space: 2, 5, or 8 for discrete emotion labels, or 0 for a continuous polarity score in [-1, 1] (negative → neutral → positive affect between the two characters).

HarryMotionsPromptConfig( directed: bool = False, n_classes: int = 2, marker_mode: HarryMotionsMarkerMode = <HarryMotionsMarkerMode.ENTITY: 'entity'>, model: wuenlp_tools.utils.prompting.LLMArchitecture = <factory>)
directed: bool = False

bool(x) -> bool

Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

n_classes: int = 2

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
@dataclass(frozen=True)
class HarryMotionsSamplingConfig:
53@dataclass(frozen=True)
54class HarryMotionsSamplingConfig:
55    # Context extraction around selected mentions (kept unchanged).
56    window_size: int = 10
57    # Interaction extraction windows over sentences.
58    window_sentences: int = 5
59    overlapping_windows: bool = True
60    top_k_characters: int = 10
61    max_pairs: int | None = None
62    workers: int = 8
63    pairing_mode: HarryMotionsPairingMode = HarryMotionsPairingMode.ALL_PAIRS
HarryMotionsSamplingConfig( window_size: int = 10, window_sentences: int = 5, overlapping_windows: bool = True, top_k_characters: int = 10, max_pairs: int | None = None, workers: int = 8, pairing_mode: HarryMotionsPairingMode = <HarryMotionsPairingMode.ALL_PAIRS: 'all_pairs'>)
window_size: int = 10

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
window_sentences: int = 5

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
overlapping_windows: bool = True

bool(x) -> bool

Returns True when the argument x is true, False otherwise. The builtins True and False are the only two instances of the class bool. The class bool is a subclass of the class int, and cannot be subclassed.

top_k_characters: int = 10

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
max_pairs: int | None = None
workers: int = 8

int([x]) -> integer int(x, base=10) -> integer

Convert a number or string to an integer, or return 0 if no arguments are given. If x is a number, return x.__int__(). For floating-point numbers, this truncates towards zero.

If x is not a number or if base is given, then x must be a string, bytes, or bytearray instance representing an integer literal in the given base. The literal can be preceded by '+' or '-' and be surrounded by whitespace. The base defaults to 10. Valid bases are 0 and 2-36. Base 0 means to interpret the base from the string as an integer literal.

>>> int('0b100', base=0)
4
class LabelResponse(pydantic.main.BaseModel):
66class LabelResponse(BaseModel):
67    label: str

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
label: str = PydanticUndefined
class SentimentScalarResponse(pydantic.main.BaseModel):
70class SentimentScalarResponse(BaseModel):
71    """Structured LLM output for ``n_classes == 0`` (continuous polarity)."""
72
73    sentiment: float

Structured LLM output for n_classes == 0 (continuous polarity).

sentiment: float = PydanticUndefined
MARKER_TOKEN_RE = re.compile('(?<!\\S)(TARG|EXP)-(\\S+?)-\\1(?!\\S)')

Compiled regular expression object.

@dataclass(frozen=True)
class HarryMotionsPrediction:
132@dataclass(frozen=True)
133class HarryMotionsPrediction:
134    label: str
135    reason: str | None = None
HarryMotionsPrediction(label: str, reason: str | None = None)
label: str
reason: str | None = None
def classify_harrymotions_continuous( sample_text: str, cfg: HarryMotionsPromptConfig, *, llm: wuenlp_tools.utils.prompting.LLM | None = None, fallback_llm: wuenlp_tools.utils.prompting.LLM | None = None) -> HarryMotionsPrediction:
419def classify_harrymotions_continuous(
420    sample_text: str,
421    cfg: HarryMotionsPromptConfig,
422    *,
423    llm: LLM | None = None,
424    fallback_llm: LLM | None = None,
425) -> HarryMotionsPrediction:
426    primary = llm or _build_llm_continuous(cfg)
427    fallback = fallback_llm or _build_fallback_llm(primary, cfg)
428    try:
429        completion = _invoke_llm_resilient(primary, sample_text)
430        return HarryMotionsPrediction(
431            label=str(_parse_sentiment(completion.content)),
432            reason=completion.reasoning,
433        )
434    except Exception as exc:
435        logger.warning(
436            "Structured sentiment parsing failed; retrying with raw text output. "
437            f"Reason: {type(exc).__name__}: {exc}"
438        )
439        last_exc: Exception | None = None
440        for attempt in range(10):
441            prompt = sample_text if attempt == 0 else (
442                sample_text + '\n\nReturn only a JSON object like {"sentiment": <float between -1 and 1>}.'
443            )
444            try:
445                completion = _invoke_llm_resilient(fallback, prompt)
446                return HarryMotionsPrediction(
447                    label=str(_parse_sentiment(completion.content)),
448                    reason=completion.reasoning,
449                )
450            except Exception as inner_exc:
451                last_exc = inner_exc
452        raise RuntimeError(
453            "Could not classify HarryMotions continuous sample after retries."
454        ) from last_exc
def classify_harrymotions_sample( sample_text: str, cfg: HarryMotionsPromptConfig, *, llm: wuenlp_tools.utils.prompting.LLM | None = None, fallback_llm: wuenlp_tools.utils.prompting.LLM | None = None) -> HarryMotionsPrediction:
457def classify_harrymotions_sample(
458    sample_text: str,
459    cfg: HarryMotionsPromptConfig,
460    *,
461    llm: LLM | None = None,
462    fallback_llm: LLM | None = None,
463) -> HarryMotionsPrediction:
464    if cfg.n_classes not in _supported_n_classes():
465        raise ValueError(f"Unsupported n_classes={cfg.n_classes}; expected one of {_supported_n_classes()}.")
466    _validate_sample_mode(sample_text, cfg.marker_mode)
467    if cfg.n_classes == 0:
468        return classify_harrymotions_continuous(
469            sample_text, cfg, llm=llm, fallback_llm=fallback_llm,
470        )
471    labels = _labels_for_setting(cfg.directed, cfg.n_classes)
472    primary = llm or _build_llm(cfg, labels)
473    fallback = fallback_llm or _build_fallback_llm(primary, cfg)
474    try:
475        completion = _invoke_llm_resilient(primary, sample_text)
476        return HarryMotionsPrediction(
477            label=_parse_label(completion.content, labels),
478            reason=completion.reasoning,
479        )
480    except Exception as exc:
481        logger.warning(
482            "Structured label parsing failed; retrying with raw text output. "
483            f"Reason: {type(exc).__name__}: {exc}"
484        )
485        last_exc: Exception | None = None
486        for attempt in range(10):
487            prompt = sample_text if attempt == 0 else (
488                sample_text
489                + "\n\nReturn only a JSON object like {\"label\":\"<one_allowed_label>\"}."
490            )
491            try:
492                completion = _invoke_llm_resilient(fallback, prompt)
493                return HarryMotionsPrediction(
494                    label=_parse_label(completion.content, labels),
495                    reason=completion.reasoning,
496                )
497            except Exception as inner_exc:
498                last_exc = inner_exc
499        raise RuntimeError(
500            "Could not classify HarryMotions discrete sample after retries."
501        ) from last_exc
@dataclass
class HarryMotionsPromptingProcessor(wuenlp_tools.pipeline.AbstractPipelineProcessor[wuenlp.impl.uima.UIMANLPStructs.UIMADocument]):
680@dataclass
681class HarryMotionsPromptingProcessor(PipelineProcessor):
682    prompt_config: HarryMotionsPromptConfig = field(default_factory=lambda: HarryMotionsPromptConfig(False, 2, HarryMotionsMarkerMode.ENTITY))
683    sampling_config: HarryMotionsSamplingConfig = field(default_factory=HarryMotionsSamplingConfig)
684    _llm: LLM | None = field(default=None, init=False, repr=False)
685    _fallback_llm: LLM | None = field(default=None, init=False, repr=False)
686
687    def _ensure_llms(self) -> tuple[LLM, LLM]:
688        if self._llm is not None and self._fallback_llm is not None:
689            return self._llm, self._fallback_llm
690        if self.prompt_config.n_classes == 0:
691            self._llm = _build_llm_continuous(self.prompt_config)
692        else:
693            labels = _labels_for_setting(self.prompt_config.directed, self.prompt_config.n_classes)
694            self._llm = _build_llm(self.prompt_config, labels)
695        self._fallback_llm = _build_fallback_llm(self._llm, self.prompt_config)
696        return self._llm, self._fallback_llm
697
698    def __call__(self, doc: UIMADocument, unit_type: type[UIMASpan] | None, overwrite: bool = False, **kwargs) -> UIMADocument:
699        label_key = _setting_feature(self.prompt_config, "label")
700        sentiment_polarity_key = _setting_feature(self.prompt_config, "sentiment_polarity")
701        continuous = self.prompt_config.n_classes == 0
702        prediction_key = sentiment_polarity_key if continuous else label_key
703        setting_key = _setting_feature(self.prompt_config, "setting")
704        marker_mode_key = _setting_feature(self.prompt_config, "marker_mode")
705        directed_key = _setting_feature(self.prompt_config, "directed")
706        window_size_key = _setting_feature(self.prompt_config, "window_size")
707        model_key = _setting_feature(self.prompt_config, "model")
708        sentiment_bin_key = _setting_feature(self.prompt_config, "sentiment_bin")
709        reason_key = _setting_feature(self.prompt_config, "reason")
710
711        interactions = list(doc.interactions)
712        targets: list[tuple[UIMAInteraction, UIMACharacterReference, UIMACharacterReference]] = []
713        if interactions:
714            if not overwrite and all(prediction_key in inter.additional_features for inter in interactions):
715                logger.info(f"All existing interactions already contain '{prediction_key}'. Skipping.")
716                return doc
717            for inter in interactions:
718                if not overwrite and prediction_key in inter.additional_features:
719                    continue
720                try:
721                    left = inter.ne1
722                    right = inter.ne2
723                except Exception:
724                    logger.warning("Skipping interaction without NE1/NE2 references.")
725                    continue
726                targets.append((inter, left, right))
727        else:
728            selected_characters = kwargs.get("characters")
729            if selected_characters is not None and not isinstance(selected_characters, list):
730                raise ValueError("characters must be a list[UIMACharacter] when provided.")
731            if selected_characters is not None and any(not isinstance(c, UIMACharacter) for c in selected_characters):
732                raise ValueError("characters must contain only UIMACharacter instances.")
733            pairs = list(_iter_interaction_pairs(
734                doc,
735                self.sampling_config,
736                directed=self.prompt_config.directed,
737                selected_characters=selected_characters,
738            ))
739            if not pairs:
740                logger.warning("No eligible character mention pairs found for HarryMotions prompting.")
741                return doc
742            for left, right in pairs:
743                inter: UIMAInteraction = doc.create_anno(UIMAInteraction, min(int(left.begin), int(right.begin)),
744                                                         max(int(left.end), int(right.end)), add_to_document=True)
745                inter.ne1 = left
746                inter.ne2 = right
747                targets.append((inter, left, right))
748
749        llm, fallback_llm = self._ensure_llms()
750
751        def _classify_single(inter: UIMAInteraction, left: UIMACharacterReference, right: UIMACharacterReference) -> tuple[UIMAInteraction, HarryMotionsPrediction]:
752            base, offset = _extract_window_text(doc, left, right, self.sampling_config.window_size)
753            role_text = _insert_role_markers(base, offset, exp_ref=left, targ_ref=right)
754            sample_text = _apply_indicator(role_text, self.prompt_config.marker_mode)
755            try:
756                prediction = classify_harrymotions_sample(
757                    sample_text, self.prompt_config, llm=llm, fallback_llm=fallback_llm,
758                )
759            except ValueError as exc:
760                logger.warning(f"Skipping invalid sample for interaction due to marker validation/parsing issue: {exc}")
761                prediction = HarryMotionsPrediction(label="")
762            return inter, prediction
763
764        workers = max(1, int(self.sampling_config.workers))
765        classified: list[tuple[UIMAInteraction, HarryMotionsPrediction]] = []
766        if workers == 1 or len(targets) <= 1:
767            for inter, left, right in tqdm(targets):
768                classified.append(_classify_single(inter, left, right))
769        else:
770            with ThreadPoolExecutor(max_workers=workers) as executor:
771                futures = [executor.submit(_classify_single, inter, left, right) for inter, left, right in targets]
772                for future in tqdm(as_completed(futures), total=len(futures)):
773                    classified.append(future.result())
774
775        for inter, prediction in classified:
776            inter.additional_features[setting_key] = _setting_id(self.prompt_config)
777            inter.additional_features[marker_mode_key] = self.prompt_config.marker_mode.value
778            inter.additional_features[directed_key] = self.prompt_config.directed
779            inter.additional_features[window_size_key] = self.sampling_config.window_size
780            inter.additional_features[model_key] = self.prompt_config.model.name
781            if prediction.reason:
782                inter.additional_features[reason_key] = prediction.reason
783
784            if continuous:
785                try:
786                    polarity = float(prediction.label) if prediction.label else 0.0
787                except (TypeError, ValueError):
788                    polarity = 0.0
789                polarity = max(-1.0, min(1.0, polarity))
790                inter.additional_features[sentiment_polarity_key] = polarity
791                inter.sentiment = polarity
792                inter.additional_features[sentiment_bin_key] = polarity
793            else:
794                inter.additional_features[label_key] = prediction.label
795                if self.prompt_config.n_classes == 2:
796                    sentiment = _sentiment_for_2class(prediction.label)
797                    if sentiment is None:
798                        sentiment = 0.0
799                    inter.sentiment = sentiment
800                    inter.additional_features[sentiment_bin_key] = sentiment
801
802        return doc
HarryMotionsPromptingProcessor( prompt_config: HarryMotionsPromptConfig = <factory>, sampling_config: HarryMotionsSamplingConfig = <factory>)
prompt_config: HarryMotionsPromptConfig
sampling_config: HarryMotionsSamplingConfig
def HarryMotionsPromptingStep( prompt_config: HarryMotionsPromptConfig | None = None, sampling_config: HarryMotionsSamplingConfig | None = None, name: str = 'HarryMotions Prompting') -> wuenlp_tools.pipeline.PipelineStep[HarryMotionsPromptingProcessor]:
805def HarryMotionsPromptingStep(
806        prompt_config: HarryMotionsPromptConfig | None = None,
807        sampling_config: HarryMotionsSamplingConfig | None = None,
808        name: str = "HarryMotions Prompting",
809) -> PipelineStep[HarryMotionsPromptingProcessor]:
810    if prompt_config is None:
811        prompt_config = HarryMotionsPromptConfig(False, 2, HarryMotionsMarkerMode.ENTITY)
812    processor = HarryMotionsPromptingProcessor(
813        prompt_config=prompt_config,
814        sampling_config=sampling_config or HarryMotionsSamplingConfig(),
815    )
816    return PipelineStep(
817        name=name,
818        processor=processor,
819        unit_type=None,
820        modified_types=[UIMAInteraction],
821        added_additional_features=[
822            "harrymotions_*",
823        ],
824    )
def HarryMotionsEntity2clStep( sampling_config: HarryMotionsSamplingConfig | None = None, name: str = 'HarryMotions Entity 2cl Prompting') -> wuenlp_tools.pipeline.PipelineStep[HarryMotionsPromptingProcessor]:
827def HarryMotionsEntity2clStep(
828        sampling_config: HarryMotionsSamplingConfig | None = None,
829        name: str = "HarryMotions Entity 2cl Prompting",
830) -> PipelineStep[HarryMotionsPromptingProcessor]:
831    return HarryMotionsPromptingStep(
832        prompt_config=HarryMotionsPromptConfig(False, 2, HarryMotionsMarkerMode.ENTITY),
833        sampling_config=sampling_config,
834        name=name,
835    )
def HarryMotionsEntity5clStep( sampling_config: HarryMotionsSamplingConfig | None = None, name: str = 'HarryMotions Entity 5cl Prompting') -> wuenlp_tools.pipeline.PipelineStep[HarryMotionsPromptingProcessor]:
838def HarryMotionsEntity5clStep(
839        sampling_config: HarryMotionsSamplingConfig | None = None,
840        name: str = "HarryMotions Entity 5cl Prompting",
841) -> PipelineStep[HarryMotionsPromptingProcessor]:
842    return HarryMotionsPromptingStep(
843        prompt_config=HarryMotionsPromptConfig(False, 5, HarryMotionsMarkerMode.ENTITY),
844        sampling_config=sampling_config,
845        name=name,
846    )
def HarryMotionsEntity8clStep( sampling_config: HarryMotionsSamplingConfig | None = None, name: str = 'HarryMotions Entity 8cl Prompting') -> wuenlp_tools.pipeline.PipelineStep[HarryMotionsPromptingProcessor]:
849def HarryMotionsEntity8clStep(
850        sampling_config: HarryMotionsSamplingConfig | None = None,
851        name: str = "HarryMotions Entity 8cl Prompting",
852) -> PipelineStep[HarryMotionsPromptingProcessor]:
853    return HarryMotionsPromptingStep(
854        prompt_config=HarryMotionsPromptConfig(False, 8, HarryMotionsMarkerMode.ENTITY),
855        sampling_config=sampling_config,
856        name=name,
857    )