a7af461cdb
The control plane gains senses — cheap, deterministic, no LLM:
- lyra/perceive.py: lexicon+signal heuristic → {sentiment, intensity, tilt, kind:
emotional|strategic|meta|build|casual}. Good at the action-relevant signal,
especially tilt (the mental-game core). Word-boundary matching so 'line' doesn't
fire inside 'pipeline'.
- mind: _perceive fills ctx.moment; _route keeps the manual mode as the dominant
frame but, on a genuinely charged moment, adds a per-turn register nudge — tilt →
"meet him there, warm and steady, don't clip into logging"; up/energized → "match
his energy." Neutral turns get nothing (don't over-narrate). Injected via
build_messages(moment=...). Logged to /logs for observability.
- tests: perceive read (tilt/strategy/up/build/casual) + route nudge on/off.
Suite 92 green, ruff clean.
Complements modes (manual frame) — perceive refines register within it, doesn't
override. Model routing (mind/mouth) is P3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 lines
4.0 KiB
Python
98 lines
4.0 KiB
Python
"""Perceive: read the moment from what Brian just said — cheap, deterministic, no LLM.
|
|
|
|
The control plane's senses. A lexicon + signal heuristic that estimates emotional
|
|
charge (sentiment, intensity, tilt) and the kind of turn (emotional / strategic /
|
|
meta / build / casual). It's rough on purpose — the point of the society-of-parts
|
|
design is that *most* parts are free heuristics and the LLM is the exception.
|
|
|
|
What it's GOOD at: catching the obvious, action-relevant signal — especially tilt
|
|
(the mental-game core of her job). What it's NOT: nuanced understanding (that's the
|
|
LLM's job downstream). `route` turns this read into a per-turn register nudge.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
# Negative / tilt charge — frustration, downswing, mental-game trouble.
|
|
_NEG = (
|
|
"tilt", "tilted", "steaming", "steam", "frustrated", "pissed", "angry", "annoyed",
|
|
"hate", "sick of", "fed up", "card dead", "carddead", "cold deck", "brutal", "cooler",
|
|
"punt", "punted", "spew", "spewing", "stuck", "losing", "bad beat", "badbeat",
|
|
"unlucky", "rigged", "sigh", "ugh", "fml", "can't win", "cant win", "miserable",
|
|
"over it", "fuck this", "hate this", "can't catch", "cant catch",
|
|
)
|
|
# Positive / up charge — running good, energized.
|
|
_POS = (
|
|
"great", "awesome", "love", "crushing", "running good", "rungood", "hell yeah",
|
|
"let's go", "lets go", "stoked", "pumped", "feeling good", "on fire", "dialed",
|
|
"killing it", "in the zone", "so good", "amazing",
|
|
)
|
|
_PROFANITY = ("fuck", "fucking", "shit", "damn", "bullshit", "fml")
|
|
# Strategic / poker-analysis cues.
|
|
_STRATEGY = (
|
|
"fold", "call", "raise", "3bet", "three-bet", "range", "equity", "gto", "bluff",
|
|
"value", "river", "turn", "flop", "preflop", "pot odds", "outs", "should i",
|
|
"what would you", "sizing", "check-raise", "overbet", "line",
|
|
)
|
|
# Meta / about-her cues.
|
|
_META = (
|
|
"do you", "are you", "yourself", "conscious", "sentient", "you feel", "you exist",
|
|
"your thoughts", "your mind", "who are you", "what are you", "your own",
|
|
)
|
|
# Building / technical cues.
|
|
_BUILD = (
|
|
"code", "function", "bug", "build", "implement", "refactor", "architecture",
|
|
"prompt", "python", "commit", "deploy", "pipeline", "algorithm", "repo", "api",
|
|
"schema", "module", "wire it", "the model",
|
|
)
|
|
|
|
|
|
def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
|
return max(lo, min(hi, x))
|
|
|
|
|
|
def _hits(text: str, lexicon: tuple[str, ...]) -> int:
|
|
"""Count lexicon matches. Multi-token terms match as substrings ('card dead');
|
|
single words match on word boundaries so 'line' doesn't fire inside 'pipeline'."""
|
|
n = 0
|
|
for term in lexicon:
|
|
if " " in term or "-" in term or "'" in term:
|
|
n += 1 if term in text else 0
|
|
else:
|
|
n += 1 if re.search(rf"\b{re.escape(term)}\b", text) else 0
|
|
return n
|
|
|
|
|
|
def read(user_msg: str) -> dict:
|
|
"""Estimate the emotional charge + kind of this turn. Returns
|
|
{sentiment: -1..1, intensity: 0..1, tilt: 0..1, kind: str}."""
|
|
t = (user_msg or "").lower()
|
|
words = re.findall(r"[a-z']+", t)
|
|
|
|
neg = _hits(t, _NEG)
|
|
pos = _hits(t, _POS)
|
|
prof = _hits(t, _PROFANITY)
|
|
exclam = user_msg.count("!")
|
|
caps = sum(1 for w in re.findall(r"[A-Za-z]{2,}", user_msg) if w.isupper())
|
|
short_and_hot = len(words) <= 6 and (neg or exclam or prof)
|
|
|
|
intensity = _clamp(0.2 * exclam + 0.25 * caps + 0.3 * prof + (0.2 if short_and_hot else 0))
|
|
sentiment = _clamp((pos - neg) * 0.5, -1.0, 1.0)
|
|
tilt = _clamp(0.35 * neg + 0.5 * intensity) if (neg or prof) else 0.0
|
|
|
|
if tilt >= 0.4 or (neg and sentiment < 0):
|
|
kind = "emotional"
|
|
elif _hits(t, _STRATEGY):
|
|
kind = "strategic"
|
|
elif _hits(t, _META):
|
|
kind = "meta"
|
|
elif _hits(t, _BUILD):
|
|
kind = "build"
|
|
elif pos and intensity >= 0.3:
|
|
kind = "emotional" # up/energized still wants an emotional read
|
|
else:
|
|
kind = "casual"
|
|
|
|
return {"sentiment": round(sentiment, 2), "intensity": round(intensity, 2),
|
|
"tilt": round(tilt, 2), "kind": kind}
|