"""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