Aller au contenu
English

Répondre à partir de règlements versionnés

  • Étude de cas 3
  • 210 min
  • Facultative · 1 étude de cas requise pour le certificat

Ce que vous saurez faire

Créez un assistant qui répond à des questions sur deux règlements américains concernant les passagers aériens à une date donnée. Il cherche seulement dans les versions en vigueur à cette date et les cite avec leurs dates. Il indique quand une date dépasse la portée de ses données. Vous mesurez ce que le filtre de date change dans la recherche, les réponses et les citations.

Étude de casPièce de portfolio

Durée
Environ 3,5 heures
S’exécute dans
VS Code, Python 3.12+
Compétences utilisées
  • Versions à une date précise
  • Dates d'entrée en vigueur provenant d'une API officielle
  • Un instantané avec des sommes de contrôle
  • Des segments qui portent leur version
  • Un filtre de date avant les k meilleurs résultats
  • Une vérification de la version citée
  • Abstention hors de la portée de l'instantané
  • recall@k avec et sans le filtre
  • Coût et latence

Décompressez le projet de départ, ouvrez le dossier dans VS Code et suivez les étapes ci-dessous. Elles commencent par un nouvel environnement virtuel.

Le projet de départ suit la structure du policy-assistant du cours. Il contient l’instantané, les questions datées, les réponses enregistrées et les éléments que vous avez créés dans le cours. Vous écrivez les trois fichiers nouveaux ici : les versions, les segments et les sources avec leur vérification de version. Le projet terminé est fourni pour comparer. Vous n’avez besoin d’aucun compte : le simulateur rejoue les réponses données par de vrais modèles.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 210 minutes

Ce que vous devez savoir

Cette étude de cas applique tout le cours à des règles qui changent. Les leçons les plus utilisées :

Ce qu’il vous faut sur votre ordinateur

  • Python 3.12 ou plus récent, VS Code et un terminal, comme dans le cours.
  • Environ 2 Go d’espace disque libre : 0,9 Go pour les paquets et 1 Go pour les deux modèles locaux. Si vous avez suivi le cours, les modèles sont déjà dans votre cache Hugging Face.

Testé sur macOS (puce Apple) avec Python 3.12.13. Les versions sont celles du cours : sentence-transformers 6.1.0, torch 2.14.1, pydantic 2.14.0, pytest 9.1.1. Les commandes Windows suivent celles du cours, mais elles n’ont pas été testées.

Rosa Mendes dirige les intervenants d’une petite ligne d’aide pour les passagers aériens à Chicago. Les gens appellent des mois après un voyage : « On m’a refusé l’embarquement sur un vol complet en juin 2022. Que me devait la compagnie aérienne? » La réponse se trouve dans un règlement fédéral américain, la partie 250 du titre 14 du CFR. Ses montants changent toutes les quelques années, et une plainte doit appliquer la règle du jour du vol.

Kofi Asante, un développeur bénévole, propose de créer un assistant qui trouve le bon passage. Rosa accepte à trois conditions. Chaque réponse doit nommer la version utilisée et ses dates, pour qu’un intervenant puisse ouvrir le même texte. Pour une date que l’assistant ne peut pas couvrir, il doit le dire, plutôt que d’utiliser la version la plus proche. Et il ne doit jamais donner l’impression d’offrir un avis juridique : la ligne d’aide rédige les plaintes, et un avocat tranche les cas difficiles.

Rosa, Kofi et la ligne d’aide sont inventés. Les règlements, leurs versions et leurs dates sont réels.

La nouvelle idée : la version en vigueur à une date donnée

Section intitulée « La nouvelle idée : la version en vigueur à une date donnée »

Pensez à un horaire d’autobus. La compagnie en imprime un nouveau chaque saison et garde les anciens dans un tiroir. « À quelle heure passait le dernier autobus le 10 juin 2022? » a une seule bonne réponse, dans l’horaire de cette saison. Si vous cherchez dans tout le tiroir, vous trouvez six horaires presque identiques, et le plus récent n’est pas le bon.

Le cours a abordé ce problème avec les versions 3 et 4 de la politique de retour de Larkfield. Un règlement pose le même problème, à plus grande échelle : chaque article change à ses propres dates. Le texte d’un article entre deux dates de changement constitue une version d’article. Celle dont la période comprend la date de la question est la version en vigueur. Un service qui montre le texte tel qu’il était à une date passée fournit des versions à une date précise. Voici deux articles de nos données sur dix ans :

Un axe temporel de 2015 à 2027 avec deux rangées. Rangée 1, article 250.5(a)(2), le maximum reçu par un passager réacheminé pour arriver avec 1 à 2 heures de retard : absent de l'instantané avant 2017; 675 $ du 1er janvier 2017 au 12 janvier 2021; une période orange en pointillés du 13 janvier au 12 avril 2021 (publication le 13 janvier 2021, entrée en vigueur le 13 avril 2021); une courte période marquée d'une étoile du 13 avril au 1er août 2021; 775 $ du 2 août 2021 au 23 octobre 2024; une période orange en pointillés du 24 octobre 2024 au 21 janvier 2025 (publication le 24 octobre 2024, entrée en vigueur le 22 janvier 2025); 1 075 $ à partir du 22 janvier 2025; un point d'interrogation après le 7 octobre 2026, date jusqu'à laquelle l'eCFR était à jour. Rangée 2, article 254.4, la limite minimale de responsabilité qu'une compagnie aérienne peut fixer pour les bagages : 3 500 $, puis 3 800 $ à partir du 13 avril 2021, puis 4 700 $ à partir du 22 janvier 2025, avec les deux mêmes périodes orange. Rangée 3 : une question posée à six dates : Q28 en mars 2015, aucune version; Q01 en mars 2019, 675 $; Q02 en février 2021, 675 $; Q03 en juin 2022, 775 $; Q04 en décembre 2024, 775 $; Q05 en juillet 2025, 1 075 $. Note : du 13 avril au 1er août 2021, l'article 250.5(b)(3), pour les vols internationaux, indiquait 1 350 $, tandis que l'article 250.9 indiquait 1 550 $; une correction l'a modifié le 2 août 2021.
Deux articles du titre 14 du CFR au fil du temps, tels que l'instantané les conserve. Chaque point représente la même question posée pour une autre date de vol.
Description textuelle du diagramme
  1. L’article 250.5(a)(2) plafonne l’indemnité pour un passager refusé à l’embarquement qui arrive avec une à deux heures de retard. Le plafond était de 675 $ jusqu’au 12 janvier 2021, de 775 $ à partir du 13 avril 2021, puis de 1 075 $ à partir du 22 janvier 2025.
  2. L’article 254.4 fixe la limite minimale de responsabilité pour les bagages : 3 500 $, puis 3 800 $, puis 4 700 $, aux mêmes dates.
  3. À deux reprises, une modification a été publiée trois mois avant son entrée en vigueur (périodes orange). Pendant ces périodes, l’ancien montant s’appliquait encore.
  4. La même question reçoit une bonne réponse différente selon la date du vol. Pour mars 2015, l’instantané ne contient aucune version.

Les périodes orange sont le piège. En décembre 2024, le nouveau montant de 1 075 $ était déjà publié, mais le montant en vigueur restait de 775 $. Une recherche qui trouve « le texte le plus récent » donne la mauvaise réponse pour tout vol pendant cette période.

Vous allez créer regulations-assistant, un petit projet Python. L’image montre le parcours complet d’une question.

Un flux. Un instantané des parties 250 et 254 à chaque date de changement, provenant de l'eCFR, récupéré le 9 octobre 2026 et vérifié avec SHA-256, devient 44 versions d'articles. Chacune est en vigueur d'une date à une autre. Elles deviennent ensuite 134 segments, chacun avec un identifiant, un paragraphe, des dates et une adresse eCFR. La question d'un intervenant et sa date mènent à une décision : la date est-elle dans l'instantané? Avant le 1er janvier 2017 : aucune version à montrer, aucune réponse et aucun appel de modèle. Oui : le filtre de date garde seulement les versions en vigueur à cette date. Ensuite, recherche avec BM25, e5, RRF et reclassement, les 5 meilleurs résultats. Le modèle écrit des affirmations avec des identifiants de passages et nomme la version. Des vérifications dans le code testent les identifiants, les nombres, les mots et la version en vigueur. La fiche de réponse montre l'article, les dates de la version et l'adresse eCFR, et indique qu'il ne s'agit pas d'un avis juridique.
La conception de regulations-assistant. La recherche et les réponses viennent du cours; les versions, la vérification de couverture et la vérification de version sont nouvelles.
Description textuelle du diagramme
  1. Un instantané des deux parties à chaque date de changement devient 44 versions d’articles, puis 134 segments. Chaque segment conserve son identifiant, son paragraphe, les dates de sa version et son adresse eCFR.
  2. Une question arrive avec une date. Si la date précède l’historique de l’instantané, l’assistant le dit et n’appelle aucun modèle.
  3. Sinon, le filtre de date garde seulement les versions en vigueur à cette date, avant la sélection des 5 meilleurs résultats.
  4. Le modèle répond par des affirmations avec des identifiants de passages et nomme la version.
  5. Le code vérifie chaque citation, y compris si sa version était en vigueur à cette date.
  6. La fiche de réponse montre chaque source avec son article, les dates de sa version et son adresse eCFR.

