AI Assistant commited on
Commit
dff6fcd
·
1 Parent(s): d13ebb9

feat: add Atlas identity, document endpoints, and Groq fallback for depleted tokens

Browse files
app/app.py CHANGED
@@ -18,9 +18,9 @@ load_dotenv()
18
 
19
  # App initialization
20
  app = FastAPI(
21
- title="Vectorless RAG API",
22
- description="Hierarchical Tree Search Document Analysis API with Multimodal Support",
23
- version="1.0.0",
24
  )
25
 
26
  # Static files for extracted document images
 
18
 
19
  # App initialization
20
  app = FastAPI(
21
+ title="Atlas - Document Intelligence API",
22
+ description="Atlas Hierarchical Tree Search Document Analysis API with Multimodal Support",
23
+ version="2.0.0",
24
  )
25
 
26
  # Static files for extracted document images
app/core/persistent_db.py CHANGED
@@ -251,6 +251,13 @@ class PersistentDB:
251
  ).fetchone()
252
  return dict(row) if row else None
253
 
 
 
 
 
 
 
 
254
 
255
  # Global singleton
256
  persistent_db = PersistentDB()
 
251
  ).fetchone()
252
  return dict(row) if row else None
253
 
254
+ def delete_doc(self, doc_id: str) -> bool:
255
+ with self._tx() as conn:
256
+ cur = conn.execute("DELETE FROM doc_meta WHERE doc_id=?", (doc_id,))
257
+ conn.execute("UPDATE chats SET doc_id=NULL, doc_name=NULL WHERE doc_id=?", (doc_id,))
258
+ return cur.rowcount > 0
259
+
260
+
261
 
262
  # Global singleton
263
  persistent_db = PersistentDB()
app/core/storage.py CHANGED
@@ -281,6 +281,26 @@ class MemoryStorage:
281
  doc["error"] = None
282
  self._trees[doc_id] = tree
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  persistent_db.upsert_doc_meta(
285
  doc_id=doc_id,
286
  filename=doc["filename"],
@@ -356,19 +376,25 @@ class MemoryStorage:
356
  "updated_at": db_meta.get("updated_at", time.time()),
357
  }
358
  if doc_obj["status"] == "ready":
359
- cache_path = os.path.join(CACHE_DIR, f"{doc_obj['content_hash']}.json") if doc_obj.get("content_hash") else None
360
- if cache_path and os.path.exists(cache_path):
361
- try:
362
- with open(cache_path, "r", encoding="utf-8") as f:
363
- cached = json.load(f)
364
- doc_obj["tree"] = cached.get("tree")
365
- self._trees[doc_id] = cached.get("tree")
366
- if cached.get("node_map"):
367
- self._node_maps[doc_id] = cached["node_map"]
368
- if cached.get("flat_sections"):
369
- self._flat_sections[doc_id] = cached["flat_sections"]
370
- except Exception:
371
- pass
 
 
 
 
 
 
372
  self._documents[doc_id] = doc_obj
373
  return doc_obj
374
  return None
@@ -392,6 +418,34 @@ class MemoryStorage:
392
  return True
393
  return doc_id in self._trees
394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  # ──────────────────────────────────────────────────────────────────────
396
  # Chat operations (all backed by SQLite now)
397
  # ──────────────────────────────────────────────────────────────────────
 
281
  doc["error"] = None
282
  self._trees[doc_id] = tree
283
 
284
+ # Persist index cache by doc_id
285
+ cache_path = os.path.join(CACHE_DIR, f"{doc_id}.json")
286
+ try:
287
+ payload = {
288
+ "doc_id": doc_id,
289
+ "filename": doc.get("filename"),
290
+ "tree": tree,
291
+ "page_count": doc.get("page_count", 1),
292
+ "section_count": doc.get("section_count", len(tree) if isinstance(tree, list) else 0),
293
+ "indexing_method": doc.get("indexing_method"),
294
+ "duration": doc.get("duration"),
295
+ "node_map": self._node_maps.get(doc_id),
296
+ "flat_sections": self._flat_sections.get(doc_id),
297
+ "created_at": doc.get("created_at", time.time()),
298
+ }
299
+ with open(cache_path, "w", encoding="utf-8") as f:
300
+ json.dump(payload, f)
301
+ except Exception:
302
+ pass
303
+
304
  persistent_db.upsert_doc_meta(
305
  doc_id=doc_id,
306
  filename=doc["filename"],
 
376
  "updated_at": db_meta.get("updated_at", time.time()),
377
  }
