diff --git a/app/main.py b/app/main.py index e395f89..4f974b9 100644 --- a/app/main.py +++ b/app/main.py @@ -1,43 +1,43 @@ """ -app/main.py — mindnet API bootstrap (WP-04 Hooks) -Version: 0.4.2 • Stand: 2025-10-07 +app/main.py — mindnet API bootstrap """ - from __future__ import annotations from fastapi import FastAPI from .config import get_settings from .routers.embed_router import router as embed_router from .routers.qdrant_router import router as qdrant_router -# WP-04 Router: from .routers.query import router as query_router from .routers.graph import router as graph_router from .routers.tools import router as tools_router -# Optional: +# NEU: Feedback Router +from .routers.feedback import router as feedback_router + try: from .routers.admin import router as admin_router except Exception: admin_router = None 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() @app.get("/healthz") def healthz(): return {"status": "ok", "qdrant": s.QDRANT_URL, "prefix": s.COLLECTION_PREFIX} - # Bestehende Router (unverändert) app.include_router(embed_router) app.include_router(qdrant_router) - # WP-04 Endpunkte app.include_router(query_router, prefix="/query", tags=["query"]) app.include_router(graph_router, prefix="/graph", tags=["graph"]) app.include_router(tools_router, prefix="/tools", tags=["tools"]) + # NEU: + app.include_router(feedback_router, prefix="/feedback", tags=["feedback"]) + if admin_router: app.include_router(admin_router, prefix="/admin", tags=["admin"]) return app -app = create_app() +app = create_app() \ No newline at end of file diff --git a/app/models/dto.py b/app/models/dto.py index 9efaf7f..a0e583c 100644 --- a/app/models/dto.py +++ b/app/models/dto.py @@ -4,28 +4,26 @@ app/models/dto.py — Pydantic-Modelle (DTOs) für WP-04 Endpunkte Zweck: Laufzeit-Modelle für FastAPI (Requests/Responses), getrennt von JSON-Schemas. 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: Python 3.12+, Pydantic 2.x, FastAPI 0.110+ Version: - 0.2.0 (Update für WP-04b Explanation Layer) + 0.3.0 (Update für WP-04c Feedback) Stand: 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 pydantic import BaseModel, Field 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"] +# --- Basis-DTOs --- + class NodeDTO(BaseModel): """Darstellung eines Knotens (Note oder Chunk) im API-Graph.""" id: str @@ -51,12 +49,14 @@ class EdgeDTO(BaseModel): direction: Literal["out", "in", "undirected"] = "out" +# --- Request Models --- + class QueryRequest(BaseModel): """ Request für /query: - mode: 'semantic' | 'edge' | 'hybrid' - - query: (optional) Freitext; Embedding wird später angebunden - - query_vector: (optional) direkter Vektor (384d) für Quick-Tests ohne Embedding + - query: (optional) Freitext + - query_vector: (optional) direkter Vektor - explain: (optional) Fordert detaillierte Erklärungen an (WP-04b) """ mode: Literal["semantic", "edge", "hybrid"] = "hybrid" @@ -65,24 +65,29 @@ class QueryRequest(BaseModel): top_k: int = 10 expand: Dict = {"depth": 1, "edge_types": ["references", "belongs_to", "prev", "next", "depends_on", "related_to"]} filters: Optional[Dict] = None - # Flags zur Steuerung der Rückgabe ret: Dict = {"with_paths": True, "with_notes": True, "with_chunks": True} - # WP-04b: Soll eine Erklärung generiert werden? 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 --- class ScoreBreakdown(BaseModel): - """ - Aufschlüsselung der Score-Komponenten. - Zeigt die gewichteten Beiträge zum Total Score. - """ + """Aufschlüsselung der Score-Komponenten.""" semantic_contribution: float = Field(..., description="W_sem * semantic_score * weight") edge_contribution: float = Field(..., description="W_edge * edge_bonus") centrality_contribution: float = Field(..., description="W_cent * centrality_bonus") - # Rohwerte für Transparenz + # Rohwerte raw_semantic: float raw_edge_bonus: float raw_centrality: float @@ -90,10 +95,7 @@ class ScoreBreakdown(BaseModel): class Reason(BaseModel): - """ - Ein semantischer Grund für das Ranking. - z.B. 'Verlinkt von Projekt X', 'Hohe Textähnlichkeit'. - """ + """Ein semantischer Grund für das Ranking.""" kind: Literal["semantic", "edge", "type", "centrality"] message: str score_impact: Optional[float] = None @@ -101,24 +103,20 @@ class Reason(BaseModel): class Explanation(BaseModel): - """ - Container für alle Erklärungsdaten eines Treffers. - """ + """Container für alle Erklärungsdaten eines Treffers.""" breakdown: ScoreBreakdown reasons: List[Reason] - # Optional: Pfade im Graphen, die zu diesem Treffer geführt haben related_edges: Optional[List[EdgeDTO]] = None -# --- End Explanation Models --- - +# --- Response Models --- class QueryHit(BaseModel): """Einzelnes Trefferobjekt für /query.""" node_id: str note_id: Optional[str] - # Flache Scores (Kompatibilität WP-04a) + # Flache Scores semantic_score: float edge_bonus: float centrality_bonus: float @@ -127,19 +125,23 @@ class QueryHit(BaseModel): paths: Optional[List[List[Dict]]] = None source: Optional[Dict] = None - # WP-04b: Erklärungsobjekt (nur gefüllt, wenn explain=True) + # WP-04b: Erklärungsobjekt explanation: Optional[Explanation] = None 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] used_mode: str latency_ms: int class GraphResponse(BaseModel): - """Antwortstruktur für /graph/{note_id} (Nachbarschaft).""" + """Antwortstruktur für /graph/{note_id}.""" center_note_id: str nodes: List[NodeDTO] edges: List[EdgeDTO] diff --git a/app/routers/feedback.py b/app/routers/feedback.py new file mode 100644 index 0000000..2ebaf4a --- /dev/null +++ b/app/routers/feedback.py @@ -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)) \ No newline at end of file diff --git a/app/routers/query.py b/app/routers/query.py index ca3e90e..4edb689 100644 --- a/app/routers/query.py +++ b/app/routers/query.py @@ -15,24 +15,31 @@ Bezug: Nutzung: app.include_router(query.router, prefix="/query", tags=["query"]) Änderungsverlauf: + 0.2.0 (2025-12-07) - Update für WP04c Feedback 0.1.0 (2025-10-07) – Erstanlage. """ - from __future__ import annotations -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, HTTPException, BackgroundTasks from app.models.dto import QueryRequest, QueryResponse from app.core.retriever import hybrid_retrieve, semantic_retrieve +# NEU: +from app.services.feedback_service import log_search router = APIRouter() @router.post("", response_model=QueryResponse) -def post_query(req: QueryRequest) -> QueryResponse: +def post_query(req: QueryRequest, background_tasks: BackgroundTasks) -> QueryResponse: try: if req.mode == "semantic": - return semantic_retrieve(req) - # default: hybrid - return hybrid_retrieve(req) + res = semantic_retrieve(req) + else: + 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: raise HTTPException(status_code=400, detail=str(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}") \ No newline at end of file diff --git a/app/services/feedback_service.py b/app/services/feedback_service.py new file mode 100644 index 0000000..6e945fe --- /dev/null +++ b/app/services/feedback_service.py @@ -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}") \ No newline at end of file