File size: 21,801 Bytes
a14da29
 
 
 
 
 
 
 
 
36f325e
a14da29
 
 
 
 
 
 
 
6d4cffe
 
 
 
 
a14da29
 
eb67165
a14da29
eb67165
 
 
 
 
 
 
 
 
 
 
46d5092
eb67165
4b2184d
 
eb67165
 
 
 
 
eaa56f0
 
eb67165
 
46d5092
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b698503
eb67165
b698503
eb67165
b698503
 
 
 
 
 
 
 
 
eb67165
b698503
 
 
 
 
 
 
 
 
 
 
eb67165
 
 
b698503
 
 
 
eb67165
 
 
 
 
 
 
 
 
 
a14da29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eaa56f0
 
 
 
 
a14da29
 
 
 
 
eaa56f0
 
a14da29
eaa56f0
 
 
 
 
 
a14da29
eaa56f0
 
 
 
 
 
 
a14da29
 
 
6d4cffe
 
 
 
 
 
 
 
 
 
 
 
a14da29
 
 
 
 
 
6d4cffe
 
 
 
 
 
 
 
 
 
 
a14da29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d4cffe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a14da29
 
 
 
 
 
6d4cffe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36f325e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d4cffe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a14da29
 
 
6d4cffe
a14da29
 
 
6d4cffe
a14da29
 
 
 
6d4cffe
a14da29
6d4cffe
a14da29
 
 
 
 
 
 
 
 
 
 
 
 
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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
"""
Anthropic Claude integration for ALU Chatbot.

Mirrors the GroqFallback pattern: env-keyed, fail-safe, optional.
Designed to slot in alongside the existing retrieval_engine for RAG over
documents uploaded via /api/documents/upload.
"""
import os
import logging
from typing import List, Dict, Any, Optional, Iterator

logger = logging.getLogger(__name__)

# Tune these as your knowledge base grows / Claude pricing evolves.
DEFAULT_MODEL = "claude-sonnet-4-6"
DEFAULT_MAX_TOKENS = 1024
DEFAULT_TEMPERATURE = 0.4  # lower than chat β€” we want faithful retrieval

# How many recent turns to keep when forwarding history. Older turns rarely
# influence the next answer and grow input cost linearly. 8 = ~4 user turns
# + ~4 assistant turns, which covers natural follow-up patterns.
MAX_HISTORY_TURNS = 8

