fix: pre-release audit round 12 — 33 bugs fixed across all tiers
Browse filesTier 1 (ship-blockers):
- README/verifier layer count corrected to 7-layer/10-point
- SQL keyword regex now strips quoted strings before checking
- validate_citations counts unique sources, extracts from body only
- Provider-specific error messages for all 3 LLM backends
- Missing config error handling in ingest.py entry point
Tier 2 (quality):
- SQL injection: alphanumeric validation on interpolated values
- LIKE wildcard escaping with ESCAPE clause
- Fallback builds fresh keyword query instead of re-running failed SQL
- NaN/NaT/inf filtering across all tabular readers
- Merged docx table cells deduplicated via element identity
- .env double-escaping fix in setup wizard
- Verification LLM exceptions skip correction instead of triggering it
- References check handles SQL and web sources
- Dead pre-loop validate_citations removed
- KB meta extension detection uses Path.suffix
- QU prompt omits SQL fields when no schema available
- Redundant collection.count() eliminated
- Row padding for zip truncation in Excel/CSV readers
Tier 3 (improvements):
- Gemini SDK migrated from google-generativeai to google-genai
- PyPDF2 migrated to pypdf
- ChromaDB collection caching (one client per session)
- CLI startup uses cached KB brief instead of LLM call
- Temperature/max_tokens validation and clamping
- Docker: removed deprecated version field, fixed bind mounts
- SQL INSERT batched with executemany (10-100x faster)
- Various small fixes (rdata None display, SELECT validation,
max_rows default, search error handling, log message guard,
fuzzy query caching)
Added .dockerignore for clean Docker builds.
All 174 tests passing. Design score 9/10.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- .dockerignore +18 -0
- README.md +13 -8
- app_cli.py +2 -2
- app_web.py +6 -3
- docker-compose.yml +4 -3
- ingest.py +7 -1
- requirements.txt +2 -2
- setup.py +9 -2
- src/config_loader.py +8 -3
- src/ingest.py +6 -6
- src/kb_meta.py +1 -1
- src/llm/__init__.py +3 -0
- src/llm/anthropic.py +10 -1
- src/llm/gemini.py +40 -20
- src/llm/openai.py +10 -1
- src/prompts.py +1 -1
- src/query_engine.py +1 -1
- src/readers/csv_tab.py +2 -1
- src/readers/docx.py +14 -0
- src/readers/excel.py +3 -2
- src/readers/pdf.py +1 -1
- src/readers/rdata.py +5 -4
- src/readers/stata.py +1 -1
- src/retriever.py +30 -12
- src/search/__init__.py +6 -3
- src/search/semantic_scholar.py +2 -2
- src/sql_ingest.py +7 -1
- src/sql_retriever.py +34 -11
- src/verifier.py +43 -16
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
.DS_Store
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.pyc
|
| 7 |
+
*.egg-info/
|
| 8 |
+
dist/
|
| 9 |
+
build/
|
| 10 |
+
.pytest_cache/
|
| 11 |
+
chroma_db/
|
| 12 |
+
sql_db/
|
| 13 |
+
knowledge_base/*
|
| 14 |
+
!knowledge_base/.gitkeep
|
| 15 |
+
docs/
|
| 16 |
+
tests/
|
| 17 |
+
CLAUDE.md
|
| 18 |
+
*.md
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# RAG Research Chatbot Template
|
| 2 |
|
| 3 |
-
A **citation-verified research assistant chatbot** that answers questions using your own documents -- PDFs, Word files, spreadsheets, datasets, and more. Every answer cites its sources with numbered references, and a
|
| 4 |
|
| 5 |
**Built for researchers.** No coding experience required. You provide your documents, choose an AI provider, and the chatbot does the rest.
|
| 6 |
|
|
@@ -20,7 +20,7 @@ A **citation-verified research assistant chatbot** that answers questions using
|
|
| 20 |
- [Step 1: Query Understanding](#step-1-query-understanding)
|
| 21 |
- [Step 2: Retrieval](#step-2-retrieval)
|
| 22 |
- [Step 3: Response Generation](#step-3-response-generation)
|
| 23 |
-
- [Step 4: Verification](#step-4-verification-
|
| 24 |
- [Step 5: Display](#step-5-display)
|
| 25 |
- [Configuration Reference](#configuration-reference)
|
| 26 |
- [Docker (Optional)](#docker-optional)
|
|
@@ -397,9 +397,9 @@ The system prompt tells the LLM to:
|
|
| 397 |
|
| 398 |
A **soft token cap** limits response length proportionally to context size -- less context means shorter answers, reducing the surface area for hallucination.
|
| 399 |
|
| 400 |
-
### Step 4: Verification (
|
| 401 |
|
| 402 |
-
This is the core differentiator. Before showing you the answer, it passes through
|
| 403 |
|
| 404 |
```
|
| 405 |
Layer 0: No-source gate
|
|
@@ -414,7 +414,7 @@ Layer 2: Response length cap
|
|
| 414 |
v
|
| 415 |
Layer 3: LLM self-verification loop (up to 3 iterations)
|
| 416 |
│ A second LLM call audits the response against the sources
|
| 417 |
-
│ using a
|
| 418 |
│ is corrected and re-verified. If it still fails after 3
|
| 419 |
│ attempts, the chatbot refuses to answer. (1-3 extra LLM calls)
|
| 420 |
v
|
|
@@ -423,6 +423,11 @@ Layer 4: Term-overlap check
|
|
| 423 |
│ appear in the source text. Flags claims with < 40% overlap
|
| 424 |
│ as potentially ungrounded. (Free, no LLM call)
|
| 425 |
v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 426 |
Layer 5: Warning-phrase scanner
|
| 427 |
Scans for phrases like "based on my knowledge" or "it is well
|
| 428 |
known" that suggest the LLM is using training data instead of
|
|
@@ -573,7 +578,7 @@ The container mounts your `knowledge_base/`, `chroma_db/`, `sql_db/`, `config.ya
|
|
| 573 |
|
| 574 |
### Advantages
|
| 575 |
|
| 576 |
-
- **No hallucination by design.** The
|
| 577 |
- **Full citation trail.** Every factual claim is tied to a specific source with page numbers or URLs. You can trace any claim back to the original document.
|
| 578 |
- **Works with your own documents.** Unlike general-purpose chatbots, this one answers from *your* knowledge base. Your PDFs, datasets, and codebooks are the primary authority.
|
| 579 |
- **Knows what it knows.** The chatbot builds a meta-overview during ingestion. Ask "What datasets do you have?" and it can answer, instead of refusing because no chunk matches.
|
|
@@ -625,9 +630,9 @@ This means it cannot find relevant passages in your documents. Try:
|
|
| 625 |
- Adding more relevant documents to `knowledge_base/` and re-running `python ingest.py`
|
| 626 |
- Enabling web search (`/websearch on`) to supplement with academic papers
|
| 627 |
|
| 628 |
-
###
|
| 629 |
|
| 630 |
-
|
| 631 |
|
| 632 |
### Ingestion is very slow
|
| 633 |
|
|
|
|
| 1 |
# RAG Research Chatbot Template
|
| 2 |
|
| 3 |
+
A **citation-verified research assistant chatbot** that answers questions using your own documents -- PDFs, Word files, spreadsheets, datasets, and more. Every answer cites its sources with numbered references, and a 7-layer verification system ensures the chatbot never makes things up.
|
| 4 |
|
| 5 |
**Built for researchers.** No coding experience required. You provide your documents, choose an AI provider, and the chatbot does the rest.
|
| 6 |
|
|
|
|
| 20 |
- [Step 1: Query Understanding](#step-1-query-understanding)
|
| 21 |
- [Step 2: Retrieval](#step-2-retrieval)
|
| 22 |
- [Step 3: Response Generation](#step-3-response-generation)
|
| 23 |
+
- [Step 4: Verification](#step-4-verification-7-layer-anti-hallucination-stack)
|
| 24 |
- [Step 5: Display](#step-5-display)
|
| 25 |
- [Configuration Reference](#configuration-reference)
|
| 26 |
- [Docker (Optional)](#docker-optional)
|
|
|
|
| 397 |
|
| 398 |
A **soft token cap** limits response length proportionally to context size -- less context means shorter answers, reducing the surface area for hallucination.
|
| 399 |
|
| 400 |
+
### Step 4: Verification (7-Layer Anti-Hallucination Stack)
|
| 401 |
|
| 402 |
+
This is the core differentiator. Before showing you the answer, it passes through seven verification layers:
|
| 403 |
|
| 404 |
```
|
| 405 |
Layer 0: No-source gate
|
|
|
|
| 414 |
v
|
| 415 |
Layer 3: LLM self-verification loop (up to 3 iterations)
|
| 416 |
│ A second LLM call audits the response against the sources
|
| 417 |
+
│ using a 10-point checklist. If errors are found, the response
|
| 418 |
│ is corrected and re-verified. If it still fails after 3
|
| 419 |
│ attempts, the chatbot refuses to answer. (1-3 extra LLM calls)
|
| 420 |
v
|
|
|
|
| 423 |
│ appear in the source text. Flags claims with < 40% overlap
|
| 424 |
│ as potentially ungrounded. (Free, no LLM call)
|
| 425 |
v
|
| 426 |
+
Layer 4.5: Citation audit
|
| 427 |
+
│ Checks that citation numbers [N] don't exceed actual source
|
| 428 |
+
│ count and that the References section mentions filenames from
|
| 429 |
+
│ retrieved sources. (Free, no LLM call)
|
| 430 |
+
v
|
| 431 |
Layer 5: Warning-phrase scanner
|
| 432 |
Scans for phrases like "based on my knowledge" or "it is well
|
| 433 |
known" that suggest the LLM is using training data instead of
|
|
|
|
| 578 |
|
| 579 |
### Advantages
|
| 580 |
|
| 581 |
+
- **No hallucination by design.** The 7-layer verification stack catches unsupported claims before they reach you. If the chatbot can't verify an answer, it refuses rather than guessing.
|
| 582 |
- **Full citation trail.** Every factual claim is tied to a specific source with page numbers or URLs. You can trace any claim back to the original document.
|
| 583 |
- **Works with your own documents.** Unlike general-purpose chatbots, this one answers from *your* knowledge base. Your PDFs, datasets, and codebooks are the primary authority.
|
| 584 |
- **Knows what it knows.** The chatbot builds a meta-overview during ingestion. Ask "What datasets do you have?" and it can answer, instead of refusing because no chunk matches.
|
|
|
|
| 630 |
- Adding more relevant documents to `knowledge_base/` and re-running `python ingest.py`
|
| 631 |
- Enabling web search (`/websearch on`) to supplement with academic papers
|
| 632 |
|
| 633 |
+
### PDF reader
|
| 634 |
|
| 635 |
+
This project uses `pypdf` (successor to PyPDF2) for PDF extraction.
|
| 636 |
|
| 637 |
### Ingestion is very slow
|
| 638 |
|
|
@@ -9,7 +9,7 @@ from rich.text import Text
|
|
| 9 |
|
| 10 |
from src.config_loader import load_config, get_api_key
|
| 11 |
from src.ingest import ingest_documents
|
| 12 |
-
from src.kb_meta import
|
| 13 |
from src.query_engine import understand_query
|
| 14 |
from src.retriever import retrieve
|
| 15 |
from src.verifier import verify_and_respond
|
|
@@ -217,7 +217,7 @@ def main() -> None:
|
|
| 217 |
welcome.append(". Use /websearch on to enable.\n\n")
|
| 218 |
|
| 219 |
# KB overview summary (LLM-generated welcome summary)
|
| 220 |
-
kb_summary =
|
| 221 |
if kb_summary:
|
| 222 |
welcome.append("Knowledge Base:\n", style="bold")
|
| 223 |
welcome.append(kb_summary + "\n\n", style="dim")
|
|
|
|
| 9 |
|
| 10 |
from src.config_loader import load_config, get_api_key
|
| 11 |
from src.ingest import ingest_documents
|
| 12 |
+
from src.kb_meta import load_kb_meta_brief
|
| 13 |
from src.query_engine import understand_query
|
| 14 |
from src.retriever import retrieve
|
| 15 |
from src.verifier import verify_and_respond
|
|
|
|
| 217 |
welcome.append(". Use /websearch on to enable.\n\n")
|
| 218 |
|
| 219 |
# KB overview summary (LLM-generated welcome summary)
|
| 220 |
+
kb_summary = load_kb_meta_brief(cfg)
|
| 221 |
if kb_summary:
|
| 222 |
welcome.append("Knowledge Base:\n", style="bold")
|
| 223 |
welcome.append(kb_summary + "\n\n", style="dim")
|
|
@@ -119,7 +119,8 @@ def render_sidebar():
|
|
| 119 |
try:
|
| 120 |
count = ingest_documents(cfg)
|
| 121 |
st.success(f"Ingested {count} chunks.")
|
| 122 |
-
# Clear
|
|
|
|
| 123 |
st.rerun()
|
| 124 |
except Exception as e:
|
| 125 |
st.error(f"Ingestion failed: {e}")
|
|
@@ -252,9 +253,10 @@ def render_chat():
|
|
| 252 |
if result.get("refused"):
|
| 253 |
status.update(label="No sufficient sources found.", state="error")
|
| 254 |
elif result.get("verification_passed") is True:
|
|
|
|
| 255 |
status.update(
|
| 256 |
label=f"Verified ({result.get('iterations', 0)} iteration(s)). "
|
| 257 |
-
f"{n_local} local + {n_web} web sources.",
|
| 258 |
state="complete",
|
| 259 |
)
|
| 260 |
elif result.get("verification_passed") is False:
|
|
@@ -263,8 +265,9 @@ def render_chat():
|
|
| 263 |
state="error",
|
| 264 |
)
|
| 265 |
else:
|
|
|
|
| 266 |
status.update(
|
| 267 |
-
label=f"Done. {n_local} local + {n_web} web sources.",
|
| 268 |
state="complete",
|
| 269 |
)
|
| 270 |
|
|
|
|
| 119 |
try:
|
| 120 |
count = ingest_documents(cfg)
|
| 121 |
st.success(f"Ingested {count} chunks.")
|
| 122 |
+
# Clear cached data so it refreshes after re-ingest
|
| 123 |
+
st.session_state.pop("kb_welcome_summary", None)
|
| 124 |
st.rerun()
|
| 125 |
except Exception as e:
|
| 126 |
st.error(f"Ingestion failed: {e}")
|
|
|
|
| 253 |
if result.get("refused"):
|
| 254 |
status.update(label="No sufficient sources found.", state="error")
|
| 255 |
elif result.get("verification_passed") is True:
|
| 256 |
+
sql_label = f" + {n_sql} SQL rows" if n_sql else ""
|
| 257 |
status.update(
|
| 258 |
label=f"Verified ({result.get('iterations', 0)} iteration(s)). "
|
| 259 |
+
f"{n_local} local{sql_label} + {n_web} web sources.",
|
| 260 |
state="complete",
|
| 261 |
)
|
| 262 |
elif result.get("verification_passed") is False:
|
|
|
|
| 265 |
state="error",
|
| 266 |
)
|
| 267 |
else:
|
| 268 |
+
sql_label2 = f" + {n_sql} SQL rows" if n_sql else ""
|
| 269 |
status.update(
|
| 270 |
+
label=f"Done. {n_local} local{sql_label2} + {n_web} web sources.",
|
| 271 |
state="complete",
|
| 272 |
)
|
| 273 |
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
|
| 2 |
services:
|
| 3 |
chatbot:
|
| 4 |
build: .
|
|
@@ -8,8 +8,9 @@ services:
|
|
| 8 |
- ./knowledge_base:/app/knowledge_base
|
| 9 |
- ./chroma_db:/app/chroma_db
|
| 10 |
- ./sql_db:/app/sql_db
|
| 11 |
-
- ./config.yaml:/app/config.yaml
|
| 12 |
-
|
|
|
|
| 13 |
environment:
|
| 14 |
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
| 15 |
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
|
|
| 1 |
+
# Prerequisites: config.yaml must exist. Run 'python setup.py' first.
|
| 2 |
services:
|
| 3 |
chatbot:
|
| 4 |
build: .
|
|
|
|
| 8 |
- ./knowledge_base:/app/knowledge_base
|
| 9 |
- ./chroma_db:/app/chroma_db
|
| 10 |
- ./sql_db:/app/sql_db
|
| 11 |
+
- ./config.yaml:/app/config.yaml:ro # Must exist before running
|
| 12 |
+
# API keys: set via environment variables below (preferred)
|
| 13 |
+
# or mount .env file: - ./.env:/app/.env:ro
|
| 14 |
environment:
|
| 15 |
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
| 16 |
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
|
@@ -1,11 +1,17 @@
|
|
| 1 |
"""Entry point for document ingestion."""
|
| 2 |
|
|
|
|
|
|
|
| 3 |
from src.ingest import ingest_documents
|
| 4 |
from src.config_loader import load_config
|
| 5 |
|
| 6 |
|
| 7 |
if __name__ == "__main__":
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
count = ingest_documents(cfg)
|
| 10 |
if count == 0:
|
| 11 |
print("\nNo documents were ingested. Add files to knowledge_base/ and try again.")
|
|
|
|
| 1 |
"""Entry point for document ingestion."""
|
| 2 |
|
| 3 |
+
import sys
|
| 4 |
+
|
| 5 |
from src.ingest import ingest_documents
|
| 6 |
from src.config_loader import load_config
|
| 7 |
|
| 8 |
|
| 9 |
if __name__ == "__main__":
|
| 10 |
+
try:
|
| 11 |
+
cfg = load_config()
|
| 12 |
+
except FileNotFoundError:
|
| 13 |
+
print("Config file not found. Run 'python setup.py' first.")
|
| 14 |
+
sys.exit(1)
|
| 15 |
count = ingest_documents(cfg)
|
| 16 |
if count == 0:
|
| 17 |
print("\nNo documents were ingested. Add files to knowledge_base/ and try again.")
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Core
|
| 2 |
chromadb>=0.4.0
|
| 3 |
-
|
| 4 |
python-docx>=1.0.0
|
| 5 |
openpyxl>=3.0.0
|
| 6 |
xlrd>=2.0.0
|
|
@@ -19,7 +19,7 @@ tokenizers>=0.13.0
|
|
| 19 |
# LLM providers
|
| 20 |
openai>=1.0.0
|
| 21 |
anthropic>=0.20.0
|
| 22 |
-
google-
|
| 23 |
|
| 24 |
# Web UI
|
| 25 |
streamlit>=1.30.0
|
|
|
|
| 1 |
# Core
|
| 2 |
chromadb>=0.4.0
|
| 3 |
+
pypdf>=4.0.0
|
| 4 |
python-docx>=1.0.0
|
| 5 |
openpyxl>=3.0.0
|
| 6 |
xlrd>=2.0.0
|
|
|
|
| 19 |
# LLM providers
|
| 20 |
openai>=1.0.0
|
| 21 |
anthropic>=0.20.0
|
| 22 |
+
google-genai>=1.0.0
|
| 23 |
|
| 24 |
# Web UI
|
| 25 |
streamlit>=1.30.0
|
|
@@ -91,7 +91,13 @@ def generate_env(provider: str, api_key: str, existing_env_path: str = None) ->
|
|
| 91 |
line = line.strip()
|
| 92 |
if line and not line.startswith("#") and "=" in line:
|
| 93 |
k, v = line.split("=", 1)
|
| 94 |
-
v = v.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
existing[k.strip()] = v
|
| 96 |
|
| 97 |
# Update with the new key
|
|
@@ -99,7 +105,8 @@ def generate_env(provider: str, api_key: str, existing_env_path: str = None) ->
|
|
| 99 |
|
| 100 |
lines = ["# Auto-generated by setup wizard"]
|
| 101 |
for k, v in sorted(existing.items()):
|
| 102 |
-
|
|
|
|
| 103 |
lines.append("")
|
| 104 |
return "\n".join(lines)
|
| 105 |
|
|
|
|
| 91 |
line = line.strip()
|
| 92 |
if line and not line.startswith("#") and "=" in line:
|
| 93 |
k, v = line.split("=", 1)
|
| 94 |
+
v = v.strip()
|
| 95 |
+
# Unwrap one matched pair of quotes
|
| 96 |
+
if (v.startswith('"') and v.endswith('"')) or \
|
| 97 |
+
(v.startswith("'") and v.endswith("'")):
|
| 98 |
+
v = v[1:-1]
|
| 99 |
+
# Unescape previously escaped characters
|
| 100 |
+
v = v.replace('\\"', '"').replace('\\\\', '\\')
|
| 101 |
existing[k.strip()] = v
|
| 102 |
|
| 103 |
# Update with the new key
|
|
|
|
| 105 |
|
| 106 |
lines = ["# Auto-generated by setup wizard"]
|
| 107 |
for k, v in sorted(existing.items()):
|
| 108 |
+
v_escaped = v.replace('\\', '\\\\').replace('"', '\\"')
|
| 109 |
+
lines.append(f'{k}="{v_escaped}"')
|
| 110 |
lines.append("")
|
| 111 |
return "\n".join(lines)
|
| 112 |
|
|
@@ -32,9 +32,14 @@ def load_config(config_path: str = None) -> dict:
|
|
| 32 |
|
| 33 |
# Normalize None-valued sections to empty dicts so chained .get() never
|
| 34 |
# fails with AttributeError (e.g. `llm:` with no sub-keys → None).
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
for provider, env_var in _ENV_KEY_MAP.items():
|
| 40 |
env_val = os.environ.get(env_var)
|
|
|
|
| 32 |
|
| 33 |
# Normalize None-valued sections to empty dicts so chained .get() never
|
| 34 |
# fails with AttributeError (e.g. `llm:` with no sub-keys → None).
|
| 35 |
+
# Recurse into nested dicts so `paths:\n vector_db:` also gets normalized.
|
| 36 |
+
def _normalize_nulls(d):
|
| 37 |
+
for key in list(d.keys()):
|
| 38 |
+
if d[key] is None:
|
| 39 |
+
d[key] = {}
|
| 40 |
+
elif isinstance(d[key], dict):
|
| 41 |
+
_normalize_nulls(d[key])
|
| 42 |
+
_normalize_nulls(cfg)
|
| 43 |
|
| 44 |
for provider, env_var in _ENV_KEY_MAP.items():
|
| 45 |
env_val = os.environ.get(env_var)
|
|
@@ -196,11 +196,14 @@ def ingest_documents(cfg: dict = None, documents_dir: str = None) -> int:
|
|
| 196 |
if not os.path.isabs(db_path):
|
| 197 |
project_root = Path(__file__).resolve().parent.parent
|
| 198 |
db_path = os.path.join(str(project_root), db_path)
|
|
|
|
| 199 |
for meta_name in ("kb_meta.txt", "kb_meta_brief.txt"):
|
| 200 |
meta_file = os.path.join(db_path, meta_name)
|
| 201 |
if os.path.exists(meta_file):
|
| 202 |
os.remove(meta_file)
|
| 203 |
-
|
|
|
|
|
|
|
| 204 |
except Exception as e:
|
| 205 |
print(f"Warning: Could not clear KB meta: {e}")
|
| 206 |
return 0
|
|
@@ -288,18 +291,15 @@ def ingest_documents(cfg: dict = None, documents_dir: str = None) -> int:
|
|
| 288 |
print(f"SQL ingestion error (non-fatal): {e}")
|
| 289 |
|
| 290 |
# ── KB meta overview (LLM-generated) ────────────────────────────
|
| 291 |
-
# Clear stale meta files before regenerating
|
| 292 |
_db_path = cfg.get("paths", {}).get("vector_db", "chroma_db")
|
| 293 |
if not os.path.isabs(_db_path):
|
| 294 |
_db_path = os.path.join(str(Path(__file__).resolve().parent.parent), _db_path)
|
| 295 |
-
for _meta_name in ("kb_meta.txt", "kb_meta_brief.txt"):
|
| 296 |
-
_meta_file = os.path.join(_db_path, _meta_name)
|
| 297 |
-
if os.path.exists(_meta_file):
|
| 298 |
-
os.remove(_meta_file)
|
| 299 |
|
| 300 |
print("\nGenerating knowledge base overview...")
|
| 301 |
try:
|
| 302 |
from src.kb_meta import build_and_store_overview
|
|
|
|
|
|
|
| 303 |
overview = build_and_store_overview(collection, cfg)
|
| 304 |
if overview:
|
| 305 |
print("KB overview generated and indexed.")
|
|
|
|
| 196 |
if not os.path.isabs(db_path):
|
| 197 |
project_root = Path(__file__).resolve().parent.parent
|
| 198 |
db_path = os.path.join(str(project_root), db_path)
|
| 199 |
+
cleared_any = False
|
| 200 |
for meta_name in ("kb_meta.txt", "kb_meta_brief.txt"):
|
| 201 |
meta_file = os.path.join(db_path, meta_name)
|
| 202 |
if os.path.exists(meta_file):
|
| 203 |
os.remove(meta_file)
|
| 204 |
+
cleared_any = True
|
| 205 |
+
if cleared_any:
|
| 206 |
+
print("Cleared stale KB overview.")
|
| 207 |
except Exception as e:
|
| 208 |
print(f"Warning: Could not clear KB meta: {e}")
|
| 209 |
return 0
|
|
|
|
| 291 |
print(f"SQL ingestion error (non-fatal): {e}")
|
| 292 |
|
| 293 |
# ── KB meta overview (LLM-generated) ────────────────────────────
|
|
|
|
| 294 |
_db_path = cfg.get("paths", {}).get("vector_db", "chroma_db")
|
| 295 |
if not os.path.isabs(_db_path):
|
| 296 |
_db_path = os.path.join(str(Path(__file__).resolve().parent.parent), _db_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
print("\nGenerating knowledge base overview...")
|
| 299 |
try:
|
| 300 |
from src.kb_meta import build_and_store_overview
|
| 301 |
+
# build_and_store_overview overwrites meta files via write_text();
|
| 302 |
+
# no pre-deletion needed — old files stay intact if generation fails.
|
| 303 |
overview = build_and_store_overview(collection, cfg)
|
| 304 |
if overview:
|
| 305 |
print("KB overview generated and indexed.")
|
|
@@ -281,7 +281,7 @@ def collect_file_records(collection, *, _cache: tuple = None) -> list[dict]:
|
|
| 281 |
if source == META_SOURCE:
|
| 282 |
continue
|
| 283 |
if source not in source_info:
|
| 284 |
-
ext = Path(source).suffix
|
| 285 |
source_info[source] = {
|
| 286 |
"source": source,
|
| 287 |
"dataset": meta.get("dataset", "general"),
|
|
|
|
| 281 |
if source == META_SOURCE:
|
| 282 |
continue
|
| 283 |
if source not in source_info:
|
| 284 |
+
ext = Path(source).suffix
|
| 285 |
source_info[source] = {
|
| 286 |
"source": source,
|
| 287 |
"dataset": meta.get("dataset", "general"),
|
|
@@ -23,6 +23,9 @@ def generate(system_prompt: str, user_message: str, cfg: dict,
|
|
| 23 |
temperature = llm_cfg.get("temperature", 0.0)
|
| 24 |
if max_tokens is None:
|
| 25 |
max_tokens = llm_cfg.get("max_tokens", 2048)
|
|
|
|
|
|
|
|
|
|
| 26 |
return PROVIDERS[provider].generate(
|
| 27 |
system_prompt=system_prompt, user_message=user_message,
|
| 28 |
api_key=api_key, model=model, temperature=temperature, max_tokens=max_tokens,
|
|
|
|
| 23 |
temperature = llm_cfg.get("temperature", 0.0)
|
| 24 |
if max_tokens is None:
|
| 25 |
max_tokens = llm_cfg.get("max_tokens", 2048)
|
| 26 |
+
# Validate parameters
|
| 27 |
+
temperature = max(0.0, min(float(temperature), 2.0))
|
| 28 |
+
max_tokens = max(1, min(int(max_tokens), 128000))
|
| 29 |
return PROVIDERS[provider].generate(
|
| 30 |
system_prompt=system_prompt, user_message=user_message,
|
| 31 |
api_key=api_key, model=model, temperature=temperature, max_tokens=max_tokens,
|
|
@@ -18,7 +18,16 @@ def generate(system_prompt: str, user_message: str, api_key: str,
|
|
| 18 |
return response.content[0].text or ""
|
| 19 |
except Exception as e:
|
| 20 |
error_type = type(e).__name__
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
def list_models(api_key: str) -> list[str]:
|
| 24 |
return list(SUPPORTED_MODELS)
|
|
|
|
| 18 |
return response.content[0].text or ""
|
| 19 |
except Exception as e:
|
| 20 |
error_type = type(e).__name__
|
| 21 |
+
error_msg = str(e)
|
| 22 |
+
# Provide specific guidance based on error type
|
| 23 |
+
if "auth" in error_type.lower() or "authentication" in error_type.lower():
|
| 24 |
+
raise RuntimeError(f"Anthropic authentication failed. Check your API key.") from e
|
| 25 |
+
elif "rate" in error_type.lower() or "429" in error_msg:
|
| 26 |
+
raise RuntimeError(f"Anthropic rate limit exceeded. Wait a moment and try again.") from e
|
| 27 |
+
elif "model" in error_msg.lower() or "not found" in error_msg.lower():
|
| 28 |
+
raise RuntimeError(f"Anthropic model error: {error_msg}") from e
|
| 29 |
+
else:
|
| 30 |
+
raise RuntimeError(f"Anthropic API error ({error_type}): {error_msg}") from e
|
| 31 |
|
| 32 |
def list_models(api_key: str) -> list[str]:
|
| 33 |
return list(SUPPORTED_MODELS)
|
|
@@ -1,29 +1,49 @@
|
|
| 1 |
-
"""Google Gemini LLM provider."""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
def generate(system_prompt: str, user_message: str, api_key: str,
|
| 5 |
model: str = None, temperature: float = 0.0,
|
| 6 |
max_tokens: int = 2048) -> str:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
generation_config=genai.GenerationConfig(temperature=temperature, max_output_tokens=max_tokens),
|
| 13 |
-
)
|
| 14 |
-
response = None
|
| 15 |
try:
|
| 16 |
-
response =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return response.text
|
| 18 |
-
except ValueError:
|
| 19 |
-
#
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
def list_models(api_key: str) -> list[str]:
|
| 29 |
return list(SUPPORTED_MODELS)
|
|
|
|
| 1 |
+
"""Google Gemini LLM provider (google-genai SDK)."""
|
| 2 |
+
|
| 3 |
+
SUPPORTED_MODELS = [
|
| 4 |
+
"gemini-2.5-pro",
|
| 5 |
+
"gemini-2.5-flash",
|
| 6 |
+
"gemini-2.0-flash",
|
| 7 |
+
]
|
| 8 |
+
|
| 9 |
|
| 10 |
def generate(system_prompt: str, user_message: str, api_key: str,
|
| 11 |
model: str = None, temperature: float = 0.0,
|
| 12 |
max_tokens: int = 2048) -> str:
|
| 13 |
+
from google import genai
|
| 14 |
+
|
| 15 |
+
model = model or SUPPORTED_MODELS[0]
|
| 16 |
+
client = genai.Client(api_key=api_key)
|
| 17 |
+
|
|
|
|
|
|
|
|
|
|
| 18 |
try:
|
| 19 |
+
response = client.models.generate_content(
|
| 20 |
+
model=model,
|
| 21 |
+
contents=user_message,
|
| 22 |
+
config=genai.types.GenerateContentConfig(
|
| 23 |
+
system_instruction=system_prompt,
|
| 24 |
+
temperature=temperature,
|
| 25 |
+
max_output_tokens=max_tokens,
|
| 26 |
+
),
|
| 27 |
+
)
|
| 28 |
+
# Check for blocked responses
|
| 29 |
+
if not response.text:
|
| 30 |
+
return ""
|
| 31 |
return response.text
|
| 32 |
+
except ValueError as e:
|
| 33 |
+
# Safety filter block
|
| 34 |
+
return f"[Gemini blocked: {e}]"
|
| 35 |
+
except Exception as e:
|
| 36 |
+
error_type = type(e).__name__
|
| 37 |
+
error_msg = str(e)
|
| 38 |
+
if "auth" in error_type.lower() or "authentication" in error_type.lower() or "api_key" in error_msg.lower():
|
| 39 |
+
raise RuntimeError(f"Gemini authentication failed. Check your API key.") from e
|
| 40 |
+
elif "rate" in error_type.lower() or "429" in error_msg:
|
| 41 |
+
raise RuntimeError(f"Gemini rate limit exceeded. Wait a moment and try again.") from e
|
| 42 |
+
elif "model" in error_msg.lower() or "not found" in error_msg.lower():
|
| 43 |
+
raise RuntimeError(f"Gemini model error: {error_msg}") from e
|
| 44 |
+
else:
|
| 45 |
+
raise RuntimeError(f"Gemini API error ({error_type}): {error_msg}") from e
|
| 46 |
+
|
| 47 |
|
| 48 |
def list_models(api_key: str) -> list[str]:
|
| 49 |
return list(SUPPORTED_MODELS)
|
|
@@ -38,7 +38,16 @@ def generate(system_prompt: str, user_message: str, api_key: str,
|
|
| 38 |
return response.choices[0].message.content or ""
|
| 39 |
except Exception as e:
|
| 40 |
error_type = type(e).__name__
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
def list_models(api_key: str) -> list[str]:
|
| 44 |
return list(SUPPORTED_MODELS)
|
|
|
|
| 38 |
return response.choices[0].message.content or ""
|
| 39 |
except Exception as e:
|
| 40 |
error_type = type(e).__name__
|
| 41 |
+
error_msg = str(e)
|
| 42 |
+
# Provide specific guidance based on error type
|
| 43 |
+
if "auth" in error_type.lower() or "authentication" in error_type.lower():
|
| 44 |
+
raise RuntimeError(f"OpenAI authentication failed. Check your API key.") from e
|
| 45 |
+
elif "rate" in error_type.lower() or "429" in error_msg:
|
| 46 |
+
raise RuntimeError(f"OpenAI rate limit exceeded. Wait a moment and try again.") from e
|
| 47 |
+
elif "model" in error_msg.lower() or "not found" in error_msg.lower():
|
| 48 |
+
raise RuntimeError(f"OpenAI model error: {error_msg}") from e
|
| 49 |
+
else:
|
| 50 |
+
raise RuntimeError(f"OpenAI API error ({error_type}): {error_msg}") from e
|
| 51 |
|
| 52 |
def list_models(api_key: str) -> list[str]:
|
| 53 |
return list(SUPPORTED_MODELS)
|
|
@@ -254,7 +254,7 @@ def build_query_understanding_prompt(
|
|
| 254 |
schema_text=sql_schema_summary
|
| 255 |
)
|
| 256 |
else:
|
| 257 |
-
sql_routing_block = ""
|
| 258 |
|
| 259 |
if kb_overview:
|
| 260 |
kb_overview_block = (
|
|
|
|
| 254 |
schema_text=sql_schema_summary
|
| 255 |
)
|
| 256 |
else:
|
| 257 |
+
sql_routing_block = '(No SQL tables available — always use route "vector" and omit sql_query.)\n'
|
| 258 |
|
| 259 |
if kb_overview:
|
| 260 |
kb_overview_block = (
|
|
@@ -93,7 +93,7 @@ def understand_query(
|
|
| 93 |
"You are a query reformulation assistant. Return only JSON.",
|
| 94 |
prompt,
|
| 95 |
cfg,
|
| 96 |
-
max_tokens=
|
| 97 |
)
|
| 98 |
result = _parse_qu_result(raw, user_query)
|
| 99 |
except Exception:
|
|
|
|
| 93 |
"You are a query reformulation assistant. Return only JSON.",
|
| 94 |
prompt,
|
| 95 |
cfg,
|
| 96 |
+
max_tokens=512,
|
| 97 |
)
|
| 98 |
result = _parse_qu_result(raw, user_query)
|
| 99 |
except Exception:
|
|
@@ -19,7 +19,8 @@ def read_csv_tab(file_path: str, delimiter: str = None) -> list[dict]:
|
|
| 19 |
row_texts = []
|
| 20 |
for row in rows[1:]:
|
| 21 |
parts = []
|
| 22 |
-
|
|
|
|
| 23 |
val = val.strip().strip('"')
|
| 24 |
if val:
|
| 25 |
parts.append(f"{header}: {val}")
|
|
|
|
| 19 |
row_texts = []
|
| 20 |
for row in rows[1:]:
|
| 21 |
parts = []
|
| 22 |
+
padded_row = list(row) + [""] * max(0, len(headers) - len(row))
|
| 23 |
+
for header, val in zip(headers, padded_row[:len(headers)]):
|
| 24 |
val = val.strip().strip('"')
|
| 25 |
if val:
|
| 26 |
parts.append(f"{header}: {val}")
|
|
@@ -5,6 +5,20 @@ def read_docx(file_path: str) -> list[dict]:
|
|
| 5 |
from docx import Document
|
| 6 |
doc = Document(file_path)
|
| 7 |
paragraphs = [p.text.strip() for p in doc.paragraphs if p.text.strip()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
if not paragraphs:
|
| 9 |
return []
|
| 10 |
return [{"page": 1, "text": "\n\n".join(paragraphs)}]
|
|
|
|
| 5 |
from docx import Document
|
| 6 |
doc = Document(file_path)
|
| 7 |
paragraphs = [p.text.strip() for p in doc.paragraphs if p.text.strip()]
|
| 8 |
+
|
| 9 |
+
# Extract embedded tables
|
| 10 |
+
for table in doc.tables:
|
| 11 |
+
for row in table.rows:
|
| 12 |
+
seen_elements = set()
|
| 13 |
+
cells = []
|
| 14 |
+
for cell in row.cells:
|
| 15 |
+
if id(cell._element) not in seen_elements:
|
| 16 |
+
seen_elements.add(id(cell._element))
|
| 17 |
+
if cell.text.strip():
|
| 18 |
+
cells.append(cell.text.strip())
|
| 19 |
+
if cells:
|
| 20 |
+
paragraphs.append(" | ".join(cells))
|
| 21 |
+
|
| 22 |
if not paragraphs:
|
| 23 |
return []
|
| 24 |
return [{"page": 1, "text": "\n\n".join(paragraphs)}]
|
|
@@ -44,8 +44,9 @@ def _rows_to_pages(rows: list[tuple], sheet_name: str) -> list[dict]:
|
|
| 44 |
row_texts = []
|
| 45 |
for row in rows[1:]:
|
| 46 |
parts = []
|
| 47 |
-
|
| 48 |
-
|
|
|
|
| 49 |
parts.append(f"{header}: {val}")
|
| 50 |
if parts:
|
| 51 |
row_texts.append("; ".join(parts))
|
|
|
|
| 44 |
row_texts = []
|
| 45 |
for row in rows[1:]:
|
| 46 |
parts = []
|
| 47 |
+
padded_row = list(row) + [None] * max(0, len(headers) - len(row))
|
| 48 |
+
for header, val in zip(headers, padded_row[:len(headers)]):
|
| 49 |
+
if val is not None and str(val).strip() and str(val).lower() not in ("nan", "nat", "<na>", "inf", "-inf"):
|
| 50 |
parts.append(f"{header}: {val}")
|
| 51 |
if parts:
|
| 52 |
row_texts.append("; ".join(parts))
|
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
|
| 4 |
def read_pdf(file_path: str) -> list[dict]:
|
| 5 |
-
from
|
| 6 |
reader = PdfReader(file_path)
|
| 7 |
pages = []
|
| 8 |
for i, page in enumerate(reader.pages):
|
|
|
|
| 2 |
|
| 3 |
|
| 4 |
def read_pdf(file_path: str) -> list[dict]:
|
| 5 |
+
from pypdf import PdfReader
|
| 6 |
reader = PdfReader(file_path)
|
| 7 |
pages = []
|
| 8 |
for i, page in enumerate(reader.pages):
|
|
@@ -6,8 +6,9 @@ def read_rdata(file_path: str) -> list[dict]:
|
|
| 6 |
result = pyreadr.read_r(file_path)
|
| 7 |
pages = []
|
| 8 |
for name, df in result.items():
|
|
|
|
| 9 |
headers = list(df.columns)
|
| 10 |
-
header_line = f"Object: {
|
| 11 |
block = []
|
| 12 |
block_chars = len(header_line)
|
| 13 |
block_start = 1
|
|
@@ -15,14 +16,14 @@ def read_rdata(file_path: str) -> list[dict]:
|
|
| 15 |
parts = []
|
| 16 |
for col in headers:
|
| 17 |
val = row[col]
|
| 18 |
-
if val is not None and str(val).strip() and str(val)
|
| 19 |
parts.append(f"{col}: {val}")
|
| 20 |
if not parts:
|
| 21 |
continue
|
| 22 |
row_text = "; ".join(parts)
|
| 23 |
if block and block_chars + len(row_text) + 1 > MAX_CHUNK_CHARS:
|
| 24 |
text = header_line + "\n".join(block)
|
| 25 |
-
pages.append({"page": f"{
|
| 26 |
block = []
|
| 27 |
block_chars = len(header_line)
|
| 28 |
block_start = idx + 1
|
|
@@ -30,5 +31,5 @@ def read_rdata(file_path: str) -> list[dict]:
|
|
| 30 |
block_chars += len(row_text) + 1
|
| 31 |
if block:
|
| 32 |
text = header_line + "\n".join(block)
|
| 33 |
-
pages.append({"page": f"{
|
| 34 |
return pages
|
|
|
|
| 6 |
result = pyreadr.read_r(file_path)
|
| 7 |
pages = []
|
| 8 |
for name, df in result.items():
|
| 9 |
+
name_display = name if name is not None else "data"
|
| 10 |
headers = list(df.columns)
|
| 11 |
+
header_line = f"Object: {name_display} | Columns: {', '.join(headers)}\n"
|
| 12 |
block = []
|
| 13 |
block_chars = len(header_line)
|
| 14 |
block_start = 1
|
|
|
|
| 16 |
parts = []
|
| 17 |
for col in headers:
|
| 18 |
val = row[col]
|
| 19 |
+
if val is not None and str(val).strip() and str(val).lower() not in ("nan", "nat", "<na>", "inf", "-inf"):
|
| 20 |
parts.append(f"{col}: {val}")
|
| 21 |
if not parts:
|
| 22 |
continue
|
| 23 |
row_text = "; ".join(parts)
|
| 24 |
if block and block_chars + len(row_text) + 1 > MAX_CHUNK_CHARS:
|
| 25 |
text = header_line + "\n".join(block)
|
| 26 |
+
pages.append({"page": f"{name_display}_rows_{block_start}-{block_start + len(block) - 1}", "text": text})
|
| 27 |
block = []
|
| 28 |
block_chars = len(header_line)
|
| 29 |
block_start = idx + 1
|
|
|
|
| 31 |
block_chars += len(row_text) + 1
|
| 32 |
if block:
|
| 33 |
text = header_line + "\n".join(block)
|
| 34 |
+
pages.append({"page": f"{name_display}_rows_{block_start}-{block_start + len(block) - 1}", "text": text})
|
| 35 |
return pages
|
|
@@ -29,7 +29,7 @@ def _dataframe_to_pages(df, meta) -> list[dict]:
|
|
| 29 |
parts = []
|
| 30 |
for col in headers:
|
| 31 |
val = row[col]
|
| 32 |
-
if val is not None and str(val).strip() and str(val)
|
| 33 |
parts.append(f"{col}: {val}")
|
| 34 |
if not parts:
|
| 35 |
continue
|
|
|
|
| 29 |
parts = []
|
| 30 |
for col in headers:
|
| 31 |
val = row[col]
|
| 32 |
+
if val is not None and str(val).strip() and str(val).lower() not in ("nan", "nat", "<na>", "inf", "-inf"):
|
| 33 |
parts.append(f"{col}: {val}")
|
| 34 |
if not parts:
|
| 35 |
continue
|
|
@@ -4,6 +4,20 @@ import re
|
|
| 4 |
|
| 5 |
from src.search import search, format_web_results_as_context
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
NO_SOURCES_REFUSAL = (
|
| 8 |
"I don't have any information on this topic in my knowledge base. "
|
| 9 |
"No relevant local documents or web sources were found. "
|
|
@@ -23,18 +37,17 @@ def retrieve_from_vectordb(query: str, cfg: dict) -> list[dict]:
|
|
| 23 |
is included as a fallback so that meta-questions about the knowledge
|
| 24 |
base can still be answered.
|
| 25 |
"""
|
| 26 |
-
from src.ingest import get_chroma_collection
|
| 27 |
-
|
| 28 |
retrieval_cfg = cfg.get("retrieval", {})
|
| 29 |
top_k = retrieval_cfg.get("top_k", 50)
|
| 30 |
max_distance = retrieval_cfg.get("max_distance", 0.55)
|
| 31 |
-
collection =
|
| 32 |
|
| 33 |
-
|
|
|
|
| 34 |
return []
|
| 35 |
|
| 36 |
# Query a large candidate pool, then filter by relevance
|
| 37 |
-
candidate_count = min(top_k,
|
| 38 |
results = collection.query(
|
| 39 |
query_texts=[query],
|
| 40 |
n_results=candidate_count,
|
|
@@ -193,6 +206,8 @@ def _build_fallback_sql_query(query: str, cfg: dict) -> str | None:
|
|
| 193 |
for col in text_cols:
|
| 194 |
for word in words:
|
| 195 |
safe_word = word.replace("'", "''")
|
|
|
|
|
|
|
| 196 |
conditions.append(f'"{col}" LIKE \'%{safe_word}%\'')
|
| 197 |
|
| 198 |
if conditions:
|
|
@@ -265,6 +280,9 @@ def _try_alternate_columns(sql_query: str, cfg: dict) -> tuple[list[dict], str]
|
|
| 265 |
|
| 266 |
max_rows = cfg.get("sql", {}).get("max_rows", 200)
|
| 267 |
safe_value = search_value.replace("'", "''")
|
|
|
|
|
|
|
|
|
|
| 268 |
for col in text_cols:
|
| 269 |
alt_query = f'SELECT * FROM "{table_name}" WHERE "{col}" LIKE \'%{safe_value}%\' LIMIT {max_rows}'
|
| 270 |
rows = execute_sql_query(alt_query, cfg)
|
|
@@ -316,7 +334,7 @@ def _run_sql_retrieval(sql_query: str, cfg: dict) -> tuple[list[dict], str, str]
|
|
| 316 |
# Step 3: if still no results, try word-level fuzzy (LIKE '%Korea%')
|
| 317 |
if not sql_rows:
|
| 318 |
word_fuzzy = make_fuzzy_query(sql_query, word_level=True)
|
| 319 |
-
if word_fuzzy and word_fuzzy != (
|
| 320 |
sql_rows = execute_sql_query(word_fuzzy, cfg)
|
| 321 |
if sql_rows:
|
| 322 |
effective_query = word_fuzzy
|
|
@@ -377,7 +395,7 @@ def retrieve(
|
|
| 377 |
and not sql_rows
|
| 378 |
and (
|
| 379 |
(route in ("vector", "both") and not db_results)
|
| 380 |
-
or route == "sql"
|
| 381 |
)
|
| 382 |
)
|
| 383 |
if sql_fallback_needed:
|
|
@@ -387,11 +405,11 @@ def retrieve(
|
|
| 387 |
if fallback_query:
|
| 388 |
sql_rows, sql_context, sql_match_type = _run_sql_retrieval(fallback_query, cfg)
|
| 389 |
else:
|
| 390 |
-
# route is "vector" or "both" —
|
| 391 |
-
if
|
| 392 |
-
|
| 393 |
-
if
|
| 394 |
-
sql_rows, sql_context, sql_match_type = _run_sql_retrieval(
|
| 395 |
|
| 396 |
# ── Web search ───────────────────────────────────────────────────
|
| 397 |
web_enabled = cfg.get("web_search", {}).get("enabled", False)
|
|
|
|
| 4 |
|
| 5 |
from src.search import search, format_web_results_as_context
|
| 6 |
|
| 7 |
+
_collection_cache = {}
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _get_cached_collection(cfg: dict):
|
| 11 |
+
"""Get or create cached ChromaDB collection."""
|
| 12 |
+
from src.ingest import get_chroma_collection
|
| 13 |
+
db_path = cfg.get("paths", {}).get("vector_db", "chroma_db")
|
| 14 |
+
embed_provider = cfg.get("embeddings", {}).get("provider", "local")
|
| 15 |
+
cache_key = f"{db_path}:{embed_provider}"
|
| 16 |
+
if cache_key not in _collection_cache:
|
| 17 |
+
_collection_cache[cache_key] = get_chroma_collection(cfg)
|
| 18 |
+
return _collection_cache[cache_key]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
NO_SOURCES_REFUSAL = (
|
| 22 |
"I don't have any information on this topic in my knowledge base. "
|
| 23 |
"No relevant local documents or web sources were found. "
|
|
|
|
| 37 |
is included as a fallback so that meta-questions about the knowledge
|
| 38 |
base can still be answered.
|
| 39 |
"""
|
|
|
|
|
|
|
| 40 |
retrieval_cfg = cfg.get("retrieval", {})
|
| 41 |
top_k = retrieval_cfg.get("top_k", 50)
|
| 42 |
max_distance = retrieval_cfg.get("max_distance", 0.55)
|
| 43 |
+
collection = _get_cached_collection(cfg)
|
| 44 |
|
| 45 |
+
total = collection.count()
|
| 46 |
+
if total == 0:
|
| 47 |
return []
|
| 48 |
|
| 49 |
# Query a large candidate pool, then filter by relevance
|
| 50 |
+
candidate_count = min(top_k, total)
|
| 51 |
results = collection.query(
|
| 52 |
query_texts=[query],
|
| 53 |
n_results=candidate_count,
|
|
|
|
| 206 |
for col in text_cols:
|
| 207 |
for word in words:
|
| 208 |
safe_word = word.replace("'", "''")
|
| 209 |
+
if not re.match(r'^\w+$', safe_word):
|
| 210 |
+
continue # Skip non-alphanumeric words
|
| 211 |
conditions.append(f'"{col}" LIKE \'%{safe_word}%\'')
|
| 212 |
|
| 213 |
if conditions:
|
|
|
|
| 280 |
|
| 281 |
max_rows = cfg.get("sql", {}).get("max_rows", 200)
|
| 282 |
safe_value = search_value.replace("'", "''")
|
| 283 |
+
# Strip dangerous characters for defense-in-depth
|
| 284 |
+
if not re.match(r"^[\w\s.,'-]+$", safe_value):
|
| 285 |
+
safe_value = re.sub(r"[;'\\\"]", "", safe_value)
|
| 286 |
for col in text_cols:
|
| 287 |
alt_query = f'SELECT * FROM "{table_name}" WHERE "{col}" LIKE \'%{safe_value}%\' LIMIT {max_rows}'
|
| 288 |
rows = execute_sql_query(alt_query, cfg)
|
|
|
|
| 334 |
# Step 3: if still no results, try word-level fuzzy (LIKE '%Korea%')
|
| 335 |
if not sql_rows:
|
| 336 |
word_fuzzy = make_fuzzy_query(sql_query, word_level=True)
|
| 337 |
+
if word_fuzzy and word_fuzzy != (fuzzy or ""):
|
| 338 |
sql_rows = execute_sql_query(word_fuzzy, cfg)
|
| 339 |
if sql_rows:
|
| 340 |
effective_query = word_fuzzy
|
|
|
|
| 395 |
and not sql_rows
|
| 396 |
and (
|
| 397 |
(route in ("vector", "both") and not db_results)
|
| 398 |
+
or (route == "sql" and not db_results)
|
| 399 |
)
|
| 400 |
)
|
| 401 |
if sql_fallback_needed:
|
|
|
|
| 405 |
if fallback_query:
|
| 406 |
sql_rows, sql_context, sql_match_type = _run_sql_retrieval(fallback_query, cfg)
|
| 407 |
else:
|
| 408 |
+
# route is "vector" or "both" — build fresh keyword query
|
| 409 |
+
# (if sql_query exists, it was already tried above and returned nothing)
|
| 410 |
+
fallback_sql = _build_fallback_sql_query(query, cfg)
|
| 411 |
+
if fallback_sql:
|
| 412 |
+
sql_rows, sql_context, sql_match_type = _run_sql_retrieval(fallback_sql, cfg)
|
| 413 |
|
| 414 |
# ── Web search ───────────────────────────────────────────────────
|
| 415 |
web_enabled = cfg.get("web_search", {}).get("enabled", False)
|
|
@@ -6,11 +6,14 @@ BACKENDS = {
|
|
| 6 |
"none": lambda query, limit=5: [],
|
| 7 |
}
|
| 8 |
|
| 9 |
-
def search(query: str, backend: str = "
|
| 10 |
fn = BACKENDS.get(backend)
|
| 11 |
if fn is None:
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
def format_web_results_as_context(results: list[dict]) -> str:
|
| 16 |
if not results:
|
|
|
|
| 6 |
"none": lambda query, limit=5: [],
|
| 7 |
}
|
| 8 |
|
| 9 |
+
def search(query: str, backend: str = "none", limit: int = 5) -> list[dict]:
|
| 10 |
fn = BACKENDS.get(backend)
|
| 11 |
if fn is None:
|
| 12 |
+
return []
|
| 13 |
+
try:
|
| 14 |
+
return fn(query, limit=limit)
|
| 15 |
+
except Exception:
|
| 16 |
+
return []
|
| 17 |
|
| 18 |
def format_web_results_as_context(results: list[dict]) -> str:
|
| 19 |
if not results:
|
|
@@ -44,7 +44,7 @@ def search_papers(query: str, limit: int = 5) -> list[dict]:
|
|
| 44 |
results.append({
|
| 45 |
"title": paper.get("title", "Untitled"), "authors": authors,
|
| 46 |
"year": paper.get("year"), "abstract": abstract, "url": url,
|
| 47 |
-
"citation_count": paper.get("citationCount"
|
| 48 |
})
|
| 49 |
-
results.sort(key=lambda x: x.get("citation_count"
|
| 50 |
return results
|
|
|
|
| 44 |
results.append({
|
| 45 |
"title": paper.get("title", "Untitled"), "authors": authors,
|
| 46 |
"year": paper.get("year"), "abstract": abstract, "url": url,
|
| 47 |
+
"citation_count": paper.get("citationCount") or 0, "source_type": "web_search",
|
| 48 |
})
|
| 49 |
+
results.sort(key=lambda x: x.get("citation_count") or 0, reverse=True)
|
| 50 |
return results
|
|
@@ -565,6 +565,7 @@ def _ingest_tables(
|
|
| 565 |
placeholders = ", ".join(["?"] * len(safe_headers))
|
| 566 |
insert_sql = f'INSERT INTO "{table_name}" VALUES ({placeholders})'
|
| 567 |
|
|
|
|
| 568 |
for row in rows:
|
| 569 |
values = []
|
| 570 |
for col_idx, col_type in enumerate(col_types):
|
|
@@ -583,7 +584,12 @@ def _ingest_tables(
|
|
| 583 |
values.append(None)
|
| 584 |
else:
|
| 585 |
values.append(str(raw).strip())
|
| 586 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
|
| 588 |
conn.commit()
|
| 589 |
|
|
|
|
| 565 |
placeholders = ", ".join(["?"] * len(safe_headers))
|
| 566 |
insert_sql = f'INSERT INTO "{table_name}" VALUES ({placeholders})'
|
| 567 |
|
| 568 |
+
batch = []
|
| 569 |
for row in rows:
|
| 570 |
values = []
|
| 571 |
for col_idx, col_type in enumerate(col_types):
|
|
|
|
| 584 |
values.append(None)
|
| 585 |
else:
|
| 586 |
values.append(str(raw).strip())
|
| 587 |
+
batch.append(values)
|
| 588 |
+
if len(batch) >= 1000:
|
| 589 |
+
conn.executemany(insert_sql, batch)
|
| 590 |
+
batch = []
|
| 591 |
+
if batch:
|
| 592 |
+
conn.executemany(insert_sql, batch)
|
| 593 |
|
| 594 |
conn.commit()
|
| 595 |
|
|
@@ -14,6 +14,15 @@ _DANGEROUS_KEYWORDS = re.compile(
|
|
| 14 |
)
|
| 15 |
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
def _strip_sql_comments(sql: str) -> str:
|
| 18 |
"""Remove SQL comments (single-line -- and multi-line /* */) from a query."""
|
| 19 |
prev = None
|
|
@@ -36,12 +45,16 @@ def _validate_sql(sql: str) -> bool:
|
|
| 36 |
return False
|
| 37 |
if ";" in stripped:
|
| 38 |
return False
|
| 39 |
-
if not stripped.upper().startswith("SELECT"):
|
|
|
|
|
|
|
|
|
|
| 40 |
return False
|
| 41 |
-
|
|
|
|
| 42 |
return False
|
| 43 |
# Block subqueries: reject if more than one SELECT keyword
|
| 44 |
-
if len(re.findall(r'\bSELECT\b',
|
| 45 |
return False
|
| 46 |
return True
|
| 47 |
|
|
@@ -102,7 +115,7 @@ def execute_sql_query(sql_query: str, cfg: dict) -> list[dict]:
|
|
| 102 |
|
| 103 |
def format_sql_results_as_context(
|
| 104 |
rows: list[dict], sql_query: str, source_file: str,
|
| 105 |
-
table_info: dict = None, max_rows: int =
|
| 106 |
) -> str:
|
| 107 |
"""Format SQL result rows as context for the verification pipeline.
|
| 108 |
|
|
@@ -121,7 +134,7 @@ def format_sql_results_as_context(
|
|
| 121 |
return ""
|
| 122 |
|
| 123 |
row_label = f"Rows returned: {len(rows)}"
|
| 124 |
-
if max_rows and len(rows) >= max_rows:
|
| 125 |
row_label += f" (truncated — more rows may exist, limit was {max_rows})"
|
| 126 |
|
| 127 |
parts = [
|
|
@@ -186,7 +199,13 @@ def make_fuzzy_query(sql: str, word_level: bool = False) -> str | None:
|
|
| 186 |
)
|
| 187 |
|
| 188 |
if not word_level:
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
if count == 0:
|
| 191 |
return None
|
| 192 |
return new_sql
|
|
@@ -194,18 +213,22 @@ def make_fuzzy_query(sql: str, word_level: bool = False) -> str | None:
|
|
| 194 |
# Word-level: extract significant words from each value
|
| 195 |
def _word_replace(m):
|
| 196 |
col_part = m.group(1)
|
| 197 |
-
value = m.group(2)
|
|
|
|
|
|
|
| 198 |
words = [
|
| 199 |
-
w
|
|
|
|
| 200 |
if len(w) >= 3 and w.lower() not in _FUZZY_STOPWORDS
|
| 201 |
]
|
|
|
|
| 202 |
if not words:
|
| 203 |
# Fallback: use the original value as phrase LIKE
|
| 204 |
-
return f"{col_part}LIKE '%{value}%'"
|
| 205 |
if len(words) == 1:
|
| 206 |
-
return f"{col_part}LIKE '%{words[0]}%'"
|
| 207 |
# Multiple words: join with AND on the same column
|
| 208 |
-
conditions = [f"{col_part}LIKE '%{w}%'" for w in words]
|
| 209 |
return "(" + " AND ".join(conditions) + ")"
|
| 210 |
|
| 211 |
new_sql, count = pattern.subn(_word_replace, sql)
|
|
|
|
| 14 |
)
|
| 15 |
|
| 16 |
|
| 17 |
+
def _strip_quoted(sql: str) -> str:
|
| 18 |
+
"""Remove quoted strings and identifiers for safe keyword checking."""
|
| 19 |
+
# Remove single-quoted strings (handles '' escaping)
|
| 20 |
+
result = re.sub(r"'(?:[^']|'')*'", "", sql)
|
| 21 |
+
# Remove double-quoted identifiers
|
| 22 |
+
result = re.sub(r'"[^"]*"', "", result)
|
| 23 |
+
return result
|
| 24 |
+
|
| 25 |
+
|
| 26 |
def _strip_sql_comments(sql: str) -> str:
|
| 27 |
"""Remove SQL comments (single-line -- and multi-line /* */) from a query."""
|
| 28 |
prev = None
|
|
|
|
| 45 |
return False
|
| 46 |
if ";" in stripped:
|
| 47 |
return False
|
| 48 |
+
if not stripped.upper().startswith("SELECT "):
|
| 49 |
+
return False
|
| 50 |
+
unquoted = _strip_quoted(stripped)
|
| 51 |
+
if _DANGEROUS_KEYWORDS.search(unquoted):
|
| 52 |
return False
|
| 53 |
+
# Block access to SQLite system tables
|
| 54 |
+
if re.search(r'\bsqlite_(master|schema|temp_master|temp_schema)\b', stripped, re.IGNORECASE):
|
| 55 |
return False
|
| 56 |
# Block subqueries: reject if more than one SELECT keyword
|
| 57 |
+
if len(re.findall(r'\bSELECT\b', unquoted, re.IGNORECASE)) > 1:
|
| 58 |
return False
|
| 59 |
return True
|
| 60 |
|
|
|
|
| 115 |
|
| 116 |
def format_sql_results_as_context(
|
| 117 |
rows: list[dict], sql_query: str, source_file: str,
|
| 118 |
+
table_info: dict = None, max_rows: int = None,
|
| 119 |
) -> str:
|
| 120 |
"""Format SQL result rows as context for the verification pipeline.
|
| 121 |
|
|
|
|
| 134 |
return ""
|
| 135 |
|
| 136 |
row_label = f"Rows returned: {len(rows)}"
|
| 137 |
+
if max_rows is not None and len(rows) >= max_rows:
|
| 138 |
row_label += f" (truncated — more rows may exist, limit was {max_rows})"
|
| 139 |
|
| 140 |
parts = [
|
|
|
|
| 199 |
)
|
| 200 |
|
| 201 |
if not word_level:
|
| 202 |
+
def _phrase_replace(m):
|
| 203 |
+
col_part = m.group(1)
|
| 204 |
+
value = m.group(2).replace("'", "''")
|
| 205 |
+
# Escape LIKE wildcards in the value
|
| 206 |
+
value = value.replace("%", "\\%").replace("_", "\\_")
|
| 207 |
+
return f"{col_part}LIKE '%{value}%' ESCAPE '\\'"
|
| 208 |
+
new_sql, count = pattern.subn(_phrase_replace, sql)
|
| 209 |
if count == 0:
|
| 210 |
return None
|
| 211 |
return new_sql
|
|
|
|
| 213 |
# Word-level: extract significant words from each value
|
| 214 |
def _word_replace(m):
|
| 215 |
col_part = m.group(1)
|
| 216 |
+
value = m.group(2).replace("'", "''")
|
| 217 |
+
# Escape LIKE wildcards in the value
|
| 218 |
+
value = value.replace("%", "\\%").replace("_", "\\_")
|
| 219 |
words = [
|
| 220 |
+
w.replace("'", "''").replace("%", "\\%").replace("_", "\\_")
|
| 221 |
+
for w in re.split(r'\W+', m.group(2))
|
| 222 |
if len(w) >= 3 and w.lower() not in _FUZZY_STOPWORDS
|
| 223 |
]
|
| 224 |
+
esc = " ESCAPE '\\'"
|
| 225 |
if not words:
|
| 226 |
# Fallback: use the original value as phrase LIKE
|
| 227 |
+
return f"{col_part}LIKE '%{value}%'{esc}"
|
| 228 |
if len(words) == 1:
|
| 229 |
+
return f"{col_part}LIKE '%{words[0]}%'{esc}"
|
| 230 |
# Multiple words: join with AND on the same column
|
| 231 |
+
conditions = [f"{col_part}LIKE '%{w}%'{esc}" for w in words]
|
| 232 |
return "(" + " AND ".join(conditions) + ")"
|
| 233 |
|
| 234 |
new_sql, count = pattern.subn(_word_replace, sql)
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
Layers:
|
| 5 |
0 — No-source refusal (pre-LLM gate)
|
|
@@ -7,6 +7,7 @@ Layers:
|
|
| 7 |
2 — Soft max-token cap (proportional to context length)
|
| 8 |
3 — LLM-as-verifier (structured JSON audit)
|
| 9 |
4 — Semantic similarity cross-check (term-overlap heuristic)
|
|
|
|
| 10 |
5 — Warning-phrase scanner (advisory flags)
|
| 11 |
|
| 12 |
The main entry point is ``verify_and_respond``.
|
|
@@ -159,17 +160,25 @@ def validate_citations(response: str, retrieval_result: dict) -> list[str]:
|
|
| 159 |
"""
|
| 160 |
warnings = []
|
| 161 |
|
| 162 |
-
# Count
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
web_count = len(retrieval_result.get("web_results", []))
|
| 165 |
-
sql_count =
|
| 166 |
total_sources = db_count + web_count + sql_count
|
| 167 |
|
| 168 |
if total_sources == 0:
|
| 169 |
return warnings
|
| 170 |
|
| 171 |
-
# Extract citation numbers from response
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
if not citation_nums:
|
| 174 |
return warnings
|
| 175 |
|
|
@@ -181,10 +190,12 @@ def validate_citations(response: str, retrieval_result: dict) -> list[str]:
|
|
| 181 |
)
|
| 182 |
|
| 183 |
# Check that source file names from retrieval appear in the References section
|
| 184 |
-
refs_match = re.search(
|
|
|
|
|
|
|
|
|
|
| 185 |
if refs_match:
|
| 186 |
refs_text = refs_match.group(0).lower()
|
| 187 |
-
db_results = retrieval_result.get("db_results", [])
|
| 188 |
matched_sources = 0
|
| 189 |
for chunk in db_results:
|
| 190 |
source = chunk.get("metadata", {}).get("source", "")
|
|
@@ -193,7 +204,24 @@ def validate_citations(response: str, retrieval_result: dict) -> list[str]:
|
|
| 193 |
filename = source.rsplit("/", 1)[-1].lower()
|
| 194 |
if filename in refs_text:
|
| 195 |
matched_sources += 1
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
warnings.append(
|
| 198 |
"References section does not mention any filenames from "
|
| 199 |
"the retrieved local sources. Citations may be fabricated."
|
|
@@ -267,7 +295,7 @@ def verify_and_respond(
|
|
| 267 |
query: str, retrieval_result: dict, cfg: dict,
|
| 268 |
original_query: str = "",
|
| 269 |
) -> dict:
|
| 270 |
-
"""Generate a response and run it through the
|
| 271 |
|
| 272 |
Args:
|
| 273 |
query: The display query (reformulated by QU layer).
|
|
@@ -350,9 +378,6 @@ def verify_and_respond(
|
|
| 350 |
"iterations": 0,
|
| 351 |
}
|
| 352 |
|
| 353 |
-
# ── Layer 4.5: Deterministic citation audit ───────────────────────
|
| 354 |
-
citation_warnings = validate_citations(response, retrieval_result)
|
| 355 |
-
|
| 356 |
# ── Short-circuit if verification is disabled ─────────────────────────
|
| 357 |
verification_cfg = cfg.get("verification", {})
|
| 358 |
if not verification_cfg.get("enabled", True):
|
|
@@ -407,7 +432,8 @@ def verify_and_respond(
|
|
| 407 |
max_tokens=1024,
|
| 408 |
)
|
| 409 |
except Exception:
|
| 410 |
-
|
|
|
|
| 411 |
vr = parse_verification_result(raw_verification)
|
| 412 |
|
| 413 |
if vr.get("pass", False):
|
|
@@ -483,8 +509,9 @@ def verify_and_respond(
|
|
| 483 |
"against the provided sources. Some claims may lack adequate "
|
| 484 |
"grounding. Please cross-check important facts."
|
| 485 |
)
|
| 486 |
-
|
| 487 |
-
|
|
|
|
| 488 |
return {
|
| 489 |
"response": response + warning,
|
| 490 |
"refused": False,
|
|
|
|
| 1 |
"""
|
| 2 |
+
7-Layer Anti-Hallucination Verification Pipeline.
|
| 3 |
|
| 4 |
Layers:
|
| 5 |
0 — No-source refusal (pre-LLM gate)
|
|
|
|
| 7 |
2 — Soft max-token cap (proportional to context length)
|
| 8 |
3 — LLM-as-verifier (structured JSON audit)
|
| 9 |
4 — Semantic similarity cross-check (term-overlap heuristic)
|
| 10 |
+
4.5 — Deterministic citation audit (validate_citations)
|
| 11 |
5 — Warning-phrase scanner (advisory flags)
|
| 12 |
|
| 13 |
The main entry point is ``verify_and_respond``.
|
|
|
|
| 160 |
"""
|
| 161 |
warnings = []
|
| 162 |
|
| 163 |
+
# Count unique source documents (not raw chunks)
|
| 164 |
+
db_results = retrieval_result.get("db_results", [])
|
| 165 |
+
unique_db_sources = set()
|
| 166 |
+
for chunk in db_results:
|
| 167 |
+
source = chunk.get("metadata", {}).get("source", "")
|
| 168 |
+
if source:
|
| 169 |
+
unique_db_sources.add(source)
|
| 170 |
+
db_count = len(unique_db_sources) if unique_db_sources else (1 if db_results else 0)
|
| 171 |
web_count = len(retrieval_result.get("web_results", []))
|
| 172 |
+
sql_count = 1 if retrieval_result.get("sql_results") else 0
|
| 173 |
total_sources = db_count + web_count + sql_count
|
| 174 |
|
| 175 |
if total_sources == 0:
|
| 176 |
return warnings
|
| 177 |
|
| 178 |
+
# Extract citation numbers from response BODY only (not References section)
|
| 179 |
+
refs_split = re.split(r'(?im)^#+\s*(references|sources)\b|^\*\*(references|sources)\*\*', response)
|
| 180 |
+
body_text = refs_split[0] if refs_split else response
|
| 181 |
+
citation_nums = set(int(m) for m in re.findall(r"\[(\d+)\]", body_text))
|
| 182 |
if not citation_nums:
|
| 183 |
return warnings
|
| 184 |
|
|
|
|
| 190 |
)
|
| 191 |
|
| 192 |
# Check that source file names from retrieval appear in the References section
|
| 193 |
+
refs_match = re.search(
|
| 194 |
+
r"(?im)(^#+\s*(references|sources)\b|^\*\*(references|sources)\*\*).*",
|
| 195 |
+
response, re.DOTALL,
|
| 196 |
+
)
|
| 197 |
if refs_match:
|
| 198 |
refs_text = refs_match.group(0).lower()
|
|
|
|
| 199 |
matched_sources = 0
|
| 200 |
for chunk in db_results:
|
| 201 |
source = chunk.get("metadata", {}).get("source", "")
|
|
|
|
| 204 |
filename = source.rsplit("/", 1)[-1].lower()
|
| 205 |
if filename in refs_text:
|
| 206 |
matched_sources += 1
|
| 207 |
+
# Also check SQL source files
|
| 208 |
+
sql_results = retrieval_result.get("sql_results", [])
|
| 209 |
+
for sql_chunk in sql_results:
|
| 210 |
+
sql_source = sql_chunk.get("metadata", {}).get("source", "")
|
| 211 |
+
if sql_source:
|
| 212 |
+
sql_filename = sql_source.rsplit("/", 1)[-1].lower()
|
| 213 |
+
if sql_filename in refs_text:
|
| 214 |
+
matched_sources += 1
|
| 215 |
+
# Also check web source URLs
|
| 216 |
+
web_results = retrieval_result.get("web_results", [])
|
| 217 |
+
for web_chunk in web_results:
|
| 218 |
+
web_url = web_chunk.get("metadata", {}).get("url", "")
|
| 219 |
+
if web_url and web_url.lower() in refs_text:
|
| 220 |
+
matched_sources += 1
|
| 221 |
+
# Only warn when db_results are present and are the primary source
|
| 222 |
+
# and no filenames matched from any source type
|
| 223 |
+
has_any_source = db_results or sql_results or web_results
|
| 224 |
+
if has_any_source and matched_sources == 0 and db_results:
|
| 225 |
warnings.append(
|
| 226 |
"References section does not mention any filenames from "
|
| 227 |
"the retrieved local sources. Citations may be fabricated."
|
|
|
|
| 295 |
query: str, retrieval_result: dict, cfg: dict,
|
| 296 |
original_query: str = "",
|
| 297 |
) -> dict:
|
| 298 |
+
"""Generate a response and run it through the 7-layer verification stack.
|
| 299 |
|
| 300 |
Args:
|
| 301 |
query: The display query (reformulated by QU layer).
|
|
|
|
| 378 |
"iterations": 0,
|
| 379 |
}
|
| 380 |
|
|
|
|
|
|
|
|
|
|
| 381 |
# ── Short-circuit if verification is disabled ─────────────────────────
|
| 382 |
verification_cfg = cfg.get("verification", {})
|
| 383 |
if not verification_cfg.get("enabled", True):
|
|
|
|
| 432 |
max_tokens=1024,
|
| 433 |
)
|
| 434 |
except Exception:
|
| 435 |
+
# Verification LLM failed — skip correction, continue to next iteration
|
| 436 |
+
continue
|
| 437 |
vr = parse_verification_result(raw_verification)
|
| 438 |
|
| 439 |
if vr.get("pass", False):
|
|
|
|
| 509 |
"against the provided sources. Some claims may lack adequate "
|
| 510 |
"grounding. Please cross-check important facts."
|
| 511 |
)
|
| 512 |
+
final_citation_warnings = validate_citations(response, retrieval_result)
|
| 513 |
+
if final_citation_warnings:
|
| 514 |
+
warning += "\n**Citation issues:** " + "; ".join(final_citation_warnings)
|
| 515 |
return {
|
| 516 |
"response": response + warning,
|
| 517 |
"refused": False,
|