LIANJie-Jason Claude Opus 4.6 commited on
Commit
fbd18c8
·
1 Parent(s): b199600

fix: pre-release audit round 12 — 33 bugs fixed across all tiers

Browse files

Tier 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 ADDED
@@ -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
README.md CHANGED
@@ -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 6-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,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-6-layer-anti-hallucination-stack)
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 (6-Layer Anti-Hallucination Stack)
401
 
402
- This is the core differentiator. Before showing you the answer, it passes through six verification layers:
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 9-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,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 6-layer verification stack catches unsupported claims before they reach you. If the chatbot can't verify an answer, it refuses rather than guessing.
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
- ### PyPDF2 deprecation warning
629
 
630
- You may see a warning about PyPDF2 being deprecated. This is cosmetic and does not affect functionality.
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
 
app_cli.py CHANGED
@@ -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 summarize_kb_for_welcome
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 = summarize_kb_for_welcome(cfg)
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")
app_web.py CHANGED
@@ -119,7 +119,8 @@ def render_sidebar():
119
  try:
120
  count = ingest_documents(cfg)
121
  st.success(f"Ingested {count} chunks.")
122
- # Clear the cached chunk count so it refreshes
 
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
 
docker-compose.yml CHANGED
@@ -1,4 +1,4 @@
1
- version: "3.8"
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
- - ./.env:/app/.env
 
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:-}
ingest.py CHANGED
@@ -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
- cfg = load_config()
 
 
 
 
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.")
requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
  # Core
2
  chromadb>=0.4.0
3
- PyPDF2>=3.0.0
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-generativeai>=0.5.0
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
setup.py CHANGED
@@ -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().strip('"').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
- lines.append(f'{k}="{v}"')
 
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
 
src/config_loader.py CHANGED
@@ -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
- for key in list(cfg.keys()):
36
- if cfg[key] is None:
37
- cfg[key] = {}
 
 
 
 
 
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)
src/ingest.py CHANGED
@@ -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
- print("Cleared stale KB overview.")
 
 
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.")
src/kb_meta.py CHANGED
@@ -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 if "." in source else ""
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"),
src/llm/__init__.py CHANGED
@@ -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,
src/llm/anthropic.py CHANGED
@@ -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
- raise RuntimeError(f"Anthropic API error ({error_type}). Check your API key and network connection.") from e
 
 
 
 
 
 
 
 
 
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)
src/llm/gemini.py CHANGED
@@ -1,29 +1,49 @@
1
- """Google Gemini LLM provider."""
2
- SUPPORTED_MODELS = ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.0-flash"]
 
 
 
 
 
 
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
- model = model or "gemini-2.5-flash"
8
- import google.generativeai as genai
9
- genai.configure(api_key=api_key)
10
- gen_model = genai.GenerativeModel(
11
- model_name=model, system_instruction=system_prompt,
12
- generation_config=genai.GenerationConfig(temperature=temperature, max_output_tokens=max_tokens),
13
- )
14
- response = None
15
  try:
16
- response = gen_model.generate_content(user_message)
 
 
 
 
 
 
 
 
 
 
 
17
  return response.text
18
- except ValueError:
19
- # Gemini raises ValueError when the response is blocked by safety filters.
20
- if response is not None:
21
- block_reason = getattr(response, "prompt_feedback", None)
22
- if block_reason:
23
- return f"[Gemini blocked: {block_reason}]"
24
- return "[Gemini blocked: response filtered by safety settings]"
25
- except Exception:
26
- return "[Gemini error: request failed]"
 
 
 
 
 
 
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)
src/llm/openai.py CHANGED
@@ -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
- raise RuntimeError(f"OpenAI API error ({error_type}). Check your API key and network connection.") from e
 
 
 
 
 
 
 
 
 
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)
src/prompts.py CHANGED
@@ -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 = (
src/query_engine.py CHANGED
@@ -93,7 +93,7 @@ def understand_query(
93
  "You are a query reformulation assistant. Return only JSON.",
94
  prompt,
95
  cfg,
96
- max_tokens=256,
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:
src/readers/csv_tab.py CHANGED
@@ -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
- for header, val in zip(headers, row):
 
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}")
src/readers/docx.py CHANGED
@@ -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)}]
src/readers/excel.py CHANGED
@@ -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
- for header, val in zip(headers, row):
48
- if val is not None and str(val).strip():
 
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))
src/readers/pdf.py CHANGED
@@ -2,7 +2,7 @@
2
 
3
 
4
  def read_pdf(file_path: str) -> list[dict]:
5
- from PyPDF2 import PdfReader
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):
src/readers/rdata.py CHANGED
@@ -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: {name} | Columns: {', '.join(headers)}\n"
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) != "nan":
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"{name}_rows_{block_start}-{block_start + len(block) - 1}", "text": text})
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"{name}_rows_{block_start}-{block_start + len(block) - 1}", "text": text})
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
src/readers/stata.py CHANGED
@@ -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) != "nan":
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
src/retriever.py CHANGED
@@ -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 = get_chroma_collection(cfg)
32
 
33
- if collection.count() == 0:
 
34
  return []
35
 
36
  # Query a large candidate pool, then filter by relevance
37
- candidate_count = min(top_k, collection.count())
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 != (make_fuzzy_query(sql_query) or ""):
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" — existing fallback logic
391
- if not sql_query:
392
- sql_query = _build_fallback_sql_query(query, cfg)
393
- if sql_query:
394
- sql_rows, sql_context, sql_match_type = _run_sql_retrieval(sql_query, cfg)
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)
src/search/__init__.py CHANGED
@@ -6,11 +6,14 @@ BACKENDS = {
6
  "none": lambda query, limit=5: [],
7
  }
8
 
9
- def search(query: str, backend: str = "semantic_scholar", limit: int = 5) -> list[dict]:
10
  fn = BACKENDS.get(backend)
11
  if fn is None:
12
- raise ValueError(f"Unknown search backend: {backend}. Available: {list(BACKENDS.keys())}")
13
- return fn(query, limit=limit)
 
 
 
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:
src/search/semantic_scholar.py CHANGED
@@ -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", 0), "source_type": "web_search",
48
  })
49
- results.sort(key=lambda x: x.get("citation_count", 0), reverse=True)
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
src/sql_ingest.py CHANGED
@@ -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
- conn.execute(insert_sql, values)
 
 
 
 
 
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
 
src/sql_retriever.py CHANGED
@@ -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
- if _DANGEROUS_KEYWORDS.search(stripped):
 
42
  return False
43
  # Block subqueries: reject if more than one SELECT keyword
44
- if len(re.findall(r'\bSELECT\b', stripped, re.IGNORECASE)) > 1:
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 = 0,
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
- new_sql, count = pattern.subn(r"\1LIKE '%\2%'", sql)
 
 
 
 
 
 
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 for w in re.split(r'\W+', value)
 
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)
src/verifier.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- 6-Layer Anti-Hallucination Verification Pipeline.
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 actual sources available
163
- db_count = len(retrieval_result.get("db_results", []))
 
 
 
 
 
 
164
  web_count = len(retrieval_result.get("web_results", []))
165
- sql_count = len(retrieval_result.get("sql_results", []))
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 body
172
- citation_nums = set(int(m) for m in re.findall(r"\[(\d+)\]", response))
 
 
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(r"(?i)\b(references|sources)\b.*", response, re.DOTALL)
 
 
 
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
- if db_results and matched_sources == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 6-layer verification stack.
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
- raw_verification = ""
 
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
- if citation_warnings:
487
- warning += "\n**Citation issues:** " + "; ".join(citation_warnings)
 
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,