378
  if doc_obj["status"] == "ready":
379
+ candidates = [f"{doc_id}.json"]
380
+ if doc_obj.get("content_hash"):
381
+ candidates.append(f"{doc_obj['content_hash']}.json")
382
+ for c_file in candidates:
383
+ cache_path = os.path.join(CACHE_DIR, c_file)
384
+ if os.path.exists(cache_path):
385
+ try:
386
+ with open(cache_path, "r", encoding="utf-8") as f:
387
+ cached = json.load(f)
388
+ doc_obj["tree"] = cached.get("tree")
389
+ self._trees[doc_id] = cached.get("tree")
390
+ if cached.get("node_map"):
391
+ self._node_maps[doc_id] = cached["node_map"]
392
+ if cached.get("flat_sections"):
393
+ self._flat_sections[doc_id] = cached["flat_sections"]
394
+ break
395
+ except Exception:
396
+ pass
397
+
398
  self._documents[doc_id] = doc_obj
399
  return doc_obj
400
  return None
 
418
  return True
419
  return doc_id in self._trees
420
 
421
+ def list_documents(self) -> List[Dict[str, Any]]:
422
+ db_docs = persistent_db.list_docs()
423
+ results = []
424
+ for d in db_docs:
425
+ doc_id = d["doc_id"]
426
+ if doc_id in self._documents:
427
+ mem = self._documents[doc_id]
428
+ merged = {**d, **{k: v for k, v in mem.items() if k != "tree"}}
429
+ results.append(merged)
430
+ else:
431
+ results.append(d)
432
+ return results
433
+
434
+ def get_document_tree(self, doc_id: str) -> Optional[Any]:
435
+ doc = self.get_document(doc_id)
436
+ if doc and doc.get("tree") is not None:
437
+ return doc["tree"]
438
+ return self._trees.get(doc_id)
439
+
440
+ def delete_document(self, doc_id: str) -> bool:
441
+ self._documents.pop(doc_id, None)
442
+ self._trees.pop(doc_id, None)
443
+ self._node_maps.pop(doc_id, None)
444
+ self._flat_sections.pop(doc_id, None)
445
+ self._active_tasks.pop(doc_id, None)
446
+ return persistent_db.delete_doc(doc_id)
447
+
448
+
449
  # ──────────────────────────────────────────────────────────────────────
450
  # Chat operations (all backed by SQLite now)
451
  # ──────────────────────────────────────────────────────────────────────
app/routers/documents.py CHANGED
@@ -305,3 +305,99 @@ async def retry_document_indexing(
305
  status="indexing",
306
  page_count=0,
307
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  status="indexing",
306
  page_count=0,
307
  )
