Files
tessera-ctl/.planning/phases/13-scraping-adapters-cross-source-dedup/13-01-PLAN.md
T
schalli 74c00166e2 docs(13): create phase plan — scraping adapters + cross-source dedup
6 plans (INGEST-02/03/07, SCHEMA-03) + Nyquist validation.
Core (registry, fingerprint, dedup, TenderSource, backfill) lands
first and is green independent of live scraping (D-01).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 08:24:16 +02:00

9.7 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
13-scraping-adapters-cross-source-dedup 01 execute 1
apps/api/prisma/schema.prisma
apps/api/prisma/migrations
apps/api/src/tenders/tender.types.ts
apps/api/src/tenders/tender-fingerprint.ts
apps/api/src/tenders/tender-fingerprint.spec.ts
true
SCHEMA-03
truths artifacts key_links
Jeder der ~2851 Bestands-DÖE-Tender besitzt genau eine TenderSource-Zeile aus seinen sourcePortal/sourceNoticeId/sourceUrl/ocid.
Zwei Records mit gleichem Titel+Auftraggeber+CPV-Division ergeben denselben Fingerprint, auch wenn Wert und Frist NULL sind.
SourceType ist eine offene Union ('doe-opendata' | 'ai-netserver' | 'cosinex-dtvp').
apps/api/src/tenders/tender-fingerprint.ts
apps/api/prisma/schema.prisma (model TenderSource, Tender.fingerprint, Tender.sources)
TenderSource.@@unique([sourcePortal, sourceNoticeId]) — eine Quell-Notiz gehoert zu genau einem Tender.
Tender.fingerprint (nullable, @@index) — SCHEMA-03 Fuzzy-Dedup-Lookup.
Legt die SCHEMA-03-Datengrundlage: neues 1:n-Modell `TenderSource` (D-03), eine additive nullable `fingerprint`-Spalte auf `Tender`, Backfill der ~2851 Bestands-DÖE-Tender (je eine TenderSource-Zeile + berechneter Fingerprint), und die pure NULL-tolerante Fingerprint-Funktion (SCHEMA-03, D-04). Erweitert `SourceType` zur offenen Union, damit die spaeteren Adapter (13-04/05) und die Registry (13-02) darauf aufbauen.

Purpose: Der Kern muss ohne jede Scraping-Abhaengigkeit voll getestet gruen sein (D-01). Dieser Plan ist genau dieser dependency-freie Datenkern. Output: schema.prisma (TenderSource + fingerprint), lokal angewendete Migration + Backfill, tender-fingerprint.ts (+ spec), erweiterte SourceType-Union.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/13-scraping-adapters-cross-source-dedup/13-CONTEXT.md @.planning/phases/13-scraping-adapters-cross-source-dedup/13-RESEARCH.md @apps/api/prisma/schema.prisma @apps/api/src/tenders/tender.types.ts @apps/api/src/tenders/tender-normalizer.service.ts Task 1: TenderSource-Modell + fingerprint-Spalte + Migration + Backfill apps/api/prisma/schema.prisma, apps/api/prisma/migrations/, apps/api/src/tenders/backfill-tender-source.ts Ergaenze in `schema.prisma` (per SCHEMA-03 / D-03, Referenz-Snippet in 13-RESEARCH.md "TenderSource-Modell (Prisma)"): neues `model TenderSource` mit id (uuid), tenderId, sourcePortal, sourceNoticeId, ocid (nullable), sourceUrl (nullable), createdAt, Relation `tender Tender @relation(fields:[tenderId], references:[id], onDelete: Cascade)`, `@@unique([sourcePortal, sourceNoticeId])` und `@@index([tenderId])`. Ergaenze in `model Tender`: `fingerprint String?` (nullable, additiv), `sources TenderSource[]`, `@@index([fingerprint])`. `dedupKey @unique` bleibt UNVERAENDERT (SCHEMA-02-Upsert-Target, D-01 — kein Drop in dieser Phase).

Erzeuge eine handgeschriebene Migration (Verzeichnis apps/api/prisma/migrations/20260723120000_add_tender_source/migration.sql) in ZWEI logischen Schritten in korrekter Reihenfolge (Pitfall 5): (a) CREATE TABLE "TenderSource" + ADD COLUMN "fingerprint" auf "Tender" + Indizes + Unique-Constraint; (b) Daten-Backfill INSERT INTO "TenderSource" (...) SELECT gen_random_uuid(), t.id, t."sourcePortal", t."sourceNoticeId", t.ocid, t."sourceUrl", now() FROM "Tender" t; (SQL-Skizze in 13-RESEARCH.md "Backfill-Migration"). Der Unique-Constraint wird NACH dem Backfill-Insert wirksam bzw. das Insert erzeugt keine Duplikate (DÖE-noticeIds sind live eindeutig).

Der fingerprint-Backfill der Bestands-Tender lebt NICHT in reinem SQL (Umlaut-/CPV-Normalisierung lebt im Code): schreibe ein einmaliges TS-Backfill-Script backfill-tender-source.ts, das alle Tender laedt, tenderFingerprint(...) (Task 2) aus title/buyerName/cpvDivisions/deadlineAt/estimatedValue berechnet und Tender.fingerprint per updateMany/Schleife setzt. estimatedValue ist Decimal? — in number/null konvertieren, bevor es an valueBucket geht.

