Martechsol commited on
Commit
608d6ed
Β·
1 Parent(s): 28b38ef

Restore to latest backup from 13 May - 2026-05-15 17:56

Browse files
.env.example CHANGED
@@ -1,34 +1,19 @@
1
- LLM_PROVIDER=openai
2
- OPENAI_API_KEY=your_openai_api_key
3
- OPENAI_MODEL=gpt-4o-mini
4
- OPENAI_REWRITE_MODEL=gpt-4o-mini
5
-
6
  GROQ_API_KEY=your_groq_api_key
7
  GROQ_MODEL=qwen/qwen3-32b
8
  GROQ_REWRITE_MODEL=llama-3.1-8b-instant
9
-
10
- FIREWORKS_API_KEY=your_fireworks_api_key
11
- FIREWORKS_MODEL=accounts/fireworks/models/qwen3-30b-a3b-instruct-2507
12
- FIREWORKS_REWRITE_MODEL=accounts/fireworks/models/qwen3-8b
13
-
14
  HF_API_KEY=your_huggingface_api_key
15
  HF_MODEL=meta-llama/Llama-3.1-8B-Instruct
16
  EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
17
- RERANKER_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
18
-
19
  DOCS_DIR=docs
20
  INDEX_DIR=data/index
21
  SESSIONS_DIR=data/sessions
22
- TOP_K=8
23
-
24
  CORS_ALLOW_ORIGINS=*
25
- API_KEY=your_internal_api_key
26
  RATE_LIMIT_REQUESTS=60
27
  RATE_LIMIT_WINDOW_SECONDS=60
28
-
29
- # SMTP Settings for OTP
30
- SMTP_SERVER=smtp.gmail.com
31
- SMTP_PORT=465
32
- SMTP_USER=
33
- SMTP_PASS=
34
- ADMIN_EMAIL=randomjoedown@gmail.com
 
1
+ LLM_PROVIDER=fireworks
2
+ FIREWORKS_API_KEY=your_fireworks_api_key
3
+ FIREWORKS_MODEL=accounts/fireworks/models/qwen3-32b
4
+ FIREWORKS_REWRITE_MODEL=accounts/fireworks/models/llama-v3p1-8b-instruct
 
5
  GROQ_API_KEY=your_groq_api_key
6
  GROQ_MODEL=qwen/qwen3-32b
7
  GROQ_REWRITE_MODEL=llama-3.1-8b-instant
 
 
 
 
 
8
  HF_API_KEY=your_huggingface_api_key
9
  HF_MODEL=meta-llama/Llama-3.1-8B-Instruct
10
  EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
 
 
11
  DOCS_DIR=docs
12
  INDEX_DIR=data/index
13
  SESSIONS_DIR=data/sessions
14
+ TOP_K=4
 
15
  CORS_ALLOW_ORIGINS=*
16
+ API_KEY=
17
  RATE_LIMIT_REQUESTS=60
18
  RATE_LIMIT_WINDOW_SECONDS=60
19
+ RAG_API_URL=
 
 
 
 
 
 
CONFIG_NOTES.md CHANGED
@@ -1,32 +1,29 @@
1
  # Configuration Backup - Martechsol RAG Chatbot
2
- **Date:** 2026-05-05
3
  **Source:** C:\Users\DELL\Desktop\RAG_backup2
4
- **Destination:** D:\RAG_Backup_2026_05_05
5
 
6
- ## Core Settings (latest state)
7
  - **App Name:** Fast RAG Chatbot
8
- - **LLM Provider:** openai
9
- - **Main LLM Model:** 4o-mini (OpenAI)
10
- - **Query Rewriter:** 4o-mini (OpenAI)
11
  - **HF Model:** meta-llama/Llama-3.1-8B-Instruct
12
  - **Embedding Model:** BAAI/bge-small-en-v1.5
13
- - **Reranker Model:** cross-encoder/ms-marco-MiniLM-L-6-v2
14
  - **Docs Directory:** docs/
15
  - **Index Directory:** data/index/
16
  - **Sessions Directory:** data/sessions/
17
- - **Chunk Size:** 350 tokens (Optimized for context window)
18
  - **Overlap:** 80 tokens
19
- - **Top K:** 8
20
- - **Max Context Chunks:** 4
21
 
22
  ## Admin Credentials
23
  - **Username:** martech_admin
24
  - **Password:** martech_admin_303
25
  - **OTP Expiry:** 300 seconds (5 minutes)
26
 
27
- ## Security & Notifications
28
  - **Auth Method:** HTTP Basic Auth + OTP (email-based)
29
- - **Admin Email:** randomjoedown@gmail.com
30
  - **SMTP Server:** smtp.gmail.com (Port 465)
31
 
32
  ## API / Integration
@@ -35,7 +32,7 @@
35
  - **Admin Endpoint:** /admin
36
  - **CORS:** Allowed for all (*) for testing
37
 
38
- ## Optimization Notes
39
- - **Latency Optimization**: Skip thinking process for Qwen3 using deterministic intent detection.
40
- - **Conciseness Protocol**: Strict 25-30 word response limit enforced via System Prompt.
41
- - **Hybrid Search**: FAISS + BM25 with Reranker for high precision.
 
1
  # Configuration Backup - Martechsol RAG Chatbot
2
+ **Date:** 2026-04-28
3
  **Source:** C:\Users\DELL\Desktop\RAG_backup2
4
+ **Destination:** D:\RAG_Backup_2026_04_28
5
 
6
+ ## Core Settings (from app/core/config.py)
7
  - **App Name:** Fast RAG Chatbot
8
+ - **LLM Provider:** groq (default)
9
+ - **Groq Model:** llama-3.1-8b-instant
 
10
  - **HF Model:** meta-llama/Llama-3.1-8B-Instruct
11
  - **Embedding Model:** BAAI/bge-small-en-v1.5
 
12
  - **Docs Directory:** docs/
13
  - **Index Directory:** data/index/
14
  - **Sessions Directory:** data/sessions/
15
+ - **Chunk Size:** 420 tokens
16
  - **Overlap:** 80 tokens
17
+ - **Top K:** 4
 
18
 
19
  ## Admin Credentials
20
  - **Username:** martech_admin
21
  - **Password:** martech_admin_303
22
  - **OTP Expiry:** 300 seconds (5 minutes)
23
 
24
+ ## Security
25
  - **Auth Method:** HTTP Basic Auth + OTP (email-based)
26
+ - **Email for OTP:** randomjoedown@gmail.com
27
  - **SMTP Server:** smtp.gmail.com (Port 465)
28
 
29
  ## API / Integration
 
32
  - **Admin Endpoint:** /admin
33
  - **CORS:** Allowed for all (*) for testing
34
 
35
+ ## Infrastructure
36
+ - **Hugging Face Path:** /data (for persistent storage)
37
+ - **Local Path:** data/ (for local storage)
38
+ - **Dockerfile:** Based on python:3.10-slim, serves on port 7860
README.md CHANGED
@@ -14,7 +14,7 @@ Production-style document QA chatbot using:
14
  - FastAPI API service
15
  - FAISS vector search
16
  - SentenceTransformer embeddings (`BAAI/bge-small-en-v1.5` by default)
17
- - OpenAI (preferred) or Hugging Face LLM APIs
18
  - Optional Gradio chat UI
19
 
20
  ## Features
@@ -42,7 +42,7 @@ Production-style document QA chatbot using:
42
  `app/services/chunker.py` - token-window chunking
43
  `app/services/embeddings.py` - embedding model wrapper
44
  `app/services/vector_store.py` - FAISS index and retrieval
45
- `app/services/llm.py` - OpenAI/HF LLM clients and prompt
46
  `app/services/rag_pipeline.py` - end-to-end chat flow
47
  `app/ui_gradio.py` - optional web chat UI
48
 
@@ -61,7 +61,7 @@ copy .env.example .env
61
  ```
62
 
63
  Then set your keys in `.env`:
64
- - `OPENAI_API_KEY` (if using OpenAI)
65
  - `HF_API_KEY` (if using Hugging Face)
66
  - Optional:
67
  - `API_KEY` for request auth (send as `x-api-key`)
@@ -122,7 +122,7 @@ If you set `RAG_API_URL`, it will call that external FastAPI endpoint instead.
122
 
123
  - Works as backend for websites (REST API is frontend-agnostic)
124
  - Persist `data/index/` volume in production
125
- - Prefer OpenAI provider for low latency
126
  - Keep `top_k` small (3-5) for speed and lower prompt tokens
127
  - Protect `/chat` with `API_KEY` in production
128
  - Set strict `CORS_ALLOW_ORIGINS` instead of `*`
@@ -158,7 +158,7 @@ Use these settings in your Space:
158
  - **Python version**: 3.10+ (3.11 recommended)
159
 
160
  Add Space Secrets:
161
- - `OPENAI_API_KEY` (or `HF_API_KEY`)
162
- - Optional: `LLM_PROVIDER`, `OPENAI_MODEL`, `HF_MODEL`, `TOP_K`
163
 
164
  Upload project files (excluding `.env`) and include your knowledge files inside `docs/`.
 
14
  - FastAPI API service
15
  - FAISS vector search
16
  - SentenceTransformer embeddings (`BAAI/bge-small-en-v1.5` by default)
17
+ - Groq (preferred) or Hugging Face LLM APIs
18
  - Optional Gradio chat UI
19
 
20
  ## Features
 
42
  `app/services/chunker.py` - token-window chunking
43
  `app/services/embeddings.py` - embedding model wrapper
44
  `app/services/vector_store.py` - FAISS index and retrieval
45
+ `app/services/llm.py` - Groq/HF LLM clients and prompt
46
  `app/services/rag_pipeline.py` - end-to-end chat flow
47
  `app/ui_gradio.py` - optional web chat UI
48
 
 
61
  ```
62
 
63
  Then set your keys in `.env`:
64
+ - `GROQ_API_KEY` (if using Groq)
65
  - `HF_API_KEY` (if using Hugging Face)
66
  - Optional:
67
  - `API_KEY` for request auth (send as `x-api-key`)
 
122
 
123
  - Works as backend for websites (REST API is frontend-agnostic)
124
  - Persist `data/index/` volume in production
125
+ - Prefer Groq provider for low latency
126
  - Keep `top_k` small (3-5) for speed and lower prompt tokens
127
  - Protect `/chat` with `API_KEY` in production
128
  - Set strict `CORS_ALLOW_ORIGINS` instead of `*`
 
158
  - **Python version**: 3.10+ (3.11 recommended)
159
 
160
  Add Space Secrets:
161
+ - `GROQ_API_KEY` (or `HF_API_KEY`)
162
+ - Optional: `LLM_PROVIDER`, `GROQ_MODEL`, `HF_MODEL`, `TOP_K`
163
 
