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