Les règlements viennent de l’eCFR, l’Electronic Code of Federal Regulations, publié par l’Office of the Federal Register et le Government Publishing Office. Nous avons choisi deux courtes parties du titre 14. La partie 250, Oversales (surréservation), détermine qui une compagnie aérienne doit solliciter d’abord quand un vol est complet. Elle fixe aussi l’indemnité à verser à un passager refusé à l’embarquement. La partie 254, Domestic baggage liability (responsabilité pour les bagages sur les vols intérieurs), fixe la limite minimale de responsabilité pour les bagages perdus ou retardés. Leurs montants sont révisés selon le même cycle de deux ans.

Le CFR est une œuvre du gouvernement américain; il appartient donc au domaine public aux États-Unis. Tout le monde peut le copier et l’adapter sans devoir en nommer la source; nous la nommons quand même sous chaque réponse. Un fait importe plus que la licence : l’eCFR n’est pas une édition juridique officielle du CFR. Les textes officiels sont le CFR annuel et le Federal Register. Cela devient une règle du projet, dans Utilisation responsable.

Nous avons aussi examiné le Règlement sur la protection des passagers aériens du Canada sur le Site Web de la législation (Justice). Deux de ses quatre versions existent en XML sous la Licence du gouvernement ouvert – Canada. Les deux plus anciennes existent seulement comme pages Web, sous d’autres conditions. Nous avons donc choisi l’eCFR, qui fournit toutes les versions par une seule API documentée.

L’eCFR offre une API de consultation à une date précise. Demandez une partie « à » une date, et elle renvoie le XML de ce jour. Un autre appel donne la liste de toutes les versions d’articles et de leurs dates. Le script scripts/fetch_snapshot.py a fait une requête pour chaque date où un article a changé. Il a aussi demandé le jour jusqu’auquel l’eCFR était à jour. Cela représentait 16 requêtes, une toutes les 1,2 secondes. Nous avons conservé chaque réponse telle quelle, avec son adresse, son heure et son empreinte SHA-256, dans regulations/.

Trois choses apprises en chemin sont maintenant des faits du projet :

  • L’historique commence le 1er janvier 2017. L’API ne renvoie rien pour le 31 décembre 2016. Beaucoup de textes étaient en vigueur avant, mais l’instantané ne peut pas le montrer. La première version de chaque article indique donc « 1er janvier 2017 ou avant ».
  • L’eCFR était à jour jusqu’au 7 octobre 2026, deux jours avant notre récupération. Pour toute date ultérieure, un nouveau changement pourrait manquer. Même la date « d’aujourd’hui » du cours, le 9 octobre 2026, est après cette date.
  • Les dernières versions sont complètes. Le fichier du 7 octobre 2026 est identique, octet par octet, à celui du 22 janvier 2025, pour les deux parties : rien n’a changé entre les deux.

Nous avons lu les versions avant de programmer la recherche. Trois constats orientent le projet :

  • Publié ne veut pas dire en vigueur. Le 13 janvier 2021 et le 24 octobre 2024, l’eCFR a ajouté une ligne aux articles modifiés : « Lien vers une modification publiée dans 89 FR 84818, le 24 octobre 2024. » Le nouveau texte est entré en vigueur trois mois plus tard. Cette ligne est un avertissement, pas la nouvelle règle.
  • Une vraie erreur dans une version. Du 13 avril au 1er août 2021, le § 250.5(b)(3), pour les vols vers un autre pays, indiquait 1 350 $. Dans la même version, (a)(3) et l’avis aux passagers du § 250.9 indiquaient 1 550 $. Une correction a modifié (b)(3) le 2 août 2021.
  • Des règles qui n’existaient pas encore. Le § 250.7 indiquait « [Reserved] » (réservé) jusqu’au 12 janvier 2021, puis son titre était « xxx » pendant trois mois. Il est ensuite devenu la règle sur les passagers déjà à bord. Une question sur 2019 ne doit pas recevoir la règle de 2021.

Avant toute recherche, Rosa et Kofi définissent une bonne réponse, comme dans Le corpus et les questions. Le fichier questions/questions.jsonl contient 34 questions, chacune avec une date (as_of) : le jour du vol ou de la question. Chacune contient une réponse de référence, les passages pertinents (article, version et paragraphe) et des clés : des expressions régulières auxquelles une bonne réponse doit correspondre. Les motifs must_not contiennent les montants des autres versions; une réponse avec le montant d’une mauvaise version échoue donc.

Type Nombre Exemple Une bonne réponse
Datée 15 Refus d’embarquement le 10 juin 2022, réacheminement avec 90 minutes de retard : l’indemnité maximale à payer? 775 $ (version du 2 août 2021 au 23 octobre 2024)
En attente 3 La même question, le 20 décembre 2024 775 $ : la modification à 1 075 $ était publiée, mais pas en vigueur
Correction 1 Chicago–Londres le 1er juin 2021, aucun vol dans les quatre heures : la limite? 1 350 $, selon le texte du § 250.5(b)(3) à cette date
Absente 2 Le 15 avril 2019, la compagnie pouvait-elle retirer un passager dont la carte d’embarquement avait été numérisée? La version en vigueur ne contient aucune règle à ce sujet
Stable 6 Qu’est-ce qu’un gros aéronef dans les règles sur les bagages? Plus de 60 sièges (identique dans toutes les versions)
Avant 2 Refus d’embarquement le 10 mars 2015 : l’indemnité maximale à payer? L’instantané ne contient aucune version à cette date
Après 1 Refus d’embarquement le 15 février 2027, aucun vol dans les deux heures : la limite? 2 150 $ dans la dernière version, avec un avertissement
Non couverte 4 Combien de temps une compagnie peut-elle garder les passagers sur l’aire de trafic? Les deux parties ne le précisent pas

Les groupes posent une question à plusieurs dates. Q01 à Q05 et Q28 sont les six points de la chronologie ci-dessus : les mêmes mots, six dates de vol, quatre bonnes réponses différentes.

L’auteur a rédigé chaque question, réponse et clé en lisant les versions; aucun modèle n’a rédigé ni étiqueté de question. Un script vérifie que chaque passage pertinent existe et est en vigueur à la date de la question. Il vérifie aussi que chaque clé correspond à sa réponse de référence. Après avoir lu les premières réponses, nous avons changé une vérification. Le must_not de Q16 détecte maintenant aussi une réponse qui commence par « Yes » (oui). Une clé avait trouvé les mots « did not state » (ne précisait pas) dans une telle réponse. Nous avons ensuite figé le jeu de données.

Les étapes utilisent VS Code et son terminal. Chaque commande s’exécute dans le dossier du projet.

  1. Téléchargez le projet de départ dans l’encadré en haut de cette page. Décompressez-le dans votre dossier de projets. Vous obtenez le dossier regulations-assistant.

  2. Dans VS Code, choisissez File > Open Folder (fichier, ouvrir un dossier), puis ouvrez regulations-assistant. Ouvrez ensuite un terminal : Terminal > New Terminal (terminal, nouveau terminal).

  3. Créez et activez un environnement virtuel :

    Fenêtre de terminal
    python3 -m venv .venv
    source .venv/bin/activate

    L’invite commence par (.venv). Si VS Code demande d’utiliser le nouvel environnement pour ce dossier, choisissez Yes (oui).

  4. Installez les versions du cours. Sur Linux, installez d’abord PyTorch pour processeur seulement : python -m pip install torch==2.14.1 --index-url https://download.pytorch.org/whl/cpu.

    Fenêtre de terminal
    python -m pip install -r requirements.txt
  5. Exécutez les tests et vérifiez l’instantané :

    Fenêtre de terminal
    python -m pytest
    python scripts/check_snapshot.py
