MuhammadMahmoud commited on
Commit
468ea61
·
1 Parent(s): 06bb2e7

enhance rag

Browse files
app/api/chat.py CHANGED
@@ -20,17 +20,10 @@ logger = logging.getLogger(__name__)
20
  @router.post("/chat", response_model=ChatResponse)
21
  async def chat_endpoint(payload: ChatRequest, request: Request):
22
  """
23
- Standard chat endpoint sends a message and returns the full response.
24
-
25
- - **message**: The user's text message.
26
- - **history**: Optional list of previous messages for context.
27
- - **mode**: Execution mode ('chat' for Q&A only, 'agent' for tool execution).
28
- Default: 'agent'
29
-
30
- Returns:
31
- - **response**: Text response from the assistant.
32
- - **history**: Updated conversation history.
33
- - **confirmation**: Pending confirmation request if risky tool needs user approval.
34
  """
35
  try:
36
  user_key = payload.session_id or "anonymous"
@@ -101,17 +94,10 @@ async def chat_endpoint(payload: ChatRequest, request: Request):
101
  )
102
  async def chat_stream(payload: ChatRequest, request: Request):
103
  """
104
- Streaming chat endpoint returns tokens as they arrive via Server-Sent Events (SSE).
105
- The user sees the response character-by-character (like ChatGPT), reducing perceived
106
- latency from ~4s to ~0.5s.
107
-
108
- - **message**: The user's text message.
109
- - **history**: Optional list of previous messages for context.
110
- - **mode**: Execution mode ('chat' for Q&A only, 'agent' for tool execution).
111
- Default: 'agent'
112
-
113
- Returns a `text/event-stream` response. Each chunk is formatted as `data: <text>\\n\\n`.
114
- The stream ends with `data: [DONE]\\n\\n`.
115
  """
116
  try:
117
  user_key = payload.session_id or "anonymous"
@@ -188,20 +174,10 @@ async def chat_confirm(
188
  approved: bool = Query(..., description="User decision: true to approve, false to reject"),
189
  ):
190
  """
191
- Confirm or reject a pending tool execution request.
192
-
193
- When a risky tool operation is detected (e.g., cancel_request), the system
194
- creates a confirmation request and returns it to the user. This endpoint allows
195
- the user to approve or reject the operation.
196
-
197
- Query Parameters:
198
- - **confirmation_id**: UUID of the pending confirmation request.
199
- - **approved**: Boolean - true to execute the tool, false to reject.
200
-
201
- Returns:
202
- - **status**: "approved" or "rejected"
203
- - **confirmation_id**: UUID of the confirmation
204
- - **message**: Status message in Arabic
205
  """
206
  try:
207
  confirmation_manager = get_confirmation_manager()
 
20
  @router.post("/chat", response_model=ChatResponse)
21
  async def chat_endpoint(payload: ChatRequest, request: Request):
22
  """
23
+ Processes standard text-based chat interactions returning a single synchronous response.
24
+ Expects a ChatRequest payload containing the user message, session ID, and chat mode.
25
+ Under the hood, it applies rate-limiting, RAG context retrieval, and multi-provider LLM processing.
26
+ Returns a ChatResponse encompassing the generated text, updated history, and optional tool confirmations.
 
 
 
 
 
 
 
27
  """
28
  try:
29
  user_key = payload.session_id or "anonymous"
 
94
  )
95
  async def chat_stream(payload: ChatRequest, request: Request):
96
  """
97
+ Handles streaming chat interactions utilizing Server-Sent Events (SSE) for low-latency responses.
98
+ Accepts the identical ChatRequest payload but yields generated text tokens in real-time.
99
+ Implements multi-tiered rate limiting and routes through the robust AI provider circuit-breaker.
100
+ Returns a sequential `text/event-stream` ending gracefully with a `data: [DONE]` signal.
 
 
 
 
 
 
 
101
  """
102
  try:
103
  user_key = payload.session_id or "anonymous"
 
174
  approved: bool = Query(..., description="User decision: true to approve, false to reject"),
175
  ):
176
  """
177
+ Processes mandatory user confirmations for sensitive tool operations triggered in AI Agent mode.
178
+ Requires a valid confirmation UUID and a boolean decision matrix (true to auto-approve, false to reject).
179
+ Validates expiration state, shifts the execution tracker, and securely unblocks backend tasks.
180
+ Returns a structured confirmation block containing the execution outcome and localized UI alerts.
 
 
 
 
 
 
 
 
 
 
181
  """
182
  try:
183
  confirmation_manager = get_confirmation_manager()
app/api/feedback.py CHANGED
@@ -31,13 +31,10 @@ class FeedbackResponse(BaseModel):
31
  @router.post("/feedback", response_model=FeedbackResponse)
32
  async def submit_feedback(data: FeedbackRequest):
33
  """
34
- Submit a correction to an AI prediction.
35
-
36
- - **prediction_id**: UUID from the original prediction response.
37
- - **original_prediction**: What the AI predicted (e.g. "Medium").
38
- - **corrected_prediction**: What it should have been (e.g. "High").
39
- - **corrected_by**: Optional employee identifier.
40
- - **reason**: Optional explanation for the correction.
41
  """
42
  feedback_id = str(uuid.uuid4())
43
  try:
@@ -58,8 +55,10 @@ async def submit_feedback(data: FeedbackRequest):
58
  @router.get("/feedback/summary")
59
  async def get_feedback_summary():
60
  """
61
- Get aggregated statistics about AI prediction corrections.
62
- Admin-only endpoint shows how often corrections are made and what patterns exist.
 
 
63
  """
64
  try:
65
  return feedback_store.get_summary()
 
31
  @router.post("/feedback", response_model=FeedbackResponse)
32
  async def submit_feedback(data: FeedbackRequest):
33
  """
34
+ Ingests explicit user feedback validating or rejecting prior AI-driven predictions.
35
+ Receives payloads mapping the original AI output alongside the human correction footprint.
36
+ Persists evaluation data chronologically into the structured feedback reporting store.
37
+ Returns a standard confirmation receipt bridging data gaps between AI logic and human oversight.
 
 
 
38
  """
39
  feedback_id = str(uuid.uuid4())
40
  try:
 
55
  @router.get("/feedback/summary")
56
  async def get_feedback_summary():
57
  """
58
+ Aggregates accumulated AI prediction corrections into high-level evaluative statistics.
59
+ Scans the feedback repository to compute drift metrics, common patterns, and accuracy rates.
60
+ Designed primarily for platform administrators establishing continuous LLM refinement loops.
61
+ Returns a structured dictionary mapping distinct models to their respective human correction summaries.
62
  """
63
  try:
64
  return feedback_store.get_summary()
app/api/health.py CHANGED
@@ -10,8 +10,10 @@ router = APIRouter(prefix="/health", tags=["health"])
10
  @router.get("/readiness")
11
  async def readiness():
12
  """
13
- Basic readiness check verifies critical env vars are configured.
14
- Returns 'ok' when the primary LLM (Groq) is configured.
 
 
15
  """
