WP04c #2

Merged
Lars merged 5 commits from WP04c into main 2025-12-08 07:07:12 +01:00
5 changed files with 155 additions and 46 deletions
Showing only changes of commit caab41a760 - Show all commits

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}")