From d784248e284386bc88add814b1d53d7db7684a2e Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 4 Oct 2026 14:17:56 +0200 Subject: [PATCH] Schliesse das Notebook-Source-Mapping, damit Wizard und Taxonomie ohne leere Kategorie starten. Co-authored-by: Cursor --- ...buying-agent-source-discovery-spec-v0.3.md | 1303 +++++++++++++++++ backend/app/api/discovery.py | 5 + backend/app/discovery/enums.py | 9 + backend/app/persistence/discovery_models.py | 2 + backend/app/services/catalog_service.py | 56 +- backend/app/services/discovery_service.py | 31 + .../0006_source_category_readiness.py | 37 + backend/scripts/bootstrap.py | 10 +- backend/scripts/seed_notebook_pilot.py | 10 +- .../tests/unit/test_project_criteria_parse.py | 6 + frontend/src/api.ts | 17 +- frontend/src/pages/AdminPage.tsx | 16 +- frontend/src/pages/WizardPage.tsx | 12 + 13 files changed, 1505 insertions(+), 9 deletions(-) create mode 100644 .specs/buying-agent-source-discovery-spec-v0.3.md create mode 100644 backend/migrations/versions/0006_source_category_readiness.py diff --git a/.specs/buying-agent-source-discovery-spec-v0.3.md b/.specs/buying-agent-source-discovery-spec-v0.3.md new file mode 100644 index 0000000..8b6fa6c --- /dev/null +++ b/.specs/buying-agent-source-discovery-spec-v0.3.md @@ -0,0 +1,1303 @@ +# Buying Agent – Source & Discovery Specification v0.3 + +**Status:** Review Candidate – vor Implementierung erneut durch den Coding-Agenten zu prüfen +**Spezifikationsversion:** 0.3 +**Stand:** 2026-10-04 + +**Ersetzt vollständig:** `buying-agent-source-discovery-spec-v0.3.md` + +**Gilt zusammen mit:** +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-frontend-mvp-spec-v0.3.md` + +Bei Widersprüchen innerhalb des Source-/Discovery-Bereichs hat diese v0.3 Vorrang. + +--- + +# 1. Ziel + +Diese Spezifikation definiert den Source-/Discovery-Pfad des **funktionierenden MVP**. + +Sie ist ausdrücklich **kein Skeleton und kein theoretischer POC**. + +Der MVP gilt in diesem Bereich erst dann als funktionsfähig, wenn der reale Pfad: + +```text +Produktkategorie wählen + ↓ +SourceCategory bestimmen + ↓ +Geizhals-Taxonomie technisch lernen + ↓ +Filter und URL-Konstrukte verifizieren + ↓ +Kriterien über API/UI anbieten + ↓ +Such-URL aus ProjectCriteria erzeugen + ↓ +SearchRun ausführen + ↓ +Ergebnisse und Attribute parsen + ↓ +Produkte identifizieren / historisieren + ↓ +Diffing + ↓ +Kandidaten / Ranking / Shortlist + ↓ +UI +``` + +end-to-end funktioniert. + +Initiale Source: **Geizhals** +Pilotkategorie: **Notebooks** + +Die Architektur darf nicht notebook-spezifisch sein. + +--- + +# 2. Grundprinzipien + +1. Geizhals ist Discovery-Quelle, nicht Decision Engine. +2. Source-Kriterien bilden für unterstützte Kategorien zunächst den primären Kriterienkatalog. +3. Es gibt keine separate Canonical-Criteria-Schicht. +4. Jedes gelernte Source-Kriterium erhält eine stabile interne `CriterionDefinition.id` (UUID). +5. SearchFilter und Ergebnisattribute werden auf dieselbe `CriterionDefinition.id` gemappt. +6. INTERNAL-Kriterien werden nur ergänzt, wenn die Source relevante Eigenschaften nicht kennt. +7. URL-Encoding wird aus beobachtetem Source-Verhalten gelernt, nicht erfunden. +8. Nur verifizierte Mappings werden produktiv verwendet. +9. Taxonomy Learning und normale SearchRuns sind getrennte Prozesse. +10. Produktidentität bleibt konservativ. +11. Historie wird strukturiert gespeichert, kein HTML-Archiv. +12. Discovery, Ranking und Decision bleiben getrennt. +13. Vollständige Reconciliation darf nur auf vollständigen SearchRuns beruhen. + +## 2.1 Konkret beobachtete Geizhals-Mechanismen für den Notebook-Pilot + +Die aktuelle Notebook-Kategorie zeigt bereits ausreichend konkrete Muster, um den MVP-Pfad präziser festzulegen: + +- zahlreiche Filter werden als auswählbare Optionen/Links dargestellt, +- Filtergruppen umfassen u. a. Hersteller, Diagonale, Auflösung, CPU-Hersteller, CPU-Serie, RAM, Speicher, Grafik, Betriebssystem und Gewicht, +- Preis, Gewicht und Diagonale sind typische Kandidaten für Range-/Input-Verhalten, +- die Ergebnisliste enthält bereits einen wesentlichen Teil der für den MVP relevanten Produktdaten, darunter Betriebssystem, Display, CPU, RAM, Speicher, Grafik, Modell-/Produktreferenz, Angebote und Bestpreis. + +Diese Beobachtungen dienen als Pilotannahmen und müssen vor finaler Parser-Implementierung am echten Live-DOM validiert werden. + +--- + +# 3. ProductCategory und SourceCategory + +## 3.1 ProductCategory + +Interner stabiler Katalog: + +```text +ProductCategory +id +name +status +``` + +Beispiel: + +```text +id = notebook +name = Notebooks +``` + +## 3.2 SourceCategory + +Mapping zur Source: + +```text +SourceCategory +id +source +external_category_key +product_category_id +source_url +status +taxonomy_status +last_taxonomy_scan_at +last_verified_at +``` + +Beispiel: + +```text +source = geizhals +external_category_key = nb +product_category_id = notebook +``` + +--- + +# 4. SourceCategory Readiness + +Eine SourceCategory besitzt einen technischen Readiness-Status: + +```text +NOT_LEARNED +LEARNING +READY +DEGRADED +BROKEN +BLOCKED +``` + +## 4.1 READY + +Eine SourceCategory ist `READY`, wenn mindestens: + +- Baseline-Kategorieseite erreichbar ist +- Taxonomie technisch gelesen werden kann +- relevante Filterdefinitionen persistiert sind +- verwendbare URL-Encoding-Regeln verifiziert sind +- Query-Generation erfolgreich getestet wurde +- Result-Parsing mindestens für Basisdaten funktioniert +- kein aktiver Block-/Captcha-Zustand vorliegt + +## 4.2 DEGRADED + +`DEGRADED`, wenn: + +- ein Teil der Filter nicht verifizierbar ist +- einzelne Mappings BROKEN sind +- Result-Parsing teilweise funktioniert +- Kategorie grundsätzlich weiter nutzbar ist + +## 4.3 BROKEN + +`BROKEN`, wenn Kernfunktionen nicht mehr zuverlässig arbeiten, z. B.: + +- DOM-Struktur vollständig inkompatibel +- keine Query-Generierung mehr möglich +- Result Parser liefert keine verwertbaren Produkte + +## 4.4 BLOCKED + +`BLOCKED`, wenn Sourcezugriff aktuell durch: + +- 403 +- Captcha +- Challenge +- wiederholte 429 + +nicht weitergeführt werden soll. + +--- + +# 5. Taxonomy Learning – operativer Algorithmus + +Taxonomie wird **on demand** gelernt. + +Der technische Ablauf ist verbindlich. + +## 5.1 Schritt 1 – Baseline laden + +Lade die Baseline-Kategorieseite, z. B.: + +```text +https://geizhals.de/?cat=nb +``` + +Speichere: + +```text +source_category +baseline_url +http_status +loaded_at +``` + +Keine HTML-Archivierung als Produktfunktion. + +Fixtures dürfen für Tests aufgezeichnet werden. + +## 5.2 Schritt 2 – Filtergruppen und Controls erkennen + +Der Geizhals-Adapter analysiert die DOM-Struktur. + +Zu erkennen sind, soweit vorhanden: + +- Filtergruppen +- Filterlabel +- Checkboxen +- Radio-Optionen +- Dropdowns +- Multi-Selects +- Min/Max-Felder +- Range-Eingaben +- freie Texteingaben +- Links / hrefs +- optionale source-interne Filter-IDs +- Einheiten +- Wertebereiche +- sichtbare Optionslabels + +Primärziel ist nicht die visuelle Darstellung, sondern die strukturelle Filtersemantik. + +## 5.3 Schritt 3 – Filtermechanismus klassifizieren + +Jeder erkannte Filter wird genau einem primären Lernweg zugeordnet: + +### Typ A – Link-/href-basiert + +Bevorzugter Weg. + +Wenn Geizhals für eine Filteroption einen nutzbaren Link / href bereitstellt, wird dieser direkt ausgewertet. + +Beispiel konzeptionell: + +```html +ab 24 GB +``` + +Der Adapter extrahiert: + +```text +label +option_label +href +``` + +### Typ B – Range-/Input-Control + +Für Controls wie Preis, Gewicht oder Diagonale, bei denen der Nutzer Werte eingibt oder einen Bereich setzt: + +1. Baseline-URL erfassen. +2. einen kontrollierten Testwert setzen, +3. resultierende Navigation/Query-Änderung erfassen, +4. Query-Diff bestimmen, +5. Parametermuster persistieren, +6. mit einem zweiten Testwert prüfen, ob das Muster reproduzierbar ist. + +### Typ C – JS-only Control + +Nur wenn weder href noch statisch erkennbare Parameterstruktur vorhanden sind: + +```text +Playwright-Fallback +``` + +Der Browser setzt den Filterzustand und beobachtet die resultierende URL bzw. Query. + +Damit gilt die Priorität: + +```text +A: href/link +→ B: Range/Input +→ C: Playwright-Fallback +``` + +## 5.4 Schritt 4 – Query-Diff bestimmen + +Vergleiche Baseline-Query und Filter-Query. + +Beispiel: + +Baseline: + +```text +?cat=nb +``` + +Filterzustand: + +```text +?cat=nb&xf=17023_ab+24GB +``` + +Query-Diff: + +```text +parameter = xf +value = 17023_ab+24GB +``` + +Für Preis können z. B. getrennte Parameter wie `bpmin` / `bpmax` erkannt werden. + +## 5.5 Schritt 5 – Filterdefinition speichern + +Erzeuge / aktualisiere: + +```text +SourceFilterDefinition +``` + +Mindestens: + +```text +id +source_category_id +criterion_definition_id +external_filter_key nullable +label +filter_type +unit nullable +encoding_type +status +last_seen_at +last_verified_at +``` + +Filtertypen: + +```text +BOOLEAN +ENUM +MULTI_ENUM +NUMERIC_RANGE +NUMERIC_THRESHOLD +TEXT +UNKNOWN +``` + +Encoding-Typen mindestens: + +```text +OPTION_TOKEN +RANGE_PARAMETERS +BOOLEAN_PARAMETER +TEXT_PARAMETER +UNKNOWN +``` + +--- + +# 6. SourceFilterOption / SourceFilterRange + +## 6.1 Diskrete Optionen + +```text +SourceFilterOption +- id +- source_filter_definition_id +- source_label +- normalized_value nullable +- operator nullable +- unit nullable +- query_parameter +- query_value +- status +``` + +## 6.2 Range-Filter + +```text +SourceFilterRange +- source_filter_definition_id +- min_parameter nullable +- max_parameter nullable +- minimum nullable +- maximum nullable +- step nullable +- unit nullable +``` + +--- + +# 7. Playwright-Fallback für JS-only Controls + +Wenn ein Filter kein direkt verwertbares href bzw. keine statisch erkennbare Query-Struktur liefert: + +```text +Taxonomy Learning + ↓ +Playwright +``` + +Ablauf: + +1. Kategorieseite öffnen +2. Ziel-Filter lokalisieren +3. Filterwert setzen +4. Navigation / URL-Änderung abwarten +5. resultierende URL erfassen +6. Query-Diff zur Baseline berechnen +7. Filterzustand wieder zurücksetzen + +Wichtig: + +- Playwright ist Fallback, nicht Primärmechanismus. +- Playwright wird für Taxonomy Learning verwendet, nicht für jeden normalen SearchRun. +- Kein Captcha-Bypass. +- Kein Login-Bypass. +- Kein Umgehen technischer Schutzmechanismen. + +--- + +# 8. Mapping Lifecycle + +Filter-/Encoding-Mappings besitzen Status: + +```text +DISCOVERED +LEARNED_UNVERIFIED +VERIFIED +BROKEN +DISABLED +``` + +Nur `VERIFIED` wird für produktive Query-Generierung verwendet. + +--- + +# 9. Roundtrip-Verifikation + +Die Roundtrip-Verifikation ist verbindlich. + +Beispiel: + +```text +RAM >= 24 GB +↓ +gelernte Query-Regel +↓ +URL selbst generieren +↓ +Geizhals aufrufen +↓ +aktiven Filterzustand prüfen +``` + +Das Mapping wird nur `VERIFIED`, wenn der Adapter auf der Zielseite eindeutig erkennt, dass der erwartete Filter aktiv ist. + +Wenn Verifikation fehlschlägt: + +```text +LEARNED_UNVERIFIED +``` + +Wenn vorher VERIFIED und nun nicht mehr reproduzierbar: + +```text +BROKEN +``` + +--- + +# 10. Erkennung von Source-Änderungen + +Taxonomy Learning vergleicht: + +```text +vorheriger Filterbestand +vs. +aktueller Filterbestand +``` + +Zu erkennen sind mindestens: + +- Filter neu +- Filter verschwunden +- Filterlabel geändert +- externe Filter-ID geändert +- Optionen geändert +- Encoding geändert +- Unit geändert +- Control-Typ geändert + +Bei relevanter Änderung: + +```text +mapping_version++ +``` + +Betroffene DiscoverySearches werden: + +```text +OUT_OF_SYNC +``` + +wenn ihre Query auf geänderten Mappings basiert. + +--- + +# 11. CriterionDefinition + +Jedes Source-Kriterium erhält eine stabile interne ID: + +```text +CriterionDefinition +- id UUID +- origin SOURCE | INTERNAL +- product_category_id +- source_id nullable +- source_category_id nullable +- label +- value_type +- unit nullable +- scoring_attribute_key nullable +- status +``` + +`CriterionDefinition.id` ist Referenzidentität, keine globale Ontologie. + +--- + +# 12. SourceAttributeMapping + +Suchfilter und Produktattribute müssen verbunden werden. + +Dafür: + +```text +SourceAttributeMapping +- id +- source_category_id +- source_attribute_key nullable +- source_attribute_label +- criterion_definition_id +- normalizer_id nullable +- status +``` + +Matching-Priorität: + +1. stabile Source-Attribute-ID +2. bekannte SourceAttributeMapping-Regel +3. normalisiertes Label-Matching +4. ansonsten UNMAPPED + +Keine aggressive automatische Zuordnung bei Unsicherheit. + +--- + +# 13. available-criteria + +Endpoint: + +```text +GET /api/v1/product-categories/{product_category_id}/available-criteria?source=geizhals +``` + +Nur verwendbare Kriterien werden dem Standardnutzer als Discovery-Filter angeboten. + +Mindestens: + +```text +criterion_definition_id +label +filter_type +unit +operators +options / range +mapping_status +supports_discovery +``` + +--- + +# 14. Query Generation + +Die Source-URL wird ausschließlich aus: + +```text +SourceCategory ++ +aktuelle ProjectCriteria ++ +VERIFIED SourceFilterMappings +``` + +erzeugt. + +Keine URL-Templates aus LLM. + +Keine hartcodierten notebook-spezifischen Query-Fragmente außerhalb des Geizhals-Adapters / Source-Datenmodells. + +Die Query-Generierung muss deterministisch, reproduzierbar, testbar und versionsgebunden sein. + +--- + +# 15. SearchRun + +Ein SearchRun hält mindestens: + +```text +discovery_search_id +criteria_version +mapping_version +generated_url +started_at +finished_at +status +result_count +page_count +new_count +changed_count +unchanged_count +missing_count +``` + +--- + +# 16. Ergebnisabruf und Pagination + +Ein Full SearchRun versucht grundsätzlich die vollständige Ergebnismenge zu lesen. + +Wenn nicht alle Seiten vollständig gelesen werden: + +```text +SearchRun.status = PARTIAL +``` + +Ein `PARTIAL`-Run darf keine: + +```text +MISSING +DISAPPEARED +``` + +Transitions erzeugen. + +--- + +# 17. TOO_BROAD + +Eine DiscoverySearch darf einen konfigurierbaren Schwellenwert besitzen. + +Beispiel: + +```text +broad_result_threshold = 300 +``` + +Bei Überschreitung: + +```text +SearchRun.status = TOO_BROAD +``` + +Kein stilles Top-N-Limit. + +Ein technischer Safety-Cap ist zulässig, wenn er explizit konfiguriert und sichtbar dokumentiert ist; der Lauf gilt dann nicht als Full Run. + +--- + +# 18. Zweistufige Datenerfassung + +Die Suchergebnisliste ist die **primäre Datenquelle des MVP**. + +Der Notebook-Pilot muss zunächst möglichst viel aus der Ergebnisliste gewinnen, bevor zusätzliche Produktdetailseiten geladen werden. + +## 18.1 Stufe A – Suchergebnisliste + +Jeder SearchRun liest aus der Ergebnisliste mindestens: + +```text +source_product_url +title +manufacturer/model soweit sichtbar +best_price +currency +availability soweit sichtbar +seller_count soweit sichtbar +sichtbare Attribute +``` + +Für die Notebook-Kategorie sind insbesondere folgende sichtbare Basisattribute als realistische MVP-Felder zu validieren: + +```text +Betriebssystem +Display +CPU +RAM +Speicher +Grafik +Modell-/Produktreferenz +Angebote +Bestpreis +``` + +Die genaue DOM-Struktur und die Verfügbarkeit einzelner Felder dürfen nicht vorab erfunden werden; sie werden im technischen Spike und Live-Pilot bestätigt. + +## 18.2 Stufe B – Produktdetailseite + +Produktdetailseiten werden nicht blind für alle Treffer geladen. + +Sie werden für relevante Kandidaten geladen, insbesondere: + +- Shortlist +- Top-N nach ProductScore +- Kandidaten mit fehlenden für Ranking relevanten Attributen +- Kandidaten mit unsicherer Identität, wenn Detailseite helfen kann + +Der Notebook-Pilot muss praktisch feststellen, welche Daten auf Liste und Detailseite verfügbar sind. + +--- + +# 19. SourceProductReference + +Mindestens: + +```text +id +source_id +source_category_id +source_url +source_title +manufacturer nullable +model nullable +manufacturer_sku nullable +gtin_ean nullable +normalized_spec_fingerprint nullable +match_status +lifecycle_status +first_seen_at +last_seen_at +``` + +--- + +# 20. Produktidentität – konservativer Pilot + +Signalreihenfolge: + +1. stabile Source-ID, falls entdeckt +2. GTIN/EAN +3. Hersteller-SKU +4. kanonische Source-URL +5. normalisierter Titel +6. Hersteller + Modell +7. differenzierende Specs + +Bei Unsicherheit: + +```text +MATCH_UNCERTAIN +``` + +Kein automatisches Merge. + +--- + +# 21. ProductObservation + +```text +ProductObservation +- id +- source_product_reference_id +- search_run_id +- observed_at +- raw_title +- best_price +- currency +- availability nullable +- seller_count nullable +``` + +--- + +# 22. ProductAttributeObservation + +```text +ProductAttributeObservation +- product_observation_id +- criterion_definition_id nullable +- raw_name +- raw_value +- normalized_value nullable +- unit nullable +- mapping_status +``` + +Wenn kein Mapping existiert: + +```text +criterion_definition_id = null +mapping_status = UNMAPPED +``` + +Unmapped Felder dürfen für spätere Admin-Korrektur sichtbar bleiben. + +--- + +# 23. Normalisierung + +Normalizer arbeitet kriteriumsabhängig. + +Beispiele: + +```text +"32 GB" -> 32 / GB +"1 TB" -> 1024 / GB, falls Zielunit GB +"1,45 kg" -> 1.45 / kg +``` + +Zu speichern: + +```text +raw_value +normalized_value +unit +normalizer_version +``` + +--- + +# 24. Diffing + +Verglichen werden nur vollständige Runs derselben DiscoverySearch. + +Run Diff: + +```text +NEW +CHANGED +UNCHANGED +MISSING +``` + +CHANGED mindestens bei relevanter Änderung von: + +```text +best_price +currency +availability +seller_count +normalisierte Attribute +``` + +--- + +# 25. Product Lifecycle + +```text +ACTIVE +DISAPPEARED +ARCHIVED +``` + +Regeln: + +```text +1 Full Run fehlend -> Run Diff MISSING +3 aufeinanderfolgende Full Runs fehlend -> Lifecycle DISAPPEARED +``` + +--- + +# 26. Ranking und Shortlist + +Bestehende Scoring-Engine kann über: + +```text +CriterionDefinition.scoring_attribute_key +``` + +angesprochen werden. + +Ohne Ranking-Konfiguration: + +```text +ranking_status = NOT_CONFIGURED +``` + +Mit Ranking: + +```text +ProductScore +product_eligibility +``` + +Shortlist Default: `5` + +--- + +# 27. Scheduling und Backoff + +Default: + +```text +180 Minuten +``` + +auf `DiscoverySearch`. + +Defensives Verhalten: + +- Timeout +- Retry mit exponentiellem Backoff +- Jitter +- 429 -> BACKOFF +- 403 -> BLOCKED +- Captcha / Challenge -> BLOCKED +- wiederholte Parsing-Fehler -> DEGRADED/BROKEN + +Keine Schutzumgehung. + +--- + +# 28. UI-Reaktion auf SourceCategory Readiness + +## READY +Normale Kriterienauswahl und Suchanlage. + +## NOT_LEARNED +UI zeigt: „Kriterien für diese Produktkategorie werden gerade vorbereitet.“ + +## LEARNING +Loading-/Progress-Zustand. + +## DEGRADED +VERIFIED Kriterien dürfen angeboten werden; Warnung anzeigen. + +## BROKEN +Keine neue produktive Suche. + +## BLOCKED +Quelle derzeit nicht automatisiert abfragbar. + +--- + +# 29. Taxonomy Learning API + +Mindestens: + +```text +POST /api/v1/sources/{source}/categories/{category}/discover-taxonomy +GET /api/v1/sources/{source}/categories/{category}/taxonomy-status +GET /api/v1/sources/{source}/categories/{category}/filters +``` + +Response mindestens: + +```text +source_category_status +filters_discovered +filters_verified +filters_unverified +filters_broken +mapping_version +last_scan_at +``` + +--- + +# 30. Source Adapter Contract + +```python +class DiscoverySourceAdapter(Protocol): + def load_category_baseline(category_ref): ... + def discover_filter_controls(document): ... + def extract_filter_links(document): ... + def derive_query_diff(baseline_url, filtered_url): ... + def verify_filter_roundtrip(filter_definition, encoded_query): ... + def build_query(criteria, mappings): ... + def fetch_result_page(url): ... + def parse_result_page(document): ... + def parse_product_detail(document): ... + def extract_active_filters(document): ... + def normalize_source_attribute(raw_attribute): ... +``` + +Playwright darf intern als Fallback-Service genutzt werden. + +--- + +# 31. Technische Module + +Mindestens logisch getrennt: + +```text +sources/geizhals/ + category_loader + taxonomy_parser + filter_encoder + roundtrip_verifier + result_parser + product_detail_parser + +discovery/ + taxonomy_service + criterion_service + mapping_service + search_execution_service + identity_service + observation_service + diff_service + candidate_service + shortlist_service +``` + +Keine Geizhals-DOM-Logik außerhalb `sources/geizhals`. + +--- + +# 32. Verpflichtender technischer Geizhals-Spike vor finalem Parserbau + +Vor der finalen Implementierung der Geizhals-Parser muss der Coding-Agent die echte Notebook-Seite technisch inspizieren. + +Der Spike muss dokumentieren: + +1. stabile DOM-Anker bzw. semantisch belastbare Selektoren, +2. tatsächliche Struktur der Filtergruppen, +3. Klassifikation der Filter in Typ A/B/C, +4. konkrete href-/Query-Muster mehrerer Filter, +5. tatsächliches Verhalten von Preis, Gewicht und Diagonale, +6. Pagination-Struktur, +7. Produktkarten-/Ergebnisstruktur, +8. verfügbare Felder direkt in der Ergebnisliste, +9. Unterschiede zwischen Listen- und Produktdetaildaten, +10. auffällige dynamische/JS-geladene Bereiche. + +Wichtig: + +- keine CSS-Klassen, DOM-Pfade oder Selektoren vorab erfinden, +- bevorzugt stabile semantische Attribute oder Source-eigene IDs verwenden, +- fragile rein visuelle Selektoren nur, wenn keine robustere Alternative existiert, +- die Ergebnisse des Spikes müssen unmittelbar in Fixtures und Parser-Tests überführt werden. + +Der Spike ist Teil der MVP-Umsetzung, nicht optionale Vorarbeit. + +--- + +# 34. Fixtures + +Fixtures sind für reproduzierbare Tests Pflicht. + +Mindestens: + +- Notebook Baseline Category HTML +- Kategorie mit gesetztem RAM-Filter +- Kategorie mit Preisbereich +- Ergebnisliste Seite 1 +- Ergebnisliste Seite 2 +- Produktdetailseite +- geänderte DOM-Version / broken fixture +- Captcha/Challenge fixture, soweit technisch sinnvoll +- 429/403 als HTTP-Testzustände + +--- + +# 34. Unit Tests + +Mindestens: + +## Taxonomy +- Filtergruppen erkannt +- Filterlabel erkannt +- href extrahiert +- Query-Diff korrekt +- Filteroption gespeichert +- Range-Parameter erkannt +- JS-only Control nutzt Fallback-Pfad +- Mapping Lifecycle korrekt +- Roundtrip -> VERIFIED +- Roundtrip fail -> LEARNED_UNVERIFIED +- ehemals VERIFIED + fail -> BROKEN + +## Attribute Mapping +- RAM -> richtige CriterionDefinition +- unbekanntes Label -> UNMAPPED +- Source-ID hat Vorrang vor Label +- Unit-Normalisierung korrekt + +## Query Generation +- nur VERIFIED +- deterministisch +- criteria_version/mapping_version gebunden +- INTERNAL ohne Source-Mapping beeinflusst URL nicht + +--- + +# 35. Integration Tests + +Mindestens: + +```text +Taxonomy Fixture +→ DB +→ available-criteria +→ ProjectCriteria +→ Query Generation +→ SearchRun Fixture +→ Result Parse +→ SourceProductReference +→ ProductObservation +→ ProductAttributeObservation +→ Diff +→ Candidate DTO +``` + +--- + +# 36. Manueller Live-Notebook-Pilot + +Der MVP gilt im Source-/Discovery-Bereich **nicht als abgenommen**, solange kein dokumentierter Live-Pilot gegen die echte Geizhals-Notebook-Kategorie erfolgreich durchgeführt wurde. + +Der Pilot muss mindestens zeigen: + +1. Notebook-Kategorie wird erreichbar geladen. +2. reale Filter werden erkannt. +3. mehrere unterschiedliche Filtertypen werden gelernt. +4. RAM-Filter wird technisch korrekt gelernt. +5. Preisfilter wird technisch korrekt gelernt. +6. Roundtrip-Verifikation funktioniert. +7. `available-criteria` liefert reale Filter. +8. daraus wird eine reale Such-URL generiert. +9. reale Suchergebnisse werden gelesen. +10. mindestens Titel + Produktlink + Preis werden extrahiert. +11. sichtbare Produktattribute werden gemappt. +12. mindestens zwei SearchRuns erzeugen History/Diff. +13. UI kann daraus Kandidaten anzeigen. + +Der Live-Pilot muss mindestens einen vollständigen technischen Kettennachweis enthalten: + +```text +echte Filtergruppe erkannt +→ href/Input-Verhalten ermittelt +→ Query-Regel gelernt +→ Roundtrip verifiziert +→ reale Ergebnisse geparst +→ reale Attribute auf CriterionDefinition gemappt +``` + +Live-Pilot wird manuell/dokumentiert ausgeführt, nicht in CI. + +--- + +# 37. Fehlerfälle + +## DOM geändert +`DEGRADED` oder `BROKEN` + +## Filter nicht verifizierbar +`LEARNED_UNVERIFIED` + +## URL-Encoding geändert +früher VERIFIED, Roundtrip scheitert: + +```text +BROKEN +mapping_version++ +DiscoverySearch -> OUT_OF_SYNC +``` + +## Result Parser unvollständig +teilweise Daten -> `DEGRADED` +keine verwertbaren Produkte -> `BROKEN` + +## Source blockiert +`BLOCKED` + +## Taxonomie teilweise verfügbar +VERIFIED Kriterien dürfen weiter angeboten werden. + +--- + +# 38. Kein stiller Fallback + +Nicht erlaubt: + +- Filter-Encoding raten +- unbekannte Source-Attribute automatisch mergen +- unvollständigen Run als vollständigen Run behandeln +- BROKEN Mapping weiterverwenden +- fehlende Kriterien durch frei erfundene Defaults ersetzen +- Candidate Ranking mit nicht belegten Attributen auffüllen + +--- + +# 39. Definition of Done + +## Taxonomy / URL Learning +- [ ] Kategorieseite wird real gelesen. +- [ ] Filter werden aus dem DOM erkannt. +- [ ] href-basierte Filter werden automatisch gelernt. +- [ ] Query-Diff funktioniert. +- [ ] Range-Parameter funktionieren. +- [ ] Playwright-Fallback existiert für JS-only Controls. +- [ ] Roundtrip-Verifikation existiert. +- [ ] Mapping Lifecycle funktioniert. +- [ ] Geizhals-Strukturänderungen werden erkannt. +- [ ] SourceCategory Readiness funktioniert. + +## Criteria / Mapping +- [ ] Source-Kriterien erhalten stabile CriterionDefinition-UUIDs. +- [ ] SearchFilter referenziert CriterionDefinition. +- [ ] SourceAttributeMapping referenziert dieselbe CriterionDefinition. +- [ ] UNMAPPED Attribute bleiben sichtbar. +- [ ] INTERNAL-Kriterien möglich. +- [ ] available-criteria basiert auf real gelernter Taxonomie. + +## Search / Results +- [ ] Query-Generierung nutzt nur VERIFIED Mappings. +- [ ] Full Pagination funktioniert. +- [ ] PARTIAL wird korrekt erkannt. +- [ ] PARTIAL löst kein MISSING/DISAPPEARED aus. +- [ ] Basisdaten aus Ergebnisliste werden extrahiert. +- [ ] Produktdetailseite kann gezielt angereichert werden. +- [ ] SourceProductReference funktioniert. +- [ ] ProductObservation funktioniert. +- [ ] ProductAttributeObservation funktioniert. +- [ ] Attribute werden normalisiert/historisiert. + +## Diff / Candidate +- [ ] NEW / CHANGED / UNCHANGED / MISSING funktionieren. +- [ ] DISAPPEARED nach drei Full Runs. +- [ ] Candidate DTOs werden erzeugt. +- [ ] Ranking nutzt nur vorhandene gemappte Daten. +- [ ] Shortlist funktioniert, wenn Ranking konfiguriert. + +## MVP Acceptance +- [ ] Notebook Live-Pilot vollständig dokumentiert. +- [ ] echte Geizhals-Kriterien im UI sichtbar. +- [ ] echte Such-URL aus UI-Kriterien generiert. +- [ ] echte Resultate eingelesen. +- [ ] echte Preise/Attribute angezeigt. +- [ ] History/Diff über mindestens zwei Runs sichtbar. +- [ ] Source-/Discovery-Pfad end-to-end über UI nutzbar. + +--- + +# 40. Arbeitsauftrag an den Coding-Agenten + +Diese Datei ersetzt `buying-agent-source-discovery-spec-v0.2.md` vollständig. + +Bitte **noch nicht implementieren**. + +Prüfe erneut den aktuellen Repository-Stand gegen: + +1. `buying-agent-v0.1-spec-v1.1.md` +2. `buying-agent-architecture-addendum-v1.md` +3. `buying-agent-source-discovery-spec-v0.3.md` +4. `buying-agent-frontend-mvp-spec-v0.3.md` + +Prüfe insbesondere: + +- ob der Taxonomy-Learning-Algorithmus vollständig implementierbar beschrieben ist, +- ob DOM/href-basierte Discovery ausreicht bzw. welche konkreten Geizhals-Strukturen im Live-Pilot validiert werden müssen, +- ob Playwright-Fallback sauber begrenzt ist, +- ob Query-Diff + Roundtrip-Verifikation widerspruchsfrei sind, +- ob SourceFilterMapping und SourceAttributeMapping klar getrennt sind, +- ob dieselbe CriterionDefinition-ID Search und Result Attributes verbindet, +- ob Readiness-Status ausreichend definiert sind, +- ob zweistufige Datenerfassung (Liste/Detailseite) implementierbar ist, +- ob Full/PARTIAL-Semantik vollständig ist, +- ob der Notebook Live-Pilot als echte MVP-Abnahme genügt, +- ob der technische Geizhals-Spike vor finalem Parserbau ausreichend konkret beschrieben ist, +- ob stabile DOM-Anker und Pagination im Live-DOM ohne erfundene Selektoren verifiziert werden können, +- ob Ergebnislisten-Daten für den MVP tatsächlich ausreichen und welche Felder zwingend Detailseiten benötigen. + +Berichte ausschließlich: + +- verbleibende echte fachliche Widersprüche, +- fehlende technische Verträge, +- technisch riskante Annahmen, +- notwendige Backend-Änderungen, +- notwendige Frontend-Änderungen. + +Keine neue Fachlogik erfinden. + +Wenn keine blockierenden Punkte mehr bestehen: + +1. aktualisierten vertikalen Implementierungsplan erstellen, +2. jeder Slice muss über die UI prüfbar sein, +3. Taxonomy Learning + Query Generation + Result Parsing früh implementieren, +4. Live-Notebook-Pilot vor Abschluss durchführen, +5. Implementierung erst nach ausdrücklicher Freigabe des Reviews starten. diff --git a/backend/app/api/discovery.py b/backend/app/api/discovery.py index 1e669b1..c2eb574 100644 --- a/backend/app/api/discovery.py +++ b/backend/app/api/discovery.py @@ -35,6 +35,11 @@ def discover_taxonomy(source_id: str, category: str, session: Session = Depends( return TaxonomyService(session).discover(source_id, category) +@router.get("/sources/{source_id}/categories/{category}/taxonomy-status") +def taxonomy_status(source_id: str, category: str, session: Session = Depends(get_session)): + return TaxonomyService(session).taxonomy_status(source_id, category) + + @router.get("/sources/{source_id}/categories/{category}/filters") def list_filters(source_id: str, category: str, session: Session = Depends(get_session)): return TaxonomyService(session).filters(source_id, category) diff --git a/backend/app/discovery/enums.py b/backend/app/discovery/enums.py index be5dfb9..38bf5c2 100644 --- a/backend/app/discovery/enums.py +++ b/backend/app/discovery/enums.py @@ -7,6 +7,15 @@ class ProjectStatus(StrEnum): ARCHIVED = "ARCHIVED" +class TaxonomyStatus(StrEnum): + NOT_LEARNED = "NOT_LEARNED" + LEARNING = "LEARNING" + READY = "READY" + DEGRADED = "DEGRADED" + BROKEN = "BROKEN" + BLOCKED = "BLOCKED" + + class MappingStatus(StrEnum): DISCOVERED = "DISCOVERED" LEARNED_UNVERIFIED = "LEARNED_UNVERIFIED" diff --git a/backend/app/persistence/discovery_models.py b/backend/app/persistence/discovery_models.py index 06b03c8..55c0922 100644 --- a/backend/app/persistence/discovery_models.py +++ b/backend/app/persistence/discovery_models.py @@ -105,6 +105,8 @@ class SourceCategoryRecord(Base): product_category_id: Mapped[str | None] = mapped_column(ForeignKey("product_categories.id")) canonical_category: Mapped[str | None] = mapped_column(String(128)) status: Mapped[str] = mapped_column(String(32), nullable=False) + taxonomy_status: Mapped[str] = mapped_column(String(32), nullable=False, default="NOT_LEARNED") + last_taxonomy_scan_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/services/catalog_service.py b/backend/app/services/catalog_service.py index a9b008f..3029d60 100644 --- a/backend/app/services/catalog_service.py +++ b/backend/app/services/catalog_service.py @@ -1,11 +1,12 @@ from __future__ import annotations from typing import Any +from uuid import uuid4 from sqlalchemy import select from sqlalchemy.orm import Session -from app.discovery.enums import FilterType, MappingStatus +from app.discovery.enums import FilterType, MappingStatus, TaxonomyStatus from app.domain.serialize import jsonable from app.errors import AppError from app.persistence.discovery_models import ( @@ -61,6 +62,7 @@ class CatalogService: "display_name": sources[row.source_id].name if row.source_id in sources else row.name, "external_category_key": row.external_category_key, "status": row.status, + "taxonomy_status": row.taxonomy_status, } for row in rows ] @@ -82,6 +84,8 @@ class CatalogService: "name": row.name, "product_category_id": row.product_category_id, "status": row.status, + "taxonomy_status": row.taxonomy_status, + "source_url": row.source_url, } for row in rows ] @@ -96,6 +100,16 @@ class CatalogService: SourceCategoryRecord.product_category_id == product_category_id, ) ).first() + if source_category is not None and source_category.taxonomy_status == TaxonomyStatus.NOT_LEARNED.value: + from app.services.discovery_service import TaxonomyService + + TaxonomyService(self.session).discover(source_id, source_category.external_category_key) + source_category = self.session.scalars( + select(SourceCategoryRecord).where( + SourceCategoryRecord.source_id == source_id, + SourceCategoryRecord.product_category_id == product_category_id, + ) + ).first() criteria: list[dict[str, Any]] = [] if source_category is not None: filters = self.session.scalars( @@ -222,3 +236,43 @@ def ensure_product_category(session: Session, category_id: str, name: str | None session.add(row) session.flush() return row + + +def ensure_source_category( + session: Session, + *, + source_id: str, + external_category_key: str, + product_category_id: str, + name: str, + source_url: str, +) -> SourceCategoryRecord: + row = session.scalars( + select(SourceCategoryRecord).where( + SourceCategoryRecord.source_id == source_id, + SourceCategoryRecord.external_category_key == external_category_key, + ) + ).first() + if row is None: + row = SourceCategoryRecord( + id=uuid4(), + source_id=source_id, + external_category_key=external_category_key, + name=name, + source_url=source_url, + product_category_id=product_category_id, + canonical_category=product_category_id, + status="ACTIVE", + taxonomy_status=TaxonomyStatus.NOT_LEARNED.value, + last_taxonomy_scan_at=None, + last_verified_at=None, + ) + session.add(row) + session.flush() + return row + if row.product_category_id is None: + row.product_category_id = product_category_id + if not row.source_url: + row.source_url = source_url + session.flush() + return row diff --git a/backend/app/services/discovery_service.py b/backend/app/services/discovery_service.py index 22927ff..2bc0137 100644 --- a/backend/app/services/discovery_service.py +++ b/backend/app/services/discovery_service.py @@ -21,6 +21,7 @@ from app.discovery.enums import ( RankingStatus, RunStatus, SearchStatus, + TaxonomyStatus, ) from app.discovery.identity import IdentitySignals, decide_identity, spec_fingerprint from app.discovery.mapping import verify_roundtrip @@ -116,6 +117,8 @@ class TaxonomyService: canonical_category=payload.get("canonical_category"), product_category_id=_linked_product_category(self.session, payload.get("canonical_category")), status="ACTIVE", + taxonomy_status=TaxonomyStatus.LEARNING.value, + last_taxonomy_scan_at=now, last_verified_at=now, ) self.session.add(category) @@ -126,6 +129,8 @@ class TaxonomyService: if payload.get("canonical_category"): category.canonical_category = payload.get("canonical_category") category.product_category_id = _linked_product_category(self.session, payload.get("canonical_category")) + category.taxonomy_status = TaxonomyStatus.LEARNING.value + category.last_taxonomy_scan_at = now category.last_verified_at = now for raw in payload.get("filters") or []: filt = self.session.scalars( @@ -156,9 +161,33 @@ class TaxonomyService: self._sync_options(filt, raw.get("options") or []) if raw.get("range"): self._sync_range(filt, raw["range"]) + category.taxonomy_status = ( + TaxonomyStatus.READY.value if payload.get("filters") else TaxonomyStatus.BROKEN.value + ) self.session.flush() return self.filters(source_id, category.external_category_key) + def taxonomy_status(self, source_id: str, category_key: str) -> dict[str, Any]: + category = self._category(source_id, category_key) + filters = self.session.scalars( + select(SourceFilterDefinitionRecord).where(SourceFilterDefinitionRecord.source_category_id == category.id) + ).all() + mappings = self.session.scalars( + select(CriterionMappingRecord).where(CriterionMappingRecord.source_category_id == category.id) + ).all() + return jsonable( + { + "source_category_status": category.taxonomy_status, + "filters_discovered": len(filters), + "filters_verified": sum(1 for row in mappings if row.status == MappingStatus.VERIFIED.value), + "filters_unverified": sum( + 1 for row in mappings if row.status == MappingStatus.LEARNED_UNVERIFIED.value + ), + "filters_broken": sum(1 for row in mappings if row.status == MappingStatus.BROKEN.value), + "last_scan_at": category.last_taxonomy_scan_at, + } + ) + def filters(self, source_id: str, category_key: str) -> dict[str, Any]: category = self._category(source_id, category_key) rows = self.session.scalars( @@ -207,7 +236,9 @@ class TaxonomyService: "name": category.name, "source_url": category.source_url, "canonical_category": category.canonical_category, + "product_category_id": category.product_category_id, "status": category.status, + "taxonomy_status": category.taxonomy_status, "last_verified_at": category.last_verified_at, "filters": filters, } diff --git a/backend/migrations/versions/0006_source_category_readiness.py b/backend/migrations/versions/0006_source_category_readiness.py new file mode 100644 index 0000000..3691d87 --- /dev/null +++ b/backend/migrations/versions/0006_source_category_readiness.py @@ -0,0 +1,37 @@ +"""Seedable SourceCategory readiness for the notebook pilot. + +Revision ID: 0006_source_category_readiness +Revises: 0005_frontend_mvp_v03 +Create Date: 2026-10-04 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0006_source_category_readiness" +down_revision: str | None = "0005_frontend_mvp_v03" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "source_categories", + sa.Column("taxonomy_status", sa.String(32), nullable=False, server_default="NOT_LEARNED"), + ) + op.add_column("source_categories", sa.Column("last_taxonomy_scan_at", sa.DateTime(timezone=True))) + op.execute( + """ + UPDATE source_categories + SET taxonomy_status = 'READY', + last_taxonomy_scan_at = last_verified_at + WHERE last_verified_at IS NOT NULL + """ + ) + + +def downgrade() -> None: + op.drop_column("source_categories", "last_taxonomy_scan_at") + op.drop_column("source_categories", "taxonomy_status") diff --git a/backend/scripts/bootstrap.py b/backend/scripts/bootstrap.py index 633d363..bc47dea 100644 --- a/backend/scripts/bootstrap.py +++ b/backend/scripts/bootstrap.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import Session from app.domain.validation import parse_scoring_model from app.persistence.db import SessionLocal from app.persistence.models import ScoringModelRecord, SourceRecord -from app.services.catalog_service import ensure_product_category +from app.services.catalog_service import ensure_product_category, ensure_source_category ROOT = Path(__file__).resolve().parent.parent RULES = ROOT / "rules" / "generic-v1.json" @@ -19,6 +19,14 @@ def run_bootstrap(session: Session) -> None: _seed_source(session) _seed_scoring_model(session) ensure_product_category(session, "notebook", "Notebooks") + ensure_source_category( + session, + source_id="geizhals", + external_category_key="nb", + product_category_id="notebook", + name="Notebooks", + source_url="https://geizhals.de/?cat=nb", + ) session.commit() diff --git a/backend/scripts/seed_notebook_pilot.py b/backend/scripts/seed_notebook_pilot.py index a8aeb06..9e9eb0b 100644 --- a/backend/scripts/seed_notebook_pilot.py +++ b/backend/scripts/seed_notebook_pilot.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app.discovery.definitions import get_definition_by_key, upsert_definition from app.persistence.models import AttributeDefinitionRecord -from app.services.catalog_service import ensure_product_category +from app.services.catalog_service import ensure_product_category, ensure_source_category NOTEBOOK_ATTRIBUTE_SEEDS = ( @@ -72,6 +72,14 @@ NOTEBOOK_CRITERION_SEEDS = ( def seed_notebook_pilot(session: Session) -> None: ensure_product_category(session, "notebook", "Notebooks") + ensure_source_category( + session, + source_id="geizhals", + external_category_key="nb", + product_category_id="notebook", + name="Notebooks", + source_url="https://geizhals.de/?cat=nb", + ) for item in NOTEBOOK_ATTRIBUTE_SEEDS: if session.get(AttributeDefinitionRecord, item["key"]) is None: session.add(AttributeDefinitionRecord(**item, description=None)) diff --git a/backend/tests/unit/test_project_criteria_parse.py b/backend/tests/unit/test_project_criteria_parse.py index 301dffd..6d376ac 100644 --- a/backend/tests/unit/test_project_criteria_parse.py +++ b/backend/tests/unit/test_project_criteria_parse.py @@ -1,6 +1,7 @@ from uuid import uuid4 from app.discovery.criteria import parse_project_criteria +from app.discovery.enums import TaxonomyStatus from app.settings import Settings @@ -22,6 +23,11 @@ def test_parse_accepts_definition_id_and_enabled_flag(): assert parsed["items"][0]["enabled_for_discovery"] is True +def test_source_category_readiness_values(): + assert TaxonomyStatus.NOT_LEARNED.value == "NOT_LEARNED" + assert TaxonomyStatus.READY.value == "READY" + + def test_cors_origins_are_split_from_environment(): settings = Settings(cors_allowed_origins="http://localhost:4173, http://127.0.0.1:4173") assert settings.cors_origins == ["http://localhost:4173", "http://127.0.0.1:4173"] diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f8cd2e5..c4d6b35 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -45,7 +45,14 @@ export const api = { }; export type ProductCategory = { id: string; name: string; status: string }; -export type CategorySource = { source: string; source_category_id: string; display_name: string; status: string }; +export type CategorySource = { + source: string; + source_category_id: string; + display_name: string; + status: string; + taxonomy_status?: string; + external_category_key?: string; +}; export type BuyingTemplate = { id: string; version: number; name: string; product_category_id?: string; market?: string; currency?: string }; export type ProjectSummary = { id: string; @@ -148,5 +155,11 @@ export type Observation = { dynamic_attributes?: { raw_name: string; normalized_value: unknown }[]; }; export type Source = { id: string; name: string }; -export type SourceCategory = { external_category_key: string; name: string; status: string }; +export type SourceCategory = { + external_category_key: string; + name: string; + status: string; + taxonomy_status?: string; + product_category_id?: string | null; +}; export type SourceFilter = { label: string; external_filter_key: string; filter_type: string; status: string }; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index a2ba08d..9e3b63a 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -18,21 +18,29 @@ export function AdminPage() { }); const discover = useMutation({ mutationFn: () => api.discoverTaxonomy(source, category), - onSuccess: () => filters.refetch(), + onSuccess: () => { + categories.refetch(); + filters.refetch(); + }, }); if (sources.isLoading) return ; + const selected = categories.data?.items.find((item) => item.external_category_key === category); return (

