Initial commit: InternalAuditInterprise
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""线索引擎模块:线索模型、生成、置信度分级、状态流转(人机闭环)。"""
|
||||
@@ -0,0 +1,136 @@
|
||||
"""线索 ORM 模型。
|
||||
|
||||
对应需求 R7(线索+证据链+解释)、R17(闭环状态)、R18(置信度分级)、R19(线索不可删)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, Enum, Float, ForeignKey, Index, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
def _enum_values(enum_cls):
|
||||
"""让 SQLAlchemy 使用枚举的 value(小写)写入 PG 原生 enum,而非 name。"""
|
||||
return [m.value for m in enum_cls]
|
||||
|
||||
|
||||
def _uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
def _now() -> dt.datetime:
|
||||
return dt.datetime.now(dt.UTC)
|
||||
|
||||
|
||||
class ConfidenceTier(str, enum.Enum):
|
||||
"""置信度三级分流(R18)。"""
|
||||
|
||||
HIGH = "high" # 高置信:直接推送处置
|
||||
MEDIUM = "medium" # 中置信:人工复核
|
||||
LOW = "low" # 低置信:归档备查
|
||||
|
||||
|
||||
class ClueStatus(str, enum.Enum):
|
||||
"""线索闭环状态机(R17)。"""
|
||||
|
||||
NEW = "new" # 新生成
|
||||
ASSIGNED = "assigned" # 已分派
|
||||
REVIEWING = "reviewing" # 研判中
|
||||
CONFIRMED = "confirmed" # 已定性属实
|
||||
DISMISSED = "dismissed" # 已定性误报
|
||||
RECTIFYING = "rectifying" # 整改中
|
||||
TRANSFERRED = "transferred" # 已移交
|
||||
CLOSED = "closed" # 已销项闭环
|
||||
|
||||
|
||||
class Clue(Base):
|
||||
"""审计线索。线索一经生成不可物理删除(R19),失效通过状态表达。"""
|
||||
|
||||
__tablename__ = "clue"
|
||||
__table_args__ = (
|
||||
Index("ix_clue_status", "status"),
|
||||
Index("ix_clue_scenario", "scenario_code"),
|
||||
Index("ix_clue_assignee", "assignee"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
risk_domain: Mapped[str] = mapped_column(String(32), nullable=False) # 收入/成本/采购/资金/合规
|
||||
scenario_code: Mapped[str] = mapped_column(String(32), nullable=False) # 如 R8/R9
|
||||
confidence: Mapped[ConfidenceTier] = mapped_column(
|
||||
Enum(ConfidenceTier, name="confidence_tier", values_callable=_enum_values),
|
||||
nullable=False,
|
||||
)
|
||||
score: Mapped[float] = mapped_column(Float, default=0.0) # 0-1 风险评分
|
||||
status: Mapped[ClueStatus] = mapped_column(
|
||||
Enum(ClueStatus, name="clue_status", values_callable=_enum_values),
|
||||
default=ClueStatus.NEW,
|
||||
nullable=False,
|
||||
)
|
||||
# 人话解释(判定理由)与证据链
|
||||
rationale: Mapped[str] = mapped_column(Text, default="")
|
||||
evidence: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
# 涉及的主体(金额、实体 id 列表等)
|
||||
subjects: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||
amount_involved: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
assignee: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# 误报/属实反馈(R18 反馈学习)
|
||||
feedback: Mapped[str | None] = mapped_column(String(16), nullable=True) # confirmed/false_positive
|
||||
|
||||
# 可追溯:产生该线索时的模型/规则/数据版本(R19 三重留痕)
|
||||
model_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
rule_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
data_version_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
history: Mapped[list[ClueStatusHistory]] = relationship(
|
||||
back_populates="clue", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ClueStatusHistory(Base):
|
||||
"""线索状态流转留痕(R17/R19)。"""
|
||||
|
||||
__tablename__ = "clue_status_history"
|
||||
__table_args__ = (Index("ix_csh_clue", "clue_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
clue_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("clue.id"), nullable=False
|
||||
)
|
||||
from_status: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
to_status: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
actor: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
clue: Mapped[Clue] = relationship(back_populates="history")
|
||||
|
||||
|
||||
class WorkingPaper(Base):
|
||||
"""审计底稿(R17):研判完成自动生成,可追溯。"""
|
||||
|
||||
__tablename__ = "working_paper"
|
||||
__table_args__ = (Index("ix_wp_clue", "clue_id"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid)
|
||||
clue_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("clue.id"), nullable=False
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, default="")
|
||||
conclusion: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
author: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
snapshot: Mapped[dict] = mapped_column(JSONB, default=dict) # 证据/版本快照
|
||||
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""线索服务:生成、置信度分级、状态流转、底稿生成、反馈。
|
||||
|
||||
对应 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()
|
||||
Reference in New Issue
Block a user