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
928 lines
39 KiB
Python
928 lines
39 KiB
Python
"""Segmentation narration/dialogue + attribution de locuteur + casting.
|
||
|
||
Approche hybride :
|
||
1. Pre-segmentation deterministe au niveau paragraphe (regles de ponctuation
|
||
francaise : un paragraphe commencant par un cadratin "—" est une replique).
|
||
2. Gemma attribue un locuteur a chaque replique, en un seul appel par chapitre
|
||
(liste numerotee + contexte), et extrait le casting (personnages + attributs).
|
||
|
||
Le decoupage fin des incises ("..., dit-il") est laisse a une passe ulterieure ;
|
||
en v1 la replique entiere est portee par la voix du personnage.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Optional
|
||
|
||
from ..models import (
|
||
Cast,
|
||
Chapter,
|
||
ChapterAnalysis,
|
||
ChapterText,
|
||
Character,
|
||
Incise,
|
||
Segment,
|
||
SegmentType,
|
||
)
|
||
from ..settings import get_settings
|
||
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*")
|
||
|
||
# --- Detection des incises (inversion verbe-sujet francaise) ------------------
|
||
# Une incise est un groupe de narration insere dans une replique ("..., dit-il.").
|
||
# On exclut tu/nous/vous (imperatifs "Donne-le-moi", "Crois-tu ?") pour limiter
|
||
# les faux positifs. Voir `detect_incises` plus bas pour les deux passes
|
||
# (inversion verbe-pronom + nominale "lanca Drummer", conscience du casting).
|
||
_INCISE_PRON = r"(?:il|elle|on|ils|elles|je)"
|
||
# Verbe de parole, eventuellement reflechi ("s'ecria", "s'exclama").
|
||
_INCISE_VERB = r"(?:[A-Za-zÀ-ÿ]+['’])?[A-Za-zÀ-ÿ]{2,}"
|
||
|
||
|
||
def segment_chapter_text(ct: ChapterText) -> list[Segment]:
|
||
"""Decoupe un chapitre en segments narration/dialogue (regles seules)."""
|
||
segments: list[Segment] = []
|
||
for para in ct.paragraphs:
|
||
if _DIALOGUE_LEAD_RE.match(para):
|
||
text = _DIALOGUE_LEAD_RE.sub("", para).strip()
|
||
segments.append(Segment(
|
||
type=SegmentType.DIALOGUE, text=text, speaker="?"))
|
||
else:
|
||
segments.append(Segment(
|
||
type=SegmentType.NARRATION, text=para, speaker="narrateur"))
|
||
return segments
|
||
|
||
|
||
# --- Attribution des locuteurs (Gemma) --------------------------------------
|
||
# Le prompt systeme est editable dans les reglages (settings.prompt_speakers).
|
||
|
||
|
||
_UNKNOWN = {"", "?", "inconnu", "narrateur"}
|
||
_CTX_CHARS = 160 # troncature du contexte narratif avant/apres
|
||
_CHUNK_MAX_DIALOGUES = 30 # repliques par appel (fiabilite du modele)
|
||
|
||
|
||
def attribute_speakers(
|
||
segments: list[Segment],
|
||
gemma: LLM,
|
||
*,
|
||
characters: Optional[list[Character]] = None,
|
||
pov: Optional[str] = None,
|
||
) -> dict[int, str]:
|
||
"""Renseigne `speaker` pour chaque dialogue (mutation en place).
|
||
|
||
Fournit au modele la liste canonique enrichie des personnages (nom, genre,
|
||
description) et, pour chaque replique, le contexte narratif AVANT et APRES
|
||
(l'incise d'attribution est souvent placee apres : "— Bonjour. dit Marie.").
|
||
|
||
Renvoie une map {index_de_segment: confidence} ("high"/"medium"/"low"),
|
||
conservee en memoire (non persistee) pour piloter la 2e passe retroactive.
|
||
Une replique dont le nom rendu sort de la liste fournie est gardee mais
|
||
marquee "low" afin d'etre reexaminee.
|
||
"""
|
||
dialogues = [(i, s) for i, s in enumerate(segments)
|
||
if s.type is SegmentType.DIALOGUE]
|
||
if not dialogues:
|
||
return {}
|
||
|
||
# Repliques deja resolues (seed par incise) : montrees comme contexte fixe,
|
||
# jamais re-demandees au modele. Si tout est resolu, rien a faire.
|
||
locked = {i for i, s in dialogues if _is_resolved(s.speaker)}
|
||
if len(locked) == len(dialogues):
|
||
return {i: "high" for i, _ in dialogues}
|
||
|
||
hint = _speakers_hint(characters, pov)
|
||
valid = {c.name.strip().lower() for c in (characters or [])}
|
||
confidence: dict[int, str] = {}
|
||
|
||
for chunk in _chunk_dialogues(dialogues, segments, hint):
|
||
prompt = (
|
||
"Voici les repliques de dialogue d'un extrait, numerotees, avec la "
|
||
"narration qui precede et qui suit chaque replique. Les repliques "
|
||
"deja attribuees affichent (locuteur: X) : ne les modifie pas, "
|
||
"sers-t'en comme contexte (alternance des tours). Pour les AUTRES, "
|
||
"indique le personnage qui parle (recopie son nom depuis la liste "
|
||
"fournie ; 'inconnu' si vraiment indeterminable) et ta confiance "
|
||
"(high/medium/low)."
|
||
f"{hint}\n\n" + "\n".join(line for _, line in chunk) +
|
||
'\n\nReponds par un tableau JSON: '
|
||
'[{"i": 0, "speaker": "Holden", "confidence": "high"}, ...]'
|
||
)
|
||
result = gemma.generate_json(prompt, system=get_settings().prompt_speakers)
|
||
by_i: dict[int, dict] = {item["i"]: item for item in result
|
||
if isinstance(item, dict) and "i" in item}
|
||
for j, (seg_idx, _line) in enumerate(chunk):
|
||
if seg_idx in locked: # seed conserve
|
||
confidence[seg_idx] = "high"
|
||
continue
|
||
seg = segments[seg_idx]
|
||
item = by_i.get(j) or {}
|
||
speaker = (str(item.get("speaker") or "inconnu").strip()
|
||
or "inconnu")
|
||
conf = str(item.get("confidence") or "low").strip().lower()
|
||
if conf not in {"high", "medium", "low"}:
|
||
conf = "low"
|
||
# Nom hors liste connue -> on garde le nom mais on le rejuge.
|
||
if (valid and speaker.lower() not in _UNKNOWN
|
||
and speaker.lower() not in valid):
|
||
conf = "low"
|
||
seg.speaker = speaker
|
||
confidence[seg_idx] = conf
|
||
return confidence
|
||
|
||
|
||
def _speakers_hint(characters: Optional[list[Character]], pov: Optional[str]) -> str:
|
||
hint = ""
|
||
if characters:
|
||
lines = []
|
||
for c in characters:
|
||
attrs = c.gender or ""
|
||
desc = f" — {c.description}" if c.description else ""
|
||
lines.append(f"- {c.name}" + (f" ({attrs})" if attrs else "") + desc)
|
||
hint += "\nPersonnages du chapitre:\n" + "\n".join(lines)
|
||
if pov:
|
||
hint += f"\nLe point de vue de ce chapitre est: {pov}."
|
||
return hint
|
||
|
||
|
||
def _is_resolved(speaker: str) -> bool:
|
||
"""Vrai si la replique a deja un locuteur sur (seed incise, etc.)."""
|
||
return (speaker or "").strip().lower() not in _UNKNOWN
|
||
|
||
|
||
def _dialogue_line(n: int, segments: list[Segment], idx: int) -> str:
|
||
seg = segments[idx]
|
||
# Replique deja resolue (ex: seed par incise) -> montree comme contexte fixe.
|
||
if _is_resolved(seg.speaker):
|
||
return f"[{n}] (locuteur: {seg.speaker}) REPLIQUE: {seg.text!r}"
|
||
before = _adjacent_narration(segments, idx, -1)
|
||
after = _adjacent_narration(segments, idx, +1)
|
||
parts = [f"[{n}]"]
|
||
if before:
|
||
parts.append(f"(avant: {before!r})")
|
||
parts.append(f"REPLIQUE: {seg.text!r}")
|
||
if after:
|
||
parts.append(f"(apres: {after!r})")
|
||
return " ".join(parts)
|
||
|
||
|
||
def _adjacent_narration(segments: list[Segment], idx: int, direction: int) -> str:
|
||
"""Texte de la narration immediatement adjacente (incise d'attribution)."""
|
||
j = idx + direction
|
||
if 0 <= j < len(segments) and segments[j].type is SegmentType.NARRATION:
|
||
return segments[j].text[:_CTX_CHARS]
|
||
return ""
|
||
|
||
|
||
def _chunk_dialogues(
|
||
dialogues: list[tuple[int, Segment]],
|
||
segments: list[Segment],
|
||
hint: str,
|
||
) -> list[list[tuple[int, str]]]:
|
||
"""Decoupe les repliques en lots tenant sous `_MAX_PROMPT_CHARS`.
|
||
|
||
Chaque lot est une liste de (index_segment, ligne_rendue) ; la ligne est
|
||
numerotee localement (0..k) pour le prompt, l'index segment sert au mapping
|
||
retour. Evite la troncature brutale sur les longs chapitres.
|
||
"""
|
||
budget = _MAX_PROMPT_CHARS - len(hint) - 400 # marge pour les consignes
|
||
chunks: list[list[tuple[int, str]]] = []
|
||
current: list[tuple[int, str]] = []
|
||
size = 0
|
||
for idx, _seg in dialogues:
|
||
line = _dialogue_line(len(current), segments, idx)
|
||
if current and (size + len(line) > budget
|
||
or len(current) >= _CHUNK_MAX_DIALOGUES):
|
||
chunks.append(current)
|
||
current = []
|
||
size = 0
|
||
line = _dialogue_line(0, segments, idx)
|
||
current.append((idx, line))
|
||
size += len(line) + 1
|
||
if current:
|
||
chunks.append(current)
|
||
return chunks
|
||
|
||
|
||
# --- Passe retroactive : re-resolution des repliques indeterminees ----------
|
||
# Le prompt systeme est editable (settings.prompt_speakers_refine).
|
||
|
||
|
||
def _refine_unknown_speakers(
|
||
segments: list[Segment],
|
||
gemma: LLM,
|
||
*,
|
||
characters: Optional[list[Character]] = None,
|
||
confidence: dict[int, str],
|
||
) -> None:
|
||
"""2e passe : re-resout les repliques restees indeterminees/peu sures.
|
||
|
||
Chaque replique douteuse est presentee avec ses voisines de dialogue DEJA
|
||
identifiees (alternance des tours) et son contexte narratif, pour exploiter
|
||
l'information venant des repliques *suivantes*. Mutation en place ; aucun
|
||
appel Gemma si rien n'est douteux.
|
||
"""
|
||
dialogues = [(i, s) for i, s in enumerate(segments)
|
||
if s.type is SegmentType.DIALOGUE]
|
||
if not dialogues:
|
||
return
|
||
pos = {seg_idx: n for n, (seg_idx, _s) in enumerate(dialogues)}
|
||
doubtful = [seg_idx for seg_idx, _s in dialogues
|
||
if segments[seg_idx].speaker.strip().lower() in _UNKNOWN
|
||
or confidence.get(seg_idx) == "low"]
|
||
if not doubtful:
|
||
return
|
||
|
||
hint = _speakers_hint(characters, pov=None)
|
||
lines = []
|
||
for j, seg_idx in enumerate(doubtful):
|
||
n = pos[seg_idx]
|
||
ctx = []
|
||
if n > 0:
|
||
prev_idx = dialogues[n - 1][0]
|
||
ctx.append(f"replique precedente (dite par "
|
||
f"{segments[prev_idx].speaker}): "
|
||
f"{segments[prev_idx].text[:_CTX_CHARS]!r}")
|
||
before = _adjacent_narration(segments, seg_idx, -1)
|
||
if before:
|
||
ctx.append(f"narration avant: {before!r}")
|
||
after = _adjacent_narration(segments, seg_idx, +1)
|
||
if after:
|
||
ctx.append(f"narration apres: {after!r}")
|
||
if n < len(dialogues) - 1:
|
||
next_idx = dialogues[n + 1][0]
|
||
ctx.append(f"replique suivante (dite par "
|
||
f"{segments[next_idx].speaker}): "
|
||
f"{segments[next_idx].text[:_CTX_CHARS]!r}")
|
||
ctx_str = (" [" + " ; ".join(ctx) + "]") if ctx else ""
|
||
lines.append(f"[{j}]{ctx_str} REPLIQUE: {segments[seg_idx].text!r}")
|
||
|
||
prompt = (
|
||
"Repliques au locuteur indetermine. Pour chacune, en t'appuyant sur les "
|
||
"repliques voisines DEJA attribuees (alternance des tours) et le "
|
||
"contexte, indique qui parle (recopie le nom depuis la liste ; "
|
||
"'inconnu' si toujours indeterminable)."
|
||
f"{hint}\n\n" + "\n".join(lines) +
|
||
'\n\nReponds par un tableau JSON: [{"i": 0, "speaker": "Holden"}, ...]'
|
||
)
|
||
result = gemma.generate_json(_truncate(prompt),
|
||
system=get_settings().prompt_speakers_refine)
|
||
by_i = {item["i"]: item.get("speaker") for item in result
|
||
if isinstance(item, dict) and "i" in item}
|
||
for j, seg_idx in enumerate(doubtful):
|
||
new = (str(by_i.get(j) or "").strip())
|
||
if new and new.lower() not in _UNKNOWN:
|
||
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: 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 "
|
||
"sont nommes. Pour chacun, donne: name (nom court canonique), gender "
|
||
"(male/female/unknown), age (child/young/adult/old/unknown), et une "
|
||
"courte description. Ignore les figurants sans nom.\n\n"
|
||
f"EXTRAIT:\n{_truncate(text)}\n\n"
|
||
'Reponds par un tableau JSON: '
|
||
'[{"name":"Holden","gender":"male","age":"adult","description":"..."}]'
|
||
)
|
||
result = gemma.generate_json(prompt, system=get_settings().prompt_characters)
|
||
characters: list[Character] = []
|
||
for item in result:
|
||
if not isinstance(item, dict) or not item.get("name"):
|
||
continue
|
||
characters.append(Character(
|
||
name=str(item["name"]).strip(),
|
||
gender=_norm(item.get("gender")),
|
||
age=_norm(item.get("age")),
|
||
description=(item.get("description") or None),
|
||
))
|
||
return characters
|
||
|
||
|
||
def merge_characters(existing: list[Character], new: list[Character]) -> list[Character]:
|
||
"""Fusionne deux listes de personnages par nom (insensible a la casse)."""
|
||
by_key = {c.name.lower(): c for c in existing}
|
||
for c in new:
|
||
key = c.name.lower()
|
||
if key in by_key:
|
||
cur = by_key[key]
|
||
cur.gender = cur.gender or c.gender
|
||
cur.age = cur.age or c.age
|
||
cur.description = cur.description or c.description
|
||
else:
|
||
by_key[key] = c
|
||
return list(by_key.values())
|
||
|
||
|
||
def _norm(value) -> Optional[str]:
|
||
if not value:
|
||
return None
|
||
v = str(value).strip().lower()
|
||
return v if v and v != "unknown" else None
|
||
|
||
|
||
# --- Helpers -----------------------------------------------------------------
|
||
|
||
# Garde-fou de contexte (caracteres) pour rester dans une fenetre raisonnable.
|
||
_MAX_PROMPT_CHARS = 24000
|
||
|
||
|
||
def _truncate(text: str) -> str:
|
||
return text if len(text) <= _MAX_PROMPT_CHARS else text[:_MAX_PROMPT_CHARS]
|
||
|
||
|
||
# --- Detection des incises (deterministe, conscience du casting) -------------
|
||
# Les incises sont annotees par des bornes (offsets) sur la replique persistee
|
||
# (non destructif) ; le rendu les fait porter par la voix du narrateur. Deux
|
||
# passes complementaires :
|
||
# 1. inversion verbe-pronom ("dit-il", "coupa-t-elle") ;
|
||
# 2. nominale : verbe de parole + sujet connu (nom du casting OU nom de role,
|
||
# ex: "compatit Holden", "lanca Drummer", "informa le soldat").
|
||
# La passe nominale s'appuie sur la liste des personnages -> peu de faux positifs
|
||
# et permet d'extraire le locuteur explicite (seeding de l'attribution).
|
||
|
||
# Pronom objet eventuel devant le verbe ("lui demanda un garde").
|
||
_CLITIC = r"(?:lui|leur|nous|vous|me|te|se|y|en|[mts]['’])"
|
||
|
||
# Formes conjuguees de verbes de parole (3e pers., passe simple / present /
|
||
# imparfait). Liste curee : on prefere rater une incise que d'en inventer une.
|
||
_SPEECH_VERBS = {
|
||
"dit", "disait", "redit", "répondit", "repondit", "répond", "repond",
|
||
"répondait", "repondait", "demanda", "demandait", "demande", "interrogea",
|
||
"questionna", "ecria", "écria", "exclama", "enquit", "lança", "lanca",
|
||
"lançait", "lance", "murmura", "chuchota", "souffla", "soupira", "ajouta",
|
||
"ajoute", "reprit", "poursuivit", "poursuit", "continua", "enchaîna",
|
||
"enchaina", "fit", "faisait", "remarqua", "observa", "nota", "déclara",
|
||
"declara", "affirma", "assura", "rétorqua", "retorqua", "répliqua",
|
||
"repliqua", "riposta", "objecta", "protesta", "insista", "renchérit",
|
||
"rencherit", "acquiesça", "acquiesca", "admit", "avoua", "convint",
|
||
"concéda", "conceda", "rectifia", "corrigea", "précisa", "precisa",
|
||
"expliqua", "raconta", "annonça", "annonca", "proclama", "ordonna",
|
||
"commanda", "supplia", "implora", "gémit", "gemit", "grogna", "ronchonna",
|
||
"maugréa", "maugrea", "marmonna", "glissa", "lâcha", "lacha", "coupa",
|
||
"interrompit", "conclut", "compléta", "completa", "suggéra", "suggera",
|
||
"proposa", "promit", "jura", "menaça", "menaca", "ironisa", "plaisanta",
|
||
"railla", "cria", "hurla", "tonna", "gronda", "rugit", "susurra",
|
||
"compatit", "salua", "appela", "héla", "hela", "interpella", "balbutia",
|
||
"bredouilla", "bafouilla", "gloussa", "ricana", "siffla", "tempêta",
|
||
"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 (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", "voix",
|
||
"inconnu", "inconnue", "étranger", "etranger", "enfant", "serviteur",
|
||
"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",
|
||
"mademoiselle", "m", "mme", "mlle", "mr", "dr", "docteur", "saint", "sainte",
|
||
}
|
||
|
||
# Ponctuations qui terminent la partie parlee : si l'incise les suit, tout le
|
||
# reste de la replique est de la narration (la parole est finie). Apres une
|
||
# simple virgule au contraire, le dialogue reprend apres l'incise.
|
||
_SENTENCE_FINAL = {"", ".", "!", "?", "…"}
|
||
|
||
|
||
def _incise_end(text: str, close_end: int, lead: str) -> int:
|
||
"""Fin effective de l'incise : jusqu'au bout de la replique si la parole
|
||
etait deja close a gauche (`.`/`!`/`?`/`…` ou debut), sinon la cloture."""
|
||
return len(text) if lead in _SENTENCE_FINAL else close_end
|
||
|
||
|
||
# Passe 1 : inversion verbe-(t-)pronom, ancree sur une ponctuation a gauche
|
||
# (virgule, point, ?, !, …) ou le debut de la replique.
|
||
_INVERSION_RE = re.compile(
|
||
r"(?P<lead>[,.!?…]|^)\s*"
|
||
r"(?P<inc>" + _INCISE_VERB + r"-(?:t-)?" + _INCISE_PRON +
|
||
r"(?:\s+[^.!?…»\",;]*?)?)" # complements eventuels ("dit-il en souriant")
|
||
r"(?P<close>[.!?…,])", # cloture : ponctuation forte OU virgule
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def _inversion_spans(text: str) -> list[tuple[int, int]]:
|
||
return [(m.start("inc"), _incise_end(text, m.end("close"), m.group("lead")))
|
||
for m in _INVERSION_RE.finditer(text)]
|
||
|
||
|
||
def _name_token_index(names) -> dict[str, str]:
|
||
"""Index token -> nom canonique (tokens distinctifs uniquement).
|
||
|
||
Un token partage par plusieurs personnages est ambigu et ecarte.
|
||
"""
|
||
idx: dict[str, str] = {}
|
||
ambiguous: set[str] = set()
|
||
for name in names or ():
|
||
for tok in re.split(r"[^\wÀ-ÿ]+", name):
|
||
t = tok.lower()
|
||
if len(t) < 2 or t in _NAME_STOP:
|
||
continue
|
||
if t in idx and idx[t] != name:
|
||
ambiguous.add(t)
|
||
else:
|
||
idx[t] = name
|
||
for t in ambiguous:
|
||
idx.pop(t, None)
|
||
return idx
|
||
|
||
|
||
# Nom propre : initiale majuscule (motif sensible a la casse).
|
||
_PROPER = r"[A-ZÀ-Ÿ][\wÀ-ÿ’'\-]+"
|
||
_REJECT = object() # le sujet n'en est pas un -> pas une incise
|
||
|
||
|
||
def _classify_subject(subj: str, idx: dict[str, str]):
|
||
"""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 ("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()
|
||
if low in idx:
|
||
return idx[low]
|
||
if low in _ROLE_NOUNS:
|
||
return None
|
||
if subj[:1].isupper() and len(low) >= 2 and low not in _NAME_STOP:
|
||
return subj.strip("’'")
|
||
return _REJECT
|
||
|
||
|
||
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)
|
||
lit_alt = "|".join(re.escape(s) for s in literals)
|
||
# Sujet : nom connu/role (insensible casse) OU nom propre (capitalise, sensible
|
||
# casse pour ne pas happer un determiner "un"/"le"). Pas d'IGNORECASE global.
|
||
subj_alt = (f"(?i:{lit_alt})|{_PROPER}") if lit_alt else _PROPER
|
||
verbs = "|".join(re.escape(v) for v in sorted(_SPEECH_VERBS, key=len, reverse=True))
|
||
pat = re.compile(
|
||
r"(?P<lead>[,.!?…]|^)\s*"
|
||
r"(?P<inc>(?:(?i:" + _CLITIC + r")\s+)?"
|
||
r"(?i:" + verbs + r")\b"
|
||
r"[^.!?…»\",;]{0,40}?\b"
|
||
r"(?P<subj>" + subj_alt + r")\b"
|
||
r"[^.!?…»\",;]*?)"
|
||
r"(?P<close>[.!?…,])",
|
||
)
|
||
out: list[tuple[int, int, Optional[str], str]] = []
|
||
for m in pat.finditer(text):
|
||
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, subj.lower()))
|
||
return out
|
||
|
||
|
||
def _merge_spans(spans: list[tuple[int, int]]) -> list[Incise]:
|
||
"""Trie et fusionne (sans chevauchement) une liste de bornes -> Incise."""
|
||
out: list[Incise] = []
|
||
last_end = -1
|
||
for s, e in sorted(set(spans)):
|
||
if s < last_end: # chevauchement -> on garde le premier vu
|
||
continue
|
||
out.append(Incise(start=s, end=e))
|
||
last_end = e
|
||
return out
|
||
|
||
|
||
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())]
|
||
return _merge_spans(spans)
|
||
|
||
|
||
def incise_speaker(text: str, incise: Incise, names) -> Optional[str]:
|
||
"""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]]:
|
||
"""Decoupe `text` en morceaux (is_incise, sous_texte) via les bornes.
|
||
|
||
Utilise au rendu : pieces dialogue -> voix du personnage, pieces incise ->
|
||
voix du narrateur. Texte conserve modulo espaces de bordure.
|
||
"""
|
||
pieces: list[tuple[bool, str]] = []
|
||
cursor = 0
|
||
for inc in sorted(incises, key=lambda i: i.start):
|
||
if inc.start < cursor: # garde-fou chevauchement
|
||
continue
|
||
before = text[cursor:inc.start]
|
||
if before.strip():
|
||
pieces.append((False, before.strip()))
|
||
body = text[inc.start:inc.end]
|
||
if body.strip():
|
||
pieces.append((True, body.strip()))
|
||
cursor = inc.end
|
||
tail = text[cursor:]
|
||
if tail.strip():
|
||
pieces.append((False, tail.strip()))
|
||
return pieces
|
||
|
||
|
||
def analyze_chapter(
|
||
chapter: Chapter,
|
||
ct: ChapterText,
|
||
gemma: LLM,
|
||
*,
|
||
book_chars: Optional[list[Character]] = None,
|
||
dedup_gemma: Optional[LLM] = None,
|
||
) -> tuple[ChapterAnalysis, list[Character]]:
|
||
"""Analyse complete d'un chapitre.
|
||
|
||
Sequence : segmentation -> extraction des personnages -> reconciliation
|
||
(dedup contre le cast cumule du livre) -> annotation des incises + seeding
|
||
du locuteur explicite -> attribution LLM des repliques restantes -> passe
|
||
retroactive. Les repliques sont persistees entieres (incises = bornes).
|
||
|
||
`book_chars` : cast cumule du livre (personnages canoniques deja connus).
|
||
`dedup_gemma` : si fourni, tranche les cas de dedup ambigus.
|
||
|
||
Renvoie (analyse, cast cumule mis a jour) ; le 2e element est l'ensemble du
|
||
casting du livre reconcilie, pret a etre persiste tel quel.
|
||
"""
|
||
from ..casting.dedup import reconcile_characters
|
||
|
||
segments = segment_chapter_text(ct)
|
||
full_text = "\n".join(ct.paragraphs)
|
||
found = extract_characters(full_text, gemma)
|
||
|
||
# Dedup AVANT l'attribution : le modele recevra des noms canoniques.
|
||
chars, name_map = reconcile_characters(book_chars or [], found, dedup_gemma)
|
||
|
||
# Liste canonique restreinte a ce chapitre (personnages detectes + POV).
|
||
chapter_canon = {(name_map.get(c.name.strip().lower()) or c.name).strip().lower()
|
||
for c in found}
|
||
chapter_chars = [c for c in chars if c.name.strip().lower() in chapter_canon]
|
||
if chapter.pov:
|
||
pv = chapter.pov.strip().lower()
|
||
for c in chars:
|
||
if (c not in chapter_chars and
|
||
(pv in c.name.lower()
|
||
or any(pv in a.lower() for a in c.aliases))):
|
||
chapter_chars.append(c)
|
||
|
||
# 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
|
||
break
|
||
|
||
conf = attribute_speakers(segments, gemma, characters=chapter_chars,
|
||
pov=chapter.pov)
|
||
if get_settings().retro_pass_use_gemma:
|
||
_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])
|
||
|
||
# Les repliques sont persistees entieres ; les incises restent des bornes
|
||
# (rendu : voix narrateur). Plus de fragmentation a l'analyse.
|
||
analysis = ChapterAnalysis(index=chapter.index, title=ct.title,
|
||
segments=segments)
|
||
return analysis, chars
|