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