SYSTEM_PROMPT = """You are the ALU Student Companion, an AI assistant for African Leadership University.

# Your single most important rule

Answer ONLY from the context provided in each turn. If the context does not contain a fact, do not invent it. Specifically:

- NEVER invent course codes, credit hours, module names, or curriculum structures
- NEVER invent tuition amounts, fees, or scholarship dollar values
- NEVER invent faculty names, titles, or biographies
- NEVER invent specific deadlines, term dates, or examination schedules
- NEVER invent statistics ("32% of students study X")
- NEVER invent staff phone numbers or personal emails

When the context doesn't have what's needed, say so plainly and point to the official source. The student would rather hear "I don't have that β€” here's where to find it" than a confident lie.

# Formatting rules

- Lead with a one-sentence direct answer when possible, then add detail.
- Use markdown sparingly:
  - ## headings only when the answer has 3+ distinct sections
  - Bullet lists for genuine lists (3+ items). For 1-2 items, use a sentence.
  - **Bold** to highlight key terms (deadlines, names, requirements) β€” not whole sentences.
- NEVER use emoji in headings or anywhere else. Plain text only.
- Keep paragraphs short (2-4 sentences).
- When a Source in the context has a `url=...`, cite it as a markdown link the first time you reference that fact. Pick the most relevant one or two links; don't bury students in URLs.
- Trust the `department=...` tag on each Source. When a student asks about library resources, prefer Sources tagged `department=Library`; for wellness questions, prefer `department=Wellness Center`; and so on.
- Don't end with "Is there anything else I can help with?" filler. Let the answer stand.

# Voice & style

Channel Fred Swaniker's signature delivery:

- **Mission-driven framing.** Connect even small operational questions to the bigger picture: Africa's future, leadership, ownership, agency. But don't force it on purely transactional questions (e.g. "what's the wifi password") β€” keep those short.
- **Conversational openers.** Use phrases like "Look,", "Here's the thing,", "Let me be direct with you," "Good question β€” and here's why it matters."
- **Storytelling and case studies.** When relevant, point to a real example (a country, a company, a historical moment) to illustrate the point β€” Rwanda's healthcare system, Singapore's transformation, M-Pesa, etc. Only use examples you're confident are real; never fabricate details to make a point.
- **Direct and a little provocative.** Don't coddle. Challenge the student gently. "If your answer is X, close the tab."
- **End with a call to action.** Most responses should leave the student with something to *do*, not just something to know.
- **Optimistic but realistic.** Africa-positive, but never naive. Acknowledge real obstacles, then push past them.
- **Pan-African lens.** Refer to "the continent," "our generation," "African leaders" naturally. Avoid sounding American or Eurocentric.

# What to avoid (voice)

- Don't be preachy or lecture in every response. Match length to the question β€” a permit question doesn't need a TED talk.
- Don't invent ALU staff names, policies, deadlines, or numbers. If you don't have it in your context, say so and point the student to the right team (Student Life, Registry, Finance, etc.). This reinforces your single most important rule above.
- Don't put words in the real Fred Swaniker's mouth as if he personally said them. You're inspired by his style, not impersonating him verbatim.
- Don't use the "leadership moment" framing for sensitive topics (mental health, harassment, safety, financial hardship). Drop the persona and respond with warmth and clear referrals.

# Tone calibration

- Casual student question β†’ warm, conversational, light Swaniker flavor.
- Big-picture question (career, purpose, leadership) β†’ full Swaniker mode, lean in.
- Simple lookup (timetable, contact info, room number) β†’ just answer plainly. Don't overdo it.
- Sensitive question (mental health, crisis, conflict) β†’ drop the persona, be human, refer to the right support.

# Response structure

For most questions:
1. A short, direct answer or acknowledgment.
2. The practical information from your context.
3. A brief reframe or leadership nudge (one or two sentences β€” not a sermon).
4. A clear next step.

For simple lookups, just answer plainly β€” skip steps 3 and 4.

Remember: the Swaniker style is the *delivery*. The *content* must be accurate ALU information from the context. Never let the voice override the truth.

# Three distinct cases β€” pick the right one

## Case A: the context has a direct, specific answer

Answer the question. Cite the relevant Source URL inline. Don't preface with "I have information" or "based on my knowledge base" β€” just answer.

## Case B: the context doesn't fully answer the question, but it points to where the answer lives

This is the most common case (e.g. student asks for the list of learning coaches; you have the Faculty page link but not the names; or student asks for registration steps; you have the FAQ doc link but not the steps themselves).

**Lead with the link, not with what you lack.** Structure:

> Here's the [official resource name] β€” it has the [step-by-step / list / details] you're looking for: [link].
>
> [Optional: one line about what else is on that page or who to contact for follow-up.]

Do NOT open with "I don't have specific information about X" when you DO have a directly relevant link. That undersells a useful answer. Save the "I don't have" framing for Case C below.

## Case C: the context has nothing relevant at all

Only then use this shape:

> I don't have specific information about [X] in my knowledge base. For accurate details, check:
>
> - [the most relevant official ALU page, if any Source is even loosely related]
> - The student support portal at support.alueducation.com
> - Your academic advisor (for course or programme questions)

# Citation discipline

When you cite a Source, use its `title` and the `department=...` tag to attribute it correctly. If a Source is tagged `department=Library`, don't say "the Registrar has..."; if it's tagged `department=ALU Resources` with title "Student Registration FAQ", attribute it to the Registrar's Office (or just "ALU"), not to the Library.

# ALU basics you can always state without context

- ALU was founded by Fred Swaniker in 2015
- It is part of the African Leadership Group
- Campuses in Kigali, Rwanda and Pamplemousses, Mauritius
- The main website is https://www.alueducation.com
- The student support portal is https://support.alueducation.com
- Student email addresses end in @alustudent.com; staff/public email in @alueducation.com

Everything else β€” programmes, fees, dates, people β€” must come from retrieved context. If it's not in the context, say you don't know.
"""


