20 KiB
phase, verified, status, score, behavior_unverified, overrides_applied, gaps, deferred, human_verification
| phase | verified | status | score | behavior_unverified | overrides_applied | gaps | deferred | human_verification | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 13-scraping-adapters-cross-source-dedup | 2026-07-23T10:15:00Z | gaps_found | 1/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:15:00Z Status: gaps_found Re-verification: No — initial verification
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 | ✗ FAILED | netserver.adapter.ts parst HTML korrekt zu RawTenderRecord[] (14/14 Tests grün), aber tender-normalizer.service.ts versteht die generische ocdsPayload-Bag nicht (getOcdsTender()/getOcdsBuyer() lesen nur .tender/.buyer) — jeder NetServer-Datensatz würde zu title='Unbenannte Ausschreibung', buyerName=null, cpvCodes=[], deadlineAt=null normalisiert. Selbst dokumentiert als "Known Stubs" in 13-04-SUMMARY.md. |
| 2 | cosinex/DTVP (DTVP) erscheint als zusätzlicher Treffer über einen SEPARATEN config-getriebenen Adapter, korrekt normalisiert | ✗ FAILED | Gleiche Grundursache wie #1: cosinex.adapter.ts parst korrekt (16/16 Tests grün, inkl. echtem ISO-8859-1-Encoding-Fix), aber dieselbe generische Bag wird vom Normalizer nicht verstanden. Selbst dokumentiert als "Known Stubs" in 13-05-SUMMARY.md. |
| 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 (siehe gaps) | Resolver-Mechanik (tender-dedup.service.ts, 3-Stufen, dedupActive-Gate), Fan-out (pollDueSources via findMany), Backfill (2851/2851) und Multi-Source-UI (TenderDetail.tsx) sind alle vorhanden, verdrahtet und isoliert getestet — VERIFIZIERT auf Mechanik-Ebene. ABER: da NetServer/cosinex-Datensätze wegen Truth 1/2 mit generischem/leerem Titel+Buyer+Frist normalisiert würden, würde ihr Fingerprint NIE mit dem echten DÖE-Gegenstück kollidieren — reale Cross-Source-Dedup-Treffer zwischen DÖE↔NetServer/cosinex sind praktisch unerreichbar, bis der Normalizer-Gap geschlossen ist. |
| 4 | Registrierung von vergabe24/aumass als Poll-Quelle wird vom System selbst (Code, nicht nur Doku) verweigert | ✓ VERIFIED | source-registry.ts: SourceRegistry.register() iteriert adapter.portals und wirft DeniedPortalError bei jedem Treffer gegen DENYLISTED_PORTALS = ['vergabe24','aumass'], auch bei gemischtem Array (kein Teil-Register). 6/6 Tests grün, inkl. explizitem vergabe24-Test, aumass-Test und Mixed-Array-Test. |
Score: 1/4 truths verified (0 present-behavior-unverified; 3 failed/partial, all rooted in one shared cause: the normalizer does not consume the generic ocdsPayload bag the two new adapters intentionally produce)
Required Artifacts
| Artifact | Expected | Status | Details |
|---|---|---|---|
apps/api/src/tenders/tender-fingerprint.ts |
NULL-tolerant pure fingerprint fn | ✓ VERIFIED | 8/8 unit tests pass; sha256([buyer,title,cpvDivisionKey,deadlineKey,valueBucket]) |
apps/api/prisma/schema.prisma (TenderSource, Tender.fingerprint) |
1:n model + additive column | ✓ VERIFIED | model TenderSource present, @@unique([sourcePortal, sourceNoticeId]), Tender.fingerprint nullable + @@index; migration applied locally |
apps/api/src/tenders/source-registry.ts |
Denylist gate | ✓ VERIFIED | DeniedPortalError, DENYLISTED_PORTALS, wired into TendersModule.onModuleInit |
apps/api/src/tenders/adapters/tender-source-adapter.interface.ts |
Generalized portals[] contract |
✓ VERIFIED | readonly portals: readonly string[] present; DÖE/NetServer/cosinex all declare it |
apps/api/src/tenders/tender-dedup.service.ts |
3-tier resolver | ✓ VERIFIED | OCID → source:noticeId → fingerprint, dedupActive hard gate on tier 3, SCHEMA-02 field-refresh preserved |
apps/api/src/tenders/tender-ingestion.service.ts (pollDueSources) |
findMany fan-out, catch-per-source |
✓ VERIFIED | prisma.tenderSourcePollConfig.findMany({where:{isActive:true}}), try/catch per config inside the loop |
apps/api/src/tenders/adapters/netserver.adapter.ts |
Config-driven, 3 portals, 1 class | ✓ VERIFIED (as HTML-parser) / ✗ (as normalized-tender producer) | portals = ['tender24','lhs-vpbw','vergabe.landbw']; parse layer solid, but output not consumable by normalizer (see Truth 1) |
apps/api/src/tenders/adapters/cosinex.adapter.ts |
Separate single-portal adapter | ✓ VERIFIED (as HTML-parser) / ✗ (as normalized-tender producer) | portals = ['cosinex-dtvp']; same caveat as above (see Truth 2) |
apps/api/src/tenders/tenders.controller.ts (getTender) |
includes sources[] |
✓ VERIFIED | include: { sources: { select: {...} } }, route order unchanged (:id after all static routes) |
apps/web/.../TenderDetail.tsx |
Renders all source links | ✓ VERIFIED | tender.sources.map(...) with portalLabel(), fallback to legacy single sourceUrl block; 2 dedicated tests pass |
Key Link Verification
| From | To | Via | Status | Details |
|---|---|---|---|---|
netserver.adapter.ts / cosinex.adapter.ts |
tender-normalizer.service.ts |
RawTenderRecord.ocdsPayload -> normalize() |
✗ NOT_WIRED (semantically) | Data physically flows (no crash), but normalize()'s field extraction contract (.tender/.buyer) does not match the flat bag shape the adapters produce — a silent, no-error data-quality break, not a compile/runtime error |
tender-ingestion.service.ts (pollDueSources) |
source-registry.ts |
registry.get(config.sourceType) |
✓ WIRED | Confirmed in code + tender-ingestion.service.spec.ts fan-out tests |
tenders.module.ts (onModuleInit) |
source-registry.ts |
sourceRegistry.register(doeAdapter/netServerAdapter/cosinexAdapter) |
✓ WIRED | All 3 adapters registered at DI boot; denylist gate structurally applies here |
tender-dedup.service.ts |
prisma.tenderSource / prisma.tender |
upsert/create on match/no-match |
✓ WIRED | Composite-key upsert target verified by dedicated test |
tenders.controller.ts (getTender) |
apps/web TenderDetail.tsx |
sources[] field on API response |
✓ WIRED | Backend include + frontend consumption both tested |
Behavioral Spot-Checks / Test Suite Results (run live during this verification)
| Check | Command | Result | Status |
|---|---|---|---|
| API full test suite | cd apps/api && npx vitest run |
25 files, 284/284 pass | ✓ PASS |
| API typecheck | cd apps/api && npx tsc --noEmit -p tsconfig.json |
clean, exit 0 | ✓ PASS |
| Web full test suite | cd apps/web && npx vitest run |
23 files, 133/133 pass | ✓ PASS |
| Web typecheck | cd apps/web && npx tsc --noEmit |
clean, exit 0 | ✓ PASS |
| Denylist refusal (source-registry.spec.ts) | (part of full suite) | 6/6 pass | ✓ PASS |
| Dedup resolver (tender-dedup.service.spec.ts) | (part of full suite) | 8/8 pass | ✓ PASS |
| Local DB — TenderSource backfill | psql -c 'SELECT count(*) FROM "TenderSource"' |
2851 | ✓ PASS (matches SELECT count(*) FROM "Tender" = 2851) |
| Local DB — fingerprint backfill | psql -c 'SELECT count(*) FROM "Tender" WHERE fingerprint IS NOT NULL' |
2851 | ✓ PASS |
| Local DB — poll configs seeded | psql -c 'SELECT "sourceType","isActive" FROM "TenderSourcePollConfig"' |
Only doe-opendata row present |
⚠️ Local API process not yet restarted since Plans 13-04/13-05 landed — seed code for ai-netserver/cosinex-dtvp exists but hasn't run against this DB (see human_verification) |
| Debt markers (TBD/FIXME/XXX) in phase files | `grep -n -E "TBD | FIXME | XXX"` across 8 key files |
Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|---|---|---|---|---|
| INGEST-02 | 13-04 | Import von AI-AG-NetServer-Portalen über konfigurierbaren Adapter | ✗ BLOCKED | Adapter fetches/parses correctly, but records would not be usably imported (garbage-normalized) — see Truth 1 |
| INGEST-03 | 13-05 | Import vom cosinex-Vergabemarktplatz über Adapter | ✗ BLOCKED | Same root cause — see Truth 2 |
| INGEST-07 | 13-02 | vergabe24/aumass hart als Denylist, keine automatische Registrierung möglich | ✓ SATISFIED | source-registry.spec.ts, code-level DeniedPortalError |
| SCHEMA-03 | 13-01/03/06 | Dedup zu einem Eintrag mit mehreren Quell-Links; greift erst ab 2. aktiver Quelle | ⚠️ PARTIALLY SATISFIED | Schema, resolver, fan-out, backfill, and display are all correctly built and tested for the mechanism; the practical DÖE↔NetServer/cosinex cross-source match is blocked by the same normalizer gap (Truth 3) |
REQUIREMENTS.md currently marks all four as [x]/"Complete" — this verification finds INGEST-02 and INGEST-03 not actually satisfiable end-to-end yet, and SCHEMA-03 only partially achieved for the two new sources (DÖE-internal re-poll/change-detection dedup, which predates this phase, remains fully intact — see below).
SCHEMA-02 Regression Check
Confirmed NOT regressed: tender-dedup.service.spec.ts has two dedicated tests ("OCID match with a changed contentHash still updates...", "...UNCHANGED contentHash does NOT call tender.update"), and the pre-existing tender-ingestion.service.spec.ts SCHEMA-02 suite (insert-once, update-in-place-on-changed-hash) was migrated to the new resolve() hand-off and remains green. The mutable-field list (title, buyerName, cpvCodes, cpvDivisions, region, plz, bundesland, deadlineAt, estimatedValue, procedureType, sourceUrl, contentHash) was copied verbatim from the old tender.upsert UPDATE branch.
Anti-Patterns Found
None — no TBD/FIXME/XXX/TODO/HACK/PLACEHOLDER markers in the 8 key phase files scanned. The normalizer gap is not a code-smell/stub pattern; it is a structurally absent capability, self-disclosed transparently in both SUMMARY.md files' "Known Stubs" sections rather than hidden.
Human Verification Required
- Real-world cross-source dedup confirmation — Test: after the normalizer gap is fixed, activate
ai-netserverorcosinex-dtvp(TenderSourcePollConfig.isActive=true), run one poll tick, and confirm a real known-overlapping tender collapses to oneTenderrow with twoTenderSourcelinks visible inTenderDetail. Expected: one results-list entry, two clickable source links. Why human: needs live portal access, a real overlapping notice, and a running scheduler tick — not provable from static code. - Poll-config seed freshness — Test: confirm the local API process has been rebuilt/restarted since Plans 13-04/13-05 so
ai-netserver/cosinex-dtvpTenderSourcePollConfigrows actually exist locally (currently onlydoe-opendatais present in the DB). Why human: requires a deploy/restart action outside this verification's scope (per project convention, Claude does not restart local Docker services).
Gaps Summary
Phase 13 built a genuinely solid, well-tested core: the TenderSource schema + 2851-row backfill, the NULL-tolerant fingerprint function, the SourceRegistry with a real code-level denylist gate (Success Criterion 4 fully achieved), the three-tier dedup resolver with dedupActive hard-gating (SCHEMA-03 mechanism), the findMany fan-out with catch-per-source isolation, and the multi-source-link display in TenderDetail. All of this is unit-tested (284/284 API, 133/133 web) and both typechecks are clean.
However, both new HTML adapters (NetServerAdapter, CosinexAdapter) deliberately punt on a required, self-documented follow-up: they carry their parsed fields in a flat generic ocdsPayload bag that TenderNormalizerService — hardcoded to the DÖE eForms/OCDS shape — cannot read. Both 13-04-SUMMARY.md and 13-05-SUMMARY.md disclose this transparently under "Known Stubs" as "out of this plan's files_modified scope" and "a natural follow-up plan" — but no such follow-up plan exists in Phase 13, and Phase 14's roadmap scope (RSS/email ingestion, admin source UI, i18n) does not cover it either. Since the poll configs for both new sources are seeded isActive: false, this gap is currently dormant in production — but it directly blocks 3 of the phase's 4 ROADMAP success criteria from being true as written ("appear as additional, correctly normalized results" x2, and real DÖE↔scraper cross-source dedup matching). Recommend a closure plan that extends TenderNormalizerService (or introduces a per-sourceType normalization strategy) to map the NetServer/cosinex generic bag into NormalizedTenderFields before this phase can be considered to have achieved its stated goal.
Verified: 2026-07-23T10:15:00Z Verifier: Claude (gsd-verifier)