Output (end of pytest, then the last lines of check_snapshot.py)
======================== 5 passed, 13 skipped in 0.44s =========================
OK versions_part-254.json SHA-256 9196296c6050…
OK sources.csv SHA-256 6c625a911adc…
17 files checked | fetched 2026-10-09 from the eCFR API | history from 2017-01-01 | up to date to 2026-10-07

Ce que cela nous indique. Les paquets fonctionnent, et l’instantané est celui que nous avons testé. Chaque chargement compare d’abord les fichiers avec regulations/SHA256SUMS. Un test remplace 4 700 $ par 7 400 $ dans une copie et vérifie que le chargement s’arrête. Les 13 tests ignorés concernent les trois fichiers que vous écrivez. Chacun s’exécute dès que son fichier existe.

Examinez l’instantané et les questions dont la modification était publiée, mais pas en vigueur :

Fenêtre de terminal
python -m regulations_assistant snapshot
python -m regulations_assistant questions --kind pending
Sortie
14 CFR Part 250 (Oversales): 7 files, on 2017-01-01, 2021-01-13, 2021-04-13, 2021-08-02, 2024-10-24, 2025-01-22, 2026-10-07
14 CFR Part 254 (Domestic baggage liability): 6 files, on 2017-01-01, 2021-01-13, 2021-04-13, 2024-10-24, 2025-01-22, 2026-10-07
16 files | fetched 2026-10-09 | history from 2017-01-01 | eCFR up to date to 2026-10-07 | checksums OK (regulations/SHA256SUMS)
Q02 2021-02-15 pending A passenger was denied boarding involuntarily on an oversold domestic flight on…
Q04 2024-12-20 pending A passenger was denied boarding involuntarily on an oversold domestic flight on…
Q14 2024-11-28 pending On a domestic flight on 28 November 2024 with a large aircraft, what was the lo…
3 questions

L’instantané contient chaque partie telle qu’elle était à plusieurs dates. La recherche a besoin d’autre chose : les versions de chaque article, avec leurs dates d’entrée en vigueur et de fin. Dans Conserver les métadonnées, les dates d’entrée en vigueur venaient d’un inventaire. Ici, nous les déduisons des copies. Parcourez les dates dans l’ordre. Quand le texte d’un article diffère de celui de la date précédente, une nouvelle version commence. L’ancienne se termine la veille.

Le XML de l’eCFR contient un élément DIV8 pour chaque article. Il comprend son titre, ses paragraphes (P), ses paragraphes sans retrait (FP), ses tableaux, ses notes et l’avis de modification publiée (XREF). Les notes de source (CITA) énumèrent les documents du Federal Register à l’origine du texte. Elles restent hors du texte consultable. La bibliothèque standard de Python lit le XML. Le projet n’a donc besoin d’aucun nouveau paquet. Créez regulations_assistant/versions.py :