308
+
309
+
310
+ @router.get("/documents")
311
+ async def list_documents(storage: MemoryStorage = Depends(get_storage)):
312
+ """List all ingested documents with status and metadata."""
313
+ docs = storage.list_documents()
314
+ return {"documents": docs}
315
+
316
+
317
+ @router.get("/documents/{document_id}")
318
+ async def get_document_details(
319
+ document_id: str,
320
+ storage: MemoryStorage = Depends(get_storage),
321
+ ):
322
+ """Retrieve details and operational metadata for a specific document."""
323
+ doc = storage.get_document(document_id)
324
+ if not doc:
325
+ raise HTTPException(status_code=404, detail="Document ID not found.")
326
+ return {
327
+ "doc_id": doc.get("doc_id", document_id),
328
+ "document_id": doc.get("doc_id", document_id),
329
+ "filename": doc.get("filename", "document.pdf"),
330
+ "status": doc.get("status", "unknown"),
331
+ "progress": doc.get("progress", 100 if doc.get("status") == "ready" else 0),
332
+ "current_stage": doc.get("current_stage", "Ready"),
333
+ "page_count": doc.get("page_count", 0),
334
+ "section_count": doc.get("section_count", 0),
335
+ "indexing_method": doc.get("indexing_method"),
336
+ "duration": doc.get("duration"),
337
+ "error": doc.get("error"),
338
+ "created_at": doc.get("created_at"),
339
+ }
340
+
341
+
342
+ @router.get("/documents/{document_id}/tree")
343
+ async def get_document_tree(
344
+ document_id: str,
345
+ storage: MemoryStorage = Depends(get_storage),
346
+ doc_service: DocumentService = Depends(get_document_service),
347
+ llm_service: LLMService = Depends(get_llm_service),
348
+ ):
349
+ """Retrieve the hierarchical section tree for a document."""
350
+ doc = storage.get_document(document_id)
351
+ if not doc:
352
+ raise HTTPException(status_code=404, detail="Document ID not found.")
353
+
354
+ tree = storage.get_document_tree(document_id)
355
+ if tree is None and doc.get("file_path") and os.path.exists(doc["file_path"]):
356
+ # Reconstruct tree using local parser and cache
357
+ tree = doc_service._local_pdf_parse(doc["file_path"])
358
+ tree = llm_service.normalize_tree(tree)
359
+ storage.set_document_ready(
360
+ doc_id=document_id,
361
+ tree=tree,
362
+ page_count=doc.get("page_count") or len(tree),
363
+ section_count=len(tree),
364
+ indexing_method=doc.get("indexing_method") or "pymupdf",
365
+ filename=doc.get("filename"),
366
+ file_path=doc.get("file_path"),
367
+ )
368
+
369
+ if tree is None:
370
+ raise HTTPException(
371
+ status_code=404,
372
+ detail="Document tree not found or indexing is still in progress."
373
+ )
374
+
375
+ return {
376
+ "doc_id": document_id,
377
+ "filename": doc.get("filename", "document.pdf"),
378
+ "tree": tree,
379
+ }
380
+
381
+
382
+
383
+ @router.delete("/documents/{document_id}")
384
+ async def delete_document(
385
+ document_id: str,
386
+ storage: MemoryStorage = Depends(get_storage),
387
+ ):
388
+ """Permanently delete a document, its cached index, and local file."""
389
+ doc = storage.get_document(document_id)
390
+ if not doc:
391
+ raise HTTPException(status_code=404, detail="Document ID not found.")
392
+
393
+ file_path = doc.get("file_path")
394
+ if file_path and os.path.exists(file_path):
395
+ try:
396
+ os.remove(file_path)
397
+ except Exception as e:
398
+ app_logger.warning(f"Could not remove document file {file_path}: {e}")
399
+
400
+ storage.delete_document(document_id)
401
+ app_logger.info(f"Deleted document {document_id}")
402
+ return {"message": "Document deleted successfully", "doc_id": document_id}
403
+
app/services/llm_service.py CHANGED
@@ -4,13 +4,17 @@ import time
4
  import json
5
  import uuid
6
  from typing import Optional, List, Dict, Any, Tuple
 
7
  from huggingface_hub import AsyncInferenceClient
 
8
 
9
  from utils.logger import app_logger
10
 
 
 
11
 
12
  class LLMService:
13
- """Dedicated LLM Service using Hugging Face Serverless Inference API."""
14
 
