Nhat Anh commited on
Commit
9391abd
·
1 Parent(s): 5602bee

Remove ngrok.exe from tracking

Browse files
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y gcc g++ curl git && rm -rf /var/lib/apt/lists/*
6
+
7
+ COPY requirements-deploy.txt ./
8
+ RUN pip install --no-cache-dir -r requirements-deploy.txt
9
+
10
+ COPY src/ ./src/
11
+ COPY app/ ./app/
12
+ COPY .chainlit/ ./.chainlit/
13
+ COPY chainlit.md ./chainlit.md
14
+
15
+ RUN useradd -m -u 1000 user && chown -R user:user /app
16
+ USER user
17
+
18
+ EXPOSE 7860
19
+
20
+ CMD ["chainlit", "run", "app/vilexagent_ui.py", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ⚖️ ViLexAgent
2
+
3
+ > **A Multi-Agent RAG System for Vietnamese Legal Q&A**
4
+ > Supports Vietnamese labor law, food safety regulations, and international trade agreements (EVFTA, CPTPP).
5
+
6
+ ---
7
+
8
+ ## Overview
9
+
10
+ ViLexAgent is an agentic Retrieval-Augmented Generation (RAG) system that answers complex legal questions about Vietnamese law and its compliance with international trade agreements. The system decomposes user queries into sub-questions, retrieves relevant legal documents from a vector database, cross-references domestic law with international standards, and synthesizes a cited, legally-grounded answer.
11
+
12
+ **Key capabilities:**
13
+ - Query decomposition into domain-specific sub-questions (labor law, food safety)
14
+ - Hybrid retrieval from domestic Vietnamese legal documents and international treaty clauses (EVFTA, CPTPP)
15
+ - Automatic cross-reference analysis with alignment scoring (`aligned` / `conflict` / `gap` / `no_international`)
16
+ - Expired document detection with explicit warnings
17
+ - Step-by-step reasoning trace visible in the UI
18
+
19
+ ---
20
+
21
+ ## Architecture
22
+
23
+ ```
24
+ User Query
25
+
26
+
27
+ ┌─────────────────────┐
28
+ │ Query Decomposer │ → Sub-questions + domain tags (labor / food_safety)
29
+ └─────────────────────┘
30
+
31
+ ├──────────────────────────────────┐
32
+ ▼ ▼
33
+ ┌──────────────────┐ ┌──────────────────────────┐
34
+ │ Domestic │ │ International │
35
+ │ Retriever │ │ Retriever │
36
+ │ (Qdrant + Jina) │ │ (Qdrant + Jina) │
37
+ └──────────────────┘ └──────────────────────────┘
38
+ │ │
39
+ └──────────────┬───────────────────┘
40
+
41
+ ┌─────────────────────┐
42
+ │ Cross-Reference │ → alignment: aligned / conflict / gap
43
+ └─────────────────────┘
44
+
45
+
46
+ ┌─────────────────────┐
47
+ │ Synthesizer │ → Final answer with citations
48
+ └─────────────────────┘
49
+ ```
50
+
51
+ **Tech Stack:**
52
+
53
+ | Layer | Technology |
54
+ |---|---|
55
+ | UI | Chainlit 2.11 |
56
+ | Agent Orchestration | LangGraph 1.1 |
57
+ | Vector Database | Qdrant |
58
+ | Embedding Model | Jina Embeddings v5 Small (4-bit, CUDA) |
59
+ | LLM Backend | LiteLLM (OpenAI-compatible) |
60
+ | OCR / PDF Parsing | PaddleOCR, PyMuPDF |
61
+ | Vietnamese NLP | Underthesea |
62
+ | Experiment Tracking | MLflow |
63
+ | Evaluation | RAGAS |
64
+ | Runtime | Python 3.13, Poetry |
65
+
66
+ ---
67
+
68
+ ## Project Structure
69
+
70
+ ```
71
+ vilexagent/
72
+ ├── app/
73
+ │ └── vilexagent_ui.py # Chainlit UI entry point
74
+ ├── src/
75
+ │ ├── agents/
76
+ │ │ ├── state.py # AgentState (LangGraph TypedDict)
77
+ │ │ ├── query_decomposer.py # Query decomposition node
78
+ │ │ ├── domestic_retriever.py # Vietnamese law retrieval node
79
+ │ │ ├── international_retriever.py # EVFTA/CPTPP retrieval node
80
+ │ │ ├── cross_reference.py # Cross-reference analysis node
81
+ │ │ ├── synthesizer.py # Answer synthesis node
82
+ │ │ └── graph.py # LangGraph pipeline definition
83
+ │ ├── ingestion/ # Document ingestion pipeline
84
+ │ ├── retrieval/ # Retrieval utilities
85
+ │ └── utils/
86
+ │ ├── llm.py # LLM client (LiteLLM wrapper)
87
+ │ ├── model_loader.py # Singleton embedding model loader
88
+ │ ├── logger.py # Loguru logger
89
+ │ └── json_utils.py # Robust JSON extractor
90
+ ├── evaluation/ # RAGAS benchmark suite
91
+ ├── docker/ # Docker/Qdrant setup
92
+ ├── tests/
93
+ ├── pyproject.toml
94
+ └── chainlit.md
95
+ ```
96
+
97
+ ---
98
+
99
+ ## Installation
100
+
101
+ ### Prerequisites
102
+
103
+ - Python 3.13
104
+ - [Poetry](https://python-poetry.org/docs/#installation)
105
+ - CUDA-capable GPU (recommended: 4GB+ VRAM)
106
+ - [Qdrant](https://qdrant.tech/documentation/quick-start/) running locally on port `6333`
107
+ - An OpenAI-compatible LLM API endpoint on port `3001`
108
+
109
+ ### Steps
110
+
111
+ **1. Clone the repository**
112
+ ```bash
113
+ git clone https://github.com/dnAnh1523/vilexagent.git
114
+ cd vilexagent
115
+ ```
116
+
117
+ **2. Install dependencies**
118
+ ```bash
119
+ poetry install
120
+ ```
121
+
122
+ **3. Set up environment variables**
123
+
124
+ Create a `.env` file in the project root:
125
+ ```env
126
+ LLM_BASE_URL=http://localhost:3001/v1
127
+ LLM_API_KEY=your_api_key_here
128
+ LLM_MODEL=your_model_name
129
+ ```
130
+
131
+ **4. Start Qdrant**
132
+ ```bash
133
+ docker compose -f docker/docker-compose.yml up -d
134
+ ```
135
+
136
+ **5. Ingest documents**
137
+
138
+ Place your Vietnamese legal PDFs and international treaty documents in the `data/` directory, then run the ingestion pipeline (see `src/ingestion/`).
139
+
140
+ **6. Run the app**
141
+ ```bash
142
+ poetry run chainlit run app/vilexagent_ui.py --port 8000
143
+ ```
144
+
145
+ Open `http://localhost:8000` in your browser.
146
+
147
+ ---
148
+
149
+ ## Usage
150
+
151
+ Once running, you can ask questions in Vietnamese about:
152
+
153
+ - **Labor law** — probationary periods, wrongful termination compensation, minimum wage, overtime
154
+ - **Food safety** — export conditions, hygiene standards, quarantine requirements
155
+ - **International compliance** — whether Vietnamese law meets EVFTA/CPTPP standards
156
+
157
+ The UI displays a step-by-step reasoning trace (collapsible) showing which documents were retrieved, cross-reference results, and alignment scores before the final answer.
158
+
159
+ **Example questions:**
160
+ ```
161
+ Thời gian thử việc tối đa theo pháp luật lao động Việt Nam là bao lâu?
162
+
163
+ Nếu người sử dụng lao động đơn phương chấm dứt hợp đồng trái pháp luật
164
+ thì phải bồi thường những gì cho người lao động?
165
+
166
+ Việt Nam có đáp ứng các tiêu chuẩn lao động của CPTPP về tự do hiệp hội không?
167
+ ```
168
+
169
+ ---
170
+
171
+ ## Configuration
172
+
173
+ Key settings in `chainlit.md` (UI welcome screen) and `.chainlit/config.toml`:
174
+
175
+ | Setting | Location | Description |
176
+ |---|---|---|
177
+ | `cot` | `config.toml [UI]` | Chain-of-thought display: `"full"` shows all steps |
178
+ | `session_timeout` | `config.toml [project]` | Session retention in seconds |
179
+ | `LLM_BASE_URL` | `.env` | OpenAI-compatible LLM endpoint |
180
+ | `LLM_MODEL` | `.env` | Model name passed to LiteLLM |
181
+
182
+ ---
183
+
184
+ ## Evaluation
185
+
186
+ The system includes a RAGAS-based evaluation suite in `evaluation/` with a benchmark of labeled legal Q&A pairs across three difficulty types:
187
+
188
+ - **Type A** — Single-domain factual queries (domestic law only)
189
+ - **Type B** — Multi-aspect domestic queries requiring reasoning across clauses
190
+ - **Type C** — Cross-reference queries requiring domestic + international alignment
191
+
192
+ Run evaluation:
193
+ ```bash
194
+ poetry run python evaluation/run_evaluation.py
195
+ ```
196
+
197
+ Results are tracked with MLflow. Start the MLflow UI:
198
+ ```bash
199
+ poetry run mlflow ui
200
+ ```
201
+
202
+ ---
203
+
204
+ ## License
205
+
206
+ This project is for educational and portfolio purposes.
207
+
208
+ ---
209
+
210
+ *Built with LangGraph · Qdrant · Chainlit · Jina Embeddings*
app/vilexagent_ui.py CHANGED
@@ -1,5 +1,6 @@
1
  # app/vilexagent_ui.py
2
  import sys
 
3
  import chainlit as cl
4
  from dotenv import load_dotenv
5
  sys.path.append(r"E:\\vilexagent")
@@ -47,6 +48,7 @@ async def on_faq(action: cl.Action):
47
  query = action.payload.get("query", "")
48
  await on_message(cl.Message(content=query))
49
 
 
50
  @cl.on_message
51
  async def on_message(message: cl.Message):
52
  query = message.content.strip()
@@ -66,116 +68,128 @@ async def on_message(message: cl.Message):
66
  "error": None
67
  }
68
 
69
- async with cl.Step(name="💭 Thought", type="run") as thought:
70
- thought.input = query
71
-
72
- # --- Step 1: Query Decomposition ---
73
- async with cl.Step(name="🔍 Phân tích câu hỏi", type="tool") as step1:
74
- step1.input = query
75
- from src.agents.query_decomposer import query_decomposer_node
76
- decomp_result = query_decomposer_node(state)
77
- state.update(decomp_result)
78
- sub_questions = state.get("sub_questions", [])
79
- requires_intl = state.get("requires_international", False)
80
-
81
- sub_q_text = "\n".join([
82
- f"- [{sq['source']}|{sq['domain']}] {sq['question']}"
83
- for sq in sub_questions
84
- ])
85
- step1.output = (
86
- f"Chia thành **{len(sub_questions)}** câu hỏi con:\n{sub_q_text}\n"
87
- f"Cần tra cứu quốc tế: {'✅' if requires_intl else '❌'}"
88
- )
89
-
90
- # --- Step 2: Domestic Retrieval ---
91
- async with cl.Step(name="📚 Tra cứu pháp luật Việt Nam", type="tool") as step2:
92
- step2.input = f"{len([sq for sq in sub_questions if sq['source'] in ('domestic','both')])} câu hỏi nội địa"
93
- from src.agents.domestic_retriever import domestic_retriever_node
94
- domestic_result = domestic_retriever_node(state)
95
- state.update(domestic_result)
96
- domestic_chunks = state.get("domestic_chunks", [])
97
-
98
- if domestic_chunks:
99
- docs_text = "\n".join([
100
- f"- {c['title'][:60]} — {c['article_number']} "
101
- f"({'⚠️ hết hiệu lực' if 'hết hiệu lực' in c.get('tinh_trang_hieu_luc','').lower() else '✅ còn hiệu lực'})"
102
- for c in domestic_chunks[:5]
103
  ])
104
- step2.output = f"Tìm thấy **{len(domestic_chunks)}** văn bản:\n{docs_text}"
105
- else:
106
- step2.output = "Không tìm thấy văn bản nội địa liên quan."
107
-
108
- # --- Step 3: International Retrieval (if needed) ---
109
- if requires_intl:
110
- async with cl.Step(name="🌐 Tra cứu tiêu chuẩn quốc tế", type="tool") as step3:
111
- step3.input = f"{len([sq for sq in sub_questions if sq['source'] in ('international','both')])} câu hỏi quốc tế"
112
- from src.agents.international_retriever import international_retriever_node
113
- intl_result = international_retriever_node(state)
114
- state.update(intl_result)
115
- intl_chunks = state.get("international_chunks", [])
116
-
117
- if intl_chunks:
118
- intl_text = "\n".join([
119
- f"- [{c.get('agreement','')}] {c['title'][:60]} — {c['article_number']}"
120
- for c in intl_chunks[:5]
 
 
 
121
  ])
122
- step3.output = f"Tìm thấy **{len(intl_chunks)}** điều khoản quốc tế:\n{intl_text}"
123
  else:
124
- step3.output = "Không tìm thấy điều khoản quốc tế liên quan."
125
-
126
- # --- Step 4: Cross-Reference --- luôn chạy
127
- async with cl.Step(name="⚖️ Đối chiếu pháp luật", type="tool") as step4:
128
- step4.input = (
129
- f"{len(state.get('domestic_chunks', []))} văn bản nội địa × "
130
- f"{len(state.get('international_chunks', []))} điều khoản quốc tế"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  )
132
- from src.agents.cross_reference import cross_reference_node
133
- xref_result = cross_reference_node(state)
134
- state.update(xref_result)
135
- cross_ref = state.get("cross_reference") or {}
136
-
137
- alignment = cross_ref.get("alignment", "unknown")
138
- alignment_emoji = {
139
- "aligned": "✅ Phù hợp",
140
- "conflict": " Mâu thuẫn",
141
- "gap": "⚠️ Còn khoảng cách",
142
- "no_international": "ℹ️ Không áp dụng"
143
- }.get(alignment, alignment)
144
-
145
- step4.output = (
146
- f"Kết quả đối chiếu: **{alignment_emoji}**\n\n"
147
- f"{cross_ref.get('explanation', '')[:300]}"
148
  )
149
-
150
- # --- Step 5: Synthesis ---
151
- async with cl.Step(name="✍️ Tổng hợp câu trả lời", type="tool") as step5:
152
- step5.input = "Tổng hợp từ tất cả nguồn"
153
- from src.agents.synthesizer import synthesizer_node
154
- synth_result = synthesizer_node(state)
155
- state.update(synth_result)
156
- step5.output = f"Đã tạo câu trả lời ({len(state.get('final_answer',''))} ký tự)"
157
-
158
- thought.output = (
159
- f"{len(state.get('domestic_chunks', []))} văn bản nội địa · "
160
- f"{len(state.get('international_chunks', []))} điều khoản quốc tế · "
161
- f"{len(state.get('final_answer',''))} ký tự"
162
- )
163
-
164
- # --- Final Answer ---
165
- final_answer = state.get("final_answer", "Xin lỗi, tôi không thể trả lời câu hỏi này.")
166
- citations = state.get("citations", [])
167
- has_expired = state.get("has_expired_docs", False)
168
-
169
- footer = ""
170
- if citations:
171
- footer += "\n\n---\n**📌 Nguồn tham khảo:**\n"
172
- for c in citations[:8]:
173
- footer += f"- {c}\n"
174
-
175
- if has_expired:
176
- footer += (
177
- "\n\n> ⚠️ **Lưu ý:** Một số văn bản pháp luật trong kết quả tra cứu "
178
- "đã **hết hiệu lực**. Vui lòng chỉ sử dụng văn bản còn hiệu lực làm căn cứ pháp lý."
179
- )
180
-
181
- await cl.Message(content=final_answer + footer).send()
 
1
  # app/vilexagent_ui.py
2
  import sys
3
+ import traceback
4
  import chainlit as cl
5
  from dotenv import load_dotenv
6
  sys.path.append(r"E:\\vilexagent")
 
48
  query = action.payload.get("query", "")
49
  await on_message(cl.Message(content=query))
50
 
51
+
52
  @cl.on_message
53
  async def on_message(message: cl.Message):
54
  query = message.content.strip()
 
68
  "error": None
69
  }
70
 
71
+ try:
72
+ async with cl.Step(name="Đang suy nghĩ...", type="run") as thought:
73
+ thought.input = query
74
+
75
+ # --- Step 1: Query Decomposition ---
76
+ async with cl.Step(name="🔍 Phân tích câu hỏi", type="tool") as step1:
77
+ step1.input = query
78
+ from src.agents.query_decomposer import query_decomposer_node
79
+ # FIX: Bọc make_async để không block UI
80
+ decomp_result = await cl.make_async(query_decomposer_node)(state)
81
+ state.update(decomp_result)
82
+ sub_questions = state.get("sub_questions", [])
83
+ requires_intl = state.get("requires_international", False)
84
+
85
+ sub_q_text = "\n".join([
86
+ f"- [{sq['source']}|{sq['domain']}] {sq['question']}"
87
+ for sq in sub_questions
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  ])
89
+ step1.output = (
90
+ f"Chia thành **{len(sub_questions)}** câu hỏi con:\n{sub_q_text}\n"
91
+ f"Cần tra cứu quốc tế: {'✅' if requires_intl else '❌'}"
92
+ )
93
+
94
+ # --- Step 2: Domestic Retrieval ---
95
+ async with cl.Step(name="📚 Tra cứu pháp luật Việt Nam", type="tool") as step2:
96
+ step2.input = f"{len([sq for sq in sub_questions if sq['source'] in ('domestic','both')])} câu hỏi nội địa"
97
+ from src.agents.domestic_retriever import domestic_retriever_node
98
+ # FIX: Bọc make_async
99
+ domestic_result = await cl.make_async(domestic_retriever_node)(state)
100
+ state.update(domestic_result)
101
+ domestic_chunks = state.get("domestic_chunks", [])
102
+
103
+ if domestic_chunks:
104
+ docs_text = "\n".join([
105
+ f"- {c['title'][:60]} {c['article_number']} "
106
+ # Thêm ép kiểu str() để chống lỗi NoneType lặt vặt
107
+ f"({'⚠️ hết hiệu lực' if 'hết hiệu lực' in str(c.get('tinh_trang_hieu_luc', '')).lower() else '✅ còn hiệu lực'})"
108
+ for c in domestic_chunks[:5]
109
  ])
110
+ step2.output = f"Tìm thấy **{len(domestic_chunks)}** văn bản:\n{docs_text}"
111
  else:
112
+ step2.output = "Không tìm thấy văn bản nội địa liên quan."
113
+
114
+ # --- Step 3: International Retrieval (if needed) ---
115
+ if requires_intl:
116
+ async with cl.Step(name="🌐 Tra cứu tiêu chuẩn quốc tế", type="tool") as step3:
117
+ step3.input = f"{len([sq for sq in sub_questions if sq['source'] in ('international','both')])} câu hỏi quốc tế"
118
+ from src.agents.international_retriever import international_retriever_node
119
+ # FIX: Bọc make_async
120
+ intl_result = await cl.make_async(international_retriever_node)(state)
121
+ state.update(intl_result)
122
+ intl_chunks = state.get("international_chunks", [])
123
+
124
+ if intl_chunks:
125
+ intl_text = "\n".join([
126
+ f"- [{c.get('agreement','')}] {c['title'][:60]} — {c['article_number']}"
127
+ for c in intl_chunks[:5]
128
+ ])
129
+ step3.output = f"Tìm thấy **{len(intl_chunks)}** điều khoản quốc tế:\n{intl_text}"
130
+ else:
131
+ step3.output = "Không tìm thấy điều khoản quốc tế liên quan."
132
+
133
+ # --- Step 4: Cross-Reference ---
134
+ async with cl.Step(name="⚖️ Đối chiếu pháp luật", type="tool") as step4:
135
+ step4.input = (
136
+ f"{len(state.get('domestic_chunks', []))} văn bản nội địa × "
137
+ f"{len(state.get('international_chunks', []))} điều khoản quốc tế"
138
+ )
139
+ from src.agents.cross_reference import cross_reference_node
140
+ # FIX: Bọc make_async
141
+ xref_result = await cl.make_async(cross_reference_node)(state)
142
+ state.update(xref_result)
143
+ cross_ref = state.get("cross_reference") or {}
144
+
145
+ alignment = cross_ref.get("alignment", "unknown")
146
+ alignment_emoji = {
147
+ "aligned": "✅ Phù hợp",
148
+ "conflict": "❌ Mâu thuẫn",
149
+ "gap": "⚠️ Còn khoảng cách",
150
+ "no_international": "ℹ️ Không áp dụng"
151
+ }.get(alignment, alignment)
152
+
153
+ step4.output = (
154
+ f"Kết quả đối chiếu: **{alignment_emoji}**\n\n"
155
+ f"{str(cross_ref.get('explanation', ''))[:300]}"
156
+ )
157
+
158
+ # --- Step 5: Synthesis ---
159
+ async with cl.Step(name="✍️ Tổng hợp câu trả lời", type="tool") as step5:
160
+ step5.input = "Tổng hợp từ tất cả nguồn"
161
+ from src.agents.synthesizer import synthesizer_node
162
+ # FIX: Bọc make_async
163
+ synth_result = await cl.make_async(synthesizer_node)(state)
164
+ state.update(synth_result)
165
+ step5.output = f"Đã tạo câu trả lời ({len(state.get('final_answer',''))} ký tự)"
166
+
167
+ thought.output = (
168
+ f"{len(state.get('domestic_chunks', []))} văn bản nội địa · "
169
+ f"{len(state.get('international_chunks', []))} điều khoản quốc tế · "
170
+ f"{len(state.get('final_answer',''))} ký tự"
171
  )
172
+
173
+ # --- Final Answer ---
174
+ final_answer = state.get("final_answer", "Xin lỗi, tôi không thể trả lời câu hỏi này.")
175
+ citations = state.get("citations", [])
176
+ has_expired = state.get("has_expired_docs", False)
177
+
178
+ footer = ""
179
+ if citations:
180
+ footer += "\n\n---\n**📌 Nguồn tham khảo:**\n"
181
+ for c in citations[:8]:
182
+ footer += f"- {c}\n"
183
+
184
+ if has_expired:
185
+ footer += (
186
+ "\n\n> ⚠️ **Lưu ý:** Một số văn bản pháp luật trong kết quả tra cứu "
187
+ "đã **hết hiệu lực**. Vui lòng chỉ sử dụng văn bản còn hiệu lực làm căn cứ pháp lý."
188
  )
189
+
190
+ await cl.Message(content=final_answer + footer).send()
191
+
192
+ except Exception as e:
193
+ # Bắt mọi lỗi xảy ra trong UI và báo thẳng ra màn hình
194
+ error_msg = f"❌ **Lỗi Hệ Thống UI:**\n```python\n{traceback.format_exc()}\n```"
195
+ await cl.Message(content=error_msg).send()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -14,7 +14,6 @@ dependencies = [
14
  "langchain-community (>=0.4.1,<0.5.0)",
15
  "qdrant-client (>=1.17.1,<2.0.0)",
16
  "sentence-transformers (>=5.4.1,<6.0.0)",
17
- "ragas (>=0.4.3,<0.5.0)",
18
  "mlflow (>=3.11.1,<4.0.0)",
19
  "pymupdf (>=1.27.2.3,<2.0.0.0)",
20
  "beautifulsoup4 (>=4.14.3,<5.0.0)",
@@ -30,10 +29,7 @@ dependencies = [
30
  "peft (>=0.19.1,<0.20.0)",
31
  "torch (>=2.11.0,<3.0.0)",
32
  "accelerate (>=1.13.0,<2.0.0)",
33
- "bitsandbytes (>=0.49.2,<0.50.0)",
34
  "google-generativeai (>=0.8.6,<0.9.0)",
35
- "litellm (>=1.83.14,<2.0.0)",
36
- "langchain-ollama (>=1.1.0,<2.0.0)",
37
  "chainlit (>=2.11.1,<3.0.0)"
38
  ]
39
 
 
14
  "langchain-community (>=0.4.1,<0.5.0)",
15
  "qdrant-client (>=1.17.1,<2.0.0)",
16
  "sentence-transformers (>=5.4.1,<6.0.0)",
 
17
  "mlflow (>=3.11.1,<4.0.0)",
18
  "pymupdf (>=1.27.2.3,<2.0.0.0)",
19
  "beautifulsoup4 (>=4.14.3,<5.0.0)",
 
29
  "peft (>=0.19.1,<0.20.0)",
30
  "torch (>=2.11.0,<3.0.0)",
31
  "accelerate (>=1.13.0,<2.0.0)",
 
32
  "google-generativeai (>=0.8.6,<0.9.0)",
 
 
33
  "chainlit (>=2.11.1,<3.0.0)"
34
  ]
35
 
requirements-deploy.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ langgraph>=1.1.10
2
+ langchain-google-genai>=4.2.2
3
+ langchain-community>=0.4.1
4
+ langchain-openai>=0.3.0
5
+ qdrant-client>=1.17.1
6
+ sentence-transformers>=5.4.1
7
+ torch --index-url https://download.pytorch.org/whl/cpu
8
+ peft>=0.19.1
9
+ accelerate>=1.13.0
10
+ huggingface-hub>=1.13.0
11
+ python-dotenv>=1.2.2
12
+ pydantic>=2.12.5
13
+ loguru>=0.7.3
14
+ chainlit>=2.11.1
src/agents/domestic_retriever.py CHANGED
@@ -1,6 +1,5 @@
1
  # src/agents/domestic_retriever.py
2
- import os
3
- from qdrant_client import QdrantClient
4
  from qdrant_client.models import Filter, FieldCondition, MatchValue
5
  from src.agents.state import AgentState
6
  from src.utils.logger import logger
@@ -19,7 +18,7 @@ _client = None
19
  def get_client():
20
  global _client
21
  if _client is None:
22
- _client = QdrantClient(url=os.getenv("QDRANT_URL", "http://localhost:6333"))
23
  return _client
24
 
25
  def retrieve_domestic(query: str, domain: str) -> list[dict]:
 
1
  # src/agents/domestic_retriever.py
2
+ from src.utils.qdrant_client import get_qdrant_client
 
3
  from qdrant_client.models import Filter, FieldCondition, MatchValue
4
  from src.agents.state import AgentState
5
  from src.utils.logger import logger
 
18
  def get_client():
19
  global _client
20
  if _client is None:
21
+ _client = get_qdrant_client()
22
  return _client
23
 
24
  def retrieve_domestic(query: str, domain: str) -> list[dict]:
src/agents/international_retriever.py CHANGED
@@ -1,6 +1,5 @@
1
  # src/agents/international_retriever.py
2
- import os
3
- from qdrant_client import QdrantClient
4
  from qdrant_client.models import Filter, FieldCondition, MatchValue
5
  from src.agents.state import AgentState
6
  from src.utils.logger import logger
@@ -18,7 +17,7 @@ _client = None
18
  def get_client():
19
  global _client
20
  if _client is None:
21
- _client = QdrantClient(url=os.getenv("QDRANT_URL", "http://localhost:6333"))
22
  return _client
23
 
24
  def retrieve_international(query: str, domain: str) -> list[dict]:
 
1
  # src/agents/international_retriever.py
2
+ from src.utils.qdrant_client import get_qdrant_client
 
3
  from qdrant_client.models import Filter, FieldCondition, MatchValue
4
  from src.agents.state import AgentState
5
  from src.utils.logger import logger
 
17
  def get_client():
18
  global _client
19
  if _client is None:
20
+ _client = get_qdrant_client()
21
  return _client
22
 
23
  def retrieve_international(query: str, domain: str) -> list[dict]:
src/retrieval/baseline.py CHANGED
@@ -1,9 +1,8 @@
1
  # src/retrieval/baseline.py
2
- import os
3
- from qdrant_client import QdrantClient
4
  from qdrant_client.models import Filter, FieldCondition, MatchValue
5
  from src.utils.model_loader import get_embedding_model
6
  from src.utils.logger import logger
 
7
 
8
  DOMESTIC_COLLECTION = "vilexagent_domestic"
9
  INTERNATIONAL_COLLECTION = "vilexagent_international"
@@ -12,7 +11,7 @@ TOP_K = 5
12
  class BaselineRetriever:
13
  def __init__(self):
14
  self.model = get_embedding_model()
15
- self.client = QdrantClient(url=os.getenv("QDRANT_URL", "http://localhost:6333"))
16
  logger.success("BaselineRetriever ready")
17
 
18
  def retrieve(self, query: str, source: str = "domestic", domain: str = None, top_k: int = TOP_K) -> list[dict]:
 
1
  # src/retrieval/baseline.py
 
 
2
  from qdrant_client.models import Filter, FieldCondition, MatchValue
3
  from src.utils.model_loader import get_embedding_model
4
  from src.utils.logger import logger
5
+ from src.utils.qdrant_client import get_qdrant_client
6
 
7
  DOMESTIC_COLLECTION = "vilexagent_domestic"
8
  INTERNATIONAL_COLLECTION = "vilexagent_international"
 
11
  class BaselineRetriever:
12
  def __init__(self):
13
  self.model = get_embedding_model()
14
+ self.client = get_qdrant_client()
15
  logger.success("BaselineRetriever ready")
16
 
17
  def retrieve(self, query: str, source: str = "domestic", domain: str = None, top_k: int = TOP_K) -> list[dict]:
src/utils/llm.py CHANGED
@@ -1,29 +1,40 @@
1
  # src/utils/llm.py
2
  import os
3
  from dotenv import load_dotenv
4
- from langchain_openai import ChatOpenAI
5
  from src.utils.logger import logger
6
 
7
  load_dotenv()
8
 
9
- FREELLM_BASE_URL = os.getenv("FREELLM_BASE_URL", "http://localhost:3001/v1")
10
- FREELLM_API_KEY = os.getenv("FREELLM_API_KEY")
11
-
12
- if not FREELLM_API_KEY:
13
- raise ValueError("FREELLM_API_KEY not set in .env")
14
-
15
  _llm = None
16
 
17
- def get_llm(temperature: float = 0) -> ChatOpenAI:
 
18
  global _llm
19
  if _llm is None:
20
- logger.info(f"Initializing LLM via FreeLLMAPI at {FREELLM_BASE_URL}")
21
- _llm = ChatOpenAI(
22
- model="meta-llama/llama-4-scout-17b-16e-instruct",
23
- base_url=FREELLM_BASE_URL,
24
- api_key=FREELLM_API_KEY,
25
- temperature=temperature,
26
- max_tokens=4096,
27
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  logger.success("LLM ready")
29
  return _llm
 
1
  # src/utils/llm.py
2
  import os
3
  from dotenv import load_dotenv
 
4
  from src.utils.logger import logger
5
 
6
  load_dotenv()
7
 
 
 
 
 
 
 
8
  _llm = None
9
 
10
+
11
+ def get_llm(temperature: float = 0):
12
  global _llm
13
  if _llm is None:
14
+ freellm_key = os.getenv("FREELLM_API_KEY")
15
+ freellm_url = os.getenv("FREELLM_BASE_URL")
16
+
17
+ if freellm_key and freellm_url:
18
+ # Local: use FreeLLMAPI proxy
19
+ from langchain_openai import ChatOpenAI
20
+ logger.info(f"Initializing LLM via FreeLLMAPI at {freellm_url}")
21
+ _llm = ChatOpenAI(
22
+ model="meta-llama/llama-4-scout-17b-16e-instruct",
23
+ base_url=freellm_url,
24
+ api_key=freellm_key,
25
+ temperature=temperature,
26
+ max_tokens=4096,
27
+ )
28
+ else:
29
+ # Cloud: use Gemini directly
30
+ from langchain_google_genai import ChatGoogleGenerativeAI
31
+ logger.info("Initializing LLM via Gemini API (cloud mode)")
32
+ _llm = ChatGoogleGenerativeAI(
33
+ model="gemini-2.5-flash",
34
+ google_api_key=os.getenv("GOOGLE_API_KEY"),
35
+ temperature=temperature,
36
+ max_tokens=4096,
37
+ )
38
+
39
  logger.success("LLM ready")
40
  return _llm
src/utils/migrate_to_cloud.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/utils/migrate_to_cloud.py
2
+ """
3
+ Migrates vectors from local Qdrant to Qdrant Cloud.
4
+ Reads all points from local collections and upserts to cloud.
5
+ """
6
+ import os
7
+ import time
8
+ from dotenv import load_dotenv
9
+ from qdrant_client import QdrantClient
10
+ from qdrant_client.models import VectorParams, Distance, PayloadSchemaType, PointStruct
11
+ from src.utils.logger import logger
12
+
13
+ load_dotenv()
14
+
15
+ LOCAL_URL = os.getenv("QDRANT_URL", "http://localhost:6333")
16
+ CLOUD_URL = os.getenv("QDRANT_CLOUD_URL")
17
+ CLOUD_API_KEY = os.getenv("QDRANT_API_KEY")
18
+ COLLECTIONS = ["vilexagent_domestic", "vilexagent_international"]
19
+
20
+ # Reduced batch size specifically for Qdrant Cloud Free Tier
21
+ BATCH_SIZE = 25
22
+
23
+
24
+ def migrate():
25
+ if not CLOUD_URL or not CLOUD_API_KEY:
26
+ raise ValueError("QDRANT_CLOUD_URL and QDRANT_API_KEY must be set in .env")
27
+
28
+ logger.info(f"Connecting to local Qdrant at {LOCAL_URL}...")
29
+ local = QdrantClient(url=LOCAL_URL)
30
+
31
+ # Increased timeout to 120 seconds to prevent WriteTimeout on large payloads
32
+ logger.info(f"Connecting to Qdrant Cloud at {CLOUD_URL}...")
33
+ cloud = QdrantClient(url=CLOUD_URL, api_key=CLOUD_API_KEY, timeout=120.0)
34
+
35
+ for collection_name in COLLECTIONS:
36
+ logger.info(f"\n{'='*50}")
37
+ logger.info(f"Migrating collection: {collection_name}")
38
+
39
+ # Get local collection info
40
+ try:
41
+ local_info = local.get_collection(collection_name)
42
+ except Exception as e:
43
+ logger.warning(f"Collection {collection_name} not found locally: {e}")
44
+ continue
45
+
46
+ vector_size = local_info.config.params.vectors.size
47
+ local_count = local.count(collection_name).count
48
+ logger.info(f"Local vectors: {local_count}, dimension: {vector_size}")
49
+
50
+ # Create cloud collection if not exists
51
+ existing = [c.name for c in cloud.get_collections().collections]
52
+ if collection_name in existing:
53
+ cloud_count = cloud.count(collection_name).count
54
+ logger.warning(f"Collection already exists in cloud with {cloud_count} vectors")
55
+ if cloud_count == local_count:
56
+ logger.success(f"Already synced, skipping")
57
+ continue
58
+ logger.info("Deleting and recreating...")
59
+ cloud.delete_collection(collection_name)
60
+
61
+ cloud.create_collection(
62
+ collection_name=collection_name,
63
+ vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE)
64
+ )
65
+
66
+ for field in ["domain", "source", "language", "tinh_trang_hieu_luc", "loai_van_ban"]:
67
+ cloud.create_payload_index(
68
+ collection_name=collection_name,
69
+ field_name=field,
70
+ field_schema=PayloadSchemaType.KEYWORD
71
+ )
72
+
73
+ logger.success(f"Cloud collection created (dim={vector_size})")
74
+
75
+ # Migrate in batches using scroll
76
+ offset = None
77
+ total_migrated = 0
78
+
79
+ while True:
80
+ results, next_offset = local.scroll(
81
+ collection_name=collection_name,
82
+ limit=BATCH_SIZE,
83
+ offset=offset,
84
+ with_vectors=True,
85
+ with_payload=True
86
+ )
87
+
88
+ if not results:
89
+ break
90
+
91
+ # Convert Record objects to PointStruct objects
92
+ points_to_upsert = [
93
+ PointStruct(
94
+ id=record.id,
95
+ vector=record.vector,
96
+ payload=record.payload
97
+ ) for record in results
98
+ ]
99
+
100
+ cloud.upsert(
101
+ collection_name=collection_name,
102
+ points=points_to_upsert
103
+ )
104
+
105
+ total_migrated += len(results)
106
+ logger.info(f" Migrated {total_migrated}/{local_count}...")
107
+
108
+ # Add a pause to prevent overwhelming the Free Tier CPU
109
+ time.sleep(0.5)
110
+
111
+ if next_offset is None:
112
+ break
113
+ offset = next_offset
114
+
115
+ cloud_count = cloud.count(collection_name).count
116
+ logger.success(f"Migration complete: {cloud_count} vectors in cloud")
117
+
118
+ logger.success("\nAll collections migrated successfully.")
119
+
120
+ if __name__ == "__main__":
121
+ migrate()
src/utils/model_loader.py CHANGED
@@ -20,8 +20,7 @@ def get_embedding_model():
20
  device=device,
21
  trust_remote_code=True,
22
  model_kwargs={
23
- "torch_dtype": torch.bfloat16,
24
- "load_in_4bit": True,
25
  "default_task": "retrieval"
26
  }
27
  )
 
20
  device=device,
21
  trust_remote_code=True,
22
  model_kwargs={
23
+ "torch_dtype": torch.float16 if device == "cuda" else torch.float32,
 
24
  "default_task": "retrieval"
25
  }
26
  )
src/utils/qdrant_client.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/utils/qdrant_client.py
2
+ import os
3
+ from qdrant_client import QdrantClient
4
+ from src.utils.logger import logger
5
+
6
+ _client = None
7
+
8
+ def get_qdrant_client() -> QdrantClient:
9
+ global _client
10
+ if _client is None:
11
+ api_key = os.getenv("QDRANT_API_KEY")
12
+ if api_key:
13
+ url = os.getenv("QDRANT_CLOUD_URL")
14
+ logger.info(f"Connecting to Qdrant Cloud at {url}")
15
+ _client = QdrantClient(url=url, api_key=api_key)
16
+ else:
17
+ url = os.getenv("QDRANT_URL", "http://localhost:6333")
18
+ logger.info(f"Connecting to local Qdrant at {url}")
19
+ _client = QdrantClient(url=url)
20
+ return _client