feat(import): score the detected format and run detection on its own #338
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#338
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-328-confidence-score"
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?
Link 6 of the ten-link import-format stack. Based on
issue-327-lexical-header-detection, not onmain— review the top commit only.Resolves #328
What was wrong
Detection returned a configuration it had never tested on the data, and only ever ran behind the magic-wand button. A source opened for the first time therefore started on
defaultConfig(;,DD/MM/YYYY, columns 0/1/2) — plausible enough to import a whole file wrong rather than fail visibly. And the sign-convention selector was shown in debit/credit mode, where parsing ignores it.What this does
1. A measured score, computed by the production row rule.
detectImportFormatreplays the configuration it just decided over every data row of the file and returns aDetectionScore(readRows,totalRows,ratio,confident). The replay callsmapRow— the same pure functionparseFilesInternalruns at import time — under the same column-level decimal arbitration (detectAmountSeparators). Per the/review-specrevision on the issue: two mappers would be the exact divergence class this chantier removes, so a test asserts the score and an end-to-end parse agree row for row on every fixture of the corpus.The score runs over the whole file, not the 20-row detection sample: the sample decides a shape cheaply, the score is what the user reads ("147 of 150 rows").
2. The 90 % threshold colours, it does not block.
Below it, a warning banner with the detailed count and a hint to check the mapping and the preview; at or above it, a neutral banner. Nothing is disabled, and there is a test spelling out why: a perfect score says nothing about the sign of what was read.
all-positivescores 100 % while every credit imports as an expense (#329's known defect) — the preview step is the real net, traversed at every import whatever the score says.3. Detection fires on its own, guarded on
!existing.A source with no stored format is detected the moment it is opened; the wand button stays, to replay it. A source that has one is never re-detected — the stored format wins, which is the whole point of the chantier.
The guard is deliberately
!existing, not!restored: a stored format that fails to decode already dispatches its own explicit error (unsupportedAmountModeand friends), and detecting over it would replace that message with a silent guess. Pinned by static guards, since the hook is untestable without jsdom:selectSource,4. Sign convention hidden in debit/credit mode.
mapRowcomputescredit - debiton magnitudes there and never readssignConvention, so the control changed nothing. Hidden, not reset — the stored value is untouched.5. Score invalidation. Cleared when the selected source changes (reducer-level, so no source can inherit another's confidence), when the format is edited by hand (compared through
formatToRow, so renaming the source keeps the banner) and when a template overwrites the format. A banner vouching for a configuration nobody measured is the misinformation the score exists to remove.Notable decisions
{status:"ok"}arm ofdetectImportFormatautoDetectConfigstaysAutoDetectResult | nullfor its ~25 config-only call sitesautoDetectConfigdetectFormatForFile(filePath, base)selectSourcecannot call the button callback: it closes overstatethatselectSourcehas just dispatched and cannot yet read. Everything is a parameter, so no stale closureuseEffecton step change (StrictMode double-fire + "already detected?" flag){...base, ...outcome.config}sign_conventiongot dropped in #324defaultConfig--accentfor the warning banner--warningvariable exists instyles.css;--negativeis reserved for the page error bannerScope
No
CHANGELOGand nodocs/change:spec-plan-import-csv-format.mdcentralizes both in link 10 of the stack, explicitly to avoid conflicts down a linear pile. Links 1-5 touched neither. No DB migration (v1→v17 unchanged). No Rust change.Link 3's source-reading guards in
importFormat.test.tswere not weakened: every change toselectSourcelands either before thelet restoredmarker or afterif (restored) {, and the new helper is declared beforeselectSource, so the sliced windows are identical.Verification
npm test— 1054 passed (baseline after link 5: 1034, +20)npm run build— green (tsc + vite)cd src-tauri && cargo check— greenGenerated autonomously by /autopilot run of 2026-08-13
Detection handed back a configuration it had never tested, and only ever ran behind the magic-wand button. A source opened for the first time therefore started on `defaultConfig` — `;`, `DD/MM/YYYY`, columns 0/1/2 — plausible enough to import a whole file wrong rather than fail visibly. `detectImportFormat` now REPLAYS what it just decided over every data row of the file and returns the rate as a `DetectionScore`. The replay runs `mapRow`, the same pure function `parseFilesInternal` runs at import time, under the same column-level decimal arbitration: two mappers would be the exact divergence this chantier removes — a score reading 100 % while the import wrote different amounts. A test asserts the two agree row for row on every fixture of the corpus. The threshold is 90 %, and it only colours the banner. Below it the panel warns and prints the detailed count ("132 of 150 rows read"); at or above it the banner is neutral. Nothing is blocked, because a perfect score says nothing about the SIGN of what was read — `all-positive` scores 100 % while every credit imports as an expense — and the preview step (#329) is the real net, traversed at every import. Detection now also fires on its own, guarded on `!existing`: a source that has never been configured. A source that HAS one is never re-detected, the stored format wins. The condition is deliberately not `!restored` — a stored format that fails to decode already reports its own error, and detecting over it would replace that message with a silent guess. The button and the automatic run share one `detectFormatForFile`, so the button replays detection instead of running a second, drifting variant of it. The score is cleared when its source changes, when the format is edited by hand (compared through the codec, so a rename keeps it) and when a template overwrites the format — a banner vouching for a configuration nobody measured is the misinformation it exists to remove. Finally, the sign-convention selector is hidden in debit/credit mode. `mapRow` computes `credit - debit` on magnitudes there and never reads `signConvention`, so the control changed nothing. Hidden, not reset: the stored value is left untouched. Tests: 1054 vitest (+20), build and cargo check green. No DB migration. CHANGELOG and docs are centralized in link 10 of the stack per the plan. Resolves #328Revue adversariale — PR #338
Verdict : APPROVE — aucun blocage. 6 suggestions, toutes non bloquantes.
Résumé
Les cinq affirmations du corps ont été vérifiées dans le code, pas sur parole.
Le point 4 (« une source déjà configurée n'est jamais re-détectée ») tient. Le seul site de détection du hook est
useImportWizard.ts:446, à l'intérieur de la brancheelsedeif (restored)(l.411) et gardé par!existing.restorednon nul impliqueexistingnon nul, donc les deux conditions se renforcent au lieu de se remplacer. Vérifié sur les trois entrées possibles :applyConfigTemplate(l.1000) ne fait queSET_SOURCE_CONFIG+SET_SELECTED_TEMPLATE_ID+SET_DETECTION_SCORE: null+ rechargement des en-têtes ; aucune détection ;toggleFile(l.548) etselectAllFiles(l.570) ne touchent ni la config ni la détection ;selectSourcen'est atteint que paronSelectSource={selectSource}(ImportPage.tsx:82), un gestionnaire d'événement. React ne double-invoque que le rendu et les effets ; aucunuseEffectn'appelleselectSource. Le double-montage n'expose rien.Le choix
!existingplutôt que!restoredest le bon : un format stocké indécodable dispatche déjà son erreur explicite (l.407), et la brancheelseouvre alors surdefaultConfigsans écraser ce message par une devinette.Le score consomme bien
mapRow, lu à la source et non déduit du corps :csvAutoDetect.ts:343appellemapRow(raw, format, { decimalSeparators })avec l'arbitragedetectAmountSeparators(l.338). La sélection des lignes descoreConfig(l.331-336) est identique caractère pour caractère à celle deparseFilesInternal(useImportWizard.ts:603, 616-620) : mêmestartIdx, même saut de la cellule vide isolée, mêmePapa.parse(preprocessQuotedCSV(...), { delimiter, skipEmptyLines: true }). Pas de second mappeur.Le seuil ne bloque rien.
detectionScore.confidentn'apparaît que dansSourceConfigPanel.tsx:95/100/111/120— bordure, icône, message.nextDisabled(ImportPage.tsx:48) ne dépend que deselectedFiles.lengthet desourceConfig.name. Aucun bouton, aucun contrôle n'est désactivé par le score.Les gardes statiques du maillon 3 sont intactes.
importFormat.test.tsn'est pas touché, et ses fenêtres découpent toujours le même texte :restoreBranch()va delet restored(l.398) àif (restored) {(l.411) et ne contient aucun des ajouts ;callbackBody("selectSource", "loadHeadersWithConfig")borne 353→479 et contient toujoursexisting?.template_id ?? null.detectFormatForFileest déclaré avantselectSource(l.322), donc aucune fenêtre ne se déplace. Les troisnot.toContainglobaux (mapping.debitAmount !== undefined,columnMapping.amount ?? 0,isNaN(credit)) restent à zéro occurrence, et le garde du maillon 1expect(SRC).toContain("mapRow(raw, config, {")matche toujours (l.626). Aucun compte trafiqué :runAutoDetect(= 1 occurrence,await detectFormatForFile(= 2.Reste : i18n dans les deux fichiers avec interpolation testée des deux côtés, aucune migration touchée (v1→v17), aucun
skip/only,--accentexiste bien (styles.css:49et:70, clair et sombre), CI frontend verte.Blocages
Aucun.
Suggestions
1. Une lecture de fichier en échec rend la source inouvrable —
useImportWizard.ts:446-452.detectFormatForFileappelleinvoke("read_file_content"), qui renvoieErrdès quefs::readéchoue (fs_commands.rs:113) : fichier disparu entre le scan et le clic, partage réseau, permission. Le rejet remonte aucatchdeselectSource(l.472), donc leSET_STEP: "source-config"de la l.471 n'est jamais atteint : l'utilisateur reste sur la liste des sources avec un message d'erreur, et ne peut plus ouvrir cette source du tout — or la sélection des fichiers vit dans le panneau de configuration, donc il ne peut même pas écarter le fichier fautif pour importer les autres. Avant la PR, le même clic ouvrait le panneau (get_file_previewavale ses erreurs, l.510). Untry/catchautour du seul appeldetectFormatForFile, sur le modèle de celui qui entouredetect_encodingjuste au-dessus (l.420-426), rendrait la panne informative sans être bloquante.2. Score périmé quand la sélection de fichiers change —
useImportWizard.ts:548et:570.Le score est mesuré sur un seul fichier, mais ni
toggleFileniselectAllFilesne le vident. Un dossier contenant deux exports de formes différentes affiche donc « Format reconnu — 50 des 50 lignes lues » pendant que le fichier retenu pour l'import n'est pas celui qui a été mesuré. C'est le quatrième trou de la liste d'invalidation du corps (source / édition manuelle / modèle), et le seul qui reste ouvert. Probabilité faible — un dossier vaut une source, donc des formes hétérogènes sont l'exception — mais l'invalidation coûte une ligne dans le reducer.3. Le fichier mesuré n'est pas nommé —
useImportWizard.ts:447vs:964.Le tir automatique mesure
source.files[0](ordre du scan), le bouton baguette mesurestate.selectedFiles[0](ordre trié, nouveaux fichiers d'abord). Les deux peuvent désigner des fichiers différents dans le même dossier. Porter le nom du fichier dans la bannière lèverait l'ambiguïté et rendrait la suggestion 2 sans objet.4. La justification du seuil ne décrit pas l'app d'aujourd'hui —
ImportPage.tsx:132-146.Le corps s'appuie sur « l'aperçu est traversé à chaque import, quoi que dise le score ». Aujourd'hui c'est faux : « Aperçu » (
handlePreview) et « Vérifier les doublons » (parseAndCheckDuplicates) sont deux boutons frères également activés, et l'aperçu est un modal facultatif. La décision reste la bonne — un seuil qui bloque serait le défaut — mais l'argument ne tiendra qu'une fois #329 livrée. En attendant, la bannière est le seul avertissement avant l'import, ce qui donne un peu plus de poids à la suggestion 2.5. Le garde « delegates to mapRow » est fragile, et la comparaison bout-en-bout prouve moins qu'annoncé —
csvAutoDetect.test.ts:947-955et:186-207.Le garde assert le texte exact de la ligne d'import (
'import { detectAmountSeparators, mapRow } from "./importFormat"') : un reformat prettier ou un réordonnancement des named imports le casse sans qu'aucune divergence n'existe. Par ailleursparseFixtureEndToEndest lui-même un miroir deparseFilesInternal— il recopie la sélection des lignes et l'arbitrage des séparateurs — donc le test « agrees row for row » oppose deux miroirs qui appellent le mêmemapRow; il valide la sélection des lignes, pas l'égalité avec le chemin d'import. Ce qui rattache réellement les deux est le gardeexpect(SRC).toContain("mapRow(raw, config, {")(l.248). Rien à corriger ici, mais le commentaire du test surestime ce que le cas démontre.6. Course entre deux sources, fenêtre élargie —
useImportWizard.ts:353.selectSourceattend désormais une lecture et un parse complets du fichier. Deux clics rapprochés A puis B laissent la détection de A résoudre après que B a été dispatchée, écrasant à la foissourceConfigetdetectionScorede B. La classe est pré-existante (getSourceByNameetdetect_encodingétaient déjàawait), mais la fenêtre passe de quelques millisecondes à la durée d'un parse complet. Un jeton de génération fermerait le trou ; à défaut, poserisLoadingpendant la détection automatique — il n'y a aujourd'hui aucun retour visuel pendant ce parse, et la liste des sources reste cliquable.maximus referenced this pull request2026-08-14 15:29:39 +00:00
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