From d0ebcdfb17ccbb51825060f6af0576fd9111958d Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sat, 13 Jun 2026 11:12:52 -0700 Subject: [PATCH 1/8] feat(backend): orchestrator API, SQLite persistence, and shared contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI app on :8001 — /stimulus/send, /response/submit, /score, /brain, /history, /stimuli, /health - Orchestrates signals (8002) + ML (8003); degrades to local fallback scoring when either is offline so the backend runs standalone - SQLite via SQLAlchemy: users, sessions, score_records (+ raw signal history) - shared/contract.json reconciled to the live signals stimulus shape; shared/stimuli.json canonical manifest (mirrors signals bank ids/baselines) Co-Authored-By: Claude Opus 4.8 --- backend/.env.example | 10 ++ backend/.gitignore | 11 ++ backend/config.py | 27 +++++ backend/crud.py | 72 ++++++++++++ backend/main.py | 60 ++++++++++ backend/models/__init__.py | 0 backend/models/database.py | 28 +++++ backend/models/score_record.py | 64 +++++++++++ backend/models/session.py | 16 +++ backend/models/user.py | 15 +++ backend/requirements.txt | 6 + backend/routes/__init__.py | 0 backend/routes/brain.py | 16 +++ backend/routes/history.py | 14 +++ backend/routes/response.py | 77 +++++++++++++ backend/routes/score.py | 16 +++ backend/routes/stimulus.py | 26 +++++ backend/schemas.py | 33 ++++++ backend/services/__init__.py | 0 backend/services/scoring.py | 181 ++++++++++++++++++++++++++++++ backend/services/signal_client.py | 42 +++++++ backend/services/stimuli.py | 25 +++++ backend/services/tribe_client.py | 23 ++++ shared/contract.json | 68 +++++++++++ shared/stimuli.json | 22 ++++ 25 files changed, 852 insertions(+) create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/config.py create mode 100644 backend/crud.py create mode 100644 backend/main.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/database.py create mode 100644 backend/models/score_record.py create mode 100644 backend/models/session.py create mode 100644 backend/models/user.py create mode 100644 backend/requirements.txt create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/brain.py create mode 100644 backend/routes/history.py create mode 100644 backend/routes/response.py create mode 100644 backend/routes/score.py create mode 100644 backend/routes/stimulus.py create mode 100644 backend/schemas.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/scoring.py create mode 100644 backend/services/signal_client.py create mode 100644 backend/services/stimuli.py create mode 100644 backend/services/tribe_client.py create mode 100644 shared/contract.json create mode 100644 shared/stimuli.json diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e27bf7c --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,10 @@ +# Pegasus backend (orchestrator) config +BACKEND_PORT=8001 +SIGNAL_SERVICE_URL=http://localhost:8002 +ML_SERVICE_URL=http://localhost:8003 +DATABASE_URL=sqlite:///./pegasus.db +SERVICE_TIMEOUT=10.0 + +# Optional. Backend does not call Claude directly today; interventions come from +# the ML service. Present only so the file matches the team .env template. +ANTHROPIC_API_KEY= diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..0c41a40 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,11 @@ +# SQLite database files (created at runtime) +*.db +*.sqlite3 +pegasus.db + +# Local virtualenv +.venv/ +venv/ + +# Env +.env diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..838d81d --- /dev/null +++ b/backend/config.py @@ -0,0 +1,27 @@ +"""Central config for the Pegasus backend. Reads from environment / .env.""" +import os + +from dotenv import load_dotenv + +load_dotenv() + +# Where this backend listens. +BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8001")) + +# Downstream services we orchestrate. +SIGNAL_SERVICE_URL = os.getenv("SIGNAL_SERVICE_URL", "http://localhost:8002") +ML_SERVICE_URL = os.getenv("ML_SERVICE_URL", "http://localhost:8003") + +# Persistence. +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./pegasus.db") + +# HTTP timeout (seconds) for calls to downstream services. +SERVICE_TIMEOUT = float(os.getenv("SERVICE_TIMEOUT", "10.0")) + +# Path to the shared/ folder (canonical contract + stimulus manifest). +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +SHARED_DIR = os.getenv("SHARED_DIR", os.path.join(os.path.dirname(_THIS_DIR), "shared")) + +# Burnout level thresholds (score is 0-100, higher = more burnout). +YELLOW_THRESHOLD = 30 +RED_THRESHOLD = 65 diff --git a/backend/crud.py b/backend/crud.py new file mode 100644 index 0000000..bad51c6 --- /dev/null +++ b/backend/crud.py @@ -0,0 +1,72 @@ +"""Thin persistence helpers over the SQLAlchemy models.""" +import json + +from sqlalchemy.orm import Session + +from models.score_record import ScoreRecord +from models.session import Session as StimulusSession +from models.user import User + + +def get_user(db: Session, user_id: str) -> User | None: + return db.get(User, user_id) + + +def ensure_user(db: Session, user_id: str, name=None, phone=None) -> User: + user = db.get(User, user_id) + if user is None: + user = User(id=user_id, name=name, phone=phone) + db.add(user) + db.commit() + return user + + +def record_session(db: Session, user_id: str, stimulus_id: str) -> StimulusSession: + sess = StimulusSession(user_id=user_id, stimulus_id=stimulus_id) + db.add(sess) + db.commit() + return sess + + +def save_score(db: Session, user_id: str, stimulus_id: str, result: dict, raw: dict) -> ScoreRecord: + rec = ScoreRecord( + user_id=user_id, + stimulus_id=stimulus_id, + score=result["score"], + level=result["level"], + tribe_deviation=result["tribe_deviation"], + behavioral_deviation=result["behavioral_deviation"], + top_indicators=json.dumps(result.get("top_indicators", [])), + intervention=result.get("intervention", ""), + brain_regions_flagged=json.dumps(result.get("brain_regions_flagged", [])), + brain_regions=json.dumps(result.get("brain_regions", {})), + confidence=result.get("confidence", 0.0), + source=result.get("source", "fallback"), + response_text=raw.get("response_text"), + response_time_ms=raw.get("response_time_ms"), + typing_wpm=raw.get("typing_wpm"), + error_rate=raw.get("error_rate"), + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec + + +def latest_score(db: Session, user_id: str) -> ScoreRecord | None: + return ( + db.query(ScoreRecord) + .filter(ScoreRecord.user_id == user_id) + .order_by(ScoreRecord.timestamp.desc(), ScoreRecord.id.desc()) + .first() + ) + + +def score_history(db: Session, user_id: str, limit: int = 100) -> list[ScoreRecord]: + return ( + db.query(ScoreRecord) + .filter(ScoreRecord.user_id == user_id) + .order_by(ScoreRecord.timestamp.asc(), ScoreRecord.id.asc()) + .limit(limit) + .all() + ) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..ea853e3 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,60 @@ +"""Pegasus backend — FastAPI orchestrator on port 8001. + +Wesley's frontend calls this service; this service calls Dhruva's signals (8002) +and Rishith's ML/TRIBE (8003), persists everything to SQLite, and serves history. +""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +import config +from models.database import init_db +from routes import brain, history, response, score, stimulus + +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + + +app = FastAPI(title="Pegasus Backend Orchestrator", version="1.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(stimulus.router) +app.include_router(response.router) +app.include_router(score.router) +app.include_router(brain.router) +app.include_router(history.router) + + +@app.get("/") +def root(): + return {"service": "Pegasus Backend", "version": "1.1.0", "docs": "/docs"} + + +@app.get("/health") +def health(): + return { + "status": "ok", + "service": "backend", + "port": config.BACKEND_PORT, + "signals_url": config.SIGNAL_SERVICE_URL, + "ml_url": config.ML_SERVICE_URL, + } + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=config.BACKEND_PORT, reload=True) diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/database.py b/backend/models/database.py new file mode 100644 index 0000000..82f5c39 --- /dev/null +++ b/backend/models/database.py @@ -0,0 +1,28 @@ +"""SQLite + SQLAlchemy setup and the FastAPI DB dependency.""" +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +from config import DATABASE_URL + +# check_same_thread is a SQLite-only quirk; harmless to compute generally. +_connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} + +engine = create_engine(DATABASE_URL, connect_args=_connect_args) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + """FastAPI dependency: yields a session and always closes it.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Create tables. Import models first so they register on Base.metadata.""" + from models import user, session, score_record # noqa: F401 + + Base.metadata.create_all(bind=engine) diff --git a/backend/models/score_record.py b/backend/models/score_record.py new file mode 100644 index 0000000..cff2bb8 --- /dev/null +++ b/backend/models/score_record.py @@ -0,0 +1,64 @@ +"""Score history table. One row per scored response (a burnout_result snapshot).""" +import json +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, Float, Integer, String, Text + +from models.database import Base + + +class ScoreRecord(Base): + __tablename__ = "score_records" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(String, index=True) + stimulus_id = Column(String, nullable=True) + + score = Column(Integer) + level = Column(String) + tribe_deviation = Column(Float) + behavioral_deviation = Column(Float) + top_indicators = Column(Text) # JSON-encoded list[str] + intervention = Column(Text) + brain_regions_flagged = Column(Text) # JSON-encoded list[str] + brain_regions = Column(Text) # JSON-encoded dict[str, float] + confidence = Column(Float) + source = Column(String) # "tribe" | "fallback" + + # Raw behavioral signals kept for history / debugging. + response_text = Column(Text, nullable=True) + response_time_ms = Column(Float, nullable=True) + typing_wpm = Column(Float, nullable=True) + error_rate = Column(Float, nullable=True) + + timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + def to_result(self) -> dict: + """Render as a contract `burnout_result`.""" + return { + "user_id": self.user_id, + "stimulus_id": self.stimulus_id, + "score": self.score, + "level": self.level, + "tribe_deviation": self.tribe_deviation, + "behavioral_deviation": self.behavioral_deviation, + "top_indicators": json.loads(self.top_indicators or "[]"), + "intervention": self.intervention, + "brain_regions_flagged": json.loads(self.brain_regions_flagged or "[]"), + "confidence": self.confidence, + "source": self.source, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + } + + def brain_payload(self) -> dict: + """Render the richer brain view for GET /brain/{user_id}.""" + return { + "user_id": self.user_id, + "stimulus_id": self.stimulus_id, + "level": self.level, + "score": self.score, + "brain_regions_flagged": json.loads(self.brain_regions_flagged or "[]"), + "brain_regions": json.loads(self.brain_regions or "{}"), + "source": self.source, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + } diff --git a/backend/models/session.py b/backend/models/session.py new file mode 100644 index 0000000..0342e57 --- /dev/null +++ b/backend/models/session.py @@ -0,0 +1,16 @@ +"""Session table. One row per stimulus delivered to a user.""" +import uuid +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, ForeignKey, String + +from models.database import Base + + +class Session(Base): + __tablename__ = "sessions" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(String, ForeignKey("users.id"), index=True) + stimulus_id = Column(String, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/models/user.py b/backend/models/user.py new file mode 100644 index 0000000..1b063a5 --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,15 @@ +"""User table. A user is identified by a client-supplied user_id.""" +from datetime import datetime, timezone + +from sqlalchemy import Column, DateTime, String + +from models.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(String, primary_key=True, index=True) + name = Column(String, nullable=True) + phone = Column(String, nullable=True) # used for red-alert SMS via signals service + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..98d3106 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 +sqlalchemy>=2.0 +httpx>=0.27 +python-dotenv>=1.0 +pydantic>=2.6 diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/brain.py b/backend/routes/brain.py new file mode 100644 index 0000000..327900b --- /dev/null +++ b/backend/routes/brain.py @@ -0,0 +1,16 @@ +"""GET /brain/{user_id} — latest brain region activations for a user.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +import crud +from models.database import get_db + +router = APIRouter(tags=["brain"]) + + +@router.get("/brain/{user_id}") +def get_brain(user_id: str, db: Session = Depends(get_db)): + rec = crud.latest_score(db, user_id) + if rec is None: + raise HTTPException(status_code=404, detail=f"No brain data yet for user '{user_id}'") + return rec.brain_payload() diff --git a/backend/routes/history.py b/backend/routes/history.py new file mode 100644 index 0000000..0a20421 --- /dev/null +++ b/backend/routes/history.py @@ -0,0 +1,14 @@ +"""GET /history/{user_id} — past burnout_results in chronological order.""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +from models.database import get_db + +router = APIRouter(tags=["history"]) + + +@router.get("/history/{user_id}") +def get_history(user_id: str, db: Session = Depends(get_db)): + recs = crud.score_history(db, user_id) + return {"user_id": user_id, "count": len(recs), "history": [r.to_result() for r in recs]} diff --git a/backend/routes/response.py b/backend/routes/response.py new file mode 100644 index 0000000..9c5208e --- /dev/null +++ b/backend/routes/response.py @@ -0,0 +1,77 @@ +"""POST /response/submit — the orchestrator endpoint. + +Flow: signals (8002) -> ML score (8003) -> persist -> red-alert (best effort). +Each downstream call degrades gracefully so the backend works standalone. +""" +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +from models.database import get_db +from schemas import ResponseSubmitRequest +from services import scoring, signal_client, stimuli, tribe_client + +router = APIRouter(tags=["response"]) +log = logging.getLogger("pegasus.backend") + + +@router.post("/response/submit") +async def submit_response(req: ResponseSubmitRequest, db: Session = Depends(get_db)): + crud.ensure_user(db, req.user_id) + stimulus = stimuli.get_stimulus(req.stimulus_id) + raw = { + "response_text": req.response_text, + "response_time_ms": req.response_time_ms, + "typing_wpm": req.typing_wpm, + "error_rate": req.error_rate, + } + + # 1) Signal analysis (Dhruva, 8002). Fall back to a local heuristic if offline. + signals_ok = True + try: + analysis = await signal_client.analyze( + req.user_id, req.response_text, req.response_time_ms, req.typing_wpm, req.error_rate + ) + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("signals service unavailable, using local analysis: %s", exc) + analysis = scoring.local_analysis(raw) + signals_ok = False + + # 2) Burnout scoring (Rishith, 8003). Fall back to local scoring if offline. + ml_signals = { + **raw, + "sentiment_score": analysis.get("sentiment_score"), + "energy_level": analysis.get("energy_level"), + "linguistic_flags": analysis.get("linguistic_flags"), + "combined_signal_score": analysis.get("combined_signal_score"), + } + try: + ml_result = await tribe_client.score(req.user_id, req.stimulus_id, ml_signals) + result = scoring.normalize_ml_result(ml_result, stimulus) + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("ML service unavailable, using fallback scoring: %s", exc) + result = scoring.fallback_score(stimulus, analysis, raw) + + # 3) Persist. + rec = crud.save_score(db, req.user_id, req.stimulus_id, result, raw) + + # 4) Red alert via signals service (best effort, never fails the request). + alert = None + if result["level"] == "red": + user = crud.get_user(db, req.user_id) + if user and user.phone: + try: + alert = await signal_client.send_alert( + req.user_id, user.phone, result["score"], result["level"], result["intervention"] + ) + except Exception as exc: # noqa: BLE001 + log.warning("alert dispatch failed: %s", exc) + alert = {"sent": False, "error": str(exc)} + + out = rec.to_result() + out["services"] = {"signals": signals_ok, "ml": result["source"] == "tribe"} + if alert is not None: + out["alert"] = alert + return out diff --git a/backend/routes/score.py b/backend/routes/score.py new file mode 100644 index 0000000..6e99681 --- /dev/null +++ b/backend/routes/score.py @@ -0,0 +1,16 @@ +"""GET /score/{user_id} — latest burnout_result for a user.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +import crud +from models.database import get_db + +router = APIRouter(tags=["score"]) + + +@router.get("/score/{user_id}") +def get_score(user_id: str, db: Session = Depends(get_db)): + rec = crud.latest_score(db, user_id) + if rec is None: + raise HTTPException(status_code=404, detail=f"No score yet for user '{user_id}'") + return rec.to_result() diff --git a/backend/routes/stimulus.py b/backend/routes/stimulus.py new file mode 100644 index 0000000..b5a5dd4 --- /dev/null +++ b/backend/routes/stimulus.py @@ -0,0 +1,26 @@ +"""POST /stimulus/send and GET /stimuli.""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +import crud +from models.database import get_db +from schemas import StimulusSendRequest +from services import stimuli + +router = APIRouter(tags=["stimulus"]) + + +@router.post("/stimulus/send") +def send_stimulus(req: StimulusSendRequest, db: Session = Depends(get_db)): + stimulus = stimuli.get_stimulus(req.stimulus_id) + if stimulus is None: + raise HTTPException(status_code=404, detail=f"Unknown stimulus_id '{req.stimulus_id}'") + crud.ensure_user(db, req.user_id) + crud.record_session(db, req.user_id, req.stimulus_id) + return {"user_id": req.user_id, "stimulus": stimulus} + + +@router.get("/stimuli") +def list_stimuli(): + items = stimuli.all_stimuli() + return {"count": len(items), "stimuli": items} diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..861fad8 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,33 @@ +"""Pydantic request/response models aligned with shared/contract.json.""" +from typing import Optional + +from pydantic import BaseModel + + +class StimulusSendRequest(BaseModel): + user_id: str + stimulus_id: str + + +class ResponseSubmitRequest(BaseModel): + user_id: str + stimulus_id: str + response_text: str + response_time_ms: float = 0.0 + typing_wpm: float = 0.0 + error_rate: float = 0.0 # 0-100, percent of keystrokes that were backspaces + + +class BurnoutResult(BaseModel): + user_id: str + stimulus_id: Optional[str] = None + score: int + level: str + tribe_deviation: float + behavioral_deviation: float + top_indicators: list[str] + intervention: str + brain_regions_flagged: list[str] + confidence: float + source: str + timestamp: Optional[str] = None diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/scoring.py b/backend/services/scoring.py new file mode 100644 index 0000000..8ed48db --- /dev/null +++ b/backend/services/scoring.py @@ -0,0 +1,181 @@ +"""Backend-side scoring fallback + result normalization. + +When Rishith's ML service (8003) is up, we use its burnout_result directly +(normalize_ml_result). When it is offline, fallback_score produces a principled +burnout_result from the signals analysis + the stimulus TRIBE baseline, so the +backend is independently demoable. +""" +from config import RED_THRESHOLD, YELLOW_THRESHOLD + +# Named regions we surface as "flagged" per level, plus a small atlas we +# synthesize activations over for the brain heatmap. +_ACTIVE_REGIONS = { + "green": ["medial prefrontal cortex", "posterior cingulate cortex"], + "yellow": ["anterior cingulate cortex", "insula", "dorsolateral prefrontal cortex"], + "red": ["amygdala", "anterior cingulate cortex", "insula"], +} +_ATLAS = [ + "medial prefrontal cortex", + "dorsolateral prefrontal cortex", + "anterior cingulate cortex", + "posterior cingulate cortex", + "insula", + "amygdala", + "ventral striatum", + "hippocampus", +] + +_INTERVENTIONS = { + "green": "Your signals look steady today. Keep doing what works — a short walk " + "or a moment of gratitude can lock in the good momentum.", + "yellow": "Some early strain is showing. Try a 5-minute breathing break, step away " + "from the screen, and protect one boundary today (decline one nonessential " + "meeting).", + "red": "Your signals point to significant strain. Please pause — take 15 minutes away " + "from work, reach out to someone you trust, and consider one thing you can take " + "off your plate today. You don't have to push through alone.", +} + + +def level_for(score: int) -> str: + if score < YELLOW_THRESHOLD: + return "green" + if score < RED_THRESHOLD: + return "yellow" + return "red" + + +def _intervention(level: str) -> str: + return _INTERVENTIONS.get(level, _INTERVENTIONS["yellow"]) + + +def _brain(level: str, score: int): + """Return (flagged_regions, {region: activation 0-1}) synthesized from score.""" + flagged = _ACTIVE_REGIONS.get(level, _ACTIVE_REGIONS["yellow"]) + base = score / 100.0 + regions = {} + for r in _ATLAS: + if r in flagged: + regions[r] = round(min(1.0, 0.55 + base * 0.45), 3) + else: + regions[r] = round(max(0.0, 0.35 - base * 0.2), 3) + return flagged, regions + + +def _indicators(analysis: dict, raw: dict, level: str) -> list[str]: + out: list[str] = [] + if float(analysis.get("sentiment_score", 0.5)) < 0.4: + out.append("negative sentiment") + if analysis.get("energy_level") == "low": + out.append("low energy") + if float(raw.get("error_rate") or 0) > 15: + out.append("high typing error rate") + wpm = float(raw.get("typing_wpm") or 0) + if 0 < wpm < 20: + out.append("slowed typing") + if float(raw.get("response_time_ms") or 0) > 60000: + out.append("delayed response") + for flag in analysis.get("linguistic_flags") or []: + out.append(str(flag).replace("_", " ")) + + if not out: + out = ["healthy engagement"] if level == "green" else ["elevated stress signals"] + + # De-dupe, preserve order, cap at 5. + seen, deduped = set(), [] + for item in out: + if item not in seen: + seen.add(item) + deduped.append(item) + return deduped[:5] + + +def local_analysis(raw: dict) -> dict: + """Last-resort analysis when the signals service is ALSO offline. + + Derives a crude combined_signal_score (0-100, higher = more burnout) from the + raw behavioral metrics only. + """ + err = float(raw.get("error_rate") or 0) + wpm = float(raw.get("typing_wpm") or 0) + rt = float(raw.get("response_time_ms") or 0) + + combined = min(35.0, err) # backspace-heavy typing + if 0 < wpm < 20: + combined += 20 # very slow typing + elif wpm > 120: + combined += 10 # frantic typing + if rt > 60000: + combined += 20 # took over a minute + elif rt > 30000: + combined += 10 + combined = min(100.0, combined) + + return { + "sentiment_score": 0.5, + "energy_level": "low" if combined >= 40 else "medium", + "linguistic_flags": [], + "combined_signal_score": round(combined, 2), + "detail": {}, + "_local": True, + } + + +def fallback_score(stimulus: dict | None, analysis: dict, raw: dict) -> dict: + """Compute a burnout_result locally from signals + the TRIBE baseline.""" + expected = float(stimulus["tribe_expected_engagement"]) if stimulus else 0.5 + combined = float(analysis.get("combined_signal_score", 50)) / 100.0 # 0-1 burnout + actual_engagement = max(0.0, min(1.0, 1 - combined)) + + tribe_dev = round(abs(expected - actual_engagement), 3) + behavioral_dev = round(combined, 3) + + score = int(round(100 * (0.6 * behavioral_dev + 0.4 * tribe_dev))) + score = max(0, min(100, score)) + level = level_for(score) + + flagged, regions = _brain(level, score) + return { + "score": score, + "level": level, + "tribe_deviation": tribe_dev, + "behavioral_deviation": behavioral_dev, + "top_indicators": _indicators(analysis, raw, level), + "intervention": _intervention(level), + "brain_regions_flagged": flagged, + "brain_regions": regions, + "confidence": 0.55 if analysis.get("_local") else 0.7, + "source": "fallback", + } + + +def normalize_ml_result(result: dict, stimulus: dict | None) -> dict: + """Coerce the ML service's burnout_result into our stored shape. + + Tolerates missing fields and synthesizes a brain_regions map if the ML + service did not return one. + """ + score = max(0, min(100, int(round(float(result.get("score", 0)))))) + level = result.get("level") or level_for(score) + + regions = result.get("brain_regions") or result.get("regions") + flagged = result.get("brain_regions_flagged") or [] + if not regions: + synth_flagged, regions = _brain(level, score) + if not flagged: + flagged = synth_flagged + elif not flagged: + flagged = [k for k, _ in sorted(regions.items(), key=lambda kv: -kv[1])[:3]] + + return { + "score": score, + "level": level, + "tribe_deviation": float(result.get("tribe_deviation", 0.0)), + "behavioral_deviation": float(result.get("behavioral_deviation", 0.0)), + "top_indicators": result.get("top_indicators") or [], + "intervention": result.get("intervention") or _intervention(level), + "brain_regions_flagged": flagged, + "brain_regions": regions, + "confidence": float(result.get("confidence", 0.8)), + "source": "tribe", + } diff --git a/backend/services/signal_client.py b/backend/services/signal_client.py new file mode 100644 index 0000000..77936cf --- /dev/null +++ b/backend/services/signal_client.py @@ -0,0 +1,42 @@ +"""HTTP client for Dhruva's signal-processing service (localhost:8002). + +Every function raises on transport/HTTP error; callers decide how to degrade. +""" +import httpx + +from config import SERVICE_TIMEOUT, SIGNAL_SERVICE_URL + + +async def analyze(user_id, response_text, response_time_ms, typing_wpm, error_rate) -> dict: + payload = { + "user_id": user_id, + "response_text": response_text, + "response_time_ms": response_time_ms, + "typing_wpm": typing_wpm, + "error_rate": error_rate, + } + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + r = await client.post(f"{SIGNAL_SERVICE_URL}/analyze", json=payload) + r.raise_for_status() + return r.json() + + +async def get_signals(user_id: str) -> dict: + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + r = await client.get(f"{SIGNAL_SERVICE_URL}/signals/{user_id}") + r.raise_for_status() + return r.json() + + +async def send_alert(user_id, phone, score, level, intervention) -> dict: + payload = { + "user_id": user_id, + "phone": phone, + "score": score, + "level": level, + "intervention": intervention, + } + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + r = await client.post(f"{SIGNAL_SERVICE_URL}/alert", json=payload) + r.raise_for_status() + return r.json() diff --git a/backend/services/stimuli.py b/backend/services/stimuli.py new file mode 100644 index 0000000..07d6b58 --- /dev/null +++ b/backend/services/stimuli.py @@ -0,0 +1,25 @@ +"""Load the canonical stimulus manifest from shared/stimuli.json.""" +import json +import os +from functools import lru_cache + +from config import SHARED_DIR + + +@lru_cache(maxsize=1) +def _load() -> list[dict]: + path = os.path.join(SHARED_DIR, "stimuli.json") + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("stimuli", []) + + +def all_stimuli() -> list[dict]: + return list(_load()) + + +def get_stimulus(stimulus_id: str) -> dict | None: + for s in _load(): + if s["id"] == stimulus_id: + return s + return None diff --git a/backend/services/tribe_client.py b/backend/services/tribe_client.py new file mode 100644 index 0000000..780a477 --- /dev/null +++ b/backend/services/tribe_client.py @@ -0,0 +1,23 @@ +"""HTTP client for Rishith's ML/TRIBE service (localhost:8003). + +Every function raises on transport/HTTP error; callers decide how to degrade. +""" +import httpx + +from config import ML_SERVICE_URL, SERVICE_TIMEOUT + + +async def score(user_id: str, stimulus_id: str, signals: dict) -> dict: + payload = {"user_id": user_id, "stimulus_id": stimulus_id, "signals": signals} + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + r = await client.post(f"{ML_SERVICE_URL}/score", json=payload) + r.raise_for_status() + return r.json() + + +async def predict(stimulus_path: str, modality: str) -> dict: + payload = {"stimulus_path": stimulus_path, "modality": modality} + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + r = await client.post(f"{ML_SERVICE_URL}/predict", json=payload) + r.raise_for_status() + return r.json() diff --git a/shared/contract.json b/shared/contract.json new file mode 100644 index 0000000..f073129 --- /dev/null +++ b/shared/contract.json @@ -0,0 +1,68 @@ +{ + "version": "1.1.0", + "owner": "jason (backend)", + "description": "Pegasus API contract. Jason owns this file. If you need a field, tell Jason.", + "services": { + "frontend": "http://localhost:3000", + "backend": "http://localhost:8001", + "signals": "http://localhost:8002", + "ml": "http://localhost:8003" + }, + "levels": { + "green": "score < 30", + "yellow": "30 <= score < 65", + "red": "score >= 65" + }, + "stimulus": { + "id": "string", + "type": "scenario|word|task", + "valence": "positive|negative|neutral", + "tribe_expected_engagement": "number (0-1), TRIBE v2 predicted engagement for a healthy brain", + "content": "string, the stimulus text shown to the user", + "prompt": "string, instruction for how the user should respond" + }, + "user_response": { + "user_id": "string", + "stimulus_id": "string", + "response_text": "string", + "response_time_ms": "number", + "typing_wpm": "number", + "error_rate": "number (0-100, percent of keystrokes that were backspaces)", + "timestamp": "ISO8601" + }, + "burnout_result": { + "user_id": "string", + "stimulus_id": "string", + "score": "number (0-100, higher = more burnout)", + "level": "green|yellow|red", + "tribe_deviation": "number (0-1)", + "behavioral_deviation": "number (0-1)", + "top_indicators": ["string"], + "intervention": "string", + "brain_regions_flagged": ["string"], + "confidence": "number (0-1)", + "source": "tribe|fallback (tribe = scored by ML service, fallback = backend heuristic when ML offline)", + "timestamp": "ISO8601" + }, + "endpoints": { + "backend (8001, owner: jason)": { + "POST /stimulus/send": "{ user_id, stimulus_id } -> { user_id, stimulus }", + "POST /response/submit": "{ user_id, stimulus_id, response_text, response_time_ms, typing_wpm, error_rate } -> burnout_result", + "GET /score/{user_id}": "-> latest burnout_result", + "GET /brain/{user_id}": "-> { brain_regions, brain_regions_flagged, level, score, ... }", + "GET /history/{user_id}": "-> { history: [burnout_result, ...] }", + "GET /stimuli": "-> { stimuli: [stimulus, ...] }", + "GET /health": "-> service status" + }, + "signals (8002, owner: dhruva)": { + "POST /analyze": "{ user_id, response_text, response_time_ms, typing_wpm, error_rate } -> { sentiment_score, energy_level, linguistic_flags, combined_signal_score, detail }", + "GET /signals/{user_id}": "-> latest analyzed signals", + "POST /alert": "{ user_id, phone, score, level, intervention } -> { sent, message_sid }" + }, + "ml (8003, owner: rishith)": { + "POST /predict": "{ stimulus_path, modality } -> { brain_activation, regions }", + "POST /score": "{ user_id, stimulus_id, signals } -> burnout_result" + } + }, + "notes": "stimulus shape mirrors signals/stimuli/bank.py (the live source of truth for stimuli). shared/stimuli.json is the canonical manifest the backend serves." +} diff --git a/shared/stimuli.json b/shared/stimuli.json new file mode 100644 index 0000000..f394fbb --- /dev/null +++ b/shared/stimuli.json @@ -0,0 +1,22 @@ +{ + "version": "1.0.0", + "note": "Canonical stimulus manifest. Mirrors ids + tribe_expected_engagement used by signals/stimuli/bank.py so the whole pipeline agrees on baselines.", + "stimuli": [ + { "id": "pos_1", "type": "scenario", "valence": "positive", "tribe_expected_engagement": 0.85, "content": "Your team just shipped a feature that users love.", "prompt": "How does this make you feel? Respond honestly in a sentence or two." }, + { "id": "pos_2", "type": "word", "valence": "positive", "tribe_expected_engagement": 0.80, "content": "VACATION", "prompt": "What comes to mind? Respond with whatever this word evokes for you." }, + { "id": "pos_3", "type": "scenario", "valence": "positive", "tribe_expected_engagement": 0.88, "content": "Your manager praised your work in front of the whole team.", "prompt": "How does this make you feel? Respond in a sentence or two." }, + { "id": "pos_4", "type": "word", "valence": "positive", "tribe_expected_engagement": 0.75, "content": "FRIDAY AFTERNOON", "prompt": "What comes to mind? Respond with whatever this evokes." }, + { "id": "pos_5", "type": "scenario", "valence": "positive", "tribe_expected_engagement": 0.82, "content": "You just solved a bug that had been blocking the team for three days.", "prompt": "How does this make you feel?" }, + { "id": "neg_1", "type": "scenario", "valence": "negative", "tribe_expected_engagement": 0.65, "content": "Your code broke production 10 minutes before a major client demo.", "prompt": "How do you feel reading this? Respond honestly." }, + { "id": "neg_2", "type": "word", "valence": "negative", "tribe_expected_engagement": 0.60, "content": "DEADLINE", "prompt": "What comes to mind immediately? Respond with whatever this word evokes." }, + { "id": "neg_3", "type": "scenario", "valence": "negative", "tribe_expected_engagement": 0.68, "content": "You have 47 unread Slack messages and a meeting starting in 5 minutes.", "prompt": "How does this scenario make you feel?" }, + { "id": "neg_4", "type": "word", "valence": "negative", "tribe_expected_engagement": 0.58, "content": "PERFORMANCE REVIEW", "prompt": "What comes to mind immediately? Respond with whatever this evokes." }, + { "id": "neg_5", "type": "scenario", "valence": "negative", "tribe_expected_engagement": 0.62, "content": "Your pull request has 23 change requests and the feature was due yesterday.", "prompt": "How does this make you feel?" }, + { "id": "neu_1", "type": "scenario", "valence": "neutral", "tribe_expected_engagement": 0.35, "content": "Conference room B is available from 2pm to 4pm.", "prompt": "Any thoughts on this? Just respond naturally." }, + { "id": "neu_2", "type": "word", "valence": "neutral", "tribe_expected_engagement": 0.30, "content": "OFFICE SUPPLIES", "prompt": "What comes to mind? Just respond naturally." }, + { "id": "neu_3", "type": "word", "valence": "neutral", "tribe_expected_engagement": 0.32, "content": "QUARTERLY REPORT", "prompt": "What does this bring to mind?" }, + { "id": "cog_1", "type": "task", "valence": "neutral", "tribe_expected_engagement": 0.70, "content": "List three things you're looking forward to this week.", "prompt": "Take your time. Be honest." }, + { "id": "cog_2", "type": "task", "valence": "neutral", "tribe_expected_engagement": 0.65, "content": "Describe how you feel about your current workload in two sentences.", "prompt": "Be as honest as you can." }, + { "id": "cog_3", "type": "task", "valence": "neutral", "tribe_expected_engagement": 0.60, "content": "What's one thing at work that has been draining your energy lately?", "prompt": "Take your time. There is no right answer." } + ] +} From 6f13ab3ed0822d59f5f00a65a9b54036bfa20a6e Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sat, 13 Jun 2026 11:29:13 -0700 Subject: [PATCH 2/8] =?UTF-8?q?feat(backend):=20v2=20=E2=80=94=20prefix=20?= =?UTF-8?q?routing,=20breakdown=20contract,=20metrics=20+=20video=20endpoi?= =?UTF-8?q?nts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure to prefix-based routers: /stimulus, /response, /score, /brain, /history, /metrics, /video - Contract v2: stimulus {type,url,category,prompt}; user_response +response_latency_ms +source(app|sms); burnout_result +breakdown{imessage,typing,facial,voice,tribe} - New: GET /metrics (trend + latest breakdown + total), POST /video/submit (forwards to video svc :8004, VideoRecord table) - Rename tribe_client -> ml_client (+get_baseline); add video_client; signal_client.send_alert matches Dhruva's live POST /alert (requires phone) - /stimulus/today daily rotation; /score returns friendly default before first check-in; keeps graceful fallback when signals/ML/video offline Co-Authored-By: Claude Opus 4.8 --- backend/config.py | 10 ++-- backend/crud.py | 55 +++++++++++++++------- backend/main.py | 31 ++++++------ backend/models/database.py | 2 +- backend/models/score_record.py | 49 ++++++------------- backend/models/user.py | 4 +- backend/models/video_record.py | 26 +++++++++++ backend/requirements.txt | 1 + backend/routes/brain.py | 37 ++++++++++++--- backend/routes/history.py | 8 ++-- backend/routes/metrics.py | 26 +++++++++++ backend/routes/response.py | 47 ++++++++++++------- backend/routes/score.py | 20 +++++--- backend/routes/stimulus.py | 34 +++++++------- backend/routes/video.py | 42 +++++++++++++++++ backend/schemas.py | 38 ++++++++------- backend/services/ml_client.py | 22 +++++++++ backend/services/scoring.py | 78 +++++++++++++++++++++++-------- backend/services/signal_client.py | 35 ++++++-------- backend/services/stimuli.py | 14 +++++- backend/services/tribe_client.py | 23 --------- backend/services/video_client.py | 16 +++++++ shared/contract.json | 63 ++++++++++++++----------- shared/stimuli.json | 26 ++++------- 24 files changed, 458 insertions(+), 249 deletions(-) create mode 100644 backend/models/video_record.py create mode 100644 backend/routes/metrics.py create mode 100644 backend/routes/video.py create mode 100644 backend/services/ml_client.py delete mode 100644 backend/services/tribe_client.py create mode 100644 backend/services/video_client.py diff --git a/backend/config.py b/backend/config.py index 838d81d..3c917ce 100644 --- a/backend/config.py +++ b/backend/config.py @@ -9,14 +9,16 @@ BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8001")) # Downstream services we orchestrate. -SIGNAL_SERVICE_URL = os.getenv("SIGNAL_SERVICE_URL", "http://localhost:8002") -ML_SERVICE_URL = os.getenv("ML_SERVICE_URL", "http://localhost:8003") +SIGNAL_SERVICE = os.getenv("SIGNAL_SERVICE_URL", "http://localhost:8002") +ML_SERVICE = os.getenv("ML_SERVICE_URL", "http://localhost:8003") +VIDEO_SERVICE = os.getenv("VIDEO_SERVICE_URL", "http://localhost:8004") # Persistence. DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./pegasus.db") -# HTTP timeout (seconds) for calls to downstream services. -SERVICE_TIMEOUT = float(os.getenv("SERVICE_TIMEOUT", "10.0")) +# HTTP timeouts (seconds) for calls to downstream services. +SERVICE_TIMEOUT = float(os.getenv("SERVICE_TIMEOUT", "15.0")) +VIDEO_TIMEOUT = float(os.getenv("VIDEO_TIMEOUT", "120.0")) # Path to the shared/ folder (canonical contract + stimulus manifest). _THIS_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/backend/crud.py b/backend/crud.py index bad51c6..8d0409c 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -1,11 +1,10 @@ """Thin persistence helpers over the SQLAlchemy models.""" -import json - from sqlalchemy.orm import Session from models.score_record import ScoreRecord -from models.session import Session as StimulusSession +from models.session import Session as PulseSession from models.user import User +from models.video_record import VideoRecord def get_user(db: Session, user_id: str) -> User | None: @@ -21,31 +20,27 @@ def ensure_user(db: Session, user_id: str, name=None, phone=None) -> User: return user -def record_session(db: Session, user_id: str, stimulus_id: str) -> StimulusSession: - sess = StimulusSession(user_id=user_id, stimulus_id=stimulus_id) +def record_session(db: Session, user_id: str, stimulus_id: str) -> PulseSession: + sess = PulseSession(user_id=user_id, stimulus_id=stimulus_id) db.add(sess) db.commit() return sess -def save_score(db: Session, user_id: str, stimulus_id: str, result: dict, raw: dict) -> ScoreRecord: +def save_score(db: Session, user_id: str, stimulus_id: str, result: dict, source: str) -> ScoreRecord: rec = ScoreRecord( user_id=user_id, stimulus_id=stimulus_id, score=result["score"], level=result["level"], - tribe_deviation=result["tribe_deviation"], - behavioral_deviation=result["behavioral_deviation"], - top_indicators=json.dumps(result.get("top_indicators", [])), + breakdown=result.get("breakdown", {}), + indicators=result.get("top_indicators", []), intervention=result.get("intervention", ""), - brain_regions_flagged=json.dumps(result.get("brain_regions_flagged", [])), - brain_regions=json.dumps(result.get("brain_regions", {})), + tribe_deviation=result.get("tribe_deviation", 0.0), + behavioral_deviation=result.get("behavioral_deviation", 0.0), + brain_regions_flagged=result.get("brain_regions_flagged", []), confidence=result.get("confidence", 0.0), - source=result.get("source", "fallback"), - response_text=raw.get("response_text"), - response_time_ms=raw.get("response_time_ms"), - typing_wpm=raw.get("typing_wpm"), - error_rate=raw.get("error_rate"), + source=source, ) db.add(rec) db.commit() @@ -62,11 +57,35 @@ def latest_score(db: Session, user_id: str) -> ScoreRecord | None: ) -def score_history(db: Session, user_id: str, limit: int = 100) -> list[ScoreRecord]: +def score_history(db: Session, user_id: str, limit: int = 30) -> list[ScoreRecord]: + """Most recent first, up to `limit`.""" return ( db.query(ScoreRecord) .filter(ScoreRecord.user_id == user_id) - .order_by(ScoreRecord.timestamp.asc(), ScoreRecord.id.asc()) + .order_by(ScoreRecord.timestamp.desc(), ScoreRecord.id.desc()) .limit(limit) .all() ) + + +def score_trend(db: Session, user_id: str) -> list[ScoreRecord]: + """Oldest first, for charting.""" + return ( + db.query(ScoreRecord) + .filter(ScoreRecord.user_id == user_id) + .order_by(ScoreRecord.timestamp.asc(), ScoreRecord.id.asc()) + .all() + ) + + +def save_video(db: Session, user_id: str, facial_score: int, facial_data: dict, voice_data: dict) -> VideoRecord: + rec = VideoRecord( + user_id=user_id, + facial_score=facial_score, + facial_data=facial_data, + voice_data=voice_data, + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec diff --git a/backend/main.py b/backend/main.py index ea853e3..88890e2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,7 +1,8 @@ """Pegasus backend — FastAPI orchestrator on port 8001. -Wesley's frontend calls this service; this service calls Dhruva's signals (8002) -and Rishith's ML/TRIBE (8003), persists everything to SQLite, and serves history. +Wesley's Expo app and Dhruva's SMS bot both call this service; it calls Dhruva's +signals (8002), Rishith's ML/TRIBE (8003) and video (8004), persists everything +to SQLite, and serves history/metrics/brain views. """ import logging from contextlib import asynccontextmanager @@ -11,7 +12,7 @@ import config from models.database import init_db -from routes import brain, history, response, score, stimulus +from routes import brain, history, metrics, response, score, stimulus, video logging.basicConfig(level=logging.INFO) @@ -22,7 +23,7 @@ async def lifespan(app: FastAPI): yield -app = FastAPI(title="Pegasus Backend Orchestrator", version="1.1.0", lifespan=lifespan) +app = FastAPI(title="Pegasus Backend", version="2.0.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -31,26 +32,28 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) -app.include_router(stimulus.router) -app.include_router(response.router) -app.include_router(score.router) -app.include_router(brain.router) -app.include_router(history.router) +app.include_router(stimulus.router, prefix="/stimulus", tags=["stimulus"]) +app.include_router(response.router, prefix="/response", tags=["response"]) +app.include_router(score.router, prefix="/score", tags=["score"]) +app.include_router(brain.router, prefix="/brain", tags=["brain"]) +app.include_router(history.router, prefix="/history", tags=["history"]) +app.include_router(metrics.router, prefix="/metrics", tags=["metrics"]) +app.include_router(video.router, prefix="/video", tags=["video"]) @app.get("/") def root(): - return {"service": "Pegasus Backend", "version": "1.1.0", "docs": "/docs"} + return {"service": "Pegasus Backend", "version": "2.0.0", "docs": "/docs"} @app.get("/health") def health(): return { - "status": "ok", - "service": "backend", + "status": "pegasus backend running", "port": config.BACKEND_PORT, - "signals_url": config.SIGNAL_SERVICE_URL, - "ml_url": config.ML_SERVICE_URL, + "signals_url": config.SIGNAL_SERVICE, + "ml_url": config.ML_SERVICE, + "video_url": config.VIDEO_SERVICE, } diff --git a/backend/models/database.py b/backend/models/database.py index 82f5c39..bd05e3b 100644 --- a/backend/models/database.py +++ b/backend/models/database.py @@ -23,6 +23,6 @@ def get_db(): def init_db(): """Create tables. Import models first so they register on Base.metadata.""" - from models import user, session, score_record # noqa: F401 + from models import score_record, session, user, video_record # noqa: F401 Base.metadata.create_all(bind=engine) diff --git a/backend/models/score_record.py b/backend/models/score_record.py index cff2bb8..7bf7755 100644 --- a/backend/models/score_record.py +++ b/backend/models/score_record.py @@ -1,14 +1,13 @@ -"""Score history table. One row per scored response (a burnout_result snapshot).""" -import json +"""Score history table ("scores"). One row per scored response (app or sms).""" from datetime import datetime, timezone -from sqlalchemy import Column, DateTime, Float, Integer, String, Text +from sqlalchemy import JSON, Column, DateTime, Float, Integer, String from models.database import Base class ScoreRecord(Base): - __tablename__ = "score_records" + __tablename__ = "scores" id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(String, index=True) @@ -16,25 +15,19 @@ class ScoreRecord(Base): score = Column(Integer) level = Column(String) - tribe_deviation = Column(Float) - behavioral_deviation = Column(Float) - top_indicators = Column(Text) # JSON-encoded list[str] - intervention = Column(Text) - brain_regions_flagged = Column(Text) # JSON-encoded list[str] - brain_regions = Column(Text) # JSON-encoded dict[str, float] - confidence = Column(Float) - source = Column(String) # "tribe" | "fallback" - - # Raw behavioral signals kept for history / debugging. - response_text = Column(Text, nullable=True) - response_time_ms = Column(Float, nullable=True) - typing_wpm = Column(Float, nullable=True) - error_rate = Column(Float, nullable=True) + breakdown = Column(JSON, default=dict) # {imessage, typing, facial, voice, tribe} + indicators = Column(JSON, default=list) # top_indicators: list[str] + intervention = Column(String) + tribe_deviation = Column(Float, default=0.0) + behavioral_deviation = Column(Float, default=0.0) + brain_regions_flagged = Column(JSON, default=list) + confidence = Column(Float, default=0.0) + source = Column(String, default="app") # "app" | "sms" timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) def to_result(self) -> dict: - """Render as a contract `burnout_result`.""" + """Render as a contract `burnout_result` (with helpful extra fields).""" return { "user_id": self.user_id, "stimulus_id": self.stimulus_id, @@ -42,23 +35,11 @@ def to_result(self) -> dict: "level": self.level, "tribe_deviation": self.tribe_deviation, "behavioral_deviation": self.behavioral_deviation, - "top_indicators": json.loads(self.top_indicators or "[]"), + "top_indicators": self.indicators or [], "intervention": self.intervention, - "brain_regions_flagged": json.loads(self.brain_regions_flagged or "[]"), + "brain_regions_flagged": self.brain_regions_flagged or [], "confidence": self.confidence, - "source": self.source, - "timestamp": self.timestamp.isoformat() if self.timestamp else None, - } - - def brain_payload(self) -> dict: - """Render the richer brain view for GET /brain/{user_id}.""" - return { - "user_id": self.user_id, - "stimulus_id": self.stimulus_id, - "level": self.level, - "score": self.score, - "brain_regions_flagged": json.loads(self.brain_regions_flagged or "[]"), - "brain_regions": json.loads(self.brain_regions or "{}"), + "breakdown": self.breakdown or {}, "source": self.source, "timestamp": self.timestamp.isoformat() if self.timestamp else None, } diff --git a/backend/models/user.py b/backend/models/user.py index 1b063a5..bfb27a6 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -1,4 +1,4 @@ -"""User table. A user is identified by a client-supplied user_id.""" +"""User table. Identified by a client-supplied user_id; phone used for SMS alerts.""" from datetime import datetime, timezone from sqlalchemy import Column, DateTime, String @@ -10,6 +10,6 @@ class User(Base): __tablename__ = "users" id = Column(String, primary_key=True, index=True) + phone = Column(String, unique=True, nullable=True) # red-alert SMS via signals service name = Column(String, nullable=True) - phone = Column(String, nullable=True) # used for red-alert SMS via signals service created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/backend/models/video_record.py b/backend/models/video_record.py new file mode 100644 index 0000000..6161ac1 --- /dev/null +++ b/backend/models/video_record.py @@ -0,0 +1,26 @@ +"""Video check-in records ("videos"). Facial + voice stress from the video service.""" +from datetime import datetime, timezone + +from sqlalchemy import JSON, Column, DateTime, Integer, String + +from models.database import Base + + +class VideoRecord(Base): + __tablename__ = "videos" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(String, index=True) + facial_score = Column(Integer, default=0) + facial_data = Column(JSON, default=dict) + voice_data = Column(JSON, default=dict) + timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict: + return { + "user_id": self.user_id, + "facial_score": self.facial_score, + "facial": self.facial_data or {}, + "voice": self.voice_data or {}, + "timestamp": self.timestamp.isoformat() if self.timestamp else None, + } diff --git a/backend/requirements.txt b/backend/requirements.txt index 98d3106..fdacde6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,4 @@ sqlalchemy>=2.0 httpx>=0.27 python-dotenv>=1.0 pydantic>=2.6 +python-multipart>=0.0.9 diff --git a/backend/routes/brain.py b/backend/routes/brain.py index 327900b..6687d4d 100644 --- a/backend/routes/brain.py +++ b/backend/routes/brain.py @@ -1,16 +1,39 @@ -"""GET /brain/{user_id} — latest brain region activations for a user.""" -from fastapi import APIRouter, Depends, HTTPException +"""GET /brain/{user_id} — TRIBE brain data for the user's latest stimulus (prefix /brain).""" +import logging + +from fastapi import APIRouter, Depends from sqlalchemy.orm import Session import crud from models.database import get_db +from services import ml_client, scoring -router = APIRouter(tags=["brain"]) +router = APIRouter() +log = logging.getLogger("pegasus.backend") -@router.get("/brain/{user_id}") -def get_brain(user_id: str, db: Session = Depends(get_db)): +@router.get("/{user_id}") +async def get_brain(user_id: str, db: Session = Depends(get_db)): rec = crud.latest_score(db, user_id) if rec is None: - raise HTTPException(status_code=404, detail=f"No brain data yet for user '{user_id}'") - return rec.brain_payload() + return {"user_id": user_id, "regions": {}, "brain_regions_flagged": [], "source": "none"} + + # Prefer the ML service's real TRIBE baseline; fall back to a synthesized map. + try: + baseline = await ml_client.get_baseline(rec.stimulus_id) + baseline["user_id"] = user_id + baseline["stimulus_id"] = rec.stimulus_id + baseline.setdefault("source", "tribe") + return baseline + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("ML baseline unavailable, synthesizing brain map: %s", exc) + flagged, regions = scoring.synth_brain(rec.level, rec.score) + return { + "user_id": user_id, + "stimulus_id": rec.stimulus_id, + "level": rec.level, + "score": rec.score, + "regions": regions, + "brain_regions_flagged": rec.brain_regions_flagged or flagged, + "source": "fallback", + } diff --git a/backend/routes/history.py b/backend/routes/history.py index 0a20421..19fb22b 100644 --- a/backend/routes/history.py +++ b/backend/routes/history.py @@ -1,14 +1,14 @@ -"""GET /history/{user_id} — past burnout_results in chronological order.""" +"""GET /history/{user_id} — recent burnout_results, newest first (prefix /history).""" from fastapi import APIRouter, Depends from sqlalchemy.orm import Session import crud from models.database import get_db -router = APIRouter(tags=["history"]) +router = APIRouter() -@router.get("/history/{user_id}") +@router.get("/{user_id}") def get_history(user_id: str, db: Session = Depends(get_db)): recs = crud.score_history(db, user_id) - return {"user_id": user_id, "count": len(recs), "history": [r.to_result() for r in recs]} + return [r.to_result() for r in recs] diff --git a/backend/routes/metrics.py b/backend/routes/metrics.py new file mode 100644 index 0000000..9bb4665 --- /dev/null +++ b/backend/routes/metrics.py @@ -0,0 +1,26 @@ +"""GET /metrics/{user_id} — trends + latest breakdown (prefix /metrics).""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +import crud +from models.database import get_db + +router = APIRouter() + + +@router.get("/{user_id}") +def get_metrics(user_id: str, db: Session = Depends(get_db)): + recs = crud.score_trend(db, user_id) # oldest first + return { + "user_id": user_id, + "score_trend": [ + { + "date": r.timestamp.isoformat() if r.timestamp else None, + "score": r.score, + "level": r.level, + } + for r in recs + ], + "latest_breakdown": recs[-1].breakdown if recs else {}, + "total_checkins": len(recs), + } diff --git a/backend/routes/response.py b/backend/routes/response.py index 9c5208e..26dc90c 100644 --- a/backend/routes/response.py +++ b/backend/routes/response.py @@ -1,6 +1,7 @@ -"""POST /response/submit — the orchestrator endpoint. +"""POST /response/submit — the orchestrator (prefix /response). Flow: signals (8002) -> ML score (8003) -> persist -> red-alert (best effort). +Called by Wesley's app (source="app") and Dhruva's SMS bot (source="sms"). Each downstream call degrades gracefully so the backend works standalone. """ import logging @@ -11,28 +12,35 @@ import crud from models.database import get_db from schemas import ResponseSubmitRequest -from services import scoring, signal_client, stimuli, tribe_client +from services import ml_client, scoring, signal_client, stimuli -router = APIRouter(tags=["response"]) +router = APIRouter() log = logging.getLogger("pegasus.backend") -@router.post("/response/submit") -async def submit_response(req: ResponseSubmitRequest, db: Session = Depends(get_db)): - crud.ensure_user(db, req.user_id) - stimulus = stimuli.get_stimulus(req.stimulus_id) +@router.post("/submit") +async def submit_response(data: ResponseSubmitRequest, db: Session = Depends(get_db)): + crud.ensure_user(db, data.user_id) + stimulus = stimuli.get_stimulus(data.stimulus_id) raw = { - "response_text": req.response_text, - "response_time_ms": req.response_time_ms, - "typing_wpm": req.typing_wpm, - "error_rate": req.error_rate, + "response_text": data.response_text, + "response_time_ms": data.response_time_ms, + "response_latency_ms": data.response_latency_ms, + "typing_wpm": data.typing_wpm, + "error_rate": data.error_rate, } # 1) Signal analysis (Dhruva, 8002). Fall back to a local heuristic if offline. signals_ok = True try: analysis = await signal_client.analyze( - req.user_id, req.response_text, req.response_time_ms, req.typing_wpm, req.error_rate + { + "user_id": data.user_id, + "response_text": data.response_text, + "response_time_ms": data.response_time_ms, + "typing_wpm": data.typing_wpm, + "error_rate": data.error_rate, + } ) except Exception as exc: # noqa: BLE001 - degrade gracefully log.warning("signals service unavailable, using local analysis: %s", exc) @@ -41,30 +49,33 @@ async def submit_response(req: ResponseSubmitRequest, db: Session = Depends(get_ # 2) Burnout scoring (Rishith, 8003). Fall back to local scoring if offline. ml_signals = { - **raw, "sentiment_score": analysis.get("sentiment_score"), "energy_level": analysis.get("energy_level"), "linguistic_flags": analysis.get("linguistic_flags"), "combined_signal_score": analysis.get("combined_signal_score"), + "typing_wpm": data.typing_wpm, + "error_rate": data.error_rate, + "response_time_ms": data.response_time_ms, + "response_latency_ms": data.response_latency_ms, } try: - ml_result = await tribe_client.score(req.user_id, req.stimulus_id, ml_signals) - result = scoring.normalize_ml_result(ml_result, stimulus) + ml_result = await ml_client.score(data.user_id, data.stimulus_id, ml_signals) + result = scoring.normalize_ml_result(ml_result, stimulus, analysis, raw) except Exception as exc: # noqa: BLE001 - degrade gracefully log.warning("ML service unavailable, using fallback scoring: %s", exc) result = scoring.fallback_score(stimulus, analysis, raw) # 3) Persist. - rec = crud.save_score(db, req.user_id, req.stimulus_id, result, raw) + rec = crud.save_score(db, data.user_id, data.stimulus_id, result, data.source) # 4) Red alert via signals service (best effort, never fails the request). alert = None if result["level"] == "red": - user = crud.get_user(db, req.user_id) + user = crud.get_user(db, data.user_id) if user and user.phone: try: alert = await signal_client.send_alert( - req.user_id, user.phone, result["score"], result["level"], result["intervention"] + data.user_id, user.phone, result["score"], result["level"], result["intervention"] ) except Exception as exc: # noqa: BLE001 log.warning("alert dispatch failed: %s", exc) diff --git a/backend/routes/score.py b/backend/routes/score.py index 6e99681..a5961aa 100644 --- a/backend/routes/score.py +++ b/backend/routes/score.py @@ -1,16 +1,24 @@ -"""GET /score/{user_id} — latest burnout_result for a user.""" -from fastapi import APIRouter, Depends, HTTPException +"""GET /score/{user_id} — latest burnout_result (prefix /score).""" +from fastapi import APIRouter, Depends from sqlalchemy.orm import Session import crud from models.database import get_db -router = APIRouter(tags=["score"]) +router = APIRouter() -@router.get("/score/{user_id}") -def get_score(user_id: str, db: Session = Depends(get_db)): +@router.get("/{user_id}") +def get_latest_score(user_id: str, db: Session = Depends(get_db)): rec = crud.latest_score(db, user_id) if rec is None: - raise HTTPException(status_code=404, detail=f"No score yet for user '{user_id}'") + return { + "user_id": user_id, + "score": 0, + "level": "green", + "intervention": "Take your first pulse check!", + "breakdown": {}, + "top_indicators": [], + "source": "none", + } return rec.to_result() diff --git a/backend/routes/stimulus.py b/backend/routes/stimulus.py index b5a5dd4..ffe4d2b 100644 --- a/backend/routes/stimulus.py +++ b/backend/routes/stimulus.py @@ -1,26 +1,26 @@ -"""POST /stimulus/send and GET /stimuli.""" -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy.orm import Session +"""Stimulus selection + delivery (prefix /stimulus).""" +from fastapi import APIRouter, HTTPException -import crud -from models.database import get_db -from schemas import StimulusSendRequest from services import stimuli -router = APIRouter(tags=["stimulus"]) +router = APIRouter() -@router.post("/stimulus/send") -def send_stimulus(req: StimulusSendRequest, db: Session = Depends(get_db)): - stimulus = stimuli.get_stimulus(req.stimulus_id) - if stimulus is None: - raise HTTPException(status_code=404, detail=f"Unknown stimulus_id '{req.stimulus_id}'") - crud.ensure_user(db, req.user_id) - crud.record_session(db, req.user_id, req.stimulus_id) - return {"user_id": req.user_id, "stimulus": stimulus} +@router.get("/today/{user_id}") +def get_today_stimulus(user_id: str): + """Return today's stimulus for a user (stable daily rotation).""" + return stimuli.stimulus_for_today(user_id) -@router.get("/stimuli") -def list_stimuli(): +@router.get("/all") +def get_all_stimuli(): items = stimuli.all_stimuli() return {"count": len(items), "stimuli": items} + + +@router.get("/{stimulus_id}") +def get_stimulus(stimulus_id: str): + s = stimuli.get_stimulus(stimulus_id) + if s is None: + raise HTTPException(status_code=404, detail=f"Unknown stimulus_id '{stimulus_id}'") + return s diff --git a/backend/routes/video.py b/backend/routes/video.py new file mode 100644 index 0000000..d27e519 --- /dev/null +++ b/backend/routes/video.py @@ -0,0 +1,42 @@ +"""POST /video/submit — video check-in (prefix /video). + +Forwards the upload to Rishith's video service (8004), stores facial + voice +stress, and returns the analysis. Degrades gracefully if the service is offline. +""" +import logging + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from sqlalchemy.orm import Session + +import crud +from models.database import get_db +from services import video_client + +router = APIRouter() +log = logging.getLogger("pegasus.backend") + + +@router.post("/submit") +async def submit_video( + video: UploadFile = File(...), + user_id: str = Form(...), + db: Session = Depends(get_db), +): + crud.ensure_user(db, user_id) + content = await video.read() + + video_ok = True + try: + result = await video_client.analyze(content, video.filename or "checkin.mp4") + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("video service unavailable: %s", exc) + result = {"facial": {}, "voice": {}, "error": f"video service unavailable: {exc}"} + video_ok = False + + facial = result.get("facial", {}) or {} + voice = result.get("voice", {}) or {} + facial_score = int(facial.get("facial_stress_score", 0) or 0) + crud.save_video(db, user_id, facial_score, facial, voice) + + result["video_service"] = video_ok + return result diff --git a/backend/schemas.py b/backend/schemas.py index 861fad8..92b0f11 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -1,33 +1,39 @@ -"""Pydantic request/response models aligned with shared/contract.json.""" +"""Pydantic request/response models aligned with shared/contract.json (v2).""" from typing import Optional from pydantic import BaseModel -class StimulusSendRequest(BaseModel): - user_id: str - stimulus_id: str - - class ResponseSubmitRequest(BaseModel): user_id: str stimulus_id: str - response_text: str + response_text: str = "" response_time_ms: float = 0.0 + response_latency_ms: float = 0.0 typing_wpm: float = 0.0 - error_rate: float = 0.0 # 0-100, percent of keystrokes that were backspaces + error_rate: float = 0.0 # 0-100 + source: str = "app" # "app" | "sms" + + +class Breakdown(BaseModel): + imessage: float = 0.0 + typing: float = 0.0 + facial: float = 0.0 + voice: float = 0.0 + tribe: float = 0.0 class BurnoutResult(BaseModel): - user_id: str + user_id: Optional[str] = None stimulus_id: Optional[str] = None score: int level: str - tribe_deviation: float - behavioral_deviation: float - top_indicators: list[str] - intervention: str - brain_regions_flagged: list[str] - confidence: float - source: str + tribe_deviation: float = 0.0 + behavioral_deviation: float = 0.0 + top_indicators: list[str] = [] + intervention: str = "" + brain_regions_flagged: list[str] = [] + confidence: float = 0.0 + breakdown: Breakdown = Breakdown() + source: Optional[str] = None timestamp: Optional[str] = None diff --git a/backend/services/ml_client.py b/backend/services/ml_client.py new file mode 100644 index 0000000..3034f4d --- /dev/null +++ b/backend/services/ml_client.py @@ -0,0 +1,22 @@ +"""HTTP client for Rishith's ML/TRIBE service (localhost:8003). + +Functions raise on transport/HTTP error; callers decide how to degrade. +""" +import httpx + +from config import ML_SERVICE, SERVICE_TIMEOUT + + +async def score(user_id: str, stimulus_id: str, signals: dict) -> dict: + payload = {"user_id": user_id, "stimulus_id": stimulus_id, "signals": signals} + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.post(f"{ML_SERVICE}/score", json=payload) + res.raise_for_status() + return res.json() + + +async def get_baseline(stimulus_id: str) -> dict: + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.get(f"{ML_SERVICE}/baseline/{stimulus_id}") + res.raise_for_status() + return res.json() diff --git a/backend/services/scoring.py b/backend/services/scoring.py index 8ed48db..fc3fc71 100644 --- a/backend/services/scoring.py +++ b/backend/services/scoring.py @@ -2,13 +2,14 @@ When Rishith's ML service (8003) is up, we use its burnout_result directly (normalize_ml_result). When it is offline, fallback_score produces a principled -burnout_result from the signals analysis + the stimulus TRIBE baseline, so the -backend is independently demoable. +burnout_result from the signals analysis + the stimulus category baseline, so the +backend is independently demoable. Both paths emit the contract `breakdown`. """ from config import RED_THRESHOLD, YELLOW_THRESHOLD -# Named regions we surface as "flagged" per level, plus a small atlas we -# synthesize activations over for the brain heatmap. +# A healthy brain is expected to engage more with activating content than calm. +_CATEGORY_EXPECTED = {"calm": 0.45, "neutral": 0.50, "activating": 0.72} + _ACTIVE_REGIONS = { "green": ["medial prefrontal cortex", "posterior cingulate cortex"], "yellow": ["anterior cingulate cortex", "insula", "dorsolateral prefrontal cortex"], @@ -49,6 +50,20 @@ def _intervention(level: str) -> str: return _INTERVENTIONS.get(level, _INTERVENTIONS["yellow"]) +def _expected_engagement(stimulus: dict | None) -> float: + if not stimulus: + return 0.5 + # New contract stimuli use `category`; legacy/SMS stimuli carry an explicit baseline. + if "tribe_expected_engagement" in stimulus: + return float(stimulus["tribe_expected_engagement"]) + return _CATEGORY_EXPECTED.get(stimulus.get("category"), 0.5) + + +def synth_brain(level: str, score: int): + """Public: synthesized (flagged_regions, regions) for the brain-view fallback.""" + return _brain(level, score) + + def _brain(level: str, score: int): """Return (flagged_regions, {region: activation 0-1}) synthesized from score.""" flagged = _ACTIVE_REGIONS.get(level, _ACTIVE_REGIONS["yellow"]) @@ -62,6 +77,30 @@ def _brain(level: str, score: int): return flagged, regions +def _typing_burnout(raw: dict) -> float: + """0-100 typing-biometrics burnout contribution from raw signals.""" + err = float(raw.get("error_rate") or 0) + wpm = float(raw.get("typing_wpm") or 0) + val = min(60.0, err) # backspace-heavy typing + if 0 < wpm < 20: + val += 25 # very slow typing + elif wpm > 120: + val += 10 # frantic typing + return round(min(100.0, val), 1) + + +def _breakdown(analysis: dict, raw: dict, tribe_dev: float) -> dict: + """Per-stream burnout contributions (0-100). Facial/voice come from video only.""" + sentiment = float(analysis.get("sentiment_score", 0.5)) + return { + "imessage": round(min(100.0, (1 - sentiment) * 100), 1), + "typing": _typing_burnout(raw), + "facial": 0.0, + "voice": 0.0, + "tribe": round(min(100.0, tribe_dev * 100), 1), + } + + def _indicators(analysis: dict, raw: dict, level: str) -> list[str]: out: list[str] = [] if float(analysis.get("sentiment_score", 0.5)) < 0.4: @@ -81,7 +120,6 @@ def _indicators(analysis: dict, raw: dict, level: str) -> list[str]: if not out: out = ["healthy engagement"] if level == "green" else ["elevated stress signals"] - # De-dupe, preserve order, cap at 5. seen, deduped = set(), [] for item in out: if item not in seen: @@ -91,22 +129,18 @@ def _indicators(analysis: dict, raw: dict, level: str) -> list[str]: def local_analysis(raw: dict) -> dict: - """Last-resort analysis when the signals service is ALSO offline. - - Derives a crude combined_signal_score (0-100, higher = more burnout) from the - raw behavioral metrics only. - """ + """Last-resort analysis when the signals service is ALSO offline.""" err = float(raw.get("error_rate") or 0) wpm = float(raw.get("typing_wpm") or 0) rt = float(raw.get("response_time_ms") or 0) - combined = min(35.0, err) # backspace-heavy typing + combined = min(35.0, err) if 0 < wpm < 20: - combined += 20 # very slow typing + combined += 20 elif wpm > 120: - combined += 10 # frantic typing + combined += 10 if rt > 60000: - combined += 20 # took over a minute + combined += 20 elif rt > 30000: combined += 10 combined = min(100.0, combined) @@ -122,8 +156,8 @@ def local_analysis(raw: dict) -> dict: def fallback_score(stimulus: dict | None, analysis: dict, raw: dict) -> dict: - """Compute a burnout_result locally from signals + the TRIBE baseline.""" - expected = float(stimulus["tribe_expected_engagement"]) if stimulus else 0.5 + """Compute a burnout_result locally from signals + the stimulus baseline.""" + expected = _expected_engagement(stimulus) combined = float(analysis.get("combined_signal_score", 50)) / 100.0 # 0-1 burnout actual_engagement = max(0.0, min(1.0, 1 - combined)) @@ -144,16 +178,16 @@ def fallback_score(stimulus: dict | None, analysis: dict, raw: dict) -> dict: "intervention": _intervention(level), "brain_regions_flagged": flagged, "brain_regions": regions, + "breakdown": _breakdown(analysis, raw, tribe_dev), "confidence": 0.55 if analysis.get("_local") else 0.7, "source": "fallback", } -def normalize_ml_result(result: dict, stimulus: dict | None) -> dict: +def normalize_ml_result(result: dict, stimulus: dict | None, analysis: dict, raw: dict) -> dict: """Coerce the ML service's burnout_result into our stored shape. - Tolerates missing fields and synthesizes a brain_regions map if the ML - service did not return one. + Tolerates missing fields and synthesizes brain_regions / breakdown if absent. """ score = max(0, min(100, int(round(float(result.get("score", 0)))))) level = result.get("level") or level_for(score) @@ -167,15 +201,19 @@ def normalize_ml_result(result: dict, stimulus: dict | None) -> dict: elif not flagged: flagged = [k for k, _ in sorted(regions.items(), key=lambda kv: -kv[1])[:3]] + tribe_dev = float(result.get("tribe_deviation", 0.0)) + breakdown = result.get("breakdown") or _breakdown(analysis, raw, tribe_dev) + return { "score": score, "level": level, - "tribe_deviation": float(result.get("tribe_deviation", 0.0)), + "tribe_deviation": tribe_dev, "behavioral_deviation": float(result.get("behavioral_deviation", 0.0)), "top_indicators": result.get("top_indicators") or [], "intervention": result.get("intervention") or _intervention(level), "brain_regions_flagged": flagged, "brain_regions": regions, + "breakdown": breakdown, "confidence": float(result.get("confidence", 0.8)), "source": "tribe", } diff --git a/backend/services/signal_client.py b/backend/services/signal_client.py index 77936cf..1fe13c7 100644 --- a/backend/services/signal_client.py +++ b/backend/services/signal_client.py @@ -1,34 +1,29 @@ """HTTP client for Dhruva's signal-processing service (localhost:8002). -Every function raises on transport/HTTP error; callers decide how to degrade. +Functions raise on transport/HTTP error; callers decide how to degrade. +NOTE: the live alert endpoint is POST /alert and REQUIRES `phone`. """ import httpx -from config import SERVICE_TIMEOUT, SIGNAL_SERVICE_URL +from config import SERVICE_TIMEOUT, SIGNAL_SERVICE -async def analyze(user_id, response_text, response_time_ms, typing_wpm, error_rate) -> dict: - payload = { - "user_id": user_id, - "response_text": response_text, - "response_time_ms": response_time_ms, - "typing_wpm": typing_wpm, - "error_rate": error_rate, - } +async def analyze(data: dict) -> dict: + """data: { user_id, response_text, response_time_ms, typing_wpm, error_rate }""" async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: - r = await client.post(f"{SIGNAL_SERVICE_URL}/analyze", json=payload) - r.raise_for_status() - return r.json() + res = await client.post(f"{SIGNAL_SERVICE}/analyze", json=data) + res.raise_for_status() + return res.json() async def get_signals(user_id: str) -> dict: async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: - r = await client.get(f"{SIGNAL_SERVICE_URL}/signals/{user_id}") - r.raise_for_status() - return r.json() + res = await client.get(f"{SIGNAL_SERVICE}/signals/{user_id}") + res.raise_for_status() + return res.json() -async def send_alert(user_id, phone, score, level, intervention) -> dict: +async def send_alert(user_id: str, phone: str, score, level: str, intervention: str) -> dict: payload = { "user_id": user_id, "phone": phone, @@ -37,6 +32,6 @@ async def send_alert(user_id, phone, score, level, intervention) -> dict: "intervention": intervention, } async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: - r = await client.post(f"{SIGNAL_SERVICE_URL}/alert", json=payload) - r.raise_for_status() - return r.json() + res = await client.post(f"{SIGNAL_SERVICE}/alert", json=payload) + res.raise_for_status() + return res.json() diff --git a/backend/services/stimuli.py b/backend/services/stimuli.py index 07d6b58..f045147 100644 --- a/backend/services/stimuli.py +++ b/backend/services/stimuli.py @@ -1,6 +1,8 @@ -"""Load the canonical stimulus manifest from shared/stimuli.json.""" +"""Load the app-facing stimulus manifest from shared/stimuli.json.""" +import hashlib import json import os +from datetime import date from functools import lru_cache from config import SHARED_DIR @@ -23,3 +25,13 @@ def get_stimulus(stimulus_id: str) -> dict | None: if s["id"] == stimulus_id: return s return None + + +def stimulus_for_today(user_id: str) -> dict: + """Deterministic daily rotation per user (same stimulus all day = stable demo).""" + items = _load() + if not items: + raise RuntimeError("stimulus manifest is empty") + key = f"{user_id}:{date.today().isoformat()}".encode() + idx = int(hashlib.sha256(key).hexdigest(), 16) % len(items) + return items[idx] diff --git a/backend/services/tribe_client.py b/backend/services/tribe_client.py deleted file mode 100644 index 780a477..0000000 --- a/backend/services/tribe_client.py +++ /dev/null @@ -1,23 +0,0 @@ -"""HTTP client for Rishith's ML/TRIBE service (localhost:8003). - -Every function raises on transport/HTTP error; callers decide how to degrade. -""" -import httpx - -from config import ML_SERVICE_URL, SERVICE_TIMEOUT - - -async def score(user_id: str, stimulus_id: str, signals: dict) -> dict: - payload = {"user_id": user_id, "stimulus_id": stimulus_id, "signals": signals} - async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: - r = await client.post(f"{ML_SERVICE_URL}/score", json=payload) - r.raise_for_status() - return r.json() - - -async def predict(stimulus_path: str, modality: str) -> dict: - payload = {"stimulus_path": stimulus_path, "modality": modality} - async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: - r = await client.post(f"{ML_SERVICE_URL}/predict", json=payload) - r.raise_for_status() - return r.json() diff --git a/backend/services/video_client.py b/backend/services/video_client.py new file mode 100644 index 0000000..d326dcf --- /dev/null +++ b/backend/services/video_client.py @@ -0,0 +1,16 @@ +"""HTTP client for Rishith's video service (localhost:8004). + +Forwards the uploaded video for facial + voice stress analysis. +Raises on transport/HTTP error; the route degrades gracefully. +""" +import httpx + +from config import VIDEO_SERVICE, VIDEO_TIMEOUT + + +async def analyze(video_bytes: bytes, filename: str) -> dict: + files = {"video": (filename, video_bytes, "video/mp4")} + async with httpx.AsyncClient(timeout=VIDEO_TIMEOUT) as client: + res = await client.post(f"{VIDEO_SERVICE}/analyze/video", files=files) + res.raise_for_status() + return res.json() diff --git a/shared/contract.json b/shared/contract.json index f073129..0461508 100644 --- a/shared/contract.json +++ b/shared/contract.json @@ -1,12 +1,13 @@ { - "version": "1.1.0", + "version": "2.0.0", "owner": "jason (backend)", - "description": "Pegasus API contract. Jason owns this file. If you need a field, tell Jason.", + "description": "Pegasus API contract. Jason owns this file. Need a field changed? Tell Jason, he updates it, everyone pulls.", "services": { "frontend": "http://localhost:3000", "backend": "http://localhost:8001", "signals": "http://localhost:8002", - "ml": "http://localhost:8003" + "ml": "http://localhost:8003", + "video": "http://localhost:8004" }, "levels": { "green": "score < 30", @@ -15,54 +16,64 @@ }, "stimulus": { "id": "string", - "type": "scenario|word|task", - "valence": "positive|negative|neutral", - "tribe_expected_engagement": "number (0-1), TRIBE v2 predicted engagement for a healthy brain", - "content": "string, the stimulus text shown to the user", - "prompt": "string, instruction for how the user should respond" + "type": "image|audio|text", + "url": "string", + "category": "calm|neutral|activating", + "prompt": "string" }, "user_response": { "user_id": "string", "stimulus_id": "string", "response_text": "string", "response_time_ms": "number", + "response_latency_ms": "number", "typing_wpm": "number", - "error_rate": "number (0-100, percent of keystrokes that were backspaces)", + "error_rate": "number (0-100)", + "source": "app|sms", "timestamp": "ISO8601" }, "burnout_result": { - "user_id": "string", - "stimulus_id": "string", - "score": "number (0-100, higher = more burnout)", + "score": "0-100", "level": "green|yellow|red", - "tribe_deviation": "number (0-1)", - "behavioral_deviation": "number (0-1)", + "tribe_deviation": "number", + "behavioral_deviation": "number", "top_indicators": ["string"], "intervention": "string", "brain_regions_flagged": ["string"], - "confidence": "number (0-1)", - "source": "tribe|fallback (tribe = scored by ML service, fallback = backend heuristic when ML offline)", + "confidence": "0-1", + "breakdown": { + "imessage": "number (text/SMS sentiment burnout contribution 0-100)", + "typing": "number (typing biometrics 0-100)", + "facial": "number (video facial stress 0-100, 0 if no video)", + "voice": "number (video voice stress 0-100, 0 if no video)", + "tribe": "number (TRIBE deviation contribution 0-100)" + }, "timestamp": "ISO8601" }, "endpoints": { "backend (8001, owner: jason)": { - "POST /stimulus/send": "{ user_id, stimulus_id } -> { user_id, stimulus }", - "POST /response/submit": "{ user_id, stimulus_id, response_text, response_time_ms, typing_wpm, error_rate } -> burnout_result", + "GET /health": "-> service status", + "GET /stimulus/today/{user_id}": "-> stimulus (daily rotation)", + "GET /stimulus/all": "-> [stimulus, ...]", + "POST /response/submit": "user_response -> burnout_result (orchestrates signals -> ML -> store)", "GET /score/{user_id}": "-> latest burnout_result", - "GET /brain/{user_id}": "-> { brain_regions, brain_regions_flagged, level, score, ... }", - "GET /history/{user_id}": "-> { history: [burnout_result, ...] }", - "GET /stimuli": "-> { stimuli: [stimulus, ...] }", - "GET /health": "-> service status" + "GET /history/{user_id}": "-> [burnout_result, ...] (most recent first, up to 30)", + "GET /metrics/{user_id}": "-> { score_trend, latest_breakdown, total_checkins }", + "GET /brain/{user_id}": "-> { regions, ... } TRIBE brain data for the latest stimulus", + "POST /video/submit": "multipart (video, user_id) -> video analysis result" }, "signals (8002, owner: dhruva)": { "POST /analyze": "{ user_id, response_text, response_time_ms, typing_wpm, error_rate } -> { sentiment_score, energy_level, linguistic_flags, combined_signal_score, detail }", "GET /signals/{user_id}": "-> latest analyzed signals", - "POST /alert": "{ user_id, phone, score, level, intervention } -> { sent, message_sid }" + "POST /alert": "{ user_id, phone, score, level, intervention } -> { sent, message_sid } (NOTE: live endpoint is /alert and REQUIRES phone)" }, "ml (8003, owner: rishith)": { - "POST /predict": "{ stimulus_path, modality } -> { brain_activation, regions }", - "POST /score": "{ user_id, stimulus_id, signals } -> burnout_result" + "POST /score": "{ user_id, stimulus_id, signals } -> burnout_result", + "GET /baseline/{stimulus_id}": "-> { regions, ... } TRIBE baseline brain activation" + }, + "video (8004, owner: rishith)": { + "POST /analyze/video": "multipart video -> { facial: {...}, voice: {...} }" } }, - "notes": "stimulus shape mirrors signals/stimuli/bank.py (the live source of truth for stimuli). shared/stimuli.json is the canonical manifest the backend serves." + "notes": "Backend keeps a graceful-fallback layer: if signals (8002) or ML (8003) are offline, /response/submit returns a degraded burnout_result (source='fallback') instead of erroring, so the app + SMS bot keep working during the demo." } diff --git a/shared/stimuli.json b/shared/stimuli.json index f394fbb..02166b0 100644 --- a/shared/stimuli.json +++ b/shared/stimuli.json @@ -1,22 +1,12 @@ { - "version": "1.0.0", - "note": "Canonical stimulus manifest. Mirrors ids + tribe_expected_engagement used by signals/stimuli/bank.py so the whole pipeline agrees on baselines.", + "version": "2.0.0", + "note": "Canonical app-facing stimulus manifest (contract v2 shape). Served by the backend /stimulus endpoints. SMS stimuli live separately in signals/stimuli/bank.py (owned by Dhruva).", "stimuli": [ - { "id": "pos_1", "type": "scenario", "valence": "positive", "tribe_expected_engagement": 0.85, "content": "Your team just shipped a feature that users love.", "prompt": "How does this make you feel? Respond honestly in a sentence or two." }, - { "id": "pos_2", "type": "word", "valence": "positive", "tribe_expected_engagement": 0.80, "content": "VACATION", "prompt": "What comes to mind? Respond with whatever this word evokes for you." }, - { "id": "pos_3", "type": "scenario", "valence": "positive", "tribe_expected_engagement": 0.88, "content": "Your manager praised your work in front of the whole team.", "prompt": "How does this make you feel? Respond in a sentence or two." }, - { "id": "pos_4", "type": "word", "valence": "positive", "tribe_expected_engagement": 0.75, "content": "FRIDAY AFTERNOON", "prompt": "What comes to mind? Respond with whatever this evokes." }, - { "id": "pos_5", "type": "scenario", "valence": "positive", "tribe_expected_engagement": 0.82, "content": "You just solved a bug that had been blocking the team for three days.", "prompt": "How does this make you feel?" }, - { "id": "neg_1", "type": "scenario", "valence": "negative", "tribe_expected_engagement": 0.65, "content": "Your code broke production 10 minutes before a major client demo.", "prompt": "How do you feel reading this? Respond honestly." }, - { "id": "neg_2", "type": "word", "valence": "negative", "tribe_expected_engagement": 0.60, "content": "DEADLINE", "prompt": "What comes to mind immediately? Respond with whatever this word evokes." }, - { "id": "neg_3", "type": "scenario", "valence": "negative", "tribe_expected_engagement": 0.68, "content": "You have 47 unread Slack messages and a meeting starting in 5 minutes.", "prompt": "How does this scenario make you feel?" }, - { "id": "neg_4", "type": "word", "valence": "negative", "tribe_expected_engagement": 0.58, "content": "PERFORMANCE REVIEW", "prompt": "What comes to mind immediately? Respond with whatever this evokes." }, - { "id": "neg_5", "type": "scenario", "valence": "negative", "tribe_expected_engagement": 0.62, "content": "Your pull request has 23 change requests and the feature was due yesterday.", "prompt": "How does this make you feel?" }, - { "id": "neu_1", "type": "scenario", "valence": "neutral", "tribe_expected_engagement": 0.35, "content": "Conference room B is available from 2pm to 4pm.", "prompt": "Any thoughts on this? Just respond naturally." }, - { "id": "neu_2", "type": "word", "valence": "neutral", "tribe_expected_engagement": 0.30, "content": "OFFICE SUPPLIES", "prompt": "What comes to mind? Just respond naturally." }, - { "id": "neu_3", "type": "word", "valence": "neutral", "tribe_expected_engagement": 0.32, "content": "QUARTERLY REPORT", "prompt": "What does this bring to mind?" }, - { "id": "cog_1", "type": "task", "valence": "neutral", "tribe_expected_engagement": 0.70, "content": "List three things you're looking forward to this week.", "prompt": "Take your time. Be honest." }, - { "id": "cog_2", "type": "task", "valence": "neutral", "tribe_expected_engagement": 0.65, "content": "Describe how you feel about your current workload in two sentences.", "prompt": "Be as honest as you can." }, - { "id": "cog_3", "type": "task", "valence": "neutral", "tribe_expected_engagement": 0.60, "content": "What's one thing at work that has been draining your energy lately?", "prompt": "Take your time. There is no right answer." } + { "id": "calm_01", "type": "image", "url": "https://picsum.photos/seed/pegasus-calm-1/800/600", "category": "calm", "prompt": "Take a slow look at this image. How does it make you feel right now?" }, + { "id": "calm_02", "type": "image", "url": "https://picsum.photos/seed/pegasus-calm-2/800/600", "category": "calm", "prompt": "Rest your eyes on this for a moment. What comes up for you?" }, + { "id": "audio_01", "type": "audio", "url": "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", "category": "calm", "prompt": "Listen for a few seconds. Notice what shifts in you, then describe it." }, + { "id": "question_01", "type": "text", "url": "", "category": "neutral", "prompt": "What's one thing on your mind right now?" }, + { "id": "question_02", "type": "text", "url": "", "category": "neutral", "prompt": "How would you describe your energy today, in a sentence?" }, + { "id": "activating_01", "type": "text", "url": "", "category": "activating", "prompt": "Your deadline just moved up by a week. React honestly — what's your first thought?" } ] } From 72ea37782858e7f55daa28398c8cb2ec0c84181c Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sat, 13 Jun 2026 11:31:06 -0700 Subject: [PATCH 3/8] =?UTF-8?q?feat(backend):=20align=20video=20contract?= =?UTF-8?q?=20=E2=80=94=20capture=20combined=5Fscore=20from=20video=20serv?= =?UTF-8?q?ice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /video/submit now reads top-level combined_score from the video service response and persists it (VideoRecord.combined_score) - contract.json: video /analyze/video documented as -> { facial{facial_stress_score,...}, voice, combined_score } Co-Authored-By: Claude Opus 4.8 --- backend/crud.py | 10 +++++++++- backend/models/video_record.py | 2 ++ backend/routes/video.py | 3 ++- shared/contract.json | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/crud.py b/backend/crud.py index 8d0409c..efc7cff 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -78,10 +78,18 @@ def score_trend(db: Session, user_id: str) -> list[ScoreRecord]: ) -def save_video(db: Session, user_id: str, facial_score: int, facial_data: dict, voice_data: dict) -> VideoRecord: +def save_video( + db: Session, + user_id: str, + facial_score: int, + combined_score: int, + facial_data: dict, + voice_data: dict, +) -> VideoRecord: rec = VideoRecord( user_id=user_id, facial_score=facial_score, + combined_score=combined_score, facial_data=facial_data, voice_data=voice_data, ) diff --git a/backend/models/video_record.py b/backend/models/video_record.py index 6161ac1..422e178 100644 --- a/backend/models/video_record.py +++ b/backend/models/video_record.py @@ -12,6 +12,7 @@ class VideoRecord(Base): id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(String, index=True) facial_score = Column(Integer, default=0) + combined_score = Column(Integer, default=0) facial_data = Column(JSON, default=dict) voice_data = Column(JSON, default=dict) timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc)) @@ -20,6 +21,7 @@ def to_dict(self) -> dict: return { "user_id": self.user_id, "facial_score": self.facial_score, + "combined_score": self.combined_score, "facial": self.facial_data or {}, "voice": self.voice_data or {}, "timestamp": self.timestamp.isoformat() if self.timestamp else None, diff --git a/backend/routes/video.py b/backend/routes/video.py index d27e519..8b7b3e2 100644 --- a/backend/routes/video.py +++ b/backend/routes/video.py @@ -36,7 +36,8 @@ async def submit_video( facial = result.get("facial", {}) or {} voice = result.get("voice", {}) or {} facial_score = int(facial.get("facial_stress_score", 0) or 0) - crud.save_video(db, user_id, facial_score, facial, voice) + combined_score = int(result.get("combined_score", 0) or 0) + crud.save_video(db, user_id, facial_score, combined_score, facial, voice) result["video_service"] = video_ok return result diff --git a/shared/contract.json b/shared/contract.json index 0461508..602e789 100644 --- a/shared/contract.json +++ b/shared/contract.json @@ -72,7 +72,7 @@ "GET /baseline/{stimulus_id}": "-> { regions, ... } TRIBE baseline brain activation" }, "video (8004, owner: rishith)": { - "POST /analyze/video": "multipart video -> { facial: {...}, voice: {...} }" + "POST /analyze/video": "multipart video (field name 'video') -> { facial: {facial_stress_score, ...}, voice: {...}, combined_score: number }" } }, "notes": "Backend keeps a graceful-fallback layer: if signals (8002) or ML (8003) are offline, /response/submit returns a degraded burnout_result (source='fallback') instead of erroring, so the app + SMS bot keep working during the demo." From 492b36664ba756be7df687cb172be7a887ee1005 Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sat, 13 Jun 2026 11:42:12 -0700 Subject: [PATCH 4/8] =?UTF-8?q?fix(backend):=20match=20Dhruva's=20new=20si?= =?UTF-8?q?gnals=20contract=20=E2=80=94=20/alert/send=20(no=20phone)=20+?= =?UTF-8?q?=20/register-phone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - signal_client.send_alert now POSTs /alert/send {user_id, score, level, intervention} (phone removed; signals looks it up) - add signal_client.register_phone -> POST /register-phone - response.py fires the red alert without a phone gate (signals owns phone lookup) - contract.json: document real signals endpoints (/alert/send, /register-phone, /send-checkin, /sms/webhook, /signals/{id}/history) and flat /analyze response Co-Authored-By: Claude Opus 4.8 --- backend/routes/response.py | 17 ++++++++--------- backend/services/signal_client.py | 24 ++++++++++++++---------- shared/contract.json | 8 ++++++-- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/backend/routes/response.py b/backend/routes/response.py index 26dc90c..5b8b59a 100644 --- a/backend/routes/response.py +++ b/backend/routes/response.py @@ -69,17 +69,16 @@ async def submit_response(data: ResponseSubmitRequest, db: Session = Depends(get rec = crud.save_score(db, data.user_id, data.stimulus_id, result, data.source) # 4) Red alert via signals service (best effort, never fails the request). + # Signals looks up the user's registered phone; we just fire on red. alert = None if result["level"] == "red": - user = crud.get_user(db, data.user_id) - if user and user.phone: - try: - alert = await signal_client.send_alert( - data.user_id, user.phone, result["score"], result["level"], result["intervention"] - ) - except Exception as exc: # noqa: BLE001 - log.warning("alert dispatch failed: %s", exc) - alert = {"sent": False, "error": str(exc)} + try: + alert = await signal_client.send_alert( + data.user_id, result["score"], result["level"], result["intervention"] + ) + except Exception as exc: # noqa: BLE001 + log.warning("alert dispatch failed: %s", exc) + alert = {"sent": False, "error": str(exc)} out = rec.to_result() out["services"] = {"signals": signals_ok, "ml": result["source"] == "tribe"} diff --git a/backend/services/signal_client.py b/backend/services/signal_client.py index 1fe13c7..9236161 100644 --- a/backend/services/signal_client.py +++ b/backend/services/signal_client.py @@ -1,7 +1,8 @@ """HTTP client for Dhruva's signal-processing service (localhost:8002). Functions raise on transport/HTTP error; callers decide how to degrade. -NOTE: the live alert endpoint is POST /alert and REQUIRES `phone`. +NOTE: alerts go to POST /alert/send (no phone). Phone is registered separately +via POST /register-phone; the signals service looks it up on send. """ import httpx @@ -23,15 +24,18 @@ async def get_signals(user_id: str) -> dict: return res.json() -async def send_alert(user_id: str, phone: str, score, level: str, intervention: str) -> dict: - payload = { - "user_id": user_id, - "phone": phone, - "score": score, - "level": level, - "intervention": intervention, - } +async def send_alert(user_id: str, score, level: str, intervention: str) -> dict: + payload = {"user_id": user_id, "score": score, "level": level, "intervention": intervention} async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: - res = await client.post(f"{SIGNAL_SERVICE}/alert", json=payload) + res = await client.post(f"{SIGNAL_SERVICE}/alert/send", json=payload) + res.raise_for_status() + return res.json() + + +async def register_phone(user_id: str, phone: str) -> dict: + """Tell the signals service which phone to text for this user.""" + payload = {"user_id": user_id, "phone": phone} + async with httpx.AsyncClient(timeout=SERVICE_TIMEOUT) as client: + res = await client.post(f"{SIGNAL_SERVICE}/register-phone", json=payload) res.raise_for_status() return res.json() diff --git a/shared/contract.json b/shared/contract.json index 602e789..d62e493 100644 --- a/shared/contract.json +++ b/shared/contract.json @@ -63,9 +63,13 @@ "POST /video/submit": "multipart (video, user_id) -> video analysis result" }, "signals (8002, owner: dhruva)": { - "POST /analyze": "{ user_id, response_text, response_time_ms, typing_wpm, error_rate } -> { sentiment_score, energy_level, linguistic_flags, combined_signal_score, detail }", + "POST /analyze": "{ user_id, response_text, response_time_ms, typing_wpm, error_rate, [+optional keystroke biometrics: hesitation_count, burst_pattern, avg_key_hold_ms, flight_time_ms, correction_loops] } -> FLAT { sentiment_score, energy_level, combined_signal_score, linguistic_flags, typing_stress_score, future_tense_pct, self_referential_pct, hedging_count, flat_affect, word_count, ..., detail{sentiment,typing,temporal,linguistic} }", "GET /signals/{user_id}": "-> latest analyzed signals", - "POST /alert": "{ user_id, phone, score, level, intervention } -> { sent, message_sid } (NOTE: live endpoint is /alert and REQUIRES phone)" + "GET /signals/{user_id}/history": "-> { records, record_count, avg_score, score_trend, word_count_trend }", + "POST /alert/send": "{ user_id, score, level, intervention } -> { sent, message_sid } (SMS only on red; phone looked up from a prior /register-phone)", + "POST /register-phone": "{ user_id, phone } -> { registered }", + "POST /send-checkin": "{ user_id, phone } -> sends a stimulus over SMS", + "POST /sms/webhook": "Twilio inbound SMS webhook (Dhruva's bot)" }, "ml (8003, owner: rishith)": { "POST /score": "{ user_id, stimulus_id, signals } -> burnout_result", From dc282fb1ea1ce78395643bfb8183971d30d615c1 Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sat, 13 Jun 2026 11:59:37 -0700 Subject: [PATCH 5/8] =?UTF-8?q?feat(backend):=20align=20to=20PRD=20?= =?UTF-8?q?=E2=80=94=20multimodal=20fusion,=20red->human-support=20safety,?= =?UTF-8?q?=20brain=20explanations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Multimodal fusion (PRD 4.5): /video/submit fuses facial+voice into a burnout score (facial weighted highest), stored source=video; breakdown surfaces facial/voice; /response/submit folds in latest video signals - Safety (PRD 6): new safety.py enforces that every RED result carries a human-support path (988 + Crisis Text Line + campus counseling + trusted contact); applied in response + video routes and burnout_result.support - Brain (PRD 5.2): /brain returns plain-English, non-clinical region explanations - contract.json: burnout_result +support +source(app|sms|video); /video/submit returns burnout_result; raw video never stored (PRD 8) Co-Authored-By: Claude Opus 4.8 --- backend/crud.py | 22 ++++++ backend/models/score_record.py | 2 + backend/routes/brain.py | 6 +- backend/routes/response.py | 11 ++- backend/routes/video.py | 46 +++++++++--- backend/safety.py | 37 ++++++++++ backend/schemas.py | 3 +- backend/services/scoring.py | 124 ++++++++++++++++++++++++++++----- shared/contract.json | 8 ++- 9 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 backend/safety.py diff --git a/backend/crud.py b/backend/crud.py index efc7cff..bb2730c 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -78,6 +78,28 @@ def score_trend(db: Session, user_id: str) -> list[ScoreRecord]: ) +def latest_video(db: Session, user_id: str) -> VideoRecord | None: + return ( + db.query(VideoRecord) + .filter(VideoRecord.user_id == user_id) + .order_by(VideoRecord.timestamp.desc(), VideoRecord.id.desc()) + .first() + ) + + +def video_signals_for(db: Session, user_id: str) -> dict | None: + """Normalized latest facial/voice signals for fusion, or None if no video yet.""" + rec = latest_video(db, user_id) + if rec is None: + return None + voice = rec.voice_data or {} + return { + "facial_score": rec.facial_score or 0, + "voice_score": voice.get("voice_stress_score", voice.get("stress_score", 0)) or 0, + "combined_score": rec.combined_score or 0, + } + + def save_video( db: Session, user_id: str, diff --git a/backend/models/score_record.py b/backend/models/score_record.py index 7bf7755..f07b1f9 100644 --- a/backend/models/score_record.py +++ b/backend/models/score_record.py @@ -3,6 +3,7 @@ from sqlalchemy import JSON, Column, DateTime, Float, Integer, String +import safety from models.database import Base @@ -40,6 +41,7 @@ def to_result(self) -> dict: "brain_regions_flagged": self.brain_regions_flagged or [], "confidence": self.confidence, "breakdown": self.breakdown or {}, + "support": safety.support_for(self.level), "source": self.source, "timestamp": self.timestamp.isoformat() if self.timestamp else None, } diff --git a/backend/routes/brain.py b/backend/routes/brain.py index 6687d4d..cae1436 100644 --- a/backend/routes/brain.py +++ b/backend/routes/brain.py @@ -24,16 +24,20 @@ async def get_brain(user_id: str, db: Session = Depends(get_db)): baseline["user_id"] = user_id baseline["stimulus_id"] = rec.stimulus_id baseline.setdefault("source", "tribe") + if baseline.get("brain_regions_flagged") and "explanations" not in baseline: + baseline["explanations"] = scoring.region_explanations(baseline["brain_regions_flagged"]) return baseline except Exception as exc: # noqa: BLE001 - degrade gracefully log.warning("ML baseline unavailable, synthesizing brain map: %s", exc) flagged, regions = scoring.synth_brain(rec.level, rec.score) + flagged = rec.brain_regions_flagged or flagged return { "user_id": user_id, "stimulus_id": rec.stimulus_id, "level": rec.level, "score": rec.score, "regions": regions, - "brain_regions_flagged": rec.brain_regions_flagged or flagged, + "brain_regions_flagged": flagged, + "explanations": scoring.region_explanations(flagged), "source": "fallback", } diff --git a/backend/routes/response.py b/backend/routes/response.py index 5b8b59a..9feab20 100644 --- a/backend/routes/response.py +++ b/backend/routes/response.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session import crud +import safety from models.database import get_db from schemas import ResponseSubmitRequest from services import ml_client, scoring, signal_client, stimuli @@ -22,6 +23,7 @@ async def submit_response(data: ResponseSubmitRequest, db: Session = Depends(get_db)): crud.ensure_user(db, data.user_id) stimulus = stimuli.get_stimulus(data.stimulus_id) + video = crud.video_signals_for(db, data.user_id) # latest facial/voice, if any raw = { "response_text": data.response_text, "response_time_ms": data.response_time_ms, @@ -57,13 +59,18 @@ async def submit_response(data: ResponseSubmitRequest, db: Session = Depends(get "error_rate": data.error_rate, "response_time_ms": data.response_time_ms, "response_latency_ms": data.response_latency_ms, + "facial_score": (video or {}).get("facial_score"), + "voice_score": (video or {}).get("voice_score"), } try: ml_result = await ml_client.score(data.user_id, data.stimulus_id, ml_signals) - result = scoring.normalize_ml_result(ml_result, stimulus, analysis, raw) + result = scoring.normalize_ml_result(ml_result, stimulus, analysis, raw, video) except Exception as exc: # noqa: BLE001 - degrade gracefully log.warning("ML service unavailable, using fallback scoring: %s", exc) - result = scoring.fallback_score(stimulus, analysis, raw) + result = scoring.fallback_score(stimulus, analysis, raw, video) + + # Safety invariant: a red result always carries a human-support path (PRD §6). + result = safety.ensure_human_support(result) # 3) Persist. rec = crud.save_score(db, data.user_id, data.stimulus_id, result, data.source) diff --git a/backend/routes/video.py b/backend/routes/video.py index 8b7b3e2..ce8ba06 100644 --- a/backend/routes/video.py +++ b/backend/routes/video.py @@ -1,7 +1,11 @@ """POST /video/submit — video check-in (prefix /video). Forwards the upload to Rishith's video service (8004), stores facial + voice -stress, and returns the analysis. Degrades gracefully if the service is offline. +stress, then FUSES facial/voice into a burnout score (PRD §4.5, facial weighted +highest) so a video check-in actually moves the check engine light. Degrades +gracefully if the video or ML service is offline. + +Raw video bytes are never persisted — only the derived analysis (PRD §8). """ import logging @@ -9,8 +13,9 @@ from sqlalchemy.orm import Session import crud +import safety from models.database import get_db -from services import video_client +from services import ml_client, scoring, video_client router = APIRouter() log = logging.getLogger("pegasus.backend") @@ -27,17 +32,40 @@ async def submit_video( video_ok = True try: - result = await video_client.analyze(content, video.filename or "checkin.mp4") + analysis = await video_client.analyze(content, video.filename or "checkin.mp4") except Exception as exc: # noqa: BLE001 - degrade gracefully log.warning("video service unavailable: %s", exc) - result = {"facial": {}, "voice": {}, "error": f"video service unavailable: {exc}"} + analysis = {"facial": {}, "voice": {}, "error": f"video service unavailable: {exc}"} video_ok = False - facial = result.get("facial", {}) or {} - voice = result.get("voice", {}) or {} + facial = analysis.get("facial", {}) or {} + voice = analysis.get("voice", {}) or {} facial_score = int(facial.get("facial_stress_score", 0) or 0) - combined_score = int(result.get("combined_score", 0) or 0) + voice_score = int(voice.get("voice_stress_score", voice.get("stress_score", 0)) or 0) + combined_score = int(analysis.get("combined_score", 0) or 0) crud.save_video(db, user_id, facial_score, combined_score, facial, voice) - result["video_service"] = video_ok - return result + # Fuse facial/voice into a burnout score (use ML if available, else local fusion). + video_sig = {"facial_score": facial_score, "voice_score": voice_score, "combined_score": combined_score} + prior = crud.latest_score(db, user_id) + prior_result = prior.to_result() if prior else None + try: + ml_result = await ml_client.score(user_id, "video_checkin", {**video_sig, "channel": "video"}) + burnout = scoring.normalize_ml_result(ml_result, None, {}, {}, video_sig) + except Exception as exc: # noqa: BLE001 - degrade gracefully + log.warning("ML unavailable for video fusion, using local fusion: %s", exc) + burnout = scoring.video_fallback_score(video_sig, prior_result) + + burnout = safety.ensure_human_support(burnout) # PRD §6 + rec = crud.save_score(db, user_id, "video_checkin", burnout, "video") + + out = { + "facial": facial, + "voice": voice, + "combined_score": combined_score, + "video_service": video_ok, + "burnout_result": rec.to_result(), + } + if "error" in analysis: + out["error"] = analysis["error"] + return out diff --git a/backend/safety.py b/backend/safety.py new file mode 100644 index 0000000..3576319 --- /dev/null +++ b/backend/safety.py @@ -0,0 +1,37 @@ +"""Safety invariants for burnout results. + +PRD §6: Pegasus never diagnoses, and a RED result must ALWAYS include a path to +human support — never just an app suggestion. The backend is the single point +that returns the final burnout_result, so we enforce that invariant here, +regardless of whether the score came from the ML service or the local fallback. +""" + +# Shown to the user and appended to red interventions if not already present. +HUMAN_SUPPORT_TEXT = ( + "You don't have to handle this alone — reaching out is a strong move. Talk to " + "someone you trust or your campus counseling center, and if you'd like to speak " + "with a trained counselor right now, you can call or text 988 (the Suicide & " + "Crisis Lifeline, US) any time." +) + +# Structured resources for the app to render as tappable support options. +SUPPORT_RESOURCES = [ + {"name": "988 Suicide & Crisis Lifeline (US)", "contact": "Call or text 988", "type": "crisis"}, + {"name": "Crisis Text Line", "contact": "Text HOME to 741741", "type": "crisis"}, + {"name": "Campus counseling", "contact": "Reach your campus counseling center", "type": "counseling"}, + {"name": "A trusted person", "contact": "Message a friend or family member you trust", "type": "personal"}, +] + + +def support_for(level: str) -> list: + """Support resources to attach to a result (only red carries them).""" + return list(SUPPORT_RESOURCES) if level == "red" else [] + + +def ensure_human_support(result: dict) -> dict: + """Guarantee a red intervention names a concrete human-support path.""" + if result.get("level") == "red": + intervention = (result.get("intervention") or "").strip() + if "988" not in intervention: + result["intervention"] = (intervention + " " + HUMAN_SUPPORT_TEXT).strip() + return result diff --git a/backend/schemas.py b/backend/schemas.py index 92b0f11..959a6c7 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -35,5 +35,6 @@ class BurnoutResult(BaseModel): brain_regions_flagged: list[str] = [] confidence: float = 0.0 breakdown: Breakdown = Breakdown() - source: Optional[str] = None + support: list[dict] = [] # human-support resources, populated on red (PRD §6) + source: Optional[str] = None # "app" | "sms" | "video" | "tribe" | "fallback" timestamp: Optional[str] = None diff --git a/backend/services/scoring.py b/backend/services/scoring.py index fc3fc71..8d4c307 100644 --- a/backend/services/scoring.py +++ b/backend/services/scoring.py @@ -1,9 +1,10 @@ """Backend-side scoring fallback + result normalization. When Rishith's ML service (8003) is up, we use its burnout_result directly -(normalize_ml_result). When it is offline, fallback_score produces a principled -burnout_result from the signals analysis + the stimulus category baseline, so the -backend is independently demoable. Both paths emit the contract `breakdown`. +(normalize_ml_result). When it is offline, the local fallbacks produce a +principled burnout_result from the available signals, so the backend is +independently demoable. Both paths emit the contract `breakdown` +{imessage, typing, facial, voice, tribe} (PRD §4.5 fusion). """ from config import RED_THRESHOLD, YELLOW_THRESHOLD @@ -26,15 +27,26 @@ "hippocampus", ] +# Plain-English, non-clinical descriptions for the Brain screen (PRD §5.2). +_REGION_EXPLANATIONS = { + "amygdala": "threat & emotional reactivity — tends to run hot when stress is high", + "anterior cingulate cortex": "conflict monitoring & emotional regulation — works harder under sustained strain", + "insula": "body awareness — tracks discomfort, tension, and fatigue", + "dorsolateral prefrontal cortex": "focus & self-control — dips when you're cognitively depleted", + "medial prefrontal cortex": "self-reflection & calm baseline — active when you feel settled", + "posterior cingulate cortex": "rest & mind-wandering — part of the calm 'default' network", + "ventral striatum": "reward & motivation — quiets down when drive is low", + "hippocampus": "memory & context — sensitive to chronic stress", +} + _INTERVENTIONS = { "green": "Your signals look steady today. Keep doing what works — a short walk " "or a moment of gratitude can lock in the good momentum.", "yellow": "Some early strain is showing. Try a 5-minute breathing break, step away " "from the screen, and protect one boundary today (decline one nonessential " "meeting).", - "red": "Your signals point to significant strain. Please pause — take 15 minutes away " - "from work, reach out to someone you trust, and consider one thing you can take " - "off your plate today. You don't have to push through alone.", + "red": "Your signals point to significant strain. Please pause and step away from " + "work for a bit, and take one thing off your plate today.", } @@ -50,15 +62,27 @@ def _intervention(level: str) -> str: return _INTERVENTIONS.get(level, _INTERVENTIONS["yellow"]) +def region_explanations(regions: list[str]) -> dict: + return {r: _REGION_EXPLANATIONS.get(r, "involved in processing this stimulus") for r in regions or []} + + def _expected_engagement(stimulus: dict | None) -> float: if not stimulus: return 0.5 - # New contract stimuli use `category`; legacy/SMS stimuli carry an explicit baseline. if "tribe_expected_engagement" in stimulus: return float(stimulus["tribe_expected_engagement"]) return _CATEGORY_EXPECTED.get(stimulus.get("category"), 0.5) +def _video_signals(video: dict | None) -> tuple[float, float]: + """(facial 0-100, voice 0-100) from a normalized video-signal dict.""" + if not video: + return 0.0, 0.0 + facial = float(video.get("facial_score") or 0) + voice = float(video.get("voice_score") or 0) + return round(min(100.0, facial), 1), round(min(100.0, voice), 1) + + def synth_brain(level: str, score: int): """Public: synthesized (flagged_regions, regions) for the brain-view fallback.""" return _brain(level, score) @@ -81,22 +105,22 @@ def _typing_burnout(raw: dict) -> float: """0-100 typing-biometrics burnout contribution from raw signals.""" err = float(raw.get("error_rate") or 0) wpm = float(raw.get("typing_wpm") or 0) - val = min(60.0, err) # backspace-heavy typing + val = min(60.0, err) if 0 < wpm < 20: - val += 25 # very slow typing + val += 25 elif wpm > 120: - val += 10 # frantic typing + val += 10 return round(min(100.0, val), 1) -def _breakdown(analysis: dict, raw: dict, tribe_dev: float) -> dict: - """Per-stream burnout contributions (0-100). Facial/voice come from video only.""" +def _breakdown(analysis: dict, raw: dict, tribe_dev: float, facial: float = 0.0, voice: float = 0.0) -> dict: + """Per-stream burnout contributions (0-100), the PRD §4.5 fusion inputs.""" sentiment = float(analysis.get("sentiment_score", 0.5)) return { "imessage": round(min(100.0, (1 - sentiment) * 100), 1), "typing": _typing_burnout(raw), - "facial": 0.0, - "voice": 0.0, + "facial": round(facial, 1), + "voice": round(voice, 1), "tribe": round(min(100.0, tribe_dev * 100), 1), } @@ -155,14 +179,19 @@ def local_analysis(raw: dict) -> dict: } -def fallback_score(stimulus: dict | None, analysis: dict, raw: dict) -> dict: - """Compute a burnout_result locally from signals + the stimulus baseline.""" +def fallback_score(stimulus: dict | None, analysis: dict, raw: dict, video: dict | None = None) -> dict: + """Score a text/typing pulse-check locally from signals + the stimulus baseline. + + `video` (latest known facial/voice for the user) is surfaced in the breakdown + for continuity but does not move a text-channel score. + """ expected = _expected_engagement(stimulus) combined = float(analysis.get("combined_signal_score", 50)) / 100.0 # 0-1 burnout actual_engagement = max(0.0, min(1.0, 1 - combined)) tribe_dev = round(abs(expected - actual_engagement), 3) behavioral_dev = round(combined, 3) + facial, voice = _video_signals(video) score = int(round(100 * (0.6 * behavioral_dev + 0.4 * tribe_dev))) score = max(0, min(100, score)) @@ -178,13 +207,69 @@ def fallback_score(stimulus: dict | None, analysis: dict, raw: dict) -> dict: "intervention": _intervention(level), "brain_regions_flagged": flagged, "brain_regions": regions, - "breakdown": _breakdown(analysis, raw, tribe_dev), + "breakdown": _breakdown(analysis, raw, tribe_dev, facial, voice), "confidence": 0.55 if analysis.get("_local") else 0.7, "source": "fallback", } -def normalize_ml_result(result: dict, stimulus: dict | None, analysis: dict, raw: dict) -> dict: +def video_fallback_score(video: dict, prior: dict | None = None) -> dict: + """Fuse a video check-in into a burnout score (PRD §4.5, facial weighted highest). + + `prior` is the user's most recent burnout_result (for behavioral/text context). + """ + facial, voice = _video_signals(video) + video_burden = (0.6 * facial + 0.4 * voice) / 100.0 # facial weighted highest + + prior = prior or {} + prior_beh = float(prior.get("behavioral_deviation") or 0.0) + prior_bd = prior.get("breakdown") or {} + + score = int(round(100 * (0.65 * video_burden + 0.35 * prior_beh))) + score = max(0, min(100, score)) + level = level_for(score) + + indicators: list[str] = [] + if facial >= 65: + indicators.append("strained facial expression") + elif facial >= 40: + indicators.append("facial tension") + if voice >= 65: + indicators.append("flat or strained voice") + elif voice >= 40: + indicators.append("vocal stress") + if not indicators: + indicators = ["relaxed facial & vocal signals"] if level == "green" else ["elevated facial/vocal strain"] + + flagged, regions = _brain(level, score) + return { + "score": score, + "level": level, + "tribe_deviation": float(prior.get("tribe_deviation") or 0.0), + "behavioral_deviation": round(video_burden, 3), + "top_indicators": indicators[:5], + "intervention": _intervention(level), + "brain_regions_flagged": flagged, + "brain_regions": regions, + "breakdown": { + "imessage": prior_bd.get("imessage", 0.0), + "typing": prior_bd.get("typing", 0.0), + "facial": round(facial, 1), + "voice": round(voice, 1), + "tribe": prior_bd.get("tribe", 0.0), + }, + "confidence": 0.6, + "source": "fallback", + } + + +def normalize_ml_result( + result: dict, + stimulus: dict | None, + analysis: dict, + raw: dict, + video: dict | None = None, +) -> dict: """Coerce the ML service's burnout_result into our stored shape. Tolerates missing fields and synthesizes brain_regions / breakdown if absent. @@ -202,7 +287,8 @@ def normalize_ml_result(result: dict, stimulus: dict | None, analysis: dict, raw flagged = [k for k, _ in sorted(regions.items(), key=lambda kv: -kv[1])[:3]] tribe_dev = float(result.get("tribe_deviation", 0.0)) - breakdown = result.get("breakdown") or _breakdown(analysis, raw, tribe_dev) + facial, voice = _video_signals(video) + breakdown = result.get("breakdown") or _breakdown(analysis, raw, tribe_dev, facial, voice) return { "score": score, diff --git a/shared/contract.json b/shared/contract.json index d62e493..5178f16 100644 --- a/shared/contract.json +++ b/shared/contract.json @@ -38,7 +38,7 @@ "tribe_deviation": "number", "behavioral_deviation": "number", "top_indicators": ["string"], - "intervention": "string", + "intervention": "string (Claude-generated via ML; warm, actionable, non-diagnostic)", "brain_regions_flagged": ["string"], "confidence": "0-1", "breakdown": { @@ -48,6 +48,8 @@ "voice": "number (video voice stress 0-100, 0 if no video)", "tribe": "number (TRIBE deviation contribution 0-100)" }, + "support": "[{name, contact, type}] human-support resources; populated ONLY on red (PRD §6: red always routes to human support)", + "source": "app|sms|video|tribe|fallback", "timestamp": "ISO8601" }, "endpoints": { @@ -60,7 +62,7 @@ "GET /history/{user_id}": "-> [burnout_result, ...] (most recent first, up to 30)", "GET /metrics/{user_id}": "-> { score_trend, latest_breakdown, total_checkins }", "GET /brain/{user_id}": "-> { regions, ... } TRIBE brain data for the latest stimulus", - "POST /video/submit": "multipart (video, user_id) -> video analysis result" + "POST /video/submit": "multipart (video, user_id) -> { facial, voice, combined_score, burnout_result } — fuses facial/voice into a burnout score (PRD 4.5); raw video is never stored" }, "signals (8002, owner: dhruva)": { "POST /analyze": "{ user_id, response_text, response_time_ms, typing_wpm, error_rate, [+optional keystroke biometrics: hesitation_count, burst_pattern, avg_key_hold_ms, flight_time_ms, correction_loops] } -> FLAT { sentiment_score, energy_level, combined_signal_score, linguistic_flags, typing_stress_score, future_tense_pct, self_referential_pct, hedging_count, flat_affect, word_count, ..., detail{sentiment,typing,temporal,linguistic} }", @@ -76,7 +78,7 @@ "GET /baseline/{stimulus_id}": "-> { regions, ... } TRIBE baseline brain activation" }, "video (8004, owner: rishith)": { - "POST /analyze/video": "multipart video (field name 'video') -> { facial: {facial_stress_score, ...}, voice: {...}, combined_score: number }" + "POST /analyze/video": "multipart video (field name 'video') -> { facial: {facial_stress_score, ...}, voice: {voice_stress_score, ...}, combined_score: number }" } }, "notes": "Backend keeps a graceful-fallback layer: if signals (8002) or ML (8003) are offline, /response/submit returns a degraded burnout_result (source='fallback') instead of erroring, so the app + SMS bot keep working during the demo." From 3e587196e8f139674046d0e76c3df8f1fd7f1fb8 Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sat, 13 Jun 2026 13:30:39 -0700 Subject: [PATCH 6/8] fix(backend): harden scoring vs bad input, reconcile red level, consistent /score default + SQLite busy timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA pass (28/28 edge-case tests green): - scoring helpers coerce None/strings and clamp out-of-range so the fallback path never 500s (negatives, huge values, null combined_signal_score, etc.) - SAFETY: normalize_ml_result reconciles level with score — an invalid level derives from score, and a red-range score (>=65) is never downgraded, so the human-support path always attaches (PRD 6) - /score default (pre-first-checkin) now returns the full burnout_result shape incl. support[] so the frontend never hits a missing key - SQLite busy timeout (30s) so concurrent app + SMS writes don't error with 'database is locked' Co-Authored-By: Claude Opus 4.8 --- backend/models/database.py | 5 +- backend/routes/score.py | 10 +++- backend/services/scoring.py | 95 +++++++++++++++++++++++-------------- 3 files changed, 71 insertions(+), 39 deletions(-) diff --git a/backend/models/database.py b/backend/models/database.py index bd05e3b..5354f30 100644 --- a/backend/models/database.py +++ b/backend/models/database.py @@ -4,8 +4,9 @@ from config import DATABASE_URL -# check_same_thread is a SQLite-only quirk; harmless to compute generally. -_connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {} +# check_same_thread is a SQLite-only quirk; timeout sets the busy-wait so +# concurrent app + SMS writes wait instead of erroring with "database is locked". +_connect_args = {"check_same_thread": False, "timeout": 30} if DATABASE_URL.startswith("sqlite") else {} engine = create_engine(DATABASE_URL, connect_args=_connect_args) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/routes/score.py b/backend/routes/score.py index a5961aa..41d6f48 100644 --- a/backend/routes/score.py +++ b/backend/routes/score.py @@ -12,13 +12,21 @@ def get_latest_score(user_id: str, db: Session = Depends(get_db)): rec = crud.latest_score(db, user_id) if rec is None: + # Same shape as a real burnout_result so the frontend never hits a missing key. return { "user_id": user_id, + "stimulus_id": None, "score": 0, "level": "green", + "tribe_deviation": 0.0, + "behavioral_deviation": 0.0, + "top_indicators": [], "intervention": "Take your first pulse check!", + "brain_regions_flagged": [], + "confidence": 0.0, "breakdown": {}, - "top_indicators": [], + "support": [], "source": "none", + "timestamp": None, } return rec.to_result() diff --git a/backend/services/scoring.py b/backend/services/scoring.py index 8d4c307..c2b2379 100644 --- a/backend/services/scoring.py +++ b/backend/services/scoring.py @@ -5,6 +5,10 @@ principled burnout_result from the available signals, so the backend is independently demoable. Both paths emit the contract `breakdown` {imessage, typing, facial, voice, tribe} (PRD §4.5 fusion). + +All inputs are treated as untrusted: helpers coerce None / strings / bad numbers +and clamp out-of-range values, so the fallback path (the last line of defense) +never raises. """ from config import RED_THRESHOLD, YELLOW_THRESHOLD @@ -50,6 +54,20 @@ } +def _num(x, default: float = 0.0) -> float: + """Coerce to float, tolerating None / strings / bad input.""" + try: + if x is None: + return default + return float(x) + except (TypeError, ValueError): + return default + + +def _clamp(x: float, lo: float, hi: float) -> float: + return max(lo, min(hi, x)) + + def level_for(score: int) -> str: if score < YELLOW_THRESHOLD: return "green" @@ -69,8 +87,8 @@ def region_explanations(regions: list[str]) -> dict: def _expected_engagement(stimulus: dict | None) -> float: if not stimulus: return 0.5 - if "tribe_expected_engagement" in stimulus: - return float(stimulus["tribe_expected_engagement"]) + if stimulus.get("tribe_expected_engagement") is not None: + return _clamp(_num(stimulus.get("tribe_expected_engagement"), 0.5), 0.0, 1.0) return _CATEGORY_EXPECTED.get(stimulus.get("category"), 0.5) @@ -78,9 +96,9 @@ def _video_signals(video: dict | None) -> tuple[float, float]: """(facial 0-100, voice 0-100) from a normalized video-signal dict.""" if not video: return 0.0, 0.0 - facial = float(video.get("facial_score") or 0) - voice = float(video.get("voice_score") or 0) - return round(min(100.0, facial), 1), round(min(100.0, voice), 1) + facial = _clamp(_num(video.get("facial_score")), 0.0, 100.0) + voice = _clamp(_num(video.get("voice_score")), 0.0, 100.0) + return round(facial, 1), round(voice, 1) def synth_brain(level: str, score: int): @@ -91,7 +109,7 @@ def synth_brain(level: str, score: int): def _brain(level: str, score: int): """Return (flagged_regions, {region: activation 0-1}) synthesized from score.""" flagged = _ACTIVE_REGIONS.get(level, _ACTIVE_REGIONS["yellow"]) - base = score / 100.0 + base = _clamp(score, 0, 100) / 100.0 regions = {} for r in _ATLAS: if r in flagged: @@ -103,40 +121,40 @@ def _brain(level: str, score: int): def _typing_burnout(raw: dict) -> float: """0-100 typing-biometrics burnout contribution from raw signals.""" - err = float(raw.get("error_rate") or 0) - wpm = float(raw.get("typing_wpm") or 0) + err = max(0.0, _num(raw.get("error_rate"))) + wpm = max(0.0, _num(raw.get("typing_wpm"))) val = min(60.0, err) if 0 < wpm < 20: val += 25 elif wpm > 120: val += 10 - return round(min(100.0, val), 1) + return round(_clamp(val, 0.0, 100.0), 1) def _breakdown(analysis: dict, raw: dict, tribe_dev: float, facial: float = 0.0, voice: float = 0.0) -> dict: """Per-stream burnout contributions (0-100), the PRD §4.5 fusion inputs.""" - sentiment = float(analysis.get("sentiment_score", 0.5)) + sentiment = _clamp(_num(analysis.get("sentiment_score"), 0.5), 0.0, 1.0) return { - "imessage": round(min(100.0, (1 - sentiment) * 100), 1), + "imessage": round(_clamp((1 - sentiment) * 100, 0.0, 100.0), 1), "typing": _typing_burnout(raw), - "facial": round(facial, 1), - "voice": round(voice, 1), - "tribe": round(min(100.0, tribe_dev * 100), 1), + "facial": round(_clamp(facial, 0.0, 100.0), 1), + "voice": round(_clamp(voice, 0.0, 100.0), 1), + "tribe": round(_clamp(tribe_dev * 100, 0.0, 100.0), 1), } def _indicators(analysis: dict, raw: dict, level: str) -> list[str]: out: list[str] = [] - if float(analysis.get("sentiment_score", 0.5)) < 0.4: + if _num(analysis.get("sentiment_score"), 0.5) < 0.4: out.append("negative sentiment") if analysis.get("energy_level") == "low": out.append("low energy") - if float(raw.get("error_rate") or 0) > 15: + if _num(raw.get("error_rate")) > 15: out.append("high typing error rate") - wpm = float(raw.get("typing_wpm") or 0) + wpm = _num(raw.get("typing_wpm")) if 0 < wpm < 20: out.append("slowed typing") - if float(raw.get("response_time_ms") or 0) > 60000: + if _num(raw.get("response_time_ms")) > 60000: out.append("delayed response") for flag in analysis.get("linguistic_flags") or []: out.append(str(flag).replace("_", " ")) @@ -154,9 +172,9 @@ def _indicators(analysis: dict, raw: dict, level: str) -> list[str]: def local_analysis(raw: dict) -> dict: """Last-resort analysis when the signals service is ALSO offline.""" - err = float(raw.get("error_rate") or 0) - wpm = float(raw.get("typing_wpm") or 0) - rt = float(raw.get("response_time_ms") or 0) + err = max(0.0, _num(raw.get("error_rate"))) + wpm = max(0.0, _num(raw.get("typing_wpm"))) + rt = max(0.0, _num(raw.get("response_time_ms"))) combined = min(35.0, err) if 0 < wpm < 20: @@ -167,7 +185,7 @@ def local_analysis(raw: dict) -> dict: combined += 20 elif rt > 30000: combined += 10 - combined = min(100.0, combined) + combined = _clamp(combined, 0.0, 100.0) return { "sentiment_score": 0.5, @@ -185,16 +203,17 @@ def fallback_score(stimulus: dict | None, analysis: dict, raw: dict, video: dict `video` (latest known facial/voice for the user) is surfaced in the breakdown for continuity but does not move a text-channel score. """ + analysis = analysis or {} + raw = raw or {} expected = _expected_engagement(stimulus) - combined = float(analysis.get("combined_signal_score", 50)) / 100.0 # 0-1 burnout - actual_engagement = max(0.0, min(1.0, 1 - combined)) + combined = _clamp(_num(analysis.get("combined_signal_score"), 50.0) / 100.0, 0.0, 1.0) + actual_engagement = _clamp(1 - combined, 0.0, 1.0) tribe_dev = round(abs(expected - actual_engagement), 3) behavioral_dev = round(combined, 3) facial, voice = _video_signals(video) - score = int(round(100 * (0.6 * behavioral_dev + 0.4 * tribe_dev))) - score = max(0, min(100, score)) + score = int(_clamp(round(100 * (0.6 * behavioral_dev + 0.4 * tribe_dev)), 0, 100)) level = level_for(score) flagged, regions = _brain(level, score) @@ -222,11 +241,10 @@ def video_fallback_score(video: dict, prior: dict | None = None) -> dict: video_burden = (0.6 * facial + 0.4 * voice) / 100.0 # facial weighted highest prior = prior or {} - prior_beh = float(prior.get("behavioral_deviation") or 0.0) + prior_beh = _clamp(_num(prior.get("behavioral_deviation")), 0.0, 1.0) prior_bd = prior.get("breakdown") or {} - score = int(round(100 * (0.65 * video_burden + 0.35 * prior_beh))) - score = max(0, min(100, score)) + score = int(_clamp(round(100 * (0.65 * video_burden + 0.35 * prior_beh)), 0, 100)) level = level_for(score) indicators: list[str] = [] @@ -245,7 +263,7 @@ def video_fallback_score(video: dict, prior: dict | None = None) -> dict: return { "score": score, "level": level, - "tribe_deviation": float(prior.get("tribe_deviation") or 0.0), + "tribe_deviation": _num(prior.get("tribe_deviation")), "behavioral_deviation": round(video_burden, 3), "top_indicators": indicators[:5], "intervention": _intervention(level), @@ -274,8 +292,13 @@ def normalize_ml_result( Tolerates missing fields and synthesizes brain_regions / breakdown if absent. """ - score = max(0, min(100, int(round(float(result.get("score", 0)))))) - level = result.get("level") or level_for(score) + result = result or {} + score = int(_clamp(round(_num(result.get("score"))), 0, 100)) + level = result.get("level") + if level not in ("green", "yellow", "red"): + level = level_for(score) + if score >= RED_THRESHOLD: + level = "red" # safety: a red-range score is never downgraded (PRD §6) regions = result.get("brain_regions") or result.get("regions") flagged = result.get("brain_regions_flagged") or [] @@ -286,20 +309,20 @@ def normalize_ml_result( elif not flagged: flagged = [k for k, _ in sorted(regions.items(), key=lambda kv: -kv[1])[:3]] - tribe_dev = float(result.get("tribe_deviation", 0.0)) + tribe_dev = _num(result.get("tribe_deviation")) facial, voice = _video_signals(video) - breakdown = result.get("breakdown") or _breakdown(analysis, raw, tribe_dev, facial, voice) + breakdown = result.get("breakdown") or _breakdown(analysis or {}, raw or {}, tribe_dev, facial, voice) return { "score": score, "level": level, "tribe_deviation": tribe_dev, - "behavioral_deviation": float(result.get("behavioral_deviation", 0.0)), + "behavioral_deviation": _num(result.get("behavioral_deviation")), "top_indicators": result.get("top_indicators") or [], "intervention": result.get("intervention") or _intervention(level), "brain_regions_flagged": flagged, "brain_regions": regions, "breakdown": breakdown, - "confidence": float(result.get("confidence", 0.8)), + "confidence": _num(result.get("confidence"), 0.8), "source": "tribe", } From 721f8444ed3efdee074547ff1ef713137a93c932 Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sat, 13 Jun 2026 17:52:07 -0700 Subject: [PATCH 7/8] =?UTF-8?q?fix(backend):=20concurrency=20=E2=80=94=20h?= =?UTF-8?q?andle=20first-time-user=20race=20+=20SQLite=20WAL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by a 30-way concurrent first-submit stress test (app + SMS hitting a brand-new user at once), which previously TIMED OUT every request: - ensure_user did get-then-insert with no IntegrityError handling; a losing race left a failed transaction holding the SQLite write lock, stalling all other requests until timeout. Now catches IntegrityError, rolls back, and reuses the row the winner created. - SQLite: enable WAL journal mode + busy_timeout=8s + synchronous=NORMAL, and a larger pool (20+30), so concurrent reads/writes don't block. .gitignore covers -wal/-shm sidecars. Verified: 30x6 concurrent submits + 15 concurrent video = 0 failures; 28/28 regression still green. Co-Authored-By: Claude Opus 4.8 --- backend/.gitignore | 6 ++++-- backend/crud.py | 13 ++++++++++--- backend/models/database.py | 20 +++++++++++++++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend/.gitignore b/backend/.gitignore index 0c41a40..9c902fd 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,7 +1,9 @@ -# SQLite database files (created at runtime) +# SQLite database files (created at runtime; -wal/-shm are WAL sidecars) *.db +*.db-wal +*.db-shm *.sqlite3 -pegasus.db +pegasus.db* # Local virtualenv .venv/ diff --git a/backend/crud.py b/backend/crud.py index bb2730c..199913a 100644 --- a/backend/crud.py +++ b/backend/crud.py @@ -1,4 +1,5 @@ """Thin persistence helpers over the SQLAlchemy models.""" +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from models.score_record import ScoreRecord @@ -13,10 +14,16 @@ def get_user(db: Session, user_id: str) -> User | None: def ensure_user(db: Session, user_id: str, name=None, phone=None) -> User: user = db.get(User, user_id) - if user is None: - user = User(id=user_id, name=name, phone=phone) - db.add(user) + if user is not None: + return user + user = User(id=user_id, name=name, phone=phone) + db.add(user) + try: db.commit() + except IntegrityError: + # Concurrent request created the same user first: roll back and reuse it. + db.rollback() + user = db.get(User, user_id) return user diff --git a/backend/models/database.py b/backend/models/database.py index 5354f30..18653e4 100644 --- a/backend/models/database.py +++ b/backend/models/database.py @@ -1,14 +1,28 @@ """SQLite + SQLAlchemy setup and the FastAPI DB dependency.""" -from sqlalchemy import create_engine +from sqlalchemy import create_engine, event from sqlalchemy.orm import declarative_base, sessionmaker from config import DATABASE_URL +_is_sqlite = DATABASE_URL.startswith("sqlite") # check_same_thread is a SQLite-only quirk; timeout sets the busy-wait so # concurrent app + SMS writes wait instead of erroring with "database is locked". -_connect_args = {"check_same_thread": False, "timeout": 30} if DATABASE_URL.startswith("sqlite") else {} +_connect_args = {"check_same_thread": False, "timeout": 10} if _is_sqlite else {} +# A roomier pool so a concurrency burst (app + SMS at once) doesn't starve on +# connection checkout. +_pool = {"pool_size": 20, "max_overflow": 30} if _is_sqlite else {} + +engine = create_engine(DATABASE_URL, connect_args=_connect_args, **_pool) + +if _is_sqlite: + @event.listens_for(engine, "connect") + def _sqlite_pragmas(dbapi_conn, _record): + cur = dbapi_conn.cursor() + cur.execute("PRAGMA journal_mode=WAL") # readers don't block the single writer + cur.execute("PRAGMA busy_timeout=8000") # wait up to 8s for the write lock + cur.execute("PRAGMA synchronous=NORMAL") # safe under WAL, much faster + cur.close() -engine = create_engine(DATABASE_URL, connect_args=_connect_args) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() From 481ac98733146b096cb23d46a98dd941e29cbb27 Mon Sep 17 00:00:00 2001 From: jasonca2023 Date: Sun, 14 Jun 2026 13:59:35 -0700 Subject: [PATCH 8/8] docs: rewrite README with full feature documentation and MIT license --- README.md | 153 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4b1bae5..ccec9fb 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,141 @@ -# pegasus +# Pegasus -## Branching model +Pegasus is a multimodal burnout detection and mental wellness platform. It works like a check engine light for the mind — passively collecting behavioral signals throughout the day, fusing them into a single wellness score, and surfacing a grounded, personalized intervention when that score drops. + +## Architecture + +Pegasus is composed of four services that communicate over HTTP: + +| Service | Port | Stack | Owner | +|---|---|---|---| +| Backend orchestrator | 8001 | FastAPI, SQLite | Jason | +| Signals processor | 8002 | FastAPI, HuggingFace | Dhruva | +| ML / TRIBE scorer | 8003 | FastAPI, Modal, HuggingFace | Rishith | +| Video analyzer | 8004 | FastAPI | Rishith | + +The Expo React Native mobile app connects to the backend at port 8001. The SMS bot runs alongside the signals service and polls iMessage via a local AppleScript bridge. + +## Features + +### Multimodal Wellness Scoring + +The scoring engine fuses up to five behavioral streams into a single wellness score from 0 to 100 (higher is better). Each stream contributes a burnout sub-score that is weighted and normalized depending on which signals are actually present in a given check-in. + +| Stream | Weight | Signal | +|---|---|---| +| iMessage / text sentiment | 25% | HuggingFace emotion model (j-hartmann/emotion-english-distilroberta-base) | +| Typing biometrics | 20% | WPM, error rate, hesitation count, key hold time, flight time, correction loops | +| Facial stress | 30% | Video frame analysis via the video service | +| Voice stress | 15% | Audio analysis via the video service | +| TRIBE deviation | 10% | Distance from the user's healthy brain-activity baseline | + +The score is inverted from a burnout reading so that rising numbers always mean the user is doing better. Scores above 70 are green, 40-69 are yellow, and below 40 are red. + +### TRIBE Baseline Model + +TRIBE (Temporal Representation of Integrated Brain Events) v2 is a deployed Modal endpoint that models healthy brain activation patterns across six macro-regions: Attention, Auditory, Emotion, Language, Motor, and Visual. For each daily stimulus, Pegasus calls TRIBE once to establish the user's expected healthy-state baseline, then measures deviation from that baseline as a behavioral burnout signal. TRIBE results are cached per stimulus to avoid repeated GPU calls. + +### Crisis and Masking Detection + +The scoring engine applies a keyword override layer on top of the emotion model to catch signals the model tends to under-read: + +- Crisis terms (suicidal ideation, self-harm language) force the iMessage sub-score to 100 regardless of the emotion model output. +- Concern terms (hopeless, burned out, can't cope, etc.) floor the sub-score proportionally to the number of terms present. +- Masking language (clustered over-reassurance such as "I'm fine, totally fine, don't worry") is detected as a tell and pulls the score out of the green even when the emotion model reads positive. + +When the overall score reaches red, the backend routes the user to human support rather than an automated intervention. + +### RAG Intervention Engine + +When the wellness score drops, Pegasus generates a single, warm, specific recommended action using a retrieval-augmented generation pipeline: + +1. A mental health coping corpus is embedded using sentence-transformers/all-MiniLM-L6-v2. +2. The most relevant snippet is retrieved using cosine similarity. +3. A HuggingFace chat model (Qwen/Qwen2.5-7B-Instruct by default) generates a grounded response using only the retrieved content. + +The pipeline degrades gracefully at every step: if HuggingFace is unreachable, the raw retrieved snippet is returned; if the corpus is unavailable, a level-appropriate canned response is used. The intervention call never raises. + +### Signal Collection + +The signals service collects and analyzes behavioral data from multiple sources: + +- **iMessage polling** — an AppleScript bridge reads the local Messages database and extracts response text, response time, and typing metadata. +- **Keystroke biometrics** — a JavaScript collector (collector.js) captures WPM, error rate, hesitation count, key hold duration, inter-key flight time, burst pattern, and correction loops from in-app typing sessions. +- **Sentiment analysis** — HuggingFace Inference API replaces any Claude-based sentiment pipeline; the model degrades to a neutral 0.5 score if the API is unreachable. +- **Linguistic analysis** — structural and lexical features of response text. +- **Temporal analysis** — time-of-day and response latency patterns. + +The signals service maintains a rolling in-memory history of the last 30 readings per user and exposes them to the backend via a typed JSON contract. + +### SMS Bot + +The SMS bot provides a low-friction check-in channel for users who are not on the mobile app: + +- Outbound check-in prompts are sent via Bloo.io SMS. +- Inbound replies are received via a webhook and routed through the full signal analysis and scoring pipeline. +- Automated reminders run on a configurable cadence. +- Stimulus prompts are sent on the same schedule as the in-app experience. + +### Video Check-in + +The mobile app includes a video check-in flow: + +1. The user records a short clip via the in-app camera (60-second maximum, with a countdown ring). +2. The clip is submitted to the video service, which extracts facial stress and voice stress scores. +3. Results are fused into the wellness score and displayed alongside the brain visualization immediately after analysis. + +### Mobile App + +The Expo React Native app is the primary user-facing interface: + +- **Home screen** — an animated breathing orb displays the current wellness score in the user's level color (green, yellow, red), along with the top behavioral indicators and the current intervention suggestion. A red reading triggers a haptic alert and a full-screen support prompt. +- **Check-in screen** — live camera with face-oval overlay, rotating wellness prompts, recording controls, and a post-analysis results view. +- **Brain screen** — a 3D brain visualization that highlights the TRIBE regions currently flagged as elevated. +- **Metrics screen** — historical wellness score charts and breakdown by signal stream. +- **History screen** — a chronological log of past check-ins and score readings. +- **Chat / Talk screen** — a conversational interface for in-app text check-ins. + +The UI uses a liquid glass visual language with level-reactive accent colors throughout. + +### Backend API + +The FastAPI backend at port 8001 is the single point of integration for the mobile app and SMS bot. It persists all readings to SQLite with WAL mode for concurrency, handles first-time-user race conditions, and exposes the following route groups: + +| Route | Purpose | +|---|---| +| `/stimulus` | Deliver the daily check-in prompt | +| `/response` | Accept a text or keystroke-biometric check-in response | +| `/score` | Return the latest wellness score for a user | +| `/brain` | Return TRIBE brain region data for the 3D visualization | +| `/history` | Return the user's check-in history | +| `/metrics` | Return aggregated score and breakdown data for charts | +| `/video` | Accept a video submission and return facial/voice analysis | + +### Infrastructure + +A launchd supervisor script keeps all four services, the iMessage poller, and the ngrok tunnel alive, restarting any process that exits. The sync script handles safe periodic state synchronization without reverting or deleting data. + +## Branch Structure ``` -main (protected — NEVER push directly) - └── dev (integration branch — PRs only) - ├── feat/rishith-ml → /ml/* - ├── feat/wesley-ui → /frontend/* - ├── feat/jason-api → /backend/*, /shared/* - └── feat/dhruva-sig → /signals/* +main + └── dev + ├── feat/jason-api backend/, shared/ + ├── feat/wesley-ui frontend/ + ├── feat/rishith-ml ml/ + └── feat/dhruva-sig signals/ ``` -## Ownership +`feat/integration` and `feat/scaffold` are integration and scaffolding branches used during active development. + +## License + +MIT License + +Copyright (c) 2024 Pegasus Contributors -| Branch | Owner | Scope | -| ----------------- | ------- | ---------------------- | -| `feat/rishith-ml` | Rishith | `ml/` | -| `feat/wesley-ui` | Wesley | `frontend/` | -| `feat/jason-api` | Jason | `backend/`, `shared/` | -| `feat/dhruva-sig` | Dhruva | `signals/` | +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -## Rules +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -- `main` is protected. No direct pushes. Releases only via PR from `dev`. -- `dev` is the integration branch. Feature branches PR into `dev`. -- Stay in your lane: only touch the directories listed for your branch. -- Rebase or merge `dev` into your feature branch regularly to stay current. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.