Skip to content

actualite_vocale

actualite_vocale

Les gardes d'actualité du chat, portées à la voix (21 septembre 2026).

Le 20 et le 21 septembre, le chat a reçu ses gardes : consigne au tour courant pour une question d'actualité, fraîcheur imposée à web_search, lecture automatique de la page du poste pour un titulaire, note SYSTEM avant la rédaction, désaccord de titulaire signalé. La voix n'en avait aucune — « Qui est le premier ministre du Canada ? » dit au micro recevait « Justin Trudeau » sans qu'une recherche parte, et rien ne le disait.

La voix ne peut pas RETENIR une réponse comme le chat : les phrases sont synthétisées à mesure qu'elles arrivent, et le silence d'une retenue serait le pire des signaux. Ce que la voix peut faire : poser la consigne (au chat, elle a suffi au premier passage), imposer la fraîcheur, lire la page du poste, prévenir le modèle avant qu'il rédige, relancer UNE fois quand le premier passage a répondu sans chercher (la parole est sortie ; la livraison suit, comme pour une promesse sans acte) — et, quand rien n'a été vérifié ou que la réponse contredit les sources, LE DIRE à voix haute en fin de tour (§100 : jamais de faux succès, même prononcé).

Les messages sont ici des dicts ({"role", "content"}), la forme de la boucle vocale ; server/actualite.py travaille sur des Message. Les fonctions de ce module font le pont, rien de plus : la logique reste dans actualite.py, une seule fois.

Classes

TourVocal dataclass

TourVocal(
    question: str,
    demande: bool = False,
    ville: str = "",
    officielle_lue: bool = False,
    relance_lecture_faite: bool = False,
    recherche_tentee: bool = False,
    recherche_refusee: bool = False,
    verification_faite: bool = False,
    outil_local_ok: bool = False,
    relance_faite: bool = False,
    lecture_faite: bool = False,
    sources: list[dict[str, Any]] = list(),
    pages_lues: set[str] = set(),
    corpus: str = "",
)

Ce qu'un tour d'actualité accumule, du micro au dernier mot.

Functions:

est_une_demande_vocale

est_une_demande_vocale(texte: str) -> bool

« Vérifie ça » et ses formes ; à la voix, aussi « c'est vrai » et « vraiment » sans ponctuation, quand c'est tout l'énoncé.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def est_une_demande_vocale(texte: str) -> bool:
    """« Vérifie ça » et ses formes ; à la voix, aussi « c'est vrai » et
    « vraiment » sans ponctuation, quand c'est tout l'énoncé."""
    return (
        est_une_demande_de_verification(texte)
        or _plat(texte) in _DEMANDE_SANS_PONCTUATION
    )

preparer_tour

preparer_tour(
    texte: str,
    historique: Sequence[dict[str, Any]],
    ville: str = "",
) -> TourVocal | None

Le tour est-il une question d'actualité, ou une demande de vérifier ce qui vient d'être dit ? None quand la voix n'a rien à garder.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def preparer_tour(
    texte: str, historique: Sequence[dict[str, Any]], ville: str = ""
) -> TourVocal | None:
    """Le tour est-il une question d'actualité, ou une demande de vérifier
    ce qui vient d'être dit ? None quand la voix n'a rien à garder."""
    if est_une_demande_vocale(texte):
        question = question_a_verifier(_en_messages(historique, texte + " ?"))
        if (
            est_une_demande_vocale(question)
            or len(question.strip()) < _QUESTION_MIN
            or question_personnelle(question)
        ):
            return None
        return TourVocal(question=question, demande=True, ville=ville)
    if question_d_actualite(texte):
        return TourVocal(question=texte, ville=ville)
    return None

consigne

consigne(tour: TourVocal) -> dict[str, str]

La consigne au tour courant, après la demande — là où le 9b obéit.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def consigne(tour: TourVocal) -> dict[str, str]:
    """La consigne au tour courant, après la demande — là où le 9b obéit."""
    if tour.demande:
        return {
            "role": "system",
            "content": CONSIGNE_DEMANDEE_VOCALE.format(question=tour.question.strip()),
        }
    return {"role": "system", "content": CONSIGNE_VOCALE}

relance

relance(
    tour: TourVocal, parle: str
) -> list[dict[str, str]]

