--- phase: quick-260907-efh plan: 01 subsystem: api tags: [dedup, fingerprint, tenders, prisma, nestjs] requires: - phase: "13-scraping-adapters-cross-source-dedup" provides: "TenderDedupService three-tier resolver, tenderFingerprint() v1, backfill-tender-source.ts" provides: - "tenderFingerprint() v2 (buyerName, title, deadlineAt only) with FINGERPRINT_VERSION_PREFIX" - "valueBucketsContradict() exported veto function" - "Value veto wired into TenderDedupService's fingerprint tier" - "Fingerprint refresh on TenderDedupService's change-detection update branch" - "TenderFingerprintBackfillService: automatic startup recompute of outdated fingerprints" affects: ["13-scraping-adapters-cross-source-dedup", "any future tender dedup work"] actuals: tokens: 9035 tasks: 3 commits: 3 tech-stack: added: [] patterns: - "Version-prefixed hash output (FINGERPRINT_VERSION_PREFIX) so a startup backfill can detect stale rows without a separate schema flag" - "Post-match veto function applied after a fingerprint hit, kept separate from the hash itself (exact bucket comparison, no similarity/threshold logic)" key-files: created: - apps/api/src/tenders/tender-fingerprint-backfill.service.ts - apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts modified: - apps/api/src/tenders/tender-fingerprint.ts - apps/api/src/tenders/tender-fingerprint.spec.ts - apps/api/src/tenders/backfill-tender-source.ts - apps/api/src/tenders/tender-dedup.service.ts - apps/api/src/tenders/tender-dedup.service.spec.ts - apps/api/src/tenders/tenders.module.ts key-decisions: - "CPV and estimatedValue removed from the hash with no replacement inside the hash — CPV is not compared anywhere anymore." - "estimatedValue returns as a post-match veto (valueBucketsContradict), not a hash input — exact order-of-magnitude comparison, never a similarity threshold." - "TenderDedupService's change-detection update branch now writes fingerprint alongside the other refreshed fields, fixing a second, independently discovered defect (fingerprint drift on update)." - "Startup backfill (TenderFingerprintBackfillService) recomputes only rows failing the FINGERPRINT_VERSION_PREFIX check, paged at 500 rows, so a second start is a no-op." requirements-completed: [SCHEMA-03] coverage: - id: D1 description: "DOE-shaped and scraper-shaped records of the same tender (same buyer/title/deadline, differing CPV/value) produce the same v2 fingerprint" requirement: "SCHEMA-03" verification: - kind: unit ref: "apps/api/src/tenders/tender-fingerprint.spec.ts#is the Kernregression this plan exists for" status: pass - kind: unit ref: "apps/api/src/tenders/tender-fingerprint.spec.ts#gold value" status: pass human_judgment: false - id: D2 description: "A fingerprint hit is vetoed when both sides carry an estimatedValue of a different order of magnitude; not vetoed when either side is null or both share the same magnitude" requirement: "SCHEMA-03" verification: - kind: unit ref: "apps/api/src/tenders/tender-fingerprint.spec.ts#valueBucketsContradict" status: pass - kind: unit ref: "apps/api/src/tenders/tender-dedup.service.spec.ts#Stufe-3-Wertveto (WINDOWS.md #11)" status: pass human_judgment: false - id: D3 description: "The change-detection update branch writes the recomputed fingerprint alongside the other refreshed fields, closing the drift bug found while measuring" requirement: "SCHEMA-03" verification: - kind: unit ref: "apps/api/src/tenders/tender-dedup.service.spec.ts#OCID match with a changed contentHash still updates the Tender mutable fields" status: pass human_judgment: false - id: D4 description: "Pre-existing Tender rows are recomputed to the v2 fingerprint automatically on startup; a second startup does no work" requirement: "SCHEMA-03" verification: - kind: unit ref: "apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts (4 tests: empty DB, old+null hash both caught, second run no-op, DB error swallowed)" status: pass - kind: other ref: "Real local-DB recompute via backfill-tender-source.js (build + node dist/tenders/backfill-tender-source.js against tessera-ctl-db-1) — measured numbers below" status: pass human_judgment: false - id: D5 description: "Next local API restart performs no further recompute (log stays silent about the backfill)" verification: [] human_judgment: true rationale: "The plan marks this a human-check step — the user restarts the local API themselves; the executor cannot restart it and observe the log itself under this task's constraints." duration: 30min completed: 2026-09-07 status: complete --- # Phase quick-260907-efh Plan 01: Cross-Source-Dedup-Fingerabdruck korrigiert Summary **Fingerabdruck-Formel auf [Vergabestelle, Titel, Frist] verengt, Wert kehrt als exakter Widerspruchs-Veto zurueck, 16.255 Bestandszeilen automatisch nachgerechnet — 26 statt 0 Duplikat-Gruppen gefunden, davon 2 durch den Wert-Veto vor Fehlzusammenlegung geschuetzt.** ## Performance - **Duration:** ca. 30 min - **Tasks:** 3/3 completed - **Files modified:** 6 modified, 2 created ## Accomplishments - `tenderFingerprint()` hasht nur noch drei statt fuenf Segmente (buyerName, title, deadlineAt) — CPV-Division und geschaetzter Wert sind vollstaendig aus dem Hash entfernt, weil sie an der Live-Datenbank gemessen systematisch nur von einer Quellenart geliefert werden und die Stufe damit fuer ihren eigentlichen Zweck (Cross-Source-Match) blind machten. - Neue Versionsmarke `FINGERPRINT_VERSION_PREFIX = "v2:"` vor dem Hash — macht veraltete Zeilen maschinell erkennbar, ohne ein zusaetzliches Schemafeld. - Neue, exportierte Funktion `valueBucketsContradict()`: exakter Groessenordnungsvergleich, angewendet als Veto NACH einem Fingerprint-Treffer in `TenderDedupService`, nie als Bestandteil des Hashes. - Beim Messen ein zweiter, unabhaengiger Defekt gefunden und mit repariert: der Aktualisierungszweig von `TenderDedupService.resolve()` aktualisierte Titel/Vergabestelle/Frist einer bestehenden Zeile, schrieb den Fingerabdruck dabei aber nie mit — der gespeicherte Wert driftete vom Inhalt der Zeile weg. Jetzt wird der einmal berechnete Fingerabdruck in beiden Zweigen (Anlegen und Aktualisieren) geschrieben. - Neuer Dienst `TenderFingerprintBackfillService` (OnApplicationBootstrap, gleiche Bauform wie die LDAP-Bind-Passwort-Nachverschluesselung): rechnet beim Start seitenweise (500 Zeilen pro Transaktion) alle Zeilen ohne die neue Versionsmarke nach; ein zweiter Start tut nichts mehr. In `tenders.module.ts` bewusst VOR `TenderSchedulerService` eingetragen. - Reale Nachrechnung gegen die lokale Datenbank gefahren (nicht nur behauptet) — Zahlen unten. ## Gemessene Zahlen (real gegen die lokale Datenbank gefahren, `backfill-tender-source.js`) | Messung | Wert | |---|---| | Zeilen in Tender | 16.255 | | Fingerabdruck-Gruppen mit mehr als einer Zeile, VOR der Nachrechnung | 0 | | Fingerabdruck-Gruppen mit mehr als einer Zeile, NACH der Nachrechnung | 26 | | Zeilen ohne die neue Versionsmarke NACH der Nachrechnung | 0 | | Gruppen davon mit widersprechenden Wert-Groessenordnungen (Veto greift) | 2 | Diese Zahlen stimmen exakt mit den in der Aufgabenstellung vorab gemessenen Werten ueberein (erwartet: 26 Gruppen, 2 Wertkonflikte). ## Die drei verbleibenden, bewusst getragenen Grenzen Woertlich im Dateikommentar von `apps/api/src/tenders/tender-fingerprint.ts` UND hier festgehalten: 1. **Ein zweites Los gleicher Groessenordnung wird faelschlich zusammengelegt.** Zwei Lose desselben Bauvorhabens mit Werten derselben Groessenordnung teilen sich Titel, Vergabestelle und Frist und werden vom Wert-Veto nicht getrennt — der Veto greift nur bei UNTERSCHIEDLICHER Groessenordnung. An der Live-Datenbank gemessen betrifft das eines der drei urspruenglich gefundenen Los-Paare (117.647 gegen 386.554 — beide Groessenordnung 10^5). Bewusst getragen: eine falsche Zusammenlegung ist sichtbar und reparierbar, die vorherigen 21 uebersehenen Duplikate waren der stille Totalausfall der Stufe. 2. **Quellen ohne Vergabestelle und ohne Frist (RSS, E-Mail-Alarm) matchen nur noch ueber den Titel.** Im Normalfall bedeutet das: sie treffen weiterhin keine DOE-Zeile (DOE traegt fast immer beides). Fehlt aber ausnahmsweise auch der DOE-Zeile Vergabestelle und Frist, reicht der Titel allein zur Zusammenlegung. 3. **Genau ein solcher Fall existiert heute schon gemessen in der Datenbank:** Titel "LSA242", einmal ueber `service-bund`, einmal ueber `doe-opendata` eingegangen — wird nach dieser Aenderung zusammengelegt. Zusaetzlich unveraendert gueltig, aus dem Dateikommentar uebernommen: der Wertvergleich bleibt ein exakter Vergleich zweier Groessenordnungs-Stufen, keine Aehnlichkeits- oder Schwellenwertsuche — das alte Verbot dazu ist NICHT aufgeweicht worden. ## Task Commits Jede Aufgabe wurde einzeln committet: 1. **Task 1: Hash-Formel verengen, Versionsmarke, Wert-Widerspruch als eigene Funktion** — `cb23e3b` (fix, tdd) — `tender-fingerprint.ts`, `tender-fingerprint.spec.ts`, `backfill-tender-source.ts` 2. **Task 2: Wert-Veto in Stufe 3 des Resolvers, Fingerabdruck bei Update mitschreiben** — `9e5445e` (fix, tdd) — `tender-dedup.service.ts`, `tender-dedup.service.spec.ts` 3. **Task 3: Automatische Nachrechnung der Bestandszeilen beim Start** — `ee3b28e` (feat) — `tender-fingerprint-backfill.service.ts`, `tender-fingerprint-backfill.service.spec.ts`, `tenders.module.ts` Hinweis zur Commit-Zaehlung: zwischen Task 2 und Task 3 landete auf `main` ein fremder Commit (`0645cac`, "docs(15): WINDOWS #10 geschlossen") aus einer parallel laufenden, unabhaengigen Aufgabe (Scope: `apps/web/`) — nicht Teil dieses Plans, nicht angefasst. Der Ausfuehrungsmodus dieser Aufgabe war SEQUENTIAL/ohne Worktree auf dem geteilten `main`-Branch, daher taucht dieser Commit zwischen den eigenen Commits im Log auf. ## Files Created/Modified - `apps/api/src/tenders/tender-fingerprint.ts` - v2-Formel, `FINGERPRINT_VERSION_PREFIX`, `valueBucketsContradict()`, ehrlicher Dateikommentar mit den drei Grenzen - `apps/api/src/tenders/tender-fingerprint.spec.ts` - Tests auf v2-Verhalten umgeschrieben, inkl. handgerechnetem Goldwert-Test - `apps/api/src/tenders/backfill-tender-source.ts` - Aufruf/Feldauswahl an neue Signatur angepasst, `decimalToNumberOrNull` entfernt (nicht mehr gebraucht) - `apps/api/src/tenders/tender-dedup.service.ts` - Fingerabdruck einmal berechnet, in Anlege- UND Aktualisierungszweig geschrieben; Wert-Veto in Stufe 3 - `apps/api/src/tenders/tender-dedup.service.spec.ts` - vier neue Wert-Veto-Tests, bestehender Update-Test um Fingerabdruck-Erwartung erweitert - `apps/api/src/tenders/tender-fingerprint-backfill.service.ts` - NEU: automatische Startup-Nachrechnung - `apps/api/src/tenders/tender-fingerprint-backfill.service.spec.ts` - NEU: vier Tests (leere DB, alter+leerer Hash, zweiter Lauf no-op, DB-Fehler geschluckt) - `apps/api/src/tenders/tenders.module.ts` - `TenderFingerprintBackfillService` als Provider, mit Kommentar VOR `TenderSchedulerService` platziert ## Decisions Made - CPV bleibt vollstaendig aus dem Fingerabdruck draussen — keine Ersatzformel, keine Wiedereinfuehrung an anderer Stelle. - Der Wertvergleich lebt bewusst in `tender-fingerprint.ts` neben der Groessenordnungsbildung, damit es genau eine Definition von "Groessenordnung" im Code gibt. - Der Aktualisierungszweig in `TenderDedupService` schreibt weiterhin niemals `ownerTenantId` — dieser zweite, beim Messen gefundene Defekt wurde bewusst NICHT mitrepariert, weil er ausserhalb der Aufgabenstellung dieses Plans liegt (siehe Threat Register T-EFH-02, "accept"). ## Deviations from Plan None - plan executed exactly as written. Der im Objective bereits angekuendigte "zweite Defekt" (Fingerabdruck-Drift im Aktualisierungszweig) war explizit Teil von Task 2 der Aufgabenstellung, keine ungeplante Abweichung. ## Issues Encountered None. ## User Setup Required None - keine externe Konfiguration noetig. Ein Punkt bleibt beim Nutzer: beim naechsten Neustart der lokalen API selbst pruefen, dass im Log KEINE Meldung ueber nachgerechnete Fingerabdruecke mehr erscheint (die lokale Datenbank wurde in diesem Lauf bereits auf v2 umgestellt — ein Log-Eintrag beim naechsten Start waere ein Zeichen, dass die Veraltet-Bedingung falsch herum greift). ## Next Phase Readiness WINDOWS.md #11 ist als `fixed` markiert (`gsd-tools windows fixed 11`). Die in 13-VERIFICATION.md offene Wahrheit 3 ist nicht mehr durch die Hash-Formel blockiert; die dort noch offene Live-Gegenprobe mit einer zweiten tatsaechlich aktiven Scraper-Quelle bleibt unveraendert offen (dedupActive erfordert `activePortalCount >= 2` — aktuell laeuft praktisch nur DOE aktiv). --- *Phase: quick-260907-efh* *Completed: 2026-09-07*