fix: mock

This commit is contained in:
2026-03-13 23:29:55 +08:00
parent b7e973e2b6
commit c72fbc9a14
7 changed files with 165 additions and 21 deletions

View File

@@ -65,7 +65,13 @@ async def upload_images(
# trigger OCR tasks in-process background (non-blocking for API response) # trigger OCR tasks in-process background (non-blocking for API response)
from app.workers.ocr_tasks import process_images_ocr_batch_async from app.workers.ocr_tasks import process_images_ocr_batch_async
pending_ids = [str(img.id) for img in results if img.ocr_status.value == "pending"] pending_imgs = [img for img in results if img.ocr_status.value == "pending"]
for img in pending_imgs:
img.ocr_status = OcrStatus.processing
if pending_imgs:
await db.flush()
await db.commit()
pending_ids = [str(img.id) for img in pending_imgs]
if pending_ids: if pending_ids:
asyncio.create_task( asyncio.create_task(
process_images_ocr_batch_async( process_images_ocr_batch_async(
@@ -171,20 +177,25 @@ async def start_case_ocr(
image_ids = payload.image_ids if payload else [] image_ids = payload.image_ids if payload else []
if image_ids: if image_ids:
images = await repo.list_by_ids_in_case(case_id, image_ids) images = await repo.list_by_ids_in_case(case_id, image_ids)
# Never submit images that are already processing: this prevents
# duplicate OCR tasks when users trigger OCR from multiple pages.
images = [img for img in images if img.ocr_status != OcrStatus.processing]
# For explicit re-run, mark selected images as processing immediately # For explicit re-run, mark selected images as processing immediately
# so frontend can reflect state transition without full page refresh. # so frontend can reflect state transition without full page refresh.
for img in images: for img in images:
img.ocr_status = OcrStatus.processing img.ocr_status = OcrStatus.processing
await db.flush() if images:
await db.commit() await db.flush()
await db.commit()
else: else:
images = await repo.list_for_ocr(case_id, include_done=include_done) images = await repo.list_for_ocr(case_id, include_done=include_done)
# Mark queued images as processing immediately, including when OCR is # Mark queued images as processing immediately, including when OCR is
# triggered from workspace page, so UI can show progress right away. # triggered from workspace page, so UI can show progress right away.
for img in images: for img in images:
img.ocr_status = OcrStatus.processing img.ocr_status = OcrStatus.processing
await db.flush() if images:
await db.commit() await db.flush()
await db.commit()
from app.workers.ocr_tasks import process_images_ocr_batch_async from app.workers.ocr_tasks import process_images_ocr_batch_async

View File

@@ -2,9 +2,12 @@ from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
ENV_FILE_PATH = Path(__file__).resolve().parents[2] / ".env"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=ENV_FILE_PATH,
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
) )
@@ -18,6 +21,7 @@ class Settings(BaseSettings):
OCR_API_KEY: str = "" OCR_API_KEY: str = ""
OCR_API_URL: str = "" OCR_API_URL: str = ""
OCR_MODEL: str = "" OCR_MODEL: str = ""
OCR_ALLOW_MOCK_FALLBACK: bool = False
OCR_PARALLELISM: int = 4 OCR_PARALLELISM: int = 4
LLM_API_KEY: str = "" LLM_API_KEY: str = ""
LLM_API_URL: str = "" LLM_API_URL: str = ""

View File

@@ -51,8 +51,12 @@ class ImageRepository(BaseRepository[EvidenceImage]):
async def list_for_ocr(self, case_id: UUID, include_done: bool = False) -> list[EvidenceImage]: async def list_for_ocr(self, case_id: UUID, include_done: bool = False) -> list[EvidenceImage]:
query = select(EvidenceImage).where(EvidenceImage.case_id == case_id) query = select(EvidenceImage).where(EvidenceImage.case_id == case_id)
if not include_done: # Always exclude currently-processing images to avoid duplicate OCR
query = query.where(EvidenceImage.ocr_status != OcrStatus.done) # submission from different trigger paths (upload/workspace/screenshots).
if include_done:
query = query.where(EvidenceImage.ocr_status != OcrStatus.processing)
else:
query = query.where(EvidenceImage.ocr_status.in_([OcrStatus.pending, OcrStatus.failed]))
result = await self.session.execute(query.order_by(EvidenceImage.uploaded_at.desc())) result = await self.session.execute(query.order_by(EvidenceImage.uploaded_at.desc()))
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -43,6 +43,17 @@ def _ocr_available() -> bool:
return bool(url and key and model) return bool(url and key and model)
def _missing_ocr_fields() -> list[str]:
missing: list[str] = []
if not (settings.OCR_API_URL or settings.LLM_API_URL):
missing.append("OCR_API_URL(or LLM_API_URL)")
if not (settings.OCR_API_KEY or settings.LLM_API_KEY):
missing.append("OCR_API_KEY(or LLM_API_KEY)")
if not (settings.OCR_MODEL or settings.LLM_MODEL):
missing.append("OCR_MODEL(or LLM_MODEL)")
return missing
def _llm_available() -> bool: def _llm_available() -> bool:
url, key, model = _llm_config() url, key, model = _llm_config()
return bool(url and key and model) return bool(url and key and model)
@@ -54,6 +65,10 @@ async def classify_page(image_path: str) -> tuple[SourceApp, PageType]:
"""Identify the source app and page type of a screenshot.""" """Identify the source app and page type of a screenshot."""
if _ocr_available(): if _ocr_available():
return await _classify_via_api(image_path) return await _classify_via_api(image_path)
if not settings.OCR_ALLOW_MOCK_FALLBACK:
missing = ", ".join(_missing_ocr_fields()) or "unknown"
raise RuntimeError(f"OCR configuration missing: {missing}")
logger.warning("OCR unavailable, falling back to mock classification for image: %s", image_path)
return _classify_mock(image_path) return _classify_mock(image_path)
@@ -63,6 +78,10 @@ async def extract_transaction_fields(
"""Extract structured transaction fields from a screenshot.""" """Extract structured transaction fields from a screenshot."""
if _ocr_available(): if _ocr_available():
return await _extract_via_api(image_path, source_app, page_type) return await _extract_via_api(image_path, source_app, page_type)
if not settings.OCR_ALLOW_MOCK_FALLBACK:
missing = ", ".join(_missing_ocr_fields()) or "unknown"
raise RuntimeError(f"OCR configuration missing: {missing}")
logger.warning("OCR unavailable, falling back to mock extraction for image: %s", image_path)
mock_data = _extract_mock(image_path, source_app, page_type) mock_data = _extract_mock(image_path, source_app, page_type)
return mock_data, json.dumps(mock_data, ensure_ascii=False) return mock_data, json.dumps(mock_data, ensure_ascii=False)

View File

@@ -28,6 +28,9 @@ async def process_images_ocr_batch_async(image_ids: list[str], max_concurrency:
"""Process many images with bounded OCR concurrency.""" """Process many images with bounded OCR concurrency."""
if not image_ids: if not image_ids:
return return
# De-duplicate in-memory to prevent repeated processing of same image id
# in a single batch submission.
image_ids = list(dict.fromkeys(image_ids))
concurrency = max(1, max_concurrency) concurrency = max(1, max_concurrency)
semaphore = asyncio.Semaphore(concurrency) semaphore = asyncio.Semaphore(concurrency)

View File

@@ -175,6 +175,40 @@ const Analysis: React.FC = () => {
a.tradeTime.localeCompare(b.tradeTime), a.tradeTime.localeCompare(b.tradeTime),
); );
const payeeAgg = useMemo(() => {
const targetTx = validTx.filter((t) => t.direction === 'out' && !t.isTransit);
const map = new Map<string, { name: string; amount: number; count: number; hasKeyword: boolean }>();
const riskKeywords = ['刷单', '客服', '店铺', '商家', '返利', '任务', '垫付', '佣金', '淘宝'];
for (const tx of targetTx) {
const name = (tx.counterpartyName || '未知收款方').trim() || '未知收款方';
const key = name.toLowerCase();
const cur = map.get(key) ?? {
name,
amount: 0,
count: 0,
hasKeyword: false,
};
cur.amount += tx.amount;
cur.count += 1;
if (!cur.hasKeyword) {
cur.hasKeyword = riskKeywords.some((kw) => name.includes(kw) || (tx.remark || '').includes(kw));
}
map.set(key, cur);
}
return Array.from(map.values())
.map((item) => {
const risk =
item.hasKeyword || item.amount >= 50000 || item.count >= 3
? 'high'
: item.amount >= 20000 || item.count >= 2
? 'medium'
: 'low';
return { ...item, risk };
})
.sort((a, b) => b.amount - a.amount)
.slice(0, 10);
}, [validTx]);
return ( return (
<div> <div>
<Card style={{ marginBottom: 16 }}> <Card style={{ marginBottom: 16 }}>
@@ -221,7 +255,7 @@ const Analysis: React.FC = () => {
<Card variant="borderless"> <Card variant="borderless">
<Statistic <Statistic
title="涉诈对手方" title="涉诈对手方"
value={3} value={payeeAgg.length}
suffix="个" suffix="个"
/> />
</Card> </Card>
@@ -280,11 +314,10 @@ const Analysis: React.FC = () => {
<Card title="收款方聚合" style={{ marginTop: 24 }}> <Card title="收款方聚合" style={{ marginTop: 24 }}>
<Space direction="vertical" style={{ width: '100%' }}> <Space direction="vertical" style={{ width: '100%' }}>
{[ {payeeAgg.length === 0 && (
{ name: '李*华 (138****5678)', amount: 70000, count: 2, risk: 'high' }, <Typography.Text type="secondary"></Typography.Text>
{ name: 'USDT地址 T9yD...Xk3m', amount: 86500, count: 1, risk: 'medium' }, )}
{ name: '财富管家-客服', amount: 30000, count: 1, risk: 'high' }, {payeeAgg.map((item, idx) => (
].map((item, idx) => (
<Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}> <Card key={idx} size="small" variant="borderless" style={{ background: '#fafafa' }}>
<Row justify="space-between" align="middle"> <Row justify="space-between" align="middle">
<Col> <Col>
@@ -299,8 +332,8 @@ const Analysis: React.FC = () => {
<Typography.Text strong style={{ color: '#cf1322' }}> <Typography.Text strong style={{ color: '#cf1322' }}>
¥{item.amount.toLocaleString()} ¥{item.amount.toLocaleString()}
</Typography.Text> </Typography.Text>
<Tag color={item.risk === 'high' ? 'red' : 'orange'}> <Tag color={item.risk === 'high' ? 'red' : item.risk === 'medium' ? 'orange' : 'default'}>
{item.risk === 'high' ? '高风险' : '中风险'} {item.risk === 'high' ? '高风险' : item.risk === 'medium' ? '中风险' : '一般'}
</Tag> </Tag>
</Space> </Space>
</Col> </Col>

View File

@@ -21,6 +21,7 @@ import {
Segmented, Segmented,
Divider, Divider,
Dropdown, Dropdown,
Modal,
} from 'antd'; } from 'antd';
import { import {
AuditOutlined, AuditOutlined,
@@ -154,6 +155,8 @@ const Review: React.FC = () => {
const [editableTx, setEditableTx] = useState<FraudAssessment['transaction'] | null>(null); const [editableTx, setEditableTx] = useState<FraudAssessment['transaction'] | null>(null);
const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed'); const [reviewAction, setReviewAction] = useState<ReviewAction>('confirmed');
const [reviewNote, setReviewNote] = useState(''); const [reviewNote, setReviewNote] = useState('');
const [supplementModal, setSupplementModal] = useState<FraudAssessment | null>(null);
const [supplementNote, setSupplementNote] = useState('');
const { data: assessData } = useQuery({ const { data: assessData } = useQuery({
queryKey: ['assessments', id], queryKey: ['assessments', id],
@@ -181,6 +184,8 @@ const Review: React.FC = () => {
message.success('复核结果已保存'); message.success('复核结果已保存');
qc.invalidateQueries({ queryKey: ['assessments', id] }); qc.invalidateQueries({ queryKey: ['assessments', id] });
setReviewModal(null); setReviewModal(null);
setSupplementModal(null);
setSupplementNote('');
}, },
}); });
@@ -232,6 +237,11 @@ const Review: React.FC = () => {
(a) => a.reviewStatus === 'confirmed', (a) => a.reviewStatus === 'confirmed',
).length; ).length;
const openSupplementDialog = (assessment: FraudAssessment, initialNote?: string) => {
setSupplementModal(assessment);
setSupplementNote(initialNote ?? assessment.reviewNote ?? '');
};
const columns: ColumnsType<FraudAssessment> = [ const columns: ColumnsType<FraudAssessment> = [
{ {
title: '交易时间', title: '交易时间',
@@ -303,6 +313,8 @@ const Review: React.FC = () => {
render: (_, r) => { render: (_, r) => {
const aiAction = getAiSuggestedAction(r, allAssessments); const aiAction = getAiSuggestedAction(r, allAssessments);
const isPending = r.reviewStatus === 'pending'; const isPending = r.reviewStatus === 'pending';
const isSupplemented =
r.reviewStatus === 'needs_info' && !!(r.reviewNote || '').trim();
const status = isPending ? aiAction : r.reviewStatus; const status = isPending ? aiAction : r.reviewStatus;
const pendingStyle: Record<string, { bg: string; border: string; color: string }> = { const pendingStyle: Record<string, { bg: string; border: string; color: string }> = {
@@ -322,9 +334,18 @@ const Review: React.FC = () => {
const doneLabel: Record<string, string> = { const doneLabel: Record<string, string> = {
confirmed: '已确认', rejected: '已排除', needs_info: '已补充', confirmed: '已确认', rejected: '已排除', needs_info: '已补充',
}; };
const label = isPending ? (pendingLabel[aiAction] || '待确认') : (doneLabel[status] || status);
const label = isPending
? (pendingLabel[aiAction] || '待确认')
: (status === 'needs_info'
? (isSupplemented ? doneLabel.needs_info : pendingLabel.needs_info)
: (doneLabel[status] || status));
const submitReviewAction = (action: ReviewAction) => { const submitReviewAction = (action: ReviewAction) => {
if (action === 'needs_info') {
openSupplementDialog(r);
return;
}
reviewMutation.mutate({ reviewMutation.mutate({
assessmentId: r.id, assessmentId: r.id,
body: { body: {
@@ -343,11 +364,18 @@ const Review: React.FC = () => {
<span style={{ <span style={{
color: v === 'confirmed' ? '#389e0d' : v === 'rejected' ? '#cf1322' : '#1677ff', color: v === 'confirmed' ? '#389e0d' : v === 'rejected' ? '#cf1322' : '#1677ff',
}}> }}>
{v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '补充'} {v === 'confirmed' ? '确认' : v === 'rejected' ? '排除' : '补充信息'}
</span> </span>
), ),
})); }));
if (!isPending && r.reviewStatus === 'needs_info') {
otherOptions.push({
key: 'needs_info',
label: <span style={{ color: '#1677ff' }}></span>,
});
}
if (isPending) { if (isPending) {
const ps = pendingStyle[aiAction] || pendingStyle.confirmed; const ps = pendingStyle[aiAction] || pendingStyle.confirmed;
return ( return (
@@ -445,7 +473,7 @@ const Review: React.FC = () => {
? getAiSuggestedAction(r, allAssessments) ? getAiSuggestedAction(r, allAssessments)
: (r.reviewStatus as ReviewAction), : (r.reviewStatus as ReviewAction),
); );
setReviewNote(''); setReviewNote(r.reviewNote || '');
}} }}
> >
@@ -807,7 +835,7 @@ const Review: React.FC = () => {
options={[ options={[
{ label: '确认 - 该笔计入被骗金额', value: 'confirmed' }, { label: '确认 - 该笔计入被骗金额', value: 'confirmed' },
{ label: '排除 - 该笔不计入被骗金额', value: 'rejected' }, { label: '排除 - 该笔不计入被骗金额', value: 'rejected' },
{ label: '补充 - 需进一步调查确认', value: 'needs_info' }, { label: '补充 - 需录入补充说明', value: 'needs_info' },
]} ]}
/> />
<Typography.Text strong></Typography.Text> <Typography.Text strong></Typography.Text>
@@ -815,7 +843,7 @@ const Review: React.FC = () => {
rows={3} rows={3}
value={reviewNote} value={reviewNote}
onChange={(e) => setReviewNote(e.target.value)} onChange={(e) => setReviewNote(e.target.value)}
placeholder="请输入复核意见或备注..." placeholder={reviewAction === 'needs_info' ? '请填写已补充的证据/线索说明(必填)' : '请输入复核意见或备注...'}
/> />
</Space> </Space>
@@ -845,6 +873,10 @@ const Review: React.FC = () => {
type="primary" type="primary"
loading={reviewMutation.isPending} loading={reviewMutation.isPending}
onClick={() => { onClick={() => {
if (reviewAction === 'needs_info') {
openSupplementDialog(reviewModal, reviewNote);
return;
}
reviewMutation.mutate({ reviewMutation.mutate({
assessmentId: reviewModal.id, assessmentId: reviewModal.id,
body: { body: {
@@ -861,6 +893,44 @@ const Review: React.FC = () => {
</> </>
)} )}
</Drawer> </Drawer>
<Modal
title="补充信息"
open={!!supplementModal}
zIndex={1400}
onCancel={() => {
setSupplementModal(null);
setSupplementNote('');
}}
onOk={() => {
if (!supplementModal) return;
if (!supplementNote.trim()) {
message.warning('请填写补充说明后再提交');
return;
}
reviewMutation.mutate({
assessmentId: supplementModal.id,
body: {
review_status: 'needs_info',
review_note: supplementNote.trim(),
reviewed_by: 'demo_user',
},
});
}}
confirmLoading={reviewMutation.isPending}
okText="提交补充"
cancelText="取消"
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>
便
</Typography.Paragraph>
<Input.TextArea
rows={4}
value={supplementNote}
onChange={(e) => setSupplementNote(e.target.value)}
placeholder="请输入补充的证据、线索、核验结果等内容..."
/>
</Modal>
</div> </div>
); );
}; };