Martechsol commited on
Commit
cdcad66
·
1 Parent(s): 227b99a

Restore: Revert codebase to stable May 14th 6 PM state

Browse files
.env.example CHANGED
@@ -1,19 +1,34 @@
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=
 
 
 
 
 
 
 
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
app/admin/templates/admin.html CHANGED
@@ -7,6 +7,7 @@
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,10 +313,23 @@
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,10 +349,6 @@
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,6 +640,163 @@
630
  color: #fca5a5
631
  }
632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
633
  /* ── Animations ── */
634
  @keyframes fadeUp {
635
  from {
@@ -746,18 +913,12 @@
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,6 +968,55 @@
807
 
808
  <div class="toast" id="toast"></div>
809
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
810
  <script>
811
  (function () {
812
  const CREDS = { user: 'martech_admin', pass: 'martech_admin_303' };
@@ -1111,38 +1321,135 @@
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
  })();
 
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
  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
  color: var(--text)
350
  }
351
 
 
 
 
 
352
  .search-wrap {
353
  padding: 12px 16px;
354
  border-bottom: 1px solid var(--border)
 
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
  </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
 
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
  } 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
  })();
app/core/config.py CHANGED
@@ -31,13 +31,13 @@ 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
38
  chunk_overlap_tokens: int = 80
39
- top_k: int = Field(default=15, alias="TOP_K") # Increased to 15 to capture more candidates for reranking
40
- max_context_chunks: int = 12 # Increased to 12 to ensure exhaustive lists (like all leave types) are fully captured
41
  request_timeout_s: float = 20.0
42
  cors_allow_origins: str = Field(default="*", alias="CORS_ALLOW_ORIGINS")
43
  api_key: str = Field(default="", alias="API_KEY")
 
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
38
  chunk_overlap_tokens: int = 80
39
+ top_k: int = Field(default=8, alias="TOP_K")
40
+ max_context_chunks: int = 4 # _build_context() caps at 1500 words anyway; 4 chunks is sufficient
41
  request_timeout_s: float = 20.0
42
  cors_allow_origins: str = Field(default="*", alias="CORS_ALLOW_ORIGINS")
43
  api_key: str = Field(default="", alias="API_KEY")
