feat: BMAD-Agenten, Kern-Workflow & lauffähiger Photo-to-Listing-Prototyp

- src/models.py: typisierte Verträge (dataclasses, Stdlib-only)
- src/llm/claude_client.py: Adapter um 'claude -p' mit Mock-Fallback
- src/agents/: BaseAgent + Vision, Market, Listing, Chat + Orchestrator
- src/workflow.py: photo_to_listing() Fassade
- spike/prototype.py + concept_spike.py: lauffähige End-to-End-Demo
- tests/: 28 unittest-Tests (Mock-Pfad, offline deterministisch)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Nora 2026-06-27 13:57:45 +00:00
parent 58408c5d49
commit cdc3d3c4dc
28 changed files with 1658 additions and 2 deletions

33
.gitignore vendored Normal file
View file

@ -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

180
README.md
View file

@ -1,2 +1,178 @@
# Testapp
Experimental Testapp for Nora &amp; 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 <repo-url>
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 <app-name>
# .env nach Änderung verschlüsseln
/opt/secrets/secrets.sh encrypt <app-name>
# .env aus verschlüsselter Datei wiederherstellen
/opt/secrets/secrets.sh decrypt <app-name>
```
**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.*

20
requirements.txt Normal file
View file

@ -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

25
spike/concept_spike.py Normal file
View file

@ -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"]))

152
spike/prototype.py Normal file
View file

@ -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())

27
src/__init__.py Normal file
View file

@ -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"

32
src/agents/__init__.py Normal file
View file

@ -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",
]

60
src/agents/base.py Normal file
View file

@ -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}>"

View file

@ -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 12 "
"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")

View file

@ -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",
)

130
src/agents/listing_agent.py Normal file
View file

@ -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

106
src/agents/orchestrator.py Normal file
View file

@ -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())

View file

@ -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",
)

0
src/api/.gitkeep Normal file
View file

5
src/llm/__init__.py Normal file
View file

@ -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"]

165
src/llm/claude_client.py Normal file
View file

@ -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)

94
src/models.py Normal file
View file

@ -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)

0
src/scrapers/.gitkeep Normal file
View file

View file

@ -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."
)

31
src/workflow.py Normal file
View file

@ -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)

0
tests/.gitkeep Normal file
View file

View file

@ -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()

View file

@ -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()

View file

@ -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()

46
tests/test_listing.py Normal file
View file

@ -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()

40
tests/test_models.py Normal file
View file

@ -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()

View file

@ -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()

38
tests/test_workflow.py Normal file
View file

@ -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()