Spaces:
Running
Running
github-actions commited on
Commit ·
e4e8a91
0
Parent(s):
deploy: bbf5721
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +23 -0
- .gitattributes +1 -0
- .github/workflows/check_size.yaml +16 -0
- .github/workflows/sync_to_hf.yaml +28 -0
- .gitignore +26 -0
- .python-version +1 -0
- AGENTS.md +77 -0
- Dockerfile +27 -0
- README.md +8 -0
- backend/__init__.py +1 -0
- backend/api/__init__.py +0 -0
- backend/api/exception_handler.py +42 -0
- backend/api/response.py +27 -0
- backend/api/routers/__init__.py +0 -0
- backend/api/routers/image_price_validation.py +130 -0
- backend/api/routers/phone_detection.py +95 -0
- backend/api/routers/text_inference.py +46 -0
- backend/api/routers/url_detection.py +22 -0
- backend/api/schemas/__init__.py +0 -0
- backend/api/schemas/image.py +71 -0
- backend/api/schemas/phone.py +124 -0
- backend/api/schemas/text.py +18 -0
- backend/api/schemas/url.py +10 -0
- backend/core/config.py +121 -0
- backend/core/lifespan.py +51 -0
- backend/core/logging.py +10 -0
- backend/main.py +40 -0
- backend/persistence/mysql_connection.py +52 -0
- backend/providers/__init__.py +5 -0
- backend/providers/groq.py +83 -0
- backend/rag/__init__.py +1 -0
- backend/rag/dto/__init__.py +0 -0
- backend/rag/dto/rag_analysis.py +21 -0
- backend/rag/rag_context.py +52 -0
- backend/rag/rag_reasoner.py +178 -0
- backend/rag/rag_retriever.py +43 -0
- backend/repository/__init__.py +0 -0
- backend/repository/case_repository.py +28 -0
- backend/repository/family_repository.py +172 -0
- backend/repository/market_price_repository.py +125 -0
- backend/repository/phone_repository.py +162 -0
- backend/repository/rag_repository.py +70 -0
- backend/services/__init__.py +0 -0
- backend/services/dto/__init__.py +0 -0
- backend/services/dto/price_analysis.py +150 -0
- backend/services/dto/text_analysis.py +12 -0
- backend/services/image_price_service/__init__.py +1 -0
- backend/services/image_price_service/case_recorder.py +58 -0
- backend/services/image_price_service/domain/__init__.py +1 -0
- backend/services/image_price_service/domain/models.py +152 -0
.dockerignore
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
.venv
|
| 4 |
+
.vscode
|
| 5 |
+
.claude
|
| 6 |
+
.pytest_cache
|
| 7 |
+
__pycache__
|
| 8 |
+
**/__pycache__
|
| 9 |
+
*.py[oc]
|
| 10 |
+
|
| 11 |
+
.env
|
| 12 |
+
.env.*
|
| 13 |
+
**/.env
|
| 14 |
+
**/.env.*
|
| 15 |
+
scam-detector-ocr-*.json
|
| 16 |
+
credentials.json
|
| 17 |
+
|
| 18 |
+
tests
|
| 19 |
+
scripts
|
| 20 |
+
backend/models
|
| 21 |
+
data/chroma
|
| 22 |
+
|
| 23 |
+
AGENTS.md
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
README.md merge=ours
|
.github/workflows/check_size.yaml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Check file size
|
| 2 |
+
on: # or directly `on: [push]` to run the action on every push on any branch
|
| 3 |
+
pull_request:
|
| 4 |
+
branches: [space-deploy]
|
| 5 |
+
|
| 6 |
+
# to run this workflow manually from the Actions tab
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
sync-to-hub:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- name: Check large files
|
| 14 |
+
uses: ActionsDesk/lfs-warning@v2.0
|
| 15 |
+
with:
|
| 16 |
+
filesizelimit: 10485760 # this is 10MB so we can sync to HF Spaces
|
.github/workflows/sync_to_hf.yaml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face hub
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [space-deploy]
|
| 5 |
+
|
| 6 |
+
# to run this workflow manually from the Actions tab
|
| 7 |
+
workflow_dispatch:
|
| 8 |
+
|
| 9 |
+
jobs:
|
| 10 |
+
sync-to-hub:
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
steps:
|
| 13 |
+
- uses: actions/checkout@v3
|
| 14 |
+
with:
|
| 15 |
+
fetch-depth: 1
|
| 16 |
+
lfs: true
|
| 17 |
+
# 只推送不含歷史的快照,避免 tests/ 內的二進位檔(及其歷史)被 HF 拒絕
|
| 18 |
+
- name: Push to hub
|
| 19 |
+
env:
|
| 20 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 21 |
+
run: |
|
| 22 |
+
git config user.name "github-actions"
|
| 23 |
+
git config user.email "github-actions@users.noreply.github.com"
|
| 24 |
+
git checkout --orphan hf-snapshot
|
| 25 |
+
git rm -r -q --cached --ignore-unmatch tests
|
| 26 |
+
git commit -q -m "deploy: ${GITHUB_SHA::7}"
|
| 27 |
+
git remote add hf https://kko12:$HF_TOKEN@huggingface.co/spaces/kko12/spam-detector
|
| 28 |
+
git push -f hf hf-snapshot:main
|
.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[oc]
|
| 4 |
+
build/
|
| 5 |
+
dist/
|
| 6 |
+
wheels/
|
| 7 |
+
*.egg-info
|
| 8 |
+
|
| 9 |
+
# Virtual environments
|
| 10 |
+
.venv
|
| 11 |
+
.vscode
|
| 12 |
+
|
| 13 |
+
backend/models
|
| 14 |
+
.env
|
| 15 |
+
.env.*
|
| 16 |
+
backend/.env
|
| 17 |
+
backend/.env.*
|
| 18 |
+
!.env.example
|
| 19 |
+
|
| 20 |
+
data/
|
| 21 |
+
db/
|
| 22 |
+
|
| 23 |
+
dev.*
|
| 24 |
+
|
| 25 |
+
scam-detector-ocr-*.json
|
| 26 |
+
credentials.json
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.14
|
AGENTS.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Code style guidelines
|
| 2 |
+
|
| 3 |
+
### 1. 實作前先產生計畫
|
| 4 |
+
|
| 5 |
+
在進行任何非瑣碎的程式碼變更前(新增功能、重構、涉及多個檔案的 bug 修復),
|
| 6 |
+
先以下列格式產出計畫,再開始實作:
|
| 7 |
+
|
| 8 |
+
1. **目標**:一句話描述要達成什麼
|
| 9 |
+
2. **影響範圍**:會修改/新增哪些檔案或模組
|
| 10 |
+
3. **拆分步驟**:條列式列出實作順序(每一步應是可獨立驗證的最小單位)
|
| 11 |
+
4. **風險/取捨**:是否有需要注意的邊界情況、相容性、效能影響
|
| 12 |
+
5. **驗證方式**:完成後如何確認正確(跑哪個測試、哪個指令)
|
| 13 |
+
計畫確認後才開始寫程式碼。若使用者未回覆確認,預設視為同意;
|
| 14 |
+
但遇到以下情況,必須停下來等待明確確認才可繼續:
|
| 15 |
+
|
| 16 |
+
- 會刪除或覆寫現有邏輯
|
| 17 |
+
- 影響資料庫 schema 或外部 API 合約
|
| 18 |
+
- 實作方式有多種合理選項,需要使用者取捨判斷
|
| 19 |
+
單一函式修正、拼字/格式修正等瑣碎變更可略過此流程,直接實作。
|
| 20 |
+
|
| 21 |
+
### 2. 巢狀與職責集中度
|
| 22 |
+
|
| 23 |
+
- 函式巢狀深度盡量不超過 2 層;超過時優先用 early return/guard clause 打平,
|
| 24 |
+
而非直接視為「必須拆函式」的訊號
|
| 25 |
+
- 函式長度沒有硬性上限;只有當同一函式混雜多種職責
|
| 26 |
+
(例如同時做 I/O 呼叫、資料轉換、商業邏輯判斷)時才需要拆分
|
| 27 |
+
- 一個檔案/模組只負責單一領域(例如「搜尋」「總結」「快取」分開放)
|
| 28 |
+
|
| 29 |
+
### 3. 函式拆分準則(避免過度拆分)
|
| 30 |
+
|
| 31 |
+
只有符合以下任一條件,才拆成獨立函式:
|
| 32 |
+
|
| 33 |
+
1. 邏輯會被複用 ≥ 2 次
|
| 34 |
+
2. 該段邏輯是獨立語意單元,可脫離上下文理解,適合單獨寫測試
|
| 35 |
+
3. 屬於不同關注點(I/O /純運算/格式化/驗證)
|
| 36 |
+
不要拆分的情況:
|
| 37 |
+
|
| 38 |
+
- 只執行一次、線性依序執行的步驟,硬切成多個小函式
|
| 39 |
+
- 拆分後函式間需要傳遞大量參數或共享可變狀態(代表本質上是同一件事)
|
| 40 |
+
- 函式名稱與內容幾乎一比一翻譯,沒有抽象價值(例如 `add_one(x): return x + 1`)
|
| 41 |
+
拆分的目的是讓呼叫端讀起來像文件,而不是讓每個函式變短。
|
| 42 |
+
|
| 43 |
+
### 4. 命名與抽象層級
|
| 44 |
+
|
| 45 |
+
- 禁止使用空泛命名:`data`/`process`/`manager`/`util`,
|
| 46 |
+
需具體對應業務語意(例如 `fetch_price_from_serp_api`,而非 `process_data`)。
|
| 47 |
+
`handle` 僅允許用於最外層協調邏輯(見第 5 節動詞詞彙表),其餘情況視為空泛命名
|
| 48 |
+
- 不要引入未被使用的抽象層(interface/abstract class/factory/strategy),
|
| 49 |
+
除非確實存在 ≥ 2 種具體實作
|
| 50 |
+
- 避免多層 wrapper/callback 包裝簡單邏輯
|
| 51 |
+
|
| 52 |
+
### 5. 命名長度控制
|
| 53 |
+
|
| 54 |
+
- 函式名應盡量控制在 2–4 個單字(動詞 + 名詞,必要時加修飾詞)
|
| 55 |
+
- 不要在函式名重複「模組名/類別名/檔案名」已表達的語意
|
| 56 |
+
(例如在 `groq_summarizer.py` 內,函式命名為 `summarize()` 而非 `summarize_with_groq()`)
|
| 57 |
+
- 使用固定動詞詞彙表:`get`/`fetch`(讀取)、`list`(多筆)、`build`/`make`(組裝)、
|
| 58 |
+
`validate`(驗證)、`normalize`(格式化)、`handle`(僅限最外層協調邏輯)
|
| 59 |
+
- 過多細節(如條件、延遲、重試策略)用參數表達,不要塞進函式名稱
|
| 60 |
+
- 只允許業界通用縮寫(`api`, `url`, `id`, `config`),業務核心詞彙不縮寫
|
| 61 |
+
|
| 62 |
+
### 6. 換行與行長度
|
| 63 |
+
|
| 64 |
+
- 單行程式碼上限為 100–120 字元(依語言慣例調整,Python 可用 100)
|
| 65 |
+
- 函式呼叫參數、import、字典/物件字面量等,只有在超過行長度上限時才拆成多行,
|
| 66 |
+
未超過時保持單行
|
| 67 |
+
- 不要僅因為「參數較多」或「表達式較長」就主動換行,以實際字元數為唯一判斷依據
|
| 68 |
+
- 若專案已有 formatter 設定(如 black/prettier 的 line-length),
|
| 69 |
+
以該設定為準,不要用更保守的預設值
|
| 70 |
+
|
| 71 |
+
### 7. 完成後自我審查
|
| 72 |
+
|
| 73 |
+
完成程式碼後,請自問:
|
| 74 |
+
|
| 75 |
+
1. 是否有函式混雜多種職責?→ 拆分(對照第 2、3 節)
|
| 76 |
+
2. 是否有只被呼叫一次、拆分後反而要跳來跳去才看得懂的函式?→ 合併回去
|
| 77 |
+
3. 是否有命名空泛、語意抽象、無法一眼看出用途的變數/函式?→ 重新命名(對照第 4、5 節)
|
Dockerfile
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 建立docker image(鏡像)
|
| 2 |
+
|
| 3 |
+
# 選擇base image(需符合 pyproject.toml 的 requires-python)
|
| 4 |
+
FROM python:3.14-slim
|
| 5 |
+
|
| 6 |
+
# 安裝 uv
|
| 7 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
| 8 |
+
|
| 9 |
+
# HF Space 以 uid 1000 執行,工作目錄需屬於該使用者才能寫入模型與資料
|
| 10 |
+
RUN useradd -m -u 1000 user && mkdir -p /home/user/app && chown user:user /home/user/app
|
| 11 |
+
USER user
|
| 12 |
+
WORKDIR /home/user/app
|
| 13 |
+
|
| 14 |
+
ENV UV_COMPILE_BYTECODE=1 \
|
| 15 |
+
UV_LINK_MODE=copy \
|
| 16 |
+
UV_PYTHON_DOWNLOADS=never \
|
| 17 |
+
PATH="/home/user/app/.venv/bin:$PATH"
|
| 18 |
+
|
| 19 |
+
# 安裝套件依賴(先只複製依賴檔,讓此層可被快取)
|
| 20 |
+
COPY --chown=user pyproject.toml uv.lock ./
|
| 21 |
+
RUN uv sync --frozen --no-dev --no-install-project
|
| 22 |
+
|
| 23 |
+
# 搬入原始碼
|
| 24 |
+
COPY --chown=user . .
|
| 25 |
+
|
| 26 |
+
# 執行cmd
|
| 27 |
+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Scam Detection
|
| 3 |
+
emoji: 🔍
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
sdk_version: "2.0"
|
| 8 |
+
---
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Scam Detection Backend Module"""
|
backend/api/__init__.py
ADDED
|
File without changes
|
backend/api/exception_handler.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import cast
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 5 |
+
from fastapi.encoders import jsonable_encoder
|
| 6 |
+
from fastapi.exceptions import RequestValidationError
|
| 7 |
+
from fastapi.responses import JSONResponse
|
| 8 |
+
from starlette.types import ExceptionHandler
|
| 9 |
+
|
| 10 |
+
from backend.api.response import ApiErrorResponse
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
def make_error_response(status_code: int, message: object | None = None) -> JSONResponse:
|
| 15 |
+
"""建立統一的錯誤回傳格式"""
|
| 16 |
+
response_content = ApiErrorResponse(
|
| 17 |
+
success=False,
|
| 18 |
+
version="1.0",
|
| 19 |
+
error_code=status_code,
|
| 20 |
+
error_message=message
|
| 21 |
+
)
|
| 22 |
+
return JSONResponse(status_code=status_code, content=jsonable_encoder(response_content))
|
| 23 |
+
|
| 24 |
+
async def handle_global_exception(req: Request, exc: Exception) -> JSONResponse:
|
| 25 |
+
"""處理全域錯誤"""
|
| 26 |
+
logger.error(f"Unhandled Exception: {str(exc)}", exc_info=True)
|
| 27 |
+
return make_error_response(status_code=500, message="Internal Server Error")
|
| 28 |
+
|
| 29 |
+
async def handle_http_exception(req: Request, exc: HTTPException) -> JSONResponse:
|
| 30 |
+
"""處理 HTTP 錯誤"""
|
| 31 |
+
logger.error(f"HTTP Exception: {str(exc.detail)}")
|
| 32 |
+
return make_error_response(status_code=exc.status_code, message=exc.detail)
|
| 33 |
+
|
| 34 |
+
async def handle_validation_exception(req: Request, exc: RequestValidationError) -> JSONResponse:
|
| 35 |
+
"""處理資料驗證錯誤"""
|
| 36 |
+
logger.error(f"Validation Error: {exc.errors()}")
|
| 37 |
+
return make_error_response(status_code=422, message=exc.errors())
|
| 38 |
+
|
| 39 |
+
def register_exception_handlers(app: FastAPI) -> None:
|
| 40 |
+
app.add_exception_handler(Exception, handle_global_exception)
|
| 41 |
+
app.add_exception_handler(HTTPException, cast(ExceptionHandler, handle_http_exception))
|
| 42 |
+
app.add_exception_handler(RequestValidationError, cast(ExceptionHandler, handle_validation_exception))
|
backend/api/response.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""建立後端統一回傳的格式"""
|
| 2 |
+
from typing import Generic, TypeVar
|
| 3 |
+
|
| 4 |
+
from pydantic import BaseModel, ConfigDict
|
| 5 |
+
from pydantic.alias_generators import to_camel
|
| 6 |
+
|
| 7 |
+
ResponseData = TypeVar("ResponseData")
|
| 8 |
+
|
| 9 |
+
class ApiResponse(BaseModel, Generic[ResponseData]):
|
| 10 |
+
"""統一回傳格式"""
|
| 11 |
+
success: bool = True
|
| 12 |
+
version: str = "1.0"
|
| 13 |
+
data: ResponseData | None = None
|
| 14 |
+
|
| 15 |
+
class ApiErrorResponse(BaseModel):
|
| 16 |
+
model_config = ConfigDict(
|
| 17 |
+
alias_generator=to_camel,
|
| 18 |
+
populate_by_name=True,
|
| 19 |
+
serialize_by_alias=True,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
"""統一回傳錯誤格式"""
|
| 23 |
+
success: bool = False
|
| 24 |
+
version: str = "1.0"
|
| 25 |
+
error_code: int
|
| 26 |
+
error_message: object | None = None
|
| 27 |
+
|
backend/api/routers/__init__.py
ADDED
|
File without changes
|
backend/api/routers/image_price_validation.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""商品圖片上傳、價格風險分析與 Google OCR 驗證 API。
|
| 2 |
+
|
| 3 |
+
正式價格端點只負責 HTTP 輸入驗證、呼叫 image_price_service,並將完整 Service
|
| 4 |
+
結果封裝成 ImagePriceResponse;OCR、商品辨識、價格統計與風險決策不在本模組。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Annotated
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
| 10 |
+
from starlette.concurrency import run_in_threadpool
|
| 11 |
+
|
| 12 |
+
from backend.api.response import ApiResponse
|
| 13 |
+
from backend.api.schemas.image import (ImagePriceDebugInfo, ImagePriceResponse,
|
| 14 |
+
ImageUploadResponse,
|
| 15 |
+
MarketPriceEstimateResponse)
|
| 16 |
+
from backend.core.config import settings
|
| 17 |
+
from backend.services.image_price_service.image_price_analyzer import \
|
| 18 |
+
image_price_analyzer
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/v1", tags=["image-inference"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.post(
|
| 24 |
+
"/image/upload",
|
| 25 |
+
response_model=ApiResponse[ImageUploadResponse],
|
| 26 |
+
summary="圖片上傳測試",
|
| 27 |
+
description="用於測試圖片上傳是否成功。",
|
| 28 |
+
)
|
| 29 |
+
async def upload_image(file: UploadFile = File(...)) -> ApiResponse[ImageUploadResponse]:
|
| 30 |
+
"""驗證圖片 MIME type、非空內容與大小,回傳上傳資訊但不執行分析。"""
|
| 31 |
+
content_type = file.content_type or ""
|
| 32 |
+
if content_type not in settings.ALLOWED_IMAGE_TYPES:
|
| 33 |
+
raise HTTPException(
|
| 34 |
+
status_code=400,
|
| 35 |
+
detail="僅支援 JPG、PNG、WEBP 圖片格式",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
data = await file.read()
|
| 39 |
+
if not data:
|
| 40 |
+
raise HTTPException(status_code=400, detail="上傳檔案不可為空")
|
| 41 |
+
|
| 42 |
+
if len(data) > settings.MAX_IMAGE_BYTES:
|
| 43 |
+
raise HTTPException(status_code=400, detail="圖片大小不可超過 20MB")
|
| 44 |
+
|
| 45 |
+
data = ImageUploadResponse(
|
| 46 |
+
filename=file.filename or "unknown",
|
| 47 |
+
content_type=content_type,
|
| 48 |
+
size_bytes=len(data),
|
| 49 |
+
message="圖片已成功上傳,可進入下一步辨識流程",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
return ApiResponse(data=data)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post(
|
| 56 |
+
"/price/analyze",
|
| 57 |
+
response_model=ApiResponse[ImagePriceResponse],
|
| 58 |
+
summary="價格驗證風險分析",
|
| 59 |
+
description="使用 OCR 分析圖片商品價格。加上 `?debug=true` 可取得 OCR 與市場價格來源的除錯資訊。",
|
| 60 |
+
)
|
| 61 |
+
async def analyze_image_price(
|
| 62 |
+
file: UploadFile = File(...),
|
| 63 |
+
include_debug: Annotated[bool | None, Query(alias="debug")] = None,
|
| 64 |
+
) -> ApiResponse[ImagePriceResponse]:
|
| 65 |
+
"""驗證商品圖片 HTTP 輸入並封裝 Service 分析結果。
|
| 66 |
+
|
| 67 |
+
Router 僅驗證 MIME type、非空內容與大小,呼叫 image_price_detector 後以
|
| 68 |
+
DTO 欄位建立 ImagePriceResponse,不計算或覆寫任何風險結果。
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
content_type = file.content_type or ""
|
| 72 |
+
if content_type not in settings.ALLOWED_IMAGE_TYPES:
|
| 73 |
+
raise HTTPException(status_code=400, detail="僅支援 JPG、PNG、WEBP 圖片格式")
|
| 74 |
+
|
| 75 |
+
data = await file.read()
|
| 76 |
+
if not data:
|
| 77 |
+
raise HTTPException(status_code=400, detail="上傳檔案不可為空")
|
| 78 |
+
if len(data) > settings.MAX_IMAGE_BYTES:
|
| 79 |
+
raise HTTPException(status_code=400, detail="圖片大小不可超過 20MB")
|
| 80 |
+
|
| 81 |
+
result = await run_in_threadpool(
|
| 82 |
+
image_price_analyzer.image_price_detector,
|
| 83 |
+
data=data,
|
| 84 |
+
filename=file.filename or "unknown",
|
| 85 |
+
content_type=content_type,
|
| 86 |
+
)
|
| 87 |
+
warnings = list(result.extraction_warnings)
|
| 88 |
+
if not result.success and result.message and result.message not in warnings:
|
| 89 |
+
warnings.append(result.message)
|
| 90 |
+
|
| 91 |
+
debug_info = None
|
| 92 |
+
if include_debug:
|
| 93 |
+
debug_info = ImagePriceDebugInfo(
|
| 94 |
+
error_code=result.error_code,
|
| 95 |
+
search_tools=result.search_tools,
|
| 96 |
+
market_price_source=result.market_price_source,
|
| 97 |
+
market_price_estimates=tuple(
|
| 98 |
+
MarketPriceEstimateResponse(
|
| 99 |
+
**estimate.model_dump(exclude={"candidates", "search_tools"})
|
| 100 |
+
)
|
| 101 |
+
for estimate in result.market_price_estimates
|
| 102 |
+
),
|
| 103 |
+
condition_source_text=result.condition_source_text,
|
| 104 |
+
condition_extraction_confidence=result.condition_extraction_confidence,
|
| 105 |
+
price_source_text=result.price_source_text,
|
| 106 |
+
extraction_confidence=result.extraction_confidence if result.success else None,
|
| 107 |
+
decision_layer=result.decision_layer,
|
| 108 |
+
warnings=warnings,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
data=ImagePriceResponse(
|
| 112 |
+
product_name=result.product_name if result.success else None,
|
| 113 |
+
listed_price=result.listed_price if result.success else None,
|
| 114 |
+
market_price=(result.market_price if result.success and result.market_price > 0 else None),
|
| 115 |
+
seller_name=result.seller_name if result.success else None,
|
| 116 |
+
risk_label=result.risk_label,
|
| 117 |
+
condition=result.condition,
|
| 118 |
+
risk_score=result.score,
|
| 119 |
+
result=result.reason,
|
| 120 |
+
debug=debug_info,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return ApiResponse(data=data)
|
| 124 |
+
|
| 125 |
+
except HTTPException:
|
| 126 |
+
raise
|
| 127 |
+
except Exception as e:
|
| 128 |
+
if settings.DEBUG:
|
| 129 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 130 |
+
raise HTTPException(status_code=500, detail="Internal Error")
|
backend/api/routers/phone_detection.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
from backend.api.response import ApiResponse
|
| 4 |
+
from backend.api.schemas.phone import (PhoneQueryRequest, PhoneQueryResponse,
|
| 5 |
+
PhoneReportError, PhoneReportRequest,
|
| 6 |
+
PhoneReportResponse)
|
| 7 |
+
from backend.services.phone_service import phone_service
|
| 8 |
+
|
| 9 |
+
# 服務層為同步 MySQL I/O,路由用 def 讓 FastAPI 放進 threadpool 執行,避免阻塞 event loop
|
| 10 |
+
router = APIRouter(prefix="/v1", tags=["phone"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post(
|
| 14 |
+
"/phones/search",
|
| 15 |
+
response_model=ApiResponse[PhoneQueryResponse],
|
| 16 |
+
summary="電話號碼查詢",
|
| 17 |
+
description="查詢電話號碼是否已存在於黑白名單。若結果為 unknown,可提示使用者進行回報。",
|
| 18 |
+
openapi_extra={
|
| 19 |
+
"requestBody": {
|
| 20 |
+
"content": {
|
| 21 |
+
"application/json": {
|
| 22 |
+
"example": {
|
| 23 |
+
"phone_number": "0422255141"
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
},
|
| 29 |
+
)
|
| 30 |
+
def query_phone(req: PhoneQueryRequest) -> ApiResponse[PhoneQueryResponse]:
|
| 31 |
+
try:
|
| 32 |
+
result = phone_service.query_phone(req.phone_number)
|
| 33 |
+
data = PhoneQueryResponse(**result)
|
| 34 |
+
return ApiResponse(data=data)
|
| 35 |
+
except Exception as e:
|
| 36 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.post(
|
| 40 |
+
"/phones/report",
|
| 41 |
+
response_model=ApiResponse[PhoneReportResponse],
|
| 42 |
+
summary="可疑號碼回報",
|
| 43 |
+
description=(
|
| 44 |
+
"當查詢結果為 unknown 時,可回報該號碼為可疑名單。"
|
| 45 |
+
" 若選擇「其他」,請一併填寫 other_type 欄位。"
|
| 46 |
+
),
|
| 47 |
+
openapi_extra={
|
| 48 |
+
"requestBody": {
|
| 49 |
+
"content": {
|
| 50 |
+
"application/json": {
|
| 51 |
+
"examples": {
|
| 52 |
+
"standard": {
|
| 53 |
+
"summary": "回報常見類別",
|
| 54 |
+
"value": {
|
| 55 |
+
"phoneNumber": "0987654321",
|
| 56 |
+
"phoneType": "假投資",
|
| 57 |
+
"reporterPhone": "0912345678",
|
| 58 |
+
"transferType": 1,
|
| 59 |
+
"transferContent": "0912345678"
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"other": {
|
| 63 |
+
"summary": "回報其他類別",
|
| 64 |
+
"value": {
|
| 65 |
+
"phoneNumber": "0987654321",
|
| 66 |
+
"phoneType": "其他",
|
| 67 |
+
"otherType": "疑似詐騙廣告",
|
| 68 |
+
"reporterPhone": "0912345678",
|
| 69 |
+
"transferType": 3,
|
| 70 |
+
"transferContent": "https://example.com/fake"
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
},
|
| 78 |
+
)
|
| 79 |
+
def report_phone(req: PhoneReportRequest) -> ApiResponse[PhoneReportResponse]:
|
| 80 |
+
try:
|
| 81 |
+
result = phone_service.report_suspicious(
|
| 82 |
+
req.phone_number,
|
| 83 |
+
req.phone_type,
|
| 84 |
+
req.other_type,
|
| 85 |
+
None,
|
| 86 |
+
req.reporter_phone,
|
| 87 |
+
req.transfer_type,
|
| 88 |
+
req.transfer_content,
|
| 89 |
+
)
|
| 90 |
+
data = PhoneReportResponse(**result)
|
| 91 |
+
return ApiResponse(data=data)
|
| 92 |
+
except PhoneReportError as e:
|
| 93 |
+
raise HTTPException(status_code=409, detail=str(e))
|
| 94 |
+
except Exception as e:
|
| 95 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/api/routers/text_inference.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
from backend.api.response import ApiResponse
|
| 4 |
+
from backend.api.schemas.text import TextRequest, TextResponse
|
| 5 |
+
from backend.core.config import settings
|
| 6 |
+
from backend.services.text_service.text_analyzer import text_analyzer
|
| 7 |
+
from backend.utils.text_cleaner import normalize_text
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/v1", tags=["text-inference"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post("/text/analyze", response_model=ApiResponse[TextResponse], summary="文字詐騙風險分析", description="使用規則、多模型做混合判斷")
|
| 13 |
+
async def analyze_text(req: TextRequest) -> ApiResponse[TextResponse]:
|
| 14 |
+
try:
|
| 15 |
+
text = normalize_text(req.text)
|
| 16 |
+
if not text:
|
| 17 |
+
raise HTTPException(status_code=400, detail="text 不可為空")
|
| 18 |
+
|
| 19 |
+
result = await text_analyzer.hybrid_detector(text)
|
| 20 |
+
data = TextResponse(**result)
|
| 21 |
+
return ApiResponse(data=data)
|
| 22 |
+
except HTTPException:
|
| 23 |
+
raise
|
| 24 |
+
except Exception as e:
|
| 25 |
+
if settings.DEBUG:
|
| 26 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 27 |
+
raise HTTPException(status_code=500, detail="Internal Error")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post("/text/analyze/base", response_model=ApiResponse[TextResponse], summary="純模型文字風險分析", description="僅使用文字分類模型")
|
| 32 |
+
def model_analyze_text(req: TextRequest) -> ApiResponse[TextResponse]:
|
| 33 |
+
try:
|
| 34 |
+
text = normalize_text(req.text)
|
| 35 |
+
if not text:
|
| 36 |
+
raise HTTPException(status_code=400, detail="text 不可為空")
|
| 37 |
+
|
| 38 |
+
result = text_analyzer.model_detector(text)
|
| 39 |
+
data = TextResponse(**result)
|
| 40 |
+
return ApiResponse(data=data)
|
| 41 |
+
except HTTPException:
|
| 42 |
+
raise
|
| 43 |
+
except Exception as e:
|
| 44 |
+
if settings.DEBUG:
|
| 45 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 46 |
+
raise HTTPException(status_code=500, detail="Internal Error")
|
backend/api/routers/url_detection.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException
|
| 2 |
+
|
| 3 |
+
from backend.api.response import ApiResponse
|
| 4 |
+
from backend.api.schemas.url import UrlRequest, UrlResponse
|
| 5 |
+
from backend.core.config import settings
|
| 6 |
+
from backend.services.url_service.url_analyzer import url_detector
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/v1", tags=["url-detector"])
|
| 9 |
+
|
| 10 |
+
# url網址分析
|
| 11 |
+
@router.post("/url/analyze", response_model=ApiResponse[UrlResponse], summary="執行url分析")
|
| 12 |
+
def analyze_url(req: UrlRequest) -> ApiResponse[UrlResponse]:
|
| 13 |
+
try:
|
| 14 |
+
url = str(req.url)
|
| 15 |
+
result = url_detector.analyze(url)
|
| 16 |
+
data = UrlResponse(**result)
|
| 17 |
+
return ApiResponse(data=data)
|
| 18 |
+
except Exception as e:
|
| 19 |
+
if settings.DEBUG:
|
| 20 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 21 |
+
raise HTTPException(status_code=500, detail="Internal Error")
|
| 22 |
+
|
backend/api/schemas/__init__.py
ADDED
|
File without changes
|
backend/api/schemas/image.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Literal
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 4 |
+
from pydantic.alias_generators import to_camel
|
| 5 |
+
|
| 6 |
+
from backend.services.dto.price_analysis import (
|
| 7 |
+
DecisionLayer,
|
| 8 |
+
MarketPriceReferenceMode,
|
| 9 |
+
MarketPriceSource,
|
| 10 |
+
MarketPriceStatus,
|
| 11 |
+
SearchTool,
|
| 12 |
+
)
|
| 13 |
+
from backend.services.image_price_service.domain.models import MarketplaceCondition
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ImageUploadResponse(BaseModel):
|
| 17 |
+
filename: str = Field(..., description="上傳檔名")
|
| 18 |
+
content_type: str = Field(..., description="檔案 MIME 類型")
|
| 19 |
+
size_bytes: int = Field(..., ge=1, description="檔案大小(byte)")
|
| 20 |
+
message: str = Field(..., description="處理結果訊息")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class MarketPriceEstimateResponse(BaseModel):
|
| 24 |
+
"""對外回傳且不含市場候選明細的價格區間。"""
|
| 25 |
+
|
| 26 |
+
model_config = ConfigDict(extra="forbid")
|
| 27 |
+
|
| 28 |
+
status: MarketPriceStatus
|
| 29 |
+
condition: MarketplaceCondition
|
| 30 |
+
reference_mode: MarketPriceReferenceMode
|
| 31 |
+
median_price: int = Field(ge=0)
|
| 32 |
+
low_price: int = Field(ge=0)
|
| 33 |
+
high_price: int = Field(ge=0)
|
| 34 |
+
sample_count: int = Field(ge=0)
|
| 35 |
+
site_count: int = Field(ge=0)
|
| 36 |
+
source: MarketPriceSource
|
| 37 |
+
confidence: float = Field(ge=0, le=1)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ImagePriceDebugInfo(BaseModel):
|
| 41 |
+
model_config = ConfigDict(extra="forbid")
|
| 42 |
+
|
| 43 |
+
error_code: str | None = None
|
| 44 |
+
search_tools: list[SearchTool] = Field(default_factory=list)
|
| 45 |
+
market_price_source: MarketPriceSource = "not_evaluated"
|
| 46 |
+
market_price_estimates: tuple[MarketPriceEstimateResponse, ...] = ()
|
| 47 |
+
condition_source_text: str = ""
|
| 48 |
+
condition_extraction_confidence: float = Field(default=0.0, ge=0, le=1)
|
| 49 |
+
price_source_text: str | None = None
|
| 50 |
+
extraction_confidence: float | None = Field(default=None, ge=0, le=1)
|
| 51 |
+
decision_layer: DecisionLayer
|
| 52 |
+
warnings: list[str] = Field(default_factory=list)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class ImagePriceResponse(BaseModel):
|
| 56 |
+
model_config = ConfigDict(
|
| 57 |
+
extra="forbid",
|
| 58 |
+
alias_generator=to_camel,
|
| 59 |
+
populate_by_name=True,
|
| 60 |
+
serialize_by_alias=True,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
product_name: str | None = None
|
| 64 |
+
condition: MarketplaceCondition | None = None
|
| 65 |
+
listed_price: int | None = Field(default=None, ge=0)
|
| 66 |
+
market_price: int | None = Field(default=None, ge=0)
|
| 67 |
+
seller_name: str | None = None
|
| 68 |
+
risk_label: Literal["LOW", "MEDIUM", "HIGH", "UNKNOWN"]
|
| 69 |
+
risk_score: str | float | None = None
|
| 70 |
+
result: str | None = None
|
| 71 |
+
debug: ImagePriceDebugInfo | None = None
|
backend/api/schemas/phone.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 4 |
+
from pydantic.alias_generators import to_camel
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class PhoneQueryRequest(BaseModel):
|
| 8 |
+
model_config = ConfigDict(
|
| 9 |
+
alias_generator=to_camel,
|
| 10 |
+
populate_by_name=True,
|
| 11 |
+
serialize_by_alias=True,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
phone_number: str = Field(..., min_length=1, max_length=20)
|
| 15 |
+
|
| 16 |
+
@model_validator(mode="after")
|
| 17 |
+
def validate_phone_number_format(self):
|
| 18 |
+
if not re.fullmatch(r"[0-9]{8,15}", self.phone_number):
|
| 19 |
+
raise ValueError("電話號碼格式錯誤,請輸入 8~15 碼數字")
|
| 20 |
+
return self
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
VALID_PHONE_TYPES = [
|
| 24 |
+
"約會交友",
|
| 25 |
+
"假投資",
|
| 26 |
+
"假信貸",
|
| 27 |
+
"假冒公務",
|
| 28 |
+
"假包裹釣魚",
|
| 29 |
+
"假求職",
|
| 30 |
+
"商業騷擾",
|
| 31 |
+
"假冒電商",
|
| 32 |
+
"其他",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class PhoneReportRequest(BaseModel):
|
| 37 |
+
model_config = ConfigDict(
|
| 38 |
+
alias_generator=to_camel,
|
| 39 |
+
populate_by_name=True,
|
| 40 |
+
serialize_by_alias=True,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
phone_number: str = Field(..., min_length=1, max_length=20)
|
| 44 |
+
phone_type: str = Field(..., min_length=1, max_length=50)
|
| 45 |
+
other_type: str | None = Field(None, max_length=100)
|
| 46 |
+
reporter_phone: str | None = Field(None, max_length=20)
|
| 47 |
+
transfer_type: int | None = Field(default=0)
|
| 48 |
+
transfer_content: str | None = Field(None, max_length=500)
|
| 49 |
+
|
| 50 |
+
@model_validator(mode="after")
|
| 51 |
+
def validate_phone_number_format(self):
|
| 52 |
+
if not re.fullmatch(r"[0-9]{8,15}", self.phone_number):
|
| 53 |
+
raise ValueError("電話號碼格式錯誤,請輸入 8~15 碼數字")
|
| 54 |
+
if self.reporter_phone and not re.fullmatch(r"[0-9]{8,15}", self.reporter_phone):
|
| 55 |
+
raise ValueError("回報者電話格式錯誤,請輸入 8~15 碼數字")
|
| 56 |
+
if self.transfer_type == 1 and self.transfer_content and not re.fullmatch(r"[0-9]{8,15}", self.transfer_content):
|
| 57 |
+
raise ValueError("轉介類型為電話號碼時,轉介內容必須為 8~15 碼數字")
|
| 58 |
+
return self
|
| 59 |
+
|
| 60 |
+
@model_validator(mode="after")
|
| 61 |
+
def validate_phone_type(self):
|
| 62 |
+
if self.phone_type not in VALID_PHONE_TYPES:
|
| 63 |
+
raise ValueError(
|
| 64 |
+
"phone_type 必須為其中之一:約會交友、假投資、假信貸、假冒公務、假包裹釣魚、假求職、商業騷擾、假冒電商、其他"
|
| 65 |
+
)
|
| 66 |
+
if self.phone_type == "其他" and not self.other_type:
|
| 67 |
+
raise ValueError("phone_type 為 '其他' 時,必須提供 other_type")
|
| 68 |
+
if self.transfer_type not in (0, 1, 2, 3, 4, 9):
|
| 69 |
+
raise ValueError("transfer_type 只能為 0、1、2、3、4、9,分別代表 無 / 電話 / LINE / URL / TG / 其他")
|
| 70 |
+
if self.transfer_type == 0 and self.transfer_content:
|
| 71 |
+
self.transfer_content = None
|
| 72 |
+
return self
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class FamilyStaticItems(BaseModel):
|
| 76 |
+
"""電話家族的直接關聯證據"""
|
| 77 |
+
model_config = ConfigDict(
|
| 78 |
+
alias_generator=to_camel,
|
| 79 |
+
populate_by_name=True,
|
| 80 |
+
serialize_by_alias=True,
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
related_phone: str = Field(..., description="關聯電話號碼")
|
| 84 |
+
weight: int = Field(..., ge=0, description="關聯強度分數")
|
| 85 |
+
reason: str = Field(..., description="關聯原因")
|
| 86 |
+
target_phone_type: str = Field(..., description="查詢號碼的詐騙類型")
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class PhoneQueryResponse(BaseModel):
|
| 90 |
+
model_config = ConfigDict(
|
| 91 |
+
alias_generator=to_camel,
|
| 92 |
+
populate_by_name=True,
|
| 93 |
+
serialize_by_alias=True,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
phone_number: str
|
| 97 |
+
status: str | None = None
|
| 98 |
+
phone_type: str | None = None
|
| 99 |
+
total_reports: int | None = None
|
| 100 |
+
first_reported_at: str | None = None
|
| 101 |
+
last_reported_at: str | None = None
|
| 102 |
+
owner_name: str | None = None
|
| 103 |
+
can_report: bool = False
|
| 104 |
+
# family analysis results (minimal but useful evidence)
|
| 105 |
+
family_static: list[FamilyStaticItems] | None = Field(
|
| 106 |
+
None,
|
| 107 |
+
description="直接關聯證據列表;如果沒有關聯,則為空陣列。",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
class PhoneReportResponse(BaseModel):
|
| 111 |
+
model_config = ConfigDict(
|
| 112 |
+
alias_generator=to_camel,
|
| 113 |
+
populate_by_name=True,
|
| 114 |
+
serialize_by_alias=True,
|
| 115 |
+
)
|
| 116 |
+
phone_number: str
|
| 117 |
+
status: str
|
| 118 |
+
total_reports: int
|
| 119 |
+
report_time: str
|
| 120 |
+
message: str
|
| 121 |
+
connection_reason: str | None = None
|
| 122 |
+
|
| 123 |
+
class PhoneReportError(Exception):
|
| 124 |
+
pass
|
backend/api/schemas/text.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 2 |
+
from pydantic.alias_generators import to_camel
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class TextRequest(BaseModel):
|
| 6 |
+
text: str = Field(..., min_length=1, max_length=10000)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class TextResponse(BaseModel):
|
| 10 |
+
model_config = ConfigDict(
|
| 11 |
+
alias_generator=to_camel,
|
| 12 |
+
populate_by_name=True,
|
| 13 |
+
serialize_by_alias=True,
|
| 14 |
+
)
|
| 15 |
+
label: str
|
| 16 |
+
score: str | float | None = None
|
| 17 |
+
reason: str | None = None
|
| 18 |
+
cls_model_confidence: float | None = None
|
backend/api/schemas/url.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, HttpUrl
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class UrlRequest(BaseModel):
|
| 5 |
+
url: HttpUrl
|
| 6 |
+
|
| 7 |
+
class UrlResponse(BaseModel):
|
| 8 |
+
label: str
|
| 9 |
+
score: str | float | None = None
|
| 10 |
+
reason: str
|
backend/core/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from pydantic import SecretStr, field_validator
|
| 5 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
+
|
| 7 |
+
CORE_DIR = Path(__file__).resolve().parent
|
| 8 |
+
BACKEND_DIR = CORE_DIR.parent
|
| 9 |
+
ROOT_DIR = BACKEND_DIR.parent
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Settings(BaseSettings):
|
| 13 |
+
APP_NAME: str = "Scam detetcor"
|
| 14 |
+
DEBUG: bool = True
|
| 15 |
+
|
| 16 |
+
HF_TEXT_REPO_ID: str = "scam-project/spam-detector-chinese"
|
| 17 |
+
HF_URL_REPO_ID: str = "scam-project/url-detector"
|
| 18 |
+
HF_PERSIST_REPO_ID: str = "scam-project/scam-rag-db"
|
| 19 |
+
HF_TOKEN: str = ""
|
| 20 |
+
|
| 21 |
+
REGEX_WEIGHT: float = 0.55
|
| 22 |
+
MODEL_WEIGHT: float = 0.45
|
| 23 |
+
|
| 24 |
+
HIGH_THRESHOLD: float = 0.5
|
| 25 |
+
MEDIUM_THRESHOLD: float = 0.6
|
| 26 |
+
UNKNOWN_THRESHOLD: float = 0.7
|
| 27 |
+
|
| 28 |
+
URL_HIGH_THRESHOLD: float = 0.5
|
| 29 |
+
URL_MEDIUM_THRESHOLD: float = 0.3
|
| 30 |
+
URL_MODEL_NAME: str = "url_scam_classifier.joblib"
|
| 31 |
+
URL_TW_RISK_BONUS: float = 0.3
|
| 32 |
+
URL_EXPAND_SHORTENED: bool = True
|
| 33 |
+
URL_EXPAND_TIMEOUT_SECONDS: float = 3.0
|
| 34 |
+
URL_EXPAND_MAX_REDIRECTS: int = 5
|
| 35 |
+
|
| 36 |
+
DEVICE: str = "cpu"
|
| 37 |
+
BASE_MODEL_PATH: str = str(BACKEND_DIR / "models")
|
| 38 |
+
|
| 39 |
+
CHUNCK_SIZE: int = 500
|
| 40 |
+
CHUNCK_OVERLAP: int = 50
|
| 41 |
+
|
| 42 |
+
RAG_MODEL_NAME: str = "openai/gpt-oss-20b"
|
| 43 |
+
RAG_ENABLED: bool = True
|
| 44 |
+
RAG_TOP_K: int = 3
|
| 45 |
+
RAG_RECORD_LIMIT: int = 2100
|
| 46 |
+
RAG_DATASET_PATH: str = str(ROOT_DIR / "data" / "raw" / "scam-dataset.json")
|
| 47 |
+
RAG_PERSIST_DIR: str = str(ROOT_DIR / "data" / "chroma")
|
| 48 |
+
EMBED_MODEL: str = "BAAI/bge-small-zh-v1.5"
|
| 49 |
+
|
| 50 |
+
NORMALIZER_MODEL: str = "openai/gpt-oss-120b"
|
| 51 |
+
PRICE_MODEL: str = "openai/gpt-oss-120b"
|
| 52 |
+
REVIEW_MODEL: str = "qwen/qwen3.8-27b"
|
| 53 |
+
# 空字串代表不指定推理強度;gpt-oss 系列可用 low/medium/high
|
| 54 |
+
NORMALIZER_REASONING_EFFORT: str = "low"
|
| 55 |
+
PRICE_REASONING_EFFORT: str = "low"
|
| 56 |
+
PRICE_LLM_TIMEOUT_SECONDS: float = 10.0
|
| 57 |
+
PRICE_LLM_MAX_RETRIES: int = 1
|
| 58 |
+
TAVILY_SEARCH_API_KEY: SecretStr = SecretStr("")
|
| 59 |
+
SERP_API_KEY: SecretStr = SecretStr("")
|
| 60 |
+
SEARCH_COUNTRY: str = "taiwan"
|
| 61 |
+
|
| 62 |
+
ALLOWED_IMAGE_TYPES : set[str] = {"image/jpeg", "image/png", "image/webp"}
|
| 63 |
+
MAX_IMAGE_BYTES : int = 20 * 1024 * 1024 # 20MB
|
| 64 |
+
MARKET_PRICE_DB_PATH: str = str(ROOT_DIR / "data" / "market_prices_tw.json")
|
| 65 |
+
ONLINE_PRICE_ENABLED: bool = True
|
| 66 |
+
ONLINE_PRICE_MAX_RESULTS: int = 20
|
| 67 |
+
SEARCH_DOMAIN: list[str] = ["biggo.com.tw", "momo.com.tw", "pchome.com.tw", "shopee.tw", "ruten.com.tw", "tw.carousell.com"]
|
| 68 |
+
EXCLUDE_DOMAIN: list[str] = ["zh.wikipedia.org"]
|
| 69 |
+
SEARCH_TIMEOUT_SECONDS: float = 10.0
|
| 70 |
+
GROQ_FALLBACK_RETRY_DELAY_SECONDS: float = 2.0
|
| 71 |
+
GROQ_RATE_LIMIT_MAX_WAIT_SECONDS: float = 30.0
|
| 72 |
+
PRODUCT_MATCH_FUZZY_MIN_SCORE: int = 86
|
| 73 |
+
ENABLE_INTELLIGENT_LAYER: bool = True
|
| 74 |
+
CASE_LOG_PATH: str = str(ROOT_DIR / "data" / "cases" / "image_analysis_cases.jsonl")
|
| 75 |
+
CASE_MEMORY_ENABLED: bool = True
|
| 76 |
+
OCR_PROVIDER: str = "google_vision"
|
| 77 |
+
GCV_LANGUAGE_HINTS: str = "zh-TW,en"
|
| 78 |
+
GCP_OCR_SERVICE_ACCOUNT_JSON : str = ""
|
| 79 |
+
|
| 80 |
+
DB_USERNAME: str = ""
|
| 81 |
+
DB_PASSWORD: str = ""
|
| 82 |
+
DB_HOST: str = ""
|
| 83 |
+
DB_PORT: int | None = None
|
| 84 |
+
DB_NAME: str = ""
|
| 85 |
+
DB_TIMEOUT: int = 10
|
| 86 |
+
DB_SSL_CA: str = ""
|
| 87 |
+
|
| 88 |
+
GROQ_API_KEY: SecretStr = SecretStr("")
|
| 89 |
+
model_config = SettingsConfigDict(
|
| 90 |
+
env_file=[os.path.join(BACKEND_DIR, ".env"),
|
| 91 |
+
os.path.join(ROOT_DIR, ".env")],
|
| 92 |
+
extra="ignore",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
@field_validator("DEBUG", mode="before")
|
| 96 |
+
@classmethod
|
| 97 |
+
def parse_debug(cls, value: object) -> object:
|
| 98 |
+
if isinstance(value, str):
|
| 99 |
+
normalized = value.strip().lower()
|
| 100 |
+
if normalized in {"release", "prod", "production"}:
|
| 101 |
+
return False
|
| 102 |
+
if normalized in {"debug", "dev", "development"}:
|
| 103 |
+
return True
|
| 104 |
+
return value
|
| 105 |
+
|
| 106 |
+
@field_validator("DB_PORT", mode="before")
|
| 107 |
+
@classmethod
|
| 108 |
+
def parse_db_port(cls, value: object) -> object:
|
| 109 |
+
if isinstance(value, str) and not value.strip():
|
| 110 |
+
return None
|
| 111 |
+
return value
|
| 112 |
+
|
| 113 |
+
@field_validator("DB_SSL_CA")
|
| 114 |
+
@classmethod
|
| 115 |
+
def resolve_db_ssl_ca(cls, value: str) -> str:
|
| 116 |
+
if value and not os.path.isabs(value):
|
| 117 |
+
return str(ROOT_DIR / value)
|
| 118 |
+
return value
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
settings = Settings()
|
backend/core/lifespan.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from langchain_chroma import Chroma
|
| 6 |
+
from langchain_huggingface import HuggingFaceEmbeddings
|
| 7 |
+
|
| 8 |
+
from backend.core.config import settings
|
| 9 |
+
from backend.rag.rag_context import RAGContext
|
| 10 |
+
from backend.rag.rag_reasoner import get_rag_chain
|
| 11 |
+
from backend.rag.rag_retriever import is_rag_ready
|
| 12 |
+
from backend.services.text_service.base_classifier import base_classifier
|
| 13 |
+
from backend.services.url_service.url_analyzer import url_detector
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@asynccontextmanager
|
| 19 |
+
async def lifespan(app: FastAPI):
|
| 20 |
+
print("應用環境啟動")
|
| 21 |
+
|
| 22 |
+
base_classifier.load_model()
|
| 23 |
+
url_detector.load_model()
|
| 24 |
+
|
| 25 |
+
# 預熱:ONNX session 首次推論與 tldextract 後綴表載入(可能需連網)都很慢,
|
| 26 |
+
# 在啟動時先跑一次,避免由第一個使用者請求承擔
|
| 27 |
+
base_classifier.predict_text("預熱")
|
| 28 |
+
url_detector.analyze("https://www.example.com/")
|
| 29 |
+
|
| 30 |
+
print("初始化 RAG")
|
| 31 |
+
RAGContext.set_app(app)
|
| 32 |
+
if not is_rag_ready():
|
| 33 |
+
RAGContext.load_vectorstore()
|
| 34 |
+
|
| 35 |
+
embeddings = HuggingFaceEmbeddings(model_name=settings.EMBED_MODEL)
|
| 36 |
+
embeddings.embed_query("warmup")
|
| 37 |
+
app.state.embeddings = embeddings
|
| 38 |
+
app.state.vectorstore = Chroma(
|
| 39 |
+
persist_directory=settings.RAG_PERSIST_DIR, embedding_function=embeddings
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
if is_rag_ready():
|
| 43 |
+
try:
|
| 44 |
+
get_rag_chain()
|
| 45 |
+
except Exception:
|
| 46 |
+
# 例如尚未設定 GROQ_API_KEY;保留延遲建立,第一次 RAG 請求時再嘗試
|
| 47 |
+
logger.warning("RAG chain 預先建立失敗,改為首次請求時建立", exc_info=True)
|
| 48 |
+
print("RAG 已初始化")
|
| 49 |
+
|
| 50 |
+
yield
|
| 51 |
+
print("應用關閉")
|
backend/core/logging.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def configure_logging():
|
| 5 |
+
"""配置日誌系統"""
|
| 6 |
+
logging.basicConfig(
|
| 7 |
+
level=logging.INFO,
|
| 8 |
+
format="%(levelname)s:%(name)s:%(message)s",
|
| 9 |
+
force=True
|
| 10 |
+
)
|
backend/main.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
|
| 4 |
+
from backend.api.exception_handler import register_exception_handlers
|
| 5 |
+
from backend.api.routers import (image_price_validation, phone_detection,
|
| 6 |
+
text_inference, url_detection)
|
| 7 |
+
from backend.core.lifespan import lifespan
|
| 8 |
+
from backend.core.logging import configure_logging
|
| 9 |
+
|
| 10 |
+
configure_logging()
|
| 11 |
+
|
| 12 |
+
# 主入口
|
| 13 |
+
app = FastAPI(
|
| 14 |
+
lifespan=lifespan,
|
| 15 |
+
title="詐騙風險偵測API",
|
| 16 |
+
description="用於檢測詐騙風險的API",
|
| 17 |
+
version="1.0.0"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
# 註冊全域例外處理
|
| 21 |
+
register_exception_handlers(app)
|
| 22 |
+
|
| 23 |
+
# CORS middleware
|
| 24 |
+
app.add_middleware(
|
| 25 |
+
CORSMiddleware,
|
| 26 |
+
allow_origins=["*"],
|
| 27 |
+
allow_credentials=False,
|
| 28 |
+
allow_methods=["*"],
|
| 29 |
+
allow_headers=["*"],
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# 掛載router
|
| 33 |
+
app.include_router(text_inference.router, prefix="/api")
|
| 34 |
+
app.include_router(url_detection.router, prefix="/api")
|
| 35 |
+
app.include_router(image_price_validation.router, prefix="/api")
|
| 36 |
+
app.include_router(phone_detection.router, prefix="/api")
|
| 37 |
+
|
| 38 |
+
@app.get("/")
|
| 39 |
+
async def root():
|
| 40 |
+
return {"message": "AI API is running..."}
|
backend/persistence/mysql_connection.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import create_engine
|
| 4 |
+
from sqlalchemy.engine import URL
|
| 5 |
+
from sqlalchemy.orm import sessionmaker
|
| 6 |
+
|
| 7 |
+
from backend.core.config import settings
|
| 8 |
+
|
| 9 |
+
host_name = settings.DB_HOST.strip()
|
| 10 |
+
port_number = settings.DB_PORT
|
| 11 |
+
if host_name and ":" in host_name and host_name.count(":") == 1:
|
| 12 |
+
host_candidate, port_candidate = host_name.rsplit(":", 1)
|
| 13 |
+
if port_candidate.isdigit():
|
| 14 |
+
host_name = host_candidate
|
| 15 |
+
port_number = int(port_candidate)
|
| 16 |
+
|
| 17 |
+
database_url = URL.create(
|
| 18 |
+
drivername="mysql+pymysql",
|
| 19 |
+
username=settings.DB_USERNAME,
|
| 20 |
+
password=settings.DB_PASSWORD,
|
| 21 |
+
host=host_name,
|
| 22 |
+
port=port_number,
|
| 23 |
+
database=settings.DB_NAME,
|
| 24 |
+
query={"charset": "utf8mb4"},
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
ssl_config = {"ca": settings.DB_SSL_CA} if settings.DB_SSL_CA else {}
|
| 28 |
+
|
| 29 |
+
connect_args: dict[str, Any] = {"connect_timeout": settings.DB_TIMEOUT}
|
| 30 |
+
if ssl_config:
|
| 31 |
+
connect_args["ssl"] = ssl_config
|
| 32 |
+
|
| 33 |
+
engine = create_engine(
|
| 34 |
+
database_url,
|
| 35 |
+
pool_pre_ping=True,
|
| 36 |
+
pool_recycle=3600,
|
| 37 |
+
connect_args=connect_args,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
SessionLocal = sessionmaker(
|
| 41 |
+
autocommit=False,
|
| 42 |
+
autoflush=False,
|
| 43 |
+
bind=engine,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def get_db():
|
| 48 |
+
db = SessionLocal()
|
| 49 |
+
try:
|
| 50 |
+
yield db
|
| 51 |
+
finally:
|
| 52 |
+
db.close()
|
backend/providers/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""外部模型與服務供應者。"""
|
| 2 |
+
|
| 3 |
+
from backend.providers.groq import GroqProvider, groq_provider
|
| 4 |
+
|
| 5 |
+
__all__ = ["GroqProvider", "groq_provider"]
|
backend/providers/groq.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""集中建立 Groq 聊天模型。"""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from langchain_core.language_models.chat_models import BaseChatModel
|
| 7 |
+
from pydantic import SecretStr
|
| 8 |
+
|
| 9 |
+
from backend.core.config import settings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class GroqProvider:
|
| 13 |
+
"""建立具有一致 API key 處理方式的 Groq 聊天模型。"""
|
| 14 |
+
|
| 15 |
+
@staticmethod
|
| 16 |
+
def create(
|
| 17 |
+
*,
|
| 18 |
+
model: str,
|
| 19 |
+
temperature: float = 0,
|
| 20 |
+
api_key: str | SecretStr | None = None,
|
| 21 |
+
reasoning_effort: str | None = None,
|
| 22 |
+
timeout: float | None = None,
|
| 23 |
+
max_retries: int | None = None,
|
| 24 |
+
model_kwargs: dict[str, Any] | None = None,
|
| 25 |
+
) -> BaseChatModel:
|
| 26 |
+
"""依用途建立 Groq 模型,僅傳入有設定的選用參數。"""
|
| 27 |
+
from langchain_groq import ChatGroq
|
| 28 |
+
|
| 29 |
+
kwargs: dict[str, Any] = {
|
| 30 |
+
"model": model,
|
| 31 |
+
"temperature": temperature,
|
| 32 |
+
}
|
| 33 |
+
secret = GroqProvider.resolve_api_key(api_key)
|
| 34 |
+
if secret is not None:
|
| 35 |
+
kwargs["api_key"] = secret
|
| 36 |
+
if reasoning_effort:
|
| 37 |
+
kwargs["reasoning_effort"] = reasoning_effort
|
| 38 |
+
if timeout is not None:
|
| 39 |
+
kwargs["timeout"] = timeout
|
| 40 |
+
if max_retries is not None:
|
| 41 |
+
kwargs["max_retries"] = max_retries
|
| 42 |
+
if model_kwargs:
|
| 43 |
+
kwargs["model_kwargs"] = dict(model_kwargs)
|
| 44 |
+
|
| 45 |
+
return ChatGroq(**kwargs)
|
| 46 |
+
|
| 47 |
+
@classmethod
|
| 48 |
+
def resolve_api_key(
|
| 49 |
+
cls,
|
| 50 |
+
value: str | SecretStr | None = None,
|
| 51 |
+
) -> SecretStr | None:
|
| 52 |
+
"""依序解析指定值、應用設定與環境變數中的 Groq API key。"""
|
| 53 |
+
secret = cls._secret(value)
|
| 54 |
+
if secret is not None:
|
| 55 |
+
return secret
|
| 56 |
+
|
| 57 |
+
secret = cls._secret(settings.GROQ_API_KEY)
|
| 58 |
+
if secret is not None:
|
| 59 |
+
return secret
|
| 60 |
+
|
| 61 |
+
return cls._secret(os.getenv("GROQ_API_KEY"))
|
| 62 |
+
|
| 63 |
+
@classmethod
|
| 64 |
+
def is_configured(
|
| 65 |
+
cls,
|
| 66 |
+
value: str | SecretStr | None = None,
|
| 67 |
+
) -> bool:
|
| 68 |
+
"""確認是否有可用的 Groq API key。"""
|
| 69 |
+
return cls.resolve_api_key(value) is not None
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
def _secret(value: str | SecretStr | None) -> SecretStr | None:
|
| 73 |
+
"""將單一非空 API key 正規化為 SecretStr。"""
|
| 74 |
+
if isinstance(value, SecretStr):
|
| 75 |
+
return value if value.get_secret_value() else None
|
| 76 |
+
if isinstance(value, str) and value.strip():
|
| 77 |
+
return SecretStr(value.strip())
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
groq_provider = GroqProvider()
|
| 82 |
+
|
| 83 |
+
__all__ = ["GroqProvider", "groq_provider"]
|
backend/rag/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""RAG (Retrieval-Augmented Generation) module"""
|
backend/rag/dto/__init__.py
ADDED
|
File without changes
|
backend/rag/dto/rag_analysis.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class RagEvidence(BaseModel):
|
| 5 |
+
used: bool
|
| 6 |
+
label: str | None = None
|
| 7 |
+
score: float | None = None
|
| 8 |
+
reason: str | None = None
|
| 9 |
+
raw_response: str | None = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class RagResponse(BaseModel):
|
| 13 |
+
urgency: float = Field(ge=0, le=1, description="催促語氣")
|
| 14 |
+
money_related: float = Field(ge=0, le=1, description="金錢獲利")
|
| 15 |
+
baiting: float = Field(ge=0, le=1, description="異常誘導")
|
| 16 |
+
asks_for_personal_info: float = Field(ge=0, le=1, description="索取個資")
|
| 17 |
+
reputation: float = Field(ge=0, le=1, description="不可信度")
|
| 18 |
+
is_anti_fraud: float = Field(ge=0, le=1, description="是否為反詐騙")
|
| 19 |
+
social_engineering: float = Field(default=0.0, ge=0, le=1, description="社交工程和情緒操控")
|
| 20 |
+
reason: str = Field(..., min_length=50, max_length=300, description="原因")
|
| 21 |
+
suggestion: str = Field(..., min_length=10, max_length=30, description="建議")
|
backend/rag/rag_context.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from huggingface_hub import snapshot_download
|
| 5 |
+
|
| 6 |
+
from backend.core.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RAGContext:
|
| 10 |
+
"""全局 RAG 上下文,存儲 app 實例"""
|
| 11 |
+
|
| 12 |
+
_app: Any = None
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def load_vectorstore() -> None:
|
| 16 |
+
"""從 HuggingFace 加載向量資料庫"""
|
| 17 |
+
print("加載向量資料庫...")
|
| 18 |
+
snapshot_download(
|
| 19 |
+
repo_id=settings.HF_PERSIST_REPO_ID,
|
| 20 |
+
repo_type="dataset",
|
| 21 |
+
local_dir=settings.RAG_PERSIST_DIR,
|
| 22 |
+
token=settings.HF_TOKEN or None
|
| 23 |
+
)
|
| 24 |
+
print("向量資料庫加載完畢")
|
| 25 |
+
|
| 26 |
+
@classmethod
|
| 27 |
+
def set_app(cls, app: Any) -> None:
|
| 28 |
+
"""設置 FastAPI app 實例"""
|
| 29 |
+
cls._app = app
|
| 30 |
+
|
| 31 |
+
@classmethod
|
| 32 |
+
def get_app(cls) -> Any:
|
| 33 |
+
"""取得 FastAPI app 實例"""
|
| 34 |
+
if cls._app is None:
|
| 35 |
+
raise RuntimeError("RAG Context 未初始化,請先呼叫 set_app()")
|
| 36 |
+
return cls._app
|
| 37 |
+
|
| 38 |
+
@classmethod
|
| 39 |
+
def get_vectorstore(cls) -> Any:
|
| 40 |
+
"""取得 vectorstore"""
|
| 41 |
+
app = cls.get_app()
|
| 42 |
+
if not hasattr(app.state, 'vectorstore'):
|
| 43 |
+
raise RuntimeError("vectorstore 尚未初始化")
|
| 44 |
+
return app.state.vectorstore
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def get_embeddings(cls) -> Any:
|
| 48 |
+
"""取得 embeddings"""
|
| 49 |
+
app = cls.get_app()
|
| 50 |
+
if not hasattr(app.state, 'embeddings'):
|
| 51 |
+
raise RuntimeError("embeddings 尚未初始化")
|
| 52 |
+
return app.state.embeddings
|
backend/rag/rag_reasoner.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from backend.core.config import settings
|
| 5 |
+
from backend.providers import groq_provider
|
| 6 |
+
from backend.rag.dto.rag_analysis import RagEvidence, RagResponse
|
| 7 |
+
from backend.rag.rag_retriever import (format_context, get_retriever,
|
| 8 |
+
is_rag_ready, normalize_query_text)
|
| 9 |
+
from backend.utils.text_cleaner import normalize_escape_sequences
|
| 10 |
+
|
| 11 |
+
_rag_chain : Any | None = None
|
| 12 |
+
|
| 13 |
+
def _require_langchain() -> tuple[Any, Any, Any]:
|
| 14 |
+
from langchain_core.output_parsers import JsonOutputParser
|
| 15 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 16 |
+
from langchain_core.runnables import RunnablePassthrough
|
| 17 |
+
|
| 18 |
+
return ChatPromptTemplate, RunnablePassthrough, JsonOutputParser
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_prompt() -> Any:
|
| 22 |
+
ChatPromptTemplate, _, _ = _require_langchain()
|
| 23 |
+
template = """
|
| 24 |
+
你是一個防詐風險分析專家,請根據訊息內容分析不同風險特徵的符合程度。
|
| 25 |
+
|
| 26 |
+
【參考案例 - 僅供理解詐騙模式,禁止複製其中文字到 reason】
|
| 27 |
+
{context}
|
| 28 |
+
【參考案例結束】
|
| 29 |
+
|
| 30 |
+
【待分析訊息 - 僅供理解內容,禁止複製其中文字到 reason】
|
| 31 |
+
{question}
|
| 32 |
+
【待分析訊息結束】
|
| 33 |
+
|
| 34 |
+
請依序完成以下判斷:
|
| 35 |
+
第一步:判斷這則訊息的主要目的
|
| 36 |
+
- A:提醒他人避免詐騙、警示風險、安全建議
|
| 37 |
+
- B:可疑訊息(詐騙、誘導、索資、施壓、領錢、假冒通知)
|
| 38 |
+
- C:一般安全訊息(正常日常對話、正常通知)
|
| 39 |
+
- D:對話類互動異常(對話中出現一方威脅、一方示弱、且具有情緒操控)
|
| 40 |
+
|
| 41 |
+
第二步:
|
| 42 |
+
- 若第一步為 A,分析以下維度(0~1):
|
| 43 |
+
- is_anti_fraud:是否具有反詐騙、教育勸導
|
| 44 |
+
直接輸出下方 JSON,reason,其他欄位全部填 0.0,用繁體中文說明,禁止複製原文
|
| 45 |
+
{format_instructions}
|
| 46 |
+
|
| 47 |
+
- 若第一步為 B 或 C,再分析以下維度(0~1):
|
| 48 |
+
- urgency:催促、限時、要求立即操作
|
| 49 |
+
- money_related:誘導金錢交易、投資、匯款,通知領錢
|
| 50 |
+
- baiting:不合理優惠、保證獲利、隱藏交易
|
| 51 |
+
- asks_for_personal_info:要求個資、帳號、驗證碼
|
| 52 |
+
- reputation:來源可疑、假冒權威、誇大承諾
|
| 53 |
+
- reason:禁止複製原文,用繁體中文說明訊息中出現哪些疑點
|
| 54 |
+
- suggestion:針對此風險給出一句具體的下一步建議,以「建議」開頭
|
| 55 |
+
|
| 56 |
+
- 若第一步為 D,分析以下維度(0~1):
|
| 57 |
+
- urgency:是否出現威脅、限時、製造恐慌
|
| 58 |
+
- money_related:是否涉及借錢、投資、轉帳請求,通知領錢
|
| 59 |
+
- baiting:是否以感情、同情、利益逐步誘導
|
| 60 |
+
- asks_for_personal_info:是否套問個資、帳號、行蹤
|
| 61 |
+
- reputation:身份是否可疑、前後矛盾、來路不明
|
| 62 |
+
- social_engineering:是否存在情緒操控、威脅(0~1)
|
| 63 |
+
- reason:禁止複製原文,用繁體中文具體說明對話中出現哪些可疑行為或話術
|
| 64 |
+
- suggestion:針對此風險給出一句具體的下一步建議,以「建議」開頭
|
| 65 |
+
輸出格式:
|
| 66 |
+
{format_instructions}
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
parser = _get_parser()
|
| 70 |
+
prompt = ChatPromptTemplate.from_template(
|
| 71 |
+
template,
|
| 72 |
+
partial_variables={"format_instructions": parser.get_format_instructions()}
|
| 73 |
+
)
|
| 74 |
+
return prompt
|
| 75 |
+
|
| 76 |
+
def _get_llm() -> Any:
|
| 77 |
+
return groq_provider.create(
|
| 78 |
+
model=settings.RAG_MODEL_NAME,
|
| 79 |
+
temperature=0.1,
|
| 80 |
+
model_kwargs={
|
| 81 |
+
"top_p": 0.7,
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _get_parser() -> Any:
|
| 87 |
+
_, _, JsonOutputParser = _require_langchain()
|
| 88 |
+
parser = JsonOutputParser(pydantic_object=RagResponse)
|
| 89 |
+
return parser
|
| 90 |
+
|
| 91 |
+
def get_rag_chain() -> Any:
|
| 92 |
+
global _rag_chain
|
| 93 |
+
if _rag_chain is None:
|
| 94 |
+
_rag_chain = build_rag_chain()
|
| 95 |
+
return _rag_chain
|
| 96 |
+
|
| 97 |
+
def build_rag_chain() -> Any:
|
| 98 |
+
_, RunnablePassthrough, _ = _require_langchain()
|
| 99 |
+
|
| 100 |
+
prompt = _get_prompt()
|
| 101 |
+
retriever = get_retriever()
|
| 102 |
+
llm = _get_llm()
|
| 103 |
+
parser = _get_parser()
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
{
|
| 107 |
+
"context": retriever | format_context,
|
| 108 |
+
"question": RunnablePassthrough() | normalize_query_text,
|
| 109 |
+
}
|
| 110 |
+
| prompt
|
| 111 |
+
| llm
|
| 112 |
+
| parser
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _to_float(v: Any) -> float:
|
| 117 |
+
"""安全轉換任意值為浮點數,預設為 0.0"""
|
| 118 |
+
try:
|
| 119 |
+
return float(v)
|
| 120 |
+
except (TypeError, ValueError):
|
| 121 |
+
return 0.0
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def parse_structured_response(text: str) -> tuple[str, float, str]:
|
| 125 |
+
payload = json.loads(text)
|
| 126 |
+
if not isinstance(payload, dict):
|
| 127 |
+
return "回傳錯誤", -1, ""
|
| 128 |
+
money = _to_float(payload.get("money_related"))
|
| 129 |
+
urgency = _to_float(payload.get("urgency"))
|
| 130 |
+
baiting = _to_float(payload.get("baiting"))
|
| 131 |
+
personal_info = _to_float(payload.get("asks_for_personal_info"))
|
| 132 |
+
is_anti_fraud = _to_float(payload.get("is_anti_fraud"))
|
| 133 |
+
social_engineering = _to_float(payload.get("social_engineering"))
|
| 134 |
+
reputation = _to_float(payload.get("reputation"))
|
| 135 |
+
|
| 136 |
+
print(money, urgency, baiting, personal_info, is_anti_fraud, social_engineering, reputation, end='\n')
|
| 137 |
+
|
| 138 |
+
score = min((money * 0.25 +
|
| 139 |
+
urgency * 0.30 +
|
| 140 |
+
baiting * 0.25 +
|
| 141 |
+
personal_info * 0.25 +
|
| 142 |
+
social_engineering * 0.30 -
|
| 143 |
+
is_anti_fraud * 1 +
|
| 144 |
+
reputation * 0.30) * 100, 100)
|
| 145 |
+
|
| 146 |
+
score = max(score, 5)
|
| 147 |
+
suggestion = normalize_escape_sequences(str(payload.get("suggestion") or ""))
|
| 148 |
+
reason = normalize_escape_sequences(str(payload.get("reason") or ""))
|
| 149 |
+
|
| 150 |
+
label = "低風險"
|
| 151 |
+
if score >= 80:
|
| 152 |
+
label = "高風險"
|
| 153 |
+
elif score >= 40:
|
| 154 |
+
label = "中等風險"
|
| 155 |
+
|
| 156 |
+
return label, score, f"{reason}{suggestion}"
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
async def analyze_with_rag(message: str) -> RagEvidence:
|
| 160 |
+
if not is_rag_ready():
|
| 161 |
+
return RagEvidence(used=False)
|
| 162 |
+
|
| 163 |
+
rag_chain = get_rag_chain()
|
| 164 |
+
# 避免被同步請求卡住
|
| 165 |
+
chain_output = await rag_chain.ainvoke(message)
|
| 166 |
+
if isinstance(chain_output, dict):
|
| 167 |
+
raw_response = json.dumps(chain_output, ensure_ascii=False)
|
| 168 |
+
else:
|
| 169 |
+
raw_response = chain_output
|
| 170 |
+
label, score, reason = parse_structured_response(raw_response)
|
| 171 |
+
|
| 172 |
+
return RagEvidence(
|
| 173 |
+
used=True,
|
| 174 |
+
label=label,
|
| 175 |
+
score=score,
|
| 176 |
+
reason=reason,
|
| 177 |
+
raw_response=raw_response,
|
| 178 |
+
)
|
backend/rag/rag_retriever.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from backend.core.config import settings
|
| 5 |
+
from backend.rag.rag_context import RAGContext
|
| 6 |
+
from backend.utils.text_cleaner import normalize_text
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _get_vectorstore() -> Any:
|
| 10 |
+
try:
|
| 11 |
+
return RAGContext.get_vectorstore()
|
| 12 |
+
except RuntimeError as e:
|
| 13 |
+
print(f"取得 vectorstore 失敗: {e}")
|
| 14 |
+
raise
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def get_retriever() -> Any:
|
| 18 |
+
vectorstore = _get_vectorstore()
|
| 19 |
+
return vectorstore.as_retriever(search_kwargs={"k": settings.RAG_TOP_K})
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def format_context(docs: list[Any]) -> str:
|
| 23 |
+
blocks: list[str] = []
|
| 24 |
+
for idx, doc in enumerate(docs, start=1):
|
| 25 |
+
blocks.append(f"[相似案例 {idx}]\n{doc.page_content[:190]}") # 限制每句參考在190以內
|
| 26 |
+
return "\n\n".join(blocks)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def normalize_query_text(message: str) -> str:
|
| 30 |
+
cleaned = normalize_text(message)
|
| 31 |
+
return f"待分析訊息: {cleaned}"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def retrieve_similar_cases(message: str) -> list[Any]:
|
| 35 |
+
if not is_rag_ready():
|
| 36 |
+
return []
|
| 37 |
+
return get_retriever().invoke(normalize_query_text(message))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def is_rag_ready() -> bool:
|
| 41 |
+
if not settings.RAG_ENABLED:
|
| 42 |
+
return False
|
| 43 |
+
return Path(settings.RAG_PERSIST_DIR).exists()
|
backend/repository/__init__.py
ADDED
|
File without changes
|
backend/repository/case_repository.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from datetime import datetime, timezone
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from backend.core.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class CaseRepository:
|
| 10 |
+
def __init__(self, path: str | None = None):
|
| 11 |
+
self._path = Path(path or settings.CASE_LOG_PATH)
|
| 12 |
+
|
| 13 |
+
def save(self, payload: dict) -> str:
|
| 14 |
+
case_id = str(uuid4())
|
| 15 |
+
envelope = {
|
| 16 |
+
"case_id": case_id,
|
| 17 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 18 |
+
"payload": payload,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
with self._path.open("a", encoding="utf-8") as f:
|
| 23 |
+
f.write(json.dumps(envelope, ensure_ascii=False) + "\n")
|
| 24 |
+
|
| 25 |
+
return case_id
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
case_repository = CaseRepository()
|
backend/repository/family_repository.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterable
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import bindparam, text
|
| 7 |
+
|
| 8 |
+
from backend.persistence.mysql_connection import engine as db_engine
|
| 9 |
+
from backend.utils.phone_normalizer import normalize, normalize_db_phone
|
| 10 |
+
|
| 11 |
+
_SELF_ROW_QUERY = text(
|
| 12 |
+
"""
|
| 13 |
+
SELECT phone_number,
|
| 14 |
+
dominant_tag,
|
| 15 |
+
first_seen_at,
|
| 16 |
+
last_seen_at,
|
| 17 |
+
referral_type,
|
| 18 |
+
referral_info
|
| 19 |
+
FROM blacklist
|
| 20 |
+
WHERE phone_number IN (:phone_db, :phone_full)
|
| 21 |
+
LIMIT 1
|
| 22 |
+
"""
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
_RELATED_ROWS_QUERY = text(
|
| 26 |
+
"""
|
| 27 |
+
SELECT DISTINCT phone_number,
|
| 28 |
+
dominant_tag AS phone_type,
|
| 29 |
+
first_seen_at AS first_reported_at,
|
| 30 |
+
last_seen_at AS last_reported_at,
|
| 31 |
+
referral_type AS transfer_type,
|
| 32 |
+
referral_info AS transfer_content
|
| 33 |
+
FROM blacklist
|
| 34 |
+
WHERE phone_number NOT IN (:phone_db, :phone_full)
|
| 35 |
+
AND (
|
| 36 |
+
referral_info IN (:phone_db, :phone_full)
|
| 37 |
+
OR LEFT(phone_number, 6) = LEFT(:phone_db, 6)
|
| 38 |
+
OR dominant_tag = :phone_type
|
| 39 |
+
)
|
| 40 |
+
ORDER BY
|
| 41 |
+
CASE
|
| 42 |
+
WHEN referral_info IN (:phone_db, :phone_full) THEN 0
|
| 43 |
+
WHEN LEFT(phone_number, 6) = LEFT(:phone_db, 6) THEN 1
|
| 44 |
+
ELSE 2
|
| 45 |
+
END
|
| 46 |
+
LIMIT 20
|
| 47 |
+
"""
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
_REPORTER_PHONES_QUERY = text(
|
| 51 |
+
"""
|
| 52 |
+
SELECT DISTINCT reporter_phone
|
| 53 |
+
FROM fraud_reports
|
| 54 |
+
WHERE phone_number IN (:phone_db, :phone_full)
|
| 55 |
+
LIMIT 10
|
| 56 |
+
"""
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
_REPORTER_ROWS_QUERY = text(
|
| 60 |
+
"""
|
| 61 |
+
SELECT DISTINCT fr.phone_number,
|
| 62 |
+
fr.tag AS phone_type,
|
| 63 |
+
fr.report_time AS first_reported_at,
|
| 64 |
+
fr.report_time AS last_reported_at,
|
| 65 |
+
fr.referral_type AS transfer_type,
|
| 66 |
+
fr.referral_info AS transfer_content
|
| 67 |
+
FROM fraud_reports fr
|
| 68 |
+
WHERE fr.reporter_phone IN :reporter_phone_list
|
| 69 |
+
LIMIT 20
|
| 70 |
+
"""
|
| 71 |
+
).bindparams(bindparam("reporter_phone_list", expanding=True))
|
| 72 |
+
|
| 73 |
+
# 只撈指定號碼的通報事件;原本每次查詢都整張表載入,資料量越大越慢
|
| 74 |
+
_REPORT_EVENTS_QUERY = text(
|
| 75 |
+
"""
|
| 76 |
+
SELECT reporter_phone, phone_number, report_time
|
| 77 |
+
FROM fraud_reports
|
| 78 |
+
WHERE phone_number IN :phone_list
|
| 79 |
+
AND reporter_phone IS NOT NULL
|
| 80 |
+
AND report_time IS NOT NULL
|
| 81 |
+
"""
|
| 82 |
+
).bindparams(bindparam("phone_list", expanding=True))
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class FamilyRepository:
|
| 86 |
+
def __init__(self, engine: Any | None = None):
|
| 87 |
+
self._engine = engine or db_engine
|
| 88 |
+
|
| 89 |
+
def list_fraud_report_events(
|
| 90 |
+
self, phones: Iterable[str], engine: Any | None = None
|
| 91 |
+
) -> dict[str, list[tuple[str, Any]]]:
|
| 92 |
+
"""回傳 {通報者: [(被通報號碼, 通報時間), ...]},只包含 phones 內號碼的事件。
|
| 93 |
+
|
| 94 |
+
號碼同時以完整格式與資料庫格式(去前導 0)查詢,與本模組其他查詢的假設一致。
|
| 95 |
+
"""
|
| 96 |
+
active_engine = engine or self._engine
|
| 97 |
+
phone_list = sorted(
|
| 98 |
+
{form for phone in phones for form in (phone, normalize_db_phone(phone)) if form}
|
| 99 |
+
)
|
| 100 |
+
if active_engine is None or not phone_list:
|
| 101 |
+
return {}
|
| 102 |
+
|
| 103 |
+
with active_engine.connect() as conn:
|
| 104 |
+
rows = conn.execute(_REPORT_EVENTS_QUERY, {"phone_list": phone_list}).mappings().all()
|
| 105 |
+
|
| 106 |
+
events: dict[str, list[tuple[str, Any]]] = {}
|
| 107 |
+
for row in rows:
|
| 108 |
+
reporter = normalize(row.get("reporter_phone"))
|
| 109 |
+
phone = normalize(row.get("phone_number"))
|
| 110 |
+
reported_at = row.get("report_time")
|
| 111 |
+
if reporter and phone and reported_at:
|
| 112 |
+
events.setdefault(reporter, []).append((phone, reported_at))
|
| 113 |
+
return events
|
| 114 |
+
|
| 115 |
+
def list_family_records(
|
| 116 |
+
self, phone_number: str, engine: Any | None = None
|
| 117 |
+
) -> list[dict[str, Any]]:
|
| 118 |
+
"""撈出查詢號碼本身、可能相關的黑名單號碼與同通報者通報過的號碼,作為家族分析輸入。"""
|
| 119 |
+
active_engine = engine or self._engine
|
| 120 |
+
normalized_phone = normalize(phone_number)
|
| 121 |
+
if active_engine is None or not normalized_phone:
|
| 122 |
+
return []
|
| 123 |
+
|
| 124 |
+
phone_params = {
|
| 125 |
+
"phone_db": normalize_db_phone(phone_number),
|
| 126 |
+
"phone_full": normalized_phone,
|
| 127 |
+
}
|
| 128 |
+
with active_engine.connect() as conn:
|
| 129 |
+
self_row = conn.execute(_SELF_ROW_QUERY, phone_params).mappings().first()
|
| 130 |
+
phone_type = self_row["dominant_tag"] if self_row else "其他"
|
| 131 |
+
related_rows = conn.execute(
|
| 132 |
+
_RELATED_ROWS_QUERY, {**phone_params, "phone_type": phone_type}
|
| 133 |
+
).mappings().all()
|
| 134 |
+
|
| 135 |
+
reporter_phones = [
|
| 136 |
+
row["reporter_phone"]
|
| 137 |
+
for row in conn.execute(_REPORTER_PHONES_QUERY, phone_params).mappings().all()
|
| 138 |
+
if row.get("reporter_phone")
|
| 139 |
+
]
|
| 140 |
+
reporter_rows = []
|
| 141 |
+
if reporter_phones:
|
| 142 |
+
reporter_rows = conn.execute(
|
| 143 |
+
_REPORTER_ROWS_QUERY, {"reporter_phone_list": reporter_phones}
|
| 144 |
+
).mappings().all()
|
| 145 |
+
|
| 146 |
+
records: list[dict[str, Any]] = []
|
| 147 |
+
if self_row:
|
| 148 |
+
records.append({
|
| 149 |
+
"電話號碼": self_row["phone_number"],
|
| 150 |
+
"通報標籤": self_row["dominant_tag"] or "其他",
|
| 151 |
+
"首次通報時間": self_row["first_seen_at"],
|
| 152 |
+
"最後通報時間": self_row["last_seen_at"],
|
| 153 |
+
"轉介類型": self_row["referral_type"] or 0,
|
| 154 |
+
"轉介內容": self_row["referral_info"] or "",
|
| 155 |
+
"通報者識別碼": "",
|
| 156 |
+
"通報次數": 1,
|
| 157 |
+
})
|
| 158 |
+
for entry in [*related_rows, *reporter_rows]:
|
| 159 |
+
records.append({
|
| 160 |
+
"電話號碼": entry["phone_number"],
|
| 161 |
+
"通報標籤": entry["phone_type"] or "其他",
|
| 162 |
+
"首次通報時間": entry["first_reported_at"],
|
| 163 |
+
"最後通報時間": entry["last_reported_at"],
|
| 164 |
+
"轉介類型": entry["transfer_type"] or 0,
|
| 165 |
+
"轉介內容": entry["transfer_content"] or "",
|
| 166 |
+
"通報者識別碼": "",
|
| 167 |
+
"通報次數": 1,
|
| 168 |
+
})
|
| 169 |
+
return records
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
family_repository = FamilyRepository()
|
backend/repository/market_price_repository.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
import unicodedata
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import TypedDict
|
| 6 |
+
|
| 7 |
+
from rapidfuzz import fuzz
|
| 8 |
+
|
| 9 |
+
from backend.core.config import settings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class MarketPriceRecord(TypedDict):
|
| 13 |
+
aliases: list[str]
|
| 14 |
+
product_name: str
|
| 15 |
+
brand_model: str
|
| 16 |
+
market_price: int
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
DEFAULT_MARKET_PRICE_RECORDS: list[MarketPriceRecord] = [
|
| 20 |
+
{
|
| 21 |
+
"aliases": [
|
| 22 |
+
"Panasonic EH-NE11",
|
| 23 |
+
"國際牌 EH-NE11",
|
| 24 |
+
"EH-NE11",
|
| 25 |
+
"Panasonic 國際牌 1200W 負離子速乾型冷熱吹風機",
|
| 26 |
+
],
|
| 27 |
+
"product_name": "Panasonic 國際牌 1200W 負離子速乾型冷熱吹風機",
|
| 28 |
+
"brand_model": "Panasonic EH-NE11",
|
| 29 |
+
"market_price": 1290,
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"aliases": ["iPhone 15", "Apple iPhone 15"],
|
| 33 |
+
"product_name": "Apple iPhone 15",
|
| 34 |
+
"brand_model": "Apple iPhone 15",
|
| 35 |
+
"market_price": 27900,
|
| 36 |
+
},
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class MarketPriceRepository:
|
| 41 |
+
def __init__(self, source_path: str | None = None):
|
| 42 |
+
self._source_path = Path(source_path or settings.MARKET_PRICE_DB_PATH)
|
| 43 |
+
self._records = self._load_records()
|
| 44 |
+
self._alias_index = self._build_alias_index(self._records)
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def _build_alias_index(
|
| 48 |
+
cls, records: list[MarketPriceRecord]
|
| 49 |
+
) -> list[tuple[MarketPriceRecord, str, str]]:
|
| 50 |
+
"""預先算好每個別名的精確比對與模糊比對正規化結果,避免每次查詢重算。"""
|
| 51 |
+
return [
|
| 52 |
+
(item, cls._normalize(alias), cls._normalize_for_fuzzy(alias))
|
| 53 |
+
for item in records
|
| 54 |
+
for alias in item["aliases"]
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
def _normalize(text: str) -> str:
|
| 59 |
+
text = unicodedata.normalize("NFKC", text).lower()
|
| 60 |
+
return re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "", text)
|
| 61 |
+
|
| 62 |
+
@staticmethod
|
| 63 |
+
def _normalize_for_fuzzy(text: str) -> str:
|
| 64 |
+
text = unicodedata.normalize("NFKC", text).lower()
|
| 65 |
+
text = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", text)
|
| 66 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 67 |
+
|
| 68 |
+
def _load_records(self) -> list[MarketPriceRecord]:
|
| 69 |
+
if not self._source_path.exists():
|
| 70 |
+
return DEFAULT_MARKET_PRICE_RECORDS.copy()
|
| 71 |
+
|
| 72 |
+
with self._source_path.open("r", encoding="utf-8") as f:
|
| 73 |
+
raw = json.load(f)
|
| 74 |
+
|
| 75 |
+
records: list[MarketPriceRecord] = []
|
| 76 |
+
for row in raw:
|
| 77 |
+
aliases = [str(a) for a in row.get("aliases", []) if str(a).strip()]
|
| 78 |
+
if not aliases:
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
market_price = int(row.get("market_price", 0) or 0)
|
| 82 |
+
records.append(
|
| 83 |
+
{
|
| 84 |
+
"aliases": aliases,
|
| 85 |
+
"product_name": str(row.get("product_name", "未知商品")),
|
| 86 |
+
"brand_model": str(row.get("brand_model", "未知型號")),
|
| 87 |
+
"market_price": market_price,
|
| 88 |
+
}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
return records
|
| 92 |
+
|
| 93 |
+
def reload(self) -> None:
|
| 94 |
+
self._records = self._load_records()
|
| 95 |
+
self._alias_index = self._build_alias_index(self._records)
|
| 96 |
+
|
| 97 |
+
def find_by_text(self, text: str) -> tuple[str, str, int]:
|
| 98 |
+
normalized = self._normalize(text)
|
| 99 |
+
for item, alias_exact, _ in self._alias_index:
|
| 100 |
+
if alias_exact in normalized:
|
| 101 |
+
return item["product_name"], item["brand_model"], item["market_price"]
|
| 102 |
+
|
| 103 |
+
# OCR 容錯:若有輕微辨識錯字,使用模糊比對兜底。
|
| 104 |
+
fuzzy_text = self._normalize_for_fuzzy(text)
|
| 105 |
+
best_score = 0.0
|
| 106 |
+
best_item: MarketPriceRecord | None = None
|
| 107 |
+
|
| 108 |
+
for item, _, alias_fuzzy in self._alias_index:
|
| 109 |
+
if not alias_fuzzy:
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
score = max(
|
| 113 |
+
float(fuzz.partial_ratio(alias_fuzzy, fuzzy_text)),
|
| 114 |
+
float(fuzz.token_set_ratio(alias_fuzzy, fuzzy_text)),
|
| 115 |
+
)
|
| 116 |
+
if score > best_score:
|
| 117 |
+
best_score = score
|
| 118 |
+
best_item = item
|
| 119 |
+
|
| 120 |
+
if best_item and best_score >= settings.PRODUCT_MATCH_FUZZY_MIN_SCORE:
|
| 121 |
+
return best_item["product_name"], best_item["brand_model"], best_item["market_price"]
|
| 122 |
+
|
| 123 |
+
return "未知商品", "未知型號", 0
|
| 124 |
+
|
| 125 |
+
market_price_repository = MarketPriceRepository()
|
backend/repository/phone_repository.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import text
|
| 7 |
+
from sqlalchemy.exc import OperationalError
|
| 8 |
+
|
| 9 |
+
from backend.persistence.mysql_connection import engine as db_engine
|
| 10 |
+
from backend.utils.phone_normalizer import normalize, normalize_db_phone
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PhoneRepository:
|
| 14 |
+
def __init__(self, engine: Any | None = None):
|
| 15 |
+
self._engine = engine or db_engine
|
| 16 |
+
|
| 17 |
+
def get_phone_record(self, phone_number: str) -> dict[str, Any] | None:
|
| 18 |
+
if self._engine is None:
|
| 19 |
+
return None
|
| 20 |
+
|
| 21 |
+
normalized_phone = normalize(phone_number)
|
| 22 |
+
db_phone = normalize_db_phone(phone_number)
|
| 23 |
+
query = text(
|
| 24 |
+
"""
|
| 25 |
+
SELECT p.phone_number,
|
| 26 |
+
p.tags,
|
| 27 |
+
b.report_count AS total_reports,
|
| 28 |
+
b.first_seen_at AS first_reported_at,
|
| 29 |
+
b.last_seen_at AS last_reported_at,
|
| 30 |
+
b.dominant_tag AS phone_type,
|
| 31 |
+
w.org_name AS owner_name
|
| 32 |
+
FROM phone p
|
| 33 |
+
LEFT JOIN blacklist b ON p.phone_number = b.phone_number
|
| 34 |
+
LEFT JOIN whitelist w ON p.phone_number = w.phone_number
|
| 35 |
+
WHERE p.phone_number IN (:phone_db, :phone_full)
|
| 36 |
+
"""
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
with self._engine.begin() as conn:
|
| 41 |
+
return conn.execute(
|
| 42 |
+
query,
|
| 43 |
+
{"phone_db": db_phone, "phone_full": normalized_phone},
|
| 44 |
+
).mappings().first()
|
| 45 |
+
except OperationalError:
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
def report_suspicious(
|
| 49 |
+
self,
|
| 50 |
+
phone_number: str,
|
| 51 |
+
phone_type: str,
|
| 52 |
+
reporter_phone: str | None,
|
| 53 |
+
transfer_type: int | None = 0,
|
| 54 |
+
transfer_content: str | None = None,
|
| 55 |
+
) -> dict[str, Any]:
|
| 56 |
+
if self._engine is None:
|
| 57 |
+
raise RuntimeError("資料庫連線失敗:database engine unavailable")
|
| 58 |
+
|
| 59 |
+
normalized_phone = normalize(phone_number)
|
| 60 |
+
db_phone = normalize_db_phone(phone_number)
|
| 61 |
+
reporter_phone = normalize(reporter_phone) if reporter_phone else None
|
| 62 |
+
transfer_content = (
|
| 63 |
+
normalize(transfer_content)
|
| 64 |
+
if transfer_type == 1 and transfer_content
|
| 65 |
+
else transfer_content
|
| 66 |
+
)
|
| 67 |
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 68 |
+
|
| 69 |
+
try:
|
| 70 |
+
with self._engine.begin() as conn:
|
| 71 |
+
phone_row = conn.execute(
|
| 72 |
+
text(
|
| 73 |
+
"SELECT phone_number, tags FROM phone WHERE phone_number IN (:phone_db, :phone_full)"
|
| 74 |
+
),
|
| 75 |
+
{"phone_db": db_phone, "phone_full": normalized_phone},
|
| 76 |
+
).mappings().first()
|
| 77 |
+
|
| 78 |
+
if phone_row and phone_row["tags"] == "White":
|
| 79 |
+
raise ValueError("該號碼已存在於白名單,無法回報為可疑號碼。")
|
| 80 |
+
|
| 81 |
+
if phone_row:
|
| 82 |
+
conn.execute(
|
| 83 |
+
text(
|
| 84 |
+
"UPDATE phone SET tags = 'Black' WHERE phone_number IN (:phone_db, :phone_full)"
|
| 85 |
+
),
|
| 86 |
+
{"phone_db": db_phone, "phone_full": normalized_phone},
|
| 87 |
+
)
|
| 88 |
+
else:
|
| 89 |
+
conn.execute(
|
| 90 |
+
text(
|
| 91 |
+
"INSERT INTO phone(phone_number, tags) VALUES (:phone_number, 'Black')"
|
| 92 |
+
),
|
| 93 |
+
{"phone_number": db_phone},
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
blacklist_row = conn.execute(
|
| 97 |
+
text(
|
| 98 |
+
"SELECT report_count, dominant_tag FROM blacklist WHERE phone_number IN (:phone_db, :phone_full)"
|
| 99 |
+
),
|
| 100 |
+
{"phone_db": db_phone, "phone_full": normalized_phone},
|
| 101 |
+
).mappings().first()
|
| 102 |
+
|
| 103 |
+
if blacklist_row:
|
| 104 |
+
conn.execute(
|
| 105 |
+
text(
|
| 106 |
+
"UPDATE blacklist "
|
| 107 |
+
"SET report_count = report_count + 1, last_seen_at = :last_seen_at, "
|
| 108 |
+
"dominant_tag = :phone_type, referral_type = :referral_type, referral_info = :referral_info "
|
| 109 |
+
"WHERE phone_number = :phone_number"
|
| 110 |
+
),
|
| 111 |
+
{
|
| 112 |
+
"last_seen_at": now,
|
| 113 |
+
"phone_type": phone_type,
|
| 114 |
+
"referral_type": transfer_type or 0,
|
| 115 |
+
"referral_info": transfer_content,
|
| 116 |
+
"phone_number": db_phone,
|
| 117 |
+
},
|
| 118 |
+
)
|
| 119 |
+
total_reports = int(blacklist_row["report_count"]) + 1
|
| 120 |
+
else:
|
| 121 |
+
conn.execute(
|
| 122 |
+
text(
|
| 123 |
+
"INSERT INTO blacklist(phone_number, report_count, first_seen_at, last_seen_at, dominant_tag, referral_type, referral_info) "
|
| 124 |
+
"VALUES (:phone_number, 1, :first_seen_at, :last_seen_at, :phone_type, :referral_type, :referral_info)"
|
| 125 |
+
),
|
| 126 |
+
{
|
| 127 |
+
"phone_number": db_phone,
|
| 128 |
+
"first_seen_at": now,
|
| 129 |
+
"last_seen_at": now,
|
| 130 |
+
"phone_type": phone_type,
|
| 131 |
+
"referral_type": transfer_type or 0,
|
| 132 |
+
"referral_info": transfer_content,
|
| 133 |
+
},
|
| 134 |
+
)
|
| 135 |
+
total_reports = 1
|
| 136 |
+
|
| 137 |
+
conn.execute(
|
| 138 |
+
text(
|
| 139 |
+
"INSERT INTO fraud_reports(phone_number, reporter_phone, report_time, tag, referral_type, referral_info) "
|
| 140 |
+
"VALUES (:phone_number, :reporter_phone, :report_time, :tag, :referral_type, :referral_info)"
|
| 141 |
+
),
|
| 142 |
+
{
|
| 143 |
+
"phone_number": db_phone,
|
| 144 |
+
"reporter_phone": reporter_phone and normalize_db_phone(reporter_phone),
|
| 145 |
+
"report_time": now,
|
| 146 |
+
"tag": phone_type,
|
| 147 |
+
"referral_type": transfer_type or 0,
|
| 148 |
+
"referral_info": transfer_content,
|
| 149 |
+
},
|
| 150 |
+
)
|
| 151 |
+
except OperationalError as exc:
|
| 152 |
+
raise RuntimeError(f"資料庫連線失敗:{exc}") from exc
|
| 153 |
+
|
| 154 |
+
return {
|
| 155 |
+
"phone_number": normalized_phone,
|
| 156 |
+
"status": "black",
|
| 157 |
+
"total_reports": total_reports,
|
| 158 |
+
"report_time": now,
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
phone_repository = PhoneRepository()
|
backend/repository/rag_repository.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
from typing import Any
|
| 3 |
+
|
| 4 |
+
from backend.core.config import settings
|
| 5 |
+
from backend.rag.rag_context import RAGContext
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _require_langchain() -> tuple[Any]:
|
| 9 |
+
from langchain_chroma import Chroma
|
| 10 |
+
|
| 11 |
+
return (Chroma,)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def get_vectorstore() -> Any:
|
| 15 |
+
"""取得向量資料庫實例"""
|
| 16 |
+
(Chroma,) = _require_langchain()
|
| 17 |
+
return Chroma(
|
| 18 |
+
persist_directory=settings.RAG_PERSIST_DIR,
|
| 19 |
+
embedding_function=RAGContext.get_embeddings(),
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def query_documents(query_text: str, k: int | None = None) -> list[Any]:
|
| 24 |
+
"""查詢向量庫中相似的文檔
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
query_text: 查詢文本
|
| 28 |
+
k: 返回結果數量,默认使用配置中的 RAG_TOP_K
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
相似文檔列表
|
| 32 |
+
"""
|
| 33 |
+
if k is None:
|
| 34 |
+
k = settings.RAG_TOP_K
|
| 35 |
+
|
| 36 |
+
vectorstore = get_vectorstore()
|
| 37 |
+
retriever = vectorstore.as_retriever(search_kwargs={"k": k})
|
| 38 |
+
return retriever.invoke(query_text)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def add_documents(texts: list[str], metadatas: list[dict[str, Any]] | None = None) -> list[str]:
|
| 42 |
+
"""新增文檔到向量庫
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
texts: 文本列表
|
| 46 |
+
metadatas: 元數據列表
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
新增文檔的 ID 列表
|
| 50 |
+
"""
|
| 51 |
+
vectorstore = get_vectorstore()
|
| 52 |
+
return vectorstore.add_texts(texts=texts, metadatas=metadatas)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def delete_documents(ids: list[str]) -> bool:
|
| 56 |
+
"""從向量庫刪除文檔
|
| 57 |
+
|
| 58 |
+
Args:
|
| 59 |
+
ids: 文檔 ID 列表
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
是否刪除成功
|
| 63 |
+
"""
|
| 64 |
+
vectorstore = get_vectorstore()
|
| 65 |
+
try:
|
| 66 |
+
vectorstore.delete(ids=ids)
|
| 67 |
+
return True
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"Error deleting documents: {e}")
|
| 70 |
+
return False
|
backend/services/__init__.py
ADDED
|
File without changes
|
backend/services/dto/__init__.py
ADDED
|
File without changes
|
backend/services/dto/price_analysis.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Literal
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 4 |
+
|
| 5 |
+
from backend.services.image_price_service.domain.models import (
|
| 6 |
+
MarketplaceCondition,
|
| 7 |
+
MarketplaceLayout,
|
| 8 |
+
)
|
| 9 |
+
from backend.services.image_price_service.domain.policy import (
|
| 10 |
+
DEFAULT_PRICE_RISK_POLICY,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
RiskLabel = Literal["LOW", "MEDIUM", "HIGH", "UNKNOWN"]
|
| 14 |
+
DecisionLayer = Literal["fast", "llm", "llm_simulated", "decision_error"]
|
| 15 |
+
MarketPriceSource = Literal["online", "fallback_local", "not_evaluated"]
|
| 16 |
+
SearchTool = Literal["serp_api", "tavily", "ddgs"]
|
| 17 |
+
MarketPriceStatus = Literal["success", "insufficient", "not_found"]
|
| 18 |
+
MarketPriceReferenceMode = Literal["iqr", "median_low_sample"]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MarketPriceCandidateEvidence(BaseModel):
|
| 22 |
+
"""保留已驗證市場候選的來源與價格證據。"""
|
| 23 |
+
|
| 24 |
+
model_config = ConfigDict(extra="forbid")
|
| 25 |
+
|
| 26 |
+
candidate_id: str
|
| 27 |
+
title: str
|
| 28 |
+
price: int = Field(gt=0)
|
| 29 |
+
condition: MarketplaceCondition
|
| 30 |
+
evidence: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class MarketPriceEstimate(BaseModel):
|
| 34 |
+
"""保留市場價格區間、資料量、來源與可信度。"""
|
| 35 |
+
|
| 36 |
+
model_config = ConfigDict(extra="forbid")
|
| 37 |
+
|
| 38 |
+
status: MarketPriceStatus
|
| 39 |
+
condition: MarketplaceCondition
|
| 40 |
+
reference_mode: MarketPriceReferenceMode
|
| 41 |
+
median_price: int = Field(ge=0)
|
| 42 |
+
low_price: int = Field(ge=0)
|
| 43 |
+
high_price: int = Field(ge=0)
|
| 44 |
+
sample_count: int = Field(ge=0)
|
| 45 |
+
site_count: int = Field(ge=0)
|
| 46 |
+
source: MarketPriceSource
|
| 47 |
+
confidence: float = Field(ge=0.0, le=1.0)
|
| 48 |
+
search_tools: list[SearchTool] = Field(default_factory=list)
|
| 49 |
+
candidates: tuple[MarketPriceCandidateEvidence, ...] = ()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class DeepAnalysisReview(BaseModel):
|
| 53 |
+
"""LLM 對有限商品狀態原文所做的結構化複核。"""
|
| 54 |
+
|
| 55 |
+
model_config = ConfigDict(extra="forbid")
|
| 56 |
+
|
| 57 |
+
reviewed_condition: MarketplaceCondition
|
| 58 |
+
condition_detail: str = ""
|
| 59 |
+
condition_evidence: str = ""
|
| 60 |
+
reason: str
|
| 61 |
+
review_confidence: float = Field(ge=0.0, le=1.0)
|
| 62 |
+
|
| 63 |
+
@model_validator(mode="after")
|
| 64 |
+
def validate_review_consistency(self) -> "DeepAnalysisReview":
|
| 65 |
+
"""拒絕缺少證據或與 UNKNOWN 狀態矛盾的複核結果。"""
|
| 66 |
+
if (
|
| 67 |
+
self.reviewed_condition != MarketplaceCondition.UNKNOWN
|
| 68 |
+
and not self.condition_evidence.strip()
|
| 69 |
+
):
|
| 70 |
+
raise ValueError("確認商品狀態時必須提供可追溯的狀態原文證據")
|
| 71 |
+
|
| 72 |
+
if (
|
| 73 |
+
self.reviewed_condition == MarketplaceCondition.UNKNOWN
|
| 74 |
+
and self.condition_detail.strip()
|
| 75 |
+
):
|
| 76 |
+
raise ValueError("商品狀態為 UNKNOWN 時不得提供已確認的狀態細節")
|
| 77 |
+
|
| 78 |
+
return self
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class ImagePriceAnalysisResult(BaseModel):
|
| 82 |
+
"""圖片價格分析的內部完整結果"""
|
| 83 |
+
|
| 84 |
+
model_config = ConfigDict(extra="forbid")
|
| 85 |
+
|
| 86 |
+
filename: str
|
| 87 |
+
content_type: str
|
| 88 |
+
extracted_text: str
|
| 89 |
+
success: bool = True
|
| 90 |
+
error_code: str | None = None
|
| 91 |
+
message: str | None = None
|
| 92 |
+
product_name: str | None = None
|
| 93 |
+
brand_model: str | None = None
|
| 94 |
+
listed_price: int | None = Field(default=None, ge=0)
|
| 95 |
+
market_price: int = Field(default=0, ge=0)
|
| 96 |
+
market_price_source: MarketPriceSource = "not_evaluated"
|
| 97 |
+
market_price_estimates: tuple[MarketPriceEstimate, ...] = ()
|
| 98 |
+
risk_label: RiskLabel = "UNKNOWN"
|
| 99 |
+
score: str | float | None = None
|
| 100 |
+
reason: str | None = None
|
| 101 |
+
evidence: list[str] = Field(default_factory=list)
|
| 102 |
+
confidence: float | None = Field(default=None, ge=0, le=1)
|
| 103 |
+
decision_layer: DecisionLayer = "fast"
|
| 104 |
+
search_tools: list[SearchTool] = Field(default_factory=list)
|
| 105 |
+
marketplace_layout: MarketplaceLayout = MarketplaceLayout.UNKNOWN
|
| 106 |
+
marketplace_confidence: float = Field(default=0.0, ge=0, le=1)
|
| 107 |
+
extraction_confidence: float | None = Field(default=None, ge=0, le=1)
|
| 108 |
+
price_source_text: str | None = None
|
| 109 |
+
price_extraction_reason: str | None = None
|
| 110 |
+
seller_name: str | None = None
|
| 111 |
+
condition: MarketplaceCondition = MarketplaceCondition.UNKNOWN
|
| 112 |
+
condition_detail: str = ""
|
| 113 |
+
condition_source_text: str = ""
|
| 114 |
+
condition_extraction_confidence: float = Field(
|
| 115 |
+
default=0.0,
|
| 116 |
+
ge=0.0,
|
| 117 |
+
le=1.0,
|
| 118 |
+
description=(
|
| 119 |
+
"只代表 extractor 擷取商品狀態的可信度,不代表商品匹配、"
|
| 120 |
+
"市場資料或價格風險可信度"
|
| 121 |
+
),
|
| 122 |
+
)
|
| 123 |
+
extraction_warnings: list[str] = Field(default_factory=list)
|
| 124 |
+
|
| 125 |
+
@field_validator("listed_price")
|
| 126 |
+
@classmethod
|
| 127 |
+
def validate_supported_listing_price(cls, value: int | None) -> int | None:
|
| 128 |
+
"""拒絕超出服務支援上限的刊登價格。"""
|
| 129 |
+
if (
|
| 130 |
+
value is not None
|
| 131 |
+
and value > DEFAULT_PRICE_RISK_POLICY.maximum_supported_price
|
| 132 |
+
):
|
| 133 |
+
raise ValueError(
|
| 134 |
+
"PRICE_OUT_OF_SUPPORTED_RANGE:刊登價格超出支援範圍"
|
| 135 |
+
)
|
| 136 |
+
return value
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class ProductIdentification(BaseModel):
|
| 140 |
+
"""商品辨識階段輸出的正規化資訊、查價搜尋詞與本地參考價格。"""
|
| 141 |
+
|
| 142 |
+
product_name: str
|
| 143 |
+
brand_model: str
|
| 144 |
+
known_specs: list[str] = Field(default_factory=list)
|
| 145 |
+
search_query: str = ""
|
| 146 |
+
market_price: int = Field(default=0, ge=0)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class OCRResponse(BaseModel):
|
| 150 |
+
extracted_text: str = Field(..., description="OCR 萃取文字")
|
backend/services/dto/text_analysis.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class BaseEvidence(BaseModel):
|
| 5 |
+
text: str
|
| 6 |
+
rule_score: int
|
| 7 |
+
rule_reason: str
|
| 8 |
+
rule_hits: list[str]
|
| 9 |
+
model_label: str
|
| 10 |
+
model_confidence: float | None
|
| 11 |
+
model_margin: float | None
|
| 12 |
+
chunk_consistent: bool
|
backend/services/image_price_service/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
backend/services/image_price_service/case_recorder.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""將圖片價格分析結果轉成案例資料並以 best-effort 方式保存。"""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from backend.core.config import settings
|
| 7 |
+
from backend.services.dto.price_analysis import ImagePriceAnalysisResult
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def record_case(
|
| 13 |
+
case_repo: Any,
|
| 14 |
+
result: ImagePriceAnalysisResult,
|
| 15 |
+
) -> str | None:
|
| 16 |
+
"""依設定保存分析案例;寫入失敗時維持原分析結果。"""
|
| 17 |
+
if not settings.CASE_MEMORY_ENABLED:
|
| 18 |
+
return None
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
return case_repo.save(
|
| 22 |
+
{
|
| 23 |
+
"filename": result.filename,
|
| 24 |
+
"content_type": result.content_type,
|
| 25 |
+
"product_name": result.product_name,
|
| 26 |
+
"brand_model": result.brand_model,
|
| 27 |
+
"selling_price": result.listed_price,
|
| 28 |
+
"market_price": result.market_price,
|
| 29 |
+
"market_price_source": result.market_price_source,
|
| 30 |
+
"market_price_estimates": [
|
| 31 |
+
estimate.model_dump(exclude={"candidates"}, mode="json")
|
| 32 |
+
for estimate in result.market_price_estimates
|
| 33 |
+
],
|
| 34 |
+
"risk_label": result.risk_label,
|
| 35 |
+
"risk_score": result.score,
|
| 36 |
+
"reason": result.reason,
|
| 37 |
+
"evidence": result.evidence,
|
| 38 |
+
"confidence": result.confidence,
|
| 39 |
+
"decision_layer": result.decision_layer,
|
| 40 |
+
"error_code": result.error_code,
|
| 41 |
+
"extracted_text": result.extracted_text,
|
| 42 |
+
"marketplace_layout": result.marketplace_layout,
|
| 43 |
+
"marketplace_confidence": result.marketplace_confidence,
|
| 44 |
+
"price_source_text": result.price_source_text,
|
| 45 |
+
"price_extraction_reason": result.price_extraction_reason,
|
| 46 |
+
"seller_name": result.seller_name,
|
| 47 |
+
"condition": result.condition,
|
| 48 |
+
"condition_detail": result.condition_detail,
|
| 49 |
+
"condition_source_text": result.condition_source_text,
|
| 50 |
+
"condition_extraction_confidence": (
|
| 51 |
+
result.condition_extraction_confidence
|
| 52 |
+
),
|
| 53 |
+
"extraction_warnings": result.extraction_warnings,
|
| 54 |
+
}
|
| 55 |
+
)
|
| 56 |
+
except Exception:
|
| 57 |
+
logger.warning("圖片價格分析案例保存失敗", exc_info=True)
|
| 58 |
+
return None
|
backend/services/image_price_service/domain/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""商品價格驗證的 domain models 與 policy。"""
|
backend/services/image_price_service/domain/models.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""定義 OCR、商品頁驗證與刊登欄位抽取共用的資料模型。"""
|
| 2 |
+
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field, model_validator
|
| 7 |
+
|
| 8 |
+
from backend.services.image_price_service.domain.policy import (
|
| 9 |
+
DEFAULT_PRICE_RISK_POLICY,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MarketplaceCondition(str, Enum):
|
| 14 |
+
"""刊登商品的使用狀況。"""
|
| 15 |
+
|
| 16 |
+
NEW = "new"
|
| 17 |
+
USED = "used"
|
| 18 |
+
UNKNOWN = "unknown"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class MarketplaceLayout(str, Enum):
|
| 22 |
+
"""商品頁截圖的版面類型。"""
|
| 23 |
+
|
| 24 |
+
MOBILE = "mobile"
|
| 25 |
+
DESKTOP = "desktop"
|
| 26 |
+
UNKNOWN = "unknown"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class PriceSection(str, Enum):
|
| 30 |
+
"""價格候選值所在的商品頁區段。"""
|
| 31 |
+
|
| 32 |
+
MAIN_PRICE = "main_price"
|
| 33 |
+
OFFER_RANGE = "offer_range"
|
| 34 |
+
DESCRIPTION = "description"
|
| 35 |
+
SELLER_INFO = "seller_info"
|
| 36 |
+
MESSAGE_BOX = "message_box"
|
| 37 |
+
DETAIL = "detail"
|
| 38 |
+
UNKNOWN = "unknown"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class OCRTextBlock(BaseModel):
|
| 42 |
+
"""保存單一 OCR 文字區塊及其座標。"""
|
| 43 |
+
|
| 44 |
+
text: str
|
| 45 |
+
x: float | None = None
|
| 46 |
+
y: float | None = None
|
| 47 |
+
width: float | None = None
|
| 48 |
+
height: float | None = None
|
| 49 |
+
confidence: float | None = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class OCRDocument(BaseModel):
|
| 53 |
+
"""保存 OCR 全文、文字區塊與頁面尺寸。"""
|
| 54 |
+
|
| 55 |
+
text: str
|
| 56 |
+
blocks: list[OCRTextBlock] = Field(default_factory=list)
|
| 57 |
+
width: float | None = None
|
| 58 |
+
height: float | None = None
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def has_coordinates(self) -> bool:
|
| 62 |
+
"""判斷所有文字區塊是否都有座標。"""
|
| 63 |
+
return bool(self.blocks) and all(
|
| 64 |
+
block.x is not None and block.y is not None
|
| 65 |
+
for block in self.blocks
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class ProductAgentResult(BaseModel):
|
| 70 |
+
"""保存模型輸出、搜尋結果與工具錯誤。"""
|
| 71 |
+
|
| 72 |
+
output: dict[str, Any]
|
| 73 |
+
tool_results: list[dict[str, Any]]
|
| 74 |
+
tool_errors: list[str]
|
| 75 |
+
|
| 76 |
+
class DetectionResult(BaseModel):
|
| 77 |
+
"""商品頁來源驗證與版型推定結果。"""
|
| 78 |
+
|
| 79 |
+
is_marketplace: bool
|
| 80 |
+
layout: MarketplaceLayout
|
| 81 |
+
confidence: float
|
| 82 |
+
evidence: list[str] = Field(default_factory=list)
|
| 83 |
+
reason: str | None = None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class PriceCandidate(BaseModel):
|
| 87 |
+
"""從 OCR 文字擷取的單一價格候選及其判定資訊。"""
|
| 88 |
+
|
| 89 |
+
amount: int
|
| 90 |
+
currency: str
|
| 91 |
+
source_text: str
|
| 92 |
+
block_index: int
|
| 93 |
+
x: float | None
|
| 94 |
+
y: float | None
|
| 95 |
+
context_before: str | None
|
| 96 |
+
context_after: str | None
|
| 97 |
+
section: PriceSection
|
| 98 |
+
confidence: float
|
| 99 |
+
reject_reason: str | None = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class MainPriceExtractionResult(BaseModel):
|
| 103 |
+
"""保存 Marketplace 刊登欄位的抽取結果。"""
|
| 104 |
+
|
| 105 |
+
price: int | None
|
| 106 |
+
currency: str | None
|
| 107 |
+
confidence: float
|
| 108 |
+
source_text: str | None
|
| 109 |
+
layout: MarketplaceLayout
|
| 110 |
+
candidates: list[PriceCandidate] = Field(default_factory=list)
|
| 111 |
+
rejected_candidates: list[PriceCandidate] = Field(default_factory=list)
|
| 112 |
+
error_code: str | None = None
|
| 113 |
+
message: str | None = None
|
| 114 |
+
reason: str | None = None
|
| 115 |
+
product_name: str | None = None
|
| 116 |
+
seller_name: str | None = None
|
| 117 |
+
condition: MarketplaceCondition = MarketplaceCondition.UNKNOWN
|
| 118 |
+
condition_detail: str = ""
|
| 119 |
+
condition_source_text: str = ""
|
| 120 |
+
condition_extraction_confidence: float = Field(
|
| 121 |
+
default=0.0,
|
| 122 |
+
ge=0.0,
|
| 123 |
+
le=1.0,
|
| 124 |
+
description=(
|
| 125 |
+
"只代表 extractor 擷取商品狀態的可信度,不代表商品匹配、"
|
| 126 |
+
"市場資料或價格風險可信度"
|
| 127 |
+
),
|
| 128 |
+
)
|
| 129 |
+
warnings: list[str] = Field(default_factory=list)
|
| 130 |
+
|
| 131 |
+
@model_validator(mode="after")
|
| 132 |
+
def validate_supported_listing_price(self) -> "MainPriceExtractionResult":
|
| 133 |
+
"""將超出服務上限的刊登價格標記為明確的擷取錯誤。"""
|
| 134 |
+
if (
|
| 135 |
+
self.price is not None
|
| 136 |
+
and self.price
|
| 137 |
+
> DEFAULT_PRICE_RISK_POLICY.maximum_supported_price
|
| 138 |
+
):
|
| 139 |
+
self.error_code = "PRICE_OUT_OF_SUPPORTED_RANGE"
|
| 140 |
+
self.message = "刊登價格超出商品價格驗證服務支援範圍"
|
| 141 |
+
return self
|
| 142 |
+
|
| 143 |
+
class MainPriceExtractionError(ValueError):
|
| 144 |
+
"""表示主價格不存在或擷取信心不足。"""
|
| 145 |
+
|
| 146 |
+
def __init__(self, result: MainPriceExtractionResult):
|
| 147 |
+
"""建立錯誤並保留原始擷取結果。"""
|
| 148 |
+
if not result.error_code:
|
| 149 |
+
raise ValueError("MainPriceExtractionError 必須包含 error_code")
|
| 150 |
+
self.result = result
|
| 151 |
+
self.error_code = result.error_code
|
| 152 |
+
super().__init__(result.message or result.error_code)
|