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