"""Streaming question generation agent - generates questions incrementally as content arrives.""" import os import json import yaml from datetime import datetime from pathlib import Path from loguru import logger from .context import ConversationContext from .research import search_web, search_news, research_speaker def load_config() -> dict: """Load configuration from config.yaml.""" config_path = Path(__file__).parent.parent / "config.yaml" if config_path.exists(): with open(config_path) as f: return yaml.safe_load(f) return {"llm": {"provider": "openai", "openai": {"model": "gpt-4o-mini"}}} def get_llm_client(): """Get LLM client based on config.""" config = load_config() provider = config.get("llm", {}).get("provider", "openai") if provider == "anthropic": from anthropic import Anthropic api_key = os.getenv("ANTHROPIC_API_KEY") if not api_key: raise ValueError("ANTHROPIC_API_KEY environment variable not set") return Anthropic(api_key=api_key), provider, config["llm"]["anthropic"]["model"] else: from openai import OpenAI api_key = os.getenv("OPENAI_API_KEY") if not api_key: raise ValueError("OPENAI_API_KEY environment variable not set") return OpenAI(api_key=api_key), provider, config["llm"]["openai"]["model"] # Concise system prompt optimized for streaming STREAMING_SYSTEM_PROMPT = """You generate insightful Q&A questions from talk transcripts. Be concise. Question types: CLARIFY (understand better), DEPTH (explore deeper), CONNECT (link to other areas), CHALLENGE (probe assumptions), PRACTICAL (real-world use), FORWARD (future implications) You have research tools. Use sparingly - only when genuinely useful. Output JSON only: {"questions": [{"q": "question text", "type": "TYPE", "why": "brief reason"}]}""" # Anthropic tools format ANTHROPIC_TOOLS = [ { "name": "search_web", "description": "Search web for context. Use sparingly.", "input_schema": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"] } }, { "name": "search_news", "description": "Search recent news. Use sparingly.", "input_schema": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"] } }, { "name": "research_speaker", "description": "Research speaker background.", "input_schema": { "type": "object", "properties": {"speaker_name": {"type": "string"}}, "required": ["speaker_name"] } } ] # OpenAI tools format OPENAI_TOOLS = [ { "type": "function", "function": { "name": "search_web", "description": "Search web for context. Use sparingly.", "strict": True, "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False } } }, { "type": "function", "function": { "name": "search_news", "description": "Search recent news. Use sparingly.", "strict": True, "parameters": { "type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False } } }, { "type": "function", "function": { "name": "research_speaker", "description": "Research speaker background.", "strict": True, "parameters": { "type": "object", "properties": {"speaker_name": {"type": "string"}}, "required": ["speaker_name"], "additionalProperties": False } } } ] TOOL_DISPLAY = { "search_web": {"icon": "🔍", "name": "Web Search"}, "search_news": {"icon": "📰", "name": "News Search"}, "research_speaker": {"icon": "👤", "name": "Speaker Research"}, } def execute_tool(tool_name: str, tool_input: dict) -> str: """Execute a tool and return concise result.""" try: if tool_name == "search_web": result = search_web(tool_input["query"], max_results=3) elif tool_name == "search_news": result = search_news(tool_input["query"], max_results=3) elif tool_name == "research_speaker": result = research_speaker(tool_input["speaker_name"]) else: return "Unknown tool" # Return concise summary if isinstance(result, dict) and "answer" in result: return result["answer"][:500] return str(result)[:500] except Exception as e: return f"Error: {str(e)}" def format_activity_log(activities: list[dict]) -> str: """Format activity log as HTML with all history.""" if not activities: return '
Waiting for content...
' html = '
' for activity in activities[-15:]: # Show last 15 activities t = activity.get("type") ts = activity.get("time", "") if t == "transcribe": words = activity.get("words", 0) html += f'
🎤 Transcribed: {words} words total
' elif t == "thinking": html += f'
🤔 Analyzing content...
' elif t == "tool_call": icon = TOOL_DISPLAY.get(activity.get("tool"), {}).get("icon", "🔧") name = TOOL_DISPLAY.get(activity.get("tool"), {}).get("name", activity.get("tool")) query = activity.get("query", "")[:40] html += f'
{icon} {name}: {query}...
' elif t == "tool_result": html += f'
Got result
' elif t == "generating": html += f'
Generating questions...
' elif t == "questions": count = activity.get("count", 0) total = activity.get("total", 0) html += f'
🎉 +{count} questions (total: {total})
' elif t == "error": msg = activity.get("msg", "Unknown error")[:50] html += f'
{msg}
' elif t == "waiting": html += f'
Waiting for more content ({activity.get("words", 0)} words)...
' html += '
' return html def format_questions_html(questions: list[dict]) -> str: """Format questions as HTML cards, newest first.""" if not questions: return '

Questions will appear as content accumulates...

