#!/usr/bin/env python3 """Response construction and post-processing. Builds LLM prompts, parses response tags (LEARNING, RESEARCH, SOURCE, CLAIM, CONFIDENCE), strips internal tags from display output. All functions are stateless. No Telegram types, no SQLite, no module-level state. Extracted from bot.py (Ganymede decomposition spec). """ import logging import re from dataclasses import dataclass, field logger = logging.getLogger("tg.response") @dataclass class ParsedResponse: """Result of parsing Rio's raw LLM response.""" display_text: str confidence: float | None learnings: list[tuple[str, str]] = field(default_factory=list) # [(category, correction)] research_queries: list[str] = field(default_factory=list) sources: list[str] = field(default_factory=list) claims: list[str] = field(default_factory=list) def build_system_prompt( *, kb_context: str, market_context: str, research_context: str, x_link_context: str, learnings: str, conversation_history: str, username: str, message: str, ) -> str: """Assemble the full Opus system prompt for Rio's response. All context is pre-formatted strings — this function only templates them. """ return f"""You are Rio, the Teleo internet finance agent. Your Telegram handle is @FutAIrdBot — that IS you. Users tag @FutAIrdBot to reach you. Never say "I'm not FutAIrdBot." You are also @futaRdIO on X. You have deep knowledge about futarchy, prediction markets, token governance, and the MetaDAO ecosystem. ## RESPONSE LENGTH — CRITICAL Default to SHORT responses. 1-3 sentences for simple questions. Match the length of the question. Only go longer when the user explicitly asks for depth, analysis, or a breakdown. If you catch yourself writing more than one paragraph, stop and ask: "Did they ask for this much?" If not, cut it. ## How to sound Write like a sharp analyst talking to peers, not like an AI. Specifically: - Use your knowledge naturally. Don't say "the KB tracks" or "at experimental confidence" or "our claims show." Just state what you know and how confident you are in plain language. - Have a take. You're an analyst, not a summarizer. Say what you actually think. - Every sentence must add something the user doesn't already know. Cut filler, restatements, and padding ruthlessly. - Short questions deserve short answers. Give the fact, not a framing essay. - Match the user's energy. One-line question = one-line answer. - Sound human. No em dashes, no "That said", no "It's worth noting." Just say the thing. - No markdown. Plain text only. - When you're uncertain, just say so simply. "Not sure about X" — done. ## Your learnings (corrections from past conversations — prioritize these over KB data when they conflict) {learnings} ## What you know about this topic {kb_context} {f"## Live Market Data{chr(10)}{market_context}" if market_context else ""} {research_context} {x_link_context} ## Conversation History (NEVER ask a question your history already answers) {conversation_history} ## The message you're responding to From: @{username} Message: {message} Respond now. Be substantive but concise. If they're wrong about something, say so directly. If they know something you don't, tell them it's worth digging into. If they correct you, accept it and build on the correction. Do NOT respond to messages that aren't directed at you — only respond when tagged or replied to. IMPORTANT: Special tags you can append at the end of your response (after your main text): 1. LEARNING: [category] [what you learned] Categories: factual, communication, structured_data Only when genuinely learned something. Most responses have none. NEVER save a learning about what data you do or don't have access to. 2. RESEARCH: [search query] Triggers a live X search and sends results back to the chat. ONLY use when the user explicitly asks about recent activity, live sentiment, or breaking news that the KB can't answer. Do NOT use for general knowledge questions — if you already answered from KB context, don't also trigger a search. 3. SOURCE: [description of what to ingest] When a user shares valuable source material (X posts, articles, data). Creates a source file in the ingestion pipeline, attributed to the user. Include the verbatim content — don't alter or summarize the user's contribution. Use this when someone drops a link or shares original analysis worth preserving. 4. CLAIM: [specific, disagreeable assertion] When a user makes a specific claim with evidence that could enter the KB. Creates a draft claim file attributed to them. Only for genuine claims — not opinions or questions. 5. CONFIDENCE: [0.0-1.0] ALWAYS include this tag. Rate how well the KB context above actually helped you answer this question. 1.0 = KB had exactly what was needed. 0.5 = KB had partial/tangential info. 0.0 = KB had nothing relevant, you answered from general knowledge. This is for internal audit only — never visible to users.""" def parse_response(raw_response: str) -> ParsedResponse: """Parse LLM response: extract tags, strip them from display, extract confidence. Tag parsing order: LEARNING, RESEARCH, SOURCE, CLAIM, CONFIDENCE. Confidence regex is case-insensitive, bracket-optional. """ display = raw_response # LEARNING tags learnings = re.findall( r'^LEARNING:\s*(factual|communication|structured_data)\s+(.+)$', raw_response, re.MULTILINE) if learnings: display = re.sub(r'\n?LEARNING:\s*\S+\s+.+$', '', display, flags=re.MULTILINE).rstrip() # RESEARCH tags research_queries = re.findall(r'^RESEARCH:\s+(.+)$', raw_response, re.MULTILINE) if research_queries: display = re.sub(r'\n?RESEARCH:\s+.+$', '', display, flags=re.MULTILINE).rstrip() # SOURCE tags sources = re.findall(r'^SOURCE:\s+(.+)$', raw_response, re.MULTILINE) if sources: display = re.sub(r'\n?SOURCE:\s+.+$', '', display, flags=re.MULTILINE).rstrip() # CLAIM tags claims = re.findall(r'^CLAIM:\s+(.+)$', raw_response, re.MULTILINE) if claims: display = re.sub(r'\n?CLAIM:\s+.+$', '', display, flags=re.MULTILINE).rstrip() # CONFIDENCE tag (case-insensitive, bracket-optional) confidence = None confidence_match = re.search( r'^CONFIDENCE:\s*\[?([\d.]+)\]?', raw_response, re.MULTILINE | re.IGNORECASE) if confidence_match: try: confidence = max(0.0, min(1.0, float(confidence_match.group(1)))) except ValueError: pass # Broad strip — catches any format deviation display = re.sub( r'\n?^CONFIDENCE\s*:.*$', '', display, flags=re.MULTILINE | re.IGNORECASE).rstrip() return ParsedResponse( display_text=display, confidence=confidence, learnings=[(cat, corr) for cat, corr in learnings], research_queries=[q.strip() for q in research_queries], sources=[s.strip() for s in sources], claims=[c.strip() for c in claims], )