675 lines
24 KiB
Python
675 lines
24 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
|
|
offline_chat_test_mode: bool = settings.TWITCH_OFFLINE_CHAT_TEST_MODE
|
|
|
|
|
|
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 offline_chat_test_mode:
|
|
return True
|
|
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(),
|
|
}
|
|
|
|
|
|
@app.post("/admin/hearthkeeper/preview", dependencies=[Depends(require_admin)])
|
|
async def preview_hearthkeeper_prompt(session_id: str | None = Form(None)) -> dict:
|
|
"""Generate a Hearthkeeper prompt preview without posting to Twitch."""
|
|
if not orchestrator:
|
|
raise HTTPException(status_code=503, detail="Orchestrator not initialized")
|
|
|
|
resolved_session_id = session_id or get_current_stream_session_id()
|
|
if not resolved_session_id:
|
|
raise HTTPException(status_code=404, detail="No active stream session found")
|
|
|
|
result = await orchestrator.preview_hearthkeeper_prompt(resolved_session_id)
|
|
if result.get("reason") == "session_not_found":
|
|
raise HTTPException(status_code=404, detail="Active session not found")
|
|
|
|
live_status = None
|
|
session_info = orchestrator.active_sessions.get(resolved_session_id)
|
|
if twitch_live_status and session_info:
|
|
live_status = (
|
|
await twitch_live_status.get_status(session_info["channel_name"])
|
|
).to_dict()
|
|
|
|
return {
|
|
"status": "preview_generated" if result.get("generated") else "failed",
|
|
"preview": result,
|
|
"would_post_now": await orchestrator.can_interact_with_chat(
|
|
session_info["channel_name"]
|
|
) if session_info else False,
|
|
"live_status": live_status,
|
|
"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,
|
|
"offline_chat_test_mode": offline_chat_test_mode,
|
|
"live_status": live_status,
|
|
"session_id": twitch_session_id,
|
|
**client_status,
|
|
"timestamp": datetime.utcnow().isoformat(),
|
|
}
|
|
|
|
|
|
@app.post("/admin/twitch/offline-test-mode", dependencies=[Depends(require_admin)])
|
|
async def set_offline_chat_test_mode(enabled: bool = Form(...)) -> dict:
|
|
"""Allow or block Twitch chat posting while the stream is offline."""
|
|
global offline_chat_test_mode
|
|
|
|
offline_chat_test_mode = enabled
|
|
return {
|
|
"status": "offline_chat_test_mode_updated",
|
|
"offline_chat_test_mode": offline_chat_test_mode,
|
|
"warning": (
|
|
"Agent may post to Twitch chat while the stream is offline"
|
|
if offline_chat_test_mode
|
|
else None
|
|
),
|
|
"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 = (
|
|
offline_chat_test_mode
|
|
or 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,
|
|
"offline_chat_test_mode": offline_chat_test_mode,
|
|
"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",
|
|
)
|