Donne120 commited on
Commit
36f325e
·
1 Parent(s): 6d4cffe

Phase 3: streaming chat via SSE with prompt caching preserved

Browse files
Files changed (2) hide show
  1. app.py +86 -0
  2. claude_engine.py +78 -1
app.py CHANGED
@@ -26,8 +26,10 @@ from claude_engine import claude_engine # noqa: E402
26
  from opportunities_service import opportunities_service # noqa: E402
27
 
28
  from fastapi import UploadFile, File, Form, HTTPException # noqa: E402
 
29
  from pydantic import BaseModel # noqa: E402
30
  from typing import Any, Dict, List, Optional # noqa: E402
 
31
 
32
 
33
  # ---------------------------------------------------------------------------
@@ -201,6 +203,90 @@ async def chat_claude(request: ClaudeChatRequest):
201
  }
202
 
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  # ---------------------------------------------------------------------------
205
  # Phase 2: Application essay coach
206
  # ---------------------------------------------------------------------------
 
26
  from opportunities_service import opportunities_service # noqa: E402
27
 
28
  from fastapi import UploadFile, File, Form, HTTPException # noqa: E402
29
+ from fastapi.responses import StreamingResponse # noqa: E402
30
  from pydantic import BaseModel # noqa: E402
31
  from typing import Any, Dict, List, Optional # noqa: E402
32
+ import json as _json # noqa: E402
33
 
34
 
35
  # ---------------------------------------------------------------------------
 
203
  }
204
 
205
 
206
+ # ---------------------------------------------------------------------------
207
+ # Phase 3: Streaming chat — Server-Sent Events
208
+ # ---------------------------------------------------------------------------
209
+
210
+ @app.post("/api/chat/claude/stream")
211
+ async def chat_claude_stream(request: ClaudeChatRequest):
212
+ """
213
+ Streaming version of /api/chat/claude. Returns a Server-Sent Events
214
+ (SSE) response. Each event has shape:
215
+
216
+ data: {"type": "chunk", "text": "..."}\\n\\n
217
+ data: {"type": "sources", "sources": [...]}\\n\\n
218
+ data: {"type": "done"}\\n\\n
219
+
220
+ On Claude failure the stream emits a single "error" event and the
221
+ client should fall back to /api/chat/claude (non-streaming) which
222
+ in turn falls back to Groq.
223
+ """
224
+ if not claude_engine.enabled:
225
+ raise HTTPException(status_code=503, detail="Claude is not configured")
226
+ if retrieval_engine is None:
227
+ raise HTTPException(status_code=503, detail="Retrieval engine unavailable")
228
+
229
+ # Retrieval is synchronous and happens BEFORE we start streaming so
230
+ # we can include sources in the first event the client receives.
231
+ try:
232
+ context_docs = retrieval_engine.retrieve_context(
233
+ query=request.message, role="student"
234
+ )
235
+ except Exception as e:
236
+ print(f"[FAIL] Retrieval error: {e}")
237
+ context_docs = []
238
+
239
+ sources = []
240
+ for doc in (context_docs or [])[:3]:
241
+ meta = getattr(doc, "metadata", None) or {}
242
+ sources.append(
243
+ {
244
+ "title": meta.get("title", "ALU Knowledge Base"),
245
+ "source": meta.get("source", "uploaded"),
246
+ "doc_id": meta.get("doc_id"),
247
+ }
248
+ )
249
+
250
+ def sse_pack(event: Dict[str, Any]) -> str:
251
+ return f"data: {_json.dumps(event, ensure_ascii=False)}\n\n"
252
+
253
+ def event_generator():
254
+ # Send sources first so the UI can render a 'Reading from...' hint.
255
+ yield sse_pack({"type": "sources", "sources": sources})
256
+
257
+ emitted_any = False
258
+ try:
259
+ for chunk in claude_engine.stream_response(
260
+ query=request.message,
261
+ context_docs=context_docs,
262
+ history=request.history,
263
+ ):
264
+ emitted_any = True
265
+ yield sse_pack({"type": "chunk", "text": chunk})
266
+ except Exception as e:
267
+ print(f"[FAIL] Stream generator error: {e}")
268
+ yield sse_pack({"type": "error", "message": "Stream interrupted"})
269
+ return
270
+
271
+ if not emitted_any:
272
+ yield sse_pack(
273
+ {"type": "error", "message": "Claude returned no content"}
274
+ )
275
+ return
276
+
277
+ yield sse_pack({"type": "done"})
278
+
279
+ return StreamingResponse(
280
+ event_generator(),
281
+ media_type="text/event-stream",
282
+ headers={
283
+ "Cache-Control": "no-cache",
284
+ "X-Accel-Buffering": "no", # disable proxy buffering
285
+ "Connection": "keep-alive",
286
+ },
287
+ )
288
+
289
+
290
  # ---------------------------------------------------------------------------