Quellen und Taxonomie

Technische IDs sind nur hier sichtbar.

-
+

Source: {source || "–"}

Kategorie: {category || "–"}

-
- {!filters.data?.filters.length ? ( + {!category ? ( + Keine Source-Kategorie hinterlegt. + ) : !filters.data?.filters.length ? ( Taxonomie wurde noch nicht geladen. ) : (
diff --git a/frontend/src/pages/WizardPage.tsx b/frontend/src/pages/WizardPage.tsx index 5c46fa4..f4da244 100644 --- a/frontend/src/pages/WizardPage.tsx +++ b/frontend/src/pages/WizardPage.tsx @@ -60,6 +60,7 @@ export function WizardPage() { }); const sourceItems = sources.data?.items || []; + const sourceRow = sourceItems.find((item) => item.source === source) || sourceItems[0]; const needsPicker = sourceItems.filter((item) => item.status === "ACTIVE").length > 1; const visible = useMemo(() => criteria.data?.criteria || [], [criteria.data]); @@ -113,6 +114,16 @@ export function WizardPage() { {categoryId && !sources.isLoading && sourceItems.length === 0 ? ( Keine Source für diese Produktkategorie verfügbar. ) : null} + {sourceRow?.taxonomy_status === "NOT_LEARNED" && criteria.isLoading ? ( + Kriterien für diese Produktkategorie werden gerade vorbereitet. + ) : null} + {sourceRow?.taxonomy_status === "LEARNING" ? : null} + {sourceRow?.taxonomy_status === "BLOCKED" ? ( + Quelle derzeit nicht automatisiert abfragbar. + ) : null} + {sourceRow?.taxonomy_status === "BROKEN" ? ( + Die Quelle für diese Kategorie ist derzeit unbrauchbar. + ) : null} {needsPicker ? (