Skip to content

llm_polish

llm_polish

Optional one-shot LLM polish for dictation (Diapason-style, opt-in).

Functions:

llm_polish_text

llm_polish_text(
    text: str,
    *,
    email_mode: bool = False,
    timeout_ms: int = 2000,
    model: str = "",
    engine: Any = None,
) -> Optional[str]

Polish dictation via the configured engine. Returns None on skip/failure.

Source code in src/diapason/speech/llm_polish.py
def llm_polish_text(
    text: str,
    *,
    email_mode: bool = False,
    timeout_ms: int = 2000,
    model: str = "",
    engine: Any = None,
) -> Optional[str]:
    """Polish dictation via the configured engine. Returns None on skip/failure."""
    raw = (text or "").strip()
    if not raw:
        return None
    if len(raw.split()) < 3:
        return None
    # Une LONGUE phrase ne se polit pas au modèle : il la RÉGÉNÈRE mot à mot,
    # et la mesure est sans appel — cinquante-huit mots prennent 4,0 s modèle
    # CHAUD, c'est-à-dire tout le budget avant même un aléa. Tenter, c'est
    # garantir l'expiration : quatre secondes brûlées pour coller le brut
    # qu'on aurait pu coller tout de suite. Le dictionnaire personnel et le
    # polissage mécanique s'appliquent toujours, eux.
    if len(raw.split()) > 40:
        logger.debug(
            "llm polish skipped: %d words, cannot fit the budget", len(raw.split())
        )
        return None

    system = _DICTATION_SYSTEM
    if email_mode:
        system = system + "\n" + _EMAIL_EXTRA

    try:
        from diapason.core.config import load_config
        from diapason.core.types import Message, Role

        cfg = load_config()
        resolved_model = (model or cfg.intelligence.default_model or "").strip()
        if not resolved_model:
            logger.debug("llm polish skipped: no default model")
            return None

        eng = engine
        if eng is None:
            from diapason.engine._discovery import get_engine

            key = (cfg.engine.default or "").strip() or None
            eng = get_engine(cfg, key)
            # get_engine rend (nom, moteur). Prendre le tuple entier donnait
            # un objet sans engine_id ni is_cloud, que le garde local-only
            # classait « distant » — sa règle « un moteur inconnu n'est pas
            # local » est juste, elle refusait donc TOUJOURS. Le polissage
            # par modèle était ainsi mort par une erreur de dépaquetage, et
            # le refus, lui, était parfaitement expliqué dans le journal.
            eng = _unwrap_engine(eng)
        if eng is None:
            return None

        # Local-only mode covers the WHOLE dictation path, not just the
        # transcription. This function received whatever engine the config
        # named and sent the dictated sentence to it without ever asking
        # whether it ran on this machine — so a user dictating with a local
        # Whisper still had every phrase polished in the cloud.
        #
        # There is no cloud-free way to polish with a remote engine, so in
        # local-only mode the answer is "no polish" — never "polish
        # elsewhere". Returning None makes the caller keep the raw text,
        # which is exactly the degradation the user asked for.
        from diapason.core.local_mode import engine_is_local, local_only

        if local_only(cfg) and not engine_is_local(eng):
            logger.info(
                "llm polish skipped: engine %r is remote and local-only mode is on — "
                "raw text kept, nothing was sent",
                getattr(eng, "engine_id", "?"),
            )
            return None

        messages = [
            Message(role=Role.SYSTEM, content=system),
            Message(
                role=Role.USER,
                content=f"===SPEECH===\n{raw}\n===END===",
            ),
        ]
        timeout_s = max(0.3, float(timeout_ms) / 1000.0)

        def _call() -> str:
            result = eng.generate(
                messages,
                model=resolved_model,
                temperature=0.1,
                max_tokens=min(512, max(64, len(raw.split()) * 4)),
            )
            content = ""
            if isinstance(result, dict):
                content = str(result.get("content") or "")
            return _strip_model_noise(content)

        # PAS de « with » : sa sortie attend la fin du travail même après
        # l'expiration. Mesuré — délai demandé 1 s, durée réelle 8,2 s sur un
        # moteur lent : le délai était factice, et une dictée derrière un
        # créneau Ollama occupé restait suspendue jusqu'au bout de la file.
        # À l'expiration on ABANDONNE le fil (il mourra seul en fin de
        # génération, sans rien retenir) et le brut se colle à l'heure dite.
        pool = ThreadPoolExecutor(max_workers=1)
        fut = pool.submit(_call)
        try:
            out = fut.result(timeout=timeout_s)
        except FuturesTimeout:
            logger.info("llm polish timed out after %sms", timeout_ms)
            return None
        finally:
            pool.shutdown(wait=False, cancel_futures=True)

        if not out or len(out) > max(40, len(raw) * 3):
            return None
        if not _langue_conservee(raw, out):
            logger.info("llm polish switched language, raw text kept")
            return None
        return out
    except Exception:
        logger.debug("llm polish failed", exc_info=True)
        return None