291
  # Phase 2: Application essay coach
292
  # ---------------------------------------------------------------------------
claude_engine.py CHANGED
@@ -7,7 +7,7 @@ documents uploaded via /api/documents/upload.
7
  """
8
  import os
9
  import logging
10
- from typing import List, Dict, Any, Optional
11
 
12
  logger = logging.getLogger(__name__)
13
 
@@ -238,6 +238,83 @@ class ClaudeEngine:
238
  logger.error(f"[FAIL] Claude API error: {e}")
239
  return None
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  # ------------------------------------------------------------------
242
  # Application essay coach
243
  # ------------------------------------------------------------------
 
7
  """
8
  import os
9
  import logging
10
+ from typing import List, Dict, Any, Optional, Iterator
11
 
12
  logger = logging.getLogger(__name__)
13
 
 
238
  logger.error(f"[FAIL] Claude API error: {e}")
239
  return None
240
 
241
+ # ------------------------------------------------------------------
242
+ # Streaming chat — yields text chunks as Claude generates them
243
+ # ------------------------------------------------------------------
244
+
245
+ def stream_response(
246
+ self,
247
+ query: str,
248
+ context_docs: Optional[List[Any]] = None,
249
+ history: Optional[List[Dict[str, str]]] = None,
250
+ max_tokens: int = DEFAULT_MAX_TOKENS,
251
+ temperature: float = DEFAULT_TEMPERATURE,
252
+ ) -> Iterator[str]:
253
+ """
254
+ Same RAG behaviour as `generate_response`, but yields text chunks
255
+ as Claude generates them. Caller is responsible for forwarding chunks
256
+ to the client (e.g. via Server-Sent Events).
257
+
258
+ Yields ONLY the incremental text. Caller should accumulate to get
259
+ the full response if needed.
260
+
261
+ On failure yields nothing — caller should treat empty stream as
262
+ "fall back to non-streaming engine".
263
+ """
264
+ if not self.enabled or not self.client:
265
+ return
266
+
267
+ context_block = self._format_context(context_docs or [])
268
+
269
+ system_blocks = [
270
+ {
271
+ "type": "text",
272
+ "text": SYSTEM_PROMPT,
273
+ "cache_control": {"type": "ephemeral"},
274
+ },
275
+ {
276
+ "type": "text",
277
+ "text": (
278
+ "Use the following context from the ALU knowledge base "
279
+ "to answer the student's question. If the context does "
280
+ "not contain the answer, say so honestly and point to "
281
+ "the official source.\n\n"
282
+ f"=== CONTEXT ===\n{context_block}\n=== END CONTEXT ==="
283
+ ),
284
+ "cache_control": {"type": "ephemeral"},
285
+ },
286
+ ]
287
+
288
+ messages = self._to_anthropic_messages(history or [], query)
289
+
290
+ try:
291
+ with self.client.messages.stream(
292
+ model=self.model,
293
+ max_tokens=max_tokens,
294
+ temperature=temperature,
295
+ system=system_blocks,
296
+ messages=messages,
297
+ ) as stream:
298
+ for text_chunk in stream.text_stream:
299
+ if text_chunk:
300
+ yield text_chunk
301
+
302
+ # Log usage from the final message for cache-hit auditing.
303
+ final = stream.get_final_message()
304
+ usage = getattr(final, "usage", None)
305
+ if usage:
306
+ logger.info(
307
+ "[OK] Claude stream complete | tokens in=%d out=%d "
308
+ "cache_read=%d cache_write=%d",
309
+ getattr(usage, "input_tokens", 0),
310
+ getattr(usage, "output_tokens", 0),
311
+ getattr(usage, "cache_read_input_tokens", 0) or 0,
312
+ getattr(usage, "cache_creation_input_tokens", 0) or 0,
313
+ )
314
+ except Exception as e:
315
+ logger.error(f"[FAIL] Claude streaming error: {e}")
316
+ return
317
+
318
  # ------------------------------------------------------------------
319
  # Application essay coach
320
  # ------------------------------------------------------------------