Spaces:
Running
Running
Donne120 commited on
Commit ·
36f325e
1
Parent(s): 6d4cffe
Phase 3: streaming chat via SSE with prompt caching preserved
Browse files- app.py +86 -0
- 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 |
# ------------------------------------------------------------------
|