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

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