prathvi9607 commited on
Commit
31e8a9f
·
verified ·
1 Parent(s): 8c303cf

Deploy VERA API (round 3 models)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +18 -7
  2. app.py +124 -0
  3. backend/alembic.ini +37 -0
  4. backend/app/__init__.py +3 -0
  5. backend/app/api/__init__.py +1 -0
  6. backend/app/api/deps.py +18 -0
  7. backend/app/api/logging.py +50 -0
  8. backend/app/api/middleware.py +192 -0
  9. backend/app/api/routes/__init__.py +1 -0
  10. backend/app/api/routes/health.py +58 -0
  11. backend/app/api/routes/products.py +80 -0
  12. backend/app/api/routes/reviews.py +115 -0
  13. backend/app/config.py +134 -0
  14. backend/app/database.py +60 -0
  15. backend/app/main.py +33 -0
  16. backend/app/ml/__init__.py +1 -0
  17. backend/app/ml/agents/__init__.py +10 -0
  18. backend/app/ml/agents/absa.py +135 -0
  19. backend/app/ml/agents/aspect_detector.py +116 -0
  20. backend/app/ml/agents/consistency.py +40 -0
  21. backend/app/ml/agents/domain_rating.py +67 -0
  22. backend/app/ml/agents/experience_extractor.py +79 -0
  23. backend/app/ml/agents/fusion.py +105 -0
  24. backend/app/ml/agents/preprocessor.py +168 -0
  25. backend/app/ml/agents/quality.py +131 -0
  26. backend/app/ml/calibration/__init__.py +1 -0
  27. backend/app/ml/calibration/conformal.py +80 -0
  28. backend/app/ml/calibration/empirical_bayes.py +276 -0
  29. backend/app/ml/constants.py +142 -0
  30. backend/app/ml/features.py +125 -0
  31. backend/app/ml/models/__init__.py +1 -0
  32. backend/app/ml/models/loader.py +128 -0
  33. backend/app/ml/models/onnx_session.py +63 -0
  34. backend/app/ml/models/tokenizer_wrapper.py +82 -0
  35. backend/app/ml/pipeline.py +218 -0
  36. backend/app/models/__init__.py +9 -0
  37. backend/app/models/calibration.py +24 -0
  38. backend/app/models/job.py +49 -0
  39. backend/app/models/label_run.py +28 -0
  40. backend/app/models/product.py +44 -0
  41. backend/app/models/review.py +87 -0
  42. backend/app/schemas/__init__.py +1 -0
  43. backend/app/schemas/product.py +39 -0
  44. backend/app/schemas/review.py +95 -0
  45. backend/app/worker/__init__.py +1 -0
  46. backend/app/worker/celery_app.py +71 -0
  47. backend/app/worker/outbox.py +93 -0
  48. backend/app/worker/tasks.py +238 -0
  49. backend/migrations/env.py +52 -0
  50. backend/migrations/script.py.mako +24 -0
README.md CHANGED
@@ -1,13 +1,24 @@
1
  ---
2
- title: Vera Api
3
- emoji: 🚀
4
- colorFrom: purple
5
- colorTo: purple
6
  sdk: gradio
7
- sdk_version: 6.27.0
8
- python_version: '3.12'
9
  app_file: app.py
 
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
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"}