196 lines
6.1 KiB
Python
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()
|