Spaces:
Running
Running
Donne120 commited on
Commit Β·
6d4cffe
1
Parent(s): eb67165
Phase 3: prompt caching, history cap, essay coach endpoint
Browse files- COST_OPTIMIZATION.md +129 -0
- app.py +38 -0
- claude_engine.py +155 -22
COST_OPTIMIZATION.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# How the Companion saves Claude API credit
|
| 2 |
+
|
| 3 |
+
This document explains the cost model and the three optimizations now baked
|
| 4 |
+
into the backend, so you understand what every chat costs and how to keep
|
| 5 |
+
that cost low as the user base grows.
|
| 6 |
+
|
| 7 |
+
## The mental model
|
| 8 |
+
|
| 9 |
+
Anthropic charges per token, both for what Claude reads (input) and what
|
| 10 |
+
Claude writes (output). A token is roughly ΒΎ of a word.
|
| 11 |
+
|
| 12 |
+
Sonnet 4.6 pricing (as of writing):
|
| 13 |
+
|
| 14 |
+
| Token type | Cost per million tokens |
|
| 15 |
+
|------------|------------------------|
|
| 16 |
+
| Standard input | $3.00 |
|
| 17 |
+
| Cache write (first time) | $3.75 (25% surcharge) |
|
| 18 |
+
| Cache read (subsequent calls) | $0.30 (10Γ cheaper) |
|
| 19 |
+
| Output | $15.00 |
|
| 20 |
+
|
| 21 |
+
There is **no session**. Every API call is stateless. The "conversation"
|
| 22 |
+
feeling is built by your frontend resending the chat history every turn.
|
| 23 |
+
That's where the bill grows.
|
| 24 |
+
|
| 25 |
+
## Optimization 1 β Prompt caching on system + context
|
| 26 |
+
|
| 27 |
+
**File:** `claude_engine.py`, the `system=[...]` block in `generate_response`.
|
| 28 |
+
|
| 29 |
+
We send TWO cache blocks at the front of every chat request:
|
| 30 |
+
|
| 31 |
+
1. **System prompt** β your instructions to Claude. Same on every call.
|
| 32 |
+
2. **Retrieved ALU context** β the JSON chunks the retrieval engine returns.
|
| 33 |
+
Often overlaps between similar questions (a lot of students ask about
|
| 34 |
+
admissions, so they all retrieve the admissions JSON).
|
| 35 |
+
|
| 36 |
+
Each block is marked `cache_control: ephemeral`, which tells Anthropic to
|
| 37 |
+
store the processed prefix for 5 minutes. The next request that arrives with
|
| 38 |
+
the SAME prefix pays $0.30/M instead of $3/M for that portion β **10Γ cheaper.**
|
| 39 |
+
|
| 40 |
+
The break-even is one cache hit per 5-minute window. Anything more is pure
|
| 41 |
+
savings.
|
| 42 |
+
|
| 43 |
+
**Logged metrics:** Every Claude response now logs `cache_read` and
|
| 44 |
+
`cache_write` token counts. Check the HF Space logs to see caching working:
|
| 45 |
+
|
| 46 |
+
```
|
| 47 |
+
[OK] Claude reply 412 chars | tokens in=58 out=312 cache_read=2483 cache_write=0
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
That line says: we paid full price for 58 input tokens (the question) and the
|
| 51 |
+
output, but got 2,483 cached tokens at 10Γ discount instead of full price.
|
| 52 |
+
Without caching that same call would have cost ~3Γ more on the input side.
|
| 53 |
+
|
| 54 |
+
## Optimization 2 β History capped at last 8 turns
|
| 55 |
+
|
| 56 |
+
**File:** `claude_engine.py`, `MAX_HISTORY_TURNS = 8` constant.
|
| 57 |
+
|
| 58 |
+
Before this change, every chat sent the ENTIRE conversation history to
|
| 59 |
+
Anthropic every turn. By message 10 that meant paying for 10 messages worth
|
| 60 |
+
of input. By message 20 it was 20 messages.
|
| 61 |
+
|
| 62 |
+
Now we cap at the most recent 8 turns (β4 user + 4 AI). Older turns rarely
|
| 63 |
+
influence the next answer, and the cap stops the linear growth of input cost
|
| 64 |
+
on long sessions.
|
| 65 |
+
|
| 66 |
+
To tune: change `MAX_HISTORY_TURNS` at the top of `claude_engine.py`. Higher
|
| 67 |
+
= more context, more cost. Lower = cheaper but the chatbot forgets sooner.
|
| 68 |
+
|
| 69 |
+
## Optimization 3 β Knowledge base trimmed
|
| 70 |
+
|
| 71 |
+
**Done in the previous session.** The fabricated 180KB `comprehensive_knowledge.json`
|
| 72 |
+
and other quarantined files were removed from the brain loader. Less context
|
| 73 |
+
gets retrieved per query β less input cost per call.
|
| 74 |
+
|
| 75 |
+
## What a single chat now costs (rough estimate)
|
| 76 |
+
|
| 77 |
+
A 5-turn conversation about admissions:
|
| 78 |
+
|
| 79 |
+
| Turn | Action | Cost notes |
|
| 80 |
+
|------|--------|------------|
|
| 81 |
+
| 1 | First question | Full price: writes cache (~$0.012) |
|
| 82 |
+
| 2 | Follow-up | Cache hit on system + context (~$0.004) |
|
| 83 |
+
| 3 | Another follow-up | Cache hit (~$0.004) |
|
| 84 |
+
| 4 | Different topic | Partial cache hit, new context (~$0.008) |
|
| 85 |
+
| 5 | Follow-up to #4 | Cache hit (~$0.004) |
|
| 86 |
+
|
| 87 |
+
**Total: ~$0.032 for a 5-turn session.** With 1,000 active students having one
|
| 88 |
+
5-turn session per week, monthly Anthropic cost β **$130/month**.
|
| 89 |
+
|
| 90 |
+
Without caching, the same usage would be 2β3Γ that.
|
| 91 |
+
|
| 92 |
+
## What you can still do to save more
|
| 93 |
+
|
| 94 |
+
Things I didn't implement automatically but you can flip on:
|
| 95 |
+
|
| 96 |
+
1. **Lower `max_tokens` to 600** β set `DEFAULT_MAX_TOKENS = 600` in
|
| 97 |
+
`claude_engine.py`. Output is 5Γ more expensive than input. Most ALU
|
| 98 |
+
answers fit in 600 tokens. Risk: very long answers get truncated.
|
| 99 |
+
|
| 100 |
+
2. **Add a Haiku router** β route obvious simple questions ("hi", "what's
|
| 101 |
+
ALU's website?") to `claude-haiku-4-5-20251001`, which is 3Γ cheaper than
|
| 102 |
+
Sonnet. I can add this when you're ready β it needs a classifier and
|
| 103 |
+
testing.
|
| 104 |
+
|
| 105 |
+
3. **Front the chatbot with a rate limit** β cap each student to e.g. 30
|
| 106 |
+
chats per day. Easy in your `app.py` middleware. Stops one student
|
| 107 |
+
accidentally (or maliciously) burning through the quota.
|
| 108 |
+
|
| 109 |
+
4. **Set a hard monthly budget in Anthropic Console** β go to your billing
|
| 110 |
+
page on console.anthropic.com and set a usage limit. If usage exceeds it,
|
| 111 |
+
the API returns errors instead of charging more. This is the safety net.
|
| 112 |
+
|
| 113 |
+
## How to verify caching is actually working
|
| 114 |
+
|
| 115 |
+
After deploying, send 2-3 chat messages in quick succession (within 5
|
| 116 |
+
minutes). Check the HF Space logs. The first call should show:
|
| 117 |
+
|
| 118 |
+
```
|
| 119 |
+
cache_read=0 cache_write=2000+
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
The second call should show:
|
| 123 |
+
|
| 124 |
+
```
|
| 125 |
+
cache_read=2000+ cache_write=0
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
If you see `cache_read` staying at 0, the cache isn't matching β usually
|
| 129 |
+
because the system prompt or context changed between calls. Open an issue.
|
app.py
CHANGED
|
@@ -201,6 +201,44 @@ async def chat_claude(request: ClaudeChatRequest):
|
|
| 201 |
}
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
# ---------------------------------------------------------------------------
|
| 205 |
# Phase 2: Opportunities for students (Tavily-backed)
|
| 206 |
# ---------------------------------------------------------------------------
|
|
|
|
| 201 |
}
|
| 202 |
|
| 203 |
|
| 204 |
+
# ---------------------------------------------------------------------------
|
| 205 |
+
# Phase 2: Application essay coach
|
| 206 |
+
# ---------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
class EssayCoachRequest(BaseModel):
|
| 209 |
+
essay: str
|
| 210 |
+
prompt: Optional[str] = None # the essay question being answered, optional
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@app.post("/api/coach/essay")
|
| 214 |
+
async def coach_essay(request: EssayCoachRequest):
|
| 215 |
+
"""
|
| 216 |
+
Give feedback on an ALU application essay draft. Stateless β does NOT
|
| 217 |
+
use chat history or RAG. Each request is independent.
|
| 218 |
+
"""
|
| 219 |
+
if not claude_engine.enabled:
|
| 220 |
+
raise HTTPException(
|
| 221 |
+
status_code=503,
|
| 222 |
+
detail="Essay coach unavailable β Claude is not configured on the server.",
|
| 223 |
+
)
|
| 224 |
+
if len(request.essay.strip()) < 50:
|
| 225 |
+
raise HTTPException(
|
| 226 |
+
status_code=400,
|
| 227 |
+
detail="Essay is too short β please share at least a paragraph.",
|
| 228 |
+
)
|
| 229 |
+
if len(request.essay) > 12000:
|
| 230 |
+
raise HTTPException(
|
| 231 |
+
status_code=400,
|
| 232 |
+
detail="Essay is too long β please keep drafts under 12,000 characters.",
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
feedback = claude_engine.coach_essay(request.essay, prompt=request.prompt)
|
| 236 |
+
if not feedback:
|
| 237 |
+
raise HTTPException(status_code=502, detail="Coach could not produce feedback.")
|
| 238 |
+
|
| 239 |
+
return {"feedback": feedback, "engine": "claude", "model": claude_engine.model}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
# ---------------------------------------------------------------------------
|
| 243 |
# Phase 2: Opportunities for students (Tavily-backed)
|
| 244 |
# ---------------------------------------------------------------------------
|
claude_engine.py
CHANGED
|
@@ -16,6 +16,11 @@ DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
| 16 |
DEFAULT_MAX_TOKENS = 1024
|
| 17 |
DEFAULT_TEMPERATURE = 0.4 # lower than chat β we want faithful retrieval
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
SYSTEM_PROMPT = """You are the ALU Student Companion, an AI assistant for African Leadership University.
|
| 20 |
|
| 21 |
# Your single most important rule
|
|
@@ -108,15 +113,35 @@ class ClaudeEngine:
|
|
| 108 |
return "\n\n---\n\n".join(lines)
|
| 109 |
|
| 110 |
@staticmethod
|
| 111 |
-
def _to_anthropic_messages(
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
msgs: List[Dict[str, str]] = []
|
| 114 |
for entry in history or []:
|
| 115 |
role = entry.get("role")
|
| 116 |
content = entry.get("content", "")
|
| 117 |
if role in ("user", "assistant") and content:
|
| 118 |
msgs.append({"role": role, "content": content})
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
if not msgs or msgs[-1]["role"] != "user" or msgs[-1]["content"] != user_message:
|
| 121 |
msgs.append({"role": "user", "content": user_message})
|
| 122 |
return msgs
|
|
@@ -143,42 +168,150 @@ class ClaudeEngine:
|
|
| 143 |
|
| 144 |
context_block = self._format_context(context_docs or [])
|
| 145 |
|
| 146 |
-
#
|
| 147 |
-
#
|
| 148 |
-
#
|
| 149 |
-
#
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
try:
|
| 160 |
response = self.client.messages.create(
|
| 161 |
model=self.model,
|
| 162 |
max_tokens=max_tokens,
|
| 163 |
temperature=temperature,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
system=[
|
| 165 |
{
|
| 166 |
"type": "text",
|
| 167 |
-
"text":
|
| 168 |
"cache_control": {"type": "ephemeral"},
|
| 169 |
}
|
| 170 |
],
|
| 171 |
-
messages=
|
| 172 |
)
|
| 173 |
-
# response.content is a list of content blocks; we want the text.
|
| 174 |
text_parts = [
|
| 175 |
block.text for block in response.content if getattr(block, "type", None) == "text"
|
| 176 |
]
|
| 177 |
-
|
| 178 |
-
logger.info(f"[OK] Claude response generated ({len(answer)} chars)")
|
| 179 |
-
return answer or None
|
| 180 |
except Exception as e:
|
| 181 |
-
logger.error(f"[FAIL]
|
| 182 |
return None
|
| 183 |
|
| 184 |
def get_status(self) -> Dict[str, Any]:
|
|
|
|
| 16 |
DEFAULT_MAX_TOKENS = 1024
|
| 17 |
DEFAULT_TEMPERATURE = 0.4 # lower than chat β we want faithful retrieval
|
| 18 |
|
| 19 |
+
# How many recent turns to keep when forwarding history. Older turns rarely
|
| 20 |
+
# influence the next answer and grow input cost linearly. 8 = ~4 user turns
|
| 21 |
+
# + ~4 assistant turns, which covers natural follow-up patterns.
|
| 22 |
+
MAX_HISTORY_TURNS = 8
|
| 23 |
+
|
| 24 |
SYSTEM_PROMPT = """You are the ALU Student Companion, an AI assistant for African Leadership University.
|
| 25 |
|
| 26 |
# Your single most important rule
|
|
|
|
| 113 |
return "\n\n---\n\n".join(lines)
|
| 114 |
|
| 115 |
@staticmethod
|
| 116 |
+
def _to_anthropic_messages(
|
| 117 |
+
history: List[Dict[str, str]],
|
| 118 |
+
user_message: str,
|
| 119 |
+
max_turns: int = MAX_HISTORY_TURNS,
|
| 120 |
+
) -> List[Dict[str, str]]:
|
| 121 |
+
"""
|
| 122 |
+
Convert frontend history into Anthropic's messages format.
|
| 123 |
+
|
| 124 |
+
Trims to the last `max_turns` turns to cap input cost on long chats.
|
| 125 |
+
Anthropic requires the messages array to start with a `user` turn,
|
| 126 |
+
so we drop a leading `assistant` if the trim landed on one.
|
| 127 |
+
"""
|
| 128 |
msgs: List[Dict[str, str]] = []
|
| 129 |
for entry in history or []:
|
| 130 |
role = entry.get("role")
|
| 131 |
content = entry.get("content", "")
|
| 132 |
if role in ("user", "assistant") and content:
|
| 133 |
msgs.append({"role": role, "content": content})
|
| 134 |
+
|
| 135 |
+
# Trim to the most recent N turns BEFORE we append the new user message,
|
| 136 |
+
# so the new message is always kept.
|
| 137 |
+
if len(msgs) > max_turns:
|
| 138 |
+
msgs = msgs[-max_turns:]
|
| 139 |
+
|
| 140 |
+
# Anthropic rejects an array that starts with 'assistant' β drop it.
|
| 141 |
+
while msgs and msgs[0]["role"] != "user":
|
| 142 |
+
msgs.pop(0)
|
| 143 |
+
|
| 144 |
+
# Ensure the last message is the new user turn we're answering.
|
| 145 |
if not msgs or msgs[-1]["role"] != "user" or msgs[-1]["content"] != user_message:
|
| 146 |
msgs.append({"role": "user", "content": user_message})
|
| 147 |
return msgs
|
|
|
|
| 168 |
|
| 169 |
context_block = self._format_context(context_docs or [])
|
| 170 |
|
| 171 |
+
# Anthropic prompt caching: structured as TWO cache blocks in `system`.
|
| 172 |
+
#
|
| 173 |
+
# Block 1 β the system prompt β almost never changes, so it stays in
|
| 174 |
+
# cache for the full 5-minute window and serves every conversation.
|
| 175 |
+
#
|
| 176 |
+
# Block 2 β the retrieved ALU context β changes per query, but when
|
| 177 |
+
# two queries retrieve overlapping content (very common: lots of
|
| 178 |
+
# questions about admissions all surface the same JSON entries),
|
| 179 |
+
# Anthropic returns a cache hit and we pay 10x less ($0.30/M vs $3/M).
|
| 180 |
+
# If the context is unique, we pay a one-time 25% cache-write surcharge
|
| 181 |
+
# ($3.75/M), so the break-even is one repeat within 5 minutes.
|
| 182 |
+
#
|
| 183 |
+
# The user message is now just the question β small, cheap, never
|
| 184 |
+
# cached (it's different every time).
|
| 185 |
+
system_blocks = [
|
| 186 |
+
{
|
| 187 |
+
"type": "text",
|
| 188 |
+
"text": SYSTEM_PROMPT,
|
| 189 |
+
"cache_control": {"type": "ephemeral"},
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"type": "text",
|
| 193 |
+
"text": (
|
| 194 |
+
"Use the following context from the ALU knowledge base "
|
| 195 |
+
"to answer the student's question. If the context does "
|
| 196 |
+
"not contain the answer, say so honestly and point to "
|
| 197 |
+
"the official source.\n\n"
|
| 198 |
+
f"=== CONTEXT ===\n{context_block}\n=== END CONTEXT ==="
|
| 199 |
+
),
|
| 200 |
+
"cache_control": {"type": "ephemeral"},
|
| 201 |
+
},
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
messages = self._to_anthropic_messages(history or [], query)
|
| 205 |
|
| 206 |
try:
|
| 207 |
response = self.client.messages.create(
|
| 208 |
model=self.model,
|
| 209 |
max_tokens=max_tokens,
|
| 210 |
temperature=temperature,
|
| 211 |
+
system=system_blocks,
|
| 212 |
+
messages=messages,
|
| 213 |
+
)
|
| 214 |
+
# response.content is a list of content blocks; we want the text.
|
| 215 |
+
text_parts = [
|
| 216 |
+
block.text for block in response.content if getattr(block, "type", None) == "text"
|
| 217 |
+
]
|
| 218 |
+
answer = "\n".join(text_parts).strip()
|
| 219 |
+
|
| 220 |
+
# Log token usage so you can audit caching behaviour. The cache
|
| 221 |
+
# read/write counts confirm whether prompt caching is paying off.
|
| 222 |
+
usage = getattr(response, "usage", None)
|
| 223 |
+
if usage:
|
| 224 |
+
logger.info(
|
| 225 |
+
"[OK] Claude reply %d chars | tokens in=%d out=%d "
|
| 226 |
+
"cache_read=%d cache_write=%d",
|
| 227 |
+
len(answer),
|
| 228 |
+
getattr(usage, "input_tokens", 0),
|
| 229 |
+
getattr(usage, "output_tokens", 0),
|
| 230 |
+
getattr(usage, "cache_read_input_tokens", 0) or 0,
|
| 231 |
+
getattr(usage, "cache_creation_input_tokens", 0) or 0,
|
| 232 |
+
)
|
| 233 |
+
else:
|
| 234 |
+
logger.info(f"[OK] Claude reply {len(answer)} chars")
|
| 235 |
+
|
| 236 |
+
return answer or None
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger.error(f"[FAIL] Claude API error: {e}")
|
| 239 |
+
return None
|
| 240 |
+
|
| 241 |
+
# ------------------------------------------------------------------
|
| 242 |
+
# Application essay coach
|
| 243 |
+
# ------------------------------------------------------------------
|
| 244 |
+
|
| 245 |
+
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.
|
| 246 |
+
|
| 247 |
+
ALU's selection criteria emphasise:
|
| 248 |
+
- A clear sense of personal mission β what problem does the applicant want to solve?
|
| 249 |
+
- Evidence of leadership, initiative, or impact in their school or community
|
| 250 |
+
- Authentic voice β ALU is wary of generic, overly polished essays
|
| 251 |
+
- Pan-African or global perspective
|
| 252 |
+
- Resilience and reflection β what did they learn from setbacks?
|
| 253 |
+
|
| 254 |
+
Your feedback style:
|
| 255 |
+
- Be specific. Quote short phrases from the draft when commenting on them.
|
| 256 |
+
- Lead with what works before what doesn't.
|
| 257 |
+
- Give 3-5 concrete suggestions, ranked by importance.
|
| 258 |
+
- Be honest. Don't tell a weak essay it's strong β students need real feedback to improve.
|
| 259 |
+
- End with a one-line summary of the essay's biggest strength and its biggest opportunity.
|
| 260 |
+
- Never rewrite the essay for them. Coach, don't ghostwrite.
|
| 261 |
+
|
| 262 |
+
Format your response as:
|
| 263 |
+
|
| 264 |
+
## What's working
|
| 265 |
+
[2-3 bullet points with quoted evidence]
|
| 266 |
+
|
| 267 |
+
## Where to improve
|
| 268 |
+
[3-5 bullet points, ranked by importance, with specific suggestions]
|
| 269 |
+
|
| 270 |
+
## Bottom line
|
| 271 |
+
[One sentence on the biggest strength + one sentence on the biggest opportunity]
|
| 272 |
+
"""
|
| 273 |
+
|
| 274 |
+
def coach_essay(
|
| 275 |
+
self,
|
| 276 |
+
essay_text: str,
|
| 277 |
+
prompt: Optional[str] = None,
|
| 278 |
+
max_tokens: int = 1500,
|
| 279 |
+
) -> Optional[str]:
|
| 280 |
+
"""
|
| 281 |
+
Give application-essay feedback. Optional `prompt` is the essay
|
| 282 |
+
question the student is answering β providing it improves the feedback.
|
| 283 |
+
Returns markdown feedback or None on failure.
|
| 284 |
+
"""
|
| 285 |
+
if not self.enabled or not self.client:
|
| 286 |
+
return None
|
| 287 |
+
if not essay_text or len(essay_text.strip()) < 50:
|
| 288 |
+
return None # Caller handles the "essay too short" case.
|
| 289 |
+
|
| 290 |
+
user_message = "Here is the student's draft essay.\n\n"
|
| 291 |
+
if prompt:
|
| 292 |
+
user_message += f"Essay prompt they're answering:\n{prompt.strip()}\n\n"
|
| 293 |
+
user_message += f"=== ESSAY DRAFT ===\n{essay_text.strip()}\n=== END DRAFT ==="
|
| 294 |
+
|
| 295 |
+
try:
|
| 296 |
+
response = self.client.messages.create(
|
| 297 |
+
model=self.model,
|
| 298 |
+
max_tokens=max_tokens,
|
| 299 |
+
temperature=0.5,
|
| 300 |
system=[
|
| 301 |
{
|
| 302 |
"type": "text",
|
| 303 |
+
"text": self.ESSAY_COACH_SYSTEM,
|
| 304 |
"cache_control": {"type": "ephemeral"},
|
| 305 |
}
|
| 306 |
],
|
| 307 |
+
messages=[{"role": "user", "content": user_message}],
|
| 308 |
)
|
|
|
|
| 309 |
text_parts = [
|
| 310 |
block.text for block in response.content if getattr(block, "type", None) == "text"
|
| 311 |
]
|
| 312 |
+
return "\n".join(text_parts).strip() or None
|
|
|
|
|
|
|
| 313 |
except Exception as e:
|
| 314 |
+
logger.error(f"[FAIL] Essay coach error: {e}")
|
| 315 |
return None
|
| 316 |
|
| 317 |
def get_status(self) -> Dict[str, Any]:
|