164
  Upload project files (excluding `.env`) and include your knowledge files inside `docs/`.
app/admin/templates/admin.html CHANGED
@@ -7,7 +7,6 @@
7
  <title>Martechsol β€” Admin Panel</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
10
- <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
11
  <style>
12
  *,
13
  *::before,
@@ -313,23 +312,10 @@
313
  border: 1px solid var(--border);
314
  border-radius: 8px;
315
  margin-top: 4px;
 
316
  z-index: 100;
317
  box-shadow: var(--shadow);
318
  overflow: hidden;
319
- /* Smooth show/hide with max-height + opacity */
320
- max-height: 0;
321
- opacity: 0;
322
- visibility: hidden;
323
- transition: max-height .35s ease, opacity .25s ease, visibility 0s .35s;
324
- pointer-events: none;
325
- }
326
-
327
- .export-menu.open {
328
- max-height: 200px;
329
- opacity: 1;
330
- visibility: visible;
331
- transition: max-height .35s ease, opacity .25s ease, visibility 0s 0s;
332
- pointer-events: auto;
333
  }
334
 
335
  .export-menu button {
@@ -349,6 +335,10 @@
349
  color: var(--text)
350
  }
351
 
 
 
 
 
352
  .search-wrap {
353
  padding: 12px 16px;
354
  border-bottom: 1px solid var(--border)
@@ -640,163 +630,6 @@
640
  color: #fca5a5
641
  }
642
 
643
- /* ── Export Modal ── */
644
- .export-modal-overlay {
645
- position: fixed;
646
- inset: 0;
647
- background: rgba(0,0,0,.6);
648
- backdrop-filter: blur(4px);
649
- z-index: 10000;
650
- display: flex;
651
- align-items: center;
652
- justify-content: center;
653
- opacity: 0;
654
- visibility: hidden;
655
- transition: opacity .25s ease, visibility 0s .25s;
656
- }
657
- .export-modal-overlay.open {
658
- opacity: 1;
659
- visibility: visible;
660
- transition: opacity .25s ease, visibility 0s 0s;
661
- }
662
- .export-modal {
663
- background: var(--surface);
664
- border: 1px solid var(--border);
665
- border-radius: 16px;
666
- padding: 32px 28px;
667
- width: 380px;
668
- box-shadow: 0 20px 60px rgba(0,0,0,.6);
669
- transform: translateY(16px) scale(.97);
670
- transition: transform .25s ease;
671
- }
672
- .export-modal-overlay.open .export-modal {
673
- transform: translateY(0) scale(1);
674
- }
675
- .export-modal-header {
676
- display: flex;
677
- align-items: center;
678
- justify-content: space-between;
679
- margin-bottom: 22px;
680
- }
681
- .export-modal-header h3 {
682
- font-size: 15px;
683
- font-weight: 700;
684
- background: linear-gradient(135deg, var(--accent), var(--accent2));
685
- -webkit-background-clip: text;
686
- -webkit-text-fill-color: transparent;
687
- }
688
- .export-modal-close {
689
- background: none;
690
- border: none;
691
- color: var(--text3);
692
- cursor: pointer;
693
- font-size: 20px;
694
- line-height: 1;
695
- transition: color .2s;
696
- padding: 2px 6px;
697
- border-radius: 6px;
698
- }
699
- .export-modal-close:hover { color: var(--text); background: var(--surface2); }
700
- .export-modal-section {
701
- margin-bottom: 20px;
702
- }
703
- .export-modal-label {
704
- font-size: 11px;
705
- font-weight: 600;
706
- text-transform: uppercase;
707
- letter-spacing: .07em;
708
- color: var(--text3);
709
- margin-bottom: 10px;
710
- }
711
- .export-radio-group {
712
- display: flex;
713
- flex-direction: column;
714
- gap: 8px;
715
- }
716
- .export-radio-option {
717
- display: flex;
718
- align-items: flex-start;
719
- gap: 10px;
720
- padding: 11px 14px;
721
- border-radius: 10px;
722
- border: 1px solid var(--border);
723
- background: var(--surface2);
724
- cursor: pointer;
725
- transition: all .2s;
726
- }
727
- .export-radio-option:hover {
728
- border-color: rgba(59,130,246,.4);
729
- background: rgba(59,130,246,.05);
730
- }
731
- .export-radio-option input[type=radio] {
732
- margin-top: 1px;
733
- accent-color: var(--accent);
734
- width: 15px;
735
- height: 15px;
736
- flex-shrink: 0;
737
- cursor: pointer;
738
- }
739
- .export-radio-option.selected {
740
- border-color: rgba(59,130,246,.5);
741
- background: rgba(59,130,246,.08);
742
- }
743
- .export-radio-text strong {
744
- font-size: 13px;
745
- font-weight: 600;
746
- color: var(--text);
747
- display: block;
748
- }
749
- .export-radio-text span {
750
- font-size: 11px;
751
- color: var(--text3);
752
- margin-top: 2px;
753
- display: block;
754
- }
755
- .export-format-group {
756
- display: flex;
757
- gap: 8px;
758
- }
759
- .export-format-btn {
760
- flex: 1;
761
- padding: 9px 12px;
762
- border-radius: 9px;
763
- border: 1px solid var(--border);
764
- background: var(--surface2);
765
- color: var(--text2);
766
- font-size: 12px;
767
- font-weight: 600;
768
- font-family: inherit;
769
- cursor: pointer;
770
- transition: all .2s;
771
- text-align: center;
772
- }
773
- .export-format-btn:hover { border-color: rgba(59,130,246,.4); color: var(--text); }
774
- .export-format-btn.active {
775
- border-color: var(--accent);
776
- background: rgba(59,130,246,.12);
777
- color: var(--accent);
778
- }
779
- .btn-export-modal-submit {
780
- width: 100%;
781
- padding: 12px;
782
- border: none;
783
- border-radius: 10px;
784
- background: linear-gradient(135deg, var(--accent), var(--accent2));
785
- color: #fff;
786
- font-size: 14px;
787
- font-weight: 600;
788
- font-family: inherit;
789
- cursor: pointer;
790
- transition: opacity .2s, transform .15s;
791
- display: flex;
792
- align-items: center;
793
- justify-content: center;
794
- gap: 8px;
795
- margin-top: 4px;
796
- }
797
- .btn-export-modal-submit:hover { opacity: .88; transform: translateY(-1px); }
798
- .btn-export-modal-submit:disabled { opacity: .5; cursor: not-allowed; transform: none; }
799
-
800
  /* ── Animations ── */
