Kansho/backend/pronoun_bind.py
2026-08-25 13:57:23 +02:00

121 lines
3.7 KiB
Python

"""Bind German 3rd-person pronouns to the last named person. Local; no new Detect tokens."""
from __future__ import annotations
import re
from dataclasses import dataclass
PLACEHOLDER = re.compile(r"\[\[(PERSON:[^\]]+)\]\]")
PIECE = re.compile(r"\[\[PERSON:[^\]]+\]\]|[A-Za-zÄÖÜäöüß]+")
FEM_CUES = {
"frau", "partnerin", "tochter", "mutter", "schwester", "freundin",
"oma", "tante",
}
MASC_CUES = {
"herr", "mann", "partner", "sohn", "vater", "bruder", "freund",
"opa", "onkel",
}
FEM_PRON = {"sie", "ihr", "ihre", "ihrem", "ihren", "ihrer", "ihres"}
MASC_PRON = {"er", "ihn", "ihm", "seine", "seinem", "seinen", "seiner", "seines"}
IHR_ADDRESS = {
"seid", "habt", "wart", "könnt", "müsst", "sollt", "wollt", "dürft",
"hättet", "wäret", "werdet",
}
PLURAL_VERB = {
"sind", "waren", "haben", "hatten", "werden", "wurden",
"können", "müssen", "wollen", "sollen",
}
@dataclass
class BindState:
last: str | None = None
last_f: str | None = None
last_m: str | None = None
def _gender_from_prev(pieces: list[re.Match], index: int) -> str | None:
seen: list[str] = []
pos = index - 1
while pos >= 0 and len(seen) < 3:
raw = pieces[pos].group(0)
if PLACEHOLDER.fullmatch(raw):
break
seen.append(raw.lower())
pos -= 1
window = set(seen)
if window & FEM_CUES:
return "f"
if window & MASC_CUES:
return "m"
return None
def _referent(state: BindState, kind: str) -> str | None:
if kind == "f" and state.last_f:
return state.last_f
if kind == "m" and state.last_m:
return state.last_m
return state.last
def _is_address_ihr(prev: str, nxt: str) -> bool:
if nxt in IHR_ADDRESS or prev in IHR_ADDRESS:
return True
if prev.endswith("t") and len(prev) >= 4:
return True
return False
def bind_pronouns(text: str, state: BindState | None = None) -> tuple[str, BindState]:
state = state or BindState()
pieces = list(PIECE.finditer(text or ""))
replacements: list[tuple[int, int, str]] = []
for i, match in enumerate(pieces):
raw = match.group(0)
person = PLACEHOLDER.fullmatch(raw)
if person:
token = person.group(1)
state.last = token
gender = _gender_from_prev(pieces, i)
if gender == "f":
state.last_f = token
elif gender == "m":
state.last_m = token
continue
low = raw.lower()
prev = pieces[i - 1].group(0).lower() if i else ""
nxt = pieces[i + 1].group(0).lower() if i + 1 < len(pieces) else ""
if low == "ihr" and _is_address_ihr(prev, nxt):
continue
if low == "sie" and nxt in PLURAL_VERB:
continue
kind = "f" if low in FEM_PRON else "m" if low in MASC_PRON else ""
if not kind:
continue
ref = _referent(state, kind)
if not ref:
continue
replacements.append((match.start(), match.end(), f"[[{ref}]]"))
if not replacements:
return text or "", state
out = text or ""
for start, end, repl in reversed(replacements):
out = out[:start] + repl + out[end:]
return out, state
def bind_user_lines(text: str) -> str:
"""Only user: lines; state carries across those lines. Instructions stay untouched."""
state = BindState()
chunks: list[str] = []
for line in (text or "").splitlines(keepends=True):
ended = line.endswith("\n")
body = line[:-1] if ended else line
if body.startswith("user:"):
bound, state = bind_pronouns(body[5:], state)
chunks.append("user:" + bound + ("\n" if ended else ""))
else:
chunks.append(line)
return "".join(chunks)