import json import inspect from typing import Any, Dict, List, Optional from app.agents.cerebras_client import CerebrasClient from app.services.student_memory import StudentMemoryService MAX_SOURCE_CHUNK_CHARS = 1800 MAX_SOURCE_CONTEXT_CHARS = 8000 MAX_STUDENT_PROFILE_CHARS = 6000 async def _maybe_await(value: Any) -> Any: if inspect.isawaitable(value): return await value return value def _clip_text(text: str, limit: int) -> str: if len(text) <= limit: return text return text[:limit].rstrip() + "\n...[truncated]" def _build_chunk_text(chunks: List[Dict[str, Any]]) -> str: parts = [] for i, c in enumerate(chunks): text = _clip_text(str(c.get("text", "")), MAX_SOURCE_CHUNK_CHARS) parts.append(f"[Source: {c.get('source', '?')}]\n{text}") return _clip_text("\n\n".join(parts), MAX_SOURCE_CONTEXT_CHARS) class StudyBuddyAgent: def __init__(self, client: Optional[CerebrasClient] = None) -> None: self._client = client or CerebrasClient() async def generate_initial_question( self, node_label: str, chunks: List[Dict[str, Any]], familiarity: str, student_profile: str = "", is_merged: bool = False, merge_summary: str = "", context_tools_summary: str = "", web_context: str = "", ): chunk_text = _build_chunk_text(chunks) student_profile = _clip_text(student_profile, MAX_STUDENT_PROFILE_CHARS) merge_note = "\n\nThe current context may span multiple uploaded papers. Keep paper-specific claims distinct." if is_merged else "" source_section = f"\n\nSOURCE MATERIAL:\n{chunk_text}" if chunk_text else "" tool_section = f"\n\nOPTIONAL CONTEXT TOOLS USED: {context_tools_summary}" if context_tools_summary else "" web_section = f"\n\n{_clip_text(web_context, MAX_SOURCE_CONTEXT_CHARS)}" if web_context else "" messages = [ { "role": "system", "content": ( "You are ResearchMate, a normal research companion for a student reading multiple papers. " "This default Pair Buddy mode is collaborative chat, not Feynman interrogation, not a quiz, " "and not a testing mode. Help the student think with the uploaded paper context, answer " f"naturally at a {familiarity} level, and invite them to steer the conversation.{merge_note}\n\n" f"STUDENT PROFILE:\n{student_profile if student_profile else 'Unknown (First time user)'}\n\n" "INSTRUCTIONS:\n" "1. Open with a short, friendly offer to work through the current paper context together.\n" "2. Do not ask a test question unless the student explicitly asks to be quizzed or Feynman mode is on.\n" "3. If the paper context is thin, say so and ask what part of the papers they want to inspect.\n" "4. Keep your response conversational, encouraging, and concisely formatted.\n" "5. NEVER use lazy phrasing like 'Based on the text', 'According to the source', or 'The provided data says'." f"{tool_section}" f"{source_section}" f"{web_section}" ) }, { "role": "user", "content": "Start Pair Buddy for the current research project." } ] async for token in self._client.stream_complete(messages): yield token async def evaluate_and_ask_next( self, node_label: str, chunks: List[Dict[str, Any]], familiarity: str, history: List[Dict[str, str]], student_answer: str, student_profile: str = "", is_merged: bool = False, merge_summary: str = "", feynman_mode: bool = False, flag_confirmed: Optional[bool] = None, project_id: str = "", context_tools_summary: str = "", web_context: str = "", ): chunk_text = _build_chunk_text(chunks) student_profile = _clip_text(student_profile, MAX_STUDENT_PROFILE_CHARS) merge_note = "\n\nThe current context may span multiple uploaded papers. Keep paper-specific claims distinct." if is_merged else "" source_section = f"\n\nSOURCE MATERIAL:\n{chunk_text}" if chunk_text else "" tool_section = f"\n\nOPTIONAL CONTEXT TOOLS USED: {context_tools_summary}" if context_tools_summary else "" web_section = f"\n\n{_clip_text(web_context, MAX_SOURCE_CONTEXT_CHARS)}" if web_context else "" formatted_history = [] for msg in history: role = "assistant" if msg["role"] == "study_buddy" else "user" # Strip any frontend flag metadata before passing to LLM content = msg["content"].replace("[Student confirmed to dig deeper]", "").replace("[Student declined to dig deeper]", "").strip() formatted_history.append({"role": role, "content": content or msg["content"]}) formatted_history.append({"role": "user", "content": student_answer}) # --- FEYNMAN MODE PIPELINE --- if feynman_mode: if project_id: await StudentMemoryService().run_feynman_agent_memory_pilot( project_id=project_id, node_label="project papers", student_answer=student_answer, ) if flag_confirmed is None: # Stage 1: Assess assess_msgs = [ {"role": "system", "content": f"You are evaluating a student's explanation against uploaded paper context. Does their explanation show a genuine gap, misconception, or superficial understanding worth digging into? Output JSON with 'has_gap' (boolean) and 'reasoning' (string).\n\nSOURCE:\n{chunk_text}"}, {"role": "user", "content": f"Student Answer: {student_answer}"} ] try: resp = await _maybe_await(self._client.complete(assess_msgs, json_schema_mode=True)) res = json.loads(resp) if res.get("has_gap"): # Stage 2: Flag yield "__FLAG__I think there's something worth digging into here. Should we dig deeper?" return except Exception as e: print("Feynman Assess error:", e) # Fallthrough to normal on error elif flag_confirmed is True: # Stage 3: Delegate/Verify # In a full implementation, we'd spawn parallel tasks for web search, memory, etc. # Here we do a focused deep dive using the local chunks and project memory. synth_msgs = [ {"role": "system", "content": ( f"You are ResearchMate in Feynman Interrogation mode. The student's explanation appears to have a gap against the uploaded paper context. " f"Using the SOURCE MATERIAL, point out the exact contradiction or gap in their reasoning, and ask a highly specific, challenging question to force them to reconcile it.\n\n" f"SOURCE MATERIAL:\n{chunk_text}" )} ] synth_msgs.extend(formatted_history) async for token in self._client.stream_complete(synth_msgs): yield token # Write idea observation to project memory if project_id: mem = StudentMemoryService() await mem.stage_project_observation( project_id=project_id, topic="project papers", observations=[f"Student had a Feynman-mode gap against the uploaded paper context: {student_answer}"], ) return # --- NORMAL (OR DECLINED FLAG) PIPELINE --- messages = [ { "role": "system", "content": ( f"You are ResearchMate, a normal research companion chatting with a student at a {familiarity} level. " f"Default Pair Buddy mode is not a quiz and not Feynman mode. Respond to the student's message using the uploaded paper context when relevant.{merge_note}\n\n" f"STUDENT PROFILE:\n{student_profile if student_profile else 'Unknown'}\n\n" "INSTRUCTIONS:\n" "1. Answer normally and helpfully. Do not interrogate, grade, or force active recall unless Feynman mode is on.\n" "2. If they seem confused, explain the relevant paper context plainly and offer a next step.\n" "3. Ask at most one optional follow-up question, and only when it naturally helps the conversation continue.\n" "4. Maintain a conversational, encouraging tone. Format with short, visually readable paragraphs.\n" "5. NEVER use lazy phrasing like 'according to the text', 'the text mentions', or 'in the source material'." f"{tool_section}" f"{source_section}" f"{web_section}" ) } ] messages.extend(formatted_history) async for token in self._client.stream_complete(messages): yield token