801
  @keyframes fadeUp {
802
  from {
@@ -913,12 +746,18 @@
913
  </div>
914
  </div>
915
  <div class="sidebar-actions">
916
- <button class="btn-export" id="btn-open-export-modal" style="flex:1">
917
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
918
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
919
- </svg>
920
- Export Chat
921
- </button>
 
 
 
 
 
 
922
  </div>
923
  <div class="search-wrap">
924
  <input type="text" id="search-inp" placeholder="Search sessions…" />
@@ -968,55 +807,6 @@
968
 
969
  <div class="toast" id="toast"></div>
970
 
971
- <!-- Export Modal -->
972
- <div class="export-modal-overlay" id="export-modal-overlay">
973
- <div class="export-modal" role="dialog" aria-modal="true" aria-labelledby="export-modal-title">
974
- <div class="export-modal-header">
975
- <h3 id="export-modal-title">Export Chat History</h3>
976
- <button class="export-modal-close" id="export-modal-close" aria-label="Close">&times;</button>
977
- </div>
978
-
979
- <div class="export-modal-section">
980
- <div class="export-modal-label">Export Scope</div>
981
- <div class="export-radio-group">
982
- <label class="export-radio-option selected" id="radio-label-one">
983
- <input type="radio" name="export-scope" value="one" id="export-scope-one" checked />
984
- <div class="export-radio-text">
985
- <strong>All sessions in one file</strong>
986
- <span>Combines every session into a single export file</span>
987
- </div>
988
- </label>
989
- <label class="export-radio-option" id="radio-label-sep">
990
- <input type="radio" name="export-scope" value="separate" id="export-scope-separate" />
991
- <div class="export-radio-text">
992
- <strong>Each session as a separate file</strong>
993
- <span>Downloads a ZIP archive containing one file per session</span>
994
- </div>
995
- </label>
996
- </div>
997
- </div>
998
-
999
- <div class="export-modal-section">
1000
- <div class="export-modal-label">File Format</div>
1001
- <div class="export-format-group">
1002
- <button class="export-format-btn active" id="fmt-excel" data-fmt="excel">
1003
- πŸ“Š Excel (.xlsx)
1004
- </button>
1005
- <button class="export-format-btn" id="fmt-json" data-fmt="json">
1006
- πŸ—‚οΈ JSON (.json)
1007
- </button>
1008
- </div>
1009
- </div>
1010
-
1011
- <button class="btn-export-modal-submit" id="btn-export-modal-submit">
1012
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
1013
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
1014
- </svg>
1015
- Export Now
1016
- </button>
1017
- </div>
1018
- </div>
1019
-
1020
  <script>
1021
  (function () {
1022
  const CREDS = { user: 'martech_admin', pass: 'martech_admin_303' };
@@ -1321,135 +1111,38 @@
1321
  } catch (e) { showToast('Delete failed: ' + e.message, 'error') }
1322
  });
1323
 
1324
- // ── Export Session (header) dropdown hover logic ──────────
1325
- document.querySelectorAll('.export-dropdown').forEach(dropdown => {
1326
- const menu = dropdown.querySelector('.export-menu');
1327
- let hideTimer = null;
1328
- function showMenu() { clearTimeout(hideTimer); menu.classList.add('open'); }
1329
- function scheduleHide() {
1330
- clearTimeout(hideTimer);
1331
- hideTimer = setTimeout(() => menu.classList.remove('open'), 300);
1332
- }
1333
- dropdown.addEventListener('mouseenter', showMenu);
1334
- dropdown.addEventListener('mouseleave', scheduleHide);
1335
- menu.addEventListener('mouseenter', showMenu);
1336
- menu.addEventListener('mouseleave', scheduleHide);
1337
- menu.querySelectorAll('button').forEach(btn => {
1338
- btn.addEventListener('click', () => { clearTimeout(hideTimer); menu.classList.remove('open'); });
1339
- });
1340
- });
1341
-
1342
- // ── Exports β€” shared download helper ──────────────────────
1343
  async function downloadExport(url, filename) {
1344
- const r = await fetch(url, { headers: { Authorization: authHeader() } });
1345
- if (!r.ok) throw new Error('HTTP ' + r.status);
1346
- const blob = await r.blob();
1347
- const a = document.createElement('a');
1348
- a.href = URL.createObjectURL(blob);
1349
- a.download = filename;
1350
- a.click();
1351
- }
1352
-
1353
- // ── Session header export (unchanged behaviour) ────────────
1354
- $('btn-export-session-excel').addEventListener('click', () => {
1355
- if (!_activeId) return;
1356
- downloadExport(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=excel`,
1357
- `session_${_activeId}.xlsx`).then(() => showToast('Export successful', 'success'))
1358
- .catch(e => showToast('Export failed: ' + e.message, 'error'));
1359
- });
1360
- $('btn-export-session-json').addEventListener('click', () => {
1361
- if (!_activeId) return;
1362
- downloadExport(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=json`,
1363
- `session_${_activeId}.json`).then(() => showToast('Export successful', 'success'))
1364
- .catch(e => showToast('Export failed: ' + e.message, 'error'));
1365
- });
1366
-
1367
- // ── Sidebar Export Modal ───────────────────────────────────
1368
- let _exportFmt = 'excel'; // active format selection
1369
-
1370
- function openExportModal() {
1371
- $('export-modal-overlay').classList.add('open');
1372
- }
1373
- function closeExportModal() {
1374
- $('export-modal-overlay').classList.remove('open');
1375
  }
1376
 
1377
- $('btn-open-export-modal').addEventListener('click', openExportModal);
1378
- $('export-modal-close').addEventListener('click', closeExportModal);
1379
- $('export-modal-overlay').addEventListener('click', e => {
1380
- if (e.target === $('export-modal-overlay')) closeExportModal();
1381
- });
1382
- document.addEventListener('keydown', e => {
1383
- if (e.key === 'Escape') closeExportModal();
1384
  });
1385
 
1386
- // Radio option highlight
1387
- document.querySelectorAll('input[name="export-scope"]').forEach(radio => {
1388
- radio.addEventListener('change', () => {
1389
- $('radio-label-one').classList.toggle('selected', $('export-scope-one').checked);
1390
- $('radio-label-sep').classList.toggle('selected', $('export-scope-separate').checked);
1391
- });
1392
  });
1393
 
1394
- // Format toggle buttons
1395
- document.querySelectorAll('.export-format-btn').forEach(btn => {
1396
- btn.addEventListener('click', () => {
1397
- document.querySelectorAll('.export-format-btn').forEach(b => b.classList.remove('active'));
1398
- btn.classList.add('active');
1399
- _exportFmt = btn.dataset.fmt;
1400
- });
1401
  });
1402
 
1403
- // Submit export
1404
- $('btn-export-modal-submit').addEventListener('click', async () => {
1405
- const scope = document.querySelector('input[name="export-scope"]:checked').value;
1406
- const fmt = _exportFmt;
1407
- const btn = $('btn-export-modal-submit');
1408
- btn.disabled = true;
1409
- btn.innerHTML = '<span style="display:inline-block;width:14px;height:14px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite"></span> Exporting…';
1410
-
1411
- try {
1412
- if (scope === 'one') {
1413
- // Single file β€” existing endpoint
1414
- const ext = fmt === 'excel' ? 'xlsx' : 'json';
1415
- await downloadExport(`/api/admin/export/all?format=${fmt}`, `all_chats_export.${ext}`);
1416
- showToast('Export successful', 'success');
1417
- closeExportModal();
1418
- } else {
1419
- // Separate files β†’ ZIP
1420
- if (!_allSessions.length) { showToast('No sessions to export', 'error'); return; }
1421
- const zip = new JSZip();
1422
- const ext = fmt === 'excel' ? 'xlsx' : 'json';
1423
- let done = 0;
1424
- for (const s of _allSessions) {
1425
- try {
1426
- const r = await fetch(
1427
- `/api/admin/export/session/${encodeURIComponent(s.session_id)}?format=${fmt}`,
1428
- { headers: { Authorization: authHeader() } }
1429
- );
1430
- if (r.ok) {
1431
- const blob = await r.blob();
1432
- const safeName = (s.user_name || s.session_id).replace(/[^a-z0-9_\-]/gi, '_');
1433
- zip.file(`${safeName}_${s.session_id.slice(-6)}.${ext}`, blob);
1434
- done++;
1435
- }
1436
- } catch {/* skip failed sessions */}
1437
- }
1438
- if (!done) throw new Error('No sessions could be exported');
1439
- const zipBlob = await zip.generateAsync({ type: 'blob' });
1440
- const a = document.createElement('a');
1441
- a.href = URL.createObjectURL(zipBlob);
1442
- a.download = `chat_export_${new Date().toISOString().slice(0,10)}.zip`;
1443
- a.click();
1444
- showToast(`Exported ${done} session${done !== 1 ? 's' : ''} as ZIP`, 'success');
1445
- closeExportModal();
1446
- }
1447
- } catch (e) {
1448
- showToast('Export failed: ' + e.message, 'error');
1449
- } finally {
1450
- btn.disabled = false;
1451
- btn.innerHTML = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg> Export Now';
1452
- }
1453
  });
1454
 
1455
  })();
 
7
  <title>Martechsol β€” Admin Panel</title>
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
 
10
  <style>
11
  *,
12
  *::before,
 
312
  border: 1px solid var(--border);
313
  border-radius: 8px;
314
  margin-top: 4px;
315
+ display: none;
316
  z-index: 100;
317
  box-shadow: var(--shadow);
318
  overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  }
320
 
321
  .export-menu button {
 
335
  color: var(--text)
336
  }
337
 
338
+ .export-dropdown:hover .export-menu {
339
+ display: block
340
+ }
341
+
342
  .search-wrap {
343
  padding: 12px 16px;
344
  border-bottom: 1px solid var(--border)
 
630
  color: #fca5a5
631
  }