16
  checks = {
17
  "dotnet_configured": bool(settings.DOTNET_API_BASE_URL),
@@ -30,18 +32,10 @@ async def readiness():
30
  @router.get("/providers")
31
  async def provider_health():
32
  """
33
- Real-time LLM provider health snapshot.
34
-
35
- Returns the current state of every provider and model in the router:
36
- - available: True if the provider can accept requests right now
37
- - permanently_disabled: True if all models returned 404 (deprecated)
38
- - cooldown_remaining_s: Seconds until the provider exits its cooldown window
39
- - active_count / total_count: How many providers are currently usable
40
-
41
- Use this endpoint to:
42
- - Set up uptime alerts (active_count == 0 → page on-call)
43
- - Debug fallback routing issues
44
- - Verify model registry after a deployment
45
  """
46
  # Import here to avoid circular imports at module load time
47
  from app.services.chat.api.llm_router import llm_router
 
10
  @router.get("/readiness")
11
  async def readiness():
12
  """
13
+ Validates essential environment configurations confirming baseline operational readiness.
14
+ Performs deterministic environment checks for active LLM keys, Redis, and Qdrant connections.
15
+ Dictates whether the gateway node should accept ingress traffic or flag as heavily degraded.
16
+ Returns a consolidated status string alongside boolean flags for active backend infrastructure elements.
17
  """
18
  checks = {
19
  "dotnet_configured": bool(settings.DOTNET_API_BASE_URL),
 
32
  @router.get("/providers")
33
  async def provider_health():
34
  """
35
+ Exposes a real-time, circuit-breaker-aware snapshot of all active LLM upstream routing providers.
36
+ Aggregates connection health, permanent deprecation status, and active cooldown timeout thresholds.
37
+ Enables DevOps systems and administrative dashboards to monitor fallback degradation pipelines proactively.
38
+ Returns granular provider arrays mapped to their respective recovery timestamps and load configurations.
 
 
 
 
 
 
 
 
39
  """
40
  # Import here to avoid circular imports at module load time
41
  from app.services.chat.api.llm_router import llm_router
app/api/kb_admin.py CHANGED
@@ -1,7 +1,8 @@
1
- from fastapi import APIRouter, HTTPException
2
  from pydantic import BaseModel
3
 
4
  from app.services.rag.rag_engine import rag_engine
 
5
 
6
  router = APIRouter(prefix="/kb", tags=["kb-admin"])
7
 
@@ -13,18 +14,66 @@ class KbDocumentInput(BaseModel):
13
 
14
  @router.get("/search")
15
  async def search_kb(q: str, top_k: int = 3):
 
 
 
 
 
 
16
  return {"results": rag_engine.search(q, top_k=top_k)}
17
 
18
 
19
  @router.post("/documents")
20
  async def add_document(payload: KbDocumentInput):
 
 
 
 
 
 
21
  if not payload.title.strip() or not payload.content.strip():
22
  raise HTTPException(status_code=400, detail="title/content مطلوبين")
23
  return rag_engine.add_document(payload.title, payload.content)
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  @router.post("/refresh")
27
  async def refresh_kb():
 
 
 
 
 
 
28
  rag_engine.refresh_index()
29
  return {"refreshed": True}
30
 
 
1
+ from fastapi import APIRouter, HTTPException, Query
2
  from pydantic import BaseModel
3
 
4
  from app.services.rag.rag_engine import rag_engine
5
+ from app.services.rag.vector_store import vector_store
6
 
7
  router = APIRouter(prefix="/kb", tags=["kb-admin"])
8
 
 
14
 
15
  @router.get("/search")
16
  async def search_kb(q: str, top_k: int = 3):
17
+ """
18
+ Interrogates the underlying Retrieval-Augmented Generation (RAG) knowledge base for semantic similarities.
19
+ Accepts text queries and vector-embeds them utilizing the natively configured embedding parameters.
20
+ Retrieves the topmost relevant corporate document snippets utilizing optimized cosine similarity algorithms.
21
+ Returns a structured array of related textual contexts to synthesize or debug LLM contextual injections.
22
+ """
23
  return {"results": rag_engine.search(q, top_k=top_k)}
24
 
25
 
26
  @router.post("/documents")
27
  async def add_document(payload: KbDocumentInput):
28
+ """
29
+ Ingests authoritative organizational documents permanently embedding them into the vector index space.
30
+ Requires structured title and content pairings to partition distinct semantic knowledge representations.
31
+ Executes automated embedding generation sequences to populate the local or remote semantic structures.
32
+ Returns boolean confirmation markers signaling the successful propagation and indexing of the data chunks.
33
+ """
34
  if not payload.title.strip() or not payload.content.strip():
35
  raise HTTPException(status_code=400, detail="title/content مطلوبين")
36
  return rag_engine.add_document(payload.title, payload.content)
37
 
38
 
39
+ @router.get("/documents")
40
+ async def list_documents(limit: int = 50, offset: int = 0):
41
+ """
42
+ Lists documents currently indexed in the knowledge base.
43
+ Supports basic pagination via limit and offset parameters.
44
+ """
45
+ return {"documents": rag_engine.list_documents(limit=limit, offset=offset)}
46
+
47
+
48
+ @router.delete("/documents/{doc_id}")
49
+ async def delete_document(doc_id: str):
50
+ """
51
+ Removes a document from the active vector index.
52
+ Requires the unique UUID of the target document.
53
+ """
54
+ success = rag_engine.delete_document(doc_id)
55
+ if not success:
56
+ raise HTTPException(status_code=404, detail="Document not found or could not be deleted")
57
+ return {"deleted": True, "id": doc_id}
58
+
59
+
60
+ @router.get("/stats")
61
+ async def kb_stats():
62
+ """
63
+ Returns operational statistics about the underlying vector database.
64
+ Useful for health checks and capacity monitoring.
65
+ """
66
+ return {"vector_store": vector_store.get_collection_info()}
67
+
68
+
69
  @router.post("/refresh")
70
  async def refresh_kb():
71
+ """
72
+ Forces a synchronous refresh operation against the local disk storage or remote vector representation indexes.
73
+ Commands the RAG singleton engine to reload memory-mapped chunks directly from persistent state directory architectures.
74
+ Essential tool for ensuring high consistency following batched corporate knowledge base uploads via the admin portal.
75
+ Returns a simple acknowledgment flag dictating the flush cycle and data reloading methodologies have successfully terminated.
76
+ """
77
  rag_engine.refresh_index()
78
  return {"refreshed": True}
79
 
app/api/ocr.py CHANGED
@@ -22,13 +22,10 @@ async def process_ocr(
22
  document_type: DocumentType = Form(..., description="Type of the document being scanned"),
23
  ):
24
  """
25
- Process an Arabic document image and extract structured data as JSON.
26
-
27
- - **file**: The document image to scan (max 10 MB).
28
- - **document_type**: One of the supported document types (id_card, income_proof, etc.).
29
-
30
- The router automatically tries providers in order (Groq → Gemini → OpenAI → HuggingFace)
31
- and falls back if any provider hits a rate limit or error.
32
  """
33
  contents = await _read_and_validate(file)
34
  try:
@@ -50,16 +47,10 @@ async def process_ocr_with_analysis(
50
  document_type: DocumentType = Form(..., description="Type of the document being scanned"),
51
  ):
52
  """
53
- **OCR + LLM Analysis** Extract structured data from a document image,
54
- then run intelligent AI analysis to produce: summary, risk level (high/medium/low),
55
- severity score (0–100), key findings, and a recommendation for the case worker.
56
-
57
- Steps performed:
58
- 1. OCR extraction via multi-provider router (Groq → Gemini → OpenAI → HuggingFace)
59
- 2. LLM analysis via Groq Llama 3.3 70B
60
-
61
- - **file**: The document image to scan (max 10 MB).
62
- - **document_type**: One of the supported document types.
63
  """
64
  contents = await _read_and_validate(file)
65
  try:
 
22
  document_type: DocumentType = Form(..., description="Type of the document being scanned"),
23
  ):
24
  """
25
+ Processes official Arabic document images (e.g., IDs, proofs) to extract raw text logic.
26
+ Accepts standard image formats (PNG, JPG) routing them through a priority-based Vision AI chain.
27
+ Automatically handles provider rotation and fallback degradation mechanisms ensuring high availability.
28
+ Returns the processed string output alongside the identifier indicating the successful extraction engine.
 
 
 
29
  """
30
  contents = await _read_and_validate(file)
31
  try:
 
47
  document_type: DocumentType = Form(..., description="Type of the document being scanned"),
48
  ):
49
  """
50
+ Executes a multi-stage structured extraction paired with semantic risk assessment on Arabic uploads.
51
+ Initiates base OCR extractions which cleanly filter into a specialized Llama 3.3 70B analytical pipeline.
52
+ Synthesizes raw text blocks into concise bullet findings, hierarchical summaries, and relative risk severities.
53
+ Outputs comprehensive JSON formatting marrying granular text data with executive case worker recommendations.
 
 
 
 
 
 
54
  """
55
  contents = await _read_and_validate(file)
56
  try:
app/api/prediction.py CHANGED
@@ -24,10 +24,10 @@ logger = logging.getLogger(__name__)
24
  @router.post("/need-level", response_model=NeedLevelResponse)
25
  async def predict_need_level(request: Request, data: NeedLevelRequest):
26
  """
27
- Assess the need level of a family's assistance request.
28
-
29
- Uses rule-based guardrails first (for extreme cases), then falls back
30
- to the ML model for nuanced classification.
31
  """
32
  prediction_id = str(uuid.uuid4())
33
  start_time = time.perf_counter()
@@ -115,10 +115,10 @@ async def predict_need_level(request: Request, data: NeedLevelRequest):
115
  @router.post("/assistance-type", response_model=AssistanceTypeResponse)
116
  async def classify_assistance(request: Request, data: NeedLevelRequest):
117
  """
118
- Classify the type of assistance needed.
119
-
120
- Uses deterministic rules first as guardrails, then falls back
121
- to an ML model for nuanced classification if available.
122
  """
123
  prediction_id = str(uuid.uuid4())
124
  start_time = time.perf_counter()
 
24
  @router.post("/need-level", response_model=NeedLevelResponse)
25
  async def predict_need_level(request: Request, data: NeedLevelRequest):
26
  """
27
+ Evaluates the urgency and need score of an assistance request using a hybrid ML approach.
28
+ Expects structured financial and demographic data mapped to a standard NeedLevelRequest model.
29
+ Instantly applies deterministic guardrails before deferring to the XGBoost risk scoring engine.
30
+ Retruns the categorized need level, confidence percentiles, and optional SHAP feature explanations.
31
  """
32
  prediction_id = str(uuid.uuid4())
33
  start_time = time.perf_counter()
 
115
  @router.post("/assistance-type", response_model=AssistanceTypeResponse)
116
  async def classify_assistance(request: Request, data: NeedLevelRequest):
117
  """
118
+ Classifies the most appropriate category of demographic assistance necessary for an applicant.
119
+ Ingests core family metrics to evaluate rigid deterministic rules (e.g., medical extremity).
120
+ Falls back transparently to a dedicated classification model capturing advanced socioeconomic contexts.
121
+ Returns the recommended assistance string definition, boolean rule triggers, and analytical confidence.
122
  """
123
  prediction_id = str(uuid.uuid4())
124
  start_time = time.perf_counter()
app/api/voice.py CHANGED
@@ -29,16 +29,10 @@ async def voice_chat(
29
  access_token: Optional[str] = Form(None, description="JWT Bearer token from .NET"),
30
  ):
31
  """
32
- **Voice AI Chat** Send audio, receive transcription + AI response.
33
-
34
- Pipeline:
35
- 1. Audio file Groq Whisper Transcribed Arabic text
36
- 2. Text → Chat Engine (FAQ → Cache → Groq Llama 3.3 70B)
37
- 3. Returns: transcription + AI response + conversation history
38
-
39
- **Supported formats:** mp3, wav, webm, m4a, ogg (max 25 MB)
40
-
41
- **Session memory:** Pass `session_id` to maintain conversation context across calls.
42
  """
43
  # ── Guard: Rate Limits ──
44
  client_ip = getattr(request, "client", None)
@@ -136,21 +130,10 @@ async def voice_stream(
136
  access_token: Optional[str] = Form(None, description="JWT Bearer token from .NET"),
137
  ):
138
  """
139
- **Streaming Voice AI Chat** Send audio, receive transcription immediately then
140
- stream the AI response token-by-token (like ChatGPT), reducing perceived latency.
141
-
142
- Pipeline:
143
- 1. Audio file → Groq Whisper → Transcribed text
144
- 2. **First SSE event** immediately sends the transcription so the UI can show it.
145
- 3. Text → Chat Engine → Groq Llama 3.3 70B (streamed token-by-token)
146
- 4. Stream ends with `data: [DONE]`
147
-
148
- **Event format:**
149
- - Transcription event: `data: {"type": "transcription", "text": "..."}\\n\\n`
150
- - Text chunk event: `data: {"type": "chunk", "text": "..."}\\n\\n`
151
- - Done event: `data: [DONE]\\n\\n`
152
-
153
- **Supported formats:** mp3, wav, webm, m4a, ogg (max 25 MB)
154
  """
