docs(260723-e7i): pre-dispatch plan for normalizer-gap-phase-13-schliessen-tende
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260723-e7i
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/tenders/tender-normalizer.service.ts
|
||||||
|
- apps/api/src/tenders/tender-normalizer.service.spec.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- INGEST-02
|
||||||
|
- INGEST-03
|
||||||
|
- SCHEMA-03
|
||||||
|
gap_closure: true
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein ai-netserver-RawTenderRecord (flache ocdsPayload-Bag) normalisiert zu title/buyerName/procedureType/deadlineAt aus der Bag, nicht zu Defaults"
|
||||||
|
- "Ein cosinex-dtvp-RawTenderRecord normalisiert identisch korrekt aus derselben Bag"
|
||||||
|
- "Der bestehende doe-opendata-Pfad (eForms/OCDS) bleibt unveraendert (keine Regression)"
|
||||||
|
- "Fingerprint eines normalisierten NetServer/Cosinex-Records nutzt echten Titel/Buyer/Frist und kann daher mit dem DÖE-Gegenstueck kollidieren"
|
||||||
|
artifacts:
|
||||||
|
- apps/api/src/tenders/tender-normalizer.service.ts
|
||||||
|
- apps/api/src/tenders/tender-normalizer.service.spec.ts
|
||||||
|
key_links:
|
||||||
|
- "netserver.adapter.ts / cosinex.adapter.ts (ocdsPayload-Bag) -> TenderNormalizerService.normalize() (per-sourceType-Dispatch)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Schliesst den Normalizer-Gap aus 13-VERIFICATION.md: `TenderNormalizerService.normalize()` versteht die generische flache `ocdsPayload`-Bag der NetServer- und Cosinex-Adapter nicht (liest nur `.tender`/`.buyer` der DÖE-OCDS-Form). Dadurch normalisieren alle ai-netserver-/cosinex-dtvp-Datensaetze zu `title='Unbenannte Ausschreibung'`, `buyerName=null`, `deadlineAt=null` — und ihr Fingerprint kollidiert nie mit dem echten DÖE-Gegenstueck.
|
||||||
|
|
||||||
|
Fix: per-`sourceType`-Dispatch in `normalize()`. Fuer `ai-netserver` und `cosinex-dtvp` wird die flache Bag `{title, buyerName, procedureType, legalFramework, deadlineAt(ISO-String|null)}` direkt in `NormalizedTenderFields` gemappt. Der bestehende DÖE-Pfad bleibt exakt gleich.
|
||||||
|
|
||||||
|
Purpose: Blockt ROADMAP-Kriterien 1/2/3 (INGEST-02, INGEST-03, SCHEMA-03) aus Phase 13. Ohne diesen Fix sind NetServer/Cosinex-Treffer nicht "korrekt normalisiert" und Cross-Source-Dedup zwischen DÖE und Scraper ist praktisch unerreichbar.
|
||||||
|
Output: Erweiterter `tender-normalizer.service.ts` + neue Unit-Tests fuer beide Bag-Quellen und die DÖE-Regression.
|
||||||
|
|
||||||
|
Bounded: nur `tender-normalizer.service.ts` + zugehoerige Spec. Kein Schema-Change, keine Adapter-Aenderung (die Bag-Konvention bleibt).
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/13-scraping-adapters-cross-source-dedup/13-VERIFICATION.md
|
||||||
|
@apps/api/src/tenders/tender-normalizer.service.ts
|
||||||
|
@apps/api/src/tenders/tender.types.ts
|
||||||
|
@apps/api/src/tenders/tender-fingerprint.ts
|
||||||
|
@apps/api/src/tenders/tender-normalizer.service.spec.ts
|
||||||
|
@apps/api/src/tenders/adapters/netserver.adapter.ts
|
||||||
|
@CLAUDE.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Per-sourceType-Dispatch + geteilte assemble-Hilfsmethode im Normalizer</name>
|
||||||
|
<files>apps/api/src/tenders/tender-normalizer.service.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- ai-netserver-Bag {title:'Bauleistung X', buyerName:'Stadt Y', procedureType:'Offenes Verfahren', legalFramework:'VOB/A', deadlineAt:'2026-09-01T10:00:00.000Z'} -> NormalizedTenderFields mit title='Bauleistung X', buyerName='Stadt Y', procedureType='Offenes Verfahren', deadlineAt=Date(2026-09-01T10:00:00Z), cpvCodes=[], cpvDivisions=[], region=null, plz=null, bundesland=null, estimatedValue=null
|
||||||
|
- cosinex-dtvp-Bag identischer Shape -> identisches Mapping-Verhalten
|
||||||
|
- Bag mit buyerName:null, procedureType:null, deadlineAt:null -> buyerName=null, procedureType=null, deadlineAt=null, title-Fallback greift wenn title fehlt/leer
|
||||||
|
- Bag mit fehlendem/leerem title -> title='Unbenannte Ausschreibung'
|
||||||
|
- doe-opendata-RawTenderRecord (eForms/OCDS) -> unveraendertes Ergebnis wie bisher (title/buyerName/cpvCodes/deadlineAt aus OCDS/eForms)
|
||||||
|
- status='active', dedupKey (ocid sonst `${sourcePortal}:${sourceNoticeId}`), contentHash und publishedAt werden fuer ALLE Quellen identisch berechnet
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Erweitere `normalize()` um einen `switch (raw.sourceType)`-Dispatch, der die quellenspezifische Feldextraktion von der geteilten Assembly trennt.
|
||||||
|
|
||||||
|
Schritt 1 — Extrahiere eine private Instanz-Methode `assemble(raw, coreFields)`, die den Tail von `normalize()` (status='active', dedupKey, contentHash via bestehendem computeContentHash, publishedAt-Fallback new Date()) plus die quellen-invarianten Felder (sourcePortal, sourceNoticeId, ocid, sourceUrl) uebernimmt und ein vollstaendiges NormalizedTenderFields zurueckgibt. `coreFields` traegt die 10 quellenspezifischen Felder: title, buyerName, cpvCodes, cpvDivisions, region, plz, bundesland, deadlineAt, estimatedValue, procedureType. Definiere dafuer einen lokalen Typ (z.B. `type CoreTenderFields = Omit<NormalizedTenderFields, 'sourcePortal'|'sourceNoticeId'|'ocid'|'dedupKey'|'status'|'sourceUrl'|'contentHash'|'publishedAt'|'fingerprint'>`). computeContentHash NICHT duplizieren — aus assemble aufrufen.
|
||||||
|
|
||||||
|
Schritt 2 — Der bestehende DÖE-Extraktionsblock (getEformsRoot/getOcdsTender/getOcdsBuyer und die daraus abgeleiteten title/buyerName/cpvCodes/cpvDivisions/region/plz/bundesland/deadlineAt/estimatedValue/procedureType) wird in eine private Methode `normalizeDoe(raw)` verschoben, die genau diese CoreTenderFields liefert und dann `this.assemble(raw, core)` zurueckgibt. Ergebnis muss byte-identisch zum jetzigen Verhalten sein (Regression-Schutz).
|
||||||
|
|
||||||
|
Schritt 3 — Neue private Methode `normalizeBag(raw)` fuer die flache Bag-Quellen. Lies `raw.ocdsPayload` als `{title?, buyerName?, procedureType?, legalFramework?, deadlineAt?}` defensiv (typeof object check, sonst leeres Objekt). Mappe: title = nicht-leerer String sonst 'Unbenannte Ausschreibung'; buyerName = String-oder-null; procedureType = String-oder-null; deadlineAt = parseOcdsDate(bag.deadlineAt) (existiert bereits, akzeptiert ISO-String, null-tolerant); cpvCodes=[], cpvDivisions=[], region=null, plz=null, bundesland=null, estimatedValue=null. legalFramework wird bewusst NICHT gemappt (kein Zielfeld in NormalizedTenderFields). Dann `this.assemble(raw, core)`.
|
||||||
|
|
||||||
|
Schritt 4 — In `normalize()`: `switch (raw.sourceType) { case 'ai-netserver': case 'cosinex-dtvp': return this.normalizeBag(raw); case 'doe-opendata': default: return this.normalizeDoe(raw); }`. Der default MUSS auf den DÖE-Pfad zeigen (kein throw), damit die additive Union kuenftig nicht bricht.
|
||||||
|
|
||||||
|
Beachte D-01-Konvention: buyerName/procedureType koennen null sein — keine Fallback-Strings dafuer erfinden. Nur title hat den Fallback 'Unbenannte Ausschreibung'.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run src/tenders/tender-normalizer.service.spec.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>tsc clean; normalize() dispatcht per sourceType; DÖE-Pfad unveraendert (bestehende Normalizer-Tests weiter gruen); computeContentHash nicht dupliziert (aus assemble aufgerufen).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Unit-Tests fuer beide Bag-Quellen + DÖE-Regression + Null-Felder</name>
|
||||||
|
<files>apps/api/src/tenders/tender-normalizer.service.spec.ts</files>
|
||||||
|
<action>
|
||||||
|
Ergaenze den bestehenden `describe('TenderNormalizerService', ...)` um einen neuen `describe`-Block fuer die generische Bag-Normalisierung. Nutze keine Fixture-ZIPs — konstruiere RawTenderRecord inline (die Bag-Quellen haben keine eForms/OCDS-Fixtures). Hilfsfunktion `bagRecord(sourceType, overrides)` baut ein RawTenderRecord mit sourceType, sourcePortal (z.B. 'tender24' bzw. 'cosinex-dtvp'), sourceNoticeId, ocid=undefined, sourceUrl, fetchedAt=new Date(), publishedAt=null, eformsPayload=null, ocdsPayload={title, buyerName, procedureType, legalFramework, deadlineAt}.
|
||||||
|
|
||||||
|
Testfaelle:
|
||||||
|
- ai-netserver: voll besetzte Bag (deadlineAt als ISO-String) -> title/buyerName/procedureType aus Bag; deadlineAt ist ein Date mit korrektem ISO-Wert; cpvCodes=[], cpvDivisions=[], region=null, plz=null, bundesland=null, estimatedValue=null; dedupKey === `${sourcePortal}:${sourceNoticeId}` (kein ocid); status==='active'.
|
||||||
|
- cosinex-dtvp: analoge voll besetzte Bag -> identisches Mapping-Verhalten.
|
||||||
|
- Null-/Leer-Felder: Bag mit buyerName=null, procedureType=null, deadlineAt=null, title='' -> buyerName===null, procedureType===null, deadlineAt===null, title==='Unbenannte Ausschreibung'.
|
||||||
|
- contentHash: ist ein 64-stelliger Hex-String (sha256) fuer eine Bag-Quelle (beweist Assembly-Wiederverwendung).
|
||||||
|
- Regression DÖE: mindestens ein bestehender Assert-Pfad ueber loadRawNoticePair(NOTICE_WITH_EFORMS_ONLY_DEADLINE) bleibt (title/deadlineAt aus eForms) — bestaetigt, dass der Refactor den DÖE-Pfad nicht veraendert. Falls schon vorhanden, nicht duplizieren.
|
||||||
|
|
||||||
|
Importiere den SourceType/RawTenderRecord-Typ aus './tender.types'. Halte die Tests deterministisch (feste ISO-Strings, kein Date.now-Vergleich fuer deadlineAt).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx vitest run src/tenders/tender-normalizer.service.spec.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Neue Tests decken ai-netserver-Bag, cosinex-dtvp-Bag, Null-/Leer-Felder und contentHash-Format ab; DÖE-Regressionstest bleibt gruen; gesamte Normalizer-Spec passt.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Volle API-Test-Suite + Typecheck als Nicht-Regressions-Gate</name>
|
||||||
|
<files>apps/api/src/tenders/tender-normalizer.service.ts</files>
|
||||||
|
<action>
|
||||||
|
Fuehre die vollstaendige API-Test-Suite und den Typecheck aus, um zu bestaetigen, dass der Normalizer-Refactor keine der 284 bestehenden Tests (insb. tender-dedup.service.spec.ts, tender-ingestion.service.spec.ts, tender-fingerprint) bricht. Keine Code-Aenderung in diesem Task ausser evtl. kleiner Fixes, falls ein nachgelagerter Test rot wird. Diese Datei ist im files_modified nur als Anker gelistet.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && npx vitest run</automated>
|
||||||
|
</verify>
|
||||||
|
<done>tsc exit 0; gesamte API-Suite gruen (>=284 Tests, jetzt inkl. der neuen Bag-Tests).</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `cd apps/api && npx vitest run src/tenders/tender-normalizer.service.spec.ts` — alle Normalizer-Tests inkl. Bag-Faelle gruen
|
||||||
|
- `cd apps/api && npx tsc --noEmit -p tsconfig.json` — clean
|
||||||
|
- `cd apps/api && npx vitest run` — volle Suite gruen (keine Dedup-/Ingestion-Regression)
|
||||||
|
- Manuell/gedanklich: fuer eine identische ai-netserver- und DÖE-Bag mit gleichem Titel/Buyer/Frist erzeugt tenderFingerprint() denselben Hash (Voraussetzung fuer Cross-Source-Dedup — bereits durch korrektes Mapping erfuellt).
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- normalize() dispatcht per sourceType; ai-netserver und cosinex-dtvp mappen die flache Bag korrekt in NormalizedTenderFields
|
||||||
|
- title-Fallback 'Unbenannte Ausschreibung'; buyerName/procedureType/deadlineAt null-tolerant; cpvCodes/cpvDivisions=[], region/plz/bundesland/estimatedValue=null
|
||||||
|
- status/dedupKey/contentHash/publishedAt via geteilter assemble-Methode (computeContentHash nicht dupliziert)
|
||||||
|
- DÖE-Pfad unveraendert (Regression-Tests gruen)
|
||||||
|
- Neue Unit-Tests decken beide Bag-Quellen + Null-Felder + DÖE-Regression ab
|
||||||
|
- Volle API-Suite + Typecheck gruen
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260723-e7i-normalizer-gap-phase-13-schliessen-tende/260723-e7i-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user