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. 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..9c902fd --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,13 @@ +# SQLite database files (created at runtime; -wal/-shm are WAL sidecars) +*.db +*.db-wal +*.db-shm +*.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..3c917ce --- /dev/null +++ b/backend/config.py @@ -0,0 +1,29 @@ +"""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 = 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 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__)) +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..199913a --- /dev/null +++ b/backend/crud.py @@ -0,0 +1,128 @@ +"""Thin persistence helpers over the SQLAlchemy models.""" +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from models.score_record import ScoreRecord +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: + 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 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 + + +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, source: str) -> ScoreRecord: + rec = ScoreRecord( + user_id=user_id, + stimulus_id=stimulus_id, + score=result["score"], + level=result["level"], + breakdown=result.get("breakdown", {}), + indicators=result.get("top_indicators", []), + intervention=result.get("intervention", ""), + 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=source, + ) + 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 = 30) -> list[ScoreRecord]: + """Most recent first, up to `limit`.""" + return ( + db.query(ScoreRecord) + .filter(ScoreRecord.user_id == user_id) + .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 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, + 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, + ) + db.add(rec) + db.commit() + db.refresh(rec) + return rec diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..88890e2 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,63 @@ +"""Pegasus backend — FastAPI orchestrator on port 8001. + +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 + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +import config +from models.database import init_db +from routes import brain, history, metrics, response, score, stimulus, video + +logging.basicConfig(level=logging.INFO) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + + +app = FastAPI(title="Pegasus Backend", version="2.0.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +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": "2.0.0", "docs": "/docs"} + + +@app.get("/health") +def health(): + return { + "status": "pegasus backend running", + "port": config.BACKEND_PORT, + "signals_url": config.SIGNAL_SERVICE, + "ml_url": config.ML_SERVICE, + "video_url": config.VIDEO_SERVICE, + } + + +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..18653e4 --- /dev/null +++ b/backend/models/database.py @@ -0,0 +1,43 @@ +"""SQLite + SQLAlchemy setup and the FastAPI DB dependency.""" +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": 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() + +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 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 new file mode 100644 index 0000000..f07b1f9 --- /dev/null +++ b/backend/models/score_record.py @@ -0,0 +1,47 @@ +"""Score history table ("scores"). One row per scored response (app or sms).""" +from datetime import datetime, timezone + +from sqlalchemy import JSON, Column, DateTime, Float, Integer, String + +import safety +from models.database import Base + + +class ScoreRecord(Base): + __tablename__ = "scores" + + 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) + 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` (with helpful extra fields).""" + 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": self.indicators or [], + "intervention": self.intervention, + "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/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..bfb27a6 --- /dev/null +++ b/backend/models/user.py @@ -0,0 +1,15 @@ +"""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 + +from models.database import Base + + +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) + 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..422e178 --- /dev/null +++ b/backend/models/video_record.py @@ -0,0 +1,28 @@ +"""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) + 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)) + + 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/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..fdacde6 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.110 +uvicorn[standard]>=0.27 +sqlalchemy>=2.0 +httpx>=0.27 +python-dotenv>=1.0 +pydantic>=2.6 +python-multipart>=0.0.9 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..cae1436 --- /dev/null +++ b/backend/routes/brain.py @@ -0,0 +1,43 @@ +"""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() +log = logging.getLogger("pegasus.backend") + + +@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: + 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") + 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": flagged, + "explanations": scoring.region_explanations(flagged), + "source": "fallback", + } diff --git a/backend/routes/history.py b/backend/routes/history.py new file mode 100644 index 0000000..19fb22b --- /dev/null +++ b/backend/routes/history.py @@ -0,0 +1,14 @@ +"""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() + + +@router.get("/{user_id}") +def get_history(user_id: str, db: Session = Depends(get_db)): + recs = crud.score_history(db, user_id) + 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 new file mode 100644 index 0000000..9feab20 --- /dev/null +++ b/backend/routes/response.py @@ -0,0 +1,94 @@ +"""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 + +from fastapi import APIRouter, Depends +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 + +router = APIRouter() +log = logging.getLogger("pegasus.backend") + + +@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) + 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, + "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( + { + "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) + analysis = scoring.local_analysis(raw) + signals_ok = False + + # 2) Burnout scoring (Rishith, 8003). Fall back to local scoring if offline. + ml_signals = { + "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, + "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, 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, 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) + + # 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": + 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"} + 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..41d6f48 --- /dev/null +++ b/backend/routes/score.py @@ -0,0 +1,32 @@ +"""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() + + +@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: + # 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": {}, + "support": [], + "source": "none", + "timestamp": None, + } + return rec.to_result() diff --git a/backend/routes/stimulus.py b/backend/routes/stimulus.py new file mode 100644 index 0000000..ffe4d2b --- /dev/null +++ b/backend/routes/stimulus.py @@ -0,0 +1,26 @@ +"""Stimulus selection + delivery (prefix /stimulus).""" +from fastapi import APIRouter, HTTPException + +from services import stimuli + +router = APIRouter() + + +@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("/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..ce8ba06 --- /dev/null +++ b/backend/routes/video.py @@ -0,0 +1,71 @@ +"""POST /video/submit — video check-in (prefix /video). + +Forwards the upload to Rishith's video service (8004), stores facial + voice +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 + +from fastapi import APIRouter, Depends, File, Form, UploadFile +from sqlalchemy.orm import Session + +import crud +import safety +from models.database import get_db +from services import ml_client, scoring, 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: + 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) + analysis = {"facial": {}, "voice": {}, "error": f"video service unavailable: {exc}"} + video_ok = False + + facial = analysis.get("facial", {}) or {} + voice = analysis.get("voice", {}) or {} + facial_score = int(facial.get("facial_stress_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) + + # 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 new file mode 100644 index 0000000..959a6c7 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,40 @@ +"""Pydantic request/response models aligned with shared/contract.json (v2).""" +from typing import Optional + +from pydantic import BaseModel + + +class ResponseSubmitRequest(BaseModel): + user_id: str + stimulus_id: 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 + 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: Optional[str] = None + stimulus_id: Optional[str] = None + score: int + level: 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() + 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/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..c2b2379 --- /dev/null +++ b/backend/services/scoring.py @@ -0,0 +1,328 @@ +"""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, 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). + +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 + +# 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"], + "red": ["amygdala", "anterior cingulate cortex", "insula"], +} +_ATLAS = [ + "medial prefrontal cortex", + "dorsolateral prefrontal cortex", + "anterior cingulate cortex", + "posterior cingulate cortex", + "insula", + "amygdala", + "ventral striatum", + "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 and step away from " + "work for a bit, and take one thing off your plate today.", +} + + +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" + if score < RED_THRESHOLD: + return "yellow" + return "red" + + +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 + 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) + + +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 = _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): + """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"]) + base = _clamp(score, 0, 100) / 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 _typing_burnout(raw: dict) -> float: + """0-100 typing-biometrics burnout contribution from raw signals.""" + 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(_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 = _clamp(_num(analysis.get("sentiment_score"), 0.5), 0.0, 1.0) + return { + "imessage": round(_clamp((1 - sentiment) * 100, 0.0, 100.0), 1), + "typing": _typing_burnout(raw), + "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 _num(analysis.get("sentiment_score"), 0.5) < 0.4: + out.append("negative sentiment") + if analysis.get("energy_level") == "low": + out.append("low energy") + if _num(raw.get("error_rate")) > 15: + out.append("high typing error rate") + wpm = _num(raw.get("typing_wpm")) + if 0 < wpm < 20: + out.append("slowed typing") + 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("_", " ")) + + if not out: + out = ["healthy engagement"] if level == "green" else ["elevated stress signals"] + + 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.""" + 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: + combined += 20 + elif wpm > 120: + combined += 10 + if rt > 60000: + combined += 20 + elif rt > 30000: + combined += 10 + combined = _clamp(combined, 0.0, 100.0) + + 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, 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. + """ + analysis = analysis or {} + raw = raw or {} + expected = _expected_engagement(stimulus) + 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(_clamp(round(100 * (0.6 * behavioral_dev + 0.4 * tribe_dev)), 0, 100)) + 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, + "breakdown": _breakdown(analysis, raw, tribe_dev, facial, voice), + "confidence": 0.55 if analysis.get("_local") else 0.7, + "source": "fallback", + } + + +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 = _clamp(_num(prior.get("behavioral_deviation")), 0.0, 1.0) + prior_bd = prior.get("breakdown") or {} + + score = int(_clamp(round(100 * (0.65 * video_burden + 0.35 * prior_beh)), 0, 100)) + 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": _num(prior.get("tribe_deviation")), + "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. + """ + 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 [] + 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]] + + tribe_dev = _num(result.get("tribe_deviation")) + facial, voice = _video_signals(video) + 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": _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": _num(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..9236161 --- /dev/null +++ b/backend/services/signal_client.py @@ -0,0 +1,41 @@ +"""HTTP client for Dhruva's signal-processing service (localhost:8002). + +Functions raise on transport/HTTP error; callers decide how to degrade. +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 + +from config import SERVICE_TIMEOUT, SIGNAL_SERVICE + + +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: + 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: + res = await client.get(f"{SIGNAL_SERVICE}/signals/{user_id}") + res.raise_for_status() + return res.json() + + +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/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/backend/services/stimuli.py b/backend/services/stimuli.py new file mode 100644 index 0000000..f045147 --- /dev/null +++ b/backend/services/stimuli.py @@ -0,0 +1,37 @@ +"""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 + + +@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 + + +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/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 new file mode 100644 index 0000000..5178f16 --- /dev/null +++ b/shared/contract.json @@ -0,0 +1,85 @@ +{ + "version": "2.0.0", + "owner": "jason (backend)", + "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", + "video": "http://localhost:8004" + }, + "levels": { + "green": "score < 30", + "yellow": "30 <= score < 65", + "red": "score >= 65" + }, + "stimulus": { + "id": "string", + "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)", + "source": "app|sms", + "timestamp": "ISO8601" + }, + "burnout_result": { + "score": "0-100", + "level": "green|yellow|red", + "tribe_deviation": "number", + "behavioral_deviation": "number", + "top_indicators": ["string"], + "intervention": "string (Claude-generated via ML; warm, actionable, non-diagnostic)", + "brain_regions_flagged": ["string"], + "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)" + }, + "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": { + "backend (8001, owner: jason)": { + "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 /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) -> { 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} }", + "GET /signals/{user_id}": "-> latest analyzed signals", + "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", + "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: {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." +} diff --git a/shared/stimuli.json b/shared/stimuli.json new file mode 100644 index 0000000..02166b0 --- /dev/null +++ b/shared/stimuli.json @@ -0,0 +1,12 @@ +{ + "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": "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?" } + ] +}