From 74e3c0dcaa47864860b90e7561841ade41ec3b3b Mon Sep 17 00:00:00 2001 From: Ken Schaefer Date: Tue, 12 May 2026 10:28:33 -0500 Subject: [PATCH] Wire dashboard context into Hearthkeeper --- app/agent/modes/hearthkeeper.py | 13 +++++++++++-- app/agent/orchestrator.py | 22 ++++++++++++++++++---- app/exports/markdown.py | 18 +++++++++++++++++- app/llm/client.py | 6 ++++++ app/llm/prompts.py | 32 +++++++++++++++++++++++++++++--- app/main.py | 30 +++++++++--------------------- app/memory/repository.py | 27 +++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 31 deletions(-) diff --git a/app/agent/modes/hearthkeeper.py b/app/agent/modes/hearthkeeper.py index 2b68da5..78bd331 100644 --- a/app/agent/modes/hearthkeeper.py +++ b/app/agent/modes/hearthkeeper.py @@ -34,9 +34,18 @@ class HearthkeeperMode: """Determine if Hearthkeeper should generate a prompt.""" return minutes_since_activity >= self.activity_threshold - async def generate_prompt(self, theme: str | None = None) -> str: + async def generate_prompt( + self, + theme: str | None = None, + dashboard: dict | None = None, + recent_discussion: list[str] | None = None, + ) -> str: """Generate a gentle prompt for the stream.""" - prompt = PromptTemplates.gentle_prompt(theme) + prompt = PromptTemplates.gentle_prompt( + current_theme=theme, + dashboard=dashboard, + recent_discussion=recent_discussion or [], + ) response = await self.llm_client.generate(prompt) logger.info("Hearthkeeper generated gentle prompt") return response diff --git a/app/agent/orchestrator.py b/app/agent/orchestrator.py index f3a81ba..6faa5ad 100644 --- a/app/agent/orchestrator.py +++ b/app/agent/orchestrator.py @@ -80,6 +80,7 @@ class AgentOrchestrator: "started_at": datetime.utcnow(), "message_count": 0, "theme": None, + "dashboard": None, "last_hearthkeeper_prompt_at": None, } self.chat_activity.record_activity(session_id) @@ -101,6 +102,7 @@ class AgentOrchestrator: limit=1, ) message_count = await repo.count_messages(session.id) + dashboard = await repo.get_dashboard(session.id) last_activity_at = ( recent_messages[0].timestamp if recent_messages else session.started_at ) @@ -110,6 +112,7 @@ class AgentOrchestrator: "started_at": session.started_at, "message_count": message_count, "theme": session.theme, + "dashboard": Repository.serialize_dashboard(dashboard), "last_hearthkeeper_prompt_at": None, } self.chat_activity.record_activity(session.id, occurred_at=last_activity_at) @@ -363,15 +366,20 @@ class AgentOrchestrator: if not session_info: return None - recent_messages = [] + active_chat_messages = [] + recent_discussion_messages = [] async for db_session in get_session(): repo = Repository(db_session) - recent_messages = await repo.get_human_messages_since( + active_chat_messages = await repo.get_human_messages_since( session_id=session_id, since=datetime.utcnow() - timedelta(minutes=1), ) + recent_discussion_messages = await repo.get_recent_human_messages( + session_id=session_id, + limit=5, + ) - if self.response_suppression.should_suppress_response(len(recent_messages)): + if self.response_suppression.should_suppress_response(len(active_chat_messages)): return { "session_id": session_id, "actions_taken": [], @@ -394,8 +402,13 @@ class AgentOrchestrator: return None try: + recent_discussion = [ + message.content for message in recent_discussion_messages[:5] + ] agent_response = await self.hearthkeeper.generate_prompt( - theme=session_info.get("theme") + theme=session_info.get("theme"), + dashboard=session_info.get("dashboard"), + recent_discussion=recent_discussion, ) session_info["last_hearthkeeper_prompt_at"] = datetime.utcnow() delivery = await self.emit_agent_response( @@ -433,4 +446,5 @@ class AgentOrchestrator: "message_count": session["message_count"], "uptime_seconds": (datetime.utcnow() - session["started_at"]).total_seconds(), "theme": session.get("theme"), + "dashboard": session.get("dashboard"), } diff --git a/app/exports/markdown.py b/app/exports/markdown.py index 1f1dfc7..56c4747 100644 --- a/app/exports/markdown.py +++ b/app/exports/markdown.py @@ -47,6 +47,9 @@ class MarkdownExporter: actions = await repo.get_session_actions(session_id) clips = await repo.get_clip_candidates(session_id) seeds = await repo.get_blog_seeds(session_id) + dashboard = await repo.get_dashboard(session_id) + + dashboard_data = Repository.serialize_dashboard(dashboard) # Build markdown date = session.started_at.strftime("%Y-%m-%d") @@ -59,7 +62,20 @@ class MarkdownExporter: # Stream Theme ledger += "## Stream Theme\n" - if session.theme: + if dashboard_data: + if dashboard_data.get("stream_title"): + ledger += f"**Title:** {dashboard_data['stream_title']}\n" + if dashboard_data.get("game"): + ledger += f"**Game:** {dashboard_data['game']}\n" + if dashboard_data.get("mood"): + ledger += f"**Mood:** {dashboard_data['mood']}\n" + if dashboard_data.get("content_angle"): + ledger += f"**Content Angle:** {dashboard_data['content_angle']}\n" + if dashboard_data.get("session_goals"): + ledger += "\n**Session Goals**\n" + for goal in dashboard_data["session_goals"]: + ledger += f"- {goal}\n" + elif session.theme: ledger += f"{session.theme}\n" else: ledger += "*No theme recorded*\n" diff --git a/app/llm/client.py b/app/llm/client.py index 902a567..ad4f384 100644 --- a/app/llm/client.py +++ b/app/llm/client.py @@ -105,6 +105,12 @@ class LLMClient: Used when no provider is configured or for testing. """ logger.debug(f"Mock generation for prompt: {prompt[:50]}...") + + if "content angle:" in prompt.lower(): + for line in prompt.splitlines(): + if line.lower().startswith("content angle:"): + angle = line.split(":", 1)[1].strip() + return f"The quiet here keeps circling back to {angle}." # Simple deterministic responses for testing if "hello" in prompt.lower(): diff --git a/app/llm/prompts.py b/app/llm/prompts.py index fb4686d..c87ea9e 100644 --- a/app/llm/prompts.py +++ b/app/llm/prompts.py @@ -7,11 +7,37 @@ class PromptTemplates: """Collection of prompt templates for different modes.""" @staticmethod - def gentle_prompt(current_theme: Optional[str] = None) -> str: + def gentle_prompt( + current_theme: Optional[str] = None, + dashboard: Optional[dict] = None, + recent_discussion: Optional[list[str]] = None, + ) -> str: """Generate a gentle prompt when chat has been inactive.""" + dashboard = dashboard or {} + recent_discussion = recent_discussion or [] + + context_lines = [ + "Generate one brief Hearthkeeper prompt for a calm, reflective livestream.", + "The prompt must be restrained, thematic, and not engagement bait.", + ] + if dashboard.get("stream_title"): + context_lines.append(f"Stream title: {dashboard['stream_title']}") + if dashboard.get("game"): + context_lines.append(f"Game: {dashboard['game']}") + if dashboard.get("mood"): + context_lines.append(f"Mood: {dashboard['mood']}") + if dashboard.get("content_angle"): + context_lines.append(f"Content angle: {dashboard['content_angle']}") + if dashboard.get("session_goals"): + goals = "; ".join(dashboard["session_goals"]) + context_lines.append(f"Session goals: {goals}") if current_theme: - return f"Gently prompt the chat about: {current_theme}" - return "Generate a gentle, inviting prompt to encourage discussion in the stream." + context_lines.append(f"Current theme: {current_theme}") + if recent_discussion: + context_lines.append("Recent human discussion:") + context_lines.extend(f"- {message}" for message in recent_discussion[:5]) + + return "\n".join(context_lines) @staticmethod def steward_response(message: str, context: Optional[str] = None) -> str: diff --git a/app/main.py b/app/main.py index 6cfdaf9..34981ad 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,6 @@ """FastAPI main application.""" import asyncio -import json import secrets from contextlib import suppress from pydantic import BaseModel, Field @@ -210,26 +209,7 @@ async def test_loop_inactivity( def serialize_dashboard(dashboard) -> dict: """Serialize a dashboard database model into an API response.""" - session_goals = [] - if dashboard.session_goals: - try: - session_goals = json.loads(dashboard.session_goals) - except json.JSONDecodeError: - session_goals = [] - - return { - "session_id": dashboard.session_id, - "raw_markdown": dashboard.raw_markdown, - "stream_title": dashboard.stream_title, - "game": dashboard.game, - "mood": dashboard.mood, - "go_live_notification": dashboard.go_live_notification, - "social_post": dashboard.social_post, - "session_goals": session_goals, - "content_angle": dashboard.content_angle, - "created_at": dashboard.created_at.isoformat(), - "updated_at": dashboard.updated_at.isoformat(), - } + return Repository.serialize_dashboard(dashboard) @app.post("/admin/session/dashboard", dependencies=[Depends(require_admin)]) @@ -253,6 +233,14 @@ async def save_session_dashboard(request: DashboardRequest) -> dict: content_angle=request.content_angle, ) + if orchestrator and request.session_id in orchestrator.active_sessions: + orchestrator.active_sessions[request.session_id]["dashboard"] = ( + serialize_dashboard(dashboard) + ) + orchestrator.active_sessions[request.session_id]["theme"] = ( + request.content_angle or request.stream_title + ) + return { "status": "dashboard_saved", "dashboard": serialize_dashboard(dashboard), diff --git a/app/memory/repository.py b/app/memory/repository.py index 37c3bf6..e7752c2 100644 --- a/app/memory/repository.py +++ b/app/memory/repository.py @@ -123,6 +123,33 @@ class Repository: result = await self.session.execute(stmt) return result.scalars().first() + @staticmethod + def serialize_dashboard(dashboard: StreamDashboard | None) -> dict | None: + """Serialize a dashboard model into a plain dict.""" + if dashboard is None: + return None + + session_goals = [] + if dashboard.session_goals: + try: + session_goals = json.loads(dashboard.session_goals) + except json.JSONDecodeError: + session_goals = [] + + return { + "session_id": dashboard.session_id, + "raw_markdown": dashboard.raw_markdown, + "stream_title": dashboard.stream_title, + "game": dashboard.game, + "mood": dashboard.mood, + "go_live_notification": dashboard.go_live_notification, + "social_post": dashboard.social_post, + "session_goals": session_goals, + "content_angle": dashboard.content_angle, + "created_at": dashboard.created_at.isoformat(), + "updated_at": dashboard.updated_at.isoformat(), + } + # Chat Message operations async def add_chat_message(