feat: 添加线索引擎、NLQ、场景检测、前端界面等核心功能模块

This commit is contained in:
freedakgmail
2026-06-16 08:15:15 +08:00
parent 7b1e2b10a8
commit 48340f6011
62 changed files with 6772 additions and 65 deletions
+86
View File
@@ -0,0 +1,86 @@
"""线索看板与处置 APIR7/R17/R18/R20)。
注意:不提供删除线索的端点(R19 线索不可删,独立性硬约束)。
"""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.api.schemas import (
AdjudicateRequest,
AssignRequest,
ClueOut,
DashboardSummary,
)
from app.clues import service as clue_svc
from app.clues.models import Clue, ClueStatus, ConfidenceTier
from app.db import get_session
router = APIRouter(prefix="/clues", tags=["clues"])
@router.get("", response_model=list[ClueOut])
def list_clues(
status: ClueStatus | None = Query(default=None),
scenario_code: str | None = Query(default=None),
confidence: ConfidenceTier | None = Query(default=None),
session: Session = Depends(get_session),
) -> list[Clue]:
return clue_svc.list_clues(
session, status=status, scenario_code=scenario_code, confidence=confidence
)
@router.get("/summary", response_model=DashboardSummary)
def summary(session: Session = Depends(get_session)) -> DashboardSummary:
"""运营看板汇总(R18/R21 的基础指标)。"""
clues = session.query(Clue).all()
by_status: dict[str, int] = {}
by_conf: dict[str, int] = {}
by_scenario: dict[str, int] = {}
total_amount = 0.0
for c in clues:
by_status[c.status.value] = by_status.get(c.status.value, 0) + 1
by_conf[c.confidence.value] = by_conf.get(c.confidence.value, 0) + 1
by_scenario[c.scenario_code] = by_scenario.get(c.scenario_code, 0) + 1
total_amount += c.amount_involved or 0.0
return DashboardSummary(
total=len(clues),
by_status=by_status,
by_confidence=by_conf,
by_scenario=by_scenario,
total_amount_involved=total_amount,
)
@router.get("/{clue_id}", response_model=ClueOut)
def get_clue(clue_id: uuid.UUID, session: Session = Depends(get_session)) -> Clue:
clue = session.get(Clue, clue_id)
if clue is None:
raise HTTPException(status_code=404, detail="线索不存在")
return clue
@router.post("/{clue_id}/assign", response_model=ClueOut)
def assign_clue(
clue_id: uuid.UUID, req: AssignRequest, session: Session = Depends(get_session)
) -> Clue:
clue = session.get(Clue, clue_id)
if clue is None:
raise HTTPException(status_code=404, detail="线索不存在")
return clue_svc.assign(session, clue, assignee=req.assignee, actor=req.actor)
@router.post("/{clue_id}/adjudicate", response_model=ClueOut)
def adjudicate_clue(
clue_id: uuid.UUID, req: AdjudicateRequest, session: Session = Depends(get_session)
) -> Clue:
clue = session.get(Clue, clue_id)
if clue is None:
raise HTTPException(status_code=404, detail="线索不存在")
clue_svc.adjudicate(session, clue, confirmed=req.confirmed, actor=req.actor, note=req.note)
return clue
+24
View File
@@ -0,0 +1,24 @@
"""自然语言查询 APIR4/R20)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.schemas import NLQRequest, NLQResponse
from app.db import get_session
from app.nlq import service as nlq
router = APIRouter(prefix="/nlq", tags=["nlq"])
@router.post("", response_model=NLQResponse)
def ask(req: NLQRequest, session: Session = Depends(get_session)) -> NLQResponse:
ans = nlq.ask(req.question, session=session)
return NLQResponse(
question=ans.question,
answer=ans.answer,
provider=ans.provider,
model=ans.model,
egress=ans.egress,
)
+49
View File
@@ -34,3 +34,52 @@ class PenetrateResponse(BaseModel):
max_depth: int
related_count: int
related: list[RelatedEntityOut]
class ClueOut(BaseModel):
id: uuid.UUID
title: str
risk_domain: str
scenario_code: str
confidence: str
score: float
status: str
rationale: str
evidence: dict = Field(default_factory=dict)
subjects: dict = Field(default_factory=dict)
amount_involved: float | None = None
assignee: str | None = None
feedback: str | None = None
model_config = {"from_attributes": True}
class AssignRequest(BaseModel):
assignee: str = Field(min_length=1)
actor: str = Field(min_length=1)
class AdjudicateRequest(BaseModel):
confirmed: bool
actor: str = Field(min_length=1)
note: str | None = None
class NLQRequest(BaseModel):
question: str = Field(min_length=1)
class NLQResponse(BaseModel):
question: str
answer: str
provider: str
model: str
egress: bool
class DashboardSummary(BaseModel):
total: int
by_status: dict[str, int]
by_confidence: dict[str, int]
by_scenario: dict[str, int]
total_amount_involved: float