poker-cli 0.11.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- poker_cli/__init__.py +3 -0
- poker_cli/builtin_ranges.py +132 -0
- poker_cli/cards.py +42 -0
- poker_cli/cli.py +1250 -0
- poker_cli/coach.py +233 -0
- poker_cli/controllers.py +539 -0
- poker_cli/engine.py +281 -0
- poker_cli/evaluator.py +67 -0
- poker_cli/history.py +198 -0
- poker_cli/models.py +129 -0
- poker_cli/ranges.py +418 -0
- poker_cli/review.py +70 -0
- poker_cli/savegame.py +235 -0
- poker_cli/settings.py +178 -0
- poker_cli/strategy.py +80 -0
- poker_cli/tournament.py +427 -0
- poker_cli/tui.py +1325 -0
- poker_cli/usage.py +276 -0
- poker_cli-0.11.2.dist-info/METADATA +201 -0
- poker_cli-0.11.2.dist-info/RECORD +24 -0
- poker_cli-0.11.2.dist-info/WHEEL +5 -0
- poker_cli-0.11.2.dist-info/entry_points.txt +2 -0
- poker_cli-0.11.2.dist-info/licenses/LICENSE +21 -0
- poker_cli-0.11.2.dist-info/top_level.txt +1 -0
poker_cli/coach.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
from .models import HandResult
|
|
11
|
+
from .usage import ModelUsageTracker, parse_claude_result, parse_codex_usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CoachError(RuntimeError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LocalCLICoach:
|
|
19
|
+
"""Run post-hand coaching through an already authenticated local agent CLI."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
provider: str,
|
|
24
|
+
*,
|
|
25
|
+
timeout: int = 180,
|
|
26
|
+
codex_model: str | None = None,
|
|
27
|
+
claude_model: str | None = None,
|
|
28
|
+
usage_tracker: ModelUsageTracker | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
if provider not in {"auto", "codex", "claude"}:
|
|
31
|
+
raise ValueError(f"unknown coach provider: {provider}")
|
|
32
|
+
self.provider = self._resolve_provider(provider)
|
|
33
|
+
self.timeout = timeout
|
|
34
|
+
self.codex_model = codex_model or os.getenv(
|
|
35
|
+
"POKER_CODEX_MODEL", "gpt-5.4-mini"
|
|
36
|
+
)
|
|
37
|
+
self.claude_model = claude_model or os.getenv(
|
|
38
|
+
"POKER_CLAUDE_MODEL", "haiku"
|
|
39
|
+
)
|
|
40
|
+
self.usage_tracker = usage_tracker
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def _resolve_provider(provider: str) -> str:
|
|
44
|
+
if provider != "auto":
|
|
45
|
+
if shutil.which(provider) is None:
|
|
46
|
+
raise CoachError(f"找不到本地 {provider} 命令")
|
|
47
|
+
return provider
|
|
48
|
+
if shutil.which("codex"):
|
|
49
|
+
return "codex"
|
|
50
|
+
if shutil.which("claude"):
|
|
51
|
+
return "claude"
|
|
52
|
+
raise CoachError("找不到本地 codex 或 claude 命令")
|
|
53
|
+
|
|
54
|
+
def review(self, result: HandResult) -> str:
|
|
55
|
+
prompt = build_coach_prompt(result)
|
|
56
|
+
if self.provider == "codex":
|
|
57
|
+
return self._review_with_codex(prompt)
|
|
58
|
+
return self._review_with_claude(prompt)
|
|
59
|
+
|
|
60
|
+
def review_tournament(self, results: list[HandResult], outcome: str) -> str:
|
|
61
|
+
prompt = build_tournament_coach_prompt(results, outcome)
|
|
62
|
+
if self.provider == "codex":
|
|
63
|
+
return self._review_with_codex(prompt)
|
|
64
|
+
return self._review_with_claude(prompt)
|
|
65
|
+
|
|
66
|
+
def _review_with_codex(self, prompt: str) -> str:
|
|
67
|
+
with tempfile.TemporaryDirectory(prefix="poker-coach-") as temp_dir:
|
|
68
|
+
output_path = Path(temp_dir) / "review.txt"
|
|
69
|
+
command = [
|
|
70
|
+
"codex",
|
|
71
|
+
"exec",
|
|
72
|
+
"--ephemeral",
|
|
73
|
+
"--ignore-user-config",
|
|
74
|
+
"--sandbox",
|
|
75
|
+
"read-only",
|
|
76
|
+
"--skip-git-repo-check",
|
|
77
|
+
"--color",
|
|
78
|
+
"never",
|
|
79
|
+
"--json",
|
|
80
|
+
"--output-last-message",
|
|
81
|
+
str(output_path),
|
|
82
|
+
]
|
|
83
|
+
if self.codex_model:
|
|
84
|
+
command.extend(("-m", self.codex_model))
|
|
85
|
+
command.extend(("-c", 'model_reasoning_effort="low"', "-"))
|
|
86
|
+
completed = self._run(command, prompt, cwd=temp_dir)
|
|
87
|
+
usage = parse_codex_usage(completed.stdout)
|
|
88
|
+
if usage is not None and self.usage_tracker is not None:
|
|
89
|
+
self.usage_tracker.record("codex", usage)
|
|
90
|
+
if not output_path.exists():
|
|
91
|
+
raise CoachError("Codex 没有生成复盘结果")
|
|
92
|
+
answer = output_path.read_text(encoding="utf-8").strip()
|
|
93
|
+
if not answer:
|
|
94
|
+
raise CoachError("Codex 返回了空复盘")
|
|
95
|
+
return answer
|
|
96
|
+
|
|
97
|
+
def _review_with_claude(self, prompt: str) -> str:
|
|
98
|
+
command = [
|
|
99
|
+
"claude",
|
|
100
|
+
"-p",
|
|
101
|
+
"--safe-mode",
|
|
102
|
+
"--tools",
|
|
103
|
+
"",
|
|
104
|
+
"--permission-mode",
|
|
105
|
+
"dontAsk",
|
|
106
|
+
"--no-session-persistence",
|
|
107
|
+
"--model",
|
|
108
|
+
self.claude_model,
|
|
109
|
+
"--effort",
|
|
110
|
+
"low",
|
|
111
|
+
"--system-prompt",
|
|
112
|
+
"Give concise, practical poker coaching. Do not use tools.",
|
|
113
|
+
"--output-format",
|
|
114
|
+
"json",
|
|
115
|
+
]
|
|
116
|
+
completed = self._run(command, prompt)
|
|
117
|
+
answer, usage = parse_claude_result(completed.stdout)
|
|
118
|
+
if usage is not None and self.usage_tracker is not None:
|
|
119
|
+
self.usage_tracker.record("claude", usage)
|
|
120
|
+
if not answer:
|
|
121
|
+
raise CoachError("Claude Code 返回了空复盘")
|
|
122
|
+
return answer
|
|
123
|
+
|
|
124
|
+
def _run(
|
|
125
|
+
self,
|
|
126
|
+
command: list[str],
|
|
127
|
+
prompt: str,
|
|
128
|
+
*,
|
|
129
|
+
cwd: str | None = None,
|
|
130
|
+
) -> subprocess.CompletedProcess[str]:
|
|
131
|
+
try:
|
|
132
|
+
completed = subprocess.run(
|
|
133
|
+
command,
|
|
134
|
+
input=prompt,
|
|
135
|
+
text=True,
|
|
136
|
+
capture_output=True,
|
|
137
|
+
timeout=self.timeout,
|
|
138
|
+
cwd=cwd,
|
|
139
|
+
check=False,
|
|
140
|
+
)
|
|
141
|
+
except subprocess.TimeoutExpired as error:
|
|
142
|
+
raise CoachError(f"{self.provider} 复盘超时") from error
|
|
143
|
+
except OSError as error:
|
|
144
|
+
raise CoachError(f"无法启动 {self.provider}: {error}") from error
|
|
145
|
+
if completed.returncode != 0:
|
|
146
|
+
detail = completed.stderr.strip().splitlines()
|
|
147
|
+
suffix = f":{detail[-1]}" if detail else ""
|
|
148
|
+
raise CoachError(f"{self.provider} 复盘失败{suffix}")
|
|
149
|
+
return completed
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_coach_prompt(result: HandResult) -> str:
|
|
153
|
+
decisions = [
|
|
154
|
+
{
|
|
155
|
+
"street": decision.street.value,
|
|
156
|
+
"hero_hole_cards": list(decision.hole_cards),
|
|
157
|
+
"community_cards": list(decision.community_cards),
|
|
158
|
+
"pot_before_action": decision.pot_before,
|
|
159
|
+
"amount_to_call": decision.to_call,
|
|
160
|
+
"hero_action": decision.action.kind.value,
|
|
161
|
+
"hero_raise_to": decision.action.amount or None,
|
|
162
|
+
"estimated_equity_vs_random_range": round(decision.equity, 4),
|
|
163
|
+
"pot_odds": round(decision.pot_odds, 4),
|
|
164
|
+
"local_baseline": decision.recommendation,
|
|
165
|
+
}
|
|
166
|
+
for decision in result.hero_decisions
|
|
167
|
+
]
|
|
168
|
+
packet = {
|
|
169
|
+
"hand_id": result.hand_id,
|
|
170
|
+
"board": [card.code() for card in result.board],
|
|
171
|
+
"result": result.summary,
|
|
172
|
+
"showdown": result.showdown,
|
|
173
|
+
"public_action_history": result.history,
|
|
174
|
+
"hero_decisions": decisions,
|
|
175
|
+
}
|
|
176
|
+
return (
|
|
177
|
+
"你是一名严谨的德州扑克教练。请根据下面这一手牌为玩家做中文复盘。\n"
|
|
178
|
+
"要求:\n"
|
|
179
|
+
"1. 先给一句结论,再按关键决策点分析;总长度控制在 500 字以内。\n"
|
|
180
|
+
"2. 区分底池赔率、范围推断、位置、下注尺度与结果导向,不能因为最后输赢倒推行动好坏。\n"
|
|
181
|
+
"3. 数据里的胜率是假设对手为随机范围的本地蒙特卡洛估计,只能作为参考。\n"
|
|
182
|
+
"4. 没有提供完整范围和求解器结果时,不得声称某个动作是精确 GTO;请说明建议的置信度。\n"
|
|
183
|
+
"5. 最后给一条下次可以直接执行的训练建议。\n"
|
|
184
|
+
"6. 不要调用工具、不要读取本地文件,只分析输入内容。\n\n"
|
|
185
|
+
f"牌局数据:\n{json.dumps(packet, ensure_ascii=False, indent=2)}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_tournament_coach_prompt(results: list[HandResult], outcome: str) -> str:
|
|
190
|
+
decisions = [
|
|
191
|
+
{
|
|
192
|
+
"hand_id": result.hand_id,
|
|
193
|
+
"street": decision.street.value,
|
|
194
|
+
"hero_hole_cards": list(decision.hole_cards),
|
|
195
|
+
"community_cards": list(decision.community_cards),
|
|
196
|
+
"pot_before_action": decision.pot_before,
|
|
197
|
+
"amount_to_call": decision.to_call,
|
|
198
|
+
"hero_action": decision.action.kind.value,
|
|
199
|
+
"hero_raise_to": decision.action.amount or None,
|
|
200
|
+
"estimated_equity_vs_random_ranges": round(decision.equity, 4),
|
|
201
|
+
"pot_odds": round(decision.pot_odds, 4),
|
|
202
|
+
"local_baseline": decision.recommendation,
|
|
203
|
+
}
|
|
204
|
+
for result in results
|
|
205
|
+
for decision in result.hero_decisions
|
|
206
|
+
]
|
|
207
|
+
deviations = [
|
|
208
|
+
decision
|
|
209
|
+
for decision in decisions
|
|
210
|
+
if decision["hero_action"] != decision["local_baseline"]
|
|
211
|
+
]
|
|
212
|
+
key_decisions = sorted(
|
|
213
|
+
deviations or decisions,
|
|
214
|
+
key=lambda item: item["pot_before_action"],
|
|
215
|
+
reverse=True,
|
|
216
|
+
)[:20]
|
|
217
|
+
packet = {
|
|
218
|
+
"outcome": outcome,
|
|
219
|
+
"hands_played": len(results),
|
|
220
|
+
"decision_count": len(decisions),
|
|
221
|
+
"key_decisions": key_decisions,
|
|
222
|
+
}
|
|
223
|
+
return (
|
|
224
|
+
"你是一名严谨的德州扑克锦标赛教练。请对玩家的整届单桌锦标赛做中文复盘。\n"
|
|
225
|
+
"要求:\n"
|
|
226
|
+
"1. 先总结三个贯穿整届比赛的倾向,再分析最多五个关键决策。\n"
|
|
227
|
+
"2. 讨论位置、有效筹码、底池赔率、范围与锦标赛阶段;避免结果导向。\n"
|
|
228
|
+
"3. 本地胜率基于随机多人范围,不是精确范围或 GTO/ICM 求解结果。\n"
|
|
229
|
+
"4. 没有充分证据时明确降低置信度,不要虚构对手手牌。\n"
|
|
230
|
+
"5. 最后给出三项下一届比赛可直接执行的训练目标;总长度 1000 字以内。\n"
|
|
231
|
+
"6. 不调用工具、不读取文件,只分析输入数据。\n\n"
|
|
232
|
+
f"锦标赛数据:\n{json.dumps(packet, ensure_ascii=False, indent=2)}"
|
|
233
|
+
)
|