poolcoach / app /schemas.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
27.4 kB
"""Pydantic request/response cho API — khớp contract §4 design doc.
Toạ độ: mét, hệ pooltool (gốc góc bàn dưới-trái), y dọc theo chiều dài bàn.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, Field
# ------------------------------------------------------------------ chung
class Point(BaseModel):
x: float
y: float
# ------------------------------------------------------------- /api/table
class TablePocket(BaseModel):
x: float
y: float
r: float # bán kính lỗ (m)
name: str # "góc dưới-trái", "giữa-phải", ...
class TableInfo(BaseModel):
w: float # chiều rộng bàn (m)
l: float # chiều dài bàn (m)
ball_r: float # bán kính bi (m)
pockets: list[TablePocket]
# ------------------------------------------------------------ /api/health
class HealthOut(BaseModel):
status: Literal["ok", "warming_up", "error"]
jit_ready: bool
# `hybrid_available`/`workers` cũ gỡ 31/07/2026 cùng net + ProcessPool
# (BRIEF việc D) — V2 chạy serial, không model. FE chỉ đọc ba trường này.
error: Optional[str] = None
# Bàn giao 14 (04/08): CHỈ THÊM trường — 3 trường cũ FE đang đọc không
# đổi. `redis_ok`/`worker_alive` chỉ có nghĩa ở mode queue; inprocess để
# null ("không áp dụng" KHÁC False = "chết").
# 14/08 (việc B): thêm giá trị "local" — CV chạy bằng luồng nhúng trong
# chính tiến trình app (một container HF), engine vẫn in-process. Chỉ
# THÊM giá trị, hai giá trị cũ giữ nguyên nghĩa.
mode: Literal["inprocess", "queue", "local"] = "inprocess"
redis_ok: Optional[bool] = None
worker_alive: Optional[bool] = None
# Bàn giao 19: CHỈ THÊM trường (nếp bàn giao 14) — heartbeat CV worker
# (scan), độc lập worker_alive (engine). Cùng quy ước: inprocess → null
# "không áp dụng" KHÁC False = "chết"; Redis chết → False.
cv_worker_alive: Optional[bool] = None
# ------------------------------------------------- /api/table-qr (04/08)
class TableQrTable(BaseModel):
id: int
name: str
class TableQrOut(BaseModel):
"""Khách quét QR bàn → một guest session TTL 24h.
`expires_at` trả AWARE UTC (có +00:00) dù DB lưu naive-UTC — FE đọc ISO
string phải biết múi giờ, đừng bắt client đoán.
"""
session_id: str
table: TableQrTable
expires_at: datetime
# --------------------------------------------------------- /api/recommend
class RecommendRequest(BaseModel):
"""Request /api/recommend — từ 31/07/2026 chỉ còn shape `balls` (full
rack); shape v1 3 bi (cue/b1/b2) gỡ cùng oracle/hybrid (BRIEF việc D).
Trường `engine`/`topk` của request cũ KHÔNG khai ở đây nữa — pydantic
mặc định BỎ QUA field lạ trong JSON, nên client 30/07 còn gửi chúng vẫn
200 và nhận cú V2 (G5.5, phương án "bị bỏ qua có chủ đích").
KHÔNG dùng model_validator để bắt "thiếu balls": ValueError trong
validator bị pydantic gói thành detail dạng LIST, mà FE toast chỉ đọc
được string. Check ở endpoint bằng HTTPException(422, "...").
"""
balls: Optional[dict[str, Point]] = None # {"cue": ..., "1": ..., ...}
alternatives: int = Field(default=3, ge=0, le=10)
# 04/08 — các trường CHỈ ĐỂ GHI LOG (bảng recommendations), không đổi
# một byte nào của response. Client cũ không gửi → None, 200 y hệt.
# `scan_id` là text tự do (bảng scans thuộc BRIEF F2 sau, chưa FK);
# `edited` = người dùng có sửa tay thế bàn sau khi scan không.
scan_id: Optional[str] = None
edited: Optional[bool] = None
# `session_id` (bàn giao 12) — phiên bàn QR: hợp lệ thì row log mang
# `tenant_id` của quán; rác/hết hạn thì bỏ qua + warning, vẫn 200.
session_id: Optional[str] = None
class PocketOut(BaseModel):
index: int
name: str
class SearchInfo(BaseModel):
engine: str # luôn "zone" từ 31/07
n_sim: int # số ô đã sim trong search
n_pot: int # số cú đạt đủ 5 tiêu chí V2
elapsed_s: float
# ------------------------------------------- /api/recommend (full rack, v2)
class OutcomeFull(BaseModel):
potted: list[str] # id bi vào lỗ trong cú (không cue)
scratch: bool
q: float
ev: float
balls_final: dict[str, Optional[Point]] # None = bi đã vào lỗ
class ShotFullOut(BaseModel):
rank: int # 1 = khuyến nghị; 0 = cú fallback
pocket: PocketOut
phi: float
v0: float
side: float
vert: float
target: str # bi mục tiêu lúc đánh
next: Optional[str] = None # bi kế tiếp sau cú | None hết bàn
win: bool # bi 9 vào lỗ hợp lệ
foul: bool # luôn False (lọc (i) của V2)
outcome: OutcomeFull
describe: str
trajectories: dict[str, list[list[float]]]
# `rank_by` = khoá xếp hạng của cú — V2 luôn "roll". (`dt` của zone V1 gỡ
# 31/07 cùng raster/thang pen — BRIEF việc D, design §8.2 "dt xoá".)
rank_by: Optional[str] = None
# Zone V2 (31/07, design §8.2) — FE hiện HAI SỐ NÀY thay cho EV/Q:
# `roll_len` = quãng đường bi cái lăn sau va chạm (m, arc length thật);
# `d_land` = khoảng cách đáp tới bi kế (m; null ở cú cuối ván — "không đo
# được", đừng vẽ thành 0).
roll_len: Optional[float] = None
d_land: Optional[float] = None
class RecommendFullResponse(BaseModel):
target: str
shots: list[ShotFullOut] # [] nếu không có cú hợp lệ ăn bi
fallback: Optional[ShotFullOut] = None # cú "ít tệ nhất", chỉ khi shots rỗng
search: SearchInfo
message: Optional[str] = None
# ------------------------------------------- /api/drills + drill-attempts
# F3 MVP degraded (BRIEF 04/08/2026 bàn giao 11): drill đọc từ file JSON repo
# (app/drills.json — xem app/drills.py vì sao không phải bảng DB), lượt tập
# TỰ KHAI đạt/trượt ghi bảng drill_attempts best-effort như recommendations.
class DrillPot(BaseModel):
ball: str # id bi phải pot ("1"...)
# "any" hoặc index lỗ trong env_h._pockets (quy ước PocketOut.index)
pocket: Literal["any"] | int
class DrillZone(BaseModel):
"""Vùng đích bi cái — đúng hệ toạ độ bàn của engine (mét)."""
cx: float
cy: float
r: float
class DrillGoal(BaseModel):
pot: DrillPot
cue_zone: DrillZone
class DrillListItem(BaseModel):
id: str
title: str
tags: list[str]
reps: int
class DrillOut(DrillListItem):
layout: dict[str, Point] # {"cue": ..., "1": ...}
goal: DrillGoal
scoring: dict[str, str] # {"pass": "pot && cue_zone"}
class DrillsListOut(BaseModel):
drills: list[DrillListItem]
class DrillAttemptIn(BaseModel):
drill_id: str
result: Literal["pass", "fail"] # tự khai — chưa có CV chấm (F2 sau)
session_id: Optional[str] = None # guest session (QR bàn) nếu có
detail: Optional[dict] = None
class DrillAttemptOut(BaseModel):
"""`persisted` là sự thật về việc LƯU, không phải về việc TẬP: không DB
vẫn 200 + false — degraded vẫn tập được, chỉ mất lịch sử (§5.3)."""
persisted: bool
id: Optional[int] = None # id row khi persisted
class DrillAttemptItem(BaseModel):
id: int
drill_id: str
result: str
session_id: Optional[str] = None
detail: Optional[dict] = None
created_at: datetime
class DrillAttemptsListOut(BaseModel):
attempts: list[DrillAttemptItem]
# {drill_id: {"pass": n, "fail": n}} — dict trần vì "pass" là keyword
# Python, không đặt được làm tên field pydantic mà không kéo theo alias.
summary: dict[str, dict[str, int]]
# --------------------------------------------- /api/displays/pair (05/08)
# TV tại bàn (bàn giao 15). `session_id` là CHUỖI MỜ — chìa khớp giữa pair
# và /api/recommend, server không validate với DB (xem app/displays.py):
# phiên QR thật hay id cục bộ FE sinh khi Space không DB đều dùng được.
class DisplayPairIn(BaseModel):
code: str = Field(min_length=1) # mã 6 số trên màn hình TV
session_id: str = Field(min_length=1)
class DisplayPairOut(BaseModel):
paired: bool
# ------------------------------------------------ /api/scan (05/08, F2)
# Scan ảnh bàn → thế bi (bàn giao 18). Request là MULTIPART (ảnh + corners
# JSON string) nên không có model request ở đây — route tự đọc File/Form.
# `scan_id` là CHUỖI MỜ như session_id: uuid sinh tại route, không bảng
# scans, không FK (bảng chờ auth — BRIEF); FE gửi lại nó trong
# /api/recommend (`scan_id` + `edited`) chỉ để ghi log.
class ScanBall(BaseModel):
"""Một bi do CV tìm thấy — toạ độ bàn hệ pooltool (m), đã kẹp vào bàn.
`type` chỉ có cue/ball. Từ bàn giao 22 bi `type="ball"` mang thêm SỐ do
BallID hai tầng theo MÀU gán (poolcoach_cv/ballid.py): `number` int 1–9
hoặc null (màu không khớp số nào), `number_conf` ∈ [0,1], cờ `wb` (có
chuẩn hoá trắng theo bi cue không). CHỈ THÊM trường — worker cũ không
gửi thì response cũng KHÔNG mọc key (route serialize exclude_unset),
client cũ bỏ qua trường lạ; bi `cue` không mang cả ba trường. Số vẫn là
GỢI Ý — người chơi sửa chip/kéo-thả (van an toàn §5.2).
"""
x: float
y: float
type: Literal["cue", "ball"]
conf: float
number: Optional[int] = None
number_conf: Optional[float] = None
wb: Optional[bool] = None
class ScanOut(BaseModel):
scan_id: str
balls: list[ScanBall]
# ------------------------- kết quả analyze MỘT cú (11/08 BG24 → A2)
# Các model Analyze* mô tả JSON kết quả của MỘT cú — hợp đồng giữa cv_worker
# (ghi shot_XX.json) và FE trang chi tiết. Endpoint 1-cú /api/analyze (BG24)
# GỠ ở A2b — từ đó các model này là SCHEMA TÀI LIỆU của file JSON per cú
# (route /shots/{idx}/result trả file thô, AnalyzerShotItem nhúng
# AnalyzeSuspectOut); giữ để test khoá strictness + người đọc tra shape.
class AnalyzeCollisionOut(BaseModel):
"""Một va chạm suy ra từ gãy khúc 2 tầng (poolcoach_cv/broadcast.py):
`kind` ∈ {"kink", "speed_drop", "kink+speed_drop"}; trường độ mạnh chỉ
có ở tầng tương ứng. BG25 CHỈ THÊM `contact` — chạm vào GÌ, heuristic
vị trí (≤1.5R mép băng → cushion; ≤2.5R bi tĩnh trước cú → ball; còn
lại unknown). Tên `contact` vì `kind` đã là tầng tín hiệu — không đổi
trường cũ; kết quả BG24 lưu cũ không có trường này (exclude_none)."""
t_s: float
x_m: float
y_m: float
kind: str
dtheta_deg: Optional[float] = None
drop_frac: Optional[float] = None
contact: Optional[Literal["ball", "cushion", "unknown"]] = None
class AnalyzeTrackPoint(BaseModel):
t_s: float
x_m: float
y_m: float
class AnalyzeBallInit(BaseModel):
"""Bi tĩnh detect được ở frame ĐẦU clip — FE vẽ thế bàn tĩnh.
A2b CHỈ THÊM ``number``/``number_conf`` (optional): số bi 1–9 theo
BallID hai tầng màu (bàn giao 22) — worker gắn từ chính frame đầu +
bbox đã dùng (cùng nguồn với overlay.mp4, cv_worker.ballid_balls_init)
để bàn metric FE tô màu + số như overlay. Kết quả cũ/màu không nhận
ra → không có trường, FE vẽ bi xám như trước (không bịa số)."""
x_m: float
y_m: float
type: Literal["cue", "ball"]
conf: float
number: Optional[int] = None
number_conf: Optional[float] = None
class AnalyzeMetrics(BaseModel):
"""Số giải tích của MỘT cú. Toạ độ hệ bàn broadcast (table_w_m ×
table_l_m, mét) — KHÔNG phải bàn pooltool; FE tự chuẩn hoá khi vẽ lên
table view. Trường None = "không đo được" (đừng vẽ thành 0)."""
v0_mps: Optional[float] = None
phi_deg: Optional[float] = None
motion_start_s: Optional[float] = None
n_collisions: int
coverage: float
max_gap_s: float
n_frames: int
n_covered: int
n_dup_frames: int
duration_s: float
table_w_m: float
table_l_m: float
elapsed_s: Optional[float] = None # worker đo — thời gian xử lý thật
class AnalyzeSpinEvidence(BaseModel):
"""Một dòng chứng cứ spin baseline (BG25): `text` tiếng Việt FE nối
hiện thẳng; `deg`/`m` là số cho máy (BG26 so baseline không parse text).
"""
axis: Literal["vertical", "lateral"]
t_s: Optional[float] = None
deg: Optional[float] = None
m: Optional[float] = None
text: str
class AnalyzeShotnetOut(BaseModel):
"""Khối ShotNet (BG28) — model suy ngược (BG27 c3) chạy KÈM analytic,
hai phương pháp hiện song song (BRIEF 28 bối cảnh 1 — analytic là đối
chứng giải thích được, bất đồng giữa hai cột là tín hiệu cho người xem).
`v0_cue_mps` là **V0 GẬY** (thước label/net — HANDOFF 26b), KHÁC thước
`metrics.v0_mps` (tốc độ BI đo từ track) — hai tên trường khác nhau có
chủ đích, UI phải dán nhãn phân biệt. `spin_vert`/`spin_side` map từ
(a, b) theo đúng harness BG27 (side = dấu a, vert quantize B_STUN_MAX);
`confidence` xếp hạng từ identifiable head (spin chỉ lộ qua va chạm).
`n_target_slots` = số slot bi mục tiêu dựng được từ detections (chẩn
đoán khe input sim→real). Model train thuần mô phỏng (P2), chưa tinh
chỉnh trên video thật — caveat này FE BẮT BUỘC hiện.
BG29 CHỈ THÊM `confidence_raw` + `det_density` (optional — worker cũ
không gửi thì key không mọc). `confidence` giữ nguyên nghĩa **hạng tin
HIỂN THỊ**, nhưng từ BG29 nó đã đi qua van mật độ detection: bài học
BG28 là head identifiable báo "high" trên cả hai cú thật lệch nặng, nên
hạng hiển thị phải chịu thêm một van đo đại lượng NGOÀI model.
`confidence_raw` giữ nguyên hạng thô từ head — không số nào bị mất."""
model: str # tên run checkpoint đã load
v0_cue_mps: float
phi_deg: float
a: float
b: float
spin_vert: Literal["follow", "stun", "draw"]
spin_side: Literal["side-L", "side-R"]
identifiable_prob: float
confidence: Literal["low", "medium", "high"]
confidence_raw: Optional[Literal["low", "medium", "high"]] = None
det_density: Optional[float] = None # det không-cue/frame của clip
inference_ms: float
n_target_slots: int
class AnalyzeHeightComp(BaseModel):
"""Cờ quy ước toạ độ (BG31): track/collisions/balls_init của kết quả
này ĐÃ bù độ cao tâm bi qua homography mặt vải hay chưa. ``on=True``
kèm camera decompose của chính clip (``h_m`` độ cao trên vải, ``f_px``
tiêu cự ước); ``on=False`` = quy ước cũ (chưa bù), ``reason`` nói vì
sao fallback. Kết quả BG23–30 lưu cũ KHÔNG có khối này (exclude_none)
— vắng cờ nghĩa là chưa bù, không áp hồi tố.
BG32 CHỈ THÊM ``cx_m``/``cy_m`` (chân camera trên hệ bàn) — optional
cùng nếp ``h_m``/``f_px``: worker cũ không gửi thì key không mọc,
kết quả cũ không có trường vẫn qua schema (lùi-tương-thích)."""
on: bool
h_m: Optional[float] = None
f_px: Optional[float] = None
cx_m: Optional[float] = None
cy_m: Optional[float] = None
reason: Optional[str] = None
class AnalyzeSuspectOut(BaseModel):
"""Van "nghi không phải cú đánh" (lát A2 phần 1 — quyết định Cowork
13/08): cờ tầng HIỂN THỊ dựa hoàn toàn trên 3 tín hiệu cú TỰ KHAI đã
có (V0 analytic ngoài [1, 8] m/s / không tìm được motion_start /
coverage < 0.8 — cv_worker.not_shot_flag). ``flagged=True`` → FE
chuyển hàng sang dạng xám "nghi không phải cú đánh" kèm ``reasons``
tiếng Việt; cú KHÔNG bị xoá/lọc, vẫn click xem được. Kết quả trước
A2 không có khối này (exclude_none) — vắng cờ = chưa qua van."""
flagged: bool
reasons: list[str] = []
class AnalyzeResimParams(BaseModel):
"""Params THẬT đã nạp vào pooltool cho một bộ resim — v0_mps là V0
GẬY (bộ analytic đã quy đổi từ tốc độ bi qua tỉ lệ đo lúc warmup,
xem ``assumptions`` của bộ)."""
v0_mps: float
phi_deg: float
a: float
b: float
class AnalyzeResimSetOut(BaseModel):
"""MỘT bộ resim ("shotnet" | "analytic" — khoá trong ``sets``, KHÔNG
trộn): hoặc đủ params/rmse/series, hoặc ``error`` nói vì sao vắng.
``series`` = {ball_id: [[t_s, x_m, y_m], ...]} lấy mẫu 50 Hz, t_s CÙNG
trục thời gian với ``track`` — trang chi tiết (nét đứt) và nút "Đánh
lại cú" dùng chung, không tính hai lần. ``t_align_s`` = nấc dò mốc t=0
đã chọn (sim t=0 là lúc gậy chạm bi, motion_start trễ hơn ≤ ~2 frame
— khai để RMSE là khớp HÌNH quỹ đạo, không phải lệch pha lấy mẫu)."""
params: Optional[AnalyzeResimParams] = None
rmse_mm: Optional[float] = None
n_points: Optional[int] = None
t0_s: Optional[float] = None
t_align_s: Optional[float] = None
assumptions: Optional[str] = None
series: Optional[dict[str, list[list[float]]]] = None
error: Optional[str] = None
class AnalyzeResimOut(BaseModel):
"""Khối ``resim`` (lát A2 phần 2): sim lại cú bằng pooltool trên bàn
kích thước broadcast với params suy được, RMSE mm theo điểm track cue
ball. Kết quả trước A2 không có khối này (exclude_none)."""
table_w_m: float
table_l_m: float
ball_r_m: float
sets: dict[str, AnalyzeResimSetOut]
class AnalyzeOverlayOut(BaseModel):
"""Khối ``overlay`` (lát A2 phần 3): tên file overlay.mp4 per cú đã
render cạnh JSON (worker render TRONG job analyze, trước khi xoá
clip). URL phát/tải là ``overlay_url`` của hàng bảng."""
file: str
class AnalyzeResultOut(BaseModel):
"""`track` là toạ độ THÔ đã loại frame trùng — smooth CHỈ ĐỂ VẼ là việc
của FE (nhiễu dọc hướng chạy ±13mm, HANDOFF 23); `warnings` tiếng Việt
hiện thẳng cho người xem (thầy dân CV — số thật + cảnh báo rõ, BRIEF #2).
BG25 CHỈ THÊM `spin_*` — baseline spin giải tích thô (broadcast.py
`_read_spin`): `spin_class` là "+"-ghép {follow,draw,stun} × {side-L,
side-R}; null = không đọc được (spin chỉ lộ qua va chạm — cú không đủ
va chạm là unidentifiable VỀ NGUYÊN TẮC, evidence nói vì sao). Route
exclude_none nên kết quả BG24 lưu cũ không mọc key mới.
BG28 CHỈ THÊM `shotnet` (optional — worker cũ/thiếu checkpoint không
gửi thì key không mọc, client cũ bỏ trường lạ: lùi-tương-thích hai
chiều).
BG31 CHỈ THÊM `height_comp` (optional, cùng nếp): worker mới luôn gửi
(on hoặc off); kết quả cũ không có key — hiểu là toạ độ CHƯA bù.
A2 CHỈ THÊM `suspect_not_shot` (van hiển thị) + `resim` (RMSE + series
resim pooltool) — optional cùng nếp."""
metrics: AnalyzeMetrics
collisions: list[AnalyzeCollisionOut]
spin_class: Optional[str] = None
spin_confidence: Optional[Literal["low", "medium"]] = None
spin_evidence: Optional[list[AnalyzeSpinEvidence]] = None
shotnet: Optional[AnalyzeShotnetOut] = None
height_comp: Optional[AnalyzeHeightComp] = None
suspect_not_shot: Optional[AnalyzeSuspectOut] = None
resim: Optional[AnalyzeResimOut] = None
overlay: Optional[AnalyzeOverlayOut] = None
track: list[AnalyzeTrackPoint]
warnings: list[str]
balls_init: list[AnalyzeBallInit] = []
# ------------------------------------- /api/analyzer/* (13/08, lát A1)
# Analyzer đa cú: upload VIDEO (1..N cú) + 4 pocket pixel → job segment
# (cắt cú) → N job analyze per cú NGUYÊN TRẠNG → danh sách cú hiện dần.
# Nguồn sự thật là FILE trên đĩa (shots.json + JSON per cú — nếp BG31 sau
# vụ mất kết quả Redis TTL BG29b); Redis chỉ mang tiến độ.
class AnalyzerVideoCreateOut(BaseModel):
id: str
status: Literal["queued"] = "queued"
class AnalyzerDemoOut(BaseModel):
"""Video mẫu đã phân tích sẵn — `id` null nghĩa là bản này không đóng
gói mẫu nào (FE im lặng bỏ qua, không báo lỗi)."""
id: Optional[str] = None
class AnalyzerCornersOut(BaseModel):
"""Gợi ý 4 góc bàn cho frame đầu (BRIEF 14/08 việc A).
`ok=False` KHÔNG phải lỗi HTTP: nghĩa là "máy không dám đề xuất" (không
thấy vải, van sanity chặn, server không có cv2) — FE quay về luồng chấm
tay y như trước. Vì thế route luôn 200 và `reason` là câu tiếng Việt
hiện thẳng cho người dùng.
`corners` là pixel trên ẢNH GỬI LÊN, cùng thứ tự quy ước với
/api/analyzer/videos. `camera`/`rails` là số để hậu kiểm, FE không cần
hiểu — `rails.swapped` nói máy có phải xoay để đưa cặp băng ngắn lên
đầu hay không (heuristic, xem docstring poolcoach_cv.autocorner).
"""
ok: bool
corners: Optional[list[list[float]]] = None
reason: Optional[str] = None
camera: Optional[dict] = None
rails: Optional[dict] = None
class AnalyzerShotNetBrief(BaseModel):
"""Trích từ khối `shotnet` của JSON per cú — đủ cho một HÀNG bảng §3.1
(V0 gậy, hướng, điểm chạm a/b, hạng tin HIỂN THỊ đã qua van mật độ).
JSON đầy đủ đọc ở endpoint /result (click hàng — A1 ra JSON thô)."""
v0_cue_mps: float
phi_deg: float
a: float
b: float
spin_vert: str
spin_side: str
confidence: Literal["low", "medium", "high"]
confidence_raw: Optional[Literal["low", "medium", "high"]] = None
det_density: Optional[float] = None
class AnalyzerShotItem(BaseModel):
"""Một HÀNG của bảng danh sách cú. `status`:
- "queued"/"running": job analyze per cú chưa xong (progress khi có);
- "done": có JSON kết quả trên đĩa — kèm tóm tắt tham số;
- "error": cú KHÔNG phân tích được — `reason` tiếng Việt nói vì sao
(mất cảnh giữa cú, track đứt, clip hỏng...) — hàng xám vẫn NẰM TRONG
danh sách (scope §5 design: không nuốt im);
- "unknown": không còn dấu vết (không file, hết TTL Redis) — chỉ xảy
ra khi worker chết giữa chừng, nói thẳng thay vì đoán.
Mốc thời gian là giây TUYỆT ĐỐI trong video nạp vào. `rmse_mm` chừa
chỗ cho resim (lát A2) — A1 luôn null, FE hiện "—".
"""
idx: int
t_start_s: float
t_end_s: float
t_onset_s: Optional[float] = None
t_settle_s: Optional[float] = None
status: Literal["queued", "running", "done", "error", "unknown"]
progress: Optional[float] = None
stage: Optional[str] = None
reason: Optional[str] = None
v0_mps: Optional[float] = None # tốc độ BI đo (analytic)
phi_deg: Optional[float] = None # hướng đo (analytic)
spin_class: Optional[str] = None
spin_confidence: Optional[str] = None
shotnet: Optional[AnalyzerShotNetBrief] = None
n_collisions: Optional[int] = None
n_warnings: Optional[int] = None
rmse_mm: Optional[float] = None
rmse_set: Optional[str] = None # bộ params của rmse_mm ("shotnet" mặc
# định; fallback "analytic" DÁN NHÃN —
# BRIEF A2: ghi rõ bộ nào, đừng trộn)
suspect_not_shot: Optional[AnalyzeSuspectOut] = None # van A2 phần 1
thumb_url: Optional[str] = None
result_url: Optional[str] = None
overlay_url: Optional[str] = None # có khi overlay_XX.mp4 trên đĩa
class AnalyzerVideoInfo(BaseModel):
"""Trạng thái tầng SCAN (cắt cú) của video: queued/running từ Redis;
done khi manifest shots.json đã nằm trên đĩa (Redis hết TTL vẫn done —
file là nguồn sự thật); error kèm message khi segment thất bại."""
id: str
status: Literal["queued", "running", "done", "error"]
progress: Optional[float] = None
stage: Optional[str] = None
message: Optional[str] = None
n_shots: Optional[int] = None
duration_s: Optional[float] = None
warnings: Optional[list[str]] = None
class AnalyzerShotsOut(BaseModel):
video: AnalyzerVideoInfo
shots: list[AnalyzerShotItem]
n_done: int
n_total: int
# -------------------------------------------------------- /api/trajectory
# THÊM 30/07/2026 — render LƯỜI. `/api/recommend` chỉ còn dựng quỹ đạo cho cú
# rank 1 (`n_render=1`); ba cú thay thế trả đủ tham số nhưng `trajectories`
# rỗng. FE gọi endpoint này khi người dùng thật sự bấm vào một cú thay thế.
#
# KHÔNG giữ state phía server, cố ý: request mang đủ thế bàn + 4 tham số cú,
# nên hai request bất kỳ độc lập nhau và endpoint không cần biết cú này đến từ
# lần `/api/recommend` nào. Sim tất định ⇒ quỹ đạo trả về ĐÚNG BẰNG quỹ đạo mà
# `n_render = 1 + alternatives` sẽ trả cho cùng cú đó (gate G6.2).
class TrajectoryRequest(BaseModel):
"""Thế bàn TRƯỚC cú + tham số cú. `balls` cùng shape `RecommendRequest`."""
balls: dict[str, Point]
phi: float # độ, hệ pooltool
v0: float # m/s
side: float = 0.0 # a ∈ [-0.4, 0.4]
vert: float = 0.0 # b ∈ [-0.4, 0.4]
class TrajectoryResponse(BaseModel):
trajectories: dict[str, list[list[float]]]
# Trả kèm dù FE hiện không đọc: client không giữ response
# `/api/recommend` (CLI, script đo) vẫn dựng lại được thế bàn sau cú.
balls_final: dict[str, Optional[Point]]