Files
fund-tracer/backend/app/services/assessment_service.py

175 lines
6.8 KiB
Python
Raw Normal View History

2026-03-11 16:28:04 +08:00
"""Fraud amount assessment and inquiry suggestion generation."""
import logging
from uuid import UUID
import httpx
2026-03-12 19:05:48 +08:00
from sqlalchemy import delete, select
2026-03-11 16:28:04 +08:00
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.transaction import TransactionRecord
from app.models.assessment import FraudAssessment, ConfidenceLevel, ReviewStatus
from app.rules.assessment_rules import classify_transaction
logger = logging.getLogger(__name__)
async def assess_case(case_id: UUID, db: AsyncSession) -> list[FraudAssessment]:
2026-03-17 23:43:19 +08:00
"""Run rule-based assessment and replace old results."""
2026-03-12 19:05:48 +08:00
# Replace mode: rerun analysis for the same case should overwrite prior assessments
# instead of appending duplicated rows.
await db.execute(
delete(FraudAssessment).where(FraudAssessment.case_id == case_id)
)
await db.flush()
2026-03-11 16:28:04 +08:00
result = await db.execute(
select(TransactionRecord)
.where(TransactionRecord.case_id == case_id)
.order_by(TransactionRecord.trade_time.asc())
)
transactions = list(result.scalars().all())
assessments: list[FraudAssessment] = []
for tx in transactions:
2026-03-17 23:43:19 +08:00
if tx.is_duplicate:
level = ConfidenceLevel.low
reason = "该笔交易在交易归并中已标记为重复记录,属于同一笔交易的重复展示。"
exclude_reason = "交易归并已判定重复(订单号一致或人工标记为重复),不重复计入被骗金额。"
review_status = ReviewStatus.rejected
assessed_amount = 0
elif tx.is_transit:
level = ConfidenceLevel.low
reason = (
f"该笔交易在交易归并中已标记为中转({tx.source_app.value} -> {tx.counterparty_name}"
"属于本人账户间资金流转。"
)
exclude_reason = "交易归并已判定中转(本人账户间互转),不直接计入被骗金额。"
review_status = ReviewStatus.rejected
assessed_amount = 0
else:
level, reason, exclude_reason = classify_transaction(tx)
review_status = ReviewStatus.pending
assessed_amount = float(tx.amount) if level != ConfidenceLevel.low else 0
2026-03-11 16:28:04 +08:00
fa = FraudAssessment(
case_id=case_id,
transaction_id=tx.id,
confidence_level=level,
2026-03-17 23:43:19 +08:00
assessed_amount=assessed_amount,
2026-03-11 16:28:04 +08:00
reason=reason,
exclude_reason=exclude_reason,
2026-03-17 23:43:19 +08:00
review_status=review_status,
2026-03-11 16:28:04 +08:00
)
db.add(fa)
assessments.append(fa)
await db.flush()
# try to enhance reasons via LLM
if settings.LLM_API_KEY and settings.LLM_API_URL:
for fa in assessments:
try:
enhanced = await _enhance_reason_via_llm(fa, transactions)
if enhanced:
fa.reason = enhanced
except Exception as e:
logger.debug("LLM reason enhancement skipped: %s", e)
await db.flush()
return assessments
async def generate_inquiry_suggestions(case_id: UUID, db: AsyncSession) -> list[str]:
"""Generate interview / inquiry suggestions based on assessment results."""
result = await db.execute(
select(FraudAssessment)
.where(FraudAssessment.case_id == case_id)
.order_by(FraudAssessment.created_at.asc())
)
assessments = list(result.scalars().all())
if not assessments:
return ["暂无分析结果,请先执行案件分析。"]
# try LLM generation
if settings.LLM_API_KEY and settings.LLM_API_URL:
try:
return await _generate_suggestions_via_llm(assessments)
except Exception as e:
logger.debug("LLM suggestions skipped: %s", e)
return _generate_suggestions_rule_based(assessments)
def _generate_suggestions_rule_based(assessments: list[FraudAssessment]) -> list[str]:
suggestions: list[str] = []
pending = [a for a in assessments if a.review_status == ReviewStatus.pending]
medium = [a for a in assessments if a.confidence_level == ConfidenceLevel.medium]
if pending:
suggestions.append(
f"{len(pending)} 笔交易尚未确认,建议逐笔向受害人核实是否受到诱导操作。"
)
if medium:
suggestions.append(
"部分交易置信度为中等,建议追问受害人交易的具体背景和对方的诱导话术。"
)
suggestions.append("是否还有其他未截图的转账记录或 APP 需要补充?")
suggestions.append("涉案金额中是否有已部分追回或返还的款项?")
suggestions.append(
"除了截图所示的 APP 外是否还存在银行柜台、ATM、其他支付平台等转账渠道"
)
return suggestions
async def _enhance_reason_via_llm(fa: FraudAssessment, all_tx: list) -> str | None:
prompt = (
f"这笔交易金额{fa.assessed_amount}元,置信等级{fa.confidence_level.value}"
f"原始认定理由:{fa.reason}"
"请用简洁中文优化认定理由表述,使之适合出现在办案文书中。只返回优化后的理由文字。"
)
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
settings.LLM_API_URL,
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"},
json={
"model": settings.LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 300,
},
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"].strip()
async def _generate_suggestions_via_llm(assessments: list[FraudAssessment]) -> list[str]:
summary_lines = []
for a in assessments:
summary_lines.append(
f"- 金额{a.assessed_amount}元, 置信{a.confidence_level.value}, "
f"状态{a.review_status.value}, 理由: {a.reason[:60]}"
)
summary = "\n".join(summary_lines)
prompt = (
"你是一名反诈案件办案助手。以下是某诈骗案件的交易认定摘要:\n"
f"{summary}\n\n"
"请生成5条笔录辅助问询建议帮助民警追问受害人以完善证据链。"
"只返回JSON数组格式的5个字符串。"
)
import json
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(
settings.LLM_API_URL,
headers={"Authorization": f"Bearer {settings.LLM_API_KEY}"},
json={
"model": settings.LLM_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 600,
},
)
resp.raise_for_status()
text = resp.json()["choices"][0]["message"]["content"].strip()
return json.loads(text.strip().strip("`").removeprefix("json").strip())