15
  def __init__(
16
  self,
@@ -28,10 +32,46 @@ class LLMService:
28
  self.summary_model = os.getenv("HF_SUMMARY_MODEL", self.retrieval_model)
29
 
30
  self.client = AsyncInferenceClient(token=self.token)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  app_logger.info(
32
  f"Initialized LLMService via Hugging Face | Retrieval: {self.retrieval_model} | Chat: {self.chat_model}"
33
  )
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def _extract_json(self, raw_text: str) -> dict:
36
  """Safely extract JSON object from LLM response or codeblock."""
37
  text = raw_text.strip()
@@ -228,13 +268,33 @@ CONVERSATION HISTORY
228
 
229
  {history_text}"""
230
 
231
- response = await self.client.chat_completion(
232
- messages=[{"role": "user", "content": prompt}],
233
- model=target_model,
234
- temperature=0.2,
235
- max_tokens=800,
236
- )
237
- return response.choices[0].message.content or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
 
239
  async def llm_tree_search(
240
  self,
@@ -545,19 +605,42 @@ If there is insufficient evidence:
545
  "node_list": []
546
  }}"""
547
 
548
- try:
549
- response = await self.client.chat_completion(
550
- messages=[{"role": "user", "content": prompt}],
551
- model=target_model,
552
- temperature=0.1,
553
- max_tokens=300,
554
- )
555
- content = response.choices[0].message.content or "{}"
556
- result = self._extract_json(content)
557
- final_nodes = result.get("node_list", [])
558
- except Exception as e:
559
- app_logger.error(f"Error during llm_tree_search: {str(e)}", exc_info=True)
560
- final_nodes = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
 
562
  t_search = time.monotonic() - t_search_start
563
  app_logger.info(
@@ -647,7 +730,8 @@ If there is insufficient evidence:
647
 
648
  effective_doc_name = doc_name or "Uploaded Document"
649
 
650
- system_prompt = f"""You are a production-grade document intelligence assistant operating inside a Retrieval-Augmented Generation (RAG) system.
 
651
 
652
  Your job is to answer the user's question using the supplied document evidence and conversation context while minimizing hallucinations and preserving factual accuracy.
653
 
@@ -997,46 +1081,87 @@ Return only the final user-facing answer."""
997
  t_gen_start = time.monotonic()
998
  t_first_token = None
999
  total_chars = 0
 
1000
 
1001
- try:
1002
- stream = await self.client.chat_completion(
1003
- messages=messages,
1004
- model=target_model,
1005
- temperature=0.2,
1006
- max_tokens=max_out_tokens,
1007
- stream=True,
1008
- )
 
1009
 
1010
- async for chunk in stream:
1011
- if chunk.choices and len(chunk.choices) > 0:
1012
- delta = chunk.choices[0].delta.content
1013
- if delta is not None:
1014
- if t_first_token is None:
1015
- t_first_token = time.monotonic()
1016
- ttft = t_first_token - t_gen_start
1017
- app_logger.info(f"[PERF] answer_generation TTFT={ttft:.2f}s model={target_model} max_tokens={max_out_tokens}")
1018
- total_chars += len(delta)
1019
- yield delta
1020
-
1021
- t_gen_end = time.monotonic()
1022
- ttft = (t_first_token - t_gen_start) if t_first_token else 0
1023
- gen_duration = (t_gen_end - t_first_token) if t_first_token else 0
1024
- total_duration = t_gen_end - t_gen_start
1025
- context_chars = len(context_msg)
1026
- context_tokens_est = context_chars // 4
1027
- app_logger.info(
1028
- f"[PERF] answer_generation total={total_duration:.2f}s TTFT={ttft:.2f}s "
1029
- f"generation={gen_duration:.2f}s output_chars={total_chars} "
1030
- f"context_chars={context_chars} context_tokens_est={context_tokens_est} nodes={len(nodes)}"
1031
- )
1032
- except Exception as e:
1033
- app_logger.error(f"Error in generate_answer_stream: {str(e)}", exc_info=True)
1034
- is_arabic = any("\u0600" <= c <= "\u06ff" for c in query)
1035
- if "402" in str(e) or "credits" in str(e).lower():
1036
- err_msg = "عذراً، تم استهلاك رصيد الاستخدام الشهري للنموذج (402 Payment Required)." if is_arabic else "Sorry, monthly included model credits are depleted (402 Payment Required)."
1037
- else:
1038
- err_msg = f"عذراً، حدث خطأ أثناء الاتصال بالنموذج ({type(e).__name__}). يرجى المحاولة لاحقاً." if is_arabic else f"Sorry, model service error occurred ({type(e).__name__}). Please try again."
1039
- yield err_msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1040
 
1041
  async def generate_conversational_stream(
1042
  self,
@@ -1045,7 +1170,7 @@ Return only the final user-facing answer."""
1045
  model: Optional[str] = None,
1046
  ):
1047
  """Conversational stream when no document is attached yet.
1048
- Uses the smaller/faster retrieval model for low-latency chat.
1049
  """
1050
  # Use chat_model (Qwen2.5-7B from HF_CHAT_MODEL env) — fast and capable for conversational use
1051
  target_model = model or self.chat_model
@@ -1060,7 +1185,8 @@ Return only the final user-facing answer."""
1060
  is_arabic_query = any("\u0600" <= c <= "\u06ff" for c in query)
1061
  lang_rule = "Respond in Arabic (فصحى طبيعية)." if is_arabic_query else "Respond in English."
1062
 
1063
- system_prompt = f"""You are a helpful AI assistant.
 
1064
  {lang_rule}
1065
  Always match the user's language.
1066
  Be concise and accurate. Never invent facts.
@@ -1074,24 +1200,49 @@ If the user asks about a document and none is uploaded, tell them to upload a PD
1074
  {"role": "user", "content": query},
1075
  ]
1076
 
1077
- try:
1078
- stream = await self.client.chat_completion(
1079
- messages=messages,
1080
- model=target_model,
1081
- temperature=0.6,
1082
- stream=True,
1083
- )
 
 
1084
 
1085
- async for chunk in stream:
1086
- if chunk.choices and len(chunk.choices) > 0:
1087
- delta = chunk.choices[0].delta.content
1088
- if delta is not None:
1089
- yield delta
1090
- except Exception as e:
1091
- app_logger.error(f"Error in generate_conversational_stream: {str(e)}", exc_info=True)
1092
- is_arabic = any("\u0600" <= c <= "\u06ff" for c in query)
1093
- if "402" in str(e) or "credits" in str(e).lower():
1094
- err_msg = "عذراً، تم استهلاك رصيد الاستخدام الشهري للنموذج (402 Payment Required)." if is_arabic else "Sorry, monthly included model credits are depleted (402 Payment Required)."
1095
- else:
1096
- err_msg = f"عذراً، خدمة النماذج غير متاحة حالياً ({type(e).__name__}). يرجى المحاولة لاحقاً." if is_arabic else f"Sorry, the model service is currently unreachable ({type(e).__name__}). Please try again."
1097
- yield err_msg
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import json
5
  import uuid
6
  from typing import Optional, List, Dict, Any, Tuple
7
+ from dotenv import load_dotenv
8
  from huggingface_hub import AsyncInferenceClient
9
+ from openai import AsyncOpenAI
10
 
11
  from utils.logger import app_logger
12
 
13
+ load_dotenv()
14
+
15
 
16
  class LLMService:
17
+ """Dedicated LLM Service using Hugging Face Serverless Inference API with Groq automatic fallback."""
18
 
19
  def __init__(
20
  self,
 
32
  self.summary_model = os.getenv("HF_SUMMARY_MODEL", self.retrieval_model)
33
 
34
  self.client = AsyncInferenceClient(token=self.token)
35
+ self.hf_exhausted = False
36
+
37
+ # Groq Fallback Client (used when Hugging Face tokens/credits are depleted)
38
+ self.groq_api_key = os.getenv("GROQ_API_KEY")
39
+ self.groq_client: Optional[AsyncOpenAI] = None
40
+ self.groq_chat_model = os.getenv("GROQ_CHAT_MODEL", "qwen/qwen3.8-27b")
41
+ self.groq_retrieval_model = os.getenv("GROQ_RETRIEVAL_MODEL", "qwen/qwen3.8-27b")
42
+
43
+ if self.groq_api_key:
44
+ self.groq_client = AsyncOpenAI(
45
+ api_key=self.groq_api_key,
46
+ base_url="https://api.groq.com/openai/v1",
47
+ )
48
+ app_logger.info(
49
+ f"Initialized Groq Fallback Client | Chat: {self.groq_chat_model} | Retrieval: {self.groq_retrieval_model}"
50
+ )
51
+
52
  app_logger.info(
53
  f"Initialized LLMService via Hugging Face | Retrieval: {self.retrieval_model} | Chat: {self.chat_model}"
54
  )
55
 
56
+ def _is_hf_exhausted(self, exc: Exception) -> bool:
57
+ """Detect Hugging Face token/credit exhaustion (402 Payment Required, Rate limits)."""
58
+ err_str = str(exc).lower()
59
+ if (
60
+ "402" in err_str
61
+ or "credit" in err_str
62
+ or "payment required" in err_str
63
+ or "quota" in err_str
64
+ or "rate limit" in err_str
65
+ or "429" in err_str
66
+ ):
67
+ if not self.hf_exhausted:
68
+ app_logger.warning(
69
+ f"[TOKEN DEPLETION] Hugging Face credits exhausted: {exc}. Switching to Groq API fallback."
70
+ )
71
+ self.hf_exhausted = True
72
+ return True
73
+ return False
74
+
75
  def _extract_json(self, raw_text: str) -> dict:
76
  """Safely extract JSON object from LLM response or codeblock."""
77
  text = raw_text.strip()
 
268
 
269
  {history_text}"""
270
 
271
+ if not self.hf_exhausted:
272
+ try:
273
+ response = await self.client.chat_completion(
274
+ messages=[{"role": "user", "content": prompt}],
275
+ model=target_model,
276
+ temperature=0.2,
277
+ max_tokens=800,
278
+ )
279
+ return response.choices[0].message.content or ""
280
+ except Exception as e:
281
+ self._is_hf_exhausted(e)
282
+ app_logger.warning(f"HF summarize_section failed ({e}). Checking Groq fallback...")
283
+
284
+ if self.groq_client:
285
+ try:
286
+ app_logger.info(f"[FALLBACK] Summarizing section via Groq ({self.groq_chat_model})")
287
+ groq_resp = await self.groq_client.chat.completions.create(
288
+ messages=[{"role": "user", "content": prompt}],
289
+ model=self.groq_chat_model,
290
+ temperature=0.2,
291
+ max_tokens=800,
292
+ )
293
+ return groq_resp.choices[0].message.content or ""
294
+ except Exception as ge:
295
+ app_logger.error(f"Groq summarize_section fallback failed: {ge}", exc_info=True)
296
+
297
+ return ""
298
 
299
  async def llm_tree_search(
300
  self,
 
605
  "node_list": []
606
  }}"""
607
 
608
+ final_nodes = []
609
+ hf_failed = False
610
+ if not self.hf_exhausted:
611
+ try:
612
+ response = await self.client.chat_completion(
613
+ messages=[{"role": "user", "content": prompt}],
614
+ model=target_model,
615
+ temperature=0.1,
616
+ max_tokens=300,
617
+ )
618
+ content = response.choices[0].message.content or "{}"
619
+ result = self._extract_json(content)
620
+ final_nodes = result.get("node_list", [])
621
+ except Exception as e:
622
+ self._is_hf_exhausted(e)
623
+ hf_failed = True
624
+ app_logger.warning(f"HuggingFace tree search failed ({e}). Checking Groq fallback...")
625
+
626
+ if (self.hf_exhausted or hf_failed) and self.groq_client:
627
+ try:
628
+ app_logger.info(
629
+ f"[FALLBACK] Executing tree search via Groq ({self.groq_retrieval_model})"
630
+ )
631
+ groq_resp = await self.groq_client.chat.completions.create(
632
+ model=self.groq_retrieval_model,
633
+ messages=[{"role": "user", "content": prompt}],
634
+ temperature=0.1,
635
+ max_tokens=300,
636
+ )
637
+ content = groq_resp.choices[0].message.content or "{}"
638
+ result = self._extract_json(content)
639
+ final_nodes = result.get("node_list", [])
640
+ target_model = f"{self.groq_retrieval_model} (Groq)"
641
+ except Exception as ge:
642
+ app_logger.error(f"Groq fallback tree search failed: {ge}", exc_info=True)
643
+ final_nodes = []
644
 
645
  t_search = time.monotonic() - t_search_start
646
  app_logger.info(
 
730
 
731
  effective_doc_name = doc_name or "Uploaded Document"
732
 
733
+ system_prompt = f"""You are Atlas, a production-grade document intelligence assistant operating inside a Retrieval-Augmented Generation (RAG) system.
734
+ Your name is Atlas. When asked about your identity, name, or purpose, introduce yourself clearly as Atlas, the Document Intelligence research assistant.
735
 
736
  Your job is to answer the user's question using the supplied document evidence and conversation context while minimizing hallucinations and preserving factual accuracy.
737
 
 
1081
  t_gen_start = time.monotonic()
1082
  t_first_token = None
1083
  total_chars = 0
1084
+ hf_stream_failed = False
1085
 
1086
+ if not self.hf_exhausted:
1087
+ try:
1088
+ stream = await self.client.chat_completion(
1089
+ messages=messages,
1090
+ model=target_model,
1091
+ temperature=0.2,
1092
+ max_tokens=max_out_tokens,
1093
+ stream=True,
1094
+ )
1095
 
1096
+ async for chunk in stream:
1097
+ if chunk.choices and len(chunk.choices) > 0:
1098
+ delta = chunk.choices[0].delta.content
1099
+ if delta is not None:
1100
+ if t_first_token is None:
1101
+ t_first_token = time.monotonic()
1102
+ ttft = t_first_token - t_gen_start
1103
+ app_logger.info(f"[PERF] answer_generation TTFT={ttft:.2f}s model={target_model} max_tokens={max_out_tokens}")
1104
+ total_chars += len(delta)
1105
+ yield delta
1106
+
1107
+ t_gen_end = time.monotonic()
1108
+ ttft = (t_first_token - t_gen_start) if t_first_token else 0
1109
+ gen_duration = (t_gen_end - t_first_token) if t_first_token else 0
1110
+ total_duration = t_gen_end - t_gen_start
1111
+ context_chars = len(context_msg)
1112
+ context_tokens_est = context_chars // 4
1113
+ app_logger.info(
1114
+ f"[PERF] answer_generation total={total_duration:.2f}s TTFT={ttft:.2f}s "
1115
+ f"generation={gen_duration:.2f}s output_chars={total_chars} "
1116
+ f"context_chars={context_chars} context_tokens_est={context_tokens_est} nodes={len(nodes)}"
1117
+ )
1118
+ return
1119
+ except Exception as e:
1120
+ self._is_hf_exhausted(e)
1121
+ hf_stream_failed = True
1122
+ app_logger.warning(f"HuggingFace stream failed ({e}). Checking Groq fallback...")
1123
+
1124
+ # Automatic Groq fallback
1125
+ if (self.hf_exhausted or hf_stream_failed) and self.groq_client:
1126
+ try:
1127
+ app_logger.info(f"[FALLBACK] Generating answer stream via Groq ({self.groq_chat_model})")
1128
+ groq_stream = await self.groq_client.chat.completions.create(
1129
+ model=self.groq_chat_model,
1130
+ messages=messages,
1131
+ temperature=0.2,
1132
+ max_tokens=max_out_tokens,
1133
+ stream=True,
1134
+ )
1135
+ async for chunk in groq_stream:
1136
+ if chunk.choices and len(chunk.choices) > 0:
1137
+ delta = chunk.choices[0].delta.content
1138
+ if delta is not None:
1139
+ if t_first_token is None:
1140
+ t_first_token = time.monotonic()
1141
+ ttft = t_first_token - t_gen_start
1142
+ app_logger.info(f"[PERF-GROQ] answer_generation TTFT={ttft:.2f}s model={self.groq_chat_model}")
1143
+ total_chars += len(delta)
1144
+ yield delta
1145
+
1146
+ t_gen_end = time.monotonic()
1147
+ ttft = (t_first_token - t_gen_start) if t_first_token else 0
1148
+ gen_duration = (t_gen_end - t_first_token) if t_first_token else 0
1149
+ total_duration = t_gen_end - t_gen_start
1150
+ app_logger.info(
1151
+ f"[PERF-GROQ] answer_generation total={total_duration:.2f}s TTFT={ttft:.2f}s "
1152
+ f"generation={gen_duration:.2f}s output_chars={total_chars} model={self.groq_chat_model}"
1153
+ )
1154
+ return
1155
+ except Exception as ge:
1156
+ app_logger.error(f"Groq fallback stream also failed: {ge}", exc_info=True)
1157
+
1158
+ is_arabic = any("\u0600" <= c <= "\u06ff" for c in query)
1159
+ err_msg = (
1160
+ "عذراً، حدث خطأ أثناء الاتصال بالنماذج. يرجى المحاولة لاحقاً."
1161
+ if is_arabic
1162
+ else "Sorry, an error occurred while connecting to model providers. Please try again."
1163
+ )
1164
+ yield err_msg
1165
 
1166
  async def generate_conversational_stream(
1167
  self,
 
1170
  model: Optional[str] = None,
1171
  ):
1172
  """Conversational stream when no document is attached yet.
1173
+ Uses the smaller/faster retrieval model for low-latency chat, with automatic Groq failover.
1174
  """
1175
  # Use chat_model (Qwen2.5-7B from HF_CHAT_MODEL env) — fast and capable for conversational use
1176
  target_model = model or self.chat_model
 
1185
  is_arabic_query = any("\u0600" <= c <= "\u06ff" for c in query)
1186
  lang_rule = "Respond in Arabic (فصحى طبيعية)." if is_arabic_query else "Respond in English."
1187
 
1188
+ system_prompt = f"""You are Atlas, a professional document intelligence and research assistant.
1189
+ Your name is Atlas (أطلس). When asked about your identity or name (e.g., "من أنت؟" or "ما اسمك؟" or "who are you?"), introduce yourself clearly as Atlas, a document intelligence and research assistant designed to understand documents and discover knowledge.
1190
  {lang_rule}
1191
  Always match the user's language.
1192
  Be concise and accurate. Never invent facts.
 
1200
  {"role": "user", "content": query},
1201
  ]
1202
 
1203
+ hf_stream_failed = False
1204
+ if not self.hf_exhausted:
1205
+ try:
1206
+ stream = await self.client.chat_completion(
1207
+ messages=messages,
1208
+ model=target_model,
1209
+ temperature=0.6,
1210
+ stream=True,
1211
+ )
1212
 
1213
+ async for chunk in stream:
1214
+ if chunk.choices and len(chunk.choices) > 0:
1215
+ delta = chunk.choices[0].delta.content
1216
+ if delta is not None:
1217
+ yield delta
1218
+ return
1219
+ except Exception as e:
1220
+ self._is_hf_exhausted(e)
1221
+ hf_stream_failed = True
1222
+ app_logger.warning(f"HF conversational stream failed ({e}). Checking Groq fallback...")
1223
+
1224
+ if (self.hf_exhausted or hf_stream_failed) and self.groq_client:
1225
+ try:
1226
+ app_logger.info(f"[FALLBACK] Conversational stream via Groq ({self.groq_chat_model})")
1227
+ groq_stream = await self.groq_client.chat.completions.create(
1228
+ model=self.groq_chat_model,
1229
+ messages=messages,
1230
+ temperature=0.6,
1231
+ stream=True,
1232
+ )
1233
+ async for chunk in groq_stream:
1234
+ if chunk.choices and len(chunk.choices) > 0:
1235
+ delta = chunk.choices[0].delta.content
1236
+ if delta is not None:
1237
+ yield delta
1238
+ return
1239
+ except Exception as ge:
1240
+ app_logger.error(f"Groq conversational fallback failed: {ge}", exc_info=True)
1241
+
1242
+ is_arabic = any("\u0600" <= c <= "\u06ff" for c in query)
1243
+ err_msg = (
1244
+ "عذراً، خدمة النماذج غير متاحة حالياً. يرجى المحاولة لاحقاً."
1245
+ if is_arabic
1246
+ else "Sorry, the model service is currently unreachable. Please try again."
1247
+ )
1248
+ yield err_msg
pyproject.toml CHANGED
@@ -7,6 +7,7 @@ requires-python = ">=3.11"
7
  dependencies = [
8
  "fastapi>=0.136.1",
9
  "huggingface-hub>=2.1.1",
 
10
  "pageindex>=0.2.8",
11
  "pymupdf>=1.27.2.3",
12
  "python-dotenv>=1.2.2",
 
7
  dependencies = [
8
  "fastapi>=0.136.1",
9
  "huggingface-hub>=2.1.1",
10
+ "openai>=2.36.0",
11
  "pageindex>=0.2.8",
12
  "pymupdf>=1.27.2.3",
13
  "python-dotenv>=1.2.2",
uv.lock CHANGED
@@ -463,6 +463,7 @@ source = { virtual = "." }
463
  dependencies = [
464
  { name = "fastapi" },
465
  { name = "huggingface-hub" },
 
466
  { name = "pageindex" },
467
  { name = "pymupdf" },
468
  { name = "python-dotenv" },
@@ -474,6 +475,7 @@ dependencies = [
474
  requires-dist = [
475
  { name = "fastapi", specifier = ">=0.136.1" },
476
  { name = "huggingface-hub", specifier = ">=2.1.1" },
 
477
  { name = "pageindex", specifier = ">=0.2.8" },
478
  { name = "pymupdf", specifier = ">=1.27.2.3" },
479
  { name = "python-dotenv", specifier = ">=1.2.2" },
 
463
  dependencies = [
464
  { name = "fastapi" },
465
  { name = "huggingface-hub" },
466
+ { name = "openai" },
467
  { name = "pageindex" },
468
  { name = "pymupdf" },
469
  { name = "python-dotenv" },
 
475
  requires-dist = [
476
  { name = "fastapi", specifier = ">=0.136.1" },
477
  { name = "huggingface-hub", specifier = ">=2.1.1" },
478
+ { name = "openai", specifier = ">=2.36.0" },
479
  { name = "pageindex", specifier = ">=0.2.8" },
480
  { name = "pymupdf", specifier = ">=1.27.2.3" },
481
  { name = "python-dotenv", specifier = ">=1.2.2" },