"""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]]