155
  # ── Guard: Rate Limits ──
156
  client_ip = getattr(request, "client", None)
 
29
  access_token: Optional[str] = Form(None, description="JWT Bearer token from .NET"),
30
  ):
31
  """
32
+ End-to-end voice processing pipeline orchestrating internal Speech-to-Text inference and intelligent chat logic.
33
+ Accepts robust audio file uploads (mp3, wav, etc.) and performs latency-optimized Whisper transcriptions.
34
+ Feeds the transcribed Arabic text directly into the chat engine along with existing session context.
35
+ Outputs a consolidated VoiceResponse detailing the text transcription, the AI reply, and history trails.
 
 
 
 
 
 
36
  """
37
  # ── Guard: Rate Limits ──
38
  client_ip = getattr(request, "client", None)
 
130
  access_token: Optional[str] = Form(None, description="JWT Bearer token from .NET"),
131
  ):
132
  """
133
+ Low-latency streaming voice pipeline bridging Whisper transcription workflows with real-time SSE token events.
134
+ Synchronously transcribes the audio payload and immediately pushes the recognized text to the client UI.
135
+ Subsequently streams the LLM's dynamically generated conversational response token-by-token directly.
136
+ Inherits all core chat fallback integrations, ensuring high availability even during backend provider stress.
 
 
 
 
 
 
 
 
 
 
 
137
  """
138
  # ── Guard: Rate Limits ──
139
  client_ip = getattr(request, "client", None)
app/core/__pycache__/config.cpython-313.pyc CHANGED
Binary files a/app/core/__pycache__/config.cpython-313.pyc and b/app/core/__pycache__/config.cpython-313.pyc differ
 
app/core/config.py CHANGED
@@ -28,6 +28,8 @@ class Settings(BaseSettings):
28
  HUGGINGFACE_API_KEY: str = ""
29
  OPENROUTER_API_KEY: str = ""
30
  HF_EMBED_MODEL: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
 
 
31
 
32
  # Chat Settings (Groq — 14,400 req/day free)
33
  GROQ_API_KEY: str = ""
 
28
  HUGGINGFACE_API_KEY: str = ""
29
  OPENROUTER_API_KEY: str = ""
30
  HF_EMBED_MODEL: str = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
31
+ EMBEDDING_MODE: str = "local" # "local" (sentence-transformers) or "api" (HuggingFace Inference)
32
+ EMBEDDING_DIM: int = 384 # Dimension of the embedding model
33
 
34
  # Chat Settings (Groq — 14,400 req/day free)
35
  GROQ_API_KEY: str = ""
app/services/chat/chat_engine.py CHANGED
@@ -116,12 +116,6 @@ class ChatEngine:
116
  msg = "لقد أجبتك على هذا السؤال للتو! 😊\nهل هناك شيء محدد لم يكن واضحاً في إجابتي السابقة؟ أنا هنا لمساعدتك."
117
  return msg, cleaned_message, list(history or [])
118
 
119
- # FAQ Pre-Check
120
- faq_hit = find_faq_answer(cleaned_message)
121
- if faq_hit:
122
- logger.info("FAQ hit for: %r — skipping API call", cleaned_message[:40])
123
- return faq_hit, cleaned_message, list(history or [])
124
-
125
  # Cache Pre-Check
126
  cache_key = get_cache_key(cleaned_message, history)
127
  cached_response = get_cached_response(cache_key)
@@ -188,6 +182,14 @@ class ChatEngine:
188
  from app.services.rag.rag_engine import rag_engine
189
  rag_context = rag_engine.get_context(cleaned_message)
190
 
 
 
 
 
 
 
 
 
191
  messages = self._build_messages(cleaned_message, history, rag_context=rag_context)
192
  cache_key = get_cache_key(cleaned_message, history)
193
 
@@ -236,6 +238,16 @@ class ChatEngine:
236
 
237
  from app.services.rag.rag_engine import rag_engine
238
  rag_context = rag_engine.get_context(cleaned_message)
 
 
 
 
 
 
 
 
 
 
239
 
240
  messages = self._build_messages(cleaned_message, history, rag_context=rag_context)
241
  cache_key = get_cache_key(cleaned_message, history)
 
116
  msg = "لقد أجبتك على هذا السؤال للتو! 😊\nهل هناك شيء محدد لم يكن واضحاً في إجابتي السابقة؟ أنا هنا لمساعدتك."
117
  return msg, cleaned_message, list(history or [])
118
 
 
 
 
 
 
 
119
  # Cache Pre-Check
120
  cache_key = get_cache_key(cleaned_message, history)
121
  cached_response = get_cached_response(cache_key)
 
182
  from app.services.rag.rag_engine import rag_engine
183
  rag_context = rag_engine.get_context(cleaned_message)
184
 
185
+ # Priority: RAG > FAQ
186
+ if not rag_context:
187
+ faq_hit = find_faq_answer(cleaned_message)
188
+ if faq_hit:
189
+ logger.info("FAQ hit for: %r — skipping API call", cleaned_message[:40])
190
+ updated_history = self._update_history(history, message, faq_hit, session_id)
191
+ return faq_hit, updated_history, None
192
+
193
  messages = self._build_messages(cleaned_message, history, rag_context=rag_context)
194
  cache_key = get_cache_key(cleaned_message, history)
195
 
 
238
 
239
  from app.services.rag.rag_engine import rag_engine
240
  rag_context = rag_engine.get_context(cleaned_message)
241
+
242
+ # Priority: RAG > FAQ
243
+ if not rag_context:
244
+ faq_hit = find_faq_answer(cleaned_message)
245
+ if faq_hit:
246
+ logger.info("FAQ hit for: %r — skipping API call in stream", cleaned_message[:40])
247
+ self._update_history(history, message, faq_hit, session_id)
248
+ yield f"data: {json.dumps(faq_hit, ensure_ascii=False)}\n\n"
249
+ yield "data: [DONE]\n\n"
250
+ return
251
 
252
  messages = self._build_messages(cleaned_message, history, rag_context=rag_context)
253
  cache_key = get_cache_key(cleaned_message, history)
app/services/rag/README.md CHANGED
@@ -1,20 +1,34 @@
1
  # خدمة البحث المعزز بالاسترجاع (RAG)
2
 
3
- محرك هجين يجمع بين بحث متجهات (Qdrant + HuggingFace Embeddings) وFallback سريع بـ TF‑IDF محلي.
 
 
 
 
 
 
 
4
 
5
  ## كيف يعمل؟
6
- - يبني مصفوفة TF‑IDF من `knowledge_base.py` عند التهيئة.
7
- - يحاول تفعيل Qdrant إذا توفرت (`QDRANT_URL` + `HUGGINGFACE_API_KEY`)، وإلا يستخدم TF‑IDF فقط.
8
- - `search(query)`: يفضّل المتجهات، ويعود إلى TFIDF عند الفشل أو غياب المفاتيح.
9
- - `get_context(query)`: يجمع أفضل النتائج في نص واحد يحقن داخل Prompt الشات.
10
- - `add_document` و`refresh_index`: لإضافة/إعادة بناء الفهارس.
11
-
12
- ## الاعتمادات
13
- - مفاتيح: `QDRANT_URL`, `QDRANT_API_KEY`, `HUGGINGFACE_API_KEY`, `HF_EMBED_MODEL`, `QDRANT_COLLECTION_NAME`.
14
- - مكتبات: `qdrant-client`, `httpx`, `scikit-learn`.
15
-
16
- ## تحسينات مقترحة
17
- - توسيع قاعدة المعرفة بـ 10–15 مقالة إضافية تغطي السياسات والأسئلة المتكررة (P1).
18
- - إضافة واجهة إدارة بسيطة (لوحة أو Endpoint) لإدخال/تعديل المقالات مع صلاحيات.
19
- - تخزين نتائج البحث في Cache قصير لتحسين زمن الاستجابة لأسئلة متكررة.
20
- - تسجيل درجات التشابه في `audit_log` لتحليل جودة الاسترجاع بمرور الوقت.
 
 
 
 
 
 
 
 
1
  # خدمة البحث المعزز بالاسترجاع (RAG)
2
 
3
+ محرك هجين يجمع بين بحث متجهات متقدم (Qdrant + Local Embeddings) وFallback سريع بـ TF‑IDF محلي.
4
+
5
+ ## البنية المعمارية الجديدة
6
+
7
+ تم ترقية النظام من (TF-IDF + RAM) إلى نظام إنتاجي متكامل:
8
+ - **Vector DB:** يستخدم Qdrant لتخزين دائم ومستمر للبيانات.
9
+ - **Embeddings:** يعتمد على نموذج `sentence-transformers` محلي (`paraphrase-multilingual-MiniLM-L12-v2`) بـ 384 بعد بسرعة عالية (بدون انتظار API خارجي).
10
+ - **Fallback:** في حال توقف Qdrant، يعود النظام فوراً لاستخدام الـ TF-IDF الموجود بالذاكرة لضمان عدم توقف الخدمة.
11
 
12
  ## كيف يعمل؟
