LifeStack / app_flask.py
Soham Banerjee
deploy: pure lifestack with partitioned wisdom pool
77da5ce
"""
app_flask.py β€” LifeStack Flask Portal (FULL FEATURE PARITY)
Complete migration of the Gradio demo to a Flask-native architecture.
Includes: Live Demo, Custom Situations, Gmail Sync, Longitudinal Analysis, Task Explorer.
"""
import os
import json
import copy
import uuid
import datetime
from collections import deque
from flask import Flask, render_template, request, jsonify, session
from core.life_state import LifeMetrics, ResourceBudget, DependencyGraph
from core.lifestack_env import LifeStackEnv, LifeStackAction
from agent.agent import LifeStackAgent
from intake.simperson import SimPerson
from agent.conflict_generator import ConflictEvent, generate_conflict, TEMPLATES
from core.action_space import apply_action, validate_action
from agent.memory import LifeStackMemory
from core.metric_schema import normalize_metric_path, is_valid_metric_path
from core.reward import compute_reward
from intake.intake import LifeIntake
from agent.conflict_predictor import ConflictPredictor
from agent.counterfactuals import generate_counterfactuals
from scripts.longitudinal_demo import LongitudinalDemo
from intake.gmail_intake import GmailIntake
from intake.calendar_intake import CalendarIntake
from core.task import Task, ExoEvent, Route, Milestone
from core.feedback import OutcomeFeedback, compute_human_feedback_reward
from core.cascade_utils import animate_cascade
app = Flask(__name__)
app.secret_key = "lifestack_secret_key_2026"
# ─── Global Instances ───
AGENT = LifeStackAgent(api_only=not bool(os.getenv('LIFESTACK_MODEL_PATH')))
MEMORY = LifeStackMemory(silent=True)
INTAKE = LifeIntake()
USER_HEALTH_OVERRIDES: dict = {} # persisted health/calendar metric deltas
EPISODE_HISTORY: deque = deque(maxlen=5) # ring buffer, most recent first
@app.route('/api/history', methods=['GET'])
@app.route('/api/history/list', methods=['GET'])
def get_history():
summaries = [
{
"id": ep.get("action", {}).get("id", ""),
"conflict": ep.get("conflict", {}).get("title", "Unknown"),
"person": ep.get("conflict", {}).get("person", "Unknown"),
"reward": ep.get("action", {}).get("reward", 0.0),
"timestamp": ep.get("timestamp", ""),
}
for ep in EPISODE_HISTORY
]
return jsonify(summaries)
@app.route('/api/history/replay/<episode_id>', methods=['GET'])
def replay_episode(episode_id):
for ep in EPISODE_HISTORY:
if ep.get("action", {}).get("id", "") == episode_id:
return jsonify(ep)
return jsonify({"error": "Episode not found"}), 404
GMAIL = GmailIntake()
CALENDAR = CalendarIntake()
LONG_DEMO = LongitudinalDemo()
DEMO_PREDICTOR = ConflictPredictor()
# Friday 6PM is always the default demo conflict
DEMO_CONFLICT = next(t for t in TEMPLATES if t.id == "d5_friday")
PERSONS = {
"Alex (Executive) β€” driven, high-stress":
SimPerson(openness=0.4, conscientiousness=0.9, extraversion=0.7, agreeableness=0.25, neuroticism=0.8, name="Alex (Executive)"),
"Chloe (Creative) β€” spontaneous, resilient":
SimPerson(openness=0.9, conscientiousness=0.2, extraversion=0.5, agreeableness=0.70, neuroticism=0.15, name="Chloe (Creative)"),
"Sam (Introvert) β€” anxious, thoughtful":
SimPerson(openness=0.5, conscientiousness=0.6, extraversion=0.1, agreeableness=0.65, neuroticism=0.9, name="Sam (Introvert)"),
"Maya (Family) β€” empathetic, nurturing":
SimPerson(openness=0.5, conscientiousness=0.7, extraversion=0.5, agreeableness=0.95, neuroticism=0.3, name="Maya (Family)"),
"Leo (Student) β€” curious, organised":
SimPerson(openness=0.85, conscientiousness=0.8, extraversion=0.4, agreeableness=0.4, neuroticism=0.55, name="Leo (Student)"),
"Arjun (Startup Lead) β€” high- conscientiousness, high-neuroticism":
SimPerson(name="Arjun", openness=0.4, conscientiousness=0.9, extraversion=0.7, agreeableness=0.25, neuroticism=0.8),
}
CONFLICT_CHOICES = {t.title: t for t in TEMPLATES}
# ─── Visual Helpers ───
DOMAIN_EMOJI = {
"career": "πŸ’Ό", "finances": "πŸ’°", "relationships": "❀️",
"physical_health": "πŸ’ͺ", "mental_wellbeing": "🧠", "time": "πŸ“…",
}
INVERTED_METRICS = {"stress_level", "debt_pressure", "workload", "commute_burden", "admin_overhead"}
_DOMAINS = ["career", "finances", "relationships", "physical_health", "mental_wellbeing", "time"]
def compute_domain_health(metrics_flat: dict) -> dict:
"""Compute per-domain health score (0-100) from flat metrics. Inverted metrics are flipped."""
health = {}
for dom in _DOMAINS:
subs = {k: v for k, v in metrics_flat.items() if k.startswith(dom + ".")}
if not subs:
health[dom] = 50.0
continue
scores = []
for k, v in subs.items():
sub = k.split(".")[1]
scores.append((100.0 - v) if sub in INVERTED_METRICS else float(v))
health[dom] = round(sum(scores) / len(scores), 1)
return health
def _normalize_action_metric_changes(action) -> None:
fixed_changes = {}
for path, delta in action.primary.metric_changes.items():
raw_path = str(path)
if "." not in raw_path:
raw_path = f"{action.primary.target_domain}.{raw_path}"
norm_path = normalize_metric_path(raw_path)
if not is_valid_metric_path(norm_path): continue
try:
fixed_changes[norm_path] = float(delta)
except (ValueError, TypeError): continue
action.primary.metric_changes = fixed_changes
# ─── Routes ───
@app.route('/')
def index():
return render_template('index.html',
persons=list(PERSONS.keys()),
conflicts=list(CONFLICT_CHOICES.keys()))
@app.route('/api/simulation/start', methods=['POST'])
def start_simulation():
data = request.json
conflict_label = data.get('conflict')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
base_metrics = LifeMetrics()
# Apply any uploaded health/calendar overrides
for path, delta in USER_HEALTH_OVERRIDES.items():
if '.' in path:
dom, sub = path.split('.', 1)
dom_obj = getattr(base_metrics, dom, None)
if dom_obj and hasattr(dom_obj, sub):
setattr(dom_obj, sub, max(0.0, min(100.0, getattr(dom_obj, sub) + delta)))
flat = base_metrics.flatten()
return jsonify({
"status": "success",
"metrics": flat,
"prediction": {
"summary": DEMO_PREDICTOR.get_prediction_summary(),
"risk_score": DEMO_PREDICTOR.get_risk_score()
}
})
@app.route('/api/simulation/cascade', methods=['POST'])
def get_cascade_frames():
data = request.json
conflict_label = data.get('conflict')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
frames = animate_cascade(conflict.primary_disruption, LifeMetrics())
return jsonify({"frames": frames})
@app.route('/api/simulation/graph', methods=['GET'])
def get_dependency_graph():
graph = DependencyGraph()
nodes = []
edges = []
# Flatten metrics to get all nodes
metrics = LifeMetrics().flatten()
for path in metrics.keys():
dom, sub = path.split('.')
nodes.append({
"id": path,
"label": sub.replace('_', ' '),
"group": dom
})
for src, targets in graph.edges.items():
for target, weight in targets:
edges.append({
"from": src,
"to": target,
"value": abs(weight),
"arrows": "to",
"color": {"color": "#4ade80" if weight > 0 else "#ef4444", "opacity": 0.2}
})
return jsonify({"nodes": nodes, "edges": edges})
@app.route('/api/simulation/action', methods=['POST'])
def perform_action():
data = request.json
person_label = data.get('person')
conflict_label = data.get('conflict')
memory_enabled = data.get('use_memory', False)
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
person = PERSONS.get(person_label, PERSONS["Alex (Executive) β€” driven, high-stress"])
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
before_metrics = copy.deepcopy(env.state.current_metrics)
before_budget = copy.deepcopy(env.state.budget)
# RAG: Build few-shot context from ChromaDB if enabled
few_shot = ""
retrieved = []
if memory_enabled:
few_shot = MEMORY.build_few_shot_prompt(conflict.title, before_metrics.flatten())
retrieved = MEMORY.retrieve_similar(conflict.title, before_metrics.flatten())
action = AGENT.get_action(before_metrics, before_budget, conflict, person, few_shot_context=few_shot)
_normalize_action_metric_changes(action)
uptake = person.respond_to_action(action.primary.action_type, action.primary.resource_cost,
before_metrics.mental_wellbeing.stress_level)
env_action = LifeStackAction.from_agent_action(action)
env_action.metric_changes = {k: v * uptake for k, v in action.primary.metric_changes.items()}
obs = env.step(env_action)
# Store decision in memory for future RAG
MEMORY.store_decision(
conflict_title=conflict.title,
action_type=action.primary.action_type,
target_domain=action.primary.target_domain,
reward=obs.reward,
metrics_snapshot=before_metrics.flatten(),
reasoning=action.reasoning
)
cf_data = generate_counterfactuals(AGENT, before_metrics, before_budget, conflict, person, action)
episode_id = "".join(str(uuid.uuid4()).split("-")[:2]).upper()
result = {
"metrics": obs.metrics,
"domain_health": compute_domain_health(obs.metrics),
"action": {
"type": action.primary.action_type,
"target": action.primary.target_domain,
"description": action.primary.description,
"reasoning": action.reasoning,
"reward": obs.reward,
"uptake": uptake,
"cost": action.primary.resource_cost,
"id": episode_id,
"memories_retrieved": retrieved
},
"counterfactuals": cf_data,
"prediction": {
"summary": DEMO_PREDICTOR.get_prediction_summary(),
"risk_score": DEMO_PREDICTOR.get_risk_score()
},
"conflict": {
"title": conflict.title,
"person": person.name
},
"timestamp": datetime.datetime.now().strftime("%H:%M:%S")
}
# Store in history
EPISODE_HISTORY.appendleft(result)
return jsonify(result)
# ─── 7-Day Trajectory ───
@app.route('/api/simulation/trajectory', methods=['POST'])
def get_trajectory():
"""
Run the agent action then perform a 7-step rollout.
Returns per-day metric snapshots for the forecast panel.
"""
data = request.json
conflict_label = data.get('conflict')
person_label = data.get('person')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
person = PERSONS.get(person_label, PERSONS["Alex (Executive) β€” driven, high-stress"])
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
before_metrics = copy.deepcopy(env.state.current_metrics)
before_budget = copy.deepcopy(env.state.budget)
action = AGENT.get_action(before_metrics, before_budget, conflict, person)
_normalize_action_metric_changes(action)
uptake = person.respond_to_action(
action.primary.action_type, action.primary.resource_cost,
before_metrics.mental_wellbeing.stress_level,
)
env_action = LifeStackAction.from_agent_action(action)
env_action.metric_changes = {k: v * uptake for k, v in action.primary.metric_changes.items()}
obs = env.step(env_action)
rollout = env.rollout(n_steps=7, gamma=0.9)
return jsonify({
"action": {
"type": action.primary.action_type,
"target": action.primary.target_domain,
"reasoning": action.reasoning,
"reward": obs.reward,
},
"day0_metrics": dict(obs.metrics),
"discounted_reward": rollout["discounted_reward"],
"trajectory": rollout["trajectory"],
})
# ─── Custom Situation Entry ───
@app.route('/api/custom/run', methods=['POST'])
def run_custom():
data = request.json
situation_input = data.get('situation', "")
# Map sliders to metrics
m = LifeMetrics()
m.career.stress_level = float(data.get('work_stress', 5)) * 10
m.finances.debt_pressure = float(data.get('money_stress', 5)) * 10
m.relationships.conflict_frequency = (10 - float(data.get('rel_quality', 5))) * 10
m.physical_health.energy_level = float(data.get('energy_level', 5)) * 10
m.time.free_time = (10 - float(data.get('time_pressure', 5))) * 10
# Apply uploaded health/calendar overrides to custom metrics
for path, delta in USER_HEALTH_OVERRIDES.items():
if '.' in path:
dom, sub = path.split('.', 1)
dom_obj = getattr(m, dom, None)
if dom_obj and hasattr(dom_obj, sub):
setattr(dom_obj, sub, max(0.0, min(100.0, getattr(dom_obj, sub) + delta)))
gmail_signals = data.get('gmail_signals')
if gmail_signals:
# Merge digital signals if provided
for k, v in gmail_signals.items():
parts = k.split(".")
if len(parts) == 2:
dom = getattr(m, parts[0], None)
if dom and hasattr(dom, parts[1]):
setattr(dom, parts[1], v)
# Extract conflict from text using LLM
conflict = INTAKE.extract_conflict(situation_input, m)
pers_dict = INTAKE.get_personality_from_description(situation_input)
person = SimPerson(
name=pers_dict.get("name", "Inferred Self"),
openness=pers_dict.get("openness", 0.5),
conscientiousness=pers_dict.get("conscientiousness", 0.5),
extraversion=pers_dict.get("extraversion", 0.5),
agreeableness=pers_dict.get("agreeableness", 0.5),
neuroticism=pers_dict.get("neuroticism", 0.5)
)
budget = ResourceBudget(time=24, money=1000, energy=100)
action = AGENT.get_action(m, budget, conflict, person)
_normalize_action_metric_changes(action)
uptake = person.respond_to_action(action.primary.action_type, action.primary.resource_cost,
m.mental_wellbeing.stress_level)
env = LifeStackEnv()
env.state.current_metrics = copy.deepcopy(m)
env.state.budget = budget
env_action = LifeStackAction.from_agent_action(action)
env_action.metric_changes = {k: v * uptake for k, v in action.primary.metric_changes.items()}
obs = env.step(env_action)
return jsonify({
"before_metrics": m.flatten(),
"after_metrics": obs.metrics,
"domain_health": compute_domain_health(obs.metrics),
"action": {
"type": action.primary.action_type,
"target": action.primary.target_domain,
"description": action.primary.description,
"reasoning": action.reasoning,
"id": "".join(str(uuid.uuid4()).split("-")[:2]).upper()
},
"person": {"name": person.name or "Inferred Self"}
})
@app.route('/api/gmail/sync', methods=['POST'])
def sync_gmail():
signals, metric_deltas, summary, is_demo = GMAIL.sync()
return jsonify({
"status": "success",
"signals": metric_deltas,
"raw": signals,
"summary": summary,
"is_demo": is_demo,
})
@app.route('/api/digital/sync', methods=['POST'])
def digital_sync():
"""
Unified Digital Sync β€” Gmail + Google Calendar + Fitness (demo payload).
Tries real OAuth for Gmail and Calendar; falls back to demo_signals.json on failure.
Fitness is always served from the demo payload (no first-party fitness API scope).
Returns merged metric deltas, per-source raw signals, and a demo flag per source.
"""
import json as _json
demo_path = os.path.join(os.path.dirname(__file__), 'data', 'demo_signals.json')
with open(demo_path) as f:
demo_full = _json.load(f)
# Gmail
gmail_signals, gmail_deltas, gmail_summary, gmail_is_demo = GMAIL.sync()
# Calendar
cal_signals, cal_deltas, cal_is_demo = CALENDAR.sync()
# Fitness β€” always demo (no live fitness API)
fitness_signals = demo_full['fitness']
fitness_deltas = {
"physical_health.sleep_quality": demo_full['derived_metric_deltas']['physical_health.sleep_quality'],
"physical_health.energy_level": demo_full['derived_metric_deltas']['physical_health.energy_level'],
"physical_health.exercise_consistency": demo_full['derived_metric_deltas']['physical_health.exercise_consistency'],
"mental_wellbeing.stress_level": demo_full['derived_metric_deltas']['mental_wellbeing.stress_level'],
}
fitness_is_demo = True
# Merge all deltas (last writer wins β€” Calendar > Gmail for overlapping keys)
merged_deltas = {}
merged_deltas.update(gmail_deltas)
merged_deltas.update(cal_deltas)
merged_deltas.update(fitness_deltas)
return jsonify({
"status": "success",
"merged_deltas": merged_deltas,
"sources": {
"gmail": {
"signals": gmail_signals if isinstance(gmail_signals, dict) else {},
"summary": gmail_summary,
"is_demo": gmail_is_demo,
},
"calendar": {
"signals": cal_signals,
"summary": cal_signals.get("summary", ""),
"is_demo": cal_is_demo,
},
"fitness": {
"signals": fitness_signals,
"summary": fitness_signals.get("summary", ""),
"is_demo": True,
},
},
"persona_note": demo_full.get("persona", "Jordan (PM at Series-B startup)"),
})
@app.route('/api/arjun/activate', methods=['POST'])
def activate_arjun():
LONG_DEMO.pre_seed_arjun()
return jsonify({"status": "success", "message": "Arjun's memory (Week 1 & 2) is now ACTIVE in ChromaDB."})
@app.route('/api/task/demo', methods=['GET'])
def get_demo_task():
dummy_routes = [
Route(id="r1", name="Rebook Premium Option", description="Call agent and rebook on premium ticket", required_action_types=["communicate", "spend"], milestones_unlocked=["m1"], final_reward=2.5),
Route(id="r2", name="Accept Delay & Work", description="Stay at airport lounge and work on laptop", required_action_types=["rest", "delegate"], milestones_unlocked=["m2"], final_reward=1.8),
]
dummy_milestones = [
Milestone(id="m1", description="Successfully rebooked flight before deadline", reward=1.0),
Milestone(id="m2", description="Caught up with all emergency slack messages", reward=0.8),
]
dummy_events = [
ExoEvent(step=2, probability=1.0, id="price_surge", description="Ticket prices sharply increased by $300."),
ExoEvent(step=4, probability=1.0, id="lounge_full", description="The airport lounge is now at maximum capacity."),
]
task = Task(
id="sample_flight_crisis", domain="flight_crisis", goal="Survive Airport Cancellation",
event_schedule=dummy_events, viable_routes=dummy_routes, milestones=dummy_milestones,
horizon=10, difficulty=4
)
return jsonify({
"goal": task.goal,
"difficulty": task.difficulty,
"routes": [{"name": r.name, "description": r.description} for r in dummy_routes],
"milestones": [{"id": m.id, "description": m.description} for m in dummy_milestones],
"events": [{"step": e.step, "id": e.id, "description": e.description} for e in dummy_events],
"story": "A major storm grounded commercial flights."
})
@app.route('/api/stats', methods=['GET'])
def get_stats():
stats = MEMORY.get_stats()
# Normalise for frontend: inject feedback_count and reward_history
all_records = []
try:
raw = MEMORY.collection.get(include=["metadatas"])
all_records = raw.get("metadatas", [])
except Exception:
pass
stats["feedback_count"] = len([m for m in all_records if m.get("type") == "feedback"])
rewards = [m.get("reward", 0.0) for m in all_records if "reward" in m]
stats["reward_history"] = rewards[-20:] if rewards else []
return jsonify(stats)
@app.route('/api/feedback/submit', methods=['POST'])
def submit_feedback():
data = request.json
try:
feedback = OutcomeFeedback(
episode_id=data.get('episode_id'),
submitted_at=datetime.datetime.now(),
overall_effectiveness=int(data.get('score', 7)),
domains_improved=data.get('improved', []),
domains_worsened=data.get('worsened', []),
unexpected_effects=data.get('notes', ""),
resolution_time_hours=float(data.get('time', 1.0))
)
MEMORY.store_feedback(feedback)
return jsonify({"status": "success", "message": f"Feedback stored for episode {feedback.episode_id}"})
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 400
# ─── Feature F1 helper: random action baseline ───
_ACTION_TYPES = ["negotiate", "communicate", "delegate", "spend", "reschedule", "rest", "deprioritize", "execute"]
def _random_action(conflict, person):
"""Purely random action baseline β€” worst possible agent, used for ablation floor."""
import random as _r
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
flat = env.state.current_metrics.flatten()
atype = _r.choice(_ACTION_TYPES)
dom = _r.choice(_DOMAINS)
key = f"{dom}.stress_level" if dom in ("career", "mental_wellbeing") else f"{dom}.liquidity" if dom == "finances" else f"{dom}.energy_level"
mc = {key: _r.uniform(-20, 20)}
rc = {"time": _r.uniform(0.5, 3.0), "energy": _r.uniform(5, 30)}
uptake = person.respond_to_action(atype, rc, flat.get("mental_wellbeing.stress_level", 70))
env_action = LifeStackAction(action_type=atype, target=dom,
metric_changes={k: v * uptake for k, v in mc.items()},
resource_cost=rc, reasoning="Random baseline.", actions_taken=1)
obs = env.step(env_action)
return {"metrics": obs.metrics, "action": {"type": atype, "target": dom,
"description": "Random action (ablation floor).",
"reasoning": "Random baseline.", "reward": obs.reward, "cost": rc}}
# ─── Feature A: Trained vs Untrained Comparison ───
BASELINE_ACTION_MAP = {
"career": ("negotiate", {"career.workload": -12.0, "mental_wellbeing.stress_level": -4.0}, {"time": 1.5, "energy": 20.0}, "Negotiate workload with manager."),
"finances": ("spend", {"finances.liquidity": -200.0, "mental_wellbeing.stress_level": -8.0}, {"time": 1.0, "energy": 10.0}, "Spend to resolve financial pressure."),
"relationships": ("communicate", {"relationships.romantic": 8.0, "mental_wellbeing.stress_level": -5.0},{"time": 0.5, "energy": 8.0}, "Call partner to check in."),
"physical_health": ("rest", {"physical_health.energy_level": 12.0, "mental_wellbeing.stress_level": -6.0}, {"time": 1.0}, "Rest to recover energy."),
"mental_wellbeing": ("rest", {"mental_wellbeing.stress_level": -15.0, "physical_health.sleep_quality": 5.0}, {"time": 1.0}, "Take a break to reduce stress."),
"time": ("reschedule", {"time.free_hours_per_week": 6.0, "career.workload": -8.0}, {"time": 1.5, "energy": 12.0}, "Reschedule non-critical tasks."),
}
def _run_baseline(conflict, person):
"""Rule-based baseline: pick the action for the worst-scoring domain."""
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
flat = env.state.current_metrics.flatten()
domain_scores = {}
for dom in ["career", "finances", "relationships", "physical_health", "mental_wellbeing", "time"]:
subs = {k: v for k, v in flat.items() if k.startswith(dom + ".")}
domain_scores[dom] = sum(subs.values()) / len(subs) if subs else 70.0
worst_dom = min(domain_scores, key=domain_scores.get)
atype, mc, rc, desc = BASELINE_ACTION_MAP.get(worst_dom, BASELINE_ACTION_MAP["mental_wellbeing"])
uptake = person.respond_to_action(atype, rc, flat.get("mental_wellbeing.stress_level", 70))
scaled_mc = {k: v * uptake for k, v in mc.items()}
env_action = LifeStackAction(
action_type=atype,
target=worst_dom,
metric_changes=scaled_mc,
resource_cost=rc,
reasoning=f"Rule-based: {worst_dom} scored {domain_scores[worst_dom]:.1f} β€” lowest domain.",
actions_taken=1,
)
obs = env.step(env_action)
return {
"metrics": obs.metrics,
"action": {
"type": atype,
"target": worst_dom,
"description": desc,
"reasoning": env_action.reasoning,
"reward": obs.reward,
"cost": rc,
}
}
def _run_agent_comparison_side(conflict, person, api_only: bool):
"""Run one side of the comparison: api_only=True β†’ untrained LLM, False β†’ GRPO-trained."""
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
before_metrics = copy.deepcopy(env.state.current_metrics)
before_budget = copy.deepcopy(env.state.budget)
action = AGENT.get_action(before_metrics, before_budget, conflict, person, api_only=api_only)
_normalize_action_metric_changes(action)
uptake = person.respond_to_action(action.primary.action_type, action.primary.resource_cost,
before_metrics.mental_wellbeing.stress_level)
env_action = LifeStackAction.from_agent_action(action)
env_action.metric_changes = {k: v * uptake for k, v in action.primary.metric_changes.items()}
obs = env.step(env_action)
return {
"metrics": obs.metrics,
"action": {
"type": action.primary.action_type,
"target": action.primary.target_domain,
"description": action.primary.description,
"reasoning": action.reasoning,
"reward": obs.reward,
"cost": action.primary.resource_cost,
}
}
@app.route('/api/comparison/run', methods=['POST'])
def run_comparison():
"""Run same conflict through untrained LLM (no RL) AND GRPO-trained LifeStack agent."""
data = request.json
conflict_label = data.get('conflict')
person_label = data.get('person')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
person = PERSONS.get(person_label, PERSONS["Alex (Executive) β€” driven, high-stress"])
# Untrained LLM path β€” forces Groq API, no GRPO optimization
try:
baseline = _run_agent_comparison_side(conflict, person, api_only=True)
except Exception as e:
baseline = {"error": str(e)}
# GRPO-trained agent path β€” uses local model if available, lazy-loaded
try:
trained = _run_agent_comparison_side(conflict, person, api_only=False)
except Exception as e:
trained = {"error": str(e)}
return jsonify({"baseline": baseline, "trained": trained})
# ─── Feature E: Memory Effect Comparison ───
@app.route('/api/memory/compare', methods=['POST'])
def memory_compare():
"""Show the same conflict resolved cold (no memory) vs warm (with RAG memory)."""
try:
data = request.json
conflict_label = data.get('conflict')
person_label = data.get('person')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
person = PERSONS.get(person_label, PERSONS["Alex (Executive) β€” driven, high-stress"])
def _run_episode(use_memory: bool):
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
before_metrics = copy.deepcopy(env.state.current_metrics)
before_budget = copy.deepcopy(env.state.budget)
few_shot = ""
retrieved = []
if use_memory:
few_shot = MEMORY.build_few_shot_prompt(conflict.title, before_metrics.flatten())
retrieved = MEMORY.retrieve_similar(conflict.title, before_metrics.flatten())
action = AGENT.get_action(before_metrics, before_budget, conflict, person, few_shot_context=few_shot)
_normalize_action_metric_changes(action)
uptake = person.respond_to_action(action.primary.action_type, action.primary.resource_cost,
before_metrics.mental_wellbeing.stress_level)
env_action = LifeStackAction.from_agent_action(action)
env_action.metric_changes = {k: v * uptake for k, v in action.primary.metric_changes.items()}
obs = env.step(env_action)
MEMORY.store_decision(
conflict_title=conflict.title,
action_type=action.primary.action_type,
target_domain=action.primary.target_domain,
reward=obs.reward,
metrics_snapshot=before_metrics.flatten(),
reasoning=action.reasoning,
)
return {
"metrics": obs.metrics,
"action": {
"type": action.primary.action_type,
"target": action.primary.target_domain,
"description": action.primary.description,
"reasoning": action.reasoning,
"reward": obs.reward,
"memories_retrieved": retrieved,
}
}
cold = _run_episode(use_memory=False)
warm = _run_episode(use_memory=True)
return jsonify({"cold": cold, "warm": warm})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ─── F2: /api/cascade/frames alias ───
@app.route('/api/cascade/frames', methods=['POST'])
def cascade_frames_alias():
"""Alias route for /api/simulation/cascade β€” same handler."""
return get_cascade_frames()
# ─── F4: Personality Comparison with OCEAN scores ───
@app.route('/api/personality/compare', methods=['POST'])
def personality_compare():
data = request.json
conflict_label = data.get('conflict')
person_a_label = data.get('person_a')
person_b_label = data.get('person_b')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
def _run_person(person_label):
person = PERSONS.get(person_label, list(PERSONS.values())[0])
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
before_m = copy.deepcopy(env.state.current_metrics)
before_b = copy.deepcopy(env.state.budget)
action = AGENT.get_action(before_m, before_b, conflict, person)
_normalize_action_metric_changes(action)
uptake = person.respond_to_action(action.primary.action_type, action.primary.resource_cost,
before_m.mental_wellbeing.stress_level)
env_action = LifeStackAction.from_agent_action(action)
env_action.metric_changes = {k: v * uptake for k, v in action.primary.metric_changes.items()}
obs = env.step(env_action)
return {
"name": person.name,
"ocean": {
"openness": round(person.openness * 100),
"conscientiousness": round(person.conscientiousness * 100),
"extraversion": round(person.extraversion * 100),
"agreeableness": round(person.agreeableness * 100),
"neuroticism": round(person.neuroticism * 100),
},
"action": {
"type": action.primary.action_type,
"target": action.primary.target_domain,
"description": action.primary.description,
"reasoning": action.reasoning,
"reward": obs.reward,
"uptake": uptake,
},
"metrics": obs.metrics,
"domain_health": compute_domain_health(obs.metrics),
}
try:
return jsonify({"a": _run_person(person_a_label), "b": _run_person(person_b_label)})
except Exception as e:
return jsonify({"error": str(e)}), 500
# ─── F6: Dedicated Counterfactual Generation ───
@app.route('/api/counterfactuals/generate', methods=['POST'])
def counterfactuals_generate():
data = request.json
conflict_label = data.get('conflict')
person_label = data.get('person')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
person = PERSONS.get(person_label, list(PERSONS.values())[0])
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
before_m = copy.deepcopy(env.state.current_metrics)
before_b = copy.deepcopy(env.state.budget)
action = AGENT.get_action(before_m, before_b, conflict, person)
_normalize_action_metric_changes(action)
cf_data = generate_counterfactuals(AGENT, before_m, before_b, conflict, person, action)
return jsonify({
"counterfactuals": cf_data,
"actual_action": {
"type": action.primary.action_type,
"target": action.primary.target_domain,
"description": action.primary.description,
},
})
# ─── F7: Memory Ablation Study ───
@app.route('/api/memory/ablation', methods=['POST'])
def memory_ablation():
"""Memory ablation: cold (0 memories) vs warm (RAG-augmented). Surfaces ablation delta."""
data = request.json
conflict_label = data.get('conflict')
person_label = data.get('person')
conflict = CONFLICT_CHOICES.get(conflict_label, DEMO_CONFLICT)
person = PERSONS.get(person_label, list(PERSONS.values())[0])
def _run(use_memory):
env = LifeStackEnv()
env.reset(conflict=conflict.primary_disruption, budget={"time": max((conflict.resource_budget or {}).get("time", 20.0), 4.0), "money": max((conflict.resource_budget or {}).get("money", 500.0), 500.0), "energy": max((conflict.resource_budget or {}).get("energy", 100.0), 20.0)})
before_m = copy.deepcopy(env.state.current_metrics)
before_b = copy.deepcopy(env.state.budget)
few_shot, retrieved = "", []
if use_memory:
few_shot = MEMORY.build_few_shot_prompt(conflict.title, before_m.flatten())
retrieved = MEMORY.retrieve_similar(conflict.title, before_m.flatten())
action = AGENT.get_action(before_m, before_b, conflict, person, few_shot_context=few_shot)
_normalize_action_metric_changes(action)
uptake = person.respond_to_action(action.primary.action_type, action.primary.resource_cost,
before_m.mental_wellbeing.stress_level)
env_action = LifeStackAction.from_agent_action(action)
env_action.metric_changes = {k: v * uptake for k, v in action.primary.metric_changes.items()}
obs = env.step(env_action)
MEMORY.store_decision(conflict_title=conflict.title, action_type=action.primary.action_type,
target_domain=action.primary.target_domain, reward=obs.reward,
metrics_snapshot=before_m.flatten(), reasoning=action.reasoning)
return {"metrics": obs.metrics, "action": {
"type": action.primary.action_type, "target": action.primary.target_domain,
"description": action.primary.description, "reasoning": action.reasoning,
"reward": obs.reward, "memories_retrieved": retrieved,
}}
cold = _run(use_memory=False)
warm = _run(use_memory=True)
delta = warm["action"]["reward"] - cold["action"]["reward"]
return jsonify({"cold": cold, "warm": warm,
"ablation_delta": round(delta, 4),
"memory_count": len(warm["action"]["memories_retrieved"])})
# ─── F10: Health + Calendar Data Upload ───
@app.route('/api/data/health/upload', methods=['POST'])
def upload_health_data():
"""Accept health/fitness JSON signals and return metric deltas."""
data = request.json or {}
sleep = float(data.get('sleep_hours', 7.0))
hr = float(data.get('resting_heart_rate', 70))
steps = float(data.get('daily_steps', 8000))
deltas = {
"physical_health.sleep_quality": round(min(100, sleep / 8 * 100) - 50, 1),
"physical_health.energy_level": round(min(100, steps / 10000 * 100) - 50, 1),
"physical_health.exercise_consistency": round(min(100, steps / 8000 * 70), 1),
"mental_wellbeing.stress_level": round(max(0.0, 80.0 - hr), 1),
}
summary = f"Sleep {sleep:.1f}h | HR {hr:.0f}bpm | Steps {int(steps):,}/day"
# Persist overrides so future simulations use the uploaded health data
USER_HEALTH_OVERRIDES.update(deltas)
return jsonify({"status": "success", "deltas": deltas, "summary": summary,
"signals": {"avg_sleep_hours": sleep, "resting_heart_rate": hr, "daily_steps_avg": steps}})
@app.route('/api/data/calendar/upload', methods=['POST'])
def upload_calendar_data():
"""Accept calendar JSON signals and return metric deltas."""
data = request.json or {}
occupancy = float(data.get('week_occupancy_pct', 50))
btb = int(data.get('back_to_back_blocks', 0))
deadlines = data.get('upcoming_deadlines', [])
critical_count = sum(1 for d in deadlines if d.get('priority') == 'critical')
deltas = {
"time.free_hours_per_week": round(-((occupancy - 50) / 5), 1),
"time.schedule_control": round(-(occupancy / 10), 1),
"mental_wellbeing.stress_level": round((occupancy / 10) + (btb * 2), 1),
"career.workload": round((occupancy - 50) / 2 + critical_count * 5, 1),
}
summary = f"Occupancy {occupancy:.0f}% | {len(deadlines)} deadlines ({critical_count} critical)"
return jsonify({"status": "success", "deltas": deltas, "summary": summary,
"signals": {"week_occupancy_pct": occupancy, "back_to_back_blocks": btb,
"upcoming_deadlines": deadlines}})
# ─── Global Error Handlers ───
@app.errorhandler(429)
def ratelimit_handler(e):
return jsonify({"error": "Rate limit exceeded. Slow down!", "details": str(e)}), 429
@app.errorhandler(500)
def server_error_handler(e):
return jsonify({"error": "Internal server error. The agent might be overwhelmed.", "details": str(e)}), 500
if __name__ == '__main__':
LONG_DEMO.pre_seed_arjun()
app.run(host='0.0.0.0', port=7860, debug=True)