WP04c #2

Merged
Lars merged 5 commits from WP04c into main 2025-12-08 07:07:12 +01:00
8 changed files with 272 additions and 68 deletions

View File

@ -0,0 +1,27 @@
# Architecture Snapshot v2.2.1
**Stand:** Nach Abschluss von WP-04c
**Kontext:** Wichtig für WP-05 Entwicklung.
## 1. Neue Komponenten (Seit v2.2.0)
### A. Feedback Service (`app/services/feedback_service.py`)
- **Zweck:** Logging von Trainingsdaten für späteres Self-Tuning (WP-08).
- **Storage:** Lokale JSONL-Dateien in `data/logs/` (Append-Only).
- `search_history.jsonl`: Query + Snapshot der Ergebnisse (Scores).
- `feedback.jsonl`: User-Rating zu spezifischer `node_id`.
### B. Explanation Layer (`app/core/retriever.py`)
- **Logik:** Der Retriever berechnet nicht nur Scores, sondern generiert `Explanation`-Objekte.
- **Graph:** `Subgraph` (in `graph_adapter.py`) führt jetzt auch `reverse_adj` (Incoming Edges), um zu erklären, warum ein Knoten wichtig ist ("Referenziert von...").
### C. DTOs (`app/models/dto.py`)
Das Datenmodell wurde massiv erweitert. Wichtige Klassen für WP-05:
- `QueryResponse`: Enthält jetzt `query_id` (UUID).
- `QueryHit`: Enthält optional `explanation` (Typ `Explanation`).
- `FeedbackRequest`: Für den Feedback-Loop.
## 2. Implikationen für WP-05 (Chat)
1. **Logging:** Auch der neue `/chat` Endpoint sollte idealerweise die `query_id` loggen oder nutzen, um Konsistenz zu wahren.
2. **DTO-Nutzung:** Der Chat-Service wird intern den Retriever aufrufen. Er muss mit den `QueryHit`-Objekten arbeiten, um den Kontext für das LLM zu bauen.
3. **Config:** Die Persönlichkeit wird in `config/prompts.yaml` definiert (Late Binding), nicht im Python-Code hardcodiert.

View File

@ -1,43 +1,43 @@
""" """
app/main.py mindnet API bootstrap (WP-04 Hooks) app/main.py mindnet API bootstrap
Version: 0.4.2 Stand: 2025-10-07
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI from fastapi import FastAPI
from .config import get_settings from .config import get_settings
from .routers.embed_router import router as embed_router from .routers.embed_router import router as embed_router
from .routers.qdrant_router import router as qdrant_router from .routers.qdrant_router import router as qdrant_router
# WP-04 Router:
from .routers.query import router as query_router from .routers.query import router as query_router
from .routers.graph import router as graph_router from .routers.graph import router as graph_router
from .routers.tools import router as tools_router from .routers.tools import router as tools_router
# Optional: # NEU: Feedback Router
from .routers.feedback import router as feedback_router
try: try:
from .routers.admin import router as admin_router from .routers.admin import router as admin_router
except Exception: except Exception:
admin_router = None admin_router = None
def create_app() -> FastAPI: def create_app() -> FastAPI:
app = FastAPI(title="mindnet API", version="0.1.0") app = FastAPI(title="mindnet API", version="0.4.3") # Version bump
s = get_settings() s = get_settings()
@app.get("/healthz") @app.get("/healthz")
def healthz(): def healthz():
return {"status": "ok", "qdrant": s.QDRANT_URL, "prefix": s.COLLECTION_PREFIX} return {"status": "ok", "qdrant": s.QDRANT_URL, "prefix": s.COLLECTION_PREFIX}
# Bestehende Router (unverändert)
app.include_router(embed_router) app.include_router(embed_router)
app.include_router(qdrant_router) app.include_router(qdrant_router)
# WP-04 Endpunkte
app.include_router(query_router, prefix="/query", tags=["query"]) app.include_router(query_router, prefix="/query", tags=["query"])
app.include_router(graph_router, prefix="/graph", tags=["graph"]) app.include_router(graph_router, prefix="/graph", tags=["graph"])
app.include_router(tools_router, prefix="/tools", tags=["tools"]) app.include_router(tools_router, prefix="/tools", tags=["tools"])
# NEU:
app.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
if admin_router: if admin_router:
app.include_router(admin_router, prefix="/admin", tags=["admin"]) app.include_router(admin_router, prefix="/admin", tags=["admin"])
return app return app
app = create_app() app = create_app()

