Spaces:
Sleeping
Sleeping
| """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]] | |