File size: 6,427 Bytes
7ff3e94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Gemini/DeepSeek-backed expert for concept extraction and student dialogue."""

import json
import os


EXPERT_SYSTEM_PROMPT = """You are a knowledgeable subject-matter expert on the \
study material provided below. A student is going to question, probe, and \
challenge you in order to deepen their own understanding. You are not a tutor \
who quizzes them; they lead the inquiry.

Rules:
- Answer directly and substantively, grounded in the material. Do not pad.
- Match the student's depth. A plain factual question gets a plain answer; a \
question that probes mechanism or challenges an assumption gets a deeper, \
reasoned response.
- When the student challenges you or surfaces a genuine edge case, limitation, \
or counterexample, acknowledge it honestly and engage with it. Do not be \
defensive, and do not pretend the material is more complete than it is.
- If they ask something the material does not cover, say so plainly and reason \
from first principles, flagging that you are going beyond the source.
- Never quiz the student back or end with "does that make sense?". They are the \
one asking the questions.
- You may use light markdown (bold, lists) when it aids clarity.

Study material:
---
{material}
---
"""

CONCEPT_PROMPT = """From the study material below, extract the {n} most \
important distinct concepts or topics a student would want to understand. \
Return a JSON object with a single key "concepts" whose value is an array of \
short concept names (2-5 words each), ordered from most to least central.

Material:
---
{material}
---
Return only the JSON object."""


class Expert:
    """Concept extraction and expert dialogue, via DeepSeek or Gemini."""

    def __init__(self, product_config):
        """Initialise the client for the configured provider."""
        self.cfg = product_config
        self.provider = getattr(product_config, "provider", "deepseek")
        if self.provider == "deepseek":
            self._init_deepseek()
        elif self.provider == "gemini":
            self._init_gemini()
        else:
            raise ValueError(f"Unknown provider: {self.provider}")

    def _init_deepseek(self):
        """Set up the DeepSeek (OpenAI-compatible) client."""
        from openai import OpenAI

        api_key = os.environ.get("DEEPSEEK_API_KEY")
        if not api_key:
            raise SystemExit(
                "DEEPSEEK_API_KEY is not set. Add it to .env, or set "
                "ProductConfig.provider = 'gemini' to use Gemini instead."
            )
        self.client = OpenAI(api_key=api_key, base_url="https://api.deepseek.com")
        self.model = self.cfg.deepseek_model

    def _init_gemini(self):
        """Set up the Gemini client."""
        from google import genai

        api_key = os.environ.get("GEMINI_API_KEY")
        if not api_key:
            raise SystemExit("GEMINI_API_KEY is not set. Add it to .env.")
        self.client = genai.Client(api_key=api_key)
        self.model = self.cfg.gemini_model

    # ---- concept extraction ----

    def extract_concepts(self, material_text):
        """Ask the model for the key concepts in the material."""
        material = material_text[: self.cfg.max_context_chars]
        prompt = CONCEPT_PROMPT.format(n=self.cfg.num_concepts, material=material)
        if self.provider == "deepseek":
            raw = self._deepseek_json(prompt)
        else:
            raw = self._gemini_json(prompt)
        try:
            parsed = json.loads(raw)
            concepts = parsed.get("concepts", []) if isinstance(parsed, dict) else []
        except (json.JSONDecodeError, TypeError):
            concepts = []
        return [c.strip() for c in concepts if isinstance(c, str) and c.strip()]

    def _deepseek_json(self, prompt):
        """One DeepSeek call returning a JSON object string."""
        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            response_format={"type": "json_object"},
            temperature=0.3,
        )
        return response.choices[0].message.content

    def _gemini_json(self, prompt):
        """One Gemini call returning a JSON object string."""
        from google.genai import types

        response = self.client.models.generate_content(
            model=self.model,
            contents=prompt,
            config=types.GenerateContentConfig(
                response_mime_type="application/json",
                temperature=0.3,
            ),
        )
        return response.text

    # ---- expert dialogue ----

    def answer(self, question, material_text, history):
        """Answer a student question given material context and chat history."""
        material = material_text[: self.cfg.max_context_chars]
        system = EXPERT_SYSTEM_PROMPT.format(material=material)
        if self.provider == "deepseek":
            return self._deepseek_answer(system, question, history)
        return self._gemini_answer(system, question, history)

    def _deepseek_answer(self, system, question, history):
        """Send question to DeepSeek and return the answer."""
        messages = [{"role": "system", "content": system}]
        for turn in history[-8:]:
            role = "user" if turn["role"] == "student" else "assistant"
            messages.append({"role": role, "content": turn["text"]})
        messages.append({"role": "user", "content": question})
        response = self.client.chat.completions.create(
            model=self.model,
            messages=messages,
            temperature=0.7,
        )
        return response.choices[0].message.content.strip()

    def _gemini_answer(self, system, question, history):
        """Send question to Gemini and return the answer."""
        from google.genai import types

        transcript = []
        for turn in history[-8:]:
            speaker = "Student" if turn["role"] == "student" else "Expert"
            transcript.append(f"{speaker}: {turn['text']}")
        transcript.append(f"Student: {question}")
        conversation = "\n\n".join(transcript)
        response = self.client.models.generate_content(
            model=self.model,
            contents=conversation,
            config=types.GenerateContentConfig(
                system_instruction=system,
                temperature=0.7,
            ),
        )
        return response.text.strip()