"""Reglages techniques editables au runtime (globaux a l'app). Contrairement a `config.py` (constantes figees lues a l'import, surchargeables seulement par variables d'environnement au demarrage), ce module expose un objet `Settings` *persiste* dans `data/settings.json` et modifiable depuis l'UI. Les valeurs par defaut reprennent celles de `config.py`. Le code du pipeline consulte `get_settings()` au moment de l'execution ; une sauvegarde invalide les caches de modeles (backends TTS, chargement Gemma) pour que les nouveaux identifiants/parametres prennent effet sans redemarrage. """ from __future__ import annotations import threading from typing import Optional from pydantic import BaseModel, Field from . import config # --- Prompts systeme par defaut (source canonique) --------------------------- # Ces chaines pilotent les trois taches Gemma. L'utilisateur peut les editer. DEFAULT_PROMPT_SPEAKERS = ( "Tu es un assistant d'analyse litteraire. Tu identifies QUI prononce chaque " "replique de dialogue dans un extrait de roman en francais. Une liste des " "personnages du chapitre t'est fournie : choisis le locuteur dans cette " "liste en recopiant son nom EXACTEMENT. Appuie-toi sur la narration qui " "PRECEDE et qui SUIT chaque replique (incise d'attribution type 'dit " "Marie'), sur les vocatifs (le personnage a qui l'on s'adresse) et sur " "l'alternance des tours de parole. Mets 'inconnu' si tu n'es pas sur. Tu " "reponds UNIQUEMENT en JSON valide, sans texte autour." ) DEFAULT_PROMPT_SPEAKERS_REFINE = ( "Tu es un assistant d'analyse litteraire. On te donne des repliques dont le " "locuteur est reste indetermine, avec le locuteur DEJA identifie des " "repliques voisines. Deduis qui parle en exploitant l'alternance des tours " "de parole et le contexte narratif autour. Choisis le nom dans la liste des " "personnages fournie, en le recopiant exactement, ou 'inconnu' si vraiment " "indeterminable. Tu reponds UNIQUEMENT en JSON valide, sans texte autour." ) DEFAULT_PROMPT_CHARACTERS = ( "Tu es un assistant d'analyse litteraire. Tu extrais la liste des " "personnages d'un extrait de roman et leurs attributs vocaux. Tu reponds " "UNIQUEMENT en JSON valide." ) DEFAULT_PROMPT_PRONUNCIATION = ( "Tu es un assistant de preparation de livre audio en francais. Tu reperes " "les mots dont la prononciation par un synthetiseur vocal francais risque " "d'etre incorrecte (noms propres etrangers, termes de science-fiction, " "acronymes). Tu reponds UNIQUEMENT en JSON valide." ) DEFAULT_PROMPT_INCISES = ( "Tu es un assistant d'analyse litteraire. Tu reperes les INCISES de " "narration inserees dans une replique de dialogue (ex: 'dit Mamie', " "'repondit le capitaine'). Tu reponds UNIQUEMENT en JSON valide, sans " "texte autour." ) DEFAULT_PROMPT_DEDUP = ( "Tu es un assistant d'analyse litteraire. Tu rapproches les differentes " "facons de nommer un meme personnage (nom complet, prenom, surnom, " "diminutif) pour eviter les doublons dans le casting d'un livre audio. Tu " "ne fusionnes deux noms que si c'est, avec certitude, la meme personne. Tu " "reponds UNIQUEMENT en JSON valide, sans texte autour." ) class Settings(BaseModel): """Reglages techniques globaux, persistes dans data/settings.json.""" # --- Modeles MLX (identifiants HuggingFace) --- gemma_model: str = config.GEMMA_MODEL qwen3_model: str = config.QWEN3_TTS_MODEL kokoro_model: str = config.KOKORO_MODEL # --- Generation Gemma --- gemma_temperature: float = Field(0.1, ge=0.0, le=2.0) gemma_max_tokens: int = Field(2048, ge=64, le=16384) # Modeles a raisonnement (Gemma 4, DeepSeek-R1, Qwen-think...) : ils emettent # une chaine de pensee avant la reponse. Active le retrait de cette pensee # (canaux <|channel>thought.../, balises ...) AVANT # le parsing JSON, et releve le plafond de tokens (la pensee en consomme). gemma_reasoning: bool = False # Plafond de tokens en mode raisonnement (la pensee en consomme beaucoup). # La generation s'arrete de toute facon des que la reponse JSON post-pensee # est complete ; ce plafond est un garde-fou contre les boucles de pensee # sans fin (certains modeles tournent en rond a temperature 0). gemma_reasoning_max_tokens: int = Field(4096, ge=256, le=16384) # Temperature en mode raisonnement. Le decodage GLOUTON (temp 0) fait boucler # les modeles a raisonnement (repetitions sans fin) ; Qwen & co recommandent # un echantillonnage. Si la temperature effective est 0, on bascule sur # celle-ci. Rend le benchmark non deterministe en mode raisonnement (inevitable). gemma_reasoning_temperature: float = Field(0.6, ge=0.0, le=2.0) # --- Prompts systeme (analyse) --- prompt_speakers: str = DEFAULT_PROMPT_SPEAKERS prompt_speakers_refine: str = DEFAULT_PROMPT_SPEAKERS_REFINE prompt_characters: str = DEFAULT_PROMPT_CHARACTERS prompt_pronunciation: str = DEFAULT_PROMPT_PRONUNCIATION prompt_incises: str = DEFAULT_PROMPT_INCISES # DEPRECIE (detection deterministe) prompt_dedup: str = DEFAULT_PROMPT_DEDUP # --- Incises --- # DEPRECIE : la detection d'incises est desormais deterministe et conscience # du casting (analysis.segmenter.detect_incises), sans fallback Gemma. Champ # conserve pour charger les settings.json existants sans erreur. split_incises_use_gemma: bool = True # --- Attribution retroactive (2e passe sur les repliques indeterminees) --- # Apres la 1re passe, une 2e passe ciblee re-resout les repliques restees # 'inconnu' (ou peu sures) en s'appuyant sur les voisins deja identifies. # Declenchee seulement s'il reste des doutes -> cout nul sinon. retro_pass_use_gemma: bool = True # --- Deduplication du casting --- # Heuristique (sure, deterministe) par defaut. La passe Gemma rattache en # plus les variantes non evidentes (diminutifs, titres) mais, avec un petit # modele local, produit des fusions erronees -> opt-in. dedup_use_gemma: bool = False # --- TTS --- default_backend: str = "kokoro" language: str = config.DEFAULT_LANGUAGE kokoro_lang_code: str = config.KOKORO_LANG_CODE kokoro_default_voice: str = config.KOKORO_DEFAULT_VOICE qwen3_default_voice: str = config.QWEN3_DEFAULT_VOICE # --- Audio (encodage final) --- target_sample_rate: int = Field(config.TARGET_SAMPLE_RATE, ge=8000, le=48000) mp3_bitrate: str = config.MP3_BITRATE target_dbfs: float = Field(config.TARGET_DBFS, ge=-40.0, le=0.0) _LOCK = threading.Lock() _cache: Optional[Settings] = None def settings_path(): return config.DATA_DIR / "settings.json" def get_settings() -> Settings: """Renvoie les reglages courants (charges depuis le disque une seule fois).""" global _cache with _LOCK: if _cache is None: path = settings_path() if path.exists(): try: _cache = Settings.model_validate_json( path.read_text(encoding="utf-8")) except Exception: # noqa: BLE001 — fichier corrompu -> defauts _cache = Settings() else: _cache = Settings() return _cache def save_settings(settings: Settings) -> Settings: """Persiste les reglages et invalide les caches de modeles.""" global _cache with _LOCK: _cache = settings path = settings_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(settings.model_dump_json(indent=2), encoding="utf-8") _invalidate_model_caches() return settings def _invalidate_model_caches() -> None: """Force le rechargement des modeles apres un changement d'identifiant/param. `get_backend` est cache par *nom* de backend, pas par id de modele ; sans purge, un changement d'id serait ignore. Idem pour le chargement Gemma. """ try: from .tts.factory import get_backend get_backend.cache_clear() except Exception: # noqa: BLE001 pass try: from .analysis.gemma import _load _load.cache_clear() except Exception: # noqa: BLE001 pass