Phases 6 et 7 : application SvelteKit, PWA, systemd, documentation
Application — rendu serveur sur adapter-node, conforme au §3bis : - tableau de bord, recherche à facettes, fiche détaillée, calendrier - /a-propos et /methode prérendues ; tout le reste en rendu serveur - API JSON ouverte : /api/textes, /api/textes/[slug], /api/echeances - recherche FTS5 avec surlignage et classement bm25 pondéré (le titre pèse dix fois plus que les points clés) - facettes recalculées sur le résultat filtré par les AUTRES facettes : cocher un thème doit recompter les statuts encore disponibles, sinon les compteurs mentent Identité OKI sobrifiée : tokens en CSS vanilla, thème sombre par défaut et clair opt-in, or comme seule couleur d'action, flag-bar une fois par écran, polices Archivo/Inter auto-hébergées. Politique de sécurité de contenu interdisant toute requête tierce. Les statuts ne reposent jamais sur la seule couleur : libellé en toutes lettres et pastille de forme distincte. Budget respecté très largement : 6,6 Ko de JavaScript sur l'accueil (3 Ko compressés) pour un plafond de 100 Ko. Les faits clés — AFD à 500 €, saisine 2026-915 DC, statut — sont lisibles sans JavaScript. Trois défauts trouvés en testant l'application, pas en relisant le code : - v_textes est une vue, et une vue SQLite n'a pas de rowid : la jointure avec l'index plein texte échouait (migration 005) - ppl-montagne remontait en « pertinence forte » alors que le corpus dit « sans portée pour la Guadeloupe » — la cotation comptait le mot sans lire la négation. Idem pour pjl-logement et accord-globe. Corrigé par une lecture du voisinage, avec quatre tests de non-régression. - les libellés d'affichage étaient importés depuis /server dans un composant, ce que SvelteKit interdit à raison PWA : service worker réseau-d'abord pour les pages, cache-d'abord pour le coffre du build. Une veille législative ne doit pas servir une page périmée quand le réseau répond — une date de promulgation change tout. Les réponses issues du cache portent un en-tête qui le dit. systemd : timer du pipeline (6 h, 18 h, dimanche 9 h, avec dispersion et rattrapage), application activée par socket avec mise en sommeil à 300 s. Unités durcies, validées par systemd-analyze. Documentation : README d'installation sur Debian/Ubuntu vierge, README-pipeline avec le piège des deux couples d'identifiants PISTE, et scripts/verifier-conformite.sh qui contrôle mécaniquement le §3bis et le §8. 172 tests, npm run check à 0 erreur, make verifier au vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
feb5544599
commit
34d81c66a8
+75
-5
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -22,6 +22,33 @@ from pipeline import chemins
|
||||
from pipeline.modeles import Pertinence
|
||||
from pipeline.parseurs.dates_fr import sans_accents
|
||||
|
||||
# Tournures par lesquelles le corpus **écarte** un territoire qu'il vient de
|
||||
# nommer : « PPL montagne — sans portée pour la Guadeloupe », « PJL logement —
|
||||
# pas de volet outre-mer à ce stade ». Compter la mention sans lire la négation
|
||||
# donnerait à ces textes la cotation la plus forte, à l'exact inverse de ce que
|
||||
# la source affirme.
|
||||
NEGATIONS = (
|
||||
"sans portee",
|
||||
"sans objet",
|
||||
"sans incidence",
|
||||
"sans specificite",
|
||||
"sans volet",
|
||||
"sans consequence",
|
||||
"ne concerne pas",
|
||||
"n'est pas concerne",
|
||||
"pas concernee",
|
||||
"pas de volet",
|
||||
"pas d'incidence",
|
||||
"non concerne",
|
||||
"hors champ",
|
||||
"exclue",
|
||||
"exclus",
|
||||
)
|
||||
|
||||
# Fenêtre de voisinage, en caractères, autour d'une mention de territoire.
|
||||
_AVANT = 90
|
||||
_APRES = 60
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Cotation:
|
||||
@@ -30,12 +57,24 @@ class Cotation:
|
||||
pertinence: Pertinence | None
|
||||
score: int
|
||||
expressions: list[str]
|
||||
exclusions: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def note(self) -> str | None:
|
||||
if self.exclusions and not self.expressions:
|
||||
return (
|
||||
"Le rapport écarte explicitement ce territoire : "
|
||||
+ ", ".join(f"« {e} »" for e in self.exclusions[:3])
|
||||
)
|
||||
if not self.expressions:
|
||||
return None
|
||||
return "Détecté d'après : " + ", ".join(self.expressions[:6])
|
||||
|
||||
note = "Détecté d'après : " + ", ".join(self.expressions[:6])
|
||||
if self.exclusions:
|
||||
note += " — mais le rapport écarte par ailleurs : " + ", ".join(
|
||||
f"« {e} »" for e in self.exclusions[:2]
|
||||
)
|
||||
return note
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
@@ -60,18 +99,31 @@ def _motifs() -> list[tuple[re.Pattern[str], str, int]]:
|
||||
|
||||
|
||||
def coter(*fragments: str | None) -> Cotation:
|
||||
"""Cote la pertinence guadeloupéenne d'un ensemble de fragments de texte."""
|
||||
"""Cote la pertinence guadeloupéenne d'un ensemble de fragments de texte.
|
||||
|
||||
Une mention niée ne compte pas. « Sans portée pour la Guadeloupe » nomme le
|
||||
territoire pour l'écarter : la lire comme une pertinence forte inverserait
|
||||
le sens de la source.
|
||||
"""
|
||||
plat = sans_accents(" ".join(f for f in fragments if f)).lower()
|
||||
if not plat.strip():
|
||||
return Cotation(pertinence=None, score=0, expressions=[])
|
||||
|
||||
score = 0
|
||||
trouvees: list[str] = []
|
||||
exclusions: list[str] = []
|
||||
|
||||
for motif, expression, poids in _motifs():
|
||||
if motif.search(plat):
|
||||
positions = [m.start() for m in motif.finditer(plat)]
|
||||
if not positions:
|
||||
continue
|
||||
|
||||
retenues = [p for p in positions if not _est_niee(plat, p)]
|
||||
if retenues:
|
||||
score += poids
|
||||
trouvees.append(expression)
|
||||
else:
|
||||
exclusions.append(_extrait_autour(plat, positions[0]))
|
||||
|
||||
seuils = _configuration()["guadeloupe"]["seuils"]
|
||||
if score >= seuils["forte"]:
|
||||
@@ -80,10 +132,28 @@ def coter(*fragments: str | None) -> Cotation:
|
||||
pertinence = Pertinence.MOYENNE
|
||||
elif score >= seuils["faible"]:
|
||||
pertinence = Pertinence.FAIBLE
|
||||
elif exclusions:
|
||||
# Le territoire est nommé, mais pour être écarté : la pertinence est
|
||||
# faible, et le motif dit pourquoi.
|
||||
pertinence = Pertinence.FAIBLE
|
||||
else:
|
||||
pertinence = None
|
||||
|
||||
return Cotation(pertinence=pertinence, score=score, expressions=trouvees)
|
||||
return Cotation(
|
||||
pertinence=pertinence, score=score, expressions=trouvees, exclusions=exclusions
|
||||
)
|
||||
|
||||
|
||||
def _est_niee(plat: str, position: int) -> bool:
|
||||
"""Vrai si une tournure d'exclusion entoure la mention trouvée."""
|
||||
voisinage = plat[max(0, position - _AVANT) : position + _APRES]
|
||||
return any(negation in voisinage for negation in NEGATIONS)
|
||||
|
||||
|
||||
def _extrait_autour(plat: str, position: int, largeur: int = 70) -> str:
|
||||
debut = max(0, position - largeur)
|
||||
fin = min(len(plat), position + largeur)
|
||||
return " ".join(plat[debut:fin].split())
|
||||
|
||||
|
||||
MOTIF_VERIFICATION = (
|
||||
|
||||
Reference in New Issue
Block a user