Files
ws-sanctum-chronicler/app/main.py

619 lines
21 KiB
Python

"""FastAPI main application."""
import asyncio
import secrets
from contextlib import suppress
from pydantic import BaseModel, Field
from fastapi import Depends, FastAPI, Form, Header, HTTPException
from datetime import datetime
import logging
from app.config import settings
from app.agent.orchestrator import AgentOrchestrator
from app.memory.database import init_db
from app.memory.database import get_session as get_db_session
from app.memory.models import AgentActionType
from app.memory.repository import Repository
from app.exports.markdown import MarkdownExporter
from app.twitch.chat import TwitchChatMessage, TwitchIRCClient, set_active_client
from app.twitch.live import TwitchLiveStatusService
logger = logging.getLogger(__name__)
app = FastAPI(
title=settings.APP_NAME,
description="AI stream assistant for monitoring and guiding Twitch chat",
version="0.1.0",
)
class DashboardRequest(BaseModel):
"""Request body for saving a stream dashboard."""
session_id: str | None = None
raw_markdown: str
stream_title: str | None = None
game: str | None = None
mood: str | None = None
go_live_notification: str | None = None
social_post: str | None = None
session_goals: list[str] = Field(default_factory=list)
content_angle: str | None = None
# Global orchestrator instance
orchestrator: AgentOrchestrator | None = None
agent_loop_task: asyncio.Task | None = None
twitch_chat_client: TwitchIRCClient | None = None
twitch_chat_task: asyncio.Task | None = None
twitch_session_id: str | None = None
twitch_live_status: TwitchLiveStatusService | None = None
async def require_admin(
admin_token: str | None = Header(default=None, alias="X-Admin-Token"),
) -> None:
"""Require the configured admin token for mutable/control endpoints."""
if not settings.ADMIN_API_KEY:
raise HTTPException(status_code=503, detail="Admin API key is not configured")
if not admin_token or not secrets.compare_digest(
admin_token,
settings.ADMIN_API_KEY,
):
raise HTTPException(status_code=401, detail="Invalid admin token")
async def agent_loop() -> None:
"""Run periodic time-based agent behavior for active sessions."""
if not orchestrator:
return
while True:
try:
results = await orchestrator.tick()
if results:
logger.info(f"Agent loop actions: {results}")
except asyncio.CancelledError:
raise
except Exception as e:
logger.error(f"Agent loop tick failed: {e}")
await asyncio.sleep(orchestrator.loop_interval_seconds)
def twitch_configured() -> bool:
"""Return whether Twitch chat has enough runtime configuration to start."""
return bool(
settings.TWITCH_CHAT_ENABLED
and settings.TWITCH_CHANNEL_NAME
and settings.TWITCH_BOT_USERNAME
and settings.TWITCH_ACCESS_TOKEN
)
async def channel_is_live_for_chat(channel_name: str) -> bool:
"""Return whether the agent may interact with chat for a channel."""
if not settings.TWITCH_REQUIRE_LIVE_STREAM:
return True
if not twitch_live_status:
return False
status = await twitch_live_status.get_status(channel_name)
return status.is_live
async def get_or_create_twitch_session(channel_name: str) -> str:
"""Use an active session for the Twitch channel, or create one."""
if not orchestrator:
raise RuntimeError("Orchestrator not initialized")
return await orchestrator.ensure_single_active_session_for_channel(channel_name)
async def handle_twitch_chat_message(message: TwitchChatMessage) -> None:
"""Route a Twitch chat message into the orchestrator."""
if not orchestrator or not twitch_session_id:
return
if settings.TWITCH_BOT_USERNAME and (
message.username.lower() == settings.TWITCH_BOT_USERNAME.lower()
):
return
await orchestrator.handle_chat_message(
session_id=twitch_session_id,
username=message.display_name or message.username,
message=message.content,
)
async def start_twitch_chat() -> None:
"""Start Twitch chat monitoring when configured."""
global twitch_chat_client, twitch_chat_task, twitch_session_id
if not orchestrator or not twitch_configured():
logger.info("Twitch chat listener not started; configuration is incomplete")
return
twitch_session_id = await get_or_create_twitch_session(settings.TWITCH_CHANNEL_NAME)
twitch_chat_client = TwitchIRCClient(
channel_name=settings.TWITCH_CHANNEL_NAME,
bot_username=settings.TWITCH_BOT_USERNAME,
access_token=settings.TWITCH_ACCESS_TOKEN,
on_message=handle_twitch_chat_message,
)
set_active_client(twitch_chat_client)
twitch_chat_task = asyncio.create_task(twitch_chat_client.run())
logger.info(
"Twitch chat listener starting for %s on session %s",
settings.TWITCH_CHANNEL_NAME,
twitch_session_id,
)
@app.on_event("startup")
async def startup_event():
"""Initialize database and services on startup."""
global orchestrator, agent_loop_task, twitch_live_status
try:
await init_db()
twitch_live_status = TwitchLiveStatusService(
cache_seconds=settings.TWITCH_LIVE_STATUS_CACHE_SECONDS
)
orchestrator = AgentOrchestrator(
loop_interval_seconds=settings.AGENT_LOOP_INTERVAL_SECONDS
)
orchestrator.set_chat_interaction_gate(channel_is_live_for_chat)
await orchestrator.restore_active_sessions()
await start_twitch_chat()
agent_loop_task = asyncio.create_task(agent_loop())
logger.info("Application started successfully")
except Exception as e:
logger.error(f"Failed to start application: {e}")
raise
@app.on_event("shutdown")
async def shutdown_event():
"""Clean up resources on shutdown."""
global twitch_chat_client, twitch_live_status
if twitch_chat_task:
twitch_chat_task.cancel()
with suppress(asyncio.CancelledError):
await twitch_chat_task
if twitch_chat_client:
await twitch_chat_client.disconnect()
set_active_client(None)
twitch_chat_client = None
twitch_live_status = None
if agent_loop_task:
agent_loop_task.cancel()
with suppress(asyncio.CancelledError):
await agent_loop_task
logger.info("Application shutting down")
@app.get("/health")
async def health_check() -> dict:
"""Health check endpoint."""
return {
"status": "healthy",
"app": settings.APP_NAME,
"environment": settings.APP_ENV,
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/session/start", dependencies=[Depends(require_admin)])
async def start_session(channel_name: str = Form(...)) -> dict:
"""Start a new stream session."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
session_id = await orchestrator.start_session(channel_name)
return {
"status": "session_started",
"session_id": session_id,
"channel": channel_name,
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/session/end", dependencies=[Depends(require_admin)])
async def end_session(session_id: str = Form(...)) -> dict:
"""End the current stream session."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
await orchestrator.end_session(session_id)
return {
"status": "session_ended",
"session_id": session_id,
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/test-message", dependencies=[Depends(require_admin)])
async def test_message(session_id: str = Form(...), message: str = Form(...), username: str = Form("test_user")) -> dict:
"""Send a test message to the orchestrator."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
response = await orchestrator.handle_chat_message(
session_id=session_id,
username=username,
message=message,
)
return {
"status": "message_processed",
"agent_response": response.get("agent_response"),
"actions_taken": response.get("actions_taken", []),
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/test-agent-response", dependencies=[Depends(require_admin)])
async def test_agent_response(
session_id: str = Form(...),
message: str = Form(...),
mode: str = Form("admin"),
) -> dict:
"""Send a test agent response through the outbound boundary."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
delivery = await orchestrator.emit_agent_response(
session_id=session_id,
message=message,
mode=mode,
)
if not delivery.get("sent"):
raise HTTPException(status_code=404, detail=delivery.get("reason", "send_failed"))
return {
"status": "agent_response_emitted",
"delivery": delivery,
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/test-loop-inactivity", dependencies=[Depends(require_admin)])
async def test_loop_inactivity(
session_id: str = Form(...),
inactive_minutes: int = Form(16),
) -> dict:
"""Verify the quiet-chat loop records exactly one Hearthkeeper prompt."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
result = await orchestrator.run_hearthkeeper_loop_test(
session_id=session_id,
inactive_minutes=inactive_minutes,
)
if result.get("reason") == "session_not_found":
raise HTTPException(status_code=404, detail="Active session not found")
return {
"status": "passed" if result["passed"] else "failed",
"result": result,
"timestamp": datetime.utcnow().isoformat(),
}
def serialize_dashboard(dashboard) -> dict:
"""Serialize a dashboard database model into an API response."""
return Repository.serialize_dashboard(dashboard)
def dashboard_status(dashboard: dict | None) -> dict:
"""Return a compact dashboard status for operator checks."""
if not dashboard:
return {
"present": False,
"stream_title": None,
"game": None,
"mood": None,
"content_angle": None,
"session_goal_count": 0,
"updated_at": None,
}
return {
"present": True,
"stream_title": dashboard.get("stream_title"),
"game": dashboard.get("game"),
"mood": dashboard.get("mood"),
"content_angle": dashboard.get("content_angle"),
"session_goal_count": len(dashboard.get("session_goals") or []),
"updated_at": dashboard.get("updated_at"),
}
def get_current_stream_session_id() -> str | None:
"""Resolve the session currently bound to Twitch or the only active session."""
if not orchestrator:
return None
if twitch_session_id and twitch_session_id in orchestrator.active_sessions:
return twitch_session_id
if len(orchestrator.active_sessions) == 1:
return next(iter(orchestrator.active_sessions))
return None
async def save_dashboard_for_session(
session_id: str,
request: DashboardRequest,
) -> dict:
"""Persist a dashboard and refresh live orchestrator context."""
async for db_session in get_db_session():
repo = Repository(db_session)
stream_session = await repo.get_session(session_id)
if not stream_session:
raise HTTPException(status_code=404, detail="Session not found")
dashboard = await repo.upsert_dashboard(
session_id=session_id,
raw_markdown=request.raw_markdown,
stream_title=request.stream_title,
game=request.game,
mood=request.mood,
go_live_notification=request.go_live_notification,
social_post=request.social_post,
session_goals=request.session_goals,
content_angle=request.content_angle,
)
dashboard_data = serialize_dashboard(dashboard)
if orchestrator and session_id in orchestrator.active_sessions:
orchestrator.active_sessions[session_id]["dashboard"] = dashboard_data
orchestrator.active_sessions[session_id]["theme"] = (
request.content_angle or request.stream_title
)
return dashboard_data
@app.post("/admin/session/dashboard", dependencies=[Depends(require_admin)])
async def save_session_dashboard(request: DashboardRequest) -> dict:
"""Create or update the approved dashboard for a stream session."""
if not request.session_id:
raise HTTPException(status_code=400, detail="session_id is required")
dashboard = await save_dashboard_for_session(request.session_id, request)
return {
"status": "dashboard_saved",
"dashboard": dashboard,
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/stream/dashboard", dependencies=[Depends(require_admin)])
async def save_current_stream_dashboard(request: DashboardRequest) -> dict:
"""Create or update the approved dashboard for the current stream session."""
session_id = get_current_stream_session_id()
if not session_id:
raise HTTPException(status_code=404, detail="No active stream session found")
dashboard = await save_dashboard_for_session(session_id, request)
return {
"status": "dashboard_saved",
"session_id": session_id,
"dashboard": dashboard,
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/admin/session/dashboard", dependencies=[Depends(require_admin)])
async def get_session_dashboard(session_id: str) -> dict:
"""Get the approved dashboard for a stream session."""
async for db_session in get_db_session():
repo = Repository(db_session)
dashboard = await repo.get_dashboard(session_id)
if not dashboard:
raise HTTPException(status_code=404, detail="Dashboard not found")
return {
"dashboard": serialize_dashboard(dashboard),
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/admin/stream/dashboard", dependencies=[Depends(require_admin)])
async def get_current_stream_dashboard() -> dict:
"""Get the approved dashboard for the current stream session."""
session_id = get_current_stream_session_id()
if not session_id:
raise HTTPException(status_code=404, detail="No active stream session found")
async for db_session in get_db_session():
repo = Repository(db_session)
dashboard = await repo.get_dashboard(session_id)
if not dashboard:
raise HTTPException(status_code=404, detail="Dashboard not found")
return {
"session_id": session_id,
"dashboard": serialize_dashboard(dashboard),
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/admin/ledger", dependencies=[Depends(require_admin)])
async def get_ledger(session_id: str) -> dict:
"""Get the markdown ledger for a session."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
exporter = MarkdownExporter()
ledger = await exporter.export_session(session_id)
return {
"session_id": session_id,
"ledger": ledger,
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/admin/session/status", dependencies=[Depends(require_admin)])
async def get_session_status(session_id: str) -> dict:
"""Get status for an active stream session."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
status = await orchestrator.get_session_status(session_id)
if not status:
raise HTTPException(status_code=404, detail="Active session not found")
return {
**status,
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/admin/loop/status", dependencies=[Depends(require_admin)])
async def get_loop_status() -> dict:
"""Get the background agent loop runtime configuration."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
return {
"status": "running" if agent_loop_task and not agent_loop_task.done() else "stopped",
**orchestrator.get_loop_status(),
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/admin/twitch/status", dependencies=[Depends(require_admin)])
async def get_twitch_status() -> dict:
"""Get Twitch chat connection status."""
configured = twitch_configured()
client_status = twitch_chat_client.status() if twitch_chat_client else {}
live_status = None
if twitch_live_status and settings.TWITCH_CHANNEL_NAME:
live_status = (
await twitch_live_status.get_status(settings.TWITCH_CHANNEL_NAME)
).to_dict()
return {
"configured": configured,
"running": bool(twitch_chat_task and not twitch_chat_task.done()),
"require_live_stream": settings.TWITCH_REQUIRE_LIVE_STREAM,
"live_status": live_status,
"session_id": twitch_session_id,
**client_status,
"timestamp": datetime.utcnow().isoformat(),
}
@app.get("/admin/stream/status", dependencies=[Depends(require_admin)])
async def get_current_stream_status() -> dict:
"""Get one operator view of the current Twitch stream runtime state."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
session_id = twitch_session_id
if not session_id and len(orchestrator.active_sessions) == 1:
session_id = next(iter(orchestrator.active_sessions))
if not session_id or session_id not in orchestrator.active_sessions:
raise HTTPException(status_code=404, detail="No active stream session found")
session_info = orchestrator.active_sessions[session_id]
latest_human_message = None
latest_hearthkeeper_action = None
dashboard = None
async for db_session in get_db_session():
repo = Repository(db_session)
dashboard = await repo.get_dashboard(session_id)
latest_human_message = await repo.get_latest_human_message(session_id)
latest_hearthkeeper_action = await repo.get_latest_action(
session_id=session_id,
action_type=AgentActionType.RESPONSE,
mode="hearthkeeper",
)
dashboard_data = serialize_dashboard(dashboard)
runtime = orchestrator.get_hearthkeeper_runtime_status(session_id)
twitch_status = twitch_chat_client.status() if twitch_chat_client else {}
live_status = None
if twitch_live_status:
live_status = (
await twitch_live_status.get_status(session_info["channel_name"])
).to_dict()
chat_interaction_allowed = (
not settings.TWITCH_REQUIRE_LIVE_STREAM
or bool(live_status and live_status["is_live"])
)
return {
"session": {
"id": session_id,
"channel": session_info["channel_name"],
"started_at": session_info["started_at"].isoformat(),
"uptime_seconds": (
datetime.utcnow() - session_info["started_at"]
).total_seconds(),
"message_count": session_info["message_count"],
"theme": session_info.get("theme"),
},
"twitch": {
"configured": twitch_configured(),
"running": bool(twitch_chat_task and not twitch_chat_task.done()),
"require_live_stream": settings.TWITCH_REQUIRE_LIVE_STREAM,
"live_status": live_status,
"session_id": twitch_session_id,
**twitch_status,
},
"dashboard": dashboard_status(dashboard_data),
"loop": {
"running": bool(agent_loop_task and not agent_loop_task.done()),
"interval_seconds": orchestrator.loop_interval_seconds,
"active_session_count": len(orchestrator.active_sessions),
},
"hearthkeeper": {
**runtime,
"chat_interaction_allowed": chat_interaction_allowed,
"last_human_chat_at": (
latest_human_message.timestamp.isoformat()
if latest_human_message
else None
),
"last_posted_at": (
latest_hearthkeeper_action.timestamp.isoformat()
if latest_hearthkeeper_action
else None
),
"last_posted_message": (
latest_hearthkeeper_action.description
if latest_hearthkeeper_action
else None
),
},
"timestamp": datetime.utcnow().isoformat(),
}
@app.post("/admin/loop/frequency", dependencies=[Depends(require_admin)])
async def set_loop_frequency(interval_seconds: float = Form(...)) -> dict:
"""Set how frequently the background agent loop runs."""
if not orchestrator:
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
try:
orchestrator.set_loop_interval(interval_seconds)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return {
"status": "loop_frequency_updated",
"interval_seconds": orchestrator.loop_interval_seconds,
"timestamp": datetime.utcnow().isoformat(),
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
app,
host="0.0.0.0",
port=8000,
log_level="info",
)