Spaces:
Sleeping
Sleeping
| """Engine recommend dùng CHUNG cho in-process lẫn worker (bàn giao 14, 04/08). | |
| `run_recommend(payload) → dict` là hàm JSON-vào/JSON-ra: route `/api/recommend` | |
| (mode inprocess) gọi thẳng trong executor, còn `scripts/engine_worker.py` | |
| (mode queue) gọi qua `handle_job` — transport khác nhau, code path engine là | |
| MỘT (bẫy 27/07: hai cấu hình đo ở hai code path suýt bị đem so với nhau). | |
| Kết quả là dict JSON-thuần (``model_dump(mode="json")``) vì reply mode queue | |
| đi qua Redis dưới dạng JSON. Float Python round-trip qua json.dumps/loads là | |
| chính xác từng bit, nên byte-equality giữa hai mode không phụ thuộc chuyện | |
| kết quả có đi qua Redis hay không. | |
| Module này cố ý KHÔNG import `app.main` (worker không cần FastAPI app) và | |
| KHÔNG đụng DB — log recommendations là việc của phía API, worker không cầm | |
| `DATABASE_URL`. | |
| """ | |
| from __future__ import annotations | |
| from .schemas import (OutcomeFull, PocketOut, Point, RecommendFullResponse, | |
| SearchInfo, ShotFullOut) | |
| def _pt(a) -> Point | None: | |
| return None if a is None else Point(x=float(a[0]), y=float(a[1])) | |
| def _field(s, name): | |
| """Đọc trường OPTIONAL của ShotFull mà không nổ trên dict shape cũ. | |
| `_shot_full_out` nhận "ShotFull hoặc dict cùng shape" (đường CLI cũ), và | |
| dict đó không có `dt`/`rank_by`. `s["dt"]` sẽ ném KeyError trên dict và | |
| AttributeError trên ShotFull cũ đã pickle — cả hai đều là 500 cho một | |
| trường vốn được phép vắng mặt. | |
| """ | |
| if isinstance(s, dict): | |
| return s.get(name) | |
| return getattr(s, name, None) | |
| def _opt_float(v): | |
| return None if v is None else round(float(v), 4) | |
| def _shot_full_out(env_h, s) -> ShotFullOut: | |
| """ShotFull (hoặc dict cùng shape) → model response.""" | |
| from poolcoach_rl.recommend import describe_full, pocket_name | |
| bf = s["balls_final"] or {} | |
| return ShotFullOut( | |
| rank=s["rank"], | |
| pocket=PocketOut(index=s["pocket"], | |
| name=pocket_name(env_h, s["pocket"])), | |
| # KHÔNG làm tròn 4 tham số cú nữa (30/07) — chúng là ĐẦU VÀO của | |
| # `/api/trajectory`, không phải số để đọc. Bản cũ tròn 2/3/3/3 chữ số; | |
| # vô hại suốt vì chưa ai sim lại từ chúng. Render lười thì có: FE gửi | |
| # đúng bộ này đi sim lại, mà `v0` của lưới là 0.8888888888888888 → | |
| # 0.889 và `phi` lệch tới 0.005° là sim ở một ô KHÁC. Sai lệch nhỏ tới | |
| # đâu cũng không cứu được, vì cú dội băng khuếch đại nó — và animation | |
| # sẽ kết thúc ở một chỗ khác `balls_final` mà `applyFinal` áp lên bàn. | |
| # Cùng lý do `zone_v2.snap_to_grid` từ chối làm tròn cho qua. | |
| # Hiển thị vẫn tròn: FE `toFixed`, còn `describe` server tự format. | |
| phi=float(s["phi"]), | |
| v0=float(s["v0"]), | |
| side=float(s["side"]), | |
| vert=float(s["vert"]), | |
| target=s["target"], | |
| next=s["next"], | |
| win=bool(s["win"]), | |
| foul=bool(s["foul"]), | |
| outcome=OutcomeFull(potted=list(s["potted"]), | |
| scratch=bool(s["scratch"]), | |
| q=round(float(s["q"]), 4), | |
| ev=round(float(s["ev"]), 4), | |
| balls_final={bid: _pt(xy) | |
| for bid, xy in bf.items()}), | |
| describe=describe_full(env_h, s), | |
| trajectories=s["trajectories"] or {}, | |
| # `s` có thể là ShotFull HOẶC dict cùng shape (docstring hàm) — dict cũ | |
| # không có các khoá này, nên đọc bằng getattr/get chứ không `s[...]`. | |
| rank_by=_field(s, "rank_by"), | |
| roll_len=_opt_float(_field(s, "roll_len")), | |
| d_land=_opt_float(_field(s, "d_land")), | |
| ) | |
| def _full_response(env_h, res, shots) -> RecommendFullResponse: | |
| message = None | |
| if not shots: | |
| # "Hết đường" là THÔNG TIN cho người tập, không phải lỗi (BRIEF 31/07 | |
| # việc B.2 — Danh: "sẽ có những quả hết đường, đó là điều nằm trong dự | |
| # tính"). Không thang nới, không fallback, không đổ cho "bàn kẹt": | |
| # tiêu chí V2 cố ý chặt, và lộ trình sẽ thêm kỹ năng lấp đúng chỗ này. | |
| message = (f"Bàn này không có đường điều bi ăn bi {res.target} đạt đủ " | |
| f"5 tiêu chí (ăn trực tiếp, góc cắt, bi cái sạch đường, " | |
| f"khoảng đáp 0.15–1.5 m, lực vừa tầm). Kỹ năng cân bi / " | |
| f"kick / safety sẽ bổ sung sau.") | |
| return RecommendFullResponse( | |
| target=res.target, | |
| shots=shots, | |
| fallback=(_shot_full_out(env_h, res.fallback) | |
| if res.fallback is not None else None), | |
| search=SearchInfo(engine=res.engine, n_sim=res.n_results, | |
| n_pot=res.n_legal_pot, | |
| elapsed_s=round(res.elapsed_s, 3)), | |
| message=message) | |
| def run_recommend(payload: dict, env_h) -> dict: | |
| """MỘT lần search ZonePlanner V2 — hàm engine chung của cả hai mode. | |
| ``payload`` JSON-thuần: ``{"balls": {id: {"x", "y"}}, "alternatives": n}`` | |
| → dict JSON-thuần của ``RecommendFullResponse``. `n_render=1` — render | |
| lười giữ nguyên (BRIEF 30/07 việc C.2). | |
| ValueError của validate_full (ngoài bàn / chồng bi / id lạ) ném NGUYÊN | |
| VĂN — caller tự quyết: route in-process → 422, worker → gói vào reply | |
| "validation" (message giữ nguyên để 422 hai mode trùng từng byte). | |
| Import trong thân hàm như app.main cũ vẫn làm: test monkeypatch | |
| `rec_pkg.recommend_v2` phải thấy hiệu lực ở CẢ hai mode. | |
| """ | |
| import numpy as np | |
| from poolcoach_rl import recommend as rec_pkg | |
| balls = {bid: np.array([xy["x"], xy["y"]]) | |
| for bid, xy in payload["balls"].items()} | |
| res = rec_pkg.recommend_v2(balls, env_h=env_h, | |
| alternatives=payload["alternatives"], | |
| n_render=1) | |
| shots = [_shot_full_out(env_h, s) for s in res.shots] | |
| return _full_response(env_h, res, shots).model_dump(mode="json") | |
| def handle_job(payload: dict, env_h) -> dict: | |
| """Reply protocol của transport — dùng bởi worker THẬT lẫn worker giả | |
| trong test (một code path, không được trôi khác nhau). | |
| ``ok=True`` → ``result`` là dict response; ``ok=False`` → ``error`` là | |
| "validation" (phía API trả 422, message validate_full nguyên văn) hoặc | |
| "internal" (500). Worker KHÔNG được chết vì một job hỏng — mọi exception | |
| thành reply lỗi, vòng BRPOP sống tiếp. | |
| """ | |
| try: | |
| return {"ok": True, "result": run_recommend(payload, env_h)} | |
| except ValueError as e: | |
| return {"ok": False, "error": "validation", "message": str(e)} | |
| except Exception as e: # noqa: BLE001 — job hỏng ≠ worker chết | |
| return {"ok": False, "error": "internal", | |
| "message": f"{type(e).__name__}: {e}"} | |