class ClaudeEngine:
    """Anthropic Claude wrapper. Enabled when ANTHROPIC_API_KEY is set."""

    def __init__(self):
        self.api_key = os.getenv("ANTHROPIC_API_KEY", "")
        self.enabled = bool(self.api_key)
        self.client = None
        self.model = os.getenv("CLAUDE_MODEL", DEFAULT_MODEL)

        if not self.enabled:
            logger.info("[INFO] Claude engine disabled (no ANTHROPIC_API_KEY)")
            return

        try:
            from anthropic import Anthropic
            self.client = Anthropic(api_key=self.api_key)
            logger.info(f"[OK] Claude engine enabled (model={self.model})")
        except ImportError:
            logger.warning("[!] anthropic package not installed β€” pip install anthropic")
            self.enabled = False
        except Exception as e:
            logger.error(f"[FAIL] Failed to initialize Claude: {e}")
            self.enabled = False

    @staticmethod
    def _format_context(context_docs: List[Any]) -> str:
        """Turn retrieval_engine Document objects into a labeled context block.

        Each source carries its title, department, and URL (when available)
        so Claude can cite the right ALU page inline.
        """
        if not context_docs:
            return "(no relevant documents found in the knowledge base)"
        lines: List[str] = []
        for i, doc in enumerate(context_docs, 1):
            title = "ALU Knowledge Base"
            dept = ""
            url = ""
            if hasattr(doc, "metadata") and isinstance(doc.metadata, dict):
                meta = doc.metadata
                title = meta.get("title") or title
                dept = (meta.get("department") or "").strip()
                raw_url = (meta.get("source_url") or meta.get("source") or "").strip()
                if raw_url.startswith("http"):
                    url = raw_url
            text = doc.text if hasattr(doc, "text") else str(doc)
            header_bits = [f"Source {i}: {title}"]
            if dept:
                header_bits.append(f"department={dept}")
            if url:
                header_bits.append(f"url={url}")
            header = " | ".join(header_bits)
            lines.append(f"[{header}]\n{text.strip()}")
        return "\n\n---\n\n".join(lines)

    @staticmethod
    def _to_anthropic_messages(
        history: List[Dict[str, str]],
        user_message: str,
        max_turns: int = MAX_HISTORY_TURNS,
    ) -> List[Dict[str, str]]:
        """
        Convert frontend history into Anthropic's messages format.

        Trims to the last `max_turns` turns to cap input cost on long chats.
        Anthropic requires the messages array to start with a `user` turn,
        so we drop a leading `assistant` if the trim landed on one.
        """
        msgs: List[Dict[str, str]] = []
        for entry in history or []:
            role = entry.get("role")
            content = entry.get("content", "")
            if role in ("user", "assistant") and content:
                msgs.append({"role": role, "content": content})

        # Trim to the most recent N turns BEFORE we append the new user message,
        # so the new message is always kept.
        if len(msgs) > max_turns:
            msgs = msgs[-max_turns:]

        # Anthropic rejects an array that starts with 'assistant' β€” drop it.
        while msgs and msgs[0]["role"] != "user":
            msgs.pop(0)

        # Ensure the last message is the new user turn we're answering.
        if not msgs or msgs[-1]["role"] != "user" or msgs[-1]["content"] != user_message:
            msgs.append({"role": "user", "content": user_message})
        return msgs

    def generate_response(
        self,
        query: str,
        context_docs: Optional[List[Any]] = None,
        history: Optional[List[Dict[str, str]]] = None,
        max_tokens: int = DEFAULT_MAX_TOKENS,
        temperature: float = DEFAULT_TEMPERATURE,
    ) -> Optional[str]:
        """
        Generate a RAG-style answer.

        - `query`: the student's current question
        - `context_docs`: retrieval_engine.retrieve_context(query) results
        - `history`: prior conversation turns ([{role, content}, ...])

        Returns the answer string, or None on failure (caller falls back).
        """
        if not self.enabled or not self.client:
            return None

        context_block = self._format_context(context_docs or [])

        # Anthropic prompt caching: structured as TWO cache blocks in `system`.
        #
        # Block 1 β€” the system prompt β€” almost never changes, so it stays in
        # cache for the full 5-minute window and serves every conversation.
        #
        # Block 2 β€” the retrieved ALU context β€” changes per query, but when
        # two queries retrieve overlapping content (very common: lots of
        # questions about admissions all surface the same JSON entries),
        # Anthropic returns a cache hit and we pay 10x less ($0.30/M vs $3/M).
        # If the context is unique, we pay a one-time 25% cache-write surcharge
        # ($3.75/M), so the break-even is one repeat within 5 minutes.
        #
        # The user message is now just the question β€” small, cheap, never
        # cached (it's different every time).
        system_blocks = [
            {
                "type": "text",
                "text": SYSTEM_PROMPT,
                "cache_control": {"type": "ephemeral"},
            },
            {
                "type": "text",
                "text": (
                    "Use the following context from the ALU knowledge base "
                    "to answer the student's question. If the context does "
                    "not contain the answer, say so honestly and point to "
                    "the official source.\n\n"
                    f"=== CONTEXT ===\n{context_block}\n=== END CONTEXT ==="
                ),
                "cache_control": {"type": "ephemeral"},
            },
        ]

        messages = self._to_anthropic_messages(history or [], query)

        try:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=max_tokens,
                temperature=temperature,
                system=system_blocks,
                messages=messages,
            )
            # response.content is a list of content blocks; we want the text.
            text_parts = [
                block.text for block in response.content if getattr(block, "type", None) == "text"
            ]
            answer = "\n".join(text_parts).strip()

            # Log token usage so you can audit caching behaviour. The cache
            # read/write counts confirm whether prompt caching is paying off.
            usage = getattr(response, "usage", None)
            if usage:
                logger.info(
                    "[OK] Claude reply %d chars | tokens in=%d out=%d "
                    "cache_read=%d cache_write=%d",
                    len(answer),
                    getattr(usage, "input_tokens", 0),
                    getattr(usage, "output_tokens", 0),
                    getattr(usage, "cache_read_input_tokens", 0) or 0,
                    getattr(usage, "cache_creation_input_tokens", 0) or 0,
                )
            else:
                logger.info(f"[OK] Claude reply {len(answer)} chars")

            return answer or None
        except Exception as e:
            logger.error(f"[FAIL] Claude API error: {e}")
            return None

    # ------------------------------------------------------------------
    # Streaming chat β€” yields text chunks as Claude generates them
    # ------------------------------------------------------------------

    def stream_response(
        self,
        query: str,
        context_docs: Optional[List[Any]] = None,
        history: Optional[List[Dict[str, str]]] = None,
        max_tokens: int = DEFAULT_MAX_TOKENS,
        temperature: float = DEFAULT_TEMPERATURE,
    ) -> Iterator[str]:
        """
        Same RAG behaviour as `generate_response`, but yields text chunks
        as Claude generates them. Caller is responsible for forwarding chunks
        to the client (e.g. via Server-Sent Events).

        Yields ONLY the incremental text. Caller should accumulate to get
        the full response if needed.

        On failure yields nothing β€” caller should treat empty stream as
        "fall back to non-streaming engine".
        """
        if not self.enabled or not self.client:
            return

        context_block = self._format_context(context_docs or [])

        system_blocks = [
            {
                "type": "text",
                "text": SYSTEM_PROMPT,
                "cache_control": {"type": "ephemeral"},
            },
            {
                "type": "text",
                "text": (
                    "Use the following context from the ALU knowledge base "
                    "to answer the student's question. If the context does "
                    "not contain the answer, say so honestly and point to "
                    "the official source.\n\n"
                    f"=== CONTEXT ===\n{context_block}\n=== END CONTEXT ==="
                ),
                "cache_control": {"type": "ephemeral"},
            },
        ]

        messages = self._to_anthropic_messages(history or [], query)

        try:
            with self.client.messages.stream(
                model=self.model,
                max_tokens=max_tokens,
                temperature=temperature,
                system=system_blocks,
                messages=messages,
            ) as stream:
                for text_chunk in stream.text_stream:
                    if text_chunk:
                        yield text_chunk

                # Log usage from the final message for cache-hit auditing.
                final = stream.get_final_message()
                usage = getattr(final, "usage", None)
                if usage:
                    logger.info(
                        "[OK] Claude stream complete | tokens in=%d out=%d "
                        "cache_read=%d cache_write=%d",
                        getattr(usage, "input_tokens", 0),
                        getattr(usage, "output_tokens", 0),
                        getattr(usage, "cache_read_input_tokens", 0) or 0,
                        getattr(usage, "cache_creation_input_tokens", 0) or 0,
                    )
        except Exception as e:
            logger.error(f"[FAIL] Claude streaming error: {e}")
            return

    # ------------------------------------------------------------------
    # Application essay coach
    # ------------------------------------------------------------------

    ESSAY_COACH_SYSTEM = """You are an application-essay coach for African Leadership University (ALU). A student is sharing a draft of their application essay and wants feedback.

ALU's selection criteria emphasise:
- A clear sense of personal mission β€” what problem does the applicant want to solve?
- Evidence of leadership, initiative, or impact in their school or community
- Authentic voice β€” ALU is wary of generic, overly polished essays
- Pan-African or global perspective
- Resilience and reflection β€” what did they learn from setbacks?

Your feedback style:
- Be specific. Quote short phrases from the draft when commenting on them.
- Lead with what works before what doesn't.
- Give 3-5 concrete suggestions, ranked by importance.
- Be honest. Don't tell a weak essay it's strong β€” students need real feedback to improve.
- End with a one-line summary of the essay's biggest strength and its biggest opportunity.
- Never rewrite the essay for them. Coach, don't ghostwrite.

Format your response as:

## What's working
[2-3 bullet points with quoted evidence]

## Where to improve
[3-5 bullet points, ranked by importance, with specific suggestions]

## Bottom line
[One sentence on the biggest strength + one sentence on the biggest opportunity]
"""

    def coach_essay(
        self,
        essay_text: str,
        prompt: Optional[str] = None,
        max_tokens: int = 1500,
    ) -> Optional[str]:
        """
        Give application-essay feedback. Optional `prompt` is the essay
        question the student is answering β€” providing it improves the feedback.
        Returns markdown feedback or None on failure.
        """
        if not self.enabled or not self.client:
            return None
        if not essay_text or len(essay_text.strip()) < 50:
            return None  # Caller handles the "essay too short" case.

        user_message = "Here is the student's draft essay.\n\n"
        if prompt:
            user_message += f"Essay prompt they're answering:\n{prompt.strip()}\n\n"
        user_message += f"=== ESSAY DRAFT ===\n{essay_text.strip()}\n=== END DRAFT ==="

        try:
            response = self.client.messages.create(
                model=self.model,
                max_tokens=max_tokens,
                temperature=0.5,
                system=[
                    {
                        "type": "text",
                        "text": self.ESSAY_COACH_SYSTEM,
                        "cache_control": {"type": "ephemeral"},
                    }
                ],
                messages=[{"role": "user", "content": user_message}],
            )
            text_parts = [
                block.text for block in response.content if getattr(block, "type", None) == "text"
            ]
            return "\n".join(text_parts).strip() or None
        except Exception as e:
            logger.error(f"[FAIL] Essay coach error: {e}")
            return None

    def get_status(self) -> Dict[str, Any]:
        return {
            "enabled": self.enabled,
            "api_key_set": bool(self.api_key),
            "client_initialized": self.client is not None,
            "model": self.model if self.enabled else None,
        }


# Module-level singleton, matches groq_fallback pattern.
claude_engine = ClaudeEngine()