632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  /* ── Animations ── */
634
  @keyframes fadeUp {
635
  from {
 
746
  </div>
747
  </div>
748
  <div class="sidebar-actions">
749
+ <div class="export-dropdown">
750
+ <button class="btn-export">
751
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
752
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
753
+ </svg>
754
+ Export All
755
+ </button>
756
+ <div class="export-menu">
757
+ <button id="btn-export-all-excel">Excel (.xlsx)</button>
758
+ <button id="btn-export-all-json">JSON (.json)</button>
759
+ </div>
760
+ </div>
761
  </div>
762
  <div class="search-wrap">
763
  <input type="text" id="search-inp" placeholder="Search sessions…" />
 
807
 
808
  <div class="toast" id="toast"></div>
809
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  <script>
811
  (function () {
812
  const CREDS = { user: 'martech_admin', pass: 'martech_admin_303' };
 
1111
  } catch (e) { showToast('Delete failed: ' + e.message, 'error') }
1112
  });
1113
 
1114
+ // ── Exports ───────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1115
  async function downloadExport(url, filename) {
1116
+ try {
1117
+ const r = await fetch(url, { headers: { Authorization: authHeader() } });
1118
+ if (!r.ok) throw new Error('Export failed');
1119
+ const blob = await r.blob();
1120
+ const link = document.createElement('a');
1121
+ link.href = window.URL.createObjectURL(blob);
1122
+ link.download = filename;
1123
+ link.click();
1124
+ showToast('Export successful', 'success');
1125
+ } catch (e) {
1126
+ showToast('Export failed: ' + e.message, 'error');
1127
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1128
  }
1129
 
1130
+ $('btn-export-all-excel').addEventListener('click', () => {
1131
+ downloadExport('/api/admin/export/all?format=excel', 'all_chats_export.xlsx');
 
 
 
 
 
1132
  });
1133
 
1134
+ $('btn-export-all-json').addEventListener('click', () => {
1135
+ downloadExport('/api/admin/export/all?format=json', 'all_chats_export.json');
 
 
 
 
1136
  });
1137
 
1138
+ $('btn-export-session-excel').addEventListener('click', () => {
1139
+ if (!_activeId) return;
1140
+ downloadExport(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=excel`, `session_${_activeId}.xlsx`);
 
 
 
 
1141
  });
1142
 
1143
+ $('btn-export-session-json').addEventListener('click', () => {
1144
+ if (!_activeId) return;
1145
+ downloadExport(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=json`, `session_${_activeId}.json`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1146
  });
1147
 
1148
  })();
app/core/config.py CHANGED
@@ -31,7 +31,7 @@ class Settings(BaseSettings):
31
  self.index_dir = Path("/data/index")
32
  self.sessions_dir = Path("/data/sessions")
33
  return self
34
-
35
  index_dir: Path = Field(default=Path("data/index"), alias="INDEX_DIR")
36
  sessions_dir: Path = Field(default=Path("data/sessions"), alias="SESSIONS_DIR")
37
  chunk_size_tokens: int = 350 # Reduced for TPM safety
 
31
  self.index_dir = Path("/data/index")
32
  self.sessions_dir = Path("/data/sessions")
33
  return self
34
+
35
  index_dir: Path = Field(default=Path("data/index"), alias="INDEX_DIR")
36
  sessions_dir: Path = Field(default=Path("data/sessions"), alias="SESSIONS_DIR")
37
  chunk_size_tokens: int = 350 # Reduced for TPM safety
app/main.py CHANGED
@@ -34,9 +34,6 @@ vector_store = FaissVectorStore(
34
  )
35
  llm_service = LLMService(
36
  provider=settings.llm_provider,
37
- openai_api_key=settings.openai_api_key,
38
- openai_model=settings.openai_model,
39
- openai_rewrite_model=settings.openai_rewrite_model,
40
  groq_api_key=settings.groq_api_key,
41
  groq_model=settings.groq_model,
42
  groq_rewrite_model=settings.groq_rewrite_model,
@@ -176,12 +173,10 @@ async def chat(
176
 
177
  error_msg = "⚠️ Oops! Something went wrong."
178
  if isinstance(e, httpx.HTTPStatusError):
179
- try:
180
- error_data = e.response.json()
181
- api_msg = error_data.get("error", {}).get("message", e.response.text)
182
- except Exception:
183
- api_msg = e.response.text
184
- error_msg = f"⚠️ API Error ({e.response.status_code}): {api_msg}"
185
 
186
  return ChatResponse(reply=error_msg, retrieved_chunks=[])
187
 
 
34
  )
35
  llm_service = LLMService(
36
  provider=settings.llm_provider,
 
 
 
37
  groq_api_key=settings.groq_api_key,
38
  groq_model=settings.groq_model,
39
  groq_rewrite_model=settings.groq_rewrite_model,
 
173
 
174
  error_msg = "⚠️ Oops! Something went wrong."
175
  if isinstance(e, httpx.HTTPStatusError):
176
+ if e.response.status_code == 429:
177
+ error_msg = "⚠️ Rate limit reached. Please slow down a bit!"
178
+ else:
179
+ error_msg = "⚠️ I encountered an error. Please try again in a few seconds."
 
 
180
 
181
  return ChatResponse(reply=error_msg, retrieved_chunks=[])
182
 
app/services/chunker.py CHANGED
@@ -1,12 +1,7 @@
1
  from typing import List, Dict
2
 
3
 
4
- import re
5
-
6
  def _tokenize(text: str) -> List[str]:
7
- # Replace hyphens with spaces for token counting, but keep the original tokens
8
- # This is a simple but effective production approach: split by whitespace
9
- # but ensure we don't count empty strings.
10
  return text.split()
11
 
12
 
 
1
  from typing import List, Dict
2
 
3
 
 
 
4
  def _tokenize(text: str) -> List[str]:
 
 
 
5
  return text.split()
6
 
7
 
app/services/llm.py CHANGED
@@ -9,99 +9,87 @@ _log = logging.getLogger(__name__)
9
  # MASTER SYSTEM PROMPT β€” Martechsol HR Assistant
10
  # Intelligence: Understand Intent β†’ Retrieve Facts β†’ Respond Precisely
11
  # ═══════════════════════════════════════════════════════════════════════
12
- SYSTEM_PROMPT = """You are the Martechsol HR Assistant β€” a highly intelligent, professional HR expert.
13
- You give precise, concise answers using ONLY the relevant parts of the Expert Data provided.
14
 
15
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
16
- RULE 0 β€” FILTER THE CONTEXT FIRST
17
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
18
- The Expert Data may contain multiple topics. Before answering:
19
- ✦ Identify which parts are DIRECTLY relevant to the question.
20
- ✦ IGNORE everything else β€” do not include unrelated policies in your answer.
 
 
 
 
 
21
 
22
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
23
- RULE 1 β€” ANSWER INTELLIGENTLY (like a real HR expert would)
24
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
25
- Understand the user's INTENT and respond accordingly:
26
-
27
- β€’ "casual leaves" β†’ give a brief summary (entitlement, carry forward, encashment) in 1-2 sentences.
28
- β€’ "i want to take casual leave" β†’ explain HOW to apply/take leave (the procedure).
29
- β€’ "can I resign?" β†’ answer YES or NO, then explain briefly.
30
- β€’ "list all leaves" / "all paid leaves" β†’ enumerate every type with its count.
31
- β€’ "how to apply for sick leave?" β†’ give the step-by-step process.
32
-
33
- Think about WHAT the user actually needs, not just what topic they mentioned.
34
 
35
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
- RULE 2 β€” FORMAT (use the simplest format that fits)
37
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38
 
39
- β–  PARAGRAPH (default β€” use for most answers):
40
- 1-3 complete sentences. Bold key values with <b>bold</b>.
41
-
42
- β–  KEY-VALUE LIST (only for enumerating 4+ items):
43
- <b>Item Name:</b> value<br>
44
- <b>Item Name:</b> value<br>
45
-
46
- β–  STRUCTURED LIST (only for step-by-step procedures):
47
- <ul>
48
- <li><b>Step 1:</b> description.</li>
49
- <li><b>Step 2:</b> description.</li>
50
- </ul>
51
-
52
- Do NOT use a list when a paragraph works. Do NOT use <br> inside <li> tags.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
55
- RULE 3 β€” QUALITY
56
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
57
- βœ“ Every fact MUST exist in the Expert Data. No guessing.
58
- βœ“ If the answer is not in Expert Data β†’ say: "I don't have that information."
59
- βœ“ NEVER mention: "document", "handbook", "manual", or any source name.
60
- βœ“ Be concise. Do not over-explain or repeat yourself.
61
- βœ“ Tone: formal, warm, and confident β€” like a knowledgeable HR advisor.
62
- βœ“ Do NOT add greetings or closing phrases."""
63
-
64
- # ── Universal HTML Post-Processor ────────────────────────────────────────────
65
- # Called by ALL providers (Groq, Fireworks, OpenAI) to ensure clean output.
66
- def _clean_html(content: str) -> str:
67
- """Sanitize LLM HTML output: fix spacing, strip filler, clean tags."""
68
- # 1. Strip <think>...</think> blocks (Qwen3, DeepSeek-R1)
69
- content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
70
-
71
- # 2. Fix <br> placement: remove <br> that appears BEFORE </li>
72
- content = re.sub(r'<br\s*/?>\s*</li>', '</li>', content, flags=re.IGNORECASE)
73
-
74
- # 3. Remove <br> right before </ul> (trailing br after last li)
75
- content = re.sub(r'<br\s*/?>\s*</ul>', '</ul>', content, flags=re.IGNORECASE)
76
-
77
- # 4. Collapse double+ <br> into single <br>
78
- content = re.sub(r'(<br\s*/?>\s*){2,}', '<br>', content, flags=re.IGNORECASE)
79
-
80
- # 5. Strip leading/trailing whitespace and newlines
81
- content = content.strip()
82
-
83
- # 6. Strip leading conversational filler
84
- content = re.sub(
85
- r'^(Okay[,.]?\s*|Alright[,.]?\s*|Sure[,.]?\s*|Let\'s see[,.]?\s*|'
86
- r'Based on (?:the )?(?:provided |available )?(?:data|information|context)[,.]?\s*|'
87
- r'According to (?:the )?(?:provided |available )?(?:data|information)[,.]?\s*)',
88
- '', content, flags=re.IGNORECASE
89
- ).strip()
90
-
91
- # 7. Remove self-talk lines (thinking-model artifacts)
92
- lines = content.split('\n')
93
- filtered = []
94
- for line in lines:
95
- is_self_talk = bool(re.match(
96
- r'^\s*(I need to|I will|I should|I\'m going to|Let me|Now I|First,? I|'
97
- r'I\'ll|The user is asking|The question is about)',
98
- line, re.IGNORECASE
99
- ))
100
- if not is_self_talk:
101
- filtered.append(line)
102
- content = '\n'.join(filtered).strip()
103
-
104
- return content
105
 
106
 
107
  def _build_context(chunks: List[Dict[str, str]], max_words: int = 1500) -> str:
@@ -129,15 +117,12 @@ def _build_context(chunks: List[Dict[str, str]], max_words: int = 1500) -> str:
129
  class LLMService:
130
  def __init__(
131
  self,
132
- provider: str = "openai",
133
- groq_api_key: str = "",
134
- groq_model: str = "qwen/qwen3-32b",
135
- groq_rewrite_model: str = "llama-3.1-8b-instant",
136
- openai_api_key: str = "",
137
- openai_model: str = "gpt-4o-mini",
138
- openai_rewrite_model: str = "gpt-4o-mini",
139
- hf_api_key: str = "",
140
- hf_model: str = "meta-llama/Llama-3.1-8B-Instruct",
141
  fireworks_api_key: str = "",
142
  fireworks_model: str = "accounts/fireworks/models/qwen3-32b",
143
  fireworks_rewrite_model: str = "accounts/fireworks/models/llama-v3p1-8b-instruct",
@@ -147,9 +132,6 @@ class LLMService:
147
  self.groq_api_key = groq_api_key
148
  self.groq_model = groq_model
149
  self.groq_rewrite_model = groq_rewrite_model
150
- self.openai_api_key = openai_api_key
151
- self.openai_model = openai_model
152
- self.openai_rewrite_model = openai_rewrite_model
153
  self.hf_api_key = hf_api_key
154
  self.hf_model = hf_model
155
  self.fireworks_api_key = fireworks_api_key
@@ -197,12 +179,6 @@ Queries:"""
197
  "You output only search queries, one per line. No explanations, no numbering.",
198
  model_override=self.fireworks_rewrite_model
199
  )
200
- elif self.provider == "openai":
201
- resp = await self._call_openai(
202
- prompt, [],
203
- "You output only search queries, one per line. No explanations, no numbering.",
204
- model_override=self.openai_rewrite_model
205
- )
206
  else:
207
  resp = await self._call_groq(
208
  prompt, [],
@@ -214,10 +190,9 @@ Queries:"""
214
  for q in resp.split("\n")
215
  if q.strip() and len(q.strip()) > 3
216
  ]
217
- # Always ensure the original query is the FIRST and prioritized search term
218
- if query in queries:
219
- queries.remove(query)
220
- queries.insert(0, query)
221
  return queries[:3]
222
  except Exception:
223
  return [query]
@@ -246,8 +221,6 @@ Queries:"""
246
 
247
  if self.provider == "fireworks":
248
  return await self._call_fireworks(user_prompt, pruned_history, system_msg)
249
- elif self.provider == "openai":
250
- return await self._call_openai(user_prompt, pruned_history, system_msg)
251
  return await self._call_groq(user_prompt, pruned_history, system_msg)
252
 
253
  async def _call_groq(
@@ -297,8 +270,32 @@ Queries:"""
297
  data = resp.json()
298
  content = data["choices"][0]["message"]["content"].strip()
299
 
300
- # ── Post-Processing ──────────────────────────────────────────────────
301
- content = _clean_html(content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  return content
303
 
304
  async def _call_fireworks(
@@ -352,47 +349,30 @@ Queries:"""
352
  data = resp.json()
353
  content = data["choices"][0]["message"]["content"].strip()
354
 
355
- # ── Post-Processing ──────────────────────────────────────────────────
356
- content = _clean_html(content)
357
- return content
358
-
359
- async def _call_openai(
360
- self,
361
- user_prompt: str,
362
- history: List[Dict[str, str]],
363
- system_msg: str,
364
- model_override: str = None
365
- ) -> str:
366
- if not self.openai_api_key:
367
- return "Expert access required (OpenAI)."
368
-
369
- url = "https://api.openai.com/v1/chat/completions"
370
- headers = {
371
- "Authorization": f"Bearer {self.openai_api_key}",
372
- "Content-Type": "application/json"
373
- }
374
-
375
- messages = [{"role": "system", "content": system_msg}]
376
- for msg in history:
377
- messages.append({"role": msg["role"], "content": msg["content"]})
378
- messages.append({"role": "user", "content": user_prompt})
379
-
380
- target_model = model_override or self.openai_model
381
-
382
- payload = {
383
- "model": target_model,
384
- "temperature": 0.0,
385
- "max_tokens": 512,
386
- "messages": messages,
387
- }
388
-
389
- resp = await self._client.post(url, headers=headers, json=payload)
390
- if resp.status_code >= 400:
391
- _log.error("OpenAI API Error %s: %s", resp.status_code, resp.text)
392
- resp.raise_for_status()
393
- data = resp.json()
394
- content = data["choices"][0]["message"]["content"].strip()
395
 
396
- # ── Post-Processing (unified) ────────────────
397
- content = _clean_html(content)
398
  return content
 
9
  # MASTER SYSTEM PROMPT β€” Martechsol HR Assistant
10
  # Intelligence: Understand Intent β†’ Retrieve Facts β†’ Respond Precisely
11
  # ═══════════════════════════════════════════════════════════════════════
12
+ SYSTEM_PROMPT = """You are the Martechsol HR Assistant β€” intelligent, precise, and formal.
 
13
 
14
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
+ STEP 1 β€” UNDERSTAND THE INTENT
16
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
+ Read the question carefully. Identify the SINGLE core topic being asked. Apply intelligent defaults:
18
+ β€’ "timing" / "timings" (no context) β†’ office working hours ONLY β€” not payment or any other timing
19
+ β€’ "leaves" / "leave" (no context) β†’ leave names + day counts ONLY β€” NOT leave policies or eligibility
20
+ β€’ "paid leaves" / "all leaves" β†’ enumerate EVERY leave type with its name and count
21
+ β€’ "salary" / "pay" (no context) β†’ salary structure or amount β€” NOT payment date unless explicitly asked
22
+ β€’ "benefits" / "perks" / "allowances" β†’ list EVERY benefit with its name and value
23
+ β€’ "terminate" / "termination" β†’ resignation/termination procedure β€” NOT general policies
24
+ If a question has an obvious workplace context, always default to the most common interpretation.
25
 
26
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
27
+ STEP 2 β€” STRICT SCOPE DISCIPLINE
28
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29
+ Answer ONLY what was asked. NEVER expand into:
30
+ β€’ Policies, approval processes, eligibility rules, or consequences β€” unless user asks for policy/process
31
+ β€’ Related topics the user did not mention
32
+ β€’ Broad overviews when a specific fact was requested
33
+ β€’ Context that wasn't in the question
 
 
 
 
34
 
35
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
36
+ STEP 3 β€” FORMAT DECISION TABLE (MANDATORY β€” follow exactly)
37
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38
 
39
+ Use this table to pick the format. Do NOT deviate.
40
+
41
+ QUESTION TYPE β†’ FORMAT TO USE
42
+ ─────────────────────────────────────────────────────
43
+ "how many X leaves?" β†’ FORMAT A (one number only)
44
+ "what is X leave?" β†’ FORMAT A (one sentence, count + 1 key fact)
45
+ "how to apply / how to get X leave" β†’ FORMAT C (procedure for THAT leave ONLY)
46
+ "what are all leaves / list all X" β†’ FORMAT B (full exhaustive list)
47
+ "paid leaves / all paid leaves" β†’ FORMAT B (full exhaustive list)
48
+ "what is the policy for X?" β†’ FORMAT C (policy for THAT leave ONLY)
49
+ "and X?" (follow-up in conversation) β†’ FORMAT A (answer only the new X, not a full list)
50
+
51
+ FORMAT A β€” SINGLE FACT
52
+ Rule: ONE complete sentence. Maximum 25–30 words. Never cut mid-sentence.
53
+ Example: You are entitled to <b>8 Sick Leave</b> days per year.
54
+
55
+ FORMAT B β€” EXHAUSTIVE LIST
56
+ Trigger: ONLY when user says "all leaves", "all benefits", "list all X", "paid leaves", "what leaves".
57
+ Rule:
58
+ β€’ Include EVERY single item found β€” omitting even one is FORBIDDEN
59
+ β€’ One item per line: <b>Item Name:</b> value<br>
60
+ β€’ No intro sentence, no closing sentence, no extra commentary
61
+ Example:
62
+ <b>Casual Leave:</b> 10 days<br>
63
+ <b>Sick Leave:</b> 8 days<br>
64
+ <b>Annual Leave:</b> 14 days<br>
65
+ <b>Maternity Leave:</b> 90 days<br>
66
+ <b>Paternity Leave:</b> 3 days<br>
67
+ <b>Hajj Leave:</b> 30 days<br>
68
+ ...(list every item β€” do NOT stop early)
69
+
70
+ FORMAT C β€” BRIEF EXPLANATION (procedure / how-to)
71
+ Trigger: ONLY when user asks "how to apply", "how to get", "what is the process", "how does X work".
72
+ Rule:
73
+ β€’ Answer ONLY for the SPECIFIC leave type asked β€” do NOT list all leaves
74
+ β€’ Maximum 3 bullet points
75
+ β€’ Each bullet = one complete, factual sentence. No filler words.
76
+ Example for "how to get sick leave":
77
+ β€’ Notify your supervisor or HR within 2 hours of your shift start if absent due to illness.
78
+ β€’ Submit your leave application immediately upon returning to work.
79
+ β€’ Provide a medical certificate; failure to do so converts the leave to unpaid.
80
 
81
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
82
+ STRICT QUALITY RULES
83
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
84
+ βœ“ ZERO hallucination β€” every fact must exist in Expert Data only. No guessing.
85
+ βœ“ If not in Expert Data β†’ reply exactly: "I don't have that information."
86
+ βœ“ Never cut a sentence mid-way β€” always complete every sentence fully
87
+ βœ“ NEVER mention: "document", "handbook", "manual", "policy file", or any source reference
88
+ βœ“ Use <b>bold</b> for names, numbers, dates, leave types, and all key terms
89
+ βœ“ Use <br> between list items for clean vertical spacing
90
+ βœ“ Tone: formal, warm, and professional β€” never robotic, never chatty
91
+ βœ“ Do NOT add greetings, closings, or "Is there anything else?" type phrases"""
92
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
 
95
  def _build_context(chunks: List[Dict[str, str]], max_words: int = 1500) -> str:
 
117
  class LLMService:
118
  def __init__(
119
  self,
120
+ provider: str,
121
+ groq_api_key: str,
122
+ groq_model: str,
123
+ groq_rewrite_model: str,
124
+ hf_api_key: str,
125
+ hf_model: str,
 
 
 
126
  fireworks_api_key: str = "",
127
  fireworks_model: str = "accounts/fireworks/models/qwen3-32b",
128
  fireworks_rewrite_model: str = "accounts/fireworks/models/llama-v3p1-8b-instruct",
 
132
  self.groq_api_key = groq_api_key
133
  self.groq_model = groq_model
134
  self.groq_rewrite_model = groq_rewrite_model
 
 
 
135
  self.hf_api_key = hf_api_key
136
  self.hf_model = hf_model
137
  self.fireworks_api_key = fireworks_api_key
 
179
  "You output only search queries, one per line. No explanations, no numbering.",
180
  model_override=self.fireworks_rewrite_model
181
  )
 
 
 
 
 
 
182
  else:
183
  resp = await self._call_groq(
184
  prompt, [],
 
190
  for q in resp.split("\n")
191
  if q.strip() and len(q.strip()) > 3
192
  ]
193
+ # Always include original query
194
+ if query not in queries:
195
+ queries.append(query)
 
196
  return queries[:3]
197
  except Exception:
198
  return [query]
 
221
 
222
  if self.provider == "fireworks":
223
  return await self._call_fireworks(user_prompt, pruned_history, system_msg)
 
 
224
  return await self._call_groq(user_prompt, pruned_history, system_msg)
225
 
226
  async def _call_groq(
 
270
  data = resp.json()
271
  content = data["choices"][0]["message"]["content"].strip()
272
 
273
+ # ── Post-Processing: Strip all internal reasoning artifacts ──
274
+
275
+ # 1. Strip <think>...</think> blocks (Qwen3, DeepSeek-R1)
276
+ content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
277
+
278
+ # 2. Strip leading conversational filler (single line only, not entire content)
279
+ content = re.sub(
280
+ r'^(Okay[,.]?\s*|Alright[,.]?\s*|Sure[,.]?\s*|Let\'s see[,.]?\s*|'
281
+ r'Based on (?:the )?(?:provided |available )?(?:data|information|context)[,.]?\s*|'
282
+ r'According to (?:the )?(?:provided |available )?(?:data|information)[,.]?\s*)',
283
+ '', content, flags=re.IGNORECASE
284
+ ).strip()
285
+
286
+ # 3. Remove lines that are pure internal self-talk (only if they appear alone at start)
287
+ lines = content.split('\n')
288
+ filtered = []
289
+ for i, line in enumerate(lines):
290
+ is_self_talk = bool(re.match(
291
+ r'^\s*(I need to|I will|I should|I\'m going to|Let me|Now I|First,? I|'
292
+ r'I\'ll|The user is asking|The question is about)',
293
+ line, re.IGNORECASE
294
+ ))
295
+ if not is_self_talk:
296
+ filtered.append(line)
297
+ content = '\n'.join(filtered).strip()
298
+
299
  return content
300
 
301
  async def _call_fireworks(
 
349
  data = resp.json()
350
  content = data["choices"][0]["message"]["content"].strip()
351
 
352
+ # ── Post-Processing: identical pipeline as Groq path ──
353
+
354
+ # 1. Strip <think>...</think> blocks (Qwen3)
355
+ content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
356
+
357
+ # 2. Strip leading conversational filler
358
+ content = re.sub(
359
+ r'^(Okay[,.]?\s*|Alright[,.]?\s*|Sure[,.]?\s*|Let\'s see[,.]?\s*|'
360
+ r'Based on (?:the )?(?:provided |available )?(?:data|information|context)[,.]?\s*|'
361
+ r'According to (?:the )?(?:provided |available )?(?:data|information)[,.]?\s*)',
362
+ '', content, flags=re.IGNORECASE
363
+ ).strip()
364
+
365
+ # 3. Remove lines that are pure internal self-talk
366
+ lines = content.split('\n')
367
+ filtered = []
368
+ for line in lines:
369
+ is_self_talk = bool(re.match(
370
+ r'^\s*(I need to|I will|I should|I\'m going to|Let me|Now I|First,? I|'
371
+ r'I\'ll|The user is asking|The question is about)',
372
+ line, re.IGNORECASE
373
+ ))
374
+ if not is_self_talk:
375
+ filtered.append(line)
376
+ content = '\n'.join(filtered).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
 
 
378
  return content
app/services/rag_pipeline.py CHANGED
@@ -1,8 +1,7 @@
1
  import logging
2
  import hashlib
3
- import re
4
  from collections import OrderedDict
5
- from typing import Dict, List, Tuple
6
 
7
  from app.services.llm import LLMService
8
  from app.services.vector_store import FaissVectorStore
@@ -10,9 +9,10 @@ from app.services.reranker import RerankerService
10
 
11
  logger = logging.getLogger(__name__)
12
 
13
- # ── Answer cache DISABLED β€” prompt changes need to take effect immediately ───
14
- # Re-enable once prompt is finalized by uncommenting the OrderedDict line
15
- _CACHE_MAX = 0
 
16
  _answer_cache: OrderedDict = OrderedDict()
17
 
18
 
@@ -20,83 +20,84 @@ def _cache_key(message: str) -> str:
20
  """Returns a stable hash key for a normalized message string."""
21
  return hashlib.md5(message.lower().strip().encode()).hexdigest()
22
 
23
-
24
- # ── Generic Query Expander (no hardcoded topics) ────────────────────────────
25
- # Works for ANY domain automatically. Extracts core keywords from any query.
26
-
27
- _STOP_WORDS = frozenset({
28
- "can", "i", "we", "you", "do", "does", "did", "the", "a", "an",
29
- "is", "am", "are", "was", "were", "be", "been", "being",
30
- "tell", "me", "about", "how", "what", "where", "when", "why", "who",
31
- "please", "would", "could", "should", "will", "shall", "may", "might",
32
- "have", "has", "had", "to", "for", "of", "in", "on", "at", "by",
33
- "with", "from", "this", "that", "these", "those", "it", "its",
34
- "my", "your", "our", "their", "there", "here", "if", "or", "and",
35
- "but", "not", "no", "so", "get", "got", "give", "any",
36
- })
37
-
38
-
39
- def _normalize_compounds(text: str) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  """
41
- Expands compound words like 'pro-rata' to all their variants.
42
- Runs in microseconds β€” no network call required.
 
43
  """
44
- compounds = re.findall(r'\b\w+(?:[-\/]\w+)+\b', text.lower())
45
- extra = ""
46
- for word in compounds:
47
- joined = re.sub(r'[-\/]', '', word) # pro-rata β†’ prorata
48
- spaced = re.sub(r'[-\/]', ' ', word) # pro-rata β†’ pro rata
49
- extra += f" {joined} {spaced}"
50
- return text + extra
51
-
52
 
53
- def _extract_keywords(message: str) -> str:
54
- """Extract meaningful content words by removing stop words."""
55
- words = message.lower().split()
56
- keywords = [w for w in words if w not in _STOP_WORDS and len(w) > 1]
57
- return " ".join(keywords) if keywords else message.lower()
 
58
 
59
-
60
- def _expand_query(message: str) -> Tuple[List[str], str]:
61
- """
62
- Generic query expansion. No hardcoded topics β€” works for any domain.
63
-
64
- Strategy:
65
- 1. Original query (with compound normalization)
66
- 2. Keywords-only version (stop words removed)
67
- Both are always searched. The retriever handles the rest.
68
-
69
- Returns:
70
- queries – list of 1-2 search strings
71
- llm_question – the original user message (passed to the LLM as-is)
72
- """
73
- # Normalize compound words in the original
74
- normalized = _normalize_compounds(message)
75
-
76
- # Extract core keywords (removes "can", "i", "we", "do", etc.)
77
- keywords = _extract_keywords(message)
78
- keywords_normalized = _normalize_compounds(keywords)
79
-
80
- queries = [normalized]
81
-
82
- # Add keywords-only version if different from original
83
- if keywords_normalized.strip() != normalized.strip().lower():
84
- queries.append(keywords_normalized)
85
-
86
- # Always pass the original message to the LLM β€” let it understand naturally
87
- return queries, message
88
-
89
-
90
-
91
- def _is_greeting(message: str) -> bool:
92
- """Fast check for greetings to skip heavy RAG processing."""
93
- greetings = {"hi", "hello", "hey", "greetings", "good morning", "good afternoon", "good evening"}
94
- words = message.lower().strip().split()
95
- return any(w in greetings for w in words) and len(words) <= 2
96
 
97
 
98
  # RRF scores are small (e.g. 0.016–0.033), so threshold must be very low
99
- RELEVANCE_THRESHOLD = 0.001
100
  # Cross-encoder logit > 0 means > 50% relevance probability
101
  RERANK_THRESHOLD = 0.0
102
  # If ALL chunks fail rerank threshold, fall back to this many top chunks
@@ -122,67 +123,81 @@ class RAGPipeline:
122
  # ── Cache check: return instantly for repeated identical questions ──
123
  key = _cache_key(message)
124
  if key in _answer_cache:
125
- _answer_cache.move_to_end(key)
126
  logger.info("Cache HIT for: '%s'", message[:40])
127
  return _answer_cache[key]
128
 
129
- # ── Step 1: Instant local query expansion (microseconds, no LLM call) ──
130
- if _is_greeting(message):
131
- queries = [message]
132
- llm_question = message
133
- else:
134
- queries, llm_question = _expand_query(message)
135
- logger.info("Expanded queries for: '%s' β†’ %s", message[:40], queries)
136
 
137
- # ── Step 2: Batched hybrid search ──
138
- all_retrieved = self.vector_store.multi_search(queries, top_k=self.top_k)
 
 
 
 
 
 
 
 
139
 
140
  # ── Step 3: Initial relevance filter ──
141
  initial_chunks = [c for c in all_retrieved if c["score"] >= RELEVANCE_THRESHOLD]
142
 
143
  if not initial_chunks:
144
- logger.info("No relevant chunks for: '%s' β€” returning no-info response", message)
 
145
  reply = await self.llm_service.answer(
146
- question=llm_question,
147
  chunks=[],
148
  history=history,
149
  user_name=user_name
150
  )
151
  return {"reply": reply, "retrieved_chunks": []}
152
 
153
- # ── Step 4: Cross-Encoder Reranking ──
154
- rerank_query = llm_question # Use the enriched question for better reranking
 
 
 
 
 
155
  reranked_chunks = self.reranker.rerank(rerank_query, initial_chunks, top_n=self.max_context_chunks)
156
 
 
157
  final_chunks = [c for c in reranked_chunks if c.get("rerank_score", 0) > RERANK_THRESHOLD]
158
 
159
- # Smart fallback: don't say "no info" when chunks were retrieved
 
 
160
  if not final_chunks and reranked_chunks:
161
  final_chunks = reranked_chunks[:RERANK_FALLBACK_N]
162
  logger.info(
163
- "Rerank fallback β€” all below threshold (best=%.3f), using top %d",
164
- reranked_chunks[0].get("rerank_score", 0), len(final_chunks)
 
165
  )
166
 
167
  logger.info(
168
- "Pipeline: retrieved=%d β†’ filtered=%d β†’ reranked=%d β†’ final=%d (best=%.3f)",
169
  len(all_retrieved), len(initial_chunks), len(reranked_chunks), len(final_chunks),
170
  reranked_chunks[0].get("rerank_score", 0) if reranked_chunks else 0.0
171
  )
172
 
173
- # ── Step 5: Generate answer ──
174
  reply = await self.llm_service.answer(
175
- question=llm_question,
176
  chunks=final_chunks,
177
  history=history,
178
  user_name=user_name
179
  )
180
  result = {"reply": reply, "retrieved_chunks": final_chunks}
181
 
182
- # ── Populate LRU cache ──
183
  _answer_cache[key] = result
184
  if len(_answer_cache) > _CACHE_MAX:
185
- _answer_cache.popitem(last=False)
186
- logger.info("Stored answer for: '%s'", message[:40])
187
 
188
  return result
 
1
  import logging
2
  import hashlib
 
3
  from collections import OrderedDict
4
+ from typing import Dict, List
5
 
6
  from app.services.llm import LLMService
7
  from app.services.vector_store import FaissVectorStore
 
9
 
10
  logger = logging.getLogger(__name__)
11
 
12
+ # ── In-memory LRU answer cache ──────────────────────────────────────────────
13
+ # Keyed on MD5 of the normalized message. Holds up to 128 unique answers.
14
+ # Clears automatically on app restart (intentional β€” KB could be re-indexed).
15
+ _CACHE_MAX = 128
16
  _answer_cache: OrderedDict = OrderedDict()
17
 
18
 
 
20
  """Returns a stable hash key for a normalized message string."""
21
  return hashlib.md5(message.lower().strip().encode()).hexdigest()
22
 
23
+ # ── Local intent-based query expander ────────────────────────────────────────
24
+ # Replaces the async Groq API call (llama-3.1-8b-instant) with deterministic
25
+ # Python rules β€” runs in microseconds, saves 3–6s per request.
26
+ # Rules mirror the exact intent-detection logic from the old LLM prompt.
27
+ # IMPORTANT: ordered most-specific β†’ least-specific to prevent false matches.
28
+ _INTENT_MAP: List[tuple] = [
29
+ # ── Leave types (most specific first) ──
30
+ ("paid leave", ["casual leave sick leave annual leave maternity paternity hajj bereavement study unauthorized",
31
+ "paid leave types employee entitlement days count"]),
32
+ ("all leave", ["casual leave sick leave annual leave maternity paternity hajj bereavement study unauthorized",
33
+ "all leave types employee entitlement days count"]),
34
+ ("maternity", ["maternity leave days duration policy", "paid leave maternity employee"]),
35
+ ("paternity", ["paternity leave days duration policy", "paid leave paternity employee"]),
36
+ ("hajj", ["hajj leave days duration policy", "paid leave hajj religious"]),
37
+ ("bereavement", ["bereavement leave death family days", "compassionate leave policy"]),
38
+ ("sick leave", ["sick leave days count policy", "medical leave employee entitlement"]),
39
+ ("casual leave", ["casual leave days count policy", "leave types employee"]),
40
+ ("annual leave", ["annual leave days count policy", "leave entitlement per year"]),
41
+ ("study leave", ["study leave education policy days", "employee study leave entitlement"]),
42
+ ("leave", ["casual leave sick leave annual leave maternity paternity hajj bereavement study unauthorized",
43
+ "leave types employee entitlement days"]),
44
+ # ── Office timing ──
45
+ ("office hour", ["office working hours schedule", "workday start end time shift"]),
46
+ ("work hour", ["office working hours schedule", "workday start end time shift"]),
47
+ ("timing", ["office working hours schedule", "workday start end time shift hours"]),
48
+ ("schedule", ["office working hours schedule", "workday start end time"]),
49
+ # ── Salary / Pay ──
50
+ ("salary", ["salary structure payroll compensation amount", "monthly pay increment deduction"]),
51
+ ("pay", ["salary structure payroll compensation", "payment date schedule"]),
52
+ ("payroll", ["salary payroll structure compensation", "monthly pay deduction"]),
53
+ ("compensation", ["salary compensation structure payroll", "monthly pay amount"]),
54
+ # ── Benefits / Allowances ──
55
+ ("allowance", ["allowances perks medical bonuses fuel transport reimbursements", "employee benefits"]),
56
+ ("benefit", ["allowances perks medical bonuses reimbursements", "employee benefits privileges"]),
57
+ ("perk", ["employee perks benefits extras privileges", "allowances bonuses"]),
58
+ ("fuel", ["fuel allowance transport reimbursement conveyance petrol"]),
59
+ ("medical", ["medical allowance health insurance coverage", "medical benefits employee"]),
60
+ ("transport", ["transport allowance fuel reimbursement conveyance"]),
61
+ ("bonus", ["bonus performance incentive annual eid festival reward"]),
62
+ # ── Termination / Resignation ──
63
+ ("terminat", ["termination resignation procedure process steps", "notice period exit policy"]),
64
+ ("resign", ["resignation procedure steps notice period", "termination exit process"]),
65
+ ("notice period", ["notice period resignation termination duration days"]),
66
+ # ── Other HR topics ──
67
+ ("probat", ["probation period duration conditions employee"]),
68
+ ("overtime", ["overtime compensation extra hours payment policy"]),
69
+ ("increment", ["salary increment raise annual review appraisal performance"]),
70
+ ("appraisal", ["performance appraisal review increment salary raise"]),
71
+ ("attendance", ["attendance policy punctuality late arrival absenteeism"]),
72
+ ("dress code", ["dress code uniform attire professional clothing policy"]),
73
+ ("remote", ["remote work work from home WFH policy telecommute"]),
74
+ ("grievance", ["grievance complaint procedure policy employee rights"]),
75
+ ("discipline", ["disciplinary action policy procedure employee"]),
76
+ ("code of conduct", ["code of conduct policy employee behaviour rules"]),
77
+ ]
78
+
79
+
80
+ def _expand_query_locally(message: str) -> List[str]:
81
  """
82
+ Expands a user query into targeted search strings using keyword rules.
83
+ Replaces the async LLM rewrite call β€” runs in microseconds, zero API cost.
84
+ Mirrors the exact intent-detection logic previously in the llama-3.1-8b prompt.
85
  """
86
+ msg_lower = message.lower()
87
+ queries: List[str] = [message] # Original query always first
 
 
 
 
 
 
88
 
89
+ for keyword, variants in _INTENT_MAP:
90
+ if keyword in msg_lower:
91
+ for v in variants:
92
+ if v not in queries:
93
+ queries.append(v)
94
+ break # Only apply first (most specific) matching intent
95
 
96
+ return queries[:3] # Cap at 3, consistent with previous LLM behaviour
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
 
99
  # RRF scores are small (e.g. 0.016–0.033), so threshold must be very low
100
+ RELEVANCE_THRESHOLD = 0.01
101
  # Cross-encoder logit > 0 means > 50% relevance probability
102
  RERANK_THRESHOLD = 0.0
103
  # If ALL chunks fail rerank threshold, fall back to this many top chunks
 
123
  # ── Cache check: return instantly for repeated identical questions ──
124
  key = _cache_key(message)
125
  if key in _answer_cache:
126
+ _answer_cache.move_to_end(key) # Mark as recently used
127
  logger.info("Cache HIT for: '%s'", message[:40])
128
  return _answer_cache[key]
129
 
130
+ # ── Step 1: Expand query locally (no API call β€” instant) ──
131
+ queries = _expand_query_locally(message)
132
+ logger.info("Expanded %d queries for: '%s' β†’ %s", len(queries), message[:40], queries)
 
 
 
 
133
 
134
+ # ── Step 2: Collect unique chunks across all queries ──
135
+ seen_ids = set()
136
+ all_retrieved = []
137
+
138
+ for q in queries:
139
+ query_chunks = self.vector_store.search(q, top_k=self.top_k)
140
+ for chunk in query_chunks:
141
+ if chunk["id"] not in seen_ids:
142
+ all_retrieved.append(chunk)
143
+ seen_ids.add(chunk["id"])
144
 
145
  # ── Step 3: Initial relevance filter ──
146
  initial_chunks = [c for c in all_retrieved if c["score"] >= RELEVANCE_THRESHOLD]
147
 
148
  if not initial_chunks:
149
+ logger.info("No relevant chunks found for: '%s' β€” returning no-info response", message)
150
+ # Pass empty chunks; LLM is instructed to say "I don't have that information"
151
  reply = await self.llm_service.answer(
152
+ question=message,
153
  chunks=[],
154
  history=history,
155
  user_name=user_name
156
  )
157
  return {"reply": reply, "retrieved_chunks": []}
158
 
159
+ # ── Step 4: Deep reranking via Cross-Encoder ──
160
+ # Enrich the reranker query with the first expanded variant (queries[1])
161
+ # to give the cross-encoder broader semantic context.
162
+ rerank_query = message
163
+ if len(queries) > 1:
164
+ rerank_query = f"{message} {queries[1]}"
165
+
166
  reranked_chunks = self.reranker.rerank(rerank_query, initial_chunks, top_n=self.max_context_chunks)
167
 
168
+ # Filter by rerank score threshold
169
  final_chunks = [c for c in reranked_chunks if c.get("rerank_score", 0) > RERANK_THRESHOLD]
170
 
171
+ # ── Smart Fallback: if ALL chunks fail the threshold, use the top N anyway ──
172
+ # This prevents the bot from saying "I don't have info" when content WAS retrieved
173
+ # but the cross-encoder wasn't confident enough (e.g. paraphrased queries)
174
  if not final_chunks and reranked_chunks:
175
  final_chunks = reranked_chunks[:RERANK_FALLBACK_N]
176
  logger.info(
177
+ "Rerank fallback activated β€” all chunks below threshold (best score=%.3f), using top %d",
178
+ reranked_chunks[0].get("rerank_score", 0),
179
+ len(final_chunks)
180
  )
181
 
182
  logger.info(
183
+ "Pipeline: retrieved=%d β†’ relevance_filtered=%d β†’ reranked=%d β†’ final=%d (best_score=%.3f)",
184
  len(all_retrieved), len(initial_chunks), len(reranked_chunks), len(final_chunks),
185
  reranked_chunks[0].get("rerank_score", 0) if reranked_chunks else 0.0
186
  )
187
 
188
+ # ── Step 5: Generate answer with top-ranked context ──
189
  reply = await self.llm_service.answer(
190
+ question=message,
191
  chunks=final_chunks,
192
  history=history,
193
  user_name=user_name
194
  )
195
  result = {"reply": reply, "retrieved_chunks": final_chunks}
196
 
197
+ # ── Populate cache (evict oldest if at capacity) ──
198
  _answer_cache[key] = result
199
  if len(_answer_cache) > _CACHE_MAX:
200
+ _answer_cache.popitem(last=False) # Remove least-recently-used
201
+ logger.info("Cache MISS β€” stored answer for: '%s'", message[:40])
202
 
203
  return result
app/services/vector_store.py CHANGED
@@ -11,26 +11,6 @@ from rank_bm25 import BM25Okapi
11
  from app.services.chunker import chunk_documents
12
  from app.services.document_loader import load_documents
13
  from app.services.embeddings import EmbeddingService
14
- import re
15
- import asyncio
16
-
17
- def _analyze(text: str) -> List[str]:
18
- """Professional analyzer for RAG: lowercases, removes noise, and handles hyphens/punctuation."""
19
- # 1. Lowercase
20
- text = text.lower()
21
-
22
- # 2. Handle compound words: index "pro-rata" or "half/day" as joined "prorata", "halfday"
23
- # Find all words containing hyphens or slashes
24
- compounds = re.findall(r'\b\w+(?:[-\/]\w+)+\b', text)
25
- for word in compounds:
26
- joined = word.replace('-', '').replace('/', '')
27
- text += f" {joined}"
28
-
29
- # 3. Final tokenization: split by non-word characters to remove punctuation but keep words
30
- tokens = re.findall(r'\b\w+\b', text)
31
-
32
- # 4. Filter short noise tokens (optional, but keep for precision)
33
- return [t for t in tokens if len(t) > 1]
34
 
35
 
36
  class FaissVectorStore:
@@ -61,8 +41,7 @@ class FaissVectorStore:
61
 
62
  def _compute_docs_fingerprint(self) -> str:
63
  hasher = hashlib.sha256()
64
- # Include analyzer version and chunk settings in fingerprint so changing them triggers re-index
65
- hasher.update("v4_hybrid_normalizer".encode("utf-8")) # bump this to force re-index
66
  hasher.update(str(self.chunk_size_tokens).encode("utf-8"))
67
  hasher.update(str(self.chunk_overlap_tokens).encode("utf-8"))
68
 
@@ -110,7 +89,7 @@ class FaissVectorStore:
110
  index = faiss.IndexFlatIP(dim)
111
  index.add(vectors)
112
 
113
- tokenized_corpus = [_analyze(c["text"]) for c in chunks]
114
  bm25 = BM25Okapi(tokenized_corpus)
115
 
116
  self.index = index
@@ -128,76 +107,54 @@ class FaissVectorStore:
128
  )
129
 
130
  def search(self, query: str, top_k: int = 4) -> List[Dict[str, str]]:
131
- """Backwards compatibility for single query search."""
132
- return self.multi_search([query], top_k=top_k)
133
-
134
- def multi_search(self, queries: List[str], top_k: int = 4) -> List[Dict[str, str]]:
135
- """
136
- Professional batched search:
137
- 1. Encodes all queries in a single batch (fast).
138
- 2. Searches FAISS for all vectors at once.
139
- 3. Runs BM25 for all queries.
140
- 4. Combines everything with a unified RRF pass.
141
- """
142
- if self.index is None or not self.metadata or self.bm25 is None or not queries:
143
  self.last_retrieved = []
144
  return []
145
 
146
- # 1. Batched Embedding β€” use encode() which handles any list size efficiently
147
- query_vectors = self.embedding_service.encode(list(queries))
148
- # Ensure shape is always 2D: (N, dim)
149
- if query_vectors.ndim == 1:
150
- query_vectors = query_vectors.reshape(1, -1)
 
 
151
 
152
- # 2. Batched FAISS Search
153
- # faiss_indices shape: (len(queries), top_k*2)
154
- faiss_scores, faiss_indices = self.index.search(
155
- np.asarray(query_vectors, dtype=np.float32),
156
- top_k * 3 # Wider pool for better merging
157
- )
158
 
159
- # 3. Batched BM25 Search
160
- # Combine all tokenized queries
161
  k = 60
162
  rrf_scores = {}
163
-
164
- for q_idx, query in enumerate(queries):
165
- # FAISS results for this query
166
- for rank, idx in enumerate(faiss_indices[q_idx]):
167
- if idx < 0 or idx >= len(self.metadata):
168
- continue
169
- # Add to RRF score
170
- rrf_scores[idx] = rrf_scores.get(idx, 0.0) + (1.0 / (k + rank + 1))
171
-
172
- # BM25 results for this query
173
- tokenized_query = _analyze(query)
174
- bm25_scores = self.bm25.get_scores(tokenized_query)
175
- bm25_top_indices = np.argsort(bm25_scores)[::-1][:top_k * 3]
176
 
177
- for rank, idx in enumerate(bm25_top_indices):
178
- if idx < 0 or idx >= len(self.metadata) or bm25_scores[idx] <= 0:
179
- continue
180
- rrf_scores[idx] = rrf_scores.get(idx, 0.0) + (1.0 / (k + rank + 1))
 
181
 
182
- # 4. Final Ranking
183
- if not rrf_scores:
184
- self.last_retrieved = []
185
- return []
186
-
187
- sorted_indices = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True)[:top_k * 2]
188
 
189
  results: List[Dict[str, str]] = []
190
  for idx in sorted_indices:
191
  chunk = self.metadata[idx]
 
 
192
  results.append(
193
  {
194
  "id": chunk["id"],
195
  "source": chunk["source"],
196
  "text": chunk["text"],
197
- "score": float(rrf_scores[idx]),
198
  }
199
  )
200
-
201
  self.last_retrieved = results
202
  return results
203
 
 
11
  from app.services.chunker import chunk_documents
12
  from app.services.document_loader import load_documents
13
  from app.services.embeddings import EmbeddingService
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
 
16
  class FaissVectorStore:
 
41
 
42
  def _compute_docs_fingerprint(self) -> str:
43
  hasher = hashlib.sha256()
44
+ # Include chunk settings in fingerprint so changing them triggers re-index
 
45
  hasher.update(str(self.chunk_size_tokens).encode("utf-8"))
46
  hasher.update(str(self.chunk_overlap_tokens).encode("utf-8"))
47
 
 
89
  index = faiss.IndexFlatIP(dim)
90
  index.add(vectors)
91
 
92
+ tokenized_corpus = [c["text"].lower().split() for c in chunks]
93
  bm25 = BM25Okapi(tokenized_corpus)
94
 
95
  self.index = index
 
107
  )
108
 
109
  def search(self, query: str, top_k: int = 4) -> List[Dict[str, str]]:
110
+ if self.index is None or not self.metadata or self.bm25 is None:
 
 
 
 
 
 
 
 
 
 
 
111
  self.last_retrieved = []
112
  return []
113
 
114
+ # Vector Search (FAISS)
115
+ query_vec = self.embedding_service.encode_query(query)
116
+ faiss_scores, faiss_indices = self.index.search(np.asarray(query_vec, dtype=np.float32), top_k * 2)
117
+
118
+ # BM25 Search
119
+ tokenized_query = query.lower().split()
120
+ bm25_scores = self.bm25.get_scores(tokenized_query)
121
 
122
+ # Get top indices for BM25
123
+ bm25_top_indices = np.argsort(bm25_scores)[::-1][:top_k * 2]
 
 
 
 
124
 
125
+ # Combine using Reciprocal Rank Fusion (RRF)
126
+ # RRF_score = 1 / (k + rank)
127
  k = 60
128
  rrf_scores = {}
129
+
130
+ # Add FAISS ranks
131
+ for rank, idx in enumerate(faiss_indices[0]):
132
+ if idx < 0 or idx >= len(self.metadata):
133
+ continue
134
+ rrf_scores[idx] = rrf_scores.get(idx, 0.0) + (1.0 / (k + rank + 1))
 
 
 
 
 
 
 
135
 
136
+ # Add BM25 ranks
137
+ for rank, idx in enumerate(bm25_top_indices):
138
+ if idx < 0 or idx >= len(self.metadata):
139
+ continue
140
+ rrf_scores[idx] = rrf_scores.get(idx, 0.0) + (1.0 / (k + rank + 1))
141
 
142
+ # Sort by combined RRF score
143
+ sorted_indices = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True)[:top_k]
 
 
 
 
144
 
145
  results: List[Dict[str, str]] = []
146
  for idx in sorted_indices:
147
  chunk = self.metadata[idx]
148
+ # Fetch the original FAISS score for fallback relevance checking if needed, or just use RRF score
149
+ # Note: RRF scores are small (e.g. 0.03), so we must adjust the threshold in rag_pipeline
150
  results.append(
151
  {
152
  "id": chunk["id"],
153
  "source": chunk["source"],
154
  "text": chunk["text"],
155
+ "score": float(rrf_scores[idx]), # RRF score
156
  }
157
  )
 
158
  self.last_retrieved = results
159
  return results
160
 
app/ui_gradio.py CHANGED
@@ -34,17 +34,11 @@ vector_store = FaissVectorStore(
34
  )
35
  llm_service = LLMService(
36
  provider=settings.llm_provider,
37
- openai_api_key=settings.openai_api_key,
38
- openai_model=settings.openai_model,
39
- openai_rewrite_model=settings.openai_rewrite_model,
40
  groq_api_key=settings.groq_api_key,
41
  groq_model=settings.groq_model,
42
  groq_rewrite_model=settings.groq_rewrite_model,
43
  hf_api_key=settings.hf_api_key,
44
  hf_model=settings.hf_model,
45
- fireworks_api_key=settings.fireworks_api_key,
46
- fireworks_model=settings.fireworks_model,
47
- fireworks_rewrite_model=settings.fireworks_rewrite_model,
48
  timeout_s=settings.request_timeout_s,
49
  )
50
  reranker_service = RerankerService(settings.reranker_model)
@@ -126,12 +120,8 @@ async def chat_fn(message: str, chat_history: List[Dict[str, str]], session_id:
126
  reply = await _get_reply(message, original_history)
127
  except httpx.HTTPStatusError as e:
128
  logger.error(f"HTTP Error: {e.response.status_code} - {e.response.text}")
129
- try:
130
- error_data = e.response.json()
131
- api_msg = error_data.get("error", {}).get("message", e.response.text)
132
- except Exception:
133
- api_msg = e.response.text
134
- reply = f"⚠️ API Error ({e.response.status_code}): {api_msg}"
135
  except Exception as e:
136
  logger.error(f"Unexpected error: {str(e)}\n{traceback.format_exc()}")
137
  reply = f"⚠️ Oops! Something went wrong."
@@ -219,45 +209,7 @@ custom_css = """
219
  border: none !important;
220
  }
221
 
222
- /* Remove top spacing before the first element in every chat bubble. */
223
- .message > *:first-child,
224
- .message p:first-child,
225
- .message ul:first-child,
226
- .message ol:first-child,
227
- .prose > p:first-child,
228
- .prose > ul:first-child,
229
- .prose > ol:first-child,
230
- .prose > *:first-child,
231
- .md p:first-child,
232
- .md > *:first-child,
233
- .md ul:first-child,
234
- .bubble-wrap > *:first-child,
235
- .bubble-wrap p:first-child,
236
- [data-testid="bot"] p:first-child,
237
- [data-testid="bot"] > div > *:first-child,
238
- .bot p:first-child,
239
- .bot > div > *:first-child,
240
- .svelte-1s78gfg p:first-child {
241
- margin-top: 0 !important;
242
- padding-top: 0 !important;
243
- }
244
-
245
- /* ── Fix ul/li spacing inside chat bubbles ── */
246
- .message ul, .prose ul, .md ul, .bot ul,
247
- [data-testid="bot"] ul {
248
- margin: 0 !important;
249
- padding-left: 1.2em !important;
250
- padding-top: 0 !important;
251
- padding-bottom: 0 !important;
252
- }
253
- .message li, .prose li, .md li, .bot li,
254
- [data-testid="bot"] li {
255
- margin: 2px 0 !important;
256
- padding: 0 !important;
257
- line-height: 1.5 !important;
258
- }
259
-
260
- /* Aggressively hide the footer */
261
  footer, .footer, footer * {
262
  display: none !important;
263
  visibility: hidden !important;
@@ -267,9 +219,8 @@ footer, .footer, footer * {
267
  }
268
  """
269
 
270
- # JS: runs immediately on page load β€” generates/loads session_id from localStorage.
271
- # Also injects a MutationObserver to eliminate the top-spacing Gradio adds inside
272
- # each chat bubble, regardless of internal class names.
273
  _SESSION_JS = """
274
  async () => {
275
  // ── Session ID: generate once, persist forever in localStorage ──
@@ -281,29 +232,6 @@ async () => {
281
  // Write the session_id into the hidden Gradio textbox
282
  const el = document.getElementById('session-id-box')?.querySelector('textarea');
283
  if (el) { el.value = sid; el.dispatchEvent(new Event('input', {bubbles:true})); }
284
-
285
- // ── Fix top spacing inside chat bubbles ──────────────────────────
286
- // Gradio wraps message content in a <p> or block element with margin-top.
287
- // We walk every rendered message and zero the first child's top margin/padding.
288
- function fixBubbleSpacing() {
289
- // Select all rendered message rows inside the chatbot
290
- const chatbot = document.getElementById('chatbot-window');
291
- if (!chatbot) return;
292
- chatbot.querySelectorAll('div, article, section').forEach(el => {
293
- const first = el.firstElementChild;
294
- if (first && (first.tagName === 'P' || first.tagName === 'UL' || first.tagName === 'OL' || first.tagName === 'B')) {
295
- first.style.setProperty('margin-top', '0', 'important');
296
- first.style.setProperty('padding-top', '0', 'important');
297
- }
298
- });
299
- }
300
-
301
- // Run once immediately, then watch for new messages
302
- fixBubbleSpacing();
303
- const observer = new MutationObserver(fixBubbleSpacing);
304
- const target = document.getElementById('chatbot-window') || document.body;
305
- observer.observe(target, { childList: true, subtree: true });
306
-
307
  return sid;
308
  }
309
  """
 
34
  )
35
  llm_service = LLMService(
36
  provider=settings.llm_provider,
 
 
 
37
  groq_api_key=settings.groq_api_key,
38
  groq_model=settings.groq_model,
39
  groq_rewrite_model=settings.groq_rewrite_model,
40
  hf_api_key=settings.hf_api_key,
41
  hf_model=settings.hf_model,
 
 
 
42
  timeout_s=settings.request_timeout_s,
43
  )
44
  reranker_service = RerankerService(settings.reranker_model)
 
120
  reply = await _get_reply(message, original_history)
121
  except httpx.HTTPStatusError as e:
122
  logger.error(f"HTTP Error: {e.response.status_code} - {e.response.text}")
123
+ reply = "⚠️ Rate limit reached. Please slow down a bit!" if e.response.status_code == 429 \
124
+ else "⚠️ I encountered an error. Please try again in a few seconds."
 
 
 
 
125
  except Exception as e:
126
  logger.error(f"Unexpected error: {str(e)}\n{traceback.format_exc()}")
127
  reply = f"⚠️ Oops! Something went wrong."
 
209
  border: none !important;
210
  }
211
 
212
+ /* Aggressively hide the footer, including HF injected footers if inside the container */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  footer, .footer, footer * {
214
  display: none !important;
215
  visibility: hidden !important;
 
219
  }
220
  """
221
 
222
+ # JS: runs immediately on page load β€” generates/loads session_id from localStorage
223
+ # and restores previous chat history. Completely non-blocking.
 
224
  _SESSION_JS = """
225
  async () => {
226
  // ── Session ID: generate once, persist forever in localStorage ──
 
232
  // Write the session_id into the hidden Gradio textbox
233
  const el = document.getElementById('session-id-box')?.querySelector('textarea');
234
  if (el) { el.value = sid; el.dispatchEvent(new Event('input', {bubbles:true})); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  return sid;
236
  }
237
  """
test_openai.py β†’ test_groq.py RENAMED
File without changes