13
+ - عند بدء التشغيل، يتم محاولة تحميل نموذج الـ Embeddings وفتح اتصال بقاعدة بيانات Qdrant.
14
+ - إذا كانت قاعدة البيانات فارغة، سيتم تلقائياً تزويدها بالبيانات الأساسية الموجودة في `knowledge_base.py`.
15
+ - `search(query)`: يفضل البحث باستخدام المتجهات أولاً، ويعود إلى TF-IDF كخيار احتياطي.
16
+ - `get_context(query)`: يجمع أفضل النتائج لتغذية الـ LLM بالمعلومات الدقيقة.
17
+
18
+ ## الاعتمادات المطلوبة
19
+ - `qdrant-client` للاتصال بقاعدة البيانات.
20
+ - `sentence-transformers` و `torch` لعملية الـ Embedding محلياً.
21
+
22
+ ## إدارة المعرفة (KB Admin API)
23
+ تم توفير مسارات متكاملة للتحكم بقاعدة المعرفة (تستمر البيانات بعد إعادة التشغيل):
24
+ 1. `GET /api/kb/documents` : استعراض المقالات الموجودة.
25
+ 2. `POST /api/kb/documents`: إضافة مقالة جديدة يتم تشفيرها وإضافتها لـ Qdrant والـ Fallback معاً.
26
+ 3. `DELETE /api/kb/documents/{id}`: مسح مقالة.
27
+ 4. `GET /api/kb/stats`: معرفة إحصائيات قاعدة البيانات (لغرض الصحة والمراقبة).
28
+
29
+ ## التشغيل
30
+ يرجى استخدام Docker Compose لتشغيل التطبيق و Qdrant معاً:
31
+ ```bash
32
+ docker-compose up -d
33
+ ```
34
+
app/services/rag/__pycache__/knowledge_base.cpython-313.pyc CHANGED
Binary files a/app/services/rag/__pycache__/knowledge_base.cpython-313.pyc and b/app/services/rag/__pycache__/knowledge_base.cpython-313.pyc differ
 
app/services/rag/__pycache__/rag_engine.cpython-313.pyc CHANGED
Binary files a/app/services/rag/__pycache__/rag_engine.cpython-313.pyc and b/app/services/rag/__pycache__/rag_engine.cpython-313.pyc differ
 
