Files
InkFlow/backend/inkflow/analysis/llm/_text.py
colgora ba1813c583 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
2026-06-21 21:32:31 +02:00

75 lines
2.7 KiB
Python

"""Helpers agnostiques du moteur : extraction JSON tolerante + retrait de la
chaine de pensee des modeles a raisonnement.
Module neutre (aucune dependance a un backend) : partage par la facade `LLM` et
par les backends (qui s'en servent pour l'arret anticipe en streaming), sans
creer de cycle d'import.
"""
from __future__ import annotations
import json
import re
from typing import Any
# Bornes d'un bloc JSON dans une reponse potentiellement bavarde.
_JSON_SPAN_RE = re.compile(r"(\{.*\}|\[.*\])", re.DOTALL)
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)```", re.DOTALL)
# Marqueurs de FIN de chaine de pensee : on ne garde que ce qui suit le dernier.
# - balises type DeepSeek-R1 / Qwen-think
# - format a canaux type Gemma 4 / Harmony (la pensee est close par <channel|>)
_REASONING_END_MARKERS = ("</think>", "<channel|>", "<|channel|>")
# Prefixe de canal/think non ferme reste en tete (pensee tronquee) : a retirer.
_REASONING_OPEN_RE = re.compile(r"^\s*(?:<\|?channel\|?>\s*\w*|<think>)", re.IGNORECASE)
def _strip_reasoning(text: str) -> str:
"""Retire la chaine de pensee d'un modele a raisonnement.
Ne conserve que ce qui suit le dernier marqueur de fin de pensee
(`</think>`, `<channel|>`...). Si seul un marqueur d'ouverture non ferme
subsiste (pensee tronquee par le budget de tokens), on le retire en tete
pour eviter de parser la pensee a la place de la reponse.
"""
t = text
for marker in _REASONING_END_MARKERS:
if marker in t:
t = t.rsplit(marker, 1)[-1]
t = _REASONING_OPEN_RE.sub("", t)
return t.strip()
def _has_complete_json(text: str) -> bool:
"""True si `text` contient deja un objet/array JSON complet et parsable.
Sert a stopper la generation des modeles a raisonnement des que la reponse
finale est ecrite (evite de consumer le budget en boucles de pensee).
"""
try:
_extract_json(text)
return True
except Exception: # noqa: BLE001
return False
def _extract_json(text: str) -> Any:
"""Extrait le premier objet/array JSON d'une reponse libre du modele.
Tolere le texte parasite avant/apres (y compris un 2e bloc) grace a
raw_decode, qui s'arrete au premier JSON complet.
"""
text = text.strip()
fence = _FENCE_RE.search(text)
if fence:
text = fence.group(1).strip()
decoder = json.JSONDecoder()
# Cherche le 1er debut de structure JSON et decode a partir de la.
for i, ch in enumerate(text):
if ch in "[{":
try:
obj, _ = decoder.raw_decode(text[i:])
return obj
except json.JSONDecodeError:
continue
raise ValueError("aucun JSON trouve dans la reponse")