Files
InternalAuditInterprise/backend/app/clues/service.py
T
2026-06-16 00:38:57 +08:00

196 lines
6.1 KiB
Python

"""线索服务:生成、置信度分级、状态流转、底稿生成、反馈。
对应 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()