Donne120 commited on
Commit
6d4cffe
Β·
1 Parent(s): eb67165

Phase 3: prompt caching, history cap, essay coach endpoint

Browse files
Files changed (3) hide show
  1. COST_OPTIMIZATION.md +129 -0
  2. app.py +38 -0
  3. 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(history: List[Dict[str, str]], user_message: str) -> List[Dict[str, str]]:
112
- """Convert frontend history into Anthropic's messages format."""
 
 
 
 
 
 
 
 
 
 
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
- # Ensure last message is the new user turn.
 
 
 
 
 
 
 
 
 
 
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
- # We send the context as the FIRST user turn so prompt caching can
147
- # reuse the system prompt across requests (Anthropic caches in 5-min
148
- # windows). The retrieved context changes per query, but the system
149
- # prompt stays stable.
150
- rag_preamble = (
151
- f"Use the following context from the ALU knowledge base to answer "
152
- f"the question. If the context is insufficient, say so honestly.\n\n"
153
- f"=== CONTEXT ===\n{context_block}\n=== END CONTEXT ===\n\n"
154
- f"Question: {query}"
155
- )
156
-
157
- messages = self._to_anthropic_messages(history or [], rag_preamble)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": SYSTEM_PROMPT,
168
  "cache_control": {"type": "ephemeral"},
169
  }
170
  ],
171
- messages=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
- answer = "\n".join(text_parts).strip()
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] Claude API error: {e}")
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]: