feat: decision-log data layer for Decide mode (learning layer)
Storage + tools so Decide mode can learn instead of one-shot tie-breaking: log the call Brian makes, resolve it later with the outcome, recall similar past calls to ground new recommendations in his own track record. - memory: decisions table + Decision dataclass + log/resolve/get/list/recall_decisions (embedding over situation+choice; embed failure never blocks a log) - tools: log_decision / resolve_decision / recall_decisions handlers + specs - tests: 9 covering roundtrip, resolve, open-only filter, similarity rank, tool layer Data layer only — NOT wired into any mode's allow-list or the Decide card yet (the prompt/taste part is left for Brian; see docs/DECISION_LOG.md). No behavior changes until the tools are added to _DECIDE_TOOLS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+121
@@ -115,6 +115,26 @@ CREATE TABLE IF NOT EXISTS ratings (
|
||||
note TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ratings_created ON ratings(created_at);
|
||||
|
||||
-- Decisions Lyra helped Brian make (Decide mode's learning layer). Logged when the
|
||||
-- call is made; resolved later with how it actually turned out; recalled by semantic
|
||||
-- similarity so a new call can lean on how similar ones went. embedding covers the
|
||||
-- situation + choice. Resolved rows (with an outcome) are the signal worth recalling.
|
||||
CREATE TABLE IF NOT EXISTS decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
situation TEXT NOT NULL, -- what was being decided
|
||||
options TEXT, -- the alternatives weighed (free text / newline list)
|
||||
choice TEXT NOT NULL, -- the call that was made
|
||||
rationale TEXT, -- why
|
||||
confidence INTEGER, -- 1-5, how sure at the time (nullable)
|
||||
tags TEXT, -- domain: poker | life | build | ... (comma-separated)
|
||||
embedding BLOB,
|
||||
outcome TEXT, -- filled in on resolve: what actually happened
|
||||
outcome_rating INTEGER, -- -1 bad / 0 mixed / +1 good (nullable until resolved)
|
||||
resolved_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_decisions_created ON decisions(created_at);
|
||||
"""
|
||||
|
||||
_conn: sqlite3.Connection | None = None
|
||||
@@ -184,6 +204,26 @@ class Era:
|
||||
score: float | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Decision:
|
||||
id: int
|
||||
created_at: str
|
||||
situation: str
|
||||
choice: str
|
||||
options: str | None = None
|
||||
rationale: str | None = None
|
||||
confidence: int | None = None
|
||||
tags: str | None = None
|
||||
outcome: str | None = None
|
||||
outcome_rating: int | None = None
|
||||
resolved_at: str | None = None
|
||||
score: float | None = None
|
||||
|
||||
@property
|
||||
def resolved(self) -> bool:
|
||||
return self.resolved_at is not None
|
||||
|
||||
|
||||
def _to_blob(vec: list[float]) -> bytes:
|
||||
return np.asarray(vec, dtype=np.float32).tobytes()
|
||||
|
||||
@@ -645,6 +685,87 @@ def backfill_journal_embeddings(limit: int | None = None) -> int:
|
||||
return n
|
||||
|
||||
|
||||
# --- decisions (Decide mode's learning layer) ---------------------------------
|
||||
|
||||
def _row_to_decision(r: sqlite3.Row) -> Decision:
|
||||
return Decision(
|
||||
id=r["id"], created_at=r["created_at"], situation=r["situation"],
|
||||
choice=r["choice"], options=r["options"], rationale=r["rationale"],
|
||||
confidence=r["confidence"], tags=r["tags"], outcome=r["outcome"],
|
||||
outcome_rating=r["outcome_rating"], resolved_at=r["resolved_at"],
|
||||
)
|
||||
|
||||
|
||||
def log_decision(situation: str, choice: str, options: str | None = None,
|
||||
rationale: str | None = None, confidence: int | None = None,
|
||||
tags: str | None = None) -> int:
|
||||
"""Record a decision Brian made. Embeds situation+choice so similar future calls
|
||||
can recall it. Returns the new row id. Resolve it later with resolve_decision."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
[emb] = llm.embed([f"{situation}\nChose: {choice}"])
|
||||
blob = _to_blob(emb)
|
||||
except Exception:
|
||||
blob = None # never block logging a decision on the embedder being down
|
||||
conn = _connection()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO decisions (created_at, situation, options, choice, rationale, "
|
||||
"confidence, tags, embedding) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(now, situation, options, choice, rationale, confidence, tags, blob),
|
||||
)
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def resolve_decision(decision_id: int, outcome: str, outcome_rating: int | None = None) -> bool:
|
||||
"""Record how a past decision turned out. Returns False if the id is unknown."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = _connection()
|
||||
with conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE decisions SET outcome = ?, outcome_rating = ?, resolved_at = ? WHERE id = ?",
|
||||
(outcome, outcome_rating, now, decision_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def get_decision(decision_id: int) -> Decision | None:
|
||||
r = _connection().execute("SELECT * FROM decisions WHERE id = ?", (decision_id,)).fetchone()
|
||||
return _row_to_decision(r) if r else None
|
||||
|
||||
|
||||
def list_decisions(limit: int = 20, open_only: bool = False) -> list[Decision]:
|
||||
"""Recent decisions, newest first. open_only -> only those not yet resolved."""
|
||||
sql = "SELECT * FROM decisions"
|
||||
if open_only:
|
||||
sql += " WHERE resolved_at IS NULL"
|
||||
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||
rows = _connection().execute(sql, (limit,)).fetchall()
|
||||
return [_row_to_decision(r) for r in rows]
|
||||
|
||||
|
||||
def recall_decisions(query: str, k: int = 5) -> list[Decision]:
|
||||
"""Top-k past decisions semantically similar to `query`, each with a `score` — so a
|
||||
new call can lean on how similar ones went. Resolved rows carry the real signal."""
|
||||
[q_vec] = llm.embed([query])
|
||||
q = np.asarray(q_vec, dtype=np.float32)
|
||||
rows = _connection().execute(
|
||||
"SELECT * FROM decisions WHERE embedding IS NOT NULL"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return []
|
||||
matrix = np.stack([_from_blob(r["embedding"]) for r in rows])
|
||||
norms = np.linalg.norm(matrix, axis=1)
|
||||
scores = (matrix @ q) / (norms * np.linalg.norm(q) + 1e-9)
|
||||
top_idx = np.argsort(scores)[::-1][:k]
|
||||
out = []
|
||||
for i in top_idx:
|
||||
d = _row_to_decision(rows[i])
|
||||
d.score = float(scores[i])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def get_setting(key: str, default: str | None = None) -> str | None:
|
||||
"""A runtime setting value (UI-tunable), or `default` if unset."""
|
||||
r = _connection().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
|
||||
@@ -81,6 +81,65 @@ def _thought_response(args: dict, ctx: dict) -> str:
|
||||
"next time I'm thinking.")
|
||||
|
||||
|
||||
def _log_decision(args: dict, ctx: dict) -> str:
|
||||
situation = (args.get("situation") or "").strip()
|
||||
choice = (args.get("choice") or "").strip()
|
||||
if not situation or not choice:
|
||||
return "Need both what was being decided and the call you landed on."
|
||||
conf = args.get("confidence")
|
||||
try:
|
||||
conf = int(conf) if conf is not None else None
|
||||
except (TypeError, ValueError):
|
||||
conf = None
|
||||
did = memory.log_decision(
|
||||
situation=situation, choice=choice,
|
||||
options=(args.get("options") or "").strip() or None,
|
||||
rationale=(args.get("rationale") or "").strip() or None,
|
||||
confidence=conf, tags=(args.get("tags") or "").strip() or None,
|
||||
)
|
||||
logbus.log("info", "decision logged (tool)", id=did)
|
||||
return (f"Logged decision #{did}. When you know how it played out, tell me and "
|
||||
"I'll close the loop on it.")
|
||||
|
||||
|
||||
def _resolve_decision(args: dict, ctx: dict) -> str:
|
||||
try:
|
||||
did = int(args.get("decision_id"))
|
||||
except (TypeError, ValueError):
|
||||
return "Which decision? I need its id (#number)."
|
||||
outcome = (args.get("outcome") or "").strip()
|
||||
if not outcome:
|
||||
return "Tell me how it turned out so I can record the outcome."
|
||||
rating = args.get("rating")
|
||||
try:
|
||||
rating = int(rating) if rating is not None else None
|
||||
except (TypeError, ValueError):
|
||||
rating = None
|
||||
if not memory.resolve_decision(did, outcome, rating):
|
||||
return f"(couldn't find decision #{did})"
|
||||
logbus.log("info", "decision resolved (tool)", id=did, rating=rating)
|
||||
return f"Closed the loop on decision #{did}. That goes into how I weigh the next one."
|
||||
|
||||
|
||||
def _recall_decisions(args: dict, ctx: dict) -> str:
|
||||
query = (args.get("query") or "").strip()
|
||||
if not query:
|
||||
return "Give me the gist of the call you're weighing and I'll pull similar past ones."
|
||||
hits = memory.recall_decisions(query, k=int(args.get("k") or 4))
|
||||
if not hits:
|
||||
return "No comparable past decisions on record yet."
|
||||
lines = []
|
||||
for d in hits:
|
||||
head = f"#{d.id} ({d.created_at[:10]}): {d.situation} → {d.choice}"
|
||||
if d.resolved:
|
||||
verdict = {1: "went well", 0: "mixed", -1: "went badly"}.get(d.outcome_rating, "resolved")
|
||||
head += f" — {verdict}: {d.outcome}"
|
||||
else:
|
||||
head += " — outcome still open"
|
||||
lines.append(head)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# name -> {spec (OpenAI function tool), handler}
|
||||
TOOLS: dict[str, dict] = {
|
||||
"journal_write": {
|
||||
@@ -481,6 +540,34 @@ TOOLS.update({
|
||||
{"thread_id": {**_N, "description": "The thread id (#number) of the thought he reacted to."},
|
||||
"brian_said": {**_S, "description": "What Brian said / his take, in your words."}},
|
||||
["thread_id", "brian_said"])},
|
||||
"log_decision": {"handler": _log_decision, "spec": _f(
|
||||
"log_decision",
|
||||
"Record a real decision Brian lands on (especially in Decide mode) so it can "
|
||||
"inform future calls. Capture it once he's settled — what he was deciding, the "
|
||||
"call, and why. Outcome comes later via resolve_decision.",
|
||||
{"situation": {**_S, "description": "What was being decided, in Brian's terms."},
|
||||
"choice": {**_S, "description": "The call he landed on."},
|
||||
"options": {**_S, "description": "The alternatives weighed (optional, newline/free text)."},
|
||||
"rationale": {**_S, "description": "Why this call (optional)."},
|
||||
"confidence": {**_N, "description": "How sure he was, 1-5 (optional)."},
|
||||
"tags": {**_S, "description": "Domain, comma-separated: poker | life | build | ... (optional)."}},
|
||||
["situation", "choice"])},
|
||||
"resolve_decision": {"handler": _resolve_decision, "spec": _f(
|
||||
"resolve_decision",
|
||||
"Close the loop on a previously logged decision once Brian knows how it turned "
|
||||
"out. This is what makes the decision log learn — outcomes sharpen future calls.",
|
||||
{"decision_id": {**_N, "description": "The decision id (#number)."},
|
||||
"outcome": {**_S, "description": "What actually happened, in Brian's terms."},
|
||||
"rating": {**_N, "description": "How it went: 1 good / 0 mixed / -1 bad (optional)."}},
|
||||
["decision_id", "outcome"])},
|
||||
"recall_decisions": {"handler": _recall_decisions, "spec": _f(
|
||||
"recall_decisions",
|
||||
"Pull past decisions similar to one Brian's weighing now, with how they turned "
|
||||
"out — so you can ground a recommendation in his own track record rather than "
|
||||
"generic advice. Use it when he's deciding something with precedent.",
|
||||
{"query": {**_S, "description": "The gist of the current call / situation."},
|
||||
"k": {**_N, "description": "How many to pull (default 4)."}},
|
||||
["query"])},
|
||||
"start_session": {"handler": _start_session, "spec": _f(
|
||||
"start_session",
|
||||
"Begin a live poker session. Call when Brian sits down to play.",
|
||||
|
||||
Reference in New Issue
Block a user