app/services/rag/embedder.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Embedding Service — Generates vector embeddings for text.
3
+
4
+ Primary: Local `sentence-transformers` model (zero-latency, no rate limits).
5
+ Fallback: HuggingFace Inference API (HTTP) if local model fails to load.
6
+
7
+ The model is loaded once at startup and reused for all embedding requests.
8
+ """
9
+
10
+ import logging
11
+ from typing import List, Optional
12
+
13
+ import httpx
14
+
15
+ from app.core.config import settings
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ── Lazy imports for sentence-transformers (heavy dependency) ────────────────
20
+ _local_model = None
21
+ _local_model_loaded = False
22
+
23
+
24
+ def _load_local_model():
25
+ """Load the sentence-transformers model into memory (one-time)."""
26
+ global _local_model, _local_model_loaded
27
+ if _local_model_loaded:
28
+ return _local_model
29
+ try:
30
+ from sentence_transformers import SentenceTransformer
31
+
32
+ model_name = settings.HF_EMBED_MODEL
33
+ logger.info("Loading local embedding model: %s ...", model_name)
34
+ _local_model = SentenceTransformer(model_name)
35
+ _local_model_loaded = True
36
+ logger.info("Local embedding model loaded successfully (dim=%d).", _local_model.get_sentence_embedding_dimension())
37
+ return _local_model
38
+ except Exception as exc:
39
+ logger.warning("Failed to load local embedding model: %s — will use API fallback.", exc)
40
+ _local_model_loaded = True # Don't retry on every call
41
+ _local_model = None
42
+ return None
43
+
44
+
45
+ class Embedder:
46
+ """Singleton embedding service with local-first, API-fallback strategy."""
47
+
48
+ def __init__(self):
49
+ self._mode = settings.EMBEDDING_MODE # "local" or "api"
50
+ self._dim = settings.EMBEDDING_DIM
51
+ self._model = None
52
+ self._ready = False
53
+
54
+ # ── Lifecycle ────────────────────────────────────────────────────────────
55
+
56
+ def initialize(self):
57
+ """Load model on startup. Call from app lifespan."""
58
+ if self._mode == "local":
59
+ self._model = _load_local_model()
60
+ if self._model:
61
+ self._ready = True
62
+ self._dim = self._model.get_sentence_embedding_dimension()
63
+ else:
64
+ logger.warning("Local model unavailable, embedding will attempt API fallback.")
65
+ elif self._mode == "api":
66
+ if settings.HUGGINGFACE_API_KEY:
67
+ self._ready = True
68
+ logger.info("Embedding mode: HuggingFace Inference API.")
69
+ else:
70
+ logger.warning("API mode selected but HUGGINGFACE_API_KEY is not set.")
71
+ else:
72
+ logger.warning("Unknown EMBEDDING_MODE: '%s'. Disabling embeddings.", self._mode)
73
+
74
+ @property
75
+ def is_ready(self) -> bool:
76
+ return self._ready
77
+
78
+ @property
79
+ def dimension(self) -> int:
80
+ return self._dim
81
+
82
+ # ── Single Text ──────────────────────────────────────────────────────────
83
+
84
+ def embed_text(self, text: str) -> Optional[List[float]]:
85
+ """
86
+ Generate embedding vector for a single text string.
87
+ Returns None on failure (caller should fallback to TF-IDF).
88
+ """
89
+ if not text or not text.strip():
90
+ return None
91
+
92
+ # Try local model first
93
+ if self._model:
94
+ return self._embed_local(text)
95
+
96
+ # Fallback to API
97
+ if settings.HUGGINGFACE_API_KEY:
98
+ return self._embed_api(text)
99
+
100
+ return None
101
+
102
+ # ── Batch ────────────────────────────────────────────────────────────────
103
+
104
+ def embed_batch(self, texts: List[str]) -> List[Optional[List[float]]]:
105
+ """
106
+ Generate embeddings for a batch of texts.
107
+ Returns a list of vectors (None entries for failures).
108
+ """
109
+ if not texts:
110
+ return []
111
+
112
+ # Local batch (efficient — single forward pass)
113
+ if self._model:
114
+ return self._embed_local_batch(texts)
115
+
116
+ # API fallback (one-by-one, slow but functional)
117
+ return [self._embed_api(t) if t and t.strip() else None for t in texts]
118
+
119
+ # ── Private: Local ───────────────────────────────────────────────────────
120
+
121
+ def _embed_local(self, text: str) -> Optional[List[float]]:
122
+ try:
123
+ vector = self._model.encode(text, normalize_embeddings=True)
124
+ return vector.tolist()
125
+ except Exception as exc:
126
+ logger.warning("Local embedding failed for text: %s", exc)
127
+ # Try API fallback
128
+ if settings.HUGGINGFACE_API_KEY:
129
+ return self._embed_api(text)
130
+ return None
131
+
132
+ def _embed_local_batch(self, texts: List[str]) -> List[Optional[List[float]]]:
133
+ try:
134
+ vectors = self._model.encode(texts, normalize_embeddings=True, show_progress_bar=False)
135
+ return [v.tolist() for v in vectors]
136
+ except Exception as exc:
137
+ logger.warning("Local batch embedding failed: %s — falling back to single.", exc)
138
+ return [self._embed_local(t) for t in texts]
139
+
140
+ # ── Private: API ─────────────────────────────────────────────────────────
141
+
142
+ def _embed_api(self, text: str) -> Optional[List[float]]:
143
+ """HuggingFace Inference API fallback."""
144
+ try:
145
+ model_name = settings.HF_EMBED_MODEL
146
+ url = f"https://api-inference.huggingface.co/pipeline/feature-extraction/{model_name}"
147
+ headers = {"Authorization": f"Bearer {settings.HUGGINGFACE_API_KEY}"}
148
+ with httpx.Client(timeout=20.0) as client:
149
+ response = client.post(url, headers=headers, json={"inputs": text})
150
+ response.raise_for_status()
151
+ data = response.json()
152
+
153
+ # Handle different response shapes
154
+ if data and isinstance(data[0], list):
155
+ if data[0] and isinstance(data[0][0], list):
156
+ # 2D: average token vectors
157
+ token_vectors = data[0]
158
+ size = len(token_vectors[0])
159
+ pooled = [0.0] * size
160
+ for row in token_vectors:
161
+ for i, val in enumerate(row):
162
+ pooled[i] += float(val)
163
+ return [v / len(token_vectors) for v in pooled]
164
+ return [float(v) for v in data[0]]
165
+ if isinstance(data, list):
166
+ return [float(v) for v in data]
167
+ return None
168
+ except Exception as exc:
169
+ logger.warning("API embedding failed: %s", exc)
170
+ return None
171
+
172
+
173
+ # ── Singleton ────────────────────────────────────────────────────────────────
174
+ embedder = Embedder()
app/services/rag/knowledge_base.py CHANGED
@@ -5,27 +5,54 @@ Contains detailed policies, rules, and facts about the Awn platform.
5
 
6
  KNOWLEDGE_BASE_DATA = [
7
  {
8
- "title": "أنواع المساعدات ومعايير الأهلية",
9
- "content": "تقدم منصة عون عدة أنواع من المساعدات: 1. المساعدة الغذائية: تستهدف الأسر التي لا يتجاوز دخلها الشهري 1500 جنيه. 2. الدعم الطبي: مخصص للحالات التي تعاني من أمراض مزمنة أو إعاقات تتطلب علاجاً مستمراً. 3. مساعدة التعليم: للأسر التي لديها أطفال في سن الدراسة (أقل من 18 عاماً) ودخلها الشهري أقل من 3000 جنيه. 4. دعم السكن: للحالات التي تعاني من ظروف سكن غير ملائمة وتتطلب تدخلاً عاجلاً. 5. الدعم المالي العام: للحالات التي لا تندرج تحت الفئات السابقة وتعاني من أزمات طارئة أو ديون تتجاوز ضعف الدخل الشهري."
10
  },
11
  {
12
- "title": "سياسة التسجيل وأنواع الحسابات",
13
- "content": "تتيح المنصة تسجيل ثلاثة أنواع من الحسابات: 1. حساب أسرة (مستفيد): لتقديم طلبات الدعم، ويتطلب إدخال بيانات دقيقة عن الدخل وعدد الأفراد والحالة الصحية وإرفاق المستندات الداعمة. 2. حساب مانح: للأفراد أو الجهات الراغبة في تقديم الدعم المالي أو العيني للحالات المعروضة. يمكن للمانح تصفح الحالات واختيار من يدعمه مباشرة. 3. حساب منظمة: للجمعيات الخيرية والمؤسسات المعتمدة التي تقوم بدور الوسيط للتحقق من صحة بيانات الأسر ومراجعة الطلبات والزيارات الميدانية قبل عرض الطلبات للمانحين."
14
  },
15
  {
16
- "title": "دورة حياة طلب المساعدة",
17
- "content": "يمر طلب المساعدة بالخطوات التالية: 1. تقديم الطلب: تقوم الأسرة بتعبئة النموذج وإرفاق المستندات. 2. التقييم المبدئي: يستخدم نظام الذكاء الاصطناعي لتحديد مستوى الاحتياج الي، متوسط، منخفض) وتصنيف نوع المساعدة بناءً على البيانات. 3. المراجعة والتحقق: تقوم منظمة معتمدة بمراجعة الطلب والمستندات وفي بعض الحالات إجراء زيارة ميدانية للتأكد من استحقاق الأسرة. 4. النشر: بعد الموافقة، يُعرض الطلب للمانحين على المنصة بهوية مجهلة لحماية كرامة الأسرة. 5. الدعم: يقوم المانح باختيار الطلب وتقديم الدعم المطلوب."
18
  },
19
  {
20
- "title": "الوثائق والمستندات المطلوبة",
21
- "content": "لضمان سرعة معالجة الطلبات، يجب إرفاق المستندات التالية إن وجدت: 1. إثبات الهوية (بطاقة الرقم القومي أو جواز السفر). 2. إثبات الدخل (مفردات مرتب، معاش، أو إقرار بعدم وجود دخل ثابت). 3. وثائق داعمة (عقد إيجار السكن، روشتات طبية أو تقارير تثبت الحالة الصحية، إيصالات مرافق، أو شهادات ميلاد الأطفال للمساعدة التعليمية). يقوم النظام بتحليل هذه المستندات آلياً لاستخراج البيانات وتقييم مستوى المخاطرة."
22
  },
23
  {
24
- "title": "سياسة الخصوصية وأمان البيانات",
25
- "content": "تلتزم منصة عون التزاماً كاملاً بحماية خصوصية المستخدمين: 1. يتم تشفير جميع البيانات الشخصية والمستندات المرفوعة. 2. عند عرض طلبات المساعدة للمانحين، يتم إخفاء هوية الأسرة واستخدام أسماء مستعارة، وتكتفي المنصة بعرض تفاصيل الاحتياج فقط. 3. لا يتم مشاركة البيانات الحساسة إلا مع المنظمات المعتمدة لأغراض التحقق والمراجعة. 4. المنصة لا تحتفظ بأي بيانات بطاقات ائتمانية ولا تعالج التحويلات المالية داخلياً."
26
  },
27
  {
28
- "title": "آلية التواصل والدعم المباشر",
29
- "content": "بمجرد أن يقرر المانح دعم حالة معينة، تقوم المنصة بتوفير قناة تواصل آمنة أو تزويد المانح بطريقة لدعم الأسرة مباشرة (حسب رغبتهم ووفقاً لسياسة المنظمة المشرفة). المنصة لا تتقاضى أي عمولات ولا تتدخل في عملية تسليم المساعدات بين المانح والمستفيد لضمان وصول الدعم بالكامل لمستحقيه."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
  ]
 
 
 
 
5
 
6
  KNOWLEDGE_BASE_DATA = [
7
  {
8
+ "title": "تعريف منصة عون وأهدافها الأساسية",
9
+ "content": "منصة عون هي جسر رقمي آمن يربط بين الأسر المحتاجة، المنظمات الخيرية، والمانحين لتقديم الدعم بطريقة شفافة وفعالة. تهدف المنصة إلى تسهيل إيصال المساعدات لمستحقيها لحفظ كرامتهم، من خلال عرض الحالات ببيانات مجهّلة سماء مستعارة). المنصة لا تقوم بأي عمليات تحويل مالي أو خصم عمولات داخلياً، بل تقتصر على كونها أداة ربط وتوثيق لتسهيل التكافل الاجتماعي والتواصل المباشر."
10
  },
11
  {
12
+ "title": "هوية ومهام المساعد الذكي في منصة عون",
13
+ "content": "المساعد الذكي لمنصة عون هو نظام ذكاء اصطناعي تم برمجته رسمياً لخدمة مستخدمي المنصة على مدار الساعة. مهامه تشمل: 1. الاستعلام عن حالة الطلبات برقم الطلب. 2. توضيح المستندات المطلوبة لكل مساعدة. 3. التنبؤ الآلي بأهلية الأسرة للدعمناءً على الدخل وعدد الأفراد). 4. توفير إحصائيات ملخصة للطلبات. 5. تمكين المستخدم من تحديث بياناته الأساسية. 6. معالجة طلبات الإلغاء للطلبات الحالية."
14
  },
15
  {
16
+ "title": "أنواع الحسابات المتاحة وطريقة التسجيل",
17
+ "content": "تتيح منصة عون 3 أنواع من الحسابات: 1. حساب أسرة (مستفيد): لتقديم طلبات الدعم، ويتطلب إدخال بيانات دقيقة عن الدخل والحالة الصحية. 2. حساب مانح: للأفراد أو الجهات الراغبة في رؤية طلبات المساعدة وتقديم الدعم المالي أو العيني. 3. حساب منظمة: لجهات المجتمع المدني التي تقوم بمراجعة وتدقيق الطلبات والقيام بزيارات ميدانية. للتسجيل، يتم الدخول لصفحة التسجيل، اختيار نوع الحساب المنشود، وإدخال البريد الإلكتروني وكلمة المرور وتأكيد الحساب."
18
  },
19
  {
20
+ "title": "خطوات الاستفادة وتقديم طلب المساعدة",
21
+ "content": "لتقديم طلب مساعدة يجب اتباع الخطوات التالية: 1. تسجيل الدخول بحساب أسرة. 2. التوجه إلى لوحة التحكم ثم الضغط على إنشاء طلب جديد عبر مسار الواجهة المخصص. 3. تحديد نوع الطلب وكتابة وصف دقيق وكامل للحالة والاحتياج. 4. رفع المستندات الداعمة وتفاصيل الدخل والصحة. 5. إرسال الطلب. يتم تقييم الطلبات وتُدرج تحت حالة قيد المراجعة حتى تتولاها منظمة."
22
  },
23
  {
24
+ "title": "أنواع المساعدات ومعايير الأهلية المحددة",
25
+ "content": "تقدم منصة عون 5 أنواع محددة من الدعم: 1. المساعدة الغذائية: للأسر التي لا يتعدى دخلها الشهري 1500 جنيه. 2. الدعم الطبي: مخصص لمرضى الحالات المزمنة والإعاقات والمشاكل الصحية الطارئة. 3. الدعم التعليمي: للأسر التي تعول أطفالاً في سن التعليم (أقل من 18 عاماً) ولديها دخل لا يجاوز 3000 جنيه. 4. دعم السكن: للحالات التي تعاني من طرد، أو سكن غير آمن. 5. مساعدة مالية عامة: للديون الطارئة والأساسيات المتأخرة المستعصية."
26
  },
27
  {
28
+ "title": "سياسة التبرعات والتحويلات المالية المباشرة",
29
+ "content": "تمنع سياسة منصة عون استلام أو إجراء أي تحويلات مالية أو تبرعات عبر النظام نفسه. دور المنصة يقتصر فقط على عملية العرض والربط. إذا قرر المانح دعم أسرة معينة، يتم توفير آلية تواصل آمنة خارج البوابة المالية للمنصة لكي يتم تحويل المبلغ مباشرة إلى حساب المستفيد أو لمنظمة وسيطة، وبذلك نضمن وصول مبلغ التبرع بالكامل لمستحقيه."
30
+ },
31
+ {
32
+ "title": "دورة حياة الطلب ومدة المراجعة المتوقعة",
33
+ "content": "دورة حياة الطلب: تقد��م الطلب -> التقييم الآلي بالذكاء الاصطناعي -> المراجعة البشرية من المنظمات -> النشر للمانحين عبر أسماء مستعارة للحماية. مدة المراجعة ليست ثابتة بل تعتمد כلياً على مقدار اكتمال المستندات المرفوعة وشفافيتها. الطلبات المكتملة ذات التقارير الطبية وإثباتات الدخل الواضحة تخضع للقبول أسرع من غيرها."
34
+ },
35
+ {
36
+ "title": "الوثائق والمستندات الثبوتية الشائعة لجميع الطلبات",
37
+ "content": "لضمان اعتماد الطلب واستلام الدعم بسرعة، تنصح المنصة برفع: بطاقة الرقم القومي سارية، ومستند يثبت مستويات الدخل (كمفردات مرتب، معاش، أو إقرار بعدم العمل)، وإرفاق مستندات الحالة (مثل عقد إيجار موثق لدعم السكن، أو تقارير طبية معتمدة للدعم الطبي، أو بطاقات مدرسية وشهادات قيد لدعم التعليم). يقوم محرك OCR باستخراج النصوص آلياً من هذه الصور."
38
+ },
39
+ {
40
+ "title": "شروط أمان البيانات، التشفير، وحماية خصوصية المستخدمين",
41
+ "content": "المنصة مُلتزمة بحماية فائقة לلخصوصية. يتم تشفير كافة البيانات الشخصية. كما أن الطلبات عندما تُنشر للمانحين تُخفى كل البيانات المباشرة مثل البطاقات الشخصية والأسماء الحقيقية بتعويضها بأسماء مستعارة، حيث يظهر فقط ملخص معتمد وموثوق للحالة. بالإضافة لاعتماد نظام حماية يمنع أي تسريب لتفاصيل سكن العوائل المستفيدة."
42
+ },
43
+ {
44
+ "title": "تجاوز مشاكل الدخول واستعادة كلمات المرور",
45
+ "content": "إذا فقد أحد المستخدمين القدرة للوصول لحسابه، يمكنه ببساطة استعادة كلمة المرور عبر الضغط على 'نسيت كلمة المرور' في صفحة تسجيل الدخول، ليرسل له النظام رابطاً آمناً عبر بريده الإلكتروني يتيح له تعيين رقم سري جديد."
46
+ },
47
+ {
48
+ "title": "التواصل مع الدعم الفني لحل المشاكل والإشعارات التقنية",
49
+ "content": "توفر منصة عون قنوات للدعم والمساعدة. يمكن الإبلاغ عن الأعطال والمشكلات من خلال قسم 'الدعم' أو بالتواصل مع المساعد الذكي، والذي في حالة استشعاره لمشكلة فنية قوية أو فشل، يقوم تلقائياً بإنشاء بإنذار (Alert) وتصنيفه כـ مشكلة عاجلة للإسراع بحلها من فريق الدعم الهندسي للمنصة."
50
+ },
51
+ {
52
+ "title": "دور الذكاء الاصطناعي في التقييم المبدئي للحالات (AI Triage)",
53
+ "content": "تعتمد المنصة على نماذج Machine Learning لتقييم الاحتياج عند إرسال الطلب (عالي الأهمية، متوسط، منخفض)، ولتصنيف الحالة آلياً لاكتشاف الطوارئ (مثل الطرد من السكن). ورغم هذا التصنيف الذكي السريع، يظل القرار النهائي مرتبطاً بالمنظمة المراجعة."
54
  }
55
  ]
56
+
57
+ class SEED_VERSION:
58
+ version = "1.1.0" # Increment this to force re-seeding the Qdrant DB
app/services/rag/rag_engine.py CHANGED
@@ -1,124 +1,57 @@
1
  """
