BuyingAgent/docs/buying-agent-v0.1-spec-v1.1.md
Lars 3ac42645ef
All checks were successful
Deploy Development / deploy (push) Successful in 1m6s
Test Suite / smoke-dev (push) Successful in 0s
Richte Develop-Build und Main-Deploy analog zur Jinkendo-Familie ein.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:47:48 +02:00

672 lines
49 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Buying Agent v0.1 – Implementierungsspezifikation
Status: implementierungsbereit · Spezifikationsversion: 1.1 · Stand: 2026-10-03
## Änderungen gegenüber Spezifikationsversion 1.0
- Preference-Scoring erhält ein explizites `strategy`-Feld; v0.1 implementiert ausschließlich `numeric_linear`, ohne vorsorgliche Plugin-Infrastruktur.
- Produkt-Eignung und Angebots-Eignung werden getrennt als `product_eligibility` und `offer_eligibility` bewertet; `overall_eligibility` wird daraus abgeleitet.
- Die Preisbewertung wird konzeptionell von der Identität eines Offers entkoppelt: v0.1 nutzt weiterhin nur die Historie des konkreten Offers, spätere Scoring-Versionen dürfen jedoch Variant- oder Market-Level-Referenzserien konsumieren.
- `raw_value` wird bei JSONB als inhaltlich unverändert, nicht als bytegenau identisch beschrieben.
- Ein verbindlicher `Future Extension Contract` dokumentiert, welche späteren Erweiterungen v0.1 nicht verbauen darf, ohne sie bereits zu implementieren.
## 1. Auftrag und Zielbild
Implementiere ein eigenständig lauffähiges Backend für einen generischen Buying-/Deal-Agenten. Das System bewertet, wie gut eine konkrete Produktvariante zu einem Bedarfsprofil passt und wie attraktiv ein bestimmtes Angebot preislich ist. Es liefert nachvollziehbare Entscheidungen, keine automatischen Käufe.
Notebooks sind der erste Anwendungsfall, nicht das Datenmodell. Weitere Kategorien wie GPU-Server, Monitore oder Kameras müssen durch neue Attribute und Profile ohne Änderungen an der Engine oder zusätzliche produktspezifische DB-Spalten möglich sein.
Diese Datei ist die vollständige Arbeitsgrundlage. Ein Zugriff auf den ursprünglichen Chat ist nicht erforderlich. Die dort beschlossene Architektur wird beibehalten. Die hier definierten Formeln, Fehlerfälle, API-Verträge und Zeitregeln konkretisieren bislang offene Punkte als verbindliche v0.1-Entscheidungen. Die Beispielwerte sind synthetische Testdaten und keine Markt- oder Herstellerangaben.
### Walking Skeleton
Der MVP muss diesen vollständigen Ablauf tatsächlich ausführen und dauerhaft speichern:
```text
Profil → Produkt → Variante + Attribute → Angebot → Preisbeobachtungen
→ Constraints → ProductScore → PriceScore → DealScore
→ Klassifikation + Begründungen → gespeicherte Evaluation → API-Antwort
```
Ein manuell erfasster Kandidat genügt für den ersten vertikalen Durchstich. Danach werden Grenzfälle und drei Beispielkandidaten ergänzt. Keine bloßen Interfaces ohne funktionsfähigen End-to-End-Pfad.
### Explizit nicht enthalten
- Keepa, Idealo, Geizhals oder andere externe Datenquellen und Scraper.
- n8n, Scheduler, Benachrichtigungen und Hintergrundjobs.
- LLM-Normalisierung oder LLM-Bewertung.
- Web-UI; generierte OpenAPI-/Swagger-Dokumentation ist erlaubt.
- Auto-Discovery, automatische Produktsuche oder automatische Zuordnung fremder Produkte.
- Checkout, automatische Käufe, Nutzerkonten, Mehrmandantenfähigkeit und öffentliche Bereitstellung.
- Wechselkursumrechnung, marktweite Preisprognosen, ML und Optimierung von Gewichten.
Diese Funktionen dürfen später über Adapter und Services ergänzt werden. Keine vorsorglichen Frameworks, leeren Integrationen oder Infrastruktur dafür bauen.
## 2. Stack und Architekturgrenzen
Verbindlich: Python 3.13, FastAPI, Pydantic 2, SQLAlchemy 2, Alembic, PostgreSQL 17, pytest und Docker Compose. Verwende einen synchronen SQLAlchemy-Zugriff mit psycopg 3; ein asynchroner Stack ist für v0.1 nicht erforderlich. Abhängigkeiten mit kompatiblen Versionen und einem eingecheckten Lockfile reproduzierbar festlegen.
Abhängigkeiten zeigen nach innen:
```text
HTTP/API → Application Services → Domain / Constraint Engine / Scoring
↓
Repositories und Source-Adapter
↓
PostgreSQL
```
- `domain` und `scoring` importieren weder FastAPI noch SQLAlchemy oder konkrete Quellen.
- Die Engine ist eine reine Berechnung über explizite Eingaben. Kein Netzwerkzugriff, kein DB-Zugriff und kein implizites Lesen der Systemzeit.
- Services laden Daten, erzeugen einen konsistenten Snapshot, rufen die Engine auf und speichern das Ergebnis atomar.
- API-Schemas und ORM-Modelle sind getrennt von Domain-Modellen. Kleine Dataclasses oder Pydantic-Modelle sind für die Domain zulässig.
- Regeln liegen als validierte Daten vor; keine Ausführung von Python, SQL, `eval` oder Templates aus Profilen.
- n8n bleibt später ein externer Aufrufer. Die gesamte Buying Engine funktioniert ohne n8n.
## 3. Fachliche Entitäten und Identität
### Product
Produktfamilie bzw. Modell, beispielsweise „Example Mobile 13“. Felder: UUID `id`, `category`, `brand`, `name`, optional `model`, `created_at`. Keine RAM-, SSD- oder Preisfelder. Kategorie ist ein normalisierter String, kein Notebook-spezifisches Enum.
### ProductVariant
Eine eindeutig beschriebene technische Konfiguration eines Products. Felder: UUID `id`, `product_id`, `name`, optionale Hersteller-SKU `manufacturer_sku`, optionale Kennungen `identifiers` als JSON-Objekt, `created_at`.
RAM, Speicher, Farbe, Tastaturlayout und Ausstattung gehören zur Variante. Angebote unterschiedlicher Konfigurationen dürfen nicht zusammengeführt werden. In v0.1 verknüpft der Aufrufer Varianten explizit; keine heuristische Identitätsauflösung. Hersteller-SKUs sind nicht global eindeutig. Identische Namen sind kein Dublettenbeweis.
### Offer
Ein konkretes Händlerangebot für genau eine Variante. Felder: UUID `id`, `variant_id`, `source_id`, `external_id`, `seller`, optionale `url`, `market` (z. B. `DE`), `currency` (dreistelliger Großbuchstaben-Code), `condition` (`new`, `refurbished`, `used`), `created_at`.
Eindeutigkeit: `(source_id, external_id)`. Unterschiedliche Verkäufer, Zustände, Märkte oder Lieferumfänge sind unterschiedliche Angebote. Technisch abweichende Bundles erfordern eine passende Variante. Diese Identitätsfelder bleiben nach Erstellung unverändert. Ein Offer enthält keinen unabhängig veränderbaren „aktuellen Preis“.
### PriceObservation
Append-only-Beobachtung eines Offers zu einem Zeitpunkt. Felder: UUID `id`, `offer_id`, `source_id`, `external_observation_id`, `observed_at`, `ingested_at`, `item_price`, nullable `shipping_price`, `currency`, `availability` (`in_stock`, `out_of_stock`, `unknown`), `raw_payload` als JSON, `confidence`.
- Geld als `Decimal`/PostgreSQL `NUMERIC(12,2)`, nie binäre Floats. API-Geldwerte werden als Dezimalstrings übertragen.
- `item_price > 0`; bekannte Versandkosten `>= 0`; null bedeutet unbekannt, nicht kostenlos.
- Gesamtpreis = Bruttopreis inklusive bekannter obligatorischer Bestandteile + Versand. Nur direkt für jeden Käufer verfügbare Preise erfassen; keine Mitgliederrabatte, Finanzierung oder personalisierte Coupons modellieren.
- Die Währung muss der Offer-Währung entsprechen. `source_id` muss der Offer-Quelle entsprechen.
- Zeitzonen sind obligatorisch; Speicherung und Ausgabe in UTC. Zeitpunkte in der Zukunft relativ zur Ingestion werden abgewiesen.
- Eindeutigkeit `(offer_id, external_observation_id)`: identischer Wiederholungsimport liefert den vorhandenen Datensatz, widersprüchlicher Inhalt HTTP 409. `ingested_at` ist serverseitig und zählt beim Inhaltsvergleich nicht mit.
- Beobachtungen werden nicht überschrieben. Korrekturen werden als neue Beobachtung mit neuer externer ID erfasst; ein administrativer Korrekturworkflow ist nicht Bestandteil von v0.1.
## 4. Generische Attribute und Normalisierung
Attribute werden für v0.1 ausschließlich an Varianten erfasst. Es gibt keine implizite Vererbung von Product-Attributen. Eine spätere Erweiterung muss eine eigene, explizite Vorrangregel definieren.
### Attributdefinition
`attribute_definitions` enthält `key`, `value_type` (`number`, `string`, `boolean`, `string_list`), nullable `canonical_unit`, optionale `minimum`/`maximum` und `description`. Schlüssel sind stabile `snake_case`-Bezeichner. Neue Definitionen lassen sich per API ohne Migration anlegen. Bestehende Definitionen sind in v0.1 unveränderlich; geänderte Semantik erhält einen neuen Schlüssel.
Beispieldefinitionen:
| key | type | canonical_unit | Wertebereich |
|---|---|---|---|
| ram_gb | number | GB | >= 0 |
| storage_gb | number | GB | >= 0 |
| weight_kg | number | kg | > 0, im Eingabevalidator |
| battery_score | number | null | 0–100 |
| display_score | number | null | 0–100 |
| cpu_score | number | null | 0–100 |
| ports | string_list | null | beliebige Strings |
`*_score`-Attribute sind ausdrücklich manuelle Einschätzungen auf einer dokumentierten 0–100-Skala; die Engine erfindet keine Benchmarks oder Messwerte.
### Attributwert
Jede Zeile in `variant_attributes` ist eine unveränderliche Aussage einer Quelle:
```json
{
"attribute": "ram_gb",
"raw_value": "24GB LPDDR5X RAM",
"normalized_value": 24,
"unit": "GB",
"normalization_status": "normalized",
"normalizer_version": "manual-v1",
"confidence": 1.0,
"source_id": "manual",
"source_reference": "demo/notebook-a/ram",
"observed_at": "2026-10-03T10:00:00Z"
}
```
Zusätzlich serverseitig: UUID `id`, `variant_id`, `ingested_at`. `raw_value` ist verpflichtend und bleibt inhaltlich unverändert erhalten; es darf ein JSON-Skalar oder -Objekt sein. Bei Speicherung als JSONB ist keine bytegenaue Erhaltung der ursprünglichen JSON-Serialisierung garantiert. Falls eine spätere Quelle den exakten Response-Body benötigt, muss dieser separat als Rohtext gespeichert werden; das ist nicht Bestandteil von v0.1. `normalized_value` ist ein typisierter JSON-Wert. Bei `normalization_status=unknown` ist er null; ein optionales `normalization_error` erklärt den fehlenden Wert. Eine erfolglose Normalisierung ist kein Zahlenwert 0.
Manuelle Erfassung liefert Raw- und Normalized-Wert explizit. `ManualNormalizer` validiert Typ, Bereich und kanonische Einheit, analysiert aber keinen Freitext. Falsche Typen oder Einheiten werden mit 422 abgewiesen. Keine automatische Umwandlung von `"32"` zu 32 oder `true` zu 1. `confidence` liegt zwischen 0 und 1 und bezeichnet die Zuverlässigkeit der Aussage, nicht den Produktnutzen. Eine absichtlich unbekannte Aussage ist zulässig.
Bei mehreren Aussagen zu demselben Schlüssel verwendet der Snapshot die jüngste nach `(observed_at, ingested_at, id)` absteigend, jeweils nur bis zum Auswertungszeitpunkt bzw. Snapshot-Cutoff. Anschließend wird deren Confidence geprüft. Ein neuer unsicherer oder unbekannter Wert darf nicht stillschweigend durch einen älteren günstigen Wert ersetzt werden. Vorrang verschiedener Quellen ist damit zunächst bewusst einfach; spätere Auswahlstrategien benötigen eine neue Regelversion.
## 5. Product Profile und Versionierung
Ein Profil beschreibt Kategorie, Budget, harte Mindestanforderungen und gewichtete Präferenzen. Es besitzt eine stabile String-ID und eine positive Integer-Version. Jede `(id, version)` ist unveränderlich. Eine Änderung wird als neue Version gespeichert. Evaluationen müssen eine konkrete Version angeben; kein implizites „latest“.
Vollständiges Beispiel für `profiles/mobile-development-notebook.json`:
```json
{
"id": "mobile-development-notebook",
"version": 1,
"name": "Mobiler Entwicklungsrechner",
"category": "notebook",
"currency": "EUR",
"market": "DE",
"allowed_conditions": ["new"],
"budget": {"target": "1500.00", "maximum": "2000.00"},
"scoring_version": "generic-v1",
"constraints": [
{"id": "min_ram", "attribute": "ram_gb", "operator": ">=", "value": 16, "mandatory": true},
{"id": "min_storage", "attribute": "storage_gb", "operator": ">=", "value": 512, "mandatory": true},
{"id": "max_weight", "attribute": "weight_kg", "operator": "<=", "value": 1.6, "mandatory": true}
],
"preferences": [
{"id": "ram", "attribute": "ram_gb", "strategy": "numeric_linear", "direction": "higher", "acceptable": 16, "ideal": 32, "weight": 0.30},
{"id": "weight", "attribute": "weight_kg", "strategy": "numeric_linear", "direction": "lower", "acceptable": 1.6, "ideal": 1.0, "weight": 0.25},
{"id": "battery", "attribute": "battery_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.20},
{"id": "display", "attribute": "display_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.15},
{"id": "cpu", "attribute": "cpu_score", "strategy": "numeric_linear", "direction": "higher", "acceptable": 60, "ideal": 100, "weight": 0.10}
]
}
```
Jede Präferenz besitzt eine explizite `strategy`. v0.1 unterstützt ausschließlich `numeric_linear`. Das Profilformat ist jedoch so zu modellieren, dass spätere Scoring-Versionen zusätzliche klar benannte Strategien wie `boolean_preferred`, `categorical_match`, `set_contains` oder `categorical_rank` einführen können, ohne die Grundstruktur eines Profils zu ändern. Für v0.1 wird dafür weder ein Plugin-System noch dynamische Code-Ausführung gebaut; unbekannte Strategien werden bei der Profilvalidierung abgewiesen.
Validierung: `0 < target < maximum`, mindestens eine Präferenz, positive Gewichte, innerhalb der jeweiligen Liste eindeutige IDs, bekannte Attributschlüssel und eine vorhandene Scoring-Version. Die Summe der Gewichte muss nicht 1 sein, weil die Engine normalisiert. Für `numeric_linear` gilt: `higher` verlangt `ideal > acceptable`, `lower` verlangt `ideal < acceptable`, und das referenzierte Attribut muss numerisch sein. Kategorie, Währung, Markt und erlaubte Zustände sind verpflichtend.
Das Profil ist ein Beispiel aus der Architekturentscheidung, keine abschließend optimierte persönliche Kaufberatung. Die Auswahl eines Notebooks für mobile Entwicklung wird nicht mit Anforderungen an einen lokalen LLM-Server vermischt.
## 6. Constraint Engine und Eignungsstatus
Unterstütze ausschließlich folgende Operatoren; verwerfe ungültige Kombinationen bereits beim Anlegen des Profils:
| Operator | Attributtyp | Vergleichswert / Semantik |
|---|---|---|
| `>=`, `<=`, `>`, `<` | number | Zahl in kanonischer Einheit |
| `==`, `!=` | number, string, boolean | gleicher skalarer Typ, exakter Vergleich |
| `in` | number, string, boolean | nichtleere Liste gleichartiger skalarer Werte |
| `contains` | string_list | einzelner String, exakte Mitgliedschaft |
| `exists` | alle | kein `value`; verwertbarer normalisierter Wert vorhanden |
Keine impliziten Typkonvertierungen, String-Teilstrings oder case-insensitive Vergleiche. Leere String-Listen sind vorhandene Werte. Fehlend, null, nicht normalisiert oder Confidence unter der Regelgrenze ergibt `UNKNOWN`, auch bei `exists` und `!=`.
Jedes Ergebnis enthält Constraint-ID, Attribut, Operator, Sollwert, Istwert, Status `PASS|FAIL|UNKNOWN`, Mandatory-Flag, gewählte Attributzeilen-ID und Reason-Code.
### Product Eligibility
`product_eligibility` beschreibt ausschließlich, ob die konkrete Produktvariante fachlich zum Profil passt. Dazu gehören:
- Kategorie des Produkts/der Variante,
- alle mandatory Produkt-Constraints,
- technische bzw. produktbezogene Daten, die für diese Constraints benötigt werden.
Ableitung:
- mindestens ein bekannter produktbezogener harter `FAIL` → `product_eligibility=INELIGIBLE`;
- sonst mindestens ein mandatory produktbezogener `UNKNOWN` → `product_eligibility=UNKNOWN`;
- sonst → `product_eligibility=ELIGIBLE`.
Optionale Constraints ändern `product_eligibility` nicht. Optionales `FAIL` oder `UNKNOWN` reduziert ausschließlich den ProductScore um die im Scoring-Modell konfigurierte Punktzahl und bleibt in den Ergebnissen sichtbar.
Der ProductScore darf unabhängig von der aktuellen Angebotslage berechnet und als Diagnostik gespeichert werden, sofern das Profil und die vorhandenen Präferenzdaten dies zulassen. Ein technisch sehr gutes Produkt verliert seinen ProductScore nicht nur deshalb, weil das betrachtete Offer aktuell zu teuer oder nicht lieferbar ist.
### Offer Eligibility
`offer_eligibility` beschreibt ausschließlich die Eignung des konkret ausgewerteten Offers. Systemseitige Offer-Gates sind:
- Markt entspricht dem Profil,
- Zustand ist in `allowed_conditions`,
- Währung entspricht der Profilwährung,
- aktuelle Preisbeobachtung ist vorhanden und frisch,
- Preis-Confidence erfüllt die Mindestschwelle,
- obligatorische Versandkosten sind bekannt,
- Gesamtpreis <= Budgetmaximum,
- aktuelle Verfügbarkeit ist `in_stock`.
Ein bekannter Verstoß ergibt `offer_eligibility=INELIGIBLE`. Fehlende/zu alte Preisbeobachtung, geringe Preis-Confidence, unbekannte Versandkosten oder unbekannte Verfügbarkeit ergeben `offer_eligibility=UNKNOWN`. `out_of_stock` ergibt `INELIGIBLE`. Ein Preis oberhalb des Budgets ist ein Angebotsproblem und macht nicht das Produkt selbst ungeeignet.
### Overall Eligibility
`overall_eligibility` wird ausschließlich aus Product und Offer Eligibility abgeleitet:
- wenn mindestens einer der beiden Status `INELIGIBLE` ist → `overall_eligibility=INELIGIBLE`;
- sonst wenn mindestens einer `UNKNOWN` ist → `overall_eligibility=UNKNOWN`;
- sonst → `overall_eligibility=ELIGIBLE`.
Alle Gates werden ausgewertet, damit die Antwort sämtliche Gründe enthält. Ein bekannter harter Fehler hat innerhalb der jeweiligen Ebene Vorrang vor unbekannten Daten. Das API-Feld `eligible` ist eine Convenience-Abbildung von `overall_eligibility`: `ELIGIBLE` → true, `INELIGIBLE` → false, `UNKNOWN` → null.
## 7. Scoring-Modell `generic-v1`
Alle Scores liegen zwischen 0 und 100. Rechne intern mit Decimal, runde nur endgültige Scores und angezeigte Komponenten auf zwei Dezimalstellen, `ROUND_HALF_UP`. Klassifikation anhand des ungerundeten DealScore. Der Snapshot enthält auch ungerundete Dezimalstrings für eine eindeutige Grenzfallprüfung.
Die unveränderliche Modelldefinition enthält mindestens:
```json
{
"version": "generic-v1",
"algorithm_id": "linear-budget-history-v1",
"supported_preference_strategies": ["numeric_linear"],
"minimum_confidence": 0.8,
"maximum_price_age_hours": 48,
"optional_constraint_penalty_points": 5,
"missing_preference_score": 0,
"product_weight": 0.6,
"price_weight": 0.4,
"budget_weight": 0.4,
"history_weight": 0.6,
"history_window_days": 90,
"minimum_history_days": 3,
"history_discount_for_max_score": 0.2,
"classification_upper_bounds": [
{"upper": 60, "inclusive": false, "label": "IGNORE"},
{"upper": 75, "inclusive": false, "label": "WATCH"},
{"upper": 85, "inclusive": false, "label": "GOOD"},
{"upper": 92, "inclusive": true, "label": "VERY_GOOD"},
{"upper": 100, "inclusive": true, "label": "EXCEPTIONAL"}
]
}
```
`clamp(x)=min(100,max(0,x))`. Modellvalidierung prüft positive Zeitfenster, gültige Confidence, nichtnegative Gewichte mit jeweiliger Summe 1, aufsteigende lückenlose Klassifikationsgrenzen, unterstützte Algorithmus-ID und eine nichtleere Liste unterstützter Preference-Strategien. Die Definition wird als kanonisches JSON inklusive SHA-256 gespeichert. Eine Version darf niemals mit geändertem Inhalt wiederverwendet werden. Neue Algorithmen oder Preference-Strategien werden als Code ergänzt und unter einer neuen Scoring-Version bzw. Algorithmus-ID registriert; JSON darf keinen ausführbaren Code enthalten.
### ProductScore
v0.1 implementiert ausschließlich die Preference-Strategie `numeric_linear`. Für eine solche numerische Präferenz mit Istwert `x`, akzeptablem Wert `a` und Idealwert `i`:
```text
higher: component = clamp(50 + 50 × (x − a) / (i − a))
lower: component = clamp(50 + 50 × (a − x) / (a − i))
base_product_score = Σ(weight × component) / Σ(weight)
product_score = clamp(base_product_score − 5 × Anzahl optionaler FAIL/UNKNOWN)
```
Damit erhält ein akzeptabler Wert 50, ein idealer oder besserer Wert maximal 100. Ein fehlender oder unsicherer Präferenzwert erhält 0; sein Gewicht bleibt im Nenner. Fehlende Daten dürfen den Score nicht verbessern. `product_coverage` ist die Summe der Gewichte verwertbarer Präferenzen geteilt durch die Summe aller Gewichte. Confidence wird nicht nochmals als Score-Multiplikator verwendet.
Der ProductScore beschreibt Produkt-Fit und wird unabhängig von `offer_eligibility` berechnet. Auch bei `product_eligibility=INELIGIBLE` oder `UNKNOWN` darf er diagnostisch ausgegeben werden; Mandatory Gates bleiben jedoch autoritativ und können niemals durch einen hohen ProductScore überstimmt werden.
Die Strategy-Auswahl erfolgt explizit über das Preference-Feld `strategy`; es gibt keine dynamische Plugin-Suche. Unbekannte Strategien sind für `generic-v1` ein Validierungsfehler.
### Auswahl des aktuellen Preises und der Referenzhistorie
Der Evaluation-Request bezeichnet genau ein Offer. Kein implizites billigstes Angebot und kein Vermischen mehrerer Händler oder Varianten.
v0.1 verwendet als `reference_history` ausschließlich Preisbeobachtungen dieses konkreten Offers. Diese Bindung ist eine Entscheidung der Scoring-Version, nicht des generischen Preis-Scoring-Vertrags. Die reine Preis-Scoring-Funktion ist daher konzeptionell als Berechnung über `current_price`, `reference_history` und `budget` zu implementieren; sie darf fachlich nicht verlangen, dass die Referenzserie dauerhaft ein Offer-Objekt ist. Spätere Scoring-Versionen dürfen Variant-Level- oder Market-Level-Referenzserien bereitstellen, ohne Product, ProductVariant, Offer oder PriceObservation neu zu modellieren.
Für v0.1 gilt für die offer-bezogene Referenzserie:
1. Betrachte ausschließlich Beobachtungen dieses Offers mit `observed_at <= as_of` und `ingested_at <= snapshot_cutoff`.
2. Aktuell ist die jüngste Zeile nach `(observed_at, ingested_at, id)` absteigend. Auch eine nicht verfügbare oder unsichere jüngste Zeile darf nicht durch eine ältere günstigere ersetzt werden.
3. Frische: `as_of − observed_at <= 48 Stunden`, einschließlich Grenze.
4. Historie endet strikt vor dem UTC-Kalendertag der aktuellen Beobachtung. Dadurch wird der aktuelle Angebotspreis nicht selbst zur historischen Referenz.
5. Für jedes Fenster N ∈ {30,90,365} gilt `[current_day − N Tage, current_day)`. Wähle zunächst pro UTC-Tag die jüngste Zeile, danach filtere Tage mit `in_stock`, bekannter Gesamtsumme und Confidence >= 0.8. So verdrängt eine spätere unbekannte Beobachtung eine frühere bekannte desselben Tages.
6. Berechne `median_30d`, `median_90d`, `lowest_365d` aus diesen Tageswerten. Bei gerader Anzahl ist der Median das arithmetische Mittel der beiden mittleren Werte. Gib die Zahl der vorhandenen Tage pro Fenster mit aus; keine Interpolation fehlender Tage.
Historienaggregate werden berechnet und im Evaluation-Snapshot gespeichert, nicht als vom Client beliebig vorgegebene Wahrheit übernommen. Die v0.1-Historie ist angebotsbezogen und keine Aussage über den Gesamtmarkt. `lowest_365d` darf als Minimum weniger beobachteter Tage angezeigt werden, muss dann ausdrücklich als Minimum der vorhandenen Daten bezeichnet sein. Der Snapshot speichert zusätzlich `price_reference_scope="offer"` und die IDs der verwendeten Beobachtungen, damit spätere Scoring-Versionen andere Scopes eindeutig unterscheiden können.
### PriceScore
Seien `p` aktueller Gesamtpreis, `t` Budgetziel, `m` Budgetmaximum, `r` 90-Tage-Median der von der Scoring-Version bereitgestellten Referenzhistorie:
```text
budget_score = 100 falls p <= t
budget_score = clamp(100 × (m − p) / (m − t)) sonst
discount = (r − p) / r
history_score = clamp(50 + 50 × discount / 0.20)
price_score = 0.40 × budget_score + 0.60 × history_score
```
Historien-Score: beim Median 50, bei 20 % günstiger 100, bei 20 % teurer 0. Nur bei mindestens drei verfügbaren Tagen im 90-Tage-Fenster ist `history_score` vorhanden. Andernfalls gilt `price_score=budget_score`, `price_basis=BUDGET_ONLY`, `history_score=null` und ein expliziter Hinweis `INSUFFICIENT_HISTORY`. Bei ausreichender Historie `price_basis=BUDGET_AND_HISTORY`. Kein Bonus für ein vermeintliches Jahrestief; 30-Tage-Median und Minimum dienen zunächst nur der Erklärung.
Ohne frischen, hinreichend sicheren und vollständig bekannten Gesamtpreis in Profilwährung ist der PriceScore null. Ein bekannter Preis oberhalb des Budgets kann diagnostisch bewertet werden, während `offer_eligibility` wegen des Budget-Gates `INELIGIBLE` bleibt. Bei falscher Währung wird ohne Wechselkursumrechnung kein PriceScore berechnet. Ausverkaufte Angebote dürfen ihren bekannten Preis diagnostisch behalten; `offer_eligibility` bleibt dennoch `INELIGIBLE`.
### DealScore und Klassifikation
Nur bei `overall_eligibility=ELIGIBLE` und vorhandenem PriceScore:
```text
deal_score = 0.60 × product_score + 0.40 × price_score
```
| Bereich des ungerundeten DealScore | Klassifikation |
|---|---|
| < 60 | IGNORE |
| >= 60 und < 75 | WATCH |
| >= 75 und < 85 | GOOD |
| >= 85 und <= 92 | VERY_GOOD |
| > 92 | EXCEPTIONAL |
Bei `overall_eligibility=INELIGIBLE`: DealScore null, Klassifikation `INELIGIBLE`. Bei `overall_eligibility=UNKNOWN`: DealScore null, Klassifikation `INSUFFICIENT_DATA`. ProductScore und vorhandener PriceScore dürfen als Diagnostik ausgegeben werden, ersetzen aber niemals die Gate-Ergebnisse. Die getrennten Product-/Offer-Status müssen in Snapshot und API sichtbar bleiben, damit ein gutes Produkt mit momentan schlechtem Angebot weiterhin beobachtbar bleibt.
### Nachvollziehbarkeit und Wiederholbarkeit
Jede Evaluation speichert Profil und Version, Modellversion und Hash, Engine-Build-ID, `as_of`, `snapshot_cutoff`, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, vollständige tatsächlich verwendete Eingaben und IDs sowie alle Komponenten, Gates, Aggregate, Coverage, Price-Reference-Scope, Reason-Codes und verständliche Texte. Persistierte Evaluationen sind unveränderlich. Ein erneuter Request erzeugt eine neue Evaluation; ein GET berechnet nichts neu.
Historische Abfragen mit `as_of` rekonstruieren nur den Datenstand zum aktuellen Snapshot-Cutoff, einschließlich später eingegangener Rückdatierungen. Sie behaupten nicht, den damaligen Wissensstand wiederherzustellen. Exakte Wiederholung einer alten Bewertung erfolgt intern aus deren gespeichertem Snapshot und zugehöriger Algorithmusversion. Alte Algorithmen bleiben verfügbar; kein Replay-HTTP-Endpunkt für v0.1 erforderlich.
## 8. Source-Adapter
Implementiere einen kleinen `Protocol` und `ManualSource`. Quell-DTOs enthalten Rohdaten, Quellenkennung, externe IDs, Zeitpunkte und Confidence; sie enthalten keine ORM-Objekte und keine Scoring-Logik.
```python
class ProductSource(Protocol):
source_key: str
def get_product(self, external_id: str) -> SourceProduct: ...
def get_offers(self, external_product_id: str) -> list[SourceOffer]: ...
def get_price_history(self, external_offer_id: str) -> list[SourcePriceObservation]: ...
```
`ManualSource` liest einen validierten lokalen Demo-Datensatz, keine externe Website. Ein Import-Service nimmt den Adapter entgegen und legt über dieselben Application Services wie die API Produkte, Varianten, Attribute, Angebote und Beobachtungen an. Explizite Variantenzuordnung aus der Fixture; keine Matching-Engine. Wiederholter Demo-Import erzeugt keine Duplikate: stabile UUIDs für Produkte/Varianten/Attribute, stabile externe Offer-/Beobachtungs-IDs und identischer Inhalt; Widersprüche melden statt überschreiben.
Adapter normalisieren keine Preise oder technischen Eigenschaften im Scoring-Modul. Der Pfad lautet Quelle → validierte DTOs → Normalisierung → Persistenz → Evaluation. Für spätere Quellen genügen zunächst Adapter und Import-Mapping. Pagination, Rate Limits und Retry-Strategien erst zusammen mit der ersten realen Quelle einführen.
## 9. HTTP-API
Alle fachlichen Endpunkte unter `/api/v1`. UUIDs für Entitäten, String-IDs für Profile, Attribute und Scoring-Modelle. Pydantic-Schemas mit `extra=forbid`; Pflichtfelder und Enums im OpenAPI-Schema dokumentieren. JSON-Ausgabe nutzt `snake_case`.
| Methode / Pfad | Vertrag |
|---|---|
| GET `/health/live` | Prozess lebt, kein DB-Zugriff |
| GET `/health/ready` | DB erreichbar und Migration auf Head; sonst 503 |
| POST `/api/v1/attribute-definitions` | unveränderliche Definition anlegen |
| GET `/api/v1/attribute-definitions` | Definitionen listen |
| GET `/api/v1/sources` | registrierte Quellen, einschließlich `manual` |
| GET `/api/v1/scoring-models/{version}` | vollständige Regeldefinition + Hash |
| POST `/api/v1/profiles` | vollständiges Profil inklusive Version anlegen |
| GET `/api/v1/profiles` | konkrete Profilversionen listen |
| GET `/api/v1/profiles/{id}?version=1` | konkrete Version; Query verpflichtend |
| POST `/api/v1/products` | Product anlegen |
| GET `/api/v1/products/{id}` | Product lesen |
| POST `/api/v1/products/{id}/variants` | Variante anlegen |
| GET `/api/v1/variants/{id}` | Variante mit Attributaussagen lesen |
| POST `/api/v1/variants/{id}/attributes` | einzelne unveränderliche Attributaussage hinzufügen |
| POST `/api/v1/variants/{id}/offers` | Offer anlegen |
| GET `/api/v1/offers/{id}` | Offer lesen |
| POST `/api/v1/offers/{id}/prices` | einzelne Beobachtung hinzufügen bzw. identisch wiederholen |
| GET `/api/v1/offers/{id}/prices` | gespeicherte Beobachtungen listen |
| POST `/api/v1/evaluate/{variant_id}` | auswerten und Ergebnis atomar speichern |
| GET `/api/v1/evaluations/{id}` | gespeichertes Ergebnis inklusive Snapshot lesen |
Listen verwenden `limit` (Default 50, maximal 200) und `offset` (>=0); Antwort `{items, limit, offset, total}`. Stabile Sortierung nach Primärschlüssel, bei Preisen `(observed_at, ingested_at, id)` aufsteigend. Keine Update-/Delete-Endpunkte in v0.1.
Evaluation-Request:
```json
{
"profile_id": "mobile-development-notebook",
"profile_version": 1,
"offer_id": "33333333-3333-4333-8333-333333333333",
"as_of": "2026-10-03T12:00:00Z"
}
```
`as_of` ist optional; der Service setzt es einmal auf Serverzeit UTC. Zukunftswerte sind ungültig. Offer muss zur angegebenen Variante gehören (422). Das Profil legt die Scoring-Version fest; kein stiller Override. Fachlich unpassende Kategorie wird im Product-Gate gespeichert; Markt, Zustand, Währung, Preis und Verfügbarkeit werden im Offer-Gate gespeichert. Keiner dieser fachlichen Fälle ist ein HTTP-Fehler.
Evaluation-Response enthält mindestens:
```json
{
"id": "44444444-4444-4444-8444-444444444444",
"profile_id": "mobile-development-notebook",
"profile_version": 1,
"scoring_version": "generic-v1",
"product_eligibility": "ELIGIBLE",
"offer_eligibility": "ELIGIBLE",
"overall_eligibility": "ELIGIBLE",
"eligible": true,
"product_score": 90.21,
"price_score": 98.24,
"deal_score": 93.42,
"classification": "EXCEPTIONAL",
"price_basis": "BUDGET_AND_HISTORY",
"product_coverage": 1.0,
"failed_constraints": [],
"unknown_constraints": [],
"reasons": [
{"code": "PRICE_BELOW_MEDIAN", "message": "Gesamtpreis 18,83 % unter dem Median der beobachteten 90-Tage-Historie."}
]
}
```
Zusätzlich verpflichtend: `variant_id`, `offer_id`, `as_of`, `snapshot_cutoff`, `scoring_hash`, `engine_build`, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, `components`, sämtliche `constraint_results` und Offer-Gates, `price_statistics`, `price_reference_scope`, `input_snapshot` und `warnings`. Die Kurzantwort oben illustriert nur die Kernfelder. `failed_constraints` enthält alle `FAIL`-Ergebnisse; `unknown_constraints` alle `UNKNOWN`-Ergebnisse. Reason-Codes sind stabil, Texte dienen Menschen und dürfen nicht als Programmierschnittstelle ausgewertet werden.
POST liefert 201, identisch wiederholte Preisbeobachtung 200; GET liefert 200. Fehlende IDs: 404. Verletzte Eindeutigkeit/abweichender Wiederholungsinhalt: 409. Ungültige Payload/Referenzkombination: 422. Einheitliches Fehlerformat `{error: {code, message, details}}`, auch für Request-Validierung. Keine SQL-Details an Clients. Eine fachlich negative Evaluation ist eine erfolgreiche 201-Antwort.
## 10. Datenbank und Transaktionen
Alle Zeitstempel `TIMESTAMPTZ`; JSON-Daten `JSONB`; UUID-Primärschlüssel für Entitäten; Decimal-Geldfelder. `created_at`/`ingested_at` werden serverseitig gesetzt. Fremdschlüssel sind obligatorisch. Kein Cascade-Delete historischer Bewertungen oder Quelldaten.
| Tabelle | Wesentliche Spalten und Constraints |
|---|---|
| `attribute_definitions` | `key` PK, Typ, Einheit, Bereich, Beschreibung |
| `sources` | `id` String-PK, `name`, `adapter_type`; Seed `manual` |
| `scoring_models` | `version` PK, `algorithm_id`, `definition` JSONB, `definition_hash`, `created_at` |
| `product_profiles` | zusammengesetzter PK `(id, version)`, `definition` JSONB, `category`, FK `scoring_version`, `definition_hash`, `created_at` |
| `products` | `id`, Kategorie, Marke, Name, Modell, `created_at` |
| `product_variants` | `id`, FK Product, Name, Hersteller-SKU, Kennungen JSONB, `created_at` |
| `variant_attributes` | `id`, FK Variante/Definition/Quelle, Raw/Normalized JSONB, Einheit, Status, Normalizer-Version, Confidence, Source-Referenz, Beobachtungs-/Ingestion-Zeit, Fehler |
| `offers` | `id`, FK Variante/Quelle, externe ID, Händler, URL, Markt, Währung, Zustand; UNIQUE Quelle/externe ID |
| `price_observations` | `id`, FK Offer/Quelle, externe Beobachtungs-ID, Preise, Währung, Verfügbarkeit, Confidence, Raw JSONB, beide Zeiten; UNIQUE Offer/externe ID |
| `evaluations` | `id`, FK Variante/Offer/Profilversion/Scoring-Version, Zeitpunkte, Hash, Build-ID, `product_eligibility`, `offer_eligibility`, `overall_eligibility`, drei nullable Scores, Klassifikation, `price_reference_scope`, vollständige Eingabe-/Ergebnis-Snapshots JSONB |
| `evaluation_components` | `id`, FK Evaluation, `kind`, `component_key`, nullable Rohscore/Gewicht/Beitrag, `details` JSONB; UNIQUE Evaluation/kind/key |
`evaluation_components` enthält Präferenzen inklusive Strategy, optionale Abzüge, Budget-/History-Komponenten, Product-Gates und Offer-Gates. JSON-Snapshots sind die vollständige Replay-Grundlage; relationale Komponenten dienen Abfragen und dürfen bei Erstellung nicht davon abweichen.
DB-Checks mindestens für nichtnegative Preise, positiven Artikelpreis, Confidence 0–1, Scores 0–100, Profilversion >0 und gültige feste Enums. Typ-/Einheiten-/Profilvalidierung bleibt zusätzlich in der Application-Schicht. Redundante Indexspalten eines Profils müssen aus dessen validierter Definition erzeugt werden.
Indizes: Attribute `(variant_id, attribute_key, observed_at DESC, ingested_at DESC, id DESC)`, Preise `(offer_id, observed_at DESC, ingested_at DESC, id DESC)`, Offers `variant_id`, Evaluationen `(profile_id, profile_version, created_at DESC)` und `variant_id`.
Eine Evaluation liest und schreibt innerhalb einer PostgreSQL-Transaktion mit konsistentem Snapshot (`REPEATABLE READ`). Zu Beginn `snapshot_cutoff` festlegen; komplette Inputs, Ergebnis und Komponenten atomar committen. Bei Fehlern kein Teilergebnis. Insert-Konflikte über UNIQUE-Constraints behandeln, nicht allein per vorherigem SELECT.
Alembic ist die einzige produktive Schemaanlage; kein `metadata.create_all()` beim API-Start. Initialmigration enthält Tabellen, Constraints und Indizes. Bootstrap von Quellen und Regeldefinitionen separat, idempotent und inhaltlich geprüft. Ein Konflikt gleicher Version mit anderem Hash stoppt den Bootstrap sichtbar.
## 11. Repository und Lieferumfang
```text
buying-agent/
├── README.md
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── .env.example
├── .gitignore
├── pyproject.toml
├── uv.lock
├── alembic.ini
├── migrations/
│ ├── env.py
│ └── versions/
├── app/
│ ├── main.py
│ ├── settings.py
│ ├── api/
│ │ ├── schemas.py
│ │ ├── profiles.py
│ │ ├── products.py
│ │ ├── offers.py
│ │ ├── evaluations.py
│ │ └── health.py
│ ├── domain/
│ │ ├── models.py
│ │ ├── attributes.py
│ │ ├── constraints.py
│ │ └── enums.py
│ ├── scoring/
│ │ ├── product.py
│ │ ├── price.py
│ │ ├── deal.py
│ │ └── engine.py
│ ├── normalization/
│ │ ├── base.py
│ │ └── manual.py
│ ├── sources/
│ │ ├── base.py
│ │ └── manual.py
│ ├── persistence/
│ │ ├── db.py
│ │ ├── models.py
│ │ └── repositories.py
│ └── services/
│ ├── ingestion_service.py
│ └── evaluation_service.py
├── rules/generic-v1.json
├── profiles/mobile-development-notebook.json
├── fixtures/manual-demo.json
├── scripts/
│ ├── bootstrap.py
│ └── demo.py
└── tests/
├── unit/
├── integration/
└── e2e/
```
`__init__.py` nach Bedarf ergänzen. Kleine Dateien dürfen sinnvoll zusammengelegt werden; Architekturgrenzen sind verbindlich. Kein generisches Repository-Framework und kein Plugin-Discovery-System erforderlich. Das Lockfile-Beispiel verwendet uv; uv als Build-/Entwicklungswerkzeug mit dokumentierter Version verwenden.
README enthält Setup, Architektur, Datenmodell, Formeln, Fehlersemantik, alle Start-/Testkommandos, vollständige Beispielrequests sowie Grenzen der synthetischen Preisbewertung. Diese Spezifikation ebenfalls im Repository unter `docs/buying-agent-v0.1-spec-v1.1.md` ablegen.
## 12. Docker und lokale Ausführung
Compose enthält nur zwei dauerhaft laufende Services: `api` und `postgres`. PostgreSQL 17 mit benanntem Datenvolume und Healthcheck. API basiert auf Python 3.13, läuft als Nicht-Root und bindet den veröffentlichten Port an `127.0.0.1:8000`. Die Datenbank braucht keinen veröffentlichten Host-Port. Secrets und lokale DB-Zugänge über `.env`, nur Platzhalter in `.env.example`.
Für das lokale Einzelinstanz-Skeleton darf das API-Entrypoint nach erfolgreichem DB-Healthcheck sequenziell `alembic upgrade head`, Bootstrap und Uvicorn ausführen. Fehlgeschlagene Migrationen verhindern den API-Start. Diese Startmigration ist keine Strategie für spätere Mehrinstanz-Deployments.
Verbindlicher Startpfad:
```sh
cp .env.example .env
docker compose up --build -d
docker compose exec api python -m scripts.demo
```
Für PowerShell zusätzlich `Copy-Item .env.example .env` dokumentieren. `scripts.demo` legt Definitionen, Profil und synthetische Kandidaten über die API an, wiederholt sich ohne Duplikate und ruft Evaluationen mit explizitem `as_of` auf. Die Demo umfasst mindestens die Fälle A, B, C und D aus Abschnitt 13. Statische Fixtures nutzen den unten definierten historischen Zeitpunkt, damit sie nicht mit der Kalenderzeit altern. Der Adapterpfad wird separat im Integrationstest geprüft; die Demo muss den HTTP-Pfad abdecken.
Testabhängigkeiten in einer Docker-Build-Stage `test` bereitstellen. README dokumentiert einen ausführbaren Compose-Test-Override oder gleichwertigen Befehl mit dieser Stage und separater Testdatenbank. Tests dürfen niemals das normale Datenvolume leeren. Kein dauerhafter dritter Service nötig.
## 13. Deterministische Beispiele und Tests
### Referenzfall A
`as_of=2026-10-03T12:00:00Z`, aktuelle Beobachtung um 10:00 UTC; alle technischen Attribute vorher beobachtet. Kategorie Notebook, Markt DE, Zustand neu, EUR, lieferbar, Confidence jeweils 1.0, Versand 0.00.
| Attribut | Wert | Präferenzscore |
|---|---:|---:|
| ram_gb | 32 | 100 |
| storage_gb | 1024 | nur Mindestconstraint |
| weight_kg | 1.2 | 83.333333… |
| battery_score | 90 | 87.5 |
| display_score | 90 | 87.5 |
| cpu_score | 90 | 87.5 |
Gesamtpreise der Historie: 1599.00 am 2026-09-01, 1599.00 am 2026-09-15, 1599.00 am 2026-10-01; jeweils 10:00 UTC. Aktuell 1298.00. Alle Zeilen betreffen dasselbe Offer und sind lieferbar. Fixtures werden vor Evaluation vollständig importiert.
Erwartungen:
```text
product_eligibility = ELIGIBLE
offer_eligibility = ELIGIBLE
overall_eligibility = ELIGIBLE; eligible = true
product_score = 90.21
median_30d = 1599.00 (2 Tage)
median_90d = 1599.00 (3 Tage)
lowest_365d = 1599.00 (3 Tage)
budget_score = 100
history_score = 97.06
price_score = 98.24
deal_score = 93.42
classification = EXCEPTIONAL
product_coverage = 1.0
```
Die Engine rechnet mit den ungerundeten Zwischenwerten. Die Response aus Abschnitt 9 bezieht sich auf diesen Fall. Das beobachtete Minimum von 1599.00 ist ausdrücklich kein belegtes Jahrestief des Marktes.
### Weitere Demokandidaten
- B: identisch zu A, aber 8 GB RAM → `product_eligibility=INELIGIBLE`, `offer_eligibility=ELIGIBLE`, `overall_eligibility=INELIGIBLE`, Mandatory RAM `FAIL`, ProductScore weiterhin diagnostisch vorhanden, kein DealScore.
- C: identisch zu A, aber keine verwertbare Gewichtsangabe → `product_eligibility=UNKNOWN`, `offer_eligibility=ELIGIBLE`, `overall_eligibility=UNKNOWN`, Mandatory Gewicht `UNKNOWN`, ProductScore diagnostisch vorhanden, Klassifikation `INSUFFICIENT_DATA`, kein DealScore.
- D: technisch identisch zu A, aber aktueller Gesamtpreis 2049.00 bei Budgetmaximum 2000.00 → `product_eligibility=ELIGIBLE`, `offer_eligibility=INELIGIBLE`, `overall_eligibility=INELIGIBLE`; ProductScore und diagnostischer PriceScore bleiben vorhanden, DealScore null. Dieser Fall beweist die Trennung von Produkt-Fit und Angebots-Fit.
### Unit-Tests
- Jeden Constraint-Operator mit PASS, FAIL, UNKNOWN; Gleichheitsgrenzen, strikte Typen, leere Listen und Confidence exakt 0.8 bzw. knapp darunter.
- Preference-Strategie `numeric_linear`: akzeptabel 50, ideal 100, Clamping, lower/higher, nicht auf 1 summierte Gewichte, fehlende Werte und optionale Abzüge; unbekannte `strategy` wird für `generic-v1` abgewiesen.
- Budget bei Ziel/Maximum/darüber; History-Score bei −20 %, 0 % und +20 % Rabatt; BUDGET_ONLY bei 0–2 Historientagen.
- Tagesauswahl vor Qualitätsfilter, Fenstergrenzen, Ausschluss des aktuellen Tages, Median gerader/ungerader Anzahl, identische Zeitstempel und stabile Tie-Breaks.
- Keine Vermischung von Offers, Varianten, Währungen oder Zuständen; aktuelle Zeile unbekannt/ausverkauft ohne Rückfall auf ältere Zeilen; ProductScore bleibt von Offer-Gates unabhängig.
- Frische exakt 48 Stunden/knapp darüber; unbekannter Versand; fehlender Preis; Kombination Mandatory FAIL und UNKNOWN.
- Klassifikationsgrenzen 60, 75, 85, 92 und unmittelbar darüber; Rundung darf keine Klasse verschieben.
- Referenzfall A einschließlich exakter gerundeter Resultate; andere Kategorie mit eigenen Attributen ohne Engine-Änderung; Fall D zur Trennung von Product/Offer Eligibility.
- Preis-Scoring-Funktion wird mit explizitem `current_price`, `reference_history` und `budget` getestet; Domain-Test darf dafür kein Offer-ORM-Objekt benötigen. `price_reference_scope=offer` ist für v0.1 verpflichtend.
- Replay aus gespeichertem Input trotz später hinzugefügter Attribute/Preise liefert identische fachliche Ergebnisse; generierte IDs und Speicherzeit sind kein Bestandteil des Vergleichs.
### Integration und End-to-End
- Gegen echtes PostgreSQL 17, nicht SQLite als Ersatz für JSONB, Numeric, Constraints oder Isolation.
- Migration auf leerer Test-DB; erneutes Upgrade ohne Änderung; initialer Downgrade/Upgrade nur in wegwerfbarer Test-DB.
- Bootstrap identisch wiederholbar, veränderte Regel mit gleicher Version wird abgewiesen.
- CRUD-Anlage-/Lesepfade, validierte Profilreferenzen, 404/409/422, Pagination, exakte Decimal- und UTC-Roundtrips.
- Doppelte Preisimporte auch bei konkurrierenden Requests: eine Beobachtung, kein stiller Content-Overwrite.
- Gescheiterte Evaluation-Persistenz hinterlässt weder Evaluation noch Komponenten.
- HTTP-End-to-End: Profil → Produkt → Variante → Attribute → Offer → Historie → Evaluation → GET; danach API-Neustart und erneutes GET desselben unveränderten Ergebnisses.
- ManualSource-Import nutzt dieselben Services und erzeugt bei Wiederholung keine Duplikate.
- Einfügen neuer Preisbeobachtungen ändert alte Evaluationen nicht; neue Evaluation verwendet neue Inputs.
## 14. Implementierungsreihenfolge
1. Projekt, Settings, Docker, PostgreSQL und initiale Migration aufsetzen.
2. Domain-Modelle, Definitions-/Profilvalidierung einschließlich `strategy`, getrennte Product-/Offer-Gates und reine Constraint-/Scoring-Funktionen samt Referenztests implementieren.
3. Preis-Scoring als reine Berechnung über `current_price`, `reference_history` und `budget` implementieren; v0.1-Assembler liefert dafür ausschließlich offer-bezogene Historie und markiert `price_reference_scope=offer`.
4. Repositories und transaktionalen Evaluation-Service integrieren.
5. API, ManualSource und Bootstrap anschließen; ersten vollständigen Durchstich ausführen.
6. Demo, Integrationstests, Fehlerfälle und nachvollziehbare Responses vervollständigen.
7. README und sauberen Start auf leerem Volume prüfen; tatsächliche Testergebnisse dokumentieren.
Routineentscheidungen innerhalb dieser Spezifikation selbst treffen. Falls eine Vorgabe technisch widersprüchlich ist, den konkreten Konflikt dokumentieren und minimal auflösen; keine stillen fachlichen Formeländerungen oder Scope-Erweiterungen. Insbesondere keine Infrastruktur für spätere Preference-Strategien, Variant-/Market-Level-Preisreihen, n8n, LLMs oder Discovery vorwegnehmen.
## 15. Definition of Done
- [ ] Ein frischer Checkout startet mit den dokumentierten Docker-Kommandos; Migration und Readiness funktionieren.
- [ ] PostgreSQL persistiert Daten über API-/Container-Neustarts hinweg.
- [ ] Alle vier Entitäten Product, ProductVariant, Offer, PriceObservation sind getrennt und korrekt verknüpft.
- [ ] Attribute sind generisch und bewahren Raw inhaltlich unverändert, Normalized, Quelle, Confidence und Normalizer-Version.
- [ ] Beispielprofil und `generic-v1` liegen versioniert als Dateien vor und werden validiert gespeichert; jede Preference besitzt `strategy`, v0.1 akzeptiert ausschließlich `numeric_linear`.
- [ ] Product-, Offer- und Overall-Eligibility werden getrennt berechnet, persistiert und über die API ausgegeben.
- [ ] Ein technisch passendes Produkt behält ProductScore/Product Eligibility unabhängig davon, ob das konkrete Offer aktuell zu teuer, ausverkauft oder anderweitig ungeeignet ist.
- [ ] Das Preis-Scoring ist fachlich über `current_price`, `reference_history` und `budget` entkoppelt; v0.1 verwendet ausschließlich `price_reference_scope=offer` und implementiert keine Variant-/Market-Level-Historie.
- [ ] Demo liefert für A, B, C und D die festgelegten Ergebnisse über die API.
- [ ] Scores, harte Gates, fehlende Daten, Historienbasis, Reference Scope und Begründungen sind sichtbar und reproduzierbar.
- [ ] Evaluationen enthalten vollständige unveränderliche Snapshots; spätere Daten und Regelversionen verändern sie nicht.
- [ ] Unit-, PostgreSQL-Integrations- und HTTP-End-to-End-Tests sind grün; Kommandos und Resultate sind dokumentiert.
- [ ] Neue Kategorie und neue Attribute funktionieren ohne Schema- oder Engine-Änderung, solange sie von den in der gewählten Scoring-Version unterstützten Strategies/Operatoren abgedeckt werden.
- [ ] Keine reale externe Quelle, kein n8n, keine LLM-Normalisierung, keine Web-UI, keine Auto-Discovery, keine Variant-/Market-Level-Preisaggregation und keine vorsorgliche Plugin-Infrastruktur wurden eingebaut.
- [ ] README ermöglicht einem anderen Entwickler Setup, Demo, Tests und Verständnis der Grenzen ohne Chat-Kontext.
Abschlussbericht des Coding-Agenten: implementierter Umfang, relevante Entscheidungen, ausgeführte Prüfungen mit Ergebnissen und gegebenenfalls konkrete verbleibende Abweichungen. Nicht ausgeführte Tests ausdrücklich als solche benennen.
## 16. Future Extension Contract
v0.1 ist ein Walking Skeleton des Zielsystems. Die folgenden Fähigkeiten sind ausdrücklich **keine v0.1-Deliverables**, die Architektur darf ihre spätere Ergänzung jedoch nicht durch produktspezifische Schemata, fest verdrahtete Scoring-Annahmen oder n8n-interne Fachlogik unnötig blockieren:
- mehrere externe Produkt- und Preisquellen,
- source-spezifische Search-/Discovery-Adapter,
- Variant-Level- und Market-Level-Preisreferenzserien,
- weitere Produktkategorien ohne produktspezifische DB-Spalten,
- weitere explizit versionierte Preference-Strategien wie `boolean_preferred`, `categorical_match`, `set_contains` oder `categorical_rank`,
- LLM-gestützte Normalisierung mit Raw-Wert, normalisiertem Wert, Confidence und Normalizer-Version,
- n8n für Scheduling, Orchestrierung und Benachrichtigungen,
- automatische Kandidatensuche und Kandidatenaufnahme,
- mehrere parallele Buying-Profile und Watchlists.
Dieser Vertrag verlangt **keine vorsorgliche Infrastruktur** für diese Funktionen. Insbesondere keine Plugin-Registry, keinen Event-Bus, keine Microservices, keine zusätzlichen Preisaggregationstabellen, keine LLM-Abstraktionsschicht und keine n8n-Workflows bauen, solange sie nicht für den aktuellen Slice erforderlich sind.
Die stabilen Erweiterungspunkte von v0.1 sind:
1. Source-Adapter liefern validierte DTOs in den bestehenden Importpfad.
2. Attribute und Constraints bleiben generisch und datengetrieben.
3. Preference-Strategien werden explizit benannt und durch versionierte Scoring-Modelle unterstützt.
4. PriceScore konsumiert eine von der Scoring-Version bereitgestellte Referenzserie statt dauerhaft an Offer-Identität gekoppelt zu sein.
5. Product-, Offer- und Overall-Eligibility bleiben getrennt, damit Monitoring später gute Produkte unabhängig von momentan schlechten Angeboten verfolgen kann.
6. n8n bleibt externer Orchestrator; die Buying Engine bleibt ohne n8n vollständig nutzbar und testbar.