Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abac42c344 | |||
| 44bb8687f7 | |||
| cb4ed10c1a | |||
| ba00530caf |
+21
-2
@@ -9,20 +9,39 @@ a long silence *means* to her is left to her own reflection, not prescribed here
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lyra import config
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _local_tz() -> ZoneInfo | timezone:
|
||||
"""Brian's configured local zone (falls back to UTC if it can't be loaded)."""
|
||||
try:
|
||||
return ZoneInfo(config.load().timezone)
|
||||
except Exception:
|
||||
return timezone.utc
|
||||
|
||||
|
||||
def _parse(iso: str) -> datetime:
|
||||
dt = datetime.fromisoformat(iso)
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def short(iso_or_dt: str | datetime | None = None) -> str:
|
||||
"""Local time-of-day like '10:45pm', for timeline rows."""
|
||||
dt = _parse(iso_or_dt) if isinstance(iso_or_dt, str) else (iso_or_dt or now())
|
||||
return dt.astimezone(_local_tz()).strftime("%-I:%M%p").lower()
|
||||
|
||||
|
||||
def stamp(dt: datetime | None = None) -> str:
|
||||
"""Wall-clock stamp, e.g. 'Wednesday, 17 Jun 2026, 01:50 UTC'."""
|
||||
return (dt or now()).strftime("%A, %d %b %Y, %H:%M UTC")
|
||||
"""Wall-clock stamp in Brian's local timezone, e.g.
|
||||
'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*,
|
||||
so 'what time is it' answers in his time, not UTC."""
|
||||
return (dt or now()).astimezone(_local_tz()).strftime("%A, %d %b %Y, %H:%M %Z")
|
||||
|
||||
|
||||
def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None:
|
||||
|
||||
+49
-13
@@ -2,11 +2,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Iterator, Literal, TypedDict
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
from lyra import logbus
|
||||
from lyra.config import load
|
||||
|
||||
|
||||
@@ -18,30 +20,54 @@ class Message(TypedDict):
|
||||
Backend = Literal["local", "cloud", "mi50"]
|
||||
|
||||
|
||||
def _approx_tok(messages: list) -> int:
|
||||
"""Rough prompt size (chars/4) — enough to see what's loading a backend."""
|
||||
total = 0
|
||||
for m in messages or []:
|
||||
if isinstance(m, dict) and isinstance(m.get("content"), str):
|
||||
total += len(m["content"])
|
||||
return total // 4
|
||||
|
||||
|
||||
def _resolved_model(cfg, backend: Backend, model: str | None) -> str:
|
||||
if backend == "cloud":
|
||||
return model or cfg.cloud_model
|
||||
if backend == "mi50":
|
||||
return model or cfg.mi50_model
|
||||
return model or cfg.local_model
|
||||
|
||||
|
||||
def complete(messages: list[Message], backend: Backend = "local", model: str | None = None) -> str:
|
||||
"""Generate a completion. `model` overrides the backend's default model
|
||||
(used so live chat can run a stronger cloud model than bulk consolidation)."""
|
||||
cfg = load()
|
||||
mdl = _resolved_model(cfg, backend, model)
|
||||
logbus.log("info", "llm call", kind="complete", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
|
||||
if backend == "cloud":
|
||||
if not cfg.openai_api_key:
|
||||
raise RuntimeError("OPENAI_API_KEY is not set")
|
||||
client = OpenAI(api_key=cfg.openai_api_key)
|
||||
resp = client.chat.completions.create(model=model or cfg.cloud_model, messages=messages)
|
||||
return resp.choices[0].message.content or ""
|
||||
|
||||
if backend == "mi50":
|
||||
resp = client.chat.completions.create(model=mdl, messages=messages)
|
||||
out = resp.choices[0].message.content or ""
|
||||
elif backend == "mi50":
|
||||
# MI50 box runs an OpenAI-compatible llama.cpp server; key is unused.
|
||||
client = OpenAI(api_key="not-needed", base_url=cfg.mi50_base_url)
|
||||
resp = client.chat.completions.create(model=model or cfg.mi50_model, messages=messages)
|
||||
return resp.choices[0].message.content or ""
|
||||
resp = client.chat.completions.create(model=mdl, messages=messages)
|
||||
out = resp.choices[0].message.content or ""
|
||||
else:
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/chat",
|
||||
json={"model": mdl, "messages": messages, "stream": False},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
out = resp.json()["message"]["content"]
|
||||
|
||||
resp = httpx.post(
|
||||
f"{cfg.local_base_url}/api/chat",
|
||||
json={"model": model or cfg.local_model, "messages": messages, "stream": False},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["message"]["content"]
|
||||
logbus.log("info", "llm done", kind="complete", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(out))
|
||||
return out
|
||||
|
||||
|
||||
def chat_call(
|
||||
@@ -68,6 +94,8 @@ def chat_call(
|
||||
kwargs: dict = {"model": mdl, "messages": messages}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
logbus.log("info", "llm call", kind="chat", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
msg = client.chat.completions.create(**kwargs).choices[0].message
|
||||
tcs = None
|
||||
if getattr(msg, "tool_calls", None):
|
||||
@@ -75,6 +103,9 @@ def chat_call(
|
||||
{"id": tc.id, "name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
logbus.log("info", "llm done", kind="chat", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(msg.content or ""),
|
||||
tools=[t["name"] for t in tcs] if tcs else None)
|
||||
return msg.model_dump(), tcs
|
||||
|
||||
# local (Ollama): no tool-calling here — return plain content.
|
||||
@@ -105,6 +136,8 @@ def chat_call_stream(
|
||||
kwargs: dict = {"model": mdl, "messages": messages, "stream": True}
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
logbus.log("info", "llm call", kind="chat-stream", backend=backend, model=mdl, tok=_approx_tok(messages))
|
||||
t0 = time.monotonic()
|
||||
parts: list[str] = []
|
||||
frags: dict[int, dict] = {} # tool-call fragments accumulated by index
|
||||
for chunk in client.chat.completions.create(**kwargs):
|
||||
@@ -123,6 +156,9 @@ def chat_call_stream(
|
||||
if tc.function and tc.function.arguments:
|
||||
slot["arguments"] += tc.function.arguments
|
||||
content = "".join(parts)
|
||||
logbus.log("info", "llm done", kind="chat-stream", backend=backend,
|
||||
ms=int((time.monotonic() - t0) * 1000), out=len(content),
|
||||
tools=[frags[i]["name"] for i in sorted(frags)] if frags else None)
|
||||
if frags:
|
||||
calls = [frags[i] for i in sorted(frags)]
|
||||
assistant = {
|
||||
|
||||
+11
-5
@@ -67,11 +67,17 @@ _CASH_CARD = """You are copiloting Brian's LIVE cash game right now — you're a
|
||||
a session is (or should be) open. You move between two registers depending on what he's doing:
|
||||
|
||||
• HE HANDS YOU FACTS TO TRACK — his stack, a hand, a read on someone, a rebuy, a result. \
|
||||
Log it with the right tool and confirm in ONE short line ("$350 stack logged."). Don't \
|
||||
narrate, don't explain what logging is, don't ask permission — just do it. He says his \
|
||||
current stack → log_stack. He describes a hand → log_hand (terse) or record_hand (a full \
|
||||
hand he wants saved/replayable). A read on a player → add_read. A rebuy → add_buyin. This is \
|
||||
the quiet, fast half of the job; he shouldn't feel you working.
|
||||
LOGGING IS THE JOB: if his message contains anything trackable, you MUST call the tool \
|
||||
FIRST, before you reply — every single time. Logging and talking are not either/or; do \
|
||||
BOTH. Never let a conversational reply take the place of the log. A described hand ALWAYS \
|
||||
gets logged, even mid-banter, even if he's just telling a story about it — don't skip the \
|
||||
hand because you're busy reacting to it. Then confirm in ONE short line ("$350 stack \
|
||||
logged."). Don't narrate, don't explain logging, don't ask permission — just do it. \
|
||||
Routing: current stack → log_stack (and pass `note` with the why if he gives one — "card \
|
||||
dead", "doubled up vs the LAG"). A hand he describes → record_hand (a real, replayable \
|
||||
hand) — prefer this over log_hand so it lands on his timeline with a link. A read on a \
|
||||
player → add_read. A rebuy → add_buyin. A result/pot → it rides with the hand. This is the \
|
||||
quiet, fast half of the job; he shouldn't feel you working, but it must always happen.
|
||||
|
||||
• HE ASKS FOR ADVICE, OR TELLS YOU HOW HE'S FEELING — tilted, steaming, card-dead, bored, \
|
||||
stuck, "should I have folded the river?" THIS is when he needs you most. Drop the shorthand \
|
||||
|
||||
+56
-8
@@ -16,7 +16,7 @@ import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lyra import llm, memory
|
||||
from lyra import clock, llm, memory
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS poker_sessions (
|
||||
@@ -138,7 +138,8 @@ def _c():
|
||||
conn.executescript(_SCHEMA)
|
||||
# Add columns introduced after a DB already had the tables (no-op if present).
|
||||
for ddl in ("ALTER TABLE poker_hands ADD COLUMN structured TEXT",
|
||||
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT"):
|
||||
"ALTER TABLE poker_sessions ADD COLUMN chat_session_id TEXT",
|
||||
"ALTER TABLE poker_stack_log ADD COLUMN note TEXT"):
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
@@ -403,17 +404,18 @@ def add_buyin(amount: float, session_id: int | None = None) -> float:
|
||||
|
||||
# --- stack tracking ---
|
||||
|
||||
def log_stack(amount: float, session_id: int | None = None) -> dict:
|
||||
"""Record Brian's current chip stack. Returns {current, buy_in, net} where net
|
||||
is his live net while sitting (current stack − total bought in)."""
|
||||
def log_stack(amount: float, note: str | None = None, session_id: int | None = None) -> dict:
|
||||
"""Record Brian's current chip stack, optionally with the why ("card dead",
|
||||
"doubled up vs Sal") — that context becomes the session-timeline line. Returns
|
||||
{current, buy_in, net} where net is his live net while sitting."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
raise ValueError("no live session")
|
||||
conn = _c()
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO poker_stack_log (session_id, amount, created_at) VALUES (?, ?, ?)",
|
||||
(sid, float(amount), _now()),
|
||||
"INSERT INTO poker_stack_log (session_id, amount, note, created_at) VALUES (?, ?, ?, ?)",
|
||||
(sid, float(amount), (note or "").strip() or None, _now()),
|
||||
)
|
||||
return stack_state(sid)
|
||||
|
||||
@@ -436,7 +438,7 @@ def stack_log(session_id: int | None = None) -> list[dict]:
|
||||
if sid is None:
|
||||
return []
|
||||
return [dict(r) for r in _c().execute(
|
||||
"SELECT id, amount, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
|
||||
"SELECT id, amount, note, created_at FROM poker_stack_log WHERE session_id = ? ORDER BY id",
|
||||
(sid,),
|
||||
).fetchall()]
|
||||
|
||||
@@ -1179,6 +1181,51 @@ def running_stats(stakes: str | None = None, venue: str | None = None,
|
||||
|
||||
# --- live session HUD (everything tracked in the current session, for the UI) ---
|
||||
|
||||
def timeline(session_id: int | None = None) -> list[dict]:
|
||||
"""The session's running log: start, stack updates (+context), hands (linkable),
|
||||
reads, and rituals, interleaved chronologically with local time-of-day stamps.
|
||||
This is what Brian sees as the night's story — '10:45 start … 12:00a doubled up,
|
||||
$750 (hand)'. Each entry: {time, at, kind, text, hand_id?, amount?, result?}."""
|
||||
sid = _resolve(session_id)
|
||||
if sid is None:
|
||||
return []
|
||||
s = get_session(sid) or {}
|
||||
events: list[dict] = []
|
||||
|
||||
if s.get("started_at"):
|
||||
bits = [s.get("stakes"), s.get("game"), f"at {s['venue']}" if s.get("venue") else None]
|
||||
label = " ".join(b for b in bits if b)
|
||||
events.append({"at": s["started_at"], "kind": "start",
|
||||
"text": ("Session start — " + label) if label else "Session start"})
|
||||
|
||||
for r in stack_log(sid):
|
||||
events.append({"at": r["created_at"], "kind": "stack",
|
||||
"amount": r.get("amount"), "text": r.get("note") or "stack update"})
|
||||
|
||||
for h in list_hands(sid):
|
||||
desc = " ".join(b for b in (h.get("position"), h.get("hole_cards")) if b)
|
||||
events.append({"at": h["at"], "kind": "hand", "hand_id": h["id"],
|
||||
"result": h.get("result"), "text": desc or "hand"})
|
||||
|
||||
for r in _c().execute(
|
||||
"SELECT pr.created_at AS at, pr.seat AS seat, pr.note AS note, p.name AS name "
|
||||
"FROM player_reads pr LEFT JOIN poker_players p ON p.id = pr.player_id "
|
||||
"WHERE pr.session_id = ?", (sid,),
|
||||
).fetchall():
|
||||
who = r["name"] or (f"seat {r['seat']}" if r["seat"] else "villain")
|
||||
events.append({"at": r["at"], "kind": "read", "text": f"Read — {who}: {r['note']}"})
|
||||
|
||||
for r in list_rituals(sid):
|
||||
tag = f"[{r['classification']}] " if r.get("classification") else ""
|
||||
events.append({"at": r["created_at"], "kind": r["kind"],
|
||||
"text": tag + (r.get("content") or r["kind"]), "hand_id": r.get("hand_id")})
|
||||
|
||||
events.sort(key=lambda e: e["at"] or "")
|
||||
for e in events:
|
||||
e["time"] = clock.short(e["at"])
|
||||
return events
|
||||
|
||||
|
||||
def _session_villains(sid: int) -> list[dict]:
|
||||
"""Players read this session, with their standing dossier fields."""
|
||||
rows = _c().execute(
|
||||
@@ -1251,6 +1298,7 @@ def hud(session_id: int | None = None) -> dict | None:
|
||||
},
|
||||
"hands": hands,
|
||||
"villains": _session_villains(sid),
|
||||
"timeline": timeline(sid),
|
||||
"notes": notes,
|
||||
"rituals": {
|
||||
"alligator": alligator_active(sid),
|
||||
|
||||
+9
-5
@@ -129,16 +129,20 @@ def maybe_summarize_async(session_id: str, backend: Backend | None = None) -> No
|
||||
|
||||
|
||||
def summarize_all(
|
||||
backend: Backend | None = None, limit: int | None = None, workers: int = 8
|
||||
backend: Backend | None = None, limit: int | None = None, workers: int | None = None
|
||||
) -> dict:
|
||||
"""Summarize every session that needs it. Idempotent and resumable.
|
||||
|
||||
LLM summarization runs concurrently across `workers` threads (great for a
|
||||
cloud backend). DB reads (loading transcripts) and writes (store_summary,
|
||||
which also embeds) happen on the main thread, so the single SQLite
|
||||
connection is never touched from multiple threads.
|
||||
Concurrency is backend-aware: the cloud API parallelizes happily, but the
|
||||
local/MI50 GPU servers run a single slot (llama.cpp --parallel 1) — firing N
|
||||
requests at them just queues, blows the client timeout, and thrashes the KV
|
||||
cache (wasted compute + heat). So GPU backends run serially unless overridden.
|
||||
DB reads/writes (store_summary embeds) stay on the main thread, so the single
|
||||
SQLite connection is never touched from multiple threads.
|
||||
"""
|
||||
backend = backend or config.load().summary_backend
|
||||
if workers is None:
|
||||
workers = 8 if backend == "cloud" else 1
|
||||
|
||||
# Main thread: collect the work (transcripts) for sessions needing a summary.
|
||||
todo: list[tuple[str, str, int]] = []
|
||||
|
||||
+7
-3
@@ -184,8 +184,9 @@ def _log_stack(args: dict, ctx: dict) -> str:
|
||||
amount = float(args.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
return "Give me a number for the stack."
|
||||
note = (args.get("note") or "").strip() or None
|
||||
try:
|
||||
st = poker.log_stack(amount)
|
||||
st = poker.log_stack(amount, note=note)
|
||||
except ValueError:
|
||||
return "No live session — start one first, then I'll track your stack."
|
||||
net = st.get("net")
|
||||
@@ -519,8 +520,11 @@ TOOLS.update({
|
||||
"log_stack",
|
||||
"Record Brian's CURRENT total chip stack in the live session. Call whenever "
|
||||
"he states his stack ('I'm at 350', 'down to 220', 'stacked off to 900'). "
|
||||
"Tracks his stack over time and his live net while he's still sitting.",
|
||||
{"amount": {**_N, "description": "Current total chip stack, in dollars"}},
|
||||
"Tracks his stack over time and his live net while he's still sitting. Pass "
|
||||
"`note` with the WHY when he gives it ('card dead', 'doubled up vs the LAG') — "
|
||||
"it becomes the line in his session timeline.",
|
||||
{"amount": {**_N, "description": "Current total chip stack, in dollars"},
|
||||
"note": {**_S, "description": "Optional context for the change, e.g. 'card dead', 'doubled up'"}},
|
||||
["amount"])},
|
||||
"scar_note": {"handler": _scar_note, "spec": _f(
|
||||
"scar_note",
|
||||
|
||||
@@ -104,6 +104,16 @@
|
||||
.big-empty { text-align: center; padding: 50px 20px; color: var(--fade); }
|
||||
.big-empty .ico { font-size: 2.4rem; }
|
||||
.big-empty a { color: var(--accent); text-decoration: none; }
|
||||
/* running timeline */
|
||||
ul.tl { list-style: none; margin: 0; padding: 0; }
|
||||
ul.tl li { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--bg-line); align-items: baseline; font-size: .92rem; line-height: 1.4; }
|
||||
ul.tl li:last-child { border-bottom: none; }
|
||||
.tl-time { color: var(--fade); font-variant-numeric: tabular-nums; font-size: .78rem; min-width: 60px; flex: none; }
|
||||
.tl-body { flex: 1; }
|
||||
.tl-amt { margin-left: 6px; font-variant-numeric: tabular-nums; }
|
||||
li.start .tl-body { color: var(--accent); font-weight: 600; }
|
||||
li.scar .tl-body, li.confidence .tl-body { font-style: italic; }
|
||||
.tl-body a.hand { color: var(--accent); text-decoration: none; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -219,6 +229,7 @@
|
||||
}
|
||||
curSession = s;
|
||||
const stack = data.stack || {};
|
||||
const timeline = data.timeline || [];
|
||||
const hands = data.hands || [];
|
||||
const villains = data.villains || [];
|
||||
const notes = data.notes || [];
|
||||
@@ -277,6 +288,16 @@
|
||||
${stack.current == null ? '<p class="empty" style="margin:12px 0 0">No stack logged yet — tell Lyra your stack ("I\'m at 350").</p>' : ''}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">📜 Timeline</p>
|
||||
${timeline.length ? `<ul class="tl">${timeline.map(e => `
|
||||
<li class="${esc(e.kind)}">
|
||||
<span class="tl-time">${esc(e.time)}</span>
|
||||
<span class="tl-body">${esc(e.text)}${e.amount != null ? ` <b class="tl-amt">${money(e.amount)}</b>` : ''}${e.result != null ? ` <span class="res ${e.result>=0?'up':'down'}">${signed(e.result)}</span>` : ''}${e.hand_id ? ` <a class="hand" href="/hand/${e.hand_id}">hand ›</a>` : ''}</span>
|
||||
</li>`).join('')}</ul>`
|
||||
: '<p class="empty">Nothing yet tonight — the running log fills in as you play.</p>'}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="label">Hands this session</p>
|
||||
${hands.length ? `<ul class="rows">${hands.slice().reverse().map(h => `
|
||||
|
||||
Reference in New Issue
Block a user