View File

@ -4,28 +4,26 @@ app/models/dto.py — Pydantic-Modelle (DTOs) für WP-04 Endpunkte
Zweck: Zweck:
Laufzeit-Modelle für FastAPI (Requests/Responses), getrennt von JSON-Schemas. Laufzeit-Modelle für FastAPI (Requests/Responses), getrennt von JSON-Schemas.
Deckt die Graph-/Retriever-Endpunkte ab. Deckt die Graph-/Retriever-Endpunkte ab.
Enthält Erweiterungen für WP-04b (Explanation Layer). Enthält Erweiterungen für WP-04b (Explanation Layer) und WP-04c (Feedback).
Kompatibilität: Kompatibilität:
Python 3.12+, Pydantic 2.x, FastAPI 0.110+ Python 3.12+, Pydantic 2.x, FastAPI 0.110+
Version: Version:
0.2.0 (Update für WP-04b Explanation Layer) 0.3.0 (Update für WP-04c Feedback)
Stand: Stand:
2025-12-07 2025-12-07
Bezug:
- schemas/*.json (Speicherschema für Notes/Chunks/Edges)
- WP-04 API-Design (Query- und Graph-Endpunkte)
Nutzung:
from app.models.dto import QueryRequest, QueryResponse, GraphResponse
""" """
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Literal, Optional, Dict, Any from typing import List, Literal, Optional, Dict, Any
import uuid
EdgeKind = Literal["references", "references_at", "backlink", "next", "prev", "belongs_to", "depends_on", "related_to", "similar_to"] EdgeKind = Literal["references", "references_at", "backlink", "next", "prev", "belongs_to", "depends_on", "related_to", "similar_to"]
# --- Basis-DTOs ---
class NodeDTO(BaseModel): class NodeDTO(BaseModel):
"""Darstellung eines Knotens (Note oder Chunk) im API-Graph.""" """Darstellung eines Knotens (Note oder Chunk) im API-Graph."""
id: str id: str
@ -51,12 +49,14 @@ class EdgeDTO(BaseModel):
direction: Literal["out", "in", "undirected"] = "out" direction: Literal["out", "in", "undirected"] = "out"
# --- Request Models ---
class QueryRequest(BaseModel): class QueryRequest(BaseModel):
""" """
Request für /query: Request für /query:
- mode: 'semantic' | 'edge' | 'hybrid' - mode: 'semantic' | 'edge' | 'hybrid'
- query: (optional) Freitext; Embedding wird später angebunden - query: (optional) Freitext
- query_vector: (optional) direkter Vektor (384d) für Quick-Tests ohne Embedding - query_vector: (optional) direkter Vektor
- explain: (optional) Fordert detaillierte Erklärungen an (WP-04b) - explain: (optional) Fordert detaillierte Erklärungen an (WP-04b)
""" """
mode: Literal["semantic", "edge", "hybrid"] = "hybrid" mode: Literal["semantic", "edge", "hybrid"] = "hybrid"
@ -65,24 +65,29 @@ class QueryRequest(BaseModel):
top_k: int = 10 top_k: int = 10
expand: Dict = {"depth": 1, "edge_types": ["references", "belongs_to", "prev", "next", "depends_on", "related_to"]} expand: Dict = {"depth": 1, "edge_types": ["references", "belongs_to", "prev", "next", "depends_on", "related_to"]}
filters: Optional[Dict] = None filters: Optional[Dict] = None
# Flags zur Steuerung der Rückgabe
ret: Dict = {"with_paths": True, "with_notes": True, "with_chunks": True} ret: Dict = {"with_paths": True, "with_notes": True, "with_chunks": True}
# WP-04b: Soll eine Erklärung generiert werden?
explain: bool = False explain: bool = False
class FeedbackRequest(BaseModel):
"""
User-Feedback zu einem spezifischen Treffer (WP-04c).
"""
query_id: str = Field(..., description="ID der ursprünglichen Suche")
node_id: str = Field(..., description="ID des bewerteten Treffers")
score: int = Field(..., ge=0, le=1, description="1 (Positiv) oder 0 (Negativ/Irrelevant)")
comment: Optional[str] = None
# --- WP-04b Explanation Models --- # --- WP-04b Explanation Models ---
class ScoreBreakdown(BaseModel): class ScoreBreakdown(BaseModel):
""" """Aufschlüsselung der Score-Komponenten."""
Aufschlüsselung der Score-Komponenten.
Zeigt die gewichteten Beiträge zum Total Score.
"""
semantic_contribution: float = Field(..., description="W_sem * semantic_score * weight") semantic_contribution: float = Field(..., description="W_sem * semantic_score * weight")
edge_contribution: float = Field(..., description="W_edge * edge_bonus") edge_contribution: float = Field(..., description="W_edge * edge_bonus")
centrality_contribution: float = Field(..., description="W_cent * centrality_bonus") centrality_contribution: float = Field(..., description="W_cent * centrality_bonus")
# Rohwerte für Transparenz # Rohwerte
raw_semantic: float raw_semantic: float
raw_edge_bonus: float raw_edge_bonus: float
raw_centrality: float raw_centrality: float
@ -90,10 +95,7 @@ class ScoreBreakdown(BaseModel):
class Reason(BaseModel): class Reason(BaseModel):
""" """Ein semantischer Grund für das Ranking."""
Ein semantischer Grund für das Ranking.
z.B. 'Verlinkt von Projekt X', 'Hohe Textähnlichkeit'.
"""
kind: Literal["semantic", "edge", "type", "centrality"] kind: Literal["semantic", "edge", "type", "centrality"]
message: str message: str
score_impact: Optional[float] = None score_impact: Optional[float] = None
@ -101,24 +103,20 @@ class Reason(BaseModel):
class Explanation(BaseModel): class Explanation(BaseModel):
""" """Container für alle Erklärungsdaten eines Treffers."""
Container für alle Erklärungsdaten eines Treffers.
"""
breakdown: ScoreBreakdown breakdown: ScoreBreakdown
reasons: List[Reason] reasons: List[Reason]
# Optional: Pfade im Graphen, die zu diesem Treffer geführt haben
related_edges: Optional[List[EdgeDTO]] = None related_edges: Optional[List[EdgeDTO]] = None
# --- End Explanation Models --- # --- Response Models ---
class QueryHit(BaseModel): class QueryHit(BaseModel):
"""Einzelnes Trefferobjekt für /query.""" """Einzelnes Trefferobjekt für /query."""
node_id: str node_id: str
note_id: Optional[str] note_id: Optional[str]
# Flache Scores (Kompatibilität WP-04a) # Flache Scores
semantic_score: float semantic_score: float
edge_bonus: float edge_bonus: float
centrality_bonus: float centrality_bonus: float
@ -127,19 +125,23 @@ class QueryHit(BaseModel):
paths: Optional[List[List[Dict]]] = None paths: Optional[List[List[Dict]]] = None
source: Optional[Dict] = None source: Optional[Dict] = None
# WP-04b: Erklärungsobjekt (nur gefüllt, wenn explain=True) # WP-04b: Erklärungsobjekt
explanation: Optional[Explanation] = None explanation: Optional[Explanation] = None
class QueryResponse(BaseModel): class QueryResponse(BaseModel):
"""Antwortstruktur für /query (Liste von Treffern + Telemetrie).""" """
Antwortstruktur für /query (Liste von Treffern + Telemetrie).
Enthält query_id für Traceability (WP-04c).
"""
query_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
results: List[QueryHit] results: List[QueryHit]
used_mode: str used_mode: str
latency_ms: int latency_ms: int
class GraphResponse(BaseModel): class GraphResponse(BaseModel):
"""Antwortstruktur für /graph/{note_id} (Nachbarschaft).""" """Antwortstruktur für /graph/{note_id}."""
center_note_id: str center_note_id: str
nodes: List[NodeDTO] nodes: List[NodeDTO]
edges: List[EdgeDTO] edges: List[EdgeDTO]

20
app/routers/feedback.py Normal file
View File

@ -0,0 +1,20 @@
"""
app/routers/feedback.py
Endpunkt für User-Feedback (WP-04c).
"""
from fastapi import APIRouter, HTTPException
from app.models.dto import FeedbackRequest
from app.services.feedback_service import log_feedback
router = APIRouter()
@router.post("", status_code=201)
def post_feedback(fb: FeedbackRequest):
"""
Nimmt Feedback entgegen (z.B. Daumen hoch für einen Treffer).
"""
try:
log_feedback(fb)
return {"status": "recorded", "query_id": fb.query_id}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@ -15,24 +15,31 @@ Bezug:
Nutzung: Nutzung:
app.include_router(query.router, prefix="/query", tags=["query"]) app.include_router(query.router, prefix="/query", tags=["query"])
Änderungsverlauf: Änderungsverlauf:
0.2.0 (2025-12-07) - Update für WP04c Feedback
0.1.0 (2025-10-07) Erstanlage. 0.1.0 (2025-10-07) Erstanlage.
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, BackgroundTasks
from app.models.dto import QueryRequest, QueryResponse from app.models.dto import QueryRequest, QueryResponse
from app.core.retriever import hybrid_retrieve, semantic_retrieve from app.core.retriever import hybrid_retrieve, semantic_retrieve
# NEU:
from app.services.feedback_service import log_search
router = APIRouter() router = APIRouter()
@router.post("", response_model=QueryResponse) @router.post("", response_model=QueryResponse)
def post_query(req: QueryRequest) -> QueryResponse: def post_query(req: QueryRequest, background_tasks: BackgroundTasks) -> QueryResponse:
try: try:
if req.mode == "semantic": if req.mode == "semantic":
return semantic_retrieve(req) res = semantic_retrieve(req)
# default: hybrid else:
return hybrid_retrieve(req) res = hybrid_retrieve(req)
# WP-04c: Logging im Hintergrund (bremst Antwort nicht)
background_tasks.add_task(log_search, req, res)
return res
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"query failed: {e}") raise HTTPException(status_code=500, detail=f"query failed: {e}")

View File

@ -0,0 +1,80 @@
"""
app/services/feedback_service.py
Service zum Loggen von Suchanfragen und Feedback (WP-04c).
Speichert Daten als JSONL für späteres Self-Tuning (WP-08).
"""
import json
import os
import time
from pathlib import Path
from typing import Dict, Any, List
from app.models.dto import QueryRequest, QueryResponse, FeedbackRequest
# Pfad für Logs (lokal auf dem Beelink/PC)
LOG_DIR = Path("data/logs")
SEARCH_LOG_FILE = LOG_DIR / "search_history.jsonl"
FEEDBACK_LOG_FILE = LOG_DIR / "feedback.jsonl"
def _ensure_log_dir():
if not LOG_DIR.exists():
os.makedirs(LOG_DIR, exist_ok=True)
def log_search(req: QueryRequest, res: QueryResponse):
"""
Speichert den "Snapshot" der Suche.
WICHTIG: Wir speichern die Scores (Breakdown), damit wir später wissen,
warum das System so entschieden hat.
"""
_ensure_log_dir()
# Wir reduzieren die Datenmenge etwas (z.B. keine vollen Texte)
hits_summary = []
for hit in res.results:
# Falls Explanation an war, speichern wir den Breakdown, sonst die Scores
breakdown = None
if hit.explanation and hit.explanation.breakdown:
breakdown = hit.explanation.breakdown.model_dump()
hits_summary.append({
"node_id": hit.node_id,
"note_id": hit.note_id,
"total_score": hit.total_score,
"breakdown": breakdown, # Wichtig für Training!
"rank_semantic": hit.semantic_score,
"rank_edge": hit.edge_bonus
})
entry = {
"timestamp": time.time(),
"query_id": res.query_id,
"query_text": req.query,
"mode": req.mode,
"top_k": req.top_k,
"hits": hits_summary
}
try:
with open(SEARCH_LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception as e:
print(f"ERROR logging search: {e}")
def log_feedback(fb: FeedbackRequest):
"""
Speichert das User-Feedback.
"""
_ensure_log_dir()
entry = {
"timestamp": time.time(),
"query_id": fb.query_id,
"node_id": fb.node_id,
"score": fb.score,
"comment": fb.comment
}
try:
with open(FEEDBACK_LOG_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception as e:
print(f"ERROR logging feedback: {e}")

View File

@ -1,6 +1,6 @@
# Mindnet v2.2 Entwickler-Workflow # Mindnet v2.2 Entwickler-Workflow
**Datei:** `DEV_WORKFLOW.md` **Datei:** `DEV_WORKFLOW.md`
**Stand:** 2025-12-07 (Aktualisiert nach WP-04b) **Stand:** 2025-12-07 (Aktualisiert: Sync-First Strategie)
Dieses Handbuch beschreibt den Entwicklungszyklus zwischen **Windows PC** (IDE), **Raspberry Pi** (Gitea) und **Beelink** (Runtime/Server). Dieses Handbuch beschreibt den Entwicklungszyklus zwischen **Windows PC** (IDE), **Raspberry Pi** (Gitea) und **Beelink** (Runtime/Server).
@ -22,12 +22,14 @@ Dieses Handbuch beschreibt den Entwicklungszyklus zwischen **Windows PC** (IDE),
Hier erstellst du die neue Funktion in einer sicheren Umgebung. Hier erstellst du die neue Funktion in einer sicheren Umgebung.
1. **Sicherstellen, dass Git bereit ist:** 1. **Basis aktualisieren (WICHTIG!):**
* Öffne VS Code. Bevor du startest, muss dein lokales `main` auf dem Stand des Servers sein.
* Unten links in der blauen Leiste sollte der aktuelle Branch stehen (z.B. `main`). * Klicke unten links auf den aktuellen Branch und wähle **`main`**.
* Klicke links im Menü "Source Control" auf die **drei Punkte (...)** -> **Pull** (oder das Synchronisieren-Symbol).
* *Erst jetzt hast du alle Dateien!*
2. **Branch erstellen (WICHTIG):** 2. **Branch erstellen:**
* Klicke unten links auf den Branch-Namen. * Klicke wieder unten links auf `main`.
* Wähle `+ Create new branch...`. * Wähle `+ Create new branch...`.
* Gib den Namen ein: `feature/was-ich-tue` (z.B. `feature/wp04b-explanation`). * Gib den Namen ein: `feature/was-ich-tue` (z.B. `feature/wp04b-explanation`).
* Drücke **Enter**. * Drücke **Enter**.
@ -69,9 +71,9 @@ Hier prüfst du, ob dein neuer Code auf dem echten Server läuft.
``` ```
5. **Test-Server neustarten (WICHTIG):** 5. **Test-Server neustarten (WICHTIG):**
Falls noch ein alter Prozess läuft, musst du ihn beenden, damit der neue Code geladen wird. Falls noch ein alter Prozess läuft, musst du ihn beenden.
* Drücke `Strg + C` falls der Server noch im Vordergrund läuft. * Drücke `Strg + C` falls der Server noch im Vordergrund läuft.
* Oder nutze `pkill -f "uvicorn app.main:app"` um Hintergrunde-Prozesse zu stoppen. * Oder nutze `pkill -f "uvicorn app.main:app"`.
Starten auf **Port 8002**: Starten auf **Port 8002**:
```bash ```bash
@ -127,6 +129,7 @@ Damit das Chaos nicht wächst, löschen wir den fertigen Branch.
``` ```
3. **VS Code:** 3. **VS Code:**
* Auf `main` wechseln. * Auf `main` wechseln.
* Sync drücken (um Löschung vom Server zu erfahren).
* `F1` -> `Git: Delete Branch` -> Branch auswählen. * `F1` -> `Git: Delete Branch` -> Branch auswählen.
--- ---
@ -135,23 +138,23 @@ Damit das Chaos nicht wächst, löschen wir den fertigen Branch.
| Wo? | Befehl | Was tut es? | | Wo? | Befehl | Was tut es? |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| **VS Code** | `Klick auf Branch-Namen` | Branch erstellen oder wechseln. | | **VS Code** | `Sync (auf main)` | **WICHTIG:** Holt neuesten Code vom Server. |
| **VS Code** | `Sync Changes` | Lädt Code zu Gitea hoch. | | **VS Code** | `Klick auf Branch` | Branch erstellen oder wechseln. |
| **Beelink** | `cd ~/mindnet_dev` | Gehe in den Test-Ordner. |
| **Beelink** | `source .venv/bin/activate` | **Aktiviert Python-Umgebung.** | | **Beelink** | `source .venv/bin/activate` | **Aktiviert Python-Umgebung.** |
| **Beelink** | `git fetch` | Aktualisiert die Liste der Remote-Branches. | | **Beelink** | `git fetch` | Aktualisiert Remote-Branches. |
| **Beelink** | `git branch -r` | Zeigt alle Branches auf dem Server an. | | **Beelink** | `git checkout <name>` | Wechsle Branch. |
| **Beelink** | `git checkout <name>` | Wechsle auf einen anderen Branch. | | **Beelink** | `git pull` | Aktualisiere aktuellen Branch. |
| **Beelink** | `uvicorn ... --port 8002` | Startet Test-Server (Dev). | | **Beelink** | `uvicorn ... --port 8002` | Startet Test-Server (Dev). |
--- ---
## 4. Sicherheitsregeln ## 4. Troubleshooting
1. **Niemals** in `~/mindnet` (Prod-Ordner) experimentieren. Dieser Ordner bleibt immer auf `main`. **"Hilfe, in meinem neuen Branch fehlen Dateien!"**
2. **Niemals** beide Umgebungen auf denselben `COLLECTION_PREFIX` zeigen lassen. Das passiert, wenn du beim Erstellen nicht aktuell warst.
* Prod `.env`: `COLLECTION_PREFIX="mindnet"` * **Lösung:**
* Dev `.env`: `COLLECTION_PREFIX="mindnet_dev"` ```bash
3. **Port-Disziplin:** # In VS Code Terminal:
* **PROD:** Port 8001 git checkout feature/mein-kaputter-branch
* **DEV:** Port 8002 git merge main
# (Das holt die fehlenden Dateien aus main nach)

View File

@ -0,0 +1,65 @@
import requests
import json
import sys
import time
# Konfiguration
BASE_URL = "http://localhost:8002"
QUERY_URL = f"{BASE_URL}/query"
FEEDBACK_URL = f"{BASE_URL}/feedback"
def run_test():
print(f"--- 1. Sende Suchanfrage an {QUERY_URL} ---")
query_payload = {
"query": "mindnet architecture",
"mode": "hybrid",
"top_k": 2,
"explain": True # Wir wollen auch Breakdown loggen
}
try:
r = requests.post(QUERY_URL, json=query_payload)
r.raise_for_status()
res_data = r.json()
except Exception as e:
print(f"ERROR bei Suche: {e}")
sys.exit(1)
query_id = res_data.get("query_id")
results = res_data.get("results", [])
if not query_id:
print("FAIL: Keine query_id in der Antwort erhalten!")
sys.exit(1)
if not results:
print("WARNUNG: Keine Treffer gefunden, Test kann Feedback nur simuliert senden.")
target_node_id = "fake-node-id"
else:
target_node_id = results[0].get("node_id")
print(f"SUCCESS: Suche OK. Erhaltene Query-ID: {query_id}")
print(f"Target Node für Feedback: {target_node_id}")
# Kurze Pause, damit Background-Task Zeit hat zu schreiben (nur für Log-Check relevant)
time.sleep(0.5)
print(f"\n--- 2. Sende Feedback an {FEEDBACK_URL} ---")
feedback_payload = {
"query_id": query_id,
"node_id": target_node_id,
"score": 1,
"comment": "Automatisierter Smoke-Test: Dieser Treffer war hilfreich."
}
try:
r_fb = requests.post(FEEDBACK_URL, json=feedback_payload)
r_fb.raise_for_status()
print(f"SUCCESS: Feedback gesendet. Status: {r_fb.status_code}")
print(f"Response: {r_fb.json()}")
except Exception as e:
print(f"ERROR bei Feedback: {e}")
sys.exit(1)
if __name__ == "__main__":
run_test()