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