2
  Hybrid RAG Engine:
3
- - Primary: vector retrieval via Qdrant + HuggingFace embeddings
4
  - Fallback: local TF-IDF retrieval
5
  """
6
 
7
  import logging
8
- from typing import List, Optional
9
 
10
- import httpx
11
  from sklearn.feature_extraction.text import TfidfVectorizer
12
  from sklearn.metrics.pairwise import cosine_similarity
13
 
14
- from app.core.config import settings
15
- from app.services.rag.knowledge_base import KNOWLEDGE_BASE_DATA
16
 
17
  logger = logging.getLogger(__name__)
18
 
19
- try:
20
- from qdrant_client import QdrantClient
21
- from qdrant_client.models import Distance, PointStruct, VectorParams
22
- except Exception: # pragma: no cover
23
- QdrantClient = None
24
- Distance = None
25
- PointStruct = None
26
- VectorParams = None
27
-
28
 
29
  class RAGEngine:
30
  def __init__(self):
31
- self.collection_name = settings.QDRANT_COLLECTION_NAME
32
  self.documents = list(KNOWLEDGE_BASE_DATA)
33
  self.vectorizer = TfidfVectorizer()
34
  self.tfidf_matrix = None
35
  self._build_tfidf_fallback()
36
- self.qdrant_client: Optional[QdrantClient] = None
37
- self._vector_enabled = False
38
- self._init_vector_backend()
39
 
40
  def _build_tfidf_fallback(self):
 
41
  docs = [f"{item['title']} {item['content']}" for item in self.documents]
42
  if not docs:
43
  self.tfidf_matrix = None
44
  return
45
  self.tfidf_matrix = self.vectorizer.fit_transform(docs)
46
 
47
- def _init_vector_backend(self):
48
- if not QdrantClient or not settings.QDRANT_URL or not settings.HUGGINGFACE_API_KEY:
49
- logger.info("Vector RAG disabled, fallback TF-IDF is active.")
50
- return
51
- try:
52
- self.qdrant_client = QdrantClient(
53
- url=settings.QDRANT_URL,
54
- api_key=settings.QDRANT_API_KEY or None,
55
- timeout=8.0,
56
- )
57
- try:
58
- self.qdrant_client.get_collection(self.collection_name)
59
- except Exception:
60
- self.qdrant_client.create_collection(
61
- collection_name=self.collection_name,
62
- vectors_config=VectorParams(size=384, distance=Distance.COSINE),
63
- )
64
- self._seed_qdrant()
65
- self._vector_enabled = True
66
- logger.info("Vector RAG enabled with collection '%s'.", self.collection_name)
67
- except Exception as exc:
68
- logger.warning("Vector RAG init failed: %s. TF-IDF fallback stays active.", exc)
69
- self._vector_enabled = False
70
-
71
- def _embed(self, text: str) -> Optional[list[float]]:
72
- try:
73
- model_name = settings.HF_EMBED_MODEL
74
- url = f"https://api-inference.huggingface.co/pipeline/feature-extraction/{model_name}"
75
- headers = {"Authorization": f"Bearer {settings.HUGGINGFACE_API_KEY}"}
76
- with httpx.Client(timeout=20.0) as client:
77
- response = client.post(url, headers=headers, json={"inputs": text})
78
- response.raise_for_status()
79
- data = response.json()
80
- if data and isinstance(data[0], list):
81
- # average token vectors if 2D response
82
- if data and data[0] and isinstance(data[0][0], list):
83
- token_vectors = data[0]
84
- size = len(token_vectors[0])
85
- pooled = [0.0] * size
86
- for row in token_vectors:
87
- for i, val in enumerate(row):
88
- pooled[i] += float(val)
89
- return [v / len(token_vectors) for v in pooled]
90
- return [float(v) for v in data[0]]
91
- if isinstance(data, list):
92
- return [float(v) for v in data]
93
- return None
94
- except Exception as exc:
95
- logger.warning("Embedding failed, fallback to TF-IDF: %s", exc)
96
- return None
97
-
98
- def _seed_qdrant(self):
99
- if not self.qdrant_client:
100
  return
101
- points = []
102
- for idx, doc in enumerate(self.documents):
103
- emb = self._embed(f"{doc['title']} {doc['content']}")
104
- if not emb:
105
- continue
106
- points.append(
107
- PointStruct(
108
- id=idx,
109
- vector=emb,
110
- payload={"title": doc["title"], "content": doc["content"]},
111
- )
112
- )
113
- if points:
114
- self.qdrant_client.upsert(collection_name=self.collection_name, points=points)
115
 
116
- def _search_tfidf(self, query: str, top_k: int = 3, threshold: float = 0.15) -> List[dict]:
 
 
 
 
 
 
 
117
  if self.tfidf_matrix is None or not query.strip():
118
  return []
 
119
  query_vec = self.vectorizer.transform([query])
120
  similarities = cosine_similarity(query_vec, self.tfidf_matrix).flatten()
121
  top_indices = similarities.argsort()[-top_k:][::-1]
 
122
  results = []
123
  for idx in top_indices:
124
  score = float(similarities[idx])
@@ -128,64 +61,69 @@ class RAGEngine:
128
  "score": score,
129
  "title": self.documents[idx]["title"],
130
  "content": self.documents[idx]["content"],
 
131
  }
132
  )
133
  return results
134
 
135
- def search(self, query: str, top_k: int = 3, threshold: float = 0.20) -> List[dict]:
 
136
  if not query.strip():
137
  return []
138
- if self._vector_enabled and self.qdrant_client:
139
- embedding = self._embed(query)
140
- if embedding:
141
- try:
142
- points = self.qdrant_client.search(
143
- collection_name=self.collection_name,
144
- query_vector=embedding,
145
- limit=top_k,
146
- score_threshold=threshold,
147
- )
148
- return [
149
- {
150
- "score": float(p.score),
151
- "title": p.payload.get("title", ""),
152
- "content": p.payload.get("content", ""),
153
- }
154
- for p in points
155
- ]
156
- except Exception as exc:
157
- logger.warning("Vector search failed, using TF-IDF fallback: %s", exc)
158
  return self._search_tfidf(query, top_k=top_k, threshold=0.12)
159
 
160
  def get_context(self, query: str, top_k: int = 3) -> str:
 
161
  results = self.search(query, top_k=top_k)
162
  if not results:
163
  return ""
164
  return "\n\n".join([f"[{r['title']}]: {r['content']}" for r in results])
165
 
166
- def add_document(self, title: str, content: str) -> dict:
 
167
  doc = {"title": title.strip(), "content": content.strip()}
 
 
168
  self.documents.append(doc)
169
  self._build_tfidf_fallback()
170
- if self._vector_enabled and self.qdrant_client:
171
- emb = self._embed(f"{doc['title']} {doc['content']}")
172
- if emb:
173
- self.qdrant_client.upsert(
174
- collection_name=self.collection_name,
175
- points=[
176
- PointStruct(
177
- id=len(self.documents),
178
- vector=emb,
179
- payload=doc,
180
- )
181
- ],
182
- )
183
- return {"added": True, "count": len(self.documents)}
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  def refresh_index(self):
 
186
  self._build_tfidf_fallback()
187
- if self._vector_enabled and self.qdrant_client:
188
- self._seed_qdrant()
189
 
190
 
191
  rag_engine = RAGEngine()
 
1
  """
2
  Hybrid RAG Engine:
3
+ - Primary: vector retrieval via Qdrant (managed by vector_store)
4
  - Fallback: local TF-IDF retrieval
