Theresa
fixed indexing error, added instruction prompt to model, updated vector db creation, added input rails, made output correctness rail less strict, adapted output on failed output guardrails, added missing requirements
d69ddeb Download rails/output.py from pxdelta/RAG: direct link, hf CLI and curl.
- Browser
- Download file 18 kB
-
https://huggingface.co/spaces/pxdelta/RAG/resolve/760d27d8899ffd15eab8fa68f62fc0d097820bc7/rails/output.py
- Command line
-
hf download hf://spaces/pxdelta/RAG@760d27d8899ffd15eab8fa68f62fc0d097820bc7/rails/output.py
-
curl -L -o output.py https://huggingface.co/spaces/pxdelta/RAG/resolve/760d27d8899ffd15eab8fa68f62fc0d097820bc7/rails/output.py
18 kB
| from sentence_transformers import SentenceTransformer | |
| from typing import Dict | |
| from helper import EMAIL_PATTERN, get_similarity_model, AUTO_ANSWERS, get_toxicity_model, get_hallucination_model, check_toxicity | |
| from transformers import pipeline, AutoTokenizer | |
| from sklearn.metrics.pairwise import cosine_similarity | |
| from typing import Dict, List | |
| from dataclasses import dataclass | |
| from enum import Enum | |
| import re | |
| class GuardrailResult: | |
| """Guardrail Result Dataclass""" | |
| passed: bool | |
| confidence: float | |
| issues: List[str] | |
| metrics: Dict[str, float] | |
| class GuardrailType(Enum): | |
| RELEVANCE = "relevance" | |
| HALLUCINATION = "hallucination" | |
| CORRECTNESS = "correctness" | |
| TOXICITY = "toxicity" | |
| class OutputGuardrails: | |
| """Guardrails for LLM output validation""" | |
| def __init__(self): | |
| self.hallucination_patterns = [ | |
| re.compile(r'\b(definitely|certainly|100%|absolutely)\b', re.IGNORECASE), # Overstated certainty | |
| re.compile(r'\b(all|never|always|no one|everyone)\b', re.IGNORECASE), # Absolute statements | |
| re.compile(r'\b(studies show|experts say|research proves)\b', re.IGNORECASE), # Unsupported authority claims | |
| ] | |
| self.fact_patterns = [ | |
| re.compile(r'\b\d{4}\b'), # years | |
| re.compile(r'\b\d+%\b'), # % | |
| re.compile(r'\b\d+\s*(Euro|Dollar|meter|kilometer|km|kg|tons)\b', re.IGNORECASE), | |
| ] | |
| try: | |
| self.sentence_model = get_similarity_model() | |
| except Exception as e: | |
| print(f"Could not retrieve sentence model for similarity checks: {e}.") | |
| try: | |
| self.hallucination_model = get_hallucination_model() | |
| except Exception as e: | |
| print(f"Could not retrieve hallucination check pipeline: {e}.") | |
| try: | |
| self.toxicity_classifier = get_toxicity_model() | |
| except Exception as e: | |
| print(f"Could not toxicity check pipeline: {e}.") | |
| def check_query_relevance(self, | |
| query: str, | |
| response: str, | |
| context: List[str]) -> GuardrailResult: | |
| """Checks how relevant the answer is to the query. | |
| Args: | |
| query (str): The input query | |
| response (str): The generated response by the model | |
| context List(str): The retrieved context from DB | |
| """ | |
| try: | |
| # query relevance | |
| query_embedding = self.sentence_model.encode([query]) | |
| response_embedding = self.sentence_model.encode([response]) | |
| relevance_score = cosine_similarity(query_embedding, response_embedding)[0][0] | |
| # context relevance | |
| context_text = " ".join(context) | |
| context_embedding = self.sentence_model.encode([context_text]) | |
| context_relevance = cosine_similarity(response_embedding, context_embedding)[0][0] | |
| # combined relevance (could change weighting if necessary) | |
| combined_relevance = (relevance_score * 0.5) + (context_relevance * 0.5) | |
| issues = [] | |
| if relevance_score < 0.3: | |
| issues.append(AUTO_ANSWERS.ANSWER_NOT_RELEVANT_TO_QUERY.value) | |
| if context_relevance < 0.4: | |
| issues.append(AUTO_ANSWERS.ANSWER_NOT_RELEVANT_TO_CONTEXT.value) | |
| passed = combined_relevance >= 0.5 and len(issues) == 0 | |
| return GuardrailResult( | |
| passed=passed, | |
| confidence=combined_relevance, | |
| issues=issues, | |
| metrics={ | |
| "query_relevance": relevance_score, | |
| "context_relevance": context_relevance, | |
| "combined_relevance": combined_relevance | |
| } | |
| ) | |
| except Exception as e: | |
| print(f"{AUTO_ANSWERS.RELEVANCE_CHECK_FAILED.value}: {e}") | |
| return GuardrailResult(False, 0.0, [AUTO_ANSWERS.RELEVANCE_CHECK_FAILED.value], {}) | |
| def _check_contradictions(self, response: str, context: str) -> float: | |
| """ | |
| Check for contradictions between response and context | |
| Args: | |
| response (str): The generated model response | |
| context (str): DB context combined to string | |
| """ | |
| response_sentences = response.split('.') | |
| context_sentences = context.split('.') | |
| contradiction_score = 0.0 | |
| for resp_sent in response_sentences: | |
| resp_sent = resp_sent.strip().lower() | |
| if not resp_sent: | |
| continue | |
| # Simple negated check (could be extended ) | |
| not_str = ' not ' | |
| negated_resp = resp_sent.replace(not_str, ' ').replace(' no ', ' ') | |
| for ctx_sent in context_sentences: | |
| ctx_sent = ctx_sent.strip().lower() | |
| if not ctx_sent: | |
| continue | |
| # check if response contradicts context and other way round | |
| if len(set(negated_resp.split()) & set(ctx_sent.split())) > 2: | |
| if (not_str in resp_sent) != (not_str in ctx_sent): | |
| contradiction_score += 0.3 | |
| break | |
| return min(contradiction_score, 1.0) | |
| def check_hallucination(self, | |
| response: str, | |
| context: List[str]) -> GuardrailResult: | |
| """Filter out hallucinations. | |
| Args: | |
| response (str): Generated model response | |
| context List(str): Retrieved context from DB | |
| """ | |
| try: | |
| context_text = " ".join(context) | |
| issues = [] | |
| # Check if model facts are consistent with context | |
| if self.hallucination_model: | |
| prompt = f"<pad> Determine if the hypothesis is true given the premise?\n\nPremise: {context}\n\nHypothesis: {response}" | |
| classifier = pipeline( | |
| "text-classification", | |
| model='vectara/hallucination_evaluation_model', | |
| tokenizer=AutoTokenizer.from_pretrained('google/flan-t5-base'), | |
| trust_remote_code=True | |
| ) | |
| full_scores = classifier(prompt, top_k=None) # List[List[Dict[str, float]]] | |
| hallucinated_entry = next((item for item in full_scores if item['label'] == 'hallucinated'), None) | |
| hallucination_score = hallucinated_entry['score'] | |
| else: | |
| # Do semantic similarity check if hallucination model pipeline is unavail | |
| context_embedding = self.sentence_model.encode([context_text]) | |
| response_embedding = self.sentence_model.encode([response]) | |
| similarity = cosine_similarity(context_embedding, response_embedding)[0][0] | |
| hallucination_score = 1.0 - similarity | |
| # check for known hallucination patterns | |
| pattern_score = 0 | |
| for pattern in self.hallucination_patterns: | |
| if pattern.search(response): | |
| pattern_score += 0.2 | |
| issues.append(f"Matched potential hallucination: {pattern.pattern}.") | |
| # Check if contradictions to context exitst | |
| contradiction_score = self._check_contradictions(response, context_text) | |
| # Gesamtscore | |
| total_hallucination_score = min( | |
| hallucination_score*0.4 + pattern_score*0.3 + contradiction_score*0.3, 1.0 | |
| ) | |
| if total_hallucination_score > 0.7: | |
| issues.append("High hallucination probability.") | |
| passed = total_hallucination_score < 0.5 | |
| return GuardrailResult( | |
| passed=passed, | |
| confidence=1.0 - total_hallucination_score, | |
| issues=issues, | |
| metrics={ | |
| "hallucination_score": total_hallucination_score, | |
| "pattern_score": pattern_score, | |
| "contradiction_score": contradiction_score | |
| } | |
| ) | |
| except Exception as e: | |
| print(f"{AUTO_ANSWERS.HALLUCINATION_CHECK_FAILED.value}: {e}") | |
| return GuardrailResult(False, 0.0, [AUTO_ANSWERS.HALLUCINATION_CHECK_FAILED.value], {}) | |
| def _extract_facts(self, text: str) -> List[str]: | |
| """ | |
| Extract checkable facts from text based on sentence structure | |
| Args: | |
| text (str): text to factcheck | |
| """ | |
| sentences = text.split('.') | |
| facts = [] | |
| for sentence in sentences: | |
| sentence = sentence.strip() | |
| if len(sentence) > 10: # ignore short sentences | |
| for pattern in self.fact_patterns: | |
| if pattern.search(sentence): | |
| facts.append(sentence) | |
| break | |
| return facts | |
| def _is_fact_supported(self, fact: str, context: str) -> bool: | |
| """Check is fact is supported by context: | |
| Args: | |
| fact (str): extracted fact | |
| context (str): context joined to str | |
| """ | |
| fact_embedding = self.sentence_model.encode([fact]) | |
| # split context into sentences, find best match | |
| context_sentences = [s.strip() for s in context.split('.') if len(s.strip()) > 10] | |
| if not context_sentences: | |
| return False | |
| context_embeddings = self.sentence_model.encode(context_sentences) | |
| similarities = cosine_similarity(fact_embedding, context_embeddings)[0] | |
| return max(similarities) > 0.7 | |
| def _check_consistency(self, response: str, context: str) -> float: | |
| """Check consistency between response and context | |
| Args: | |
| response (str): generated model response | |
| context (str): context combined to string | |
| """ | |
| response_embedding = self.sentence_model.encode([response]) | |
| context_embedding = self.sentence_model.encode([context]) | |
| consistency = cosine_similarity(response_embedding, context_embedding)[0][0] | |
| return max(0.0, consistency) | |
| def _check_completeness(self, response: str, context: str) -> float: | |
| """Check if answer is complete based on answer length and context coverage: | |
| Args: | |
| resposne (str): generated model output | |
| context (str): context combined to str | |
| """ | |
| response_words = set(response.lower().split()) | |
| context_words = set(context.lower().split()) | |
| if len(context_words) == 0: | |
| return 1.0 | |
| optimal_length = 20 # might want to adapt optimal word length, just a guess | |
| coverage = len(response_words & context_words) / min(len(context_words), 100) | |
| length_factor = min(len(response.split()) / optimal_length, 1.0) | |
| return (coverage * 0.7) + (length_factor * 0.3) | |
| def check_correctness(self, | |
| response: str, | |
| context: List[str]) -> GuardrailResult: | |
| """Check correctness based on given context. | |
| Args: | |
| response (str): Generated model response | |
| context List(str): Retrieved context from DB | |
| """ | |
| try: | |
| context_text = " ".join(context) | |
| issues = [] | |
| # context supports facts | |
| response_facts = self._extract_facts(response) | |
| supported_facts = 0 | |
| total_facts = len(response_facts) | |
| if total_facts > 0: | |
| for fact in response_facts: | |
| if self._is_fact_supported(fact, context_text): | |
| supported_facts += 1 | |
| else: | |
| issues.append(f"Unsupported fact: {fact}") | |
| support_ratio = supported_facts / total_facts | |
| else: | |
| support_ratio = 1.0 # no checkable facts | |
| consistency_score = self._check_consistency(response, context_text) | |
| completeness_score = self._check_completeness(response, context_text) | |
| correctness_score = ( | |
| support_ratio * 0.5 + | |
| consistency_score * 0.3 + | |
| completeness_score * 0.2 | |
| ) | |
| if support_ratio < 0.7: | |
| issues.append(AUTO_ANSWERS.NOT_SUPPORTED_BY_CONTEXT.value) | |
| if consistency_score < 0.4: | |
| issues.append(AUTO_ANSWERS.INCONSISTENT_WITH_CONTEXT.value) | |
| passed = correctness_score >= 0.5 and len(issues) <= 1 | |
| return GuardrailResult( | |
| passed=passed, | |
| confidence=correctness_score, | |
| issues=issues, | |
| metrics={ | |
| "support_ratio": support_ratio, | |
| "consistency_score": consistency_score, | |
| "completeness_score": completeness_score, | |
| "correctness_score": correctness_score | |
| } | |
| ) | |
| except Exception as e: | |
| print(f"{AUTO_ANSWERS.CORRECTNESS_CHECK_FAILED.value} {e}") | |
| return GuardrailResult(False, 0.0, [AUTO_ANSWERS.CORRECTNESS_CHECK_FAILED.value], {}) | |
| def check_toxicity(self, response: str): | |
| """ Toxicity check with toxicity check pipeline | |
| Args: | |
| response (str): response generated by model | |
| Returns: | |
| """ | |
| print("CHECKING OUTPUT LANGUAGE: ") | |
| toxicity_passed, toxicity_score, text = check_toxicity(response) | |
| return GuardrailResult( | |
| passed=toxicity_passed, | |
| confidence=1 - toxicity_score, | |
| issues=[text] if text else [], | |
| metrics={"toxicity_passed": toxicity_passed} | |
| ) | |
| def check(self, | |
| query: str, | |
| response: str, | |
| context: List[str]) -> Dict[GuardrailType, GuardrailResult]: | |
| """Execute Guard rail checks. | |
| Args: | |
| query (str): User query | |
| response (str): Response generated by model | |
| context (str): given context | |
| """ | |
| results = {} | |
| if response.find("I can only help with university academic topics") != -1 or response.find("I don't have access to that information. Please contact ") != -1: | |
| tasks = [ | |
| self.check_hallucination(response, context), | |
| self.check_toxicity(response) | |
| ] | |
| hallucination_result, toxicity_result = tasks | |
| results[GuardrailType.HALLUCINATION] = hallucination_result | |
| results[GuardrailType.TOXICITY] = toxicity_result | |
| else: | |
| tasks = [ | |
| self.check_query_relevance(query, response, context), | |
| self.check_hallucination(response, context), | |
| self.check_correctness(response, context), | |
| self.check_toxicity(response) | |
| ] | |
| relevance_result, hallucination_result, correctness_result, toxicity_result = tasks | |
| results[GuardrailType.RELEVANCE] = relevance_result | |
| results[GuardrailType.HALLUCINATION] = hallucination_result | |
| results[GuardrailType.CORRECTNESS] = correctness_result | |
| results[GuardrailType.TOXICITY] = toxicity_result | |
| print("Result for query: ", query) | |
| print("WITH CONTEXT: ", context) | |
| print("Model output: ", response) | |
| for guardrail_type, result in results.items(): | |
| print(f"\n Guardrail: {guardrail_type.value.upper()}") | |
| print(f" Passed: {result.passed}") | |
| print(f" Confidence: {result.confidence:.3f}") | |
| if result.issues: | |
| print(f" Issues: {', '.join(result.issues)}") | |
| else: | |
| print(f" Issues: None") | |
| print(" Metrics:") | |
| for metric, value in result.metrics.items(): | |
| print(f" - {metric}: {value:.4f}") | |
| return results | |
| def format_guardrail_issues(self, gr_result: dict, response: str) -> str: | |
| """Formats the issues text to readable output | |
| Parametes: | |
| - gr_result (dict): The dictionary containing the different results from the output guardrail check | |
| Returns: | |
| Formatted string. | |
| """ | |
| count = 0 | |
| for key, gr in gr_result.items(): | |
| if not gr.passed and key in [GuardrailType.HALLUCINATION, GuardrailType.TOXICITY]: | |
| count += 1 | |
| lines = ["ISSUES DETECTED WITH OUTPUT:\n"] | |
| for guardrail_type, result in gr_result.items(): | |
| issues = result.issues | |
| title = f"- {guardrail_type.value.capitalize()} issues:" | |
| print(issues) | |
| if issues: | |
| lines.append(title) | |
| for issue in issues: | |
| lines.append(f" • {issue}") | |
| if guardrail_type == GuardrailType.TOXICITY and not result.passed: | |
| lines.append(f" • {AUTO_ANSWERS.REPHRASE_SENTENCE.value}") | |
| print(lines) | |
| if count != 0: | |
| return "\n".join(lines) | |
| lines.append(f"\nThe respective reponse is: \n\n{response}") | |
| return "\n".join(lines) | |