Spaces:
Sleeping
Sleeping
File size: 7,299 Bytes
78738de | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """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}"}
|