app/main.py CHANGED
@@ -34,6 +34,9 @@ vector_store = FaissVectorStore(
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,
@@ -42,9 +45,6 @@ llm_service = LLMService(
42
  fireworks_api_key=settings.fireworks_api_key,
43
  fireworks_model=settings.fireworks_model,
44
  fireworks_rewrite_model=settings.fireworks_rewrite_model,
45
- openai_api_key=settings.openai_api_key,
46
- openai_model=settings.openai_model,
47
- openai_rewrite_model=settings.openai_rewrite_model,
48
  timeout_s=settings.request_timeout_s,
49
  )
50
  reranker_service = RerankerService(settings.reranker_model)
@@ -176,10 +176,12 @@ async def chat(
176
 
177
  error_msg = "⚠️ Oops! Something went wrong."
178
  if isinstance(e, httpx.HTTPStatusError):
179
- if e.response.status_code == 429:
180
- error_msg = "⚠️ Rate limit reached. Please slow down a bit!"
181
- else:
182
- error_msg = "⚠️ I encountered an error. Please try again in a few seconds."
 
 
183
 
184
  return ChatResponse(reply=error_msg, retrieved_chunks=[])
185
 
 
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,
 
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)
 
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
 
app/services/llm.py CHANGED
@@ -9,82 +9,102 @@ _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 — intelligent, precise, and formal.
 
13
 
14
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
15
- CORE REFERENCE DATA (Leave Entitlements)
16
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
17
- Casual Leave: 10 days
18
- Sick Leave: 8 days
19
- Annual Leave: 14 days
20
- • Maternity Leave: 90 days (Female only)
21
- • Paternity Leave: 3 days (Male only)
22
- • Bereavement Leave: 3 days
23
- • Hajj Leave: 30 days
24
 
25
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26
- STEP 1 — UNDERSTAND THE INTENT
27
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28
- Read the question carefully. Identify the core topics (could be multiple). Apply workplace logic:
29
- • "timing" / "timings" (no context) → office working hours ONLY.
30
- • "leaves" / "leave" (no context) → leave names + day counts ONLY.
31
- • "paid leaves" / "all leaves" → enumerate EVERY leave type with its name and count.
32
- • "salary" / "pay" (no context) → salary structure or amount.
33
- • "benefits" / "perks" / "allowances" → list EVERY benefit with its name and value.
34
- • "terminate" / "termination" → resignation/termination procedure.
35
- If a question has an obvious workplace context, always default to the most common interpretation.
36
 
37
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
38
- STEP 2 SCOPE & INTELLIGENCE
39
- ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40
- Answer ONLY what was asked using the Expert Data. Use intelligence to bridge logical gaps:
41
- EXHAUSTIVE LISTS: For "all leaves" or "paid leaves," you MUST list EVERY single leave type found in the Expert Data. Omitting even one (like **Annual Leave** or **Hajj Leave**) is a failure. Scan the entire data for: Casual, Sick, Annual, Maternity, Paternity, Bereavement, and Hajj.
42
- • ALTERNATIVES: If a user is ineligible (e.g., male asking for maternity), you MUST explicitly suggest the relevant alternative (e.g., **Paternity Leave**) from the data.
43
- CONCLUSIONS: If the user mentions a specific number (e.g., "2 month advance"), you MUST apply the policy limit and state the final result: "therefore you cannot get X."
44
- • MANAGEMENT: If the data states a manager "approves" or "manages" leaves, it implies they have the authority to reject, cancel, or postpone them based on business needs.
45
- • NOTIFICATION: If the data describes a "requirement" to inform HR, conclude that failure to do so is a violation of that process.
46
- • AUTHORITY: Keep answers direct. No conversational fillers or source references.
47
- • CACHE VERSION: v3 (intelligence boost active).
48
 
49
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
50
- STEP 3 — FORMAT DECISION TABLE (MANDATORY)
51
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
52
 
53
- QUESTION TYPE → FORMAT TO USE
54
- ─────────────────────────────────────────────────────
55
- "how many X leaves?" → FORMAT A (one number only)
56
- "what is X leave / eligibility?" → FORMAT A (one sentence, count + 1 key fact)
57
- "how to apply / how to get X leave" → FORMAT C (procedure for THAT leave ONLY)
58
- "what are all leaves / list all X" → FORMAT B (full exhaustive list)
59
- "paid leaves / all paid leaves" → FORMAT B (full exhaustive list)
60
- "what is the policy for X?" → FORMAT C (policy for THAT leave ONLY)
61
- "and X?" (follow-up in conversation) → FORMAT A (answer only the new X)
62
 
63
- FORMAT A SINGLE FACT
64
- Rule: ONE complete sentence. Maximum 25–30 words. Never cut mid-sentence.
65
- Example: You are entitled to <b>8 Sick Leave</b> days per year.
66
 
67
- FORMAT B EXHAUSTIVE LIST
68
- Rule: Include EVERY single item found. No intro/outro. One item per line: <b>Item:</b> value<br>
 
 
 
69
 
70
- FORMAT C BRIEF EXPLANATION (procedure / how-to)
71
- Rule: Answer ONLY for the SPECIFIC topic. Maximum 3 bullet points. No filler.
72
 
73
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
74
- STRICT QUALITY RULES
75
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
76
- ZERO hallucination — every fact must exist in Expert Data only. No guessing.
77
- ✓ If not in Expert Data → reply exactly: "I don't have that information."
78
- Never cut a sentence mid-way always complete every sentence fully
79
- NEVER mention: "document", "handbook", "manual", "policy file", or any source reference
80
- Use <b>bold</b> for names, numbers, dates, leave types, and all key terms
81
- Use <br> between list items for clean vertical spacing
82
- ✓ Tone: formal, warm, and professional — never robotic, never chatty
83
- Do NOT add greetings, closings, or "Is there anything else?" type phrases"""
84
-
85
-
86
-
87
- def _build_context(chunks: List[Dict[str, str]], max_words: int = 3000) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  """Combines retrieved chunks into a clean context string, capped at max_words.
89
  Prevents TPM spikes when chunks are unexpectedly large."""
90
  if not chunks:
@@ -109,32 +129,32 @@ def _build_context(chunks: List[Dict[str, str]], max_words: int = 3000) -> str:
109
  class LLMService:
110
  def __init__(
111
  self,
112
- provider: str,
113
- groq_api_key: str,
114
- groq_model: str,
115
- groq_rewrite_model: str,
116
- hf_api_key: str,
117
- hf_model: str,
118
- fireworks_api_key: str = "",
119
- fireworks_model: str = "accounts/fireworks/models/qwen3-32b",
120
- fireworks_rewrite_model: str = "accounts/fireworks/models/llama-v3p1-8b-instruct",
121
  openai_api_key: str = "",
122
  openai_model: str = "gpt-4o-mini",
123
  openai_rewrite_model: str = "gpt-4o-mini",
 
 
 
 
 
124
  timeout_s: float = 20.0,
125
  ) -> None:
126
  self.provider = provider.lower().strip()
127
  self.groq_api_key = groq_api_key
128
  self.groq_model = groq_model
129
  self.groq_rewrite_model = groq_rewrite_model
 
 
 
130
  self.hf_api_key = hf_api_key
131
  self.hf_model = hf_model
132
  self.fireworks_api_key = fireworks_api_key
133
  self.fireworks_model = fireworks_model
134
  self.fireworks_rewrite_model = fireworks_rewrite_model
135
- self.openai_api_key = openai_api_key
136
- self.openai_model = openai_model
137
- self.openai_rewrite_model = openai_rewrite_model
138
  self.timeout_s = timeout_s
139
  # Persistent client — reused across all calls; eliminates TCP+TLS handshake per request
140
  self._client = httpx.AsyncClient(
@@ -194,9 +214,10 @@ Queries:"""
194
  for q in resp.split("\n")
195
  if q.strip() and len(q.strip()) > 3
196
  ]