5
  """
6
 
7
  import logging
8
+ from typing import Dict, List, Any
9
 
 
10
  from sklearn.feature_extraction.text import TfidfVectorizer
11
  from sklearn.metrics.pairwise import cosine_similarity
12
 
13
+ from app.services.rag.knowledge_base import KNOWLEDGE_BASE_DATA, SEED_VERSION
14
+ from app.services.rag.vector_store import vector_store
15
 
16
  logger = logging.getLogger(__name__)
17
 
 
 
 
 
 
 
 
 
 
18
 
19
  class RAGEngine:
20
  def __init__(self):
 
21
  self.documents = list(KNOWLEDGE_BASE_DATA)
22
  self.vectorizer = TfidfVectorizer()
23
  self.tfidf_matrix = None
24
  self._build_tfidf_fallback()
 
 
 
25
 
26
  def _build_tfidf_fallback(self):
27
+ """Build the in-memory TF-IDF matrix from self.documents."""
28
  docs = [f"{item['title']} {item['content']}" for item in self.documents]
29
  if not docs:
30
  self.tfidf_matrix = None
31
  return
32
  self.tfidf_matrix = self.vectorizer.fit_transform(docs)
33
 
34
+ def initialize(self):
35
+ """Called at app startup. Seeds Qdrant if empty."""
36
+ if not vector_store.is_connected:
37
+ logger.info("Vector store not connected. RAG will use TF-IDF fallback only.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
+ # Check if we need to seed
41
+ if vector_store.collection_is_empty():
42
+ logger.info("Qdrant collection is empty. Seeding from knowledge base (version %s)...", getattr(SEED_VERSION, "version", "1"))
43
+ count = vector_store.upsert_documents_batch(self.documents, source="seed")
44
+ logger.info("Seeded %d documents into vector store.", count)
45
+
46
+ def _search_tfidf(self, query: str, top_k: int = 3, threshold: float = 0.15) -> List[Dict[str, Any]]:
47
+ """Fallback keyword search."""
48
  if self.tfidf_matrix is None or not query.strip():
49
  return []
50
+
51
  query_vec = self.vectorizer.transform([query])
52
  similarities = cosine_similarity(query_vec, self.tfidf_matrix).flatten()
53
  top_indices = similarities.argsort()[-top_k:][::-1]
54
+
55
  results = []
56
  for idx in top_indices:
57
  score = float(similarities[idx])
 
61
  "score": score,
62
  "title": self.documents[idx]["title"],
63
  "content": self.documents[idx]["content"],
64
+ "id": str(idx), # TF-IDF uses array index as ID
65
  }
66
  )
67
  return results
68
 
69
+ def search(self, query: str, top_k: int = 3, threshold: float = 0.20) -> List[Dict[str, Any]]:
70
+ """Hybrid search: Prefer vector search, fallback to TF-IDF."""
71
  if not query.strip():
72
  return []
73
+
74
+ if vector_store.is_connected:
75
+ results = vector_store.search(query, top_k=top_k, threshold=threshold)
76
+ if results:
77
+ return results
78
+ logger.debug("Vector search returned 0 results. Trying TF-IDF fallback.")
79
+
80
+ # Fallback
 
 
 
 
 
 
 
 
 
 
 
 
81
  return self._search_tfidf(query, top_k=top_k, threshold=0.12)
82
 
83
  def get_context(self, query: str, top_k: int = 3) -> str:
84
+ """Get formatted context string for LLM injection."""
85
  results = self.search(query, top_k=top_k)
86
  if not results:
87
  return ""
88
  return "\n\n".join([f"[{r['title']}]: {r['content']}" for r in results])
89
 
90
+ def add_document(self, title: str, content: str) -> Dict[str, Any]:
91
+ """Add a document to both vector store and local fallback block."""
92
  doc = {"title": title.strip(), "content": content.strip()}
93
+
94
+ # 1. Update fallback
95
  self.documents.append(doc)
96
  self._build_tfidf_fallback()
97
+
98
+ # 2. Update vector store
99
+ doc_id = None
100
+ if vector_store.is_connected:
101
+ doc_id = vector_store.upsert_document(title=doc["title"], content=doc["content"])
102
+
103
+ return {
104
+ "added": True,
105
+ "id": doc_id,
106
+ "vector_store": bool(doc_id),
107
+ "fallback_count": len(self.documents)
108
+ }
109
+
110
+ def delete_document(self, doc_id: str) -> bool:
111
+ """Delete from vector store. (Note: difficult to delete from TF-IDF list without ID tracking)"""
112
+ if vector_store.is_connected:
113
+ return vector_store.delete_document(doc_id)
114
+ return False
115
+
116
+ def list_documents(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
117
+ """List documents from vector store."""
118
+ if vector_store.is_connected:
119
+ return vector_store.list_documents(limit=limit, offset=offset)
120
+ # Fallback to local list (basic)
121
+ return [{"id": str(i), "title": d["title"], "content": d["content"]} for i, d in enumerate(self.documents[offset:offset+limit])]
122
 
123
  def refresh_index(self):
124
+ """Force rebuild of fallback and re-seed vector store if empty."""
125
  self._build_tfidf_fallback()
126
+ self.initialize()
 
127
 
128
 
129
  rag_engine = RAGEngine()
app/services/rag/vector_store.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Vector Store — Qdrant-backed persistent vector storage for the RAG knowledge base.
3
+
4
+ Manages the full lifecycle: connection, collection creation, document CRUD, and search.
5
+ Designed to be initialized during app startup and shared across requests.
6
+ """
7
+
8
+ import logging
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from app.core.config import settings
14
+ from app.services.rag.embedder import embedder
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ── Lazy imports (graceful degradation if qdrant-client not installed) ────────
19
+ try:
20
+ from qdrant_client import QdrantClient
21
+ from qdrant_client.models import (
22
+ Distance,
23
+ FieldCondition,
24
+ Filter,
25
+ MatchValue,
26
+ PointStruct,
27
+ VectorParams,
28
+ )
29
+
30
+ QDRANT_AVAILABLE = True
31
+ except ImportError:
32
+ QDRANT_AVAILABLE = False
33
+ QdrantClient = None
34
+ logger.warning("qdrant-client not installed — vector store disabled.")
35
+
36
+
37
+ class VectorStore:
38
+ """Persistent vector storage backed by Qdrant."""
39
+
40
+ def __init__(self):
41
+ self.client: Optional[Any] = None
42
+ self.collection_name = settings.QDRANT_COLLECTION_NAME
43
+ self._connected = False
44
+
45
+ # ── Lifecycle ────────────────────────────────────────────────────────────
46
+
47
+ def connect(self) -> bool:
48
+ """
49
+ Establish connection to Qdrant and ensure the collection exists.
50
+ Returns True if connected, False otherwise.
51
+ """
52
+ if not QDRANT_AVAILABLE:
53
+ logger.warning("qdrant-client not available — vector store disabled.")
54
+ return False
55
+
56
+ if not settings.QDRANT_URL:
57
+ logger.info("QDRANT_URL not configured — vector store disabled.")
58
+ return False
59
+
60
+ try:
61
+ self.client = QdrantClient(
62
+ url=settings.QDRANT_URL,
63
+ api_key=settings.QDRANT_API_KEY or None,
64
+ timeout=10.0,
65
+ )
66
+ # Verify connectivity
67
+ self.client.get_collections()
68
+ self._connected = True
69
+ logger.info("Connected to Qdrant at %s", settings.QDRANT_URL)
70
+
71
+ # Ensure collection exists
72
+ self._ensure_collection()
73
+ return True
74
+
75
+ except Exception as exc:
76
+ logger.warning("Failed to connect to Qdrant: %s — vector store disabled.", exc)
77
+ self.client = None
78
+ self._connected = False
79
+ return False
80
+
81
+ def disconnect(self):
82
+ """Clean up Qdrant client on shutdown."""
83
+ if self.client:
84
+ try:
85
+ self.client.close()
86
+ except Exception:
87
+ pass
88
+ self.client = None
89
+ self._connected = False
90
+ logger.info("Disconnected from Qdrant.")
91
+
92
+ @property
93
+ def is_connected(self) -> bool:
94
+ return self._connected and self.client is not None
95
+
96
+ # ── Collection Management ────────────────────────────────────────────────
97
+
98
+ def _ensure_collection(self):
99
+ """Create collection if it doesn't exist."""
100
+ try:
101
+ self.client.get_collection(self.collection_name)
102
+ logger.info("Qdrant collection '%s' already exists.", self.collection_name)
103
+ except Exception:
104
+ dim = embedder.dimension
105
+ self.client.create_collection(
106
+ collection_name=self.collection_name,
107
+ vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
108
+ )
109
+ logger.info("Created Qdrant collection '%s' (dim=%d, cosine).", self.collection_name, dim)
110
+
111
+ def get_collection_info(self) -> Dict[str, Any]:
112
+ """Return collection statistics for health/admin endpoints."""
113
+ if not self.is_connected:
114
+ return {"status": "disconnected"}
115
+ try:
116
+ info = self.client.get_collection(self.collection_name)
117
+ return {
118
+ "status": "connected",
119
+ "collection": self.collection_name,
120
+ "vectors_count": info.vectors_count,
121
+ "points_count": info.points_count,
122
+ "segments_count": len(info.segments) if hasattr(info, "segments") else None,
123
+ "disk_data_size": getattr(info, "disk_data_size", None),
124
+ }
125
+ except Exception as exc:
126
+ return {"status": "error", "detail": str(exc)}
127
+
128
+ def collection_is_empty(self) -> bool:
129
+ """Check whether the collection has zero points."""
130
+ if not self.is_connected:
131
+ return True
132
+ try:
133
+ info = self.client.get_collection(self.collection_name)
134
+ return (info.points_count or 0) == 0
135
+ except Exception:
136
+ return True
137
+
138
+ # ── Document CRUD ────────────────────────────────────────────────────────
139
+
140
+ def upsert_document(
141
+ self,
142
+ title: str,
143
+ content: str,
144
+ doc_id: Optional[str] = None,
145
+ metadata: Optional[Dict[str, Any]] = None,
146
+ ) -> Optional[str]:
147
+ """
148
+ Insert or update a single document.
149
+ Returns the document ID (UUID string) on success, None on failure.
150
+ """
151
+ if not self.is_connected:
152
+ return None
153
+
154
+ text = f"{title} {content}"
155
+ vector = embedder.embed_text(text)
156
+ if not vector:
157
+ logger.warning("Failed to embed document: %s", title[:50])
158
+ return None
159
+
160
+ point_id = doc_id or str(uuid.uuid4())
161
+ payload = {
162
+ "title": title.strip(),
163
+ "content": content.strip(),
164
+ "created_at": datetime.now(timezone.utc).isoformat(),
165
+ "source": "api",
166
+ **(metadata or {}),
167
+ }
168
+
169
+ try:
170
+ self.client.upsert(
171
+ collection_name=self.collection_name,
172
+ points=[PointStruct(id=point_id, vector=vector, payload=payload)],
173
+ )
174
+ return point_id
175
+ except Exception as exc:
176
+ logger.error("Failed to upsert document '%s': %s", title[:50], exc)
177
+ return None
178
+
179
+ def upsert_documents_batch(
180
+ self,
181
+ documents: List[Dict[str, str]],
182
+ source: str = "seed",
183
+ ) -> int:
184
+ """
185
+ Batch insert documents. Each dict must have 'title' and 'content'.
186
+ Returns the number of successfully inserted documents.
187
+ """
188
+ if not self.is_connected or not documents:
189
+ return 0
190
+
191
+ texts = [f"{d['title']} {d['content']}" for d in documents]
192
+ vectors = embedder.embed_batch(texts)
193
+
194
+ points = []
195
+ for doc, vec in zip(documents, vectors):
196
+ if vec is None:
197
+ continue
198
+ point_id = doc.get("id") or str(uuid.uuid4())
199
+ points.append(
200
+ PointStruct(
201
+ id=point_id,
202
+ vector=vec,
203
+ payload={
204
+ "title": doc["title"].strip(),
205
+ "content": doc["content"].strip(),
206
+ "created_at": datetime.now(timezone.utc).isoformat(),
207
+ "source": source,
208
+ },
209
+ )
210
+ )
211
+
212
+ if not points:
213
+ return 0
214
+
215
+ try:
216
+ # Qdrant supports batches up to 100 by default
217
+ batch_size = 64
218
+ for i in range(0, len(points), batch_size):
219
+ self.client.upsert(
220
+ collection_name=self.collection_name,
221
+ points=points[i : i + batch_size],
222
+ )
223
+ logger.info("Batch upserted %d documents into Qdrant.", len(points))
224
+ return len(points)
225
+ except Exception as exc:
226
+ logger.error("Batch upsert failed: %s", exc)
227
+ return 0
228
+
229
+ def delete_document(self, doc_id: str) -> bool:
230
+ """Delete a document by its ID. Returns True on success."""
231
+ if not self.is_connected:
232
+ return False
233
+ try:
234
+ self.client.delete(
235
+ collection_name=self.collection_name,
236
+ points_selector=[doc_id],
237
+ )
238
+ return True
239
+ except Exception as exc:
240
+ logger.error("Failed to delete document '%s': %s", doc_id, exc)
241
+ return False
242
+
243
+ def list_documents(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
244
+ """List documents in the collection (paginated via scroll)."""
245
+ if not self.is_connected:
246
+ return []
247
+ try:
248
+ results, _next = self.client.scroll(
249
+ collection_name=self.collection_name,
250
+ limit=limit,
251
+ offset=offset if offset else None,
252
+ with_payload=True,
253
+ with_vectors=False,
254
+ )
255
+ return [
256
+ {
257
+ "id": str(point.id),
258
+ "title": point.payload.get("title", ""),
259
+ "content": point.payload.get("content", "")[:200],
260
+ "source": point.payload.get("source", ""),
261
+ "created_at": point.payload.get("created_at", ""),
262
+ }
263
+ for point in results
264
+ ]
265
+ except Exception as exc:
266
+ logger.error("Failed to list documents: %s", exc)
267
+ return []
268
+
269
+ # ── Search ───────────────────────────────────────────────────────────────
270
+
271
+ def search(
272
+ self,
273
+ query: str,
274
+ top_k: int = 3,
275
+ threshold: float = 0.20,
276
+ ) -> List[Dict[str, Any]]:
277
+ """
278
+ Semantic search: embed the query and find nearest documents.
279
+ Returns list of dicts with score, title, content.
280
+ """
281
+ if not self.is_connected:
282
+ return []
283
+
284
+ vector = embedder.embed_text(query)
285
+ if not vector:
286
+ return []
287
+
288
+ try:
289
+ points = self.client.search(
290
+ collection_name=self.collection_name,
291
+ query_vector=vector,
292
+ limit=top_k,
293
+ score_threshold=threshold,
294
+ )
295
+ return [
296
+ {
297
+ "score": float(p.score),
298
+ "title": p.payload.get("title", ""),
299
+ "content": p.payload.get("content", ""),
300
+ "id": str(p.id),
301
+ }
302
+ for p in points
303
+ ]
304
+ except Exception as exc:
305
+ logger.warning("Vector search failed: %s", exc)
306
+ return []
307
+
308
+
309
+ # ── Singleton ────────────────────────────────────────────────────────────────
310
+ vector_store = VectorStore()
docker-compose.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ qdrant:
3
+ image: qdrant/qdrant:latest
4
+ ports:
5
+ - "6333:6333"
6
+ volumes:
7
+ - qdrant_data:/qdrant/storage
8
+ restart: unless-stopped
9
+
10
+ app:
11
+ build: .
12
+ ports:
13
+ - "7860:7860"
14
+ depends_on:
15
+ - qdrant
16
+ env_file:
17
+ - ../.env
18
+ environment:
19
+ # Override to use Docker network name instead of localhost
20
+ - QDRANT_URL=http://qdrant:6333
21
+ restart: unless-stopped
22
+
23
+ volumes:
24
+ qdrant_data:
main.py CHANGED
@@ -59,6 +59,20 @@ async def lifespan(app: FastAPI):
59
  app.state.models = await asyncio.to_thread(get_or_train_models, False)
60
  logger.info("Successfully loaded/trained ML models.")
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  # Start background model revalidation task
63
  asyncio.create_task(periodic_model_revalidation())
64
 
@@ -71,6 +85,11 @@ async def lifespan(app: FastAPI):
71
  yield
72
  app.state.models.clear()
73
  await redis_client.disconnect()
 
 
 
 
 
74
  logger.info("Shutting down %s...", settings.PROJECT_NAME)
75
 
76
 
 
59
  app.state.models = await asyncio.to_thread(get_or_train_models, False)
60
  logger.info("Successfully loaded/trained ML models.")
61
 
62
+ # Initialize RAG vector store and embeddings
63
+ from app.services.rag.embedder import embedder
64
+ from app.services.rag.vector_store import vector_store
65
+ from app.services.rag.rag_engine import rag_engine
66
+
67
+ logger.info("Initializing embedding service...")
68
+ embedder.initialize()
69
+
70
+ logger.info("Connecting to vector store...")
71
+ vector_store.connect()
72
+
73
+ logger.info("Initializing RAG engine...")
74
+ rag_engine.initialize()
75
+
76
  # Start background model revalidation task
77
  asyncio.create_task(periodic_model_revalidation())
78
 
 
85
  yield
86
  app.state.models.clear()
87
  await redis_client.disconnect()
88
+
89
+ # Graceful vector store disconnect
90
+ from app.services.rag.vector_store import vector_store
91
+ vector_store.disconnect()
92
+
93
  logger.info("Shutting down %s...", settings.PROJECT_NAME)
94
 
95
 
requirements.txt CHANGED
@@ -19,6 +19,7 @@ huggingface_hub>=0.24.0,<1.0.0
19
  cachetools>=5.5.0,<6.0.0
20
  shap==0.45.0
21
  qdrant-client>=1.11.0,<2.0.0
 
22
  upstash-redis>=1.1.0,<2.0.0
23
  presidio-analyzer>=2.2.0,<3.0.0
24
  presidio-anonymizer>=2.2.0,<3.0.0
 
19
  cachetools>=5.5.0,<6.0.0
20
  shap==0.45.0
21
  qdrant-client>=1.11.0,<2.0.0
22
+ sentence-transformers>=3.0.0,<4.0.0
23
  upstash-redis>=1.1.0,<2.0.0
24
  presidio-analyzer>=2.2.0,<3.0.0
25
  presidio-anonymizer>=2.2.0,<3.0.0
verify_rag.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ # Add current dir to path to allow absolute imports
5
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__))))
6
+
7
+ import asyncio
8
+ from app.core.config import settings
9
+ from app.services.rag.embedder import embedder
10
+ from app.services.rag.vector_store import vector_store
11
+ from app.services.rag.rag_engine import rag_engine
12
+
13
+ async def main():
14
+ print("Initializing embedder...")
15
+ embedder.initialize()
16
+ print(f"Embedder mode: {embedder.is_ready}, dimension: {embedder.dimension}")
17
+
18
+ vec = embedder.embed_text("مرحبا بك في منصة عون")
19
+ if vec:
20
+ print(f"Embedding successful: Length {len(vec)}, first elements: {vec[:3]}")
21
+ else:
22
+ print("Embedding failed!")
23
+
24
+ print("\nConnecting to Qdrant (Make sure Docker is running!)...")
25
+ # This might fail if Docker Qdrant is not up, but it shouldn't crash the app.
26
+ success = vector_store.connect()
27
+ print(f"Qdrant connection: {success}")
28
+
29
+ if success:
30
+ print(vector_store.get_collection_info())
31
+
32
+ print("\nInitializing RAG Engine...")
33
+ rag_engine.initialize()
34
+ print(f"TF-IDF Matrix Shape: {rag_engine.tfidf_matrix.shape if rag_engine.tfidf_matrix is not None else 'None'}")
35
+
36
+ print("\nTesting Hybrid Search:")
37
+ res = rag_engine.search("كيف يتم دعم الأسر؟")
38
+ for r in res:
39
+ print(f"- [Score: {r['score']:.4f}]: {r['title']}")
40
+
41
+ if __name__ == "__main__":
42
+ asyncio.run(main())