gutcheck 0.1.0__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.
- gutcheck/__init__.py +13 -0
- gutcheck/archetypes.py +168 -0
- gutcheck/coach.py +302 -0
- gutcheck/coach_daemon.py +1230 -0
- gutcheck/coach_watch.py +440 -0
- gutcheck/doctrine.md +195 -0
- gutcheck/doctrine.py +179 -0
- gutcheck/engine.py +241 -0
- gutcheck/equity.py +278 -0
- gutcheck/game.py +1569 -0
- gutcheck/history.py +222 -0
- gutcheck/keystore.py +165 -0
- gutcheck/leakstore.py +512 -0
- gutcheck/mcp_server.py +705 -0
- gutcheck/paths.py +256 -0
- gutcheck/ranges.py +148 -0
- gutcheck/seam.py +239 -0
- gutcheck/server.py +900 -0
- gutcheck/settings.py +244 -0
- gutcheck/static/index.html +1524 -0
- gutcheck-0.1.0.dist-info/METADATA +168 -0
- gutcheck-0.1.0.dist-info/RECORD +26 -0
- gutcheck-0.1.0.dist-info/WHEEL +5 -0
- gutcheck-0.1.0.dist-info/entry_points.txt +3 -0
- gutcheck-0.1.0.dist-info/licenses/LICENSE +202 -0
- gutcheck-0.1.0.dist-info/top_level.txt +1 -0
gutcheck/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""gutcheck — a felt-sense poker reading-trainer.
|
|
2
|
+
|
|
3
|
+
The importable package: the game engine, the local web table server, the MCP coach
|
|
4
|
+
proxy, and the operator-owned graduating-leak store. Submodules are imported lazily by
|
|
5
|
+
their consumers (``from gutcheck import server`` / ``from gutcheck.seam import Seam``);
|
|
6
|
+
this ``__init__`` deliberately imports nothing so a plain ``import gutcheck`` has no
|
|
7
|
+
side effects and can't trip the server→coach_daemon→… chain at package-load time.
|
|
8
|
+
|
|
9
|
+
All runtime state lives OUTSIDE the package, under ``~/.gutcheck`` (override with
|
|
10
|
+
``GUTCHECK_DATA_DIR``) — see ``gutcheck.paths`` — so a read-only wheel install is safe.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
gutcheck/archetypes.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Villain archetypes: legible, tunable, exploitable opponents.
|
|
2
|
+
|
|
3
|
+
Three v0 player types, each a distinct exploit lesson:
|
|
4
|
+
- rock "The Nit" : ultra-tight, value-only, never bluffs, folds to heat.
|
|
5
|
+
Exploit: steal relentlessly; fold when it wakes up.
|
|
6
|
+
- reg "The TAG" : tight-aggressive, c-bets, value-bets, occasional bluff.
|
|
7
|
+
Exploit: the toughest; play straightforward, pick spots.
|
|
8
|
+
- station "The Calling Station": loose-passive, calls too much, rarely folds/raises.
|
|
9
|
+
Exploit: value-bet thin and relentlessly; never bluff it.
|
|
10
|
+
|
|
11
|
+
Decisions key off a hand-strength read + position + a seeded RNG. Made-hand
|
|
12
|
+
strength only in v0 (draws are narrated by the coach layer, not played by bots).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import pokerkit as pk
|
|
18
|
+
|
|
19
|
+
RANK_ORDER = "23456789TJQKA"
|
|
20
|
+
LABEL_TIER = {
|
|
21
|
+
"High card": 0, "One pair": 1, "Two pair": 2, "Three of a kind": 3,
|
|
22
|
+
"Straight": 4, "Flush": 5, "Full house": 6, "Four of a kind": 7,
|
|
23
|
+
"Straight flush": 8,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rv(card: str) -> int:
|
|
28
|
+
return RANK_ORDER.index(card[0])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def preflop_bucket(hole: list[str]) -> str:
|
|
32
|
+
"""premium / strong / playable / trash."""
|
|
33
|
+
r1, r2 = hole[0][0], hole[1][0]
|
|
34
|
+
s1, s2 = hole[0][1], hole[1][1]
|
|
35
|
+
v1, v2 = _rv(hole[0]), _rv(hole[1])
|
|
36
|
+
hi, lo = max(v1, v2), min(v1, v2)
|
|
37
|
+
pair = r1 == r2
|
|
38
|
+
suited = s1 == s2
|
|
39
|
+
gap = hi - lo
|
|
40
|
+
T, A_, K, Q, J = (RANK_ORDER.index(x) for x in "TAKQJ")
|
|
41
|
+
|
|
42
|
+
if pair and hi >= T: # TT+
|
|
43
|
+
return "premium"
|
|
44
|
+
if hi == A_ and lo >= K: # AK
|
|
45
|
+
return "premium"
|
|
46
|
+
if hi == A_ and lo >= Q: # AQ
|
|
47
|
+
return "strong"
|
|
48
|
+
if pair: # 22-99
|
|
49
|
+
return "strong" if hi >= RANK_ORDER.index("7") else "playable"
|
|
50
|
+
if hi == A_: # any other ace
|
|
51
|
+
return "playable" if (suited or lo >= T) else "trash"
|
|
52
|
+
if hi >= K and lo >= T: # KT+/KJ/KQ
|
|
53
|
+
return "strong" if suited else "playable"
|
|
54
|
+
if suited and gap <= 2 and lo >= RANK_ORDER.index("4"): # suited connectors
|
|
55
|
+
return "playable"
|
|
56
|
+
if hi >= J and lo >= RANK_ORDER.index("9"): # J9+/JT
|
|
57
|
+
return "playable"
|
|
58
|
+
return "trash"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def postflop_strength(hole: list[str], board: list[str]) -> str:
|
|
62
|
+
"""air / weak / medium / strong / monster (made-hand only)."""
|
|
63
|
+
h = pk.StandardHighHand.from_game("".join(hole), "".join(board))
|
|
64
|
+
tier = LABEL_TIER[h.entry.label.value]
|
|
65
|
+
if tier == 0:
|
|
66
|
+
return "air"
|
|
67
|
+
if tier == 1: # one pair — refine
|
|
68
|
+
board_ranks = sorted((_rv(c) for c in board), reverse=True)
|
|
69
|
+
hole_ranks = [_rv(c) for c in hole]
|
|
70
|
+
top_board = board_ranks[0]
|
|
71
|
+
if hole[0][0] == hole[1][0] and hole_ranks[0] > top_board:
|
|
72
|
+
return "strong" # overpair
|
|
73
|
+
if top_board in hole_ranks:
|
|
74
|
+
return "medium" # top pair
|
|
75
|
+
return "weak" # middle/bottom/board pair
|
|
76
|
+
if tier == 2:
|
|
77
|
+
return "strong" # two pair
|
|
78
|
+
return "monster" # set+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
STRENGTH_RANK = {"air": 0, "weak": 1, "medium": 2, "strong": 3, "monster": 4,
|
|
82
|
+
"trash": 0, "playable": 1, "premium": 4}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _clamp_raise(ctx: dict, target: int) -> str:
|
|
86
|
+
lo, hi = ctx["legal"]["min_raise_to"], ctx["legal"]["max_raise_to"]
|
|
87
|
+
if lo is None:
|
|
88
|
+
return "c"
|
|
89
|
+
target = max(lo, min(hi, int(target)))
|
|
90
|
+
return f"r{target}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def decide(archetype: str, ctx: dict) -> str:
|
|
94
|
+
"""Return an action token: 'f' | 'c' | 'rN'.
|
|
95
|
+
|
|
96
|
+
ctx keys: hole, board, legal, pot, bb, street_index, rng, facing_bet,
|
|
97
|
+
current_level (the bet amount to call up to this street).
|
|
98
|
+
"""
|
|
99
|
+
legal = ctx["legal"]
|
|
100
|
+
rng = ctx["rng"]
|
|
101
|
+
facing = legal["call_amount"] > 0
|
|
102
|
+
can_raise = legal["raise"]
|
|
103
|
+
pot = ctx["pot"]
|
|
104
|
+
bb = ctx["bb"]
|
|
105
|
+
|
|
106
|
+
if ctx["street_index"] == 0:
|
|
107
|
+
strength = STRENGTH_RANK[preflop_bucket(ctx["hole"])]
|
|
108
|
+
open_to = 3 * bb
|
|
109
|
+
else:
|
|
110
|
+
strength = STRENGTH_RANK[postflop_strength(ctx["hole"], ctx["board"])]
|
|
111
|
+
open_to = ctx["current_level"] + max(bb, int(0.66 * pot))
|
|
112
|
+
|
|
113
|
+
# ---- archetype policies ----
|
|
114
|
+
if archetype == "rock":
|
|
115
|
+
# plays only strong+; raises premium/monster for value; never bluffs.
|
|
116
|
+
if not facing:
|
|
117
|
+
if can_raise and strength >= 4:
|
|
118
|
+
return _clamp_raise(ctx, open_to)
|
|
119
|
+
if strength >= 3:
|
|
120
|
+
return "c" # slow with strong but not nut
|
|
121
|
+
return "c" if legal["call_amount"] == 0 else "f"
|
|
122
|
+
# facing a bet
|
|
123
|
+
if strength >= 4 and can_raise and rng.random() < 0.6:
|
|
124
|
+
return _clamp_raise(ctx, open_to)
|
|
125
|
+
if strength >= 3:
|
|
126
|
+
return "c"
|
|
127
|
+
return "f"
|
|
128
|
+
|
|
129
|
+
if archetype == "reg":
|
|
130
|
+
# TAG: opens strong, c-bets, value-raises, bluffs ~12%.
|
|
131
|
+
bluff = rng.random() < 0.12
|
|
132
|
+
if not facing:
|
|
133
|
+
if can_raise and (strength >= 3 or (strength >= 2 and rng.random() < 0.5) or bluff):
|
|
134
|
+
return _clamp_raise(ctx, open_to)
|
|
135
|
+
if strength >= 1:
|
|
136
|
+
return "c"
|
|
137
|
+
return "c" if legal["call_amount"] == 0 else "f"
|
|
138
|
+
if strength >= 3 and can_raise and rng.random() < 0.55:
|
|
139
|
+
return _clamp_raise(ctx, open_to)
|
|
140
|
+
if strength >= 2:
|
|
141
|
+
return "c"
|
|
142
|
+
if strength >= 1 and ctx["street_index"] == 0: # call one bet w/ playable preflop
|
|
143
|
+
return "c"
|
|
144
|
+
if bluff and can_raise and rng.random() < 0.4:
|
|
145
|
+
return _clamp_raise(ctx, open_to)
|
|
146
|
+
return "f"
|
|
147
|
+
|
|
148
|
+
if archetype == "station":
|
|
149
|
+
# loose-passive: calls almost anything, rarely raises, almost never folds.
|
|
150
|
+
if not facing:
|
|
151
|
+
if can_raise and strength >= 4 and rng.random() < 0.4:
|
|
152
|
+
return _clamp_raise(ctx, open_to)
|
|
153
|
+
return "c"
|
|
154
|
+
if strength >= 4 and can_raise and rng.random() < 0.3:
|
|
155
|
+
return _clamp_raise(ctx, open_to)
|
|
156
|
+
# calls with anything but the rawest air, and even some air
|
|
157
|
+
if strength >= 1 or rng.random() < 0.45:
|
|
158
|
+
return "c"
|
|
159
|
+
return "f"
|
|
160
|
+
|
|
161
|
+
raise ValueError(f"unknown archetype: {archetype!r}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
ARCHETYPES = {
|
|
165
|
+
"rock": "The Nit",
|
|
166
|
+
"reg": "The TAG",
|
|
167
|
+
"station": "The Calling Station",
|
|
168
|
+
}
|
gutcheck/coach.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""gutcheck coach channel — a tiny local SQLite request/response bus.
|
|
2
|
+
|
|
3
|
+
Decouples the game app (server.py, which WRITES coaching requests) from the coach
|
|
4
|
+
worker (a warm CC session via coach_watch.py, or a headless daemon next slice, which
|
|
5
|
+
READS pending requests and WRITES prose responses). A plain inbox pattern, purely
|
|
6
|
+
local and zero-dep (stdlib sqlite3 only). WAL mode so the
|
|
7
|
+
single-threaded game server and a separate worker process read/write concurrently
|
|
8
|
+
without locking each other out.
|
|
9
|
+
|
|
10
|
+
The channel carries NO money and NO game authority — only a coaching request (a
|
|
11
|
+
frozen snapshot of one spot) and the coach's prose reply. So it deliberately has none
|
|
12
|
+
of career.json's durability/idempotency machinery: a lost request is a non-event (just
|
|
13
|
+
ask again), so there is nothing here a torn write can corrupt.
|
|
14
|
+
|
|
15
|
+
Importable API (server + worker) and a CLI (the worker / debugging):
|
|
16
|
+
|
|
17
|
+
python -m gutcheck.coach ask --kind decision --context-file spot.json -> prints the new id
|
|
18
|
+
python -m gutcheck.coach pending -> JSON list of unanswered
|
|
19
|
+
python -m gutcheck.coach get --id 7 -> JSON of one request
|
|
20
|
+
python -m gutcheck.coach respond --id 7 --body-file reply.txt -> mark answered
|
|
21
|
+
python -m gutcheck.coach poll --id 7 -> {"status":..,"response":..}
|
|
22
|
+
python -m gutcheck.coach clean --older-than 24 -> prune old rows
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import contextlib
|
|
29
|
+
import json
|
|
30
|
+
import sqlite3
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from . import paths
|
|
36
|
+
|
|
37
|
+
# The coach channel db lives in the per-user data dir (paths.py / GUTCHECK_DATA_DIR),
|
|
38
|
+
# resolved lazily in _conn() so a test/smoke can redirect it off the real channel.
|
|
39
|
+
|
|
40
|
+
KINDS = ("decision", "review")
|
|
41
|
+
# A coaching request that no worker ever answered is junk after this long — a stale
|
|
42
|
+
# "flow is thinking…" the player long since clicked past. Pruned by clean().
|
|
43
|
+
_PENDING_TTL_H = 6
|
|
44
|
+
# Bound a runaway worker reply: the writer is trusted (your own CC session / a launchd
|
|
45
|
+
# daemon), but a giant response would bloat the row and ship whole to the browser on poll.
|
|
46
|
+
_MAX_RESPONSE = 16 * 1024
|
|
47
|
+
# Paths whose schema this process has already ensured — so the DDL stays OFF the hot
|
|
48
|
+
# poll path (poll fires every ~1.2s). WAL is a persistent DB property; the table is
|
|
49
|
+
# IF NOT EXISTS — both only need running once per path per process.
|
|
50
|
+
_INITIALIZED: set[str] = set()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _conn(path: Path | None = None) -> sqlite3.Connection:
|
|
54
|
+
if path is None:
|
|
55
|
+
path = paths.coach_db()
|
|
56
|
+
# Self-heal the data dir: the coach CLI and the launchd daemon can run BEFORE the server ever
|
|
57
|
+
# calls ensure_data_dir(), and sqlite.connect() on a missing parent raises "unable to open
|
|
58
|
+
# database file". Ensuring it here covers every coach entry point at the one chokepoint.
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
c = sqlite3.connect(str(path), timeout=5.0)
|
|
61
|
+
c.row_factory = sqlite3.Row
|
|
62
|
+
c.execute("PRAGMA busy_timeout=5000") # wait out a concurrent writer rather than erroring
|
|
63
|
+
# CREATE TABLE runs on EVERY connection (cheap — a no-op when the table exists) so the channel
|
|
64
|
+
# SELF-HEALS after reset_data() deletes coach.db, ACROSS PROCESSES too: every coach op opens a
|
|
65
|
+
# fresh per-call connection (no long-lived fd to a stale inode), so the L3-feared daemon
|
|
66
|
+
# split-brain can't persist. The _INITIALIZED gate below covers only the once-per-process work.
|
|
67
|
+
c.execute(
|
|
68
|
+
"""CREATE TABLE IF NOT EXISTS requests (
|
|
69
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
70
|
+
created REAL NOT NULL,
|
|
71
|
+
kind TEXT NOT NULL,
|
|
72
|
+
context TEXT NOT NULL, -- JSON: the frozen spot
|
|
73
|
+
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'answered'
|
|
74
|
+
response TEXT, -- the coach's prose
|
|
75
|
+
meta TEXT, -- JSON: opaque worker-computed blob
|
|
76
|
+
answered REAL -- (the modeled-equity, for the drill reveal)
|
|
77
|
+
)"""
|
|
78
|
+
)
|
|
79
|
+
key = str(path)
|
|
80
|
+
if key not in _INITIALIZED:
|
|
81
|
+
c.execute("PRAGMA journal_mode=WAL") # persistent db property; set once per process per path
|
|
82
|
+
# An older coach.db predates `meta` — add it (cheap, one-time). Guarded by a column probe
|
|
83
|
+
# because ADD COLUMN errors if it already exists. The probe→ALTER is NOT atomic across
|
|
84
|
+
# PROCESSES, so a concurrent racer can add the column between our probe and our ALTER —
|
|
85
|
+
# swallow ONLY that duplicate-column error; a real lock/IO/read-only failure must propagate
|
|
86
|
+
# (swallowing it would mark this path _INITIALIZED with no meta column, breaking set_meta).
|
|
87
|
+
if "meta" not in {r[1] for r in c.execute("PRAGMA table_info(requests)")}:
|
|
88
|
+
try:
|
|
89
|
+
c.execute("ALTER TABLE requests ADD COLUMN meta TEXT")
|
|
90
|
+
except sqlite3.OperationalError as e:
|
|
91
|
+
if "duplicate column" not in str(e).lower():
|
|
92
|
+
raise
|
|
93
|
+
_INITIALIZED.add(key)
|
|
94
|
+
c.commit()
|
|
95
|
+
return c
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _row_to_dict(r: sqlite3.Row) -> dict:
|
|
99
|
+
d = dict(r)
|
|
100
|
+
try:
|
|
101
|
+
d["context"] = json.loads(d["context"])
|
|
102
|
+
except (json.JSONDecodeError, TypeError):
|
|
103
|
+
d["context"] = {}
|
|
104
|
+
return d
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def ask(kind: str, context: dict, path: Path | None = None) -> int:
|
|
108
|
+
"""Write a pending coaching request. Returns its id (the poll handle)."""
|
|
109
|
+
if kind not in KINDS:
|
|
110
|
+
raise ValueError(f"unknown coach request kind: {kind!r} (expected {KINDS})")
|
|
111
|
+
# closing() closes the connection; the inner `c` is the transaction (commit/rollback).
|
|
112
|
+
with contextlib.closing(_conn(path)) as c, c:
|
|
113
|
+
cur = c.execute(
|
|
114
|
+
"INSERT INTO requests (created, kind, context, status) VALUES (?,?,?,'pending')",
|
|
115
|
+
(time.time(), kind, json.dumps(context)),
|
|
116
|
+
)
|
|
117
|
+
return int(cur.lastrowid)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def pending(path: Path | None = None) -> list[dict]:
|
|
121
|
+
"""All unanswered requests, oldest first (the worker's work queue)."""
|
|
122
|
+
with contextlib.closing(_conn(path)) as c:
|
|
123
|
+
rows = c.execute(
|
|
124
|
+
"SELECT * FROM requests WHERE status='pending' ORDER BY id ASC"
|
|
125
|
+
).fetchall()
|
|
126
|
+
return [_row_to_dict(r) for r in rows]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get(req_id: int, path: Path | None = None) -> dict | None:
|
|
130
|
+
with contextlib.closing(_conn(path)) as c:
|
|
131
|
+
r = c.execute("SELECT * FROM requests WHERE id=?", (req_id,)).fetchone()
|
|
132
|
+
return _row_to_dict(r) if r else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def respond(req_id: int, body: str, path: Path | None = None) -> bool:
|
|
136
|
+
"""Attach the coach's reply and mark answered. Returns False if the id is unknown
|
|
137
|
+
or already answered (so a double-respond — two workers, a retry — is a no-op, never
|
|
138
|
+
an overwrite of a delivered answer)."""
|
|
139
|
+
body = (body or "")[:_MAX_RESPONSE]
|
|
140
|
+
with contextlib.closing(_conn(path)) as c, c:
|
|
141
|
+
cur = c.execute(
|
|
142
|
+
"UPDATE requests SET response=?, status='answered', answered=? "
|
|
143
|
+
"WHERE id=? AND status='pending'",
|
|
144
|
+
(body, time.time(), req_id),
|
|
145
|
+
)
|
|
146
|
+
return cur.rowcount > 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def poll(req_id: int, path: Path | None = None) -> dict:
|
|
150
|
+
"""The app's read side: {'status': 'pending'|'answered'|'missing', 'response': ...}."""
|
|
151
|
+
row = get(req_id, path)
|
|
152
|
+
if row is None:
|
|
153
|
+
return {"status": "missing", "response": None}
|
|
154
|
+
return {"status": row["status"], "response": row["response"]}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def set_meta(req_id: int, obj: dict, path: Path | None = None) -> bool:
|
|
158
|
+
"""Attach an OPAQUE structured blob to a request, separate from the prose response.
|
|
159
|
+
|
|
160
|
+
The worker computes the modeled equity exactly ONCE (coach_watch._ensure_modeled_equity
|
|
161
|
+
early-returns if it is already on the ctx) and stores it here, so the app can REVEAL it at the
|
|
162
|
+
gut-commit beat WITHOUT re-running the Monte-Carlo — and so the drill's number is byte-identical
|
|
163
|
+
to the number the coach prose cites. (Byte-identical because it is the SAME computed blob, NOT
|
|
164
|
+
because the MC is deterministic — it isn't; a recompute would wiggle ±1-2%. The single-compute
|
|
165
|
+
early-return is what makes it stable.) The channel stays equity-agnostic: just JSON in, JSON out.
|
|
166
|
+
|
|
167
|
+
`meta` (equity) and `response` (prose) are SEPARATE, non-atomic writes BY DESIGN: the equity is the
|
|
168
|
+
ground-truth reveal (authoritative, available the moment it's computed), the prose is best-effort
|
|
169
|
+
narration (may never arrive — no worker up, or one that dies between this write and `respond`). The
|
|
170
|
+
app reveals the number regardless and only narrates when the prose lands.
|
|
171
|
+
|
|
172
|
+
WRITE-ONCE (`AND meta IS NULL`): the FIRST worker to compute the equity wins and the number is FROZEN.
|
|
173
|
+
A concurrent OR restarted worker that computes a second, MC-wiggled value cannot overwrite the number
|
|
174
|
+
the browser may already have revealed — so the late/losing worker must REREAD the stored value and use
|
|
175
|
+
THAT for its prose (coach_watch._ensure_modeled_equity does), keeping the drill reveal and the prose
|
|
176
|
+
byte-identical even under two workers. Returns True iff THIS call set it (False if the id is unknown
|
|
177
|
+
OR meta was already set)."""
|
|
178
|
+
with contextlib.closing(_conn(path)) as c, c:
|
|
179
|
+
cur = c.execute("UPDATE requests SET meta=? WHERE id=? AND meta IS NULL",
|
|
180
|
+
(json.dumps(obj), req_id))
|
|
181
|
+
return cur.rowcount > 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def poll_meta(req_id: int, path: Path | None = None) -> dict:
|
|
185
|
+
"""The app's read side for the opaque blob: {'status': 'ready'|'pending'|'no_equity'|'missing', 'meta': obj|None}.
|
|
186
|
+
|
|
187
|
+
'ready' = the blob is present and parseable (the drill reveals it).
|
|
188
|
+
'pending' = the request exists, is still UNANSWERED, and has no blob yet — keep polling.
|
|
189
|
+
'no_equity' = TERMINAL: the worker finished (answered) but produced no usable number — equity
|
|
190
|
+
computation failed, the spot expired, or the stored blob is corrupt. The app stops
|
|
191
|
+
waiting, reveals "no number", but STILL surfaces the prose (it exists). This split
|
|
192
|
+
(consulting row['status']) is what stops an answered-but-equity-less row from looking
|
|
193
|
+
like "still computing" until timeout and then suppressing prose that's already there
|
|
194
|
+
(codex L3 HIGH).
|
|
195
|
+
'missing' = no such request (expired/never-was)."""
|
|
196
|
+
row = get(req_id, path)
|
|
197
|
+
if row is None:
|
|
198
|
+
return {"status": "missing", "meta": None}
|
|
199
|
+
raw = row.get("meta")
|
|
200
|
+
if raw:
|
|
201
|
+
try:
|
|
202
|
+
return {"status": "ready", "meta": json.loads(raw)}
|
|
203
|
+
except (json.JSONDecodeError, TypeError):
|
|
204
|
+
return {"status": "no_equity", "meta": None} # present-but-corrupt → terminal, not "computing"
|
|
205
|
+
# no blob yet: still computing only if the request is unanswered; an answered row will never get one
|
|
206
|
+
if row.get("status") == "answered":
|
|
207
|
+
return {"status": "no_equity", "meta": None}
|
|
208
|
+
return {"status": "pending", "meta": None}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def clean(older_than_hours: float = 24.0, path: Path | None = None) -> int:
|
|
212
|
+
"""Prune answered rows older than the cutoff, and pending rows older than the
|
|
213
|
+
pending-TTL (a request no worker ever picked up). Returns rows deleted."""
|
|
214
|
+
now = time.time()
|
|
215
|
+
ans_cut = now - older_than_hours * 3600
|
|
216
|
+
pend_cut = now - _PENDING_TTL_H * 3600
|
|
217
|
+
with contextlib.closing(_conn(path)) as c, c:
|
|
218
|
+
cur = c.execute(
|
|
219
|
+
"DELETE FROM requests WHERE (status='answered' AND COALESCE(answered, created) < ?) "
|
|
220
|
+
"OR (status='pending' AND created < ?)",
|
|
221
|
+
(ans_cut, pend_cut),
|
|
222
|
+
)
|
|
223
|
+
return cur.rowcount
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------- CLI ----------------
|
|
227
|
+
def _read_text_arg(direct: str | None, file_arg: str | None) -> str:
|
|
228
|
+
if direct is not None:
|
|
229
|
+
return direct
|
|
230
|
+
if file_arg:
|
|
231
|
+
return Path(file_arg).read_text()
|
|
232
|
+
data = sys.stdin.read()
|
|
233
|
+
if not data:
|
|
234
|
+
raise SystemExit("no body: pass --body, --body-file, or pipe via stdin")
|
|
235
|
+
return data
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def main(argv: list[str] | None = None) -> int:
|
|
239
|
+
ap = argparse.ArgumentParser(description="gutcheck coach channel")
|
|
240
|
+
ap.add_argument("--db", default=None, help="channel db path (default: <data-dir>/coach.db)")
|
|
241
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
242
|
+
|
|
243
|
+
a = sub.add_parser("ask", help="write a pending coaching request")
|
|
244
|
+
a.add_argument("--kind", required=True, choices=KINDS)
|
|
245
|
+
a.add_argument("--context", help="inline JSON context")
|
|
246
|
+
a.add_argument("--context-file", help="JSON context file ('-' for stdin)")
|
|
247
|
+
|
|
248
|
+
sub.add_parser("pending", help="list unanswered requests (JSON)")
|
|
249
|
+
|
|
250
|
+
g = sub.add_parser("get", help="fetch one request (JSON)")
|
|
251
|
+
g.add_argument("--id", type=int, required=True)
|
|
252
|
+
|
|
253
|
+
r = sub.add_parser("respond", help="attach a coaching reply + mark answered")
|
|
254
|
+
r.add_argument("--id", type=int, required=True)
|
|
255
|
+
r.add_argument("--body", help="inline reply text")
|
|
256
|
+
r.add_argument("--body-file", help="reply text file")
|
|
257
|
+
|
|
258
|
+
p = sub.add_parser("poll", help="read a request's status/response (JSON)")
|
|
259
|
+
p.add_argument("--id", type=int, required=True)
|
|
260
|
+
|
|
261
|
+
cl = sub.add_parser("clean", help="prune old rows")
|
|
262
|
+
cl.add_argument("--older-than", type=float, default=24.0, help="answered-row age cutoff (hours)")
|
|
263
|
+
|
|
264
|
+
args = ap.parse_args(argv)
|
|
265
|
+
db = Path(args.db) if args.db else paths.coach_db()
|
|
266
|
+
|
|
267
|
+
if args.cmd == "ask":
|
|
268
|
+
if args.context_file == "-" or (args.context is None and not args.context_file):
|
|
269
|
+
raw = sys.stdin.read()
|
|
270
|
+
elif args.context is not None:
|
|
271
|
+
raw = args.context
|
|
272
|
+
else:
|
|
273
|
+
raw = Path(args.context_file).read_text()
|
|
274
|
+
ctx = json.loads(raw)
|
|
275
|
+
print(ask(args.kind, ctx, db))
|
|
276
|
+
return 0
|
|
277
|
+
if args.cmd == "pending":
|
|
278
|
+
print(json.dumps(pending(db), indent=2))
|
|
279
|
+
return 0
|
|
280
|
+
if args.cmd == "get":
|
|
281
|
+
row = get(args.id, db)
|
|
282
|
+
if row is None:
|
|
283
|
+
print(json.dumps({"error": "not found", "id": args.id}))
|
|
284
|
+
return 1
|
|
285
|
+
print(json.dumps(row, indent=2))
|
|
286
|
+
return 0
|
|
287
|
+
if args.cmd == "respond":
|
|
288
|
+
body = _read_text_arg(args.body, args.body_file)
|
|
289
|
+
ok = respond(args.id, body, db)
|
|
290
|
+
print(json.dumps({"id": args.id, "answered": ok}))
|
|
291
|
+
return 0 if ok else 1
|
|
292
|
+
if args.cmd == "poll":
|
|
293
|
+
print(json.dumps(poll(args.id, db)))
|
|
294
|
+
return 0
|
|
295
|
+
if args.cmd == "clean":
|
|
296
|
+
print(json.dumps({"deleted": clean(args.older_than, db)}))
|
|
297
|
+
return 0
|
|
298
|
+
return 2
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
raise SystemExit(main())
|