197
- # Always include original query
198
- if query not in queries:
199
- queries.append(query)
 
200
  return queries[:3]
201
  except Exception:
202
  return [query]
@@ -225,7 +246,7 @@ Queries:"""
225
 
226
  if self.provider == "fireworks":
227
  return await self._call_fireworks(user_prompt, pruned_history, system_msg)
228
- if self.provider == "openai":
229
  return await self._call_openai(user_prompt, pruned_history, system_msg)
230
  return await self._call_groq(user_prompt, pruned_history, system_msg)
231
 
@@ -276,32 +297,8 @@ Queries:"""
276
  data = resp.json()
277
  content = data["choices"][0]["message"]["content"].strip()
278
 
279
- # ── Post-Processing: Strip all internal reasoning artifacts ──
280
-
281
- # 1. Strip <think>...</think> blocks (Qwen3, DeepSeek-R1)
282
- content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
283
-
284
- # 2. Strip leading conversational filler (single line only, not entire content)
285
- content = re.sub(
286
- r'^(Okay[,.]?\s*|Alright[,.]?\s*|Sure[,.]?\s*|Let\'s see[,.]?\s*|'
287
- r'Based on (?:the )?(?:provided |available )?(?:data|information|context)[,.]?\s*|'
288
- r'According to (?:the )?(?:provided |available )?(?:data|information)[,.]?\s*)',
289
- '', content, flags=re.IGNORECASE
290
- ).strip()
291
-
292
- # 3. Remove lines that are pure internal self-talk (only if they appear alone at start)
293
- lines = content.split('\n')
294
- filtered = []
295
- for i, line in enumerate(lines):
296
- is_self_talk = bool(re.match(
297
- r'^\s*(I need to|I will|I should|I\'m going to|Let me|Now I|First,? I|'
298
- r'I\'ll|The user is asking|The question is about)',
299
- line, re.IGNORECASE
300
- ))
301
- if not is_self_talk:
302
- filtered.append(line)
303
- content = '\n'.join(filtered).strip()
304
-
305
  return content
306
 