regulations_assistant/versions.py
"""Section versions: what each section said, and from when to when it was in force.
The snapshot has each part as it was on several dates. This file turns those copies into versions:
1. Parse a part (eCFR XML) into sections, and each section into blocks: paragraphs, tables,
headings inside a section, and the eCFR's notice of a published amendment ("Link to an
amendment published at 89 FR 84818, Oct. 24, 2024."). The source note (CITA) and the
approval note (APPRO) are kept out of the text. Each block keeps its top-level paragraph
label, "(a)", "(b)"..., so that a citation can name it.
2. Walk the dates in order. When a section's text differs from the day before, a new version
starts on that date, and the previous version ends the day before.
3. The first version starts on the first date of the snapshot (2017-01-01, where the eCFR's history
starts): it may have been in force earlier, but the snapshot cannot say. The last version has no
end date: it is still in force on the day the eCFR was up to date to (current_to).
`coverage()` says whether a question's date is inside what the snapshot can answer.
"""
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import date, timedelta
TOP_LABEL = re.compile(r"^\(([a-z])\)\s") # "(a) Subject to ..." starts paragraph (a)
CONTAINERS = {"DIV", "EXTRACT"} # wrappers: read what is inside them
def clean(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
@dataclass(frozen=True)
class Block:
kind: str # paragraph, table, heading, notice
text: str
paragraph: str # the top-level label, for example "(b)"; "" before the first label
@dataclass(frozen=True)
class SectionText:
section: str # 250.5
heading: str # § 250.5 Amount of denied boarding compensation for ...
blocks: tuple
amended_by: str # the source note: the Federal Register documents that made this text
@property
def text(self) -> str:
return "\n".join(b.text for b in self.blocks)
@dataclass(frozen=True)
class SectionVersion:
section: str
part: str
heading: str
effective_from: str # YYYY-MM-DD
effective_to: str # YYYY-MM-DD, or "" when still in force on current_to
blocks: tuple = field(repr=False)
amended_by: str = ""
pending: str = "" # the eCFR's notice of an amendment published but not yet in force
@property
def text(self) -> str:
return "\n".join(b.text for b in self.blocks)
def in_force(self, as_of: str) -> bool:
return self.effective_from <= as_of and (not self.effective_to or as_of <= self.effective_to)
@property
def url(self) -> str:
"""The eCFR page of this section as it was on the first day of this version."""
return f"https://www.ecfr.gov/on/{self.effective_from}/title-14/section-{self.section}"
def _blocks(element, out: list, label: list) -> None:
for child in element:
tag = child.tag
if tag in CONTAINERS:
_blocks(child, out, label)
elif tag == "P" or tag.startswith("FP") or tag == "FTNT": # FP: a flush paragraph; FTNT: a footnote
text = clean("".join(child.itertext()))
m = TOP_LABEL.match(text)
if tag == "P" and m and (not label[0] or ord(m.group(1)) == ord(label[0][1]) + 1 or m.group(1) == "a"):
label[0] = f"({m.group(1)})"
if text:
out.append(Block("paragraph", text, label[0]))
elif tag == "TABLE":
rows = [" | ".join(clean("".join(td.itertext())) for td in tr.iter("TD")) for tr in child.iter("TR")]
out.append(Block("table", "\n".join(r for r in rows if r.strip(" |")), label[0]))
elif tag in ("HD1", "HD2", "HD3"):
out.append(Block("heading", clean("".join(child.itertext())), label[0]))
elif tag == "XREF" and "amendment published" in "".join(child.itertext()):
out.append(Block("notice", clean("".join(child.itertext())), label[0]))
def parse_part(xml: bytes) -> dict[str, SectionText]:
"""One part on one date: {section number: its heading, blocks and source note}."""
root = ET.fromstring(xml)
sections = {}
for div in root.iter("DIV8"):
blocks: list = []
_blocks(div, blocks, [""])
cita = div.find("CITA")
sections[div.get("N")] = SectionText(div.get("N"), clean("".join(div.find("HEAD").itertext())),
tuple(blocks), clean("".join(cita.itertext())) if cita is not None else "")
return sections
def day_before(day: str) -> str:
return (date.fromisoformat(day) - timedelta(days=1)).isoformat()
def build_versions(snapshot) -> list[SectionVersion]:
"""Every version of every section in the snapshot, in order of section and date."""
versions = []
for part in sorted({f.part for f in snapshot.files if f.kind == "part"}):
current: dict[str, tuple[str, SectionText]] = {} # section -> (from, text) of the open version
for day in snapshot.dates(part):
sections = parse_part(snapshot.xml(part, day))
for number in list(current): # a section that disappears ends the day before
if number not in sections:
start, old = current.pop(number)
versions.append(_version(part, old, start, day_before(day)))
for number, now in sections.items():
if number in current and (current[number][1].heading, current[number][1].text) == (now.heading, now.text):
continue # same text: the same version goes on
if number in current:
start, old = current[number]
versions.append(_version(part, old, start, day_before(day)))
current[number] = (day, now)
for start, text in current.values():
versions.append(_version(part, text, start, ""))
return sorted(versions, key=lambda v: (_order(v.section), v.effective_from))
def _version(part: str, s: SectionText, start: str, end: str) -> SectionVersion:
pending = next((b.text for b in s.blocks if b.kind == "notice"), "")
return SectionVersion(s.section, part, s.heading, start, end, s.blocks, s.amended_by, pending)
def _order(section: str) -> list:
return [int(x) if x.isdigit() else x for x in re.split(r"(\d+)", section)]
def in_force(versions: list[SectionVersion], section: str, as_of: str) -> SectionVersion | None:
return next((v for v in versions if v.section == section and v.in_force(as_of)), None)
def coverage(as_of: str, snapshot) -> str:
"""'before': the snapshot holds no version on that date; 'after': the date is later than the day
the eCFR was up to date to, so a newer change may be missing; 'inside' otherwise."""
if as_of < snapshot.history_start:
return "before"
if as_of > snapshot.current_to:
return "after"
return "inside"

Trois choix comptent. Une version porte le nom de son premier jour. Son nom est donc aussi sa date de début. L’avis « Link to an amendment published at … » (lien vers une modification publiée à…) reste dans un bloc distinct, marqué notice (avis). Le modèle doit le voir, et pending (en attente) indique qu’un changement s’en vient. Enfin, coverage() pose une question distincte du filtre : « L’instantané peut-il répondre pour cette date? »

Exécutez les tests, puis examinez un article au fil du temps :

Fenêtre de terminal
python -m pytest
python -m regulations_assistant versions 254.4
Output (the end of pytest, then the versions)
========================= 9 passed, 9 skipped in 0.07s =========================
254.4 2017-01-01 to 2021-01-12 | 71 words | § 254.4 Carrier liability.
254.4 2021-01-13 to 2021-04-12 | 83 words | § 254.4 Carrier liability. (amendment published, not yet in force)
254.4 2021-04-13 to 2024-10-23 | 71 words | § 254.4 Carrier liability.
254.4 2024-10-24 to 2025-01-21 | 83 words | § 254.4 Carrier liability. (amendment published, not yet in force)
254.4 2025-01-22 to today | 71 words | § 254.4 Carrier liability.
5 versions of 1 section

Ce que cela nous apprend. Un article court compte cinq versions. Trois présentent la règle avec un nouveau montant, et deux présentent l’ancienne règle avec un avis ajouté. Pour les deux parties, versions trouve 44 versions de 18 articles. Un test les compare à la liste des versions de l’eCFR : les mêmes 44 articles et dates. Notre comparaison des textes n’a donc manqué aucun changement et n’en a inventé aucun.

Découpez chaque version en segments comme dans Découper de façon réfléchie. Coupez entre les paragraphes de premier niveau, à 200 mots maximum, jamais à l’intérieur d’un bloc. Deux éléments sont nouveaux. L’identifiant du segment est un hachage de l’article, du premier jour de la version et des positions. Ainsi, le même paragraphe dans six versions reçoit six identifiants. Une citation pointe toujours vers une seule version. Chaque segment porte aussi les dates de sa version et son adresse eCFR, https://www.ecfr.gov/on/2021-04-13/title-14/section-250.5. Cette adresse ouvre le texte tel qu’il était ce jour-là.

Créez regulations_assistant/chunk.py :

regulations_assistant/chunk.py
"""Chunks of section versions, with stable IDs and the version's dates, as in the course's policy assistant.
A short section version is one chunk. A long one (250.5 has about 1,000 words) is cut between its
top-level paragraphs, (a), (b), ..., into chunks of at most MAX_WORDS words. A paragraph that is
longer than that is cut between its blocks; a block (one sub-paragraph, or a table) is never cut.
Every chunk starts with the section's heading, so "(b) ... $1,550" still says which section it is.
The chunk ID is the first 12 hexadecimal characters of SHA-256 over
"section|effective_from|start|end": two versions of the same paragraph get two IDs, so a citation
always points to one version. Every chunk carries the version's dates; the date filter and the
citation checks use them.
"""
import hashlib
from dataclasses import asdict, dataclass
from .versions import SectionVersion
MAX_WORDS = 200
def chunk_id(section: str, effective_from: str, start: int, end: int) -> str:
return hashlib.sha256(f"{section}|{effective_from}|{start}|{end}".encode("utf-8")).hexdigest()[:12]
@dataclass(frozen=True)
class Chunk:
chunk_id: str
section: str # 250.5
part: str # 250
heading: str # § 250.5 Amount of denied boarding compensation ...
version: str # a version is named by its first day (= effective_from)
effective_from: str
effective_to: str # "" = still in force on the day the eCFR was up to date to
position: int
start: int # character positions in the version's text
end: int
paragraphs: str # the top-level paragraphs it holds, for example "(a),(b)"
text: str # what is indexed and shown to the model
url: str # the eCFR page of the section on the version's first day
pending: str # the eCFR's notice of a published amendment not yet in force, or ""
def to_dict(self) -> dict:
return asdict(self)
def in_force(self, as_of: str) -> bool:
return self.effective_from <= as_of and (not self.effective_to or as_of <= self.effective_to)
def words(spans: list) -> int:
return sum(len(block.text.split()) for block, _, _ in spans)
def chunks_of(version: SectionVersion, max_words: int = MAX_WORDS) -> list[Chunk]:
spans, pos = [], 0 # (block, start, end) in the version's text
for block in version.blocks:
spans.append((block, pos, pos + len(block.text)))
pos += len(block.text) + 1 # the blocks are joined with "\n"
paragraphs: list[list] = [] # the blocks of each top-level paragraph; a notice joins the next
for span in spans:
if paragraphs and (span[0].paragraph == paragraphs[-1][-1][0].paragraph
or paragraphs[-1][-1][0].kind == "notice"):
paragraphs[-1].append(span)
else:
paragraphs.append([span])
parts: list[list] = [[]]
for paragraph in paragraphs:
if parts[-1] and words(parts[-1]) + words(paragraph) > max_words:
parts.append([]) # the paragraph does not fit: start a new chunk
for span in paragraph: # a paragraph too long for one chunk is cut between blocks
if parts[-1] and words(parts[-1]) + words([span]) > max_words:
parts.append([])
parts[-1].append(span)
return [_make(version, part, i) for i, part in enumerate(parts)]
def _make(version: SectionVersion, spans: list, position: int) -> Chunk:
start, end = (spans[0][1], spans[-1][2]) if spans else (0, 0) # a reserved section has no text
labels = list(dict.fromkeys(block.paragraph for block, _, _ in spans if block.paragraph))
body = "\n".join(block.text for block, _, _ in spans)
return Chunk(
chunk_id=chunk_id(version.section, version.effective_from, start, end), section=version.section,
part=version.part, heading=version.heading, version=version.effective_from,
effective_from=version.effective_from, effective_to=version.effective_to, position=position,
start=start, end=end, paragraphs=",".join(labels), text=f"14 CFR {version.heading}\n{body}".strip(),
url=version.url, pending=version.pending)
def chunk_all(versions: list[SectionVersion]) -> list[Chunk]:
return [c for v in versions for c in chunks_of(v)]

Exécutez les tests, construisez l’index et examinez les segments d’un article :

Fenêtre de terminal
python -m pytest
python -m regulations_assistant ingest
python -m regulations_assistant chunks 254.4
Sortie
======================== 13 passed, 5 skipped in 0.12s =========================
index index.sqlite: 134 chunks from 44 section versions | embeddings intfloat/multilingual-e5-small | 27.9 s
db8de54f5d91 2017-01-01 to 2021-01-12 #0 - 77 words | On any flight segment using large aircraft,…
cb303a8b8934 2021-01-13 to 2021-04-12 #0 - 89 words | Link to an amendment published at 86 FR 253…
e9c55bed3e23 2021-04-13 to 2024-10-23 #0 - 77 words | On any flight segment using large aircraft,…
50c05ac5e1c7 2024-10-24 to 2025-01-21 #0 - 89 words | Link to an amendment published at 89 FR 848…
ef9d00e64081 2025-01-22 to today #0 - 77 words | On any flight segment using large aircraft,…
5 chunks

Ce que cela nous apprend. Cinq versions, cinq segments, cinq identifiants et presque le même texte. Seuls le montant et l’avis dans deux versions diffèrent. Pour un moteur de recherche, ils sont presque identiques. Le segment médian compte 153 mots. ingest a pris 27,9 secondes sur notre Mac, surtout pour charger le modèle e5.

L’index contient toutes les versions de chaque article. Le filtre de date de Recherche lexicale, dense et hybride garde seulement les segments en vigueur à la date de la question. Il s’exécute avant que la recherche choisisse les 5 premiers résultats. La commande de recherche peut le désactiver. Essayez la question Q03, pour un vol le 10 juin 2022, des deux façons :

Fenêtre de terminal
python -m regulations_assistant search "A passenger was denied boarding involuntarily on an oversold domestic flight on 10 June 2022. The airline rebooked them on a flight that arrived 90 minutes after their original flight. What is the most the airline had to pay as denied boarding compensation?" --as-of 2022-06-10

Exécutez ensuite la même commande avec --as-of none.

Q03, un vol le 10 juin 2022. À gauche, sans filtre de date, les 5 premiers résultats du modèle de reclassement : article 250.9(b), du 13 janvier 2021 au 12 avril 2021; 250.9(b), du 1er janvier 2017 au 12 janvier 2021; 250.9(b), du 13 janvier 2021 au 12 avril 2021; 250.5(a), du 13 janvier 2021 au 12 avril 2021; 250.9(b), du 13 janvier 2021 au 12 avril 2021. Les cinq portent la mention hors vigueur. chat-small a répondu : les règlements dans les passages n'indiquent pas le maximum selon la version en vigueur le 10 juin 2022. À droite, avec le filtre de date : 250.2b(a),(b), du 13 avril 2021 à aujourd'hui; trois segments de 250.9(b), du 13 avril 2021 au 23 octobre 2024; 250.5(a), du 2 août 2021 au 23 octobre 2024. Les cinq sont en vigueur. chat-small a répondu : au moins 200 % du prix du billet ou 775 $, selon le montant le plus bas, versions en vigueur du 2 août 2021 au 23 octobre 2024.
Une recherche réelle (Q03, reclassement). Sans le filtre, les cinq places vont à des versions terminées en 2021. Avec lui, les cinq sont en vigueur à la date du vol.
Description textuelle du diagramme

Sans le filtre, les 5 premiers résultats du modèle de reclassement (reranker) contiennent quatre segments de versions terminées en avril 2021. Le cinquième vient d’une version terminée en janvier 2021. Aucune n’était en vigueur le 10 juin 2022, et la bonne version n’y figure pas. Le modèle a vu les dates dans les en-têtes et s’est abstenu. Avec le filtre, les cinq segments sont en vigueur à cette date. Ils comprennent le § 250.5(a) de la version du 2 août 2021, et le modèle a répondu 775 $.

Les anciennes versions occupent les premières places parce qu’elles ne sont pas erronées sur le plan des mots. Elles décrivent la même règle, avec un autre montant. Le modèle de reclassement ne peut pas savoir que la date de la question les rend non pertinentes. Mesurez cela sur les 26 questions ayant des passages pertinents, avec chaque méthode de recherche, filtre activé puis désactivé :

Fenêtre de terminal
python -m regulations_assistant eval --method rerank
python -m regulations_assistant eval --method rerank --no-date-filter
Sortie
rerank, top 5, date filter on: hit@5 0.923 | recall@5 0.865 | precision@5 0.462 | MRR 0.827 (26 questions with relevant passages)
chunks not in force on the question's date, in the top 5: 0 (in 0 of 34 questions); first chunk from another version of the right paragraph: 0
rerank, top 5, date filter off: hit@5 0.731 | recall@5 0.500 | precision@5 0.169 | MRR 0.415 (26 questions with relevant passages)
chunks not in force on the question's date, in the top 5: 132 (in 33 of 34 questions); first chunk from another version of the right paragraph: 12

Un segment compte comme pertinent seulement s’il vient de la bonne version. Le même paragraphe d’une autre version ne compte pas comme résultat pertinent. Les autres méthodes, avec --method bm25, dense et hybrid, donnent :

Méthode hit@5, filtre activé hit@5, filtre désactivé MRR activé / désactivé Segments hors vigueur parmi les 5 premiers, filtre désactivé (sur 170)
BM25 0.885 0.731 0.673 / 0.476 115
Dense (e5) 0.885 0.538 0.731 / 0.332 136
Hybride (RRF) 0.846 0.577 0.708 / 0.396 121
Reclassement 0.923 0.731 0.827 / 0.415 132
  • Rerank, with the date filter
  • Rerank, without the date filter (dashed)
recall@k du modèle de reclassement sur les 26 questions ayant des passages pertinents, avec et sans filtre de date (instantané du 9 octobre 2026, e5, modèle de reclassement du cours). Pertinent signifie la bonne version.
Description textuelle du graphique

Deux courbes montrent le rappel à k = 1, 3, 5 et 10. Avec le filtre de date : 0,481, 0,788, 0,865, 1,000. Sans lui : 0,173, 0,365, 0,500, 0,635. La courbe sans filtre est plus basse à chaque k, et l’écart ne se referme pas à k = 10.

Ce que cela nous apprend. Sans le filtre, environ quatre des cinq premières places vont à des versions hors vigueur à la date de la question. Toutes les méthodes perdent, surtout la recherche dense. Pour un modèle de plongement, six versions d’un paragraphe signifient la même chose. L’écart persiste à k = 10. Ajouter des passages ne règle donc pas le problème. Avec le filtre, le modèle de reclassement trouve la bonne version pour 24 des 26 questions, en environ 0,5 seconde chacune. Ses deux échecs (Q22, Q23) concernent des articles stables que d’autres passages ont exclus des premiers résultats.

Avant de faire confiance à l’instantané, mesurez ce que le modèle sait sans lui. Suivez Ce qu’est la génération augmentée par récupération. Posez Q04 à chat-small, pour un vol le 20 décembre 2024, sans passages :

Fenêtre de terminal
python -m regulations_assistant ask Q04 --closed-book
Sortie
Question (2024-12-20): A passenger was denied boarding involuntarily on an oversold domestic flight on 20 December 2024. The airline rebooked them on a flight that arrived 90 minutes after their original flight. What is the most the airline had to pay as denied boarding compensation?
The model alone, without the regulations:
For a 90-minute arrival delay, the airline owed 200% of the passenger’s one-way fare, capped at **$1,075**.
gpt-6-luna-2026-09-22 | 106 tokens in, 329 out | 3.112 s

Cette réponse est assurée et fausse. Le montant de 1 075 $ a été publié le 24 octobre 2024, mais est entré en vigueur le 22 janvier 2025. Le 20 décembre 2024, la règle était de 775 $. Le modèle connaît le nouveau montant, mais pas sa date d’entrée en vigueur.

Sur l’ensemble du jeu, le modèle seul en sait beaucoup. Selon les corrigés, il répond correctement à 20 des 26 questions auxquelles on peut répondre. Nous avons lu les 6 échecs. Trois utilisent la mauvaise version (Q04; 1 350 $ pour septembre 2021, Q10; l’ancien numéro de formulaire pour 2023, Q21). Un manque la règle de 2021 sur les passagers déjà embarqués (Q19). Un autre donne le mauvais article comme justification (Q17). Le dernier est vide : le raisonnement caché a utilisé les 2 000 jetons (tokens) disponibles (Q15). Le modèle a aussi répondu aux 8 questions auxquelles l’instantané ne peut pas répondre.

Une de ces 8 réponses est particulièrement instructive : Q28, pour un vol le 10 mars 2015. Le modèle seul a dit 650 $, sans source. Le Federal Register montre que c’est exact. La limite est passée à 675 $ le 25 août 2015. Notre instantané, qui commence en 2017, ne peut pas le savoir. Gardez cela en tête pour la partie 6.

Le pipeline de réponse utilise la fonction ask() du cours, avec deux ajouts. Avant la recherche, coverage() détermine si la date se trouve dans l’instantané. Avant le 1er janvier 2017, l’assistant répond sans modèle. Après la réponse, une nouvelle vérification s’exécute : chaque passage cité doit être en vigueur à la date de la question. Les vérifications de Répondre et citer ne peuvent pas détecter ce problème. « 775 $ », avec une citation de la version de 2021 pour un vol en 2025, réussit les cinq vérifications.

L’application, et non le modèle, écrit chaque ligne de source à partir de l’index. Elle indique l’article, le paragraphe, les dates de la version et l’adresse eCFR. Créez regulations_assistant/sources.py :

regulations_assistant/sources.py
"""Show an answer the way a case worker should see it, and check that it cites the right version.
The model gives claims and passage IDs. The application writes everything else from the index:
- every source with its section, the dates of its version, and the eCFR address of that version;
- a line about the question's date when the snapshot cannot be sure (after the day the eCFR was up
to date to: a newer change would be missing);
- the notice: what this is not, and that the eCFR is not an official legal edition.
`check_versions` is the citation check that this case study adds: every cited passage must come
from a version in force on the question's date. The course's checks (cite.py) would pass a claim
that cites the $775 version for a flight in 2025; this one flags it as wrong_version.
"""
from .snapshot import ATTRIBUTION
NOTICE = ("Question date {as_of}. This is what the regulation's text said, not legal advice. "
"Check the eCFR page and the Federal Register before you rely on it.")
AFTER = ("Note: {as_of} is after {current_to}, the day the eCFR was up to date to when this snapshot was made. "
"A change made after {current_to} is not in it.")
def check_versions(answer, store, as_of: str) -> list[str]:
"""The cited IDs whose version was not in force on as_of (in order of first citation)."""
wrong = []
for cid in answer.cited():
chunk = store.chunk(cid)
if chunk is not None and not chunk.in_force(as_of):
wrong.append(cid)
return wrong
def dates(chunk) -> str:
return f"in force {chunk.effective_from} to {chunk.effective_to or 'today'}"
def render(answer, checks: list, store, as_of: str, snapshot) -> str:
head = [AFTER.format(as_of=as_of, current_to=snapshot.current_to), ""] if as_of > snapshot.current_to else []
foot = ["", NOTICE.format(as_of=as_of), ATTRIBUTION]
if not answer.answerable:
return "\n".join(head + [f"Answer: {answer.answer}"] + foot)
sources = [c for c in (store.chunk(i) for i in answer.cited()) if c is not None]
number = {c.chunk_id: i for i, c in enumerate(sources, 1)}
wrong = set(check_versions(answer, store, as_of))
lines = head + [f"Answer: {answer.answer}", ""]
for claim, check in zip(answer.claims, checks):
refs = "".join(f"[{number[i]}]" for i in claim.chunk_ids if i in number) or "[no source]"
problems = list(check.problems) + [f"wrong_version:{i}" for i in claim.chunk_ids if i in wrong]
lines.append(f"- {claim.text} {refs}" + (f" (check: {'; '.join(problems)})" if problems else ""))
lines += ["", f"Sources (question date {as_of}):"]
for i, c in enumerate(sources, 1):
flag = "" if c.chunk_id not in wrong else f" NOT IN FORCE ON {as_of}"
lines.append(f"[{i}] 14 CFR {c.section} {c.paragraphs}, version {dates(c)}{flag}")
lines.append(f" {c.url}")
return "\n".join(lines + foot)

Exécutez les tests, puis posez Q04 avec l’instantané :

Fenêtre de terminal
python -m pytest
python -m regulations_assistant ask Q04
Output (recorded answer, chat-small)
============================== 18 passed in 0.14s ==============================
Question (2024-12-20): A passenger was denied boarding involuntarily on an oversold domestic flight on 20 December 2024. The airline rebooked them on a flight that arrived 90 minutes after their original flight. What is the most the airline had to pay as denied boarding compensation?
Date filter: 2024-12-20
Passages: 1c4d6d940b9a, a17194a3d78a, 6dda423ba161, 2f1842e9ccb9, 503f2a66ff16 (1680 tokens, estimated)
Answer: Under the versions in force from 2024-10-24 to 2025-01-21, a 90-minute arrival delay falls in the more-than-one-but-less-than-two-hour category. The regulation specifies compensation of at least 200% of the fare or $775, whichever is lower, so $775 is the highest amount specified by that formula; the passenger’s fare is not provided.
- The applicable category is an alternate arrival more than one hour but less than two hours after the original planned arrival. [1][2]
- For domestic travel in that category, the regulation specifies compensation of at least 200% of the fare or $775, whichever is lower. [1][2]
Sources (question date 2024-12-20):
[1] 14 CFR 250.5 (a), version in force 2024-10-24 to 2025-01-21
https://www.ecfr.gov/on/2024-10-24/title-14/section-250.5
[2] 14 CFR 250.9 (b), version in force 2024-10-24 to 2025-01-21
https://www.ecfr.gov/on/2024-10-24/title-14/section-250.9
Question date 2024-12-20. This is what the regulation's text said, not legal advice. Check the eCFR page and the Federal Register before you rely on it.
Source: eCFR (ecfr.gov), 14 CFR Parts 250 and 254, fetched 2026-10-09. US government work, public domain. The eCFR is not an official legal edition of the CFR.
gpt-6-luna-2026-09-22 | 1927 tokens in, 915 out | 7.286 s

Ce que cela nous apprend. Le passage du § 250.5 vu par le modèle commence par « Link to an amendment published at 89 FR 84818 » (lien vers une modification publiée à 89 FR 84818). Le modèle n’a pas utilisé le montant à venir. Il a nommé la version et ses dates, comme le demande la règle 4 de la requête (prompt). Les deux sources renvoient au texte tel qu’il était le 24 octobre 2024. Le modèle seul a dit 1 075 $ pour la même question.

Maintenant, une date avant l’instantané :

Fenêtre de terminal
python -m regulations_assistant ask Q28
Sortie
Question (2015-03-10): A passenger was denied boarding involuntarily on an oversold domestic flight on 10 March 2015. The airline rebooked them on a flight that arrived 90 minutes after their original flight. What is the most the airline had to pay as denied boarding compensation?
No search and no model call: the date is outside the snapshot.
Answer: The snapshot holds no version in force on 2015-03-10: its history starts on 2017-01-01. The rule may have existed then, but this assistant cannot show it.
Question date 2015-03-10. This is what the regulation's text said, not legal advice. Check the eCFR page and the Federal Register before you rely on it.
Source: eCFR (ecfr.gov), 14 CFR Parts 250 and 254, fetched 2026-10-09. US government work, public domain. The eCFR is not an official legal edition of the CFR.

C’est l’abstention de Traiter les preuves difficiles, décidée par le code plutôt que par le modèle. « Aucune version dans l’instantané » ne signifie pas « aucune règle ». La réponse indique que la règle a pu exister.

Erreur courante

La réponse est juste, pour une autre date

Symptôme. Une personne responsable du dossier rédige une plainte avec le montant donné par l’assistant. La compagnie aérienne répond que le montant n’était pas en vigueur le jour du vol.

Cause. Les vérifications des citations ont réussi. L’identifiant était réel, figurait dans le contexte, et le montant était dans le passage. Mais le passage venait d’une autre version. Rien ne comparait la version à la date.

Correction. Filtrez par date avant de choisir les k premiers résultats, pour empêcher la citation d’autres versions. Vérifiez chaque passage cité par rapport à la date dans le code (check_versions). Affichez la version et les dates de chaque source sur la carte de réponse. Une mauvaise version devient alors visible pour le lecteur, pas seulement dans un journal.

Avec --no-date-filter, le projet exécute un assistant naïf, sans filtre de date ni vérification de couverture. Nous avons enregistré chat-small sur les 34 questions de cette façon. Commencez par Q03, la recherche de la partie 3 :

Fenêtre de terminal
python -m regulations_assistant ask Q03 --no-date-filter
Sortie (extrait)
Date filter: off
Passages: 5077dd776912, 69e13aa7108b, 6f13357b3c16, 74b46264229e, 4123ef37981b (1680 tokens, estimated)
Answer: The regulations in the passages do not say what the maximum denied boarding compensation was under the version in force on 2022-06-10.

Le modèle n’a pas utilisé un ancien montant. Il a lu les dates dans les en-têtes et vu qu’aucun passage n’était en vigueur le 10 juin 2022. Il s’est donc abstenu. Comme pour les politiques de Larkfield, les dates dans l’en-tête font une grande partie du travail, mais la bonne réponse est perdue. Q05 a échoué de la même façon.

Maintenant Q28, le vol de mars 2015, sans vérification de couverture :

Fenêtre de terminal
python -m regulations_assistant ask Q28 --no-date-filter
Sortie (extrait)
Answer: Under 14 CFR 250.9, in the version in force from 2017-01-01 or earlier to 2021-01-12, a 90-minute arrival delay falls in the tier requiring 200% of the fare, capped at $675. Therefore, the most the airline had to pay was $675.
- For domestic travel, alternate transportation arriving more than one hour but less than two hours after the original planned arrival time requires compensation of 200% of the fare, capped at $675, under the version in force from 2017-01-01 or earlier to 2021-01-12. [1] (check: wrong_version:69e13aa7108b)
- A 90-minute delay is more than one hour and less than two hours, so the maximum compensation was $675. [1] (check: wrong_version:69e13aa7108b)
Sources (question date 2015-03-10):
[1] 14 CFR 250.9 (b), version in force 2017-01-01 to 2021-01-12 NOT IN FORCE ON 2015-03-10

C’est une véritable réponse fondée sur la mauvaise version, pire que celle du modèle seul. L’en-tête disait « 2017-01-01 or earlier » (1er janvier 2017 ou avant). Le modèle l’a interprété comme « aussi en 2015 ». La vraie limite était alors de 650 $. Seule la vérification de couverture dans le code empêche cela. Ici, la vérification de version l’a détecté, et la carte affiche « NOT IN FORCE » (hors vigueur). La question sur les bagages de juin 2016 (Q29) a obtenu 3 500 $ de la même façon. Ce montant était juste par hasard.

Le troisième cas est plus discret. Pour le vol vers Londres le 1er juin 2021 (Q09), le § 250.5(b) de cette version a été exclu des 5 premiers résultats. Le modèle a répondu à partir de l’avis du § 250.9, lui aussi en vigueur, avec 1 550 $. C’est l’erreur réelle des premières vérifications. Avec le filtre, il a vu les deux textes et répondu 1 350 $ à partir du § 250.5. Aucune réponse ne mentionnait le conflit. Une personne doit donc le faire, comme dans les preuves contradictoires.

Toutes les exécutions ci-dessous utilisent les mêmes 34 questions, le même instantané, la requête answer_v1 et les 5 premiers résultats. Le simulateur rejoue les réponses enregistrées sur Azure :

Fenêtre de terminal
python -m regulations_assistant eval --answers
python -m regulations_assistant eval --answers --no-date-filter
python -m regulations_assistant eval --answers --method bm25
Output (the first run)
chat-small, rerank, top 5, answer_v1, date filter on: 34 questions
answerable, correct: 24/26
answerable, abstained: 2/26
no answer expected, correct: 6/8
shown but wrong: 2 (Q16, Q33)
citations of the right version: 33/33 (100.0%)
claims flagged by the checks: 3 of 39
model calls: 32 (the other questions were answered without one)
tokens in / out (median): 1794 / 328
latency median / p95: 2.86 / 4.43 s (recorded)
cost per question (median): US$0.00035
chat-small, 34 questions Reclassement, filtre activé Reclassement, filtre désactivé BM25, filtre activé
Réponse possible, correcte 24/26 20/26 20/26
Réponse possible, abstention 2 5 5
Aucune réponse attendue, correct 6/8 4/8 7/8
Réponse affichée mais fausse 2 (Q16, Q33) 5 (Q09, Q16, Q28, Q29, Q33) 2 (Q09, Q33)
Citations de la bonne version 33/33 23/25 23/23
Latence, médiane / p95 (enregistrée) 2.86 / 4.43 s 2.61 / 4.22 s 2.84 / 4.96 s
Coût par question (médiane) 0,00035 $ US 0,00030 $ US 0,00032 $ US

Ce que cela nous apprend. Le filtre de date a donné 4 bonnes réponses de plus, avec le même modèle et le même coût. Il a gardé chaque citation sur la bonne version. Il a transformé trois réponses affichées mais fausses en bonnes réponses ou en abstentions honnêtes. Sans lui, les pertes sont surtout des abstentions. La bonne version a été exclue des premiers résultats, et les en-têtes ont empêché le modèle d’en utiliser une mauvaise.

Deux réponses affichées restent fausses. Pour Q16 (2020), les deux modèles ont dit « Oui ». Le § 250.9 dit que la compagnie aérienne paie. Ils en ont donc déduit qu’elle doit offrir de payer sans demande. Cette règle n’est arrivée qu’en 2021. Pour Q33, un vol vers Paris, le modèle a donné le minimum intérieur, 4 700 $. La recherche avait manqué le § 254.2, qui limite la partie 254 aux vols intérieurs. Ce sont des échecs de Trouver où ça échoue. Le premier est une déduction que le texte ne fait pas. Le second est un passage manqué sur le champ d’application.

Comparons maintenant les modèles. Nous avons enregistré chat-strong sur un échantillon : les 17 questions à numéro pair, qui comprennent tous les types. Comparez-le à chat-small sur les mêmes 17 questions :

Fenêtre de terminal
python -m regulations_assistant eval --answers --ids Q02,Q04,Q06,Q08,Q10,Q12,Q14,Q16,Q18,Q20,Q22,Q24,Q26,Q28,Q30,Q32,Q34 --model chat-strong
Sur les 17 questions paires chat-small, filtre activé chat-strong, filtre activé chat-small, filtre désactivé chat-strong, filtre désactivé
Réponse possible, correcte 10/12 11/12 9/12 9/12
Aucune réponse attendue, correct 4/5 4/5 3/5 4/5
Réponse affichée mais fausse 1 (Q16) 1 (Q16) 2 (Q16, Q28) 1 (Q28)
Citations de la bonne version 13/13 14/14 10/11 9/10
Latence, médiane / p95 (enregistrée) 2.68 / 4.16 s 4.34 / 4.86 s 2.73 / 4.93 s 3.86 / 7.39 s
Coût par question (médiane) 0,00031 $ US 0,00574 $ US 0,00030 $ US 0,00556 $ US

Ce que cela nous apprend. Le modèle plus puissant a obtenu une bonne réponse de plus : Q24, qui exige deux passages. Il a fait la même déduction pour Q16. Sans le filtre, il a aussi dit 675 $ pour 2015. Le filtre de date a donc aidé davantage que le modèle plus puissant, sans coût supplémentaire. chat-strong coûte environ 18 fois plus par question. Sans gain clair, nous choisissons chat-small avec le filtre. Avec le modèle de reclassement (environ 0,5 seconde) et le modèle (environ 2,9 secondes), la personne responsable du dossier attend environ 3,4 secondes.

La conception finale utilise la requête answer_v1, chat-small, les 5 premiers résultats du reclassement, le filtre de date et la vérification de couverture. Elle vérifie la version de chaque citation. Voici une question du début à la fin, pour une date après l’instantané :

Fenêtre de terminal
python -m regulations_assistant ask Q30
Output (recorded answer, chat-small)
Question (2027-02-15): A passenger was denied boarding involuntarily on an oversold domestic flight on 15 February 2027. The airline offered no other flight arriving within two hours of the original one. What was the compensation limit?
Date filter: 2027-02-15
Passages: 29aa7740d1df, cde04502622d, 6dda423ba161, 56caaf943f74, 786f4a9fd5cb (1696 tokens, estimated)
Note: 2027-02-15 is after 2026-10-07, the day the eCFR was up to date to when this snapshot was made. A change made after 2026-10-07 is not in it.
Answer: In the version in force from 2025-01-22 to today, compensation is at least 400% of the fare, capped at $2,150 (whichever is lower).
- For an involuntary denial on a domestic oversold flight with no alternate transportation planned to arrive less than two hours after the original flight, compensation is at least 400% of the fare or $2,150, whichever is lower. [1]
Sources (question date 2027-02-15):
[1] 14 CFR 250.9 (b), version in force 2025-01-22 to today
https://www.ecfr.gov/on/2025-01-22/title-14/section-250.9
Question date 2027-02-15. This is what the regulation's text said, not legal advice. Check the eCFR page and the Federal Register before you rely on it.
Source: eCFR (ecfr.gov), 14 CFR Parts 250 and 254, fetched 2026-10-09. US government work, public domain. The eCFR is not an official legal edition of the CFR.
gpt-6-luna-2026-09-22 | 1882 tokens in, 281 out | 2.606 s

La réponse utilise la dernière version et nomme son premier jour. L’application ajoute la note. Le § 250.5(e) dit que le ministère révise les montants tous les deux ans. Une personne responsable du dossier doit vérifier l’eCFR pour un vol en 2027.

Pour poser votre propre question, vous avez besoin d’un modèle actif. Le simulateur connaît seulement les questions enregistrées et le dit : « No recording for this request » (aucun enregistrement pour cette requête). Copiez .env.example vers .env. Choisissez un modèle local gratuit avec Ollama, ou votre propre clé, comme dans le cours. Vos réponses différeront des réponses enregistrées. Le filtre, les vérifications et les sources fonctionnent de la même façon.

  • Le filtre agit dans la recherche. Sans lui, 132 des 170 places parmi les 5 premiers résultats allaient à des versions hors vigueur. Les bonnes réponses sont passées de 24 à 20.
  • Les dates dans l’en-tête limitent les dégâts. Dans l’instantané, les modèles se sont abstenus plutôt que d’utiliser une mauvaise version.
  • Hors de l’instantané, le code décide. Les réponses fondées sur la mauvaise version concernaient des dates avant 2017.
  • Le modèle seul connaît les montants, pas les dates : 1 075 $ pour décembre 2024, et aucune source pour quoi que ce soit.
  • Une version peut être erronée. En 2021, deux articles en vigueur se contredisaient. Une personne doit trancher.
  • Limites. Une personne a rédigé les 34 questions. Seulement 26 ont des passages. Une question fait donc varier une mesure de récupération d’environ 0,04. Deux courtes parties d’un seul titre. Réponses enregistrées une fois; une autre exécution peut différer. Les vérifications automatiques laissent passer des erreurs. Nous avons donc lu chaque réponse.
  • Ce n’est pas un avis juridique. L’assistant rapporte le texte d’une version, pas ce qu’un passager devrait recevoir. Les cas réels dépendent du tarif, du billet et du motif du refus. Une personne vérifie tout élément important, et un avocat tranche les cas difficiles.
  • Montrez toujours la version et ses dates. Chaque source renvoie au texte eCFR du premier jour de la version. Le lecteur peut ainsi repérer une mauvaise version, même si toutes les autres vérifications réussissent.
  • L’eCFR n’est pas le texte officiel, et il peut avoir du retard. Les textes officiels sont le CFR annuel et le Federal Register. L’instantané était à jour au 7 octobre 2026. Actualisez-le, refaites l’évaluation et vérifiez les modifications du Federal Register qui ne figurent pas encore dans l’eCFR.
  • Indiquez ce que l’instantané ne couvre pas : les dates avant 2017, les bagages sur les vols internationaux, les retards, les remboursements et les règles sur le tarmac. L’assistant s’abstient; la personne qui traite le dossier doit savoir pourquoi.
  • Données personnelles. Gardez les noms et les réservations des appelants hors des journaux (logs). Ne les transmettez pas à un modèle hébergé sans consentement et sans contrat couvrant cette utilisation.

Ce projet montre une compétence absente de nombreux projets RAG : des réponses justes pour une date donnée. Il utilise une source officielle et des versions vérifiées à partir de la liste de l’éditeur. Il comprend un filtre évalué, une vérification des versions dans le code et une comparaison honnête qui inclut les coûts.

Quoi mettre dans le dépôt :

regulations-assistant/
README.md what it is, what it is not, how to run and check it, the results
DATASET.md source, licence, fetch date, history start, up-to-date date, checksums, limits
LICENSE MIT, for the code
regulations/ the snapshot (public domain, with the source named in README and DATASET.md)
regulations_assistant/ the code, including versions.py, chunk.py and sources.py
questions/ the 34 dated questions with keys and passages
recordings/ the recorded answers, so that anyone can replay them
scripts/ check_snapshot.py, fetch_snapshot.py, build_snapshot.py
tests/ pytest tests (18, no model download needed)

Un modèle de README :

README.md (outline)
# regulations-assistant
## What it is
A cited Q&A assistant over 14 CFR Parts 250 and 254 at every version date (eCFR snapshot of
2026-10-09), for passenger case workers. Every answer names its version. Not legal advice.
## Results (34 dated questions, recorded 2026-10-09)
- Search: rerank hit@5 0.923 with the date filter, 0.731 without; 132 of 170 top-5 places
went to versions not in force without it.
- Answers, chat-small + rerank: 24/26 right with the filter, 20/26 without;
33/33 citations of the right version.
- The model alone: 20/26 by the keys, wrong version for a published-not-in-force amount.
- chat-strong: 11/12 against 10/12 on a 17-question sample, about 18 times the cost.
## Design choices
- Official point-in-time API, one fetch, SHA-256 checked; versions checked against the eCFR's list.
- Chunk IDs include the version's first day; the date filter runs before the top k.
- A coverage check before the model; a version check on every citation.
## Limits
(one snapshot, two parts, history from 2017, one author's questions, the eCFR is not official)

Comment le décrire en entrevue : « J’ai créé un assistant qui répond à des questions sur un règlement à une date donnée. Avec un filtre par date, les réponses justes sont passées de 20 à 24 sur 26. Les 33 citations indiquaient toutes la bonne version. »

Allez plus loin : chacune de ces pistes constitue une bonne prochaine étape, avec un seul changement principal.

  • Une actualisation. Récupérez la liste des versions plus tard, ajoutez toute nouvelle date, reconstruisez et évaluez. Quelles réponses enregistrées ne sont plus à jour?
  • Le conflit, dans le code. Si deux passages en vigueur donnent des montants différents, montrez les deux et signalez la réponse, comme l’exige Q09.
  • Le Canada. Utilisez les deux versions XML du Règlement sur la protection des passagers aériens (DORS/2019-150) sur le GitHub de Justice Canada. Conservez la mention de source de la Licence du gouvernement ouvert – Canada.
  • Les passages sur le champ d’application. Incluez systématiquement les § 250.2 et § 254.2 dans chaque contexte, et mesurez de nouveau Q22 et Q33.

Vérifiez votre compréhension

Sans filtre par date, les modèles utilisaient rarement un ancien montant pour une date couverte par l'instantané. Un collègue conclut que le filtre est inutile : les dates dans les en-têtes suffisent. Qu'est-ce que cette conclusion oublie? Utilisez les chiffres de cette étude de cas.

Afficher la réponse

Elle oublie ce que les en-têtes ne peuvent pas faire : placer la bonne version parmi les cinq premiers résultats. Sans filtre, 132 des 170 places parmi les cinq premiers résultats revenaient à des versions non en vigueur. Le rappel@5 est passé de 0,865 à 0,500. Le modèle s’abstenait alors au lieu de répondre (Q03, Q05). Les réponses justes sont donc passées de 24 à 20 sur 26. Les en-têtes ont aussi échoué pour les dates antérieures à l’instantané. « 1er janvier 2017 ou avant » a mené à 675 $ pour 2015, alors que le bon montant était 650 $. Le filtre empêche les anciennes versions d’occuper le budget et d’apparaître dans les citations; les en-têtes montrent les dates au lecteur.

Vérifiez votre compréhension

Q04 porte sur un vol du 20 décembre 2024. Le passage du § 250.5 en vigueur ce jour-là commence par « Link to an amendment published at 89 FR 84818, Oct. 24, 2024. » (lien vers une modification publiée le 24 octobre 2024 dans 89 FR 84818). Pourquoi 775 $ est-il juste et 1 075 $ incorrect? Quels éléments du projet protègent la réponse?

Afficher la réponse

La modification a été publiée le 24 octobre 2024, mais elle est entrée en vigueur le 22 janvier 2025. Le 20 décembre 2024, la version en vigueur indiquait encore 775 $; l’avis annonçait seulement un changement à venir. Le filtre par date exclut de la recherche la version du 22 janvier 2025, qui indique 1 075 $. La règle 4 de la requête (prompt) précise qu’un avis publié ne change pas la règle avant l’entrée en vigueur de la nouvelle version. La vérification des versions signalerait une citation de la version de 2025. Le modèle seul, sans passages, a répondu 1 075 $.

Vérifiez votre compréhension

Pour le vol du 10 mars 2015 (Q28), pourquoi la conception finale répond-elle sans appeler de modèle? Pourquoi cette réponse n'est-elle pas « il n'y avait aucune règle »?

Afficher la réponse

L’historique de l’instantané commence le 1er janvier 2017. Aucune de ses versions ne peut donc être en vigueur en 2015. Un modèle ayant reçu la version de 2017 comme texte le plus proche a répondu 675 $, citant une version non en vigueur. Le montant réel était alors de 650 $. Le code prend donc la décision : coverage() renvoie « before » (avant), et l’assistant indique qu’il ne peut pas montrer la règle. Il ne dit pas qu’il n’y avait aucune règle. La partie 250 existait en 2015, et la note de source du § 250.5 énumère des modifications antérieures. Seul l’instantané ne remonte pas assez loin.

Récapitulatif

  • Un règlement change section par section. Déduisez les versions de chaque section à partir de copies datées, puis vérifiez-les avec la liste de l’éditeur.
  • Mettez le premier jour de la version dans l’identifiant du segment, et ses dates dans chaque segment.
  • Filtrez selon la date de la question avant de sélectionner les k premiers résultats. Sans filtre, quatre places sur cinq revenaient à des versions non en vigueur.
  • Décidez dans le code ce que l’instantané ne couvre pas : avant son historique, aucune réponse; après sa date, un avertissement.
  • Vérifiez la version de chaque citation selon la date. Montrez la version et ses dates dans chaque réponse.
  • Publication ne signifie pas entrée en vigueur : un avis de modification n’est pas la nouvelle règle.

Vous avez créé un assistant dont les réponses peuvent être vérifiées avec le texte en vigueur à la bonne date. La personne qui traite le dossier peut faire cette vérification. Le travail final vous demande de créer et de mesurer votre propre assistant.

Leçon suivante : Travail final