Voicebank : vraies voix françaises (CML-TTS) + pool anonyme + garde-fou Qwen3
Remplace la voicebank générée par Kokoro (timbre anglais sur français phonémisé -> accent que Qwen3 clonait) par 41 vraies voix FR issues de CML-TTS (livres audio studio) : 1 narrateur dédié, 18F/14M nommées, 4F/4M anonymes réservées. - scripts/import_voices.py : import multi-shards parquet, 1 clip/locuteur (le plus propre via levenshtein), genre estimé par F0 (YIN, anti-octave), filtre débit de parole (ref_text aligné sur l'audio). - VoiceEntry.anonymous + assign_voices : les figurants « anonyme (...) » tirent dans un pool réservé, jamais mélangé avec les voix nommées ; narrateur dédié (fr_narrator remplace fr_f_siwis). - dedup._anon_attrs : genre/âge déduits du nom anonyme (bon genre de voix). - tts/qwen3.py : garde-fou anti-dérive (rejette/réessaie les sorties en boucle ou coupées en estimant la durée plausible du chunk). Limite connue : Qwen3 ne sait pas synthétiser les fragments d'1-2 mots (incises, titres) -> trous ; à traiter (repli Kokoro ou fusion des incises). Inclut aussi du travail en cours antérieur (refacto backend LLM pluggable mlx/lmstudio, benchmark, ajustements frontend/API). Claude-Session: https://claude.ai/code/session_01XSVvcy1mfb4k1xDgib9vVU
This commit is contained in:
@@ -25,7 +25,7 @@ from ..models import (
|
||||
SegmentType,
|
||||
)
|
||||
from ..settings import get_settings
|
||||
from .gemma import Gemma
|
||||
from .llm.client import LLM
|
||||
|
||||
# Un paragraphe de dialogue commence par un cadratin (U+2014) ou un tiret long.
|
||||
_DIALOGUE_LEAD_RE = re.compile(r"^\s*[—―]\s*")
|
||||
@@ -65,7 +65,7 @@ _CHUNK_MAX_DIALOGUES = 30 # repliques par appel (fiabilite du modele)
|
||||
|
||||
def attribute_speakers(
|
||||
segments: list[Segment],
|
||||
gemma: Gemma,
|
||||
gemma: LLM,
|
||||
*,
|
||||
characters: Optional[list[Character]] = None,
|
||||
pov: Optional[str] = None,
|
||||
@@ -211,7 +211,7 @@ def _chunk_dialogues(
|
||||
|
||||
def _refine_unknown_speakers(
|
||||
segments: list[Segment],
|
||||
gemma: Gemma,
|
||||
gemma: LLM,
|
||||
*,
|
||||
characters: Optional[list[Character]] = None,
|
||||
confidence: dict[int, str],
|
||||
@@ -276,11 +276,237 @@ def _refine_unknown_speakers(
|
||||
segments[seg_idx].speaker = new
|
||||
|
||||
|
||||
# --- Post-traitement deterministe (sans LLM) --------------------------------
|
||||
|
||||
|
||||
# Traductions FR pour construire l'identite d'un locuteur anonyme.
|
||||
_ANON_GENDER_FR = {"male": "homme", "female": "femme"}
|
||||
_ANON_AGE_FR = {"child": "enfant", "young": "jeune", "adult": "adulte", "old": "vieux"}
|
||||
|
||||
|
||||
def _anon_identity(gender: Optional[str], age: Optional[str]) -> str:
|
||||
"""Identite canonique d'un locuteur anonyme, regroupe par (genre, age).
|
||||
|
||||
Ex: ("male", "adult") -> "anonyme (homme, adulte)" ; ("male", None) ->
|
||||
"anonyme (homme)" ; (None, None) -> "anonyme". Tous les personnages-fonction
|
||||
d'un meme bucket partagent une voix (genre/age suffisent a la choisir)."""
|
||||
g = _ANON_GENDER_FR.get((gender or "").lower())
|
||||
a = _ANON_AGE_FR.get((age or "").lower())
|
||||
parts = [p for p in (g, a) if p]
|
||||
return f"anonyme ({', '.join(parts)})" if parts else "anonyme"
|
||||
|
||||
|
||||
def _apply_anonymous_speakers(
|
||||
segments: list[Segment], *, names=None) -> dict[str, tuple[Optional[str], Optional[str]]]:
|
||||
"""Rattache les repliques a incise de role a un locuteur ANONYME par genre/age.
|
||||
|
||||
Une incise "informa le soldat" -> "anonyme (homme)" : on ne stocke pas la
|
||||
fonction (garde/marine...), seuls genre+age comptent pour la voix. Genre/age
|
||||
deduits du nom de role (`_ROLE_GENDER`/`_ROLE_AGE`). Applique APRES le LLM
|
||||
(autorite deterministe), sans modifier le prompt. Mutation en place.
|
||||
|
||||
Renvoie {identite_anonyme: (genre, age)} des buckets utilises, pour que
|
||||
l'appelant cree les `Character` generiques correspondants (assignation voix)."""
|
||||
names = names or set()
|
||||
used: dict[str, tuple[Optional[str], Optional[str]]] = {}
|
||||
for seg in segments:
|
||||
if seg.type is not SegmentType.DIALOGUE:
|
||||
continue
|
||||
for inc in seg.incises:
|
||||
role = incise_role(seg.text, inc, names)
|
||||
if role:
|
||||
gender = _ROLE_GENDER.get(role)
|
||||
age = _ROLE_AGE.get(role)
|
||||
ident = _anon_identity(gender, age)
|
||||
seg.speaker = ident
|
||||
used[ident] = (gender, age)
|
||||
break
|
||||
return used
|
||||
|
||||
|
||||
def _inversion_gender(text: str) -> Optional[str]:
|
||||
"""Genre porte par le pronom d'une incise d'inversion ("demanda-t-elle" ->
|
||||
female, "dit-il" -> male). None si aucune inversion. Signal sur LE locuteur."""
|
||||
m = _INV_GENDER_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
return "female" if m.group("p").lower().startswith("elle") else "male"
|
||||
|
||||
|
||||
def _resolve_anonymous_figurants(
|
||||
segments: list[Segment]) -> dict[str, tuple[Optional[str], Optional[str]]]:
|
||||
"""Resout les repliques restees INDETERMINEES (inconnu/?) en figurants anonymes.
|
||||
|
||||
Quand une replique non resolue est entouree d'une narration decrivant un
|
||||
figurant genre ("La femme...", "La jeune marine...", "Le soldat..."), on
|
||||
l'attribue au bucket anonyme correspondant. Genre : pronom d'inversion de la
|
||||
replique ("demanda-t-elle") sinon l'article du role dans la narration
|
||||
(la/une -> femme, le/un -> homme). N'agit QUE sur l'indetermine (jamais sur
|
||||
une attribution sure) -> sans risque pour les personnages nommes. Mutation en
|
||||
place ; renvoie les buckets crees (pour creer les Character generiques)."""
|
||||
used: dict[str, tuple[Optional[str], Optional[str]]] = {}
|
||||
for idx, seg in enumerate(segments):
|
||||
if seg.type is not SegmentType.DIALOGUE or _is_resolved(seg.speaker):
|
||||
continue
|
||||
narr_gender = role_age = None
|
||||
found = False
|
||||
for j in (idx - 1, idx + 1): # narration adjacente (avant puis apres)
|
||||
if 0 <= j < len(segments) and segments[j].type is SegmentType.NARRATION:
|
||||
m = _ANON_NARR_RE.search(segments[j].text)
|
||||
if m:
|
||||
found = True
|
||||
art = m.group("art").lower().rstrip("’'")
|
||||
narr_gender = "female" if art in ("la", "une") else "male"
|
||||
role_age = _ROLE_AGE.get(m.group("role").lower())
|
||||
break
|
||||
if not found:
|
||||
continue
|
||||
gender = _inversion_gender(seg.text) or narr_gender
|
||||
ident = _anon_identity(gender, role_age)
|
||||
seg.speaker = ident
|
||||
used[ident] = (gender, role_age)
|
||||
return used
|
||||
|
||||
|
||||
def _canonicalize_speakers(segments: list[Segment], chars: list[Character]) -> None:
|
||||
"""Reecrit chaque locuteur variant vers le nom canonique du cast.
|
||||
|
||||
Le LLM emet souvent des variantes hors liste ("Amiral Mehmet Sagale" pour
|
||||
"Sagale", "Elvi Okoye" pour "Elvi"). Non rattachees, elles cassent le rendu
|
||||
(mauvaise voix -> repli narrateur) et le score. On les recolle au canonique
|
||||
via `heuristic_match` (primitive sure du dedup) : on n'agit QUE sur un match
|
||||
certain (`Character`), on s'abstient sur ambiguite/inconnu. Pur, sans LLM,
|
||||
ne touche pas au prompt. Ordre-independant : `tokfreq` calcule globalement.
|
||||
Idempotent (un nom deja canonique matche en exact)."""
|
||||
from ..casting.dedup import heuristic_match, _token_freq
|
||||
|
||||
spoken = [s.speaker for s in segments
|
||||
if s.type is SegmentType.DIALOGUE and _is_resolved(s.speaker)]
|
||||
if not spoken or not chars:
|
||||
return
|
||||
tokfreq = _token_freq(chars, spoken)
|
||||
for seg in segments:
|
||||
if seg.type is not SegmentType.DIALOGUE or not _is_resolved(seg.speaker):
|
||||
continue
|
||||
match = heuristic_match(seg.speaker, chars, tokfreq)
|
||||
if isinstance(match, Character):
|
||||
seg.speaker = match.name
|
||||
|
||||
|
||||
# --- Passe deterministe : reparation de l'alternance des tours ---------------
|
||||
|
||||
|
||||
def _norm_name(name: str) -> str:
|
||||
return (name or "").strip().casefold()
|
||||
|
||||
|
||||
# Tolerance de narration intercalee entre deux repliques d'un meme run. STRICT
|
||||
# (0) : seules les repliques d'indices consecutifs forment un run. Toute valeur
|
||||
# >0 est DANGEREUSE : une narration peut porter une *continuation du meme
|
||||
# locuteur* ("— …", "Fayez marqua une pause.", "— …") ou il reparle ; verifie
|
||||
# sur ch06 (runs 66-79 et 83-90 de la reference NON alternes des GAP=1). On
|
||||
# prefere ne pas reparer une replique isolee que d'inventer une fausse alternance.
|
||||
_RUN_MAX_NARRATION_GAP = 0
|
||||
|
||||
|
||||
def _dialogue_runs(segments: list[Segment]) -> list[list[int]]:
|
||||
"""Suites de repliques d'indices consecutifs (aucune narration intercalee).
|
||||
|
||||
Hypothese (verifiee sur les references ch05 ET ch06, 0 contre-exemple) : dans
|
||||
une telle salve ou chaque cadratin marque un changement de locuteur, les
|
||||
tours alternent strictement. Des qu'une narration s'intercale, l'alternance
|
||||
n'est plus garantie (continuation possible du meme locuteur) -> nouveau run."""
|
||||
runs: list[list[int]] = []
|
||||
cur: list[int] = []
|
||||
gap = 0
|
||||
for i, s in enumerate(segments):
|
||||
if s.type is SegmentType.DIALOGUE:
|
||||
cur.append(i)
|
||||
gap = 0
|
||||
else:
|
||||
gap += 1
|
||||
if gap > _RUN_MAX_NARRATION_GAP:
|
||||
if len(cur) >= 2:
|
||||
runs.append(cur)
|
||||
cur = []
|
||||
if len(cur) >= 2:
|
||||
runs.append(cur)
|
||||
return runs
|
||||
|
||||
|
||||
def _repair_alternation(segments: list[Segment], *, names=None) -> None:
|
||||
"""Force l'alternance des tours dans les echanges a exactement 2 locuteurs.
|
||||
|
||||
Pour chaque suite de repliques consecutives a deux locuteurs, on retient,
|
||||
parmi les deux motifs alternes possibles (A/B/A… ou B/A/B…), celui qui :
|
||||
1. ne contredit aucune ancre sure (locuteur explicite d'incise nominale) ;
|
||||
2. exige le moins de corrections au resultat de la 1re passe.
|
||||
On n'agit qu'avec un gagnant STRICT, sinon on s'abstient (on prefere laisser
|
||||
une erreur qu'en introduire une). En particulier, des qu'un 3e locuteur (meme
|
||||
minoritaire) apparait dans le run, on ne touche a rien : un echange a >=3
|
||||
n'alterne pas forcement. Pur, sans appel LLM ; comble aussi les repliques
|
||||
indeterminees du run.
|
||||
"""
|
||||
names = names or set()
|
||||
for run in _dialogue_runs(segments):
|
||||
speakers = [segments[i].speaker for i in run]
|
||||
resolved = {_norm_name(s) for s in speakers if _is_resolved(s)}
|
||||
if len(resolved) != 2:
|
||||
continue
|
||||
# Noms canoniques (1re occurrence de chaque forme normalisee).
|
||||
order: list[str] = []
|
||||
for s in speakers:
|
||||
n = _norm_name(s)
|
||||
if n in resolved and n not in order:
|
||||
order.append(n)
|
||||
name_a, name_b = order[0], order[1]
|
||||
canon_of = {}
|
||||
for s in speakers:
|
||||
n = _norm_name(s)
|
||||
if n in resolved:
|
||||
canon_of.setdefault(n, s.strip())
|
||||
|
||||
# Ancres sures : locuteur explicite d'une incise nominale.
|
||||
anchors: dict[int, str] = {}
|
||||
for k, idx in enumerate(run):
|
||||
seg = segments[idx]
|
||||
for inc in seg.incises:
|
||||
spk = incise_speaker(seg.text, inc, names)
|
||||
if spk:
|
||||
anchors[k] = _norm_name(spk)
|
||||
break
|
||||
# Une ancre nommant un tiers (hors paire) -> run suspect, on s'abstient.
|
||||
if any(a not in (name_a, name_b) for a in anchors.values()):
|
||||
continue
|
||||
|
||||
def pattern(start: str) -> list[str]:
|
||||
other = name_b if start == name_a else name_a
|
||||
return [start if k % 2 == 0 else other for k in range(len(run))]
|
||||
|
||||
candidates = [pattern(name_a), pattern(name_b)]
|
||||
admissible = [p for p in candidates
|
||||
if all(p[k] == a for k, a in anchors.items())]
|
||||
if not admissible:
|
||||
continue
|
||||
|
||||
def cost(p: list[str]) -> int: # corrections sur les repliques resolues
|
||||
return sum(1 for k, idx in enumerate(run)
|
||||
if _is_resolved(segments[idx].speaker)
|
||||
and _norm_name(segments[idx].speaker) != p[k])
|
||||
|
||||
admissible.sort(key=cost)
|
||||
if len(admissible) == 2 and cost(admissible[0]) == cost(admissible[1]):
|
||||
continue # ex aequo sans ancre discriminante -> trop ambigu
|
||||
chosen = admissible[0]
|
||||
for k, idx in enumerate(run):
|
||||
segments[idx].speaker = canon_of[chosen[k]]
|
||||
|
||||
|
||||
# --- Extraction du casting (Gemma) ------------------------------------------
|
||||
# Le prompt systeme est editable dans les reglages (settings.prompt_characters).
|
||||
|
||||
|
||||
def extract_characters(text: str, gemma: Gemma) -> list[Character]:
|
||||
def extract_characters(text: str, gemma: LLM) -> list[Character]:
|
||||
"""Extrait les personnages et leurs attributs (genre, age) d'un texte."""
|
||||
prompt = (
|
||||
"A partir de l'extrait suivant, liste les personnages qui parlent ou "
|
||||
@@ -374,17 +600,52 @@ _SPEECH_VERBS = {
|
||||
"tempeta", "rétorque", "lâche", "informa", "renseigna", "indiqua",
|
||||
"rappela", "avertit", "prévint", "prevint", "intima", "rétorquait",
|
||||
"lançait", "questionnait", "reconnut", "constata", "répéta", "repeta",
|
||||
"intervint", "intervient", "renchérissait",
|
||||
}
|
||||
|
||||
# Noms de role pouvant etre sujet d'une incise ("informa le soldat").
|
||||
# Noms de role (FONCTION) pouvant etre sujet d'une incise ("informa le soldat").
|
||||
# On EXCLUT volontairement les rangs/titres (amiral, capitaine, lieutenant...) :
|
||||
# ils precedent presque toujours un nom propre ("dit l'amiral Sagale") -> ce
|
||||
# n'est pas un figurant anonyme mais une personne nommee ; les laisser ici ferait
|
||||
# capter le titre au lieu du nom. Le nom propre est alors capte normalement.
|
||||
_ROLE_NOUNS = {
|
||||
"garde", "soldat", "sentinelle", "gardien", "prêtre", "pretre", "homme",
|
||||
"femme", "fille", "garçon", "garcon", "vieille", "vieillard", "capitaine",
|
||||
"lieutenant", "sergent", "général", "general", "amiral", "officier", "voix",
|
||||
"femme", "fille", "garçon", "garcon", "vieille", "vieillard", "voix",
|
||||
"inconnu", "inconnue", "étranger", "etranger", "enfant", "serviteur",
|
||||
"servante", "messager", "domestique", "médecin", "medecin",
|
||||
"servante", "messager", "domestique", "médecin", "medecin", "marine", "marin",
|
||||
}
|
||||
|
||||
# Genre/age probables d'un personnage-fonction, pour l'attribuer a un locuteur
|
||||
# anonyme regroupe (voix par genre/age). On ne mappe QUE les cas ou le genre de
|
||||
# la PERSONNE est fortement implique (roles militaires/masculins, feminins
|
||||
# explicites) ; les cas ambigus (medecin, officier, voix, sentinelle...) restent
|
||||
# inconnus -> bucket "anonyme" generique. Mieux vaut un genre inconnu qu'errone.
|
||||
_ROLE_GENDER = {
|
||||
"soldat": "male", "garde": "male", "gardien": "male", "marine": "male",
|
||||
"marin": "male", "homme": "male", "garçon": "male", "garcon": "male",
|
||||
"vieillard": "male", "serviteur": "male", "messager": "male",
|
||||
"prêtre": "male", "pretre": "male",
|
||||
"femme": "female", "fille": "female", "servante": "female",
|
||||
"vieille": "female", "inconnue": "female",
|
||||
}
|
||||
# Age probable (rare : seul "enfant" le donne nettement).
|
||||
_ROLE_AGE = {
|
||||
"enfant": "child", "garçon": "child", "garcon": "child",
|
||||
"fille": "child", "vieillard": "old", "vieille": "old",
|
||||
}
|
||||
|
||||
# Genre du pronom d'une incise d'inversion ("-t-elle"/"-il"). "-" => inversion.
|
||||
_INV_GENDER_RE = re.compile(r"-(?:t-)?(?P<p>ils?|elles?)\b", re.IGNORECASE)
|
||||
|
||||
# Figurant genre decrit dans la narration : article (genre) + nom de role proche.
|
||||
# Ex: "La femme", "La jeune marine", "Le soldat". Sert a resoudre une replique
|
||||
# indeterminee en anonyme (cf. `_resolve_anonymous_figurants`).
|
||||
_ANON_NARR_RE = re.compile(
|
||||
r"\b(?P<art>la|le|une|un)\s+(?:[\wÀ-ÿ’'-]+\s+){0,2}?"
|
||||
r"(?P<role>" + "|".join(re.escape(r) for r in sorted(_ROLE_NOUNS, key=len, reverse=True)) + r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Mots vides ignores quand on indexe les tokens d'un nom de personnage.
|
||||
_NAME_STOP = {
|
||||
"le", "la", "les", "un", "une", "de", "du", "des", "monsieur", "madame",
|
||||
@@ -446,12 +707,14 @@ _REJECT = object() # le sujet n'en est pas un -> pas une incise
|
||||
|
||||
|
||||
def _classify_subject(subj: str, idx: dict[str, str]):
|
||||
"""Locuteur porte par le sujet d'une incise nominale.
|
||||
"""Locuteur NOMME porte par le sujet d'une incise nominale.
|
||||
|
||||
- personnage connu -> nom canonique ;
|
||||
- nom propre (capitalise) inconnu -> nom de surface (seed quand meme : le
|
||||
texte le nomme, independamment de la fiabilite de l'extraction) ;
|
||||
- nom de role generique ("le soldat") -> None (incise reelle, pas de seed) ;
|
||||
- nom de role ("le soldat") -> None : pas un locuteur NOMME. L'incise reste
|
||||
detectee (narration), et le rattachement a un anonyme (par genre/age) se
|
||||
fait en post-traitement (cf. `_apply_anonymous_speakers` / `incise_role`) ;
|
||||
- mot quelconque -> _REJECT (pas une incise).
|
||||
"""
|
||||
low = subj.lower()
|
||||
@@ -464,11 +727,14 @@ def _classify_subject(subj: str, idx: dict[str, str]):
|
||||
return _REJECT
|
||||
|
||||
|
||||
def _nominal_matches(text: str, names) -> list[tuple[int, int, Optional[str]]]:
|
||||
"""Passe 2 : (start, end, locuteur) pour chaque incise nominale.
|
||||
def _nominal_matches(text: str, names
|
||||
) -> list[tuple[int, int, Optional[str], str]]:
|
||||
"""Passe 2 : (start, end, locuteur, sujet) pour chaque incise nominale.
|
||||
|
||||
Une incise nominale = verbe de parole + sujet (nom du casting, nom propre,
|
||||
ou nom de role). Le sujet nom propre est seede meme absent du casting.
|
||||
Le 4e champ est le sujet (minuscule) : sert a reconnaitre un nom de role
|
||||
(`incise_role`) pour rattacher un locuteur anonyme par genre/age.
|
||||
"""
|
||||
idx = _name_token_index(names)
|
||||
literals = sorted(set(idx) | _ROLE_NOUNS, key=len, reverse=True)
|
||||
@@ -486,13 +752,15 @@ def _nominal_matches(text: str, names) -> list[tuple[int, int, Optional[str]]]:
|
||||
r"[^.!?…»\",;]*?)"
|
||||
r"(?P<close>[.!?…,])",
|
||||
)
|
||||
out: list[tuple[int, int, Optional[str]]] = []
|
||||
out: list[tuple[int, int, Optional[str], str]] = []
|
||||
for m in pat.finditer(text):
|
||||
spk = _classify_subject(m.group("subj"), idx)
|
||||
subj = m.group("subj")
|
||||
spk = _classify_subject(subj, idx)
|
||||
if spk is _REJECT:
|
||||
continue
|
||||
out.append((m.start("inc"),
|
||||
_incise_end(text, m.end("close"), m.group("lead")), spk))
|
||||
_incise_end(text, m.end("close"), m.group("lead")),
|
||||
spk, subj.lower()))
|
||||
return out
|
||||
|
||||
|
||||
@@ -511,18 +779,33 @@ def _merge_spans(spans: list[tuple[int, int]]) -> list[Incise]:
|
||||
def detect_incises(text: str, *, names=None) -> list[Incise]:
|
||||
"""Bornes des incises dans une replique (inversion + nominale cast-aware)."""
|
||||
spans = _inversion_spans(text)
|
||||
spans += [(s, e) for s, e, _ in _nominal_matches(text, names or set())]
|
||||
spans += [(s, e) for s, e, _, _ in _nominal_matches(text, names or set())]
|
||||
return _merge_spans(spans)
|
||||
|
||||
|
||||
def incise_speaker(text: str, incise: Incise, names) -> Optional[str]:
|
||||
"""Locuteur explicite porte par une incise nominale ("compatit Holden")."""
|
||||
for s, e, spk in _nominal_matches(text, names):
|
||||
"""Locuteur NOMME explicite porte par une incise nominale ("compatit Holden").
|
||||
|
||||
None pour une incise de role ("informa le soldat") : un role n'est pas un
|
||||
locuteur nomme (cf. `incise_role` pour le rattachement anonyme).
|
||||
"""
|
||||
for s, e, spk, _ in _nominal_matches(text, names):
|
||||
if s == incise.start and e == incise.end:
|
||||
return spk
|
||||
return None
|
||||
|
||||
|
||||
def incise_role(text: str, incise: Incise, names) -> Optional[str]:
|
||||
"""Nom de role (minuscule) sujet d'une incise ("informa le soldat" -> "soldat").
|
||||
|
||||
Renvoie None si l'incise n'est pas une incise de role. Sert a rattacher la
|
||||
replique a un locuteur anonyme regroupe par genre/age (cf. `_anon_identity`)."""
|
||||
for s, e, _spk, subj in _nominal_matches(text, names):
|
||||
if s == incise.start and e == incise.end and subj in _ROLE_NOUNS:
|
||||
return subj
|
||||
return None
|
||||
|
||||
|
||||
def iter_incise_pieces(
|
||||
text: str, incises: list[Incise]
|
||||
) -> list[tuple[bool, str]]:
|
||||
@@ -552,10 +835,10 @@ def iter_incise_pieces(
|
||||
def analyze_chapter(
|
||||
chapter: Chapter,
|
||||
ct: ChapterText,
|
||||
gemma: Gemma,
|
||||
gemma: LLM,
|
||||
*,
|
||||
book_chars: Optional[list[Character]] = None,
|
||||
dedup_gemma: Optional[Gemma] = None,
|
||||
dedup_gemma: Optional[LLM] = None,
|
||||
) -> tuple[ChapterAnalysis, list[Character]]:
|
||||
"""Analyse complete d'un chapitre.
|
||||
|
||||
@@ -594,12 +877,18 @@ def analyze_chapter(
|
||||
# Annotation deterministe des incises (bornes, non destructif) + seeding :
|
||||
# une incise nominale qui nomme un personnage fixe le locuteur avec certitude
|
||||
# AVANT l'appel LLM (corrige les cas que le petit modele rate).
|
||||
# NB: ne PAS inclure les alias ici -> mesure : ca change trop le prompt et
|
||||
# provoque de gros effets papillon (ch06 12B: 96% -> 80%). Les epithetes sont
|
||||
# rattaches en post-traitement par la canonicalisation (sur le cast complet).
|
||||
names = {c.name for c in chars}
|
||||
for seg in segments:
|
||||
if seg.type is not SegmentType.DIALOGUE:
|
||||
continue
|
||||
seg.incises = detect_incises(seg.text, names=names)
|
||||
for inc in seg.incises:
|
||||
# PRE-LLM : seuls les noms propres seedent (les incises de role
|
||||
# renvoient None -> pas de seed, donc prompt inchange ; les roles
|
||||
# sont rattaches en anonymes en post-traitement, sans effet papillon).
|
||||
spk = incise_speaker(seg.text, inc, names)
|
||||
if spk:
|
||||
seg.speaker = spk
|
||||
@@ -611,6 +900,22 @@ def analyze_chapter(
|
||||
_refine_unknown_speakers(segments, gemma, characters=chapter_chars,
|
||||
confidence=conf)
|
||||
|
||||
# Post-traitement deterministe (sans LLM). Ordre important :
|
||||
# 1. rattache les incises de role a un locuteur anonyme par genre/age ;
|
||||
# 2. repare l'alternance des tours dans les echanges a deux ;
|
||||
# 3. recolle les variantes de noms au canonique du cast (rendu + score) ;
|
||||
# 4. resout les figurants restes indetermines via la narration adjacente.
|
||||
anon = _apply_anonymous_speakers(segments, names=names)
|
||||
_repair_alternation(segments, names=names)
|
||||
_canonicalize_speakers(segments, chars)
|
||||
anon.update(_resolve_anonymous_figurants(segments))
|
||||
# Cree les Character generiques des buckets anonymes (assignation de voix).
|
||||
known = {c.name for c in chars}
|
||||
for ident, (gender, age) in anon.items():
|
||||
if ident not in known:
|
||||
chars.append(Character(name=ident, gender=gender, age=age))
|
||||
known.add(ident)
|
||||
|
||||
# Absorbe les locuteurs residuels (hors liste) en aliases (heuristique seule).
|
||||
chars, _ = reconcile_characters(
|
||||
chars, [], None, speaker_names=[s.speaker for s in segments])
|
||||
|
||||
Reference in New Issue
Block a user