Spaces:
Sleeping
Sleeping
| 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 | |