Spaces:
Runtime error
Runtime error
Deploy VERA API (round 3 models)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +18 -7
- app.py +124 -0
- backend/alembic.ini +37 -0
- backend/app/__init__.py +3 -0
- backend/app/api/__init__.py +1 -0
- backend/app/api/deps.py +18 -0
- backend/app/api/logging.py +50 -0
- backend/app/api/middleware.py +192 -0
- backend/app/api/routes/__init__.py +1 -0
- backend/app/api/routes/health.py +58 -0
- backend/app/api/routes/products.py +80 -0
- backend/app/api/routes/reviews.py +115 -0
- backend/app/config.py +134 -0
- backend/app/database.py +60 -0
- backend/app/main.py +33 -0
- backend/app/ml/__init__.py +1 -0
- backend/app/ml/agents/__init__.py +10 -0
- backend/app/ml/agents/absa.py +135 -0
- backend/app/ml/agents/aspect_detector.py +116 -0
- backend/app/ml/agents/consistency.py +40 -0
- backend/app/ml/agents/domain_rating.py +67 -0
- backend/app/ml/agents/experience_extractor.py +79 -0
- backend/app/ml/agents/fusion.py +105 -0
- backend/app/ml/agents/preprocessor.py +168 -0
- backend/app/ml/agents/quality.py +131 -0
- backend/app/ml/calibration/__init__.py +1 -0
- backend/app/ml/calibration/conformal.py +80 -0
- backend/app/ml/calibration/empirical_bayes.py +276 -0
- backend/app/ml/constants.py +142 -0
- backend/app/ml/features.py +125 -0
- backend/app/ml/models/__init__.py +1 -0
- backend/app/ml/models/loader.py +128 -0
- backend/app/ml/models/onnx_session.py +63 -0
- backend/app/ml/models/tokenizer_wrapper.py +82 -0
- backend/app/ml/pipeline.py +218 -0
- backend/app/models/__init__.py +9 -0
- backend/app/models/calibration.py +24 -0
- backend/app/models/job.py +49 -0
- backend/app/models/label_run.py +28 -0
- backend/app/models/product.py +44 -0
- backend/app/models/review.py +87 -0
- backend/app/schemas/__init__.py +1 -0
- backend/app/schemas/product.py +39 -0
- backend/app/schemas/review.py +95 -0
- backend/app/worker/__init__.py +1 -0
- backend/app/worker/celery_app.py +71 -0
- backend/app/worker/outbox.py +93 -0
- backend/app/worker/tasks.py +238 -0
- backend/migrations/env.py +52 -0
- backend/migrations/script.py.mako +24 -0
README.md
CHANGED
|
@@ -1,13 +1,24 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: purple
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
|
|
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: VERA API
|
| 3 |
+
colorFrom: blue
|
| 4 |
+
colorTo: gray
|
|
|
|
| 5 |
sdk: gradio
|
| 6 |
+
sdk_version: 5.49.1
|
| 7 |
+
python_version: "3.12"
|
| 8 |
app_file: app.py
|
| 9 |
+
suggested_hardware: zero-a10g
|
| 10 |
pinned: false
|
| 11 |
+
short_description: VERA earbud review analysis API (FastAPI + Celery worker)
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# VERA API (Hugging Face Space)
|
| 15 |
+
|
| 16 |
+
Backend for the VERA review analyzer. `app.py` starts loopback Redis, applies database
|
| 17 |
+
migrations, starts one Celery worker (which downloads the models from the private
|
| 18 |
+
`HF_MODEL_REPO`), and serves the FastAPI app on port 7860.
|
| 19 |
+
|
| 20 |
+
Endpoints: `GET /health`, `GET /ready`, `POST /api/reviews`, `GET /api/reviews/{id}`,
|
| 21 |
+
`GET /api/reviews/{id}/status`, `GET /api/products`, `GET /api/products/{asin}`.
|
| 22 |
+
|
| 23 |
+
Secrets (Settings -> Variables and secrets): `DATABASE_URL` (Supabase session pooler,
|
| 24 |
+
`?sslmode=require`), `HF_MODEL_REPO`, `HF_TOKEN` (read), `SECRET_KEY`, `CORS_ORIGINS`.
|
app.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VERA API + worker launcher for a free Hugging Face Gradio Space on ZeroGPU hardware.
|
| 2 |
+
|
| 3 |
+
Why this shape: Docker Spaces and CPU-basic Gradio Spaces require a PRO plan, while a Gradio
|
| 4 |
+
Space on ZeroGPU hardware is free. ZeroGPU stops any app that never reports startup, and that
|
| 5 |
+
report is normally sent by ``gradio.Blocks.launch``. This launcher instead serves the existing
|
| 6 |
+
FastAPI app with uvicorn and sends the report itself (``spaces.zero.startup``). All inference
|
| 7 |
+
runs on CPU with onnxruntime; the GPU is never requested, so the ZeroGPU quota is not used.
|
| 8 |
+
|
| 9 |
+
One container owns: loopback Redis (Celery broker), Alembic migrations, one solo Celery worker
|
| 10 |
+
(which downloads the models from ``HF_MODEL_REPO`` and runs the outbox dispatcher), and the API
|
| 11 |
+
on port 7860. If Redis or the worker exits, the process exits so the Space restarts.
|
| 12 |
+
|
| 13 |
+
Required Space secrets: DATABASE_URL, HF_MODEL_REPO, HF_TOKEN (read), SECRET_KEY, CORS_ORIGINS.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import subprocess
|
| 20 |
+
import sys
|
| 21 |
+
import threading
|
| 22 |
+
import time
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
import spaces # must be imported before any CUDA-related package
|
| 26 |
+
|
| 27 |
+
ROOT = Path(__file__).resolve().parent
|
| 28 |
+
BACKEND = ROOT / "backend"
|
| 29 |
+
MODEL_DIR = Path(os.environ.get("MODEL_DIR", "/home/user/models"))
|
| 30 |
+
REDIS_URL = "redis://127.0.0.1:6379/0"
|
| 31 |
+
|
| 32 |
+
for name in ("DATABASE_URL", "HF_MODEL_REPO"):
|
| 33 |
+
if not os.environ.get(name):
|
| 34 |
+
sys.exit(f"Set {name} in the Space secrets")
|
| 35 |
+
|
| 36 |
+
os.environ.update(
|
| 37 |
+
ENVIRONMENT="production",
|
| 38 |
+
MODEL_DIR=str(MODEL_DIR),
|
| 39 |
+
REDIS_URL=REDIS_URL,
|
| 40 |
+
CELERY_BROKER_URL=REDIS_URL,
|
| 41 |
+
CELERY_RESULT_BACKEND="",
|
| 42 |
+
)
|
| 43 |
+
MODEL_DIR.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
sys.path.insert(0, str(BACKEND))
|
| 45 |
+
os.chdir(BACKEND)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@spaces.GPU
|
| 49 |
+
def _zerogpu_placeholder() -> str:
|
| 50 |
+
"""ZeroGPU only reports startup when at least one GPU function exists. Never called."""
|
| 51 |
+
return "unused"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _log(message: str) -> None:
|
| 55 |
+
print(f"[vera-launcher] {message}", flush=True)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def start_redis() -> subprocess.Popen[bytes]:
|
| 59 |
+
Path("/tmp/redis").mkdir(exist_ok=True)
|
| 60 |
+
proc = subprocess.Popen(
|
| 61 |
+
["redis-server", "--bind", "127.0.0.1", "--protected-mode", "yes", "--port", "6379",
|
| 62 |
+
"--dir", "/tmp/redis", "--save", "", "--appendonly", "no"]
|
| 63 |
+
)
|
| 64 |
+
for _ in range(30):
|
| 65 |
+
ping = subprocess.run(["redis-cli", "-h", "127.0.0.1", "ping"], capture_output=True)
|
| 66 |
+
if ping.stdout.strip() == b"PONG":
|
| 67 |
+
_log("redis ready")
|
| 68 |
+
return proc
|
| 69 |
+
if proc.poll() is not None:
|
| 70 |
+
break
|
| 71 |
+
time.sleep(1)
|
| 72 |
+
sys.exit("redis-server failed to start")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def run_migrations() -> None:
|
| 76 |
+
subprocess.run([sys.executable, "-m", "alembic", "upgrade", "head"], check=True)
|
| 77 |
+
_log("database migrations applied")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def start_worker() -> subprocess.Popen[bytes]:
|
| 81 |
+
return subprocess.Popen(
|
| 82 |
+
[sys.executable, "-m", "celery", "-A", "app.worker.celery_app", "worker",
|
| 83 |
+
"--pool=solo", "--concurrency=1", "--loglevel=info"]
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def supervise(children: dict[str, subprocess.Popen[bytes]]) -> None:
|
| 88 |
+
"""Exit the whole Space if a required child dies, so the platform restarts it."""
|
| 89 |
+
while True:
|
| 90 |
+
for name, proc in children.items():
|
| 91 |
+
if proc.poll() is not None:
|
| 92 |
+
_log(f"{name} exited with code {proc.returncode}; stopping the Space")
|
| 93 |
+
for other in children.values():
|
| 94 |
+
if other.poll() is None:
|
| 95 |
+
other.terminate()
|
| 96 |
+
os._exit(1)
|
| 97 |
+
time.sleep(5)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def main() -> None:
|
| 101 |
+
import uvicorn
|
| 102 |
+
|
| 103 |
+
redis_proc = start_redis()
|
| 104 |
+
run_migrations()
|
| 105 |
+
worker = start_worker()
|
| 106 |
+
threading.Thread(
|
| 107 |
+
target=supervise, args=({"redis": redis_proc, "worker": worker},), daemon=True
|
| 108 |
+
).start()
|
| 109 |
+
|
| 110 |
+
from app.main import app
|
| 111 |
+
|
| 112 |
+
@app.get("/", include_in_schema=False)
|
| 113 |
+
def root() -> dict[str, str]:
|
| 114 |
+
return {"service": "VERA review analysis API", "health": "/health", "ready": "/ready"}
|
| 115 |
+
|
| 116 |
+
zero = getattr(spaces, "zero", None)
|
| 117 |
+
if zero is not None and hasattr(zero, "startup"):
|
| 118 |
+
zero.startup()
|
| 119 |
+
_log("ZeroGPU startup reported")
|
| 120 |
+
uvicorn.run(app, host="0.0.0.0", port=7860, proxy_headers=False, access_log=False)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
if __name__ == "__main__":
|
| 124 |
+
main()
|
backend/alembic.ini
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[alembic]
|
| 2 |
+
script_location = migrations
|
| 3 |
+
prepend_sys_path = .
|
| 4 |
+
sqlalchemy.url = postgresql://unused:unused@localhost/vera
|
| 5 |
+
|
| 6 |
+
[loggers]
|
| 7 |
+
keys = root,sqlalchemy,alembic
|
| 8 |
+
|
| 9 |
+
[handlers]
|
| 10 |
+
keys = console
|
| 11 |
+
|
| 12 |
+
[formatters]
|
| 13 |
+
keys = generic
|
| 14 |
+
|
| 15 |
+
[logger_root]
|
| 16 |
+
level = WARN
|
| 17 |
+
handlers = console
|
| 18 |
+
|
| 19 |
+
[logger_sqlalchemy]
|
| 20 |
+
level = WARN
|
| 21 |
+
handlers =
|
| 22 |
+
qualname = sqlalchemy.engine
|
| 23 |
+
|
| 24 |
+
[logger_alembic]
|
| 25 |
+
level = INFO
|
| 26 |
+
handlers =
|
| 27 |
+
qualname = alembic
|
| 28 |
+
|
| 29 |
+
[handler_console]
|
| 30 |
+
class = StreamHandler
|
| 31 |
+
args = (sys.stderr,)
|
| 32 |
+
level = NOTSET
|
| 33 |
+
formatter = generic
|
| 34 |
+
|
| 35 |
+
[formatter_generic]
|
| 36 |
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
| 37 |
+
datefmt = %H:%M:%S
|
backend/app/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VERA — Verified Experience & Review Analyzer (backend package)."""
|
| 2 |
+
|
| 3 |
+
__version__ = "1.0.0"
|
backend/app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI routing and dependencies."""
|
backend/app/api/deps.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared FastAPI dependencies."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Generator
|
| 6 |
+
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.database import SessionLocal
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_db() -> Generator[Session, None, None]:
|
| 13 |
+
"""Yield one request-scoped SQLAlchemy session."""
|
| 14 |
+
db = SessionLocal()
|
| 15 |
+
try:
|
| 16 |
+
yield db
|
| 17 |
+
finally:
|
| 18 |
+
db.close()
|
backend/app/api/logging.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured API logging with request correlation and bounded, safe access fields."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
from contextvars import ContextVar
|
| 8 |
+
from datetime import UTC, datetime
|
| 9 |
+
from logging.config import dictConfig
|
| 10 |
+
|
| 11 |
+
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class JsonFormatter(logging.Formatter):
|
| 15 |
+
"""Serialize log records without exception bodies, query strings, or request payloads."""
|
| 16 |
+
|
| 17 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 18 |
+
payload = {
|
| 19 |
+
"timestamp": datetime.fromtimestamp(record.created, UTC).isoformat(),
|
| 20 |
+
"level": record.levelname,
|
| 21 |
+
"logger": record.name,
|
| 22 |
+
"message": record.getMessage(),
|
| 23 |
+
"request_id": request_id_context.get(),
|
| 24 |
+
}
|
| 25 |
+
for field in ("method", "route", "status_code", "duration_ms", "exception_type"):
|
| 26 |
+
if hasattr(record, field):
|
| 27 |
+
payload[field] = getattr(record, field)
|
| 28 |
+
if record.exc_info and record.exc_info[0]:
|
| 29 |
+
payload["exception_type"] = record.exc_info[0].__name__
|
| 30 |
+
return json.dumps(payload, ensure_ascii=True, default=str)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def configure_json_logging() -> None:
|
| 34 |
+
"""Configure the API's console logs; access logging is owned by the middleware."""
|
| 35 |
+
dictConfig(
|
| 36 |
+
{
|
| 37 |
+
"version": 1,
|
| 38 |
+
"disable_existing_loggers": False,
|
| 39 |
+
"formatters": {"json": {"()": JsonFormatter}},
|
| 40 |
+
"handlers": {
|
| 41 |
+
"console": {"class": "logging.StreamHandler", "formatter": "json"},
|
| 42 |
+
"null": {"class": "logging.NullHandler"},
|
| 43 |
+
},
|
| 44 |
+
"root": {"level": "INFO", "handlers": ["console"]},
|
| 45 |
+
"loggers": {
|
| 46 |
+
"uvicorn": {"handlers": ["console"], "propagate": False},
|
| 47 |
+
"uvicorn.access": {"handlers": ["null"], "propagate": False},
|
| 48 |
+
},
|
| 49 |
+
}
|
| 50 |
+
)
|
backend/app/api/middleware.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bounded ASGI request handling and process-local submission throttling."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import math
|
| 7 |
+
import re
|
| 8 |
+
import time
|
| 9 |
+
from collections import OrderedDict, deque
|
| 10 |
+
from ipaddress import ip_address, ip_network
|
| 11 |
+
from threading import Lock
|
| 12 |
+
from uuid import uuid4
|
| 13 |
+
|
| 14 |
+
from starlette.datastructures import Headers, MutableHeaders
|
| 15 |
+
from starlette.responses import JSONResponse
|
| 16 |
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
| 17 |
+
|
| 18 |
+
from app.api.logging import request_id_context
|
| 19 |
+
from app.config import Settings
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger("vera.requests")
|
| 22 |
+
REQUEST_ID_PATTERN = re.compile(r"[A-Za-z0-9._-]{1,64}\Z")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class SlidingWindowLimiter:
|
| 26 |
+
"""Bound memory and requests per client; restart/multiple-process limits are documented."""
|
| 27 |
+
|
| 28 |
+
def __init__(self, limit: int, window: int, max_clients: int) -> None:
|
| 29 |
+
self.limit, self.window, self.max_clients = limit, window, max_clients
|
| 30 |
+
self.clients: OrderedDict[str, deque[float]] = OrderedDict()
|
| 31 |
+
self.lock = Lock()
|
| 32 |
+
|
| 33 |
+
def check(self, client: str, now: float | None = None) -> int:
|
| 34 |
+
"""Return zero when allowed, otherwise seconds until a retry may be accepted."""
|
| 35 |
+
now = time.monotonic() if now is None else now
|
| 36 |
+
with self.lock:
|
| 37 |
+
while self.clients and next(iter(self.clients.values()))[-1] <= now - self.window:
|
| 38 |
+
self.clients.popitem(last=False)
|
| 39 |
+
bucket = self.clients.get(client)
|
| 40 |
+
if bucket is None:
|
| 41 |
+
if len(self.clients) >= self.max_clients:
|
| 42 |
+
return self.window
|
| 43 |
+
bucket = self.clients[client] = deque()
|
| 44 |
+
while bucket and bucket[0] <= now - self.window:
|
| 45 |
+
bucket.popleft()
|
| 46 |
+
if len(bucket) >= self.limit:
|
| 47 |
+
return max(1, math.ceil(bucket[0] + self.window - now))
|
| 48 |
+
bucket.append(now)
|
| 49 |
+
self.clients.move_to_end(client)
|
| 50 |
+
return 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class RequestGuard:
|
| 54 |
+
"""Apply correlation IDs, body bounds, security headers, and POST rate limits."""
|
| 55 |
+
|
| 56 |
+
def __init__(self, app: ASGIApp, settings: Settings) -> None:
|
| 57 |
+
self.app = app
|
| 58 |
+
self.settings = settings
|
| 59 |
+
self.limiter = SlidingWindowLimiter(
|
| 60 |
+
settings.REVIEW_RATE_LIMIT,
|
| 61 |
+
settings.REVIEW_RATE_WINDOW_SECONDS,
|
| 62 |
+
settings.RATE_LIMIT_MAX_CLIENTS,
|
| 63 |
+
)
|
| 64 |
+
self.proxies = [
|
| 65 |
+
ip_network(value.strip(), strict=False)
|
| 66 |
+
for value in settings.TRUSTED_PROXY_CIDRS.split(",")
|
| 67 |
+
if value.strip()
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
def client_key(self, scope: Scope, headers: Headers) -> str:
|
| 71 |
+
"""Strip only explicitly trusted proxy hops from the right of a forwarded chain."""
|
| 72 |
+
peer = (scope.get("client") or ("unknown", 0))[0]
|
| 73 |
+
try:
|
| 74 |
+
address = ip_address(peer)
|
| 75 |
+
if not any(address in network for network in self.proxies):
|
| 76 |
+
return str(address)
|
| 77 |
+
hops = headers.get("x-forwarded-for", "").split(",")
|
| 78 |
+
if len(hops) > 20:
|
| 79 |
+
return peer
|
| 80 |
+
for hop in reversed(hops):
|
| 81 |
+
address = ip_address(hop.strip())
|
| 82 |
+
if not any(address in network for network in self.proxies):
|
| 83 |
+
return str(address)
|
| 84 |
+
return str(address)
|
| 85 |
+
except ValueError:
|
| 86 |
+
return peer
|
| 87 |
+
|
| 88 |
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
| 89 |
+
if scope["type"] != "http":
|
| 90 |
+
await self.app(scope, receive, send)
|
| 91 |
+
return
|
| 92 |
+
headers = Headers(scope=scope)
|
| 93 |
+
supplied = headers.get("x-request-id", "")
|
| 94 |
+
request_id = supplied if REQUEST_ID_PATTERN.fullmatch(supplied) else uuid4().hex
|
| 95 |
+
token = request_id_context.set(request_id)
|
| 96 |
+
scope.setdefault("state", {})["request_id"] = request_id
|
| 97 |
+
started = time.monotonic()
|
| 98 |
+
status_code = 500
|
| 99 |
+
response_started = False
|
| 100 |
+
|
| 101 |
+
async def guarded_send(message: Message) -> None:
|
| 102 |
+
nonlocal status_code, response_started
|
| 103 |
+
if message["type"] == "http.response.start":
|
| 104 |
+
response_started = True
|
| 105 |
+
status_code = message["status"]
|
| 106 |
+
outgoing = MutableHeaders(scope=message)
|
| 107 |
+
outgoing["X-Request-ID"] = request_id
|
| 108 |
+
outgoing["X-Content-Type-Options"] = "nosniff"
|
| 109 |
+
outgoing["X-Frame-Options"] = "DENY"
|
| 110 |
+
outgoing["Referrer-Policy"] = "no-referrer"
|
| 111 |
+
outgoing["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
| 112 |
+
outgoing["Cache-Control"] = "no-store"
|
| 113 |
+
origin = headers.get("origin")
|
| 114 |
+
if origin and (
|
| 115 |
+
origin in self.settings.cors_origins or "*" in self.settings.cors_origins
|
| 116 |
+
):
|
| 117 |
+
outgoing["Access-Control-Allow-Origin"] = origin
|
| 118 |
+
outgoing["Access-Control-Expose-Headers"] = (
|
| 119 |
+
"X-Total-Count, X-Request-ID, Retry-After"
|
| 120 |
+
)
|
| 121 |
+
outgoing.add_vary_header("Origin")
|
| 122 |
+
if self.settings.is_production:
|
| 123 |
+
outgoing["Strict-Transport-Security"] = "max-age=31536000"
|
| 124 |
+
await send(message)
|
| 125 |
+
|
| 126 |
+
async def reject(code: int, detail: str, extra: dict[str, str] | None = None) -> None:
|
| 127 |
+
await JSONResponse(
|
| 128 |
+
{"detail": detail, "request_id": request_id}, status_code=code, headers=extra
|
| 129 |
+
)(scope, receive, guarded_send)
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
length = headers.get("content-length")
|
| 133 |
+
if length is not None:
|
| 134 |
+
if not length.isascii() or not length.isdigit() or len(length) > 10:
|
| 135 |
+
await reject(400, "Invalid Content-Length header.")
|
| 136 |
+
return
|
| 137 |
+
if int(length) > self.settings.MAX_REQUEST_BODY_BYTES:
|
| 138 |
+
await reject(413, "Request body is too large.")
|
| 139 |
+
return
|
| 140 |
+
if headers.get("content-encoding", "identity").lower() != "identity":
|
| 141 |
+
await reject(415, "Compressed request bodies are not supported.")
|
| 142 |
+
return
|
| 143 |
+
if scope["method"] == "POST" and scope["path"].rstrip("/") == "/api/reviews":
|
| 144 |
+
retry = self.limiter.check(self.client_key(scope, headers))
|
| 145 |
+
if retry:
|
| 146 |
+
await reject(
|
| 147 |
+
429,
|
| 148 |
+
"Too many reviews submitted. Please wait before trying again.",
|
| 149 |
+
{"Retry-After": str(retry)},
|
| 150 |
+
)
|
| 151 |
+
return
|
| 152 |
+
# Buffer at most the configured limit before any handler can persist side effects.
|
| 153 |
+
body = bytearray()
|
| 154 |
+
while True:
|
| 155 |
+
message = await receive()
|
| 156 |
+
if message["type"] == "http.disconnect":
|
| 157 |
+
status_code = 499
|
| 158 |
+
return
|
| 159 |
+
chunk = message.get("body", b"")
|
| 160 |
+
if len(body) + len(chunk) > self.settings.MAX_REQUEST_BODY_BYTES:
|
| 161 |
+
await reject(413, "Request body is too large.")
|
| 162 |
+
return
|
| 163 |
+
body.extend(chunk)
|
| 164 |
+
if not message.get("more_body", False):
|
| 165 |
+
break
|
| 166 |
+
delivered = False
|
| 167 |
+
|
| 168 |
+
async def replay() -> Message:
|
| 169 |
+
nonlocal delivered
|
| 170 |
+
if not delivered:
|
| 171 |
+
delivered = True
|
| 172 |
+
return {"type": "http.request", "body": bytes(body), "more_body": False}
|
| 173 |
+
return await receive()
|
| 174 |
+
|
| 175 |
+
await self.app(scope, replay, guarded_send)
|
| 176 |
+
except Exception as exc:
|
| 177 |
+
logger.error("request_failed", extra={"exception_type": type(exc).__name__})
|
| 178 |
+
if response_started:
|
| 179 |
+
raise
|
| 180 |
+
await reject(500, "The service could not complete this request.")
|
| 181 |
+
finally:
|
| 182 |
+
route = getattr(scope.get("route"), "path", "unmatched")
|
| 183 |
+
logger.info(
|
| 184 |
+
"request_completed",
|
| 185 |
+
extra={
|
| 186 |
+
"method": scope["method"],
|
| 187 |
+
"route": route,
|
| 188 |
+
"status_code": status_code,
|
| 189 |
+
"duration_ms": round((time.monotonic() - started) * 1000, 2),
|
| 190 |
+
},
|
| 191 |
+
)
|
| 192 |
+
request_id_context.reset(token)
|
backend/app/api/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""HTTP route modules."""
|
backend/app/api/routes/health.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Liveness and diagnostic health endpoint."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
from sqlalchemy import text
|
| 10 |
+
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.database import SessionLocal
|
| 13 |
+
from app.ml.models.loader import artifact_status
|
| 14 |
+
|
| 15 |
+
router = APIRouter(tags=["health"])
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def database_ready() -> bool:
|
| 19 |
+
"""Check database reachability without loading models or publishing queue work."""
|
| 20 |
+
try:
|
| 21 |
+
with SessionLocal() as db:
|
| 22 |
+
db.execute(text("SELECT 1"))
|
| 23 |
+
return True
|
| 24 |
+
except Exception:
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/ready")
|
| 29 |
+
def ready() -> JSONResponse:
|
| 30 |
+
"""Readiness is database reachability, not a guarantee of worker/model readiness."""
|
| 31 |
+
available = database_ready()
|
| 32 |
+
return JSONResponse(
|
| 33 |
+
{"status": "ready" if available else "unavailable", "database": available},
|
| 34 |
+
status_code=200 if available else 503,
|
| 35 |
+
headers={} if available else {"Retry-After": "10"},
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@router.get("/health")
|
| 40 |
+
def health() -> dict[str, Any]:
|
| 41 |
+
"""Return 200 for liveness, with explicit dependency diagnostics.
|
| 42 |
+
|
| 43 |
+
The API itself does not load ML artifacts: workers own inference and fail
|
| 44 |
+
closed on missing artifacts. Keeping liveness independent means uptime
|
| 45 |
+
monitors can report a useful degraded state during a model rollout.
|
| 46 |
+
"""
|
| 47 |
+
database_ok = database_ready()
|
| 48 |
+
try:
|
| 49 |
+
models = artifact_status(settings.MODEL_DIR)
|
| 50 |
+
except OSError:
|
| 51 |
+
models = {"accessible": False}
|
| 52 |
+
ready = database_ok and all(models.values())
|
| 53 |
+
return {
|
| 54 |
+
"status": "healthy" if ready else "degraded",
|
| 55 |
+
"database": database_ok,
|
| 56 |
+
"model_artifacts": models,
|
| 57 |
+
"environment": settings.ENVIRONMENT,
|
| 58 |
+
}
|
backend/app/api/routes/products.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Product ranking and detail routes (PRD §14.2, §16.4)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any, Literal
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
| 8 |
+
from sqlalchemy import asc, desc, func, nulls_last
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from app.api.deps import get_db
|
| 12 |
+
from app.models.product import Product
|
| 13 |
+
from app.models.review import Review, ReviewStatus
|
| 14 |
+
from app.schemas.product import ProductDetailResponse, ProductResponse
|
| 15 |
+
from app.schemas.review import ReviewResponse
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/products", tags=["products"])
|
| 18 |
+
|
| 19 |
+
SortBy = Literal["corrected_score", "star_average", "review_count", "uncertainty", "rank_position"]
|
| 20 |
+
SortOrder = Literal["asc", "desc"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@router.get("", response_model=list[ProductResponse])
|
| 24 |
+
def list_products(
|
| 25 |
+
response: Response,
|
| 26 |
+
sort_by: SortBy = "corrected_score",
|
| 27 |
+
order: SortOrder = "desc",
|
| 28 |
+
limit: int = Query(default=50, ge=1, le=100),
|
| 29 |
+
offset: int = Query(default=0, ge=0),
|
| 30 |
+
db: Session = Depends(get_db),
|
| 31 |
+
) -> list[Product]:
|
| 32 |
+
"""Paginated ranking of scored products.
|
| 33 |
+
|
| 34 |
+
Product shells without a completed review are excluded. Ties break on ASIN
|
| 35 |
+
so pagination is stable. ``X-Total-Count`` carries the unpaginated total.
|
| 36 |
+
"""
|
| 37 |
+
column = getattr(Product, sort_by)
|
| 38 |
+
direction: Any = desc(column) if order == "desc" else asc(column)
|
| 39 |
+
query = db.query(Product).filter(Product.corrected_score.is_not(None))
|
| 40 |
+
response.headers["X-Total-Count"] = str(query.count())
|
| 41 |
+
return (
|
| 42 |
+
query.order_by(nulls_last(direction), asc(Product.asin)).offset(offset).limit(limit).all()
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("/{asin}", response_model=ProductDetailResponse)
|
| 47 |
+
def get_product(
|
| 48 |
+
asin: str,
|
| 49 |
+
reviews_limit: int = Query(default=20, ge=1, le=100),
|
| 50 |
+
reviews_offset: int = Query(default=0, ge=0),
|
| 51 |
+
db: Session = Depends(get_db),
|
| 52 |
+
) -> ProductDetailResponse:
|
| 53 |
+
"""Product aggregate plus a newest-first page of its completed reviews."""
|
| 54 |
+
product = db.get(Product, asin)
|
| 55 |
+
if product is None:
|
| 56 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Product not found")
|
| 57 |
+
|
| 58 |
+
base = db.query(Review).filter(
|
| 59 |
+
Review.product_asin == asin, Review.status == ReviewStatus.DONE.value
|
| 60 |
+
)
|
| 61 |
+
total = base.with_entities(func.count(Review.id)).scalar() or 0
|
| 62 |
+
reviews = (
|
| 63 |
+
base.order_by(desc(Review.created_at), desc(Review.id))
|
| 64 |
+
.offset(reviews_offset)
|
| 65 |
+
.limit(reviews_limit)
|
| 66 |
+
.all()
|
| 67 |
+
)
|
| 68 |
+
return ProductDetailResponse.model_validate(
|
| 69 |
+
{
|
| 70 |
+
**ProductResponse.model_validate(product).model_dump(),
|
| 71 |
+
"eb_shrinkage_factor": product.eb_shrinkage_factor,
|
| 72 |
+
"aspect_summary": product.aspect_summary,
|
| 73 |
+
"last_aggregated_at": product.last_aggregated_at,
|
| 74 |
+
"reviews": [ReviewResponse.model_validate(r) for r in reviews],
|
| 75 |
+
"reviews_total": total,
|
| 76 |
+
"reviews_limit": reviews_limit,
|
| 77 |
+
"reviews_offset": reviews_offset,
|
| 78 |
+
"reviews_has_more": reviews_offset + len(reviews) < total,
|
| 79 |
+
}
|
| 80 |
+
)
|
backend/app/api/routes/reviews.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Review submission and polling routes (PRD §14.1–14.2)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from datetime import UTC, datetime
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 9 |
+
from sqlalchemy.exc import IntegrityError
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
+
from app.api.deps import get_db
|
| 13 |
+
from app.models.job import Job, JobKind
|
| 14 |
+
from app.models.product import Product
|
| 15 |
+
from app.models.review import Review, ReviewStatus
|
| 16 |
+
from app.schemas.review import ReviewCreate, ReviewResponse, ReviewTaskStatus
|
| 17 |
+
from app.worker.outbox import send_wakeup
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
|
| 21 |
+
|
| 22 |
+
STATUS_MESSAGES: dict[str, str] = {
|
| 23 |
+
ReviewStatus.PENDING.value: "Review is queued for processing",
|
| 24 |
+
ReviewStatus.PROCESSING.value: "Review is being processed",
|
| 25 |
+
ReviewStatus.DONE.value: "Review processing completed",
|
| 26 |
+
ReviewStatus.FAILED.value: "Review processing failed",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _get_review_or_404(db: Session, review_id: int) -> Review:
|
| 31 |
+
review = db.get(Review, review_id)
|
| 32 |
+
if review is None:
|
| 33 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Review not found")
|
| 34 |
+
return review
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _ensure_product_shell(db: Session, asin: str, title: str | None) -> None:
|
| 38 |
+
"""Create or update the product row without losing concurrent first reviews."""
|
| 39 |
+
product = db.get(Product, asin)
|
| 40 |
+
if product is None:
|
| 41 |
+
try:
|
| 42 |
+
# The savepoint lets a concurrent ASIN insert fail locally while
|
| 43 |
+
# preserving the review/job transaction that follows it.
|
| 44 |
+
with db.begin_nested():
|
| 45 |
+
db.add(Product(asin=asin, title=title))
|
| 46 |
+
db.flush()
|
| 47 |
+
except IntegrityError:
|
| 48 |
+
product = db.get(Product, asin)
|
| 49 |
+
if product is None:
|
| 50 |
+
raise
|
| 51 |
+
if product is not None and title and not product.title:
|
| 52 |
+
product.title = title
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post("", response_model=ReviewTaskStatus, status_code=status.HTTP_202_ACCEPTED)
|
| 56 |
+
def submit_review(payload: ReviewCreate, db: Session = Depends(get_db)) -> ReviewTaskStatus:
|
| 57 |
+
"""Persist a review and its processing job atomically; return for polling.
|
| 58 |
+
|
| 59 |
+
The product shell is created first because ``reviews.product_asin`` is a
|
| 60 |
+
foreign key (the PRD's schema made every first review of an ASIN fail).
|
| 61 |
+
After commit a wake-up is sent to Redis; if Redis is down the job is still
|
| 62 |
+
durable and the outbox dispatcher delivers it later, so this never 503s.
|
| 63 |
+
"""
|
| 64 |
+
asin = payload.product_asin
|
| 65 |
+
if asin:
|
| 66 |
+
_ensure_product_shell(db, asin, payload.product_title)
|
| 67 |
+
|
| 68 |
+
review = Review(
|
| 69 |
+
text=payload.text,
|
| 70 |
+
star_rating=payload.star_rating,
|
| 71 |
+
helpful_votes=payload.helpful_votes,
|
| 72 |
+
verified_purchase=payload.verified_purchase,
|
| 73 |
+
product_title=payload.product_title,
|
| 74 |
+
product_asin=asin,
|
| 75 |
+
status=ReviewStatus.PENDING.value,
|
| 76 |
+
)
|
| 77 |
+
db.add(review)
|
| 78 |
+
db.flush() # assigns review.id
|
| 79 |
+
job = Job(kind=JobKind.REVIEW.value, review_id=review.id)
|
| 80 |
+
db.add(job)
|
| 81 |
+
db.commit()
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
send_wakeup(job.id)
|
| 85 |
+
job.last_dispatched_at = datetime.now(UTC)
|
| 86 |
+
db.commit()
|
| 87 |
+
except Exception:
|
| 88 |
+
db.rollback()
|
| 89 |
+
logger.warning("Broker unavailable for job %s; outbox will retry", job.id, exc_info=True)
|
| 90 |
+
|
| 91 |
+
return ReviewTaskStatus(
|
| 92 |
+
review_id=review.id,
|
| 93 |
+
task_id=job.id,
|
| 94 |
+
status=ReviewStatus.PENDING.value,
|
| 95 |
+
message="Review queued for processing",
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@router.get("/{review_id}", response_model=ReviewResponse)
|
| 100 |
+
def get_review(review_id: int, db: Session = Depends(get_db)) -> Review:
|
| 101 |
+
"""Get a review and, when available, its pipeline output."""
|
| 102 |
+
return _get_review_or_404(db, review_id)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@router.get("/{review_id}/status", response_model=ReviewTaskStatus)
|
| 106 |
+
def get_review_status(review_id: int, db: Session = Depends(get_db)) -> ReviewTaskStatus:
|
| 107 |
+
"""Lightweight lifecycle state for polling clients."""
|
| 108 |
+
review = _get_review_or_404(db, review_id)
|
| 109 |
+
job_id = db.query(Job.id).filter(Job.review_id == review.id).scalar()
|
| 110 |
+
return ReviewTaskStatus(
|
| 111 |
+
review_id=review.id,
|
| 112 |
+
task_id=job_id,
|
| 113 |
+
status=review.status,
|
| 114 |
+
message=STATUS_MESSAGES.get(review.status, "Unknown status"),
|
| 115 |
+
)
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application settings loaded from environment variables / the repo-root `.env`.
|
| 2 |
+
|
| 3 |
+
Every setting is documented in `.env.example`. Settings are read once and
|
| 4 |
+
cached via :func:`get_settings`; import the module-level ``settings`` object for
|
| 5 |
+
convenience.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from ipaddress import ip_network
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Literal
|
| 14 |
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
| 15 |
+
|
| 16 |
+
from pydantic import Field, field_validator
|
| 17 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 18 |
+
|
| 19 |
+
#: Repository root (``backend/app/config.py`` -> two parents up from ``app``).
|
| 20 |
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _normalize_database_url(url: str) -> str:
|
| 24 |
+
"""Force the psycopg2 driver for Postgres URLs.
|
| 25 |
+
|
| 26 |
+
Supabase/Heroku-style ``postgres://`` URLs are rejected by SQLAlchemy 2.x,
|
| 27 |
+
so both ``postgres://`` and bare ``postgresql://`` are rewritten to
|
| 28 |
+
``postgresql+psycopg2://``. Other schemes (e.g. ``sqlite://``) pass through.
|
| 29 |
+
"""
|
| 30 |
+
for prefix in ("postgres://", "postgresql://"):
|
| 31 |
+
if url.startswith(prefix):
|
| 32 |
+
return "postgresql+psycopg2://" + url[len(prefix) :]
|
| 33 |
+
return url
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _normalize_redis_url(url: str) -> str:
|
| 37 |
+
"""Ensure TLS Redis URLs carry ``ssl_cert_reqs``.
|
| 38 |
+
|
| 39 |
+
Celery refuses ``rediss://`` broker URLs without an explicit
|
| 40 |
+
``ssl_cert_reqs`` query parameter. Upstash presents a valid public
|
| 41 |
+
certificate, so ``required`` (full verification) is the safe default.
|
| 42 |
+
"""
|
| 43 |
+
if not url.startswith("rediss://"):
|
| 44 |
+
return url
|
| 45 |
+
parts = urlsplit(url)
|
| 46 |
+
query = dict(parse_qsl(parts.query))
|
| 47 |
+
query.setdefault("ssl_cert_reqs", "required")
|
| 48 |
+
return urlunsplit(parts._replace(query=urlencode(query)))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class Settings(BaseSettings):
|
| 52 |
+
"""Typed runtime configuration."""
|
| 53 |
+
|
| 54 |
+
model_config = SettingsConfigDict(
|
| 55 |
+
env_file=REPO_ROOT / ".env",
|
| 56 |
+
env_file_encoding="utf-8",
|
| 57 |
+
extra="ignore",
|
| 58 |
+
case_sensitive=True,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# --- Database / queue ---------------------------------------------------
|
| 62 |
+
DATABASE_URL: str = Field(..., description="SQLAlchemy URL (Supabase or local Postgres).")
|
| 63 |
+
REDIS_URL: str = Field(..., description="Redis URL (Upstash rediss:// or local redis://).")
|
| 64 |
+
CELERY_BROKER_URL: str = ""
|
| 65 |
+
CELERY_RESULT_BACKEND: str = ""
|
| 66 |
+
|
| 67 |
+
# --- App ------------------------------------------------------------------
|
| 68 |
+
ENVIRONMENT: Literal["development", "test", "production"] = "development"
|
| 69 |
+
SECRET_KEY: str = ""
|
| 70 |
+
CORS_ORIGINS: str = "http://localhost:3000"
|
| 71 |
+
REVIEW_RATE_LIMIT: int = Field(default=10, ge=1, le=10000)
|
| 72 |
+
REVIEW_RATE_WINDOW_SECONDS: int = Field(default=60, ge=1, le=86400)
|
| 73 |
+
RATE_LIMIT_MAX_CLIENTS: int = Field(default=10000, ge=1, le=100000)
|
| 74 |
+
MAX_REQUEST_BODY_BYTES: int = Field(default=65536, ge=1024, le=1048576)
|
| 75 |
+
TRUSTED_PROXY_CIDRS: str = ""
|
| 76 |
+
|
| 77 |
+
@field_validator("TRUSTED_PROXY_CIDRS")
|
| 78 |
+
@classmethod
|
| 79 |
+
def _trusted_proxies(cls, value: str) -> str:
|
| 80 |
+
for network in value.split(","):
|
| 81 |
+
if network.strip():
|
| 82 |
+
ip_network(network.strip(), strict=False)
|
| 83 |
+
return value.strip()
|
| 84 |
+
|
| 85 |
+
# --- Models -----------------------------------------------------------------
|
| 86 |
+
MODEL_DIR: Path = Path("data/models")
|
| 87 |
+
HF_MODEL_REPO: str = ""
|
| 88 |
+
HF_TOKEN: str = ""
|
| 89 |
+
|
| 90 |
+
@field_validator("DATABASE_URL")
|
| 91 |
+
@classmethod
|
| 92 |
+
def _db_url(cls, value: str) -> str:
|
| 93 |
+
return _normalize_database_url(value.strip())
|
| 94 |
+
|
| 95 |
+
@field_validator("REDIS_URL", "CELERY_BROKER_URL", "CELERY_RESULT_BACKEND")
|
| 96 |
+
@classmethod
|
| 97 |
+
def _redis_url(cls, value: str) -> str:
|
| 98 |
+
return _normalize_redis_url(value.strip())
|
| 99 |
+
|
| 100 |
+
@field_validator("MODEL_DIR")
|
| 101 |
+
@classmethod
|
| 102 |
+
def _model_dir(cls, value: Path) -> Path:
|
| 103 |
+
# Relative paths are anchored at the repo root so the API (run from
|
| 104 |
+
# `backend/`) and notebooks (run from the root) resolve the same folder.
|
| 105 |
+
return value if value.is_absolute() else (REPO_ROOT / value).resolve()
|
| 106 |
+
|
| 107 |
+
# --- Derived --------------------------------------------------------------
|
| 108 |
+
@property
|
| 109 |
+
def broker_url(self) -> str:
|
| 110 |
+
"""Celery broker; falls back to ``REDIS_URL``."""
|
| 111 |
+
return self.CELERY_BROKER_URL or self.REDIS_URL
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def result_backend(self) -> str:
|
| 115 |
+
"""Celery result backend; falls back to ``REDIS_URL``."""
|
| 116 |
+
return self.CELERY_RESULT_BACKEND or self.REDIS_URL
|
| 117 |
+
|
| 118 |
+
@property
|
| 119 |
+
def cors_origins(self) -> list[str]:
|
| 120 |
+
"""``CORS_ORIGINS`` split on commas, blanks removed."""
|
| 121 |
+
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def is_production(self) -> bool:
|
| 125 |
+
return self.ENVIRONMENT == "production"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@lru_cache(maxsize=1)
|
| 129 |
+
def get_settings() -> Settings:
|
| 130 |
+
"""Return the cached settings instance."""
|
| 131 |
+
return Settings() # values come from env
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
settings = get_settings()
|
backend/app/database.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy engine, session factory, and declarative base."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import JSON, create_engine
|
| 9 |
+
from sqlalchemy.dialects.postgresql import JSONB
|
| 10 |
+
from sqlalchemy.engine import Engine
|
| 11 |
+
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
| 12 |
+
from sqlalchemy.pool import StaticPool
|
| 13 |
+
|
| 14 |
+
from app.config import settings
|
| 15 |
+
|
| 16 |
+
#: JSON column type: native JSONB on Postgres, plain JSON elsewhere (SQLite tests/CI).
|
| 17 |
+
JSONType = JSON().with_variant(JSONB(), "postgresql")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Base(DeclarativeBase):
|
| 21 |
+
"""Declarative base for all ORM models."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def build_engine(url: str) -> Engine:
|
| 25 |
+
"""Create an engine with settings appropriate for the URL's dialect."""
|
| 26 |
+
if url.startswith("sqlite"):
|
| 27 |
+
# In-memory SQLite needs a single shared connection across threads.
|
| 28 |
+
in_memory = ":memory:" in url or url.rstrip("/") == "sqlite:"
|
| 29 |
+
return create_engine(
|
| 30 |
+
url,
|
| 31 |
+
connect_args={"check_same_thread": False},
|
| 32 |
+
poolclass=StaticPool if in_memory else None,
|
| 33 |
+
)
|
| 34 |
+
return create_engine(
|
| 35 |
+
url,
|
| 36 |
+
pool_pre_ping=True, # survive Supabase/pooler idle disconnects
|
| 37 |
+
pool_size=5,
|
| 38 |
+
max_overflow=5,
|
| 39 |
+
pool_recycle=1800,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
engine = build_engine(settings.DATABASE_URL)
|
| 44 |
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@contextmanager
|
| 48 |
+
def session_scope() -> Iterator[Session]:
|
| 49 |
+
"""Context-managed session for non-request code (Celery tasks, scripts).
|
| 50 |
+
|
| 51 |
+
Rolls back on any exception and always closes the session.
|
| 52 |
+
"""
|
| 53 |
+
db = SessionLocal()
|
| 54 |
+
try:
|
| 55 |
+
yield db
|
| 56 |
+
except Exception:
|
| 57 |
+
db.rollback()
|
| 58 |
+
raise
|
| 59 |
+
finally:
|
| 60 |
+
db.close()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entry point."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
|
| 8 |
+
from app.api.logging import configure_json_logging
|
| 9 |
+
from app.api.middleware import RequestGuard
|
| 10 |
+
from app.api.routes import health, products, reviews
|
| 11 |
+
from app.config import settings
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def create_app() -> FastAPI:
|
| 15 |
+
"""Construct the HTTP API without loading worker-owned ML models."""
|
| 16 |
+
app = FastAPI(title="VERA API", version="1.0.0")
|
| 17 |
+
configure_json_logging()
|
| 18 |
+
app.add_middleware(
|
| 19 |
+
CORSMiddleware,
|
| 20 |
+
allow_origins=settings.cors_origins,
|
| 21 |
+
allow_credentials=False,
|
| 22 |
+
allow_methods=["GET", "POST"],
|
| 23 |
+
allow_headers=["Content-Type", "Authorization", "X-Request-ID"],
|
| 24 |
+
expose_headers=["X-Total-Count", "X-Request-ID", "Retry-After"],
|
| 25 |
+
)
|
| 26 |
+
app.add_middleware(RequestGuard, settings=settings)
|
| 27 |
+
app.include_router(health.router)
|
| 28 |
+
app.include_router(reviews.router)
|
| 29 |
+
app.include_router(products.router)
|
| 30 |
+
return app
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
app = create_app()
|
backend/app/ml/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""VERA ML pipeline: seven signal agents, fusion, and calibration (PRD §8–§11)."""
|
backend/app/ml/agents/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The seven VERA signal agents (PRD §9).
|
| 2 |
+
|
| 3 |
+
1. :class:`~app.ml.agents.preprocessor.TextPreprocessor`
|
| 4 |
+
2. :class:`~app.ml.agents.experience_extractor.ExperienceExtractor`
|
| 5 |
+
3. :class:`~app.ml.agents.absa.ABSAModule`
|
| 6 |
+
4. :class:`~app.ml.agents.consistency.ConsistencyScorer`
|
| 7 |
+
5. :class:`~app.ml.agents.quality.QualityAssessor`
|
| 8 |
+
6. :class:`~app.ml.agents.domain_rating.DomainRatingModel`
|
| 9 |
+
7. :class:`~app.ml.agents.fusion.FusionModel`
|
| 10 |
+
"""
|
backend/app/ml/agents/absa.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 3 — Aspect-Based Sentiment Analysis (PRD §9, Agent 3).
|
| 2 |
+
|
| 3 |
+
Uses ``yangheng/deberta-v3-base-absa-v1.1`` exported to ONNX, unchanged (no
|
| 4 |
+
fine-tuning). The learned :class:`~app.ml.agents.aspect_detector.AspectDetector`
|
| 5 |
+
decides which of the 10 earbud aspects are mentioned (keyword matching is the
|
| 6 |
+
fallback when no detector is loaded); the model then classifies sentiment for
|
| 7 |
+
those aspects in ONE batched inference call.
|
| 8 |
+
|
| 9 |
+
Input format: ``[CLS] review_text [SEP] aspect [SEP]`` — review FIRST, aspect
|
| 10 |
+
SECOND (PRD weakness #4). Only the review is ever truncated.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
from collections.abc import Mapping
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import TYPE_CHECKING, Any
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
from app.ml.constants import ASPECT_KEYWORDS, ASPECT_PHRASES, EARBUD_ASPECTS
|
| 24 |
+
from app.ml.models.onnx_session import InferenceSessionLike, build_feed, create_session, softmax
|
| 25 |
+
from app.ml.models.tokenizer_wrapper import LightTokenizer
|
| 26 |
+
|
| 27 |
+
if TYPE_CHECKING:
|
| 28 |
+
from app.ml.agents.aspect_detector import AspectDetector
|
| 29 |
+
|
| 30 |
+
_VALID_SENTIMENTS = frozenset({"negative", "neutral", "positive"})
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _compile_keyword_patterns() -> dict[str, re.Pattern[str]]:
|
| 34 |
+
"""One regex per aspect; keywords must start at a word boundary."""
|
| 35 |
+
return {
|
| 36 |
+
aspect: re.compile(
|
| 37 |
+
r"\b(?:" + "|".join(re.escape(kw) for kw in ASPECT_KEYWORDS[aspect]) + r")",
|
| 38 |
+
re.IGNORECASE,
|
| 39 |
+
)
|
| 40 |
+
for aspect in EARBUD_ASPECTS
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
_KEYWORD_PATTERNS = _compile_keyword_patterns()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def load_id2label(config_path: str | Path) -> dict[int, str]:
|
| 48 |
+
"""Read the ABSA label mapping written by the export notebook.
|
| 49 |
+
|
| 50 |
+
The PRD hardcoded ``{0: negative, 1: neutral, 2: positive}``; reading the
|
| 51 |
+
exported ``config.id2label`` removes the risk of a silent label swap.
|
| 52 |
+
"""
|
| 53 |
+
with open(config_path, encoding="utf-8") as fh:
|
| 54 |
+
raw = json.load(fh)["id2label"]
|
| 55 |
+
mapping = {int(k): str(v).strip().lower() for k, v in raw.items()}
|
| 56 |
+
if set(mapping.values()) != _VALID_SENTIMENTS:
|
| 57 |
+
raise ValueError(f"absa_config.json id2label must cover {_VALID_SENTIMENTS}, got {mapping}")
|
| 58 |
+
return mapping
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def not_mentioned() -> dict[str, Any]:
|
| 62 |
+
"""Aspect result for an aspect the review does not mention."""
|
| 63 |
+
return {"detected": False, "sentiment": "not_mentioned", "confidence": 0.0}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ABSAModule:
|
| 67 |
+
"""Agent 3: per-aspect detection + sentiment."""
|
| 68 |
+
|
| 69 |
+
def __init__(
|
| 70 |
+
self,
|
| 71 |
+
model_path: str | Path,
|
| 72 |
+
tokenizer_path: str | Path,
|
| 73 |
+
config_path: str | Path,
|
| 74 |
+
*,
|
| 75 |
+
session: InferenceSessionLike | None = None,
|
| 76 |
+
tokenizer: LightTokenizer | None = None,
|
| 77 |
+
id2label: dict[int, str] | None = None,
|
| 78 |
+
detector: AspectDetector | None = None,
|
| 79 |
+
) -> None:
|
| 80 |
+
"""
|
| 81 |
+
Args:
|
| 82 |
+
detector: Learned mention detector. Without it, keyword matching decides
|
| 83 |
+
which aspects are mentioned (test/legacy behaviour).
|
| 84 |
+
"""
|
| 85 |
+
self.id2label = id2label or load_id2label(config_path)
|
| 86 |
+
self.tokenizer = tokenizer or LightTokenizer(tokenizer_path)
|
| 87 |
+
self.session = session or create_session(model_path)
|
| 88 |
+
self.detector = detector
|
| 89 |
+
|
| 90 |
+
@staticmethod
|
| 91 |
+
def keyword_detect(text: str, aspect: str) -> bool:
|
| 92 |
+
"""Fast pre-filter: is the aspect plausibly mentioned?"""
|
| 93 |
+
return bool(_KEYWORD_PATTERNS[aspect].search(text))
|
| 94 |
+
|
| 95 |
+
def classify_aspects(self, text: str, aspects: list[str]) -> dict[str, dict[str, Any]]:
|
| 96 |
+
"""Classify sentiment for several aspects of one review in a single batch."""
|
| 97 |
+
if not aspects:
|
| 98 |
+
return {}
|
| 99 |
+
encoded = self.tokenizer.encode_pairs([(text, ASPECT_PHRASES[a]) for a in aspects])
|
| 100 |
+
logits = np.asarray(self.session.run(None, build_feed(self.session, encoded))[0])
|
| 101 |
+
probs = softmax(logits.astype(np.float64), axis=-1)
|
| 102 |
+
|
| 103 |
+
label2id = {label: idx for idx, label in self.id2label.items()}
|
| 104 |
+
results: dict[str, dict[str, Any]] = {}
|
| 105 |
+
for row, aspect in zip(probs, aspects, strict=True):
|
| 106 |
+
pred_idx = int(np.argmax(row))
|
| 107 |
+
results[aspect] = {
|
| 108 |
+
"detected": True,
|
| 109 |
+
"sentiment": self.id2label[pred_idx],
|
| 110 |
+
"confidence": round(float(row[pred_idx]), 4),
|
| 111 |
+
"prob_neg": round(float(row[label2id["negative"]]), 4),
|
| 112 |
+
"prob_neu": round(float(row[label2id["neutral"]]), 4),
|
| 113 |
+
"prob_pos": round(float(row[label2id["positive"]]), 4),
|
| 114 |
+
}
|
| 115 |
+
return results
|
| 116 |
+
|
| 117 |
+
def mentioned(self, clean_text: str) -> dict[str, bool]:
|
| 118 |
+
"""Which aspects the review mentions: learned detector if loaded, else keywords."""
|
| 119 |
+
if self.detector is not None:
|
| 120 |
+
return self.detector.detect(clean_text)
|
| 121 |
+
return {a: self.keyword_detect(clean_text, a) for a in EARBUD_ASPECTS}
|
| 122 |
+
|
| 123 |
+
def process(
|
| 124 |
+
self, clean_text: str, mentioned: Mapping[str, bool] | None = None
|
| 125 |
+
) -> dict[str, Any]:
|
| 126 |
+
"""Return ``{"aspects": {aspect_key: result}}`` for all 10 aspects, in canonical order.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
mentioned: Precomputed mention decisions. Training passes out-of-fold
|
| 130 |
+
detector decisions here so fusion never sees in-sample detections.
|
| 131 |
+
"""
|
| 132 |
+
decisions = mentioned if mentioned is not None else self.mentioned(clean_text)
|
| 133 |
+
detected = [a for a in EARBUD_ASPECTS if decisions.get(a, False)]
|
| 134 |
+
classified = self.classify_aspects(clean_text, detected)
|
| 135 |
+
return {"aspects": {a: classified.get(a, not_mentioned()) for a in EARBUD_ASPECTS}}
|
backend/app/ml/agents/aspect_detector.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Learned aspect detector — decides WHICH of the 10 earbud aspects a review mentions.
|
| 2 |
+
|
| 3 |
+
Replaces the keyword-only pre-filter of Agent 3 (PRD §9). Keyword lists alone
|
| 4 |
+
reached detection F1 0.54 (build quality) and 0.66 (connectivity) on the round-1
|
| 5 |
+
test split: durability phrasings were missed and generic words ("pair of
|
| 6 |
+
headphones") fired falsely.
|
| 7 |
+
|
| 8 |
+
Model, per aspect: logistic regression on word 1–2-gram TF-IDF features plus
|
| 9 |
+
the keyword hits of ALL aspects as extra features, with a decision threshold
|
| 10 |
+
chosen by product-grouped out-of-fold F1 on training rows only
|
| 11 |
+
(``training/vera_training/aspects.py``). Features are built by
|
| 12 |
+
:func:`detector_features`, shared by training and serving.
|
| 13 |
+
|
| 14 |
+
SECURITY: the artifact is a joblib pickle — only load files produced by the VERA
|
| 15 |
+
training pipeline.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from collections.abc import Sequence
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any, Final
|
| 23 |
+
|
| 24 |
+
import joblib
|
| 25 |
+
import numpy as np
|
| 26 |
+
import scipy.sparse as sp
|
| 27 |
+
import sklearn
|
| 28 |
+
|
| 29 |
+
from app.ml.agents.absa import ABSAModule
|
| 30 |
+
from app.ml.constants import EARBUD_ASPECTS
|
| 31 |
+
|
| 32 |
+
#: Scale of the keyword indicator columns relative to TF-IDF values (≈ unit-norm rows).
|
| 33 |
+
KEYWORD_FEATURE_WEIGHT: Final = 3.0
|
| 34 |
+
ARTIFACT_KEYS: Final = frozenset({"vectorizer", "models", "thresholds", "aspects"})
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def keyword_matrix(texts: Sequence[str]) -> np.ndarray:
|
| 38 |
+
"""``(n_texts, 10)`` float matrix of keyword hits in canonical aspect order."""
|
| 39 |
+
return np.array(
|
| 40 |
+
[[ABSAModule.keyword_detect(text, aspect) for aspect in EARBUD_ASPECTS] for text in texts],
|
| 41 |
+
dtype=np.float64,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def detector_features(vectorizer: Any, texts: Sequence[str]) -> sp.csr_matrix:
|
| 46 |
+
"""TF-IDF features concatenated with weighted keyword indicators."""
|
| 47 |
+
tfidf = vectorizer.transform(list(texts))
|
| 48 |
+
keywords = sp.csr_matrix(keyword_matrix(texts) * KEYWORD_FEATURE_WEIGHT)
|
| 49 |
+
return sp.hstack([tfidf, keywords]).tocsr()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class AspectDetector:
|
| 53 |
+
"""Per-aspect probabilities and thresholded mention decisions."""
|
| 54 |
+
|
| 55 |
+
def __init__(self, artifact_path: str | Path) -> None:
|
| 56 |
+
artifacts = joblib.load(artifact_path)
|
| 57 |
+
self._init_from(artifacts, source=str(artifact_path))
|
| 58 |
+
|
| 59 |
+
@classmethod
|
| 60 |
+
def from_components(
|
| 61 |
+
cls,
|
| 62 |
+
vectorizer: Any,
|
| 63 |
+
models: dict[str, Any],
|
| 64 |
+
thresholds: dict[str, float],
|
| 65 |
+
) -> AspectDetector:
|
| 66 |
+
"""Build from in-memory objects (training code and tests)."""
|
| 67 |
+
instance = cls.__new__(cls)
|
| 68 |
+
instance._init_from(
|
| 69 |
+
{
|
| 70 |
+
"vectorizer": vectorizer,
|
| 71 |
+
"models": models,
|
| 72 |
+
"thresholds": thresholds,
|
| 73 |
+
"aspects": list(EARBUD_ASPECTS),
|
| 74 |
+
},
|
| 75 |
+
source="memory",
|
| 76 |
+
)
|
| 77 |
+
return instance
|
| 78 |
+
|
| 79 |
+
def _init_from(self, artifacts: dict[str, Any], source: str) -> None:
|
| 80 |
+
missing = ARTIFACT_KEYS - set(artifacts)
|
| 81 |
+
if missing:
|
| 82 |
+
raise ValueError(f"{source} is missing keys {sorted(missing)}")
|
| 83 |
+
if tuple(artifacts["aspects"]) != EARBUD_ASPECTS:
|
| 84 |
+
raise ValueError(f"{source} aspect order differs from EARBUD_ASPECTS; retrain it")
|
| 85 |
+
trained_with = artifacts.get("sklearn_version")
|
| 86 |
+
if trained_with is not None and trained_with != sklearn.__version__:
|
| 87 |
+
raise ValueError(
|
| 88 |
+
f"{source} trained with scikit-learn {trained_with}, runtime has "
|
| 89 |
+
f"{sklearn.__version__}; retrain with the pinned version."
|
| 90 |
+
)
|
| 91 |
+
self.vectorizer = artifacts["vectorizer"]
|
| 92 |
+
self.models: dict[str, Any] = artifacts["models"]
|
| 93 |
+
self.thresholds: dict[str, float] = {
|
| 94 |
+
k: float(v) for k, v in artifacts["thresholds"].items()
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
def probabilities(self, texts: Sequence[str]) -> np.ndarray:
|
| 98 |
+
"""``(n_texts, 10)`` mention probabilities in canonical aspect order."""
|
| 99 |
+
features = detector_features(self.vectorizer, texts)
|
| 100 |
+
return np.column_stack(
|
| 101 |
+
[self.models[aspect].predict_proba(features)[:, 1] for aspect in EARBUD_ASPECTS]
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
def decide(self, probabilities: np.ndarray) -> list[dict[str, bool]]:
|
| 105 |
+
"""Apply per-aspect thresholds to a probability matrix."""
|
| 106 |
+
return [
|
| 107 |
+
{
|
| 108 |
+
aspect: bool(row[i] >= self.thresholds[aspect])
|
| 109 |
+
for i, aspect in enumerate(EARBUD_ASPECTS)
|
| 110 |
+
}
|
| 111 |
+
for row in np.atleast_2d(probabilities)
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
def detect(self, text: str) -> dict[str, bool]:
|
| 115 |
+
"""Mention decisions for one review."""
|
| 116 |
+
return self.decide(self.probabilities([text]))[0]
|
backend/app/ml/agents/consistency.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 4 — Star-Text Consistency Scorer (PRD §9, Agent 4).
|
| 2 |
+
|
| 3 |
+
Measures how far the clicked star rating is from the text-derived experience
|
| 4 |
+
score. A large gap means the star is unreliable.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import Any, Final
|
| 10 |
+
|
| 11 |
+
from app.ml.constants import SCORE_RANGE
|
| 12 |
+
|
| 13 |
+
#: Gap (in stars) beyond which the star is flagged as inflated / deflated.
|
| 14 |
+
INFLATION_THRESHOLD: Final = 1.0
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ConsistencyScorer:
|
| 18 |
+
"""Agent 4: star vs text agreement."""
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
def process(star_rating: float, experience_score: float) -> dict[str, Any]:
|
| 22 |
+
"""Compare the star with Agent 2's score.
|
| 23 |
+
|
| 24 |
+
Returns:
|
| 25 |
+
``star_text_gap`` (positive = star higher than text), its absolute
|
| 26 |
+
value, inflation/deflation flags, ``consistency_score`` in [0, 1]
|
| 27 |
+
(1 = perfect agreement; max gap on a 1–5 scale is 4), and
|
| 28 |
+
``star_trust_weight`` = consistency² (downweights stars harder).
|
| 29 |
+
"""
|
| 30 |
+
gap = float(star_rating) - float(experience_score)
|
| 31 |
+
abs_gap = abs(gap)
|
| 32 |
+
consistency = max(0.0, 1.0 - abs_gap / SCORE_RANGE)
|
| 33 |
+
return {
|
| 34 |
+
"star_text_gap": round(gap, 3),
|
| 35 |
+
"absolute_gap": round(abs_gap, 3),
|
| 36 |
+
"star_inflated": gap > INFLATION_THRESHOLD,
|
| 37 |
+
"star_deflated": gap < -INFLATION_THRESHOLD,
|
| 38 |
+
"consistency_score": round(consistency, 3),
|
| 39 |
+
"star_trust_weight": round(consistency**2, 3),
|
| 40 |
+
}
|
backend/app/ml/agents/domain_rating.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 6 — Domain Rating Model (PRD §9, Agent 6).
|
| 2 |
+
|
| 3 |
+
TF-IDF -> Ridge regression trained on the UNLABELED earbud corpus, using its
|
| 4 |
+
star ratings as a noisy proxy target. Training lives offline in
|
| 5 |
+
``training/vera_training/domain.py`` (with the held-out ASIN leakage guard);
|
| 6 |
+
this module only loads and applies the artifact.
|
| 7 |
+
|
| 8 |
+
SECURITY: the artifact is a joblib pickle. Only load files produced by the VERA
|
| 9 |
+
training notebooks from your own HF repo — unpickling untrusted files executes code.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
import joblib
|
| 18 |
+
import numpy as np
|
| 19 |
+
import sklearn
|
| 20 |
+
|
| 21 |
+
from app.ml.constants import SCORE_MAX, SCORE_MIN
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class DomainRatingModel:
|
| 25 |
+
"""Agent 6: text -> implied rating from domain vocabulary."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, model_path: str | Path) -> None:
|
| 28 |
+
try:
|
| 29 |
+
artifacts = joblib.load(model_path)
|
| 30 |
+
except ModuleNotFoundError as exc: # e.g. "numpy._core" from a numpy-2 pickle
|
| 31 |
+
raise ValueError(
|
| 32 |
+
f"{model_path} was pickled with incompatible library versions ({exc}). "
|
| 33 |
+
"Retrain it in the production-pinned .venv (see notebooks/05)."
|
| 34 |
+
) from exc
|
| 35 |
+
if not {"vectorizer", "model"} <= set(artifacts):
|
| 36 |
+
raise ValueError(f"{model_path} must contain 'vectorizer' and 'model'")
|
| 37 |
+
trained_with = artifacts.get("sklearn_version")
|
| 38 |
+
if trained_with is not None and trained_with != sklearn.__version__:
|
| 39 |
+
raise ValueError(
|
| 40 |
+
f"{model_path} trained with scikit-learn {trained_with}, runtime has "
|
| 41 |
+
f"{sklearn.__version__}; retrain with the pinned version."
|
| 42 |
+
)
|
| 43 |
+
self.vectorizer = artifacts["vectorizer"]
|
| 44 |
+
self.model = artifacts["model"]
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def from_components(cls, vectorizer: Any, model: Any) -> DomainRatingModel:
|
| 48 |
+
"""Build from in-memory objects (training notebooks and tests)."""
|
| 49 |
+
instance = cls.__new__(cls)
|
| 50 |
+
instance.vectorizer = vectorizer
|
| 51 |
+
instance.model = model
|
| 52 |
+
return instance
|
| 53 |
+
|
| 54 |
+
def process(self, clean_text: str) -> dict[str, Any]:
|
| 55 |
+
"""Predict the implied rating.
|
| 56 |
+
|
| 57 |
+
``domain_confidence`` is the PRD's proxy: 1 − distance to the nearest
|
| 58 |
+
integer star (Ridge gives no native uncertainty).
|
| 59 |
+
"""
|
| 60 |
+
features = self.vectorizer.transform([clean_text])
|
| 61 |
+
raw_score = float(self.model.predict(features)[0])
|
| 62 |
+
domain_score = float(np.clip(raw_score, SCORE_MIN, SCORE_MAX))
|
| 63 |
+
domain_confidence = 1.0 - abs(domain_score - round(domain_score))
|
| 64 |
+
return {
|
| 65 |
+
"domain_score": round(domain_score, 3),
|
| 66 |
+
"domain_confidence": round(domain_confidence, 3),
|
| 67 |
+
}
|
backend/app/ml/agents/experience_extractor.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 2 — Experience Extractor (PRD §9, Agent 2).
|
| 2 |
+
|
| 3 |
+
Fine-tuned DeBERTa-v3-base regression head, exported to INT8 ONNX, estimates
|
| 4 |
+
the experience score (1–5) from review text alone.
|
| 5 |
+
|
| 6 |
+
The model input is produced by :func:`format_experience_input`, which the
|
| 7 |
+
fine-tuning notebook imports too — training and serving must see identical
|
| 8 |
+
text. (The PRD prepended ``"{title} [SEP]"`` at inference but trained on the
|
| 9 |
+
bare text: a train/serve skew. Both now use the bare cleaned text.)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
from app.ml.constants import SCORE_MAX, SCORE_MIN
|
| 20 |
+
from app.ml.models.onnx_session import InferenceSessionLike, build_feed, create_session
|
| 21 |
+
from app.ml.models.tokenizer_wrapper import LightTokenizer
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def format_experience_input(clean_text: str) -> str:
|
| 25 |
+
"""Canonical model input text. Shared with ``vera_training.finetune``."""
|
| 26 |
+
return clean_text.strip()
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class ExperienceExtractor:
|
| 30 |
+
"""Agent 2: text -> predicted experience score."""
|
| 31 |
+
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
model_path: str | Path,
|
| 35 |
+
tokenizer_path: str | Path,
|
| 36 |
+
*,
|
| 37 |
+
session: InferenceSessionLike | None = None,
|
| 38 |
+
tokenizer: LightTokenizer | None = None,
|
| 39 |
+
) -> None:
|
| 40 |
+
"""Load the ONNX session and tokenizer.
|
| 41 |
+
|
| 42 |
+
``session`` / ``tokenizer`` may be injected (tests); otherwise they are
|
| 43 |
+
built from the paths.
|
| 44 |
+
"""
|
| 45 |
+
# max_length comes from the exported tokenizer.json (= fine-tuning length).
|
| 46 |
+
self.tokenizer = tokenizer or LightTokenizer(tokenizer_path)
|
| 47 |
+
self.session = session or create_session(model_path)
|
| 48 |
+
|
| 49 |
+
def predict_raw(self, texts: list[str]) -> np.ndarray:
|
| 50 |
+
"""Return unclipped regression outputs, shape ``(len(texts),)``."""
|
| 51 |
+
encoded = self.tokenizer.encode_batch([format_experience_input(t) for t in texts])
|
| 52 |
+
outputs = self.session.run(None, build_feed(self.session, encoded))
|
| 53 |
+
logits = np.asarray(outputs[0], dtype=np.float64)
|
| 54 |
+
return logits.reshape(len(texts), -1)[:, 0]
|
| 55 |
+
|
| 56 |
+
@staticmethod
|
| 57 |
+
def output_from_raw(raw_score: float) -> dict[str, Any]:
|
| 58 |
+
"""Agent 2 output dict from one raw regression value.
|
| 59 |
+
|
| 60 |
+
Shared with training, where out-of-fold predictions come from PyTorch
|
| 61 |
+
rather than this ONNX session.
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
``predicted_score`` clipped to [1, 5], the ``raw_score``, and
|
| 65 |
+
``model_uncertainty`` — distance of the raw output from the nearest
|
| 66 |
+
0.5 label step (a fixed proxy feature defined by the PRD; the
|
| 67 |
+
calibrated uncertainty comes from conformal prediction, not this).
|
| 68 |
+
"""
|
| 69 |
+
raw = float(raw_score)
|
| 70 |
+
nearest_half = round(raw * 2) / 2
|
| 71 |
+
return {
|
| 72 |
+
"predicted_score": float(np.clip(raw, SCORE_MIN, SCORE_MAX)),
|
| 73 |
+
"raw_score": raw,
|
| 74 |
+
"model_uncertainty": abs(raw - nearest_half),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
def predict(self, clean_text: str) -> dict[str, Any]:
|
| 78 |
+
"""Predict one review (see :meth:`output_from_raw` for the fields)."""
|
| 79 |
+
return self.output_from_raw(float(self.predict_raw([clean_text])[0]))
|
backend/app/ml/agents/fusion.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 7 — Fusion (PRD §10.2).
|
| 2 |
+
|
| 3 |
+
A bootstrap ensemble of XGBoost regressors maps the 40 fusion features to the
|
| 4 |
+
corrected experience score. The ensemble mean is the point estimate; the
|
| 5 |
+
ensemble standard deviation feeds normalised conformal intervals.
|
| 6 |
+
|
| 7 |
+
Artifact format — ``fusion_ensemble.json`` (see DECISIONS.md: JSON instead of
|
| 8 |
+
the PRD's joblib pickle, because pickled XGBoost objects break across library
|
| 9 |
+
versions and unpickling is a code-execution risk)::
|
| 10 |
+
|
| 11 |
+
{
|
| 12 |
+
"format_version": 1,
|
| 13 |
+
"feature_names": [...], # must equal app.ml.features.FEATURE_NAMES
|
| 14 |
+
"imputation_means": {...}, # Agent 5 training means
|
| 15 |
+
"members": ["<xgboost JSON model>", ...],
|
| 16 |
+
"metadata": {...} # free-form: n_train, xgboost_version, ...
|
| 17 |
+
}
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
from collections.abc import Sequence
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Final
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import xgboost as xgb
|
| 29 |
+
|
| 30 |
+
from app.ml.agents.quality import IMPUTATION_KEYS
|
| 31 |
+
from app.ml.constants import SCORE_MAX, SCORE_MIN
|
| 32 |
+
from app.ml.features import FEATURE_NAMES, N_FEATURES
|
| 33 |
+
|
| 34 |
+
FORMAT_VERSION: Final = 1
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def save_fusion_artifact(
|
| 38 |
+
path: str | Path,
|
| 39 |
+
members: Sequence[xgb.XGBRegressor | xgb.Booster],
|
| 40 |
+
imputation_means: dict[str, float],
|
| 41 |
+
metadata: dict[str, Any] | None = None,
|
| 42 |
+
) -> None:
|
| 43 |
+
"""Serialise an ensemble to the portable JSON artifact."""
|
| 44 |
+
if not members:
|
| 45 |
+
raise ValueError("ensemble needs at least one member")
|
| 46 |
+
absent = [k for k in IMPUTATION_KEYS if k not in imputation_means]
|
| 47 |
+
if absent:
|
| 48 |
+
raise ValueError(f"imputation_means missing keys: {absent}")
|
| 49 |
+
|
| 50 |
+
serialised = []
|
| 51 |
+
for member in members:
|
| 52 |
+
booster = member.get_booster() if isinstance(member, xgb.XGBRegressor) else member
|
| 53 |
+
serialised.append(bytes(booster.save_raw(raw_format="json")).decode("utf-8"))
|
| 54 |
+
|
| 55 |
+
payload = {
|
| 56 |
+
"format_version": FORMAT_VERSION,
|
| 57 |
+
"feature_names": list(FEATURE_NAMES),
|
| 58 |
+
"imputation_means": {k: float(v) for k, v in imputation_means.items()},
|
| 59 |
+
"members": serialised,
|
| 60 |
+
"metadata": {"xgboost_version": xgb.__version__, **(metadata or {})},
|
| 61 |
+
}
|
| 62 |
+
Path(path).write_text(json.dumps(payload), encoding="utf-8")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class FusionModel:
|
| 66 |
+
"""Agent 7: loads the ensemble and predicts mean ± std."""
|
| 67 |
+
|
| 68 |
+
def __init__(self, artifact_path: str | Path) -> None:
|
| 69 |
+
payload = json.loads(Path(artifact_path).read_text(encoding="utf-8"))
|
| 70 |
+
|
| 71 |
+
if payload.get("format_version") != FORMAT_VERSION:
|
| 72 |
+
raise ValueError(f"Unsupported fusion artifact version {payload.get('format_version')}")
|
| 73 |
+
if tuple(payload["feature_names"]) != FEATURE_NAMES:
|
| 74 |
+
raise ValueError(
|
| 75 |
+
"Fusion artifact feature_names differ from app.ml.features.FEATURE_NAMES — "
|
| 76 |
+
"retrain the ensemble with the current feature contract."
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
self.imputation_means: dict[str, float] = payload["imputation_means"]
|
| 80 |
+
self.metadata: dict[str, Any] = payload.get("metadata", {})
|
| 81 |
+
self.members: list[xgb.Booster] = []
|
| 82 |
+
for raw in payload["members"]:
|
| 83 |
+
booster = xgb.Booster()
|
| 84 |
+
booster.load_model(bytearray(raw.encode("utf-8")))
|
| 85 |
+
if booster.num_features() != N_FEATURES:
|
| 86 |
+
raise ValueError(
|
| 87 |
+
f"member expects {booster.num_features()} features, not {N_FEATURES}"
|
| 88 |
+
)
|
| 89 |
+
self.members.append(booster)
|
| 90 |
+
if not self.members:
|
| 91 |
+
raise ValueError("fusion artifact contains no members")
|
| 92 |
+
|
| 93 |
+
def predict_members(self, features: np.ndarray) -> np.ndarray:
|
| 94 |
+
"""Raw member predictions, shape ``(n_members, n_rows)``."""
|
| 95 |
+
matrix = np.atleast_2d(np.asarray(features, dtype=np.float32))
|
| 96 |
+
return np.stack([np.asarray(m.inplace_predict(matrix)) for m in self.members])
|
| 97 |
+
|
| 98 |
+
def predict(self, features: np.ndarray) -> dict[str, float]:
|
| 99 |
+
"""Predict one review: clipped ensemble mean and member std."""
|
| 100 |
+
preds = self.predict_members(features)[:, 0]
|
| 101 |
+
return {
|
| 102 |
+
"score": float(np.clip(preds.mean(), SCORE_MIN, SCORE_MAX)),
|
| 103 |
+
"raw_mean": float(preds.mean()),
|
| 104 |
+
"ensemble_std": float(preds.std()),
|
| 105 |
+
}
|
backend/app/ml/agents/preprocessor.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 1 — Text Preprocessor (PRD §9, Agent 1).
|
| 2 |
+
|
| 3 |
+
Gates downstream agents with clean text, language detection, an earbud
|
| 4 |
+
contamination check, and text-quality metrics.
|
| 5 |
+
|
| 6 |
+
DESIGN CONSTRAINT: zero torch / transformers imports. Loading
|
| 7 |
+
SentenceTransformer here would add ~350 MB RAM and blow Render's 512 MB limit.
|
| 8 |
+
Instead: ``langdetect`` (pure Python) + a tiny sklearn TF-IDF over anchor
|
| 9 |
+
phrases + word-boundary keyword matching.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import html
|
| 15 |
+
import re
|
| 16 |
+
from typing import Any, Final
|
| 17 |
+
|
| 18 |
+
from langdetect import DetectorFactory, detect
|
| 19 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 20 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 21 |
+
|
| 22 |
+
DetectorFactory.seed = 42 # make langdetect deterministic
|
| 23 |
+
|
| 24 |
+
_URL_RE: Final = re.compile(r"(?:https?://|www\.)\S+", re.IGNORECASE)
|
| 25 |
+
_BREAK_TAG_RE: Final = re.compile(r"<\s*(?:br|/p|p)\s*/?\s*>", re.IGNORECASE)
|
| 26 |
+
_TAG_RE: Final = re.compile(r"<[^>]+>")
|
| 27 |
+
_WS_RE: Final = re.compile(r"\s+")
|
| 28 |
+
_SENTENCE_SPLIT_RE: Final = re.compile(r"[.!?]+")
|
| 29 |
+
|
| 30 |
+
#: Minimum characters for langdetect to be meaningful (PRD weakness #14).
|
| 31 |
+
MIN_LANGDETECT_CHARS: Final = 10
|
| 32 |
+
#: TF-IDF cosine threshold for the body-text earbud check.
|
| 33 |
+
EARBUD_SIMILARITY_THRESHOLD: Final = 0.25
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class TextPreprocessor:
|
| 37 |
+
"""Agent 1: clean, validate, and characterise review text."""
|
| 38 |
+
|
| 39 |
+
EARBUD_KEYWORDS: Final[tuple[str, ...]] = (
|
| 40 |
+
"earbud",
|
| 41 |
+
"earbuds",
|
| 42 |
+
"earphone",
|
| 43 |
+
"earphones",
|
| 44 |
+
"in-ear",
|
| 45 |
+
"headphone",
|
| 46 |
+
"headphones",
|
| 47 |
+
"headset",
|
| 48 |
+
"tws",
|
| 49 |
+
"true wireless",
|
| 50 |
+
"noise cancel",
|
| 51 |
+
"noise cancelling",
|
| 52 |
+
"noise canceling",
|
| 53 |
+
"airpod",
|
| 54 |
+
"airpods",
|
| 55 |
+
"bluetooth ear",
|
| 56 |
+
"over-ear",
|
| 57 |
+
"on-ear",
|
| 58 |
+
"anc",
|
| 59 |
+
"wireless ear",
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
#: Anchor phrases for the TF-IDF cosine fallback (no model download).
|
| 63 |
+
_ANCHORS: Final[tuple[str, ...]] = (
|
| 64 |
+
"sound quality is great",
|
| 65 |
+
"bass is powerful",
|
| 66 |
+
"noise cancellation works",
|
| 67 |
+
"battery life lasts hours",
|
| 68 |
+
"fit in my ear comfortably",
|
| 69 |
+
"earbud fell out",
|
| 70 |
+
"earphone sounds",
|
| 71 |
+
"in-ear headphone",
|
| 72 |
+
"bluetooth connection drops",
|
| 73 |
+
"microphone quality for calls",
|
| 74 |
+
"charging case",
|
| 75 |
+
"pairing device",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
def __init__(self) -> None:
|
| 79 |
+
# Tiny TF-IDF fitted once on the anchors (~KBs of RAM).
|
| 80 |
+
self._vec = TfidfVectorizer(ngram_range=(1, 2), max_features=2000)
|
| 81 |
+
self._vec.fit(self._ANCHORS)
|
| 82 |
+
self._anchor_matrix = self._vec.transform(self._ANCHORS)
|
| 83 |
+
# Word-boundary match: "anc" must not fire on "dance" / "balance".
|
| 84 |
+
self._keyword_re = re.compile(
|
| 85 |
+
r"\b(?:" + "|".join(re.escape(k) for k in self.EARBUD_KEYWORDS) + r")\b",
|
| 86 |
+
re.IGNORECASE,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# ------------------------------------------------------------------
|
| 90 |
+
@staticmethod
|
| 91 |
+
def clean_text(text: str) -> str:
|
| 92 |
+
"""Strip URLs and HTML, unescape entities, and normalise whitespace.
|
| 93 |
+
|
| 94 |
+
Line-break tags become spaces first — Amazon reviews use ``<br />``
|
| 95 |
+
heavily and removing it outright glues adjacent words together.
|
| 96 |
+
"""
|
| 97 |
+
text = html.unescape(text or "")
|
| 98 |
+
text = _URL_RE.sub(" ", text)
|
| 99 |
+
text = _BREAK_TAG_RE.sub(" ", text)
|
| 100 |
+
text = _TAG_RE.sub(" ", text)
|
| 101 |
+
return _WS_RE.sub(" ", text).strip()
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def detect_language(text: str) -> str:
|
| 105 |
+
"""Return an ISO 639-1 code; fail open to ``'en'``.
|
| 106 |
+
|
| 107 |
+
langdetect raises on very short, emoji-only, or punctuation-only text.
|
| 108 |
+
Those are expected inputs, not retryable errors, so they must never
|
| 109 |
+
crash a Celery task (PRD weakness #14).
|
| 110 |
+
"""
|
| 111 |
+
cleaned = text.strip()
|
| 112 |
+
if len(cleaned) < MIN_LANGDETECT_CHARS:
|
| 113 |
+
return "en"
|
| 114 |
+
try:
|
| 115 |
+
return str(detect(cleaned[:500]))
|
| 116 |
+
except Exception:
|
| 117 |
+
return "en"
|
| 118 |
+
|
| 119 |
+
def is_earbud_content(self, text: str, title: str | None) -> tuple[bool, float]:
|
| 120 |
+
"""Two-stage contamination filter.
|
| 121 |
+
|
| 122 |
+
Stage 1: word-boundary keyword match on title + first 200 body chars.
|
| 123 |
+
Stage 2: max TF-IDF cosine between body text and earbud anchor phrases.
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
``(is_earbud, confidence)`` where confidence is 1.0 for a keyword hit.
|
| 127 |
+
"""
|
| 128 |
+
combined = f"{title or ''} {text[:200]}"
|
| 129 |
+
if self._keyword_re.search(combined):
|
| 130 |
+
return True, 1.0
|
| 131 |
+
|
| 132 |
+
text_vec = self._vec.transform([text[:500]])
|
| 133 |
+
max_sim = float(cosine_similarity(text_vec, self._anchor_matrix)[0].max())
|
| 134 |
+
return max_sim > EARBUD_SIMILARITY_THRESHOLD, round(max_sim, 4)
|
| 135 |
+
|
| 136 |
+
@staticmethod
|
| 137 |
+
def quality_metrics(text: str) -> dict[str, Any]:
|
| 138 |
+
"""Length and lexical-diversity metrics on cleaned text."""
|
| 139 |
+
words = text.split()
|
| 140 |
+
sentences = [s for s in (p.strip() for p in _SENTENCE_SPLIT_RE.split(text)) if len(s) > 3]
|
| 141 |
+
n_words = len(words)
|
| 142 |
+
n_sentences = max(len(sentences), 1)
|
| 143 |
+
return {
|
| 144 |
+
"word_count": n_words,
|
| 145 |
+
"sentence_count": n_sentences,
|
| 146 |
+
"avg_sentence_length": n_words / n_sentences,
|
| 147 |
+
"vocab_richness": len({w.lower() for w in words}) / max(n_words, 1),
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
def process(self, text: str, title: str | None = "") -> dict[str, Any]:
|
| 151 |
+
"""Run Agent 1 on raw review text.
|
| 152 |
+
|
| 153 |
+
``has_url`` is computed on the RAW text: the PRD computed it after URLs
|
| 154 |
+
were stripped, so it was always ``False``.
|
| 155 |
+
"""
|
| 156 |
+
raw = text or ""
|
| 157 |
+
clean = self.clean_text(raw)
|
| 158 |
+
lang = self.detect_language(clean)
|
| 159 |
+
is_earbud, earbud_conf = self.is_earbud_content(clean, title)
|
| 160 |
+
return {
|
| 161 |
+
"clean_text": clean,
|
| 162 |
+
"language": lang,
|
| 163 |
+
"is_english": lang == "en",
|
| 164 |
+
"is_earbud": is_earbud,
|
| 165 |
+
"earbud_confidence": earbud_conf,
|
| 166 |
+
"has_url": bool(_URL_RE.search(raw)),
|
| 167 |
+
**self.quality_metrics(clean),
|
| 168 |
+
}
|
backend/app/ml/agents/quality.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent 5 — Review Quality Assessor (PRD §9, Agent 5).
|
| 2 |
+
|
| 3 |
+
Interpretable heuristics (not a learned model) scoring how trustworthy a review
|
| 4 |
+
is as a signal. The result weights reviews in product aggregation.
|
| 5 |
+
|
| 6 |
+
Fixes vs the PRD sketch (see DECISIONS.md):
|
| 7 |
+
* The PRD formula referenced ``quality_score`` inside its own definition via a
|
| 8 |
+
``'quality_score' in dir()`` hack (always False there) and double-counted the
|
| 9 |
+
verified flag. The composite is now a plain weighted average.
|
| 10 |
+
* ``exp(-age/730)`` is an e-folding time, not a "2-year half-life".
|
| 11 |
+
Freshness now uses ``0.5 ** (age / 730)``, and future dates clip to age 0
|
| 12 |
+
(previously produced freshness > 1).
|
| 13 |
+
* Missing metadata (``None``) is imputed with training-set means stored in the
|
| 14 |
+
fusion artifact, never hard zeros (PRD §10.1, weakness #15).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import math
|
| 20 |
+
from datetime import UTC, datetime
|
| 21 |
+
from typing import Any, Final
|
| 22 |
+
|
| 23 |
+
#: Dataset end date — freshness reference point for timestamped corpus reviews.
|
| 24 |
+
CUTOFF_DATE: Final = datetime(2023, 9, 1, tzinfo=UTC)
|
| 25 |
+
FRESHNESS_HALF_LIFE_DAYS: Final = 730.0
|
| 26 |
+
HELPFUL_VOTES_SATURATION: Final = 100
|
| 27 |
+
LENGTH_SATURATION_WORDS: Final = 500
|
| 28 |
+
|
| 29 |
+
WEIGHTS: Final[dict[str, float]] = {
|
| 30 |
+
"helpfulness": 0.35,
|
| 31 |
+
"freshness": 0.25,
|
| 32 |
+
"length": 0.25,
|
| 33 |
+
"verified": 0.15,
|
| 34 |
+
}
|
| 35 |
+
QUALITY_FLOOR: Final = 0.05
|
| 36 |
+
|
| 37 |
+
#: Keys the imputation mapping must provide (training-set means of the agent outputs).
|
| 38 |
+
IMPUTATION_KEYS: Final[tuple[str, ...]] = (
|
| 39 |
+
"helpfulness_score",
|
| 40 |
+
"freshness_score",
|
| 41 |
+
"verified_purchase",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _clip01(value: float) -> float:
|
| 46 |
+
return min(max(value, 0.0), 1.0)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class QualityAssessor:
|
| 50 |
+
"""Agent 5: metadata -> trust weight."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, imputation: dict[str, float] | None = None) -> None:
|
| 53 |
+
"""
|
| 54 |
+
Args:
|
| 55 |
+
imputation: Training-set means for :data:`IMPUTATION_KEYS`, loaded
|
| 56 |
+
from ``fusion_ensemble.json``. May be ``None`` only when every
|
| 57 |
+
review carries full metadata (the offline training corpus).
|
| 58 |
+
"""
|
| 59 |
+
if imputation is not None:
|
| 60 |
+
absent = [k for k in IMPUTATION_KEYS if k not in imputation]
|
| 61 |
+
if absent:
|
| 62 |
+
raise ValueError(f"imputation mapping missing keys: {absent}")
|
| 63 |
+
self.imputation = imputation
|
| 64 |
+
|
| 65 |
+
def _impute(self, key: str) -> float:
|
| 66 |
+
if self.imputation is None:
|
| 67 |
+
raise ValueError(
|
| 68 |
+
f"Metadata for {key!r} is missing and no imputation means were provided"
|
| 69 |
+
)
|
| 70 |
+
return float(self.imputation[key])
|
| 71 |
+
|
| 72 |
+
@staticmethod
|
| 73 |
+
def helpfulness(helpful_votes: int) -> float:
|
| 74 |
+
"""Log-normalised helpful votes, saturating at 100 votes."""
|
| 75 |
+
votes = max(int(helpful_votes), 0)
|
| 76 |
+
return _clip01(math.log1p(votes) / math.log1p(HELPFUL_VOTES_SATURATION))
|
| 77 |
+
|
| 78 |
+
@staticmethod
|
| 79 |
+
def freshness(timestamp_ms: int) -> float:
|
| 80 |
+
"""True half-life decay relative to :data:`CUTOFF_DATE`."""
|
| 81 |
+
review_date = datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)
|
| 82 |
+
age_days = max((CUTOFF_DATE - review_date).days, 0)
|
| 83 |
+
return float(0.5 ** (age_days / FRESHNESS_HALF_LIFE_DAYS))
|
| 84 |
+
|
| 85 |
+
def process(
|
| 86 |
+
self,
|
| 87 |
+
helpful_votes: int | None,
|
| 88 |
+
verified: bool | None,
|
| 89 |
+
timestamp_ms: int | None,
|
| 90 |
+
word_count: int,
|
| 91 |
+
) -> dict[str, Any]:
|
| 92 |
+
"""Score one review; ``None`` inputs are imputed with training means."""
|
| 93 |
+
imputed: list[str] = []
|
| 94 |
+
|
| 95 |
+
if helpful_votes is None:
|
| 96 |
+
help_score = self._impute("helpfulness_score")
|
| 97 |
+
imputed.append("helpful_votes")
|
| 98 |
+
else:
|
| 99 |
+
help_score = self.helpfulness(helpful_votes)
|
| 100 |
+
|
| 101 |
+
if timestamp_ms is None:
|
| 102 |
+
fresh_score = self._impute("freshness_score")
|
| 103 |
+
imputed.append("timestamp")
|
| 104 |
+
else:
|
| 105 |
+
fresh_score = self.freshness(timestamp_ms)
|
| 106 |
+
|
| 107 |
+
if verified is None:
|
| 108 |
+
verified_score = self._impute("verified_purchase")
|
| 109 |
+
imputed.append("verified_purchase")
|
| 110 |
+
else:
|
| 111 |
+
verified_score = 1.0 if verified else 0.0
|
| 112 |
+
|
| 113 |
+
length_score = _clip01(max(word_count, 0) / LENGTH_SATURATION_WORDS)
|
| 114 |
+
|
| 115 |
+
quality = (
|
| 116 |
+
WEIGHTS["helpfulness"] * help_score
|
| 117 |
+
+ WEIGHTS["freshness"] * fresh_score
|
| 118 |
+
+ WEIGHTS["length"] * length_score
|
| 119 |
+
+ WEIGHTS["verified"] * verified_score
|
| 120 |
+
)
|
| 121 |
+
quality = min(max(quality, QUALITY_FLOOR), 1.0)
|
| 122 |
+
|
| 123 |
+
return {
|
| 124 |
+
"helpfulness_score": round(help_score, 3),
|
| 125 |
+
"freshness_score": round(fresh_score, 3),
|
| 126 |
+
"length_score": round(length_score, 3),
|
| 127 |
+
"verified_purchase": round(verified_score, 3),
|
| 128 |
+
"quality_score": round(quality, 3),
|
| 129 |
+
"trust_weight": round(quality, 3),
|
| 130 |
+
"imputed_fields": imputed,
|
| 131 |
+
}
|
backend/app/ml/calibration/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Conformal calibration (per review) and Empirical Bayes aggregation (per product)."""
|
backend/app/ml/calibration/conformal.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Production split-conformal interval predictor (PRD §10.3).
|
| 2 |
+
|
| 3 |
+
Reads the pre-fitted quantile from ``conformal_qhat.json`` — no MAPIE, no
|
| 4 |
+
sklearn, only ``json`` + ``numpy`` (PRD weakness #16). Fitting happens offline
|
| 5 |
+
in ``training/vera_training/calibration.py`` (notebook 06).
|
| 6 |
+
|
| 7 |
+
Two nonconformity methods are supported (see DECISIONS.md):
|
| 8 |
+
|
| 9 |
+
* ``absolute`` — score ``|y − ŷ|``; interval ``ŷ ± q̂``. Constant width, so
|
| 10 |
+
"confidence" only varies through clipping at the scale edges.
|
| 11 |
+
* ``normalized`` — score ``|y − ŷ| / (σ̂ + floor)`` with σ̂ = ensemble std;
|
| 12 |
+
interval ``ŷ ± q̂·(σ̂ + floor)``. Same finite-sample coverage guarantee, but
|
| 13 |
+
widths adapt per review, which makes the confidence value informative.
|
| 14 |
+
|
| 15 |
+
JSON schema::
|
| 16 |
+
|
| 17 |
+
{"method": "absolute"|"normalized", "q_hat": float, "alpha": float,
|
| 18 |
+
"nominal_coverage": float, "empirical_coverage": float,
|
| 19 |
+
"calibration_n": int, "sigma_floor": float (normalized only)}
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import json
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Any, Final
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
from app.ml.constants import SCORE_MAX, SCORE_MIN, SCORE_RANGE
|
| 31 |
+
|
| 32 |
+
METHODS: Final = frozenset({"absolute", "normalized"})
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def interval_confidence(lower: float, upper: float) -> float:
|
| 36 |
+
"""Map interval width to [0, 1]: a 0.5-wide interval -> 0.875, 2-wide -> 0.5."""
|
| 37 |
+
return float(np.clip(1.0 - (upper - lower) / SCORE_RANGE, 0.0, 1.0))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ConformalPredictor:
|
| 41 |
+
"""Online conformal predictor; loaded once per worker process."""
|
| 42 |
+
|
| 43 |
+
def __init__(self, qhat_path: str | Path) -> None:
|
| 44 |
+
data: dict[str, Any] = json.loads(Path(qhat_path).read_text(encoding="utf-8"))
|
| 45 |
+
self.method: str = data.get("method", "absolute")
|
| 46 |
+
if self.method not in METHODS:
|
| 47 |
+
raise ValueError(f"Unknown conformal method {self.method!r}")
|
| 48 |
+
self.q_hat = float(data["q_hat"])
|
| 49 |
+
if not np.isfinite(self.q_hat) or self.q_hat < 0:
|
| 50 |
+
raise ValueError(f"q_hat must be a finite non-negative float, got {self.q_hat}")
|
| 51 |
+
self.nominal_coverage = float(data["nominal_coverage"])
|
| 52 |
+
self.sigma_floor = float(data.get("sigma_floor", 0.0))
|
| 53 |
+
if self.method == "normalized" and self.sigma_floor <= 0:
|
| 54 |
+
raise ValueError("normalized conformal requires sigma_floor > 0")
|
| 55 |
+
self._meta = data
|
| 56 |
+
|
| 57 |
+
def half_width(self, ensemble_std: float = 0.0) -> float:
|
| 58 |
+
"""Interval half-width for one prediction."""
|
| 59 |
+
if self.method == "normalized":
|
| 60 |
+
return self.q_hat * (max(float(ensemble_std), 0.0) + self.sigma_floor)
|
| 61 |
+
return self.q_hat
|
| 62 |
+
|
| 63 |
+
def predict_interval(
|
| 64 |
+
self, base_pred: float, ensemble_std: float = 0.0
|
| 65 |
+
) -> tuple[float, float, float]:
|
| 66 |
+
"""Return ``(lower, upper, confidence)`` clipped to the 1–5 scale."""
|
| 67 |
+
hw = self.half_width(ensemble_std)
|
| 68 |
+
lower = float(np.clip(base_pred - hw, SCORE_MIN, SCORE_MAX))
|
| 69 |
+
upper = float(np.clip(base_pred + hw, SCORE_MIN, SCORE_MAX))
|
| 70 |
+
return lower, upper, interval_confidence(lower, upper)
|
| 71 |
+
|
| 72 |
+
def health_info(self) -> dict[str, Any]:
|
| 73 |
+
"""Calibration metadata for diagnostics."""
|
| 74 |
+
return {
|
| 75 |
+
"method": self.method,
|
| 76 |
+
"q_hat": self.q_hat,
|
| 77 |
+
"nominal_coverage": self.nominal_coverage,
|
| 78 |
+
"empirical_coverage": self._meta.get("empirical_coverage"),
|
| 79 |
+
"calibration_n": self._meta.get("calibration_n"),
|
| 80 |
+
}
|
backend/app/ml/calibration/empirical_bayes.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Product-level Empirical Bayes aggregation (PRD §11).
|
| 2 |
+
|
| 3 |
+
A product with one 5.0 review must not outrank a product with 500 reviews
|
| 4 |
+
averaging 4.6. Each product's quality-weighted mean is shrunk toward the global
|
| 5 |
+
mean in proportion to how noisy that mean is (normal–normal model).
|
| 6 |
+
|
| 7 |
+
Model, for product j with review scores y_ij and trust weights w_ij::
|
| 8 |
+
|
| 9 |
+
m_j = Σ w_ij y_ij / Σ w_ij (trust-weighted mean)
|
| 10 |
+
n_eff = (Σ w_ij)² / Σ w_ij² (Kish effective sample size)
|
| 11 |
+
σ²_j = max(weighted var_j, 0.05) (noise floor, PRD weakness #5)
|
| 12 |
+
— conservative default variance when the product has one review
|
| 13 |
+
s²_j = σ²_j / n_eff (sampling variance of m_j)
|
| 14 |
+
B_j = s²_j / (s²_j + τ²) (shrinkage factor)
|
| 15 |
+
θ̂_j = (1 − B_j)·m_j + B_j·μ (EB estimate)
|
| 16 |
+
sd_j = sqrt(B_j · τ²) (posterior sd = uncertainty)
|
| 17 |
+
|
| 18 |
+
Prior (fitted across all products, method of moments)::
|
| 19 |
+
|
| 20 |
+
μ = mean of all review scores
|
| 21 |
+
τ² = max(Var_j(m_j) − mean_j(s²_j), 0.01) (between-product variance)
|
| 22 |
+
|
| 23 |
+
Corrections vs the PRD sketch (see DECISIONS.md): the PRD set τ² to the variance
|
| 24 |
+
of *individual review scores* (that is total variance, not between-product
|
| 25 |
+
variance), used τ² as σ² for single-review products, and reported an ad-hoc
|
| 26 |
+
``sqrt(σ²/n + B²τ²)`` instead of the posterior standard deviation.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
from collections import Counter
|
| 32 |
+
from collections.abc import Iterable, Mapping, Sequence
|
| 33 |
+
from dataclasses import dataclass, field
|
| 34 |
+
from typing import Any, Final
|
| 35 |
+
|
| 36 |
+
import numpy as np
|
| 37 |
+
|
| 38 |
+
from app.ml.constants import EARBUD_ASPECTS, SCORE_MAX, SCORE_MIN
|
| 39 |
+
|
| 40 |
+
SIGMA_SQ_FLOOR: Final = 0.05
|
| 41 |
+
TAU_SQ_FLOOR: Final = 0.01
|
| 42 |
+
#: Below this many products the between-product variance is not estimable.
|
| 43 |
+
MIN_PRODUCTS_FOR_PRIOR: Final = 3
|
| 44 |
+
#: Weakly informative fallbacks: product means within ~±1 star, reviews within ~±0.7.
|
| 45 |
+
DEFAULT_TAU_SQ: Final = 0.25
|
| 46 |
+
DEFAULT_SIGMA_SQ: Final = 0.5
|
| 47 |
+
|
| 48 |
+
_SENTIMENT_TIE_ORDER: Final = ("negative", "positive", "neutral")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class ReviewSignal:
|
| 53 |
+
"""The per-review quantities aggregation needs."""
|
| 54 |
+
|
| 55 |
+
product_asin: str
|
| 56 |
+
corrected_score: float
|
| 57 |
+
quality_weight: float
|
| 58 |
+
star_rating: float
|
| 59 |
+
aspects: Mapping[str, Any] = field(default_factory=dict)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass(frozen=True)
|
| 63 |
+
class EBPrior:
|
| 64 |
+
global_mean: float
|
| 65 |
+
tau_sq: float
|
| 66 |
+
pooled_sigma_sq: float
|
| 67 |
+
n_products: int
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@dataclass(frozen=True)
|
| 71 |
+
class ProductAggregate:
|
| 72 |
+
product_asin: str
|
| 73 |
+
corrected_score: float
|
| 74 |
+
uncertainty: float
|
| 75 |
+
review_count: int
|
| 76 |
+
trust_weighted_score: float
|
| 77 |
+
star_average: float | None
|
| 78 |
+
eb_shrinkage_factor: float
|
| 79 |
+
aspect_summary: dict[str, Any]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass(frozen=True)
|
| 83 |
+
class _ProductStats:
|
| 84 |
+
n: int
|
| 85 |
+
weighted_mean: float
|
| 86 |
+
weighted_var: float | None # None when n == 1
|
| 87 |
+
n_eff: float
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _product_stats(scores: Sequence[float], weights: Sequence[float]) -> _ProductStats:
|
| 91 |
+
y = np.asarray(scores, dtype=np.float64)
|
| 92 |
+
w = np.asarray(weights, dtype=np.float64)
|
| 93 |
+
if y.size == 0:
|
| 94 |
+
raise ValueError("cannot aggregate a product with no reviews")
|
| 95 |
+
if y.shape != w.shape:
|
| 96 |
+
raise ValueError("scores and weights must have the same length")
|
| 97 |
+
w = np.where(np.isfinite(w) & (w > 0), w, 0.0)
|
| 98 |
+
if w.sum() <= 0: # all weights unusable -> uniform
|
| 99 |
+
w = np.ones_like(y)
|
| 100 |
+
w = w / w.sum()
|
| 101 |
+
|
| 102 |
+
mean = float(np.dot(w, y))
|
| 103 |
+
n_eff = float(1.0 / np.sum(w**2))
|
| 104 |
+
var: float | None = None
|
| 105 |
+
if y.size > 1:
|
| 106 |
+
denom = 1.0 - float(np.sum(w**2)) # unbiased reliability-weighted variance
|
| 107 |
+
var = float(np.dot(w, (y - mean) ** 2) / denom) if denom > 1e-12 else 0.0
|
| 108 |
+
return _ProductStats(n=int(y.size), weighted_mean=mean, weighted_var=var, n_eff=n_eff)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class EmpiricalBayesAggregator:
|
| 112 |
+
"""Fit a prior across products, then shrink each product's mean toward it."""
|
| 113 |
+
|
| 114 |
+
def __init__(self) -> None:
|
| 115 |
+
self.prior: EBPrior | None = None
|
| 116 |
+
|
| 117 |
+
# ------------------------------------------------------------------
|
| 118 |
+
def fit(self, grouped: Mapping[str, tuple[Sequence[float], Sequence[float]]]) -> EBPrior:
|
| 119 |
+
"""Estimate μ, τ² and the pooled within-product variance.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
grouped: ``asin -> (corrected_scores, quality_weights)``.
|
| 123 |
+
"""
|
| 124 |
+
if not grouped:
|
| 125 |
+
raise ValueError("fit() needs at least one product")
|
| 126 |
+
stats = {asin: _product_stats(*sw) for asin, sw in grouped.items()}
|
| 127 |
+
all_scores = np.concatenate([np.asarray(s, dtype=np.float64) for s, _ in grouped.values()])
|
| 128 |
+
|
| 129 |
+
multi = [s for s in stats.values() if s.weighted_var is not None]
|
| 130 |
+
if multi:
|
| 131 |
+
dof = np.array([s.n - 1 for s in multi], dtype=np.float64)
|
| 132 |
+
variances = np.array([s.weighted_var for s in multi], dtype=np.float64)
|
| 133 |
+
pooled = float(np.dot(dof, variances) / dof.sum())
|
| 134 |
+
else:
|
| 135 |
+
pooled = DEFAULT_SIGMA_SQ
|
| 136 |
+
pooled = max(pooled, SIGMA_SQ_FLOOR)
|
| 137 |
+
|
| 138 |
+
if len(stats) >= MIN_PRODUCTS_FOR_PRIOR:
|
| 139 |
+
means = np.array([s.weighted_mean for s in stats.values()])
|
| 140 |
+
sampling = np.array([self._sigma_sq(s, pooled) / s.n_eff for s in stats.values()])
|
| 141 |
+
tau_sq = max(float(means.var(ddof=1) - sampling.mean()), TAU_SQ_FLOOR)
|
| 142 |
+
else:
|
| 143 |
+
tau_sq = DEFAULT_TAU_SQ
|
| 144 |
+
|
| 145 |
+
self.prior = EBPrior(
|
| 146 |
+
global_mean=float(all_scores.mean()),
|
| 147 |
+
tau_sq=tau_sq,
|
| 148 |
+
pooled_sigma_sq=pooled,
|
| 149 |
+
n_products=len(stats),
|
| 150 |
+
)
|
| 151 |
+
return self.prior
|
| 152 |
+
|
| 153 |
+
@staticmethod
|
| 154 |
+
def _sigma_sq(stats: _ProductStats, pooled_sigma_sq: float) -> float:
|
| 155 |
+
# A one-review product has no within-product variance estimate. The
|
| 156 |
+
# observed pooled variance can be deceptively near zero when existing
|
| 157 |
+
# multi-review products are unanimous, so using it here would allow an
|
| 158 |
+
# isolated 5.0 to outrank a well-supported 4.6. Use the documented
|
| 159 |
+
# weakly informative variance instead; observed variance takes over as
|
| 160 |
+
# soon as a product has two reviews.
|
| 161 |
+
raw = (
|
| 162 |
+
stats.weighted_var
|
| 163 |
+
if stats.weighted_var is not None
|
| 164 |
+
else max(pooled_sigma_sq, DEFAULT_SIGMA_SQ)
|
| 165 |
+
)
|
| 166 |
+
return max(raw, SIGMA_SQ_FLOOR)
|
| 167 |
+
|
| 168 |
+
# ------------------------------------------------------------------
|
| 169 |
+
def aggregate_product(
|
| 170 |
+
self,
|
| 171 |
+
corrected_scores: Sequence[float],
|
| 172 |
+
quality_weights: Sequence[float],
|
| 173 |
+
aspect_sentiments: Sequence[Mapping[str, Any]],
|
| 174 |
+
star_ratings: Sequence[float] | None = None,
|
| 175 |
+
product_asin: str = "",
|
| 176 |
+
) -> ProductAggregate:
|
| 177 |
+
"""Compute the EB-shrunk aggregate for one product. Requires :meth:`fit` first."""
|
| 178 |
+
if self.prior is None:
|
| 179 |
+
raise RuntimeError("call fit() before aggregate_product()")
|
| 180 |
+
prior = self.prior
|
| 181 |
+
stats = _product_stats(corrected_scores, quality_weights)
|
| 182 |
+
|
| 183 |
+
s_sq = self._sigma_sq(stats, prior.pooled_sigma_sq) / stats.n_eff
|
| 184 |
+
shrink = float(np.clip(s_sq / (s_sq + prior.tau_sq), 0.0, 1.0))
|
| 185 |
+
eb_score = (1.0 - shrink) * stats.weighted_mean + shrink * prior.global_mean
|
| 186 |
+
posterior_sd = float(np.sqrt(shrink * prior.tau_sq))
|
| 187 |
+
|
| 188 |
+
stars = list(star_ratings) if star_ratings is not None else []
|
| 189 |
+
return ProductAggregate(
|
| 190 |
+
product_asin=product_asin,
|
| 191 |
+
corrected_score=round(float(np.clip(eb_score, SCORE_MIN, SCORE_MAX)), 2),
|
| 192 |
+
uncertainty=round(posterior_sd, 3),
|
| 193 |
+
review_count=stats.n,
|
| 194 |
+
trust_weighted_score=round(stats.weighted_mean, 2),
|
| 195 |
+
star_average=round(float(np.mean(stars)), 2) if stars else None,
|
| 196 |
+
eb_shrinkage_factor=round(shrink, 3),
|
| 197 |
+
aspect_summary=aggregate_aspects(aspect_sentiments),
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
def aggregate_all(
|
| 201 |
+
self, reviews: Iterable[ReviewSignal]
|
| 202 |
+
) -> tuple[EBPrior, dict[str, ProductAggregate]]:
|
| 203 |
+
"""Fit the prior on every product and aggregate each one."""
|
| 204 |
+
by_asin: dict[str, list[ReviewSignal]] = {}
|
| 205 |
+
for review in reviews:
|
| 206 |
+
by_asin.setdefault(review.product_asin, []).append(review)
|
| 207 |
+
if not by_asin:
|
| 208 |
+
raise ValueError("aggregate_all() needs at least one review")
|
| 209 |
+
|
| 210 |
+
prior = self.fit(
|
| 211 |
+
{
|
| 212 |
+
asin: ([r.corrected_score for r in rs], [r.quality_weight for r in rs])
|
| 213 |
+
for asin, rs in by_asin.items()
|
| 214 |
+
}
|
| 215 |
+
)
|
| 216 |
+
aggregates = {
|
| 217 |
+
asin: self.aggregate_product(
|
| 218 |
+
[r.corrected_score for r in rs],
|
| 219 |
+
[r.quality_weight for r in rs],
|
| 220 |
+
[r.aspects for r in rs],
|
| 221 |
+
star_ratings=[r.star_rating for r in rs],
|
| 222 |
+
product_asin=asin,
|
| 223 |
+
)
|
| 224 |
+
for asin, rs in by_asin.items()
|
| 225 |
+
}
|
| 226 |
+
return prior, aggregates
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def rank_products(aggregates: Mapping[str, ProductAggregate]) -> list[str]:
|
| 230 |
+
"""ASINs best-first: EB score desc, then more reviews, then lower uncertainty, then ASIN."""
|
| 231 |
+
return sorted(
|
| 232 |
+
aggregates,
|
| 233 |
+
key=lambda a: (
|
| 234 |
+
-aggregates[a].corrected_score,
|
| 235 |
+
-aggregates[a].review_count,
|
| 236 |
+
aggregates[a].uncertainty,
|
| 237 |
+
a,
|
| 238 |
+
),
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def aggregate_aspects(aspect_sentiments: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
| 243 |
+
"""Per-aspect mention rate, sentiment counts, net sentiment, and majority label.
|
| 244 |
+
|
| 245 |
+
``net_sentiment`` = (positive − negative) / mentions, in [−1, 1] (drives the
|
| 246 |
+
radar chart). Ties for the majority label resolve negative > positive >
|
| 247 |
+
neutral, so a split verdict surfaces the complaint. Reviews whose aspect
|
| 248 |
+
dict lacks a key (e.g. legacy rows) count as "not mentioned".
|
| 249 |
+
"""
|
| 250 |
+
total = len(aspect_sentiments)
|
| 251 |
+
summary: dict[str, Any] = {}
|
| 252 |
+
for aspect in EARBUD_ASPECTS:
|
| 253 |
+
mentions = [
|
| 254 |
+
a[aspect]["sentiment"]
|
| 255 |
+
for a in aspect_sentiments
|
| 256 |
+
if isinstance(a.get(aspect), Mapping) and a[aspect].get("detected")
|
| 257 |
+
]
|
| 258 |
+
counts = Counter(mentions)
|
| 259 |
+
n = len(mentions)
|
| 260 |
+
if n == 0:
|
| 261 |
+
dominant = "not_mentioned"
|
| 262 |
+
else:
|
| 263 |
+
top = max(counts.values())
|
| 264 |
+
dominant = next(s for s in _SENTIMENT_TIE_ORDER if counts.get(s, 0) == top)
|
| 265 |
+
summary[aspect] = {
|
| 266 |
+
"mention_count": n,
|
| 267 |
+
"detected_fraction": round(n / total, 3) if total else 0.0,
|
| 268 |
+
"positive": counts.get("positive", 0),
|
| 269 |
+
"negative": counts.get("negative", 0),
|
| 270 |
+
"neutral": counts.get("neutral", 0),
|
| 271 |
+
"net_sentiment": (
|
| 272 |
+
round((counts.get("positive", 0) - counts.get("negative", 0)) / n, 3) if n else 0.0
|
| 273 |
+
),
|
| 274 |
+
"dominant_sentiment": dominant,
|
| 275 |
+
}
|
| 276 |
+
return summary
|
backend/app/ml/constants.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Constants shared by production agents, the training package, and tests.
|
| 2 |
+
|
| 3 |
+
Aspect keys are snake_case everywhere (API, DB JSON, features, labels). The
|
| 4 |
+
natural-language phrase in :data:`ASPECT_PHRASES` is only used as the second
|
| 5 |
+
sentence fed to the ABSA model.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import Final, Literal
|
| 11 |
+
|
| 12 |
+
#: Canonical, ordered aspect keys. ORDER IS PART OF THE FUSION FEATURE CONTRACT —
|
| 13 |
+
#: never reorder without retraining the ensemble.
|
| 14 |
+
EARBUD_ASPECTS: Final[tuple[str, ...]] = (
|
| 15 |
+
"sound_quality",
|
| 16 |
+
"bass",
|
| 17 |
+
"battery_life",
|
| 18 |
+
"comfort",
|
| 19 |
+
"fit",
|
| 20 |
+
"build_quality",
|
| 21 |
+
"connectivity",
|
| 22 |
+
"microphone",
|
| 23 |
+
"noise_cancellation",
|
| 24 |
+
"value",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
#: Aspect key -> phrase given to the ABSA model as the aspect sentence.
|
| 28 |
+
ASPECT_PHRASES: Final[dict[str, str]] = {key: key.replace("_", " ") for key in EARBUD_ASPECTS}
|
| 29 |
+
|
| 30 |
+
#: Keyword pre-filter per aspect. Matched on word boundaries at the START of a
|
| 31 |
+
#: word (so "isolat" matches "isolation" but "fit" does not match "benefit").
|
| 32 |
+
#: "cheap" is deliberately only under `value`: "cheap plastic" is still caught
|
| 33 |
+
#: by `build_quality` through "plastic".
|
| 34 |
+
ASPECT_KEYWORDS: Final[dict[str, tuple[str, ...]]] = {
|
| 35 |
+
"sound_quality": (
|
| 36 |
+
"sound",
|
| 37 |
+
"audio",
|
| 38 |
+
"music",
|
| 39 |
+
"clarity",
|
| 40 |
+
"treble",
|
| 41 |
+
"highs",
|
| 42 |
+
"mids",
|
| 43 |
+
"tinny",
|
| 44 |
+
"muddy",
|
| 45 |
+
),
|
| 46 |
+
"bass": ("bass", "low end", "boom", "thump", "sub-bass", "subwoofer"),
|
| 47 |
+
"battery_life": ("battery", "charge", "charging", "playtime", "endurance", "hours of"),
|
| 48 |
+
"comfort": ("comfort", "uncomfortable", "ear pain", "hurt", "wear", "wearing", "sore"),
|
| 49 |
+
"fit": ("fit", "fits", "stay in", "stays in", "fall out", "falls out", "ear tip", "seal"),
|
| 50 |
+
# Durability counts as build quality in the labeling protocol ("broke after a week",
|
| 51 |
+
# "connector bent"), which the original 7 keywords missed (train-split recall 0.35).
|
| 52 |
+
"build_quality": (
|
| 53 |
+
"build",
|
| 54 |
+
"built",
|
| 55 |
+
"construct",
|
| 56 |
+
"durab",
|
| 57 |
+
"sturd",
|
| 58 |
+
"flims",
|
| 59 |
+
"fragil",
|
| 60 |
+
"well made",
|
| 61 |
+
"well-made",
|
| 62 |
+
"cheaply made",
|
| 63 |
+
"poorly made",
|
| 64 |
+
"plastic",
|
| 65 |
+
"material",
|
| 66 |
+
"hinge",
|
| 67 |
+
"crack",
|
| 68 |
+
"broke",
|
| 69 |
+
"break",
|
| 70 |
+
"snapped",
|
| 71 |
+
"fell apart",
|
| 72 |
+
"falling apart",
|
| 73 |
+
"came apart",
|
| 74 |
+
"cord",
|
| 75 |
+
"cable",
|
| 76 |
+
"wire",
|
| 77 |
+
"jack",
|
| 78 |
+
"connector",
|
| 79 |
+
"stopped working",
|
| 80 |
+
"quit working",
|
| 81 |
+
"died",
|
| 82 |
+
"defect",
|
| 83 |
+
"peel",
|
| 84 |
+
"wore out",
|
| 85 |
+
"worn out",
|
| 86 |
+
),
|
| 87 |
+
# "pair" / "range" were removed: "a pair of headphones" and "price range" made
|
| 88 |
+
# connectivity precision 0.52 on the train split.
|
| 89 |
+
"connectivity": (
|
| 90 |
+
"bluetooth",
|
| 91 |
+
"connect",
|
| 92 |
+
"disconnect",
|
| 93 |
+
"reconnect",
|
| 94 |
+
"pairing",
|
| 95 |
+
"paired",
|
| 96 |
+
"pair with",
|
| 97 |
+
"pair to",
|
| 98 |
+
"latency",
|
| 99 |
+
"lag",
|
| 100 |
+
"delay",
|
| 101 |
+
"drops out",
|
| 102 |
+
"dropped out",
|
| 103 |
+
"dropout",
|
| 104 |
+
"cuts out",
|
| 105 |
+
"cutting out",
|
| 106 |
+
"signal",
|
| 107 |
+
"out of range",
|
| 108 |
+
"sync",
|
| 109 |
+
"stutter",
|
| 110 |
+
"multipoint",
|
| 111 |
+
),
|
| 112 |
+
"microphone": ("mic", "microphone", "calls", "phone call", "voice", "recording"),
|
| 113 |
+
"noise_cancellation": (
|
| 114 |
+
"anc",
|
| 115 |
+
"noise cancel",
|
| 116 |
+
"noise-cancel",
|
| 117 |
+
"passive isolation",
|
| 118 |
+
"isolat",
|
| 119 |
+
"block out",
|
| 120 |
+
"blocks out",
|
| 121 |
+
"transparency mode",
|
| 122 |
+
),
|
| 123 |
+
"value": ("price", "value", "worth", "money", "cheap", "expensive", "cost", "budget"),
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
Sentiment = Literal["positive", "negative", "neutral", "not_mentioned"]
|
| 127 |
+
|
| 128 |
+
#: Numeric encoding of aspect sentiment for fusion features (PRD §10.1).
|
| 129 |
+
SENTIMENT_TO_NUMERIC: Final[dict[str, int]] = {
|
| 130 |
+
"negative": -1,
|
| 131 |
+
"neutral": 0,
|
| 132 |
+
"positive": 1,
|
| 133 |
+
"not_mentioned": 0,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
#: Valid experience-score label values produced by the labeler (PRD §7.2).
|
| 137 |
+
LABEL_SCORE_VALUES: Final[tuple[float, ...]] = (1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0)
|
| 138 |
+
|
| 139 |
+
SCORE_MIN: Final[float] = 1.0
|
| 140 |
+
SCORE_MAX: Final[float] = 5.0
|
| 141 |
+
#: Largest possible distance on the 1–5 scale.
|
| 142 |
+
SCORE_RANGE: Final[float] = SCORE_MAX - SCORE_MIN
|
backend/app/ml/features.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fusion feature contract (PRD §10.1).
|
| 2 |
+
|
| 3 |
+
This module is the SINGLE source of truth for the 40-dimensional feature vector.
|
| 4 |
+
Notebook 05 (training), notebook 06 (calibration), and the production pipeline
|
| 5 |
+
all call :func:`build_feature_vector`, so train/serve skew is impossible by
|
| 6 |
+
construction. Changing the order or meaning of any feature requires retraining
|
| 7 |
+
the ensemble and re-fitting the calibrator.
|
| 8 |
+
|
| 9 |
+
Imputation of missing metadata happens upstream in Agent 5
|
| 10 |
+
(:class:`~app.ml.agents.quality.QualityAssessor`); the PRD's
|
| 11 |
+
``build_feature_vector(imputation=...)`` parameter was never actually used.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any, Final
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
from app.ml.constants import EARBUD_ASPECTS, SENTIMENT_TO_NUMERIC
|
| 21 |
+
|
| 22 |
+
FEATURE_NAMES: Final[tuple[str, ...]] = (
|
| 23 |
+
# Base signals (3)
|
| 24 |
+
"star_rating",
|
| 25 |
+
"experience_score",
|
| 26 |
+
"domain_score",
|
| 27 |
+
# Consistency signals (4)
|
| 28 |
+
"star_text_gap",
|
| 29 |
+
"absolute_gap",
|
| 30 |
+
"star_inflated",
|
| 31 |
+
"consistency_score",
|
| 32 |
+
# Quality signals (4)
|
| 33 |
+
"quality_score",
|
| 34 |
+
"helpfulness_score",
|
| 35 |
+
"freshness_score",
|
| 36 |
+
"verified_purchase",
|
| 37 |
+
# Text quality signals (4)
|
| 38 |
+
"word_count_norm",
|
| 39 |
+
"vocab_richness",
|
| 40 |
+
"avg_sentence_length_norm",
|
| 41 |
+
"experience_model_uncertainty",
|
| 42 |
+
# Aspect detection (10) + aspect sentiment (10)
|
| 43 |
+
*(f"aspect_detected__{a}" for a in EARBUD_ASPECTS),
|
| 44 |
+
*(f"aspect_sentiment__{a}" for a in EARBUD_ASPECTS),
|
| 45 |
+
# Interactions (5)
|
| 46 |
+
"star_x_experience",
|
| 47 |
+
"abs_star_minus_domain",
|
| 48 |
+
"experience_x_quality",
|
| 49 |
+
"consistency_x_trust",
|
| 50 |
+
"domain_confidence_x_domain_score",
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
N_FEATURES: Final = len(FEATURE_NAMES)
|
| 54 |
+
|
| 55 |
+
#: Feature-name groups used by the ablation study (PRD §13.2).
|
| 56 |
+
FEATURE_GROUPS: Final[dict[str, tuple[str, ...]]] = {
|
| 57 |
+
"star": ("star_rating",),
|
| 58 |
+
"experience": ("experience_score", "experience_model_uncertainty", "star_x_experience"),
|
| 59 |
+
"domain": ("domain_score", "abs_star_minus_domain", "domain_confidence_x_domain_score"),
|
| 60 |
+
"consistency": ("star_text_gap", "absolute_gap", "star_inflated", "consistency_score"),
|
| 61 |
+
"quality": (
|
| 62 |
+
"quality_score",
|
| 63 |
+
"helpfulness_score",
|
| 64 |
+
"freshness_score",
|
| 65 |
+
"verified_purchase",
|
| 66 |
+
"word_count_norm",
|
| 67 |
+
"vocab_richness",
|
| 68 |
+
"avg_sentence_length_norm",
|
| 69 |
+
"experience_x_quality",
|
| 70 |
+
"consistency_x_trust",
|
| 71 |
+
),
|
| 72 |
+
"absa": tuple(n for n in FEATURE_NAMES if n.startswith("aspect_")),
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def build_feature_vector(agent_outputs: dict[str, Any]) -> np.ndarray:
|
| 77 |
+
"""Assemble the fusion features from Agents 1–6.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
agent_outputs: Mapping with keys ``preprocessor``, ``experience_extractor``,
|
| 81 |
+
``absa``, ``consistency``, ``quality``, ``domain_rating`` (each the
|
| 82 |
+
agent's ``process``/``predict`` output) and ``star_rating``.
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
``float32`` array of shape ``(N_FEATURES,)``.
|
| 86 |
+
"""
|
| 87 |
+
pre = agent_outputs["preprocessor"]
|
| 88 |
+
exp = agent_outputs["experience_extractor"]
|
| 89 |
+
absa = agent_outputs["absa"]["aspects"]
|
| 90 |
+
cons = agent_outputs["consistency"]
|
| 91 |
+
qual = agent_outputs["quality"]
|
| 92 |
+
dom = agent_outputs["domain_rating"]
|
| 93 |
+
star = float(agent_outputs["star_rating"])
|
| 94 |
+
|
| 95 |
+
feats: list[float] = [
|
| 96 |
+
star,
|
| 97 |
+
exp["predicted_score"],
|
| 98 |
+
dom["domain_score"],
|
| 99 |
+
cons["star_text_gap"],
|
| 100 |
+
cons["absolute_gap"],
|
| 101 |
+
float(cons["star_inflated"]),
|
| 102 |
+
cons["consistency_score"],
|
| 103 |
+
qual["quality_score"],
|
| 104 |
+
qual["helpfulness_score"],
|
| 105 |
+
qual["freshness_score"],
|
| 106 |
+
float(qual["verified_purchase"]),
|
| 107 |
+
pre["word_count"] / 500,
|
| 108 |
+
pre["vocab_richness"],
|
| 109 |
+
pre["avg_sentence_length"] / 20,
|
| 110 |
+
exp["model_uncertainty"],
|
| 111 |
+
]
|
| 112 |
+
feats += [float(absa[a]["detected"]) for a in EARBUD_ASPECTS]
|
| 113 |
+
feats += [float(SENTIMENT_TO_NUMERIC[absa[a]["sentiment"]]) for a in EARBUD_ASPECTS]
|
| 114 |
+
feats += [
|
| 115 |
+
star * exp["predicted_score"],
|
| 116 |
+
abs(star - dom["domain_score"]),
|
| 117 |
+
exp["predicted_score"] * qual["quality_score"],
|
| 118 |
+
cons["consistency_score"] * qual["trust_weight"],
|
| 119 |
+
dom["domain_confidence"] * dom["domain_score"],
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
vector = np.asarray(feats, dtype=np.float32)
|
| 123 |
+
if vector.shape != (N_FEATURES,): # guards against silent contract drift
|
| 124 |
+
raise AssertionError(f"feature vector has {vector.shape[0]} dims, expected {N_FEATURES}")
|
| 125 |
+
return vector
|
backend/app/ml/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Model artifact management: tokenizer wrapper, ONNX session helpers, HF Hub loader."""
|
backend/app/ml/models/loader.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model artifact registry and HuggingFace Hub download (PRD §17.3).
|
| 2 |
+
|
| 3 |
+
Production policy (user decision, see DECISIONS.md): the worker **hard-fails**
|
| 4 |
+
if any artifact is missing. There is no heuristic fallback.
|
| 5 |
+
|
| 6 |
+
CLI (from ``backend/``)::
|
| 7 |
+
|
| 8 |
+
python -m app.ml.models.loader # download missing artifacts, then verify
|
| 9 |
+
python -m app.ml.models.loader --check # verify only, no network
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import logging
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Final
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
#: Every file the production pipeline needs, with the notebook that produces it.
|
| 23 |
+
REQUIRED_ARTIFACTS: Final[dict[str, str]] = {
|
| 24 |
+
"experience_model.onnx": "notebooks/04_finetune_experience.ipynb (INT8 ONNX)",
|
| 25 |
+
"experience_tokenizer.json": "notebooks/04_finetune_experience.ipynb",
|
| 26 |
+
"absa_model.onnx": "notebooks/04_finetune_experience.ipynb (ABSA export section)",
|
| 27 |
+
"absa_tokenizer.json": "notebooks/04_finetune_experience.ipynb (ABSA export section)",
|
| 28 |
+
"absa_config.json": "notebooks/04_finetune_experience.ipynb (ABSA export section)",
|
| 29 |
+
"aspect_detector.joblib": "training/scripts/train_all.py --stages aspects",
|
| 30 |
+
"domain_tfidf.joblib": "notebooks/05_train_fusion.ipynb (domain model section)",
|
| 31 |
+
"fusion_ensemble.json": "notebooks/05_train_fusion.ipynb",
|
| 32 |
+
"conformal_qhat.json": "notebooks/06_calibration.ipynb",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class ModelArtifactsMissingError(RuntimeError):
|
| 37 |
+
"""Raised when required model artifacts are absent after any download attempt."""
|
| 38 |
+
|
| 39 |
+
def __init__(self, model_dir: Path, missing: list[str]) -> None:
|
| 40 |
+
self.model_dir = model_dir
|
| 41 |
+
self.missing = missing
|
| 42 |
+
details = "\n".join(
|
| 43 |
+
f" - {name} <- produced by {REQUIRED_ARTIFACTS[name]}" for name in missing
|
| 44 |
+
)
|
| 45 |
+
super().__init__(
|
| 46 |
+
f"{len(missing)} required model artifact(s) missing in {model_dir}:\n{details}\n"
|
| 47 |
+
"Run notebooks 04 (Colab) and 05-06 (local), then upload the files to "
|
| 48 |
+
"HF_MODEL_REPO or copy them into MODEL_DIR."
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def missing_artifacts(model_dir: Path) -> list[str]:
|
| 53 |
+
"""Return the names of required artifacts not present (or empty) in ``model_dir``."""
|
| 54 |
+
return [
|
| 55 |
+
name
|
| 56 |
+
for name in REQUIRED_ARTIFACTS
|
| 57 |
+
if not (model_dir / name).is_file() or (model_dir / name).stat().st_size == 0
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def artifact_status(model_dir: Path) -> dict[str, bool]:
|
| 62 |
+
"""Map each required artifact to whether it is present. Used by ``/health``."""
|
| 63 |
+
missing = set(missing_artifacts(model_dir))
|
| 64 |
+
return {name: name not in missing for name in REQUIRED_ARTIFACTS}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def ensure_models(model_dir: Path, hf_repo: str = "", hf_token: str = "") -> dict[str, Path]:
|
| 68 |
+
"""Download missing artifacts from the Hub (if configured) and verify all exist.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
model_dir: Local artifact directory (created if absent).
|
| 72 |
+
hf_repo: ``user/repo`` on HuggingFace Hub; empty disables downloading.
|
| 73 |
+
hf_token: Optional token for private repos.
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
Mapping of artifact name to its local path.
|
| 77 |
+
|
| 78 |
+
Raises:
|
| 79 |
+
ModelArtifactsMissingError: If anything is still missing afterwards.
|
| 80 |
+
"""
|
| 81 |
+
model_dir.mkdir(parents=True, exist_ok=True)
|
| 82 |
+
missing = missing_artifacts(model_dir)
|
| 83 |
+
|
| 84 |
+
if missing and hf_repo:
|
| 85 |
+
from huggingface_hub import hf_hub_download # imported lazily: network-only path
|
| 86 |
+
|
| 87 |
+
for name in missing:
|
| 88 |
+
logger.info("Downloading %s from %s ...", name, hf_repo)
|
| 89 |
+
try:
|
| 90 |
+
hf_hub_download(
|
| 91 |
+
repo_id=hf_repo,
|
| 92 |
+
filename=name,
|
| 93 |
+
local_dir=model_dir,
|
| 94 |
+
token=hf_token or None,
|
| 95 |
+
)
|
| 96 |
+
except Exception as exc: # report every failure, then fail once below
|
| 97 |
+
logger.error("Failed to download %s: %s", name, exc)
|
| 98 |
+
missing = missing_artifacts(model_dir)
|
| 99 |
+
|
| 100 |
+
if missing:
|
| 101 |
+
raise ModelArtifactsMissingError(model_dir, missing)
|
| 102 |
+
|
| 103 |
+
logger.info("All %d model artifacts ready in %s", len(REQUIRED_ARTIFACTS), model_dir)
|
| 104 |
+
return {name: model_dir / name for name in REQUIRED_ARTIFACTS}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _main(argv: list[str] | None = None) -> int:
|
| 108 |
+
from app.config import settings
|
| 109 |
+
|
| 110 |
+
parser = argparse.ArgumentParser(description="Download / verify VERA model artifacts.")
|
| 111 |
+
parser.add_argument("--check", action="store_true", help="verify only; never download")
|
| 112 |
+
args = parser.parse_args(argv)
|
| 113 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
| 114 |
+
|
| 115 |
+
try:
|
| 116 |
+
ensure_models(
|
| 117 |
+
settings.MODEL_DIR,
|
| 118 |
+
hf_repo="" if args.check else settings.HF_MODEL_REPO,
|
| 119 |
+
hf_token=settings.HF_TOKEN,
|
| 120 |
+
)
|
| 121 |
+
except ModelArtifactsMissingError as exc:
|
| 122 |
+
logger.error("%s", exc)
|
| 123 |
+
return 1
|
| 124 |
+
return 0
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
sys.exit(_main())
|
backend/app/ml/models/onnx_session.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thin onnxruntime helpers shared by the transformer agents (Agents 2 and 3)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any, Protocol
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class InferenceSessionLike(Protocol):
|
| 12 |
+
"""Subset of ``onnxruntime.InferenceSession`` the agents rely on (eases testing)."""
|
| 13 |
+
|
| 14 |
+
def get_inputs(self) -> list[Any]: ...
|
| 15 |
+
|
| 16 |
+
def run(
|
| 17 |
+
self, output_names: list[str] | None, input_feed: dict[str, np.ndarray]
|
| 18 |
+
) -> list[Any]: ...
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def create_session(model_path: str | Path, intra_op_threads: int = 2) -> InferenceSessionLike:
|
| 22 |
+
"""Create a CPU inference session with full graph optimisation."""
|
| 23 |
+
import onnxruntime as ort # local import keeps module import cheap for tests
|
| 24 |
+
|
| 25 |
+
path = Path(model_path)
|
| 26 |
+
if not path.is_file():
|
| 27 |
+
raise FileNotFoundError(f"ONNX model not found: {path}")
|
| 28 |
+
opts = ort.SessionOptions()
|
| 29 |
+
opts.intra_op_num_threads = intra_op_threads
|
| 30 |
+
opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
| 31 |
+
session: InferenceSessionLike = ort.InferenceSession(
|
| 32 |
+
str(path), sess_options=opts, providers=["CPUExecutionProvider"]
|
| 33 |
+
)
|
| 34 |
+
return session
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def build_feed(
|
| 38 |
+
session: InferenceSessionLike, encoded: dict[str, np.ndarray]
|
| 39 |
+
) -> dict[str, np.ndarray]:
|
| 40 |
+
"""Select exactly the inputs the graph declares.
|
| 41 |
+
|
| 42 |
+
DeBERTa-v3 has ``type_vocab_size=0``, so its ONNX export has no
|
| 43 |
+
``token_type_ids`` input; feeding an undeclared input makes onnxruntime
|
| 44 |
+
raise ``INVALID_ARGUMENT``. Conversely, BERT-style exports require it.
|
| 45 |
+
"""
|
| 46 |
+
feed: dict[str, np.ndarray] = {}
|
| 47 |
+
for spec in session.get_inputs():
|
| 48 |
+
name = spec.name
|
| 49 |
+
if name in encoded:
|
| 50 |
+
feed[name] = encoded[name].astype(np.int64, copy=False)
|
| 51 |
+
elif name == "token_type_ids":
|
| 52 |
+
feed[name] = np.zeros_like(encoded["input_ids"], dtype=np.int64)
|
| 53 |
+
else:
|
| 54 |
+
raise KeyError(f"ONNX graph expects unsupported input {name!r}")
|
| 55 |
+
return feed
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def softmax(logits: np.ndarray, axis: int = -1) -> np.ndarray:
|
| 59 |
+
"""Numerically stable softmax (the PRD's ``exp(x)/sum(exp(x))`` overflows for large logits)."""
|
| 60 |
+
shifted = logits - np.max(logits, axis=axis, keepdims=True)
|
| 61 |
+
exp = np.exp(shifted)
|
| 62 |
+
result: np.ndarray = exp / np.sum(exp, axis=axis, keepdims=True)
|
| 63 |
+
return result
|
backend/app/ml/models/tokenizer_wrapper.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Torch-free tokenizer (PRD §4.4).
|
| 2 |
+
|
| 3 |
+
Drop-in replacement for ``transformers.AutoTokenizer`` in production. The
|
| 4 |
+
HuggingFace ``tokenizers`` library is a small Rust extension that reads the same
|
| 5 |
+
``tokenizer.json`` a fine-tuned model ships with, but never imports torch.
|
| 6 |
+
|
| 7 |
+
Differences from the PRD sketch (see DECISIONS.md):
|
| 8 |
+
* Loads from a local ``tokenizer.json`` file (downloaded with the ONNX model)
|
| 9 |
+
instead of ``Tokenizer.from_pretrained`` — no network at inference time, and
|
| 10 |
+
the base ``microsoft/deberta-v3-base`` repo does not ship a ``tokenizer.json``.
|
| 11 |
+
* Pads to the longest sequence in the batch instead of always to 512 tokens.
|
| 12 |
+
The ONNX graphs have dynamic axes, so this is numerically identical and
|
| 13 |
+
several times faster on a shared CPU.
|
| 14 |
+
* The pad id/token are read from the vocabulary instead of hardcoded to 0.
|
| 15 |
+
* Pair inputs use ``only_first`` truncation so the (short) aspect sentence is
|
| 16 |
+
never cut — only the review body is truncated.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
from tokenizers import Encoding, Tokenizer
|
| 25 |
+
|
| 26 |
+
_PAD_CANDIDATES = ("[PAD]", "<pad>", "<PAD>")
|
| 27 |
+
DEFAULT_MAX_LENGTH = 512
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class LightTokenizer:
|
| 31 |
+
"""Rust-backed tokenizer producing int64 numpy batches for onnxruntime."""
|
| 32 |
+
|
| 33 |
+
def __init__(self, tokenizer_path: str | Path, max_length: int | None = None) -> None:
|
| 34 |
+
"""
|
| 35 |
+
Args:
|
| 36 |
+
tokenizer_path: ``tokenizer.json`` exported with the model.
|
| 37 |
+
max_length: Truncation length. ``None`` uses the length stored in the
|
| 38 |
+
file's truncation settings (written at export so serving matches
|
| 39 |
+
training), falling back to 512.
|
| 40 |
+
"""
|
| 41 |
+
path = Path(tokenizer_path)
|
| 42 |
+
if not path.is_file():
|
| 43 |
+
raise FileNotFoundError(f"tokenizer.json not found: {path}")
|
| 44 |
+
self._tok = Tokenizer.from_file(str(path))
|
| 45 |
+
stored = (self._tok.truncation or {}).get("max_length")
|
| 46 |
+
self.max_length = int(max_length or stored or DEFAULT_MAX_LENGTH)
|
| 47 |
+
|
| 48 |
+
pad_token = next((t for t in _PAD_CANDIDATES if self._tok.token_to_id(t) is not None), None)
|
| 49 |
+
if pad_token is None:
|
| 50 |
+
raise ValueError(f"No padding token {_PAD_CANDIDATES} in vocabulary of {path}")
|
| 51 |
+
self.pad_token = pad_token
|
| 52 |
+
self.pad_id = int(self._tok.token_to_id(pad_token))
|
| 53 |
+
self._tok.enable_padding(pad_id=self.pad_id, pad_token=self.pad_token)
|
| 54 |
+
|
| 55 |
+
# ------------------------------------------------------------------
|
| 56 |
+
def __call__(self, text: str, text_pair: str | None = None) -> dict[str, np.ndarray]:
|
| 57 |
+
"""Encode one text (or one text pair) into a batch of size 1."""
|
| 58 |
+
if text_pair is None:
|
| 59 |
+
return self.encode_batch([text])
|
| 60 |
+
return self.encode_pairs([(text, text_pair)])
|
| 61 |
+
|
| 62 |
+
def encode_batch(self, texts: list[str]) -> dict[str, np.ndarray]:
|
| 63 |
+
"""Encode single sequences, padded to the longest in the batch."""
|
| 64 |
+
if not texts:
|
| 65 |
+
raise ValueError("encode_batch() needs at least one text")
|
| 66 |
+
self._tok.enable_truncation(max_length=self.max_length, strategy="longest_first")
|
| 67 |
+
return self._to_arrays(self._tok.encode_batch(texts))
|
| 68 |
+
|
| 69 |
+
def encode_pairs(self, pairs: list[tuple[str, str]]) -> dict[str, np.ndarray]:
|
| 70 |
+
"""Encode (first, second) pairs; only the first sequence is ever truncated."""
|
| 71 |
+
if not pairs:
|
| 72 |
+
raise ValueError("encode_pairs() needs at least one pair")
|
| 73 |
+
self._tok.enable_truncation(max_length=self.max_length, strategy="only_first")
|
| 74 |
+
return self._to_arrays(self._tok.encode_batch(pairs))
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def _to_arrays(encodings: list[Encoding]) -> dict[str, np.ndarray]:
|
| 78 |
+
return {
|
| 79 |
+
"input_ids": np.asarray([e.ids for e in encodings], dtype=np.int64),
|
| 80 |
+
"attention_mask": np.asarray([e.attention_mask for e in encodings], dtype=np.int64),
|
| 81 |
+
"token_type_ids": np.asarray([e.type_ids for e in encodings], dtype=np.int64),
|
| 82 |
+
}
|
backend/app/ml/pipeline.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""VERA pipeline orchestrator (PRD §8.2).
|
| 2 |
+
|
| 3 |
+
Runs Agents 1–6, fuses their signals with Agent 7, and wraps the point estimate
|
| 4 |
+
in a conformal interval. Product-level Empirical Bayes aggregation runs
|
| 5 |
+
separately (``app.worker.tasks.aggregate_products_task``) because it needs every
|
| 6 |
+
review of every product.
|
| 7 |
+
|
| 8 |
+
Model policy: :class:`VERAPipeline` **hard-fails** at construction when any
|
| 9 |
+
artifact is missing (user decision, DECISIONS.md) — see
|
| 10 |
+
:mod:`app.ml.models.loader`.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
import time
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
from app.ml.agents.absa import ABSAModule
|
| 22 |
+
from app.ml.agents.aspect_detector import AspectDetector
|
| 23 |
+
from app.ml.agents.consistency import ConsistencyScorer
|
| 24 |
+
from app.ml.agents.domain_rating import DomainRatingModel
|
| 25 |
+
from app.ml.agents.experience_extractor import ExperienceExtractor
|
| 26 |
+
from app.ml.agents.fusion import FusionModel
|
| 27 |
+
from app.ml.agents.preprocessor import TextPreprocessor
|
| 28 |
+
from app.ml.agents.quality import QualityAssessor
|
| 29 |
+
from app.ml.calibration.conformal import ConformalPredictor
|
| 30 |
+
from app.ml.constants import EARBUD_ASPECTS
|
| 31 |
+
from app.ml.features import build_feature_vector
|
| 32 |
+
from app.ml.models.loader import ensure_models
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger(__name__)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class SignalAgents:
|
| 39 |
+
"""Agents 1–6. ``experience`` may be ``None`` when callers supply its output
|
| 40 |
+
directly (offline training uses out-of-fold predictions)."""
|
| 41 |
+
|
| 42 |
+
preprocessor: TextPreprocessor
|
| 43 |
+
experience: ExperienceExtractor | None
|
| 44 |
+
absa: ABSAModule
|
| 45 |
+
consistency: ConsistencyScorer
|
| 46 |
+
quality: QualityAssessor
|
| 47 |
+
domain: DomainRatingModel
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def extract_signals(
|
| 51 |
+
agents: SignalAgents,
|
| 52 |
+
*,
|
| 53 |
+
text: str,
|
| 54 |
+
star_rating: float,
|
| 55 |
+
helpful_votes: int | None = None,
|
| 56 |
+
verified_purchase: bool | None = None,
|
| 57 |
+
timestamp_ms: int | None = None,
|
| 58 |
+
product_title: str | None = "",
|
| 59 |
+
experience_output: dict[str, Any] | None = None,
|
| 60 |
+
aspect_mentions: dict[str, bool] | None = None,
|
| 61 |
+
) -> dict[str, Any]:
|
| 62 |
+
"""Run Agents 1–6 and return the ``agent_outputs`` mapping for fusion.
|
| 63 |
+
|
| 64 |
+
Shared by production and the training notebooks so both build features
|
| 65 |
+
through exactly the same code path. Training supplies out-of-fold
|
| 66 |
+
``experience_output`` and ``aspect_mentions``; production computes both.
|
| 67 |
+
"""
|
| 68 |
+
pre = agents.preprocessor.process(text, product_title)
|
| 69 |
+
clean = pre["clean_text"]
|
| 70 |
+
|
| 71 |
+
if experience_output is None:
|
| 72 |
+
if agents.experience is None:
|
| 73 |
+
raise ValueError("experience_output is required when no ExperienceExtractor is loaded")
|
| 74 |
+
experience_output = agents.experience.predict(clean)
|
| 75 |
+
|
| 76 |
+
quality = agents.quality.process(
|
| 77 |
+
helpful_votes=helpful_votes,
|
| 78 |
+
verified=verified_purchase,
|
| 79 |
+
timestamp_ms=timestamp_ms,
|
| 80 |
+
word_count=pre["word_count"],
|
| 81 |
+
)
|
| 82 |
+
return {
|
| 83 |
+
"preprocessor": pre,
|
| 84 |
+
"experience_extractor": experience_output,
|
| 85 |
+
"absa": agents.absa.process(clean, aspect_mentions),
|
| 86 |
+
"consistency": agents.consistency.process(
|
| 87 |
+
star_rating, experience_output["predicted_score"]
|
| 88 |
+
),
|
| 89 |
+
"quality": quality,
|
| 90 |
+
"domain_rating": agents.domain.process(clean),
|
| 91 |
+
"star_rating": float(star_rating),
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def build_explanation(agent_outputs: dict[str, Any], corrected_score: float) -> str:
|
| 96 |
+
"""Human-readable summary for the ScoreCard (PRD §16.2)."""
|
| 97 |
+
star = agent_outputs["star_rating"]
|
| 98 |
+
cons = agent_outputs["consistency"]
|
| 99 |
+
aspects = agent_outputs["absa"]["aspects"]
|
| 100 |
+
pre = agent_outputs["preprocessor"]
|
| 101 |
+
|
| 102 |
+
def names(sentiment: str) -> list[str]:
|
| 103 |
+
return [a.replace("_", " ") for a in EARBUD_ASPECTS if aspects[a]["sentiment"] == sentiment]
|
| 104 |
+
|
| 105 |
+
parts: list[str] = []
|
| 106 |
+
if cons["star_inflated"]:
|
| 107 |
+
parts.append(
|
| 108 |
+
f"The text implies about {corrected_score:.1f} stars despite the {star:g}-star rating."
|
| 109 |
+
)
|
| 110 |
+
elif cons["star_deflated"]:
|
| 111 |
+
parts.append(
|
| 112 |
+
f"The text is more positive than the {star:g}-star rating suggests "
|
| 113 |
+
f"(about {corrected_score:.1f})."
|
| 114 |
+
)
|
| 115 |
+
else:
|
| 116 |
+
parts.append("The star rating and the review text broadly agree.")
|
| 117 |
+
|
| 118 |
+
negatives, positives = names("negative"), names("positive")
|
| 119 |
+
if negatives:
|
| 120 |
+
parts.append(f"Concerns: {', '.join(negatives)}.")
|
| 121 |
+
if positives:
|
| 122 |
+
parts.append(f"Praised: {', '.join(positives)}.")
|
| 123 |
+
if not pre["is_english"]:
|
| 124 |
+
parts.append(f"Warning: detected language '{pre['language']}'; scores are English-trained.")
|
| 125 |
+
if not pre["is_earbud"]:
|
| 126 |
+
parts.append("Warning: this review may not be about earbuds or headphones.")
|
| 127 |
+
return " ".join(parts)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class VERAPipeline:
|
| 131 |
+
"""End-to-end per-review inference. Construct once per worker process."""
|
| 132 |
+
|
| 133 |
+
def __init__(self, model_dir: Path, hf_repo: str = "", hf_token: str = "") -> None:
|
| 134 |
+
started = time.perf_counter()
|
| 135 |
+
paths = ensure_models(model_dir, hf_repo=hf_repo, hf_token=hf_token) # hard-fail
|
| 136 |
+
|
| 137 |
+
self.fusion = FusionModel(paths["fusion_ensemble.json"])
|
| 138 |
+
self.conformal = ConformalPredictor(paths["conformal_qhat.json"])
|
| 139 |
+
self.agents = SignalAgents(
|
| 140 |
+
preprocessor=TextPreprocessor(),
|
| 141 |
+
experience=ExperienceExtractor(
|
| 142 |
+
paths["experience_model.onnx"], paths["experience_tokenizer.json"]
|
| 143 |
+
),
|
| 144 |
+
absa=ABSAModule(
|
| 145 |
+
paths["absa_model.onnx"],
|
| 146 |
+
paths["absa_tokenizer.json"],
|
| 147 |
+
paths["absa_config.json"],
|
| 148 |
+
detector=AspectDetector(paths["aspect_detector.joblib"]),
|
| 149 |
+
),
|
| 150 |
+
consistency=ConsistencyScorer(),
|
| 151 |
+
quality=QualityAssessor(imputation=self.fusion.imputation_means),
|
| 152 |
+
domain=DomainRatingModel(paths["domain_tfidf.joblib"]),
|
| 153 |
+
)
|
| 154 |
+
logger.info("VERA pipeline loaded in %.1fs", time.perf_counter() - started)
|
| 155 |
+
|
| 156 |
+
@classmethod
|
| 157 |
+
def from_components(
|
| 158 |
+
cls, agents: SignalAgents, fusion: FusionModel, conformal: ConformalPredictor
|
| 159 |
+
) -> VERAPipeline:
|
| 160 |
+
"""Assemble from pre-built parts (tests, notebooks)."""
|
| 161 |
+
instance = cls.__new__(cls)
|
| 162 |
+
instance.agents = agents
|
| 163 |
+
instance.fusion = fusion
|
| 164 |
+
instance.conformal = conformal
|
| 165 |
+
return instance
|
| 166 |
+
|
| 167 |
+
def process(
|
| 168 |
+
self,
|
| 169 |
+
*,
|
| 170 |
+
text: str,
|
| 171 |
+
star_rating: float,
|
| 172 |
+
helpful_votes: int | None = None,
|
| 173 |
+
verified_purchase: bool | None = None,
|
| 174 |
+
product_title: str | None = "",
|
| 175 |
+
timestamp_ms: int | None = None,
|
| 176 |
+
) -> dict[str, Any]:
|
| 177 |
+
"""Score one review (PRD §3.1 output contract)."""
|
| 178 |
+
started = time.perf_counter()
|
| 179 |
+
outputs = extract_signals(
|
| 180 |
+
self.agents,
|
| 181 |
+
text=text,
|
| 182 |
+
star_rating=star_rating,
|
| 183 |
+
helpful_votes=helpful_votes,
|
| 184 |
+
verified_purchase=verified_purchase,
|
| 185 |
+
timestamp_ms=timestamp_ms,
|
| 186 |
+
product_title=product_title,
|
| 187 |
+
)
|
| 188 |
+
fused = self.fusion.predict(build_feature_vector(outputs))
|
| 189 |
+
lower, upper, confidence = self.conformal.predict_interval(
|
| 190 |
+
fused["score"], fused["ensemble_std"]
|
| 191 |
+
)
|
| 192 |
+
corrected = round(fused["score"], 3)
|
| 193 |
+
|
| 194 |
+
pre, cons, qual = outputs["preprocessor"], outputs["consistency"], outputs["quality"]
|
| 195 |
+
return {
|
| 196 |
+
"corrected_score": corrected,
|
| 197 |
+
"interval": [round(lower, 3), round(upper, 3)],
|
| 198 |
+
"confidence": round(confidence, 3),
|
| 199 |
+
"aspects": outputs["absa"]["aspects"],
|
| 200 |
+
# Agent 4 definition: star − text-derived experience score (positive = inflated).
|
| 201 |
+
"star_text_gap": cons["star_text_gap"],
|
| 202 |
+
"review_quality": qual["quality_score"],
|
| 203 |
+
"ensemble_std": round(fused["ensemble_std"], 4),
|
| 204 |
+
"flags": {
|
| 205 |
+
"language": pre["language"],
|
| 206 |
+
"is_english": pre["is_english"],
|
| 207 |
+
"is_earbud": pre["is_earbud"],
|
| 208 |
+
"earbud_confidence": pre["earbud_confidence"],
|
| 209 |
+
"has_url": pre["has_url"],
|
| 210 |
+
"star_inflated": cons["star_inflated"],
|
| 211 |
+
"star_deflated": cons["star_deflated"],
|
| 212 |
+
"experience_score": round(outputs["experience_extractor"]["predicted_score"], 3),
|
| 213 |
+
"domain_score": outputs["domain_rating"]["domain_score"],
|
| 214 |
+
"imputed_fields": qual["imputed_fields"],
|
| 215 |
+
},
|
| 216 |
+
"explanation": build_explanation(outputs, corrected),
|
| 217 |
+
"latency_ms": round((time.perf_counter() - started) * 1000, 1),
|
| 218 |
+
}
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy ORM models. Importing this package registers every table on ``Base``."""
|
| 2 |
+
|
| 3 |
+
from app.models.calibration import CalibrationCheckpoint
|
| 4 |
+
from app.models.job import Job
|
| 5 |
+
from app.models.label_run import LabelRun
|
| 6 |
+
from app.models.product import Product
|
| 7 |
+
from app.models.review import Review, ReviewStatus
|
| 8 |
+
|
| 9 |
+
__all__ = ["CalibrationCheckpoint", "Job", "LabelRun", "Product", "Review", "ReviewStatus"]
|
backend/app/models/calibration.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`calibration_checkpoints` table — history of fitted conformal q_hat values (PRD §10.3)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import DateTime, Float, Integer, String, func
|
| 8 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 9 |
+
|
| 10 |
+
from app.database import Base
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CalibrationCheckpoint(Base):
|
| 14 |
+
__tablename__ = "calibration_checkpoints"
|
| 15 |
+
|
| 16 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 17 |
+
method: Mapped[str] = mapped_column(String(20), nullable=False, default="absolute")
|
| 18 |
+
q_hat: Mapped[float] = mapped_column(Float, nullable=False)
|
| 19 |
+
empirical_coverage: Mapped[float] = mapped_column(Float, nullable=False)
|
| 20 |
+
nominal_coverage: Mapped[float] = mapped_column(Float, nullable=False)
|
| 21 |
+
calibration_set_size: Mapped[int] = mapped_column(Integer, nullable=False)
|
| 22 |
+
fitted_at: Mapped[datetime] = mapped_column(
|
| 23 |
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 24 |
+
)
|
backend/app/models/job.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`jobs` table — transactional outbox for background work.
|
| 2 |
+
|
| 3 |
+
Why an outbox: Postgres and Redis cannot commit atomically. The API writes the
|
| 4 |
+
review AND its job row in one database transaction, so a job can never be lost
|
| 5 |
+
between "review saved" and "task queued". Redis only carries wake-up messages;
|
| 6 |
+
the database owns attempts, back-off, and completion. Duplicate deliveries are
|
| 7 |
+
harmless because consumers lock the job row and skip finished jobs.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from enum import StrEnum
|
| 14 |
+
from uuid import uuid4
|
| 15 |
+
|
| 16 |
+
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
|
| 17 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 18 |
+
|
| 19 |
+
from app.database import Base
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class JobKind(StrEnum):
|
| 23 |
+
REVIEW = "review" # run the ML pipeline for one review
|
| 24 |
+
AGGREGATE = "aggregate" # recompute every product aggregate + ranking
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Job(Base):
|
| 28 |
+
__tablename__ = "jobs"
|
| 29 |
+
__table_args__ = (Index("idx_jobs_dispatch", "finished_at", "available_at"),)
|
| 30 |
+
|
| 31 |
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
| 32 |
+
kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
| 33 |
+
review_id: Mapped[int | None] = mapped_column(
|
| 34 |
+
ForeignKey("reviews.id", ondelete="CASCADE"), unique=True
|
| 35 |
+
)
|
| 36 |
+
attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False)
|
| 37 |
+
#: Earliest time the job may run (pushed forward by retry back-off).
|
| 38 |
+
available_at: Mapped[datetime] = mapped_column(
|
| 39 |
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 40 |
+
)
|
| 41 |
+
#: Last time a wake-up was sent to Redis; NULL means "send now".
|
| 42 |
+
last_dispatched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 43 |
+
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 44 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 45 |
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def __repr__(self) -> str:
|
| 49 |
+
return f"<Job {self.id} kind={self.kind} attempts={self.attempts} done={self.finished_at}>"
|
backend/app/models/label_run.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`label_runs` table — audit trail of LLM-generated labels (PRD §7, §15)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import DateTime, Float, Index, Integer, String, func
|
| 9 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 10 |
+
|
| 11 |
+
from app.database import Base, JSONType
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class LabelRun(Base):
|
| 15 |
+
__tablename__ = "label_runs"
|
| 16 |
+
__table_args__ = (Index("idx_label_runs_hash", "review_text_hash"),)
|
| 17 |
+
|
| 18 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 19 |
+
review_text_hash: Mapped[str] = mapped_column(String(64), nullable=False) # sha256 hex
|
| 20 |
+
labeler: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 21 |
+
experience_score: Mapped[float | None] = mapped_column(Float)
|
| 22 |
+
aspects: Mapped[dict[str, Any] | None] = mapped_column(JSONType)
|
| 23 |
+
has_irony: Mapped[bool | None] = mapped_column()
|
| 24 |
+
review_quality: Mapped[str | None] = mapped_column(String(10))
|
| 25 |
+
raw_response: Mapped[dict[str, Any] | None] = mapped_column(JSONType)
|
| 26 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 27 |
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 28 |
+
)
|
backend/app/models/product.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`products` table — Empirical-Bayes product aggregates (PRD §11, §15)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import TYPE_CHECKING, Any
|
| 7 |
+
|
| 8 |
+
from sqlalchemy import DateTime, Float, Index, Integer, String, Text, func
|
| 9 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 10 |
+
|
| 11 |
+
from app.database import Base, JSONType
|
| 12 |
+
|
| 13 |
+
if TYPE_CHECKING:
|
| 14 |
+
from app.models.review import Review
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Product(Base):
|
| 18 |
+
__tablename__ = "products"
|
| 19 |
+
__table_args__ = (Index("idx_products_rank", "rank_position"),)
|
| 20 |
+
|
| 21 |
+
asin: Mapped[str] = mapped_column(String(20), primary_key=True)
|
| 22 |
+
title: Mapped[str | None] = mapped_column(Text)
|
| 23 |
+
|
| 24 |
+
corrected_score: Mapped[float | None] = mapped_column(Float)
|
| 25 |
+
uncertainty: Mapped[float | None] = mapped_column(Float)
|
| 26 |
+
trust_weighted_score: Mapped[float | None] = mapped_column(Float)
|
| 27 |
+
star_average: Mapped[float | None] = mapped_column(Float)
|
| 28 |
+
eb_shrinkage_factor: Mapped[float | None] = mapped_column(Float)
|
| 29 |
+
review_count: Mapped[int] = mapped_column(
|
| 30 |
+
Integer, nullable=False, default=0, server_default="0"
|
| 31 |
+
)
|
| 32 |
+
aspect_summary: Mapped[dict[str, Any] | None] = mapped_column(JSONType)
|
| 33 |
+
|
| 34 |
+
rank_position: Mapped[int | None] = mapped_column(Integer)
|
| 35 |
+
previous_rank_position: Mapped[int | None] = mapped_column(Integer)
|
| 36 |
+
last_aggregated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 37 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 38 |
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
reviews: Mapped[list[Review]] = relationship(back_populates="product")
|
| 42 |
+
|
| 43 |
+
def __repr__(self) -> str:
|
| 44 |
+
return f"<Product asin={self.asin!r} score={self.corrected_score} n={self.review_count}>"
|
backend/app/models/review.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`reviews` table — one submitted review and its pipeline output (PRD §15)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from enum import StrEnum
|
| 7 |
+
from typing import TYPE_CHECKING, Any
|
| 8 |
+
|
| 9 |
+
from sqlalchemy import (
|
| 10 |
+
CheckConstraint,
|
| 11 |
+
DateTime,
|
| 12 |
+
Float,
|
| 13 |
+
ForeignKey,
|
| 14 |
+
Index,
|
| 15 |
+
Integer,
|
| 16 |
+
String,
|
| 17 |
+
Text,
|
| 18 |
+
func,
|
| 19 |
+
)
|
| 20 |
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 21 |
+
|
| 22 |
+
from app.database import Base, JSONType
|
| 23 |
+
|
| 24 |
+
if TYPE_CHECKING:
|
| 25 |
+
from app.models.product import Product
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class ReviewStatus(StrEnum):
|
| 29 |
+
"""Processing lifecycle of a review."""
|
| 30 |
+
|
| 31 |
+
PENDING = "pending"
|
| 32 |
+
PROCESSING = "processing"
|
| 33 |
+
DONE = "done"
|
| 34 |
+
FAILED = "failed"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class Review(Base):
|
| 38 |
+
__tablename__ = "reviews"
|
| 39 |
+
__table_args__ = (
|
| 40 |
+
CheckConstraint("star_rating >= 1 AND star_rating <= 5", name="ck_reviews_star_range"),
|
| 41 |
+
CheckConstraint(
|
| 42 |
+
"status IN ('pending', 'processing', 'done', 'failed')", name="ck_reviews_status"
|
| 43 |
+
),
|
| 44 |
+
Index("idx_reviews_asin", "product_asin"),
|
| 45 |
+
Index("idx_reviews_status", "status"),
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 49 |
+
|
| 50 |
+
# --- Input ----------------------------------------------------------------
|
| 51 |
+
text: Mapped[str] = mapped_column(Text, nullable=False)
|
| 52 |
+
star_rating: Mapped[float] = mapped_column(Float, nullable=False)
|
| 53 |
+
# NULL means "not supplied" — the pipeline imputes training means (PRD §10.1).
|
| 54 |
+
helpful_votes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
| 55 |
+
verified_purchase: Mapped[bool | None] = mapped_column(nullable=True)
|
| 56 |
+
product_title: Mapped[str | None] = mapped_column(Text, nullable=True)
|
| 57 |
+
product_asin: Mapped[str | None] = mapped_column(
|
| 58 |
+
String(20), ForeignKey("products.asin", ondelete="SET NULL"), nullable=True
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# --- Pipeline output ------------------------------------------------------
|
| 62 |
+
corrected_score: Mapped[float | None] = mapped_column(Float)
|
| 63 |
+
interval_lower: Mapped[float | None] = mapped_column(Float)
|
| 64 |
+
interval_upper: Mapped[float | None] = mapped_column(Float)
|
| 65 |
+
confidence_value: Mapped[float | None] = mapped_column(Float)
|
| 66 |
+
star_text_gap: Mapped[float | None] = mapped_column(Float)
|
| 67 |
+
review_quality: Mapped[float | None] = mapped_column(Float)
|
| 68 |
+
aspects: Mapped[dict[str, Any] | None] = mapped_column(JSONType)
|
| 69 |
+
flags: Mapped[dict[str, Any] | None] = mapped_column(JSONType)
|
| 70 |
+
explanation: Mapped[str | None] = mapped_column(Text)
|
| 71 |
+
error_message: Mapped[str | None] = mapped_column(Text)
|
| 72 |
+
|
| 73 |
+
# --- Bookkeeping ----------------------------------------------------------
|
| 74 |
+
status: Mapped[str] = mapped_column(
|
| 75 |
+
String(20), nullable=False, default=ReviewStatus.PENDING.value
|
| 76 |
+
)
|
| 77 |
+
created_at: Mapped[datetime] = mapped_column(
|
| 78 |
+
DateTime(timezone=True), server_default=func.now(), nullable=False
|
| 79 |
+
)
|
| 80 |
+
updated_at: Mapped[datetime] = mapped_column(
|
| 81 |
+
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
product: Mapped[Product | None] = relationship(back_populates="reviews")
|
| 85 |
+
|
| 86 |
+
def __repr__(self) -> str:
|
| 87 |
+
return f"<Review id={self.id} status={self.status} asin={self.product_asin!r}>"
|
backend/app/schemas/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Pydantic request and response schemas for the public API."""
|
backend/app/schemas/product.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Product API contracts."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel, ConfigDict
|
| 9 |
+
|
| 10 |
+
from app.schemas.review import ReviewResponse
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ProductResponse(BaseModel):
|
| 14 |
+
"""One row of the product ranking."""
|
| 15 |
+
|
| 16 |
+
model_config = ConfigDict(from_attributes=True)
|
| 17 |
+
|
| 18 |
+
asin: str
|
| 19 |
+
title: str | None
|
| 20 |
+
corrected_score: float | None
|
| 21 |
+
uncertainty: float | None
|
| 22 |
+
trust_weighted_score: float | None
|
| 23 |
+
star_average: float | None
|
| 24 |
+
review_count: int
|
| 25 |
+
rank_position: int | None
|
| 26 |
+
previous_rank_position: int | None
|
| 27 |
+
aspect_summary: dict[str, Any] | None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ProductDetailResponse(ProductResponse):
|
| 31 |
+
"""Product aggregate plus one page of completed reviews."""
|
| 32 |
+
|
| 33 |
+
eb_shrinkage_factor: float | None
|
| 34 |
+
last_aggregated_at: datetime | None
|
| 35 |
+
reviews: list[ReviewResponse]
|
| 36 |
+
reviews_total: int
|
| 37 |
+
reviews_limit: int
|
| 38 |
+
reviews_offset: int
|
| 39 |
+
reviews_has_more: bool
|
backend/app/schemas/review.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Review API contracts."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from enum import StrEnum
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 10 |
+
|
| 11 |
+
from app.ml.constants import Sentiment
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AspectSentiment(BaseModel):
|
| 15 |
+
"""Inference result for one earbud aspect."""
|
| 16 |
+
|
| 17 |
+
detected: bool
|
| 18 |
+
sentiment: Sentiment
|
| 19 |
+
confidence: float = Field(ge=0.0, le=1.0)
|
| 20 |
+
prob_neg: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 21 |
+
prob_neu: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 22 |
+
prob_pos: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ReviewCreate(BaseModel):
|
| 26 |
+
"""A review submitted for asynchronous scoring."""
|
| 27 |
+
|
| 28 |
+
text: str = Field(min_length=20, max_length=10_000)
|
| 29 |
+
star_rating: float = Field(ge=1.0, le=5.0)
|
| 30 |
+
helpful_votes: int | None = Field(default=None, ge=0)
|
| 31 |
+
verified_purchase: bool | None = None
|
| 32 |
+
product_title: str | None = Field(default=None, max_length=1_000)
|
| 33 |
+
product_asin: str | None = Field(default=None, max_length=20)
|
| 34 |
+
|
| 35 |
+
@field_validator("text")
|
| 36 |
+
@classmethod
|
| 37 |
+
def text_must_contain_non_whitespace(cls, value: str) -> str:
|
| 38 |
+
value = value.strip()
|
| 39 |
+
if not value:
|
| 40 |
+
raise ValueError("text must contain non-whitespace characters")
|
| 41 |
+
return value
|
| 42 |
+
|
| 43 |
+
@field_validator("product_title", "product_asin", mode="before")
|
| 44 |
+
@classmethod
|
| 45 |
+
def strip_optional_strings(cls, value: object) -> str | None:
|
| 46 |
+
if value is None:
|
| 47 |
+
return None
|
| 48 |
+
if not isinstance(value, str):
|
| 49 |
+
raise ValueError("must be a string")
|
| 50 |
+
return value.strip() or None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class ReviewTaskStatus(BaseModel):
|
| 54 |
+
"""Polling response returned after a review is submitted."""
|
| 55 |
+
|
| 56 |
+
review_id: int
|
| 57 |
+
task_id: str | None
|
| 58 |
+
status: str
|
| 59 |
+
message: str
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class ReviewResponse(BaseModel):
|
| 63 |
+
"""Persisted review plus any completed pipeline result."""
|
| 64 |
+
|
| 65 |
+
model_config = ConfigDict(from_attributes=True)
|
| 66 |
+
|
| 67 |
+
id: int
|
| 68 |
+
text: str
|
| 69 |
+
star_rating: float
|
| 70 |
+
helpful_votes: int | None
|
| 71 |
+
verified_purchase: bool | None
|
| 72 |
+
product_title: str | None
|
| 73 |
+
product_asin: str | None
|
| 74 |
+
corrected_score: float | None
|
| 75 |
+
interval_lower: float | None
|
| 76 |
+
interval_upper: float | None
|
| 77 |
+
confidence_value: float | None
|
| 78 |
+
star_text_gap: float | None
|
| 79 |
+
review_quality: float | None
|
| 80 |
+
aspects: dict[str, AspectSentiment] | None
|
| 81 |
+
flags: dict[str, Any] | None
|
| 82 |
+
explanation: str | None
|
| 83 |
+
error_message: str | None
|
| 84 |
+
status: str
|
| 85 |
+
created_at: datetime
|
| 86 |
+
updated_at: datetime
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class ReviewLifecycle(StrEnum):
|
| 90 |
+
"""Public lifecycle values; mirrors the persisted ORM enum."""
|
| 91 |
+
|
| 92 |
+
PENDING = "pending"
|
| 93 |
+
PROCESSING = "processing"
|
| 94 |
+
DONE = "done"
|
| 95 |
+
FAILED = "failed"
|
backend/app/worker/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Celery application and asynchronous VERA tasks."""
|
backend/app/worker/celery_app.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celery application (PRD §14.4), tuned for the Upstash free tier.
|
| 2 |
+
|
| 3 |
+
UPSTASH BUDGET: the free tier allows 10,000 Redis commands/day. Celery's default
|
| 4 |
+
0.1 s broker polling would burn ~864K/day, so ``polling_interval`` is 2.0 s.
|
| 5 |
+
Even that is ~43K polls/day for ONE worker — above the free quota. Run a single
|
| 6 |
+
worker, and expect to need a paid Upstash plan (or local Redis) for sustained
|
| 7 |
+
use. Results are ignored (the DB stores outcomes), saving result-backend writes.
|
| 8 |
+
|
| 9 |
+
Worker start::
|
| 10 |
+
|
| 11 |
+
cd backend && celery -A app.worker.celery_app worker --loglevel=info
|
| 12 |
+
|
| 13 |
+
Startup behaviour:
|
| 14 |
+
* ``worker_init`` loads the ML pipeline and exits the process if any artifact is
|
| 15 |
+
missing (hard-fail policy, DECISIONS.md) — before consuming any message.
|
| 16 |
+
* ``worker_ready`` starts the outbox dispatcher thread (:mod:`app.worker.outbox`).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from celery import Celery
|
| 25 |
+
from celery.signals import worker_init, worker_ready
|
| 26 |
+
|
| 27 |
+
from app.config import settings
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
celery_app = Celery("vera", broker=settings.broker_url, include=["app.worker.tasks"])
|
| 32 |
+
celery_app.conf.update(
|
| 33 |
+
task_serializer="json",
|
| 34 |
+
accept_content=["json"],
|
| 35 |
+
timezone="UTC",
|
| 36 |
+
enable_utc=True,
|
| 37 |
+
task_ignore_result=True,
|
| 38 |
+
broker_transport_options={"visibility_timeout": 3600, "polling_interval": 2.0},
|
| 39 |
+
broker_connection_timeout=3,
|
| 40 |
+
broker_connection_retry_on_startup=True,
|
| 41 |
+
worker_prefetch_multiplier=1,
|
| 42 |
+
task_acks_late=True,
|
| 43 |
+
task_reject_on_worker_lost=True,
|
| 44 |
+
# Serialize inference so this worker does not duplicate the loaded FP32
|
| 45 |
+
# models. Deployment memory must be sized and verified separately; the
|
| 46 |
+
# free-tier 512 MB target is below the documented production requirement.
|
| 47 |
+
worker_pool="solo",
|
| 48 |
+
worker_concurrency=1,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@worker_init.connect
|
| 53 |
+
def load_models_or_exit(**_: Any) -> None:
|
| 54 |
+
"""Fail fast: a worker without valid artifacts must not consume jobs."""
|
| 55 |
+
from app.worker.tasks import get_pipeline
|
| 56 |
+
|
| 57 |
+
try:
|
| 58 |
+
get_pipeline()
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
logger.critical("VERA model initialisation failed: %s", exc)
|
| 61 |
+
# SystemExit (a BaseException) escapes Celery's signal error handling.
|
| 62 |
+
raise SystemExit(1) from exc
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@worker_ready.connect
|
| 66 |
+
def start_outbox(**_: Any) -> None:
|
| 67 |
+
"""Run the outbox dispatcher alongside the worker (no extra process needed)."""
|
| 68 |
+
from app.worker.outbox import start_background_dispatcher
|
| 69 |
+
|
| 70 |
+
start_background_dispatcher()
|
| 71 |
+
logger.info("Outbox dispatcher thread started")
|
backend/app/worker/outbox.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Outbox dispatcher: turns unfinished `jobs` rows into Celery wake-ups.
|
| 2 |
+
|
| 3 |
+
Runs as a daemon thread inside the worker (started on ``worker_ready`` in
|
| 4 |
+
:mod:`app.worker.celery_app`), so a free-tier deployment needs no extra process.
|
| 5 |
+
It can also run standalone::
|
| 6 |
+
|
| 7 |
+
cd backend && python -m app.worker.outbox
|
| 8 |
+
|
| 9 |
+
A job is (re)sent when it is unfinished, due (``available_at <= now``), and was
|
| 10 |
+
never dispatched or was dispatched more than :data:`REDISPATCH_AFTER` ago — this
|
| 11 |
+
recovers messages lost to a Redis outage or a crashed worker. Importing this
|
| 12 |
+
module never loads ML models.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import contextlib
|
| 18 |
+
import logging
|
| 19 |
+
import threading
|
| 20 |
+
from datetime import UTC, datetime, timedelta
|
| 21 |
+
from typing import Final
|
| 22 |
+
|
| 23 |
+
from sqlalchemy import or_
|
| 24 |
+
|
| 25 |
+
from app.database import SessionLocal
|
| 26 |
+
from app.models.job import Job
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
POLL_INTERVAL_SECONDS: Final = 5.0
|
| 31 |
+
REDISPATCH_AFTER: Final = timedelta(seconds=60)
|
| 32 |
+
RUN_JOB_TASK: Final = "vera.run_job"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def send_wakeup(job_id: str) -> None:
|
| 36 |
+
"""Publish one wake-up message. Raises if the broker is unreachable."""
|
| 37 |
+
from app.worker.celery_app import celery_app # lazy: avoid import cycle
|
| 38 |
+
|
| 39 |
+
celery_app.send_task(RUN_JOB_TASK, args=[job_id], retry=False)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def dispatch_pending(limit: int = 100) -> int:
|
| 43 |
+
"""Send wake-ups for due jobs. Returns the number sent."""
|
| 44 |
+
now = datetime.now(UTC)
|
| 45 |
+
sent = 0
|
| 46 |
+
with SessionLocal() as db:
|
| 47 |
+
jobs = (
|
| 48 |
+
db.query(Job)
|
| 49 |
+
.filter(
|
| 50 |
+
Job.finished_at.is_(None),
|
| 51 |
+
Job.available_at <= now,
|
| 52 |
+
or_(
|
| 53 |
+
Job.last_dispatched_at.is_(None),
|
| 54 |
+
Job.last_dispatched_at < now - REDISPATCH_AFTER,
|
| 55 |
+
),
|
| 56 |
+
)
|
| 57 |
+
.order_by(Job.available_at, Job.id)
|
| 58 |
+
.with_for_update(skip_locked=True)
|
| 59 |
+
.limit(limit)
|
| 60 |
+
.all()
|
| 61 |
+
)
|
| 62 |
+
for job in jobs:
|
| 63 |
+
send_wakeup(job.id)
|
| 64 |
+
job.last_dispatched_at = now
|
| 65 |
+
sent += 1
|
| 66 |
+
db.commit()
|
| 67 |
+
return sent
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def run_forever(stop: threading.Event | None = None) -> None:
|
| 71 |
+
"""Poll until ``stop`` is set. Errors are logged; the loop never dies."""
|
| 72 |
+
stop = stop or threading.Event()
|
| 73 |
+
while not stop.is_set():
|
| 74 |
+
try:
|
| 75 |
+
count = dispatch_pending()
|
| 76 |
+
if count:
|
| 77 |
+
logger.info("Outbox dispatched %d job(s)", count)
|
| 78 |
+
except Exception:
|
| 79 |
+
logger.exception("Outbox dispatch failed; will retry")
|
| 80 |
+
stop.wait(POLL_INTERVAL_SECONDS)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def start_background_dispatcher() -> threading.Event:
|
| 84 |
+
"""Start :func:`run_forever` in a daemon thread; set the returned event to stop it."""
|
| 85 |
+
stop = threading.Event()
|
| 86 |
+
threading.Thread(target=run_forever, args=(stop,), name="vera-outbox", daemon=True).start()
|
| 87 |
+
return stop
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
| 92 |
+
with contextlib.suppress(KeyboardInterrupt):
|
| 93 |
+
run_forever()
|
backend/app/worker/tasks.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Celery task: execute one outbox job (review inference or product aggregation).
|
| 2 |
+
|
| 3 |
+
Delivery model (see :mod:`app.models.job`): Redis messages are only wake-ups
|
| 4 |
+
and may be duplicated or lost; the ``jobs`` row is the source of truth. Every
|
| 5 |
+
run locks its job row, skips finished jobs, and records retries durably with
|
| 6 |
+
exponential back-off. :mod:`app.worker.outbox` re-sends anything unfinished.
|
| 7 |
+
|
| 8 |
+
ML models load lazily through :func:`get_pipeline`, so importing this module
|
| 9 |
+
(e.g. from tests) never touches model artifacts.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import logging
|
| 15 |
+
from datetime import UTC, datetime, timedelta
|
| 16 |
+
from functools import lru_cache
|
| 17 |
+
from typing import TYPE_CHECKING, Any, Final
|
| 18 |
+
|
| 19 |
+
from sqlalchemy import text
|
| 20 |
+
from sqlalchemy.orm import Session
|
| 21 |
+
|
| 22 |
+
from app.config import settings
|
| 23 |
+
from app.database import SessionLocal
|
| 24 |
+
from app.models.job import Job, JobKind
|
| 25 |
+
from app.models.product import Product
|
| 26 |
+
from app.models.review import Review, ReviewStatus
|
| 27 |
+
from app.worker.celery_app import celery_app
|
| 28 |
+
|
| 29 |
+
if TYPE_CHECKING:
|
| 30 |
+
from app.ml.pipeline import VERAPipeline
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
MAX_REVIEW_ATTEMPTS: Final = 4
|
| 35 |
+
MAX_AGGREGATE_ATTEMPTS: Final = 10
|
| 36 |
+
MAX_BACKOFF_SECONDS: Final = 300
|
| 37 |
+
#: Arbitrary constant key for the Postgres advisory lock around aggregation.
|
| 38 |
+
AGGREGATION_LOCK_KEY: Final = 19842026
|
| 39 |
+
FAILED_REVIEW_MESSAGE: Final = "Review analysis failed. Please try again later."
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@lru_cache(maxsize=1)
|
| 43 |
+
def get_pipeline() -> VERAPipeline:
|
| 44 |
+
"""Load production artifacts once per worker process. Never falls back."""
|
| 45 |
+
from app.ml.pipeline import VERAPipeline
|
| 46 |
+
|
| 47 |
+
return VERAPipeline(settings.MODEL_DIR, settings.HF_MODEL_REPO, settings.HF_TOKEN)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _as_utc(value: datetime) -> datetime:
|
| 51 |
+
"""Normalise DB datetimes: SQLite returns naive values, Postgres aware ones."""
|
| 52 |
+
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def backoff_seconds(attempts: int) -> int:
|
| 56 |
+
"""5 s, 10 s, 20 s, … capped at :data:`MAX_BACKOFF_SECONDS`."""
|
| 57 |
+
return min(MAX_BACKOFF_SECONDS, 5 * 2 ** min(attempts, 6))
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ----------------------------------------------------------------------------
|
| 61 |
+
# Job handlers — run inside the caller's transaction; the caller commits.
|
| 62 |
+
# ----------------------------------------------------------------------------
|
| 63 |
+
def process_review(db: Session, review_id: int) -> None:
|
| 64 |
+
"""Run the pipeline for one review and store its result.
|
| 65 |
+
|
| 66 |
+
The review row stays locked for the whole inference, so a crash rolls back
|
| 67 |
+
cleanly and a duplicate delivery cannot write concurrently. The status is not
|
| 68 |
+
committed as "processing" separately; clients see "pending" until "done".
|
| 69 |
+
"""
|
| 70 |
+
review = db.query(Review).filter(Review.id == review_id).with_for_update().one_or_none()
|
| 71 |
+
if review is None or review.status in (ReviewStatus.DONE.value, ReviewStatus.FAILED.value):
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
result = get_pipeline().process(
|
| 75 |
+
text=review.text,
|
| 76 |
+
star_rating=review.star_rating,
|
| 77 |
+
helpful_votes=review.helpful_votes,
|
| 78 |
+
verified_purchase=review.verified_purchase,
|
| 79 |
+
product_title=review.product_title,
|
| 80 |
+
)
|
| 81 |
+
review.corrected_score = result["corrected_score"]
|
| 82 |
+
review.interval_lower, review.interval_upper = result["interval"]
|
| 83 |
+
review.confidence_value = result["confidence"]
|
| 84 |
+
review.star_text_gap = result["star_text_gap"]
|
| 85 |
+
review.review_quality = result["review_quality"]
|
| 86 |
+
review.aspects = result["aspects"]
|
| 87 |
+
review.flags = result["flags"]
|
| 88 |
+
review.explanation = result["explanation"]
|
| 89 |
+
review.error_message = None
|
| 90 |
+
review.status = ReviewStatus.DONE.value
|
| 91 |
+
|
| 92 |
+
# Reviews without an ASIN never create aggregation work (PRD weakness #3).
|
| 93 |
+
if review.product_asin:
|
| 94 |
+
db.add(Job(kind=JobKind.AGGREGATE.value))
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def aggregate_products(db: Session, current_job_id: str | None = None) -> int:
|
| 98 |
+
"""Recompute EB aggregates and ranks for ALL products. Returns product count.
|
| 99 |
+
|
| 100 |
+
The EB prior is a population quantity, so every product is refreshed under
|
| 101 |
+
one lock (Postgres advisory transaction lock — released on commit/rollback,
|
| 102 |
+
no TTL to expire mid-run). Other unfinished aggregate jobs visible *before*
|
| 103 |
+
the review snapshot is read are marked finished: their reviews are already
|
| 104 |
+
committed and therefore included, so re-running them would be pure waste.
|
| 105 |
+
"""
|
| 106 |
+
from app.ml.calibration.empirical_bayes import (
|
| 107 |
+
EmpiricalBayesAggregator,
|
| 108 |
+
ReviewSignal,
|
| 109 |
+
rank_products,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if db.get_bind().dialect.name == "postgresql":
|
| 113 |
+
db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": AGGREGATION_LOCK_KEY})
|
| 114 |
+
|
| 115 |
+
superseded = (
|
| 116 |
+
db.query(Job)
|
| 117 |
+
.filter(
|
| 118 |
+
Job.kind == JobKind.AGGREGATE.value,
|
| 119 |
+
Job.finished_at.is_(None),
|
| 120 |
+
Job.id != (current_job_id or ""),
|
| 121 |
+
)
|
| 122 |
+
.with_for_update(skip_locked=True)
|
| 123 |
+
.all()
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
rows = (
|
| 127 |
+
db.query(
|
| 128 |
+
Review.product_asin,
|
| 129 |
+
Review.corrected_score,
|
| 130 |
+
Review.review_quality,
|
| 131 |
+
Review.star_rating,
|
| 132 |
+
Review.aspects,
|
| 133 |
+
)
|
| 134 |
+
.filter(
|
| 135 |
+
Review.status == ReviewStatus.DONE.value,
|
| 136 |
+
Review.product_asin.is_not(None),
|
| 137 |
+
Review.corrected_score.is_not(None),
|
| 138 |
+
)
|
| 139 |
+
.all()
|
| 140 |
+
)
|
| 141 |
+
now = datetime.now(UTC)
|
| 142 |
+
for job in superseded:
|
| 143 |
+
job.finished_at = now
|
| 144 |
+
|
| 145 |
+
signals = [
|
| 146 |
+
ReviewSignal(
|
| 147 |
+
product_asin=row.product_asin,
|
| 148 |
+
corrected_score=float(row.corrected_score),
|
| 149 |
+
quality_weight=float(row.review_quality) if row.review_quality is not None else 0.5,
|
| 150 |
+
star_rating=float(row.star_rating),
|
| 151 |
+
aspects=row.aspects or {},
|
| 152 |
+
)
|
| 153 |
+
for row in rows
|
| 154 |
+
]
|
| 155 |
+
if not signals:
|
| 156 |
+
return 0
|
| 157 |
+
|
| 158 |
+
_, aggregates = EmpiricalBayesAggregator().aggregate_all(signals)
|
| 159 |
+
for rank, asin in enumerate(rank_products(aggregates), start=1):
|
| 160 |
+
aggregate = aggregates[asin]
|
| 161 |
+
product = db.get(Product, asin)
|
| 162 |
+
if product is None: # FK makes this impossible unless data was edited by hand
|
| 163 |
+
raise RuntimeError(f"Completed review references missing product {asin!r}")
|
| 164 |
+
product.previous_rank_position = product.rank_position
|
| 165 |
+
product.rank_position = rank
|
| 166 |
+
product.corrected_score = aggregate.corrected_score
|
| 167 |
+
product.uncertainty = aggregate.uncertainty
|
| 168 |
+
product.trust_weighted_score = aggregate.trust_weighted_score
|
| 169 |
+
product.star_average = aggregate.star_average
|
| 170 |
+
product.eb_shrinkage_factor = aggregate.eb_shrinkage_factor
|
| 171 |
+
product.review_count = aggregate.review_count
|
| 172 |
+
product.aspect_summary = aggregate.aspect_summary
|
| 173 |
+
product.last_aggregated_at = now
|
| 174 |
+
return len(aggregates)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
# ----------------------------------------------------------------------------
|
| 178 |
+
# Celery entry point
|
| 179 |
+
# ----------------------------------------------------------------------------
|
| 180 |
+
def _record_failure(db: Session, job_id: str) -> None:
|
| 181 |
+
"""Increment attempts; schedule a retry or mark the job (and review) failed."""
|
| 182 |
+
job = db.query(Job).filter(Job.id == job_id).with_for_update().one_or_none()
|
| 183 |
+
if job is None or job.finished_at is not None:
|
| 184 |
+
return # a duplicate delivery finished it meanwhile
|
| 185 |
+
|
| 186 |
+
job.attempts += 1
|
| 187 |
+
limit = MAX_REVIEW_ATTEMPTS if job.kind == JobKind.REVIEW.value else MAX_AGGREGATE_ATTEMPTS
|
| 188 |
+
now = datetime.now(UTC)
|
| 189 |
+
if job.attempts >= limit:
|
| 190 |
+
job.finished_at = now
|
| 191 |
+
if job.kind == JobKind.REVIEW.value and job.review_id is not None:
|
| 192 |
+
review = db.get(Review, job.review_id)
|
| 193 |
+
if review is not None and review.status != ReviewStatus.DONE.value:
|
| 194 |
+
review.status = ReviewStatus.FAILED.value
|
| 195 |
+
review.error_message = FAILED_REVIEW_MESSAGE
|
| 196 |
+
logger.error("Job %s (%s) failed permanently after %d attempts", job.id, job.kind, limit)
|
| 197 |
+
else:
|
| 198 |
+
job.available_at = now + timedelta(seconds=backoff_seconds(job.attempts))
|
| 199 |
+
job.last_dispatched_at = None # let the outbox re-send once due
|
| 200 |
+
db.commit()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def execute_job(job_id: str) -> str:
|
| 204 |
+
"""Run one job to completion or record its failure. Returns an outcome label."""
|
| 205 |
+
with SessionLocal() as db:
|
| 206 |
+
try:
|
| 207 |
+
job = (
|
| 208 |
+
db.query(Job)
|
| 209 |
+
.filter(Job.id == job_id)
|
| 210 |
+
.with_for_update(skip_locked=True)
|
| 211 |
+
.one_or_none()
|
| 212 |
+
)
|
| 213 |
+
if job is None or job.finished_at is not None:
|
| 214 |
+
return "ignored" # unknown, finished, or locked by a concurrent delivery
|
| 215 |
+
if _as_utc(job.available_at) > datetime.now(UTC):
|
| 216 |
+
return "deferred"
|
| 217 |
+
|
| 218 |
+
if job.kind == JobKind.REVIEW.value and job.review_id is not None:
|
| 219 |
+
process_review(db, job.review_id)
|
| 220 |
+
elif job.kind == JobKind.AGGREGATE.value:
|
| 221 |
+
aggregate_products(db, current_job_id=job.id)
|
| 222 |
+
else:
|
| 223 |
+
raise ValueError(f"Unknown job kind {job.kind!r}")
|
| 224 |
+
|
| 225 |
+
job.finished_at = datetime.now(UTC)
|
| 226 |
+
db.commit()
|
| 227 |
+
return "done"
|
| 228 |
+
except Exception:
|
| 229 |
+
db.rollback()
|
| 230 |
+
logger.exception("Job %s failed; recording durable retry", job_id)
|
| 231 |
+
_record_failure(db, job_id)
|
| 232 |
+
return "retry_or_failed"
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@celery_app.task(name="vera.run_job", ignore_result=True)
|
| 236 |
+
def run_job(job_id: str) -> dict[str, Any]:
|
| 237 |
+
"""Celery wrapper around :func:`execute_job`."""
|
| 238 |
+
return {"job_id": job_id, "status": execute_job(job_id)}
|
backend/migrations/env.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Alembic environment configured from VERA's runtime settings."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from logging.config import fileConfig
|
| 6 |
+
|
| 7 |
+
from alembic import context
|
| 8 |
+
from sqlalchemy import engine_from_config, pool
|
| 9 |
+
|
| 10 |
+
import app.models # noqa: F401 # Register all ORM metadata before autogeneration.
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.database import Base
|
| 13 |
+
|
| 14 |
+
config = context.config
|
| 15 |
+
# ConfigParser treats "%" as interpolation; URL-encoded passwords contain it.
|
| 16 |
+
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL.replace("%", "%%"))
|
| 17 |
+
if config.config_file_name is not None:
|
| 18 |
+
fileConfig(config.config_file_name)
|
| 19 |
+
|
| 20 |
+
target_metadata = Base.metadata
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run_migrations_offline() -> None:
|
| 24 |
+
"""Run migrations without an Engine, e.g. to generate SQL."""
|
| 25 |
+
context.configure(
|
| 26 |
+
url=settings.DATABASE_URL,
|
| 27 |
+
target_metadata=target_metadata,
|
| 28 |
+
literal_binds=True,
|
| 29 |
+
dialect_opts={"paramstyle": "named"},
|
| 30 |
+
compare_type=True,
|
| 31 |
+
)
|
| 32 |
+
with context.begin_transaction():
|
| 33 |
+
context.run_migrations()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def run_migrations_online() -> None:
|
| 37 |
+
"""Run migrations against the configured database."""
|
| 38 |
+
connectable = engine_from_config(
|
| 39 |
+
config.get_section(config.config_ini_section, {}),
|
| 40 |
+
prefix="sqlalchemy.",
|
| 41 |
+
poolclass=pool.NullPool,
|
| 42 |
+
)
|
| 43 |
+
with connectable.connect() as connection:
|
| 44 |
+
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
| 45 |
+
with context.begin_transaction():
|
| 46 |
+
context.run_migrations()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
if context.is_offline_mode():
|
| 50 |
+
run_migrations_offline()
|
| 51 |
+
else:
|
| 52 |
+
run_migrations_online()
|
backend/migrations/script.py.mako
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""${message}
|
| 2 |
+
|
| 3 |
+
Revision ID: ${up_revision}
|
| 4 |
+
Revises: ${down_revision | comma,n}
|
| 5 |
+
Create Date: ${create_date}
|
| 6 |
+
|
| 7 |
+
"""
|
| 8 |
+
from alembic import op
|
| 9 |
+
import sqlalchemy as sa
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# revision identifiers, used by Alembic.
|
| 13 |
+
revision = ${repr(up_revision)}
|
| 14 |
+
down_revision = ${repr(down_revision)}
|
| 15 |
+
branch_labels = ${repr(branch_labels)}
|
| 16 |
+
depends_on = ${repr(depends_on)}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
${upgrades if upgrades else "pass"}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def downgrade() -> None:
|
| 24 |
+
${downgrades if downgrades else "pass"}
|