Martechsol commited on
Commit ·
cdcad66
1
Parent(s): 227b99a
Restore: Revert codebase to stable May 14th 6 PM state
Browse files- .env.example +22 -7
- app/admin/templates/admin.html +347 -40
- app/core/config.py +3 -3
- app/main.py +9 -7
- app/services/llm.py +103 -150
- app/services/rag_pipeline.py +103 -128
- app/services/vector_store.py +73 -30
.env.example
CHANGED
|
@@ -1,19 +1,34 @@
|
|
| 1 |
-
LLM_PROVIDER=
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 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=
|
|
|
|
| 15 |
CORS_ALLOW_ORIGINS=*
|
| 16 |
-
API_KEY=
|
| 17 |
RATE_LIMIT_REQUESTS=60
|
| 18 |
RATE_LIMIT_WINDOW_SECONDS=60
|
| 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
|
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 |
-
<
|
| 750 |
-
<
|
| 751 |
-
<
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 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 |
-
// ──
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 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 |
-
|
| 1131 |
-
|
|
|
|
|
|
|
|
|
|
| 1132 |
});
|
| 1133 |
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1137 |
|
|
|
|
| 1138 |
$('btn-export-session-excel').addEventListener('click', () => {
|
| 1139 |
if (!_activeId) return;
|
| 1140 |
-
downloadExport(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=excel`,
|
|
|
|
|
|
|
| 1141 |
});
|
| 1142 |
-
|
| 1143 |
$('btn-export-session-json').addEventListener('click', () => {
|
| 1144 |
if (!_activeId) return;
|
| 1145 |
-
downloadExport(`/api/admin/export/session/${encodeURIComponent(_activeId)}?format=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">×</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=
|
| 40 |
-
max_context_chunks: int =
|
| 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 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 13 |
|
| 14 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 15 |
-
|
| 16 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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 |
-
|
| 27 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 28 |
-
|
| 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 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
•
|
| 42 |
-
|
| 43 |
-
|
| 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 |
-
|
| 51 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 52 |
|
| 53 |
-
|
| 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 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
|
| 71 |
-
Rule: Answer ONLY for the SPECIFIC topic. Maximum 3 bullet points. No filler.
|
| 72 |
|
| 73 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 74 |
-
|
| 75 |
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 76 |
-
✓
|
| 77 |
-
✓ If not in Expert Data →
|
| 78 |
-
✓
|
| 79 |
-
✓
|
| 80 |
-
✓
|
| 81 |
-
✓
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 198 |
-
if query
|
| 199 |
-
queries.
|
|
|
|
| 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 |
-
|
| 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
|
| 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
|
| 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":
|
| 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 |
-
|
| 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 |
-
# ──
|
| 13 |
-
#
|
| 14 |
-
|
| 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
|
| 21 |
-
return hashlib.md5(
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
#
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 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
|
| 93 |
-
|
| 94 |
-
Mirrors the exact intent-detection logic previously in the llama-3.1-8b prompt.
|
| 95 |
"""
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
# RRF scores are small (e.g. 0.016–0.033), so threshold must be very low
|
| 110 |
-
RELEVANCE_THRESHOLD = 0.
|
| 111 |
# Cross-encoder logit > 0 means > 50% relevance probability
|
| 112 |
-
RERANK_THRESHOLD =
|
| 113 |
# If ALL chunks fail rerank threshold, fall back to this many top chunks
|
| 114 |
-
RERANK_FALLBACK_N =
|
| 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)
|
| 137 |
logger.info("Cache HIT for: '%s'", message[:40])
|
| 138 |
return _answer_cache[key]
|
| 139 |
|
| 140 |
-
# ── Step 1:
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
# ── Step 2:
|
| 145 |
-
|
| 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
|
| 160 |
-
# Pass empty chunks; LLM is instructed to say "I don't have that information"
|
| 161 |
reply = await self.llm_service.answer(
|
| 162 |
-
question=
|
| 163 |
chunks=[],
|
| 164 |
history=history,
|
| 165 |
user_name=user_name
|
| 166 |
)
|
| 167 |
return {"reply": reply, "retrieved_chunks": []}
|
| 168 |
|
| 169 |
-
# ── Step 4:
|
| 170 |
-
|
| 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 |
-
#
|
| 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
|
| 188 |
-
reranked_chunks[0].get("rerank_score", 0),
|
| 189 |
-
len(final_chunks)
|
| 190 |
)
|
| 191 |
|
| 192 |
logger.info(
|
| 193 |
-
"Pipeline: retrieved=%d →
|
| 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
|
| 199 |
reply = await self.llm_service.answer(
|
| 200 |
-
question=
|
| 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
|
| 208 |
_answer_cache[key] = result
|
| 209 |
if len(_answer_cache) > _CACHE_MAX:
|
| 210 |
-
_answer_cache.popitem(last=False)
|
| 211 |
-
logger.info("
|
| 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 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
|
| 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"]
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
self.last_retrieved = []
|
| 112 |
return []
|
| 113 |
|
| 114 |
-
#
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
tokenized_query = query.lower().split()
|
| 120 |
-
bm25_scores = self.bm25.get_scores(tokenized_query)
|
| 121 |
|
| 122 |
-
#
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
-
#
|
| 126 |
-
#
|
| 127 |
k = 60
|
| 128 |
rrf_scores = {}
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
-
#
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]),
|
| 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 |
|