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