Wende Migration lokal an (KEIN Docker-Deploy Testserver, MEMORY): docker compose exec -T db psql -U tessera -d tessera_dev -f - bzw. pnpm --filter @tessera/api exec prisma migrate deploy gegen die lokale DB; danach pnpm --filter @tessera/api exec prisma generate. DB-Name lokal verifizieren (tessera_dev laut Phase-12-Vorbild; Research nennt tessera) — vor dem Anwenden \l pruefen. cd apps/api && npx prisma validate && npx prisma generate model TenderSource + Tender.fingerprint + Tender.sources existieren; prisma validate gruen; Migration lokal angewendet; SELECT count(*) FROM "TenderSource" == count der Tender (manuelle DB-Pruefung, siehe 13-VALIDATION.md).

Task 2: Pure NULL-tolerante Fingerprint-Funktion (SCHEMA-03, D-04) apps/api/src/tenders/tender-fingerprint.ts, apps/api/src/tenders/tender-fingerprint.spec.ts - Gleicher Titel + Auftraggeber + CPV-Division, beide Wert=NULL und Frist=NULL -> gleicher Fingerprint (NULL-Toleranz, 92.4% NULL Wert / 15.7% NULL Frist live). - Unterschiedlicher Titel (nach normText) -> unterschiedlicher Fingerprint (Kollisions-Schutz, Pitfall 1). - Umlaut-Normalisierung: "Muenchen" und "München" ergeben denselben normText-Beitrag. - CPV-Divisions reihenfolge-unabhaengig (sortiert), dedupliziert. - valueBucket: NULL -> "", sonst Groessenordnung (log10-Floor) — 12000 und 15000 landen im selben Bucket. - Rueckgabe ist stabiler sha256-Hex-String (deterministisch ueber Laeufe). Implementiere `tender-fingerprint.ts` als pure Funktion `tenderFingerprint(f: { buyerName: string|null; title: string; cpvDivisions: string[]; deadlineAt: Date|null; estimatedValue: number|null }): string` exakt nach 13-RESEARCH.md Pattern 4: interne Helfer `normText` (lowercase, ae/oe/ue/ss, non-alnum->space, trim), `cpvDivisionKey` (Set+sort+join), `valueBucket` (NULL->"", sonst `String(Math.floor(Math.log10(Math.max(v,1))))`), `deadlineKey` (Datum auf Tageskorn oder ""). Kanonischer String `[buyer, title, cpvDivKey, deadlineKey, valueBucket].join('|')` -> `createHash('sha256').update(...).digest('hex')` (Node crypto, kein Selbstbau, V6). KEIN Aehnlichkeits-Threshold (deterministisch, O(1)-Lookup). Schreibe die Tests RED-first gemaess behavior-Block, dann Implementierung bis gruen. pnpm --filter @tessera/api test -- tender-fingerprint Alle behavior-Faelle gruen; Funktion ist pure (kein Prisma/IO-Import); NULL-Toleranz + Kollisions-Fall abgedeckt. Task 3: SourceType-Union erweitern + NormalizedTenderFields.fingerprint apps/api/src/tenders/tender.types.ts Erweitere in `tender.types.ts` `SourceType` von `'doe-opendata'` zur offenen Union `'doe-opendata' | 'ai-netserver' | 'cosinex-dtvp'` (13-RESEARCH.md Pattern 1). Ergaenze `NormalizedTenderFields` um `fingerprint: string` (der in 13-03 vom Dedup-Resolver gesetzte SCHEMA-03-Schluessel) — Feld additiv, damit der Normalizer/Resolver ihn fuellen kann. Passe den Normalizer NICHT hier an (das macht 13-03), aber stelle sicher, dass der Typ-Zusatz `npx tsc --noEmit` nicht bricht (Feld ggf. vorerst optional `fingerprint?: string` falls der Normalizer es noch nicht setzt — dokumentiere die Wahl im Kommentar). Kommentar aktualisieren: Union-Erweiterung ist die einzige breaking-Aenderung, lokal begrenzt. cd apps/api && npx tsc --noEmit -p tsconfig.json SourceType-Union enthaelt die drei Slugs; NormalizedTenderFields kennt fingerprint; `tsc --noEmit` gruen im API-Package.

<threat_model>

Trust Boundaries

Boundary Description
Migration -> DB Schema-/Datenaenderung an Produktions-naher lokaler DB; Reihenfolge-Fehler kann Backfill brechen.

STRIDE Threat Register

Threat ID Category Component Severity Disposition Mitigation Plan
T-13-01-01 Tampering Backfill-Migration medium mitigate 2-Schritt-Reihenfolge (Tabelle+Spalten vor Constraint/Backfill, Pitfall 5); lokal angewendet, kein Testserver-Deploy.
T-13-01-02 Denial of Service fingerprint-Backfill ueber 2851 Rows low accept Einmaliges Script, Batch/Schleife; kein Laufzeit-Pfad.
T-13-01-03 Info Disclosure Decimal->number Konvertierung estimatedValue low mitigate NULL bleibt NULL (valueBucket NULL-tolerant); keine Praezisionsannahme im Hash (nur Groessenordnung).
</threat_model>
- `prisma validate` + `prisma generate` gruen. - `pnpm --filter @tessera/api test -- tender-fingerprint` gruen. - `npx tsc --noEmit` gruen. - Manuell (13-VALIDATION.md): `SELECT count(*) FROM "TenderSource"` == `SELECT count(*) FROM "Tender"`; `SELECT count(*) FROM "Tender" WHERE fingerprint IS NOT NULL` == Bestand.

<success_criteria> TenderSource-Tabelle existiert und ist 1:1 mit Bestands-Tendern backfilled; fingerprint-Spalte existiert + backfilled; pure Fingerprint-Fn NULL-tolerant + kollisionsarm getestet; SourceType-Union offen. TrAegt SCHEMA-03 (Datenschicht). </success_criteria>

Create `.planning/phases/13-scraping-adapters-cross-source-dedup/13-01-SUMMARY.md` when done