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
]
|