Normalizer-Gap (quick 260723-e7i) closed criteria 1/2; re-verification lifts Phase 13 from 1/4 to 3/4. Truth 3 (cross-source fingerprint dedup) stays PARTIAL: newly-found cpvDivisions asymmetry (scrapers always [], ~79% of DÖE tenders populated) makes exact-match fingerprint miss most real overlaps. User decision: accept as dormant deferral — dedup is inert (both scrapers isActive=false), decide with live data once a 2nd source is activated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
21 KiB
phase, verified, status, score, behavior_unverified, overrides_applied, re_verification, gaps, deferred, human_verification, residual_gap_decision
| phase | verified | status | score | behavior_unverified | overrides_applied | re_verification | gaps | deferred | human_verification | residual_gap_decision | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 13-scraping-adapters-cross-source-dedup | 2026-07-23T10:30:00Z | gaps_found | 3/4 must-haves verified | 0 | 0 |
|
|
|
|
Phase 13: Scraping Adapters & Cross-Source Deduplication Verification Report
Phase Goal: Die Plattform erweitert die Ausschreibungs-Abdeckung um AI-AG-NetServer- und cosinex-Portal-Adapter und fasst Ausschreibungen, die auf mehreren Quellen auftauchen, zu einem Eintrag zusammen — während sie das Scrapen der AGB-verbotenen Portale strukturell verweigert. Verified: 2026-07-23T10:30:00Z Status: gaps_found Re-verification: Yes — after gap closure (quick task 260723-e7i)
What Changed Since Last Verification
Quick task 260723-e7i (commits 82c9a48, 9881005) added a per-sourceType dispatch to
TenderNormalizerService.normalize(). Read live in this re-verification
(apps/api/src/tenders/tender-normalizer.service.ts):
normalize()switches onraw.sourceType:ai-netserver/cosinex-dtvp→normalizeBag();doe-opendata/default →normalizeDoe()(default never throws).normalizeBag()reads the flatocdsPayloadbag{title, buyerName, procedureType, legalFramework, deadlineAt}and mapstitle(fallback'Unbenannte Ausschreibung'),buyerName/procedureType(null-tolerant, no invented fallback),deadlineAtvia the existingparseOcdsDate().cpvCodes/cpvDivisions=[],region/plz/bundesland/estimatedValue=null(there is genuinely no such data in the NetServer/cosinex HTML — confirmed by reading both adapters' column extraction).- A shared
assemble()computesstatus/dedupKey/contentHash/publishedAtidentically for both paths —normalizeDoe()is a byte-identical extraction of the pre-existing DÖE logic.
This closes the previously-identified root cause for Truths 1 and 2 — NetServer and cosinex records now carry their real title/buyer/procedureType/deadline instead of falling through to defaults. It partially closes Truth 3: title/buyer/deadline now flow correctly into the fingerprint, but re-verification surfaced a new, previously-undetected issue specific to the CPV dimension of the fingerprint that still blocks most real-world cross-source matches (see Gap below).
Goal Achievement
Observable Truths (ROADMAP Success Criteria)
| # | Truth | Status | Evidence |
|---|---|---|---|
| 1 | NetServer-Portale (lhs-vpbw, tender24, vergabe.landbw) erscheinen als zusätzliche, korrekt normalisierte Treffer über EINEN config-getriebenen Adapter | ✓ VERIFIED (flipped) | normalizeBag() now maps ai-netserver bag records into real title/buyerName/procedureType/deadlineAt (verified live: tender-normalizer.service.spec.ts "ai-netserver: maps the flat bag into NormalizedTenderFields" test asserts title==='Bauleistung X', buyerName==='Stadt Y', deadlineAt ISO round-trip). cpvCodes=[]/region/plz/bundesland/estimatedValue=null is CORRECT (not a stub) — the NetServer HTML table structurally has no such columns (confirmed reading netserver.adapter.ts: 6 cells = published/title/buyer/procedureType/legalFramework/deadline, no CPV). Full API suite green (288/288), tsc clean. |
| 2 | cosinex/DTVP (DTVP) erscheint als zusätzlicher Treffer über einen SEPARATEN config-getriebenen Adapter, korrekt normalisiert | ✓ VERIFIED (flipped) | Identical fix applies via the same normalizeBag() dispatch for cosinex-dtvp. Verified live: "cosinex-dtvp: maps an identical bag shape identically" test passes. cosinex.adapter.ts's csx-new-table columns (published/deadline/title/type/buyer/action) likewise have no CPV column — cpvCodes=[] is correct, not a gap. |
| 3 | Ein über DÖE + einem Scraping-Adapter gesehener Tender erscheint EINMAL mit Links zu allen Quell-Portalen (Fuzzy-Fingerprint-Dedup); Dedup greift erst ab 2. aktiver Quelle | ⚠️ PARTIAL (still, for a NEW reason) | Mechanism (resolver, dedupActive gate, fan-out, backfill, multi-source UI) remains fully built/tested — unchanged from prior verification. The originally-identified blocker (title/buyer/deadline defaults) IS closed. BUT: tenderFingerprint() hashes cpvDivisionKey as an exact-match segment; NetServer/cosinex always produce cpvDivisions=[] (no CPV data exists in their HTML) while ~79% of existing DÖE tenders (2250/2851 live, array_length>0) have non-empty cpvDivisions. For any such tender, the scraper-side fingerprint hash will NEVER equal the DÖE-side hash, so the fingerprint tier will not collapse them — "shows up once" will fail for an estimated majority of real overlapping notices. No existing test exercises this asymmetry (the one cross-source fingerprint test in tender-dedup.service.spec.ts gives both synthetic records the same non-empty cpvDivisions). See Gaps. |
| 4 | Registrierung von vergabe24/aumass als Poll-Quelle wird vom System selbst (Code, nicht nur Doku) verweigert | ✓ VERIFIED (re-confirmed, no regression) | source-registry.spec.ts 6/6 pass (unchanged); source-registry.ts untouched by the quick-fix (only tender-normalizer.service.ts/.spec.ts were modified — confirmed via git log on the file). |
Score: 3/4 truths verified (0 present-behavior-unverified; 1 partial — mechanism fully built and now fed correct title/buyer/deadline data, but a newly-identified CPV-availability asymmetry in the fingerprint still blocks most real cross-source matches)
Required Artifacts (delta since prior verification)
| Artifact | Expected | Status | Details |
|---|---|---|---|
apps/api/src/tenders/tender-normalizer.service.ts |
Per-sourceType dispatch mapping the generic bag into NormalizedTenderFields |
✓ VERIFIED | normalize() switches on raw.sourceType; normalizeBag() present, normalizeDoe() unchanged (byte-identical DÖE extraction preserved under a renamed private method); shared assemble() extracted, computeContentHash not duplicated |
apps/api/src/tenders/tender-normalizer.service.spec.ts |
Tests for both bag sources + null fields + DÖE regression | ✓ VERIFIED | 14/14 tests pass (10 pre-existing DÖE + 4 new bag tests); DÖE fixture-based regression tests untouched and green |
All other artifacts from the prior verification (fingerprint fn, schema, source-registry, adapters, dedup resolver, controller, TenderDetail) are unchanged by this quick task and were re-confirmed passing as part of the full-suite run below (no regression).
Key Link Verification (delta)
| From | To | Via | Status | Details |
|---|---|---|---|---|
netserver.adapter.ts / cosinex.adapter.ts |
tender-normalizer.service.ts |
RawTenderRecord.ocdsPayload -> normalize() |
✓ WIRED (flipped from NOT_WIRED) | normalizeBag() now correctly extracts the flat bag; verified by dedicated bag tests and by the field-authority correctness argument above (empty CPV is a correct reflection of absent source data, not a mapping bug) |
tender-normalizer.service.ts (normalizeBag output) |
tender-fingerprint.ts (tenderFingerprint) |
NormalizedTenderFields.cpvDivisions -> cpvDivisionKey() |
⚠️ PARTIAL — semantically mismatched | Data flows without error, but the exact-match hash semantics mean a structurally-always-empty cpvDivisions on the scraper side will not collide with a populated cpvDivisions on the DÖE side for ~79% of existing tenders — see Gap |
Behavioral Spot-Checks / Test Suite Results (run live during this re-verification)
| Check | Command | Result | Status |
|---|---|---|---|
| API full test suite | cd apps/api && npx vitest run |
25 files, 288/288 pass (up from 284; +4 new bag-mapping tests) | ✓ PASS |
| API typecheck | cd apps/api && npx tsc --noEmit -p tsconfig.json |
clean, exit 0 | ✓ PASS |
| Normalizer spec only | (part of full suite) | tender-normalizer.service.spec.ts 14/14 pass, incl. new describe('generic ocdsPayload bag ...') block |
✓ PASS |
| Dedup resolver (unchanged) | (part of full suite) | tender-dedup.service.spec.ts 8/8 pass |
✓ PASS |
| Fingerprint fn (unchanged) | (part of full suite) | tender-fingerprint.spec.ts 8/8 pass |
✓ PASS |
| Denylist refusal (unchanged) | (part of full suite) | source-registry.spec.ts 6/6 pass |
✓ PASS |
| Live DB — CPV-empty ratio (new check, this re-verification) | docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c "SELECT count(*) total, count(*) FILTER (WHERE array_length(cpvDivisions,1) IS NULL OR array_length(cpvDivisions,1)=0) empty_cpv FROM Tender" |
2851 total, 601 empty_cpv (~21%) | ⚠️ Confirms the fingerprint CPV-mismatch gap above — ~79% of existing tenders have non-empty cpvDivisions |
| Live DB — poll configs seeded (re-checked, unchanged) | docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c 'SELECT "sourceType","isActive" FROM "TenderSourcePollConfig"' |
Only doe-opendata row present |
⚠️ Still not restarted since Plans 13-04/13-05 — unaffected by this quick task, human_verification item carried forward unchanged |
| Debt markers (TBD/FIXME/XXX/TODO/HACK/PLACEHOLDER) in modified files | `grep -n -E "TBD | FIXME | XXX |
| Git history confirms scope | git log --oneline -- apps/api/src/tenders/tender-normalizer.service.ts |
82c9a48 feat(quick-260723-e7i): per-sourceType dispatch in TenderNormalizerService is the latest commit on the file |
✓ PASS |
Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| INGEST-02 | 13-04 + quick-260723-e7i | Import von AI-AG-NetServer-Portalen über konfigurierbaren Adapter | ✓ SATISFIED | Adapter fetches/parses correctly AND records now normalize correctly (Truth 1 flipped) |
| INGEST-03 | 13-05 + quick-260723-e7i | Import vom cosinex-Vergabemarktplatz über Adapter | ✓ SATISFIED | Same fix applies (Truth 2 flipped) |
| INGEST-07 | 13-02 | vergabe24/aumass hart als Denylist, keine automatische Registrierung möglich | ✓ SATISFIED (re-confirmed) | Unchanged, 6/6 tests green |
| SCHEMA-03 | 13-01/03/06 + quick-260723-e7i | Dedup zu einem Eintrag mit mehreren Quell-Links; greift erst ab 2. aktiver Quelle | ⚠️ PARTIALLY SATISFIED (still) | Mechanism + now-correct title/buyer/deadline inputs are in place, but the CPV-availability asymmetry in the fingerprint blocks the practical "shows up once" outcome for most real DÖE↔scraper duplicates |
REQUIREMENTS.md currently marks all four as [x]/"Complete" (Phase 13). This re-verification confirms INGEST-02 and INGEST-03 are now genuinely satisfiable end-to-end. SCHEMA-03 remains only partially achieved for the two new sources specifically because of the CPV-fingerprint asymmetry documented above — this is a narrower, more precise finding than the prior verification's broader "everything defaults" blocker, but it is not yet fully closed.
Anti-Patterns Found
None in the two files modified by the quick fix (tender-normalizer.service.ts, tender-normalizer.service.spec.ts) — no TBD/FIXME/XXX/TODO/HACK/PLACEHOLDER markers.
Human Verification Required
- Real-world cross-source dedup confirmation (carried forward, refined) — after
ai-netserver/cosinex-dtvpis activated and a poll tick runs, confirm a real overlapping notice collapses to oneTenderwith twoTenderSourcelinks. Given the CPV-asymmetry finding, this is now more likely to succeed specifically for a DÖE counterpart that happens to have no CPV code — not a general guarantee. Why human: needs live portal access, a real overlapping notice, and a running scheduler tick. - Poll-config seed freshness (carried forward, unchanged, re-checked live) — local API process still needs a rebuild/restart so
ai-netserver/cosinex-dtvpTenderSourcePollConfigrows exist (confirmed viadocker exec psql: still only 1 row). Why human: requires a deploy/restart action outside verification scope.
Gaps Summary
Quick task 260723-e7i successfully closed the root cause originally blocking Success Criteria 1 and 2: TenderNormalizerService now correctly maps the NetServer/cosinex flat ocdsPayload bag into real title/buyerName/procedureType/deadlineAt, confirmed by 4 new passing unit tests and a clean 288/288 full-suite run + clean tsc. Truths 1, 2, and 4 are now fully VERIFIED.
Success Criterion 3 (cross-source dedup) is closer to achieved than before, but this re-verification surfaced a new, previously-undetected residual issue distinct from the one just fixed: tenderFingerprint() requires an EXACT match on cpvDivisionKey, and NetServer/cosinex structurally can never populate cpvDivisions (there is no CPV column in either portal's search-results HTML). A live DB check shows ~79% of existing DÖE tenders carry non-empty cpvDivisions, meaning the fingerprint tier will fail to collapse the majority of real DÖE↔NetServer/cosinex duplicates even with correct title/buyer/deadline data. This is provable from code + live DB stats, not something requiring live portal access — it is a developer decision (e.g., treat an empty cpvDivisions as a wildcard segment rather than requiring exact equality) that has not yet been made. Recommend a small follow-up plan or an explicit accepted-limitation override before closing SCHEMA-03/Success Criterion 3 for good.
The two previously-identified human_verification items (live overlap confirmation once a second source is active; local poll-config seed freshness) are unaffected by the quick fix and are carried forward unchanged — re-checked live in this session, the local DB still shows only the doe-opendata poll config row.
Verified: 2026-07-23T10:30:00Z Verifier: Claude (gsd-verifier)