Commit ·
de36225
0
Parent(s):
Huggingface Deployment
Browse files- .dockerignore +87 -0
- .gitignore +64 -0
- Agent/agentic_workflow.py +0 -0
- Agent/invariants.py +485 -0
- Agent/llm_health_check.py +36 -0
- Agent/mcp_client.py +48 -0
- Agent/mcp_server.py +503 -0
- Agent/memory_manager.py +467 -0
- Agent/merge_lora_to_ollama.py +244 -0
- Agent/merge_qwen_to_ollama.py +181 -0
- Agent/merged_model_test.py +26 -0
- Agent/migrate_to_neon.py +54 -0
- Agent/test_adapter_weights.py +45 -0
- Agent/test_merged_model.py +19 -0
- Agent/test_query_runner.py +20 -0
- Agent/tools/document_scanner.py +223 -0
- Agent/tools/granite_vision.py +120 -0
- Agent/worker.py +235 -0
- Dockerfile +65 -0
- README.md +548 -0
- docker-compose.yml +79 -0
- requirements.txt +38 -0
- start_hf.sh +57 -0
- web/app.py +170 -0
- web/static/app.js +584 -0
- web/static/index.html +996 -0
- web/static/table-demo.html +338 -0
.dockerignore
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Exclude heavy folders from Docker image
|
| 2 |
+
**/__pycache__
|
| 3 |
+
**/.venv
|
| 4 |
+
**/.git
|
| 5 |
+
**/.ipynb_checkpoints
|
| 6 |
+
**/hf_cache
|
| 7 |
+
**/node_modules
|
| 8 |
+
|
| 9 |
+
# Exclude local database data
|
| 10 |
+
**/postgres-data
|
| 11 |
+
**/mysql-data
|
| 12 |
+
|
| 13 |
+
# Exclude large model weights and research artifacts
|
| 14 |
+
Agent/Gemma3_1B_weights
|
| 15 |
+
Agent/merged_gemma1b_text2Sql
|
| 16 |
+
Agent/merged_gemma3_1b_text2Sql
|
| 17 |
+
Agent/Qwen2.5_3b_weights
|
| 18 |
+
Agent/Qwen2.5-3B-Instruct
|
| 19 |
+
merged_qwen2_5_3b_text2sql
|
| 20 |
+
merged_gemma3_1b_text2sql
|
| 21 |
+
.venv-merge/
|
| 22 |
+
|
| 23 |
+
# Exclude all Jupyter Notebooks EXCEPT the proof-of-work notebook
|
| 24 |
+
**/*.ipynb
|
| 25 |
+
!Fine_Tuning_Qwen_2_5_3B.ipynb
|
| 26 |
+
**/.ipynb_checkpoints
|
| 27 |
+
|
| 28 |
+
# Exclude redundant project clutter
|
| 29 |
+
backups/
|
| 30 |
+
debug_files/
|
| 31 |
+
test_files/
|
| 32 |
+
test_results/
|
| 33 |
+
dump-mysql.bat
|
| 34 |
+
dump-postgres.bat
|
| 35 |
+
agentic_improvements.md
|
| 36 |
+
invariants_analysis.md
|
| 37 |
+
progress_summary.txt
|
| 38 |
+
mcp_server.log
|
| 39 |
+
merge.log
|
| 40 |
+
response.json
|
| 41 |
+
run-dev.ps1
|
| 42 |
+
setup_ollama.bat
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# Exclude experimental/redundant Python scripts
|
| 46 |
+
Agent/trial*.py
|
| 47 |
+
Agent/test*.py
|
| 48 |
+
Agent/old_*.py
|
| 49 |
+
Agent/OLLAMA_GPU_NOTES.md
|
| 50 |
+
**/debug_*.py
|
| 51 |
+
|
| 52 |
+
# Exclude local uploads and generated files (keep empty folder)
|
| 53 |
+
chat_store/docs/*
|
| 54 |
+
!uploads/.gitkeep
|
| 55 |
+
|
| 56 |
+
# Exclude local configuration (Hugging Face uses Secrets instead)
|
| 57 |
+
.env
|
| 58 |
+
.env.local
|
| 59 |
+
|
| 60 |
+
# Exclude local logs and IDE settings
|
| 61 |
+
*.log
|
| 62 |
+
.vscode/
|
| 63 |
+
.idea/
|
| 64 |
+
.DS_Store
|
| 65 |
+
|
| 66 |
+
# Exclude Python build artifacts
|
| 67 |
+
__pycache__/
|
| 68 |
+
*.pyc
|
| 69 |
+
*.pyo
|
| 70 |
+
*.pyd
|
| 71 |
+
.Python
|
| 72 |
+
env/
|
| 73 |
+
venv/
|
| 74 |
+
.venv/
|
| 75 |
+
pip-log.txt
|
| 76 |
+
pip-delete-this-directory.txt
|
| 77 |
+
.toast
|
| 78 |
+
.mypy_cache/
|
| 79 |
+
.pytest_cache/
|
| 80 |
+
.hypothesis/
|
| 81 |
+
|
| 82 |
+
# Exclude large local data
|
| 83 |
+
uploads/*
|
| 84 |
+
!uploads/.gitkeep
|
| 85 |
+
chat_store/docs/*
|
| 86 |
+
!chat_store/docs/.gitkeep
|
| 87 |
+
.idea/
|
.gitignore
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local Models and Weights
|
| 2 |
+
Agent/*_weights/
|
| 3 |
+
Agent/Qwen*/
|
| 4 |
+
Agent/Gemma*/
|
| 5 |
+
Agent/merged_*/
|
| 6 |
+
merged_*/
|
| 7 |
+
*.bin
|
| 8 |
+
*.gguf
|
| 9 |
+
*.onnx
|
| 10 |
+
|
| 11 |
+
# Large Sample Database (Not needed in cloud)
|
| 12 |
+
employee-sample-database/
|
| 13 |
+
|
| 14 |
+
# Data and History
|
| 15 |
+
chat_store/
|
| 16 |
+
Agent/chat_store_private.json
|
| 17 |
+
|
| 18 |
+
# Docker and DB
|
| 19 |
+
docker/data/
|
| 20 |
+
|
| 21 |
+
# Python
|
| 22 |
+
__pycache__/
|
| 23 |
+
*.py[cod]
|
| 24 |
+
*.egg-info/
|
| 25 |
+
*.egg
|
| 26 |
+
*.pyo
|
| 27 |
+
*.pyd
|
| 28 |
+
*.so
|
| 29 |
+
|
| 30 |
+
# Virtual environments
|
| 31 |
+
venv/
|
| 32 |
+
.venv/
|
| 33 |
+
.env
|
| 34 |
+
.env.local
|
| 35 |
+
!.env.example
|
| 36 |
+
|
| 37 |
+
# Jupyter Notebooks and checkpoints
|
| 38 |
+
*.ipynb
|
| 39 |
+
.ipynb_checkpoints/
|
| 40 |
+
|
| 41 |
+
# Experimental/Redundant Scripts
|
| 42 |
+
Agent/trial*.py
|
| 43 |
+
Agent/old_*.py
|
| 44 |
+
Agent/requirements-merge.txt
|
| 45 |
+
debug_files/
|
| 46 |
+
test_results/
|
| 47 |
+
|
| 48 |
+
# Data and Uploads (Keep the folders, ignore the contents)
|
| 49 |
+
uploads/*
|
| 50 |
+
!uploads/.gitkeep
|
| 51 |
+
chat_store/docs/*
|
| 52 |
+
!chat_store/docs/.gitkeep
|
| 53 |
+
|
| 54 |
+
# OS-specific and IDEs
|
| 55 |
+
.DS_Store
|
| 56 |
+
Thumbs.db
|
| 57 |
+
.vscode/
|
| 58 |
+
.idea/
|
| 59 |
+
|
| 60 |
+
# Logs
|
| 61 |
+
*.log
|
| 62 |
+
mcp_server.log
|
| 63 |
+
merge.log
|
| 64 |
+
response.json
|
Agent/agentic_workflow.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
Agent/invariants.py
ADDED
|
@@ -0,0 +1,485 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invariant classification and enforcement for text2sql without hardcoded SQL templates.
|
| 2 |
+
|
| 3 |
+
All regeneration remains purely LLM-driven. We only express semantic/structural
|
| 4 |
+
constraints ("invariants") and ask the model to satisfy missing ones.
|
| 5 |
+
|
| 6 |
+
Categories:
|
| 7 |
+
MANAGER_LIST -> list current department managers
|
| 8 |
+
DEPARTMENT_LIST -> list department names
|
| 9 |
+
AVG_SALARY_BY_DEPT -> average current salary per department
|
| 10 |
+
TITLE_COUNT -> count employees with a given title (e.g. Senior Engineer)
|
| 11 |
+
GENERIC -> anything else (no special invariants)
|
| 12 |
+
|
| 13 |
+
Each invariant is represented as a dict:
|
| 14 |
+
{
|
| 15 |
+
'name': short identifier,
|
| 16 |
+
'description': human-readable guidance,
|
| 17 |
+
'check': callable(sql:str)->bool
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
We DO NOT inject SQL fragments. We only:
|
| 21 |
+
- detect which invariants fail
|
| 22 |
+
- compile a focused regeneration prompt listing just the missing invariants
|
| 23 |
+
- request a corrected SQL (single statement ending with semicolon + LIMIT 100)
|
| 24 |
+
|
| 25 |
+
This preserves the "no hardcoded fallback query" policy.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
import os
|
| 29 |
+
import re
|
| 30 |
+
from typing import List, Dict, Callable, Optional
|
| 31 |
+
|
| 32 |
+
CATEGORY_MANAGER_LIST = "MANAGER_LIST"
|
| 33 |
+
CATEGORY_DEPT_LIST = "DEPARTMENT_LIST"
|
| 34 |
+
CATEGORY_AVG_SALARY = "AVG_SALARY_BY_DEPT"
|
| 35 |
+
CATEGORY_TITLE_COUNT = "TITLE_COUNT"
|
| 36 |
+
CATEGORY_EMP_COUNT_BY_DEPT = "EMP_COUNT_BY_DEPT"
|
| 37 |
+
CATEGORY_GENERIC = "GENERIC"
|
| 38 |
+
|
| 39 |
+
# Newly added categories (extended taxonomy)
|
| 40 |
+
CATEGORY_SIMPLE_COUNT_EMP = "SIMPLE_COUNT_EMP"
|
| 41 |
+
CATEGORY_SIMPLE_COUNT_EMP_CURRENT = "SIMPLE_COUNT_EMP_CURRENT"
|
| 42 |
+
CATEGORY_SIMPLE_COUNT_DEPT = "SIMPLE_COUNT_DEPT"
|
| 43 |
+
CATEGORY_SALARY_EXTREMES_GLOBAL = "SALARY_EXTREMES_GLOBAL"
|
| 44 |
+
CATEGORY_SALARY_RANGE_BY_DEPT = "SALARY_RANGE_BY_DEPT"
|
| 45 |
+
CATEGORY_TOP_N_SALARIES = "TOP_N_SALARIES"
|
| 46 |
+
CATEGORY_DEPT_MANAGER_LIST = "DEPT_MANAGER_LIST" # dept + manager names
|
| 47 |
+
CATEGORY_MANAGER_TENURE = "MANAGER_TENURE"
|
| 48 |
+
CATEGORY_MANAGER_CHANGE_FREQUENCY = "MANAGER_CHANGE_FREQUENCY"
|
| 49 |
+
CATEGORY_TITLE_DISTINCT_COUNT = "TITLE_DISTINCT_COUNT"
|
| 50 |
+
CATEGORY_TITLE_SPECIFIC_COUNT = "TITLE_SPECIFIC_COUNT"
|
| 51 |
+
CATEGORY_TITLE_DISTRIBUTION_BY_DEPT = "TITLE_DISTRIBUTION_BY_DEPT"
|
| 52 |
+
CATEGORY_PROMOTION_PATH_ENGINEER_TO_SENIOR = "PROMOTION_PATH_ENGINEER_TO_SENIOR"
|
| 53 |
+
CATEGORY_EMP_TENURE_CURRENT_DEPT = "EMP_TENURE_CURRENT_DEPT"
|
| 54 |
+
CATEGORY_HIRING_TREND_BY_YEAR = "HIRING_TREND_BY_YEAR"
|
| 55 |
+
CATEGORY_SALARY_TREND_BY_YEAR = "SALARY_TREND_BY_YEAR"
|
| 56 |
+
CATEGORY_EMP_DEPT_CHANGES = "EMP_DEPT_CHANGES"
|
| 57 |
+
CATEGORY_GENDER_DIST_BY_DEPT = "GENDER_DIST_BY_DEPT"
|
| 58 |
+
CATEGORY_GENDER_PAY_GAP_BY_DEPT = "GENDER_PAY_GAP_BY_DEPT"
|
| 59 |
+
CATEGORY_FEMALE_MANAGERS_COUNT = "FEMALE_MANAGERS_COUNT"
|
| 60 |
+
CATEGORY_AVG_SALARY_BY_TITLE_AND_DEPT = "AVG_SALARY_BY_TITLE_AND_DEPT"
|
| 61 |
+
CATEGORY_GENDER_MANAGEMENT_COUNT = "GENDER_MANAGEMENT_COUNT"
|
| 62 |
+
CATEGORY_SENIOR_STAFF_SUPERLATIVE = "SENIOR_STAFF_SUPERLATIVE"
|
| 63 |
+
|
| 64 |
+
Category = str
|
| 65 |
+
|
| 66 |
+
# ---------------- Classification -----------------
|
| 67 |
+
|
| 68 |
+
def classify_question(question: str) -> Category:
|
| 69 |
+
q = question.lower()
|
| 70 |
+
# Specific order matters: more specialized patterns first
|
| 71 |
+
if any(k in q for k in ["top", "highest-paid", "highest paid"]) and "salary" in q:
|
| 72 |
+
return CATEGORY_TOP_N_SALARIES
|
| 73 |
+
if any(kw in q for kw in ["total number of employees", "total employees"]) or ("count" in q and "employee" in q and "department" not in q and "current" not in q):
|
| 74 |
+
return CATEGORY_SIMPLE_COUNT_EMP
|
| 75 |
+
if ("how many" in q or "count" in q) and "currently" in q and "employee" in q:
|
| 76 |
+
return CATEGORY_SIMPLE_COUNT_EMP_CURRENT
|
| 77 |
+
if ("how many" in q or "number of" in q or "count" in q) and "departments" in q and "manager" not in q:
|
| 78 |
+
return CATEGORY_SIMPLE_COUNT_DEPT
|
| 79 |
+
if any(w in q for w in ["highest", "lowest"]) and "salary" in q and "department" not in q:
|
| 80 |
+
return CATEGORY_SALARY_EXTREMES_GLOBAL
|
| 81 |
+
if "salary range" in q and "department" in q:
|
| 82 |
+
return CATEGORY_SALARY_RANGE_BY_DEPT
|
| 83 |
+
if "most employees" in q and "department" in q:
|
| 84 |
+
return CATEGORY_EMP_COUNT_BY_DEPT
|
| 85 |
+
if "highest average salary" in q and "department" in q:
|
| 86 |
+
return CATEGORY_AVG_SALARY
|
| 87 |
+
if any(k in q for k in ["manager", "managers"]) and "department" in q:
|
| 88 |
+
if "tenure" in q or "long" in q or "position" in q:
|
| 89 |
+
return CATEGORY_MANAGER_TENURE
|
| 90 |
+
if "changed" in q or "changes" in q or "frequently" in q:
|
| 91 |
+
return CATEGORY_MANAGER_CHANGE_FREQUENCY
|
| 92 |
+
if "list" in q and "manager" in q and "department" in q:
|
| 93 |
+
return CATEGORY_DEPT_MANAGER_LIST
|
| 94 |
+
return CATEGORY_MANAGER_LIST
|
| 95 |
+
if ("how many" in q or "count" in q) and "job title" in q and ("exist" in q or "different" in q):
|
| 96 |
+
return CATEGORY_TITLE_DISTINCT_COUNT
|
| 97 |
+
if ("how many" in q or "count" in q) and any(t in q for t in ["senior engineer", "senior engineers"]):
|
| 98 |
+
return CATEGORY_TITLE_SPECIFIC_COUNT
|
| 99 |
+
if "distribution" in q and "title" in q and "department" in q:
|
| 100 |
+
return CATEGORY_TITLE_DISTRIBUTION_BY_DEPT
|
| 101 |
+
if "promoted" in q and "engineer" in q and "senior engineer" in q:
|
| 102 |
+
return CATEGORY_PROMOTION_PATH_ENGINEER_TO_SENIOR
|
| 103 |
+
if ("how long" in q or "tenure" in q) and "current" in q and "department" in q and "employee" in q:
|
| 104 |
+
return CATEGORY_EMP_TENURE_CURRENT_DEPT
|
| 105 |
+
if ("hiring trend" in q) or ("hire" in q and "year" in q):
|
| 106 |
+
return CATEGORY_HIRING_TREND_BY_YEAR
|
| 107 |
+
if ("salaries" in q or "salary" in q) and "changed" in q and ("over time" in q or "year" in q):
|
| 108 |
+
return CATEGORY_SALARY_TREND_BY_YEAR
|
| 109 |
+
if "changed departments" in q or ("employees" in q and "changed" in q and "department" in q):
|
| 110 |
+
return CATEGORY_EMP_DEPT_CHANGES
|
| 111 |
+
if "gender distribution" in q and "department" in q:
|
| 112 |
+
return CATEGORY_GENDER_DIST_BY_DEPT
|
| 113 |
+
if "gender pay gap" in q and "department" in q:
|
| 114 |
+
return CATEGORY_GENDER_PAY_GAP_BY_DEPT
|
| 115 |
+
if ("female" in q and "manager" in q and ("how many" in q or "count" in q)):
|
| 116 |
+
return CATEGORY_FEMALE_MANAGERS_COUNT
|
| 117 |
+
if "average salary" in q and "title" in q and "department" in q:
|
| 118 |
+
return CATEGORY_AVG_SALARY_BY_TITLE_AND_DEPT
|
| 119 |
+
if ("gender" in q and "management" in q) or ("each gender" in q and "management" in q):
|
| 120 |
+
return CATEGORY_GENDER_MANAGEMENT_COUNT
|
| 121 |
+
if ("most" in q and "senior" in q and "staff" in q) or ("senior staff" in q and "most" in q):
|
| 122 |
+
return CATEGORY_SENIOR_STAFF_SUPERLATIVE
|
| 123 |
+
if any(k in q for k in ["list departments", "department names", "department name", "departments list"]):
|
| 124 |
+
return CATEGORY_DEPT_LIST
|
| 125 |
+
if "average" in q and "salary" in q and "department" in q:
|
| 126 |
+
return CATEGORY_AVG_SALARY
|
| 127 |
+
if ("count" in q or "how many" in q or "number of" in q) and any(t in q for t in ["engineer", "engineers", "title"]):
|
| 128 |
+
return CATEGORY_TITLE_COUNT
|
| 129 |
+
if ("count" in q or "how many" in q or "number of" in q or any(s in q for s in ["most", "largest", "highest", "fewest", "least"])) and "department" in q and ("employee" in q or "employees" in q):
|
| 130 |
+
return CATEGORY_EMP_COUNT_BY_DEPT
|
| 131 |
+
return CATEGORY_GENERIC
|
| 132 |
+
|
| 133 |
+
# --------------- Invariant Builders ---------------
|
| 134 |
+
|
| 135 |
+
def _regex(pattern: str) -> Callable[[str], bool]:
|
| 136 |
+
rx = re.compile(pattern, re.IGNORECASE)
|
| 137 |
+
return lambda sql: bool(rx.search(sql))
|
| 138 |
+
|
| 139 |
+
def _contains_all(*subs: str) -> Callable[[str], bool]:
|
| 140 |
+
def check(sql: str) -> bool:
|
| 141 |
+
l = sql.lower()
|
| 142 |
+
return all(s.lower() in l for s in subs)
|
| 143 |
+
return check
|
| 144 |
+
|
| 145 |
+
def build_invariants(category: Category, question: str) -> List[Dict[str, any]]:
|
| 146 |
+
inv: List[Dict[str, any]] = []
|
| 147 |
+
if category == CATEGORY_MANAGER_LIST:
|
| 148 |
+
inv += [
|
| 149 |
+
{"name": "has_department_join", "description": "Join department (alias d) with dept_manager (alias dm)", "check": lambda s: re.search(r"join\s+dept_manager", s, re.IGNORECASE) and re.search(r"from\s+department", s, re.IGNORECASE)},
|
| 150 |
+
{"name": "has_employee_join", "description": "Join employee (alias e) for manager names", "check": _regex(r"join\s+employee")},
|
| 151 |
+
{"name": "selects_names", "description": "Select employee first_name and last_name", "check": lambda s: all(x in s.lower() for x in ["first_name", "last_name"])},
|
| 152 |
+
{"name": "filters_current", "description": "Filter current managers dm.to_date='9999-01-01'", "check": _regex(r"dm\.to_date\s*=\s*'9999-01-01'")},
|
| 153 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100 semicolon", "check": _regex(r"limit\s+100;?\s*$")},
|
| 154 |
+
]
|
| 155 |
+
elif category == CATEGORY_DEPT_LIST:
|
| 156 |
+
inv += [
|
| 157 |
+
{"name": "from_department", "description": "Query FROM department table", "check": _regex(r"from\s+department")},
|
| 158 |
+
{"name": "select_dept_name", "description": "Select dept_name column", "check": _regex(r"dept_name")},
|
| 159 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 160 |
+
]
|
| 161 |
+
elif category == CATEGORY_AVG_SALARY:
|
| 162 |
+
inv += [
|
| 163 |
+
{"name": "joins_salary", "description": "Join salary s (FROM salary s)", "check": _regex(r"from\s+salary\s+s")},
|
| 164 |
+
{"name": "joins_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 165 |
+
{"name": "joins_department", "description": "Join department d", "check": _regex(r"join\s+department")},
|
| 166 |
+
{"name": "avg_salary", "description": "Uses AVG(s.salary)", "check": _regex(r"avg\s*\(\s*s\.salary\s*\)")},
|
| 167 |
+
{"name": "current_filters", "description": "Filters current rows s.to_date='9999-01-01' and de.to_date='9999-01-01'", "check": lambda s: "s.to_date='9999-01-01'" in s.lower() and "de.to_date='9999-01-01'" in s.lower()},
|
| 168 |
+
{"name": "group_by_dept", "description": "GROUP BY department name", "check": _regex(r"group\s+by\s+.*dept_name")},
|
| 169 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 170 |
+
]
|
| 171 |
+
elif category == CATEGORY_TITLE_COUNT:
|
| 172 |
+
# Attempt to infer canonical title (not used in invariants; regeneration prompt may hint)
|
| 173 |
+
inv += [
|
| 174 |
+
{"name": "from_title", "description": "FROM title table (alias t)", "check": _regex(r"from\s+title")},
|
| 175 |
+
{"name": "alias_t", "description": "Alias 't' used for title table", "check": _regex(r"from\s+title\s+(?:as\s+)?t")},
|
| 176 |
+
{"name": "current_rows", "description": "Filter current rows t.to_date='9999-01-01'", "check": _regex(r"t\.to_date\s*=\s*'9999-01-01'")},
|
| 177 |
+
{"name": "count_distinct", "description": "COUNT(DISTINCT t.emp_no)", "check": _regex(r"count\s*\(\s*distinct\s+t\.emp_no\s*\)")},
|
| 178 |
+
{"name": "has_title_filter", "description": "Filter on t.title = '<Some Title>'", "check": _regex(r"t\.title\s*=\s*'[^']+'")} ,
|
| 179 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 180 |
+
]
|
| 181 |
+
elif category == CATEGORY_EMP_COUNT_BY_DEPT:
|
| 182 |
+
inv += [
|
| 183 |
+
{"name": "has_dept_emp_join", "description": "Join dept_emp de with department d", "check": lambda s: re.search(r"join\s+dept_emp", s, re.IGNORECASE) and re.search(r"join\s+department|from\s+department", s, re.IGNORECASE)},
|
| 184 |
+
{"name": "select_dept_name", "description": "Select d.dept_name", "check": _regex(r"d\.dept_name|dept_name")},
|
| 185 |
+
{"name": "select_count", "description": "Include COUNT(*) or COUNT(de.emp_no)", "check": _regex(r"count\s*\(")},
|
| 186 |
+
{"name": "group_by", "description": "GROUP BY d.dept_name present", "check": _regex(r"group\s+by\s+.*dept_name")},
|
| 187 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 188 |
+
{"name": "count_alias", "description": "Provide an explicit alias e.g. COUNT(*) AS employee_count", "check": _regex(r"count\s*\(.*\)\s+as\s+employee_count")},
|
| 189 |
+
]
|
| 190 |
+
# If the question is superlative-oriented, encourage ordering
|
| 191 |
+
ql = question.lower()
|
| 192 |
+
if any(w in ql for w in ["most", "largest", "highest", "fewest", "least"]):
|
| 193 |
+
inv.append({"name": "order_by_count", "description": "ORDER BY employee_count DESC (or ASC for fewest/least)", "check": lambda s: ("fewest" in ql or "least" in ql and re.search(r"order\s+by\s+employee_count\s+asc", s, re.IGNORECASE)) or re.search(r"order\s+by\s+employee_count\s+desc", s, re.IGNORECASE)})
|
| 194 |
+
elif category == CATEGORY_SIMPLE_COUNT_EMP:
|
| 195 |
+
inv += [
|
| 196 |
+
{"name": "from_employee", "description": "FROM employee table", "check": _regex(r"from\s+employee")},
|
| 197 |
+
{"name": "count_all", "description": "COUNT(*) with alias total_employees", "check": _regex(r"count\s*\(\s*\*\s*\)\s+as\s+total_employees")},
|
| 198 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 199 |
+
]
|
| 200 |
+
elif category == CATEGORY_SIMPLE_COUNT_EMP_CURRENT:
|
| 201 |
+
inv += [
|
| 202 |
+
{"name": "join_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 203 |
+
{"name": "current_filter", "description": "de.to_date='9999-01-01' filter", "check": _regex(r"de\.to_date\s*=\s*'9999-01-01'")},
|
| 204 |
+
{"name": "count_distinct_emp", "description": "COUNT(DISTINCT de.emp_no) alias current_employee_count", "check": _regex(r"count\s*\(\s*distinct\s+de\.emp_no\s*\)\s+as\s+current_employee_count")},
|
| 205 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 206 |
+
]
|
| 207 |
+
elif category == CATEGORY_SIMPLE_COUNT_DEPT:
|
| 208 |
+
inv += [
|
| 209 |
+
{"name": "from_department", "description": "FROM department", "check": _regex(r"from\s+department")},
|
| 210 |
+
{"name": "count_depts", "description": "COUNT(*) AS department_count", "check": _regex(r"count\s*\(\s*\*\s*\)\s+as\s+department_count")},
|
| 211 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 212 |
+
]
|
| 213 |
+
elif category == CATEGORY_SALARY_EXTREMES_GLOBAL:
|
| 214 |
+
# Extremely strict invariants for highest/lowest salary by department
|
| 215 |
+
inv += [
|
| 216 |
+
{"name": "from_salary_s", "description": "FROM salary s", "check": _regex(r"from\s+salary\s+s")},
|
| 217 |
+
{"name": "join_dept_emp_de", "description": "JOIN dept_emp de ON s.emp_no = de.emp_no", "check": _regex(r"join\s+dept_emp\s+de\s+on\s+s\.emp_no\s*=\s*de\.emp_no")},
|
| 218 |
+
{"name": "join_department_d", "description": "JOIN department d ON de.dept_no = d.dept_no", "check": _regex(r"join\s+department\s+d\s+on\s+de\.dept_no\s*=\s*d\.dept_no")},
|
| 219 |
+
{"name": "select_dept_name", "description": "SELECT d.dept_name", "check": _regex(r"select\s+d\.dept_name")},
|
| 220 |
+
{"name": "select_max_min_salary", "description": "SELECT MAX(s.salary) AS highest_salary, MIN(s.salary) AS lowest_salary", "check": lambda s: re.search(r"max\s*\(\s*s\.salary\s*\)\s+as\s+highest_salary", s, re.IGNORECASE) and re.search(r"min\s*\(\s*s\.salary\s*\)\s+as\s+lowest_salary", s, re.IGNORECASE)},
|
| 221 |
+
{"name": "group_by_dept_name", "description": "GROUP BY d.dept_name", "check": _regex(r"group\s+by\s+d\.dept_name")},
|
| 222 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 223 |
+
{"name": "no_unaliased_salary", "description": "Do not use MAX(salary) or MIN(salary) without s. alias", "check": lambda s: not re.search(r"max\s*\(\s*salary\s*\)", s, re.IGNORECASE) and not re.search(r"min\s*\(\s*salary\s*\)", s, re.IGNORECASE)},
|
| 224 |
+
{"name": "no_unaliased_dept_name", "description": "Do not use dept_name without d. alias", "check": lambda s: not re.search(r"[^a-zA-Z0-9_]dept_name", s) or re.search(r"d\.dept_name", s)},
|
| 225 |
+
]
|
| 226 |
+
elif category == CATEGORY_SALARY_RANGE_BY_DEPT:
|
| 227 |
+
# Stricter invariants for salary range by department
|
| 228 |
+
inv += [
|
| 229 |
+
{"name": "joins_salary", "description": "FROM salary s", "check": _regex(r"from\s+salary\s+s")},
|
| 230 |
+
{"name": "joins_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 231 |
+
{"name": "joins_department", "description": "Join department d", "check": _regex(r"join\s+department")},
|
| 232 |
+
{"name": "select_dept_name", "description": "Select d.dept_name", "check": _regex(r"d\.dept_name")},
|
| 233 |
+
{"name": "min_max", "description": "MIN(s.salary) & MAX(s.salary)", "check": lambda s: re.search(r"min\s*\(\s*s\.salary", s, re.IGNORECASE) and re.search(r"max\s*\(\s*s\.salary", s, re.IGNORECASE)},
|
| 234 |
+
{"name": "dept_filter", "description": "Filter on d.dept_name=...", "check": _regex(r"d\.dept_name\s*=\s*'")},
|
| 235 |
+
{"name": "group_by_dept", "description": "GROUP BY d.dept_name", "check": _regex(r"group\s+by\s+.*d\.dept_name")},
|
| 236 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 237 |
+
]
|
| 238 |
+
elif category == CATEGORY_TOP_N_SALARIES:
|
| 239 |
+
inv += [
|
| 240 |
+
{"name": "joins_salary", "description": "FROM salary s", "check": _regex(r"from\s+salary\s+s")},
|
| 241 |
+
{"name": "current_filter", "description": "s.to_date='9999-01-01'", "check": _regex(r"s\.to_date\s*=\s*'9999-01-01'")},
|
| 242 |
+
{"name": "order_desc", "description": "ORDER BY s.salary DESC", "check": _regex(r"order\s+by\s+s\.salary\s+desc")},
|
| 243 |
+
{"name": "limit_present", "description": "Has a LIMIT clause", "check": _regex(r"limit\s+\d+")},
|
| 244 |
+
]
|
| 245 |
+
elif category == CATEGORY_DEPT_MANAGER_LIST:
|
| 246 |
+
inv += [
|
| 247 |
+
{"name": "joins_dept_manager", "description": "Join dept_manager dm", "check": _regex(r"join\s+dept_manager")},
|
| 248 |
+
{"name": "joins_employee", "description": "Join employee e", "check": _regex(r"join\s+employee")},
|
| 249 |
+
{"name": "select_names", "description": "Select manager first_name / last_name", "check": lambda s: "first_name" in s.lower() and "last_name" in s.lower()},
|
| 250 |
+
{"name": "dept_name", "description": "Select dept_name", "check": _regex(r"dept_name")},
|
| 251 |
+
{"name": "current_filter", "description": "dm.to_date='9999-01-01'", "check": _regex(r"dm\.to_date\s*=\s*'9999-01-01'")},
|
| 252 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 253 |
+
]
|
| 254 |
+
elif category == CATEGORY_MANAGER_TENURE:
|
| 255 |
+
inv += [
|
| 256 |
+
{"name": "joins_dept_manager", "description": "Join dept_manager dm", "check": _regex(r"join\s+dept_manager")},
|
| 257 |
+
{"name": "current_filter", "description": "dm.to_date='9999-01-01'", "check": _regex(r"dm\.to_date\s*=\s*'9999-01-01'")},
|
| 258 |
+
{"name": "tenure_expr", "description": "Contains dm.from_date for tenure calc", "check": _regex(r"dm\.from_date")},
|
| 259 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 260 |
+
]
|
| 261 |
+
elif category == CATEGORY_MANAGER_CHANGE_FREQUENCY:
|
| 262 |
+
inv += [
|
| 263 |
+
{"name": "from_dept_manager", "description": "FROM dept_manager", "check": _regex(r"from\s+dept_manager")},
|
| 264 |
+
{"name": "group_by_dept", "description": "GROUP BY dept_no or dept", "check": _regex(r"group\s+by\s+.*dept")},
|
| 265 |
+
{"name": "count_changes", "description": "COUNT(*) or COUNT(DISTINCT emp_no)", "check": _regex(r"count\s*\(")},
|
| 266 |
+
{"name": "order_desc", "description": "ORDER BY count desc", "check": _regex(r"order\s+by\s+.*count.*desc")},
|
| 267 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 268 |
+
]
|
| 269 |
+
elif category == CATEGORY_TITLE_DISTINCT_COUNT:
|
| 270 |
+
inv += [
|
| 271 |
+
{"name": "from_title", "description": "FROM title t", "check": _regex(r"from\s+title")},
|
| 272 |
+
{"name": "count_distinct_titles", "description": "COUNT(DISTINCT t.title) alias title_count", "check": _regex(r"count\s*\(\s*distinct\s+t\.title\s*\)\s+as\s+title_count")},
|
| 273 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 274 |
+
]
|
| 275 |
+
elif category == CATEGORY_TITLE_SPECIFIC_COUNT:
|
| 276 |
+
inv += [
|
| 277 |
+
{"name": "from_title", "description": "FROM title t", "check": _regex(r"from\s+title")},
|
| 278 |
+
{"name": "title_filter", "description": "Filter t.title='Senior Engineer'", "check": _regex(r"t\.title\s*=\s*'senior engineer'")},
|
| 279 |
+
{"name": "current_filter", "description": "t.to_date='9999-01-01'", "check": _regex(r"t\.to_date\s*=\s*'9999-01-01'")},
|
| 280 |
+
{"name": "count_distinct_emp", "description": "COUNT(DISTINCT t.emp_no)", "check": _regex(r"count\s*\(\s*distinct\s+t\.emp_no")},
|
| 281 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 282 |
+
]
|
| 283 |
+
elif category == CATEGORY_TITLE_DISTRIBUTION_BY_DEPT:
|
| 284 |
+
inv += [
|
| 285 |
+
{"name": "joins_title", "description": "Join title t", "check": _regex(r"join\s+title")},
|
| 286 |
+
{"name": "joins_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 287 |
+
{"name": "joins_department", "description": "Join department d", "check": _regex(r"join\s+department")},
|
| 288 |
+
{"name": "current_filters", "description": "t.to_date & de.to_date current filters", "check": lambda s: "t.to_date='9999-01-01'" in s.lower() and "de.to_date='9999-01-01'" in s.lower()},
|
| 289 |
+
{"name": "group_by", "description": "GROUP BY dept and title", "check": _regex(r"group\s+by\s+.*(dept|title).*(title|dept)")},
|
| 290 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 291 |
+
]
|
| 292 |
+
elif category == CATEGORY_PROMOTION_PATH_ENGINEER_TO_SENIOR:
|
| 293 |
+
inv += [
|
| 294 |
+
{"name": "dual_title_refs", "description": "References title table twice (t1, t2)", "check": _regex(r"title\s+t1.*title\s+t2|title\s+t2.*title\s+t1")},
|
| 295 |
+
{"name": "promotion_condition", "description": "t1.title='Engineer' AND t2.title='Senior Engineer'", "check": _regex(r"t1\.title\s*=\s*'engineer'.*t2\.title\s*=\s*'senior engineer'|t2\.title\s*=\s*'senior engineer'.*t1\.title\s*=\s*'engineer'")},
|
| 296 |
+
{"name": "temporal_order", "description": "t1.from_date < t2.from_date", "check": _regex(r"t1\.from_date\s*<\s*t2\.from_date")},
|
| 297 |
+
{"name": "distinct_emp", "description": "DISTINCT emp_no selected", "check": _regex(r"select\s+distinct\s+t1\.emp_no|select\s+distinct\s+t2\.emp_no")},
|
| 298 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 299 |
+
]
|
| 300 |
+
elif category == CATEGORY_EMP_TENURE_CURRENT_DEPT:
|
| 301 |
+
inv += [
|
| 302 |
+
{"name": "joins_dept_emp", "description": "FROM dept_emp de", "check": _regex(r"from\s+dept_emp")},
|
| 303 |
+
{"name": "current_filter", "description": "de.to_date='9999-01-01'", "check": _regex(r"de\.to_date\s*=\s*'9999-01-01'")},
|
| 304 |
+
{"name": "tenure_expr", "description": "Uses de.from_date", "check": _regex(r"de\.from_date")},
|
| 305 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 306 |
+
]
|
| 307 |
+
elif category == CATEGORY_HIRING_TREND_BY_YEAR:
|
| 308 |
+
inv += [
|
| 309 |
+
{"name": "from_employee", "description": "FROM employee e", "check": _regex(r"from\s+employee")},
|
| 310 |
+
{"name": "year_extract", "description": "EXTRACT(YEAR FROM hire_date)", "check": _regex(r"extract\s*\(\s*year\s+from\s+.*hire_date")},
|
| 311 |
+
{"name": "group_by_year", "description": "GROUP BY year", "check": _regex(r"group\s+by\s+.*year")},
|
| 312 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 313 |
+
]
|
| 314 |
+
elif category == CATEGORY_SALARY_TREND_BY_YEAR:
|
| 315 |
+
inv += [
|
| 316 |
+
{"name": "from_salary", "description": "FROM salary s", "check": _regex(r"from\s+salary")},
|
| 317 |
+
{"name": "year_extract", "description": "EXTRACT(YEAR FROM s.from_date)", "check": _regex(r"extract\s*\(\s*year\s+from\s+s\.from_date")},
|
| 318 |
+
{"name": "avg_salary", "description": "AVG(s.salary)", "check": _regex(r"avg\s*\(\s*s\.salary")},
|
| 319 |
+
{"name": "group_by_year", "description": "GROUP BY year", "check": _regex(r"group\s+by\s+.*year")},
|
| 320 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 321 |
+
]
|
| 322 |
+
elif category == CATEGORY_EMP_DEPT_CHANGES:
|
| 323 |
+
inv += [
|
| 324 |
+
{"name": "from_dept_emp", "description": "FROM dept_emp de", "check": _regex(r"from\s+dept_emp")},
|
| 325 |
+
{"name": "group_by_emp", "description": "GROUP BY de.emp_no", "check": _regex(r"group\s+by\s+.*emp_no")},
|
| 326 |
+
{"name": "having_multiple", "description": "HAVING COUNT(DISTINCT dept_no) > 1", "check": _regex(r"having\s+count\s*\(\s*distinct\s+dept_no\s*\)\s*>\s*1")},
|
| 327 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 328 |
+
]
|
| 329 |
+
elif category == CATEGORY_GENDER_DIST_BY_DEPT:
|
| 330 |
+
inv += [
|
| 331 |
+
{"name": "joins_employee", "description": "Join employee e", "check": _regex(r"join\s+employee")},
|
| 332 |
+
{"name": "joins_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 333 |
+
{"name": "joins_department", "description": "Join department d", "check": _regex(r"join\s+department")},
|
| 334 |
+
{"name": "current_filter", "description": "de.to_date='9999-01-01'", "check": _regex(r"de\.to_date\s*=\s*'9999-01-01'")},
|
| 335 |
+
{"name": "group_by_dept_gender", "description": "GROUP BY dept & gender", "check": _regex(r"group\s+by\s+.*(dept|gender).*(gender|dept)")},
|
| 336 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 337 |
+
]
|
| 338 |
+
elif category == CATEGORY_GENDER_PAY_GAP_BY_DEPT:
|
| 339 |
+
inv += [
|
| 340 |
+
{"name": "joins_salary", "description": "Join salary s", "check": _regex(r"join\s+salary")},
|
| 341 |
+
{"name": "joins_employee", "description": "Join employee e", "check": _regex(r"join\s+employee")},
|
| 342 |
+
{"name": "joins_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 343 |
+
{"name": "joins_department", "description": "Join department d", "check": _regex(r"join\s+department")},
|
| 344 |
+
{"name": "current_filters", "description": "Current rows s.to_date & de.to_date", "check": lambda s: "s.to_date='9999-01-01'" in s.lower() and "de.to_date='9999-01-01'" in s.lower()},
|
| 345 |
+
{"name": "avg_salary", "description": "AVG(s.salary)", "check": _regex(r"avg\s*\(\s*s\.salary")},
|
| 346 |
+
{"name": "group_by_dept_gender", "description": "GROUP BY dept & gender", "check": _regex(r"group\s+by\s+.*(dept|gender).*(gender|dept)")},
|
| 347 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 348 |
+
]
|
| 349 |
+
elif category == CATEGORY_FEMALE_MANAGERS_COUNT:
|
| 350 |
+
inv += [
|
| 351 |
+
{"name": "joins_dept_manager", "description": "Join dept_manager dm", "check": _regex(r"join\s+dept_manager")},
|
| 352 |
+
{"name": "joins_employee", "description": "Join employee e", "check": _regex(r"join\s+employee")},
|
| 353 |
+
{"name": "female_filter", "description": "e.gender='F'", "check": _regex(r"e\.gender\s*=\s*'f'")},
|
| 354 |
+
{"name": "current_filter", "description": "dm.to_date='9999-01-01'", "check": _regex(r"dm\.to_date\s*=\s*'9999-01-01'")},
|
| 355 |
+
{"name": "count_distinct_mgr", "description": "COUNT(DISTINCT dm.emp_no)", "check": _regex(r"count\s*\(\s*distinct\s+dm\.emp_no")},
|
| 356 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 357 |
+
]
|
| 358 |
+
elif category == CATEGORY_AVG_SALARY_BY_TITLE_AND_DEPT:
|
| 359 |
+
inv += [
|
| 360 |
+
{"name": "joins_salary", "description": "Join salary s", "check": _regex(r"join\s+salary")},
|
| 361 |
+
{"name": "joins_title", "description": "Join title t", "check": _regex(r"join\s+title")},
|
| 362 |
+
{"name": "joins_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 363 |
+
{"name": "joins_department", "description": "Join department d", "check": _regex(r"join\s+department")},
|
| 364 |
+
{"name": "current_filters", "description": "Current rows for s,de,t", "check": lambda s: all(x in s.lower() for x in ["s.to_date='9999-01-01'", "de.to_date='9999-01-01'", "t.to_date='9999-01-01'"])},
|
| 365 |
+
{"name": "avg_salary", "description": "AVG(s.salary)", "check": _regex(r"avg\s*\(\s*s\.salary")},
|
| 366 |
+
{"name": "group_by", "description": "GROUP BY dept & title", "check": _regex(r"group\s+by\s+.*(dept|title).*(title|dept)")},
|
| 367 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 368 |
+
]
|
| 369 |
+
elif category == CATEGORY_GENDER_MANAGEMENT_COUNT:
|
| 370 |
+
inv += [
|
| 371 |
+
{"name": "joins_dept_manager", "description": "Join dept_manager dm", "check": _regex(r"join\s+dept_manager")},
|
| 372 |
+
{"name": "joins_employee", "description": "Join employee e", "check": _regex(r"join\s+employee")},
|
| 373 |
+
{"name": "current_filter", "description": "dm.to_date='9999-01-01'", "check": _regex(r"dm\.to_date\s*=\s*'9999-01-01'")},
|
| 374 |
+
{"name": "group_by_gender", "description": "GROUP BY gender", "check": _regex(r"group\s+by\s+.*gender")},
|
| 375 |
+
{"name": "count_mgr", "description": "COUNT(*) or COUNT(dm.emp_no)", "check": _regex(r"count\s*\(")},
|
| 376 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 377 |
+
]
|
| 378 |
+
elif category == CATEGORY_SENIOR_STAFF_SUPERLATIVE:
|
| 379 |
+
inv += [
|
| 380 |
+
{"name": "joins_title", "description": "Join title t", "check": _regex(r"join\s+title")},
|
| 381 |
+
{"name": "joins_dept_emp", "description": "Join dept_emp de", "check": _regex(r"join\s+dept_emp")},
|
| 382 |
+
{"name": "joins_department", "description": "Join department d", "check": _regex(r"join\s+department")},
|
| 383 |
+
{"name": "senior_filter", "description": "t.title LIKE 'Senior%'", "check": _regex(r"t\.title\s+like\s+'senior%'")},
|
| 384 |
+
{"name": "current_filters", "description": "Current rows de.to_date & t.to_date", "check": lambda s: "de.to_date='9999-01-01'" in s.lower() and "t.to_date='9999-01-01'" in s.lower()},
|
| 385 |
+
{"name": "group_by_dept", "description": "GROUP BY d.dept_name", "check": _regex(r"group\s+by\s+.*dept_name")},
|
| 386 |
+
{"name": "order_desc", "description": "ORDER BY count desc", "check": _regex(r"order\s+by\s+.*count.*desc")},
|
| 387 |
+
{"name": "limit_100", "description": "Ends with LIMIT 100", "check": _regex(r"limit\s+100;?\s*$")},
|
| 388 |
+
]
|
| 389 |
+
return inv
|
| 390 |
+
|
| 391 |
+
# --------------- Enforcement -----------------
|
| 392 |
+
|
| 393 |
+
def evaluate_invariants(sql: str, invariants: List[Dict[str, any]]) -> List[Dict[str, any]]:
|
| 394 |
+
failures = []
|
| 395 |
+
for inv in invariants:
|
| 396 |
+
try:
|
| 397 |
+
ok = inv['check'](sql)
|
| 398 |
+
except Exception:
|
| 399 |
+
ok = False
|
| 400 |
+
if not ok:
|
| 401 |
+
failures.append(inv)
|
| 402 |
+
return failures
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
# More explicit prompt for join-based salary extremes queries
|
| 406 |
+
REGEN_PROMPT_TEMPLATE = (
|
| 407 |
+
"You produced SQL for the user question. Some required invariants are missing.\n"
|
| 408 |
+
"Regenerate a SINGLE valid PostgreSQL SQL query that satisfies ALL listed invariants.\n"
|
| 409 |
+
"Return ONLY the SQL (no commentary). Must end with LIMIT 100; and a semicolon.\n"
|
| 410 |
+
"If the question is about highest/lowest salary by department, you MUST:\n"
|
| 411 |
+
"- Use FROM salary s\n"
|
| 412 |
+
"- JOIN dept_emp de ON s.emp_no = de.emp_no\n"
|
| 413 |
+
"- JOIN department d ON de.dept_no = d.dept_no\n"
|
| 414 |
+
"- SELECT d.dept_name, MAX(s.salary) AS highest_salary, MIN(s.salary) AS lowest_salary\n"
|
| 415 |
+
"- GROUP BY d.dept_name\n"
|
| 416 |
+
"- Use correct table aliases everywhere (no unaliased salary or dept_name)\n"
|
| 417 |
+
"- Do NOT reference columns that do not exist in the schema\n"
|
| 418 |
+
"User Question: {question}\n"
|
| 419 |
+
"Previous Attempt: {previous_sql}\n"
|
| 420 |
+
"Missing Invariants:\n{invariant_lines}\n"
|
| 421 |
+
"Regenerate now:"
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def regenerate_with_constraints(question: str, previous_sql: str, missing: List[Dict[str, any]], llm, run_llm_fn, attempt: int) -> Optional[str]:
|
| 426 |
+
invariant_lines = "\n".join(f"- {m['name']}: {m['description']}" for m in missing)
|
| 427 |
+
prompt = REGEN_PROMPT_TEMPLATE.format(question=question, previous_sql=previous_sql, invariant_lines=invariant_lines)
|
| 428 |
+
# Use proper timeout values instead of hardcoded ones
|
| 429 |
+
# Start with a good timeout and decrease gradually for subsequent attempts
|
| 430 |
+
base_timeout = float(os.environ.get("INVARIANTS_BASE_TIMEOUT", 90)) # Start with 90s
|
| 431 |
+
timeout = max(30.0, base_timeout - attempt * 20.0) # Decrease by 20s per attempt, minimum 30s
|
| 432 |
+
try:
|
| 433 |
+
raw = run_llm_fn(llm, prompt, timeout=max(8.0, timeout), retries=0)
|
| 434 |
+
# Normalize raw response to text
|
| 435 |
+
raw_text = getattr(raw, 'text', None)
|
| 436 |
+
if raw_text is None:
|
| 437 |
+
raw_text = str(raw)
|
| 438 |
+
# Extract first SELECT ... ;
|
| 439 |
+
m = re.search(r"(SELECT[\s\S]+?;)", raw_text, flags=re.IGNORECASE)
|
| 440 |
+
candidate = m.group(1) if m else raw_text.strip()
|
| 441 |
+
# Clean markdown fences
|
| 442 |
+
candidate = re.sub(r"```(?:sql)?|```", "", candidate, flags=re.IGNORECASE).strip()
|
| 443 |
+
# Ensure single statement & required LIMIT 100 at end
|
| 444 |
+
# Collapse accidental mid-statement semicolon before LIMIT
|
| 445 |
+
candidate = re.sub(r";\s*(LIMIT\s+\d+)", r" \1", candidate, flags=re.IGNORECASE)
|
| 446 |
+
# Append LIMIT 100 if missing (invariants will still verify)
|
| 447 |
+
if 'limit' not in candidate.lower():
|
| 448 |
+
if candidate.endswith(';'):
|
| 449 |
+
candidate = candidate[:-1] + ' LIMIT 100;'
|
| 450 |
+
else:
|
| 451 |
+
candidate += ' LIMIT 100;'
|
| 452 |
+
# Guarantee trailing semicolon
|
| 453 |
+
if not candidate.strip().endswith(';'):
|
| 454 |
+
candidate += ';'
|
| 455 |
+
return candidate
|
| 456 |
+
except Exception as e:
|
| 457 |
+
print(f"[INVARIANTS] Regeneration failed: {e}")
|
| 458 |
+
return None
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def enforce_invariants(question: str, sql: str, llm, run_llm_fn, max_regen: int = 2) -> Dict[str, any]:
|
| 462 |
+
category = classify_question(question)
|
| 463 |
+
if category == CATEGORY_GENERIC:
|
| 464 |
+
return {"category": category, "final_sql": sql, "regenerations": []}
|
| 465 |
+
invariants = build_invariants(category, question)
|
| 466 |
+
regenerations = []
|
| 467 |
+
current_sql = sql
|
| 468 |
+
# Increase max_regen to 5 for more LLM attempts
|
| 469 |
+
for attempt in range(max_regen + 4):
|
| 470 |
+
missing = evaluate_invariants(current_sql, invariants)
|
| 471 |
+
if not missing:
|
| 472 |
+
return {"category": category, "final_sql": current_sql, "regenerations": regenerations}
|
| 473 |
+
if attempt == max_regen:
|
| 474 |
+
# Give up, return last with missing list for transparency
|
| 475 |
+
return {"category": category, "final_sql": current_sql, "regenerations": regenerations, "remaining_missing": [m['name'] for m in missing]}
|
| 476 |
+
regen_sql = regenerate_with_constraints(question, current_sql, missing, llm, run_llm_fn, attempt)
|
| 477 |
+
if not regen_sql:
|
| 478 |
+
return {"category": category, "final_sql": current_sql, "regenerations": regenerations, "remaining_missing": [m['name'] for m in missing]}
|
| 479 |
+
regenerations.append({
|
| 480 |
+
"attempt": attempt + 1,
|
| 481 |
+
"missing_before": [m['name'] for m in missing],
|
| 482 |
+
"sql": regen_sql
|
| 483 |
+
})
|
| 484 |
+
current_sql = regen_sql
|
| 485 |
+
return {"category": category, "final_sql": current_sql, "regenerations": regenerations}
|
Agent/llm_health_check.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, time, json, sys
|
| 2 |
+
from llama_index.llms.ollama import Ollama
|
| 3 |
+
|
| 4 |
+
BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
| 5 |
+
MODEL = os.environ.get("FINETUNED_MODEL_NAME") or os.environ.get("OLLAMA_MODEL_NAME", "gemma3:1b")
|
| 6 |
+
|
| 7 |
+
print(f"Checking Ollama LLM connectivity: base_url={BASE_URL} model={MODEL}")
|
| 8 |
+
|
| 9 |
+
# Lightweight client check without llama_index first (raw HTTP) to see if daemon responds
|
| 10 |
+
try:
|
| 11 |
+
import requests
|
| 12 |
+
r = requests.get(f"{BASE_URL.rstrip('/')}/api/tags", timeout=5)
|
| 13 |
+
print(f"/api/tags status: {r.status_code}")
|
| 14 |
+
if r.ok:
|
| 15 |
+
tags = r.json().get('models', [])
|
| 16 |
+
names = [m.get('name') for m in tags]
|
| 17 |
+
print(f"Available models: {names}")
|
| 18 |
+
if MODEL not in names:
|
| 19 |
+
print(f"Model '{MODEL}' not yet pulled. Run: ollama pull {MODEL}")
|
| 20 |
+
else:
|
| 21 |
+
print("Non-200 response from Ollama daemon")
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print(f"Raw HTTP check failed: {e}")
|
| 24 |
+
|
| 25 |
+
# LlamaIndex call
|
| 26 |
+
try:
|
| 27 |
+
llm = Ollama(model=MODEL, base_url=BASE_URL, temperature=0.1, request_timeout=30)
|
| 28 |
+
t0 = time.time()
|
| 29 |
+
resp = llm.complete(prompt="SELECT 1;")
|
| 30 |
+
dt = time.time() - t0
|
| 31 |
+
print(f"LLM call success in {dt:.2f}s. Raw text: {resp.text[:120]!r}")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"LLM complete() failed: {type(e).__name__}: {e}")
|
| 34 |
+
sys.exit(1)
|
| 35 |
+
|
| 36 |
+
print("Health check finished.")
|
Agent/mcp_client.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal MCP client for local MCP server.
|
| 2 |
+
|
| 3 |
+
Provides mcp_complete(prompt, timeout, context) -> str
|
| 4 |
+
Tries to POST to MCP server and return the completion text. Raises on errors so caller can fallback.
|
| 5 |
+
"""
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import requests
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
MCP_URL = os.environ.get("MCP_URL", "http://127.0.0.1:8701/complete")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def mcp_complete(prompt: str, timeout: float = 30.0, context: Optional[Dict[str, Any]] = None) -> str:
|
| 15 |
+
"""Send a completion request to the local MCP server and return text.
|
| 16 |
+
|
| 17 |
+
Raises requests.RequestException on transport errors or ValueError if MCP returns error.
|
| 18 |
+
"""
|
| 19 |
+
payload = {
|
| 20 |
+
"prompt": prompt,
|
| 21 |
+
"context": context or {},
|
| 22 |
+
"timeout": timeout
|
| 23 |
+
}
|
| 24 |
+
headers = {"Content-Type": "application/json"}
|
| 25 |
+
resp = requests.post(MCP_URL, data=json.dumps(payload), headers=headers, timeout=timeout)
|
| 26 |
+
resp.raise_for_status()
|
| 27 |
+
body = resp.json()
|
| 28 |
+
# Expecting {'text': '...'} from MCP server
|
| 29 |
+
if not isinstance(body, dict) or 'text' not in body:
|
| 30 |
+
raise ValueError(f"Unexpected MCP response: {body}")
|
| 31 |
+
return body['text']
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def mcp_call_tool(prompt: str, timeout: float = 60.0, context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 35 |
+
"""Call the MCP /complete endpoint and return the full JSON response.
|
| 36 |
+
|
| 37 |
+
This is useful when asking MCP to run a registered tool; the tool result will
|
| 38 |
+
appear under the `tool_result` key in the returned dict.
|
| 39 |
+
"""
|
| 40 |
+
payload = {
|
| 41 |
+
"prompt": prompt,
|
| 42 |
+
"context": context or {},
|
| 43 |
+
"timeout": timeout,
|
| 44 |
+
}
|
| 45 |
+
headers = {"Content-Type": "application/json"}
|
| 46 |
+
resp = requests.post(MCP_URL, data=json.dumps(payload), headers=headers, timeout=timeout)
|
| 47 |
+
resp.raise_for_status()
|
| 48 |
+
return resp.json()
|
Agent/mcp_server.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
from typing import Any, Dict, Optional, List
|
| 4 |
+
import asyncio
|
| 5 |
+
import importlib
|
| 6 |
+
import uuid
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
# Attempt to import agent workflow for schema/inspector & utilities (optional)
|
| 11 |
+
try:
|
| 12 |
+
import Agent.agentic_workflow as aw
|
| 13 |
+
print(f"[MCP STARTUP] Successfully imported Agent.agentic_workflow")
|
| 14 |
+
except Exception as e:
|
| 15 |
+
print(f"[MCP STARTUP] Failed to import Agent.agentic_workflow: {e}")
|
| 16 |
+
aw = None
|
| 17 |
+
|
| 18 |
+
app = FastAPI(title="Minimal MCP Server (dev)")
|
| 19 |
+
|
| 20 |
+
# Simple tool registry
|
| 21 |
+
TOOLS: Dict[str, Any] = {}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CompleteRequest(BaseModel):
|
| 25 |
+
prompt: str
|
| 26 |
+
context: Optional[Dict[str, Any]] = None
|
| 27 |
+
timeout: Optional[float] = 30.0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def register_tool(name: str, fn: Any):
|
| 31 |
+
TOOLS[name] = fn
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def _call_tool(name: str, args: Dict[str, Any]) -> Any:
|
| 35 |
+
if name not in TOOLS:
|
| 36 |
+
raise KeyError(f"Tool '{name}' not registered")
|
| 37 |
+
fn = TOOLS[name]
|
| 38 |
+
if asyncio.iscoroutinefunction(fn):
|
| 39 |
+
return await fn(**(args or {}))
|
| 40 |
+
else:
|
| 41 |
+
# run sync in threadpool
|
| 42 |
+
loop = asyncio.get_running_loop()
|
| 43 |
+
return await loop.run_in_executor(None, lambda: fn(**(args or {})))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@app.on_event("startup")
|
| 47 |
+
async def _startup():
|
| 48 |
+
# Register built-in/mock tools lazily to avoid importing heavy modules at import time
|
| 49 |
+
# Try to register a real document scanner implementation; otherwise register a mock
|
| 50 |
+
ds_registered = None
|
| 51 |
+
try:
|
| 52 |
+
from .tools import document_scanner as ds
|
| 53 |
+
register_tool("document_scanner.process", ds.process_image)
|
| 54 |
+
ds_registered = f"Agent.tools.document_scanner.process_image (relative import)"
|
| 55 |
+
except Exception:
|
| 56 |
+
# If relative import fails (running as script), try absolute import
|
| 57 |
+
try:
|
| 58 |
+
import Agent.tools.document_scanner as ds2
|
| 59 |
+
register_tool("document_scanner.process", ds2.process_image)
|
| 60 |
+
ds_registered = f"Agent.tools.document_scanner.process_image (absolute import)"
|
| 61 |
+
except Exception:
|
| 62 |
+
# Register a fallback mock tool
|
| 63 |
+
def _mock_doc(**kwargs):
|
| 64 |
+
# Accept arbitrary keyword args (image_url, image_bytes, etc.) to be tolerant
|
| 65 |
+
return {"doc_id": str(uuid.uuid4()), "text": "(mock OCR output)", "metadata": {"pages": 1, "confidence": 0.0}, "received_args": kwargs}
|
| 66 |
+
register_tool("document_scanner.process", _mock_doc)
|
| 67 |
+
ds_registered = "mock(document_scanner.process)"
|
| 68 |
+
|
| 69 |
+
# Log which document scanner implementation was registered for easier debugging
|
| 70 |
+
try:
|
| 71 |
+
print(f"[MCP STARTUP] Registered document_scanner.process -> {ds_registered}")
|
| 72 |
+
except Exception:
|
| 73 |
+
pass
|
| 74 |
+
|
| 75 |
+
# Register Granite Vision direct visual Q&A tool
|
| 76 |
+
gv_registered = None
|
| 77 |
+
try:
|
| 78 |
+
from .tools import granite_vision as gv
|
| 79 |
+
register_tool("granite_vision.qa", gv.qa)
|
| 80 |
+
gv_registered = "Agent.tools.granite_vision.qa (relative import)"
|
| 81 |
+
except Exception as e1:
|
| 82 |
+
print(f"[MCP STARTUP] granite_vision relative import FAILED: {type(e1).__name__}: {e1}")
|
| 83 |
+
try:
|
| 84 |
+
import Agent.tools.granite_vision as gv2
|
| 85 |
+
register_tool("granite_vision.qa", gv2.qa)
|
| 86 |
+
gv_registered = "Agent.tools.granite_vision.qa (absolute import)"
|
| 87 |
+
except Exception as e2:
|
| 88 |
+
print(f"[MCP STARTUP] granite_vision absolute import FAILED: {type(e2).__name__}: {e2}")
|
| 89 |
+
def _mock_gv(**kwargs):
|
| 90 |
+
return {"answer": "(granite_vision unavailable - mock)", "model": "mock", "received_args": kwargs}
|
| 91 |
+
register_tool("granite_vision.qa", _mock_gv)
|
| 92 |
+
gv_registered = "mock(granite_vision.qa)"
|
| 93 |
+
print(f"[MCP STARTUP] Registered granite_vision.qa -> {gv_registered}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# Register OCR QA composite tool: runs OCR then runs the agent OCR QA helper
|
| 97 |
+
try:
|
| 98 |
+
# Use the global aw variable instead of re-importing
|
| 99 |
+
if not aw:
|
| 100 |
+
raise ImportError("Agent workflow not available")
|
| 101 |
+
|
| 102 |
+
async def _ocr_qa_tool(image_url: Optional[str] = None, image_bytes: Optional[bytes] = None, question: Optional[str] = None, **kwargs):
|
| 103 |
+
# Call the document scanner tool first (use the registered one to allow mocks)
|
| 104 |
+
ds_fn = TOOLS.get("document_scanner.process")
|
| 105 |
+
if not ds_fn:
|
| 106 |
+
raise RuntimeError("document_scanner.process not registered")
|
| 107 |
+
|
| 108 |
+
loop = asyncio.get_running_loop()
|
| 109 |
+
try:
|
| 110 |
+
if asyncio.iscoroutinefunction(ds_fn):
|
| 111 |
+
tool_res = await ds_fn(image_url=image_url, image_bytes=image_bytes, options=kwargs)
|
| 112 |
+
else:
|
| 113 |
+
tool_res = await loop.run_in_executor(None, lambda: ds_fn(image_url=image_url, image_bytes=image_bytes, options=kwargs))
|
| 114 |
+
except Exception as e:
|
| 115 |
+
return {"error": f"OCR tool failed: {e}"}
|
| 116 |
+
|
| 117 |
+
doc_id = tool_res.get("doc_id") if isinstance(tool_res, dict) else None
|
| 118 |
+
ocr_text = (tool_res.get("text") if isinstance(tool_res, dict) else str(tool_res)) or ""
|
| 119 |
+
|
| 120 |
+
# Persist OCR text so downstream callers can retrieve it via doc_id
|
| 121 |
+
try:
|
| 122 |
+
if doc_id and ocr_text:
|
| 123 |
+
docs_dir = os.path.join("chat_store", "docs")
|
| 124 |
+
os.makedirs(docs_dir, exist_ok=True)
|
| 125 |
+
doc_path = os.path.join(docs_dir, f"{doc_id}.txt")
|
| 126 |
+
# Write file (overwrite if exists)
|
| 127 |
+
with open(doc_path, "w", encoding="utf-8") as df:
|
| 128 |
+
df.write(ocr_text)
|
| 129 |
+
# Also add a short history entry if agent chat store available
|
| 130 |
+
try:
|
| 131 |
+
if hasattr(aw, 'chat_store_private') and hasattr(aw, 'ChatMessage') and hasattr(aw, 'MessageRole'):
|
| 132 |
+
user_msg = aw.ChatMessage(role=aw.MessageRole.USER, content=f"Uploaded document {doc_id}")
|
| 133 |
+
assistant_msg = aw.ChatMessage(role=aw.MessageRole.ASSISTANT, content=f"OCR stored: {ocr_text[:400]}")
|
| 134 |
+
aw.chat_store_private.add_message(key="conversation", message=user_msg)
|
| 135 |
+
aw.chat_store_private.add_message(key="conversation", message=assistant_msg)
|
| 136 |
+
try:
|
| 137 |
+
aw.chat_store_private.persist(str(aw.private_store_path))
|
| 138 |
+
except Exception:
|
| 139 |
+
pass
|
| 140 |
+
except Exception:
|
| 141 |
+
pass
|
| 142 |
+
except Exception:
|
| 143 |
+
# Non-fatal: continue even if persistence fails
|
| 144 |
+
pass
|
| 145 |
+
|
| 146 |
+
# If a question was provided, answer it using the agent helper (run in executor to avoid blocking)
|
| 147 |
+
if question:
|
| 148 |
+
try:
|
| 149 |
+
# Use executor if helper is sync to avoid blocking event loop
|
| 150 |
+
if callable(getattr(aw, 'ocr_agent_qa', None)):
|
| 151 |
+
answer = await loop.run_in_executor(None, lambda: aw.ocr_agent_qa(question, doc_id))
|
| 152 |
+
return {"doc_id": doc_id, "answer": answer}
|
| 153 |
+
else:
|
| 154 |
+
return {"doc_id": doc_id, "answer": "ocr_agent_qa not available"}
|
| 155 |
+
except Exception as e:
|
| 156 |
+
return {"error": f"ocr_agent_qa failed: {e}", "tool_result": tool_res}
|
| 157 |
+
|
| 158 |
+
# No question: return the OCR result and doc_id
|
| 159 |
+
return {"doc_id": doc_id, "ocr_text": ocr_text}
|
| 160 |
+
|
| 161 |
+
register_tool("ocr_qa", _ocr_qa_tool)
|
| 162 |
+
print(f"[MCP STARTUP] Registered ocr_qa -> real _ocr_qa_tool (agent available)")
|
| 163 |
+
except Exception:
|
| 164 |
+
# If agent import fails, register a tolerant mock tool
|
| 165 |
+
def _ocr_qa_mock(**kwargs):
|
| 166 |
+
return {"doc_id": str(uuid.uuid4()), "answer": "(ocr_qa unavailable - mock response)", "received_args": kwargs}
|
| 167 |
+
register_tool("ocr_qa", _ocr_qa_mock)
|
| 168 |
+
print(f"[MCP STARTUP] Registered ocr_qa -> mock(ocr_qa)")
|
| 169 |
+
|
| 170 |
+
# ------------------------------------------------------------------
|
| 171 |
+
# Deterministic schema-aware SQL helper tools (agentic compliant)
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
ENABLE_FAST_SQL = os.environ.get("ENABLE_MCP_FAST_TOOLS", "1") in ("1","true","True")
|
| 174 |
+
if not ENABLE_FAST_SQL:
|
| 175 |
+
print("[MCP STARTUP] Fast SQL tools disabled via ENABLE_MCP_FAST_TOOLS=0")
|
| 176 |
+
return
|
| 177 |
+
|
| 178 |
+
# Initialize database connection if not already done
|
| 179 |
+
if not aw:
|
| 180 |
+
print("[MCP STARTUP][FAST] Agent workflow not available; skipping fast SQL tool registration.")
|
| 181 |
+
return
|
| 182 |
+
|
| 183 |
+
if not getattr(aw, 'inspector', None) or not getattr(aw, 'available_tables', None):
|
| 184 |
+
print("[MCP STARTUP][FAST] Database not initialized; attempting to initialize...")
|
| 185 |
+
try:
|
| 186 |
+
# Initialize database connection - use await since we're in async context
|
| 187 |
+
db_uri = os.environ.get("DB_CONNECTION_URL")
|
| 188 |
+
if not db_uri:
|
| 189 |
+
print("[MCP STARTUP][FAST] DB_CONNECTION_URL not set; skipping fast SQL tool registration.")
|
| 190 |
+
return
|
| 191 |
+
db_result = await aw.initialize_database(db_uri)
|
| 192 |
+
if db_result.get("tables"):
|
| 193 |
+
print(f"[MCP STARTUP][FAST] Database initialized with {len(db_result['tables'])} tables")
|
| 194 |
+
else:
|
| 195 |
+
print("[MCP STARTUP][FAST] Database initialization failed; skipping fast SQL tool registration.")
|
| 196 |
+
return
|
| 197 |
+
except Exception as db_err:
|
| 198 |
+
print(f"[MCP STARTUP][FAST] Database initialization error: {db_err}; skipping fast SQL tool registration.")
|
| 199 |
+
return
|
| 200 |
+
|
| 201 |
+
inspector = aw.inspector
|
| 202 |
+
available_tables = aw.available_tables
|
| 203 |
+
|
| 204 |
+
def _colnames(table: str) -> List[str]:
|
| 205 |
+
try:
|
| 206 |
+
return [c['name'] for c in inspector.get_columns(table_name=table)]
|
| 207 |
+
except Exception:
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
def _has_tables(*tables: str) -> bool:
|
| 211 |
+
at_lower = {t.lower() for t in available_tables}
|
| 212 |
+
return all(t.lower() in at_lower for t in tables)
|
| 213 |
+
|
| 214 |
+
# Simple validation & sanitization re-use
|
| 215 |
+
validate_sql_query = getattr(aw, 'validate_sql_query', lambda q: {"valid": True})
|
| 216 |
+
fix_common_sql_errors = getattr(aw, 'fix_common_sql_errors', lambda q: q)
|
| 217 |
+
|
| 218 |
+
async def _verify_sql(sql: str, question: str, tag: str) -> str:
|
| 219 |
+
"""Optional light LLM verification (short prompt) if LLM available."""
|
| 220 |
+
if not aw or not getattr(aw, 'Settings', None) or not getattr(aw.Settings, 'llm', None):
|
| 221 |
+
return sql
|
| 222 |
+
try:
|
| 223 |
+
prompt = (
|
| 224 |
+
f"{aw.EMPLOYEE_DB_SCHEMA}\n\nYou are an SQL verifier. Tag={tag}."
|
| 225 |
+
" If the candidate SQL is valid and uses only existing tables/columns keep it."
|
| 226 |
+
" If SMALL fixes (alias, LIMIT 100, semicolon) needed, fix them. Never invent tables/columns."
|
| 227 |
+
f"\nQuestion: {question}\nCandidate SQL:\n{sql}\nReturn ONLY final SQL." )
|
| 228 |
+
resp = aw.Settings.llm.complete(prompt=prompt)
|
| 229 |
+
txt = getattr(resp, 'text', str(resp)).strip()
|
| 230 |
+
if '```' in txt:
|
| 231 |
+
parts = re.split(r"```(?:sql)?", txt, flags=re.IGNORECASE)
|
| 232 |
+
txt = '\n'.join(p for p in parts if 'select' in p.lower() or 'with' in p.lower())
|
| 233 |
+
if txt.lower().startswith('sql'):
|
| 234 |
+
txt = txt[3:].strip()
|
| 235 |
+
txt = fix_common_sql_errors(txt)
|
| 236 |
+
v = validate_sql_query(txt)
|
| 237 |
+
if v.get('valid'):
|
| 238 |
+
return txt
|
| 239 |
+
except Exception as ve:
|
| 240 |
+
print(f"[FAST][VERIFY][WARN] Verification failed: {ve}; using original SQL")
|
| 241 |
+
return sql
|
| 242 |
+
|
| 243 |
+
def _build_sql(select_cols: List[str], base_table: str, joins: Optional[List[str]] = None,
|
| 244 |
+
where: Optional[List[str]] = None, group_by: Optional[List[str]] = None,
|
| 245 |
+
order_by: Optional[str] = None, limit: Optional[int] = 100) -> str:
|
| 246 |
+
parts = ["SELECT " + ", ".join(select_cols), "FROM " + base_table]
|
| 247 |
+
for j in (joins or []):
|
| 248 |
+
parts.append(j)
|
| 249 |
+
if where:
|
| 250 |
+
parts.append("WHERE " + " AND ".join(where))
|
| 251 |
+
if group_by:
|
| 252 |
+
parts.append("GROUP BY " + ", ".join(group_by))
|
| 253 |
+
if order_by:
|
| 254 |
+
parts.append("ORDER BY " + order_by)
|
| 255 |
+
if limit is not None:
|
| 256 |
+
parts.append(f"LIMIT {limit}")
|
| 257 |
+
sql = " ".join(parts)
|
| 258 |
+
if not sql.strip().endswith(';'):
|
| 259 |
+
sql += ';'
|
| 260 |
+
return sql
|
| 261 |
+
|
| 262 |
+
async def tool_list_departments(question: str = ""):
|
| 263 |
+
if not _has_tables('department'):
|
| 264 |
+
return {"error": "department table not available"}
|
| 265 |
+
cols = _colnames('department')
|
| 266 |
+
name_col = 'dept_name' if 'dept_name' in cols else (cols[0] if cols else 'dept_name')
|
| 267 |
+
sql = _build_sql([f"d.{name_col} AS department_name"], 'department d', order_by='department_name ASC')
|
| 268 |
+
sql = fix_common_sql_errors(sql)
|
| 269 |
+
sql = await _verify_sql(sql, question or 'List departments', 'list_departments')
|
| 270 |
+
return {"sql": sql}
|
| 271 |
+
|
| 272 |
+
async def tool_count_employees(question: str = ""):
|
| 273 |
+
if not _has_tables('employee'):
|
| 274 |
+
return {"error": "employee table not available"}
|
| 275 |
+
sql = _build_sql(["COUNT(*) AS total_employees"], 'employee e', limit=None)
|
| 276 |
+
sql = fix_common_sql_errors(sql)
|
| 277 |
+
sql = await _verify_sql(sql, question or 'Total employees', 'count_employees')
|
| 278 |
+
return {"sql": sql}
|
| 279 |
+
|
| 280 |
+
async def tool_employee_count_by_department(question: str = ""):
|
| 281 |
+
if not _has_tables('department','dept_emp'):
|
| 282 |
+
return {"error": "required tables missing"}
|
| 283 |
+
dcols = _colnames('department'); decols = _colnames('dept_emp')
|
| 284 |
+
dept_name = 'dept_name' if 'dept_name' in dcols else (dcols[0] if dcols else 'dept_name')
|
| 285 |
+
dept_no = 'dept_no' if 'dept_no' in dcols or 'dept_no' in decols else 'dept_no'
|
| 286 |
+
emp_no = 'emp_no' if 'emp_no' in decols else 'emp_no'
|
| 287 |
+
where = []
|
| 288 |
+
if 'to_date' in decols:
|
| 289 |
+
where.append("de.to_date = '9999-01-01'")
|
| 290 |
+
sql = _build_sql([f"d.{dept_name} AS department_name", f"COUNT(DISTINCT de.{emp_no}) AS employee_count"],
|
| 291 |
+
'department d', joins=[f"JOIN dept_emp de ON d.{dept_no} = de.{dept_no}"],
|
| 292 |
+
where=where, group_by=[f"d.{dept_name}"], order_by='employee_count DESC')
|
| 293 |
+
sql = fix_common_sql_errors(sql)
|
| 294 |
+
sql = await _verify_sql(sql, question or 'Employees per department', 'employee_count_by_department')
|
| 295 |
+
return {"sql": sql}
|
| 296 |
+
|
| 297 |
+
async def tool_gender_pay_gap(question: str = ""):
|
| 298 |
+
if not _has_tables('department','dept_emp','employee','salary'):
|
| 299 |
+
return {"error": "required tables missing"}
|
| 300 |
+
scolumns = _colnames('salary')
|
| 301 |
+
sval = 'salary' if 'salary' in scolumns else ('amount' if 'amount' in scolumns else (scolumns[0] if scolumns else 'salary'))
|
| 302 |
+
where = ["de.to_date = '9999-01-01'", "s.to_date = '9999-01-01'"]
|
| 303 |
+
sql = _build_sql([
|
| 304 |
+
"d.dept_name",
|
| 305 |
+
f"AVG(CASE WHEN e.gender='M' THEN s.{sval} END) AS avg_male_salary",
|
| 306 |
+
f"AVG(CASE WHEN e.gender='F' THEN s.{sval} END) AS avg_female_salary",
|
| 307 |
+
f"(AVG(CASE WHEN e.gender='M' THEN s.{sval} END) - AVG(CASE WHEN e.gender='F' THEN s.{sval} END)) AS gap",
|
| 308 |
+
f"CASE WHEN AVG(CASE WHEN e.gender='M' THEN s.{sval} END)=0 THEN NULL ELSE ((AVG(CASE WHEN e.gender='M' THEN s.{sval} END) - AVG(CASE WHEN e.gender='F' THEN s.{sval} END))/NULLIF(AVG(CASE WHEN e.gender='M' THEN s.{sval} END),0))*100 END AS pct_gap"
|
| 309 |
+
], 'department d',
|
| 310 |
+
joins=['JOIN dept_emp de ON d.dept_no = de.dept_no','JOIN employee e ON de.emp_no = e.emp_no','JOIN salary s ON e.emp_no = s.emp_no'],
|
| 311 |
+
where=where, group_by=['d.dept_name'], order_by='gap DESC')
|
| 312 |
+
sql = fix_common_sql_errors(sql)
|
| 313 |
+
sql = await _verify_sql(sql, question or 'Gender pay gap', 'gender_pay_gap')
|
| 314 |
+
return {"sql": sql}
|
| 315 |
+
|
| 316 |
+
async def tool_salary_extremes(question: str = ""):
|
| 317 |
+
if not _has_tables('salary'):
|
| 318 |
+
return {"error": "salary table missing"}
|
| 319 |
+
scolumns = _colnames('salary'); sval = 'salary' if 'salary' in scolumns else ('amount' if 'amount' in scolumns else (scolumns[0] if scolumns else 'salary'))
|
| 320 |
+
where = ["s.to_date = '9999-01-01'"] if 'to_date' in scolumns else []
|
| 321 |
+
sql = _build_sql([f"MAX(s.{sval}) AS highest_salary", f"MIN(s.{sval}) AS lowest_salary"], 'salary s', where=where, limit=None)
|
| 322 |
+
sql = fix_common_sql_errors(sql)
|
| 323 |
+
sql = await _verify_sql(sql, question or 'Salary extremes', 'salary_extremes')
|
| 324 |
+
return {"sql": sql}
|
| 325 |
+
|
| 326 |
+
async def tool_salary_range_department(department_name: str = "Development", question: str = ""):
|
| 327 |
+
if not _has_tables('department','dept_emp','salary'):
|
| 328 |
+
return {"error": "required tables missing"}
|
| 329 |
+
scolumns = _colnames('salary'); sval = 'salary' if 'salary' in scolumns else ('amount' if 'amount' in scolumns else (scolumns[0] if scolumns else 'salary'))
|
| 330 |
+
# Escape single quotes in department_name for SQL safety (basic)
|
| 331 |
+
escaped_dept = department_name.replace("'", "''")
|
| 332 |
+
where = ["de.to_date = '9999-01-01'", "s.to_date = '9999-01-01'", f"d.dept_name = '{escaped_dept}'"]
|
| 333 |
+
sql = _build_sql(["d.dept_name", f"MAX(s.{sval}) AS max_salary", f"MIN(s.{sval}) AS min_salary"], 'department d',
|
| 334 |
+
joins=['JOIN dept_emp de ON d.dept_no = de.dept_no','JOIN salary s ON de.emp_no = s.emp_no'],
|
| 335 |
+
where=where, group_by=['d.dept_name'], limit=None)
|
| 336 |
+
sql = fix_common_sql_errors(sql)
|
| 337 |
+
sql = await _verify_sql(sql, question or 'Department salary range', 'salary_range_department')
|
| 338 |
+
return {"sql": sql, "department": department_name}
|
| 339 |
+
|
| 340 |
+
async def tool_top_paid_employees(top_n: int = 10, question: str = ""):
|
| 341 |
+
if not _has_tables('employee','salary'):
|
| 342 |
+
return {"error": "required tables missing"}
|
| 343 |
+
scolumns = _colnames('salary'); sval = 'salary' if 'salary' in scolumns else (scolumns[0] if scolumns else 'salary')
|
| 344 |
+
where = ["s.to_date = '9999-01-01'"] if 'to_date' in scolumns else []
|
| 345 |
+
sql = _build_sql(["e.emp_no","e.first_name","e.last_name", f"s.{sval} AS salary"], 'employee e',
|
| 346 |
+
joins=['JOIN salary s ON e.emp_no = s.emp_no'], where=where,
|
| 347 |
+
order_by=f"s.{sval} DESC", limit=top_n)
|
| 348 |
+
sql = fix_common_sql_errors(sql)
|
| 349 |
+
sql = await _verify_sql(sql, question or 'Top paid employees', 'top_paid_employees')
|
| 350 |
+
return {"sql": sql, "top_n": top_n}
|
| 351 |
+
|
| 352 |
+
async def tool_department_highest_avg_salary(question: str = ""):
|
| 353 |
+
if not _has_tables('department','dept_emp','salary'):
|
| 354 |
+
return {"error": "required tables missing"}
|
| 355 |
+
scolumns = _colnames('salary'); sval = 'salary' if 'salary' in scolumns else (scolumns[0] if scolumns else 'salary')
|
| 356 |
+
where = ["de.to_date = '9999-01-01'", "s.to_date = '9999-01-01'"]
|
| 357 |
+
sql = _build_sql(["d.dept_name", f"AVG(s.{sval}) AS avg_salary"], 'department d',
|
| 358 |
+
joins=['JOIN dept_emp de ON d.dept_no = de.dept_no','JOIN salary s ON de.emp_no = s.emp_no'],
|
| 359 |
+
where=where, group_by=['d.dept_name'], order_by='avg_salary DESC', limit=1)
|
| 360 |
+
sql = fix_common_sql_errors(sql)
|
| 361 |
+
sql = await _verify_sql(sql, question or 'Department highest average salary', 'dept_highest_avg_salary')
|
| 362 |
+
return {"sql": sql}
|
| 363 |
+
|
| 364 |
+
async def tool_department_manager_listing(question: str = ""):
|
| 365 |
+
if not _has_tables('department','dept_manager','employee'):
|
| 366 |
+
return {"error": "required tables missing"}
|
| 367 |
+
where = ["dm.to_date = '9999-01-01'"]
|
| 368 |
+
sql = _build_sql(["d.dept_name AS department_name","e.first_name","e.last_name"], 'department d',
|
| 369 |
+
joins=['JOIN dept_manager dm ON d.dept_no = dm.dept_no','JOIN employee e ON dm.emp_no = e.emp_no'],
|
| 370 |
+
where=where, order_by='department_name ASC')
|
| 371 |
+
sql = fix_common_sql_errors(sql)
|
| 372 |
+
sql = await _verify_sql(sql, question or 'Department managers', 'department_manager_listing')
|
| 373 |
+
return {"sql": sql}
|
| 374 |
+
|
| 375 |
+
# NEW: Complex analytical tools for business intelligence
|
| 376 |
+
async def tool_gender_distribution_by_dept(question: str = ""):
|
| 377 |
+
if not _has_tables('department','dept_emp','employee'):
|
| 378 |
+
return {"error": "required tables missing"}
|
| 379 |
+
where = ["de.to_date = '9999-01-01'"]
|
| 380 |
+
sql = _build_sql([
|
| 381 |
+
"d.dept_name AS department_name",
|
| 382 |
+
"e.gender",
|
| 383 |
+
"COUNT(*) AS count",
|
| 384 |
+
"ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (PARTITION BY d.dept_name), 2) AS percentage"
|
| 385 |
+
], 'department d',
|
| 386 |
+
joins=['JOIN dept_emp de ON d.dept_no = de.dept_no','JOIN employee e ON de.emp_no = e.emp_no'],
|
| 387 |
+
where=where, group_by=['d.dept_name', 'e.gender'], order_by='department_name, gender')
|
| 388 |
+
sql = fix_common_sql_errors(sql)
|
| 389 |
+
sql = await _verify_sql(sql, question or 'Gender distribution by department', 'gender_distribution_by_dept')
|
| 390 |
+
return {"sql": sql}
|
| 391 |
+
|
| 392 |
+
async def tool_hiring_trend_by_year(question: str = ""):
|
| 393 |
+
if not _has_tables('employee'):
|
| 394 |
+
return {"error": "required table employee missing"}
|
| 395 |
+
sql = _build_sql([
|
| 396 |
+
"EXTRACT(YEAR FROM e.hire_date) AS hire_year",
|
| 397 |
+
"COUNT(*) AS employees_hired",
|
| 398 |
+
"COUNT(CASE WHEN e.gender='M' THEN 1 END) AS male_hires",
|
| 399 |
+
"COUNT(CASE WHEN e.gender='F' THEN 1 END) AS female_hires"
|
| 400 |
+
], 'employee e', group_by=['EXTRACT(YEAR FROM e.hire_date)'], order_by='hire_year ASC')
|
| 401 |
+
sql = fix_common_sql_errors(sql)
|
| 402 |
+
sql = await _verify_sql(sql, question or 'Hiring trends by year', 'hiring_trend_by_year')
|
| 403 |
+
return {"sql": sql}
|
| 404 |
+
|
| 405 |
+
async def tool_employee_tenure_by_dept(question: str = ""):
|
| 406 |
+
if not _has_tables('department','dept_emp','employee'):
|
| 407 |
+
return {"error": "required tables missing"}
|
| 408 |
+
where = ["de.to_date = '9999-01-01'"]
|
| 409 |
+
sql = _build_sql([
|
| 410 |
+
"d.dept_name AS department_name",
|
| 411 |
+
"AVG(EXTRACT(DAYS FROM (CURRENT_DATE - e.hire_date))/365.25) AS avg_tenure_years",
|
| 412 |
+
"MIN(EXTRACT(DAYS FROM (CURRENT_DATE - e.hire_date))/365.25) AS min_tenure_years",
|
| 413 |
+
"MAX(EXTRACT(DAYS FROM (CURRENT_DATE - e.hire_date))/365.25) AS max_tenure_years"
|
| 414 |
+
], 'department d',
|
| 415 |
+
joins=['JOIN dept_emp de ON d.dept_no = de.dept_no','JOIN employee e ON de.emp_no = e.emp_no'],
|
| 416 |
+
where=where, group_by=['d.dept_name'], order_by='avg_tenure_years DESC')
|
| 417 |
+
sql = fix_common_sql_errors(sql)
|
| 418 |
+
sql = await _verify_sql(sql, question or 'Employee tenure by department', 'employee_tenure_by_dept')
|
| 419 |
+
return {"sql": sql}
|
| 420 |
+
|
| 421 |
+
async def tool_female_managers_count(question: str = ""):
|
| 422 |
+
if not _has_tables('dept_manager','employee'):
|
| 423 |
+
return {"error": "required tables missing"}
|
| 424 |
+
where = ["dm.to_date = '9999-01-01'", "e.gender = 'F'"]
|
| 425 |
+
sql = _build_sql([
|
| 426 |
+
"COUNT(*) AS female_managers_count",
|
| 427 |
+
"COUNT(*) * 100.0 / (SELECT COUNT(*) FROM dept_manager dm2 WHERE dm2.to_date = '9999-01-01') AS percentage_female_managers"
|
| 428 |
+
], 'dept_manager dm',
|
| 429 |
+
joins=['JOIN employee e ON dm.emp_no = e.emp_no'],
|
| 430 |
+
where=where, limit=None)
|
| 431 |
+
sql = fix_common_sql_errors(sql)
|
| 432 |
+
sql = await _verify_sql(sql, question or 'Female managers count', 'female_managers_count')
|
| 433 |
+
return {"sql": sql}
|
| 434 |
+
|
| 435 |
+
async def tool_avg_salary_by_title_dept(question: str = ""):
|
| 436 |
+
"""Get average salary by title and department."""
|
| 437 |
+
if not _has_tables('department', 'dept_emp', 'salary', 'title', 'employee'):
|
| 438 |
+
return {"error": "required tables missing"}
|
| 439 |
+
where = ["de.to_date = '9999-01-01'", "s.to_date = '9999-01-01'", "t.to_date = '9999-01-01'"]
|
| 440 |
+
sql = _build_sql(["d.dept_name AS department_name", "t.title", "AVG(s.salary) AS avg_salary"], 'department d',
|
| 441 |
+
joins=['JOIN dept_emp de ON d.dept_no = de.dept_no',
|
| 442 |
+
'JOIN employee e ON de.emp_no = e.emp_no',
|
| 443 |
+
'JOIN salary s ON e.emp_no = s.emp_no',
|
| 444 |
+
'JOIN title t ON e.emp_no = t.emp_no'],
|
| 445 |
+
where=where, group_by=['d.dept_name', 't.title'],
|
| 446 |
+
order_by='d.dept_name, avg_salary DESC', limit=100)
|
| 447 |
+
sql = fix_common_sql_errors(sql)
|
| 448 |
+
sql = await _verify_sql(sql, question or 'Average salary by title and department', 'avg_salary_by_title_dept')
|
| 449 |
+
return {"sql": sql}
|
| 450 |
+
|
| 451 |
+
register_tool('fast_sql.list_departments', tool_list_departments)
|
| 452 |
+
register_tool('fast_sql.count_employees', tool_count_employees)
|
| 453 |
+
register_tool('fast_sql.employee_count_by_department', tool_employee_count_by_department)
|
| 454 |
+
register_tool('fast_sql.gender_pay_gap', tool_gender_pay_gap) # RE-ENABLED: Complex analytical queries
|
| 455 |
+
register_tool('fast_sql.salary_extremes', tool_salary_extremes)
|
| 456 |
+
register_tool('fast_sql.salary_range_department', tool_salary_range_department)
|
| 457 |
+
register_tool('fast_sql.top_paid_employees', tool_top_paid_employees)
|
| 458 |
+
register_tool('fast_sql.department_highest_avg_salary', tool_department_highest_avg_salary)
|
| 459 |
+
register_tool('fast_sql.department_manager_listing', tool_department_manager_listing)
|
| 460 |
+
|
| 461 |
+
# Register new complex analytical tools
|
| 462 |
+
register_tool('fast_sql.gender_distribution_by_dept', tool_gender_distribution_by_dept)
|
| 463 |
+
register_tool('fast_sql.hiring_trend_by_year', tool_hiring_trend_by_year)
|
| 464 |
+
register_tool('fast_sql.employee_tenure_by_dept', tool_employee_tenure_by_dept)
|
| 465 |
+
register_tool('fast_sql.female_managers_count', tool_female_managers_count)
|
| 466 |
+
register_tool('fast_sql.avg_salary_by_title_dept', tool_avg_salary_by_title_dept)
|
| 467 |
+
print('[MCP STARTUP] Registered fast SQL tools: ' + ', '.join([k for k in TOOLS if k.startswith('fast_sql.')]))
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
@app.post("/complete")
|
| 471 |
+
async def complete(req: CompleteRequest):
|
| 472 |
+
"""Minimal completion endpoint:
|
| 473 |
+
- If context contains {'tool_call': {'name': ..., 'args': {...}}}, the server will run the tool and return its result.
|
| 474 |
+
- Otherwise it returns a simple echo of the prompt under 'text'.
|
| 475 |
+
This scaffold is purposely small so you can extend tool orchestration later.
|
| 476 |
+
"""
|
| 477 |
+
ctx = req.context or {}
|
| 478 |
+
# Tool invocation flow
|
| 479 |
+
if 'tool_call' in ctx:
|
| 480 |
+
tc = ctx['tool_call']
|
| 481 |
+
name = tc.get('name')
|
| 482 |
+
args = tc.get('args', {})
|
| 483 |
+
try:
|
| 484 |
+
tool_res = await _call_tool(name, args)
|
| 485 |
+
return {"text": f"Tool '{name}' executed.", "tool_result": tool_res}
|
| 486 |
+
except KeyError as ke:
|
| 487 |
+
raise HTTPException(status_code=404, detail=str(ke))
|
| 488 |
+
except Exception as e:
|
| 489 |
+
raise HTTPException(status_code=500, detail=f"Tool call failed: {e}")
|
| 490 |
+
|
| 491 |
+
# No tool requested: return a simple completion echo for now
|
| 492 |
+
# Future: integrate a local model here and tool loop orchestration
|
| 493 |
+
return {"text": req.prompt}
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
@app.get("/tools")
|
| 497 |
+
def list_tools():
|
| 498 |
+
return {"tools": list(TOOLS.keys())}
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
@app.get("/health")
|
| 502 |
+
def health():
|
| 503 |
+
return {"status": "ok", "tools_registered": len(TOOLS)}
|
Agent/memory_manager.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Memory management utilities for the text2sql agent.
|
| 3 |
+
"""
|
| 4 |
+
import gc
|
| 5 |
+
import os
|
| 6 |
+
import time
|
| 7 |
+
import psutil
|
| 8 |
+
import threading
|
| 9 |
+
import weakref
|
| 10 |
+
from contextlib import contextmanager
|
| 11 |
+
from typing import Optional, Callable, Dict, Any
|
| 12 |
+
|
| 13 |
+
class MemoryManager:
|
| 14 |
+
"""Memory manager for monitoring and managing system memory."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, threshold_mb: float = 1000.0, critical_mb: float = 300.0,
|
| 17 |
+
check_interval: float = 0.5, monitor_active: bool = True):
|
| 18 |
+
"""Initialize the memory manager.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
threshold_mb: Memory threshold in MB below which to take action
|
| 22 |
+
critical_mb: Critical memory level in MB below which to abort operations
|
| 23 |
+
check_interval: Interval in seconds for background memory checks
|
| 24 |
+
monitor_active: Whether to start the background monitor thread
|
| 25 |
+
"""
|
| 26 |
+
# Allow environment overrides to tune aggressiveness
|
| 27 |
+
try:
|
| 28 |
+
_env_threshold = os.environ.get("MEM_THRESHOLD_MB")
|
| 29 |
+
_env_critical = os.environ.get("MEM_CRITICAL_MB")
|
| 30 |
+
if _env_threshold: threshold_mb = float(_env_threshold)
|
| 31 |
+
if _env_critical: critical_mb = float(_env_critical)
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
| 34 |
+
self.threshold_mb = threshold_mb
|
| 35 |
+
self.critical_mb = critical_mb
|
| 36 |
+
self.check_interval = check_interval
|
| 37 |
+
self._lock = threading.RLock()
|
| 38 |
+
self._monitor_thread = None
|
| 39 |
+
self._keep_monitoring = False
|
| 40 |
+
|
| 41 |
+
# Track instances with weak references when possible to avoid holding
|
| 42 |
+
# strong references that prevent GC. Keep a fallback list for objects
|
| 43 |
+
# that are not weak-referenceable.
|
| 44 |
+
self._active_llm_refs = [] # list of weakref.ref where possible
|
| 45 |
+
self._active_llm_strong = [] # fallback strong refs
|
| 46 |
+
self._instance_count = 0
|
| 47 |
+
|
| 48 |
+
# Soft cap to avoid accidentally registering many parallel LLMs
|
| 49 |
+
self.max_instances = 1
|
| 50 |
+
|
| 51 |
+
# Margin (MB) above critical required to allow new registrations
|
| 52 |
+
self.registration_margin_mb = 200.0
|
| 53 |
+
|
| 54 |
+
# Track last emergency cleanup time to avoid repeated rapid-fire releases
|
| 55 |
+
self._last_emergency = 0.0
|
| 56 |
+
self._emergency_cooldown = 10.0 # seconds between emergency attempts
|
| 57 |
+
# Counter-based suspension flag to prevent emergency releases while in critical sections
|
| 58 |
+
self._suspend_emergency_depth = 0
|
| 59 |
+
|
| 60 |
+
# Minimum available memory (MB) at which we will STILL retain a single LLM instance
|
| 61 |
+
# even if below critical, to avoid thrashing (re-init cost is high). Configurable.
|
| 62 |
+
try:
|
| 63 |
+
self.min_retain_mb = float(os.environ.get("MIN_RETAIN_LLM_MB", "250"))
|
| 64 |
+
except Exception:
|
| 65 |
+
self.min_retain_mb = 250.0
|
| 66 |
+
|
| 67 |
+
# Start memory monitor if requested
|
| 68 |
+
if monitor_active:
|
| 69 |
+
self.start_monitor()
|
| 70 |
+
|
| 71 |
+
def _prune_active_instances(self):
|
| 72 |
+
"""Remove dead weakrefs and return list of live instances."""
|
| 73 |
+
live = []
|
| 74 |
+
# Prune weak refs
|
| 75 |
+
for ref in list(self._active_llm_refs):
|
| 76 |
+
inst = ref()
|
| 77 |
+
if inst is None:
|
| 78 |
+
try:
|
| 79 |
+
self._active_llm_refs.remove(ref)
|
| 80 |
+
except ValueError:
|
| 81 |
+
pass
|
| 82 |
+
else:
|
| 83 |
+
live.append(inst)
|
| 84 |
+
# Add strong refs
|
| 85 |
+
for inst in list(self._active_llm_strong):
|
| 86 |
+
if inst is not None:
|
| 87 |
+
live.append(inst)
|
| 88 |
+
return live
|
| 89 |
+
|
| 90 |
+
def register_llm_instance(self, llm_instance):
|
| 91 |
+
"""Register an LLM instance for tracking.
|
| 92 |
+
|
| 93 |
+
This will refuse registration (and attempt to release the provided
|
| 94 |
+
instance) if the system memory is too low. It prefers weak references
|
| 95 |
+
so that the manager itself does not keep objects alive.
|
| 96 |
+
"""
|
| 97 |
+
with self._lock:
|
| 98 |
+
# Prune dead weakrefs first
|
| 99 |
+
self._prune_active_instances()
|
| 100 |
+
|
| 101 |
+
stats = self.get_memory_stats()
|
| 102 |
+
# Relax policy: always allow the FIRST instance as long as we are above critical.
|
| 103 |
+
# Only enforce margin for additional instances (even though max_instances is 1 today).
|
| 104 |
+
if self._instance_count == 0:
|
| 105 |
+
if stats['available_mb'] < self.critical_mb:
|
| 106 |
+
# Too dangerous even for first instance
|
| 107 |
+
try:
|
| 108 |
+
if hasattr(llm_instance, 'close'):
|
| 109 |
+
llm_instance.close()
|
| 110 |
+
elif hasattr(llm_instance, 'release'):
|
| 111 |
+
llm_instance.release()
|
| 112 |
+
except Exception:
|
| 113 |
+
pass
|
| 114 |
+
raise MemoryError(f"Refusing first LLM registration: below critical ({stats['available_mb']:.1f}MB < {self.critical_mb}MB)")
|
| 115 |
+
else:
|
| 116 |
+
# For subsequent instances keep the stricter margin
|
| 117 |
+
if stats['available_mb'] < (self.critical_mb + self.registration_margin_mb):
|
| 118 |
+
try:
|
| 119 |
+
if hasattr(llm_instance, 'close'):
|
| 120 |
+
llm_instance.close()
|
| 121 |
+
elif hasattr(llm_instance, 'release'):
|
| 122 |
+
llm_instance.release()
|
| 123 |
+
except Exception:
|
| 124 |
+
pass
|
| 125 |
+
raise MemoryError(f"Refusing to register additional LLM (avail={stats['available_mb']:.1f}MB; need >= {self.critical_mb + self.registration_margin_mb:.1f}MB)")
|
| 126 |
+
|
| 127 |
+
# Enforce max instances cap
|
| 128 |
+
if self._instance_count >= self.max_instances:
|
| 129 |
+
raise MemoryError(f"Refusing to register LLM instance: max_instances={self.max_instances} reached")
|
| 130 |
+
|
| 131 |
+
# Try to create a weakref to avoid holding a strong reference
|
| 132 |
+
try:
|
| 133 |
+
ref = weakref.ref(llm_instance, lambda r: None)
|
| 134 |
+
self._active_llm_refs.append(ref)
|
| 135 |
+
except TypeError:
|
| 136 |
+
# Object not weak-referenceable; store strong ref but warn
|
| 137 |
+
self._active_llm_strong.append(llm_instance)
|
| 138 |
+
|
| 139 |
+
self._instance_count += 1
|
| 140 |
+
print(f"LLM instance registered (total: {self._instance_count})")
|
| 141 |
+
|
| 142 |
+
def unregister_llm_instance(self, llm_instance):
|
| 143 |
+
"""Unregister an LLM instance from tracking."""
|
| 144 |
+
with self._lock:
|
| 145 |
+
# Remove from weak refs
|
| 146 |
+
removed = False
|
| 147 |
+
for ref in list(self._active_llm_refs):
|
| 148 |
+
inst = ref()
|
| 149 |
+
if inst is None:
|
| 150 |
+
# Dead ref; prune
|
| 151 |
+
try:
|
| 152 |
+
self._active_llm_refs.remove(ref)
|
| 153 |
+
except ValueError:
|
| 154 |
+
pass
|
| 155 |
+
continue
|
| 156 |
+
if inst is llm_instance:
|
| 157 |
+
try:
|
| 158 |
+
self._active_llm_refs.remove(ref)
|
| 159 |
+
except ValueError:
|
| 160 |
+
pass
|
| 161 |
+
removed = True
|
| 162 |
+
break
|
| 163 |
+
|
| 164 |
+
# Remove from strong refs
|
| 165 |
+
if not removed:
|
| 166 |
+
for i, instance in enumerate(list(self._active_llm_strong)):
|
| 167 |
+
if instance is llm_instance:
|
| 168 |
+
del self._active_llm_strong[i]
|
| 169 |
+
removed = True
|
| 170 |
+
break
|
| 171 |
+
|
| 172 |
+
if removed:
|
| 173 |
+
self._instance_count = max(0, self._instance_count - 1)
|
| 174 |
+
print(f"LLM instance unregistered (remaining: {self._instance_count})")
|
| 175 |
+
|
| 176 |
+
def get_memory_stats(self) -> Dict[str, float]:
|
| 177 |
+
"""Get current memory statistics."""
|
| 178 |
+
vm = psutil.virtual_memory()
|
| 179 |
+
return {
|
| 180 |
+
"total_mb": vm.total / (1024 * 1024),
|
| 181 |
+
"available_mb": vm.available / (1024 * 1024),
|
| 182 |
+
"used_mb": vm.used / (1024 * 1024),
|
| 183 |
+
"percent_used": vm.percent
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
def is_memory_safe(self) -> bool:
|
| 187 |
+
"""Check if memory is at a safe level."""
|
| 188 |
+
return self.get_memory_stats()["available_mb"] >= self.threshold_mb
|
| 189 |
+
|
| 190 |
+
def is_memory_critical(self) -> bool:
|
| 191 |
+
"""Check if memory is at a critical level."""
|
| 192 |
+
stats = self.get_memory_stats()
|
| 193 |
+
available = stats["available_mb"]
|
| 194 |
+
is_critical = available <= self.critical_mb
|
| 195 |
+
|
| 196 |
+
# Debug logging for memory state
|
| 197 |
+
if is_critical:
|
| 198 |
+
print(f"🚨 [MEMORY_DEBUG] Critical state: {available:.1f}MB <= {self.critical_mb}MB threshold")
|
| 199 |
+
|
| 200 |
+
return is_critical
|
| 201 |
+
|
| 202 |
+
def force_collect_garbage(self):
|
| 203 |
+
"""Force aggressive garbage collection."""
|
| 204 |
+
# Run multiple collection cycles
|
| 205 |
+
for i in range(3):
|
| 206 |
+
gc.collect(i)
|
| 207 |
+
|
| 208 |
+
# Clear any reference cycles
|
| 209 |
+
if hasattr(gc, 'freeze'):
|
| 210 |
+
gc.freeze()
|
| 211 |
+
|
| 212 |
+
# Clear module caches if possible
|
| 213 |
+
if hasattr(gc, 'clear_caches'):
|
| 214 |
+
gc.clear_caches()
|
| 215 |
+
|
| 216 |
+
# Log memory status after cleanup
|
| 217 |
+
stats = self.get_memory_stats()
|
| 218 |
+
#print(f"After garbage collection: {stats['available_mb']:.2f}MB available ({stats['percent_used']}% used)")
|
| 219 |
+
|
| 220 |
+
def _monitor_memory(self):
|
| 221 |
+
"""Background thread for memory monitoring."""
|
| 222 |
+
while self._keep_monitoring:
|
| 223 |
+
stats = self.get_memory_stats()
|
| 224 |
+
|
| 225 |
+
# Check for critical memory conditions
|
| 226 |
+
if stats["available_mb"] <= self.critical_mb:
|
| 227 |
+
#print(f"⚠️ CRITICAL MEMORY WARNING: Only {stats['available_mb']:.2f}MB available!")
|
| 228 |
+
self.force_collect_garbage()
|
| 229 |
+
|
| 230 |
+
# If still critical after cleanup, take more drastic measures
|
| 231 |
+
if self.is_memory_critical():
|
| 232 |
+
now = time.time()
|
| 233 |
+
# Only attempt emergency release if cooldown passed
|
| 234 |
+
if now - self._last_emergency > self._emergency_cooldown:
|
| 235 |
+
# Check if we have tracked instances (prune dead refs first)
|
| 236 |
+
live = self._prune_active_instances()
|
| 237 |
+
if live:
|
| 238 |
+
# If only a single LLM instance and we still have above min_retain_mb, skip releasing to prevent constant re-init.
|
| 239 |
+
if len(live) == 1 and stats["available_mb"] > self.min_retain_mb:
|
| 240 |
+
print(f"⚠️ Critical memory but retaining single LLM (avail={stats['available_mb']:.1f}MB > {self.min_retain_mb}MB safeguard)")
|
| 241 |
+
else:
|
| 242 |
+
# Skip emergency release while suspended
|
| 243 |
+
if self._suspend_emergency_depth > 0:
|
| 244 |
+
print(f"🚫 Emergency release suppressed (depth={self._suspend_emergency_depth}) during critical section")
|
| 245 |
+
else:
|
| 246 |
+
self._emergency_release_llm_instances()
|
| 247 |
+
else:
|
| 248 |
+
# Log once per cooldown when no instances are registered
|
| 249 |
+
print("🚨 Emergency memory state detected but no LLM instances registered to release")
|
| 250 |
+
self._last_emergency = now
|
| 251 |
+
else:
|
| 252 |
+
# Cooldown active; skip repeated emergency attempts
|
| 253 |
+
pass
|
| 254 |
+
|
| 255 |
+
# Regular check for low memory
|
| 256 |
+
elif stats["available_mb"] <= self.threshold_mb:
|
| 257 |
+
#print(f"⚠️ Low memory warning: {stats['available_mb']:.2f}MB available")
|
| 258 |
+
self.force_collect_garbage()
|
| 259 |
+
|
| 260 |
+
# Sleep for the check interval
|
| 261 |
+
time.sleep(self.check_interval)
|
| 262 |
+
|
| 263 |
+
def _emergency_release_llm_instances(self):
|
| 264 |
+
"""Emergency release of LLM instances to free memory."""
|
| 265 |
+
with self._lock:
|
| 266 |
+
# Build list of live instances from weak and strong refs
|
| 267 |
+
instances = self._prune_active_instances()
|
| 268 |
+
print(f"Attempting to release {len(instances)} LLM instances")
|
| 269 |
+
|
| 270 |
+
for llm in instances:
|
| 271 |
+
try:
|
| 272 |
+
# Try to close or release resources
|
| 273 |
+
if hasattr(llm, 'close'):
|
| 274 |
+
llm.close()
|
| 275 |
+
elif hasattr(llm, 'release'):
|
| 276 |
+
llm.release()
|
| 277 |
+
|
| 278 |
+
# Set to None to break references
|
| 279 |
+
if hasattr(llm, '_client'):
|
| 280 |
+
llm._client = None
|
| 281 |
+
if hasattr(llm, '_model'):
|
| 282 |
+
llm._model = None
|
| 283 |
+
except Exception as e:
|
| 284 |
+
print(f"Error releasing LLM instance: {str(e)}")
|
| 285 |
+
|
| 286 |
+
# Clear our tracking lists
|
| 287 |
+
self._active_llm_refs.clear()
|
| 288 |
+
self._active_llm_strong.clear()
|
| 289 |
+
self._instance_count = 0
|
| 290 |
+
|
| 291 |
+
# Force garbage collection after cleanup and attempt to return pages
|
| 292 |
+
self.force_collect_garbage()
|
| 293 |
+
try:
|
| 294 |
+
# Best-effort working set trim to return pages to OS (Windows)
|
| 295 |
+
self.trim_working_set()
|
| 296 |
+
except Exception:
|
| 297 |
+
pass
|
| 298 |
+
|
| 299 |
+
# ---------------- Public high-level cleanup APIs -----------------
|
| 300 |
+
def release_all_llms(self):
|
| 301 |
+
"""Public wrapper to release all tracked LLM instances (non-emergency manual call)."""
|
| 302 |
+
self._emergency_release_llm_instances()
|
| 303 |
+
|
| 304 |
+
def trim_working_set(self):
|
| 305 |
+
"""Attempt to return unused pages to OS (best-effort, platform-specific)."""
|
| 306 |
+
try:
|
| 307 |
+
import platform, ctypes
|
| 308 |
+
if platform.system().lower() == 'windows':
|
| 309 |
+
PROCESS_SET_QUOTA = 0x0100
|
| 310 |
+
PROCESS_QUERY_INFORMATION = 0x0400
|
| 311 |
+
kernel32 = ctypes.windll.kernel32
|
| 312 |
+
psapi = ctypes.windll.psapi
|
| 313 |
+
GetCurrentProcess = kernel32.GetCurrentProcess
|
| 314 |
+
hProc = GetCurrentProcess()
|
| 315 |
+
psapi.EmptyWorkingSet(hProc)
|
| 316 |
+
else:
|
| 317 |
+
# For *nix, reading /proc/self might encourage trimming after gc
|
| 318 |
+
pass
|
| 319 |
+
except Exception as e:
|
| 320 |
+
print(f"Working set trim not supported/failed: {e}")
|
| 321 |
+
|
| 322 |
+
def perform_full_cleanup(self) -> Dict[str, float]:
|
| 323 |
+
"""Comprehensive cleanup: release LLMs, GC, trim working set, return new stats."""
|
| 324 |
+
before = self.get_memory_stats()
|
| 325 |
+
self.release_all_llms()
|
| 326 |
+
self.force_collect_garbage()
|
| 327 |
+
self.trim_working_set()
|
| 328 |
+
after = self.get_memory_stats()
|
| 329 |
+
delta = after['available_mb'] - before['available_mb']
|
| 330 |
+
print(f"Post-query full cleanup reclaimed {delta:.2f}MB (avail: {after['available_mb']:.2f}MB)")
|
| 331 |
+
return after
|
| 332 |
+
|
| 333 |
+
# --------------- Critical section helpers ---------------
|
| 334 |
+
@contextmanager
|
| 335 |
+
def suspend_emergency(self, reason: str = ""):
|
| 336 |
+
"""Context manager to temporarily suspend emergency LLM releases.
|
| 337 |
+
|
| 338 |
+
Use around critical sections (e.g., active LLM generation) to prevent
|
| 339 |
+
the monitor thread from tearing down the only LLM mid-call, which can
|
| 340 |
+
cause intermittent generation failures.
|
| 341 |
+
"""
|
| 342 |
+
with self._lock:
|
| 343 |
+
self._suspend_emergency_depth += 1
|
| 344 |
+
if reason:
|
| 345 |
+
print(f"⏸️ Suspended emergency releases (depth={self._suspend_emergency_depth}) reason={reason}")
|
| 346 |
+
try:
|
| 347 |
+
yield
|
| 348 |
+
finally:
|
| 349 |
+
with self._lock:
|
| 350 |
+
self._suspend_emergency_depth = max(0, self._suspend_emergency_depth - 1)
|
| 351 |
+
if reason:
|
| 352 |
+
print(f"▶️ Resumed emergency releases (depth={self._suspend_emergency_depth}) reason={reason}")
|
| 353 |
+
|
| 354 |
+
def start_monitor(self):
|
| 355 |
+
"""Start the background memory monitor."""
|
| 356 |
+
with self._lock:
|
| 357 |
+
if self._monitor_thread is None or not self._monitor_thread.is_alive():
|
| 358 |
+
self._keep_monitoring = True
|
| 359 |
+
self._monitor_thread = threading.Thread(
|
| 360 |
+
target=self._monitor_memory,
|
| 361 |
+
daemon=True,
|
| 362 |
+
name="MemoryMonitorThread"
|
| 363 |
+
)
|
| 364 |
+
self._monitor_thread.start()
|
| 365 |
+
print("Memory monitor started in background")
|
| 366 |
+
|
| 367 |
+
def stop_monitor(self):
|
| 368 |
+
"""Stop the background memory monitor."""
|
| 369 |
+
with self._lock:
|
| 370 |
+
self._keep_monitoring = False
|
| 371 |
+
if self._monitor_thread and self._monitor_thread.is_alive():
|
| 372 |
+
self._monitor_thread.join(timeout=1.0)
|
| 373 |
+
print("Memory monitor stopped")
|
| 374 |
+
|
| 375 |
+
# Create a global memory manager instance
|
| 376 |
+
memory_manager = MemoryManager(
|
| 377 |
+
threshold_mb=500.0, # 500MB — warning/action threshold (reduced from 1000MB)
|
| 378 |
+
critical_mb=300.0, # 300MB — BSOD-protection hard floor (unchanged)
|
| 379 |
+
check_interval=1.0, # Check every second
|
| 380 |
+
monitor_active=True # Start monitoring immediately
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
class TimeoutManager:
|
| 384 |
+
"""Manages operation timeouts with proper cleanup."""
|
| 385 |
+
|
| 386 |
+
@staticmethod
|
| 387 |
+
def run_with_timeout(func: Callable, timeout: float, *args, **kwargs) -> Any:
|
| 388 |
+
"""
|
| 389 |
+
Run a function with a timeout.
|
| 390 |
+
|
| 391 |
+
Args:
|
| 392 |
+
func: Function to run
|
| 393 |
+
timeout: Timeout in seconds
|
| 394 |
+
*args, **kwargs: Arguments to pass to the function
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
The result of the function
|
| 398 |
+
|
| 399 |
+
Raises:
|
| 400 |
+
TimeoutError: If the function times out
|
| 401 |
+
"""
|
| 402 |
+
result = None
|
| 403 |
+
exception = None
|
| 404 |
+
completed = threading.Event()
|
| 405 |
+
|
| 406 |
+
# Thread function
|
| 407 |
+
def worker():
|
| 408 |
+
nonlocal result, exception
|
| 409 |
+
try:
|
| 410 |
+
result = func(*args, **kwargs)
|
| 411 |
+
except Exception as e:
|
| 412 |
+
exception = e
|
| 413 |
+
finally:
|
| 414 |
+
completed.set()
|
| 415 |
+
|
| 416 |
+
# Start the worker thread
|
| 417 |
+
thread = threading.Thread(target=worker, daemon=True)
|
| 418 |
+
thread.start()
|
| 419 |
+
|
| 420 |
+
# Wait for completion or timeout
|
| 421 |
+
if not completed.wait(timeout):
|
| 422 |
+
# Force garbage collection before raising timeout
|
| 423 |
+
memory_manager.force_collect_garbage()
|
| 424 |
+
|
| 425 |
+
# Raise timeout error
|
| 426 |
+
raise TimeoutError(f"Operation timed out after {timeout} seconds")
|
| 427 |
+
|
| 428 |
+
# If there was an exception, raise it
|
| 429 |
+
if exception is not None:
|
| 430 |
+
raise exception
|
| 431 |
+
|
| 432 |
+
return result
|
| 433 |
+
|
| 434 |
+
def safe_check_memory(threshold_mb: Optional[float] = None) -> bool:
|
| 435 |
+
"""
|
| 436 |
+
Safely check if there's enough memory available.
|
| 437 |
+
Also performs garbage collection if memory is low.
|
| 438 |
+
|
| 439 |
+
Args:
|
| 440 |
+
threshold_mb: Optional threshold in MB (uses memory manager's threshold if None)
|
| 441 |
+
|
| 442 |
+
Returns:
|
| 443 |
+
True if memory is safe, False if it's below threshold
|
| 444 |
+
"""
|
| 445 |
+
# Use memory manager's threshold if none provided
|
| 446 |
+
if threshold_mb is None:
|
| 447 |
+
threshold_mb = memory_manager.threshold_mb
|
| 448 |
+
|
| 449 |
+
# Get current memory stats
|
| 450 |
+
stats = memory_manager.get_memory_stats()
|
| 451 |
+
|
| 452 |
+
# Log memory status
|
| 453 |
+
print(f"Memory status: {stats['available_mb']:.2f}MB available of {stats['total_mb']:.2f}MB total")
|
| 454 |
+
|
| 455 |
+
# If memory is below threshold, run garbage collection
|
| 456 |
+
if stats['available_mb'] < threshold_mb:
|
| 457 |
+
memory_manager.force_collect_garbage()
|
| 458 |
+
|
| 459 |
+
# Get updated stats after garbage collection
|
| 460 |
+
stats = memory_manager.get_memory_stats()
|
| 461 |
+
|
| 462 |
+
# If still below threshold, return False
|
| 463 |
+
if stats['available_mb'] < threshold_mb:
|
| 464 |
+
print(f"⚠️ Low memory warning: Only {stats['available_mb']:.2f}MB available, need at least {threshold_mb:.2f}MB")
|
| 465 |
+
return False
|
| 466 |
+
|
| 467 |
+
return True
|
Agent/merge_lora_to_ollama.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Merge a PEFT LoRA adapter (Gemma 1B) into base weights and prepare an Ollama model directory.
|
| 2 |
+
|
| 3 |
+
Usage (after installing extra deps in requirements.txt):
|
| 4 |
+
python Agent/merge_lora_to_ollama.py \
|
| 5 |
+
--base google/gemma-1.1-1b-it \
|
| 6 |
+
--adapter-dir Agent/Gemma3_1B_weights \
|
| 7 |
+
--out-dir merged_gemma1b_text2sql \
|
| 8 |
+
--ollama-model-name gemma1b-text2sql
|
| 9 |
+
|
| 10 |
+
Then create the Ollama model:
|
| 11 |
+
(In PowerShell)
|
| 12 |
+
ollama create gemma1b-text2sql -f merged_gemma1b_text2sql/Modelfile
|
| 13 |
+
|
| 14 |
+
Finally set FINETUNED_MODEL_NAME=gemma1b-text2sql in your .env (or environment) and run the agent.
|
| 15 |
+
|
| 16 |
+
Notes:
|
| 17 |
+
- Ensure you used the SAME base checkpoint for finetuning as you pass via --base.
|
| 18 |
+
- Script keeps everything on CPU (device_map="cpu") to stay RAM friendly; you can add --load-in-8bit if you install bitsandbytes and have GPU.
|
| 19 |
+
"""
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
from huggingface_hub import HfApi
|
| 27 |
+
try: # Compatibility across hub versions
|
| 28 |
+
from huggingface_hub.errors import RepositoryNotFoundError # type: ignore
|
| 29 |
+
except Exception: # Older versions
|
| 30 |
+
class RepositoryNotFoundError(Exception):
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
import torch
|
| 34 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 35 |
+
from peft import PeftModel
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _detect_base_from_adapter(adapter_dir: str) -> str | None:
|
| 39 |
+
p = Path(adapter_dir)
|
| 40 |
+
resolved = p.resolve()
|
| 41 |
+
print(f"[auto-base] Inspecting adapter_dir: {adapter_dir} -> {resolved}")
|
| 42 |
+
if not resolved.exists():
|
| 43 |
+
print(f"[auto-base] Directory does not exist: {resolved}")
|
| 44 |
+
# Attempt smart fallbacks (common when user runs inside Agent/ already)
|
| 45 |
+
candidates = []
|
| 46 |
+
name = p.name
|
| 47 |
+
cwd = Path.cwd()
|
| 48 |
+
candidates.append(cwd / name)
|
| 49 |
+
candidates.append(cwd.parent / name)
|
| 50 |
+
candidates.append(cwd.parent / 'Agent' / name)
|
| 51 |
+
for cand in candidates:
|
| 52 |
+
if cand.exists():
|
| 53 |
+
print(f"[auto-base] Found alternative adapter dir: {cand}")
|
| 54 |
+
resolved = cand
|
| 55 |
+
break
|
| 56 |
+
if not resolved.exists():
|
| 57 |
+
print(f"[auto-base] Fallback search failed. Tried: {[str(c) for c in candidates]}")
|
| 58 |
+
return None
|
| 59 |
+
cfg_path = resolved / "adapter_config.json"
|
| 60 |
+
if not cfg_path.exists():
|
| 61 |
+
try:
|
| 62 |
+
contents = [c.name for c in resolved.iterdir()]
|
| 63 |
+
except Exception:
|
| 64 |
+
contents = []
|
| 65 |
+
print(f"[auto-base] adapter_config.json not found in {resolved}. Contents: {contents}")
|
| 66 |
+
return None
|
| 67 |
+
try:
|
| 68 |
+
raw = cfg_path.read_text(encoding="utf-8")
|
| 69 |
+
print(f"[auto-base] Found adapter_config.json ({len(raw)} bytes)")
|
| 70 |
+
data = json.loads(raw)
|
| 71 |
+
base = data.get("base_model_name_or_path")
|
| 72 |
+
print(f"[auto-base] base_model_name_or_path: {base}")
|
| 73 |
+
return base
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"[auto-base] Failed reading adapter_config.json: {e}")
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def merge_adapter(base: str, adapter_dir: str, out_dir: str, ollama_model_name: str, template_temperature: float = 0.05, hf_token: str | None = None, local_files_only: bool = False, auto_base: bool = False, trust_remote_code: bool = True):
|
| 80 |
+
out_path = Path(out_dir)
|
| 81 |
+
out_path.mkdir(parents=True, exist_ok=True)
|
| 82 |
+
|
| 83 |
+
if auto_base or base.lower() == "auto":
|
| 84 |
+
detected = _detect_base_from_adapter(adapter_dir)
|
| 85 |
+
if detected:
|
| 86 |
+
print(f"[auto-base] Detected base model from adapter_config: {detected}")
|
| 87 |
+
base = detected
|
| 88 |
+
else:
|
| 89 |
+
raise RuntimeError("Could not auto-detect base model; supply --base explicitly.")
|
| 90 |
+
|
| 91 |
+
auth_kwargs = {}
|
| 92 |
+
if hf_token:
|
| 93 |
+
# Only pass 'token' (use_auth_token deprecated & causes conflict if both set)
|
| 94 |
+
auth_kwargs = {"token": hf_token}
|
| 95 |
+
os.environ.setdefault("HF_TOKEN", hf_token)
|
| 96 |
+
os.environ.setdefault("HUGGINGFACEHUB_API_TOKEN", hf_token)
|
| 97 |
+
print(f"Using HF token (masked): {hf_token[:8]}... (length {len(hf_token)})")
|
| 98 |
+
|
| 99 |
+
# Quick repo existence check (skipped if local_files_only)
|
| 100 |
+
if not local_files_only:
|
| 101 |
+
try:
|
| 102 |
+
HfApi().model_info(base, token=hf_token)
|
| 103 |
+
except RepositoryNotFoundError:
|
| 104 |
+
print(f"\n[ERROR] Base model repo '{base}' not found on Hugging Face.\n")
|
| 105 |
+
print("Troubleshooting:")
|
| 106 |
+
print(" - Verify the exact model id: e.g. 'google/gemma-2-2b-it' or 'google/gemma-1.1-2b-it'.")
|
| 107 |
+
print(" - If it's a gated/private model ensure your token has accepted the terms.")
|
| 108 |
+
print(" - Pass --hf-token YOUR_TOKEN or set HF_TOKEN env var.")
|
| 109 |
+
return 2
|
| 110 |
+
except Exception as e:
|
| 111 |
+
print(f"[WARN] Could not verify model existence pre-download: {e}")
|
| 112 |
+
|
| 113 |
+
print(f"Loading base model: {base} (local_files_only={local_files_only})")
|
| 114 |
+
try:
|
| 115 |
+
tokenizer = AutoTokenizer.from_pretrained(base, local_files_only=local_files_only, **auth_kwargs)
|
| 116 |
+
except Exception as e:
|
| 117 |
+
msg = str(e)
|
| 118 |
+
if "403" in msg or "Forbidden" in msg:
|
| 119 |
+
print("\n[AUTH ERROR] 403 while fetching tokenizer/config.")
|
| 120 |
+
print("Checklist:")
|
| 121 |
+
print(" 1. Ensure token has: 'Read access to public gated models'.")
|
| 122 |
+
print(" 2. Visit model page and accept license (if not already).")
|
| 123 |
+
print(" 3. If using a fine-grained token, enable 'Gated repos' permission.")
|
| 124 |
+
print(" 4. Retry with: --hf-token $env:HF_TOKEN (PowerShell: $env:HF_TOKEN='xxxx')")
|
| 125 |
+
print(" 5. If model already cached on another machine, copy that ~/.cache/huggingface/hub directory.")
|
| 126 |
+
raise
|
| 127 |
+
|
| 128 |
+
# Keep on CPU to reduce VRAM needs; if you want faster and have GPU, remove device_map and add torch_dtype.
|
| 129 |
+
model_kwargs = dict(
|
| 130 |
+
device_map="cpu",
|
| 131 |
+
local_files_only=local_files_only,
|
| 132 |
+
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
| 133 |
+
**auth_kwargs,
|
| 134 |
+
)
|
| 135 |
+
if trust_remote_code:
|
| 136 |
+
model_kwargs["trust_remote_code"] = True
|
| 137 |
+
# Lower RAM peak during load
|
| 138 |
+
model_kwargs["low_cpu_mem_usage"] = True
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
print("[stage] Downloading / loading base model weights...")
|
| 142 |
+
base_model = AutoModelForCausalLM.from_pretrained(base, **model_kwargs)
|
| 143 |
+
print("[stage] Base model loaded successfully.")
|
| 144 |
+
except ValueError as ve:
|
| 145 |
+
msg = str(ve)
|
| 146 |
+
if "does not recognize this architecture" in msg or "model type" in msg:
|
| 147 |
+
print("[model-load] Unrecognized model type. Attempting retry after upgrading transformers may be required.")
|
| 148 |
+
print("Suggestion: pip install --upgrade transformers huggingface_hub --no-cache-dir")
|
| 149 |
+
if not trust_remote_code:
|
| 150 |
+
print("Retrying with trust_remote_code=True ...")
|
| 151 |
+
model_kwargs["trust_remote_code"] = True
|
| 152 |
+
base_model = AutoModelForCausalLM.from_pretrained(base, **model_kwargs)
|
| 153 |
+
else:
|
| 154 |
+
# Last resort: advise source install
|
| 155 |
+
raise
|
| 156 |
+
else:
|
| 157 |
+
raise
|
| 158 |
+
except Exception as e:
|
| 159 |
+
msg = str(e)
|
| 160 |
+
if "403" in msg or "Forbidden" in msg:
|
| 161 |
+
print("\n[AUTH ERROR] 403 while fetching model weights (config or pytorch model). See guidance above.")
|
| 162 |
+
raise
|
| 163 |
+
|
| 164 |
+
print(f"Loading LoRA adapter from: {adapter_dir}")
|
| 165 |
+
peft_model = PeftModel.from_pretrained(base_model, adapter_dir)
|
| 166 |
+
print("[stage] LoRA adapter loaded.")
|
| 167 |
+
|
| 168 |
+
print("Merging LoRA weights into base weights (this may take a minute)...")
|
| 169 |
+
merged_model = peft_model.merge_and_unload()
|
| 170 |
+
print("[stage] Merge complete in-memory.")
|
| 171 |
+
|
| 172 |
+
print(f"Saving merged model to: {out_dir}")
|
| 173 |
+
merged_model.save_pretrained(out_dir)
|
| 174 |
+
tokenizer.save_pretrained(out_dir)
|
| 175 |
+
print("[stage] Saved model and tokenizer.")
|
| 176 |
+
|
| 177 |
+
# Create a simple Modelfile for Ollama (avoid nested triple-quote confusion)
|
| 178 |
+
modelfile_lines = [
|
| 179 |
+
# Use current directory as model context so Modelfile works regardless of parent path
|
| 180 |
+
"FROM .",
|
| 181 |
+
f"PARAMETER temperature {template_temperature}",
|
| 182 |
+
"# Basic template; adjust if you have a custom chat format.",
|
| 183 |
+
'TEMPLATE """{{ .Prompt }}"""',
|
| 184 |
+
""
|
| 185 |
+
]
|
| 186 |
+
modelfile = "\n".join(modelfile_lines)
|
| 187 |
+
(out_path / "Modelfile").write_text(modelfile, encoding="utf-8")
|
| 188 |
+
|
| 189 |
+
meta = {
|
| 190 |
+
"base": base,
|
| 191 |
+
"adapter_dir": adapter_dir,
|
| 192 |
+
"merged_dir": out_dir,
|
| 193 |
+
"ollama_model_name": ollama_model_name,
|
| 194 |
+
"temperature": template_temperature,
|
| 195 |
+
}
|
| 196 |
+
(out_path / "merge_metadata.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
| 197 |
+
|
| 198 |
+
print("Merge complete. Next steps:")
|
| 199 |
+
print(f" 1. ollama create {ollama_model_name} -f {out_dir}/Modelfile")
|
| 200 |
+
print(f" 2. Set FINETUNED_MODEL_NAME={ollama_model_name}")
|
| 201 |
+
print(" 3. Run your agent code again.")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def main():
|
| 205 |
+
if len(sys.argv) == 1:
|
| 206 |
+
print("\n[merge_lora_to_ollama] Missing arguments. Example usage:\n")
|
| 207 |
+
print("python Agent/merge_lora_to_ollama.py \\")
|
| 208 |
+
print(" --base google/gemma-1.1-1b-it \\")
|
| 209 |
+
print(" --adapter-dir Agent/Gemma3_1B_weights \\")
|
| 210 |
+
print(" --out-dir merged_gemma1b_text2sql \\")
|
| 211 |
+
print(" --ollama-model-name gemma1b-text2sql\n")
|
| 212 |
+
return 1
|
| 213 |
+
|
| 214 |
+
parser = argparse.ArgumentParser(description="Merge a Gemma LoRA adapter for Ollama usage.")
|
| 215 |
+
parser.add_argument("--base", required=True, help="Base HF model id used during finetuning (e.g., google/gemma-3-1b-it or 'auto' to read adapter_config)")
|
| 216 |
+
parser.add_argument("--auto-base", action="store_true", help="Ignore --base value and read base from adapter_config.json")
|
| 217 |
+
parser.add_argument("--adapter-dir", required=True, help="Path to the LoRA adapter directory (with adapter_model.safetensors)")
|
| 218 |
+
parser.add_argument("--out-dir", required=True, help="Directory to write merged weights and Modelfile")
|
| 219 |
+
parser.add_argument("--ollama-model-name", required=True, help="Target Ollama model name to create")
|
| 220 |
+
parser.add_argument("--temperature", type=float, default=0.05, help="Default temperature for Modelfile")
|
| 221 |
+
parser.add_argument("--hf-token", help="Hugging Face access token (optional; else read HF_TOKEN / HUGGINGFACEHUB_API_TOKEN env)")
|
| 222 |
+
parser.add_argument("--local-files-only", action="store_true", help="Do not attempt to download; require weights to be cached locally")
|
| 223 |
+
parser.add_argument("--no-trust-remote-code", action="store_true", help="Disable trust_remote_code (not recommended for Gemma 3)")
|
| 224 |
+
args = parser.parse_args()
|
| 225 |
+
|
| 226 |
+
hf_token = args.hf_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
|
| 227 |
+
if not hf_token:
|
| 228 |
+
print("[INFO] No HF token provided. If the model is gated/private, pass --hf-token or set HF_TOKEN env.")
|
| 229 |
+
|
| 230 |
+
merge_adapter(
|
| 231 |
+
base=args.base,
|
| 232 |
+
adapter_dir=args.adapter_dir,
|
| 233 |
+
out_dir=args.out_dir,
|
| 234 |
+
ollama_model_name=args.ollama_model_name,
|
| 235 |
+
template_temperature=args.temperature,
|
| 236 |
+
hf_token=hf_token,
|
| 237 |
+
local_files_only=args.local_files_only,
|
| 238 |
+
auto_base=args.auto_base,
|
| 239 |
+
trust_remote_code=not args.no_trust_remote_code,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
if __name__ == "__main__":
|
| 244 |
+
main()
|
Agent/merge_qwen_to_ollama.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"""Merge a PEFT LoRA adapter (Qwen 2.5 3b) into base weights and prepare an Ollama model directory.
|
| 3 |
+
|
| 4 |
+
This is a standalone, non-destructive helper. It follows the same pattern as
|
| 5 |
+
`Agent/merge_lora_to_ollama.py` but defaults to a Qwen base and includes flags
|
| 6 |
+
for optional low-memory / 8-bit loading.
|
| 7 |
+
|
| 8 |
+
Usage (PowerShell):
|
| 9 |
+
Agent/merge_qwen_to_ollama.py \
|
| 10 |
+
--base Qwenpython/Qwen2.5-3B-Instruct \
|
| 11 |
+
--adapter-dir Agent/Qwen2.5_3b_weights \
|
| 12 |
+
--out-dir merged_qwen2_5_3b_text2sql \
|
| 13 |
+
--ollama-model-name qwen2.5-text2sql
|
| 14 |
+
|
| 15 |
+
Notes:
|
| 16 |
+
- Keeps merge on CPU to minimize peak memory.
|
| 17 |
+
- If you have a GPU and want faster tokenizers/model ops, see OLLAMA GPU notes file.
|
| 18 |
+
"""
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
from huggingface_hub import HfApi
|
| 27 |
+
except Exception:
|
| 28 |
+
HfApi = None
|
| 29 |
+
|
| 30 |
+
import torch
|
| 31 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 32 |
+
from peft import PeftModel
|
| 33 |
+
from transformers import BitsAndBytesConfig
|
| 34 |
+
|
| 35 |
+
def _detect_base_from_adapter(adapter_dir: str) -> str | None:
|
| 36 |
+
p = Path(adapter_dir)
|
| 37 |
+
resolved = p.resolve()
|
| 38 |
+
print(f"[auto-base] Inspecting adapter_dir: {adapter_dir} -> {resolved}")
|
| 39 |
+
if not resolved.exists():
|
| 40 |
+
print(f"[auto-base] Directory does not exist: {resolved}")
|
| 41 |
+
# Attempt smart fallbacks (common when user runs inside Agent/ already)
|
| 42 |
+
candidates = []
|
| 43 |
+
name = p.name
|
| 44 |
+
cwd = Path.cwd()
|
| 45 |
+
candidates.append(cwd / name)
|
| 46 |
+
candidates.append(cwd.parent / name)
|
| 47 |
+
candidates.append(cwd.parent / 'Agent' / name)
|
| 48 |
+
for cand in candidates:
|
| 49 |
+
if cand.exists():
|
| 50 |
+
print(f"[auto-base] Found alternative adapter dir: {cand}")
|
| 51 |
+
resolved = cand
|
| 52 |
+
break
|
| 53 |
+
if not resolved.exists():
|
| 54 |
+
print(f"[auto-base] Fallback search failed. Tried: {[str(c) for c in candidates]}")
|
| 55 |
+
return None
|
| 56 |
+
cfg_path = resolved / "adapter_config.json"
|
| 57 |
+
if not cfg_path.exists():
|
| 58 |
+
try:
|
| 59 |
+
contents = [c.name for c in resolved.iterdir()]
|
| 60 |
+
except Exception:
|
| 61 |
+
contents = []
|
| 62 |
+
print(f"[auto-base] adapter_config.json not found in {resolved}. Contents: {contents}")
|
| 63 |
+
return None
|
| 64 |
+
try:
|
| 65 |
+
raw = cfg_path.read_text(encoding="utf-8")
|
| 66 |
+
print(f"[auto-base] Found adapter_config.json ({len(raw)} bytes)")
|
| 67 |
+
data = json.loads(raw)
|
| 68 |
+
base = data.get("base_model_name_or_path")
|
| 69 |
+
print(f"[auto-base] base_model_name_or_path: {base}")
|
| 70 |
+
return base
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"[auto-base] Failed reading adapter_config.json: {e}")
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
def merge_adapter(base: str, adapter_dir: str, out_dir: str, ollama_model_name: str, template_temperature: float = 0.05, hf_token: str | None = None, local_files_only: bool = False, trust_remote_code: bool = True, auto_base: bool = False):
|
| 76 |
+
out_path = Path(out_dir)
|
| 77 |
+
out_path.mkdir(parents=True, exist_ok=True)
|
| 78 |
+
|
| 79 |
+
# If requested, detect base from adapter_config.json
|
| 80 |
+
if auto_base or (isinstance(base, str) and base.lower() == "auto"):
|
| 81 |
+
detected = _detect_base_from_adapter(adapter_dir)
|
| 82 |
+
if detected:
|
| 83 |
+
print(f"[auto-base] Detected base model from adapter_config: {detected}")
|
| 84 |
+
base = detected
|
| 85 |
+
else:
|
| 86 |
+
raise RuntimeError("Could not auto-detect base model; supply --base explicitly.")
|
| 87 |
+
|
| 88 |
+
# Load the tokenizer first, it's small and fast
|
| 89 |
+
try:
|
| 90 |
+
tokenizer = AutoTokenizer.from_pretrained(base, local_files_only=local_files_only)
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f"Failed loading tokenizer for base '{base}': {e}")
|
| 93 |
+
raise
|
| 94 |
+
|
| 95 |
+
# Configure 4-bit quantization with the necessary offload flag
|
| 96 |
+
quantization_config = BitsAndBytesConfig(
|
| 97 |
+
load_in_4bit=True,
|
| 98 |
+
bnb_4bit_quant_type="nf4",
|
| 99 |
+
bnb_4bit_compute_dtype=torch.float16,
|
| 100 |
+
bnb_4bit_use_double_quant=True,
|
| 101 |
+
llm_int8_enable_fp32_cpu_offload=True, # THIS IS CRUCIAL
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
print(f"Loading base model: {base} (using 4-bit quantization with auto device mapping) 🧠💡")
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
# Use the BitsAndBytesConfig and let device_map="auto" handle the offloading
|
| 108 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 109 |
+
base,
|
| 110 |
+
quantization_config=quantization_config,
|
| 111 |
+
device_map="auto",
|
| 112 |
+
low_cpu_mem_usage=True,
|
| 113 |
+
local_files_only=local_files_only,
|
| 114 |
+
trust_remote_code=trust_remote_code,
|
| 115 |
+
)
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"Failed loading base model '{base}': {e}")
|
| 118 |
+
raise
|
| 119 |
+
|
| 120 |
+
print(f"Loading LoRA adapter from: {adapter_dir}")
|
| 121 |
+
peft_model = PeftModel.from_pretrained(base_model, adapter_dir)
|
| 122 |
+
print("LoRA adapter loaded.")
|
| 123 |
+
|
| 124 |
+
print("Merging LoRA weights into base weights (in-memory)...")
|
| 125 |
+
merged_model = peft_model.merge_and_unload()
|
| 126 |
+
print("Merge complete.")
|
| 127 |
+
|
| 128 |
+
print(f"Saving merged model to: {out_dir}")
|
| 129 |
+
merged_model.save_pretrained(out_dir)
|
| 130 |
+
tokenizer.save_pretrained(out_dir)
|
| 131 |
+
|
| 132 |
+
modelfile_lines = [
|
| 133 |
+
"FROM .",
|
| 134 |
+
f"PARAMETER temperature {template_temperature}",
|
| 135 |
+
'TEMPLATE """{{ .Prompt }}"""',
|
| 136 |
+
"",
|
| 137 |
+
]
|
| 138 |
+
(out_path / "Modelfile").write_text("\n".join(modelfile_lines), encoding="utf-8")
|
| 139 |
+
|
| 140 |
+
meta = {
|
| 141 |
+
"base": base,
|
| 142 |
+
"adapter_dir": adapter_dir,
|
| 143 |
+
"merged_dir": out_dir,
|
| 144 |
+
"ollama_model_name": ollama_model_name,
|
| 145 |
+
"temperature": template_temperature,
|
| 146 |
+
}
|
| 147 |
+
(out_path / "merge_metadata.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
| 148 |
+
|
| 149 |
+
print("Merge complete. Next steps:")
|
| 150 |
+
print(f" 1. ollama create {ollama_model_name} -f {out_dir}/Modelfile")
|
| 151 |
+
print(f" 2. Set FINETUNED_MODEL_NAME={ollama_model_name} in your environment")
|
| 152 |
+
print(" 3. Run your agent code again.")
|
| 153 |
+
|
| 154 |
+
def main():
|
| 155 |
+
parser = argparse.ArgumentParser(description="Merge a Qwen LoRA adapter for Ollama usage.")
|
| 156 |
+
parser.add_argument("--base", required=True, help="Base HF model id used during finetuning (default: qwen2.5:3b-instruct)")
|
| 157 |
+
parser.add_argument("--auto-base", action="store_true", help="Ignore --base value and read base from adapter_config.json")
|
| 158 |
+
parser.add_argument("--adapter-dir", required=True, help="Path to the LoRA adapter directory (with adapter_model.safetensors)")
|
| 159 |
+
parser.add_argument("--out-dir", required=True, help="Directory to write merged weights and Modelfile")
|
| 160 |
+
parser.add_argument("--ollama-model-name", required=True, help="Target Ollama model name to create")
|
| 161 |
+
parser.add_argument("--temperature", type=float, default=0.05, help="Default temperature for Modelfile")
|
| 162 |
+
parser.add_argument("--hf-token", help="Hugging Face access token (optional)")
|
| 163 |
+
parser.add_argument("--local-files-only", action="store_true", help="Do not attempt to download; require weights to be cached locally")
|
| 164 |
+
parser.add_argument("--no-trust-remote-code", action="store_true", help="Disable trust_remote_code (not recommended for some models)")
|
| 165 |
+
args = parser.parse_args()
|
| 166 |
+
|
| 167 |
+
hf_token = args.hf_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACEHUB_API_TOKEN")
|
| 168 |
+
|
| 169 |
+
merge_adapter(
|
| 170 |
+
base=args.base,
|
| 171 |
+
adapter_dir=args.adapter_dir,
|
| 172 |
+
out_dir=args.out_dir,
|
| 173 |
+
ollama_model_name=args.ollama_model_name,
|
| 174 |
+
template_temperature=args.temperature,
|
| 175 |
+
hf_token=hf_token,
|
| 176 |
+
local_files_only=args.local_files_only,
|
| 177 |
+
trust_remote_code=not args.no_trust_remote_code,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
if __name__ == "__main__":
|
| 181 |
+
main()
|
Agent/merged_model_test.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from safetensors.numpy import load_file
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
base = Path(r"e:\Capstone_Project\text2sql-agent-mcp\merged_qwen2_5_3b_text2sql")
|
| 6 |
+
safetensor = base / "model.safetensors"
|
| 7 |
+
cfg_path = base / "config.json"
|
| 8 |
+
|
| 9 |
+
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
| 10 |
+
hidden = cfg.get("hidden_size")
|
| 11 |
+
print("expected attn_q shape (hidden, hidden) =", hidden, hidden)
|
| 12 |
+
|
| 13 |
+
print("Loading safetensors (this may use RAM)...")
|
| 14 |
+
tensors = load_file(str(safetensor))
|
| 15 |
+
|
| 16 |
+
# find candidate keys
|
| 17 |
+
candidates = [k for k in tensors.keys() if "attn_q.weight" in k or "blk.0.attn_q.weight" in k]
|
| 18 |
+
if not candidates:
|
| 19 |
+
# also show some top-level keys to help debug
|
| 20 |
+
print("No attn_q keys found. Showing first 40 keys in the safetensor:")
|
| 21 |
+
for k in list(tensors.keys())[:40]:
|
| 22 |
+
print(" ", k)
|
| 23 |
+
else:
|
| 24 |
+
print("Found attn_q keys (showing up to 30):")
|
| 25 |
+
for k in candidates[:30]:
|
| 26 |
+
print(k, "->", tensors[k].shape, "dtype:", tensors[k].dtype)
|
Agent/migrate_to_neon.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import asyncio
|
| 3 |
+
from sqlalchemy import create_engine, text
|
| 4 |
+
from sqlalchemy.engine import URL
|
| 5 |
+
import pandas as pd
|
| 6 |
+
from dotenv import load_dotenv
|
| 7 |
+
|
| 8 |
+
# Try to find .env in current dir and Agent dir
|
| 9 |
+
load_dotenv()
|
| 10 |
+
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '.env'))
|
| 11 |
+
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
|
| 12 |
+
|
| 13 |
+
# LOCAL_DB = "postgresql://postgres:postgres@localhost:5432/employee"
|
| 14 |
+
LOCAL_DB = os.environ.get("DB_CONNECTION_URL")
|
| 15 |
+
# NEON_DB = Your new Neon connection string
|
| 16 |
+
NEON_DB = os.environ.get("NEON_DB_URL")
|
| 17 |
+
|
| 18 |
+
async def migrate():
|
| 19 |
+
if not NEON_DB:
|
| 20 |
+
print("❌ Error: NEON_DB_URL not found in environment variables.")
|
| 21 |
+
return
|
| 22 |
+
|
| 23 |
+
print("🚀 Starting Cloud Migration to Neon.tech...")
|
| 24 |
+
|
| 25 |
+
# 1. Connect to Local
|
| 26 |
+
local_engine = create_engine(LOCAL_DB)
|
| 27 |
+
|
| 28 |
+
# 2. Connect to Neon (Ensure SSL)
|
| 29 |
+
neon_uri = NEON_DB
|
| 30 |
+
if 'sslmode=' not in neon_uri:
|
| 31 |
+
sep = '&' if '?' in neon_uri else '?'
|
| 32 |
+
neon_uri += f"{sep}sslmode=require"
|
| 33 |
+
|
| 34 |
+
neon_engine = create_engine(neon_uri)
|
| 35 |
+
|
| 36 |
+
tables = ["department", "employee", "dept_emp", "dept_manager", "salary", "title"]
|
| 37 |
+
|
| 38 |
+
for table in tables:
|
| 39 |
+
print(f"📦 Migrating table: {table}...")
|
| 40 |
+
try:
|
| 41 |
+
# Read data from local
|
| 42 |
+
df = pd.read_sql_table(table, local_engine)
|
| 43 |
+
print(f" - Found {len(df)} rows.")
|
| 44 |
+
|
| 45 |
+
# Write to Neon (replace if exists)
|
| 46 |
+
df.to_sql(table, neon_engine, if_exists='replace', index=False)
|
| 47 |
+
print(f" ✅ {table} successfully uploaded to Neon.")
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f" ❌ Failed to migrate {table}: {e}")
|
| 50 |
+
|
| 51 |
+
print("\n🎉 Migration Complete! Your cloud database is ready.")
|
| 52 |
+
|
| 53 |
+
if __name__ == "__main__":
|
| 54 |
+
asyncio.run(migrate())
|
Agent/test_adapter_weights.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Set environment variables BEFORE importing torch
|
| 3 |
+
import os
|
| 4 |
+
os.environ["TORCHINDUCTOR_DISABLE"] = "1"
|
| 5 |
+
os.environ["TORCH_COMPILE_DISABLE"] = "1"
|
| 6 |
+
# Optional: force a specific GPU (uncomment if needed)
|
| 7 |
+
# os.environ["CUDA_VISIBLE_DEVICES"] = "0"
|
| 8 |
+
|
| 9 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 10 |
+
from peft import PeftModel
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
# In-code disables for torch.compile and inductor/triton
|
| 14 |
+
torch._dynamo.config.suppress_errors = True
|
| 15 |
+
#torch._dynamo.config.enable = False
|
| 16 |
+
torch.set_float32_matmul_precision('high')
|
| 17 |
+
|
| 18 |
+
#base = "google/gemma-3-1b-it"
|
| 19 |
+
#adapter = "Agent/Gemma3_1B_weights"
|
| 20 |
+
base = "Qwen/Qwen2.5-3B-Instruct"
|
| 21 |
+
adapter = "Agent/Qwen2.5_3b_weights"
|
| 22 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 23 |
+
base,
|
| 24 |
+
dtype="auto",
|
| 25 |
+
device_map=None,
|
| 26 |
+
load_in_8bit=True,
|
| 27 |
+
low_cpu_mem_usage=True,
|
| 28 |
+
local_files_only=False # Set to False to force download if needed
|
| 29 |
+
#trust_remote_code=True
|
| 30 |
+
)
|
| 31 |
+
model = PeftModel.from_pretrained(model, adapter, local_files_only=True)
|
| 32 |
+
model = model.to("cuda") # Move model to GPU
|
| 33 |
+
tokenizer = AutoTokenizer.from_pretrained(base)
|
| 34 |
+
|
| 35 |
+
prompt = "Schema: employee(emp_no, birth_date, first_name, last_name, gender, hire_date),\n" \
|
| 36 |
+
"department(dept_no, dept_name),\n" \
|
| 37 |
+
"dept_emp(emp_no, dept_no, from_date, to_date),\n" \
|
| 38 |
+
"salary(emp_no, salary, from_date, to_date),\n" \
|
| 39 |
+
"Write PostgreSQL query for highest and lowest salary across all departments. Do not include any explanation."
|
| 40 |
+
inputs = tokenizer(prompt, return_tensors="pt")
|
| 41 |
+
# Move all input tensors to CUDA
|
| 42 |
+
inputs = {k: v.to("cuda") for k, v in inputs.items()}
|
| 43 |
+
|
| 44 |
+
output = model.generate(**inputs, max_new_tokens=1028)
|
| 45 |
+
print(tokenizer.decode(output[0], skip_special_tokens=True))
|
Agent/test_merged_model.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from safetensors.numpy import load_file
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
base = Path(r"e:\Capstone_Project\text2sql-agent-mcp\merged_qwen2_5_3b_text2sql")
|
| 5 |
+
safetensor = base / "model.safetensors"
|
| 6 |
+
|
| 7 |
+
print("Loading safetensors (may use RAM)...")
|
| 8 |
+
d = load_file(str(safetensor))
|
| 9 |
+
|
| 10 |
+
weights = [k for k in d.keys() if k.endswith(".weight")]
|
| 11 |
+
print("weight keys count:", len(weights))
|
| 12 |
+
|
| 13 |
+
if weights:
|
| 14 |
+
for k in weights[:200]:
|
| 15 |
+
print(k, d[k].shape, d[k].dtype)
|
| 16 |
+
else:
|
| 17 |
+
print("\nNo '.weight' keys found — showing first 120 keys:")
|
| 18 |
+
for k in list(d.keys())[:120]:
|
| 19 |
+
print(k)
|
Agent/test_query_runner.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
# Ensure Agent package path is importable
|
| 5 |
+
sys.path.insert(0, r"e:/Capstone_Project/text2sql-agent-mcp/Agent")
|
| 6 |
+
import backups.agentic_workflow4 as aw
|
| 7 |
+
|
| 8 |
+
async def run_one():
|
| 9 |
+
# Initialize DB (use the module's configured DB_CONNECTION_URL when available)
|
| 10 |
+
db_uri = os.environ.get('DB_CONNECTION_URL') or getattr(aw, 'DB_CONNECTION_URL', None)
|
| 11 |
+
await aw.initialize_database(db_uri)
|
| 12 |
+
# Run a single question
|
| 13 |
+
# Prefix with 're-execute' to force regeneration and bypass any cached error in memory
|
| 14 |
+
res = await aw.agentic_query_process('re-execute Who are the top 10 highest-paid employees?')
|
| 15 |
+
print('\n--- AGENT OUTPUT START ---')
|
| 16 |
+
print(res)
|
| 17 |
+
print('--- AGENT OUTPUT END ---')
|
| 18 |
+
|
| 19 |
+
if __name__ == '__main__':
|
| 20 |
+
asyncio.run(run_one())
|
Agent/tools/document_scanner.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
try:
|
| 3 |
+
# Ensure local .env values (OLLAMA_*) are loaded when this module is imported
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
load_dotenv()
|
| 6 |
+
except Exception:
|
| 7 |
+
pass
|
| 8 |
+
import uuid
|
| 9 |
+
import re
|
| 10 |
+
import base64
|
| 11 |
+
from typing import Dict, Any, Optional
|
| 12 |
+
|
| 13 |
+
def _mock_result(received: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 14 |
+
out = {"doc_id": str(uuid.uuid4()), "text": "(mock OCR output)", "metadata": {"pages": 1, "confidence": 0.0}}
|
| 15 |
+
if received:
|
| 16 |
+
out["received_args"] = received
|
| 17 |
+
return out
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def clean_ocr_text(raw: Optional[str]) -> str:
|
| 21 |
+
"""Lightweight post-processing for OCR output to improve readability.
|
| 22 |
+
|
| 23 |
+
- turns literal "\\n" sequences into real newlines
|
| 24 |
+
- unescapes common unicode/HTML escapes when safe
|
| 25 |
+
- collapses repeated spaces and blank lines
|
| 26 |
+
- trims and merges lines that were broken mid-sentence
|
| 27 |
+
"""
|
| 28 |
+
if not raw:
|
| 29 |
+
return ""
|
| 30 |
+
s = raw
|
| 31 |
+
try:
|
| 32 |
+
# Convert literal backslash-n to actual newline
|
| 33 |
+
s = s.replace('\\n', '\n')
|
| 34 |
+
except Exception:
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
# Try to interpret common escape sequences (conservative)
|
| 38 |
+
try:
|
| 39 |
+
s_decoded = s.encode('utf-8', errors='surrogatepass').decode('unicode_escape')
|
| 40 |
+
# Accept decoded result if it reduces backslash artifacts
|
| 41 |
+
if '\\n' not in s_decoded:
|
| 42 |
+
s = s_decoded
|
| 43 |
+
except Exception:
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
# Normalize line endings and whitespace
|
| 47 |
+
s = s.replace('\r\n', '\n').replace('\r', '\n')
|
| 48 |
+
s = re.sub(r'[ \t]{2,}', ' ', s)
|
| 49 |
+
# Collapse 3+ newlines into max 2
|
| 50 |
+
s = re.sub(r'\n{3,}', '\n\n', s)
|
| 51 |
+
|
| 52 |
+
# Trim whitespace on each line
|
| 53 |
+
lines = [ln.strip() for ln in s.splitlines()]
|
| 54 |
+
|
| 55 |
+
# Merge lines that likely were broken mid-sentence: if a line doesn't end with
|
| 56 |
+
# sentence punctuation and the next line begins with a lowercase letter or digit,
|
| 57 |
+
# join them with a space.
|
| 58 |
+
merged = []
|
| 59 |
+
i = 0
|
| 60 |
+
while i < len(lines):
|
| 61 |
+
line = lines[i]
|
| 62 |
+
if i < len(lines) - 1:
|
| 63 |
+
nxt = lines[i + 1]
|
| 64 |
+
if line and nxt and not re.search(r'[\.\!\?\:;]$', line) and re.match(r'^[a-z0-9]', nxt):
|
| 65 |
+
line = line + ' ' + nxt
|
| 66 |
+
i += 2
|
| 67 |
+
# Continue merging subsequent similar lines
|
| 68 |
+
while i < len(lines) and lines[i] and not re.search(r'[\.\!\?\:;]$', line) and re.match(r'^[a-z0-9]', lines[i]):
|
| 69 |
+
line = line + ' ' + lines[i]
|
| 70 |
+
i += 1
|
| 71 |
+
merged.append(line)
|
| 72 |
+
continue
|
| 73 |
+
merged.append(line)
|
| 74 |
+
i += 1
|
| 75 |
+
|
| 76 |
+
s = '\n'.join([ln for ln in merged if ln])
|
| 77 |
+
|
| 78 |
+
# Remove long runs of underscores or pipes
|
| 79 |
+
s = re.sub(r'[_\|]{2,}', '', s)
|
| 80 |
+
|
| 81 |
+
# Strip control chars except common whitespace
|
| 82 |
+
s = ''.join(ch for ch in s if ord(ch) >= 9 and ord(ch) != 11 and ord(ch) != 12)
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
import html
|
| 86 |
+
|
| 87 |
+
s = html.unescape(s)
|
| 88 |
+
except Exception:
|
| 89 |
+
pass
|
| 90 |
+
|
| 91 |
+
# Decode literal unicode escapes like \u0027 -> ' and \xA4 -> currency symbol
|
| 92 |
+
try:
|
| 93 |
+
def _u_decode(m):
|
| 94 |
+
code = m.group(1)
|
| 95 |
+
try:
|
| 96 |
+
return chr(int(code, 16))
|
| 97 |
+
except Exception:
|
| 98 |
+
return m.group(0)
|
| 99 |
+
|
| 100 |
+
s = re.sub(r'\\u([0-9A-Fa-f]{4})', _u_decode, s)
|
| 101 |
+
s = re.sub(r'\\x([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s)
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
# Remove stray leading 'n' characters that appear before uppercase words (common OCR/newline artifact)
|
| 106 |
+
try:
|
| 107 |
+
s = re.sub(r'(?<=\s|^)[nN](?=[A-Z])', '', s)
|
| 108 |
+
except Exception:
|
| 109 |
+
pass
|
| 110 |
+
|
| 111 |
+
# Ensure space after commas when missing
|
| 112 |
+
try:
|
| 113 |
+
s = re.sub(r',(?=[^\s])', ', ', s)
|
| 114 |
+
except Exception:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
# Remove spaces before punctuation
|
| 118 |
+
try:
|
| 119 |
+
s = re.sub(r'\s+([,\.\:\;\%])', r'\1', s)
|
| 120 |
+
except Exception:
|
| 121 |
+
pass
|
| 122 |
+
|
| 123 |
+
# Attempt to fix common mojibake (double-encoded UTF-8) conservatively.
|
| 124 |
+
# Only apply if there are visible 'Ã' sequences suggesting encoding issues.
|
| 125 |
+
try:
|
| 126 |
+
if 'Ã' in s or 'Â' in s:
|
| 127 |
+
try:
|
| 128 |
+
candidate = s.encode('latin-1').decode('utf-8')
|
| 129 |
+
# Accept candidate if it reduces suspicious sequences
|
| 130 |
+
if candidate.count('Ã') < s.count('Ã'):
|
| 131 |
+
s = candidate
|
| 132 |
+
except Exception:
|
| 133 |
+
pass
|
| 134 |
+
except Exception:
|
| 135 |
+
pass
|
| 136 |
+
|
| 137 |
+
return s.strip()
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def process_image(image_url: Optional[str] = None, image_bytes: Optional[bytes] = None, options: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
| 141 |
+
"""Process an image using local OCR first (pytesseract), with a light transformers fallback.
|
| 142 |
+
|
| 143 |
+
Accepts either `image_url` or `image_bytes` (bytes or base64 string). Returns dict: {doc_id, text, metadata}
|
| 144 |
+
"""
|
| 145 |
+
|
| 146 |
+
# Lightweight imports that may be missing in minimal environments
|
| 147 |
+
try:
|
| 148 |
+
from PIL import Image
|
| 149 |
+
from io import BytesIO
|
| 150 |
+
import requests
|
| 151 |
+
except Exception:
|
| 152 |
+
# Missing imaging/network deps -> return mock with echo of args
|
| 153 |
+
return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})
|
| 154 |
+
|
| 155 |
+
# Normalize image_bytes if base64 string was passed
|
| 156 |
+
if isinstance(image_bytes, str):
|
| 157 |
+
try:
|
| 158 |
+
image_bytes = base64.b64decode(image_bytes)
|
| 159 |
+
except Exception:
|
| 160 |
+
image_bytes = None
|
| 161 |
+
|
| 162 |
+
# Acquire PIL image
|
| 163 |
+
img = None
|
| 164 |
+
try:
|
| 165 |
+
if image_bytes:
|
| 166 |
+
img = Image.open(BytesIO(image_bytes)).convert("RGB")
|
| 167 |
+
elif image_url:
|
| 168 |
+
resp = requests.get(image_url, timeout=15)
|
| 169 |
+
resp.raise_for_status()
|
| 170 |
+
img = Image.open(BytesIO(resp.content)).convert("RGB")
|
| 171 |
+
else:
|
| 172 |
+
return _mock_result(received={"image_url": image_url, "has_bytes": False})
|
| 173 |
+
except Exception:
|
| 174 |
+
return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})
|
| 175 |
+
|
| 176 |
+
# PRIORITY: Try local Tesseract (pytesseract) first — lightweight and deterministic on Windows
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
import pytesseract
|
| 180 |
+
# Robustly find tesseract executable: prefer PATH, then common Windows install locations
|
| 181 |
+
try:
|
| 182 |
+
import shutil
|
| 183 |
+
found = shutil.which('tesseract')
|
| 184 |
+
except Exception:
|
| 185 |
+
found = None
|
| 186 |
+
if not found:
|
| 187 |
+
# common install locations on Windows
|
| 188 |
+
for candidate in (r"C:\Program Files\Tesseract-OCR\tesseract.exe", r"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"):
|
| 189 |
+
try:
|
| 190 |
+
if os.path.exists(candidate):
|
| 191 |
+
found = candidate
|
| 192 |
+
break
|
| 193 |
+
except Exception:
|
| 194 |
+
continue
|
| 195 |
+
try:
|
| 196 |
+
if found:
|
| 197 |
+
pytesseract.pytesseract.tesseract_cmd = found
|
| 198 |
+
print(f"[document_scanner] using tesseract executable: {found}")
|
| 199 |
+
else:
|
| 200 |
+
# leave default; pytesseract will raise a helpful error which we catch below
|
| 201 |
+
print("[document_scanner] tesseract executable not found in PATH or common locations")
|
| 202 |
+
except Exception:
|
| 203 |
+
# ignore assignment errors and let pytesseract raise on use
|
| 204 |
+
pass
|
| 205 |
+
|
| 206 |
+
try:
|
| 207 |
+
if img is not None:
|
| 208 |
+
print("[document_scanner] attempting PRIMARY pytesseract OCR")
|
| 209 |
+
ttxt = pytesseract.image_to_string(img)
|
| 210 |
+
if ttxt and ttxt.strip():
|
| 211 |
+
print(f"[document_scanner] pytesseract returned text (len={len(ttxt.strip())})")
|
| 212 |
+
return {"doc_id": str(uuid.uuid4()), "text": clean_ocr_text(ttxt), "metadata": {"pages": 1, "confidence": None}}
|
| 213 |
+
else:
|
| 214 |
+
print("[document_scanner] pytesseract returned no text; falling through to model-based fallbacks")
|
| 215 |
+
except Exception as e_pyt_first:
|
| 216 |
+
print(f"[document_scanner] primary pytesseract attempt failed: {e_pyt_first}")
|
| 217 |
+
except Exception:
|
| 218 |
+
# pytesseract not installed in environment — continue to available backends
|
| 219 |
+
pass
|
| 220 |
+
|
| 221 |
+
# Last resort: return a safe mock that echoes inputs
|
| 222 |
+
# (Tesseract failed or is not installed — Q&A will still work via Granite Vision if image is saved)
|
| 223 |
+
return _mock_result(received={"image_url": image_url, "has_bytes": bool(image_bytes)})
|
Agent/tools/granite_vision.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vision MCP tool - visual Q&A via Groq Vision API (llama-3.2-11b-vision-preview).
|
| 3 |
+
|
| 4 |
+
Cloud path : image -> Groq Llama 3.2 Vision -> structured answer (FREE, 14400 req/day)
|
| 5 |
+
Local path : image -> Ollama qwen2-vl -> answer (dev only)
|
| 6 |
+
|
| 7 |
+
Registered in mcp_server.py as 'granite_vision.qa'.
|
| 8 |
+
"""
|
| 9 |
+
import os
|
| 10 |
+
import base64
|
| 11 |
+
import requests
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def qa(image_path: str, question: str, model_path: Optional[str] = None) -> dict:
|
| 16 |
+
"""Answer a question about a document image using Groq Vision or local Ollama."""
|
| 17 |
+
|
| 18 |
+
print(f"[vision.qa] image={image_path} question={question[:80]}")
|
| 19 |
+
|
| 20 |
+
# CLOUD MODE: Groq Vision API
|
| 21 |
+
if os.environ.get("USE_HF_CLOUD", "0") in ("1", "true", "True"):
|
| 22 |
+
groq_key = os.environ.get("GROQ_API_KEY")
|
| 23 |
+
groq_model = os.environ.get("GROQ_VISION_MODEL", "meta-llama/llama-4-scout-17b-16e-instruct")
|
| 24 |
+
|
| 25 |
+
if groq_key:
|
| 26 |
+
print(f"[granite_vision.qa] CLOUD MODE: {groq_model} via Groq")
|
| 27 |
+
try:
|
| 28 |
+
from groq import Groq
|
| 29 |
+
|
| 30 |
+
client = Groq(api_key=groq_key)
|
| 31 |
+
|
| 32 |
+
with open(image_path, "rb") as f:
|
| 33 |
+
img_b64 = base64.b64encode(f.read()).decode("utf-8")
|
| 34 |
+
|
| 35 |
+
ext = os.path.splitext(image_path)[-1].lower().lstrip(".")
|
| 36 |
+
mime = "image/jpeg" if ext in ("jpg", "jpeg") else f"image/{ext}"
|
| 37 |
+
|
| 38 |
+
response = client.chat.completions.create(
|
| 39 |
+
model=groq_model,
|
| 40 |
+
messages=[
|
| 41 |
+
{
|
| 42 |
+
"role": "user",
|
| 43 |
+
"content": [
|
| 44 |
+
{
|
| 45 |
+
"type": "text",
|
| 46 |
+
"text": (
|
| 47 |
+
"You are analyzing a document image. "
|
| 48 |
+
"Look carefully at the layout, tables, numbers, and text. "
|
| 49 |
+
"Answer the following question based only on what you see:\n\n"
|
| 50 |
+
+ question
|
| 51 |
+
)
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"type": "image_url",
|
| 55 |
+
"image_url": {
|
| 56 |
+
"url": f"data:{mime};base64,{img_b64}"
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
]
|
| 60 |
+
}
|
| 61 |
+
],
|
| 62 |
+
max_tokens=512
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
answer = response.choices[0].message.content.strip()
|
| 66 |
+
print(f"[granite_vision.qa] Groq answer ({len(answer)} chars): {answer[:200]}")
|
| 67 |
+
return {"answer": answer, "model": f"{groq_model} (Groq Vision)"}
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print(f"[granite_vision.qa] Groq failed: {e}")
|
| 71 |
+
return {
|
| 72 |
+
"answer": f"Groq Vision error: {e}",
|
| 73 |
+
"model": groq_model,
|
| 74 |
+
"error": str(e)
|
| 75 |
+
}
|
| 76 |
+
else:
|
| 77 |
+
print("[granite_vision.qa] GROQ_API_KEY not set in HF secrets.")
|
| 78 |
+
return {
|
| 79 |
+
"answer": "GROQ_API_KEY is not configured. Please add it in HuggingFace Space secrets.",
|
| 80 |
+
"model": "none",
|
| 81 |
+
"error": "missing_groq_key"
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
# LOCAL MODE (dev only): Ollama
|
| 85 |
+
local_model = os.environ.get("GRANITE_MODEL_PATH", "qwen2-vl:2b")
|
| 86 |
+
print(f"[granite_vision.qa] LOCAL MODE: sending image to Ollama ({local_model})")
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
with open(image_path, "rb") as f:
|
| 90 |
+
img_b64 = base64.b64encode(f.read()).decode("utf-8")
|
| 91 |
+
|
| 92 |
+
base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434").rstrip("/")
|
| 93 |
+
url = f"{base_url}/api/chat"
|
| 94 |
+
payload = {
|
| 95 |
+
"model": local_model,
|
| 96 |
+
"messages": [
|
| 97 |
+
{
|
| 98 |
+
"role": "user",
|
| 99 |
+
"content": f"Carefully analyze this document image. {question}",
|
| 100 |
+
"images": [img_b64]
|
| 101 |
+
}
|
| 102 |
+
],
|
| 103 |
+
"stream": False,
|
| 104 |
+
"keep_alive": 60,
|
| 105 |
+
"options": {"num_ctx": 1024, "temperature": 0.1}
|
| 106 |
+
}
|
| 107 |
+
r = requests.post(url, json=payload, timeout=300)
|
| 108 |
+
if r.status_code == 200:
|
| 109 |
+
answer = r.json().get("message", {}).get("content", "").strip()
|
| 110 |
+
print(f"[granite_vision.qa] Ollama answer ({len(answer)} chars): {answer[:200]}")
|
| 111 |
+
return {"answer": answer, "model": f"{local_model} (Ollama)"}
|
| 112 |
+
else:
|
| 113 |
+
msg = f"Ollama HTTP {r.status_code}: {r.text}"
|
| 114 |
+
print(f"[granite_vision.qa] {msg}")
|
| 115 |
+
return {"answer": msg, "model": f"{local_model} (Ollama)", "error": "http_error"}
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
msg = f"[ERROR] Ollama Q&A failed: {e}"
|
| 119 |
+
print(f"[granite_vision.qa] {msg}")
|
| 120 |
+
return {"answer": msg, "model": f"{local_model} (Ollama)", "error": str(e)}
|
Agent/worker.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import asyncio
|
| 3 |
+
import io
|
| 4 |
+
import sys
|
| 5 |
+
import contextlib
|
| 6 |
+
from fastapi import FastAPI, HTTPException
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from fastapi import File, UploadFile
|
| 10 |
+
import base64
|
| 11 |
+
import uuid
|
| 12 |
+
import uvicorn
|
| 13 |
+
|
| 14 |
+
# Worker that imports the agent and exposes a simple HTTP API
|
| 15 |
+
app = FastAPI(title="Agent Worker")
|
| 16 |
+
|
| 17 |
+
class QueryReq(BaseModel):
|
| 18 |
+
question: str
|
| 19 |
+
doc_id: Optional[str] = None
|
| 20 |
+
mode: Optional[str] = None # 'text2sql' or 'ocr_qa'
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class UploadResp(BaseModel):
|
| 24 |
+
doc_id: str
|
| 25 |
+
text_preview: str
|
| 26 |
+
image_url: str
|
| 27 |
+
|
| 28 |
+
class OcrQAReq(BaseModel):
|
| 29 |
+
question: str
|
| 30 |
+
doc_id: str
|
| 31 |
+
|
| 32 |
+
class OcrQAResp(BaseModel):
|
| 33 |
+
doc_id: str
|
| 34 |
+
answer: str
|
| 35 |
+
used_llm: bool
|
| 36 |
+
|
| 37 |
+
# Lazy import agent module (do at startup so heavy init happens in worker)
|
| 38 |
+
agent = None
|
| 39 |
+
# Lock to prevent concurrent capture of stdout/stderr which would interleave logs
|
| 40 |
+
capture_lock = asyncio.Lock()
|
| 41 |
+
|
| 42 |
+
@app.on_event("startup")
|
| 43 |
+
async def startup_event():
|
| 44 |
+
global agent
|
| 45 |
+
# Import here so the LLM and DB init occur inside the worker process
|
| 46 |
+
# Use package-relative import so we load Agent.agentic_workflow (not a top-level file)
|
| 47 |
+
try:
|
| 48 |
+
from . import agentic_workflow as agentic_workflow
|
| 49 |
+
except Exception:
|
| 50 |
+
# fallback to absolute package import
|
| 51 |
+
import Agent.agentic_workflow as agentic_workflow
|
| 52 |
+
agent = agentic_workflow
|
| 53 |
+
# Initialize DB (best-effort)
|
| 54 |
+
db_url = os.environ.get("DB_CONNECTION_URL")
|
| 55 |
+
if db_url:
|
| 56 |
+
try:
|
| 57 |
+
await agent.initialize_database(uri=db_url)
|
| 58 |
+
except Exception as e:
|
| 59 |
+
# Log but continue; agentic_workflow has fallback behavior
|
| 60 |
+
print(f"Worker: initialize_database failed: {e}")
|
| 61 |
+
else:
|
| 62 |
+
print("Worker: DB_CONNECTION_URL not set; skipping DB init")
|
| 63 |
+
|
| 64 |
+
@app.get("/health")
|
| 65 |
+
async def health():
|
| 66 |
+
return {"status": "ok"}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.post("/query")
|
| 70 |
+
async def query(req: QueryReq):
|
| 71 |
+
global agent
|
| 72 |
+
if agent is None:
|
| 73 |
+
raise HTTPException(status_code=503, detail="Agent not initialized")
|
| 74 |
+
q = req.question
|
| 75 |
+
doc_id = getattr(req, 'doc_id', None)
|
| 76 |
+
mode = getattr(req, 'mode', None)
|
| 77 |
+
# Capture stdout/stderr produced by the agent for this request
|
| 78 |
+
buf_out = io.StringIO()
|
| 79 |
+
buf_err = io.StringIO()
|
| 80 |
+
async with capture_lock:
|
| 81 |
+
try:
|
| 82 |
+
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
|
| 83 |
+
# Robust mode switch: if mode is 'ocr_qa' or doc_id is present, use OCR agent; else use SQL logic
|
| 84 |
+
if (mode == 'ocr_qa') or (doc_id and (mode is None or mode == 'auto')):
|
| 85 |
+
# Use OCR Q&A agent
|
| 86 |
+
# ocr_agent_qa may be sync, so run in executor
|
| 87 |
+
import asyncio
|
| 88 |
+
answer = await asyncio.get_event_loop().run_in_executor(
|
| 89 |
+
None, lambda: agent.ocr_agent_qa(q, doc_id)
|
| 90 |
+
)
|
| 91 |
+
result = answer
|
| 92 |
+
sql_out = None # No SQL for OCR Q&A
|
| 93 |
+
else:
|
| 94 |
+
# Use standard Text-to-SQL agentic workflow
|
| 95 |
+
result = await agent.agentic_query_process(q, None)
|
| 96 |
+
try:
|
| 97 |
+
sql_out = getattr(agent, 'LAST_GENERATED_SQL', None)
|
| 98 |
+
except Exception:
|
| 99 |
+
sql_out = None
|
| 100 |
+
except ValueError as ve:
|
| 101 |
+
# Treat known generation failures as normal answers so UI doesn't show nested JSON error
|
| 102 |
+
err_msg = str(ve)
|
| 103 |
+
if "Unable to generate SQL query" in err_msg:
|
| 104 |
+
result = err_msg # Provide user-visible plain text
|
| 105 |
+
sql_out = None
|
| 106 |
+
else:
|
| 107 |
+
import traceback
|
| 108 |
+
traceback.print_exc(file=buf_err)
|
| 109 |
+
raise HTTPException(status_code=500, detail=err_msg)
|
| 110 |
+
except Exception as e:
|
| 111 |
+
import traceback
|
| 112 |
+
traceback.print_exc(file=buf_err)
|
| 113 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 114 |
+
logs = buf_out.getvalue()
|
| 115 |
+
errlogs = buf_err.getvalue()
|
| 116 |
+
if errlogs:
|
| 117 |
+
logs = logs + "\n" + errlogs
|
| 118 |
+
return {"answer": result, "logs": logs, "sql": sql_out}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@app.post("/ocr-qa", response_model=OcrQAResp)
|
| 122 |
+
async def ocr_qa(req: OcrQAReq):
|
| 123 |
+
"""Answer a question about a previously uploaded document using its doc_id.
|
| 124 |
+
|
| 125 |
+
This uses the agent's ocr_agent_qa helper which pulls stored OCR text and
|
| 126 |
+
consults the LLM (or returns a low-memory fallback excerpt).
|
| 127 |
+
"""
|
| 128 |
+
global agent
|
| 129 |
+
if agent is None:
|
| 130 |
+
raise HTTPException(status_code=503, detail="Agent not initialized")
|
| 131 |
+
question = req.question
|
| 132 |
+
doc_id = req.doc_id
|
| 133 |
+
# Run potentially blocking OCR QA in thread pool if it's sync
|
| 134 |
+
try:
|
| 135 |
+
answer = await asyncio.get_event_loop().run_in_executor(
|
| 136 |
+
None, lambda: agent.ocr_agent_qa(question, doc_id)
|
| 137 |
+
)
|
| 138 |
+
except Exception as e:
|
| 139 |
+
raise HTTPException(status_code=500, detail=f"ocr_qa failed: {e}")
|
| 140 |
+
used_llm = not answer.startswith("[Low memory]") and not answer.startswith("No OCR text") and not answer.startswith("Error while")
|
| 141 |
+
return OcrQAResp(doc_id=doc_id, answer=answer, used_llm=used_llm)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@app.post("/upload-image", response_model=UploadResp)
|
| 145 |
+
async def upload_image(file: UploadFile = File(...)):
|
| 146 |
+
"""Accept an image, save locally, call MCP document_scanner.process eagerly,
|
| 147 |
+
persist OCR to private chat store, and return a doc_id plus preview.
|
| 148 |
+
"""
|
| 149 |
+
global agent
|
| 150 |
+
if agent is None:
|
| 151 |
+
raise HTTPException(status_code=503, detail="Agent not initialized")
|
| 152 |
+
|
| 153 |
+
# Ensure uploads directory exists
|
| 154 |
+
uploads_dir = "uploads"
|
| 155 |
+
os.makedirs(uploads_dir, exist_ok=True)
|
| 156 |
+
|
| 157 |
+
# Save uploaded file
|
| 158 |
+
filename = f"{uuid.uuid4()}_{file.filename}"
|
| 159 |
+
path = os.path.join(uploads_dir, filename)
|
| 160 |
+
try:
|
| 161 |
+
contents = await file.read()
|
| 162 |
+
with open(path, "wb") as f:
|
| 163 |
+
f.write(contents)
|
| 164 |
+
except Exception as e:
|
| 165 |
+
raise HTTPException(status_code=500, detail=f"Failed to save upload: {e}")
|
| 166 |
+
|
| 167 |
+
# Call MCP document_scanner tool eagerly with base64 payload to avoid exposing file server
|
| 168 |
+
b64 = base64.b64encode(contents).decode('utf-8')
|
| 169 |
+
try:
|
| 170 |
+
from .mcp_client import mcp_call_tool
|
| 171 |
+
ctx = {"tool_call": {"name": "document_scanner.process", "args": {"image_bytes": b64}}}
|
| 172 |
+
resp = mcp_call_tool(prompt="run document scanner", timeout=60.0, context=ctx)
|
| 173 |
+
# Expect tool_result in response
|
| 174 |
+
tool_result = resp.get("tool_result") if isinstance(resp, dict) else None
|
| 175 |
+
except Exception:
|
| 176 |
+
# Fall back to local direct call if MCP unavailable or tool failed
|
| 177 |
+
tool_result = None
|
| 178 |
+
try:
|
| 179 |
+
if agent and hasattr(agent, 'tools') and hasattr(agent.tools, 'document_scanner'):
|
| 180 |
+
try:
|
| 181 |
+
tool_result = agent.tools.document_scanner.process_image(image_bytes=b64)
|
| 182 |
+
except Exception:
|
| 183 |
+
tool_result = None
|
| 184 |
+
except Exception:
|
| 185 |
+
tool_result = None
|
| 186 |
+
if not tool_result:
|
| 187 |
+
tool_result = {"doc_id": str(uuid.uuid4()), "text": "(ocr unavailable)", "metadata": {}}
|
| 188 |
+
|
| 189 |
+
# Persist OCR to private chat store file for retrieval by doc_id
|
| 190 |
+
doc_id = tool_result.get("doc_id") if isinstance(tool_result, dict) and tool_result.get("doc_id") else str(uuid.uuid4())
|
| 191 |
+
ocr_text = tool_result.get("text", "") if isinstance(tool_result, dict) else str(tool_result)
|
| 192 |
+
|
| 193 |
+
# Save to docs/ and also to chat_store_private for history
|
| 194 |
+
docs_dir = os.path.join("chat_store", "docs")
|
| 195 |
+
os.makedirs(docs_dir, exist_ok=True)
|
| 196 |
+
doc_path = os.path.join(docs_dir, f"{doc_id}.txt")
|
| 197 |
+
try:
|
| 198 |
+
with open(doc_path, "w", encoding="utf-8") as df:
|
| 199 |
+
df.write(ocr_text)
|
| 200 |
+
except Exception as e:
|
| 201 |
+
print(f"Failed to persist OCR text: {e}")
|
| 202 |
+
|
| 203 |
+
# Also persist the original image path so Granite Vision can do direct visual Q&A
|
| 204 |
+
img_path_file = os.path.join(docs_dir, f"{doc_id}.img_path")
|
| 205 |
+
try:
|
| 206 |
+
abs_img_path = os.path.abspath(path)
|
| 207 |
+
with open(img_path_file, "w", encoding="utf-8") as ipf:
|
| 208 |
+
ipf.write(abs_img_path)
|
| 209 |
+
print(f"[upload] Saved image path for doc_id={doc_id}: {abs_img_path}")
|
| 210 |
+
except Exception as e:
|
| 211 |
+
print(f"[upload] Failed to persist image path: {e}")
|
| 212 |
+
|
| 213 |
+
# Also add a short entry to chat_store_private for traceability
|
| 214 |
+
try:
|
| 215 |
+
try:
|
| 216 |
+
from .agentic_workflow import chat_store_private, ChatMessage, MessageRole
|
| 217 |
+
except ImportError:
|
| 218 |
+
from Agent.agentic_workflow import chat_store_private, ChatMessage, MessageRole
|
| 219 |
+
# Create minimal messages
|
| 220 |
+
user_msg = ChatMessage(role=MessageRole.USER, content=f"Uploaded document {doc_id}")
|
| 221 |
+
assistant_msg = ChatMessage(role=MessageRole.ASSISTANT, content=f"OCR stored: {ocr_text[:400]}")
|
| 222 |
+
chat_store_private.add_message(key="conversation", message=user_msg)
|
| 223 |
+
chat_store_private.add_message(key="conversation", message=assistant_msg)
|
| 224 |
+
chat_store_private.persist(str("chat_store/chat_store_private.json"))
|
| 225 |
+
except Exception:
|
| 226 |
+
pass
|
| 227 |
+
|
| 228 |
+
# Return doc id and small preview
|
| 229 |
+
preview = ocr_text[:400]
|
| 230 |
+
image_url = path
|
| 231 |
+
return UploadResp(doc_id=doc_id, text_preview=preview, image_url=image_url)
|
| 232 |
+
|
| 233 |
+
if __name__ == '__main__':
|
| 234 |
+
port = int(os.environ.get("WORKER_PORT", 8700))
|
| 235 |
+
uvicorn.run(app, host="127.0.0.1", port=port)
|
Dockerfile
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for Hugging Face Spaces
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Prevent Python from writing pyc files and buffering stdout/stderr
|
| 5 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 6 |
+
ENV PYTHONUNBUFFERED=1
|
| 7 |
+
ENV USE_HF_CLOUD=1
|
| 8 |
+
|
| 9 |
+
# Pre-set Ollama model directory so the build-time pull goes to the right place
|
| 10 |
+
ENV OLLAMA_MODELS=/app/ollama_models
|
| 11 |
+
ENV HOME=/app
|
| 12 |
+
|
| 13 |
+
# Install system dependencies
|
| 14 |
+
RUN apt-get update && apt-get install -y \
|
| 15 |
+
curl \
|
| 16 |
+
zstd \
|
| 17 |
+
tesseract-ocr \
|
| 18 |
+
libtesseract-dev \
|
| 19 |
+
gcc \
|
| 20 |
+
libpq-dev \
|
| 21 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 22 |
+
|
| 23 |
+
# Install latest Ollama via official install script
|
| 24 |
+
# (replaces the pinned v0.5.11 which cannot pull qwen3.5:2b)
|
| 25 |
+
RUN curl -fsSL https://ollama.ai/install.sh | sh
|
| 26 |
+
|
| 27 |
+
# Pre-bake qwen3.5:2b vision model into the image at build time.
|
| 28 |
+
# The model will be stored in /app/ollama_models and available
|
| 29 |
+
# immediately when the container starts — no runtime pull needed.
|
| 30 |
+
RUN mkdir -p /app/ollama_models && \
|
| 31 |
+
OLLAMA_MODELS=/app/ollama_models ollama serve > /tmp/ollama_build.log 2>&1 & \
|
| 32 |
+
OLLAMA_PID=$! && \
|
| 33 |
+
echo "[BUILD] Waiting for Ollama to start (PID=$OLLAMA_PID)..." && \
|
| 34 |
+
for i in $(seq 1 30); do \
|
| 35 |
+
if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then \
|
| 36 |
+
echo "[BUILD] Ollama is ready."; break; \
|
| 37 |
+
fi; \
|
| 38 |
+
echo "[BUILD] Waiting... ($i/30)"; \
|
| 39 |
+
sleep 2; \
|
| 40 |
+
done && \
|
| 41 |
+
echo "[BUILD] Pulling qwen3.5:2b into image layers..." && \
|
| 42 |
+
OLLAMA_MODELS=/app/ollama_models ollama pull qwen3.5:2b && \
|
| 43 |
+
echo "[BUILD] Model pull complete. Stopping build-time Ollama..." && \
|
| 44 |
+
kill $OLLAMA_PID 2>/dev/null || true && \
|
| 45 |
+
wait $OLLAMA_PID 2>/dev/null || true && \
|
| 46 |
+
echo "[BUILD] qwen3.5:2b pre-baked successfully."
|
| 47 |
+
|
| 48 |
+
WORKDIR /app
|
| 49 |
+
|
| 50 |
+
# Install Python dependencies
|
| 51 |
+
COPY requirements.txt .
|
| 52 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 53 |
+
pip install --no-cache-dir -r requirements.txt && \
|
| 54 |
+
pip install --no-cache-dir llama-index-llms-huggingface-api huggingface_hub
|
| 55 |
+
|
| 56 |
+
# Copy application code
|
| 57 |
+
COPY . .
|
| 58 |
+
|
| 59 |
+
# Hugging Face Spaces requires port 7860
|
| 60 |
+
EXPOSE 7860
|
| 61 |
+
|
| 62 |
+
# Fix line endings and permissions on startup script
|
| 63 |
+
RUN sed -i 's/\r$//' start_hf.sh && chmod +x start_hf.sh
|
| 64 |
+
|
| 65 |
+
CMD ["./start_hf.sh"]
|
README.md
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: QueryLens Text2SQL-DocVQA
|
| 3 |
+
emoji: 🤖
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
tags:
|
| 10 |
+
- agents
|
| 11 |
+
- mcp
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# 🤖 Agentic Text2SQL & Document VQA
|
| 15 |
+
Multi-Agent System
|
| 16 |
+
|
| 17 |
+

|
| 18 |
+

|
| 19 |
+

|
| 20 |
+

|
| 21 |
+
|
| 22 |
+
An advanced, hardware-optimized AI pipeline built on a multi-agent orchestration architecture. This system seamlessly integrates natural language Text-to-SQL generation with a Vision-Language Model (VLM) for Optical Character Recognition (OCR) and Visual Question Answering (VQA).
|
| 23 |
+
|
| 24 |
+
By leveraging the Model Context Protocol (MCP), local LLMs via Ollama, and intelligent memory management, this project achieves production-grade data extraction and database querying running entirely on local hardware within tight memory constraints.
|
| 25 |
+
|
| 26 |
+
---
|
| 27 |
+
|
| 28 |
+
## Key Features
|
| 29 |
+
|
| 30 |
+
- **Multi-Agent Orchestration:** A dynamic routing system featuring specialized agents — MemoryAgent, QueryCraftAgent, and ResultPresenterAgent — each with a clearly defined role in the pipeline.
|
| 31 |
+
- **Vision-Language Question Answering:** Integrates Qwen 3.5 2B Vision for accurate data extraction from complex documents such as invoices and unstructured tables, served via Ollama.
|
| 32 |
+
- **Three-Level Text-to-SQL Engine:** Translates natural language questions into syntactically valid SQL through a progressive reasoning architecture, with automatic self-correction at each escalation level.
|
| 33 |
+
- **Semantic Gatekeeping:** A multi-layer validation pipeline filters hallucinated SQL, undefined aliases, missing joins, and incorrect aggregation patterns before any query reaches the database.
|
| 34 |
+
- **Dynamic VRAM Orchestration:** Custom memory management algorithms automatically load and unload models from GPU VRAM to prevent out-of-memory crashes, enabling concurrent AI workloads on consumer hardware.
|
| 35 |
+
- **Unified MCP Integration:** All external data sources — relational databases and vision models alike — are accessed through a single, uniform Model Context Protocol interface.
|
| 36 |
+
- **Database Agnostic Design:** Architected to interface with PostgreSQL, MySQL, SQLite, MS SQL Server, and Oracle via SQLAlchemy.
|
| 37 |
+
- **Conversational Interface:** A full Web UI supporting natural language interaction with persistent session context.
|
| 38 |
+
|
| 39 |
+
---
|
| 40 |
+
|
| 41 |
+
## System Architecture and Agentic Workflow
|
| 42 |
+
|
| 43 |
+
The project employs a decoupled, asynchronous microservices architecture. The Web UI communicates with a backend worker, which orchestrates all agent requests through the Model Context Protocol.
|
| 44 |
+
|
| 45 |
+
### The Three-Agent Pipeline
|
| 46 |
+
|
| 47 |
+
**1. MemoryAgent**
|
| 48 |
+
|
| 49 |
+
The MemoryAgent acts as the entry point for all user queries. It performs three functions before any SQL generation occurs: checking whether the current query can be satisfied from historical conversation context, rephrasing ambiguous or under-specified queries based on prior interactions, and routing validated new queries to the appropriate downstream agent. This layer prevents redundant computation and improves response latency for repeated or related questions.
|
| 50 |
+
|
| 51 |
+
**2. QueryCraftAgent — The Three-Level Text-to-SQL Engine**
|
| 52 |
+
|
| 53 |
+
The QueryCraftAgent is the core reasoning engine of the system. It implements a progressive escalation architecture that balances inference speed with accuracy:
|
| 54 |
+
|
| 55 |
+
- **Level 1 — Pure LLM Intelligence:** Zero-shot semantic translation of natural language to SQL using Qwen 2.5 Coder. The generated query passes through a semantic validation pipeline that checks alias correctness, required table joins, aggregation integrity, and department or entity filters before being accepted.
|
| 56 |
+
|
| 57 |
+
- **Level 2 — LLM with SQL Fixer:** When Level 1 fails validation, the agent enters an autonomous self-correction loop. The SQL Fixer applies a deterministic set of targeted transformations — normalizing aliases, injecting missing to_date filters, repairing phantom column references, and enforcing PostgreSQL-compliant GROUP BY clauses — before re-validating the output.
|
| 58 |
+
|
| 59 |
+
- **Level 3 — LLM with MCP Schema-Grounded SQL Generation:** The deepest reasoning level, reserved for queries that fail both prior levels. The agent calls one of fourteen registered `fast_sql` MCP tools, each of which queries the live database schema via SQLAlchemy's inspector to retrieve actual column names and table structures, then programmatically constructs a valid SQL template grounded in the real schema. This template is passed as a verified hint to the LLM, which refines it before the Fixer loop runs. Because the hint is built from the actual schema rather than the LLM's training data, this level eliminates the class of failures caused by hallucinated column names or missing joins.
|
| 60 |
+
|
| 61 |
+
**3. ResultPresenterAgent**
|
| 62 |
+
|
| 63 |
+
The ResultPresenterAgent executes the validated SQL query against the target database, translates raw result sets into natural conversational responses, handles execution errors and edge cases, and persists the interaction to the conversation history for future MemoryAgent lookups.
|
| 64 |
+
|
| 65 |
+
### Multi-Agent Orchestration Flow
|
| 66 |
+
|
| 67 |
+
```mermaid
|
| 68 |
+
graph TD
|
| 69 |
+
User([User]) --> Web["Web Application\nPort 8000"]
|
| 70 |
+
|
| 71 |
+
Web -->|Image Upload| Worker["Backend Worker\nPort 8700"]
|
| 72 |
+
Web -->|Text Query| Worker
|
| 73 |
+
|
| 74 |
+
subgraph MCP["MCP Tool Server — Port 8701"]
|
| 75 |
+
DocScan["document_scanner.process\nTesseract OCR"]
|
| 76 |
+
FastSQL["fast_sql tools\nSchema-Grounded SQL Generation"]
|
| 77 |
+
VisionQA["granite_vision.qa\nQwen 3.5 2B Vision"]
|
| 78 |
+
end
|
| 79 |
+
|
| 80 |
+
Worker -->|"Upload: MCP tool call"| DocScan
|
| 81 |
+
DocScan -->|"doc_id + OCR text + image path"| Store[("Document Store\nchat_store/docs/")]
|
| 82 |
+
|
| 83 |
+
Worker -->|"OCR Q&A mode\ndoc_id present"| VisionQA
|
| 84 |
+
Store -->|image path| VisionQA
|
| 85 |
+
VisionQA --> OllamaV(["Ollama\nQwen 3.5 2B Vision"])
|
| 86 |
+
OllamaV --> Presenter
|
| 87 |
+
|
| 88 |
+
Worker -->|Text Query| Memory["MemoryAgent"]
|
| 89 |
+
Memory -->|Cached answer| Presenter["ResultPresenterAgent"]
|
| 90 |
+
Memory -->|New query| QCAgent["QueryCraftAgent"]
|
| 91 |
+
|
| 92 |
+
QCAgent -->|"Level 1 and 2\nDirect Ollama call"| OllamaSQL(["Ollama\nQwen 2.5 Coder"])
|
| 93 |
+
QCAgent -->|"Level 3\nMCP tool call"| FastSQL
|
| 94 |
+
FastSQL -->|"Schema-validated SQL hint"| OllamaSQL
|
| 95 |
+
OllamaSQL -->|Raw SQL| Fixer["SQL Fixer +\nSemantic Validator"]
|
| 96 |
+
Fixer -->|"Validated SQL\nvia SQLAlchemy"| DB[("PostgreSQL / MySQL")]
|
| 97 |
+
|
| 98 |
+
DB --> Presenter
|
| 99 |
+
Presenter -->|Persist to history| Memory
|
| 100 |
+
Presenter --> Answer(["Natural Language Answer"])
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## Infrastructure and Deployment Architecture
|
| 106 |
+
|
| 107 |
+
### Containerized Microservices
|
| 108 |
+
|
| 109 |
+
The system is implemented as a Dockerized microservices architecture, with each component running as an isolated, independently deployable service. Docker Compose orchestrates three services: the main application and agent worker, the MCP tool server, and the relational databases (PostgreSQL and MySQL). Database containers are provisioned with health checks using `pg_isready` and `mysqladmin ping`, and the application container is configured with `condition: service_healthy` dependencies to eliminate the startup race condition inherent in naive `depends_on` configurations.
|
| 110 |
+
|
| 111 |
+
### Native-Bridge Hybrid Offloading
|
| 112 |
+
|
| 113 |
+
For peak inference performance, the system uses a hybrid offloading strategy. The application logic and MCP servers run inside Docker containers for portability and isolation, while Ollama and all GPU inference are offloaded to the native Windows host and accessed by the containers over the WSL2 host bridge network (`OLLAMA_BASE_URL=http://<wsl2-host-ip>:11434`).
|
| 114 |
+
|
| 115 |
+
This architecture eliminates the virtualization overhead introduced by Docker Desktop's Linux VM layer. By giving the inference engine direct access to NVIDIA CUDA cores and the full system memory pool — rather than a virtualized slice — the 1.5B parameter model achieves response times measured in seconds rather than minutes. The separation also means that GPU memory pressure from Ollama does not compete with the container's allocated memory ceiling.
|
| 116 |
+
|
| 117 |
+
### Database Health and Startup Reliability
|
| 118 |
+
|
| 119 |
+
A production-grade startup sequence is enforced through Docker health checks and application-level retry logic. The agent application implements a ten-attempt retry loop with five-second intervals on initialization, including a `SELECT 1` connection verification before the schema is read. This ensures consistent behavior across restarts and eliminates the class of intermittent failures caused by the application reading an empty or partially initialized schema during database startup.
|
| 120 |
+
|
| 121 |
+
### MCP Tool Server Architecture
|
| 122 |
+
|
| 123 |
+
The Model Context Protocol server runs as a dedicated microservice on port 8701. Three categories of tools are registered at startup:
|
| 124 |
+
|
| 125 |
+
**document_scanner.process** handles Tesseract OCR and is called at image upload time, not at query time. It returns a doc_id, the extracted text, and triggers persistence of both the OCR output and the image path to the document store.
|
| 126 |
+
|
| 127 |
+
**granite_vision.qa** is the direct visual question-answering tool backed by Qwen 3.5 2B Vision via Ollama. It is called when a user submits a question with a doc_id and the original image file is available on disk. The internal tool identifier `granite_vision.qa` was retained from the original design specification; the underlying model was later replaced with Qwen 3.5 2B Vision for improved OCR accuracy and reduced VRAM usage, with no changes required to the agent orchestration code.
|
| 128 |
+
|
| 129 |
+
**fast_sql tools** are fourteen schema-grounded SQL generation tools used exclusively at Level 3 of the QueryCraftAgent. Each tool queries the live database schema via SQLAlchemy's inspector to retrieve actual column names, then uses a deterministic `_build_sql()` function to construct a valid query, optionally verified by a short LLM call. The result is returned as a schema-validated SQL hint to the QueryCraftAgent, which passes it to the LLM for final refinement. This design means Level 3 failures are caused by query complexity or intent ambiguity, never by hallucinated schema details.
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## Unified Multimodal Integration via MCP
|
| 134 |
+
|
| 135 |
+
A defining architectural feature of this system is that OCR and multimodal reasoning are integrated through the same Model Context Protocol used for database access. The Qwen 3.5 2B Vision model is registered as a named MCP tool: `granite_vision.qa`. This internal tool identifier was retained from the original design specification; the underlying engine was subsequently replaced with Qwen 3.5 2B Vision, which demonstrated superior OCR accuracy and a smaller VRAM footprint. Because the tool interface is decoupled from its implementation, this substitution required no changes to the agent orchestration code — only the MCP tool registration was updated.
|
| 136 |
+
|
| 137 |
+
This design delivers three concrete engineering properties:
|
| 138 |
+
|
| 139 |
+
**Uniform Protocol:** The orchestrator issues identical-format tool calls regardless of whether the data source is a SQL table or a visual document. There are no special-case code paths for different modalities.
|
| 140 |
+
|
| 141 |
+
**Modular Replaceability:** The vision model is encapsulated behind its MCP interface. Upgrading to a different vision model — such as a GPT-4V endpoint — requires updating only the tool registration, not any agent logic.
|
| 142 |
+
|
| 143 |
+
**Cross-Modal Reasoning Capability:** Because both data sources share the same protocol, the agent can compose multi-step reasoning across modalities. For example, the system can extract a value from a document via the vision tool and immediately verify it against a database record via the SQL tool, within a single agentic workflow.
|
| 144 |
+
|
| 145 |
+
```text
|
| 146 |
+
Multimodal Request Flow
|
| 147 |
+
|
| 148 |
+
Worker receives query with image path
|
| 149 |
+
|
|
| 150 |
+
+-- Image detected --> ocr_agent_qa()
|
| 151 |
+
|
|
| 152 |
+
+-- Unload Qwen 2.5 Coder (free VRAM)
|
| 153 |
+
|
|
| 154 |
+
+-- MCP tool call: granite_vision.qa
|
| 155 |
+
|
|
| 156 |
+
MCP Server (port 8701)
|
| 157 |
+
|
|
| 158 |
+
Qwen 3.5 2B Vision (Ollama)
|
| 159 |
+
|
|
| 160 |
+
Structured answer returned
|
| 161 |
+
|
|
| 162 |
+
+-- No image --> Standard Text-to-SQL pipeline (unchanged)
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## SQL Validation Pipeline
|
| 168 |
+
|
| 169 |
+
The semantic validation layer is one of the most technically substantive components of the system. Rather than trusting LLM output directly, every generated SQL query passes through a deterministic validation pipeline before execution. Key checks include:
|
| 170 |
+
|
| 171 |
+
- **Alias Integrity:** All table aliases used in SELECT, WHERE, JOIN, and GROUP BY clauses must be defined in FROM or JOIN declarations. The validator maintains a set of defined and used aliases and rejects queries with undefined references.
|
| 172 |
+
- **Salary Table Recognition:** The `salary` table name was explicitly excluded from the SQL keyword blocklist, as it is both a legitimate table name and a column name in the employee schema. Incorrect inclusion in the keyword set caused the validator to silently discard valid salary table joins.
|
| 173 |
+
- **Department Filter Enforcement:** Queries that reference a specific department by name are validated to confirm that a corresponding WHERE filter exists in the generated SQL. This prevents queries that return all departments when the user asked for one.
|
| 174 |
+
- **PostgreSQL GROUP BY Compliance:** Queries containing aggregate functions are checked to ensure all non-aggregated SELECT columns appear in the GROUP BY clause, enforcing strict PostgreSQL semantics.
|
| 175 |
+
- **Manager Schema Awareness:** Queries involving manager data are validated to confirm use of the `dept_manager` table rather than `dept_emp`. The validation produces a warning rather than a hard rejection, allowing the downstream semantic adjuster to regenerate the correct SQL before the query reaches the database.
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## Research Methodology: Fine-Tuning and Model Selection
|
| 180 |
+
|
| 181 |
+
As part of the project's scientific evaluation, a custom model fine-tuning pipeline was developed and benchmarked against the selected production model.
|
| 182 |
+
|
| 183 |
+
**LoRA Fine-Tuning:** The base Qwen 2.5 3B model was fine-tuned using Low-Rank Adaptation on a specialized Text-to-SQL dataset.
|
| 184 |
+
|
| 185 |
+
**Weight Merging:** The LoRA adapters were merged back into the base model weights via `merge_qwen_to_ollama.py` and quantized for local deployment via Ollama.
|
| 186 |
+
|
| 187 |
+
**Comparative Evaluation:** The fine-tuned general model was benchmarked against the zero-shot `qwen2.5-coder:1.5b`, which is pre-trained on a large corpus of code and SQL.
|
| 188 |
+
|
| 189 |
+
**Engineering Decision:** The benchmark results showed that the Coder model's zero-shot SQL accuracy exceeded that of the general LoRA fine-tune, with faster inference and lower error rates on complex multi-table joins. The production architecture was updated accordingly. This outcome demonstrates a data-driven engineering decision: fine-tuning is not always the optimal path when a domain-specialized pretrained model exists for the target task.
|
| 190 |
+
|
| 191 |
+
See `Fine_Tuning_Qwen_2_5_3B.ipynb` and the `merged_qwen2_5_3b_text2sql` directory for the complete fine-tuning methodology and benchmark results.
|
| 192 |
+
|
| 193 |
+
---
|
| 194 |
+
|
| 195 |
+
## Technology Stack
|
| 196 |
+
|
| 197 |
+
| Component | Technology |
|
| 198 |
+
|---|---|
|
| 199 |
+
| Core Framework | Python 3.11+, FastAPI, Uvicorn |
|
| 200 |
+
| AI Orchestration | LlamaIndex, Model Context Protocol (MCP) |
|
| 201 |
+
| Local Inference | Ollama (C++ optimized, memory-mapped) |
|
| 202 |
+
| Text-to-SQL Model | qwen2.5-coder:1.5b |
|
| 203 |
+
| Vision/OCR Model | qwen3.5:2b (registered as granite_vision.qa) |
|
| 204 |
+
| Database ORM | SQLAlchemy |
|
| 205 |
+
| Containerization | Docker, Docker Compose |
|
| 206 |
+
| Databases | PostgreSQL, MySQL |
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## Installation and Setup
|
| 211 |
+
|
| 212 |
+
### Prerequisites
|
| 213 |
+
|
| 214 |
+
- Ollama installed and running on the host machine
|
| 215 |
+
- Python 3.11 or higher
|
| 216 |
+
- Docker and Docker Compose
|
| 217 |
+
- A supported database (PostgreSQL or MySQL)
|
| 218 |
+
|
| 219 |
+
### 1. Pull Required Models
|
| 220 |
+
|
| 221 |
+
```bash
|
| 222 |
+
ollama pull qwen2.5-coder:1.5b
|
| 223 |
+
ollama pull qwen3.5:2b
|
| 224 |
+
```
|
| 225 |
+
|
| 226 |
+
### 2. Configure Environment
|
| 227 |
+
|
| 228 |
+
Install Python dependencies:
|
| 229 |
+
|
| 230 |
+
```bash
|
| 231 |
+
pip install -r requirements.txt
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
Create a `.env` file in the `Agent/` directory:
|
| 235 |
+
|
| 236 |
+
```env
|
| 237 |
+
DB_CONNECTION_URL=postgresql://user:password@localhost:5432/dbname
|
| 238 |
+
OLLAMA_MODEL_NAME=qwen2.5-coder:1.5b
|
| 239 |
+
GRANITE_MODEL_PATH=qwen3.5:2b
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
### 3. Run Without Docker
|
| 243 |
+
|
| 244 |
+
Start each microservice in a separate terminal:
|
| 245 |
+
|
| 246 |
+
```bash
|
| 247 |
+
# Terminal 1 — Backend Worker
|
| 248 |
+
python -u -m uvicorn "Agent.worker:app" --port 8700
|
| 249 |
+
|
| 250 |
+
# Terminal 2 — MCP Tool Server
|
| 251 |
+
python -m uvicorn "Agent.mcp_server:app" --port 8701
|
| 252 |
+
|
| 253 |
+
# Terminal 3 — Web UI
|
| 254 |
+
python -m uvicorn "web.app:app" --port 8000
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
Navigate to `http://localhost:8000` to interact with the system.
|
| 258 |
+
|
| 259 |
+
### 4. Run With Docker
|
| 260 |
+
|
| 261 |
+
Update `OLLAMA_BASE_URL` in `docker-compose.yml` to point to your host machine's Ollama instance, then run:
|
| 262 |
+
|
| 263 |
+
```bash
|
| 264 |
+
docker-compose up --build agent-app
|
| 265 |
+
```
|
| 266 |
+
|
| 267 |
+
The database containers include health checks and will be fully initialized before the application starts.
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
## Deployment on HuggingFace Spaces
|
| 272 |
+
|
| 273 |
+
This section documents the **complete deployment journey** — from the first proof-of-concept tunnel approach to the final production-ready hybrid architecture. Each phase is explained with commands and rationale so this can serve as a reference for deploying any multi-modal AI application on HuggingFace Spaces.
|
| 274 |
+
|
| 275 |
+
---
|
| 276 |
+
|
| 277 |
+
### Understanding HuggingFace Spaces Constraints
|
| 278 |
+
|
| 279 |
+
Before choosing a deployment strategy, it is essential to understand what the free tier of HuggingFace Spaces provides and restricts:
|
| 280 |
+
|
| 281 |
+
| Resource | Free Tier |
|
| 282 |
+
|----------|-----------|
|
| 283 |
+
| CPU | 2 vCPUs |
|
| 284 |
+
| RAM | 16 GB |
|
| 285 |
+
| GPU | None |
|
| 286 |
+
| Storage | Ephemeral (wiped on restart) |
|
| 287 |
+
| Public port | 7860 only |
|
| 288 |
+
| Timeout | Space sleeps after inactivity |
|
| 289 |
+
|
| 290 |
+
The key challenge: **running a local LLM (e.g. `qwen2.5-coder:1.5b`) via Ollama consumes ~1.5–3 GB RAM, leaving limited headroom for vision models and the application stack.** Running a vision-language model such as `qwen3.5:2b` (~3 GB) alongside the text model causes out-of-memory (OOM) failures on the free CPU tier.
|
| 291 |
+
|
| 292 |
+
---
|
| 293 |
+
|
| 294 |
+
### Phase 1 — Cloudflare Tunnel: Bridging Local Resources to the Cloud
|
| 295 |
+
|
| 296 |
+
#### Theory
|
| 297 |
+
|
| 298 |
+
The simplest approach to deploy a resource-intensive app on HF Spaces is to **not run the heavy models in the cloud at all**. Instead, you run the LLM and VLM locally on your development machine and expose the Ollama API endpoint to the internet via a secure tunnel. The HF Space container merely hosts the web frontend and proxies inference requests back to your local machine.
|
| 299 |
+
|
| 300 |
+
This approach has zero cloud compute cost and allows full GPU acceleration locally. However, it requires your development machine to remain **online and running at all times**, making it unsuitable for a persistent public demo.
|
| 301 |
+
|
| 302 |
+
#### Setup
|
| 303 |
+
|
| 304 |
+
**Step 1: Install Cloudflared on your local machine**
|
| 305 |
+
```bash
|
| 306 |
+
# Windows (via winget)
|
| 307 |
+
winget install Cloudflare.cloudflared
|
| 308 |
+
|
| 309 |
+
# Or download directly
|
| 310 |
+
# https://github.com/cloudflare/cloudflared/releases
|
| 311 |
+
```
|
| 312 |
+
|
| 313 |
+
**Step 2: Start Ollama locally**
|
| 314 |
+
```bash
|
| 315 |
+
ollama serve
|
| 316 |
+
# Ollama now listens on http://localhost:11434
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
**Step 3: Create a public tunnel to your local Ollama port**
|
| 320 |
+
```bash
|
| 321 |
+
cloudflared tunnel --url http://localhost:11434
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
Cloudflared will print a public HTTPS URL like:
|
| 325 |
+
```
|
| 326 |
+
https://your-random-id.trycloudflare.com
|
| 327 |
+
```
|
| 328 |
+
|
| 329 |
+
**Step 4: Set the tunnel URL as a secret in your HF Space**
|
| 330 |
+
|
| 331 |
+
In the HF Space settings under **Variables and Secrets**, add:
|
| 332 |
+
```
|
| 333 |
+
OLLAMA_BASE_URL = https://your-random-id.trycloudflare.com
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
The application code reads `OLLAMA_BASE_URL` and routes all inference calls through the tunnel to your local GPU.
|
| 337 |
+
|
| 338 |
+
#### Limitation
|
| 339 |
+
|
| 340 |
+
- The tunnel URL changes every time `cloudflared` restarts, requiring a secret update.
|
| 341 |
+
- Requires a continuously running local machine.
|
| 342 |
+
- Not viable for a shared capstone demo or public deployment.
|
| 343 |
+
|
| 344 |
+
---
|
| 345 |
+
|
| 346 |
+
### Phase 2 — Self-Contained Docker: Running Ollama Inside the Container
|
| 347 |
+
|
| 348 |
+
#### Theory
|
| 349 |
+
|
| 350 |
+
The next evolution is to **embed Ollama directly into the Docker image** and pull models at container startup. This makes the deployment fully self-contained — no external machine is needed. The HF Space container becomes a complete inference server.
|
| 351 |
+
|
| 352 |
+
The challenge is that HF Spaces rebuilds the Docker image on every push but does **not cache downloaded model weights** between restarts (storage is ephemeral). This means every **Factory Reboot** triggers a fresh ~1–3 GB model download, causing a cold-start delay of 2–5 minutes.
|
| 353 |
+
|
| 354 |
+
To prevent HF's health monitor from timing out the Space during this download, model pulling is performed **asynchronously in the background**, allowing the web UI to become responsive immediately while the model fetches in parallel.
|
| 355 |
+
|
| 356 |
+
#### Dockerfile Changes
|
| 357 |
+
|
| 358 |
+
Add Ollama installation to the `Dockerfile`:
|
| 359 |
+
|
| 360 |
+
```dockerfile
|
| 361 |
+
# Install Ollama binary (pinned to a stable release)
|
| 362 |
+
RUN curl -L https://github.com/ollama/ollama/releases/download/v0.5.11/ollama-linux-amd64.tgz \
|
| 363 |
+
-o ollama.tgz && \
|
| 364 |
+
tar -C /usr -xzf ollama.tgz && \
|
| 365 |
+
rm ollama.tgz
|
| 366 |
+
```
|
| 367 |
+
|
| 368 |
+
#### Startup Script (`start_hf.sh`)
|
| 369 |
+
|
| 370 |
+
```bash
|
| 371 |
+
#!/bin/bash
|
| 372 |
+
export HOME=/root
|
| 373 |
+
|
| 374 |
+
echo "[HF-INIT] Starting Ollama server..."
|
| 375 |
+
ollama serve &
|
| 376 |
+
OLLAMA_PID=$!
|
| 377 |
+
|
| 378 |
+
# Wait for Ollama to become healthy (up to 20 attempts)
|
| 379 |
+
for i in $(seq 1 20); do
|
| 380 |
+
if curl -sf http://localhost:11434/api/tags > /dev/null 2>&1; then
|
| 381 |
+
echo "[HF-INIT] Ollama is ALIVE!"
|
| 382 |
+
break
|
| 383 |
+
fi
|
| 384 |
+
echo "[HF-INIT] Waiting... ($i/20)"
|
| 385 |
+
sleep 3
|
| 386 |
+
done
|
| 387 |
+
|
| 388 |
+
# Pull the text model asynchronously (non-blocking)
|
| 389 |
+
echo "[HF-INIT] Pulling qwen2.5-coder:1.5b in background..."
|
| 390 |
+
ollama pull qwen2.5-coder:1.5b &
|
| 391 |
+
|
| 392 |
+
# Start application services immediately (do not wait for model pull)
|
| 393 |
+
echo "[HF-INIT] Starting Backend Worker..."
|
| 394 |
+
python -m uvicorn Agent.worker:app --host 0.0.0.0 --port 8700 &
|
| 395 |
+
|
| 396 |
+
echo "[HF-INIT] Starting MCP Tool Server..."
|
| 397 |
+
python -m uvicorn Agent.mcp_server:app --host 0.0.0.0 --port 8701 &
|
| 398 |
+
|
| 399 |
+
echo "[HF-INIT] Starting Web UI on port 7860..."
|
| 400 |
+
exec python -m uvicorn web.app:app --host 0.0.0.0 --port 7860
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
The `&` (ampersand) operator sends the model pull to the background. The `exec` on the last line replaces the shell process with the web server, ensuring HF's process monitor tracks the correct PID.
|
| 404 |
+
|
| 405 |
+
#### Limitation
|
| 406 |
+
|
| 407 |
+
- **Vision model OOM**: Adding `qwen3.5:2b` alongside `qwen2.5-coder:1.5b` exceeds the 16 GB RAM limit when both are loaded simultaneously.
|
| 408 |
+
- Model weights are re-downloaded on every Factory Reboot.
|
| 409 |
+
- Ollama version pinning is critical — newer models require newer Ollama binaries and pulling the wrong combination causes `412` errors.
|
| 410 |
+
|
| 411 |
+
---
|
| 412 |
+
|
| 413 |
+
### Phase 3 — Hybrid Serverless API (Final Production Architecture)
|
| 414 |
+
|
| 415 |
+
#### Theory
|
| 416 |
+
|
| 417 |
+
The final and most robust architecture eliminates the in-container LLM entirely for text generation and uses **external serverless inference APIs** instead. The container only runs lightweight services (web UI, API proxy, Tesseract OCR), while all neural inference is delegated to dedicated providers:
|
| 418 |
+
|
| 419 |
+
| Task | Provider | Model | Cost |
|
| 420 |
+
|------|----------|-------|------|
|
| 421 |
+
| Text-to-SQL | HuggingFace Inference API | `Qwen/Qwen2.5-Coder-7B-Instruct` | Free tier |
|
| 422 |
+
| Vision Q&A | Groq API | `meta-llama/llama-4-scout-17b-16e-instruct` | Free (14,400 req/day) |
|
| 423 |
+
| OCR | Tesseract (in container) | — | Free |
|
| 424 |
+
| Database | Neon PostgreSQL | — | Free tier |
|
| 425 |
+
|
| 426 |
+
This eliminates OOM risk entirely, removes the model download cold-start, and makes the container startup under 30 seconds.
|
| 427 |
+
|
| 428 |
+
#### Why Groq for Vision Instead of HuggingFace Serverless?
|
| 429 |
+
|
| 430 |
+
HuggingFace's serverless inference for vision-language models has two limitations on the free tier:
|
| 431 |
+
1. Most VLM models are not routed through any enabled provider for free-tier accounts.
|
| 432 |
+
2. Google Gemini's free tier blocks requests originating from cloud datacenter IP ranges (such as HF's infrastructure), setting the effective quota to zero even for valid free-tier API keys.
|
| 433 |
+
|
| 434 |
+
**Groq** is purpose-built for API-first server deployments and explicitly allows free-tier requests from cloud IPs. It provides 14,400 free requests per day with no IP restrictions.
|
| 435 |
+
|
| 436 |
+
#### Required HuggingFace Space Secrets
|
| 437 |
+
|
| 438 |
+
Set these in **Space Settings → Variables and Secrets**:
|
| 439 |
+
|
| 440 |
+
```
|
| 441 |
+
USE_HF_CLOUD = 1
|
| 442 |
+
HF_API_TOKEN = # HuggingFace access token
|
| 443 |
+
GROQ_API_KEY = # From console.groq.com (free)
|
| 444 |
+
DB_CONNECTION_URL = # Neon PostgreSQL connection string
|
| 445 |
+
```
|
| 446 |
+
|
| 447 |
+
#### Obtaining API Keys
|
| 448 |
+
|
| 449 |
+
**HuggingFace Token:**
|
| 450 |
+
1. Go to https://huggingface.co/settings/tokens
|
| 451 |
+
2. Create a token with `read` scope (or `write` if you need to push models)
|
| 452 |
+
|
| 453 |
+
**Groq API Key:**
|
| 454 |
+
1. Go to https://console.groq.com
|
| 455 |
+
2. Sign in with Google / GitHub
|
| 456 |
+
3. Navigate to **API Keys** → **Create API Key**
|
| 457 |
+
4. Copy the key
|
| 458 |
+
|
| 459 |
+
**Neon PostgreSQL:**
|
| 460 |
+
1. Go to https://neon.tech
|
| 461 |
+
2. Create a free project
|
| 462 |
+
3. Copy the connection string from the dashboard
|
| 463 |
+
|
| 464 |
+
#### Slim Dockerfile (No Ollama Required)
|
| 465 |
+
|
| 466 |
+
```dockerfile
|
| 467 |
+
FROM python:3.11-slim
|
| 468 |
+
|
| 469 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 470 |
+
ENV PYTHONUNBUFFERED=1
|
| 471 |
+
ENV USE_HF_CLOUD=1
|
| 472 |
+
|
| 473 |
+
RUN apt-get update && apt-get install -y \
|
| 474 |
+
curl \
|
| 475 |
+
tesseract-ocr \
|
| 476 |
+
libtesseract-dev \
|
| 477 |
+
gcc \
|
| 478 |
+
libpq-dev \
|
| 479 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 480 |
+
|
| 481 |
+
WORKDIR /app
|
| 482 |
+
COPY requirements.txt .
|
| 483 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 484 |
+
|
| 485 |
+
COPY . .
|
| 486 |
+
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
| 487 |
+
USER appuser
|
| 488 |
+
|
| 489 |
+
CMD ["bash", "start_hf.sh"]
|
| 490 |
+
```
|
| 491 |
+
|
| 492 |
+
#### Key `requirements.txt` Additions
|
| 493 |
+
|
| 494 |
+
```txt
|
| 495 |
+
huggingface_hub # HF Inference API client
|
| 496 |
+
groq>=0.9.0 # Groq Vision API client
|
| 497 |
+
google-genai>=1.0.0 # Optional: Google Gemini (requires billing for cloud IPs)
|
| 498 |
+
pytesseract>=0.3.10 # Local OCR fallback
|
| 499 |
+
pillow>=10.0.0
|
| 500 |
+
```
|
| 501 |
+
|
| 502 |
+
#### Deployment Steps
|
| 503 |
+
|
| 504 |
+
1. **Create a HuggingFace Space** at https://huggingface.co/spaces
|
| 505 |
+
- SDK: **Docker**
|
| 506 |
+
- Visibility: Public or Private
|
| 507 |
+
|
| 508 |
+
2. **Push your repository** to the Space's git remote:
|
| 509 |
+
```bash
|
| 510 |
+
git remote add space https://huggingface.co/spaces/<your-username>/<space-name>
|
| 511 |
+
git push space main
|
| 512 |
+
```
|
| 513 |
+
|
| 514 |
+
3. **Add secrets** in Space Settings (see table above). Do **not** hardcode secrets in the Dockerfile or any committed file.
|
| 515 |
+
|
| 516 |
+
4. **Factory Reboot** the Space after adding secrets to trigger a clean rebuild with the new environment variables.
|
| 517 |
+
|
| 518 |
+
5. **Monitor the build logs** — a successful startup shows:
|
| 519 |
+
```
|
| 520 |
+
[GRANITE_VQA] Target Model: meta-llama/llama-4-scout-17b-16e-instruct (via Groq Vision)
|
| 521 |
+
[INFO] CLOUD MODE: Initializing Official HF InferenceClient (Qwen/Qwen2.5-Coder-7B-Instruct)
|
| 522 |
+
INFO: Uvicorn running on http://0.0.0.0:7860
|
| 523 |
+
```
|
| 524 |
+
|
| 525 |
+
#### Important Notes on Line Endings
|
| 526 |
+
|
| 527 |
+
HuggingFace containers run Linux. If you develop on Windows, your shell scripts will have `\r\n` (CRLF) line endings, which cause `bad interpreter` errors on Linux. Add this to your `Dockerfile` to strip them automatically:
|
| 528 |
+
|
| 529 |
+
```dockerfile
|
| 530 |
+
RUN sed -i 's/\r$//' start_hf.sh && chmod +x start_hf.sh
|
| 531 |
+
```
|
| 532 |
+
|
| 533 |
+
Alternatively, configure Git to normalize line endings:
|
| 534 |
+
```bash
|
| 535 |
+
git config core.autocrlf input
|
| 536 |
+
```
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
---
|
| 540 |
+
|
| 541 |
+
## Project Summary
|
| 542 |
+
|
| 543 |
+
This project demonstrates that a self-correcting, multi-modal AI pipeline can be built and deployed entirely on consumer hardware through careful architectural decisions: progressive SQL generation with semantic validation, unified MCP-based tool access across data modalities, dynamic VRAM orchestration to prevent memory exhaustion, and a hybrid containerization strategy that preserves native GPU performance while maintaining deployment portability.
|
| 544 |
+
|
| 545 |
+
The system was developed iteratively, with each architectural decision — from model selection to validation logic to infrastructure design — informed by observed failure modes and measured outcomes rather than theoretical assumptions.
|
| 546 |
+
|
| 547 |
+
---
|
| 548 |
+
*Built with ❤️ for advanced Agentic Data Engineering.*
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
# Main Agentic Application
|
| 5 |
+
agent-app:
|
| 6 |
+
build: .
|
| 7 |
+
container_name: text2sql-agent-app
|
| 8 |
+
restart: unless-stopped
|
| 9 |
+
env_file: Agent/.env
|
| 10 |
+
environment:
|
| 11 |
+
- OLLAMA_BASE_URL=http://172.27.224.1:11434
|
| 12 |
+
- LLM_CTX=4096
|
| 13 |
+
deploy:
|
| 14 |
+
resources:
|
| 15 |
+
limits:
|
| 16 |
+
cpus: '2.0'
|
| 17 |
+
memory: 4G
|
| 18 |
+
ports:
|
| 19 |
+
- "8000:8000"
|
| 20 |
+
- "8700:8700"
|
| 21 |
+
- "8701:8701"
|
| 22 |
+
depends_on:
|
| 23 |
+
employee-postgres:
|
| 24 |
+
condition: service_healthy
|
| 25 |
+
employee-mysql:
|
| 26 |
+
condition: service_healthy
|
| 27 |
+
volumes:
|
| 28 |
+
- ./Agent:/app/Agent
|
| 29 |
+
- ./web:/app/web
|
| 30 |
+
networks:
|
| 31 |
+
- employee-net
|
| 32 |
+
|
| 33 |
+
# MySQL Database
|
| 34 |
+
employee-mysql:
|
| 35 |
+
image: mysql:8.0
|
| 36 |
+
container_name: employee-mysql
|
| 37 |
+
restart: unless-stopped
|
| 38 |
+
env_file: Agent/.env
|
| 39 |
+
ports:
|
| 40 |
+
- "${MYSQL_PORT:-3306}:3306"
|
| 41 |
+
volumes:
|
| 42 |
+
- ./employee-sample-database/mysql/dataset_small:/docker-entrypoint-initdb.d
|
| 43 |
+
- mysql-data:/var/lib/mysql
|
| 44 |
+
networks:
|
| 45 |
+
- employee-net
|
| 46 |
+
healthcheck:
|
| 47 |
+
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "--silent"]
|
| 48 |
+
interval: 5s
|
| 49 |
+
timeout: 5s
|
| 50 |
+
retries: 12
|
| 51 |
+
start_period: 30s
|
| 52 |
+
|
| 53 |
+
# PostgreSQL Database
|
| 54 |
+
employee-postgres:
|
| 55 |
+
image: postgres:15
|
| 56 |
+
container_name: employee-postgres
|
| 57 |
+
restart: unless-stopped
|
| 58 |
+
env_file: Agent/.env
|
| 59 |
+
ports:
|
| 60 |
+
- "${POSTGRES_PORT:-5432}:5432"
|
| 61 |
+
volumes:
|
| 62 |
+
- ./employee-sample-database/postgres/dataset_small:/docker-entrypoint-initdb.d
|
| 63 |
+
- postgres-data:/var/lib/postgresql/data
|
| 64 |
+
networks:
|
| 65 |
+
- employee-net
|
| 66 |
+
healthcheck:
|
| 67 |
+
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-postgres}"]
|
| 68 |
+
interval: 5s
|
| 69 |
+
timeout: 5s
|
| 70 |
+
retries: 12
|
| 71 |
+
start_period: 30s
|
| 72 |
+
|
| 73 |
+
volumes:
|
| 74 |
+
mysql-data:
|
| 75 |
+
postgres-data:
|
| 76 |
+
|
| 77 |
+
networks:
|
| 78 |
+
employee-net:
|
| 79 |
+
driver: bridge
|
requirements.txt
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Core Frameworks
|
| 2 |
+
fastapi>=0.100.0
|
| 3 |
+
uvicorn>=0.23.0
|
| 4 |
+
sqlalchemy>=2.0.0
|
| 5 |
+
pydantic>=2.0.0
|
| 6 |
+
python-multipart>=0.0.6
|
| 7 |
+
pandas>=2.0.0
|
| 8 |
+
|
| 9 |
+
# Multi-Agent Orchestration
|
| 10 |
+
llama-index>=0.10.0
|
| 11 |
+
llama-index-core>=0.10.0
|
| 12 |
+
llama-index-llms-ollama>=0.1.2
|
| 13 |
+
llama-index-llms-huggingface-api
|
| 14 |
+
huggingface_hub
|
| 15 |
+
|
| 16 |
+
# Model Context Protocol
|
| 17 |
+
mcp>=0.1.0
|
| 18 |
+
sqlparse>=0.4.4
|
| 19 |
+
|
| 20 |
+
# HTTP & Async
|
| 21 |
+
requests>=2.31.0
|
| 22 |
+
aiohttp>=3.9.0
|
| 23 |
+
psutil>=5.9.0
|
| 24 |
+
|
| 25 |
+
# Database Drivers
|
| 26 |
+
pymysql>=1.1.0
|
| 27 |
+
asyncpg>=0.29.0
|
| 28 |
+
psycopg2-binary>=2.9.0
|
| 29 |
+
aiomysql>=0.2.0
|
| 30 |
+
|
| 31 |
+
# Document Vision processing
|
| 32 |
+
pillow>=10.0.0
|
| 33 |
+
pytesseract>=0.3.10
|
| 34 |
+
google-genai>=1.0.0
|
| 35 |
+
groq>=0.9.0
|
| 36 |
+
|
| 37 |
+
# Environment
|
| 38 |
+
python-dotenv>=1.0.0
|
start_hf.sh
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
|
| 3 |
+
# Cloud-Optimized Startup Script for Hugging Face Spaces
|
| 4 |
+
# Model is pre-baked into the image — no runtime pull needed.
|
| 5 |
+
|
| 6 |
+
# 0. Start Ollama server
|
| 7 |
+
echo "[HF-INIT] Starting Ollama server..."
|
| 8 |
+
export HOME=/app
|
| 9 |
+
export OLLAMA_HOST=0.0.0.0:11434
|
| 10 |
+
export OLLAMA_MODELS=/app/ollama_models
|
| 11 |
+
|
| 12 |
+
ollama serve > /app/ollama.log 2>&1 &
|
| 13 |
+
|
| 14 |
+
# Wait for Ollama to be ready
|
| 15 |
+
echo "[HF-INIT] Waiting for Ollama to start..."
|
| 16 |
+
for i in {1..20}; do
|
| 17 |
+
if curl -s http://localhost:11434/api/tags > /dev/null 2>&1; then
|
| 18 |
+
echo "[HF-INIT] Ollama is ready."
|
| 19 |
+
break
|
| 20 |
+
fi
|
| 21 |
+
if [ $i -eq 10 ]; then
|
| 22 |
+
echo "[HF-INIT] Still waiting — Ollama log:"
|
| 23 |
+
cat /app/ollama.log
|
| 24 |
+
fi
|
| 25 |
+
echo "[HF-INIT] Waiting... ($i/20)"
|
| 26 |
+
sleep 2
|
| 27 |
+
done
|
| 28 |
+
|
| 29 |
+
# Verify the pre-baked model is present before starting the app.
|
| 30 |
+
# If it is missing for any reason, pull it now (blocking, so app waits).
|
| 31 |
+
echo "[HF-INIT] Verifying qwen3.5:2b is available..."
|
| 32 |
+
if ollama list 2>/dev/null | grep -q "qwen3.5:2b"; then
|
| 33 |
+
echo "[HF-INIT] qwen3.5:2b confirmed present."
|
| 34 |
+
else
|
| 35 |
+
echo "[HF-INIT] WARNING: qwen3.5:2b not found in pre-baked layers. Pulling now..."
|
| 36 |
+
ollama pull qwen3.5:2b
|
| 37 |
+
if [ $? -eq 0 ]; then
|
| 38 |
+
echo "[HF-INIT] Pull succeeded."
|
| 39 |
+
else
|
| 40 |
+
echo "[HF-INIT] ERROR: Pull failed. Vision Q&A will not work."
|
| 41 |
+
fi
|
| 42 |
+
fi
|
| 43 |
+
|
| 44 |
+
# 1. Start Backend Worker
|
| 45 |
+
echo "[HF-INIT] Starting Backend Worker on port 8700..."
|
| 46 |
+
python -u -m uvicorn "Agent.worker:app" --host 0.0.0.0 --port 8700 &
|
| 47 |
+
|
| 48 |
+
# 2. Start MCP Tool Server
|
| 49 |
+
echo "[HF-INIT] Starting MCP Tool Server on port 8701..."
|
| 50 |
+
python -m uvicorn "Agent.mcp_server:app" --host 0.0.0.0 --port 8701 &
|
| 51 |
+
|
| 52 |
+
# 3. Start Web UI
|
| 53 |
+
echo "[HF-INIT] Starting Web UI on port 7860..."
|
| 54 |
+
python -m uvicorn "web.app:app" --host 0.0.0.0 --port 7860 &
|
| 55 |
+
|
| 56 |
+
# Keep the container alive
|
| 57 |
+
wait
|
web/app.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import httpx
|
| 3 |
+
import logging
|
| 4 |
+
import traceback
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from fastapi import FastAPI, HTTPException, Request
|
| 7 |
+
from pydantic import BaseModel
|
| 8 |
+
from fastapi.staticfiles import StaticFiles
|
| 9 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
|
| 12 |
+
WORKER_URL = os.environ.get("WORKER_URL", "http://localhost:8700")
|
| 13 |
+
# Timeout (seconds) when calling the worker; re-execute and heavy queries can take longer.
|
| 14 |
+
# Make configurable via environment variable WORKER_TIMEOUT (default 300s).
|
| 15 |
+
WORKER_TIMEOUT = float(os.environ.get("WORKER_TIMEOUT", "450")) # Increased to 450s to handle complex queries
|
| 16 |
+
|
| 17 |
+
# Reusable AsyncClient to avoid per-request connection setup overhead
|
| 18 |
+
client: httpx.AsyncClient | None = None
|
| 19 |
+
|
| 20 |
+
app = FastAPI(title="Text2SQL Web API")
|
| 21 |
+
app.add_middleware(
|
| 22 |
+
CORSMiddleware,
|
| 23 |
+
allow_origins=["*"], # adjust in production
|
| 24 |
+
allow_credentials=True,
|
| 25 |
+
allow_methods=["*"],
|
| 26 |
+
allow_headers=["*"],
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
# Serve static frontend under /static and provide a root route
|
| 30 |
+
static_dir = os.path.join(os.path.dirname(__file__), 'static')
|
| 31 |
+
if os.path.isdir(static_dir):
|
| 32 |
+
app.mount("/static", StaticFiles(directory=static_dir, html=True), name="static")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.get("/")
|
| 36 |
+
async def root_index():
|
| 37 |
+
index_path = os.path.join(static_dir, 'index.html')
|
| 38 |
+
return FileResponse(index_path)
|
| 39 |
+
|
| 40 |
+
class QueryReq(BaseModel):
|
| 41 |
+
question: str
|
| 42 |
+
# Optional doc_id produced by a prior /upload-image call
|
| 43 |
+
doc_id: Optional[str] = None
|
| 44 |
+
# Explicit mode hint from client: 'text2sql' or 'ocr_qa'. When omitted, worker may auto-detect.
|
| 45 |
+
mode: Optional[str] = None
|
| 46 |
+
|
| 47 |
+
class OcrQAReq(BaseModel):
|
| 48 |
+
question: str
|
| 49 |
+
doc_id: str
|
| 50 |
+
|
| 51 |
+
class OcrQAResp(BaseModel):
|
| 52 |
+
doc_id: str
|
| 53 |
+
answer: str
|
| 54 |
+
used_llm: bool
|
| 55 |
+
|
| 56 |
+
@app.get('/health')
|
| 57 |
+
async def health():
|
| 58 |
+
return {"status": "ok"}
|
| 59 |
+
|
| 60 |
+
@app.post('/api/query')
|
| 61 |
+
async def api_query(req: QueryReq):
|
| 62 |
+
# Use a shared AsyncClient created at startup to avoid TCP/TLS/HTTP handshakes per request.
|
| 63 |
+
global client
|
| 64 |
+
if client is None:
|
| 65 |
+
# fallback to create a temporary client (shouldn't happen if startup ran)
|
| 66 |
+
client = httpx.AsyncClient(timeout=WORKER_TIMEOUT)
|
| 67 |
+
try:
|
| 68 |
+
payload = {"question": req.question}
|
| 69 |
+
# Forward explicit mode if provided so the worker doesn't infer from lingering doc_id
|
| 70 |
+
if getattr(req, 'mode', None):
|
| 71 |
+
payload['mode'] = req.mode
|
| 72 |
+
if getattr(req, 'doc_id', None):
|
| 73 |
+
payload['doc_id'] = req.doc_id
|
| 74 |
+
r = await client.post(f"{WORKER_URL}/query", json=payload)
|
| 75 |
+
r.raise_for_status()
|
| 76 |
+
return r.json()
|
| 77 |
+
except httpx.HTTPStatusError as e:
|
| 78 |
+
# Log the full traceback and response body for debugging
|
| 79 |
+
logging.error("Worker returned non-2xx status: %s", e.response.status_code)
|
| 80 |
+
logging.error("Response text: %s", getattr(e.response, 'text', str(e)))
|
| 81 |
+
logging.error(traceback.format_exc())
|
| 82 |
+
raise HTTPException(status_code=e.response.status_code, detail=getattr(e.response, 'text', str(e)))
|
| 83 |
+
except httpx.ReadTimeout as e:
|
| 84 |
+
# Worker took too long. Return a 504 gateway timeout with a helpful message.
|
| 85 |
+
logging.error("Timeout contacting worker after %ss: %s", WORKER_TIMEOUT, str(e))
|
| 86 |
+
logging.error(traceback.format_exc())
|
| 87 |
+
return JSONResponse(status_code=504, content={
|
| 88 |
+
"error": "worker_timeout",
|
| 89 |
+
"message": f"Worker did not respond within {WORKER_TIMEOUT} seconds. The query may be long-running (re-execute) or the worker is busy. Try increasing WORKER_TIMEOUT or running the worker with more resources.",
|
| 90 |
+
})
|
| 91 |
+
except Exception as e:
|
| 92 |
+
logging.error("Error contacting worker: %s", str(e))
|
| 93 |
+
logging.error(traceback.format_exc())
|
| 94 |
+
# Return a JSON response containing the error detail to help debugging from the client
|
| 95 |
+
return JSONResponse(status_code=500, content={"error": str(e), "trace": traceback.format_exc()})
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@app.post('/api/ocr-qa')
|
| 99 |
+
async def api_ocr_qa(req: OcrQAReq):
|
| 100 |
+
global client
|
| 101 |
+
if client is None:
|
| 102 |
+
client = httpx.AsyncClient(timeout=WORKER_TIMEOUT)
|
| 103 |
+
try:
|
| 104 |
+
payload = {"question": req.question, "doc_id": req.doc_id}
|
| 105 |
+
r = await client.post(f"{WORKER_URL}/ocr-qa", json=payload)
|
| 106 |
+
r.raise_for_status()
|
| 107 |
+
return r.json()
|
| 108 |
+
except httpx.HTTPStatusError as e:
|
| 109 |
+
logging.error("Worker OCR QA non-2xx: %s", e.response.status_code)
|
| 110 |
+
logging.error("Response text: %s", getattr(e.response, 'text', str(e)))
|
| 111 |
+
return JSONResponse(status_code=e.response.status_code, content={"error": getattr(e.response, 'text', str(e))})
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logging.error("Error contacting worker OCR QA: %s", str(e))
|
| 114 |
+
return JSONResponse(status_code=500, content={"error": str(e), "trace": traceback.format_exc()})
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@app.on_event('startup')
|
| 118 |
+
async def startup_event():
|
| 119 |
+
global client
|
| 120 |
+
if client is None:
|
| 121 |
+
client = httpx.AsyncClient(timeout=WORKER_TIMEOUT)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
@app.on_event('shutdown')
|
| 125 |
+
async def shutdown_event():
|
| 126 |
+
global client
|
| 127 |
+
if client is not None:
|
| 128 |
+
await client.aclose()
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@app.post('/api/test')
|
| 132 |
+
async def api_test(req: QueryReq):
|
| 133 |
+
# Simple echo endpoint to verify POST handling without contacting the worker
|
| 134 |
+
return {"ok": True, "echo": req.question}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@app.post('/upload-image')
|
| 138 |
+
async def upload_image(file: bytes = None, request: Request = None):
|
| 139 |
+
"""Proxy an image upload to the worker's /upload-image endpoint.
|
| 140 |
+
|
| 141 |
+
Accepts multipart/form-data file field 'file' from the browser.
|
| 142 |
+
Returns the worker JSON (doc_id, text_preview, image_url) on success.
|
| 143 |
+
"""
|
| 144 |
+
# Use the request.form() to access the uploaded file
|
| 145 |
+
try:
|
| 146 |
+
form = await request.form()
|
| 147 |
+
f = form.get('file')
|
| 148 |
+
if f is None:
|
| 149 |
+
raise HTTPException(status_code=400, detail='file field is required')
|
| 150 |
+
# forward the file to the worker
|
| 151 |
+
files = {'file': (f.filename, await f.read(), f.content_type)}
|
| 152 |
+
async with httpx.AsyncClient(timeout=120) as c:
|
| 153 |
+
r = await c.post(f"{WORKER_URL}/upload-image", files=files)
|
| 154 |
+
r.raise_for_status()
|
| 155 |
+
return JSONResponse(status_code=200, content=r.json())
|
| 156 |
+
except HTTPException:
|
| 157 |
+
raise
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logging.error('upload-image proxy failed: %s', str(e))
|
| 160 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.get('/api/test')
|
| 164 |
+
async def api_test_get():
|
| 165 |
+
return {"ok": True, "msg": "POST to /api/test with JSON {question: ...} to echo"}
|
| 166 |
+
|
| 167 |
+
if __name__ == '__main__':
|
| 168 |
+
import uvicorn
|
| 169 |
+
port = int(os.environ.get('WEB_PORT', 8000))
|
| 170 |
+
uvicorn.run(app, host='0.0.0.0', port=port)
|
web/static/app.js
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const askBtn = document.getElementById('ask');
|
| 2 |
+
const questionEl = document.getElementById('question');
|
| 3 |
+
const answerEl = document.getElementById('answer');
|
| 4 |
+
const sqlEl = document.getElementById('sql');
|
| 5 |
+
const memoryEl = document.getElementById('memory');
|
| 6 |
+
const querycraftEl = document.getElementById('querycraft');
|
| 7 |
+
const presenterEl = document.getElementById('presenter');
|
| 8 |
+
const uploadInput = document.getElementById('upload');
|
| 9 |
+
const uploadBtn = document.getElementById('uploadBtn');
|
| 10 |
+
const uploadStatus = document.getElementById('uploadStatus');
|
| 11 |
+
const uploadControls = document.getElementById('uploadControls');
|
| 12 |
+
const modeToggle = document.getElementById('modeToggle');
|
| 13 |
+
let currentDocId = null;
|
| 14 |
+
let currentMode = 'text2sql';
|
| 15 |
+
const statusEl = document.getElementById('status');
|
| 16 |
+
const timeEl = document.getElementById('time');
|
| 17 |
+
const recalcBtn = document.getElementById('recalc');
|
| 18 |
+
const spinner = document.getElementById('spinner');
|
| 19 |
+
const spinnerText = document.getElementById('spinnerText');
|
| 20 |
+
const askBtnEl = askBtn;
|
| 21 |
+
|
| 22 |
+
// Mode toggle logic
|
| 23 |
+
modeToggle.addEventListener('change', function() {
|
| 24 |
+
currentMode = modeToggle.value;
|
| 25 |
+
console.log('[DEBUG] Mode changed to:', currentMode); // Debug log
|
| 26 |
+
if(currentMode === 'ocr_qa') {
|
| 27 |
+
uploadControls.classList.remove('hidden');
|
| 28 |
+
console.log('[DEBUG] Upload controls shown'); // Debug log
|
| 29 |
+
} else {
|
| 30 |
+
uploadControls.classList.add('hidden');
|
| 31 |
+
currentDocId = null;
|
| 32 |
+
uploadStatus.textContent = 'No upload';
|
| 33 |
+
uploadStatus.className = 'status-text';
|
| 34 |
+
console.log('[DEBUG] Upload controls hidden'); // Debug log
|
| 35 |
+
}
|
| 36 |
+
});
|
| 37 |
+
|
| 38 |
+
// Initialize UI state
|
| 39 |
+
console.log('[DEBUG] Initial mode:', currentMode); // Debug log
|
| 40 |
+
if(currentMode === 'ocr_qa') {
|
| 41 |
+
uploadControls.classList.remove('hidden');
|
| 42 |
+
} else {
|
| 43 |
+
uploadControls.classList.add('hidden');
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Helper to programmatically switch to OCR Q&A mode
|
| 47 |
+
function switchToOcrQaMode() {
|
| 48 |
+
if (modeToggle.value !== 'ocr_qa') {
|
| 49 |
+
modeToggle.value = 'ocr_qa';
|
| 50 |
+
currentMode = 'ocr_qa';
|
| 51 |
+
uploadControls.classList.remove('hidden');
|
| 52 |
+
// Fire a change event so any listeners update state consistently
|
| 53 |
+
const ev = new Event('change', { bubbles: true });
|
| 54 |
+
modeToggle.dispatchEvent(ev);
|
| 55 |
+
console.log('[DEBUG] Switched to OCR Q&A mode');
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// Extract mode change logic into a function so it can be called directly
|
| 60 |
+
function onModeChange() {
|
| 61 |
+
currentMode = modeToggle.value;
|
| 62 |
+
console.log('[DEBUG] onModeChange called, mode:', currentMode); // Debug log
|
| 63 |
+
if(currentMode === 'ocr_qa') {
|
| 64 |
+
uploadControls.classList.remove('hidden');
|
| 65 |
+
console.log('[DEBUG] Upload controls shown via onModeChange'); // Debug log
|
| 66 |
+
} else {
|
| 67 |
+
uploadControls.classList.add('hidden');
|
| 68 |
+
currentDocId = null;
|
| 69 |
+
uploadStatus.textContent = 'No upload';
|
| 70 |
+
uploadStatus.className = 'status-text';
|
| 71 |
+
console.log('[DEBUG] Upload controls hidden via onModeChange'); // Debug log
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Mode toggle logic
|
| 76 |
+
modeToggle.addEventListener('change', onModeChange);
|
| 77 |
+
|
| 78 |
+
// Upload handler: create a temporary file input on demand to avoid issues with a permanently hidden input.
|
| 79 |
+
if (uploadBtn) {
|
| 80 |
+
uploadBtn.addEventListener('click', () => {
|
| 81 |
+
// If not in OCR Q&A mode, switch and highlight, then open file picker after a delay
|
| 82 |
+
if (modeToggle.value !== 'ocr_qa') {
|
| 83 |
+
switchToOcrQaMode();
|
| 84 |
+
// Visually highlight the dropdown to show the switch
|
| 85 |
+
modeToggle.style.boxShadow = '0 0 0 3px rgba(15, 98, 254, 0.2)';
|
| 86 |
+
modeToggle.style.transition = 'box-shadow 0.2s';
|
| 87 |
+
setTimeout(() => {
|
| 88 |
+
modeToggle.style.boxShadow = '';
|
| 89 |
+
// Now open the file picker
|
| 90 |
+
const tmp = document.createElement('input');
|
| 91 |
+
tmp.type = 'file';
|
| 92 |
+
tmp.accept = 'image/*';
|
| 93 |
+
tmp.style.position = 'absolute';
|
| 94 |
+
tmp.style.left = '-9999px';
|
| 95 |
+
document.body.appendChild(tmp);
|
| 96 |
+
|
| 97 |
+
tmp.addEventListener('change', async () => {
|
| 98 |
+
const files = tmp.files;
|
| 99 |
+
if (!files || files.length === 0) {
|
| 100 |
+
uploadStatus.textContent = 'No file selected';
|
| 101 |
+
document.body.removeChild(tmp);
|
| 102 |
+
return;
|
| 103 |
+
}
|
| 104 |
+
switchToOcrQaMode();
|
| 105 |
+
uploadStatus.textContent = 'Uploading...';
|
| 106 |
+
uploadStatus.className = 'status-text text-warning';
|
| 107 |
+
const fd = new FormData();
|
| 108 |
+
fd.append('file', files[0]);
|
| 109 |
+
try {
|
| 110 |
+
const r = await fetch('/upload-image', { method: 'POST', body: fd });
|
| 111 |
+
if (!r.ok) {
|
| 112 |
+
const j = await r.json().catch(()=>({}));
|
| 113 |
+
uploadStatus.textContent = 'Upload failed: ' + (j.detail || r.statusText);
|
| 114 |
+
uploadStatus.className = 'status-text text-error';
|
| 115 |
+
document.body.removeChild(tmp);
|
| 116 |
+
return;
|
| 117 |
+
}
|
| 118 |
+
const j = await r.json();
|
| 119 |
+
currentDocId = j.doc_id;
|
| 120 |
+
uploadStatus.textContent = `Uploaded (doc_id=${currentDocId})`;
|
| 121 |
+
uploadStatus.className = 'status-text text-success';
|
| 122 |
+
} catch (e) {
|
| 123 |
+
uploadStatus.textContent = 'Upload error: ' + String(e);
|
| 124 |
+
uploadStatus.className = 'status-text text-error';
|
| 125 |
+
}
|
| 126 |
+
document.body.removeChild(tmp);
|
| 127 |
+
});
|
| 128 |
+
tmp.click();
|
| 129 |
+
}, 350); // 350ms to allow UI to update and user to see the switch
|
| 130 |
+
return;
|
| 131 |
+
}
|
| 132 |
+
// Already in OCR Q&A mode: open file picker immediately
|
| 133 |
+
const tmp = document.createElement('input');
|
| 134 |
+
tmp.type = 'file';
|
| 135 |
+
tmp.accept = 'image/*';
|
| 136 |
+
tmp.style.position = 'absolute';
|
| 137 |
+
tmp.style.left = '-9999px';
|
| 138 |
+
document.body.appendChild(tmp);
|
| 139 |
+
|
| 140 |
+
tmp.addEventListener('change', async () => {
|
| 141 |
+
const files = tmp.files;
|
| 142 |
+
if (!files || files.length === 0) {
|
| 143 |
+
uploadStatus.textContent = 'No file selected';
|
| 144 |
+
uploadStatus.className = 'status-text';
|
| 145 |
+
document.body.removeChild(tmp);
|
| 146 |
+
return;
|
| 147 |
+
}
|
| 148 |
+
switchToOcrQaMode();
|
| 149 |
+
uploadStatus.textContent = 'Uploading...';
|
| 150 |
+
uploadStatus.className = 'status-text text-warning';
|
| 151 |
+
const fd = new FormData();
|
| 152 |
+
fd.append('file', files[0]);
|
| 153 |
+
try {
|
| 154 |
+
const r = await fetch('/upload-image', { method: 'POST', body: fd });
|
| 155 |
+
if (!r.ok) {
|
| 156 |
+
const j = await r.json().catch(()=>({}));
|
| 157 |
+
uploadStatus.textContent = 'Upload failed: ' + (j.detail || r.statusText);
|
| 158 |
+
uploadStatus.className = 'status-text text-error';
|
| 159 |
+
document.body.removeChild(tmp);
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
const j = await r.json();
|
| 163 |
+
currentDocId = j.doc_id;
|
| 164 |
+
uploadStatus.textContent = `Uploaded (doc_id=${currentDocId})`;
|
| 165 |
+
uploadStatus.className = 'status-text text-success';
|
| 166 |
+
} catch (e) {
|
| 167 |
+
uploadStatus.textContent = 'Upload error: ' + String(e);
|
| 168 |
+
uploadStatus.className = 'status-text text-error';
|
| 169 |
+
}
|
| 170 |
+
document.body.removeChild(tmp);
|
| 171 |
+
});
|
| 172 |
+
tmp.click();
|
| 173 |
+
});
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
function setRunning(running){
|
| 177 |
+
if(running){
|
| 178 |
+
askBtnEl.disabled = true;
|
| 179 |
+
recalcBtn.disabled = true;
|
| 180 |
+
questionEl.disabled = true;
|
| 181 |
+
spinner.classList.remove('hidden');
|
| 182 |
+
spinnerText.classList.remove('hidden');
|
| 183 |
+
spinnerText.textContent = 'Processing...';
|
| 184 |
+
// hide the small status text while spinner is active
|
| 185 |
+
statusEl.textContent = '';
|
| 186 |
+
console.log('[DEBUG] Loading state: ON, spinner should be visible'); // Debug log
|
| 187 |
+
} else {
|
| 188 |
+
askBtnEl.disabled = false;
|
| 189 |
+
recalcBtn.disabled = false;
|
| 190 |
+
questionEl.disabled = false;
|
| 191 |
+
spinner.classList.add('hidden');
|
| 192 |
+
spinnerText.classList.add('hidden');
|
| 193 |
+
// restore default status
|
| 194 |
+
statusEl.textContent = 'Ready';
|
| 195 |
+
console.log('[DEBUG] Loading state: OFF, spinner should be hidden'); // Debug log
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function renderPresenterTrace(text){
|
| 200 |
+
if(!text) return '—';
|
| 201 |
+
// Split traces into chunks separated by blank lines and render as collapsible
|
| 202 |
+
const parts = String(text).split(/\n\s*\n/).filter(Boolean);
|
| 203 |
+
const container = document.createElement('div');
|
| 204 |
+
parts.forEach((p, i) => {
|
| 205 |
+
const d = document.createElement('details');
|
| 206 |
+
const summary = document.createElement('summary');
|
| 207 |
+
summary.textContent = p.split('\n')[0].slice(0,80);
|
| 208 |
+
summary.style.cursor = 'pointer';
|
| 209 |
+
summary.style.padding = '0.5rem';
|
| 210 |
+
summary.style.borderRadius = '0.375rem';
|
| 211 |
+
summary.style.marginBottom = '0.5rem';
|
| 212 |
+
summary.style.backgroundColor = 'var(--bg-tertiary)';
|
| 213 |
+
const pre = document.createElement('pre');
|
| 214 |
+
pre.style.whiteSpace = 'pre-wrap';
|
| 215 |
+
pre.style.fontSize = '0.75rem';
|
| 216 |
+
pre.style.padding = '0.75rem';
|
| 217 |
+
pre.style.backgroundColor = 'var(--bg-secondary)';
|
| 218 |
+
pre.style.borderRadius = '0.375rem';
|
| 219 |
+
pre.style.margin = '0';
|
| 220 |
+
pre.textContent = p;
|
| 221 |
+
d.appendChild(summary);
|
| 222 |
+
d.appendChild(pre);
|
| 223 |
+
if(i===0) d.open = true;
|
| 224 |
+
container.appendChild(d);
|
| 225 |
+
});
|
| 226 |
+
return container;
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
// small helper to escape HTML
|
| 230 |
+
const esc = s => String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 231 |
+
|
| 232 |
+
// Detect if text contains a pipe/markdown table and return true
|
| 233 |
+
function isMarkdownTable(text){
|
| 234 |
+
if(!text) return false;
|
| 235 |
+
const lines = String(text).split(/\r?\n/).map(l=>l.trim()).filter(Boolean);
|
| 236 |
+
if(lines.length < 2) return false;
|
| 237 |
+
// Count lines that contain pipe separators
|
| 238 |
+
const pipeLines = lines.filter(l => l.includes('|'));
|
| 239 |
+
if(pipeLines.length < 2) return false;
|
| 240 |
+
// If the second line is a markdown separator like | --- | --- | or ---|---
|
| 241 |
+
const second = lines[1];
|
| 242 |
+
if(/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(second)) return true;
|
| 243 |
+
// Otherwise, if majority of non-empty lines contain pipes, treat as table
|
| 244 |
+
return (pipeLines.length / lines.length) >= 0.5;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// Parse a simple markdown/pipe table into a DOM <table>
|
| 248 |
+
function parseMarkdownTable(text){
|
| 249 |
+
const lines = String(text).split(/\r?\n/).map(l=>l.trim()).filter(Boolean);
|
| 250 |
+
// find the first contiguous block of pipe lines
|
| 251 |
+
let start = -1, end = -1;
|
| 252 |
+
for(let i=0;i<lines.length;i++){
|
| 253 |
+
if(lines[i].includes('|')){ start = i; break; }
|
| 254 |
+
}
|
| 255 |
+
if(start === -1) return null;
|
| 256 |
+
for(let i=start;i<lines.length;i++){
|
| 257 |
+
if(!lines[i].includes('|')){ end = i; break; }
|
| 258 |
+
}
|
| 259 |
+
if(end === -1) end = lines.length;
|
| 260 |
+
const block = lines.slice(start, end);
|
| 261 |
+
const rows = block.map(r => {
|
| 262 |
+
let s = r;
|
| 263 |
+
if(s.startsWith('|')) s = s.slice(1);
|
| 264 |
+
if(s.endsWith('|')) s = s.slice(0,-1);
|
| 265 |
+
return s.split('|').map(c => c.trim());
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
// If second row is separator, treat first row as header
|
| 269 |
+
let header = null;
|
| 270 |
+
if(rows.length >= 2 && rows[1].every(c => /^:?-{3,}:?$/.test(c))) {
|
| 271 |
+
header = rows[0];
|
| 272 |
+
rows.splice(0,2);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const table = document.createElement('table');
|
| 276 |
+
table.className = 'answer-table';
|
| 277 |
+
const thead = document.createElement('thead');
|
| 278 |
+
const tbody = document.createElement('tbody');
|
| 279 |
+
|
| 280 |
+
if(header){
|
| 281 |
+
const tr = document.createElement('tr');
|
| 282 |
+
header.forEach(h => { const th = document.createElement('th'); th.textContent = h || ''; tr.appendChild(th); });
|
| 283 |
+
thead.appendChild(tr);
|
| 284 |
+
} else if(rows.length>0){
|
| 285 |
+
// use first row as header if no separator present
|
| 286 |
+
const tr = document.createElement('tr');
|
| 287 |
+
rows[0].forEach((_,i) => { const th = document.createElement('th'); th.textContent = `col ${i+1}`; tr.appendChild(th); });
|
| 288 |
+
thead.appendChild(tr);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
rows.forEach(r => {
|
| 292 |
+
const tr = document.createElement('tr');
|
| 293 |
+
r.forEach(c => { const td = document.createElement('td'); td.textContent = c; tr.appendChild(td); });
|
| 294 |
+
tbody.appendChild(tr);
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
if(thead.childElementCount) table.appendChild(thead);
|
| 298 |
+
table.appendChild(tbody);
|
| 299 |
+
|
| 300 |
+
// Create enhanced table wrapper with controls
|
| 301 |
+
const wrapper = document.createElement('div');
|
| 302 |
+
wrapper.className = 'table-wrapper';
|
| 303 |
+
|
| 304 |
+
// Create table controls
|
| 305 |
+
const controls = document.createElement('div');
|
| 306 |
+
controls.className = 'table-controls';
|
| 307 |
+
|
| 308 |
+
const controlsLeft = document.createElement('div');
|
| 309 |
+
controlsLeft.className = 'table-controls-left';
|
| 310 |
+
|
| 311 |
+
const tableInfo = document.createElement('div');
|
| 312 |
+
tableInfo.className = 'table-info';
|
| 313 |
+
const rowCount = tbody.children.length;
|
| 314 |
+
const colCount = header ? header.length : (rows.length > 0 ? rows[0].length : 0);
|
| 315 |
+
tableInfo.textContent = `${rowCount} rows × ${colCount} columns`;
|
| 316 |
+
|
| 317 |
+
controlsLeft.appendChild(tableInfo);
|
| 318 |
+
|
| 319 |
+
const controlsRight = document.createElement('div');
|
| 320 |
+
controlsRight.className = 'table-controls-right';
|
| 321 |
+
|
| 322 |
+
const resizeControls = document.createElement('div');
|
| 323 |
+
resizeControls.className = 'table-resize-controls';
|
| 324 |
+
|
| 325 |
+
// Size control buttons
|
| 326 |
+
const sizes = [
|
| 327 |
+
{ name: 'Compact', class: 'compact' },
|
| 328 |
+
{ name: 'Comfortable', class: 'comfortable' },
|
| 329 |
+
{ name: 'Spacious', class: 'spacious' }
|
| 330 |
+
];
|
| 331 |
+
|
| 332 |
+
sizes.forEach((size, index) => {
|
| 333 |
+
const btn = document.createElement('button');
|
| 334 |
+
btn.textContent = size.name;
|
| 335 |
+
btn.className = 'table-resize-btn';
|
| 336 |
+
if (index === 1) btn.classList.add('active'); // Default to comfortable
|
| 337 |
+
|
| 338 |
+
btn.addEventListener('click', () => {
|
| 339 |
+
// Remove all size classes
|
| 340 |
+
table.classList.remove('compact', 'comfortable', 'spacious');
|
| 341 |
+
// Add selected size class
|
| 342 |
+
table.classList.add(size.class);
|
| 343 |
+
|
| 344 |
+
// Update active button
|
| 345 |
+
resizeControls.querySelectorAll('.table-resize-btn').forEach(b => b.classList.remove('active'));
|
| 346 |
+
btn.classList.add('active');
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
resizeControls.appendChild(btn);
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
controlsRight.appendChild(resizeControls);
|
| 353 |
+
|
| 354 |
+
controls.appendChild(controlsLeft);
|
| 355 |
+
controls.appendChild(controlsRight);
|
| 356 |
+
|
| 357 |
+
// Create table container
|
| 358 |
+
const container = document.createElement('div');
|
| 359 |
+
container.className = 'table-container';
|
| 360 |
+
container.appendChild(table);
|
| 361 |
+
|
| 362 |
+
wrapper.appendChild(controls);
|
| 363 |
+
wrapper.appendChild(container);
|
| 364 |
+
|
| 365 |
+
// Set default size
|
| 366 |
+
table.classList.add('comfortable');
|
| 367 |
+
|
| 368 |
+
return wrapper;
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// heuristics: split logs into agent sections
|
| 372 |
+
function splitLogs(logs) {
|
| 373 |
+
const sections = {memory: '', querycraft: '', presenter: ''};
|
| 374 |
+
if (!logs) {
|
| 375 |
+
console.log('[DEBUG] splitLogs: No logs provided');
|
| 376 |
+
return sections;
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
console.log('[DEBUG] splitLogs: Input logs length:', logs.length);
|
| 380 |
+
console.log('[DEBUG] splitLogs: First 500 chars:', logs.substring(0, 500));
|
| 381 |
+
console.log('[DEBUG] splitLogs: Last 500 chars:', logs.substring(Math.max(0, logs.length - 500)));
|
| 382 |
+
|
| 383 |
+
// Normalize line endings
|
| 384 |
+
const lines = logs.replace(/\r/g,'').split('\n');
|
| 385 |
+
console.log('[DEBUG] splitLogs: Total lines:', lines.length);
|
| 386 |
+
|
| 387 |
+
// Look for explicit markers first
|
| 388 |
+
let mode = null;
|
| 389 |
+
let memoryMarkerFound = false;
|
| 390 |
+
let querycraftMarkerFound = false;
|
| 391 |
+
let presenterMarkerFound = false;
|
| 392 |
+
|
| 393 |
+
for (let i = 0; i < lines.length; i++) {
|
| 394 |
+
const line = lines[i];
|
| 395 |
+
const l = line.trim();
|
| 396 |
+
|
| 397 |
+
// Match the exact agent section headers from agentic_workflow.py
|
| 398 |
+
if (/=== MEMORY AGENT ===/i.test(l) || /INITIAL MEMORY/i.test(l)) {
|
| 399 |
+
mode = 'memory';
|
| 400 |
+
memoryMarkerFound = true;
|
| 401 |
+
console.log(`[DEBUG] splitLogs: Found MEMORY marker at line ${i}: "${l}"`);
|
| 402 |
+
continue;
|
| 403 |
+
}
|
| 404 |
+
if (/=== QUERY CRAFT AGENT ===/i.test(l) || /QUERY_CRAFT/i.test(l) || /MICRO-HINT/i.test(l)) {
|
| 405 |
+
mode = 'querycraft';
|
| 406 |
+
querycraftMarkerFound = true;
|
| 407 |
+
console.log(`[DEBUG] splitLogs: Found QUERYCRAFT marker at line ${i}: "${l}"`);
|
| 408 |
+
continue;
|
| 409 |
+
}
|
| 410 |
+
if (/=== RESULT PRESENTER AGENT ===/i.test(l) || /PRESENTER/i.test(l)) {
|
| 411 |
+
mode = 'presenter';
|
| 412 |
+
presenterMarkerFound = true;
|
| 413 |
+
console.log(`[DEBUG] splitLogs: Found PRESENTER marker at line ${i}: "${l}"`);
|
| 414 |
+
continue;
|
| 415 |
+
}
|
| 416 |
+
// Additional patterns to catch query craft related logs
|
| 417 |
+
if (/\[FAST\]/i.test(l) || /\[GEN\]/i.test(l) || /\[MICRO-HINT\]/i.test(l)) {
|
| 418 |
+
mode = 'querycraft';
|
| 419 |
+
console.log(`[DEBUG] splitLogs: Found QUERYCRAFT pattern at line ${i}: "${l}"`);
|
| 420 |
+
}
|
| 421 |
+
// Heuristic: SQL lines go to querycraft
|
| 422 |
+
if (/^(SELECT|WITH|INSERT|UPDATE|DELETE)\b/i.test(l)) {
|
| 423 |
+
mode = 'querycraft';
|
| 424 |
+
sections.querycraft += l + '\n';
|
| 425 |
+
console.log(`[DEBUG] splitLogs: Found SQL pattern at line ${i}, mode set to querycraft`);
|
| 426 |
+
continue;
|
| 427 |
+
}
|
| 428 |
+
if (!mode) {
|
| 429 |
+
// Seed with memory until a clearer marker appears
|
| 430 |
+
sections.memory += line + '\n';
|
| 431 |
+
} else {
|
| 432 |
+
sections[mode] += line + '\n';
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
console.log('[DEBUG] splitLogs: Markers found - Memory:', memoryMarkerFound, 'QueryCraft:', querycraftMarkerFound, 'Presenter:', presenterMarkerFound);
|
| 437 |
+
console.log('[DEBUG] splitLogs: Section lengths - Memory:', sections.memory.length, 'QueryCraft:', sections.querycraft.length, 'Presenter:', sections.presenter.length);
|
| 438 |
+
console.log('[DEBUG] splitLogs: Memory section preview:', sections.memory.substring(0, 200));
|
| 439 |
+
console.log('[DEBUG] splitLogs: QueryCraft section preview:', sections.querycraft.substring(0, 200));
|
| 440 |
+
console.log('[DEBUG] splitLogs: Presenter section preview:', sections.presenter.substring(0, 200));
|
| 441 |
+
|
| 442 |
+
return sections;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
function findSQL(logs) {
|
| 446 |
+
if (!logs) return '';
|
| 447 |
+
// Primary: explicit markers
|
| 448 |
+
const markerRe = /<<<SQL_REVISED_START>>>([\s\S]*?)<<<SQL_REVISED_END>>>/m;
|
| 449 |
+
let m = logs.match(markerRe);
|
| 450 |
+
if (m) return m[1].trim();
|
| 451 |
+
const markerRe2 = /<<<SQL_START>>>([\s\S]*?)<<<SQL_END>>>/m;
|
| 452 |
+
m = logs.match(markerRe2);
|
| 453 |
+
if (m) return m[1].trim();
|
| 454 |
+
// Fallback: first standalone SQL-ish block up to semicolon
|
| 455 |
+
const fallback = logs.match(/^(?:.*?)(SELECT|WITH)[^;]{0,4000};/im);
|
| 456 |
+
if (fallback) {
|
| 457 |
+
const startIdx = fallback.index;
|
| 458 |
+
if (startIdx != null) {
|
| 459 |
+
const slice = logs.slice(startIdx).match(/([\s\S]*?;)/);
|
| 460 |
+
if (slice) return slice[1].trim();
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
return '';
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
async function runQuery(q){
|
| 467 |
+
if (!q) return alert('Please enter a question');
|
| 468 |
+
answerEl.textContent = 'Thinking...';
|
| 469 |
+
setRunning(true);
|
| 470 |
+
timeEl.textContent = '';
|
| 471 |
+
sqlEl.textContent = '-- SQL will appear here --';
|
| 472 |
+
memoryEl.textContent = querycraftEl.textContent = presenterEl.textContent = '—';
|
| 473 |
+
|
| 474 |
+
const startTime = Date.now();
|
| 475 |
+
|
| 476 |
+
try {
|
| 477 |
+
const body = { question: q, mode: currentMode };
|
| 478 |
+
if (currentMode === 'ocr_qa' && currentDocId) body.doc_id = currentDocId;
|
| 479 |
+
const res = await fetch('/api/query', {
|
| 480 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 481 |
+
body: JSON.stringify(body)
|
| 482 |
+
});
|
| 483 |
+
let j = null;
|
| 484 |
+
if (!res.ok) {
|
| 485 |
+
try { j = await res.json(); } catch(err){ const t = await res.text(); answerEl.textContent = 'Error: ' + t; setRunning(false); return; }
|
| 486 |
+
if (j.error === 'worker_timeout') { answerEl.textContent = 'Worker timeout: ' + (j.message || 'The worker did not respond in time.'); setRunning(false); return; }
|
| 487 |
+
answerEl.textContent = j.error || j.message || JSON.stringify(j);
|
| 488 |
+
if (j.trace) presenterEl.textContent = j.trace;
|
| 489 |
+
setRunning(false);
|
| 490 |
+
return;
|
| 491 |
+
}
|
| 492 |
+
j = await res.json();
|
| 493 |
+
|
| 494 |
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
| 495 |
+
|
| 496 |
+
if (j.answer) {
|
| 497 |
+
// Render markdown/pipe tables as HTML tables for readability
|
| 498 |
+
if(isMarkdownTable(j.answer)){
|
| 499 |
+
const node = parseMarkdownTable(j.answer);
|
| 500 |
+
answerEl.innerHTML = '';
|
| 501 |
+
if(node) answerEl.appendChild(node);
|
| 502 |
+
else answerEl.textContent = j.answer;
|
| 503 |
+
} else {
|
| 504 |
+
// Non-table answers: preserve plain text
|
| 505 |
+
answerEl.textContent = j.answer;
|
| 506 |
+
}
|
| 507 |
+
} else answerEl.textContent = JSON.stringify(j, null, 2);
|
| 508 |
+
|
| 509 |
+
// Prefer direct sql field from worker, else derive from logs
|
| 510 |
+
if (j.sql) {
|
| 511 |
+
const codeEl = document.getElementById('sql');
|
| 512 |
+
codeEl.textContent = j.sql;
|
| 513 |
+
if(window.Prism && Prism.highlightElement) Prism.highlightElement(codeEl);
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
if (j.logs) {
|
| 517 |
+
console.log('[DEBUG] runQuery: Processing logs of length:', j.logs.length);
|
| 518 |
+
const sections = splitLogs(j.logs);
|
| 519 |
+
console.log('[DEBUG] runQuery: Sections received:', {
|
| 520 |
+
memory: sections.memory.length,
|
| 521 |
+
querycraft: sections.querycraft.length,
|
| 522 |
+
presenter: sections.presenter.length
|
| 523 |
+
});
|
| 524 |
+
|
| 525 |
+
const memoryContent = sections.memory.trim() || '—';
|
| 526 |
+
const querycraftContent = sections.querycraft.trim() || '—';
|
| 527 |
+
const presenterContent = sections.presenter.trim() || '';
|
| 528 |
+
|
| 529 |
+
console.log('[DEBUG] runQuery: Setting memory content length:', memoryContent.length);
|
| 530 |
+
console.log('[DEBUG] runQuery: Setting querycraft content length:', querycraftContent.length);
|
| 531 |
+
console.log('[DEBUG] runQuery: Setting presenter content length:', presenterContent.length);
|
| 532 |
+
|
| 533 |
+
memoryEl.textContent = memoryContent;
|
| 534 |
+
querycraftEl.textContent = querycraftContent;
|
| 535 |
+
|
| 536 |
+
if(presenterContent){
|
| 537 |
+
console.log('[DEBUG] runQuery: Rendering presenter trace');
|
| 538 |
+
const node = renderPresenterTrace(presenterContent);
|
| 539 |
+
presenterEl.innerHTML = '';
|
| 540 |
+
presenterEl.appendChild(node);
|
| 541 |
+
} else {
|
| 542 |
+
console.log('[DEBUG] runQuery: No presenter content, setting dash');
|
| 543 |
+
presenterEl.textContent = '—';
|
| 544 |
+
}
|
| 545 |
+
if (!j.sql) {
|
| 546 |
+
const sql = findSQL(j.logs) || '';
|
| 547 |
+
if (sql) {
|
| 548 |
+
const codeEl = document.getElementById('sql');
|
| 549 |
+
codeEl.textContent = sql;
|
| 550 |
+
if(window.Prism && Prism.highlightElement) Prism.highlightElement(codeEl);
|
| 551 |
+
}
|
| 552 |
+
}
|
| 553 |
+
} else {
|
| 554 |
+
console.log('[DEBUG] runQuery: No logs in response');
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
statusEl.textContent = 'Done';
|
| 558 |
+
timeEl.textContent = `${elapsed}s`;
|
| 559 |
+
setRunning(false);
|
| 560 |
+
} catch (e) {
|
| 561 |
+
answerEl.textContent = 'Error: ' + String(e);
|
| 562 |
+
setRunning(false);
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
askBtn.onclick = () => runQuery(questionEl.value.trim());
|
| 567 |
+
|
| 568 |
+
recalcBtn.onclick = () => {
|
| 569 |
+
const q = questionEl.value.trim();
|
| 570 |
+
if (!q) return alert('Please enter a question');
|
| 571 |
+
const normalized = q.replace(/^(?:re-execute\s+)+/i, '');
|
| 572 |
+
const prefixed = 're-execute ' + normalized;
|
| 573 |
+
// do not modify the visible textarea; send the prefixed query directly
|
| 574 |
+
runQuery(prefixed);
|
| 575 |
+
};
|
| 576 |
+
|
| 577 |
+
// Enter key support
|
| 578 |
+
questionEl.addEventListener('keydown', (e) => {
|
| 579 |
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
| 580 |
+
e.preventDefault();
|
| 581 |
+
runQuery(questionEl.value.trim());
|
| 582 |
+
}
|
| 583 |
+
});
|
| 584 |
+
|
web/static/index.html
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="utf-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 7 |
+
<title>QueryLens - Transform natural language into SQL</title>
|
| 8 |
+
<link rel="icon"
|
| 9 |
+
href="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTAiIGZpbGw9IiMwRjYyRkUiLz4KPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeD0iNCIgeT0iNCIgY2xhc3M9InF1ZXJ5LWljb24iPgo8cGF0aCBkPSJNMTIgMlY3TTEyIDdMMTUgM001IDkuNUg5LjVNOS41IDkuNUw2IDZNMTkgMTQuNUgxNC41TTE0LjUgMTQuNUwxOCAxOE0xMiAxN1YyMk0xMiAyMkw5IDE5IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4KPC9zdmc+">
|
| 10 |
+
|
| 11 |
+
<!-- Fonts -->
|
| 12 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 13 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 14 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
| 15 |
+
rel="stylesheet">
|
| 16 |
+
|
| 17 |
+
<!-- Icons -->
|
| 18 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lucide@0.292.0/dist/umd/lucide.min.css">
|
| 19 |
+
|
| 20 |
+
<style>
|
| 21 |
+
:root {
|
| 22 |
+
/* Modern Color Palette */
|
| 23 |
+
--primary: #0F62FE;
|
| 24 |
+
--primary-dark: #0353E9;
|
| 25 |
+
--primary-light: #4589FF;
|
| 26 |
+
--secondary: #8A3FFC;
|
| 27 |
+
--accent: #FF6B35;
|
| 28 |
+
|
| 29 |
+
/* Neutrals - Enhanced for better contrast */
|
| 30 |
+
--gray-50: #F8FAFC;
|
| 31 |
+
--gray-100: #F1F5F9;
|
| 32 |
+
--gray-200: #E2E8F0;
|
| 33 |
+
--gray-300: #CBD5E1;
|
| 34 |
+
--gray-400: #94A3B8;
|
| 35 |
+
--gray-500: #64748B;
|
| 36 |
+
--gray-600: #475569;
|
| 37 |
+
--gray-700: #334155;
|
| 38 |
+
--gray-800: #1E293B;
|
| 39 |
+
--gray-900: #0F172A;
|
| 40 |
+
|
| 41 |
+
/* Semantic */
|
| 42 |
+
--success: #10B981;
|
| 43 |
+
--warning: #F59E0B;
|
| 44 |
+
--error: #EF4444;
|
| 45 |
+
|
| 46 |
+
/* Surfaces - Improved contrast */
|
| 47 |
+
--bg-primary: #FAFBFC;
|
| 48 |
+
/* Slightly off-white for better contrast */
|
| 49 |
+
--bg-secondary: #F4F6F8;
|
| 50 |
+
/* Darker secondary background */
|
| 51 |
+
--bg-tertiary: #E8EDF3;
|
| 52 |
+
/* More distinct tertiary */
|
| 53 |
+
--card-bg: #FFFFFF;
|
| 54 |
+
/* Pure white cards for contrast */
|
| 55 |
+
--border: #D1D9E0;
|
| 56 |
+
/* Darker borders for definition */
|
| 57 |
+
--border-light: #E5EAF0;
|
| 58 |
+
/* Subtle borders */
|
| 59 |
+
|
| 60 |
+
/* Typography - Enhanced contrast */
|
| 61 |
+
--text-primary: #1A202C;
|
| 62 |
+
/* Darker primary text */
|
| 63 |
+
--text-secondary: #4A5568;
|
| 64 |
+
/* Stronger secondary text */
|
| 65 |
+
--text-tertiary: #718096;
|
| 66 |
+
/* More readable tertiary text */
|
| 67 |
+
|
| 68 |
+
/* Spacing */
|
| 69 |
+
--space-1: 0.25rem;
|
| 70 |
+
--space-2: 0.5rem;
|
| 71 |
+
--space-3: 0.75rem;
|
| 72 |
+
--space-4: 1rem;
|
| 73 |
+
--space-5: 1.25rem;
|
| 74 |
+
--space-6: 1.5rem;
|
| 75 |
+
--space-8: 2rem;
|
| 76 |
+
--space-10: 2.5rem;
|
| 77 |
+
--space-12: 3rem;
|
| 78 |
+
--space-16: 4rem;
|
| 79 |
+
|
| 80 |
+
/* Shadows */
|
| 81 |
+
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
| 82 |
+
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
| 83 |
+
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
| 84 |
+
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
| 85 |
+
|
| 86 |
+
/* Border Radius */
|
| 87 |
+
--radius-sm: 0.375rem;
|
| 88 |
+
--radius-md: 0.5rem;
|
| 89 |
+
--radius-lg: 0.75rem;
|
| 90 |
+
--radius-xl: 1rem;
|
| 91 |
+
|
| 92 |
+
/* Transitions */
|
| 93 |
+
--transition-fast: 150ms ease;
|
| 94 |
+
--transition-normal: 250ms ease;
|
| 95 |
+
--transition-slow: 350ms ease;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/* Reset & Base Styles */
|
| 99 |
+
*,
|
| 100 |
+
*::before,
|
| 101 |
+
*::after {
|
| 102 |
+
box-sizing: border-box;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
html,
|
| 106 |
+
body {
|
| 107 |
+
height: 100%;
|
| 108 |
+
margin: 0;
|
| 109 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 110 |
+
background: var(--bg-secondary);
|
| 111 |
+
color: var(--text-primary);
|
| 112 |
+
-webkit-font-smoothing: antialiased;
|
| 113 |
+
-moz-osx-font-smoothing: grayscale;
|
| 114 |
+
font-size: 16px;
|
| 115 |
+
line-height: 1.6;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/* Layout */
|
| 119 |
+
.app-container {
|
| 120 |
+
min-height: 100vh;
|
| 121 |
+
display: flex;
|
| 122 |
+
flex-direction: column;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.main-content {
|
| 126 |
+
flex: 1;
|
| 127 |
+
max-width: 1400px;
|
| 128 |
+
margin: 0 auto;
|
| 129 |
+
padding: var(--space-6) var(--space-4);
|
| 130 |
+
width: 100%;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* Header */
|
| 134 |
+
.header {
|
| 135 |
+
background: var(--card-bg);
|
| 136 |
+
border-bottom: 1px solid var(--border);
|
| 137 |
+
box-shadow: var(--shadow-sm);
|
| 138 |
+
position: sticky;
|
| 139 |
+
top: 0;
|
| 140 |
+
z-index: 50;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.header-content {
|
| 144 |
+
max-width: 1400px;
|
| 145 |
+
margin: 0 auto;
|
| 146 |
+
padding: var(--space-4) var(--space-4);
|
| 147 |
+
display: flex;
|
| 148 |
+
align-items: center;
|
| 149 |
+
gap: var(--space-4);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.brand {
|
| 153 |
+
display: flex;
|
| 154 |
+
align-items: center;
|
| 155 |
+
gap: var(--space-3);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.brand-icon {
|
| 159 |
+
width: 48px;
|
| 160 |
+
height: 48px;
|
| 161 |
+
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
| 162 |
+
border-radius: var(--radius-lg);
|
| 163 |
+
display: flex;
|
| 164 |
+
align-items: center;
|
| 165 |
+
justify-content: center;
|
| 166 |
+
box-shadow: var(--shadow-md);
|
| 167 |
+
position: relative;
|
| 168 |
+
overflow: hidden;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
.brand-icon::before {
|
| 172 |
+
content: '';
|
| 173 |
+
position: absolute;
|
| 174 |
+
top: 0;
|
| 175 |
+
left: 0;
|
| 176 |
+
right: 0;
|
| 177 |
+
bottom: 0;
|
| 178 |
+
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.1) 50%, transparent 70%);
|
| 179 |
+
animation: shimmer 3s infinite;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
@keyframes shimmer {
|
| 183 |
+
0% {
|
| 184 |
+
transform: translateX(-100%);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
100% {
|
| 188 |
+
transform: translateX(100%);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.brand-icon svg {
|
| 193 |
+
width: 24px;
|
| 194 |
+
height: 24px;
|
| 195 |
+
color: white;
|
| 196 |
+
z-index: 1;
|
| 197 |
+
position: relative;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.brand-text h1 {
|
| 201 |
+
margin: 0;
|
| 202 |
+
font-size: 1.875rem;
|
| 203 |
+
font-weight: 800;
|
| 204 |
+
background: linear-gradient(135deg, var(--text-primary) 0%, var(--primary) 100%);
|
| 205 |
+
-webkit-background-clip: text;
|
| 206 |
+
-webkit-text-fill-color: transparent;
|
| 207 |
+
background-clip: text;
|
| 208 |
+
letter-spacing: -0.025em;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.brand-text p {
|
| 212 |
+
margin: 0;
|
| 213 |
+
color: var(--text-secondary);
|
| 214 |
+
font-size: 0.875rem;
|
| 215 |
+
font-weight: 500;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/* Query Section */
|
| 219 |
+
.query-section {
|
| 220 |
+
background: var(--card-bg);
|
| 221 |
+
border-radius: var(--radius-xl);
|
| 222 |
+
box-shadow: var(--shadow-lg);
|
| 223 |
+
border: 1px solid var(--border);
|
| 224 |
+
margin-bottom: var(--space-8);
|
| 225 |
+
overflow: hidden;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.query-header {
|
| 229 |
+
padding: var(--space-6) var(--space-6) 0;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.query-title {
|
| 233 |
+
margin: 0 0 var(--space-2);
|
| 234 |
+
font-size: 1.125rem;
|
| 235 |
+
font-weight: 600;
|
| 236 |
+
color: var(--text-primary);
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.query-body {
|
| 240 |
+
padding: 0 var(--space-6) var(--space-6);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
.textarea-container {
|
| 244 |
+
position: relative;
|
| 245 |
+
margin-bottom: var(--space-4);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.query-textarea {
|
| 249 |
+
width: 100%;
|
| 250 |
+
min-height: 120px;
|
| 251 |
+
padding: var(--space-4);
|
| 252 |
+
border: 2px solid var(--border);
|
| 253 |
+
border-radius: var(--radius-lg);
|
| 254 |
+
font-size: 1rem;
|
| 255 |
+
font-family: inherit;
|
| 256 |
+
line-height: 1.6;
|
| 257 |
+
resize: vertical;
|
| 258 |
+
background: var(--bg-primary);
|
| 259 |
+
color: var(--text-primary);
|
| 260 |
+
transition: var(--transition-normal);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.query-textarea:focus {
|
| 264 |
+
outline: none;
|
| 265 |
+
border-color: var(--primary);
|
| 266 |
+
box-shadow: 0 0 0 3px rgba(15, 98, 254, 0.1);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.query-textarea::placeholder {
|
| 270 |
+
color: var(--text-tertiary);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
.query-controls {
|
| 274 |
+
display: flex;
|
| 275 |
+
align-items: center;
|
| 276 |
+
gap: var(--space-3);
|
| 277 |
+
flex-wrap: wrap;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.mode-selector {
|
| 281 |
+
display: flex;
|
| 282 |
+
align-items: center;
|
| 283 |
+
gap: var(--space-2);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.mode-selector label {
|
| 287 |
+
font-size: 0.875rem;
|
| 288 |
+
font-weight: 500;
|
| 289 |
+
color: var(--text-secondary);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.mode-select {
|
| 293 |
+
padding: var(--space-2) var(--space-3);
|
| 294 |
+
border: 1px solid var(--border);
|
| 295 |
+
border-radius: var(--radius-md);
|
| 296 |
+
font-size: 0.875rem;
|
| 297 |
+
background: var(--bg-primary);
|
| 298 |
+
color: var(--text-primary);
|
| 299 |
+
min-width: 120px;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
/* Buttons */
|
| 303 |
+
.btn {
|
| 304 |
+
display: inline-flex;
|
| 305 |
+
align-items: center;
|
| 306 |
+
gap: var(--space-2);
|
| 307 |
+
padding: var(--space-3) var(--space-4);
|
| 308 |
+
border-radius: var(--radius-md);
|
| 309 |
+
font-size: 0.875rem;
|
| 310 |
+
font-weight: 600;
|
| 311 |
+
border: none;
|
| 312 |
+
cursor: pointer;
|
| 313 |
+
transition: var(--transition-normal);
|
| 314 |
+
text-decoration: none;
|
| 315 |
+
position: relative;
|
| 316 |
+
overflow: hidden;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
.btn:disabled {
|
| 320 |
+
opacity: 0.5;
|
| 321 |
+
cursor: not-allowed;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.btn-primary {
|
| 325 |
+
background: var(--primary);
|
| 326 |
+
color: white;
|
| 327 |
+
box-shadow: var(--shadow-sm);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
.btn-primary:hover:not(:disabled) {
|
| 331 |
+
background: var(--primary-dark);
|
| 332 |
+
transform: translateY(-1px);
|
| 333 |
+
box-shadow: var(--shadow-md);
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.btn-secondary {
|
| 337 |
+
background: var(--bg-primary);
|
| 338 |
+
color: var(--text-primary);
|
| 339 |
+
border: 1px solid var(--border);
|
| 340 |
+
box-shadow: var(--shadow-sm);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
.btn-secondary:hover:not(:disabled) {
|
| 344 |
+
background: var(--bg-tertiary);
|
| 345 |
+
transform: translateY(-1px);
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.upload-controls {
|
| 349 |
+
display: flex;
|
| 350 |
+
align-items: center;
|
| 351 |
+
gap: var(--space-3);
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.status-area {
|
| 355 |
+
margin-left: auto;
|
| 356 |
+
display: flex;
|
| 357 |
+
align-items: center;
|
| 358 |
+
gap: var(--space-3);
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
/* Spinner */
|
| 362 |
+
.spinner {
|
| 363 |
+
width: 24px;
|
| 364 |
+
height: 24px;
|
| 365 |
+
border: 3px solid var(--border);
|
| 366 |
+
border-top-color: var(--primary);
|
| 367 |
+
border-radius: 50%;
|
| 368 |
+
animation: spin 1s linear infinite;
|
| 369 |
+
display: inline-block;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.spinner.hidden {
|
| 373 |
+
display: none !important;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
@keyframes spin {
|
| 377 |
+
to {
|
| 378 |
+
transform: rotate(360deg);
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.status-text {
|
| 383 |
+
font-size: 0.875rem;
|
| 384 |
+
color: var(--text-secondary);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/* Results Grid */
|
| 388 |
+
.results-grid {
|
| 389 |
+
display: grid;
|
| 390 |
+
grid-template-columns: 1fr;
|
| 391 |
+
gap: var(--space-6);
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
@media (min-width: 1024px) {
|
| 395 |
+
.results-grid {
|
| 396 |
+
grid-template-columns: 2fr 1fr;
|
| 397 |
+
gap: var(--space-6);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
/* CRITICAL FIX: Prevent grid blowout from wide tables */
|
| 401 |
+
.results-grid>.main-results {
|
| 402 |
+
min-width: 0;
|
| 403 |
+
/* Allow grid cell to shrink below content size */
|
| 404 |
+
overflow: hidden;
|
| 405 |
+
/* Contain overflowing content */
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.results-grid>.agent-panels {
|
| 409 |
+
min-width: 0;
|
| 410 |
+
/* Prevent agent panels from being pushed off-screen */
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
/* Cards */
|
| 415 |
+
.card {
|
| 416 |
+
background: var(--card-bg);
|
| 417 |
+
border-radius: var(--radius-xl);
|
| 418 |
+
border: 1px solid var(--border);
|
| 419 |
+
box-shadow: var(--shadow-md);
|
| 420 |
+
overflow: hidden;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.card-header {
|
| 424 |
+
padding: var(--space-4) var(--space-6);
|
| 425 |
+
border-bottom: 1px solid var(--border-light);
|
| 426 |
+
background: var(--bg-tertiary);
|
| 427 |
+
display: flex;
|
| 428 |
+
align-items: center;
|
| 429 |
+
justify-content: space-between;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.card-title {
|
| 433 |
+
margin: 0;
|
| 434 |
+
font-size: 1rem;
|
| 435 |
+
font-weight: 600;
|
| 436 |
+
color: var(--text-primary);
|
| 437 |
+
display: flex;
|
| 438 |
+
align-items: center;
|
| 439 |
+
gap: var(--space-2);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.card-content {
|
| 443 |
+
padding: var(--space-6);
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.answer-content {
|
| 447 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
| 448 |
+
background: var(--bg-secondary);
|
| 449 |
+
border-radius: var(--radius-lg);
|
| 450 |
+
padding: var(--space-4);
|
| 451 |
+
white-space: pre-wrap;
|
| 452 |
+
color: var(--text-primary);
|
| 453 |
+
font-size: 0.875rem;
|
| 454 |
+
line-height: 1.6;
|
| 455 |
+
min-height: 120px;
|
| 456 |
+
border: 1px solid var(--border-light);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.sql-content {
|
| 460 |
+
background: var(--gray-900);
|
| 461 |
+
color: #E2E8F0;
|
| 462 |
+
border-radius: var(--radius-lg);
|
| 463 |
+
padding: var(--space-4);
|
| 464 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
| 465 |
+
font-size: 0.875rem;
|
| 466 |
+
line-height: 1.6;
|
| 467 |
+
overflow-x: auto;
|
| 468 |
+
max-height: 300px;
|
| 469 |
+
overflow-y: auto;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
/* Agent Panels */
|
| 473 |
+
.agent-panels {
|
| 474 |
+
display: grid;
|
| 475 |
+
gap: var(--space-4);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.panel {
|
| 479 |
+
border: 1px solid var(--border-light);
|
| 480 |
+
border-radius: var(--radius-lg);
|
| 481 |
+
overflow: hidden;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.panel-header {
|
| 485 |
+
padding: var(--space-3) var(--space-4);
|
| 486 |
+
background: var(--bg-tertiary);
|
| 487 |
+
border-bottom: 1px solid var(--border-light);
|
| 488 |
+
cursor: pointer;
|
| 489 |
+
user-select: none;
|
| 490 |
+
display: flex;
|
| 491 |
+
align-items: center;
|
| 492 |
+
justify-content: space-between;
|
| 493 |
+
transition: var(--transition-fast);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.panel-header:hover {
|
| 497 |
+
background: var(--bg-secondary);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.panel-title {
|
| 501 |
+
font-size: 0.875rem;
|
| 502 |
+
font-weight: 600;
|
| 503 |
+
color: var(--text-primary);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.panel-content {
|
| 507 |
+
padding: var(--space-4);
|
| 508 |
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
| 509 |
+
font-size: 0.8rem;
|
| 510 |
+
line-height: 1.5;
|
| 511 |
+
color: var(--text-secondary);
|
| 512 |
+
white-space: pre-wrap;
|
| 513 |
+
max-height: 300px;
|
| 514 |
+
overflow-y: auto;
|
| 515 |
+
background: var(--bg-primary);
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
/* Tables */
|
| 519 |
+
.table-wrapper {
|
| 520 |
+
position: relative;
|
| 521 |
+
margin: var(--space-4) 0;
|
| 522 |
+
border-radius: var(--radius-lg);
|
| 523 |
+
box-shadow: var(--shadow-lg);
|
| 524 |
+
background: var(--card-bg);
|
| 525 |
+
border: 1px solid var(--border);
|
| 526 |
+
overflow: hidden;
|
| 527 |
+
max-width: 100%;
|
| 528 |
+
/* Prevent table from breaking grid layout */
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
.table-controls {
|
| 532 |
+
display: flex;
|
| 533 |
+
align-items: center;
|
| 534 |
+
justify-content: space-between;
|
| 535 |
+
padding: var(--space-3) var(--space-4);
|
| 536 |
+
background: var(--bg-tertiary);
|
| 537 |
+
border-bottom: 1px solid var(--border);
|
| 538 |
+
gap: var(--space-3);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
.table-controls-left {
|
| 542 |
+
display: flex;
|
| 543 |
+
align-items: center;
|
| 544 |
+
gap: var(--space-3);
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
.table-controls-right {
|
| 548 |
+
display: flex;
|
| 549 |
+
align-items: center;
|
| 550 |
+
gap: var(--space-2);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
.table-info {
|
| 554 |
+
font-size: 0.875rem;
|
| 555 |
+
color: var(--text-secondary);
|
| 556 |
+
font-weight: 500;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
.table-resize-controls {
|
| 560 |
+
display: flex;
|
| 561 |
+
align-items: center;
|
| 562 |
+
gap: var(--space-2);
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
.table-resize-btn {
|
| 566 |
+
padding: var(--space-1) var(--space-2);
|
| 567 |
+
border: 1px solid var(--border);
|
| 568 |
+
border-radius: var(--radius-sm);
|
| 569 |
+
background: var(--bg-primary);
|
| 570 |
+
color: var(--text-secondary);
|
| 571 |
+
font-size: 0.75rem;
|
| 572 |
+
cursor: pointer;
|
| 573 |
+
transition: var(--transition-normal);
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
.table-resize-btn:hover {
|
| 577 |
+
background: var(--bg-secondary);
|
| 578 |
+
color: var(--text-primary);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
.table-resize-btn.active {
|
| 582 |
+
background: var(--primary);
|
| 583 |
+
color: white;
|
| 584 |
+
border-color: var(--primary);
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.table-container {
|
| 588 |
+
overflow: auto;
|
| 589 |
+
max-height: 70vh;
|
| 590 |
+
max-width: 100%;
|
| 591 |
+
position: relative;
|
| 592 |
+
border: 1px solid var(--border);
|
| 593 |
+
border-radius: var(--radius-lg);
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
.answer-table {
|
| 597 |
+
width: 100%;
|
| 598 |
+
border-collapse: separate;
|
| 599 |
+
border-spacing: 0;
|
| 600 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 601 |
+
font-size: 0.875rem;
|
| 602 |
+
background: var(--card-bg);
|
| 603 |
+
table-layout: auto;
|
| 604 |
+
min-width: 100%;
|
| 605 |
+
}
|
| 606 |
+
|
| 607 |
+
.answer-table.compact {
|
| 608 |
+
table-layout: fixed;
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
.answer-table.comfortable {
|
| 612 |
+
table-layout: auto;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.answer-table.spacious {
|
| 616 |
+
table-layout: auto;
|
| 617 |
+
font-size: 1rem;
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
.answer-table th,
|
| 621 |
+
.answer-table td {
|
| 622 |
+
padding: var(--space-3) var(--space-4);
|
| 623 |
+
text-align: left;
|
| 624 |
+
border-bottom: 1px solid var(--border-light);
|
| 625 |
+
border-right: 1px solid var(--border-light);
|
| 626 |
+
vertical-align: top;
|
| 627 |
+
position: relative;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
.answer-table.compact th,
|
| 631 |
+
.answer-table.compact td {
|
| 632 |
+
padding: var(--space-2) var(--space-3);
|
| 633 |
+
max-width: 120px;
|
| 634 |
+
overflow: hidden;
|
| 635 |
+
text-overflow: ellipsis;
|
| 636 |
+
white-space: nowrap;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
.answer-table.comfortable th,
|
| 640 |
+
.answer-table.comfortable td {
|
| 641 |
+
padding: var(--space-3) var(--space-4);
|
| 642 |
+
max-width: 200px;
|
| 643 |
+
word-wrap: break-word;
|
| 644 |
+
white-space: normal;
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
.answer-table.spacious th,
|
| 648 |
+
.answer-table.spacious td {
|
| 649 |
+
padding: var(--space-4) var(--space-6);
|
| 650 |
+
max-width: 300px;
|
| 651 |
+
word-wrap: break-word;
|
| 652 |
+
white-space: normal;
|
| 653 |
+
overflow-wrap: break-word;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.answer-table th {
|
| 657 |
+
background: #F1F5F9;
|
| 658 |
+
/* Stronger header background */
|
| 659 |
+
font-weight: 600;
|
| 660 |
+
color: #1A202C;
|
| 661 |
+
/* Darker header text */
|
| 662 |
+
position: sticky;
|
| 663 |
+
top: 0;
|
| 664 |
+
z-index: 10;
|
| 665 |
+
border-top: none;
|
| 666 |
+
border-bottom: 2px solid #D1D9E0;
|
| 667 |
+
/* Stronger header border */
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
.answer-table th:last-child,
|
| 671 |
+
.answer-table td:last-child {
|
| 672 |
+
border-right: none;
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
.answer-table tbody tr:hover {
|
| 676 |
+
background: #E8F4FD;
|
| 677 |
+
/* Subtle blue hover */
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
.answer-table tbody tr:nth-child(even) {
|
| 681 |
+
background: #F7F9FC;
|
| 682 |
+
/* More visible alternating rows */
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.answer-table tbody tr:nth-child(even):hover {
|
| 686 |
+
background: #E8F4FD;
|
| 687 |
+
/* Consistent hover color */
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
/* Column resizer */
|
| 691 |
+
.column-resizer {
|
| 692 |
+
position: absolute;
|
| 693 |
+
right: 0;
|
| 694 |
+
top: 0;
|
| 695 |
+
bottom: 0;
|
| 696 |
+
width: 4px;
|
| 697 |
+
cursor: col-resize;
|
| 698 |
+
background: transparent;
|
| 699 |
+
transition: background-color 0.2s;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.column-resizer:hover {
|
| 703 |
+
background: var(--primary);
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
/* Responsive adjustments */
|
| 707 |
+
@media (max-width: 768px) {
|
| 708 |
+
.table-controls {
|
| 709 |
+
flex-direction: column;
|
| 710 |
+
align-items: stretch;
|
| 711 |
+
gap: var(--space-2);
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
.table-controls-left,
|
| 715 |
+
.table-controls-right {
|
| 716 |
+
justify-content: space-between;
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
.answer-table th,
|
| 720 |
+
.answer-table td {
|
| 721 |
+
padding: var(--space-2) var(--space-3);
|
| 722 |
+
font-size: 0.8rem;
|
| 723 |
+
}
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
/* Footer */
|
| 727 |
+
.footer {
|
| 728 |
+
margin-top: var(--space-12);
|
| 729 |
+
padding: var(--space-8) 0;
|
| 730 |
+
text-align: center;
|
| 731 |
+
color: var(--text-tertiary);
|
| 732 |
+
font-size: 0.875rem;
|
| 733 |
+
border-top: 1px solid var(--border-light);
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
/* Responsive */
|
| 737 |
+
@media (max-width: 768px) {
|
| 738 |
+
.main-content {
|
| 739 |
+
padding: var(--space-4) var(--space-3);
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
.header-content {
|
| 743 |
+
padding: var(--space-3);
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
.query-controls {
|
| 747 |
+
flex-direction: column;
|
| 748 |
+
align-items: stretch;
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
.status-area {
|
| 752 |
+
margin-left: 0;
|
| 753 |
+
margin-top: var(--space-3);
|
| 754 |
+
}
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
/* Utility Classes */
|
| 758 |
+
.hidden {
|
| 759 |
+
display: none !important;
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
.text-success {
|
| 763 |
+
color: var(--success) !important;
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
.text-warning {
|
| 767 |
+
color: var(--warning) !important;
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
.text-error {
|
| 771 |
+
color: var(--error) !important;
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
/* Upload Controls - Show/Hide */
|
| 775 |
+
.upload-controls.hidden {
|
| 776 |
+
display: none !important;
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
.upload-controls {
|
| 780 |
+
display: flex !important;
|
| 781 |
+
align-items: center;
|
| 782 |
+
gap: var(--space-3);
|
| 783 |
+
}
|
| 784 |
+
</style>
|
| 785 |
+
|
| 786 |
+
<!-- Prism for SQL highlighting -->
|
| 787 |
+
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
|
| 788 |
+
</head>
|
| 789 |
+
|
| 790 |
+
<body>
|
| 791 |
+
<div class="app-container">
|
| 792 |
+
<!-- Header -->
|
| 793 |
+
<header class="header">
|
| 794 |
+
<div class="header-content">
|
| 795 |
+
<div class="brand">
|
| 796 |
+
<div class="brand-icon">
|
| 797 |
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
| 798 |
+
<path
|
| 799 |
+
d="M12 2V7M12 7L15 3M5 9.5H9.5M9.5 9.5L6 6M19 14.5H14.5M14.5 14.5L18 18M12 17V22M12 22L9 19"
|
| 800 |
+
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
| 801 |
+
</svg>
|
| 802 |
+
</div>
|
| 803 |
+
<div class="brand-text">
|
| 804 |
+
<h1>QueryLens</h1>
|
| 805 |
+
<p>Transform natural language into SQL, and documents into insight.</p>
|
| 806 |
+
</div>
|
| 807 |
+
</div>
|
| 808 |
+
</div>
|
| 809 |
+
</header>
|
| 810 |
+
|
| 811 |
+
<!-- Main Content -->
|
| 812 |
+
<main class="main-content">
|
| 813 |
+
<!-- Query Section -->
|
| 814 |
+
<section class="query-section">
|
| 815 |
+
<div class="query-header">
|
| 816 |
+
<h2 class="query-title">Ask a Question</h2>
|
| 817 |
+
</div>
|
| 818 |
+
<div class="query-body">
|
| 819 |
+
<div class="textarea-container">
|
| 820 |
+
<textarea id="question" class="query-textarea"
|
| 821 |
+
placeholder="e.g. Which department has the highest average salary?"></textarea>
|
| 822 |
+
</div>
|
| 823 |
+
|
| 824 |
+
<div class="query-controls">
|
| 825 |
+
<div class="mode-selector">
|
| 826 |
+
<label for="modeToggle">Mode:</label>
|
| 827 |
+
<select id="modeToggle" class="mode-select">
|
| 828 |
+
<option value="text2sql">Text-to-SQL</option>
|
| 829 |
+
<option value="ocr_qa">OCR Q&A</option>
|
| 830 |
+
</select>
|
| 831 |
+
</div>
|
| 832 |
+
|
| 833 |
+
<button id="ask" class="btn btn-primary">
|
| 834 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 835 |
+
stroke-width="2">
|
| 836 |
+
<path d="M12 19l7-7 3 3-7 7-3-3z" />
|
| 837 |
+
<path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z" />
|
| 838 |
+
</svg>
|
| 839 |
+
Ask
|
| 840 |
+
</button>
|
| 841 |
+
|
| 842 |
+
<button id="recalc" class="btn btn-secondary">
|
| 843 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 844 |
+
stroke-width="2">
|
| 845 |
+
<path d="M1 4v6h6" />
|
| 846 |
+
<path d="M23 20v-6h-6" />
|
| 847 |
+
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" />
|
| 848 |
+
</svg>
|
| 849 |
+
Re-execute
|
| 850 |
+
</button>
|
| 851 |
+
|
| 852 |
+
<div id="uploadControls" class="upload-controls hidden">
|
| 853 |
+
<button id="uploadBtn" class="btn btn-secondary">
|
| 854 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 855 |
+
stroke-width="2">
|
| 856 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
| 857 |
+
<polyline points="17,8 12,3 7,8" />
|
| 858 |
+
<line x1="12" y1="3" x2="12" y2="15" />
|
| 859 |
+
</svg>
|
| 860 |
+
Upload Image
|
| 861 |
+
</button>
|
| 862 |
+
<span id="uploadStatus" class="status-text">No upload</span>
|
| 863 |
+
</div>
|
| 864 |
+
|
| 865 |
+
<div class="status-area">
|
| 866 |
+
<div id="spinner" class="spinner hidden"></div>
|
| 867 |
+
<span id="spinnerText" class="status-text hidden">Processing...</span>
|
| 868 |
+
<span id="status" class="status-text">Ready</span>
|
| 869 |
+
<span id="time" class="status-text"></span>
|
| 870 |
+
</div>
|
| 871 |
+
</div>
|
| 872 |
+
</div>
|
| 873 |
+
</section>
|
| 874 |
+
|
| 875 |
+
<!-- Results Grid -->
|
| 876 |
+
<div class="results-grid">
|
| 877 |
+
<!-- Main Results Column -->
|
| 878 |
+
<div class="main-results">
|
| 879 |
+
<!-- Answer Card -->
|
| 880 |
+
<div class="card">
|
| 881 |
+
<div class="card-header">
|
| 882 |
+
<h3 class="card-title">
|
| 883 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 884 |
+
stroke-width="2">
|
| 885 |
+
<path d="M9 11l3 3L22 4" />
|
| 886 |
+
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
| 887 |
+
</svg>
|
| 888 |
+
Answer
|
| 889 |
+
</h3>
|
| 890 |
+
</div>
|
| 891 |
+
<div class="card-content">
|
| 892 |
+
<div id="answer" class="answer-content">No answer yet.</div>
|
| 893 |
+
</div>
|
| 894 |
+
</div>
|
| 895 |
+
|
| 896 |
+
<!-- SQL Card -->
|
| 897 |
+
<div class="card" style="margin-top: var(--space-6);">
|
| 898 |
+
<details id="sql-details" open>
|
| 899 |
+
<summary class="card-header" style="cursor: pointer;">
|
| 900 |
+
<h3 class="card-title">
|
| 901 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 902 |
+
stroke-width="2">
|
| 903 |
+
<polyline points="16,18 22,12 16,6" />
|
| 904 |
+
<polyline points="8,6 2,12 8,18" />
|
| 905 |
+
</svg>
|
| 906 |
+
Generated SQL
|
| 907 |
+
</h3>
|
| 908 |
+
</summary>
|
| 909 |
+
<div class="card-content">
|
| 910 |
+
<pre
|
| 911 |
+
class="sql-content"><code id="sql" class="language-sql">-- SQL will appear here after the agent crafts a query --</code></pre>
|
| 912 |
+
</div>
|
| 913 |
+
</details>
|
| 914 |
+
</div>
|
| 915 |
+
</div>
|
| 916 |
+
|
| 917 |
+
<!-- Agent Panels Column -->
|
| 918 |
+
<div class="agent-panels">
|
| 919 |
+
<div class="panel">
|
| 920 |
+
<details open>
|
| 921 |
+
<summary class="panel-header">
|
| 922 |
+
<span class="panel-title">
|
| 923 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 924 |
+
stroke-width="2" style="margin-right: 0.5rem;">
|
| 925 |
+
<path
|
| 926 |
+
d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" />
|
| 927 |
+
</svg>
|
| 928 |
+
Memory Agent
|
| 929 |
+
</span>
|
| 930 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 931 |
+
stroke-width="2">
|
| 932 |
+
<polyline points="6,9 12,15 18,9" />
|
| 933 |
+
</svg>
|
| 934 |
+
</summary>
|
| 935 |
+
<div id="memory" class="panel-content">—</div>
|
| 936 |
+
</details>
|
| 937 |
+
</div>
|
| 938 |
+
|
| 939 |
+
<div class="panel">
|
| 940 |
+
<details open>
|
| 941 |
+
<summary class="panel-header">
|
| 942 |
+
<span class="panel-title">
|
| 943 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 944 |
+
stroke-width="2" style="margin-right: 0.5rem;">
|
| 945 |
+
<path d="M12 20h9" />
|
| 946 |
+
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
| 947 |
+
</svg>
|
| 948 |
+
Query Craft Agent
|
| 949 |
+
</span>
|
| 950 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 951 |
+
stroke-width="2">
|
| 952 |
+
<polyline points="6,9 12,15 18,9" />
|
| 953 |
+
</svg>
|
| 954 |
+
</summary>
|
| 955 |
+
<div id="querycraft" class="panel-content">—</div>
|
| 956 |
+
</details>
|
| 957 |
+
</div>
|
| 958 |
+
|
| 959 |
+
<div class="panel">
|
| 960 |
+
<details open>
|
| 961 |
+
<summary class="panel-header">
|
| 962 |
+
<span class="panel-title">
|
| 963 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 964 |
+
stroke-width="2" style="margin-right: 0.5rem;">
|
| 965 |
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
| 966 |
+
<polyline points="14,2 14,8 20,8" />
|
| 967 |
+
<line x1="16" y1="13" x2="8" y2="13" />
|
| 968 |
+
<line x1="16" y1="17" x2="8" y2="17" />
|
| 969 |
+
<polyline points="10,9 9,9 8,9" />
|
| 970 |
+
</svg>
|
| 971 |
+
Result Presenter Agent
|
| 972 |
+
</span>
|
| 973 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
| 974 |
+
stroke-width="2">
|
| 975 |
+
<polyline points="6,9 12,15 18,9" />
|
| 976 |
+
</svg>
|
| 977 |
+
</summary>
|
| 978 |
+
<div id="presenter" class="panel-content">—</div>
|
| 979 |
+
</details>
|
| 980 |
+
</div>
|
| 981 |
+
</div>
|
| 982 |
+
</div>
|
| 983 |
+
</main>
|
| 984 |
+
|
| 985 |
+
<!-- Footer -->
|
| 986 |
+
<footer class="footer">
|
| 987 |
+
<p>QueryLens — Powered by advanced AI agents for intelligent data analysis</p>
|
| 988 |
+
</footer>
|
| 989 |
+
</div>
|
| 990 |
+
|
| 991 |
+
<script src="/static/app.js"></script>
|
| 992 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
| 993 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
|
| 994 |
+
</body>
|
| 995 |
+
|
| 996 |
+
</html>
|
web/static/table-demo.html
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Table Demo</title>
|
| 7 |
+
<style>
|
| 8 |
+
/* Copy the same CSS variables and table styles from the main app */
|
| 9 |
+
:root {
|
| 10 |
+
--primary: #0f62fe;
|
| 11 |
+
--primary-hover: #0353e9;
|
| 12 |
+
--bg-primary: #FAFBFC;
|
| 13 |
+
--bg-secondary: #F4F6F8;
|
| 14 |
+
--bg-tertiary: #E8EDF3;
|
| 15 |
+
--card-bg: #ffffff;
|
| 16 |
+
--text-primary: #1A202C;
|
| 17 |
+
--text-secondary: #4A5568;
|
| 18 |
+
--text-tertiary: #718096;
|
| 19 |
+
--border: #D1D9E0;
|
| 20 |
+
--border-light: #E5EAF0;
|
| 21 |
+
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
| 22 |
+
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
| 23 |
+
--radius-sm: 0.25rem;
|
| 24 |
+
--radius-md: 0.375rem;
|
| 25 |
+
--radius-lg: 0.5rem;
|
| 26 |
+
--radius-xl: 0.75rem;
|
| 27 |
+
--space-1: 0.25rem;
|
| 28 |
+
--space-2: 0.5rem;
|
| 29 |
+
--space-3: 0.75rem;
|
| 30 |
+
--space-4: 1rem;
|
| 31 |
+
--space-6: 1.5rem;
|
| 32 |
+
--space-8: 2rem;
|
| 33 |
+
--space-12: 3rem;
|
| 34 |
+
--transition-normal: all 0.2s ease-in-out;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
body {
|
| 38 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 39 |
+
background: var(--bg-secondary);
|
| 40 |
+
color: var(--text-primary);
|
| 41 |
+
margin: 2rem;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.table-wrapper {
|
| 45 |
+
position: relative;
|
| 46 |
+
margin: var(--space-4) 0;
|
| 47 |
+
border-radius: var(--radius-lg);
|
| 48 |
+
box-shadow: var(--shadow-lg);
|
| 49 |
+
background: var(--card-bg);
|
| 50 |
+
border: 1px solid var(--border);
|
| 51 |
+
overflow: hidden;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.table-controls {
|
| 55 |
+
display: flex;
|
| 56 |
+
align-items: center;
|
| 57 |
+
justify-content: space-between;
|
| 58 |
+
padding: var(--space-3) var(--space-4);
|
| 59 |
+
background: var(--bg-tertiary);
|
| 60 |
+
border-bottom: 1px solid var(--border);
|
| 61 |
+
gap: var(--space-3);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.table-controls-left {
|
| 65 |
+
display: flex;
|
| 66 |
+
align-items: center;
|
| 67 |
+
gap: var(--space-3);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.table-controls-right {
|
| 71 |
+
display: flex;
|
| 72 |
+
align-items: center;
|
| 73 |
+
gap: var(--space-2);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.table-info {
|
| 77 |
+
font-size: 0.875rem;
|
| 78 |
+
color: var(--text-secondary);
|
| 79 |
+
font-weight: 500;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.table-resize-controls {
|
| 83 |
+
display: flex;
|
| 84 |
+
align-items: center;
|
| 85 |
+
gap: var(--space-2);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.table-resize-btn {
|
| 89 |
+
padding: var(--space-1) var(--space-2);
|
| 90 |
+
border: 1px solid var(--border);
|
| 91 |
+
border-radius: var(--radius-sm);
|
| 92 |
+
background: var(--bg-primary);
|
| 93 |
+
color: var(--text-secondary);
|
| 94 |
+
font-size: 0.75rem;
|
| 95 |
+
cursor: pointer;
|
| 96 |
+
transition: var(--transition-normal);
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.table-resize-btn:hover {
|
| 100 |
+
background: var(--bg-secondary);
|
| 101 |
+
color: var(--text-primary);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.table-resize-btn.active {
|
| 105 |
+
background: var(--primary);
|
| 106 |
+
color: white;
|
| 107 |
+
border-color: var(--primary);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.table-container {
|
| 111 |
+
overflow: auto;
|
| 112 |
+
max-height: 70vh;
|
| 113 |
+
position: relative;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.answer-table {
|
| 117 |
+
width: 100%;
|
| 118 |
+
border-collapse: separate;
|
| 119 |
+
border-spacing: 0;
|
| 120 |
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 121 |
+
font-size: 0.875rem;
|
| 122 |
+
background: var(--card-bg);
|
| 123 |
+
table-layout: auto;
|
| 124 |
+
min-width: 100%;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.answer-table.compact {
|
| 128 |
+
table-layout: fixed;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.answer-table.comfortable {
|
| 132 |
+
table-layout: auto;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.answer-table.spacious {
|
| 136 |
+
table-layout: auto;
|
| 137 |
+
font-size: 1rem;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
.answer-table th,
|
| 141 |
+
.answer-table td {
|
| 142 |
+
padding: var(--space-3) var(--space-4);
|
| 143 |
+
text-align: left;
|
| 144 |
+
border-bottom: 1px solid var(--border-light);
|
| 145 |
+
border-right: 1px solid var(--border-light);
|
| 146 |
+
vertical-align: top;
|
| 147 |
+
position: relative;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.answer-table.compact th,
|
| 151 |
+
.answer-table.compact td {
|
| 152 |
+
padding: var(--space-2) var(--space-3);
|
| 153 |
+
max-width: 120px;
|
| 154 |
+
overflow: hidden;
|
| 155 |
+
text-overflow: ellipsis;
|
| 156 |
+
white-space: nowrap;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.answer-table.comfortable th,
|
| 160 |
+
.answer-table.comfortable td {
|
| 161 |
+
padding: var(--space-3) var(--space-4);
|
| 162 |
+
max-width: 200px;
|
| 163 |
+
word-wrap: break-word;
|
| 164 |
+
white-space: normal;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.answer-table.spacious th,
|
| 168 |
+
.answer-table.spacious td {
|
| 169 |
+
padding: var(--space-4) var(--space-6);
|
| 170 |
+
max-width: none;
|
| 171 |
+
word-wrap: break-word;
|
| 172 |
+
white-space: normal;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.answer-table th {
|
| 176 |
+
background: #F1F5F9;
|
| 177 |
+
font-weight: 600;
|
| 178 |
+
color: #1A202C;
|
| 179 |
+
position: sticky;
|
| 180 |
+
top: 0;
|
| 181 |
+
z-index: 10;
|
| 182 |
+
border-top: none;
|
| 183 |
+
border-bottom: 2px solid #D1D9E0;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.answer-table th:last-child,
|
| 187 |
+
.answer-table td:last-child {
|
| 188 |
+
border-right: none;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.answer-table tbody tr:hover {
|
| 192 |
+
background: #E8F4FD;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.answer-table tbody tr:nth-child(even) {
|
| 196 |
+
background: #F7F9FC;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.answer-table tbody tr:nth-child(even):hover {
|
| 200 |
+
background: #E8F4FD;
|
| 201 |
+
}
|
| 202 |
+
</style>
|
| 203 |
+
</head>
|
| 204 |
+
<body>
|
| 205 |
+
<h1>Enhanced Table Demo</h1>
|
| 206 |
+
<p>This demonstrates the new resizable table interface:</p>
|
| 207 |
+
|
| 208 |
+
<div class="table-wrapper">
|
| 209 |
+
<div class="table-controls">
|
| 210 |
+
<div class="table-controls-left">
|
| 211 |
+
<div class="table-info">9 rows × 7 columns</div>
|
| 212 |
+
</div>
|
| 213 |
+
<div class="table-controls-right">
|
| 214 |
+
<div class="table-resize-controls">
|
| 215 |
+
<button class="table-resize-btn" onclick="setTableSize('compact')">Compact</button>
|
| 216 |
+
<button class="table-resize-btn active" onclick="setTableSize('comfortable')">Comfortable</button>
|
| 217 |
+
<button class="table-resize-btn" onclick="setTableSize('spacious')">Spacious</button>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
<div class="table-container">
|
| 222 |
+
<table class="answer-table comfortable">
|
| 223 |
+
<thead>
|
| 224 |
+
<tr>
|
| 225 |
+
<th>dept_name</th>
|
| 226 |
+
<th>avg_salary</th>
|
| 227 |
+
<th>num_employee</th>
|
| 228 |
+
<th>male_salary</th>
|
| 229 |
+
<th>female_salary</th>
|
| 230 |
+
<th>avg_male_salary</th>
|
| 231 |
+
<th>avg_female_salary</th>
|
| 232 |
+
</tr>
|
| 233 |
+
</thead>
|
| 234 |
+
<tbody>
|
| 235 |
+
<tr>
|
| 236 |
+
<td>Sales</td>
|
| 237 |
+
<td>$86,123.43</td>
|
| 238 |
+
<td>130</td>
|
| 239 |
+
<td>$6,181,954</td>
|
| 240 |
+
<td>$5,014,084</td>
|
| 241 |
+
<td>$84,684.37</td>
|
| 242 |
+
<td>$87,966.44</td>
|
| 243 |
+
</tr>
|
| 244 |
+
<tr>
|
| 245 |
+
<td>Marketing</td>
|
| 246 |
+
<td>$81,874.03</td>
|
| 247 |
+
<td>34</td>
|
| 248 |
+
<td>$1,736,164</td>
|
| 249 |
+
<td>$1,047,554</td>
|
| 250 |
+
<td>$82,674.38</td>
|
| 251 |
+
<td>$80,581.15</td>
|
| 252 |
+
</tr>
|
| 253 |
+
<tr>
|
| 254 |
+
<td>Finance</td>
|
| 255 |
+
<td>$76,399.98</td>
|
| 256 |
+
<td>43</td>
|
| 257 |
+
<td>$2,026,534</td>
|
| 258 |
+
<td>$1,258,664</td>
|
| 259 |
+
<td>$75,056.70</td>
|
| 260 |
+
<td>$78,666.75</td>
|
| 261 |
+
</tr>
|
| 262 |
+
<tr>
|
| 263 |
+
<td>Development</td>
|
| 264 |
+
<td>$69,564.44</td>
|
| 265 |
+
<td>203</td>
|
| 266 |
+
<td>$8,159,084</td>
|
| 267 |
+
<td>$5,962,504</td>
|
| 268 |
+
<td>$69,735.73</td>
|
| 269 |
+
<td>$69,331.42</td>
|
| 270 |
+
</tr>
|
| 271 |
+
<tr>
|
| 272 |
+
<td>Research</td>
|
| 273 |
+
<td>$69,147.39</td>
|
| 274 |
+
<td>51</td>
|
| 275 |
+
<td>$2,279,864</td>
|
| 276 |
+
<td>$1,246,654</td>
|
| 277 |
+
<td>$69,086.76</td>
|
| 278 |
+
<td>$69,258.56</td>
|
| 279 |
+
</tr>
|
| 280 |
+
<tr>
|
| 281 |
+
<td>Production</td>
|
| 282 |
+
<td>$68,537.28</td>
|
| 283 |
+
<td>186</td>
|
| 284 |
+
<td>$7,929,244</td>
|
| 285 |
+
<td>$4,818,694</td>
|
| 286 |
+
<td>$68,355.55</td>
|
| 287 |
+
<td>$68,838.43</td>
|
| 288 |
+
</tr>
|
| 289 |
+
<tr>
|
| 290 |
+
<td>Quality Management</td>
|
| 291 |
+
<td>$64,859.40</td>
|
| 292 |
+
<td>48</td>
|
| 293 |
+
<td>$2,112,644</td>
|
| 294 |
+
<td>$1,000,604</td>
|
| 295 |
+
<td>$64,019.61</td>
|
| 296 |
+
<td>$66,706.93</td>
|
| 297 |
+
</tr>
|
| 298 |
+
<tr>
|
| 299 |
+
<td>Customer Service</td>
|
| 300 |
+
<td>$63,643.00</td>
|
| 301 |
+
<td>61</td>
|
| 302 |
+
<td>$1,730,834</td>
|
| 303 |
+
<td>$2,151,394</td>
|
| 304 |
+
<td>$61,815.43</td>
|
| 305 |
+
<td>$65,193.67</td>
|
| 306 |
+
</tr>
|
| 307 |
+
<tr>
|
| 308 |
+
<td>Human Resources</td>
|
| 309 |
+
<td>$63,507.28</td>
|
| 310 |
+
<td>46</td>
|
| 311 |
+
<td>$2,151,834</td>
|
| 312 |
+
<td>$769,499</td>
|
| 313 |
+
<td>$63,289.29</td>
|
| 314 |
+
<td>$64,124.92</td>
|
| 315 |
+
</tr>
|
| 316 |
+
</tbody>
|
| 317 |
+
</table>
|
| 318 |
+
</div>
|
| 319 |
+
</div>
|
| 320 |
+
|
| 321 |
+
<script>
|
| 322 |
+
function setTableSize(size) {
|
| 323 |
+
const table = document.querySelector('.answer-table');
|
| 324 |
+
const buttons = document.querySelectorAll('.table-resize-btn');
|
| 325 |
+
|
| 326 |
+
// Remove all size classes
|
| 327 |
+
table.classList.remove('compact', 'comfortable', 'spacious');
|
| 328 |
+
|
| 329 |
+
// Add selected size class
|
| 330 |
+
table.classList.add(size);
|
| 331 |
+
|
| 332 |
+
// Update active button
|
| 333 |
+
buttons.forEach(btn => btn.classList.remove('active'));
|
| 334 |
+
event.target.classList.add('active');
|
| 335 |
+
}
|
| 336 |
+
</script>
|
| 337 |
+
</body>
|
| 338 |
+
</html>
|