Le premier passage a parlé sans chercher : ce qui a été dit reste dans le fil (c'est sorti des haut-parleurs), et une sommation suit.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def relance(tour: TourVocal, parle: str) -> list[dict[str, str]]:
    """Le premier passage a parlé sans chercher : ce qui a été dit reste dans
    le fil (c'est sorti des haut-parleurs), et une sommation suit."""
    tour.relance_faite = True
    return [
        {"role": "assistant", "content": parle.strip()},
        {"role": "system", "content": CONSIGNE_FERME_VOCALE},
    ]

relance_possible

relance_possible(tour: TourVocal, parle: str) -> bool

La passe prononcée avoue n'avoir pas trouvé, après une vérification, et aucune relance n'a encore eu lieu.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def relance_possible(tour: TourVocal, parle: str) -> bool:
    """La passe prononcée avoue n'avoir pas trouvé, après une vérification,
    et aucune relance n'a encore eu lieu."""
    return (
        not tour.relance_lecture_faite
        and tour.verification_faite
        and est_une_non_reponse(parle, orale=True, question=tour.question)
    )

lecture_possible

lecture_possible(tour: TourVocal) -> bool

Une source s'impose à lire — ce qui vaut un « Je lis la source. » AVANT le silence de la lecture, et seulement alors : l'accusé était prononcé sans source à lire, puis plus rien (revue du 21/09, 22 h).

Source code in src/diapason/speech/realtime/actualite_vocale.py
def lecture_possible(tour: TourVocal) -> bool:
    """Une source s'impose à lire — ce qui vaut un « Je lis la source. »
    AVANT le silence de la lecture, et seulement alors : l'accusé était
    prononcé sans source à lire, puis plus rien (revue du 21/09, 22 h)."""
    return bool(
        sources_prometteuses(tour.sources, tour.question, sorted(tour.pages_lues))
    )

relance_apres_non_reponse

relance_apres_non_reponse(
    tour: TourVocal,
    parle: str,
    lire: Lecteur | None,
    peut_chercher: bool | Callable[[], bool] = True,
) -> list[dict[str, str]] | None

La réponse prononcée avoue n'avoir pas trouvé : le code lit la source la plus prometteuse (deux essais) et le modèle reprend d'après elle — une fois. Sans source qui s'impose, ou pages illisibles : une autre requête, si le budget le permet encore. None quand il n'y a rien à reprendre (banc du 21/09, Coupe Stanley : nhl.com était dans les résultats, le 9b n'y allait pas). parle est la DERNIÈRE passe, pas tout le tour : un aveu prononcé avant la recherche ne fait pas relire une page après la bonne réponse (revue du 21/09). peut_chercher se demande APRÈS les lectures, qui consomment le budget : un booléen pris avant promettait un web_search que le tour ne pouvait plus payer (revue du 21/09, 22 h).

Source code in src/diapason/speech/realtime/actualite_vocale.py
def relance_apres_non_reponse(
    tour: TourVocal,
    parle: str,
    lire: Lecteur | None,
    peut_chercher: bool | Callable[[], bool] = True,
) -> list[dict[str, str]] | None:
    """La réponse prononcée avoue n'avoir pas trouvé : le code lit la source
    la plus prometteuse (deux essais) et le modèle reprend d'après elle —
    une fois. Sans source qui s'impose, ou pages illisibles : une autre
    requête, si le budget le permet encore. None quand il n'y a rien à
    reprendre (banc du 21/09, Coupe Stanley : nhl.com était dans les
    résultats, le 9b n'y allait pas). ``parle`` est la DERNIÈRE passe, pas
    tout le tour : un aveu prononcé avant la recherche ne fait pas relire
    une page après la bonne réponse (revue du 21/09). ``peut_chercher`` se
    demande APRÈS les lectures, qui consomment le budget : un booléen pris
    avant promettait un web_search que le tour ne pouvait plus payer (revue
    du 21/09, 22 h)."""
    if not relance_possible(tour, parle):
        return None
    tour.relance_lecture_faite = True
    messages: list[dict[str, str]] = [{"role": "assistant", "content": parle.strip()}]
    if lire is not None:
        from diapason.tools.web_search import url_canonique

        candidates = sources_prometteuses(
            tour.sources, tour.question, sorted(tour.pages_lues)
        )
        for src in candidates[:LECTURES_SUR_NON_REPONSE]:
            url = str(src["url"])
            page = lire("web_read", {"url": url, "focus": tour.question})
            if not page.get("ok") or not str(page.get("content") or "").strip():
                if "budget" in str(page.get("error") or "").lower():
                    return None
                continue
            tour.pages_lues.add(url_canonique(url))
            pmeta = (
                page.get("metadata") if isinstance(page.get("metadata"), dict) else {}
            )
            texte, nouvelles = renumeroter(
                str(page["content"]),
                sous_l_url_demandee(list(pmeta.get("sources") or []), url),
                tour.sources,
            )
            tour.sources.extend(nouvelles)
            tour.corpus += "\n" + texte
            messages.append(
                {
                    "role": "system",
                    "content": _tronquer(texte)
                    + "\n\n"
                    + CONSIGNE_PAGE_LUE_VOCALE.format(
                        titre=str(src.get("title") or url)[:80]
                    ),
                }
            )
            return messages
    if not (peut_chercher() if callable(peut_chercher) else peut_chercher):
        return None
    messages.append({"role": "system", "content": CONSIGNE_AUTRE_REQUETE_VOCALE})
    return messages

preparer_appel

preparer_appel(
    tour: TourVocal, nom: str, arguments: dict[str, Any]
) -> dict[str, Any]

Fraîcheur et vertical décidés par le code, pas par le modèle.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def preparer_appel(
    tour: TourVocal, nom: str, arguments: dict[str, Any]
) -> dict[str, Any]:
    """Fraîcheur et vertical décidés par le code, pas par le modèle."""
    if nom != "web_search":
        return arguments
    try:
        complets = json.loads(
            completer_arguments(
                json.dumps(arguments, ensure_ascii=False), tour.question
            )
        )
    except (ValueError, TypeError):
        return arguments
    return complets if isinstance(complets, dict) else arguments

deja_lue

deja_lue(
    tour: TourVocal, nom: str, arguments: dict[str, Any]
) -> dict[str, Any] | None

Le modèle redemande la page que le code vient de lire : la réponse, sans réseau (revue du 21/09 : deux téléchargements, le même texte deux fois dans le fil).

Source code in src/diapason/speech/realtime/actualite_vocale.py
def deja_lue(
    tour: TourVocal, nom: str, arguments: dict[str, Any]
) -> dict[str, Any] | None:
    """Le modèle redemande la page que le code vient de lire : la réponse,
    sans réseau (revue du 21/09 : deux téléchargements, le même texte deux
    fois dans le fil)."""
    if nom != "web_read":
        return None
    from diapason.tools.web_search import url_canonique

    url = str(arguments.get("url") or "")
    if url and url_canonique(url) in tour.pages_lues:
        ref = next(
            (
                s["ref"]
                for s in tour.sources
                if url_canonique(str(s.get("url") or "")) == url_canonique(url)
            ),
            None,
        )
        ou = f" sous le numéro {ref}" if ref else ""
        return {"ok": True, "content": f"Cette page est déjà lue ci-dessus{ou}."}
    return None

absorber_resultat

absorber_resultat(
    tour: TourVocal,
    nom: str,
    arguments: dict[str, Any],
    resultat: dict[str, Any],
    lire: Lecteur | None = None,
) -> dict[str, Any]

Après un outil : les sources numérotées rejoignent le tour, et pour un titulaire la page du poste est lue et jointe au résultat — exactement ce que fait agentic_stream pour le chat. Rend ce que le MODÈLE lit.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def absorber_resultat(
    tour: TourVocal,
    nom: str,
    arguments: dict[str, Any],
    resultat: dict[str, Any],
    lire: Lecteur | None = None,
) -> dict[str, Any]:
    """Après un outil : les sources numérotées rejoignent le tour, et pour un
    titulaire la page du poste est lue et jointe au résultat — exactement
    ce que fait agentic_stream pour le chat. Rend ce que le MODÈLE lit."""
    contenu = str(resultat.get("content") or "")
    ok = bool(resultat.get("ok"))
    meta = (
        resultat.get("metadata") if isinstance(resultat.get("metadata"), dict) else {}
    )
    if nom == "web_search":
        tour.recherche_tentee = True
        if not ok and not recherche_concluante(nom, ok, contenu):
            tour.recherche_refusee = (
                tour.recherche_refusee
                or not contenu.strip()
                or bool(resultat.get("error"))
            )
        if not recherche_concluante(nom, ok, contenu):
            # P6 : la page officielle se lit même quand la recherche est vide.
            return _pour_le_modele(_avec_page_officielle(tour, resultat, lire))
    elif nom == "web_read":
        if not ok:
            return _pour_le_modele(resultat)
    else:
        if ok:
            tour.outil_local_ok = True
        return resultat
    from diapason.tools.web_search import url_canonique

    tour.verification_faite = True
    sources = meta.get("sources") or []
    if nom == "web_read":
        url = str(arguments.get("url") or "")
        sources = sous_l_url_demandee(list(sources), url)
        if url:
            tour.pages_lues.add(url_canonique(url))
    contenu, nouvelles = renumeroter(contenu, sources, tour.sources)
    tour.sources.extend(nouvelles)
    tour.corpus += "\n" + contenu
    contenu = _tronquer(contenu)
    if (
        nom == "web_search"
        and lire is not None
        and not tour.lecture_faite
        and question_de_titulaire(tour.question)
    ):
        url = page_de_reference(tour.sources, tour.question)
        if url:
            tour.lecture_faite = True
            page = lire("web_read", {"url": url, "focus": tour.question})
            if page.get("ok") and str(page.get("content") or "").strip():
                pmeta = (
                    page.get("metadata")
                    if isinstance(page.get("metadata"), dict)
                    else {}
                )
                texte, nv = renumeroter(
                    str(page["content"]),
                    sous_l_url_demandee(list(pmeta.get("sources") or []), url),
                    tour.sources,
                )
                tour.sources.extend(nv)
                tour.corpus += "\n" + texte
                tour.pages_lues.add(url_canonique(url))
                contenu += "\n\nPage lue (web_read) :\n" + _tronquer(texte)
            else:
                raison = str(page.get("error") or page.get("content") or "")[:200]
                contenu += f"\n\nPage du poste non lue ({url}) : {raison}"
    resultat = {**resultat, "content": contenu}
    if nom == "web_search":
        resultat = _avec_page_officielle(tour, resultat, lire)
    return _pour_le_modele(resultat)

note

note(tour: TourVocal) -> dict[str, str] | None

Ce que le code sait des sources, dit au modèle avant qu'il rédige.

Source code in src/diapason/speech/realtime/actualite_vocale.py
def note(tour: TourVocal) -> dict[str, str] | None:
    """Ce que le code sait des sources, dit au modèle avant qu'il rédige."""
    if not tour.corpus.strip():
        return None
    texte, _donnees = note_avant_redaction(tour.sources, tour.corpus, tour.question)
    return {"role": "system", "content": texte} if texte else None

epilogue

epilogue(
    tour: TourVocal,
    reponse: str,
    derniere_passe: str | None = None,
) -> str

La phrase à prononcer après la réponse, ou "" : de mémoire, ou en désaccord avec les sources sur le titulaire.

Revue vocale du 21/09 : « je le dis de mémoire » était prononcé après une réponse vide, après une question de précision, après un aveu déjà dit, et après un fait lu à l'horloge locale. Chacun de ces cas se tait ; une réponse vide reçoit l'aveu qui se tient seul. Le désaccord se juge sur la DERNIÈRE passe : « De mémoire, c'est Justin Trudeau » dit avant la recherche taisait l'avertissement sur « Justin Trudeau, depuis 2015 » dit après elle, les sources désignant Mark Carney (revue du 21/09, 22 h).

Source code in src/diapason/speech/realtime/actualite_vocale.py
def epilogue(tour: TourVocal, reponse: str, derniere_passe: str | None = None) -> str:
    """La phrase à prononcer après la réponse, ou "" : de mémoire, ou en
    désaccord avec les sources sur le titulaire.

    Revue vocale du 21/09 : « je le dis de mémoire » était prononcé après une
    réponse vide, après une question de précision, après un aveu déjà dit,
    et après un fait lu à l'horloge locale. Chacun de ces cas se tait ; une
    réponse vide reçoit l'aveu qui se tient seul. Le désaccord se juge sur
    la DERNIÈRE passe : « De mémoire, c'est Justin Trudeau » dit avant la
    recherche taisait l'avertissement sur « Justin Trudeau, depuis 2015 »
    dit après elle, les sources désignant Mark Carney (revue du 21/09, 22 h).
    """
    if tour.verification_faite:
        desaccord = desaccord_sur_le_titulaire(
            derniere_passe if derniere_passe is not None else reponse,
            tour.corpus,
            tour.question,
        )
        if desaccord:
            return (
                f"Attention : les sources désignent "
                f"{', '.join(desaccord['sources'])} comme titulaire, pas "
                f"{desaccord['answer']}."
            )
        return ""
    if _DEJA_AVOUE.search(_plat(reponse)):
        return ""
    if not tour.verification_faite:
        if tour.outil_local_ok and not tour.recherche_tentee:
            return ""
        if not reponse.strip():
            return AVEU
        if not _affirme_quelque_chose(reponse):
            return ""
        if tour.recherche_refusee:
            return AVEU_VOCAL_REFUS
        return AVEU_VOCAL_RECHERCHE if tour.recherche_tentee else AVEU_VOCAL
    return ""