Spaces:
Running
Running
| """ | |
| 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(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, | |
| } | |