Compare commits

..

No commits in common. "ab60106fb92b1d2f9566b18d6d1e2cd556792e8f" and "78d8ab11eda05e88a1539b9fcc754e1b9a6a480d" have entirely different histories.

5 changed files with 46 additions and 155 deletions

View File

@ -1,43 +1,43 @@
""" """
app/main.py mindnet API bootstrap app/main.py mindnet API bootstrap (WP-04 Hooks)
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
# NEU: Feedback Router # Optional:
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.4.3") # Version bump app = FastAPI(title="mindnet API", version="0.1.0")
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,26 +4,28 @@ 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) und WP-04c (Feedback). Enthält Erweiterungen für WP-04b (Explanation Layer).
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.3.0 (Update für WP-04c Feedback) 0.2.0 (Update für WP-04b Explanation Layer)
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
@ -49,14 +51,12 @@ 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 - query: (optional) Freitext; Embedding wird später angebunden
- query_vector: (optional) direkter Vektor - query_vector: (optional) direkter Vektor (384d) für Quick-Tests ohne Embedding
- 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,29 +65,24 @@ 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 # Rohwerte für Transparenz
raw_semantic: float raw_semantic: float
raw_edge_bonus: float raw_edge_bonus: float
raw_centrality: float raw_centrality: float
@ -95,7 +90,10 @@ 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
@ -103,20 +101,24 @@ 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
# --- Response Models --- # --- End Explanation 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 # Flache Scores (Kompatibilität WP-04a)
semantic_score: float semantic_score: float
edge_bonus: float edge_bonus: float
centrality_bonus: float centrality_bonus: float
@ -125,23 +127,19 @@ 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 # WP-04b: Erklärungsobjekt (nur gefüllt, wenn explain=True)
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}.""" """Antwortstruktur für /graph/{note_id} (Nachbarschaft)."""
center_note_id: str center_note_id: str
nodes: List[NodeDTO] nodes: List[NodeDTO]
edges: List[EdgeDTO] edges: List[EdgeDTO]

View File

@ -1,20 +0,0 @@
"""
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,31 +15,24 @@ 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, BackgroundTasks from fastapi import APIRouter, HTTPException
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, background_tasks: BackgroundTasks) -> QueryResponse: def post_query(req: QueryRequest) -> QueryResponse:
try: try:
if req.mode == "semantic": if req.mode == "semantic":
res = semantic_retrieve(req) return semantic_retrieve(req)
else: # default: hybrid
res = hybrid_retrieve(req) return 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

@ -1,80 +0,0 @@
"""
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}")