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:
Cyber Mawonaj
2026-07-25 22:34:22 -04:00
co-authored by Claude Opus 5
parent feb5544599
commit 34d81c66a8
52 changed files with 7459 additions and 40 deletions
+75 -5
View File
@@ -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 = (
+34
View File
@@ -0,0 +1,34 @@
-- ============================================================================
-- Migration 005 — exposer le rowid dans la vue de consultation
--
-- L'index FTS5 est à contenu externe : il se joint à la table `textes` par
-- `rowid`. Or une vue SQLite n'a pas de rowid propre, et `v_textes.rowid`
-- échoue avec « no such column ».
--
-- Sans cette colonne, l'application ne peut pas à la fois filtrer sur les
-- champs dérivés de la vue (`devant_cc`) et joindre l'index plein texte — soit
-- exactement ce que demande la recherche à facettes.
-- ============================================================================
DROP VIEW v_textes;
CREATE VIEW v_textes AS
SELECT
t.rowid AS rowid,
t.*,
(SELECT COUNT(*) FROM sources s WHERE s.texte_id = t.id) AS nb_sources,
(SELECT COUNT(*) FROM evenements e WHERE e.texte_id = t.id) AS nb_evenements,
CASE
WHEN t.statut = 'saisie_cc' THEN 1
WHEN EXISTS (SELECT 1 FROM decisions_cc d
WHERE d.texte_id = t.id
AND d.date_decision IS NULL
AND COALESCE(d.resultat, 'en_instance') = 'en_instance') THEN 1
WHEN EXISTS (SELECT 1 FROM evenements e
WHERE e.texte_id = t.id AND e.type_etape = 'saisine_cc')
AND NOT EXISTS (SELECT 1 FROM evenements e
WHERE e.texte_id = t.id AND e.type_etape = 'decision_cc')
AND t.statut <> 'promulguee' THEN 1
ELSE 0
END AS devant_cc
FROM textes t;