poolcoach / app /engine.py
masterdanh's picture
deploy: snapshot for HF Space
78738de
Raw
History Blame Contribute Delete
7.3 kB
"""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}"}