feat(import): detect transaction columns by header label #337
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#337
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-327-lexical-header-detection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Generated autonomously by /autopilot run of 2026-08-13
Resolves #327. Link 5 of the import-format stack — based on
issue-325-amount-parsing, not onmain.What changed
A lexical layer in front of the shape heuristics. Detection reasoned on the shape of the data alone, so it could not tell a debit column from a credit one:
Date;Description;Credit;Debitwas mapped by position and every sign came out inverted — silently, since the total is merely negated and no aggregate check notices.src/utils/headerDictionary.ts(new) — the FR/EN transaction dictionary (date ; description/libellé/détail/transaction ; montant/amount ; débit/retrait/déboursé/withdrawal ; crédit/dépôt/encaissement/deposit ; solde/balance), the header-role resolver, and the two matching helpersnormalizeHeaderCell/matchHeaderColumnmoved out ofcsvAutoDetect.ts(bodies unchanged). Its own module becausemontantis an exclusion token for holdings (VALUE_HEADER_KEYWORDS) and the primary amount keyword for transactions — two tables that must not collide. The helpers were moved rather than exported so the dependency stays one-way; exporting them and importing them back would makecsvAutoDetect↔headerDictionarycircular.detectHeadergains a lexical signal — a row the shape test rejects only because of a number is a header when it names ≥ 2 roles. The date test stays absolute, which is what keeps a transaction likeDEPOT PAIE EMPLOYEUR(it containsdepot) from being swallowed as a header.positive_expenseand importing every deposit as an expense.detectImportFormatreports the reason;autoDetectConfigkeeps its old shape for callers that only need the config.import.errors.absoluteIndicatorFormatin FR + EN, and the pre-existing English literal"Auto-detection failed…"(rendered whatever the interface language) becameimport.errors.autoDetectFailed.KNOWN DEFECT blocks flipped
Link 1 tagged the detection defects
#328; they are this issue's. Both are now fixed, markers dropped, tags corrected to #327 — no test deleted:debit-credit-reversed— was "maps Credit to debitAmount"; now mapped 3/2, parses toREFERENCE_AMOUNTS, and reads identically to its Debit-first twin. The "still nets to the same total, which is why the bug hides" case now asserts the reference total.absolute-indicator— was "indistinguishable from all-positive"; now refused withimport.errors.absoluteIndicatorFormat, produces no config, and refuses on the values alone with no header row to help.header-numeric-label(#325's) — comment retagged to #327; the residual bare-number header case (a column titled2025) is covered by an inline CSV rather than a 12th fixture, so #326's frozen corpus and itstoHaveLength(11)stay intact.all-positivestill belongs to #329 and passes unchanged.corpus integrity's "none returns null" had to move:absolute-indicatoris refused by design now. The test is kept, scoped to the other ten, and joined by "never fails silently: every fixture is a config or a stated reason".Checks
npm test— 1034 vitest green (989 after link 4; +26 in the newheaderDictionary.test.ts, +19 incsvAutoDetect.test.ts)npm run build— tsc + vite greencd src-tauri && cargo check— green (no Rust work)Notes for the reviewer
CHANGELOG/docsentry on purpose — the plan centralizes both in issue 10 (#332) to avoid a conflict at every stage of the linear stack, as links 1-4 did.narrowCandidates): the layer must never turn "detected, possibly wrong" into "detected nothing"./pr-review— REQUEST_CHANGESRésumé
Le cœur de la PR tient. J'ai vérifié les claims plutôt que de les croire :
diffde la région holdings entreissue-325-amount-parsinget la tête : les seuls écarts sont le retrait denormalizeHeaderCell/matchHeaderColumnet l'ajout du commentaire NOTE. Les corps sont inchangés au caractère près, etSYMBOL/QUANTITY/PRICE/BOOKCOST/VALUE_HEADER_KEYWORDSsont intacts. La justification du cycle d'import tient :csvAutoDetectimporteheaderDictionary, l'inverse aurait fermé la boucle.matchTransactionHeaders(["Symbole","Quantite","Prix","Montant"])rendroleCount: 1(<MIN_HEADER_ROLE_MATCHES), et0pour les en-têtes EN et « Valeur marchande ». La clause lexicale dedetectHeaderne peut pas se déclencher là.detectHeaderreste par ailleurs strictement plus permissif (hasDate→ data ;!hasNumber→ header ; le reste seul est nouveau).narrowCandidatesne peut pas vider la liste (csvAutoDetect.ts:275-281), et j'ai repassé chaque sortie anticipée : aucun chemin nouveau ne transforme unconfigenfailed. Le seul changement d'issue est lerejectedvoulu.autoDetectConfig=ok ? config : null. Un seul site de production consommait la détection (useImportWizard.ts:871), il est passé àdetectImportFormat; la raison n'est donc perdue nulle part. Le banner résout bien la clé (ImportPage.tsx:67,t(state.error, { defaultValue: state.error }), posé au lien 4).it, tous lesdescribede la base sont là,toHaveLength(11)et le garde statiqueuseImportWizardintacts,all-positivereste en KNOWN DEFECT pour #329, zéroskip/only. Le retag #328 → #327 est correct : #328 est « score de confiance et détection lancée d'office », rien à voir.6f64fc5.Un blocage : la couche lexicale régresse le choix de la colonne description sur un format réel.
Blocages
1.
src/utils/headerDictionary.ts:33+src/utils/csvAutoDetect.ts:571-579— l'indice lexical de description n'a aucun veto par les données, ettransactionse déclenche sur une colonne de TYPEContrairement à la date (rejouée à 0,8 de taux de parse,
csvAutoDetect.ts:395-405) et au montant (contraint àamountCandidates.includes(preferred),:648-651), la description retournepreferreddès qu'elle n'est ni la date ni numérique. Il n'y a rien derrière. Le corps de la PR dit « each hint is a preference the data can veto » — pour la description c'est faux.Différentiel base vs tête sur un relevé Tangerine (
Date,Transaction,Name,Memo,Amount, format d'export réel d'une banque canadienne personnelle, donc dans la cible de la spec) :Aucune colonne ne matche
description/libelle/detail, donctransactiongagne, et chaque transaction importée porte la description « DEBIT » ou « CREDIT » au lieu du nom du marchand. La catégorisation automatique par mots-clés — la fonction qui vit de ce champ — ne trouve plus rien, et la liste de transactions devient illisible. L'heuristique de forme faisait le bon choix avant cette PR.Même mécanisme, seconde manifestation : une colonne libellée mais vide est mappée quand même (
Date;Libelle;Detail;MontantavecLibellevide →description: 1, base →2).C'est exactement ce que le commentaire de
headerDictionary.ts:25-27annonce vouloir éviter (« a keyword that fires on the wrong column is worse than a keyword that never fires »).Correctif proposé — un veto par cardinalité plutôt que par longueur : calculer d'abord le gagnant de forme, puis ne garder
preferredque s'il a du contenu non vide et que ses valeurs distinctes ne le trahissent pas comme un énuméré (p. ex.distinct >= rows.length / 2quandrows.length >= 4). Sur Tangerine,Transactiona 2 valeurs distinctes sur 6 lignes → forme reprend la main ; sur le testLibellevsNotede la PR (csvAutoDetect.test.ts:793-806),Libellea 3 distinctes sur 3 → le libellé gagne toujours. Attention : un veto par ratio de longueur casserait ce test-là (14 vs 45 caractères), c'est pourquoi je propose la cardinalité. Alternative plus économique mais qui sort du texte de #327 : retirer ou déclassertransactiondu dictionnaire de description.Suggestions (non bloquantes)
a.
headerDictionary.ts:175—DRmanque à l'alphabet indicateur.["d","db"]/["c","cr"]suit la spec à la lettre, maisDr/Crest la paire d'abréviation comptable standard. Vérifié : un fichierDate;Description;Montant;SensavecDR/CRen colonne adjacente échappe au refus et repart enpositive_expense— le défaut que #327 doit fermer, à un mot près. Un"dr"de plus coûte une ligne.b.
csvAutoDetect.ts:720— la borne d'adjacence laisse passer les indicateurs placés ailleurs.Date;Sens;Description;Montant(indicateur en 1, montant en 3) →status: ok,positive_expense, chaqueCimporté en dépense. Le compromis est assumé et conforme à la spec, mais le garde-fou pourrait s'élargir sans ouvrir de fausse alarme : « toute colonne non numérique dont toutes les valeurs sont dans l'alphabet D/C avec ≥ 2 tokens distincts » est déjà la condition acceptée pour la voisine. À noter dans #328 ou #332 si vous le laissez tel quel.c. Faux positif possible, et le message oriente alors mal. Une colonne adjacente sans rapport tenant ≥ 2 lettres de l'alphabet (un code titulaire
C/D, par exemple) fait refuser le fichier. C'est récupérable — le panneau de configuration manuelle reste ouvert, l'auto-détection est un bouton — maisimport.errors.absoluteIndicatorFormatdit alors « réexportez votre relevé », ce qui est un mauvais conseil pour ce fichier-là. Ajouter « ou configurez les colonnes manuellement » couvrirait les deux cas.d.
csvAutoDetect.ts:900—\u2014littéral dans un commentaire//au lieu du tiret cadratin.e.
headerDictionary.ts:28-46— six tables exportées, importées nulle part (ni code, ni tests). SeulsmatchTransactionHeaders, les deux helpers,MIN_HEADER_ROLE_MATCHESet les*_INDICATOR_TOKENSsont consommés. Les repasser enconstinterne jusqu'à ce que #330 en ait besoin.f.
csvAutoDetect.ts:88-95— la doc du wrapper décrit des appelants qui n'existent plus.autoDetectConfign'a aucun appelant de production depuis cette PR ; ses 34 sites d'appel sont tous des tests. C'est un bon choix (ne pas churner le corpus gelé de #326), mais autant l'écrire : « surface conservée pour les tests de corpus » plutôt que « callers that only need the configuration ».Blocage levé — corrigé sur la tête de pile
detectDescriptionColumnoppose maintenant un veto par cardinalité à la colonne préférée : une description ne répète presque rien, un énuméré répète presque tout. Le veto par longueur a été écarté —NoteetLibellésont courts tous les deux.Le correctif est le commit
ef7de3csurissue-332-docs-adr-changelog, la tête de la pile, plutôt que sur cette branche : la chaîne se merge en--ff-onlyd'un seul tenant, et corriger ici aurait imposé un rebase en cascade de toute la descendance. L'attribution reste lisible — le commit porteRefs #327.Vérifié par mutation : annuler le correctif fait rougir un test dédié. Un garde qui ne peut pas échouer ne garde rien.
Suite complète verte : 1181 vitest, build tsc + vite,
cargo check.Mergée dans
mainen fast-forward avec le reste de la pile (tip37b832e).Forgejo ne détecte pas un merge local comme merged — la PR est donc fermée à la main, et l'issue liée s'est fermée automatiquement via son
Resolves #N.Tip cumulé validé avant push : 1181 vitest, 111 tests Rust, build tsc + vite. La CI ne tourne pas sur push
main, cette validation locale était donc le seul filet.Pull request closed