307
  async def _call_fireworks(
@@ -355,32 +352,8 @@ Queries:"""
355
  data = resp.json()
356
  content = data["choices"][0]["message"]["content"].strip()
357
 
358
- # ── Post-Processing: identical pipeline as Groq path ──
359
-
360
- # 1. Strip <think>...</think> blocks (Qwen3)
361
- content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
362
-
363
- # 2. Strip leading conversational filler
364
- content = re.sub(
365
- r'^(Okay[,.]?\s*|Alright[,.]?\s*|Sure[,.]?\s*|Let\'s see[,.]?\s*|'
366
- r'Based on (?:the )?(?:provided |available )?(?:data|information|context)[,.]?\s*|'
367
- r'According to (?:the )?(?:provided |available )?(?:data|information)[,.]?\s*)',
368
- '', content, flags=re.IGNORECASE
369
- ).strip()
370
-
371
- # 3. Remove lines that are pure internal self-talk
372
- lines = content.split('\n')
373
- filtered = []
374
- for line in lines:
375
- is_self_talk = bool(re.match(
376
- r'^\s*(I need to|I will|I should|I\'m going to|Let me|Now I|First,? I|'
377
- r'I\'ll|The user is asking|The question is about)',
378
- line, re.IGNORECASE
379
- ))
380
- if not is_self_talk:
381
- filtered.append(line)
382
- content = '\n'.join(filtered).strip()
383
-
384
  return content
385
 
386
  async def _call_openai(
@@ -409,7 +382,7 @@ Queries:"""
409
  payload = {
410
  "model": target_model,
411
  "temperature": 0.0,
412
- "max_tokens": 800,
413
  "messages": messages,
414
  }
415
 
@@ -420,26 +393,6 @@ Queries:"""
420
  data = resp.json()
421
  content = data["choices"][0]["message"]["content"].strip()
422
 
423
- # ── Post-Processing ──
424
- # Strip leading conversational filler
425
- content = re.sub(
426
- r'^(Okay[,.]?\s*|Alright[,.]?\s*|Sure[,.]?\s*|Let\'s see[,.]?\s*|'
427
- r'Based on (?:the )?(?:provided |available )?(?:data|information|context)[,.]?\s*|'
428
- r'According to (?:the )?(?:provided |available )?(?:data|information)[,.]?\s*)',
429
- '', content, flags=re.IGNORECASE
430
- ).strip()
431
-
432
- # Remove lines that are pure internal self-talk
433
- lines = content.split('\n')
434
- filtered = []
435
- for line in lines:
436
- is_self_talk = bool(re.match(
437
- r'^\s*(I need to|I will|I should|I\'m going to|Let me|Now I|First,? I|'
438
- r'I\'ll|The user is asking|The question is about)',
439
- line, re.IGNORECASE
440
- ))
441
- if not is_self_talk:
442
- filtered.append(line)
443
- content = '\n'.join(filtered).strip()
444
-
445
  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 — 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:
108
  """Combines retrieved chunks into a clean context string, capped at max_words.
109
  Prevents TPM spikes when chunks are unexpectedly large."""
110
  if not chunks:
 
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",
144
  timeout_s: float = 20.0,
145
  ) -> None:
146
  self.provider = provider.lower().strip()
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
156
  self.fireworks_model = fireworks_model
157
  self.fireworks_rewrite_model = fireworks_rewrite_model
 
 
 
158
  self.timeout_s = timeout_s
159
  # Persistent client — reused across all calls; eliminates TCP+TLS handshake per request
160
  self._client = httpx.AsyncClient(
 
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
 
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
 
 
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
  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(
 
382
  payload = {
383
  "model": target_model,
384
  "temperature": 0.0,
385
+ "max_tokens": 512,
386
  "messages": messages,
387
  }
388
 
 
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
app/services/rag_pipeline.py CHANGED
@@ -1,7 +1,8 @@
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,109 +10,97 @@ from app.services.reranker import RerankerService
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
 
19
  def _cache_key(message: str) -> str:
20
- """Returns a stable hash key for a normalized message string (v3 for intelligence boost)."""
21
- return hashlib.md5(f"v3-{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",
31
- "maternity paternity hajj bereavement",
32
- "paid leave types employee entitlement days count",
33
- "unauthorized absence study leave policy"]),
34
- ("all leave", ["casual leave sick leave annual leave maternity paternity hajj bereavement study unauthorized",
35
- "all leave types employee entitlement days count"]),
36
- ("maternity", ["maternity leave days duration policy eligibility female", "paid leave maternity employee"]),
37
- ("paternity", ["paternity leave days duration policy eligibility male", "paid leave paternity employee"]),
38
- ("hajj", ["hajj leave days duration policy notification HR visa", "paid leave hajj religious requirement"]),
39
- ("bereavement", ["bereavement leave death family days notification", "compassionate leave policy"]),
40
- ("sick leave", ["sick leave days count policy medical certificate", "medical leave employee entitlement"]),
41
- ("casual leave", ["casual leave days count policy notice period", "leave types employee rules"]),
42
- ("annual leave", ["annual leave days count policy accumulation encashment", "leave entitlement per year"]),
43
- ("study leave", ["study leave education policy days qualification", "employee study leave entitlement"]),
44
- ("leave", ["casual leave sick leave annual leave maternity paternity hajj bereavement study unauthorized",
45
- "leave types employee entitlement days eligibility"]),
46
- # ── Management / Rights / Rejection (New Intelligence Layer) ──
47
- ("reject", ["management rights leave approval rejection authority cancel postpone", "leave application rules"]),
48
- ("refuse", ["management rights leave approval rejection authority cancel postpone", "leave application rules"]),
49
- ("cancel", ["cancel leave policy management rights postpone approved leave", "leave rules"]),
50
- ("manager", ["management rights authority supervisor approval leave rejection", "manager role leave"]),
51
- ("inform", ["employee notification obligation HR inform report notify absence", "leave application process"]),
52
- ("notify", ["employee notification obligation HR inform report notify absence", "leave application process"]),
53
- ("what if", ["consequences unauthorized absence policy rules management rights", "disciplinary action process"]),
54
- # ── Office timing ──
55
- ("office hour", ["office working hours schedule", "workday start end time shift"]),
56
- ("work hour", ["office working hours schedule", "workday start end time shift"]),
57
- ("timing", ["office working hours schedule", "workday start end time hours"]),
58
- ("schedule", ["office working hours schedule", "workday start end time"]),
59
- # ── Salary / Pay ──
60
- ("salary", ["salary structure payroll compensation amount advance salary", "monthly pay increment deduction"]),
61
- ("pay", ["salary structure payroll compensation", "payment date schedule advance pay"]),
62
- ("payroll", ["salary payroll structure compensation", "monthly pay deduction"]),
63
- ("compensation", ["salary compensation structure payroll", "monthly pay amount"]),
64
- # ── Benefits / Allowances ──
65
- ("allowance", ["allowances perks medical bonuses fuel transport reimbursements", "employee benefits"]),
66
- ("benefit", ["allowances perks medical bonuses reimbursements", "employee benefits privileges"]),
67
- ("perk", ["employee perks benefits extras privileges", "allowances bonuses"]),
68
- ("fuel", ["fuel allowance transport reimbursement conveyance petrol"]),
69
- ("medical", ["medical allowance health insurance coverage", "medical benefits employee"]),
70
- ("transport", ["transport allowance fuel reimbursement conveyance"]),
71
- ("bonus", ["bonus performance incentive annual eid festival reward"]),
72
- # ── Termination / Resignation ──
73
- ("terminat", ["termination resignation procedure process steps", "notice period exit policy"]),
74
- ("resign", ["resignation procedure steps notice period", "termination exit process"]),
75
- ("notice period", ["notice period resignation termination duration days leaves during notice"]),
76
- # ── Other HR topics ──
77
- ("probat", ["probation period duration conditions employee"]),
78
- ("overtime", ["overtime compensation extra hours payment policy"]),
79
- ("increment", ["salary increment raise annual review appraisal performance"]),
80
- ("appraisal", ["performance appraisal review increment salary raise"]),
81
- ("attendance", ["attendance policy punctuality late arrival absenteeism"]),
82
- ("dress code", ["dress code uniform attire professional clothing policy"]),
83
- ("remote", ["remote work work from home WFH policy telecommute evaluation"]),
84
- ("grievance", ["grievance complaint procedure policy employee rights"]),
85
- ("discipline", ["disciplinary action policy procedure employee"]),
86
- ("code of conduct", ["code of conduct policy employee behaviour rules"]),
87
- ]
88
-
89
-
90
- def _expand_query_locally(message: str) -> List[str]:
91
  """
92
- Expands a user query into targeted search strings using keyword rules.
93
- Replaces the async LLM rewrite call runs in microseconds, zero API cost.
94
- Mirrors the exact intent-detection logic previously in the llama-3.1-8b prompt.
95
  """
96
- msg_lower = message.lower()
97
- queries: List[str] = [message] # Original query always first
 
 
 
 
 
98
 
99
- for keyword, variants in _INTENT_MAP:
100
- if keyword in msg_lower:
101
- for v in variants:
102
- if v not in queries:
103
- queries.append(v)
104
- # Removed break to allow multiple intent matches (e.g. "maternity" + "reject")
105
 
106
- return queries[:6] # Increased to 6 to handle the split leave queries effectively
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
 
109
  # RRF scores are small (e.g. 0.016–0.033), so threshold must be very low
110
- RELEVANCE_THRESHOLD = 0.002 # Dropped further to catch all leave segments
111
  # Cross-encoder logit > 0 means > 50% relevance probability
112
- RERANK_THRESHOLD = -0.8 # Relaxed further to ensure the AI sees all retrieved leave types
113
  # If ALL chunks fail rerank threshold, fall back to this many top chunks
114
- RERANK_FALLBACK_N = 10 # Fallback to a large number to ensure completeness for lists
115
 
116
 
117
  class RAGPipeline:
@@ -133,81 +122,67 @@ class RAGPipeline:
133
  # ── Cache check: return instantly for repeated identical questions ──
134
  key = _cache_key(message)
135
  if key in _answer_cache:
136
- _answer_cache.move_to_end(key) # Mark as recently used
137
  logger.info("Cache HIT for: '%s'", message[:40])
138
  return _answer_cache[key]
139
 
140
- # ── Step 1: Expand query locally (no API call — instant) ──
141
- queries = _expand_query_locally(message)
142
- logger.info("Expanded %d queries for: '%s' → %s", len(queries), message[:40], queries)
 
 
 
 
143
 
144
- # ── Step 2: Collect unique chunks across all queries ──
145
- seen_ids = set()
146
- all_retrieved = []
147
-
148
- for q in queries:
149
- query_chunks = self.vector_store.search(q, top_k=self.top_k)
150
- for chunk in query_chunks:
151
- if chunk["id"] not in seen_ids:
152
- all_retrieved.append(chunk)
153
- seen_ids.add(chunk["id"])
154
 
155
  # ── Step 3: Initial relevance filter ──
156
  initial_chunks = [c for c in all_retrieved if c["score"] >= RELEVANCE_THRESHOLD]
157
 
158
  if not initial_chunks:
159
- logger.info("No relevant chunks found for: '%s' — returning no-info response", message)
160
- # Pass empty chunks; LLM is instructed to say "I don't have that information"
161
  reply = await self.llm_service.answer(
162
- question=message,
163
  chunks=[],
164
  history=history,
165
  user_name=user_name
166
  )
167
  return {"reply": reply, "retrieved_chunks": []}
168
 
169
- # ── Step 4: Deep reranking via Cross-Encoder ──
170
- # Enrich the reranker query with the first expanded variant (queries[1])
171
- # to give the cross-encoder broader semantic context.
172
- rerank_query = message
173
- if len(queries) > 1:
174
- rerank_query = f"{message} {queries[1]}"
175
-
176
  reranked_chunks = self.reranker.rerank(rerank_query, initial_chunks, top_n=self.max_context_chunks)
177
 
178
- # Filter by rerank score threshold
179
  final_chunks = [c for c in reranked_chunks if c.get("rerank_score", 0) > RERANK_THRESHOLD]
180
 
181
- # ── Smart Fallback: if ALL chunks fail the threshold, use the top N anyway ──
182
- # This prevents the bot from saying "I don't have info" when content WAS retrieved
183
- # but the cross-encoder wasn't confident enough (e.g. paraphrased queries)
184
  if not final_chunks and reranked_chunks:
185
  final_chunks = reranked_chunks[:RERANK_FALLBACK_N]
186
  logger.info(
187
- "Rerank fallback activated — all chunks below threshold (best score=%.3f), using top %d",
188
- reranked_chunks[0].get("rerank_score", 0),
189
- len(final_chunks)
190
  )
191
 
192
  logger.info(
193
- "Pipeline: retrieved=%d → relevance_filtered=%d → reranked=%d → final=%d (best_score=%.3f)",
194
  len(all_retrieved), len(initial_chunks), len(reranked_chunks), len(final_chunks),
195
  reranked_chunks[0].get("rerank_score", 0) if reranked_chunks else 0.0
196
  )
197
 
198
- # ── Step 5: Generate answer with top-ranked context ──
199
  reply = await self.llm_service.answer(
200
- question=message,
201
  chunks=final_chunks,
202
  history=history,
203
  user_name=user_name
204
  )
205
  result = {"reply": reply, "retrieved_chunks": final_chunks}
206
 
207
- # ── Populate cache (evict oldest if at capacity) ──
208
  _answer_cache[key] = result
209
  if len(_answer_cache) > _CACHE_MAX:
210
- _answer_cache.popitem(last=False) # Remove least-recently-used
211
- logger.info("Cache MISS — stored answer for: '%s'", message[:40])
212
 
213
  return result
 
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
 
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
 
19
  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 microsecondsno 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
103
+ RERANK_FALLBACK_N = 2
104
 
105
 
106
  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
app/services/vector_store.py CHANGED
@@ -11,6 +11,26 @@ 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
 
15
 
16
  class FaissVectorStore:
@@ -41,7 +61,8 @@ 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,7 +110,7 @@ class FaissVectorStore:
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,54 +128,76 @@ class FaissVectorStore:
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
 
 
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
 
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
  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
  )
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