Skip to content

actualite

actualite

Une question d'actualité se vérifie sur le web avant d'être répondue.

20 septembre 2026, 22:30, dans le mini-panneau : « Qui est le président actuel du Canada ? » → « Le président actuel du Canada est Justin Trudeau, 23e Premier ministre, en poste depuis 2015. » En 5,1 s, sans un seul appel d'outil. Deux erreurs dans une phrase : le Canada n'a pas de président, et le nom vient de l'entraînement du modèle, pas de septembre 2026. Le 9b avait web_search sous la main et la règle « web_search — ce qui est récent » dans son identité ; il a répondu de mémoire parce qu'il CROYAIT savoir.

Un petit modèle ne devine pas qu'un fait a pu changer : il faut le lui dire au tour même, et vérifier qu'il l'a fait. Trois pièces :

  • question_d_actualite reconnaît une question dont la réponse dépend du moment (fonction en cours, prix, résultat, sortie, météo, « actuel », « aujourd'hui », une année récente) et qui ne porte pas sur les données personnelles de Carlito — celles-là ont leurs propres outils.
  • consigne_actualite se pose au tour courant, comme le rappel des questions interactives : c'est là que le 9b obéit (constat du 19/09).
  • CONSIGNE_FERME sert une seule relance quand le premier passage n'a quand même appelé aucun outil ; si la relance échoue aussi, la réponse part avec un avertissement en tête plutôt que comme une vérité.

Classes

Functions:

question_d_actualite

question_d_actualite(texte: str) -> bool

Vrai si la réponse dépend du moment et ne concerne pas ses données.

Une question de fait (« qui est », « quel est le prix ») sur un titulaire, une valeur ou avec un marqueur de temps ; ou un marqueur ET un sujet sans forme interrogative (« la météo à Ottawa aujourd'hui »).

Source code in src/diapason/server/actualite.py
def question_d_actualite(texte: str) -> bool:
    """Vrai si la réponse dépend du moment et ne concerne pas ses données.

    Une question de fait (« qui est », « quel est le prix ») sur un titulaire,
    une valeur ou avec un marqueur de temps ; ou un marqueur ET un sujet sans
    forme interrogative (« la météo à Ottawa aujourd'hui »).
    """
    plat = _normaliser(texte).replace("’", "'")
    if not plat or _PERSONNEL.search(plat) or _INTEMPOREL.search(plat):
        return False
    if _HORLOGE.search(plat):
        return False
    if _FAIT_SUFFISANT.search(plat):
        return True
    marqueur = bool(_MARQUEURS.search(plat))
    sujet = bool(_SUJETS.search(plat))
    if _QUESTION_DE_FAIT.search(plat):
        return marqueur or sujet
    return marqueur and sujet

question_personnelle

question_personnelle(texte: str) -> bool

Les données de Carlito ne se cherchent pas sur le web.

Source code in src/diapason/server/actualite.py
def question_personnelle(texte: str) -> bool:
    """Les données de Carlito ne se cherchent pas sur le web."""
    plat = _plat(texte)
    return _PERSONNEL.search(plat) is not None or _IDENTITE.search(plat) is not None

est_une_demande_de_verification

est_une_demande_de_verification(texte: str) -> bool

« Vérifie ça », « c'est vrai ? », « check this online » — une demande courte qui porte sur ce qui vient d'être dit.

Source code in src/diapason/server/actualite.py
def est_une_demande_de_verification(texte: str) -> bool:
    """« Vérifie ça », « c'est vrai ? », « check this online » — une demande
    courte qui porte sur ce qui vient d'être dit."""
    plat = _plat(texte)
    return (
        bool(plat)
        and len(plat) <= _DEMANDE_MAX
        and (_DEMANDE_DE_VERIFICATION.search(plat) is not None)
    )

question_a_verifier

question_a_verifier(
    messages: Sequence[Message], forcee: bool = False
) -> str

Ce qu'il faut chercher : la question qui précède la demande, en sautant TOUTES les demandes de vérification consécutives (revue du 21/09 : une seconde pression vérifiait « Vérifie ça en ligne. »). Avec le bouton (forcee), le dernier message EST la demande, quel que soit son libellé — traduit ou reformulé. Sans question avant : le message lui-même s'il n'est pas une demande nue, sinon "" (rien à vérifier).

Source code in src/diapason/server/actualite.py
def question_a_verifier(messages: Sequence[Message], forcee: bool = False) -> str:
    """Ce qu'il faut chercher : la question qui précède la demande, en
    sautant TOUTES les demandes de vérification consécutives (revue du
    21/09 : une seconde pression vérifiait « Vérifie ça en ligne. »). Avec
    le bouton (``forcee``), le dernier message EST la demande, quel que soit
    son libellé — traduit ou reformulé. Sans question avant : le message
    lui-même s'il n'est pas une demande nue, sinon "" (rien à vérifier)."""
    utilisateur = [m.content or "" for m in messages if m.role == Role.USER]
    if not utilisateur:
        return ""
    if not forcee and not est_une_demande_de_verification(utilisateur[-1]):
        return utilisateur[-1]
    for texte in reversed(utilisateur[:-1]):
        if not est_une_demande_de_verification(texte):
            return texte
    # Rien avant : une affirmation envoyée seule avec le bouton se vérifie
    # elle-même ; une demande nue (« Vérifie ça ») n'a rien à vérifier.
    return "" if est_une_demande_de_verification(utilisateur[-1]) else utilisateur[-1]

recherche_concluante

recherche_concluante(
    nom: str, succes: bool, contenu: str
) -> bool

Un web_search qui a rendu quelque chose : la seule vérification qui compte.

Source code in src/diapason/server/actualite.py
def recherche_concluante(nom: str, succes: bool, contenu: str) -> bool:
    """Un web_search qui a rendu quelque chose : la seule vérification qui compte."""
    if nom != "web_search" or not succes:
        return False
    return bool(contenu.strip()) and not _RECHERCHE_VIDE.search(contenu)

consigne_actualite

consigne_actualite(
    messages: Sequence[Message], texte: str = CONSIGNE
) -> list[Message]

La consigne au tour courant, après la demande, comme le rappel des questions.

Source code in src/diapason/server/actualite.py
def consigne_actualite(
    messages: Sequence[Message], texte: str = CONSIGNE
) -> list[Message]:
    """La consigne au tour courant, après la demande, comme le rappel des questions."""
    return [*messages, Message(role=Role.SYSTEM, content=texte)]

parametres_de_recherche

parametres_de_recherche(question: str) -> dict[str, Any]

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

Une semaine pour ce qui se périme en jours, un an pour un titulaire ou une version ; les journaux d'abord pour ce qui fait l'actualité. Le modèle peut préciser davantage, jamais relâcher : ces clés ne sont posées que si l'appel ne les porte pas déjà.

Source code in src/diapason/server/actualite.py
def parametres_de_recherche(question: str) -> dict[str, Any]:
    """Fraîcheur et vertical décidés par le code, pas par le modèle.

    Une semaine pour ce qui se périme en jours, un an pour un titulaire ou
    une version ; les journaux d'abord pour ce qui fait l'actualité. Le
    modèle peut préciser davantage, jamais relâcher : ces clés ne sont posées
    que si l'appel ne les porte pas déjà.
    """
    plat = _normaliser(question).replace("’", "'")
    parametres: dict[str, Any] = {
        "recency": "week" if _TRES_FRAIS.search(plat) else "year"
    }
    if _JOURNAUX.search(plat):
        parametres["news"] = True
    return parametres

completer_arguments

completer_arguments(arguments: str, question: str) -> str

Pose recency/news sur un appel web_search ; le modèle précise, jamais ne relâche.

Revue du 20/09 : recency: "recent" (invalide) partait sans fraîcheur, month remplaçait la semaine décidée par le code, news: false éteignait le vertical. Le plus strict des deux gagne ; l'invalide vaut absent.

Source code in src/diapason/server/actualite.py
def completer_arguments(arguments: str, question: str) -> str:
    """Pose recency/news sur un appel web_search ; le modèle précise, jamais ne relâche.

    Revue du 20/09 : `recency: "recent"` (invalide) partait sans fraîcheur,
    `month` remplaçait la semaine décidée par le code, `news: false` éteignait
    le vertical. Le plus strict des deux gagne ; l'invalide vaut absent.
    """
    try:
        donnees = json.loads(arguments) if arguments else {}
    except (ValueError, TypeError):
        return arguments
    if not isinstance(donnees, dict):
        return arguments
    voulu = parametres_de_recherche(question)
    du_modele = str(donnees.get("recency") or "").lower()
    if du_modele not in _FRAICHEURS_ORDONNEES:
        donnees["recency"] = voulu["recency"]
    else:
        donnees["recency"] = min(
            du_modele, voulu["recency"], key=_FRAICHEURS_ORDONNEES.index
        )
    if voulu.get("news"):
        donnees["news"] = True
    return json.dumps(donnees, ensure_ascii=False)

elements_hors_sources

elements_hors_sources(
    reponse: str, sources: str, question: str = ""
) -> list[str]

Les affirmations datées ou nommées de la réponse absentes des sources.

Les mots de la question ne comptent pas (ils viennent de Carlito), ni l'année du jour (le contexte MAINTENANT la donne au modèle).

Source code in src/diapason/server/actualite.py
def elements_hors_sources(reponse: str, sources: str, question: str = "") -> list[str]:
    """Les affirmations datées ou nommées de la réponse absentes des sources.

    Les mots de la question ne comptent pas (ils viennent de Carlito), ni
    l'année du jour (le contexte MAINTENANT la donne au modèle).
    """
    if not reponse.strip() or not sources.strip():
        return []
    from datetime import date

    corpus = _normaliser(sources) + "\n" + _normaliser(question)
    corpus_compact = _compacter(sources) + "\n" + _compacter(question)
    annee_du_jour = str(date.today().year)
    candidats: list[str] = []
    candidats += [a for a in _ANNEE.findall(reponse) if a != annee_du_jour]
    candidats += [m.strip() for m in _NOMBRE.findall(reponse)]
    # Un mot seul en début de phrase porte aussi une majuscule : on ne retient
    # que les suites d'au moins deux mots capitalisés (Mark Carney, Coupe Stanley).
    for m in _NOM_PROPRE.finditer(reponse):
        nom = _sans_tete_commune(m.group(0).strip())
        if nom:
            candidats.append(nom)
    manquants: list[str] = []
    for c in candidats:
        if c in manquants:
            continue
        if c[0].isdigit():
            if not _valeur_retrouvee(c, corpus_compact):
                manquants.append(c)
        elif not _nom_retrouve(c, corpus):
            manquants.append(c)
    return manquants[:6]

question_de_titulaire

question_de_titulaire(question: str) -> bool

Une question qui demande le NOM d'une personne en fonction.

Source code in src/diapason/server/actualite.py
def question_de_titulaire(question: str) -> bool:
    """Une question qui demande le NOM d'une personne en fonction."""
    plat = _plat(question)
    if not plat:
        return False
    if _FAIT_SUFFISANT.search(plat) and re.search(
        r"\bqui (?:dirige|preside|gouverne)", plat
    ):
        return True
    return (
        _TITULAIRES.search(plat) is not None
        and _DEMANDE_UN_NOM.search(plat) is not None
    )

page_de_reference

page_de_reference(
    sources: Sequence[dict[str, Any]], question: str
) -> str

L'URL de la page qui décrit la FONCTION, à lire en entier.

D'abord une page dont le titre porte la fonction demandée (« Premier ministre du Canada — Wikipédia », « Maire de Montréal » pour « la mairesse »), en écartant les listes (« Liste des Premiers ministres » ne dit pas qui est en poste) et, entre deux, celle dont le titre reprend le plus de mots de la question (« du Canada » avant « du Québec ») ; sinon la première page Wikipédia ; sinon rien — on ne lit pas une page au hasard.

Source code in src/diapason/server/actualite.py
def page_de_reference(sources: Sequence[dict[str, Any]], question: str) -> str:
    """L'URL de la page qui décrit la FONCTION, à lire en entier.

    D'abord une page dont le titre porte la fonction demandée (« Premier
    ministre du Canada — Wikipédia », « Maire de Montréal » pour « la
    mairesse »), en écartant les listes (« Liste des Premiers ministres » ne
    dit pas qui est en poste) et, entre deux, celle dont le titre reprend le
    plus de mots de la question (« du Canada » avant « du Québec ») ; sinon
    la première page Wikipédia ; sinon rien — on ne lit pas une page au
    hasard.
    """
    fonction = _motif_de_la_fonction(question)
    if fonction is None:
        return ""
    candidates = [s for s in sources if isinstance(s, dict) and s.get("url")]
    mots = _ressort(question)
    meilleure: tuple[int, int, str] | None = None
    for rang, s in enumerate(candidates):
        titre = _plat(str(s.get("title") or ""))
        url = str(s["url"])
        # La page DU poste : un titre qui commence par la fonction (« Premier
        # ministre du Canada — Wikipédia »), ou un site de référence qui la
        # porte (pm.gc.ca). Essai du 21/09 : « Le premier ministre Carney
        # rencontre… » d'un journal était lu comme la page du poste.
        # Banc du 21/09 : « Pape à Paris : où voir Léon XIV… » (sortiraparis)
        # commençait par la fonction et passait pour la page du poste. Seuls
        # les sites de référence comptent.
        de_reference = any(d in url for d in _DOMAINES_DE_REFERENCE)
        m = fonction.search(titre)
        if m and not _LISTE.match(titre) and de_reference:
            score = sum(1 for m in mots if m in titre)
            if meilleure is None or score > meilleure[0]:
                meilleure = (score, rang, url)
    if meilleure is not None:
        return meilleure[2]
    # Sans page de la fonction, une page Wikipédia — celle dont le titre situe
    # la question, avant une biographie : « Qui est le président actuel du
    # Canada ? » lisait la biographie de Justin Trudeau (essai du 21/09).
    for rang, s in enumerate(candidates):
        url = str(s["url"])
        titre = _normaliser(str(s.get("title") or ""))
        if "wikipedia.org" in url and not _LISTE.match(titre):
            score = sum(1 for m in mots if m in titre)
            if meilleure is None or score > meilleure[0]:
                meilleure = (score, rang, url)
    return meilleure[2] if meilleure is not None else ""

titulaires_selon_sources

titulaires_selon_sources(
    corpus: str, question: str = ""
) -> list[dict[str, Any]]

Qui les sources désignent comme titulaire en place, et où.

Seules comptent les sources dont l'en-tête situe la fonction demandée (« Canada » pour « premier ministre du Canada »). Dans chacune, ligne par ligne : le nom propre le plus proche d'un marqueur de fonction en cours (« Titulaire actuel », « incumbent » ; ou « actuel », « current », « depuis » collés au mot de la fonction), sauf marqueur historique (« premier titulaire », « en poste de 2015 à 2025 ») ou nom précédé d'un mot de prédécesseur. Un signal tiré des sources, jamais une vérité : la réponse cite, le code compare. Résultat trié : le plus récent d'abord.

Source code in src/diapason/server/actualite.py
def titulaires_selon_sources(corpus: str, question: str = "") -> list[dict[str, Any]]:
    """Qui les sources désignent comme titulaire en place, et où.

    Seules comptent les sources dont l'en-tête situe la fonction demandée
    (« Canada » pour « premier ministre du Canada »). Dans chacune, ligne par
    ligne : le nom propre le plus proche d'un marqueur de fonction en cours
    (« Titulaire actuel », « incumbent » ; ou « actuel », « current »,
    « depuis » collés au mot de la fonction), sauf marqueur historique
    (« premier titulaire », « en poste de 2015 à 2025 ») ou nom précédé d'un
    mot de prédécesseur. Un signal tiré des sources, jamais une vérité : la
    réponse cite, le code compare. Résultat trié : le plus récent d'abord.
    """
    fonction = _motif_de_la_fonction(question)
    if fonction is None:
        return []
    ressort = _ressort(question)
    trouves: dict[str, dict[str, Any]] = {}
    # Ligne par ligne : une ligne est un paragraphe, une rangée d'infobox ou
    # un extrait. Sur le bloc entier, « …du Canada\nTitulaire actuel » faisait
    # un nom de « Canada Titulaire » (constaté le 21/09).
    for ref, date, en_tete, texte in _blocs_de_sources(corpus):
        en_tete_plat = _normaliser(en_tete).replace("-", " ")
        if not _situe(ressort, en_tete_plat):
            continue
        # « Titulaire actuel » désigne la fonction de SA page : la fonction
        # demandée doit ouvrir la page (titre, début), sinon c'est l'infobox
        # d'un autre poste — la page du premier ministre parle bien quelque
        # part « du ministre des Finances » (revue du 21/09).
        tete_de_page = _normaliser(en_tete + " " + texte[:400]).replace("_", " ")
        fonction_du_bloc = fonction.search(tete_de_page) is not None
        for ligne in texte.split("\n"):
            for nom, par_nature in _titulaires_de_la_ligne(
                ligne, fonction, fonction_du_bloc
            ):
                cle = _cle_de_nom(nom)
                connu = trouves.get(cle)
                if connu is None or (date and date > connu["date"]):
                    trouves[cle] = {
                        "nom": nom,
                        "ref": ref,
                        "date": date,
                        "par_nature": par_nature or bool(connu and connu["par_nature"]),
                    }
    # Une rangée « Titulaire actuel | Nom » l'emporte sur les phrases : la
    # page du pape désignait Léon XIV (infobox) ET Robert Francis Prevost
    # (« élu pape … est le pape actuel ») — le même homme (revue du 21/09).
    if any(t["par_nature"] for t in trouves.values()):
        trouves = {k: t for k, t in trouves.items() if t["par_nature"]}
    return sorted(
        (
            {"nom": t["nom"], "ref": t["ref"], "date": t["date"]}
            for t in trouves.values()
        ),
        key=lambda t: t["date"],
        reverse=True,
    )

sources_datees

sources_datees(
    sources: Sequence[dict[str, Any]], question: str
) -> str

La date de la source la plus récente si elle est trop vieille pour la question ; "" si les sources sont assez fraîches — ou si l'une d'elles n'est pas datée : sans date, on ne dit rien (§5 ; revue du 21/09 — la source non datée est souvent la bonne).

Source code in src/diapason/server/actualite.py
def sources_datees(sources: Sequence[dict[str, Any]], question: str) -> str:
    """La date de la source la plus récente si elle est trop vieille pour la
    question ; "" si les sources sont assez fraîches — ou si l'une d'elles
    n'est pas datée : sans date, on ne dit rien (§5 ; revue du 21/09 — la
    source non datée est souvent la bonne)."""
    from datetime import date, timedelta

    liste = [s for s in sources if isinstance(s, dict)]
    if not liste:
        return ""
    dates = sorted(str(s.get("date") or "") for s in liste)
    if not all(_DATE_ISO.fullmatch(d) for d in dates):
        return ""
    plat = _plat(question)
    age_max = _AGE_MAX_JOURS_TRES_FRAIS if _TRES_FRAIS.search(plat) else _AGE_MAX_JOURS
    plus_recente = dates[-1]
    if plus_recente <= (date.today() - timedelta(days=age_max)).isoformat():
        return plus_recente
    return ""

note_avant_redaction

note_avant_redaction(
    sources: Sequence[dict[str, Any]],
    corpus: str,
    question: str,
) -> tuple[str, dict[str, Any]]

(consigne SYSTEM pour le modèle ou "", données pour l'interface).

Deux constats mécaniques, chacun posé seulement s'il est établi : l'âge des sources quand elles sont trop vieilles pour la question, et le nom que les sources désignent comme titulaire quand la question en cherche un. Deux noms différents → retenir le plus récent et nommer le désaccord. Le tout recalculé à chaque passage : ce qui est rendu REMPLACE la note et les données précédentes.

Source code in src/diapason/server/actualite.py
def note_avant_redaction(
    sources: Sequence[dict[str, Any]], corpus: str, question: str
) -> tuple[str, dict[str, Any]]:
    """(consigne SYSTEM pour le modèle ou "", données pour l'interface).

    Deux constats mécaniques, chacun posé seulement s'il est établi : l'âge
    des sources quand elles sont trop vieilles pour la question, et le nom
    que les sources désignent comme titulaire quand la question en cherche
    un. Deux noms différents → retenir le plus récent et nommer le désaccord.
    Le tout recalculé à chaque passage : ce qui est rendu REMPLACE la note
    et les données précédentes.
    """
    lignes: list[str] = []
    donnees: dict[str, Any] = {}
    vieille = sources_datees(sources, question)
    if vieille:
        lignes.append(
            f"Les sources trouvées datent au plus du {vieille} : dis-le dans ta "
            "réponse, la situation a pu changer depuis."
        )
        donnees["sourcesDatedAt"] = vieille
    if question_de_titulaire(question):
        titulaires = titulaires_selon_sources(corpus, question)
        if len(titulaires) == 1:
            seul = _libelle(titulaires[0])
            lignes.append(
                f"Les sources désignent {seul} comme titulaire actuel : appuie-toi "
                "dessus et cite cette source ; ne nomme pas un prédécesseur "
                "comme titulaire."
            )
        elif titulaires:
            lignes.append(
                "Les sources désignent plusieurs titulaires : "
                + " ; ".join(_libelle(t) for t in titulaires)
                + ". Retiens la source la plus récente et nomme le désaccord."
            )
    return "\n".join(lignes), donnees

desaccord_sur_le_titulaire

desaccord_sur_le_titulaire(
    reponse: str, corpus: str, question: str
) -> dict[str, Any] | None

La réponse nomme quelqu'un que les sources ne désignent pas comme titulaire : {"answer": nom, "sources": [noms]}, sinon None.

Seulement quand les sources désignent UN titulaire (deux, c'est déjà un désaccord entre elles, pas avec la réponse), que la réponse nomme au moins une personne, et que cette personne est bien donnée comme prédécesseur quelque part dans les sources — sans cela, le signal reposerait sur l'extraction seule, qui se trompe (revue du 21/09).

Source code in src/diapason/server/actualite.py
def desaccord_sur_le_titulaire(
    reponse: str, corpus: str, question: str
) -> dict[str, Any] | None:
    """La réponse nomme quelqu'un que les sources ne désignent pas comme
    titulaire : {"answer": nom, "sources": [noms]}, sinon None.

    Seulement quand les sources désignent UN titulaire (deux, c'est déjà un
    désaccord entre elles, pas avec la réponse), que la réponse nomme au
    moins une personne, et que cette personne est bien donnée comme
    prédécesseur quelque part dans les sources — sans cela, le signal
    reposerait sur l'extraction seule, qui se trompe (revue du 21/09).
    """
    if not question_de_titulaire(question):
        return None
    titulaires = titulaires_selon_sources(corpus, question)
    if not titulaires:
        return None
    attendu = titulaires[0]["nom"]
    noms_de_la_reponse: list[str] = []
    for _debut, _fin, nom in _noms_de_la_ligne(reponse.replace("\n", " ")):
        if nom not in noms_de_la_reponse:
            noms_de_la_reponse.append(nom)
    if not noms_de_la_reponse:
        return None
    if any(_cle_de_nom(n) == _cle_de_nom(attendu) for n in noms_de_la_reponse):
        return None
    if any(_nom_retrouve(attendu, _normaliser(n)) for n in noms_de_la_reponse):
        return None
    if len(titulaires) > 1:
        # Plusieurs titulaires : le plus récent est attendu, à condition que
        # sa source soit strictement plus récente ; la réponse qui nomme un
        # autre titulaire sans lui recevait « Vérifié en ligne » (revue du
        # 21/09). Sans date qui tranche, silence.
        date = titulaires[0]["date"]
        if not date or any(t["date"] == date for t in titulaires[1:]):
            return None
        anciens = {_cle_de_nom(t["nom"]): t["nom"] for t in titulaires[1:]}
        coupable = next(
            (
                anciens[_cle_de_nom(n)]
                for n in noms_de_la_reponse
                if _cle_de_nom(n) in anciens
            ),
            None,
        )
    else:
        coupable = next(
            (n for n in noms_de_la_reponse if _nomme_un_predecesseur(n, corpus)), None
        )
    if coupable is None:
        return None
    return {"answer": coupable, "sources": [attendu]}

est_une_non_reponse

est_une_non_reponse(
    reponse: str,
    orale: bool = False,
    question: str | None = None,
) -> bool

La réponse avoue ne pas avoir trouvé le fait dans les sources — et n'affirme rien d'autre ; ou elle promet d'aller le chercher.

Revue du 21/09 : « Les Hurricanes ont gagné [7] ; les sources ne précisent pas le score » déclenchait une lecture, un passage de plus et la réponse en double. Une phrase qui avoue à côté d'une phrase qui affirme (une citation [N] ; à l'oral, un nom, un nombre) est une réponse avec une réserve — SAUF si l'aveu porte sur ce qui était demandé : « Le score exact … n'est pas spécifié dans les résultats » sous « Quel a été le score … ? » (banc du 21/09, 22 h) est une non-réponse malgré le [6] de la phrase d'avant. Le critère : l'OBJET de l'aveu (« le score », « le vainqueur ») est un mot de la question, ou son frère (« gagné »), et n'est pas dans une phrase qui cite. Et une PROMESSE (« je vais lire l'article complet ») en dernière phrase compte toujours : rien ne la tiendra. Ce dont PARLENT les résultats n'est pas une affirmation.

Source code in src/diapason/server/actualite.py
def est_une_non_reponse(
    reponse: str, orale: bool = False, question: str | None = None
) -> bool:
    """La réponse avoue ne pas avoir trouvé le fait dans les sources — et
    n'affirme rien d'autre ; ou elle promet d'aller le chercher.

    Revue du 21/09 : « Les Hurricanes ont gagné [7] ; les sources ne
    précisent pas le score » déclenchait une lecture, un passage de plus et
    la réponse en double. Une phrase qui avoue à côté d'une phrase qui
    affirme (une citation [N] ; à l'oral, un nom, un nombre) est une réponse
    avec une réserve — SAUF si l'aveu porte sur ce qui était demandé : « Le
    score exact … n'est pas spécifié dans les résultats » sous « Quel a été
    le score … ? » (banc du 21/09, 22 h) est une non-réponse malgré le [6]
    de la phrase d'avant. Le critère : l'OBJET de l'aveu (« le score », « le
    vainqueur ») est un mot de la question, ou son frère (« gagné »), et
    n'est pas dans une phrase qui cite. Et une PROMESSE (« je vais lire
    l'article complet ») en dernière phrase compte toujours : rien ne la
    tiendra. Ce dont PARLENT les résultats n'est pas une affirmation.
    """
    phrases = [ph for ph in _PHRASES.split(reponse or "") if ph.strip()]
    if not phrases:
        return False
    if _PROMESSE.search(_plat(phrases[-1])):
        return True
    aveux: list[str] = []
    affirmations: list[str] = []
    for ph in phrases:
        plat = _plat(ph)
        m = _NON_REPONSE.search(plat)
        if m or _PROMESSE.search(plat):
            aveux.append(plat)
            # « Les Panthers ont gagné 4-2 [1], les sources ne précisent pas
            # le buteur » : ce qui précède l'aveu dans la même phrase affirme.
            avant = ph[: m.start()] if m else ""
            if avant.strip() and _affirme(avant, orale, question):
                affirmations.append(avant)
            continue
        if _DESCRIPTION_DES_SOURCES.match(plat):
            continue
        if _affirme(ph, orale, question):
            affirmations.append(ph)
    if not aveux:
        return False
    if not affirmations:
        return True
    return _l_aveu_porte_sur_la_question(aveux, affirmations, question)

sources_prometteuses

sources_prometteuses(
    sources: Sequence[dict[str, Any]],
    question: str,
    deja_lues: Sequence[str] = (),
) -> list[dict[str, Any]]

Les sources à lire quand la réponse avoue n'avoir pas trouvé, la plus prometteuse d'abord (vide : rien ne s'impose).

Un point par mot du TITRE atteint par un mot entier de la question (quatre lettres, ou sa traduction du lexique) — entier : « temps » n'est pas dans « printemps » ; par mot du titre, pas de la question : « président » se traduit par lui-même et comptait double (revue du 21/09, 22 h) ; deux par année de la question, et deux de moins pour une AUTRE année (« Coupe Stanley 2025 : les Panthers » gagnait sur « 2026 Stanley Cup Final ») ; sans année dans la question, l'année en cours est implicite : un point pour elle, deux de moins pour une année d'AVANT l'année passée — en janvier, l'édition de l'année passée est encore la dernière (revue du 21/09, 22 h : au 15 janvier 2027, « 2026 Stanley Cup Final » était écartée pour un aperçu des séries 2027) ; un point si le site est de référence ; un demi-point si la source a une date ISO de moins de trente jours. Deux points nets au moins avant de lire : un seul mot commun (« Canada ») ne dit pas le sujet. Jamais un agrégateur (MSN ne se lit pas). À égalité, la première pastille.

Source code in src/diapason/server/actualite.py
def sources_prometteuses(
    sources: Sequence[dict[str, Any]],
    question: str,
    deja_lues: Sequence[str] = (),
) -> list[dict[str, Any]]:
    """Les sources à lire quand la réponse avoue n'avoir pas trouvé, la plus
    prometteuse d'abord (vide : rien ne s'impose).

    Un point par mot du TITRE atteint par un mot entier de la question
    (quatre lettres, ou sa traduction du lexique) — entier : « temps » n'est
    pas dans « printemps » ; par mot du titre, pas de la question : « président »
    se traduit par lui-même et comptait double (revue du 21/09, 22 h) ; deux
    par année de la question, et deux de moins pour une AUTRE année (« Coupe
    Stanley 2025 : les Panthers » gagnait sur « 2026 Stanley Cup Final ») ;
    sans année dans la question, l'année en cours est implicite : un point
    pour elle, deux de moins pour une année d'AVANT l'année passée — en
    janvier, l'édition de l'année passée est encore la dernière (revue du
    21/09, 22 h : au 15 janvier 2027, « 2026 Stanley Cup Final » était
    écartée pour un aperçu des séries 2027) ; un point si le site est de
    référence ; un demi-point si la source a une date ISO de moins de trente
    jours. Deux points nets au moins avant de lire : un seul mot commun
    (« Canada ») ne dit pas le sujet. Jamais un agrégateur (MSN ne se lit
    pas). À égalité, la première pastille.
    """
    from datetime import date, timedelta

    from diapason.tools.web_search import url_canonique

    mots = set(_MOT_DE_QUESTION.findall(_plat(question)))
    mots -= _MOTS_VIDES_DE_QUESTION | _MOTS_GRAMMATICAUX
    if not mots:
        return []
    annees = {m for m in mots if m.isdigit()}
    lexique = set()
    for m in mots:
        lexique.update(_LEXIQUE.get(m, ()))
    annee_en_cours = str(date.today().year)
    lues = {url_canonique(u) for u in deja_lues}
    recent = (date.today() - timedelta(days=_JOURS_RECENTS)).isoformat()
    classees: list[tuple[float, int, dict[str, Any]]] = []
    for rang, src in enumerate(sources):
        if not isinstance(src, dict) or not src.get("url"):
            continue
        url = str(src["url"])
        if url_canonique(url) in lues:
            continue
        if any(d in url for d in _AGREGATEURS):
            continue
        jetons = set(_MOT_DE_TITRE.findall(_plat(str(src.get("title") or ""))))
        atteints = {
            t for t in jetons if (t in mots and not t.isdigit()) or t in lexique
        }
        communs = len(atteints)
        if communs == 0:
            continue
        score: float = communs
        annees_du_titre = {t for t in jetons if t.isdigit()}
        if annees:
            score += 2 * len(annees & annees_du_titre)
            if annees_du_titre - annees:
                score -= 2
        else:
            if annee_en_cours in annees_du_titre:
                score += 1
            annee_passee = str(int(annee_en_cours) - 1)
            if any(a < annee_passee for a in annees_du_titre):
                score -= 2
        if any(d in url for d in _DOMAINES_DE_REFERENCE):
            score += 1
        if score < 2:
            continue
        date_src = str(src.get("date") or "")
        if _DATE_ISO.fullmatch(date_src) and date_src >= recent:
            score += 0.5
        classees.append((score, rang, src))
    classees.sort(key=lambda t: (-t[0], t[1]))
    return [src for _score, _rang, src in classees]

source_la_plus_prometteuse

source_la_plus_prometteuse(
    sources: Sequence[dict[str, Any]],
    question: str,
    deja_lues: Sequence[str] = (),
) -> dict[str, Any] | None

La première de sources_prometteuses, ou None.

Source code in src/diapason/server/actualite.py
def source_la_plus_prometteuse(
    sources: Sequence[dict[str, Any]],
    question: str,
    deja_lues: Sequence[str] = (),
) -> dict[str, Any] | None:
    """La première de ``sources_prometteuses``, ou None."""
    classees = sources_prometteuses(sources, question, deja_lues)
    return classees[0] if classees else None

niveau_de_verification

niveau_de_verification(
    reponse: str,
    sources: Sequence[dict[str, Any]],
    verification_faite: bool,
    signal: dict[str, Any] | None = None,
    dernier_passage: str | None = None,
    question: str | None = None,
) -> str

verified / partial / memory.

De mémoire tant qu'aucune recherche n'a rendu quelque chose ; partiel quand la réponse ne cite aucune source, cite un numéro qui n'existe pas, ou que le contrôle a relevé quelque chose ; vérifié sinon. La non-réponse se juge sur le DERNIER passage : « je dois lire la page… » suivi du chiffre trouvé est une réponse (banc du 21/09).

Source code in src/diapason/server/actualite.py
def niveau_de_verification(
    reponse: str,
    sources: Sequence[dict[str, Any]],
    verification_faite: bool,
    signal: dict[str, Any] | None = None,
    dernier_passage: str | None = None,
    question: str | None = None,
) -> str:
    """verified / partial / memory.

    De mémoire tant qu'aucune recherche n'a rendu quelque chose ; partiel
    quand la réponse ne cite aucune source, cite un numéro qui n'existe pas,
    ou que le contrôle a relevé quelque chose ; vérifié sinon. La
    non-réponse se juge sur le DERNIER passage : « je dois lire la page… »
    suivi du chiffre trouvé est une réponse (banc du 21/09).
    """
    if not verification_faite:
        return DE_MEMOIRE
    connus = {s.get("ref") for s in sources if isinstance(s, dict)}
    cites = {int(n) for n in _CITATION.findall(reponse or "")}
    if not cites or not cites <= connus:
        return PARTIEL
    # « [1] » tout seul n'est pas une réponse vérifiée (revue du 21/09).
    if not _CITATION.sub("", reponse or "").strip():
        return PARTIEL
    # Une réponse qui dit n'avoir pas trouvé n'est pas vérifiée, même citée.
    if est_une_non_reponse(
        dernier_passage if dernier_passage is not None else reponse, question=question
    ):
        return PARTIEL
    # Revue du 21/09 : le [1] de la non-réponse suffisait ; la reprise qui
    # répond sans citer était « vérifiée ». Le dernier passage cite, lui aussi.
    if dernier_passage is not None:
        cites_a_la_fin = {int(n) for n in _CITATION.findall(dernier_passage)}
        if not cites_a_la_fin or not cites_a_la_fin <= connus:
            return PARTIEL
    if signal and (
        signal.get("notFound")
        or signal.get("disagreement")
        or signal.get("sourcesDatedAt")
    ):
        return PARTIEL
    return VERIFIE

renumeroter

renumeroter(
    contenu: str,
    sources: list[dict[str, Any]],
    deja: list[dict[str, Any]],
) -> tuple[str, list[dict[str, Any]]]

Une seconde recherche ne recommence pas à [1] : ses numéros suivent, et une page déjà vue garde son premier numéro (revue du 20/09 : deux recherches rendaient deux pastilles vers le même article).

Source code in src/diapason/server/actualite.py
def renumeroter(
    contenu: str,
    sources: list[dict[str, Any]],
    deja: list[dict[str, Any]],
) -> tuple[str, list[dict[str, Any]]]:
    """Une seconde recherche ne recommence pas à [1] : ses numéros suivent, et
    une page déjà vue garde son premier numéro (revue du 20/09 : deux
    recherches rendaient deux pastilles vers le même article)."""
    from diapason.tools.web_search import url_canonique

    connus = {url_canonique(str(d.get("url") or "")): d["ref"] for d in deja}
    prochain = len(deja) + 1
    correspondance: dict[int, int] = {}
    nouvelles: list[dict[str, Any]] = []
    for src in sources:
        if not isinstance(src, dict) or not isinstance(src.get("ref"), int):
            continue
        cle = url_canonique(str(src.get("url") or ""))
        if cle in connus:
            correspondance[src["ref"]] = connus[cle]
            continue
        correspondance[src["ref"]] = prochain
        connus[cle] = prochain
        nouvelles.append({**src, "ref": prochain})
        prochain += 1
    if not correspondance:
        return contenu, []
    texte = _NUMERO_DE_LIGNE.sub(
        lambda m: f"[{correspondance.get(int(m.group(1)), int(m.group(1)))}]",
        contenu,
    )
    return texte, nouvelles

sous_l_url_demandee

sous_l_url_demandee(
    sources: list[dict[str, Any]], url: str
) -> list[dict[str, Any]]

La page lue garde la pastille de l'URL demandée : après une redirection (http → https, www), web_read rend l'URL finale et la page déjà [2] recevait une quatrième pastille (revue du 21/09).

Source code in src/diapason/server/actualite.py
def sous_l_url_demandee(
    sources: list[dict[str, Any]], url: str
) -> list[dict[str, Any]]:
    """La page lue garde la pastille de l'URL demandée : après une
    redirection (http → https, www), web_read rend l'URL finale et la page
    déjà [2] recevait une quatrième pastille (revue du 21/09)."""
    if not url:
        return sources
    return [
        {**src, "url": url} if isinstance(src, dict) and src.get("url") != url else src
        for src in sources
    ]