"""线索服务:生成、置信度分级、状态流转、底稿生成、反馈。 对应 R7 / R17 / R18 / R19。所有状态变更写入历史并记自审计日志(线索不可删)。 """ from __future__ import annotations import uuid from sqlalchemy.orm import Session from app.audit import service as audit from app.clues.models import ( Clue, ClueStatus, ClueStatusHistory, ConfidenceTier, WorkingPaper, ) # 允许的状态流转(R17 闭环) _ALLOWED_TRANSITIONS: dict[ClueStatus, set[ClueStatus]] = { ClueStatus.NEW: {ClueStatus.ASSIGNED, ClueStatus.REVIEWING}, ClueStatus.ASSIGNED: {ClueStatus.REVIEWING}, ClueStatus.REVIEWING: {ClueStatus.CONFIRMED, ClueStatus.DISMISSED}, ClueStatus.CONFIRMED: {ClueStatus.RECTIFYING, ClueStatus.TRANSFERRED}, ClueStatus.DISMISSED: {ClueStatus.CLOSED}, ClueStatus.RECTIFYING: {ClueStatus.CLOSED}, ClueStatus.TRANSFERRED: {ClueStatus.CLOSED}, ClueStatus.CLOSED: set(), } class IllegalTransitionError(ValueError): """非法的线索状态流转。""" def score_to_tier(score: float) -> ConfidenceTier: """风险评分映射到置信度三级(R18)。""" if score >= 0.8: return ConfidenceTier.HIGH if score >= 0.5: return ConfidenceTier.MEDIUM return ConfidenceTier.LOW def create_clue( session: Session, *, title: str, risk_domain: str, scenario_code: str, score: float, rationale: str, evidence: dict, subjects: dict | None = None, amount_involved: float | None = None, model_version: str | None = None, rule_version: str | None = None, data_version_id: uuid.UUID | None = None, actor: str = "system", ) -> Clue: """生成一条线索,自动按评分分级,并记录创建留痕。""" clue = Clue( title=title, risk_domain=risk_domain, scenario_code=scenario_code, confidence=score_to_tier(score), score=score, status=ClueStatus.NEW, rationale=rationale, evidence=evidence, subjects=subjects or {}, amount_involved=amount_involved, model_version=model_version, rule_version=rule_version, data_version_id=data_version_id, ) session.add(clue) session.flush() _add_history(session, clue, None, ClueStatus.NEW, actor, "线索生成") audit.record( session, actor, "create_clue", target_type="clue", target_id=str(clue.id), detail={"scenario": scenario_code, "score": score, "confidence": clue.confidence.value}, ) return clue def _add_history( session: Session, clue: Clue, from_status: ClueStatus | None, to_status: ClueStatus, actor: str, note: str | None, ) -> None: session.add( ClueStatusHistory( clue_id=clue.id, from_status=from_status.value if from_status else None, to_status=to_status.value, actor=actor, note=note, ) ) session.flush() def transition( session: Session, clue: Clue, to_status: ClueStatus, actor: str, note: str | None = None ) -> Clue: """执行状态流转,校验合法性并留痕。""" if to_status not in _ALLOWED_TRANSITIONS.get(clue.status, set()): raise IllegalTransitionError( f"线索状态不能从 {clue.status.value} 流转到 {to_status.value}" ) from_status = clue.status clue.status = to_status session.flush() _add_history(session, clue, from_status, to_status, actor, note) audit.record( session, actor, "transition_clue", target_type="clue", target_id=str(clue.id), detail={"from": from_status.value, "to": to_status.value, "note": note}, ) return clue def assign(session: Session, clue: Clue, assignee: str, actor: str) -> Clue: clue.assignee = assignee session.flush() if clue.status == ClueStatus.NEW: transition(session, clue, ClueStatus.ASSIGNED, actor, f"分派给 {assignee}") audit.record( session, actor, "assign_clue", target_type="clue", target_id=str(clue.id), detail={"assignee": assignee}, ) return clue def adjudicate( session: Session, clue: Clue, confirmed: bool, actor: str, note: str | None = None ) -> WorkingPaper: """研判定性:确认属实或误报,自动生成审计底稿并记录反馈(R17/R18)。""" if clue.status not in (ClueStatus.ASSIGNED, ClueStatus.REVIEWING, ClueStatus.NEW): # 允许从 NEW/ASSIGNED 直接进入研判 pass if clue.status != ClueStatus.REVIEWING: # 先进入研判中 target = ClueStatus.REVIEWING if target in _ALLOWED_TRANSITIONS.get(clue.status, set()): transition(session, clue, ClueStatus.REVIEWING, actor, "进入研判") to = ClueStatus.CONFIRMED if confirmed else ClueStatus.DISMISSED transition(session, clue, to, actor, note) clue.feedback = "confirmed" if confirmed else "false_positive" session.flush() paper = WorkingPaper( clue_id=clue.id, content=note or "", conclusion=to.value, author=actor, snapshot={ "evidence": clue.evidence, "rationale": clue.rationale, "score": clue.score, "model_version": clue.model_version, "rule_version": clue.rule_version, "data_version_id": str(clue.data_version_id) if clue.data_version_id else None, }, ) session.add(paper) session.flush() audit.record( session, actor, "create_working_paper", target_type="working_paper", target_id=str(paper.id), detail={"clue_id": str(clue.id), "conclusion": to.value}, ) return paper def list_clues( session: Session, *, status: ClueStatus | None = None, scenario_code: str | None = None, confidence: ConfidenceTier | None = None, ) -> list[Clue]: q = session.query(Clue) if status: q = q.filter(Clue.status == status) if scenario_code: q = q.filter(Clue.scenario_code == scenario_code) if confidence: q = q.filter(Clue.confidence == confidence) return q.order_by(Clue.score.desc()).all()