Spaces:
Running
Running
File size: 24,308 Bytes
0017280 fbd18c8 0017280 fbd18c8 0017280 585c3ec 0017280 91410f8 f95cdb7 fbd18c8 f95cdb7 91410f8 f9f7706 91410f8 fbd18c8 f9f7706 fbd18c8 91410f8 fbd18c8 f9f7706 fbd18c8 91410f8 f95cdb7 fbd18c8 f95cdb7 fbd18c8 f95cdb7 fbd18c8 f95cdb7 fbd18c8 91410f8 0017280 9093d8f 0017280 9093d8f 0017280 1330e5c 9093d8f 1330e5c 0017280 585c3ec c23d859 0017280 fbd18c8 0017280 c23d859 0017280 c23d859 0017280 9815e49 0017280 68a6539 0017280 c23d859 8f35c1b 0017280 a73c23b 0017280 c23d859 0017280 f95cdb7 0017280 9093d8f 1330e5c 9093d8f 91410f8 1330e5c 0017280 91410f8 0017280 f95cdb7 1330e5c f95cdb7 1330e5c 0017280 9093d8f f95cdb7 0017280 91410f8 0017280 91410f8 0017280 91410f8 0017280 9093d8f f795a1a 9093d8f fbd18c8 f95cdb7 0017280 9815e49 f95cdb7 0017280 9815e49 f95cdb7 91410f8 0017280 f95cdb7 0017280 9093d8f 91410f8 9093d8f 91410f8 0017280 9093d8f c86d40f 9093d8f f95cdb7 9093d8f 0017280 f95cdb7 0017280 fbd18c8 0017280 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 | """
7-Layer Anti-Hallucination Verification Pipeline.
Layers:
0 β No-source refusal (pre-LLM gate)
1 β System prompt guardrails (handled by src.prompts)
2 β Soft max-token cap (proportional to context length)
3 β LLM-as-verifier (structured JSON audit)
4 β Semantic similarity cross-check (term-overlap heuristic)
4.5 β Deterministic citation audit (validate_citations)
5 β Warning-phrase scanner (advisory flags)
The main entry point is ``verify_and_respond``.
"""
import json
import re
from src.prompts import build_prompt, build_verification_prompt, build_vc_analyzer_prompt
from src.llm import generate
from src.retriever import NO_SOURCES_REFUSAL
# ββ Layer 5: Warning phrases (advisory, NOT errors) ββββββββββββββββββββββββββ
WARNING_PHRASES = [
"based on my knowledge",
"generally speaking",
"it is well known",
"as we all know",
"it is widely accepted",
"common understanding suggests",
"from what I know",
"I recall that",
]
# ββ Refusal message when verification fails after corrections βββββββββββββββββ
REFUSAL_AFTER_VERIFICATION = (
"I was unable to produce a response that passed verification "
"against the provided sources. To avoid presenting ungrounded "
"information, I must decline to answer. Please try rephrasing "
"your question or adding more relevant documents."
)
# ββ Layer 2: Soft max-token cap βββββββββββββββββββββββββββββββββββββββββββββββ
def compute_soft_max_tokens(context_chars: int, default_max: int) -> int:
"""Return a token budget proportional to context length.
Short context should produce short answers to reduce hallucination
surface area.
Args:
context_chars: Number of characters in the combined context.
default_max: The configured ``max_tokens`` ceiling.
Returns:
An integer token cap: 1024 if context < 500 chars,
1536 if context < 2000 chars, otherwise *default_max*.
"""
if context_chars < 500:
return 1024
if context_chars < 2000:
return 1536
return default_max
# ββ Layer 5: Warning-phrase scanner βββββββββββββββββββββββββββββββββββββββββββ
def scan_warning_phrases(response: str) -> list[dict]:
"""Scan a response for phrases that hint at training-data leakage.
These are *advisory* flags β they do not constitute errors on their
own but are forwarded to the LLM verifier for contextual review.
Args:
response: The AI-generated text to scan.
Returns:
A list of flag dicts, each with keys ``phrase``, ``severity``,
and ``message``.
"""
flags: list[dict] = []
lower = response.lower()
for phrase in WARNING_PHRASES:
if phrase in lower:
flags.append({
"phrase": phrase,
"severity": "advisory",
"message": (
f"Phrase '{phrase}' may indicate the model is drawing "
f"on training data rather than the provided context."
),
})
return flags
# ββ Layer 4: Similarity cross-check ββββββββββββββββββββββββββββββββββββββββββ
def compute_similarity_flags(
response: str, context: str, cfg: dict
) -> list[dict]:
"""Check that cited claims have reasonable term overlap with context.
For every sentence in *response* that contains a ``[N]`` citation
marker, we tokenise the sentence and the context into lowercase
words, then compute the fraction of sentence words that appear
anywhere in the context. If the overlap is below 40 %, the claim
is flagged as potentially ungrounded.
Args:
response: AI-generated response text.
context: The combined retrieval context.
cfg: Application config (reserved for future threshold tuning).
Returns:
A list of advisory flag dicts.
"""
flags: list[dict] = []
# Split into sentences (crude but sufficient for flagging)
sentences = re.split(r"(?<=[.!?])\s+", response)
citation_pattern = re.compile(r"\[\d+\]")
# Build a set of context words once
context_words = set(re.findall(r"[a-z]{3,}", context.lower()))
if not context_words:
return flags
for sentence in sentences:
if not citation_pattern.search(sentence):
continue
# Strip citation markers before tokenising
clean = citation_pattern.sub("", sentence)
words = re.findall(r"[a-z]{3,}", clean.lower())
if not words:
continue
overlap = sum(1 for w in words if w in context_words) / len(words)
if overlap < 0.4:
flags.append({
"claim": sentence.strip(),
"overlap": round(overlap, 2),
"severity": "advisory",
"message": (
f"Cited claim has only {overlap:.0%} term overlap with "
f"context β may be unsupported."
),
})
return flags
def validate_citations(response: str, retrieval_result: dict) -> list[str]:
"""Deterministic citation audit (Layer 4.5).
Checks that:
- Citation numbers [N] in the response body don't exceed source count.
- References section lists source names that match actual retrieved sources.
Returns a list of warning strings (empty if all checks pass).
"""
warnings = []
# Count citable units β each retrieved chunk is a separate citable
# reference that the LLM may cite as [1], [2], etc. Counting unique
# *files* instead would trigger false "fabricated reference" warnings
# when a single file contributes multiple chunks (e.g., a PDF cited
# as [1]β[10] for 10 different sections).
db_results = retrieval_result.get("db_results", [])
db_count = len(db_results)
web_count = len(retrieval_result.get("web_results", []))
# SQL rows come from a single table query β count as 1 citable source,
# not one per row, since the LLM cites the table as a single reference.
sql_count = 1 if retrieval_result.get("sql_results") else 0
total_sources = db_count + web_count + sql_count
if total_sources == 0:
return warnings
# Extract citation numbers from response BODY only (not References section)
refs_split = re.split(
r'(?im)^#+\s*(?:references?|sources?)[\s:]*(?:\(.*?\))?\s*$|^\*\*(?:references?|sources?)[\s:]*(?:\(.*?\))?\*\*\s*$',
response
)
body_text = refs_split[0] if refs_split else response
citation_nums = set(int(m) for m in re.findall(r"\[(\d+)\]", body_text))
if not citation_nums:
return warnings
max_citation = max(citation_nums)
if max_citation > total_sources:
warnings.append(
f"Citation [{max_citation}] exceeds available source count "
f"({total_sources}). Possible fabricated reference."
)
# Check that source file names from retrieval appear in the References section
refs_match = re.search(
r"(?im)(?:^#+\s*(?:references?|sources?)[\s:]*(?:\(.*?\))?\s*$|^\*\*(?:references?|sources?)[\s:]*(?:\(.*?\))?\*\*\s*$).*",
response, re.DOTALL,
)
if refs_match:
refs_text = refs_match.group(0).lower()
matched_sources = 0
for chunk in db_results:
source = chunk.get("metadata", {}).get("source", "")
if source:
# Check for filename (last component of path)
filename = source.rsplit("/", 1)[-1].lower()
if filename in refs_text:
matched_sources += 1
# Also check SQL source β SQL results are plain row dicts (no
# metadata wrapper). All rows come from a single table/source file
# whose name is embedded in the context as "Source: <filename>".
# Extract it from the combined context instead of iterating rows.
sql_results = retrieval_result.get("sql_results", [])
if sql_results:
context_text = retrieval_result.get("context", "")
sql_source_match = re.search(
r"Source:\s*(.+)", context_text,
)
if sql_source_match:
sql_filename = sql_source_match.group(1).strip().rsplit("/", 1)[-1].lower()
if sql_filename in refs_text:
matched_sources += 1
# Also check web source URLs β web results are flat dicts with
# a top-level "url" key (no metadata wrapper).
web_results = retrieval_result.get("web_results", [])
for web_chunk in web_results:
web_url = web_chunk.get("url", "")
if web_url and web_url.lower() in refs_text:
matched_sources += 1
# Only warn when db_results are present and are the primary source
# and no filenames matched from any source type
has_any_source = db_results or sql_results or web_results
if has_any_source and matched_sources == 0 and db_results:
warnings.append(
"References section does not mention any filenames from "
"the retrieved local sources. Citations may be fabricated."
)
return warnings
# ββ Layer 3 helper: parse LLM verification JSON ββββββββββββββββββββββββββββββ
def parse_verification_result(raw: str) -> dict:
"""Extract a structured verification dict from raw LLM output.
Attempts ``json.loads`` first; falls back to regex extraction of a
JSON object. If all parsing fails, returns a synthetic *fail*
result so the pipeline treats unparseable output conservatively.
Args:
raw: The raw string returned by the verifier LLM.
Returns:
A dict with at least ``errors``, ``error_count``, and ``pass``.
"""
if not raw:
return {
"errors": [{"description": "Verification LLM returned no output."}],
"error_count": 1,
"pass": False,
}
# Try direct parse
try:
result = json.loads(raw)
if isinstance(result, dict) and "pass" in result:
if not isinstance(result.get("errors"), list):
result["errors"] = []
if not isinstance(result.get("error_count"), int):
result["error_count"] = len(result["errors"])
result["pass"] = result.get("pass") is True
return result
except (json.JSONDecodeError, TypeError):
pass
# Fallback: scan for the first valid JSON object using raw_decode
decoder = json.JSONDecoder()
for i, ch in enumerate(raw):
if ch == '{':
try:
result, _ = decoder.raw_decode(raw, i)
if isinstance(result, dict) and "pass" in result:
if not isinstance(result.get("errors"), list):
result["errors"] = []
if not isinstance(result.get("error_count"), int):
result["error_count"] = len(result["errors"])
result["pass"] = result.get("pass") is True
return result
except json.JSONDecodeError:
continue
# Unparseable β fail conservatively
return {
"errors": [{"description": "Verification output was not parseable JSON."}],
"error_count": 1,
"pass": False,
}
# ββ Main entry point βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def verify_and_respond(
query: str, retrieval_result: dict, cfg: dict, value_chain:str,
original_query: str = "",
) -> dict:
"""Generate a response and run it through the 7-layer verification stack.
Args:
query: The display query (reformulated by QU layer).
retrieval_result: Dict from ``src.retriever.retrieve`` containing
``context``, ``db_results``, ``web_results``, ``has_sources``.
cfg: Full application configuration dict.
original_query: The user's raw input before QU reformulation.
Included in the user message so the LLM can cross-check intent.
Returns:
A dict with keys:
- ``response`` (str): The final text to show the user.
- ``refused`` (bool): True if the pipeline refused to answer.
- ``verification_passed`` (bool | None): True/False/None.
- ``iterations`` (int): Number of verification loops executed.
"""
# ββ Layer 0: No-source refusal (pre-LLM gate) ββββββββββββββββββββββββ
if not retrieval_result.get("has_sources", False):
return {
"response": NO_SOURCES_REFUSAL,
"refused": True,
"verification_passed": None,
"iterations": 0,
}
context = retrieval_result.get("context", "")
bot_name = cfg.get("chatbot", {}).get("name", "ResearchBot")
domain = cfg.get("chatbot", {}).get("domain", "research")
llm_cfg = cfg.get("llm", {})
default_max = llm_cfg.get("max_tokens", 8192)
# ββ Load KB overview for general awareness (brief version) ββββββββββ
from src.kb_meta import load_kb_meta_brief
kb_overview = load_kb_meta_brief(cfg)
# ββ Layer 2: Soft max-token cap βββββββββββββββββββββββββββββββββββββββ
soft_max = compute_soft_max_tokens(len(context), default_max)
# ββ Layer 1: System prompt guardrails (built into prompt) βββββββββββββ
system_prompt = build_vc_analyzer_prompt(cfg.get("policy_docs", []), context, value_chain, domain)
# ββ Build user message with original query for intent cross-check ββββ
if original_query and original_query != query:
user_message = (
f"{query}\n\n"
f"(Original message from the user: \"{original_query}\". "
f"If the reformulated question above misunderstood the user's "
f"intent, prioritize answering what the user originally asked.)"
)
else:
user_message = query
# ββ Generate initial response βββββββββββββββββββββββββββββββββββββββββ
try:
response = generate(system_prompt, user_message, cfg, max_tokens=soft_max)
except Exception as e:
return {
"response": f"An error occurred while generating the response: {e}",
"refused": True,
"verification_passed": None,
"iterations": 0,
}
# ββ Guard: empty response from LLM ββββββββββββββββββββββββββββββββββββ
if not response or not response.strip():
return {
"response": (
"The AI model was unable to generate a response. "
"Please try rephrasing your question."
),
"refused": True,
"verification_passed": False,
"iterations": 0,
}
# ββ Guard: detect provider-level content blocks ββββββββββββββββββββββββ
lower_resp = response.lower()
if (lower_resp.startswith("[gemini blocked")
or lower_resp.startswith("[gemini error")
or lower_resp.startswith("[blocked")):
return {
"response": (
"The LLM provider blocked this request due to content "
"safety filters. Please try rephrasing your question."
),
"refused": True,
"verification_passed": None,
"iterations": 0,
}
# ββ Short-circuit if verification is disabled βββββββββββββββββββββββββ
verification_cfg = cfg.get("verification", {})
if not verification_cfg.get("enabled", True):
print("WARNING: Verification is disabled (verification.enabled=false). "
"Responses may contain ungrounded claims.")
return {
"response": response,
"refused": False,
"verification_passed": None,
"iterations": 0,
}
max_iterations = verification_cfg.get("max_iterations", 3)
strict_mode = verification_cfg.get("strict_mode", True)
# Guard: max_iterations=0 with enabled=true β clamp to 1.
# Verification cannot be bypassed via iteration count alone;
# users must explicitly set verification.enabled: false.
if max_iterations <= 0:
print("WARNING: Verification iterations set to 0 but verification "
"is enabled. Clamping to 1 iteration. To disable verification, "
"set verification.enabled: false.")
max_iterations = 1
# ββ Verification loop βββββββββββββββββββββββββββββββββββββββββββββββββ
initial_response = response
any_verification_ran = False # Track whether ANY verification call succeeded
for iteration in range(1, max_iterations + 1):
# Layer 5: Warning-phrase scan
phrase_flags = scan_warning_phrases(response)
phrase_flag_strs = [f["message"] for f in phrase_flags]
# Layer 4: Similarity cross-check
sim_flags = compute_similarity_flags(response, context, cfg)
sim_flag_strs = [f["message"] for f in sim_flags]
# Layer 4.5: Citation audit (recomputed each iteration after corrections)
citation_warnings = validate_citations(response, retrieval_result)
citation_flag_strs = citation_warnings if citation_warnings else []
# Layer 3: LLM-as-verifier
all_sim_flags = sim_flag_strs + citation_flag_strs
verification_prompt = build_verification_prompt(
response, context, phrase_flag_strs, all_sim_flags,
)
try:
raw_verification = generate(
"""
You are a verification agent. Your job is to audit the following
AI-generated response against the provided context and flag any
problems.You are a strict verification agent. Return only JSON.""",
verification_prompt,
cfg,
max_tokens=1024,
)
except Exception:
# Verification LLM failed β skip correction, continue to next iteration
continue
any_verification_ran = True
vr = parse_verification_result(raw_verification)
if vr.get("pass", False):
return {
"response": response,
"refused": False,
"verification_passed": True,
"iterations": iteration,
}
# Verification failed β attempt correction
error_count = vr.get("error_count", len(vr.get("errors", [])))
# Correct and loop for re-verification
# Truncate previous response to reduce prompt competition with context
truncated_response = response[:1500] + "..." if len(response) > 1500 else response
correction_prompt = (
f"The user's original question was: {query}\n\n"
f"Your previous response (truncated for brevity):\n{truncated_response}\n\n"
f"This response failed verification with "
f"{error_count} error(s):\n"
+ json.dumps(vr.get("errors", []), indent=2)
+ "\n\nRewrite your COMPLETE response from scratch to fix ALL the issues "
"listed above. Use ONLY the provided context. Keep all citation rules."
)
try:
response = generate(
system_prompt, correction_prompt, cfg, max_tokens=soft_max
)
except Exception:
break
# Guard: empty or None correction response
if not response or not response.strip():
response = initial_response
break
# ββ Verify the final correction (last iteration corrected but never verified) ββ
if response != initial_response:
phrase_flags = scan_warning_phrases(response)
similarity_flags = compute_similarity_flags(response, context, cfg)
citation_flags_final = validate_citations(response, retrieval_result)
all_sim_final = [f["message"] for f in similarity_flags] + citation_flags_final
vp = build_verification_prompt(
response, context,
[f["message"] for f in phrase_flags],
all_sim_final,
)
try:
vr = parse_verification_result(generate(
"""
You are a verification agent. Your job is to audit the following
AI-generated response against the provided context and flag any
problems.You are a strict verification agent. Return only JSON.""",
vp, cfg, max_tokens=1024,
))
any_verification_ran = True
except Exception:
vr = {"pass": False, "errors": [], "error_count": 0}
if vr.get("pass", False):
return {
"response": response,
"refused": False,
"verification_passed": True,
"iterations": max_iterations,
}
# ββ Handle total verification system failure ββββββββββββββββββββββββββ
# If ALL verification LLM calls failed (not "didn't pass" β actually
# failed to run), this is a system failure, not a content-quality issue.
if not any_verification_ran:
print("ERROR: All verification LLM calls failed. "
"Verification could not run at all.")
if strict_mode:
return {
"response": (
"Verification was unable to run due to repeated LLM "
"errors. To avoid presenting unverified information, "
"I must decline to answer. Please check your LLM "
"configuration and try again."
),
"refused": True,
"verification_passed": False,
"iterations": max_iterations,
}
# Non-strict: return with a prominent system-failure warning
warning = (
"\n\n---\n**WARNING: Verification system failure.** "
"The verification pipeline was unable to run due to "
"repeated LLM errors. This response has NOT been verified "
"at all β treat all claims as unverified."
)
return {
"response": response + warning,
"refused": False,
"verification_passed": False,
"iterations": max_iterations,
}
# ββ Exhausted iterations (verification ran but never passed) ββββββββββ
if strict_mode:
return {
"response": REFUSAL_AFTER_VERIFICATION,
"refused": True,
"verification_passed": False,
"iterations": max_iterations,
}
# Non-strict: return with a warning
warning = (
"\n\n---\n**Note:** This response could not be fully verified "
"against the provided sources. Some claims may lack adequate "
"grounding. Please cross-check important facts."
)
final_citation_warnings = validate_citations(response, retrieval_result)
if final_citation_warnings:
warning += "\n**Citation issues:** " + "; ".join(final_citation_warnings)
return {
"response": response + warning,
"refused": False,
"verification_passed": False,
"iterations": max_iterations,
}
|