clanker / app /main.py
deucebucket's picture
harden: session Secure flag + /say length cap
1f7d20c verified
Raw
History Blame Contribute Delete
8.72 kB
import os
import secrets
import time
import uuid
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from starlette.middleware.sessions import SessionMiddleware
from app.scorer import score_with_trace
from app.soul_bridge import SoulBridge
from app.appearance import mood_to_appearance
from app.trace_view import shape_trace
from app.crisis_view import crisis_block
from app.vocab import mood_word
from app.voice import emoticon, simlish, voice_params
from app.audit import AuditLog, build_row
from app.persistence import restore_soul, Snapshotter
from app.auth import make_auth_router
from app.identity import RecognitionStore, hash_uid
from app.care import CareState, CARE_ACTIONS
from app.toys import toy_score, TOY_NAMES
from app.relation import RelationStore
from app.moments import MomentsStore, notable
from app.autonomy import maybe_self_soothe
from clanker_soul import Score
app = FastAPI(title="Clanker Pet")
app.add_middleware(
SessionMiddleware,
secret_key=os.environ.get("SESSION_SECRET") or secrets.token_hex(16),
max_age=60 * 60 * 24 * 365, # 1 year: the anonymous-recognition cookie persists
same_site="lax",
https_only=True, # HF Spaces are HTTPS-only; mark the cookie Secure
)
_DB = os.environ.get("SOUL_DB", "./data/soul.db")
_STATE_REPO = os.environ.get("SOUL_STATE_REPO", "deucebucket/clanker-soul-state")
restore_soul(_DB, _STATE_REPO) # boot: pull latest soul before opening it
_bridge = SoulBridge(db_path=_DB, agent_id="clanker-pet-baby-v1")
_snap = Snapshotter(db_path=_DB, repo_id=_STATE_REPO)
_audit = AuditLog(repo_id="deucebucket/clanker-audit")
_recog = RecognitionStore(_DB)
_care = CareState(_DB)
_relation = RelationStore(_DB)
_moments = MomentsStore(_DB)
_SESSION = uuid.uuid4().hex[:12]
def _traffic() -> int:
"""Cumulative interaction count (soul events) — drives traffic-aware decay."""
try:
return int(_bridge.maturity().get("events", 0))
except Exception:
return 0
_care.rebase_legacy_traffic(_traffic()) # one-time: fix pre-traffic-column rows
def _apply_neglect_once():
s = _care.neglect_score(_traffic())
if s:
_bridge.ingest(s)
_apply_neglect_once()
app.include_router(make_auth_router())
class SayIn(BaseModel):
text: str = Field(max_length=2000)
class CareIn(BaseModel):
action: str
class ToyIn(BaseModel):
toy: str
def _state() -> dict:
mood = _bridge.mood()
soul = _bridge.soul_baseline()
return {
"appearance": mood_to_appearance(mood),
"mood_word": mood_word(mood),
"emoticon": emoticon(mood),
"needs": _care.needs(_traffic()),
"soul": soul,
"distance": _bridge.soul_distance(),
# default: no text-derived crisis (physical/idle states are calm). The
# /say handler overrides this with the real read+structures concern.
"crisis": {"value": 0.0, "label": "calm"},
}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/snapshot")
def snapshot():
state = _state()
soothe = maybe_self_soothe(_bridge, state.get("needs"), time.time())
if soothe:
_snap.maybe_snapshot()
state = _state() # re-read mood after the self-soothe lift
state["self_soothe"] = soothe # tells the client which item to go carry
return state
def _enrich_physical(state: dict, label: str, read: list[int], deltas: list[int]) -> None:
"""Toy/care interactions have no language to parse, but they DO push a Score
through the soul. Surface the real read, deltas, armor and dominant move so the
panels track the action instead of showing the previous chat message, and mark
the trace as a physical (non-linguistic) interaction."""
state["read"] = read
state["deltas"] = deltas
state["acceptance"] = _bridge.last_acceptance()
state["trace"] = {"words": [], "structures": [], "contributors": [],
"kind": "physical", "label": label}
state["why"] = f"{label}: " + _why(read, deltas)
@app.post("/care")
def care(body: CareIn):
if body.action not in CARE_ACTIONS:
raise HTTPException(status_code=422, detail=f"unknown action: {body.action!r}")
s = _care.score_for(body.action)
before = _bridge.mood()
_bridge.ingest(s)
_snap.maybe_snapshot()
after = _bridge.mood()
_care.do(body.action, _traffic()) # stamp fill AFTER this interaction counts
read = [s.v, s.a, s.d, s.u, s.g, s.w, s.i]
deltas = [after[i] - before[i] for i in range(7)]
state = _state()
_enrich_physical(state, f"you {body.action} it", read, deltas)
state["line"] = f"you {body.action} it"
return state
@app.post("/toy")
def toy(body: ToyIn):
if body.toy not in TOY_NAMES:
raise HTTPException(status_code=422, detail=f"unknown toy: {body.toy!r}")
s = toy_score(body.toy)
before = _bridge.mood()
_bridge.ingest(s)
_snap.maybe_snapshot()
after = _bridge.mood()
read = [s.v, s.a, s.d, s.u, s.g, s.w, s.i]
deltas = [after[i] - before[i] for i in range(7)]
state = _state()
_enrich_physical(state, body.toy, read, deltas)
state["line"] = body.toy
_audit.append(build_row(
session=_SESSION,
text=f"[toy:{body.toy}]",
read=read,
mood_before=before,
mood_after=after,
deltas=deltas,
trace={"words": [], "structures": [], "contributors": []},
mood_word=state["mood_word"],
ts=time.time(),
))
return state
def _why(read: list[int], deltas: list[int]) -> str:
names = ["V", "A", "D", "U", "G", "W", "I"]
big = max(range(7), key=lambda i: abs(deltas[i]))
sign = "+" if deltas[big] >= 0 else ""
return f"strongest move: {names[big]} {sign}{deltas[big]} (read {names[big]}={read[big]})"
@app.post("/say")
def say(body: SayIn, request: Request):
text = body.text.strip()
if not text:
raise HTTPException(status_code=422, detail="empty text")
score, raw, trace = score_with_trace(text, source="space:chat")
_bridge.ingest(score)
_snap.maybe_snapshot()
mood = _bridge.mood()
state = _state()
state["read"] = raw
state["deltas"] = _bridge.deltas()
state["trace"] = shape_trace(trace)
state["crisis"] = crisis_block(raw, state["trace"].get("structures"))
state["why"] = _why(raw, state["deltas"])
state["simlish"] = simlish(mood)
state["voice"] = voice_params(mood)
state["acceptance"] = _bridge.last_acceptance()
breached = bool(state["acceptance"].get("breached"))
res = notable(raw, state["deltas"], breached=breached, crisis_emergency=False)
if res:
_moments.capture(res[0], raw, res[1])
# anonymous, sign-in-free recognition: every browser gets a hashed device id in
# its (persistent) session cookie. No HF login, no username — just "this device".
uid = request.session.get("uid")
if not uid:
uid = hash_uid(secrets.token_hex(16))
request.session["uid"] = uid
state["recognition"] = _recog.see(uid)
if not request.session.get("greeted") and _relation.known(uid):
aff = _relation.affinity(uid)
_bridge.ingest(Score(v=aff[0], a=aff[1], d=aff[2], u=aff[3], g=aff[4], w=aff[5], i=aff[6], source="relation:recall"))
request.session["greeted"] = True
_relation.observe(uid, raw, state["mood_word"])
aff = _relation.affinity(uid)
state["relation"] = {"known": _relation.known(uid), "affinity_valence": (aff or [128])[0]}
_audit.append(build_row(
session=_SESSION,
text=text,
read=raw,
mood_before=_bridge.previous_mood(),
mood_after=_bridge.mood(),
deltas=state["deltas"],
trace=state["trace"],
mood_word=state["mood_word"],
ts=time.time(),
))
return state
@app.get("/history")
def history():
return {"events": _bridge.history(limit=50)}
@app.get("/moments")
def moments():
return {"events": _moments.recent(limit=20)}
@app.get("/llms.txt")
def llms_txt():
"""Plain-text project explainer for LLMs/agents (llmstxt.org convention)."""
return FileResponse("static/llms.txt", media_type="text/plain")
@app.get("/robots.txt")
def robots_txt():
return PlainTextResponse(
"User-agent: *\nAllow: /\n\n# About this site: /llms.txt\n"
)
# static frontend (Task 7). Mounted last so /health etc. win.
if os.path.isdir("static"):
@app.get("/")
def index():
return FileResponse("static/index.html")
app.mount("/static", StaticFiles(directory="static"), name="static")