"""Project Knowledge Graph API.""" from __future__ import annotations from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from ..core.response import success_response from ..database import get_db from ..dependencies.auth import get_current_user from ..dependencies.tenant import get_tenant_id from ..models import User from ..services.auth import has_any_role from ..services.project_knowledge_graph import ProjectKnowledgeGraphService router = APIRouter(prefix="/ai-steward/knowledge-graph", tags=["AI Steward"]) class StructuredSyncRequest(BaseModel): limit: int = Field(default=1000, ge=1, le=5000) class CodeScanRequest(BaseModel): base_dir: Optional[str] = Field(default=None, max_length=400) class FeishuDeltaEnqueueRequest(BaseModel): limit: int = Field(default=500, ge=1, le=2000) class EventProcessRequest(BaseModel): max_batch: int = Field(default=200, ge=1, le=1000) max_retry: int = Field(default=3, ge=0, le=10) class IncrementalCycleRequest(BaseModel): delta_limit: int = Field(default=1000, ge=1, le=5000) event_batch: int = Field(default=200, ge=1, le=1000) code_scan_hours: int = Field(default=6, ge=1, le=168) code_scan_base_dir: Optional[str] = Field(default=None, max_length=400) def _require_manager(user: User) -> None: if not has_any_role(str(user.role or ""), ["BOSS", "ADMIN"]): raise HTTPException(status_code=403, detail="仅管理员可操作知识图谱同步") @router.get("/overview") async def get_kg_overview( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _ = current_user service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) data = await service.get_overview() return success_response(data) @router.get("/sync-runs") async def list_sync_runs( limit: int = Query(default=50, ge=1, le=200), run_type: Optional[str] = Query(default=None, max_length=40), status: Optional[str] = Query(default=None, max_length=20), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _require_manager(current_user) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) data = await service.list_sync_runs( limit=int(limit), run_type=run_type, status=status, ) return success_response(data) @router.get("/sync-slo") async def get_sync_slo( lookback_hours: int = Query(default=24, ge=1, le=24 * 30), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _require_manager(current_user) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) data = await service.get_sync_slo(lookback_hours=int(lookback_hours)) return success_response(data) @router.post("/sync/structured") async def sync_structured( body: StructuredSyncRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _require_manager(current_user) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) data = await service.sync_structured_entities(limit=int(body.limit)) return success_response(data) @router.post("/sync/codebase") async def sync_codebase( body: CodeScanRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _require_manager(current_user) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) root = Path("/app/backend").resolve() requested = Path(body.base_dir).resolve() if body.base_dir else root # 仅允许扫描 backend 根目录或其子目录 if root not in requested.parents and requested != root: raise HTTPException(status_code=400, detail="base_dir 仅允许 /app/backend 范围内路径") try: data = await service.sync_codebase_snapshot(base_dir=str(requested)) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return success_response(data) @router.post("/sync/feishu") async def enqueue_feishu_delta( body: FeishuDeltaEnqueueRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _require_manager(current_user) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) data = await service.enqueue_feishu_delta_events(limit=int(body.limit)) return success_response(data) @router.post("/events/process") async def process_ingest_events( body: EventProcessRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _require_manager(current_user) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) data = await service.process_pending_events( max_batch=int(body.max_batch), max_retry=int(body.max_retry), ) return success_response(data) @router.post("/sync/incremental") async def run_incremental_cycle( body: IncrementalCycleRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): _require_manager(current_user) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) root = Path("/app/backend").resolve() requested = Path(body.code_scan_base_dir).resolve() if body.code_scan_base_dir else None if requested is not None and root not in requested.parents and requested != root: raise HTTPException(status_code=400, detail="code_scan_base_dir 仅允许 /app/backend 范围内路径") data = await service.run_incremental_cycle( delta_limit=int(body.delta_limit), event_batch=int(body.event_batch), code_scan_hours=int(body.code_scan_hours), code_scan_base_dir=str(requested) if requested else None, run_source="manual_api", ) return success_response(data)