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:
parent
58408c5d49
commit
cdc3d3c4dc
28 changed files with 1658 additions and 2 deletions
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
180
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 <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
20
requirements.txt
Normal 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
25
spike/concept_spike.py
Normal 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
152
spike/prototype.py
Normal 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
27
src/__init__.py
Normal 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
32
src/agents/__init__.py
Normal 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
60
src/agents/base.py
Normal 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}>"
|
||||
90
src/agents/chat_moderation_agent.py
Normal file
90
src/agents/chat_moderation_agent.py
Normal 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 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")
|
||||
108
src/agents/image_analysis_agent.py
Normal file
108
src/agents/image_analysis_agent.py
Normal 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
130
src/agents/listing_agent.py
Normal 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
106
src/agents/orchestrator.py
Normal 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())
|
||||
102
src/agents/price_research_agent.py
Normal file
102
src/agents/price_research_agent.py
Normal 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
0
src/api/.gitkeep
Normal file
5
src/llm/__init__.py
Normal file
5
src/llm/__init__.py
Normal 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
165
src/llm/claude_client.py
Normal 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
94
src/models.py
Normal 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
0
src/scrapers/.gitkeep
Normal file
23
src/scrapers/ebay_sold_scraper.py
Normal file
23
src/scrapers/ebay_sold_scraper.py
Normal 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
31
src/workflow.py
Normal 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
0
tests/.gitkeep
Normal file
42
tests/test_chat_moderation.py
Normal file
42
tests/test_chat_moderation.py
Normal 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()
|
||||
36
tests/test_claude_client.py
Normal file
36
tests/test_claude_client.py
Normal 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()
|
||||
36
tests/test_image_analysis.py
Normal file
36
tests/test_image_analysis.py
Normal 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
46
tests/test_listing.py
Normal 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
40
tests/test_models.py
Normal 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()
|
||||
39
tests/test_price_research.py
Normal file
39
tests/test_price_research.py
Normal 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
38
tests/test_workflow.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue