74c00166e2
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>
135 lines
9.9 KiB
Markdown
135 lines
9.9 KiB
Markdown
---
|
|
phase: 13-scraping-adapters-cross-source-dedup
|
|
plan: 03
|
|
type: execute
|
|
wave: 2
|
|
depends_on: ["13-01", "13-02"]
|
|
files_modified:
|
|
- apps/api/src/tenders/tender-dedup.service.ts
|
|
- apps/api/src/tenders/tender-dedup.service.spec.ts
|
|
- apps/api/src/tenders/tender-ingestion.service.ts
|
|
- apps/api/src/tenders/tender-ingestion.service.spec.ts
|
|
- apps/api/src/tenders/tenders.module.ts
|
|
autonomous: true
|
|
requirements: [SCHEMA-03]
|
|
must_haves:
|
|
truths:
|
|
- "Mit nur DÖE aktiv (activePortalCount < 2) laeuft die Fingerprint-Stufe NIE — zwei Tender mit gleichem Fingerprint bleiben zwei Tender (D-05, Erfolgskriterium 3, inert)."
|
|
- "Ab der 2. aktiven Quelle haengt ein Fingerprint-Match eine zusaetzliche TenderSource an den bestehenden Tender an, statt einen neuen Tender zu erzeugen (D-03/D-04)."
|
|
- "pollDueSources faechert ueber ALLE aktiven Configs (findMany isActive), mit catch-per-source (eine kaputte Quelle killt den Tick nicht, D-01)."
|
|
artifacts:
|
|
- apps/api/src/tenders/tender-dedup.service.ts
|
|
key_links:
|
|
- "pollDueSources -> SourceRegistry.get(config.sourceType) -> adapter -> normalize -> TenderDedupService.resolve(dedupActive = activePortalCount >= 2)."
|
|
- "TenderDedupService: OCID -> source:noticeId -> fingerprint; Match -> tenderSource.upsert; kein Match -> tender.create + tenderSource.create."
|
|
---
|
|
|
|
<objective>
|
|
Verdrahtet SCHEMA-03 in den Ingestion-Pfad: der dreistufige Dedup-Resolver (OCID -> Quelle:NoticeId -> Fingerprint, D-04) und der Fan-out von `pollDueSources` (poll-once-fan-out-many ueber die Registry, NICHT findUnique/findFirst) mit catch-per-source-Fehlerisolation (D-01). Das Fingerprint-Gate greift hart erst ab `activePortalCount >= 2` (D-05) — mit nur DÖE bleibt das bestehende Verhalten exakt unveraendert (Erfolgskriterium 3, inert).
|
|
|
|
Purpose: Der Merge-Kern, der aus "dieselbe Ausschreibung ueber mehrere Quellen" EINEN Tender mit mehreren TenderSource macht.
|
|
Output: tender-dedup.service.ts (+ spec), erweitertes pollDueSources (+ spec), Modul-Wiring von SourceRegistry + Adaptern + DedupService.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
|
@$HOME/.claude/gsd-core/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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/src/tenders/tender-ingestion.service.ts
|
|
@apps/api/src/tenders/tenders.module.ts
|
|
@apps/api/src/tenders/tender-normalizer.service.ts
|
|
@apps/api/src/tenders/source-registry.ts
|
|
@apps/api/src/tenders/tender-fingerprint.ts
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Dreistufiger Dedup-Resolver (D-04/D-05)</name>
|
|
<files>apps/api/src/tenders/tender-dedup.service.ts, apps/api/src/tenders/tender-dedup.service.spec.ts</files>
|
|
<behavior>
|
|
- dedupActive=false (nur DÖE): zwei normalisierte Records mit gleichem Fingerprint aber verschiedenen source:noticeIds erzeugen ZWEI Tender (Fingerprint-Stufe uebersprungen, D-05).
|
|
- dedupActive=true, OCID-Match: haengt TenderSource an bestehenden Tender, created=false.
|
|
- dedupActive=true, kein OCID aber source:noticeId bereits vorhanden (idempotenter Re-Poll): upsert der TenderSource, kein neuer Tender.
|
|
- dedupActive=true, kein OCID/noticeId-Match aber Fingerprint-Match: haengt zusaetzliche TenderSource an, created=false (D-03 Merge).
|
|
- Kein Match: tender.create (inkl. fingerprint) + tenderSource.create, created=true.
|
|
- tenderSource.upsert nutzt @@unique([sourcePortal, sourceNoticeId]) als where-Target.
|
|
</behavior>
|
|
<action>
|
|
Implementiere `tender-dedup.service.ts` (`@Injectable`) nach 13-RESEARCH.md Pattern 5: Methode `resolve(n: NormalizedTenderFields, opts: { dedupActive: boolean }): Promise<{ tenderId: string; created: boolean }>`. Reihenfolge: (1) OCID-Match `prisma.tender.findFirst({ where: { ocid } })` falls ocid vorhanden; (2) `prisma.tenderSource.findUnique({ where: { sourcePortal_sourceNoticeId: {...} } })` -> zugehoerigen Tender; (3) NUR wenn `opts.dedupActive` und noch kein Match: `prisma.tender.findFirst({ where: { fingerprint: tenderFingerprint(n) } })`. Bei Match: `tenderSource.upsert` (create: an match.id haengen, update: sourceUrl) und `created:false`. Ohne Match: `tender.create({ data: { ...felder, fingerprint: tenderFingerprint(n) } })` + `tenderSource.create`, `created:true`. Der `dedupActive`-Gate ist der D-05-Kern — Stufe 3 wird bei nur einer Quelle NIE erreicht.
|
|
|
|
WICHTIG (T-10-09 / Phase-10-Muster): plain PrismaService, KEIN forTenant()/RLS — Tender/TenderSource sind plattform-global. Schreibe die Spec RED-first mit gemocktem PrismaService (kein echtes IO) nach behavior-Block; der inert-Fall (dedupActive=false -> zwei Tender) ist der D-05-Beweis.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @tessera/api test -- tender-dedup</automated>
|
|
</verify>
|
|
<done>Alle behavior-Faelle gruen; inert bei einer Quelle, Merge ab zwei Quellen; nutzt tenderFingerprint aus 13-01.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: pollDueSources Fan-out + catch-per-source + Dedup-Hook</name>
|
|
<files>apps/api/src/tenders/tender-ingestion.service.ts, apps/api/src/tenders/tender-ingestion.service.spec.ts</files>
|
|
<action>
|
|
Baue `pollDueSources` von `findUnique({ sourceType:'doe-opendata' })` auf Fan-out um (13-RESEARCH.md Pattern 3): `const configs = await prisma.tenderSourcePollConfig.findMany({ where: { isActive: true } })`; `activePortalCount = configs.length`; `dedupActive = activePortalCount >= 2` (D-05). Iteriere die Configs: `const adapter = registry.get(config.sourceType as SourceType); if (!adapter) continue;` — je Config in `try { ... } catch (err) { this.logger.error(...) }` (catch-per-source, D-01: eine kaputte Quelle killt die anderen nicht). Der bestehende Day-Cursor-Gate (`nextDayToFetch`) + fetch + normalize bleiben PRO Quelle erhalten; ersetze den direkten `tender.upsert`-Block durch `dedup.resolve(normalized, { dedupActive })` und sammle `created`-Tender-IDs (Delta-Boundary D-07 bleibt: nur genuin neue IDs an `matching.matchDelta`). lastIngestedDay-Advance bleibt pro Config (Cursor je sourceType). Injiziere `SourceRegistry` und `TenderDedupService` in den Constructor; entferne die direkte `DoeOpenDataAdapter`-Nutzung im Poll-Pfad zugunsten `registry.get('doe-opendata')`.
|
|
|
|
Erweitere die bestehende `tender-ingestion.service.spec.ts`: (a) fan-out ueber mehrere aktive Configs; (b) catch-per-source (Quelle A wirft -> Quelle B laeuft trotzdem, Tick wirft nicht); (c) dedupActive-Gate (1 Config -> resolve mit dedupActive=false; 2 Configs -> dedupActive=true); (d) matchDelta bekommt nur created-IDs. Registry/Dedup im Test mocken.
|
|
</action>
|
|
<verify>
|
|
<automated>pnpm --filter @tessera/api test -- tender-ingestion.service</automated>
|
|
</verify>
|
|
<done>pollDueSources faechert ueber alle aktiven Configs; catch-per-source getestet; dedupActive an activePortalCount>=2 gebunden; Delta-Boundary erhalten; Spec gruen.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 3: Modul-Wiring — SourceRegistry + Adapter-Registrierung + DedupService</name>
|
|
<files>apps/api/src/tenders/tenders.module.ts</files>
|
|
<action>
|
|
Registriere in `tenders.module.ts` die neuen Provider `SourceRegistry` und `TenderDedupService`. Verdrahte die Adapter-Registrierung bei DI-Boot: der `DoeOpenDataAdapter` (und in 13-04/05 die neuen Adapter) werden nach Instanziierung ueber `SourceRegistry.register(adapter)` eingetragen — nutze ein `OnModuleInit`-Hook oder einen Factory-Provider, der die vorhandenen Adapter-Provider einsammelt und registriert (so greift auch das Denylist-Gate strukturell bei Boot, D-06). Halte die Registrierungsstelle so, dass 13-04/13-05 ihren Adapter additiv ergaenzen koennen (dieser Plan ist der einzige Schreiber von tenders.module.ts in Wave 2; 13-04/05 folgen serialisiert in spaeteren Waves). DI-Graph muss aufloesbar bleiben.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && npx tsc --noEmit -p tsconfig.json && pnpm --filter @tessera/api test -- src/tenders</automated>
|
|
</verify>
|
|
<done>SourceRegistry + TenderDedupService sind Provider; DoeOpenDataAdapter wird bei Boot registriert; DI-Graph aufloesbar; tenders-Suite gruen.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| Adapter-Fetch -> Ingestion-Tick | Ein blockendes/kaputtes Portal darf den globalen Tick nicht killen (D-01). |
|
|
| Normalisierte Records -> Dedup/DB | Falsches Gate koennte Bestand faelschlich mergen (D-05). |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
|
| T-13-03-01 | Denial of Service | ein blockendes Portal im Fan-out | high | mitigate | catch-per-source: try/catch je Config, Tick wirft nie (D-01, Pattern 3). |
|
|
| T-13-03-02 | Tampering | Dedup laeuft versehentlich mit 1 Quelle | high | mitigate | dedupActive = activePortalCount >= 2, explizit getestet (inert-Fall, D-05/Pitfall 3). |
|
|
| T-13-03-03 | Info Disclosure | Tenant-Leak durch RLS-Extension auf globalen Tabellen | medium | mitigate | plain PrismaService, kein forTenant() (T-10-09-Muster). |
|
|
| T-13-03-04 | Tampering | Fingerprint-Kollision merged unaehnliche Tender | medium | accept | Titel im Hash (Pitfall 1); bei Kollision Granularitaet erhoehen, kein Threshold. |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `pnpm --filter @tessera/api test -- tender-dedup` gruen (inert + merge).
|
|
- `pnpm --filter @tessera/api test -- tender-ingestion.service` gruen (fan-out + catch-per-source + gate).
|
|
- `npx tsc --noEmit` + `pnpm --filter @tessera/api test -- src/tenders` gruen (DI-Graph).
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
Fan-out ueber alle aktiven Quellen mit Fehlerisolation; dreistufiger Dedup ab 2. Quelle haengt TenderSource an statt neuen Tender; inert mit nur DÖE. TrAegt SCHEMA-03 (Wiring) + Erfolgskriterium 3 (Dedup-Logik).
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/13-scraping-adapters-cross-source-dedup/13-03-SUMMARY.md` when done
|
|
</output>
|