File size: 9,597 Bytes
2e818da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
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