' html = "" total = len(questions) # Reverse to show newest first for i, q in enumerate(reversed(questions)): qtype = q.get("type", q.get("category", "")) question = q.get("q", q.get("question", "")) why = q.get("why", q.get("reasoning", "")) timestamp = q.get("timestamp", "") type_colors = { "CLARIFY": "#3b82f6", "DEPTH": "#8b5cf6", "CONNECT": "#10b981", "CHALLENGE": "#f59e0b", "PRACTICAL": "#ef4444", "FORWARD": "#6366f1" } color = type_colors.get(qtype, "#667eea") # Number from total down (newest = highest number) num = total - i html += f'''
{qtype} {timestamp}
{num}. {question}
{why}
''' return html def generate_questions_sync( context: ConversationContext, existing_questions: list[dict], activity_log: list[dict], speaker_name: str = "" ) -> tuple[list[dict], list[dict], str, str]: """ Generate new questions synchronously (non-generator version). Supports both OpenAI and Anthropic providers based on config. Args: context: Current conversation context existing_questions: Questions already generated activity_log: Activity log to append to speaker_name: Optional speaker name Returns: Tuple of (all_questions, updated_activity_log, questions_html, log_html) """ client, provider, model = get_llm_client() # Add thinking activity activity_log.append({"type": "thinking"}) # Build prompt with existing questions to avoid existing_q_text = "\n".join([f"- {q.get('q', q.get('question', ''))}" for q in existing_questions[-10:]]) transcript = context.get_full_transcript() recent = context.get_recent_transcript(num_segments=3) user_message = f"""Recent content from talk: {recent} Full context length: {len(transcript)} chars {f"Speaker: {speaker_name}" if speaker_name else ""} Already asked (avoid similar): {existing_q_text if existing_q_text else "None yet"} Generate 1-3 NEW insightful questions based on the recent content. Be concise.""" # Limit iterations for speed max_iterations = 3 iteration = 0 new_questions = [] try: if provider == "anthropic": new_questions = _generate_with_anthropic( client, model, user_message, activity_log, context, max_iterations ) else: new_questions = _generate_with_openai( client, model, user_message, activity_log, context, max_iterations ) except Exception as e: logger.error(f"Question generation failed: {e}") activity_log.append({"type": "error", "msg": str(e)}) # Combine questions all_questions = existing_questions + new_questions # Log success if new_questions: activity_log.append({"type": "questions", "count": len(new_questions), "total": len(all_questions)}) return ( all_questions, activity_log, format_questions_html(all_questions), format_activity_log(activity_log) ) def _generate_with_anthropic(client, model, user_message, activity_log, context, max_iterations): """Generate questions using Anthropic API.""" messages = [{"role": "user", "content": user_message}] new_questions = [] iteration = 0 while iteration < max_iterations: iteration += 1 response = client.messages.create( model=model, max_tokens=1024, system=STREAMING_SYSTEM_PROMPT, tools=ANTHROPIC_TOOLS, messages=messages ) if response.stop_reason == "tool_use": tool_results = [] for block in response.content: if block.type == "tool_use": query = block.input.get("query", block.input.get("speaker_name", "")) activity_log.append({"type": "tool_call", "tool": block.name, "query": query}) result = execute_tool(block.name, block.input) activity_log.append({"type": "tool_result"}) tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": result }) messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "user", "content": tool_results}) else: activity_log.append({"type": "generating"}) final_text = "" for block in response.content: if hasattr(block, "text"): final_text += block.text new_questions = _parse_questions(final_text, context) break return new_questions def _generate_with_openai(client, model, user_message, activity_log, context, max_iterations): """Generate questions using OpenAI API.""" messages = [ {"role": "system", "content": STREAMING_SYSTEM_PROMPT}, {"role": "user", "content": user_message} ] new_questions = [] iteration = 0 while iteration < max_iterations: iteration += 1 response = client.chat.completions.create( model=model, max_completion_tokens=1024, tools=OPENAI_TOOLS, messages=messages ) choice = response.choices[0] if choice.finish_reason == "tool_calls" and choice.message.tool_calls: tool_messages = [] for tool_call in choice.message.tool_calls: func_name = tool_call.function.name func_args = json.loads(tool_call.function.arguments) query = func_args.get("query", func_args.get("speaker_name", "")) activity_log.append({"type": "tool_call", "tool": func_name, "query": query}) result = execute_tool(func_name, func_args) activity_log.append({"type": "tool_result"}) tool_messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result }) messages.append(choice.message) messages.extend(tool_messages) else: activity_log.append({"type": "generating"}) final_text = choice.message.content or "" new_questions = _parse_questions(final_text, context) break return new_questions def _parse_questions(text: str, context: ConversationContext) -> list[dict]: """Parse questions from LLM response text.""" new_questions = [] try: json_start = text.find("{") json_end = text.rfind("}") + 1 if json_start >= 0 and json_end > json_start: result = json.loads(text[json_start:json_end]) raw_questions = result.get("questions", []) timestamp = datetime.now().strftime("%H:%M:%S") # Add timestamp to each question for q in raw_questions: q["timestamp"] = timestamp new_questions.append(q) # Store in context context.add_question( question=q.get("q", ""), category=q.get("type", ""), reasoning=q.get("why", "") ) except json.JSONDecodeError: pass return new_questions