github-actions commited on
Commit
e4e8a91
·
0 Parent(s):

deploy: bbf5721

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +23 -0
  2. .gitattributes +1 -0
  3. .github/workflows/check_size.yaml +16 -0
  4. .github/workflows/sync_to_hf.yaml +28 -0
  5. .gitignore +26 -0
  6. .python-version +1 -0
  7. AGENTS.md +77 -0
  8. Dockerfile +27 -0
  9. README.md +8 -0
  10. backend/__init__.py +1 -0
  11. backend/api/__init__.py +0 -0
  12. backend/api/exception_handler.py +42 -0
  13. backend/api/response.py +27 -0
  14. backend/api/routers/__init__.py +0 -0
  15. backend/api/routers/image_price_validation.py +130 -0
  16. backend/api/routers/phone_detection.py +95 -0
  17. backend/api/routers/text_inference.py +46 -0
  18. backend/api/routers/url_detection.py +22 -0
  19. backend/api/schemas/__init__.py +0 -0
  20. backend/api/schemas/image.py +71 -0
  21. backend/api/schemas/phone.py +124 -0
  22. backend/api/schemas/text.py +18 -0
  23. backend/api/schemas/url.py +10 -0
  24. backend/core/config.py +121 -0
  25. backend/core/lifespan.py +51 -0
  26. backend/core/logging.py +10 -0
  27. backend/main.py +40 -0
  28. backend/persistence/mysql_connection.py +52 -0
  29. backend/providers/__init__.py +5 -0
  30. backend/providers/groq.py +83 -0
  31. backend/rag/__init__.py +1 -0
  32. backend/rag/dto/__init__.py +0 -0
  33. backend/rag/dto/rag_analysis.py +21 -0
  34. backend/rag/rag_context.py +52 -0
  35. backend/rag/rag_reasoner.py +178 -0
  36. backend/rag/rag_retriever.py +43 -0
  37. backend/repository/__init__.py +0 -0
  38. backend/repository/case_repository.py +28 -0
  39. backend/repository/family_repository.py +172 -0
  40. backend/repository/market_price_repository.py +125 -0
  41. backend/repository/phone_repository.py +162 -0
  42. backend/repository/rag_repository.py +70 -0
  43. backend/services/__init__.py +0 -0
  44. backend/services/dto/__init__.py +0 -0
  45. backend/services/dto/price_analysis.py +150 -0
  46. backend/services/dto/text_analysis.py +12 -0
  47. backend/services/image_price_service/__init__.py +1 -0
  48. backend/services/image_price_service/case_recorder.py +58 -0
  49. backend/services/image_price_service/domain/__init__.py +1 -0
  50. 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)