diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78d4214 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# --- Secrets (NIEMALS committen, siehe SOPS/age) --- +.env +*.env +!*.enc.env +*.key +keys.txt + +# --- Python --- +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +env/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# --- IDE / OS --- +.idea/ +.vscode/ +.DS_Store + +# --- Daten / Artefakte --- +data/ +uploads/ +*.log +*.sqlite3 diff --git a/README.md b/README.md index c63eeed..f979d17 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,178 @@ -# Testapp -Experimental Testapp for Nora & Claude +# eBay-Auto-Lister + +**Automatisiertes Multi-Agent-System zur Erstellung und Verwaltung von eBay-Verkaufsangeboten.** + +Der eBay-Auto-Lister analysiert Produktfotos, recherchiert aktuelle Marktpreise, generiert verkaufsoptimierte Listings und beantwortet Käuferanfragen – vollständig orchestriert durch ein BMAD-basiertes Agenten-Framework. + +--- + +## Features / Was die App macht + +- Automatische Artikelerkennung und Zustandsbewertung anhand von Produktfotos +- Marktpreisrecherche über verkaufte eBay-Artikel und weitere Marktplätze +- KI-generierte Verkaufstexte (Titel, Beschreibung, Kategorie, Attribute) +- Automatische Beantwortung und Moderation von Käufernachrichten +- Koordinierter Ablauf aller Agenten durch einen zentralen Orchestrator +- Erweiterbare Architektur nach BMAD-Prinzipien + +--- + +## Die vier Fach-Agenten + +| Agent | Aufgabe | Input | Output | +|---|---|---|---| +| **Bildanalyse-Agent** | Analysiert Produktfotos; erkennt Artikel, Zustand und Merkmale | Produktfotos (JPG/PNG) | Strukturierte Artikelbeschreibung (Typ, Zustand, Merkmale) | +| **Preis-Recherche-Agent** | Recherchiert Marktpreise über verkaufte Listings und Marktplätze; schlägt optimalen Verkaufspreis vor | Artikelbeschreibung vom Bildanalyse-Agenten | Preisvorschlag mit Marktdaten und Begründung | +| **Listing-Erstellung-Agent** | Erzeugt eBay-konformen Titel, Beschreibung, Kategorie und Produktattribute | Artikelbeschreibung + Preisvorschlag | Fertiges Listing (Titel, Beschreibung, Kategorie-ID, Attribute) | +| **Chat-Moderation-Agent** | Beantwortet Käuferfragen, moderiert Nachrichten und eskaliert bei Bedarf | Eingehende Käufernachrichten + Listing-Kontext | Antwortvorschläge bzw. automatische Antworten | + +--- + +## BMAD-Orchestrierung + +Das System folgt dem **BMAD-Orchestrator-Pattern** (Business-Modular Agent Design): + +Ein zentraler **Orchestrator** nimmt den Nutzerauftrag entgegen, zerlegt ihn in Teilaufgaben und delegiert diese sequenziell oder parallel an die zuständigen Fach-Agenten. Jeder Agent ist eigenständig, hat klar definierte Eingaben und Ausgaben und kommuniziert ausschließlich über standardisierte Datenstrukturen (JSON/Pydantic-Modelle). + +``` +Nutzer + └─► Orchestrator + ├─► Bildanalyse-Agent → Artikeldaten + ├─► Preis-Recherche-Agent → Preisvorschlag + ├─► Listing-Erstellung-Agent → fertiges Listing + └─► Chat-Moderation-Agent → Käufer-Antworten +``` + +Der Orchestrator aggregiert alle Ergebnisse, behandelt Fehler und gibt dem Nutzer eine einheitliche Rückmeldung. Agenten können unabhängig ausgetauscht oder erweitert werden. + +--- + +## Projektstruktur + +``` +Testapp/ +├── src/ +│ ├── agents/ +│ │ ├── orchestrator.py # Zentraler Orchestrator +│ │ ├── image_analysis_agent.py # Bildanalyse-Agent +│ │ ├── price_research_agent.py # Preis-Recherche-Agent +│ │ ├── listing_agent.py # Listing-Erstellung-Agent +│ │ └── chat_moderation_agent.py # Chat-Moderation-Agent +│ ├── scrapers/ +│ │ ├── ebay_sold_scraper.py # Scraper für verkaufte eBay-Artikel +│ │ └── marketplace_scraper.py # Weitere Marktplatz-Daten +│ ├── api/ +│ │ ├── ebay_api.py # eBay API-Integration +│ │ └── models.py # Pydantic-Datenmodelle +│ └── __init__.py +├── docs/ +│ ├── architecture.md # Systemarchitektur +│ └── agent_specs.md # Agenten-Spezifikationen +├── tests/ +│ ├── test_image_analysis.py +│ ├── test_price_research.py +│ ├── test_listing.py +│ └── test_chat_moderation.py +├── spike/ +│ └── concept_spike.py # Proof-of-Concept (keine externen Abhängigkeiten) +├── .env # Secrets (NICHT committen, siehe Sicherheit) +├── .gitignore +├── requirements.txt +└── README.md +``` + +--- + +## Schnellstart / Setup + +### Voraussetzungen + +- Python 3.10+ +- (Optional) eBay Developer Account für API-Zugang + +### Installation + +```bash +# Repository klonen +git clone +cd Testapp + +# Virtuelle Umgebung erstellen und aktivieren +python3 -m venv .venv +source .venv/bin/activate + +# Abhängigkeiten installieren +pip install -r requirements.txt + +# .env-Datei anlegen (Vorlage: .env.example) +cp .env.example .env +# .env mit eigenen API-Keys befüllen (siehe Sicherheit & Secrets) +``` + +### Concept Spike (ohne externe Pakete) + +Der Spike demonstriert das Grundprinzip der Agenten-Orchestrierung mit reinem Python — keine Installation erforderlich: + +```bash +python3 spike/concept_spike.py +``` + +### Vollständige App starten + +```bash +python3 -m src.agents.orchestrator +``` + +--- + +## Status / Roadmap + +| Phase | Status | Beschreibung | +|---|---|---| +| Grundgerüst | ✅ Fertig | Projektstruktur, Basisklassen, Dateigerüst | +| Concept Spike | ✅ Fertig | Orchestrator-Pattern ohne externe Abhängigkeiten | +| Bildanalyse-Agent | 🔄 In Arbeit | Integration eines Vision-Modells | +| Preis-Recherche-Agent | 📋 Geplant | eBay Sold Listings + Marktdaten | +| Listing-Erstellung-Agent | 📋 Geplant | LLM-basierte Textgenerierung | +| Chat-Moderation-Agent | 📋 Geplant | Automatische Antworten | +| eBay API-Integration | 📋 Geplant | Trading API / Listing-Upload | +| Tests & CI | 📋 Geplant | Pytest-Suite, GitHub Actions | + +--- + +## Sicherheit & Secrets + +**Niemals API-Keys oder Passwörter im Klartext committen.** + +Dieses Projekt verwendet **SOPS + age** zur verschlüsselten Verwaltung aller Secrets: + +```bash +# Secret sicher bearbeiten +/opt/secrets/secrets.sh edit + +# .env nach Änderung verschlüsseln +/opt/secrets/secrets.sh encrypt + +# .env aus verschlüsselter Datei wiederherstellen +/opt/secrets/secrets.sh decrypt +``` + +**Regeln:** +- `.env`-Dateien müssen `chmod 600` haben +- `.env` ist in `.gitignore` eingetragen — unter keinen Umständen committen +- Neue Secrets immer zuerst in `secrets.sh` registrieren, dann verschlüsseln +- Encrypted Secrets liegen unter `/opt/secrets/*.enc.env` + +--- + +## Rechtlicher Hinweis + +- **eBay API:** Die Nutzung der eBay-API unterliegt den [eBay Developer Program Agreement](https://developer.ebay.com) und den geltenden Nutzungsbedingungen. Vor dem Produktiveinsatz ist ein gültiger API-Key erforderlich. +- **Scraping:** Web-Scraping ist nur im Rahmen der eBay-Nutzungsbedingungen zulässig. Automatisiertes Scraping, das gegen die Terms of Service verstößt, ist verboten und nicht Bestandteil dieses Projekts. +- **Dieses Projekt** dient ausschließlich dem legalen Eigengebrauch. Der Betreiber übernimmt keine Haftung für missbräuchliche Nutzung. + +--- + +## Lizenz + +*Lizenz: Platzhalter — noch nicht festgelegt.* diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1d91e14 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,20 @@ +# eBay-Auto-Lister – Abhängigkeiten (Starter, Versionen beim ersten Pin festlegen) +# +# Der Spike (spike/concept_spike.py) und die Agenten-Stubs laufen bewusst NUR +# mit der Python-Standardbibliothek. Die folgenden Pakete werden erst benötigt, +# sobald echte Implementierungen die Stubs ersetzen. + +# --- LLM / Agenten --- +# anthropic # Claude-Modelle (Bildanalyse, Listing-Texte, Chat-Moderation) + +# --- API-Layer (src/api) --- +# fastapi +# uvicorn[standard] +# pydantic + +# --- Preis-Recherche / Scraper (src/scrapers) --- +# httpx +# beautifulsoup4 + +# --- Tests --- +# pytest diff --git a/spike/concept_spike.py b/spike/concept_spike.py new file mode 100644 index 0000000..2658b0f --- /dev/null +++ b/spike/concept_spike.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Concept-Spike: Orchestrator-Pattern ohne externe Abhängigkeiten. + +Minimaler Einstiegspunkt, der den vollständigen Photo-to-Listing-Prototyp im +deterministischen Mock-Modus startet – keine Installation, kein Netz nötig:: + + python3 spike/concept_spike.py + +Für die volle Demo mit echtem Foto siehe ``spike/prototype.py --image … --live``. +""" + +from __future__ import annotations + +import os +import sys + +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from spike.prototype import main # noqa: E402 + +if __name__ == "__main__": + # Mock erzwingen: der Spike läuft garantiert ohne CLI/Netz. + raise SystemExit(main(["--mock"])) diff --git a/spike/prototype.py b/spike/prototype.py new file mode 100644 index 0000000..2f13abd --- /dev/null +++ b/spike/prototype.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Lauffähiger Photo-to-Listing-Prototyp des eBay-Auto-Listers. + +Demonstriert die komplette BMAD-Orchestrierung end-to-end: + Foto ──► Bildanalyse ──► Preis-Recherche ──► Listing ──► Käufer-Chat + +Betrieb +------- +Ohne Argumente läuft eine vollständige Demo im Mock-Modus (nur Standard- +bibliothek, keine Installation, kein Netz nötig):: + + python3 spike/prototype.py + +Mit echtem Produktfoto über den authentifizierten claude-CLI:: + + python3 spike/prototype.py --image /pfad/zum/foto.jpg --live + +Das Skript ist bewusst aus jedem Verzeichnis lauffähig (es fügt den Projekt- +Root selbst zum Importpfad hinzu). +""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys + +# Projekt-Root (eine Ebene über spike/) importierbar machen. +_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from src.agents.orchestrator import Orchestrator # noqa: E402 +from src.llm import ClaudeClient # noqa: E402 +from src.models import Listing # noqa: E402 + +_RULE = "═" * 64 +_DEMO_QUESTIONS = [ + "Was kostet der Versand?", + "Ist der Preis noch verhandelbar?", + "Der Artikel kam defekt an, ich will mein Geld zurück.", # -> Eskalation +] + + +def _section(title: str) -> None: + print(f"\n{_RULE}\n {title}\n{_RULE}") + + +def run_demo( + image_path: str | None, + description: str | None, + mode: str, + questions: list[str], +) -> int: + client = ClaudeClient(mode=mode) + orch = Orchestrator(client) + + print(f"\n eBay-Auto-Lister · Photo-to-Listing-Prototyp") + print(f" Betriebsmodus: {orch.mode.upper()}" + f"{' (claude-CLI)' if orch.mode == 'live' else ' (deterministischer Mock)'}") + if image_path: + print(f" Eingabe-Foto: {image_path}") + elif description: + print(f" Eingabe-Text: {description}") + + # --- Kern-Workflow ---------------------------------------------------- + result = orch.photo_to_listing(image_path=image_path, description=description) + a, p, listing = result.analysis, result.price, result.listing + + _section("1 · BILDANALYSE (Vision-Agent)") + print(f" Erkannt: {a.title_guess}") + print(f" Marke: {a.brand or '–'}") + print(f" Kategorie: {a.category}") + print(f" Zustand: {a.condition} (Score {a.condition_score:.2f})") + print(f" Merkmale: {', '.join(a.features) or '–'}") + print(f" Mängel: {', '.join(a.defects) or '–'}") + print(f" Sicherheit: {a.confidence:.0%} [Quelle: {a.source}]") + + _section("2 · PREIS-RECHERCHE (Market-Agent)") + print(f" Vorschlag: {p.suggested_price:.2f} {p.currency}") + print(f" Spanne: {p.price_min:.2f} – {p.price_max:.2f} {p.currency}") + print(f" Basis: {p.rationale} [Quelle: {p.source}]") + + _section("3 · LISTING (Listing-Agent)") + print(f" Titel ({len(listing.title)}/80): {listing.title}") + print(f" Kategorie-ID: {listing.category_id}") + print(f" Preis: {listing.price:.2f} {listing.currency}") + print(" Artikelmerkmale:") + for key, value in listing.item_specifics.items(): + print(f" • {key}: {value}") + print("\n Beschreibung:\n") + for line in listing.description.splitlines(): + print(f" {line}") + + # --- Chat-Demo -------------------------------------------------------- + if questions: + _section("4 · KÄUFER-CHAT (Chat-Moderation-Agent)") + for q in questions: + reply = orch.answer_question(q, listing) + flag = " ⚠ ESKALATION an Mensch" if reply.escalate else "" + print(f"\n Frage: {q}") + print(f" Antwort: {reply.answer}{flag}") + + _section("✓ FERTIG") + print(" Der komplette Photo-to-Listing-Ablauf wurde durchlaufen.") + print(f" (Tipp: mit --json gibt es das Ergebnis maschinenlesbar aus.)\n") + return 0 + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--image", help="Pfad zu einem Produktfoto (Live-Modus)") + parser.add_argument("--description", help="Textuelle Artikelbeschreibung") + parser.add_argument("--live", action="store_true", help="Live-Modus erzwingen (claude-CLI)") + parser.add_argument("--mock", action="store_true", help="Mock-Modus erzwingen") + parser.add_argument("--json", action="store_true", help="Nur JSON-Ergebnis ausgeben") + parser.add_argument("--no-chat", action="store_true", help="Chat-Demo überspringen") + parser.add_argument( + "--question", action="append", default=None, + help="Eigene Käuferfrage (mehrfach möglich)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + logging.basicConfig(level=logging.WARNING, format="%(message)s") + + mode = "auto" + if args.live: + mode = "live" + elif args.mock: + mode = "mock" + + image = args.image + description = args.description + # Standard-Demo, wenn weder Foto noch Text angegeben wurde. + if not image and not description: + description = "gebrauchte Over-Ear Bluetooth-Kopfhörer mit Noise Cancelling" + + if args.json: + orch = Orchestrator(ClaudeClient(mode=mode)) + print(orch.photo_to_listing(image_path=image, description=description).to_json()) + return 0 + + questions = [] if args.no_chat else (args.question or _DEMO_QUESTIONS) + return run_demo(image, description, mode, questions) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..2892d9a --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,27 @@ +"""eBay-Auto-Lister — BMAD-basiertes Multi-Agent-System. + +Schnelleinstieg:: + + from src import photo_to_listing + result = photo_to_listing(description="gebrauchte Bluetooth-Kopfhörer") + print(result.to_json()) +""" + +from .models import ( + ChatReply, + ItemAnalysis, + Listing, + ListingResult, + PriceSuggestion, +) +from .workflow import photo_to_listing + +__all__ = [ + "ItemAnalysis", + "PriceSuggestion", + "Listing", + "ChatReply", + "ListingResult", + "photo_to_listing", +] +__version__ = "0.1.0" diff --git a/src/agents/__init__.py b/src/agents/__init__.py new file mode 100644 index 0000000..828cd4a --- /dev/null +++ b/src/agents/__init__.py @@ -0,0 +1,32 @@ +"""BMAD-Fach-Agenten des eBay-Auto-Listers. + +Rollen-Mapping (Auftrag -> README): + vision = ImageAnalysisAgent (Bildanalyse-Agent) + market = PriceResearchAgent (Preis-Recherche-Agent) + listing = ListingAgent (Listing-Erstellung-Agent) + chat = ChatModerationAgent (Chat-Moderation-Agent) +""" + +from .base import BaseAgent +from .chat_moderation_agent import ChatModerationAgent +from .image_analysis_agent import ImageAnalysisAgent +from .listing_agent import ListingAgent +from .orchestrator import Orchestrator +from .price_research_agent import PriceResearchAgent + +# Kurz-Aliase entsprechend der BMAD-Rollennamen im Auftrag. +VisionAgent = ImageAnalysisAgent +MarketAgent = PriceResearchAgent +ChatAgent = ChatModerationAgent + +__all__ = [ + "BaseAgent", + "Orchestrator", + "ImageAnalysisAgent", + "PriceResearchAgent", + "ListingAgent", + "ChatModerationAgent", + "VisionAgent", + "MarketAgent", + "ChatAgent", +] diff --git a/src/agents/base.py b/src/agents/base.py new file mode 100644 index 0000000..47bf5c6 --- /dev/null +++ b/src/agents/base.py @@ -0,0 +1,60 @@ +"""BMAD-Basisklasse für alle Fach-Agenten. + +BMAD (Business-Modular Agent Design): jeder Agent ist eigenständig, hat einen +Namen/eine Rolle, einen klar definierten Eingang und Ausgang und kennt seinen +Betriebsmodus (live über den Claude-CLI oder deterministischer Mock). +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod + +from ..llm import ClaudeClient, ClaudeError, ClaudeUnavailable + + +class BaseAgent(ABC): + """Abstrakte Basis: ein Agent = eine Verantwortung, ein ``run``.""" + + #: Kurzname / BMAD-Rolle, z. B. "vision", "market". + name: str = "base" + #: Menschenlesbare Beschreibung der Aufgabe. + role: str = "Basis-Agent" + + def __init__( + self, + client: ClaudeClient | None = None, + logger: logging.Logger | None = None, + ) -> None: + self.client = client or ClaudeClient() + self.log = logger or logging.getLogger(f"agent.{self.name}") + + @property + def mode(self) -> str: + """Effektiver Modus dieses Agenten ("live" oder "mock").""" + return self.client.mode + + @abstractmethod + def run(self, payload): + """Verarbeitet eine typisierte Eingabe zu einer typisierten Ausgabe.""" + raise NotImplementedError + + # -- Helfer für abgeleitete Agenten ------------------------------------ + + def _try_live_json(self, prompt: str, image_path: str | None = None): + """Versucht einen Live-JSON-Aufruf; gibt ``None`` im Mock/Fehlerfall. + + Kapselt das wiederkehrende Muster: live versuchen, bei fehlendem CLI + oder Fehler sauber auf den Mock-Pfad zurückfallen (BMAD-Robustheit – + ein Agent darf nie die gesamte Pipeline reißen). + """ + if self.mode != "live": + return None + try: + return self.client.complete_json(prompt, image_path=image_path) + except (ClaudeError, ClaudeUnavailable) as exc: + self.log.warning("Live-Aufruf fehlgeschlagen, nutze Mock: %s", exc) + return None + + def __repr__(self) -> str: # pragma: no cover - Komfort + return f"<{type(self).__name__} name={self.name!r} mode={self.mode!r}>" diff --git a/src/agents/chat_moderation_agent.py b/src/agents/chat_moderation_agent.py new file mode 100644 index 0000000..1d842be --- /dev/null +++ b/src/agents/chat_moderation_agent.py @@ -0,0 +1,90 @@ +"""Chat-Moderation-Agent. + +Eingang: Käufernachricht + Listing-Kontext. +Ausgang: :class:`~src.models.ChatReply` (Antwortvorschlag, ggf. Eskalation). +""" + +from __future__ import annotations + +from ..models import ChatReply, Listing +from .base import BaseAgent + +_PROMPT = ( + "Du bist ein freundlicher, knapper eBay-Verkäufer-Assistent. Beantworte die " + "Käuferfrage zum folgenden Angebot sachlich auf Deutsch. Erfinde keine Fakten, " + "die nicht aus dem Angebot hervorgehen. Eskaliere (escalate=true), wenn es um " + "Reklamation, Rückgabe, Beschwerde, Streit oder rechtliche Themen geht.\n\n" + "Angebot:\nTitel: {title}\nPreis: {price} {currency}\n" + "Beschreibung: {description}\n\n" + "Käuferfrage: {question}\n\n" + 'JSON-Schema:\n{{ "answer": str, "escalate": bool }}' +) + +# Eskalations-Trigger (Mock-Pfad). +_ESCALATE = ( + "reklamation", "rückgabe", "ruckgabe", "zurück", "defekt", "kaputt", + "beschwerde", "anwalt", "betrug", "geld zurück", "widerruf", "streit", +) + +# Einfache FAQ-Intents -> Antwortbaustein (Mock-Pfad). +def _faq_answer(question: str, listing: Listing) -> str | None: + q = question.lower() + if any(w in q for w in ("versand", "verschick", "porto", "liefer")): + return ("Der Versand erfolgt als versichertes Paket innerhalb von 1–2 " + "Werktagen nach Zahlungseingang.") + if any(w in q for w in ("preis", "vb", "verhandel", "nachlass", "günstiger", "gunstiger")): + return (f"Der Preis beträgt {listing.price:.2f} {listing.currency} (Sofort-Kauf). " + "Ein faires Angebot prüfe ich gern.") + if any(w in q for w in ("zustand", "kratzer", "gebraucht", "neu")): + zustand = listing.item_specifics.get("Zustand", "siehe Beschreibung") + return f"Zum Zustand: {zustand}. Alle Details stehen in der Artikelbeschreibung." + if any(w in q for w in ("verfügbar", "verfugbar", "noch da", "vorhanden")): + return "Ja, der Artikel ist noch verfügbar." + if any(w in q for w in ("abhol", "selbstabhol")): + return "Abholung ist nach Absprache möglich." + return None + + +class ChatModerationAgent(BaseAgent): + name = "chat" + role = "Chat-Moderation-Agent – beantwortet und moderiert Käuferanfragen" + + def run(self, payload) -> ChatReply: + """``payload``: dict mit ``question`` und ``listing`` (Listing).""" + question = payload["question"] + listing: Listing = payload["listing"] + + prompt = _PROMPT.format( + title=listing.title, + price=listing.price, + currency=listing.currency, + description=listing.description[:600], + question=question, + ) + data = self._try_live_json(prompt) + if data is not None: + return ChatReply( + question=question, + answer=str(data.get("answer", "")).strip(), + escalate=bool(data.get("escalate", False)), + source="claude-cli", + ) + return self._mock(question, listing) + + # ----------------------------------------------------------------- mock + @staticmethod + def _mock(question: str, listing: Listing) -> ChatReply: + q = question.lower() + if any(trigger in q for trigger in _ESCALATE): + return ChatReply( + question=question, + answer=("Danke für Ihre Nachricht – ich kümmere mich persönlich darum " + "und melde mich zeitnah."), + escalate=True, + source="mock", + ) + answer = _faq_answer(question, listing) + if answer is None: + answer = ("Danke für Ihre Nachricht! Alle bekannten Details stehen in der " + "Beschreibung – bei konkreten Rückfragen helfe ich gern weiter.") + return ChatReply(question=question, answer=answer, escalate=False, source="mock") diff --git a/src/agents/image_analysis_agent.py b/src/agents/image_analysis_agent.py new file mode 100644 index 0000000..c22608f --- /dev/null +++ b/src/agents/image_analysis_agent.py @@ -0,0 +1,108 @@ +"""Bildanalyse-Agent ("Vision"). + +Eingang: Pfad zu einem Produktfoto (oder eine textuelle Beschreibung als +Fallback für Demos ohne Bild). +Ausgang: :class:`~src.models.ItemAnalysis`. +""" + +from __future__ import annotations + +from ..models import ItemAnalysis +from .base import BaseAgent + +_PROMPT = ( + "Du bist ein Experte für Gebrauchtwaren-Ankauf. Analysiere das Produktfoto " + "und liefere eine strukturierte Einschätzung für ein eBay-Verkaufsangebot. " + "Schätze Artikeltyp, Marke, Zustand und sichtbare Merkmale/Mängel.\n\n" + "JSON-Schema:\n" + "{\n" + ' "title_guess": str, // kurze Artikelbezeichnung\n' + ' "category": str, // z. B. "Kopfhörer", "Sneaker"\n' + ' "brand": str|null,\n' + ' "condition": str, // z. B. "Gebraucht – sehr gut"\n' + ' "condition_score": float, // 0.0 defekt .. 1.0 neuwertig\n' + ' "features": [str],\n' + ' "defects": [str],\n' + ' "confidence": float // 0.0 .. 1.0\n' + "}" +) + +# Deterministische Mock-Heuristik: Stichwort im Dateinamen/Beschreibung -> +# plausibles Beispielobjekt. Hält den Prototyp ohne Bild/CLI lauffähig. +_MOCK_TABLE = { + "kopfhörer": ("Over-Ear Bluetooth-Kopfhörer", "Kopfhörer", "Sony", + ["Bluetooth", "Active Noise Cancelling", "faltbar"]), + "headphone": ("Over-Ear Bluetooth-Kopfhörer", "Kopfhörer", "Sony", + ["Bluetooth", "Active Noise Cancelling", "faltbar"]), + "sneaker": ("Sneaker Low-Top", "Sneaker", "Nike", + ["Gr. 43", "Leder", "Originalkarton"]), + "schuh": ("Sneaker Low-Top", "Sneaker", "Nike", + ["Gr. 43", "Leder", "Originalkarton"]), + "kamera": ("Spiegellose Systemkamera", "Digitalkameras", "Canon", + ["24 MP", "inkl. Kit-Objektiv", "WLAN"]), + "uhr": ("Automatik-Armbanduhr", "Armbanduhren", "Seiko", + ["Edelstahl", "Saphirglas", "Datumsanzeige"]), + "konsole": ("Spielkonsole", "Konsolen", "Sony", + ["1 TB", "inkl. Controller", "4K"]), +} +_MOCK_DEFAULT = ( + "Over-Ear Bluetooth-Kopfhörer", "Kopfhörer", "Sony", + ["Bluetooth", "Active Noise Cancelling", "faltbar"], +) + + +class ImageAnalysisAgent(BaseAgent): + name = "vision" + role = "Bildanalyse-Agent – erkennt Artikel, Zustand und Merkmale" + + def run(self, payload) -> ItemAnalysis: + """``payload``: dict mit ``image_path`` und/oder ``description``.""" + if isinstance(payload, str): + payload = {"image_path": payload} + image_path = payload.get("image_path") + description = payload.get("description") + + prompt = _PROMPT + if description: + prompt += f"\n\nZusätzlicher Kontext zum Artikel: {description}" + + data = self._try_live_json(prompt, image_path=image_path) + if data is not None: + return self._from_json(data) + return self._mock(image_path, description) + + # ----------------------------------------------------------------- live + @staticmethod + def _from_json(data: dict) -> ItemAnalysis: + return ItemAnalysis( + title_guess=str(data.get("title_guess", "Unbekannter Artikel")), + category=str(data.get("category", "Sonstiges")), + brand=data.get("brand") or None, + condition=str(data.get("condition", "Gebraucht")), + condition_score=float(data.get("condition_score", 0.7)), + features=[str(f) for f in data.get("features", [])], + defects=[str(d) for d in data.get("defects", [])], + confidence=float(data.get("confidence", 0.6)), + source="claude-cli", + ) + + # ----------------------------------------------------------------- mock + @staticmethod + def _mock(image_path: str | None, description: str | None) -> ItemAnalysis: + haystack = f"{image_path or ''} {description or ''}".lower() + title, category, brand, features = _MOCK_DEFAULT + for keyword, entry in _MOCK_TABLE.items(): + if keyword in haystack: + title, category, brand, features = entry + break + return ItemAnalysis( + title_guess=title, + category=category, + brand=brand, + condition="Gebraucht – sehr gut", + condition_score=0.85, + features=list(features), + defects=["leichte Gebrauchsspuren"], + confidence=0.55, + source="mock", + ) diff --git a/src/agents/listing_agent.py b/src/agents/listing_agent.py new file mode 100644 index 0000000..1371ae3 --- /dev/null +++ b/src/agents/listing_agent.py @@ -0,0 +1,130 @@ +"""Listing-Erstellung-Agent. + +Eingang: :class:`~src.models.ItemAnalysis` + :class:`~src.models.PriceSuggestion`. +Ausgang: :class:`~src.models.Listing` (eBay-konform). +""" + +from __future__ import annotations + +from ..models import ItemAnalysis, Listing, PriceSuggestion +from .base import BaseAgent + +_TITLE_MAX = 80 # eBay-Titel-Limit + +_PROMPT = ( + "Du bist ein Profi für verkaufsstarke eBay-Angebote. Erstelle ein " + "eBay-konformes Listing für den folgenden Artikel.\n\n" + "Artikel: {title}\nMarke: {brand}\nKategorie: {category}\n" + "Zustand: {condition}\nMerkmale: {features}\nMängel: {defects}\n" + "Vorgeschlagener Preis: {price} {currency}\n\n" + "Regeln: Titel max. 80 Zeichen, keyword-stark, ohne Ausrufezeichen-Spam. " + "Beschreibung sachlich, ehrlich zum Zustand, mit Stichpunkten.\n\n" + "JSON-Schema:\n" + "{{\n" + ' "title": str, // <= 80 Zeichen\n' + ' "description": str,\n' + ' "category_id": str, // eBay-Kategorie-ID (Schätzung erlaubt)\n' + ' "item_specifics": {{ }} // Schlüssel/Wert, z. B. Marke, Zustand\n' + "}}" +) + +# Stichwort -> eBay-Kategorie-ID (Auszug, Demo-Werte). +_CATEGORY_IDS = { + "kopfhörer": "112529", + "sneaker": "15709", + "schuh": "15709", + "kamera": "31388", + "uhr": "31387", + "konsole": "139971", +} +_CATEGORY_DEFAULT = "267" # "Sonstiges" + + +class ListingAgent(BaseAgent): + name = "listing" + role = "Listing-Erstellung-Agent – erzeugt Titel, Text, Kategorie, Attribute" + + def run(self, payload) -> Listing: + """``payload``: (ItemAnalysis, PriceSuggestion) oder dict.""" + analysis, price = self._unpack(payload) + prompt = _PROMPT.format( + title=analysis.title_guess, + brand=analysis.brand or "unbekannt", + category=analysis.category, + condition=analysis.condition, + features=", ".join(analysis.features) or "keine", + defects=", ".join(analysis.defects) or "keine bekannt", + price=price.suggested_price, + currency=price.currency, + ) + data = self._try_live_json(prompt) + if data is not None: + return self._from_json(data, analysis, price) + return self._mock(analysis, price) + + @staticmethod + def _unpack(payload) -> tuple[ItemAnalysis, PriceSuggestion]: + if isinstance(payload, dict): + return payload["analysis"], payload["price"] + analysis, price = payload + return analysis, price + + # ----------------------------------------------------------------- live + def _from_json( + self, data: dict, analysis: ItemAnalysis, price: PriceSuggestion + ) -> Listing: + title = str(data.get("title", analysis.title_guess))[:_TITLE_MAX] + specifics = data.get("item_specifics") or {} + if not isinstance(specifics, dict): + specifics = {} + return Listing( + title=title, + description=str(data.get("description", "")), + category_id=str(data.get("category_id") or self._category_id(analysis)), + item_specifics={str(k): str(v) for k, v in specifics.items()}, + price=price.suggested_price, + currency=price.currency, + source="claude-cli", + ) + + # ----------------------------------------------------------------- mock + def _mock(self, analysis: ItemAnalysis, price: PriceSuggestion) -> Listing: + brand = analysis.brand or "" + title = " ".join( + part for part in [brand, analysis.title_guess, analysis.condition] + if part + )[:_TITLE_MAX] + + feature_lines = "\n".join(f"- {f}" for f in analysis.features) + defect_lines = "\n".join(f"- {d}" for d in analysis.defects) or "- keine bekannt" + description = ( + f"{brand + ' ' if brand else ''}{analysis.title_guess}\n\n" + f"Zustand: {analysis.condition}\n\n" + f"Merkmale:\n{feature_lines}\n\n" + f"Hinweise zum Zustand:\n{defect_lines}\n\n" + f"Preis: {price.suggested_price:.2f} {price.currency} (Sofort-Kauf). " + f"Versand als versichertes Paket. Privatverkauf, keine Rücknahme." + ) + specifics = {"Zustand": analysis.condition} + if brand: + specifics["Marke"] = brand + for i, feat in enumerate(analysis.features, 1): + specifics[f"Merkmal {i}"] = feat + + return Listing( + title=title, + description=description, + category_id=self._category_id(analysis), + item_specifics=specifics, + price=price.suggested_price, + currency=price.currency, + source="mock", + ) + + @staticmethod + def _category_id(analysis: ItemAnalysis) -> str: + haystack = f"{analysis.category} {analysis.title_guess}".lower() + for keyword, cid in _CATEGORY_IDS.items(): + if keyword in haystack: + return cid + return _CATEGORY_DEFAULT diff --git a/src/agents/orchestrator.py b/src/agents/orchestrator.py new file mode 100644 index 0000000..5d88d33 --- /dev/null +++ b/src/agents/orchestrator.py @@ -0,0 +1,106 @@ +"""Zentraler Orchestrator (BMAD-Orchestrator-Pattern). + +Nimmt den Nutzerauftrag entgegen, delegiert sequenziell an die Fach-Agenten und +aggregiert die Ergebnisse zu einem einheitlichen :class:`~src.models.ListingResult`. +Jeder Agent kommuniziert ausschließlich über die typisierten Modelle aus +:mod:`src.models`. + +CLI: + python3 -m src.agents.orchestrator [--image PFAD] [--description TEXT] [--live] +""" + +from __future__ import annotations + +import argparse +import logging + +from ..llm import ClaudeClient +from ..models import ChatReply, ItemAnalysis, Listing, ListingResult, PriceSuggestion +from .chat_moderation_agent import ChatModerationAgent +from .image_analysis_agent import ImageAnalysisAgent +from .listing_agent import ListingAgent +from .price_research_agent import PriceResearchAgent + + +class Orchestrator: + """Koordiniert Vision → Market → Listing und beantwortet Käuferfragen.""" + + def __init__(self, client: ClaudeClient | None = None) -> None: + self.client = client or ClaudeClient() + self.log = logging.getLogger("agent.orchestrator") + self.vision = ImageAnalysisAgent(self.client) + self.market = PriceResearchAgent(self.client) + self.listing = ListingAgent(self.client) + self.chat = ChatModerationAgent(self.client) + + @property + def mode(self) -> str: + return self.client.mode + + # ------------------------------------------------------------------ core + def photo_to_listing( + self, + image_path: str | None = None, + description: str | None = None, + ) -> ListingResult: + """Kern-Workflow: Foto/Beschreibung -> fertiges Listing.""" + if not image_path and not description: + raise ValueError("image_path oder description erforderlich") + + self.log.info("1/3 Bildanalyse (mode=%s)…", self.mode) + analysis: ItemAnalysis = self.vision.run( + {"image_path": image_path, "description": description} + ) + + self.log.info("2/3 Preis-Recherche…") + price: PriceSuggestion = self.market.run(analysis) + + self.log.info("3/3 Listing-Erstellung…") + listing: Listing = self.listing.run((analysis, price)) + + return ListingResult(analysis=analysis, price=price, listing=listing) + + # ------------------------------------------------------------------ chat + def answer_question(self, question: str, listing: Listing) -> ChatReply: + """Delegiert eine Käuferfrage an den Chat-Moderation-Agenten.""" + return self.chat.run({"question": question, "listing": listing}) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="eBay-Auto-Lister Orchestrator") + parser.add_argument("--image", help="Pfad zu einem Produktfoto") + parser.add_argument("--description", help="Textuelle Artikelbeschreibung (Fallback)") + parser.add_argument( + "--live", action="store_true", + help="Live-Modus erzwingen (nutzt den claude-CLI statt Mock)", + ) + parser.add_argument( + "--mock", action="store_true", + help="Mock-Modus erzwingen (kein CLI-Aufruf)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + logging.basicConfig(level=logging.INFO, format="%(message)s") + args = _build_parser().parse_args(argv) + + mode = "auto" + if args.live: + mode = "live" + elif args.mock: + mode = "mock" + + image = args.image + description = args.description + if not image and not description: + description = "Beispielartikel: gebrauchte Over-Ear Bluetooth-Kopfhörer" + + orch = Orchestrator(ClaudeClient(mode=mode)) + result = orch.photo_to_listing(image_path=image, description=description) + print(result.to_json()) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/src/agents/price_research_agent.py b/src/agents/price_research_agent.py new file mode 100644 index 0000000..58a6f27 --- /dev/null +++ b/src/agents/price_research_agent.py @@ -0,0 +1,102 @@ +"""Preis-Recherche-Agent ("Market"). + +Eingang: :class:`~src.models.ItemAnalysis`. +Ausgang: :class:`~src.models.PriceSuggestion`. + +Hinweis: Echtes Scraping verkaufter eBay-Listings unterliegt den eBay-AGB und +ist hier bewusst NICHT enthalten (siehe ``src/scrapers``). Der Live-Pfad nutzt +die Marktkenntnis des Modells als Schätzung; der Mock-Pfad eine transparente +Heuristik aus Kategorie und Zustand. +""" + +from __future__ import annotations + +from ..models import ItemAnalysis, PriceSuggestion +from .base import BaseAgent + +_PROMPT = ( + "Du bist ein eBay-Preisanalyst. Schätze auf Basis deiner Marktkenntnis den " + "realistischen Verkaufspreis (Sofort-Kauf) für den folgenden Gebrauchtartikel " + "auf dem deutschen eBay-Markt.\n\n" + "Artikel: {title}\nMarke: {brand}\nKategorie: {category}\n" + "Zustand: {condition} (Score {score})\nMerkmale: {features}\n\n" + "JSON-Schema:\n" + "{{\n" + ' "suggested_price": float,\n' + ' "price_min": float,\n' + ' "price_max": float,\n' + ' "currency": "EUR",\n' + ' "rationale": str\n' + "}}" +) + +# Grobe Basispreise je Kategorie-Stichwort (EUR, gebraucht, mittlerer Zustand). +_BASE_PRICES = { + "kopfhörer": 120.0, + "sneaker": 70.0, + "digitalkamera": 350.0, + "kamera": 350.0, + "armbanduhr": 180.0, + "uhr": 180.0, + "konsole": 250.0, +} +_BASE_DEFAULT = 40.0 + + +class PriceResearchAgent(BaseAgent): + name = "market" + role = "Preis-Recherche-Agent – schlägt einen Marktpreis vor" + + def run(self, payload: ItemAnalysis) -> PriceSuggestion: + prompt = _PROMPT.format( + title=payload.title_guess, + brand=payload.brand or "unbekannt", + category=payload.category, + condition=payload.condition, + score=round(payload.condition_score, 2), + features=", ".join(payload.features) or "keine", + ) + data = self._try_live_json(prompt) + if data is not None: + return self._from_json(data) + return self._mock(payload) + + # ----------------------------------------------------------------- live + @staticmethod + def _from_json(data: dict) -> PriceSuggestion: + suggested = float(data.get("suggested_price", 0.0)) + return PriceSuggestion( + suggested_price=round(suggested, 2), + price_min=round(float(data.get("price_min", suggested * 0.8)), 2), + price_max=round(float(data.get("price_max", suggested * 1.2)), 2), + currency=str(data.get("currency", "EUR")), + rationale=str(data.get("rationale", "")), + sample_size=0, + source="claude-cli", + ) + + # ----------------------------------------------------------------- mock + @staticmethod + def _mock(item: ItemAnalysis) -> PriceSuggestion: + category = item.category.lower() + base = _BASE_DEFAULT + for keyword, price in _BASE_PRICES.items(): + if keyword in category: + base = price + break + # Zustand skaliert den Preis zwischen 50 % (defekt) und 100 % (neuwertig). + factor = 0.5 + 0.5 * max(0.0, min(1.0, item.condition_score)) + suggested = round(base * factor, 2) + return PriceSuggestion( + suggested_price=suggested, + price_min=round(suggested * 0.85, 2), + price_max=round(suggested * 1.15, 2), + currency="EUR", + sample_size=12, + rationale=( + f"Heuristik: Basispreis {base:.0f} € für Kategorie " + f"'{item.category}', skaliert mit Zustands-Score " + f"{item.condition_score:.2f}." + ), + source="mock", + ) diff --git a/src/api/.gitkeep b/src/api/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/llm/__init__.py b/src/llm/__init__.py new file mode 100644 index 0000000..9e79d0d --- /dev/null +++ b/src/llm/__init__.py @@ -0,0 +1,5 @@ +"""LLM-Adapter-Schicht für den eBay-Auto-Lister.""" + +from .claude_client import ClaudeClient, ClaudeError, ClaudeUnavailable, extract_json + +__all__ = ["ClaudeClient", "ClaudeError", "ClaudeUnavailable", "extract_json"] diff --git a/src/llm/claude_client.py b/src/llm/claude_client.py new file mode 100644 index 0000000..a750657 --- /dev/null +++ b/src/llm/claude_client.py @@ -0,0 +1,165 @@ +"""Adapter für den bereits authentifizierten ``claude`` Command-Line-Client. + +Statt einen API-Key im Code zu halten (siehe SOPS/age-Policy), rufen die +LLM-gestützten Agenten den lokal installierten ``claude``-CLI als Subprozess +auf (``claude -p``). Ist der CLI nicht verfügbar, melden die Agenten das über +``ClaudeUnavailable`` und schalten auf ihren deterministischen Mock-Pfad um – +so bleibt der Prototyp auch offline / in CI lauffähig. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess + + +class ClaudeError(RuntimeError): + """Der CLI wurde aufgerufen, lieferte aber einen Fehler.""" + + +class ClaudeUnavailable(RuntimeError): + """Der CLI ist nicht installiert oder Mock-Modus ist erzwungen.""" + + +def extract_json(text: str): + """Extrahiert das erste vollständige JSON-Objekt/-Array aus ``text``. + + Modelle verpacken JSON gern in Prosa oder ```-Codeblöcke. Diese Funktion + ist tolerant: erst direktes ``json.loads``, dann Suche nach dem ersten + balancierten ``{...}``/``[...]``. + """ + text = text.strip() + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + for opener, closer in (("{", "}"), ("[", "]")): + start = text.find(opener) + if start == -1: + continue + depth = 0 + in_str = False + escape = False + for i in range(start, len(text)): + ch = text[i] + if in_str: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_str = False + continue + if ch == '"': + in_str = True + elif ch == opener: + depth += 1 + elif ch == closer: + depth -= 1 + if depth == 0: + candidate = text[start : i + 1] + try: + return json.loads(candidate) + except json.JSONDecodeError: + break + raise ClaudeError(f"Keine gültige JSON-Antwort gefunden in:\n{text[:500]}") + + +class ClaudeClient: + """Dünner Wrapper um ``claude -p``. + + Parameter + --------- + mode: ``"auto"`` (live wenn CLI da, sonst mock), ``"live"`` oder ``"mock"``. + model: optionales Modell-Override (sonst CLI-Default bzw. ``CLAUDE_MODEL``). + timeout: Sekunden bis Abbruch eines Aufrufs. + """ + + def __init__( + self, + mode: str = "auto", + model: str | None = None, + timeout: int = 180, + cli: str = "claude", + allowed_tools: str = "Read", + ) -> None: + if mode not in ("auto", "live", "mock"): + raise ValueError(f"Ungültiger mode: {mode!r}") + self._mode = mode + self.cli = cli + self.model = model or os.environ.get("CLAUDE_MODEL") + self.timeout = timeout + self.allowed_tools = allowed_tools + + def available(self) -> bool: + return shutil.which(self.cli) is not None + + @property + def mode(self) -> str: + """Effektiver Modus: löst ``"auto"`` zur Laufzeit auf.""" + if self._mode == "auto": + return "live" if self.available() else "mock" + return self._mode + + def complete( + self, + prompt: str, + image_path: str | None = None, + system: str | None = None, + ) -> str: + """Sendet ``prompt`` an den CLI und liefert die Textantwort.""" + if self.mode == "mock": + raise ClaudeUnavailable("ClaudeClient läuft im Mock-Modus") + if not self.available(): + raise ClaudeUnavailable(f"CLI {self.cli!r} nicht im PATH") + + full = prompt + if image_path: + full = ( + f"Lies und analysiere die Bilddatei unter dem absoluten Pfad: " + f"{image_path}\n\n{prompt}" + ) + + cmd = [self.cli, "-p", full] + if self.model: + cmd += ["--model", self.model] + if system: + cmd += ["--append-system-prompt", system] + if image_path and self.allowed_tools: + # Lesezugriff auf die Bilddatei vorab erlauben -> kein Prompt im + # nicht-interaktiven Modus. + cmd += ["--allowed-tools", self.allowed_tools] + + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.timeout, + ) + except subprocess.TimeoutExpired as exc: + raise ClaudeError(f"Timeout nach {self.timeout}s") from exc + except FileNotFoundError as exc: + raise ClaudeUnavailable(str(exc)) from exc + + if proc.returncode != 0: + raise ClaudeError(proc.stderr.strip() or f"exit code {proc.returncode}") + return proc.stdout.strip() + + def complete_json( + self, + prompt: str, + image_path: str | None = None, + system: str | None = None, + ): + """Wie :meth:`complete`, parst die Antwort aber als JSON.""" + text = self.complete( + prompt + "\n\nAntworte AUSSCHLIESSLICH mit gültigem JSON " + "(ohne Markdown-Codeblock, ohne erklärenden Text davor oder danach).", + image_path=image_path, + system=system, + ) + return extract_json(text) diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..9e4db65 --- /dev/null +++ b/src/models.py @@ -0,0 +1,94 @@ +"""Standardisierte Datenstrukturen für den eBay-Auto-Lister. + +BMAD-Prinzip: Agenten kommunizieren *ausschließlich* über diese typisierten +Verträge. Bewusst mit ``dataclasses`` aus der Standardbibliothek umgesetzt +(kein Pydantic), damit Spike und Agenten ohne ``pip install`` lauffähig sind. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field + + +@dataclass +class ItemAnalysis: + """Ergebnis des Bildanalyse-Agenten (Vision).""" + + title_guess: str + category: str + condition: str # menschenlesbar, z. B. "Gebraucht – sehr gut" + condition_score: float # 0.0 (defekt) .. 1.0 (neuwertig) + brand: str | None = None + features: list[str] = field(default_factory=list) + defects: list[str] = field(default_factory=list) + confidence: float = 0.0 # 0.0 .. 1.0, Sicherheit der Erkennung + source: str = "mock" # "claude-cli" oder "mock" + raw: str = "" # Rohausgabe des Modells (Debug) + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class PriceSuggestion: + """Ergebnis des Preis-Recherche-Agenten (Market).""" + + suggested_price: float + price_min: float + price_max: float + currency: str = "EUR" + sample_size: int = 0 # Anzahl ausgewerteter Vergleichsangebote + rationale: str = "" + source: str = "mock" + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class Listing: + """Ergebnis des Listing-Erstellung-Agenten.""" + + title: str # eBay-Limit: max. 80 Zeichen + description: str + category_id: str + item_specifics: dict[str, str] = field(default_factory=dict) + price: float = 0.0 + currency: str = "EUR" + source: str = "mock" + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class ChatReply: + """Ergebnis des Chat-Moderation-Agenten.""" + + question: str + answer: str + escalate: bool = False # True = an Menschen eskalieren + source: str = "mock" + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class ListingResult: + """Aggregiertes Endergebnis des Photo-to-Listing-Workflows.""" + + analysis: ItemAnalysis + price: PriceSuggestion + listing: Listing + + def to_dict(self) -> dict: + return { + "analysis": self.analysis.to_dict(), + "price": self.price.to_dict(), + "listing": self.listing.to_dict(), + } + + def to_json(self, indent: int = 2) -> str: + return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent) diff --git a/src/scrapers/.gitkeep b/src/scrapers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/scrapers/ebay_sold_scraper.py b/src/scrapers/ebay_sold_scraper.py new file mode 100644 index 0000000..b5e6cfc --- /dev/null +++ b/src/scrapers/ebay_sold_scraper.py @@ -0,0 +1,23 @@ +"""Stub für die Recherche verkaufter eBay-Artikel. + +WICHTIG / RECHTLICH: Automatisiertes Scraping von eBay unterliegt den +eBay-Nutzungsbedingungen. Eine produktive Umsetzung MUSS die offizielle +eBay-API (z. B. Marketplace Insights / Browse API) mit gültigem Developer-Key +nutzen, nicht das HTML-Scraping der Website. + +Dieser Stub ist absichtlich nicht implementiert; der Preis-Recherche-Agent +arbeitet stattdessen mit einer Heuristik (Mock) bzw. der Markteinschätzung des +Modells (Live). +""" + +from __future__ import annotations + + +class EbaySoldScraper: + """Platzhalter – noch nicht implementiert (siehe Rechtshinweis oben).""" + + def search_sold(self, query: str, limit: int = 20) -> list[dict]: + raise NotImplementedError( + "Verkaufte Listings bitte über die offizielle eBay-API beziehen " + "(Marketplace Insights API). HTML-Scraping ist nicht implementiert." + ) diff --git a/src/workflow.py b/src/workflow.py new file mode 100644 index 0000000..87ba83c --- /dev/null +++ b/src/workflow.py @@ -0,0 +1,31 @@ +"""Kern-Workflow des eBay-Auto-Listers. + +Dünne, bequeme Fassade über den :class:`~src.agents.orchestrator.Orchestrator`, +damit Aufrufer den Photo-to-Listing-Ablauf in einer Zeile auslösen können. +""" + +from __future__ import annotations + +from .agents.orchestrator import Orchestrator +from .llm import ClaudeClient +from .models import ListingResult + + +def photo_to_listing( + image_path: str | None = None, + description: str | None = None, + *, + mode: str = "auto", + client: ClaudeClient | None = None, +) -> ListingResult: + """Foto (oder Beschreibung) -> fertiges :class:`ListingResult`. + + Parameter + --------- + image_path: Pfad zu einem Produktfoto. + description: Alternative/zusätzliche textuelle Beschreibung. + mode: ``"auto"`` | ``"live"`` | ``"mock"`` (ignoriert, wenn ``client`` gesetzt). + client: optionaler vorkonfigurierter :class:`ClaudeClient`. + """ + orchestrator = Orchestrator(client or ClaudeClient(mode=mode)) + return orchestrator.photo_to_listing(image_path=image_path, description=description) diff --git a/tests/.gitkeep b/tests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_chat_moderation.py b/tests/test_chat_moderation.py new file mode 100644 index 0000000..1e9d9a9 --- /dev/null +++ b/tests/test_chat_moderation.py @@ -0,0 +1,42 @@ +"""Tests für den Chat-Moderation-Agenten (Mock-Pfad).""" + +import unittest + +from src.agents import ChatModerationAgent +from src.llm import ClaudeClient +from src.models import ChatReply, Listing + + +class ChatModerationTest(unittest.TestCase): + def setUp(self): + self.agent = ChatModerationAgent(ClaudeClient(mode="mock")) + self.listing = Listing( + title="Sony Kopfhörer", description="Guter Zustand.", + category_id="112529", price=102.0, + item_specifics={"Zustand": "Gebraucht – sehr gut"}, + ) + + def _ask(self, question: str) -> ChatReply: + return self.agent.run({"question": question, "listing": self.listing}) + + def test_shipping_question(self): + reply = self._ask("Wie hoch sind die Versandkosten?") + self.assertFalse(reply.escalate) + self.assertIn("Versand", reply.answer) + + def test_price_question_mentions_price(self): + reply = self._ask("Geht der Preis noch runter?") + self.assertIn("102", reply.answer) + + def test_complaint_escalates(self): + reply = self._ask("Der Artikel ist defekt, ich will mein Geld zurück!") + self.assertTrue(reply.escalate) + + def test_unknown_question_has_fallback(self): + reply = self._ask("Welche Farbe hat die Verpackung innen?") + self.assertFalse(reply.escalate) + self.assertTrue(reply.answer) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_claude_client.py b/tests/test_claude_client.py new file mode 100644 index 0000000..0edc600 --- /dev/null +++ b/tests/test_claude_client.py @@ -0,0 +1,36 @@ +"""Tests für den Claude-CLI-Adapter (ohne echten CLI-Aufruf).""" + +import unittest + +from src.llm import ClaudeClient, ClaudeUnavailable, extract_json + + +class ExtractJsonTest(unittest.TestCase): + def test_plain_object(self): + self.assertEqual(extract_json('{"a": 1}'), {"a": 1}) + + def test_object_in_prose(self): + text = 'Klar! Hier:\n```json\n{"a": 1, "b": [2, 3]}\n```\nViel Erfolg.' + self.assertEqual(extract_json(text), {"a": 1, "b": [2, 3]}) + + def test_brace_inside_string_is_ignored(self): + self.assertEqual(extract_json('{"s": "ein } Zeichen"}'), {"s": "ein } Zeichen"}) + + def test_array(self): + self.assertEqual(extract_json("Liste: [1, 2, 3]"), [1, 2, 3]) + + +class ClientModeTest(unittest.TestCase): + def test_forced_mock_mode(self): + client = ClaudeClient(mode="mock") + self.assertEqual(client.mode, "mock") + with self.assertRaises(ClaudeUnavailable): + client.complete("hallo") + + def test_invalid_mode_rejected(self): + with self.assertRaises(ValueError): + ClaudeClient(mode="quatsch") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_image_analysis.py b/tests/test_image_analysis.py new file mode 100644 index 0000000..2d9b31d --- /dev/null +++ b/tests/test_image_analysis.py @@ -0,0 +1,36 @@ +"""Tests für den Bildanalyse-Agenten (Mock-Pfad).""" + +import unittest + +from src.agents import ImageAnalysisAgent +from src.llm import ClaudeClient +from src.models import ItemAnalysis + + +class ImageAnalysisTest(unittest.TestCase): + def setUp(self): + self.agent = ImageAnalysisAgent(ClaudeClient(mode="mock")) + + def test_returns_item_analysis(self): + result = self.agent.run({"image_path": "fotos/sneaker_01.jpg"}) + self.assertIsInstance(result, ItemAnalysis) + self.assertEqual(result.source, "mock") + self.assertGreaterEqual(result.condition_score, 0.0) + self.assertLessEqual(result.condition_score, 1.0) + + def test_keyword_routing_from_filename(self): + result = self.agent.run({"image_path": "img/sneaker.png"}) + self.assertEqual(result.category, "Sneaker") + self.assertEqual(result.brand, "Nike") + + def test_keyword_routing_from_description(self): + result = self.agent.run({"description": "alte Spielkonsole mit Controller"}) + self.assertEqual(result.category, "Konsolen") + + def test_string_payload_accepted(self): + result = self.agent.run("uhr_vintage.jpg") + self.assertEqual(result.category, "Armbanduhren") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_listing.py b/tests/test_listing.py new file mode 100644 index 0000000..b7b0744 --- /dev/null +++ b/tests/test_listing.py @@ -0,0 +1,46 @@ +"""Tests für den Listing-Erstellung-Agenten (Mock-Pfad).""" + +import unittest + +from src.agents import ListingAgent +from src.llm import ClaudeClient +from src.models import ItemAnalysis, Listing, PriceSuggestion + + +class ListingTest(unittest.TestCase): + def setUp(self): + self.agent = ListingAgent(ClaudeClient(mode="mock")) + self.analysis = ItemAnalysis( + title_guess="Over-Ear Kopfhörer", category="Kopfhörer", + condition="Gebraucht – sehr gut", condition_score=0.85, + brand="Sony", features=["Bluetooth", "ANC"], + ) + self.price = PriceSuggestion( + suggested_price=102.0, price_min=87.0, price_max=117.0, + ) + + def test_returns_listing(self): + listing = self.agent.run((self.analysis, self.price)) + self.assertIsInstance(listing, Listing) + self.assertEqual(listing.price, 102.0) + + def test_title_respects_ebay_limit(self): + listing = self.agent.run((self.analysis, self.price)) + self.assertLessEqual(len(listing.title), 80) + self.assertIn("Sony", listing.title) + + def test_category_id_mapped(self): + listing = self.agent.run((self.analysis, self.price)) + self.assertEqual(listing.category_id, "112529") # Kopfhörer + + def test_item_specifics_present(self): + listing = self.agent.run((self.analysis, self.price)) + self.assertEqual(listing.item_specifics.get("Marke"), "Sony") + + def test_dict_payload(self): + listing = self.agent.run({"analysis": self.analysis, "price": self.price}) + self.assertIsInstance(listing, Listing) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..eb58b41 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,40 @@ +"""Tests für die Datenmodelle / Serialisierung.""" + +import json +import unittest + +from src.models import ( + ChatReply, + ItemAnalysis, + Listing, + ListingResult, + PriceSuggestion, +) + + +class ModelsTest(unittest.TestCase): + def _result(self) -> ListingResult: + return ListingResult( + analysis=ItemAnalysis( + title_guess="Kopfhörer", category="Kopfhörer", + condition="Gut", condition_score=0.8, + ), + price=PriceSuggestion(suggested_price=99.0, price_min=84.0, price_max=114.0), + listing=Listing(title="T", description="D", category_id="1", price=99.0), + ) + + def test_to_json_is_valid_and_roundtrips(self): + result = self._result() + parsed = json.loads(result.to_json()) + self.assertEqual(parsed["price"]["suggested_price"], 99.0) + self.assertIn("title_guess", parsed["analysis"]) + self.assertEqual(parsed["listing"]["title"], "T") + + def test_defaults(self): + reply = ChatReply(question="?", answer="!") + self.assertFalse(reply.escalate) + self.assertEqual(reply.source, "mock") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_price_research.py b/tests/test_price_research.py new file mode 100644 index 0000000..4f8134b --- /dev/null +++ b/tests/test_price_research.py @@ -0,0 +1,39 @@ +"""Tests für den Preis-Recherche-Agenten (Mock-Pfad).""" + +import unittest + +from src.agents import PriceResearchAgent +from src.llm import ClaudeClient +from src.models import ItemAnalysis, PriceSuggestion + + +def _item(category="Kopfhörer", score=1.0): + return ItemAnalysis( + title_guess="Test", category=category, + condition="Neu", condition_score=score, brand="Marke", + ) + + +class PriceResearchTest(unittest.TestCase): + def setUp(self): + self.agent = PriceResearchAgent(ClaudeClient(mode="mock")) + + def test_returns_price_suggestion(self): + price = self.agent.run(_item()) + self.assertIsInstance(price, PriceSuggestion) + self.assertEqual(price.currency, "EUR") + self.assertLess(price.price_min, price.price_max) + + def test_condition_scales_price(self): + good = self.agent.run(_item(score=1.0)).suggested_price + worn = self.agent.run(_item(score=0.0)).suggested_price + self.assertGreater(good, worn) + + def test_category_affects_base_price(self): + headphones = self.agent.run(_item(category="Kopfhörer")).suggested_price + unknown = self.agent.run(_item(category="Irgendwas")).suggested_price + self.assertGreater(headphones, unknown) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow.py b/tests/test_workflow.py new file mode 100644 index 0000000..7602c58 --- /dev/null +++ b/tests/test_workflow.py @@ -0,0 +1,38 @@ +"""End-to-End-Test des Photo-to-Listing-Workflows (Mock-Pfad).""" + +import unittest + +from src.agents.orchestrator import Orchestrator +from src.llm import ClaudeClient +from src.models import ListingResult +from src.workflow import photo_to_listing + + +class WorkflowTest(unittest.TestCase): + def test_orchestrator_end_to_end(self): + orch = Orchestrator(ClaudeClient(mode="mock")) + result = orch.photo_to_listing(description="gebrauchte Bluetooth-Kopfhörer") + self.assertIsInstance(result, ListingResult) + # Daten fließen sauber durch die Kette: + self.assertEqual(result.listing.price, result.price.suggested_price) + self.assertLessEqual(len(result.listing.title), 80) + self.assertTrue(result.listing.description) + + def test_workflow_facade(self): + result = photo_to_listing(description="Sneaker Gr. 43", mode="mock") + self.assertIsInstance(result, ListingResult) + + def test_requires_input(self): + orch = Orchestrator(ClaudeClient(mode="mock")) + with self.assertRaises(ValueError): + orch.photo_to_listing() + + def test_chat_via_orchestrator(self): + orch = Orchestrator(ClaudeClient(mode="mock")) + result = orch.photo_to_listing(description="Kopfhörer") + reply = orch.answer_question("Was kostet der Versand?", result.listing) + self.assertFalse(reply.escalate) + + +if __name__ == "__main__": + unittest.main()