From f93afb3e5807dbc376ffd67065428166f02a96fa Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Mon, 2 Mar 2026 11:34:57 +0100 Subject: [PATCH 01/14] Calculate aggregate score as a mean --- backend/core/evaluation.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/backend/core/evaluation.py b/backend/core/evaluation.py index dd8cc97..18ef1fd 100644 --- a/backend/core/evaluation.py +++ b/backend/core/evaluation.py @@ -32,7 +32,7 @@ def _get_sub_prompt(self, main_req_name: str, reference: str, source: str, conte }} """ - def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]]) -> str: + def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]], computed_score: float) -> str: simplified_results = [] for res in sub_results: simplified_results.append({ @@ -48,13 +48,14 @@ def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]]) -> str: SUB-REQUIREMENT FINDINGS: {json.dumps(simplified_results, indent=2, ensure_ascii=False)} +COMPUTED OVERALL SCORE: {computed_score:.1f} +(This score is the arithmetic mean of all sub-requirement scores and has already been calculated.) + TASK: Aggregate these findings into a final compliance assessment for the main requirement. INSTRUCTIONS: -1. Calculate an overall score reflecting the compliance level. If critical sub-requirements score low, the overall score should be proportionally low. - -2. Write 'auditor_notes' as an EXECUTIVE SUMMARY for management/stakeholders (NO technical legal references): +1. Write 'auditor_notes' as an EXECUTIVE SUMMARY for management/stakeholders (NO technical legal references): - Write in paragraph form (NOT a list, NO bullet points, NO semicolons separating items) - Start with overall compliance status (e.g., "Partially compliant", "Non-compliant", "Fully compliant") - Describe FUNCTIONALLY what was found and what gaps exist @@ -62,7 +63,7 @@ def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]]) -> str: - DO NOT mention specific article numbers, paragraph numbers, or section codes - Focus on impact and actionable insights -3. Write 'rationale' as a TECHNICAL ANALYSIS for compliance experts (WITH legal references): +2. Write 'rationale' as a TECHNICAL ANALYSIS for compliance experts (WITH legal references): - Reference specific sub-requirement codes and scores (e.g., "Article 15 Para 1 scored 2", "ISO 42001 section 6.1.1 scored 1") - Map findings to regulatory requirements precisely - Explain technical compliance implications @@ -78,9 +79,8 @@ def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]]) -> str: EXAMPLE FORMAT for rationale (WITH legal references): "The overall score reflects mixed compliance across five evaluated sub-requirements. Article 15 Para 1 (EU AI Act) received a score of 2, indicating the system's purpose is identified but lacks comprehensive robustness evidence. Article 15 Para 3 scored 0 as no accuracy metrics are declared, failing a mandatory EU AI Act requirement. ISO 42001 sections 6.1.1 and 8.1 both scored 1, showing minimal risk management and operational control evidence. Only section 6.1.2 achieved a score of 2, demonstrating some risk awareness without formal processes. These deficiencies in critical regulatory areas justify the low overall compliance score." -Respond in JSON format: +Respond in JSON format (DO NOT include 'score' - it has already been calculated): {{ - "score": "Integer 0-5 or 'N/A'", "auditor_notes": "Executive summary paragraph describing compliance status, evidence found, and specific gaps.", "rationale": "Detailed analytical justification referencing specific sub-requirement findings and their implications." }} @@ -108,14 +108,29 @@ def evaluate_sub_requirement(self, main_req_name: str, sub_req_name: str, source def aggregate_results(self, sub_results: List[Dict[str, Any]]) -> Dict[str, Any]: """ Aggregates multiple sub-requirement results into a final report. + Score is computed as the arithmetic mean of sub-requirement scores. """ - agg_prompt = self._get_aggregate_prompt(sub_results) + # Calculate average score from sub-requirements + numeric_scores = [] + for res in sub_results: + score = res.get("score") + try: + numeric_scores.append(float(score)) + except (ValueError, TypeError): + pass # Skip non-numeric scores + + if numeric_scores: + computed_score = sum(numeric_scores) / len(numeric_scores) + else: + computed_score = 0.0 + + agg_prompt = self._get_aggregate_prompt(sub_results, computed_score) agg_response = self.llm.invoke(agg_prompt).content.strip() try: cleaned_agg = agg_response.replace("```json", "").replace("```", "").strip() agg_result = json.loads(cleaned_agg) except Exception: - agg_result = {"score": "N/A", "auditor_notes": "Parsing failed", "rationale": agg_response} + agg_result = {"auditor_notes": "Parsing failed", "rationale": agg_response} # Helper: ensure auditor_notes is a string notes_val = agg_result.get("auditor_notes", "") @@ -129,7 +144,7 @@ def aggregate_results(self, sub_results: List[Dict[str, Any]]) -> Dict[str, Any] notes_str = str(notes_val) return { - "score": agg_result.get("score", "N/A"), + "score": round(computed_score, 1), # Use computed average score "auditor_notes": notes_str, "rationale": agg_result.get("rationale", ""), "prompt": agg_prompt From 3ea899d0e663666de9fffb1b406883f9c847e382 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Mon, 2 Mar 2026 11:40:01 +0100 Subject: [PATCH 02/14] Remove old metrics --- evaluate_rag.py | 35 ---------------------- evaluation/__init__.py | 6 ---- evaluation/case_evaluation.py | 36 ++++------------------ evaluation/metrics.py | 56 +++++++---------------------------- 4 files changed, 16 insertions(+), 117 deletions(-) diff --git a/evaluate_rag.py b/evaluate_rag.py index c3752b2..d02e70b 100644 --- a/evaluate_rag.py +++ b/evaluate_rag.py @@ -239,10 +239,6 @@ def main() -> None: # Log per-case metrics with a step index (for easier aggregation in MLflow UI charts) if res.get("mae_score") is not None: mlflow.log_metric("mae_score_list", res["mae_score"], step=i) - if res.get("mean_note_similarity") is not None: - mlflow.log_metric("note_similarity_list", res["mean_note_similarity"], step=i) - if res.get("groundedness_score") is not None: - mlflow.log_metric("groundedness_list", res["groundedness_score"], step=i) if res.get("faithfulness_score") is not None: mlflow.log_metric("faithfulness_list", res["faithfulness_score"], step=i) if res.get("relevancy_score") is not None: @@ -265,8 +261,6 @@ def main() -> None: total_score_pairs = sum(r["num_pairs"] for r in all_results) - total_note_pairs = sum(r.get("note_similarity_count", 0) for r in all_results) - total_groundedness_samples = sum(r.get("groundedness_sample_count", 0) for r in all_results) total_faithfulness_samples = sum(r.get("faithfulness_sample_count", 0) for r in all_results) total_relevancy_samples = sum(r.get("relevancy_sample_count", 0) for r in all_results) total_correctness_samples = sum(r.get("correctness_sample_count", 0) for r in all_results) @@ -278,21 +272,6 @@ def main() -> None: else 0.0 ) - # Weighted note similarity by number of note pairs - weighted_note_similarity = ( - sum((r.get("mean_note_similarity") or 0.0) * (r.get("note_similarity_count") or 0) for r in all_results) / total_note_pairs - if total_note_pairs > 0 - else 0.0 - ) - - # Weighted groundedness by sample count - weighted_groundedness = None - groundedness_results = [r for r in all_results if r.get("groundedness_score") is not None] - if groundedness_results and total_groundedness_samples > 0: - weighted_groundedness = ( - sum((r.get("groundedness_score") or 0) * (r.get("groundedness_sample_count") or 0) for r in groundedness_results) / total_groundedness_samples - ) - # Weighted faithfulness by sample count weighted_faithfulness = None faithfulness_results = [r for r in all_results if r.get("faithfulness_score") is not None] @@ -319,14 +298,10 @@ def main() -> None: else: # Fallback values if no results were processed (e.g., all cases were skipped due to missing files) total_score_pairs = 0 - total_note_pairs = 0 - total_groundedness_samples = 0 total_faithfulness_samples = 0 total_relevancy_samples = 0 total_correctness_samples = 0 weighted_mae = 0.0 - weighted_note_similarity = 0.0 - weighted_groundedness = None weighted_faithfulness = None weighted_relevancy = None weighted_correctness = None @@ -335,13 +310,9 @@ def main() -> None: "total_cases": len(all_results), "total_score_pairs": total_score_pairs, "weighted_mae_score": weighted_mae, - "total_note_pairs": total_note_pairs, - "mean_note_similarity": weighted_note_similarity, - "total_groundedness_samples": total_groundedness_samples, "total_faithfulness_samples": total_faithfulness_samples, "total_relevancy_samples": total_relevancy_samples, "total_correctness_samples": total_correctness_samples, - "mean_groundedness_score": weighted_groundedness, "mean_faithfulness_score": weighted_faithfulness, "mean_relevancy_score": weighted_relevancy, "mean_correctness_score": weighted_correctness, @@ -356,13 +327,7 @@ def main() -> None: mlflow.log_metric("mae_score_pairs", total_score_pairs) if weighted_mae is not None: mlflow.log_metric("mae_weighted_score", weighted_mae) - mlflow.log_metric("note_similarity_pairs", total_note_pairs) - if weighted_note_similarity is not None: - mlflow.log_metric("note_similarity_mean", weighted_note_similarity) - if weighted_groundedness is not None: - mlflow.log_metric("groundedness_score", weighted_groundedness) - mlflow.log_metric("groundedness_samples", total_groundedness_samples) if weighted_faithfulness is not None: mlflow.log_metric("faithfulness_score", weighted_faithfulness) mlflow.log_metric("faithfulness_samples", total_faithfulness_samples) diff --git a/evaluation/__init__.py b/evaluation/__init__.py index 27c3eee..35f09b8 100644 --- a/evaluation/__init__.py +++ b/evaluation/__init__.py @@ -4,7 +4,6 @@ from evaluation.data_loading import load_params, load_text, load_ground_truth_csv from evaluation.metrics import ( compute_mae, - compute_note_similarity, compute_ragas_metrics, ) @@ -17,12 +16,7 @@ "load_text", "load_ground_truth_csv", "compute_mae", - "compute_note_similarity", "compute_ragas_metrics", - "split_document_for_groundedness", - "build_requirement_question", - "select_relevant_contexts", - "extract_ground_truth_note", "log_case_input_artifacts", "evaluate_single_case", "slugify_case_name", diff --git a/evaluation/case_evaluation.py b/evaluation/case_evaluation.py index bb34391..dce941a 100644 --- a/evaluation/case_evaluation.py +++ b/evaluation/case_evaluation.py @@ -8,7 +8,6 @@ from evaluation.data_loading import load_ground_truth_csv, load_text from evaluation.metrics import ( compute_mae, - compute_note_similarity, compute_ragas_metrics, compute_main_requirement_metrics, ) @@ -117,7 +116,6 @@ def evaluate_single_case( # Initialize accumulators for metrics gt_scores: List[float] = [] pred_scores: List[float] = [] - note_similarities: List[float] = [] ragas_records: List[Dict[str, Any]] = [] if ground_truth: @@ -128,14 +126,14 @@ def evaluate_single_case( print(f"⚠ Skipping prediction with missing or unmatched Requirement_ID: {requirement_id}") continue - document_context = pred.get("Context") or [] # Get the context from the prediction, which should be the same as the one used for RAG evaluation. Used for groundedness evaluation in RAGAS, to ensure consistency between the information available at inference time and at evaluation time. + document_context = pred.get("Context") or [] # Get the context from the prediction for RAGAS evaluation gt_row = ground_truth[requirement_id] gt_score: Optional[float] = None pred_score: Optional[float] = None - # If Score is 'N/A' or missing, we treat it as None and exclude from MAE calculation, but still include in note similarity and groundedness if notes are available. Log warnings for invalid score formats. + # If Score is 'N/A' or missing, we treat it as None and exclude from MAE calculation. Log warnings for invalid score formats. try: if gt_row.get("Score") != 'N/A': gt_score = float(gt_row.get("Score", "0")) @@ -169,22 +167,15 @@ def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: #rationale = pred.get("Rationale") or pred.get("rationale") #pred_note = auditor_notes + ("\nRationale: " + rationale if rationale else "") pred_note = auditor_notes - if gt_note and pred_note: - try: - similarity = compute_note_similarity(gt_note, pred_note, embedding_model) - note_similarities.append(similarity) - except Exception as exc: - print(f"⚠ Failed to compute note similarity for {requirement_id}: {exc}") - - # Build question text for RAGAS groundedness evaluation + + # Build question text for RAGAS evaluation identifier = requirement_id or "Unknown requirement" requirement_name = pred.get("Requirement_Name") or gt_row.get("Requirement_Name") title = requirement_name # Only use title and id, no metadata question_text = f"Is the provided document compliant with the requirement '{title}', according with the provided regulatory chunks from UE AI ACT and ISO standard 42001:2023?" - # Context is made up by the whole chunks extracted from the Document under Test for the particular requirement. This is the same context that the RAG engine used to produce the prediction, so it allows us to evaluate groundedness in a way that is consistent with the actual information available to the model at inference time. - # We can reuse the already built context, passing it as a parameter to this function. + # Context is made up by the whole chunks extracted from the Document under Test for the particular requirement. ragas_records.append( { @@ -225,14 +216,8 @@ def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: # Compute Metrics if ground_truth: mae = compute_mae(gt_scores, pred_scores) - mean_note_similarity = ( - sum(note_similarities) / len(note_similarities) - if note_similarities - else 0.0 - ) - # Compute faithfulness on SUB-requirements (without ground truth) + # Compute faithfulness on SUB-requirements sub_metrics = compute_ragas_metrics(sub_ragas_records, embedding_model=embedding_model) - case_groundedness_score = sub_metrics.get("groundedness") case_faithfulness_score = sub_metrics.get("faithfulness") # Compute AnswerCorrectness and AnswerRelevancy on MAIN requirements @@ -255,10 +240,6 @@ def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: "num_pairs": len(gt_scores), "mae_score": mae, "artifacts": artifacts, - "note_similarity_count": len(note_similarities), - "mean_note_similarity": mean_note_similarity, - "groundedness_score": case_groundedness_score, - "groundedness_sample_count": len(sub_ragas_records), "faithfulness_score": case_faithfulness_score, "faithfulness_sample_count": len(sub_ragas_records), "relevancy_score": case_relevancy_score, @@ -272,7 +253,6 @@ def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: else: # No Ground Truth available, we can only compute RAGAS metrics that do not require GT sub_metrics = compute_ragas_metrics(sub_ragas_records, embedding_model=embedding_model) - case_groundedness_score = sub_metrics.get("groundedness") case_faithfulness_score = sub_metrics.get("faithfulness") # Compute AnswerRelevancy on MAIN requirements (doesn't require GT) @@ -286,10 +266,6 @@ def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: "num_pairs": 0, "mae_score": None, "artifacts": artifacts, - "note_similarity_count": 0, - "mean_note_similarity": None, - "groundedness_score": case_groundedness_score, - "groundedness_sample_count": len(sub_ragas_records), "faithfulness_score": case_faithfulness_score, "faithfulness_sample_count": len(sub_ragas_records), "relevancy_score": case_relevancy_score, diff --git a/evaluation/metrics.py b/evaluation/metrics.py index 0814d25..9479ec7 100644 --- a/evaluation/metrics.py +++ b/evaluation/metrics.py @@ -12,21 +12,15 @@ try: from ragas import evaluate as ragas_evaluate - from ragas.metrics import Faithfulness, ResponseGroundedness, AnswerRelevancy, AnswerCorrectness + from ragas.metrics import Faithfulness, AnswerRelevancy, AnswerCorrectness except ImportError: ragas_evaluate = None - ResponseGroundedness = None Faithfulness = None AnswerRelevancy = None AnswerCorrectness = None -RAGAS_GROUNDEDNESS_AVAILABLE = ( - Dataset is not None - and ragas_evaluate is not None - and ResponseGroundedness is not None -) RAGAS_FAITHFULNESS_AVAILABLE = ( Dataset is not None and ragas_evaluate is not None @@ -52,33 +46,7 @@ def compute_mae(gt_scores: List[float], pred_scores: List[float]) -> float: return sum(diffs) / len(diffs) -def compute_note_similarity(gt_note: str, pred_note: str, embedding_model) -> float: - """ - Compute cosine similarity between ground-truth and predicted notes using embeddings. - Args: - gt_note (str): Ground-truth note text. - pred_note (str): Predicted note text. - embedding_model: SentenceTransformer or compatible model with .encode(). - Returns: - float: Cosine similarity between the two notes in [-1, 1]. - """ - if not gt_note or not pred_note: - return 0.0 - if embedding_model is None: - raise ValueError("embedding_model must be provided to compute_note_similarity.") - - embeddings = embedding_model.encode( - [gt_note, pred_note], - convert_to_numpy=True, - ) - gt_vec, pred_vec = embeddings - gt_norm = np.linalg.norm(gt_vec) - pred_norm = np.linalg.norm(pred_vec) - if not gt_norm or not pred_norm: - return 0.0 - similarity = float(np.dot(gt_vec, pred_vec) / (gt_norm * pred_norm)) # Cosine similarity (Dot product divided by norms) - return max(min(similarity, 1.0), -1.0) # Ensure similarity is in [-1, 1] range @@ -86,17 +54,17 @@ def compute_note_similarity(gt_note: str, pred_note: str, embedding_model) -> fl def compute_ragas_metrics(samples: List[Dict[str, Any]], embedding_model=None) -> Dict[str, Optional[float]]: """ - Compute groundedness, faithfulness, relevancy, and correctness scores for a list of samples using a single Ragas evaluation call. + Compute faithfulness scores for a list of samples using a single Ragas evaluation call. Args: samples: List of evaluation samples embedding_model: Optional SentenceTransformer model instance to reuse (avoids reloading) - Returns a dict with keys 'groundedness', 'faithfulness', 'relevancy', and 'correctness'. + Returns a dict with key 'faithfulness'. """ if not samples: - return {"groundedness": None, "faithfulness": None, "relevancy": None, "correctness": None} - if not (RAGAS_GROUNDEDNESS_AVAILABLE and RAGAS_FAITHFULNESS_AVAILABLE and RAGAS_RELEVANCY_AVAILABLE and RAGAS_CORRECTNESS_AVAILABLE): - print("⚠ RAGAS metrics unavailable (check ragas/datasets installation).") - return {"groundedness": None, "faithfulness": None, "relevancy": None, "correctness": None} + return {"faithfulness": None} + if not RAGAS_FAITHFULNESS_AVAILABLE: + print("⚠ RAGAS faithfulness metric unavailable (check ragas/datasets installation).") + return {"faithfulness": None} import yaml import os @@ -112,7 +80,7 @@ def compute_ragas_metrics(samples: List[Dict[str, Any]], embedding_model=None) - if llm_model is None: print("⚠ LLM model name not found in params.yaml under evaluation.llm_model.") - return {"groundedness": None, "faithfulness": None, "relevancy": None, "correctness": None} + return {"faithfulness": None} # Configure LLM for RAGAS llm = ChatOpenAI(model=llm_model, temperature=llm_temperature, request_timeout=180) @@ -153,17 +121,13 @@ def compute_ragas_metrics(samples: List[Dict[str, Any]], embedding_model=None) - faithfulness = None print(" ⚠️ No faithfulness column found!") - # Groundedness is not computed # Relevancy and Correctness are computed separately on main requirements return { - "groundedness": None, - "faithfulness": faithfulness, - "relevancy": None, # Computed separately on main requirements - "correctness": None # Computed separately on main requirements + "faithfulness": faithfulness } except Exception as e: print(f"⚠ Failed to compute RAGAS metrics: {e}") - return {"groundedness": None, "faithfulness": None, "relevancy": None, "correctness": None} + return {"faithfulness": None} def compute_main_requirement_metrics( From 3d8ee5125b4f22fd5567e438587f44229c50bb9a Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Mon, 2 Mar 2026 11:41:57 +0100 Subject: [PATCH 03/14] Frontend bug fix --- evaluation/metrics.py | 1 - frontend/pages/Audit_Compliance.py | 7 +++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/evaluation/metrics.py b/evaluation/metrics.py index 9479ec7..67fbe97 100644 --- a/evaluation/metrics.py +++ b/evaluation/metrics.py @@ -1,7 +1,6 @@ """Metrics computation for RAG evaluation.""" from typing import List, Dict, Any, Optional -import numpy as np from langchain_openai import ChatOpenAI, OpenAIEmbeddings try: from datasets import Dataset diff --git a/frontend/pages/Audit_Compliance.py b/frontend/pages/Audit_Compliance.py index 6fdb4e6..602db8e 100644 --- a/frontend/pages/Audit_Compliance.py +++ b/frontend/pages/Audit_Compliance.py @@ -591,6 +591,13 @@ def get_reference_details(req_id, requirements_data): # ---------------------------- st.progress(req["progress"]) + + with col_B: + st.caption("AI FINDINGS") + st.write(req["notes"]) + + st.caption("Rationale") + st.text(req.get("rationale", "No rationale provided.")) elif analyze_btn and not doc_text: st.warning("Please upload a file or paste text.") From 75db8dbafd11277133c5d43143be70103d41b076 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:19:41 +0000 Subject: [PATCH 04/14] refactor: remove AI-like conversational comments and notes - Removed train-of-thought comments from `backend/rag_engine.py` - Removed first-person and conversational notes from `evaluation/case_evaluation.py`, `frontend/app.py`, `vectorize_data.py`, and `evaluate_rag.py` - Reformatted excessively long single-line comments into standard professional docstrings and block comments - Removed "AI engine" phrasing from frontend UI notes to sound more professional Co-authored-by: davidedm26 <117094949+davidedm26@users.noreply.github.com> --- backend/rag_engine.py | 47 +++++++++++++---------------------- evaluate_rag.py | 9 +++---- evaluation/case_evaluation.py | 13 +++++----- frontend/app.py | 4 +-- vectorize_data.py | 8 +++--- 5 files changed, 34 insertions(+), 47 deletions(-) diff --git a/backend/rag_engine.py b/backend/rag_engine.py index 1bbedab..e93b072 100644 --- a/backend/rag_engine.py +++ b/backend/rag_engine.py @@ -74,7 +74,7 @@ class RequirementReportAPI(BaseModel): class AuditResponse(BaseModel): - requirements: List[RequirementReport] #We can include the providex doc context for future steps + requirements: List[RequirementReport] # Includes the provided doc context for future steps class AuditResponseAPI(BaseModel): @@ -95,7 +95,8 @@ class AuditResponseAPI(BaseModel): _initialized = False # Flag to prevent re-initialization if already done -def _candidate_paths(relative_path: str) -> List[str]: # Generate candidate paths for a given relative path, including both relative and absolute forms, and ensure uniqueness while preserving order +def _candidate_paths(relative_path: str) -> List[str]: + # Generate candidate paths for a given relative path, including both relative and absolute forms, and ensure uniqueness while preserving order rel = relative_path.replace("\\", "/") candidates = [ os.path.join(PROJECT_ROOT, rel), @@ -109,7 +110,9 @@ def _candidate_paths(relative_path: str) -> List[str]: # Generate candidate path return unique -# Initialization function to set up vector DB, mapping, requirement chunks, and LLM. It checks for environment variables to determine how to connect to Qdrant (external service vs embedded index) and loads necessary data files. The function can be forced to re-initialize if needed. +# Initialization function to set up vector DB, mapping, requirement chunks, and LLM. +# It checks for environment variables to determine how to connect to Qdrant (external service vs embedded index) +# and loads necessary data files. The function can be forced to re-initialize if needed. def init_rag(force: bool = False) -> None: global vector_db, llm, embedding_model, requirement_chunks, _initialized, retrieval_engine, evaluation_engine if _initialized and not force: @@ -168,28 +171,10 @@ def init_rag(force: bool = False) -> None: # Initialize engines evaluation_engine = EvaluationEngine(llm) - # Note: RetrievalEngine needs a doc_client, but that's per-document (in-memory). - # We can instantiate it dynamically or pass None and set it later. - # But wait, RetrievalEngine handles queries. It needs the embedding model. - # The doc_client is passed to the query method, so we can init RetrievalEngine here with just the model if we redesign it slightly, - # OR we just use it as a helper class instantiated per request? - # Let's keep it consistent: Init one "Service" or helper. - # But wait, the original `_query_qdrant` took `doc_client` as arg. - # So `RetrievalEngine` methods should probably take `doc_client`. - # Let's adjust RetrievalEngine to be a stateless service or initialized with global dependencies. - - # Actually, RetrievalEngine was designed in step 1 to take `doc_client` in __init__. - # That means we need to instantiate it *per audit* or *per evaluation*. - # Let's instantiate a global helper if possible, or just keep the class definition available. - # Ideally, `RetrievalEngine` holds the embedding model. + + # Instantiate RetrievalEngine globally with the embedding model. + # The doc_client will be provided per-request during evaluation. retrieval_engine = RetrievalEngine(doc_client=None, embedding_model=embedding_model) - # We will override doc_client in the method call or set it on the instance before use. - # Better design: pass doc_client to `query_for_requirement`. Let's assume I did that in step 1 (I did). - # Wait, I checked step 1 code: - # `class RetrievalEngine: def __init__(self, doc_client: QdrantClient, embedding_model: Optional[SentenceTransformer] = None): ...` - # `def query_for_requirement(self, collection_name: str, ...)` -> it uses `self.doc_client`. - # So I must instantiate it with the doc_client. - # Since doc_client is created in `audit_document`, I should instantiate RetrievalEngine there. _initialized = True except Exception as exc: @@ -270,8 +255,7 @@ def evaluate_requirement( ) # 2. Select relevant document chunks using RetrievalEngine - # Instantiate RetrievalEngine for this document context - # Note: We use the global embedding_model + # Instantiate RetrievalEngine for this document context using the global embedding_model retriever = RetrievalEngine(doc_client=_doc_client, embedding_model=embedding_model) pre_rerank_top_k = int(rag_params.get("pre_rerank_top_k", 10)) @@ -373,7 +357,7 @@ def evaluate_requirement( for cid, data in unique_chunks.items(): ragas_contexts.append(f"[DOCUMENT] {data['content']}") - # We need to reconstruct the result dict expected by aggregate_results + # Reconstruct the result dict expected by aggregate_results # Combine rationale and notes for RAGAS evaluation to improve groundedness combined_answer = f"{result.get('rationale', '')}\n\nSummary: {result.get('auditor_notes', '')}" @@ -436,7 +420,9 @@ def audit_document( doc_embs = _embed_chunks(doc_chunks, embedding_model) - #Store document chunks and embeddings in a temporary in-memory Qdrant collection for efficient retrieval during requirement evaluation. This avoids the need for file-based storage and cleanup issues, while still allowing us to leverage Qdrant's vector search capabilities. + # Store document chunks and embeddings in a temporary in-memory Qdrant collection + # for efficient retrieval during requirement evaluation. This avoids file-based storage + # and cleanup issues while still leveraging Qdrant's vector search capabilities. temp_collection = f"temp_doc_{os.getpid()}_audit" # Use in-memory Qdrant for doc_client to avoid file locking and cleanup issues doc_client = QdrantClient(":memory:") @@ -464,7 +450,7 @@ def audit_document( index_path = vect_params.get("vector_index_path", "data/processed/vector_index") regulatory_client = QdrantClient(path=index_path) - # For each requirement in requirement_chunks (list), call evaluate_requirement to get the report for that requirement and collect all reports in a list. + # For each requirement in requirement_chunks, call evaluate_requirement to get its report. req_iter = requirement_chunks if requirement_limit is not None: @@ -480,7 +466,8 @@ def audit_document( ) requirements_reports.append(requirement_report) - if debug_dump_path: # If a debug dump path is provided, save the raw requirement reports + if debug_dump_path: + # If a debug dump path is provided, save the raw requirement reports debug_dir = os.path.dirname(debug_dump_path) or "." os.makedirs(debug_dir, exist_ok=True) with open(debug_dump_path, "w", encoding="utf-8") as f: diff --git a/evaluate_rag.py b/evaluate_rag.py index d02e70b..aae5258 100644 --- a/evaluate_rag.py +++ b/evaluate_rag.py @@ -96,7 +96,7 @@ def main() -> None: # Set seed for reproducibility where possible random.seed(random_seed) - # Prepare metrics dir ( useful when MLflow is not configured and we rely on local JSON output for metrics storage, e.g., for DVC tracking ) + # Prepare metrics dir (useful when MLflow is not configured and local JSON output is needed for metrics storage, e.g., for DVC tracking) metrics_dir = os.path.dirname(metrics_output) if metrics_dir: os.makedirs(metrics_dir, exist_ok=True) @@ -173,8 +173,7 @@ def main() -> None: example_content = "EXAMPLE_CONTENT" example_chunks = ["EXAMPLE_CHUNK_1", "EXAMPLE_CHUNK_2"] - # Access private methods or use a public method to get template if available - # Since methods are semi-private (_get_sub_prompt), we access them for logging purposes + # Access methods to log prompt templates sub_prompt_template = rag_engine.evaluation_engine._get_sub_prompt("EXAMPLE_MAIN_REQ", example_reference, "EXAMPLE_SOURCE", example_content, example_chunks) with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_sub_prompt_template.txt", encoding="utf-8") as tf: tf.write(sub_prompt_template) @@ -206,7 +205,7 @@ def main() -> None: print(f"Evaluating case: {name}") if not os.path.exists(doc_path): print(f"⚠ Document not found: {doc_path}") - continue # Skip this case if document is missing + continue if report_path is None or not os.path.exists(report_path): print(f"⚠ Ground truth report not found, Score MAE, Note Similarity and RAGAS Correctness metrics will be skipped for case: {doc_path}") report_path_for_eval = None @@ -256,7 +255,7 @@ def main() -> None: artifact_path=f"cases/{case_slug}/outputs/{label}", ) - # After processing all cases, compute aggregated metrics across all results and log them to MLflow and/or save to a JSON file for DVC tracking or other uses. + # Compute aggregated metrics across all results and log them to MLflow and/or save to a JSON file for DVC tracking. if all_results: diff --git a/evaluation/case_evaluation.py b/evaluation/case_evaluation.py index dce941a..5fbc976 100644 --- a/evaluation/case_evaluation.py +++ b/evaluation/case_evaluation.py @@ -53,9 +53,9 @@ def evaluate_single_case( # The rationale provides detailed, grounded analysis combined_answer = sub.get('Rationale', '') - # The prompt/question logic needs to be reconstructed or we rely on contexts - # Since we didn't save the explicit ragas_question in SubRequirementReport, - # we will re-generate it here based on the available data. + # The prompt/question logic needs to be reconstructed or rely on contexts + # Since the explicit ragas_question is not saved in SubRequirementReport, + # re-generate it here based on the available data. req_name = pred.get("Requirement_Name", "Unknown") sub_name = sub.get("Reference", "") source = sub.get("Source", "") @@ -66,7 +66,7 @@ def evaluate_single_case( contexts = sub.get("Contexts", []) - # Only add if there is at least some context or a non-trivial answer + # Add only if there is at least some context or a non-trivial answer if (contexts and any(c.strip() for c in contexts)) or (combined_answer and combined_answer.strip() and "no information" not in combined_answer.lower()): sub_ragas_records.append({ "question": ragas_question, @@ -133,7 +133,8 @@ def evaluate_single_case( gt_score: Optional[float] = None pred_score: Optional[float] = None - # If Score is 'N/A' or missing, we treat it as None and exclude from MAE calculation. Log warnings for invalid score formats. + # If Score is 'N/A' or missing, treat it as None and exclude from MAE calculation. + # Log warnings for invalid score formats. try: if gt_row.get("Score") != 'N/A': gt_score = float(gt_row.get("Score", "0")) @@ -251,7 +252,7 @@ def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: ragas_records, # Return main requirement records too ) else: - # No Ground Truth available, we can only compute RAGAS metrics that do not require GT + # No Ground Truth available, compute only RAGAS metrics that do not require GT sub_metrics = compute_ragas_metrics(sub_ragas_records, embedding_model=embedding_model) case_faithfulness_score = sub_metrics.get("faithfulness") diff --git a/frontend/app.py b/frontend/app.py index c351dcc..25f1112 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -160,7 +160,7 @@ """, unsafe_allow_html=True) - st.info("💡 **Note:** This is an AI-based decision support tool and does not replace professional legal advice.") + st.info("💡 **Disclaimer:** This tool provides decision support based on automated analysis and does not replace professional legal advice.") # ============================================================================== # HEADER (LOGO + TITLE) @@ -204,7 +204,7 @@ st.markdown("""
Click to Start Audit
- Our AI engine is ready to analyze your technical documentation
+ The analysis engine is ready to process your technical documentation
against EU AI Act & ISO 42001 standards.
""", unsafe_allow_html=True) diff --git a/vectorize_data.py b/vectorize_data.py index b28a3f9..54cce45 100644 --- a/vectorize_data.py +++ b/vectorize_data.py @@ -44,7 +44,7 @@ def main(): print(f"Loaded {len(requirements)} requirements from {chunks_path}.") # Decide mode: local qdrant process (path) or remote service (host:port) - # If the QDRANT_HOST environment variable is set, we assume remote service mode; otherwise, we use local path-based storage. This allows flexibility for different deployment scenarios (local development vs production). + # Determine the Qdrant connection mode: remote service (QDRANT_HOST set) or local path-based storage. qdrant_host = os.getenv("QDRANT_HOST") # Use localhost to connect to qdrant container when running this script in local environment. qdrant_port = int(os.getenv("QDRANT_PORT", "6333")) @@ -52,7 +52,7 @@ def main(): vector_index_path = vect_params.get('vector_index_path') collection_name = vect_params.get('collection_name') - # Helper function to wait for Qdrant service to be ready (only relevant for remote service mode) + # Wait for Qdrant service to be ready (only relevant for remote service mode) def wait_for_qdrant(host: str, port: int, timeout: int = 60): url = f"http://{host}:{port}/healthz" deadline = time.time() + timeout @@ -66,7 +66,7 @@ def wait_for_qdrant(host: str, port: int, timeout: int = 60): time.sleep(1) return False - if qdrant_host: # If QDRANT_HOST is set, we're in remote service mode + if qdrant_host: # Remote service mode print(f"Using remote Qdrant service at {qdrant_host}:{qdrant_port}") if not wait_for_qdrant(qdrant_host, qdrant_port, timeout=60): print("⚠ Qdrant service not ready (health check failed). Aborting.") @@ -175,7 +175,7 @@ def wait_for_qdrant(host: str, port: int, timeout: int = 60): json.dump({"status": "indexed", "count": len(requirements)}, f) else: - print("Note: no local vector_index_path configured; skipping snapshot creation for remote Qdrant.") + print("Notice: no local vector_index_path configured; skipping snapshot creation for remote Qdrant.") print("✓ Vectorization completed.") From 313ecd78e1e0bde87dead44281c1d8c2dcee8861 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Mon, 2 Mar 2026 17:14:48 +0100 Subject: [PATCH 05/14] Debug eval --- backend/rag_engine.py | 2 +- data/ground_truth/raw_data.dvc | 6 +++--- dvc.lock | 36 +++++++++++++++++----------------- evaluate_rag.py | 2 +- params.yaml | 6 +++--- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/backend/rag_engine.py b/backend/rag_engine.py index 1bbedab..cc0dca3 100644 --- a/backend/rag_engine.py +++ b/backend/rag_engine.py @@ -41,7 +41,7 @@ def load_params(path: Optional[str] = None) -> Dict[str, Any]: -RequirementScore = Union[int, str] # Score can be an integer from 0 to 5, or "N/A" if not applicable or if parsing fails +RequirementScore = Union[int, float, str] # Score can be an integer/float from 0 to 5, or "N/A" if not applicable or if parsing fails class SubRequirementReport(BaseModel): Reference: str diff --git a/data/ground_truth/raw_data.dvc b/data/ground_truth/raw_data.dvc index be54c92..ed98817 100644 --- a/data/ground_truth/raw_data.dvc +++ b/data/ground_truth/raw_data.dvc @@ -1,6 +1,6 @@ outs: -- md5: 3ee810d753fe5b07f4a09cf03edcc914.dir - size: 7510581 - nfiles: 16 +- md5: fb49202c57f95c814626efc6a93eab87.dir + size: 7522953 + nfiles: 17 hash: md5 path: raw_data diff --git a/dvc.lock b/dvc.lock index 3cf1821..bd81369 100644 --- a/dvc.lock +++ b/dvc.lock @@ -98,25 +98,25 @@ stages: deps: - path: backend/rag_engine.py hash: md5 - md5: 2b974013413dc0e9d2631911e19e401f - size: 23979 + md5: e6e210d4a7b4d932be8d7a45375c90f9 + size: 23992 - path: data/ground_truth hash: md5 - md5: 81ce162dacdac7ae45f824a0e0658991.dir - size: 7510706 - nfiles: 18 + md5: 576b8991f61e6383ebe3510b661eb7b0.dir + size: 7523078 + nfiles: 19 - path: data/processed/requirement_chunks.json hash: md5 md5: 16b6d0d3c497bcc8e45c091ea69260af size: 121766 - path: evaluate_rag.py hash: md5 - md5: 2851df8f052246e69e4b641a370edd6b - size: 20644 + md5: 5c6a97586f709ef4f7143e6f73bcd262 + size: 18453 - path: evaluation/ hash: md5 - md5: 9938ea6c11593c695b1e723d9bbc5c17.dir - size: 73802 + md5: 808daa05e8c1605c693d03fe0b1a5b6d.dir + size: 66721 nfiles: 13 params: params.yaml: @@ -125,13 +125,13 @@ stages: llm_temperature: 0.0 mlflow_experiment: rag_evaluation case_selector: - - 2 - requirement_limit: 2 + - 1 + requirement_limit: ground_truth: - name: Evaluation1 document_path: data/ground_truth/raw_data/Evaluation1/documentation.txt - report_path: data/ground_truth/raw_data/Evaluation1/report_2.CSV + report_path: data/ground_truth/raw_data/Evaluation1/report_3.CSV - name: Evaluation2 document_path: data/ground_truth/raw_data/Evaluation2/documentation.txt @@ -163,13 +163,13 @@ stages: outs: - path: metrics/rag_eval.json hash: md5 - md5: 2f79f8d528e2df2480e5de2206189462 - size: 2443 + md5: 0d9759fcce7757f983878b6e6265d6e4 + size: 14052 - path: metrics/ragas_main_requirements.json hash: md5 - md5: ffd329c918ce28585736ad502194cf27 - size: 3070 + md5: e5d4449c59f5b0615dc2bf83ce253cd4 + size: 30041 - path: metrics/ragas_sub_requirements.json hash: md5 - md5: 9129a9d190f22012c11df684245dda66 - size: 19488 + md5: 14c986cbdbf933ffd820ef65d60ba9a3 + size: 235415 diff --git a/evaluate_rag.py b/evaluate_rag.py index d02e70b..7021370 100644 --- a/evaluate_rag.py +++ b/evaluate_rag.py @@ -183,7 +183,7 @@ def main() -> None: agg_prompt_template = rag_engine.evaluation_engine._get_aggregate_prompt([ {"reference": example_reference, "score": 5, "answer": "Good"} - ]) + ], computed_score=3.5) with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_aggregate_prompt_template.txt", encoding="utf-8") as tf: tf.write(agg_prompt_template) tf.flush() diff --git a/params.yaml b/params.yaml index 00a73ec..27f6109 100644 --- a/params.yaml +++ b/params.yaml @@ -25,12 +25,12 @@ evaluation: llm_model: "gpt-4o-mini" # RAGAS evaluation LLM model llm_temperature: 0.0 mlflow_experiment: "rag_evaluation" - case_selector: [2] # e.g., 2 or [1,3] to evaluate specific cases; null runs all - requirement_limit: 2 # Limit number of requirements to evaluate; null evaluates all + case_selector: [1] # e.g., 2 or [1,3] to evaluate specific cases; null runs all + requirement_limit: null # Limit number of requirements to evaluate; null evaluates all ground_truth: - name: "Evaluation1" document_path: "data/ground_truth/raw_data/Evaluation1/documentation.txt" - report_path: "data/ground_truth/raw_data/Evaluation1/report_2.CSV" + report_path: "data/ground_truth/raw_data/Evaluation1/report_3.CSV" - name: "Evaluation2" document_path: "data/ground_truth/raw_data/Evaluation2/documentation.txt" report_path: "data/ground_truth/raw_data/Evaluation2/report_2.CSV" From fe274d7c5c55ef94d6dcf86d87f2f5514ed29357 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Mon, 2 Mar 2026 20:08:24 +0100 Subject: [PATCH 06/14] Clean code --- .dockerignore | 77 +++++++-- .env.example | 2 +- .github/scripts/check_and_issue.py | 4 - .gitignore | 133 +++++++++++---- backend/core/evaluation.py | 5 +- backend/core/retrieval.py | 40 ++++- backend/rag_engine.py | 62 +++---- data/ground_truth/.gitignore | 1 - docker-compose.yml | 6 +- evaluate_rag.py | 36 ++-- evaluation/__init__.py | 4 +- evaluation/case_evaluation.py | 254 ++++++++++++----------------- evaluation/metrics.py | 8 +- generate_debug_audits.py | 93 ++++++++++- img/logo.png | Bin 32773 -> 75909 bytes ingestion/data_ingestion.py | 227 ++++++++++++++++++++------ ingestion/parse_aia.py | 31 +++- ingestion/parse_iso.py | 27 ++- params.yaml | 33 ++-- requirements.txt | 52 +++--- vectorize_data.py | 9 +- 21 files changed, 720 insertions(+), 384 deletions(-) delete mode 100644 data/ground_truth/.gitignore diff --git a/.dockerignore b/.dockerignore index 2729aa3..65a6202 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,43 +1,98 @@ -# Escludi file di sistema e cache Python +# ============================================================================== +# Docker Build Context Exclusions - LegalAIze +# ============================================================================== +# Files and directories excluded from Docker build context to reduce image size +# and prevent sensitive data from being included in containers. +# ============================================================================== + +# ------------------------------------------------------------------------------ +# Python Runtime & Cache +# ------------------------------------------------------------------------------ __pycache__/ *.py[cod] *$py.class *.so .Python + +# Virtual Environments env/ venv/ ENV/ .venv +backend_venv/ +frontend_venv/ -# Escludi file IDE +# ------------------------------------------------------------------------------ +# Development Tools & IDEs +# ------------------------------------------------------------------------------ .vscode/ .idea/ *.swp *.swo +*.swn -# Escludi file OS +# ------------------------------------------------------------------------------ +# OS-Specific Files +# ------------------------------------------------------------------------------ .DS_Store Thumbs.db +desktop.ini -# SECURITY: EXCLUDE KEYS AND SECRETS +# ------------------------------------------------------------------------------ +# Security & Credentials (NEVER include in images) +# ------------------------------------------------------------------------------ .env dagshub_token.txt -*.log +*.pem +*.key +*.crt +secrets/ -# Exclude data, models, output, DVC, git, etc. +# ------------------------------------------------------------------------------ +# Data & Model Artifacts (too large for container images) +# ------------------------------------------------------------------------------ data/ models/ mlruns/ mlartifacts/ -.dvc/ +metrics/ +output/ +tmp/ +logs/ + +# ------------------------------------------------------------------------------ +# Version Control & Experiment Tracking +# ------------------------------------------------------------------------------ .git/ +.dvc/ *.dvc +.gitignore +.dockerignore + +# ------------------------------------------------------------------------------ +# Archives & Large Files +# ------------------------------------------------------------------------------ *.gz *.zip *.tar *.tar.gz +*.rar +*.7z + +# ------------------------------------------------------------------------------ +# Jupyter Notebooks & Documentation +# ------------------------------------------------------------------------------ *.ipynb -output/ -tmp/ -logs/ -notebooks/ \ No newline at end of file +notebooks/ +docs/ +*.md +!README.md + +# ------------------------------------------------------------------------------ +# Test & CI/CD +# ------------------------------------------------------------------------------ +tests/ +.pytest_cache/ +.coverage +htmlcov/ +.github/ \ No newline at end of file diff --git a/.env.example b/.env.example index 08e12a1..be92a2f 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # MLflow/DagsHub Configuration Examples -# Use one of the following configurations for MLflow tracking. +# Use ONLY ONE of the following configurations for MLflow tracking. # 1. Local MLflow tracking (recommended for most users) MLFLOW_TRACKING_URI=http://localhost:5000 diff --git a/.github/scripts/check_and_issue.py b/.github/scripts/check_and_issue.py index 59f9639..a296c24 100644 --- a/.github/scripts/check_and_issue.py +++ b/.github/scripts/check_and_issue.py @@ -12,9 +12,7 @@ # List of actually used metrics in rag_eval.json metric_keys = [ "weighted_mae_score", - "mean_groundedness_score", "mean_faithfulness_score", - "mean_note_similarity", "ragas_correctness", "ragas_answer_relevancy" ] @@ -22,9 +20,7 @@ # Default thresholds (add more if needed) default_thresholds = { "weighted_mae_score": ("max", float(os.getenv("THRESHOLD_MAE", "1.0"))), - "mean_groundedness_score": ("min", float(os.getenv("THRESHOLD_GROUNDEDNESS", "0.5"))), "mean_faithfulness_score": ("min", float(os.getenv("THRESHOLD_FAITHFULNESS", "0.5"))), - "mean_note_similarity": ("min", float(os.getenv("THRESHOLD_NOTE_SIMILARITY", "0.35"))), "ragas_correctness": ("min", 0.5), "ragas_answer_relevancy": ("min", 0.5) # You can add more custom thresholds here diff --git a/.gitignore b/.gitignore index d37410f..8149a81 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,137 @@ -# Python +# ============================================================================== +# LegalAIze - Git Ignore Rules +# ============================================================================== +# Files and directories excluded from version control to keep the repository +# clean, secure, and focused on source code. +# ============================================================================== + +# ------------------------------------------------------------------------------ +# Python Runtime & Cache +# ------------------------------------------------------------------------------ __pycache__/ *.py[cod] *$py.class *.so +*.pyo +*.pyd .Python +*.egg +*.egg-info/ +dist/ +build/ + +# Virtual Environments env/ venv/ -backend/backend_venv/ -frontend/frontend_venv/ ENV/ .venv +backend/backend_venv/ +frontend/frontend_venv/ + +# Jupyter Notebooks +.ipynb_checkpoints/ +*.ipynb -# MLflow +# ------------------------------------------------------------------------------ +# Machine Learning & Experiment Tracking +# ------------------------------------------------------------------------------ +# MLflow experiment tracking mlruns/ mlartifacts/ -# DVC -/data/*.csv -/data/*.pdf +# Model artifacts (too large for git) +/models/*.pkl +/models/*.h5 +/models/*.pth +/models/*.joblib + +# Metrics and evaluation outputs +metrics/** +!metrics/.gitkeep + +# ------------------------------------------------------------------------------ +# Data Version Control (DVC) +# ------------------------------------------------------------------------------ .dvc/config.local .dvc/tmp - -/models/*.pkl .dvc/cache +# DVC-tracked large files (tracked via .dvc files instead) +/data/*.csv +/data/*.pdf +data/qdrant_storage/ +data/processed/* +data/debug/* +data/ground_truth/raw_data/** +data/unused/** +data/raw_data/*.pdf +data/raw_data/*.html + +# Data mapping (generated, not source) +data/mapping.json -# IDE +# ------------------------------------------------------------------------------ +# Development Tools & IDEs +# ------------------------------------------------------------------------------ .vscode/ .idea/ *.swp *.swo +*.swn +.project +.pydevproject +.settings/ -# OS +# ------------------------------------------------------------------------------ +# Operating System Files +# ------------------------------------------------------------------------------ .DS_Store Thumbs.db +desktop.ini +*.bak +*~ -# Environment variables +# ------------------------------------------------------------------------------ +# Security & Credentials (NEVER commit!) +# ------------------------------------------------------------------------------ .env +*.key +*.pem +*.crt +dagshub_token.txt +secrets/ -# Docker +# ------------------------------------------------------------------------------ +# Logs & Temporary Files +# ------------------------------------------------------------------------------ *.log +*.tmp +tmp/ +logs/ +*.pid -# Token -dagshub_token.txt - -backend/debug_responses/ +# Docker logs +docker-compose.override.yml -# Data -data/mapping.json -data/qdrant_storage/ -data/ground_truth/raw_data/** -data/unused/** -data/raw_data/*.pdf -data/raw_data/*.html -data/processed/* -data/debug/* +# ------------------------------------------------------------------------------ +# Testing & Coverage +# ------------------------------------------------------------------------------ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +# ------------------------------------------------------------------------------ +# Application-Specific +# ------------------------------------------------------------------------------ +backend/debug_responses/ -# Metrics -metrics/** -metrics/ragas_records.json +# ------------------------------------------------------------------------------ +# Archives (typically not source code) +# ------------------------------------------------------------------------------ +*.zip +*.tar +*.tar.gz +*.rar +*.7z diff --git a/backend/core/evaluation.py b/backend/core/evaluation.py index 18ef1fd..78b31a0 100644 --- a/backend/core/evaluation.py +++ b/backend/core/evaluation.py @@ -1,7 +1,10 @@ +""" +This module defines the EvaluationEngine class, which is responsible for evaluating compliance of documents against regulatory requirements using a language model (LLM). It provides methods to evaluate individual sub-requirements and aggregate results into a final compliance assessment. The engine generates detailed prompts for the LLM to ensure structured and comprehensive responses, including both technical rationales and executive summaries for stakeholders. +""" + import json from typing import Dict, Any, List from langchain_openai import ChatOpenAI -from pydantic import BaseModel class EvaluationEngine: def __init__(self, llm: ChatOpenAI): diff --git a/backend/core/retrieval.py b/backend/core/retrieval.py index aa15ef5..b1a7a72 100644 --- a/backend/core/retrieval.py +++ b/backend/core/retrieval.py @@ -1,5 +1,4 @@ from typing import List, Dict, Any, Optional -import os import re from qdrant_client import QdrantClient from sentence_transformers import SentenceTransformer @@ -12,19 +11,46 @@ def __init__(self, doc_client: QdrantClient, embedding_model: Optional[SentenceT def _remove_law_names(self, text: str) -> str: """ Removes common law names and references to focus search on semantic content. + Strips regulatory identifiers while preserving the substantive content. """ law_patterns = [ - r"\bISO\s*42001\b", + # EU AI Act variants r"\bEU\s*AI\s*ACT\b", - r"\bCodice\s*Etico\b", - r"\bAnnex\s*XI\b", - r"\bGDPR\b", - r"\bRegolamento\s*\(EU\)\b", - r"\blegge\b", r"\bAI\s*Act\b", + r"\bArtificial\s*Intelligence\s*Act\b", + r"\bAI\s*Regulation\b", + r"\bRegulation\s*\(EU\)\s*2024/1689\b", + + # ISO standards + r"\bISO\s*/?IEC\s*42001\b", + r"\bISO\s*42001\b", + r"\bISO\s*/?IEC\s*27001\b", + r"\bISO\s*/?IEC\s*27701\b", + r"\bISO\s*/?IEC\b", r"\bISO\b", r"\bIEC\b", r"\b42001\b", + r"\b27001\b", + + # EU regulations and directives + r"\bGDPR\b", + r"\bGeneral\s*Data\s*Protection\s*Regulation\b", + r"\bRegulation\s*\(EU\)\b", + r"\bDirective\s*\(EU\)\b", + r"\bRegolamento\s*\(EU\)\b", + + # Annexes and articles + r"\bAnnex\s*[IVX]+\b", + r"\bArticle\s*\d+\b", + r"\bArt\.\s*\d+\b", + + # Generic legal terms + r"\b[Rr]egulation\b", + r"\b[Dd]irective\b", + r"\b[Ss]tandard\b", + r"\b[Ll]egge\b", + r"\bCode\s*of\s*Ethics\b", + r"\bCodice\s*Etico\b", ] for pat in law_patterns: text = re.sub(pat, "", text, flags=re.IGNORECASE) diff --git a/backend/rag_engine.py b/backend/rag_engine.py index c826eea..51fcffd 100644 --- a/backend/rag_engine.py +++ b/backend/rag_engine.py @@ -88,7 +88,7 @@ class AuditResponseAPI(BaseModel): embedding_model: Optional[SentenceTransformer] = None requirement_chunks: Dict[str, Any] = {} -# New engines +# Main RAG components: RetrievalEngine and EvaluationEngine, instantiated globally for reuse across requests retrieval_engine: Optional[RetrievalEngine] = None evaluation_engine: Optional[EvaluationEngine] = None @@ -170,12 +170,14 @@ def init_rag(force: bool = False) -> None: print(f"✓ Embedding model initialized: {embedding_model_name}") # Initialize engines - evaluation_engine = EvaluationEngine(llm) - # Instantiate RetrievalEngine globally with the embedding model. - # The doc_client will be provided per-request during evaluation. + # Instantiate RetrievalEngine retrieval_engine = RetrievalEngine(doc_client=None, embedding_model=embedding_model) + # Instantiate Evaluation Engine with the global LLM + evaluation_engine = EvaluationEngine(llm) + + _initialized = True except Exception as exc: print(f"⚠ Init Error: {exc}") @@ -194,7 +196,7 @@ def rag_ready() -> bool: def _get_requirement_chunks_from_qdrant(requirement_name: str, regulatory_client: QdrantClient, regulatory_collection: str) -> list: """ - Retrieve all regulatory chunks (already embedded) for a given requirement from Qdrant. + Retrieve all regulatory chunks (already embedded) for a given requirement from Qdrant. The retrival is deterministic and based on the requirement name. Args: requirement_name: The name of the requirement. regulatory_client: QdrantClient instance for the regulatory chunks DB. @@ -258,33 +260,15 @@ def evaluate_requirement( # Instantiate RetrievalEngine for this document context using the global embedding_model retriever = RetrievalEngine(doc_client=_doc_client, embedding_model=embedding_model) - pre_rerank_top_k = int(rag_params.get("pre_rerank_top_k", 10)) + document_chunks_top_k = int(rag_params.get("document_chunks_top_k", 10)) - top_doc_chunks_by_group = retriever.query_for_requirement( + # Use the requirement chunks embeddings as queries to retrieve the most relevant document chunks for this requirement. The retrieval is done per group (e.g., per regulatory reference) to maintain traceability and relevance of the retrieved context. + top_doc_chunks_per_subreq = retriever.query_for_requirement( collection_name=_temp_collection, req_chunks_embeddings=req_chunks_embeddings, - top_k=pre_rerank_top_k + top_k=document_chunks_top_k ) - # Optimization: deduplication of retrieved chunks across groups - unique_chunks = {} # chunk_id -> {"content": str, "refs": set} - for group, chunks in top_doc_chunks_by_group.items(): - for c in chunks: - cid = c['chunk_id'] - if cid not in unique_chunks: - unique_chunks[cid] = { - "content": c['content'], - "refs": {group} - } - else: - unique_chunks[cid]["refs"].add(group) - - # Build context (optional, for debug or legacy reasons) - doc_context_list = [] - for cid, data in unique_chunks.items(): - doc_context_list.append(data['content']) - - # 3. Evaluate Sub-requirements using EvaluationEngine sub_results = [] sub_reports = [] @@ -299,6 +283,7 @@ def evaluate_requirement( control = reg_chunk.get("control", "") guidance = reg_chunk.get("implementation_guidance", "") regulatory_parts = [] + # Build regulatory context if content: regulatory_parts.append(content) if control: @@ -307,8 +292,8 @@ def evaluate_requirement( regulatory_parts.append(f"[IMPLEMENTATION_GUIDANCE] {guidance}") content = "\n".join(regulatory_parts).strip() - # Find document chunks associated with this reference using top_doc_chunks_by_group - # Build group_key based on source + # Find document chunks associated with this reference using top_doc_chunks_per_subreq + # Build group_key based on source for search in top_doc_chunks_per_subreq. group_key = None if reg_chunk.get('source') == 'EU_AI_ACT': group_key = f"AI_ACT::{reference}" @@ -317,20 +302,15 @@ def evaluate_requirement( else: group_key = reference - doc_chunks_for_group = top_doc_chunks_by_group.get(group_key, []) + # Retrieve the relevant document chunks for this sub-requirement (reference) + doc_chunks_for_group = top_doc_chunks_per_subreq.get(group_key, []) relevant_chunks = [] if doc_chunks_for_group: for chunk in doc_chunks_for_group: relevant_chunks.append(chunk['content']) else: - # Fallback: include all document chunks - for cid, data in unique_chunks.items(): - relevant_chunks.append(data["content"]) + print(f"⚠ No relevant document chunks found for reference '{reference}' (group key: '{group_key}'). This may affect the evaluation of this sub-requirement.") - sub_req_data = { - "name": reference, - "regulatory_content": content - } # Use EvaluationEngine to evaluate result = evaluator.evaluate_sub_requirement( @@ -341,7 +321,7 @@ def evaluate_requirement( associated_chunks=relevant_chunks ) - # Compose context for RAGAS (legacy / tracking) + # Compose context for RAGAS ragas_contexts = [] if content: reg_name = reference @@ -353,9 +333,8 @@ def evaluate_requirement( for chunk in doc_chunks_for_group: ragas_contexts.append(f"[DOCUMENT] {chunk['content']}") else: - # Fallback: include all document chunks - for cid, data in unique_chunks.items(): - ragas_contexts.append(f"[DOCUMENT] {data['content']}") + ragas_contexts.append("[DOCUMENT] No relevant document chunks found for this reference.") + # Reconstruct the result dict expected by aggregate_results # Combine rationale and notes for RAGAS evaluation to improve groundedness @@ -451,7 +430,6 @@ def audit_document( regulatory_client = QdrantClient(path=index_path) # For each requirement in requirement_chunks, call evaluate_requirement to get its report. - req_iter = requirement_chunks if requirement_limit is not None: req_iter = req_iter[:requirement_limit] diff --git a/data/ground_truth/.gitignore b/data/ground_truth/.gitignore deleted file mode 100644 index 215fee8..0000000 --- a/data/ground_truth/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/raw_data diff --git a/docker-compose.yml b/docker-compose.yml index 2e2f791..46851ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,8 @@ services: # The seeder will perform its own HTTP health polling before starting. - qdrant_seeder: + qdrant_seeder: # Separate service to seed Qdrant after it is healthy + # Allows to run the data ingestion and vectorization stages independently of the backend, and ensures seeding only happens after Qdrant is ready. build: ./qdrant_init container_name: ml-qdrant-seeder volumes: @@ -34,7 +35,6 @@ services: ports: - "8000:8000" volumes: - #- ./backend:/app - ./params.yaml:/app/params.yaml:ro - ./data/mapping.json:/app/data/mapping.json:ro - ./data/processed/chunks.json:/app/data/processed/chunks.json:ro @@ -72,4 +72,4 @@ services: networks: default: - name: ml-network + name: legalaize-network diff --git a/evaluate_rag.py b/evaluate_rag.py index 5847185..1417ae6 100644 --- a/evaluate_rag.py +++ b/evaluate_rag.py @@ -2,6 +2,7 @@ RAG Evaluation Script This script evaluates the RAG system using the local RAG engine defined in backend.rag_engine. It loads evaluation cases defined in params.yaml, runs them through the RAG engine, computes metrics and logs results to MLflow (if configured). """ + import warnings warnings.filterwarnings("ignore", category=FutureWarning) @@ -73,6 +74,7 @@ def setup_mlflow(): def main() -> None: + # Load parameters from params.yaml params = load_params() eval_params = params.get("evaluation", {}) precompute_params = params.get("precompute", {}) @@ -88,9 +90,7 @@ def main() -> None: case_selector = normalize_case_selector(eval_params.get("case_selector")) requirement_limit = eval_params.get("requirement_limit", None) - - - + # Initialize embedding model for evaluation embedding_model = SentenceTransformer(vect_params.get("model_name", "all-MiniLM-L6-v2")) # Set seed for reproducibility where possible @@ -111,17 +111,18 @@ def main() -> None: if mlflow is not None: mlflow.set_experiment(experiment_name) + # Start MLflow run context (if MLflow is configured properly, otherwise this will be None and logging calls will be skipped) run_ctx = ( mlflow.start_run(run_name="rag_eval") if mlflow is not None else None ) - if rag_engine is None: raise RuntimeError("backend.rag_engine is not available. Run evaluate_rag from the repository root.") + # Initialize RAG engine components (like retriever, LLM, etc.) before running evaluations. This ensures that the RAG system is ready to process the evaluation cases. The initialization logic is defined in backend.rag_engine.init_rag(), which is also called on startup in the FastAPI app lifespan event. rag_engine.init_rag() print("✓ Using local RAG engine (backend.rag_engine)") @@ -164,7 +165,6 @@ def main() -> None: mlflow.log_param("chunk_overlap", rag_engine.rag_params.get("document_chunk_overlap")) # Log prompt templates to MLflow after run initialization - try: # Use the EvaluationEngine from rag_engine to access prompt templates if rag_engine.evaluation_engine: @@ -175,21 +175,22 @@ def main() -> None: # Access methods to log prompt templates sub_prompt_template = rag_engine.evaluation_engine._get_sub_prompt("EXAMPLE_MAIN_REQ", example_reference, "EXAMPLE_SOURCE", example_content, example_chunks) - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_sub_prompt_template.txt", encoding="utf-8") as tf: - tf.write(sub_prompt_template) - tf.flush() - mlflow.log_artifact(tf.name, artifact_path="prompt_templates") + sub_prompt_file = os.path.join(tempfile.gettempdir(), "sub_prompt_template.txt") + with open(sub_prompt_file, "w", encoding="utf-8") as f: + f.write(sub_prompt_template) + mlflow.log_artifact(sub_prompt_file, artifact_path="prompt_templates") agg_prompt_template = rag_engine.evaluation_engine._get_aggregate_prompt([ {"reference": example_reference, "score": 5, "answer": "Good"} ], computed_score=3.5) - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_aggregate_prompt_template.txt", encoding="utf-8") as tf: - tf.write(agg_prompt_template) - tf.flush() - mlflow.log_artifact(tf.name, artifact_path="prompt_templates") + agg_prompt_file = os.path.join(tempfile.gettempdir(), "aggregate_prompt_template.txt") + with open(agg_prompt_file, "w", encoding="utf-8") as f: + f.write(agg_prompt_template) + mlflow.log_artifact(agg_prompt_file, artifact_path="prompt_templates") except Exception as e: print(f"⚠ Failed to log prompt templates to MLflow: {e}") + # Select evaluation cases based on case_selector filtered_cases = select_cases(gt_cases, case_selector) # Select cases based on case_selector (params) @@ -257,8 +258,6 @@ def main() -> None: # Compute aggregated metrics across all results and log them to MLflow and/or save to a JSON file for DVC tracking. if all_results: - - total_score_pairs = sum(r["num_pairs"] for r in all_results) total_faithfulness_samples = sum(r.get("faithfulness_sample_count", 0) for r in all_results) total_relevancy_samples = sum(r.get("relevancy_sample_count", 0) for r in all_results) @@ -362,6 +361,13 @@ def main() -> None: print("✓ RAG evaluation completed.") finally: + # Clean up resources to avoid shutdown warnings + if rag_engine and hasattr(rag_engine, 'vector_db') and rag_engine.vector_db: + try: + rag_engine.vector_db.close() + except Exception: + pass # Ignore errors during cleanup + if run_ctx is not None: mlflow.end_run() diff --git a/evaluation/__init__.py b/evaluation/__init__.py index 35f09b8..7ac52b5 100644 --- a/evaluation/__init__.py +++ b/evaluation/__init__.py @@ -4,7 +4,7 @@ from evaluation.data_loading import load_params, load_text, load_ground_truth_csv from evaluation.metrics import ( compute_mae, - compute_ragas_metrics, + compute_subrequirements_ragas_metrics, ) from evaluation.mlflow_utils import log_case_input_artifacts @@ -16,7 +16,7 @@ "load_text", "load_ground_truth_csv", "compute_mae", - "compute_ragas_metrics", + "compute_subrequirements_ragas_metrics", "log_case_input_artifacts", "evaluate_single_case", "slugify_case_name", diff --git a/evaluation/case_evaluation.py b/evaluation/case_evaluation.py index 5fbc976..313ebce 100644 --- a/evaluation/case_evaluation.py +++ b/evaluation/case_evaluation.py @@ -1,18 +1,30 @@ -"""Case evaluation logic.""" +"""Case evaluation logic. +Evaluate a single legal compliance case using RAG engine and optional ground truth comparison. +This function performs comprehensive compliance evaluation by: +1. Loading the document and optional ground truth data +2. Running RAG-based audit through the backend engine +3. Computing metrics including MAE (Mean Absolute Error), RAGAS faithfulness, relevancy, and correctness +4. Generating and saving artifact files (predictions, prompts) for MLflow tracking +5. Processing both main requirements and sub-requirements +Notes: + - Missing or invalid scores ('N/A' or non-numeric values) are excluded from MAE calculation + - Sub-requirement rationale is used for faithfulness evaluation + - Prompts and predictions are saved for MLflow artifact tracking and downstream analysis + - Ground truth matching is performed by Requirement_ID; unmatched predictions are skipped with warnings + - Contexts are normalized to lists for RAGAS compatibility +""" import os import json from typing import Dict, Any, List, Optional, Tuple -import numpy as np from evaluation.data_loading import load_ground_truth_csv, load_text from evaluation.metrics import ( compute_mae, - compute_ragas_metrics, + compute_subrequirements_ragas_metrics, compute_main_requirement_metrics, ) - def evaluate_single_case( *, case_name: str, @@ -29,10 +41,12 @@ def evaluate_single_case( except ImportError: raise RuntimeError("backend.rag_engine is not available. Run evaluate_rag from the project root.") + # Load ground truth if available, otherwise set to empty dict. The ground truth CSV is expected to have a 'Requirement_ID' column that matches the 'Requirement_ID' in the RAG predictions for proper comparison. If the file is missing or invalid, a warning is logged and the evaluation proceeds without GT comparison. if ground_truth: ground_truth = load_ground_truth_csv(gt_path) # Benchmark report else: ground_truth = {} + document_text = load_text(doc_path) # Original document text for RAG evaluation if rag_engine is None: @@ -40,7 +54,6 @@ def evaluate_single_case( audit_response = rag_engine.audit_document(document_text, requirement_limit=requirement_limit) # Get predictions (list of RequirementReport objects) from the RAG engine for this document - # Include the prompt in logged artifacts for downstream analysis and MLflow logging. predictions = [report.model_dump() for report in audit_response.requirements] @@ -49,14 +62,11 @@ def evaluate_single_case( for pred in predictions: sub_reqs = pred.get("SubRequirements") or [] for sub in sub_reqs: - # Use rationale for both faithfulness and relevancy + # Use rationale for faithfulness evaluation # The rationale provides detailed, grounded analysis - combined_answer = sub.get('Rationale', '') + rationale_answer = sub.get('Rationale', '') - # The prompt/question logic needs to be reconstructed or rely on contexts - # Since the explicit ragas_question is not saved in SubRequirementReport, - # re-generate it here based on the available data. - req_name = pred.get("Requirement_Name", "Unknown") + # Extract sub-requirement reference and source for question formulation and logging sub_name = sub.get("Reference", "") source = sub.get("Source", "") @@ -67,10 +77,10 @@ def evaluate_single_case( contexts = sub.get("Contexts", []) # Add only if there is at least some context or a non-trivial answer - if (contexts and any(c.strip() for c in contexts)) or (combined_answer and combined_answer.strip() and "no information" not in combined_answer.lower()): + if (contexts and any(c.strip() for c in contexts)) or (rationale_answer and rationale_answer.strip() and "no information" not in rationale_answer.lower()): sub_ragas_records.append({ "question": ragas_question, - "answer": combined_answer, + "answer": rationale_answer, "contexts": contexts, "ground_truth": "", # Not available at sub-requirement level "requirement_id": pred.get("Requirement_ID", "unknown"), @@ -113,167 +123,107 @@ def evaluate_single_case( artifacts[f"prompt_{req_id}_sub_{safe_ref}_{sub_source}"] = sub_prompt_file + # Helper function to extract ground truth notes + def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: + """Extract ground truth auditor notes from a CSV row.""" + return ( + row.get("Auditor Notes") + or row.get("auditor_notes") + or row.get("Auditor_Notes") + ) + # Initialize accumulators for metrics gt_scores: List[float] = [] pred_scores: List[float] = [] ragas_records: List[Dict[str, Any]] = [] - if ground_truth: - # Process each prediction and corresponding ground truth entry, matching by Requirement_ID. If Requirement_ID is missing or does not match any GT entry, skip that prediction and log a warning. - for pred in predictions: - requirement_id = pred.get("Requirement_ID") + # Process predictions - unified loop handles both GT and no-GT cases + for pred in predictions: + requirement_id = pred.get("Requirement_ID") or "Unknown requirement" + document_context = pred.get("Context") or [] + pred_note = pred.get("Auditor_Notes") or pred.get("auditor_notes") + requirement_name = pred.get("Requirement_Name") + + gt_note = "" + gt_row = None + + # If ground truth is available, try to match by Requirement_ID + if ground_truth: if not requirement_id or requirement_id not in ground_truth: print(f"⚠ Skipping prediction with missing or unmatched Requirement_ID: {requirement_id}") continue - - document_context = pred.get("Context") or [] # Get the context from the prediction for RAGAS evaluation - + gt_row = ground_truth[requirement_id] - + gt_note = extract_ground_truth_note(gt_row) or "" + + # Extract scores for MAE calculation gt_score: Optional[float] = None pred_score: Optional[float] = None - - # If Score is 'N/A' or missing, treat it as None and exclude from MAE calculation. - # Log warnings for invalid score formats. + try: if gt_row.get("Score") != 'N/A': gt_score = float(gt_row.get("Score", "0")) except ValueError: - print(f"⚠ Invalid GT score for Requirement_ID {requirement_id}: {gt_row.get('Score') }.") - + print(f"⚠ Invalid GT score for Requirement_ID {requirement_id}: {gt_row.get('Score')}.") + try: if pred.get("Score") != 'N/A': pred_score = float(pred.get("Score", "0")) except (TypeError, ValueError): - print(f"⚠ Invalid predicted score for Requirement_ID {requirement_id}: {pred.get('Score') }.") - + print(f"⚠ Invalid predicted score for Requirement_ID {requirement_id}: {pred.get('Score')}.") + if gt_score is not None and pred_score is not None: gt_scores.append(gt_score) pred_scores.append(pred_score) - - # Compute note similarity for this couple - - # Extract GT note - def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: - """Extract ground truth auditor notes from a CSV row.""" - return ( - row.get("Auditor Notes") - or row.get("auditor_notes") - or row.get("Auditor_Notes") - ) - gt_note = extract_ground_truth_note(gt_row) - - auditor_notes = pred.get("Auditor_Notes") or pred.get("auditor_notes") - #rationale = pred.get("Rationale") or pred.get("rationale") - #pred_note = auditor_notes + ("\nRationale: " + rationale if rationale else "") - pred_note = auditor_notes - - # Build question text for RAGAS evaluation - identifier = requirement_id or "Unknown requirement" - requirement_name = pred.get("Requirement_Name") or gt_row.get("Requirement_Name") - title = requirement_name - # Only use title and id, no metadata - question_text = f"Is the provided document compliant with the requirement '{title}', according with the provided regulatory chunks from UE AI ACT and ISO standard 42001:2023?" - - # Context is made up by the whole chunks extracted from the Document under Test for the particular requirement. - - ragas_records.append( - { - "question": question_text, - "answer": pred_note or "", - # RAGAS expects a list of strings for 'contexts', even if only one context is used - "contexts": document_context if isinstance(document_context, list) else [document_context], - "ground_truth": gt_note or "", - "requirement_id": requirement_id, - "case": case_name, - } - ) - else: - # No Ground Truth avaiable - for pred in predictions: - document_context = pred.get("Context") or [] - requirement_id = pred.get("Requirement_ID") or "Unknown requirement" - requirement_name = pred.get("Requirement_Name") - title = requirement_name or "" - question_text = f"Is the provided document compliant with the requirement '{title}', according with the provided regulatory chunks from UE AI ACT and ISO standard 42001:2023?" - - auditor_notes = pred.get("Auditor_Notes") or pred.get("auditor_notes") - #rationale = pred.get("Rationale") or pred.get("rationale") - #pred_note = auditor_notes + ("\nRationale: " + rationale if rationale else "") - pred_note = auditor_notes - - ragas_records.append( - { - "question": question_text, - "answer": pred_note or "", - "contexts": document_context if isinstance(document_context, list) else [document_context], - "ground_truth": "", - "requirement_id": requirement_id, - "case": case_name, - } - ) - - # Compute Metrics - if ground_truth: - mae = compute_mae(gt_scores, pred_scores) - # Compute faithfulness on SUB-requirements - sub_metrics = compute_ragas_metrics(sub_ragas_records, embedding_model=embedding_model) - case_faithfulness_score = sub_metrics.get("faithfulness") + # Use GT requirement name if prediction doesn't have it + if not requirement_name: + requirement_name = gt_row.get("Requirement_Name") - # Compute AnswerCorrectness and AnswerRelevancy on MAIN requirements - main_metrics = compute_main_requirement_metrics( - ragas_records, embedding_model=embedding_model - ) - case_correctness_score = main_metrics.get("correctness") - case_relevancy_score = main_metrics.get("relevancy") - - # Check for critical failures - if case_faithfulness_score is None: - print("⚠ Faithfulness score is None, RAGAS evaluation may have failed.") - if case_correctness_score is None: - print("⚠ AnswerCorrectness is None - check if main requirements have ground truth auditor notes.") - if case_relevancy_score is None: - print("⚠ AnswerRelevancy is None - RAGAS evaluation may have failed.") - - return ( - { - "num_pairs": len(gt_scores), - "mae_score": mae, - "artifacts": artifacts, - "faithfulness_score": case_faithfulness_score, - "faithfulness_sample_count": len(sub_ragas_records), - "relevancy_score": case_relevancy_score, - "relevancy_sample_count": len(ragas_records), # Main requirements - "correctness_score": case_correctness_score, - "correctness_sample_count": len(ragas_records), # Main requirements - }, - sub_ragas_records, - ragas_records, # Return main requirement records too - ) - else: - # No Ground Truth available, compute only RAGAS metrics that do not require GT - sub_metrics = compute_ragas_metrics(sub_ragas_records, embedding_model=embedding_model) - case_faithfulness_score = sub_metrics.get("faithfulness") + # Build RAGAS record (same for both GT and no-GT cases) + title = requirement_name or "" + question_text = f"Is the provided document compliant with the requirement '{title}', according with the provided regulatory chunks from UE AI ACT and ISO standard 42001:2023?" - # Compute AnswerRelevancy on MAIN requirements (doesn't require GT) - main_metrics = compute_main_requirement_metrics( - ragas_records, embedding_model=embedding_model - ) - case_relevancy_score = main_metrics.get("relevancy") - - return ( - { - "num_pairs": 0, - "mae_score": None, - "artifacts": artifacts, - "faithfulness_score": case_faithfulness_score, - "faithfulness_sample_count": len(sub_ragas_records), - "relevancy_score": case_relevancy_score, - "relevancy_sample_count": len(ragas_records), - "correctness_score": None, - "correctness_sample_count": 0, - }, - sub_ragas_records, - ragas_records, # Return main requirement records too - ) + # Context is not need for answer relevancy and correctness evaluation. + ragas_records.append({ + "question": question_text, + "answer": pred_note or "", + #"contexts": document_context if isinstance(document_context, list) else [document_context], + "ground_truth": gt_note, + "requirement_id": requirement_id, + "case": case_name, + }) + + # Compute metrics (faithfulness always, relevancy and correctness only with GT) + sub_metrics = compute_subrequirements_ragas_metrics(sub_ragas_records) + case_faithfulness_score = sub_metrics.get("faithfulness") + + main_metrics = compute_main_requirement_metrics(ragas_records, embedding_model=embedding_model) + case_relevancy_score = main_metrics.get("relevancy") + case_correctness_score = main_metrics.get("correctness") if ground_truth else None + + # MAE only computed if ground truth available + mae = compute_mae(gt_scores, pred_scores) if ground_truth else None + + # Check for critical failures + if case_faithfulness_score is None: + print("⚠ Faithfulness score is None, RAGAS evaluation may have failed.") + if case_relevancy_score is None: + print("⚠ AnswerRelevancy is None - RAGAS evaluation may have failed.") + if ground_truth and case_correctness_score is None: + print("⚠ AnswerCorrectness is None - check if main requirements have ground truth auditor notes.") + + # Build and return results + results = { + "num_pairs": len(gt_scores) if ground_truth else 0, + "mae_score": mae, + "artifacts": artifacts, + "faithfulness_score": case_faithfulness_score, + "faithfulness_sample_count": len(sub_ragas_records), + "relevancy_score": case_relevancy_score, + "relevancy_sample_count": len(ragas_records), + "correctness_score": case_correctness_score, + "correctness_sample_count": len(ragas_records) if ground_truth else 0, + } + + return results, sub_ragas_records, ragas_records diff --git a/evaluation/metrics.py b/evaluation/metrics.py index 67fbe97..bb7ecd8 100644 --- a/evaluation/metrics.py +++ b/evaluation/metrics.py @@ -46,12 +46,7 @@ def compute_mae(gt_scores: List[float], pred_scores: List[float]) -> float: - - - - - -def compute_ragas_metrics(samples: List[Dict[str, Any]], embedding_model=None) -> Dict[str, Optional[float]]: +def compute_subrequirements_ragas_metrics(samples: List[Dict[str, Any]]) -> Dict[str, Optional[float]]: """ Compute faithfulness scores for a list of samples using a single Ragas evaluation call. Args: @@ -75,7 +70,6 @@ def compute_ragas_metrics(samples: List[Dict[str, Any]], embedding_model=None) - # Extract the LLM model name from the params llm_model = params.get("evaluation", {}).get("llm_model", None) llm_temperature = params.get("evaluation", {}).get("llm_temperature", 0.0) - embedding_model_name = params.get("vectorization", {}).get("model_name", "all-MiniLM-L6-v2") if llm_model is None: print("⚠ LLM model name not found in params.yaml under evaluation.llm_model.") diff --git a/generate_debug_audits.py b/generate_debug_audits.py index 1532235..bebea9f 100644 --- a/generate_debug_audits.py +++ b/generate_debug_audits.py @@ -1,12 +1,19 @@ """ -Script to generate audit JSON files for the first 4 evaluation cases. +Script to generate audit JSON files for evaluation cases. Saves them in data/debug/ for inspection and debugging. + +Usage: + python generate_debug_audits.py # Process first 5 cases (default) + python generate_debug_audits.py --case 0 # Process case at index 0 + python generate_debug_audits.py --case Evaluation1 # Process case by name + python generate_debug_audits.py --case all # Process all cases """ import os import json import yaml -from typing import Dict, Any +import argparse +from typing import Dict, Any, List # Import backend modules from backend import rag_engine @@ -19,8 +26,76 @@ def load_params() -> Dict[str, Any]: return yaml.safe_load(f) +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Generate audit JSON files for evaluation cases", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python generate_debug_audits.py # Process first 5 cases (default) + python generate_debug_audits.py --case 0 # Process case at index 0 + python generate_debug_audits.py --case Evaluation1 # Process case by name + python generate_debug_audits.py --case all # Process all cases + """ + ) + parser.add_argument( + "--case", + type=str, + default=None, + help="Case to process: index (0-based), name, or 'all' for all cases. Default: first 5 cases" + ) + return parser.parse_args() + + +def select_cases(evaluation_cases: List[Dict[str, Any]], case_arg: str = None) -> List[Dict[str, Any]]: + """ + Select evaluation cases based on command line argument. + + Args: + evaluation_cases: Full list of evaluation cases from params.yaml + case_arg: Command line argument: index, name, 'all', or None for first 5 + + Returns: + List of selected cases to process + """ + if case_arg is None: + # Default: first 5 cases (Evaluation1-5) + return evaluation_cases[:5] + + if case_arg.lower() == "all": + # Process all cases + return evaluation_cases + + # Try to parse as index + try: + index = int(case_arg) + if 0 <= index < len(evaluation_cases): + return [evaluation_cases[index]] + else: + print(f"❌ Error: Index {index} out of range (0-{len(evaluation_cases)-1})") + return [] + except ValueError: + pass + + # Try to match by name + for case in evaluation_cases: + if case.get("name") == case_arg: + return [case] + + # Case not found + print(f"❌ Error: Case '{case_arg}' not found") + print(f"\nAvailable cases:") + for idx, case in enumerate(evaluation_cases): + print(f" [{idx}] {case.get('name')}") + return [] + + def main(): - """Generate audit reports for first 4 evaluation cases.""" + """Generate audit reports for selected evaluation cases.""" + + # Parse arguments + args = parse_args() print("=" * 80) print("Generating Debug Audit Reports") @@ -30,10 +105,16 @@ def main(): params = load_params() evaluation_cases = params.get("evaluation", {}).get("ground_truth", []) - # Get first 4 cases - cases_to_process = evaluation_cases[:4] + print(f"\nTotal available cases: {len(evaluation_cases)}") + + # Select cases to process + cases_to_process = select_cases(evaluation_cases, args.case) + + if not cases_to_process: + print("\n❌ No cases selected. Exiting.") + return - print(f"\nProcessing {len(cases_to_process)} cases:") + print(f"\nProcessing {len(cases_to_process)} case(s):") for case in cases_to_process: print(f" - {case.get('name')}") diff --git a/img/logo.png b/img/logo.png index 0def881a65734b3984396a1f6ccde866332d4151..5bfaeead4d15e165815b999469bf180b41d3fb73 100644 GIT binary patch literal 75909 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�gN=C46gZ*3CBR6utq2pk`cwQ++r#ee*H31L~J$M zgFjI_;|b?-;7?>%^5<51yPtt4LNsyIFv+1a@PP?RYe}9OsAUKR3`o$U_=XUs$z*gRoKSax82bx>O?Y&sE(9Q! z@Ui~i!ZZd6xT(dw_O19}N@C8Z3&TV~WzU`M%7e_!JL)%By`ihJyrX%THhgsJ4SUtv zS)%~3JvE-}XMb?k2;rE~qMxY5S!f;M(E|jD&Jr%%5ZRM**H(f*W|EgS=APEH6!WKF zi1nQK!?*UH=f0iInETLt@|&kd7wtnZBz0#6FkORe)`yTl4RQleA?e#1X$*d4O^0zW zb?G{AP05mhno+m4-jJ4ko9I>)$7sg$x`&S|6|(OWh$ZK4PK-R%Y+Hk0PLp z0)bFP%l8bh|NI*zO~ZUdV-I*NlwzyGl^)bVFr#q|J0eAP4IoI|ccDy%?*R-upbZs* z4S|S2Jqkh-$S`%I=?^gA)eyH}4~DFZX^?;sA`2|5*c&*BG?9uYh!v`<5NtpPEJn5g zOyngA9S?zd3?~)iUO>AJpx}nTkf{e`W3R2v)R9JiArNA4Ft#SNgbWZ%fY)DY%J(5ed3d=ET_=4gca-5kXu z-vSXp!m$D>d8pqdyhjWI8`R|%7JS45`UYf(dWD0#6LWl1l+pqqH74gKkQau6UbqFq zlmJ_R3I3>pK@%Wx2nOUv^$XI2Jx;&1`fdn6JPz_o4CPh=}OQb zy~nQ7*D=F4NqCJA5h$_fyoNoUDM8pCA`uYSc|n^IYQOJ+vD9!|v~HlgHm7M|KcWdy zWmN^ajV{|7E5`;BpT zVJm|HLxsWYMSea#cL~;vsGAE~q&wHf`9s(0W_}@+Cs`t2?R=&7J(NQz!GhU~V}}0v zA%g&&3+gUkWoHHb7kQ&iN?HrgS0zidRX2>AIDMA($l=yCrdWC%x8>MxGu}1= zHRYOgw`olG-~Qy{Q#`1xD|DY#vgf$eN}yr(Sq+=2+}5XeqB>1B?M`?Q(d=g^TCa2> zH`HcplibgOv+hS9-Tj3GwPV$^ZC_hGEOK$JAN0??^-+g@X2ZEQG4WPt@#Nbwk{5E?DPB# zBP#E)0oA^ju07BQT`A7e67paY;r(_ZsNd%N;}|`q4Nar(2r-{dPru1gD5==s$a!8hKQwpyqrFT?FPs|+G92fdI*oIKq8DsRU6}5z zc>WM$zi@?Z#nNEIFn!A5)j++0kCLx*(;S5H=gag%tC2^HH$)b15|AA@6m#-M? zkVx{!`qR0&ZXI*;>hV5&Liz#+eKF;B=>ZUm6cHqb70I7>0-4@+a9FH z79Lydzi}&G>_Zik@@eKLvgh5!*>fViis7q5V}0GxKueP4VZV>qSr5zcnh#n+lQ!0o zS~>f6HZxxI77^Qzy?9D;LQgMn;ZRa&qWxP{53b~PPo{%TuO8$_s~1FHE!REZF?e{0 zWsm4W(??U;pfCy1`G$|(Q#Y&M=q7j>zX%_nd*rFW#qsrntHVH}XFz89h*DK%UypIz zv6{<2l{qDjCRAEv+-bTZYp2ZVcGw%GrAEW|W7{ueAl6k~HhD{Te{cPuo2iFDpy$w# zDJQ#?)2^%0-6~g{(z_Fk4)mJ`=RWAl^_)m_6WhkxO0RV95_@N&ey<`sjwz>~o14{e zFZDv6&`ULMfra$5JpTUNBk~MQUtUPn?K)eUs3p*hw>If&=01RvH5k@sc6M(x-r}yv zYI^zTRK!jZ#1~Obf1uu~_&{&*+=^%notWK+tuaR^+Q`k0GH*tn9ZQovFtj`JXI5f@ zPrM_$qM@A36szfx;ub@^aP@U<9$V|0eOKWfo7sKsw)@(xu7u78or8}%-+Hqxgg^A! zvhxMOk(ycOg4iuypMhO3cMtV2JqffA7M8Ufv3~sau7<3=VD_j@P;N`rYt~_7{Vk7Q z%T45tG4uowP>y`T6Vk~{* z2vwh?=g*rwJUmw-^^=bbEAM&}cOvScA1OogQJdn2eb1F@s>HH~-t11lp*J=BRJl?w z^x|#32AjYEUQV1r{+-JAmj>j`q<>D8s|4@3l#+fTJURQm)hE?vcf6QoROG!aZKkF4 zikF98mXTeWlfjq=7f!()VEjI(FC-@mZid&y9>_rbM#0Q1`e)nI`Gc-=uX06gXb>p79c~D}7ckv}M_%oGenU<3liBz( z6GL#}+}kg)D-Oj^zx3yXM5y=om2mFQ=!l9iJM@6eC}+asrNP^so67dQ(0y@xTr9!j zU~poL0rlBS0a0eHImg~AuWV3d3xBH=nyKh2GLwXHlP=G-KjfpTEydj#Bcb^%R5{0X zSe2QRFs1u|zmATs#bUyDf!)>xJA8zTSl1*NeVdOxzOBsjoUUyC=@-fiUR8QN88@Hi zW?={f(Rcpio^&UJ4WzGo3_SSp?)%ZjM=K(ckGHoSxR&oLcI&ndSKep|wRA1*3DTYV z9A9m>qjz6^&N4Zu9j(9Nn&55c(QuV)Ifq1F$N1CLS<){Rt~}rm^8N$?!&I{2)$_+i zZoa}>$5mo@21V+Bnou*kJLy~<@G0m#B=k^Gw6H;oDb1OiUsa0dAnSH`2Vo4HKeTAH z{leZm$$QN-ICbNFe&PP~xuviWiH~)O`{L$CC zL#0ixt&9>H4g4$!BJ{|uk&EqqLByk8WSTP6k9j8+%Wbv@T&Qxmk zxY*>rZ#8`$kw>o1o_0}X6)Om@5WLZ^R*xfn`>`{!VyR_BVb`X&)ZdL)ZkFh@e2*@F zzR>K^u#wVbJ<YGt0pzP)kba*f_DJR!((wV~M=V>!P9BY@n$!A3LqGiNgVM=D zqloth{04J=H1>xY^AsjvuT~CT8wpKMGiUY-^8P;Q#x8bZ{<3CZmfci)%Zy2kjZV;{ za?51drg&Dq*>oAvKx_yBI={P^-0hiZy6Ei~?isxC1@^-IV=dE(Q4jDxd|1D-9(>LC zkhhOr@Z&?B%>A8P`8~wX3~5-I9oTvCoZ~_kanom-$ZZ^AXfR~Uxw~~-?e&fsKkg-= z!JMQkqv@~XM8X=*zbu-rG~G~E;uG>Uv%I!nK=*AS`(iNnCli;)fuBQUcv86343P6B zAH3+bgNldFKMXF~9(2A{u+pIYJzg-iq0weOV*uHO4Hj>I7+f;cR@Yv>Hzt(r(d&C# zOQZWgFiDRxX^n~rU7J4fS*87Ric|0Em zT6IE7 z?qPZ)e)4o(@7~LsJgX&7#E`a}i1``qE!CoAeWO!3T#os_y=^bFh7J6`!8kr^LF_PcG_!>+}-r7kf`Lmw_A`#td$lg-`U7?6A@K}_Z9yEM~x zQk#yd^KHUqq)!gAxV{o>4c9#{->Mnrv@JZgx@2H4gU*^3m(m4Qjx(p?I#dtm`UMB9 zUQ+mk@zcLhYRxXq!EiT@x9_d!m*!j5!AFD#{IyLZW>zl+?G`rKUU7|+wFmJM=MQZ@ zk;fm}nq?*3s(IT&o+(05U-DqGN-psOf4sNKc8u=+fe2ZFVxw;9z{P@d7jv*SE!Ica zLi*>qyD)<~6Z&n?Z63KMprBlf} z?f1vNZ|qLb3Jl3XreqJ;)@6=KH0+#`w>0p39x}t`B#<~9ze_s$t*K?KDVKmguY9T7 z&=ljIKj!Ci#ss) z{870rbCSqemP^syPQfb>?S8z_OVyb&VJGN#=h$qPD^HhjH0AZO5xGO#9$V1#E^+r# zplWvBhR;t~-mV$F(^HsxHy|p`d)euTFR@eCRLJAyP9^0B&rOS!F6uDf|mSbo7is8CQp@A@uN{d0GQk}FJ2EGysAb_PVK4?1%6@H|-2lj)j0 zt;-#DLey4P)w7d*I?<+qE5Sz(JmP@`iDSMQ`%Z1s-EYhRcU>-vI6SmzjEOno(rzO7 z5@V*y;>r8<-LcwgX^*|Jp62Q2BqGLx*;fl0f0{^h4+iDbmX+SF&-~06cB$P*v0FhP z?PVs8Hej{sy>;m7Oww_ET7P2`Eg|MZmIv<~VzVBHY3o2EW*7g^ed9#BOKGn2J6B7C z?baEeGtUoi58gMZ4QgZ2(LJ%VTIw(gw~ImYaDAQznh$WC6sH!+{Kz6rX#^Y z3>jR}7jBt6CqPQtnd`=TifUv`@lK{m!*jdfR-MZJRnas2iy!9YG^iqiSxrMr%tRx0 zyNDLHNOB(KZrM2#g6+=}`RIP*`4P6T{<(}hY`AnuiANbOif2EJPd&&`taqvxcFeho18=ErBw~HwmUpG+|>QpU?S%5pk~O9ezB{HHtHs&A%mA6 zdij*_mB9PY9mOq7nN({;4>O_DcN?ZR9zHnClkb;%v)=QByH-Ye@l?Kw*aHWqtnA@& zcRhiI4;l)44TIHeq7aM~r$ty}&`bcD&)z5~U#cRB&NUlOH3y9Cn5ulYg=d#`OIzhT6;b{( zbGkM=eAI*$O2!N;C*2Kl@3xbWs$zWyhZ3!>Z5+1OP0t?okLTW&JA8h=OgjE(-rXwR zzHQ#SdJg_5H#$1Cq@U{(9MP7`$KAl5JM!Ud&d4?=!@I%CofSC*#%Cy3ESoG_OdVmf3nrqHjFx@<8*<^RR}Dl-Hw7W40Eyx5MwcmP#rdIJDzl zNz~KS%}FN~tX&Jo*=HVJJN_;4;1^XkgIkeYF8b-)-yEm$&dxc>?;WixZ!D>J;{4tl zyg|WRnIw0W^6uLlZ7aTQGl#86_1d@}&zVm=<(X!SE_yj3M4hXvS*9&L^2QH4v`A8G z<9olr&iKdYQ+s}>uJyzIk_sFAPYp0s@MRG#H5EMuw#hJ52sIlGWq(OfMiaJ}gbb}| zrL0{2cgVs96X*T|F1QCJAIKX_)aVA&FQg`L$^YlU>&Ldgz4^jst^Qq3fii5n7p)$c zU+|KcUr-Kt1~)-^dIoJPV@uMLl5d6ghdnud?&eJlt>e}DTPM+`kDI6C*qS8$#fxRUjBFWch)EJ;sYr;mBNX7EAdCLLOyAU(H@ zEW+OHgZCL2)aqid-OPD=XV7eS*G4z7x8jnA#(0cQ5oJ0(ukP^STZz}fj(r#&LccA3y8OVx$xwb{8}2Sn2$$)91JkkmD+hDHQ%Gx zWi!4>9GT-i>Ra*>dn+VX=CaIF=ZxmJWyP{wQO zv}i)=dN5Iy=RQN?jx)Y?*Of@7z}ZFkn4736Z%;|$x)I4%Cz;c4`u+Rpn1 zJ*TU#=0qot?Dfj|aD^zJ7@XJmQP5$Dbo2C;J!>5ce)}(JHEcVn&waQ&MvQ8$%sh)z zF-hzIc8xq)k?e>(<3(bA|_aZS`*>kPFL{ceE=2TBunRn^Aai%*o&Zj{y6_P$#)Vtz8usOlz_{YUPNNTPUT zP^aNe!lw(rkT%PY7wJ2%51bdh^wZ&e`UReX@|gca*;|LT**uNHw3N~!g%w&kg1b9KiUgP94hilKrFd`%Qi{8~TTl8tzw^H5yT0#S-#LF|?@cmu-!rqb zv%9mibDzgg|AMjI^+CB!%y3?Bw_eb4hq=OxcuT-xG-EG1KykbSQ(~)ZdI+iUy56@Q zI`Qc2EaRXevJpW8YxXojMP_1X6VxaIkAn*rmZel^a(wFl=wHrTNg0hlVjEnub z8b3f>&YFLY^md?Ty^O6Fn@=dCdVRV>YGO3SdDnV#i$S{9#WlZ4;rnw*1W~8-`UKqj z0AFF+0=0(ZYcO~j_?E7phS68RU0LT3T9yLqjGvPQ&?w|p-CWigsb`r9`4OBb*q%?J zDQ}k`v!-A(zltFNTUK9HTn($N#BOQz51K=&Kb17<+7Eu3`u;5Cf}t-(l-XzsfW}z; zX_|j>{cX0nO4EW*fR5$oYe=E=c;TnJdxk$~p^x)HM(C=kIFa3=Ly${YAAG6BLqmN^s*7t~Hq>-9=S7;FvBPmn$)Hb4bP8!hqdq4dTw9Ul z!{aNgyav80_UpTs=_i*SFbZ$8O@!MDt}zy>HUE{y_;ztI@n`#e4h44WA>&eBXupBoeTt@#92&i2ey04H5T zNK>*J(G(`AR)@g>pA<`YDLG5VXG&fiIIQhNeTt!Q2%vjw5J*b?`z4#Hz+k!TCoHOp z-gv`0ukc2wjHV?dZPNVUdzo^#p1TYUcO&%nVM1C>1AuDD9)hhG+b^x-_oT8~-fbPHpmU}**@&=S(6Dchm zECsAV_`dp>Yu8Tz*3=e4?Q$h^kT4DiLS-O)AF3;_U`a-0$}Ra{oJX4DCoP($p0yBb zFZ*aaq$ps}mDl^7CHc@dt8@eyrT0K~X1P6_b>9cZH;_~}4oVc62vkVFj`-$!Nlc9H zeaED;pJAk$odGU9p2mK z0fBG%lYGiCG%AT+JB=RSB1G&@Ei+Hq?z7137(77bn>qut7U$NM-h~8jCsteL0d13A z?O`R9xy7K{khjxx1$crV5+sthlW_Ohx!405aIx>vif{AG?o!10)Vh$}G)rK1uu(I#9=%h>fbb13;I*J>cK3XU>;jr0rKNe&NqYBC|SJ`;aS)tY_TS(n^>VXcewqFWWDccG=&_>j!` ztkihpQB=U;qwojP`_;@;&114k=gi8?GqcP1E6fhKgK$-=%w!Lfh1f(b4P^G=pAvO5 zrdgNFntgG|EY9C*+&*C}w~rsHVX-0S1Ozm(wTVj%;IL`*rRE=oncL#un>VKKWW>cr zHq34$ZxLSTOLY~iFGsUGzTc&p0#&Jcw@^gKcqHV0?2KoT6%WA&^+qRbtMZ5WyL$oL za4>K5HwaEG9~d1SQDFo$+p34mM|eRfrla!DKUtb=&{Co2F^lA&&eJL+_x_Eil~LaFdgJw} zM>}P4j~EsKBNr-;>=$?4W1(YK4kc>;;g78o&$~ac8Y^lx(4F2j*FJ4SlSO`-8yvGY z^vc>2Q`KC-cf|KHK{~z@%G3{Sf|}X_cX__2DR<^Y(^h&fIg3P4dN}(CYDP$m<}yMo zfAlBvt1!_vF)#lV947TpRxpb&2qa0%x>RC%`^IjW<6$O|lB_}brw#R~-DPS>`wuI% z!5=&BWW_a)>Zcwx$B#lW2WXgzk3$7R1-=MI=#LU0@5vy@!kT&o!4eS$nmHJGf0$u^%DAH!CD4-N(3Yk3 zqtEtQ?-XnlrU~lk_aW>^x-v%7^y*KEX^lK`&6!L~$O5+Ix5rRaKj62pZ%rL0&{>z@LQo789|lEUX(CHT$M;cRd^s9sRQG8Ki@$0AMMX&Bb+Qa80tdd zx=RwdzV0)>7qrw$mJlsQf1BK`>($q}AId(t?33lR=nCg-xnzMF3#T>7m&8FwrinvK zQzm`j3e%__#-f#<49cfZrm4?FRx8xv*s9(8Iu3uq1VetyCNSr1)g=yF-rAC0GMAUK{`@A>(WS{i8N79O7v(NHuP;x zYDmnz?h{O~@L!rJFJI7sQzkmDGCwSZ#2fUO|9Eot3RAGid*ml@m5RKavbgI_Xaq}1 zBXs>6k8B~yE1l>-<@|F-7i*^5h#lU=gY--l6?A1inZn=9HMtuWNTG1eDF~E};L{&8 zfp3ytlbNAzt7-P!x5^>t@$jn6$8VmHQt@-Fzai>C5f*T%gelU*R{PODVTRJ!%WMQM zL~8|KDbu~-cBsdAhzs%Do8k&;A0HSUd8&EgPz|0NqU55*TkD2T%#&am2mLhf~XChEkv2n81@@ac?VK?#v+cmYWmz&`b1YC zF>V9Qnwum_S!+&O9~%3^3*I|0Sl7MOT9Lv=c{r4+hI$CbadOqFUmrhxI`Idsiv5gs zuDHh~Dz!zXS!L6aiIi=ExlbO}TdRph{6`qRDYvrG6$a8q!##4es36>aGEpSPP>Efa!z%%__m8D}_d(sjAtsy8(5{kRQu<54 zd#MfvqnwYRsit*hK~%+J#m*A5)jMC0g+Y;GEPv3D z&nzcy?l`X+fX>I1vySyD9#Z>x-k36_1{BrM za1y1-++UHH7#ib5CbbF?%l-aUDxwQjY#_Y&8hxzaV;E+tG^hlKnovk700LB!ocWZ6 zH+`5gmaE@_jyCm<$fvM$j?7zCU!bCa(2MCkkbwd}v~$VoV%M2ld+G82fgsm8(&Yr> zZ=~m{ElCP5`7olQWG0nCh{UbYgUTBdO^sR8eRm|m!Z!8iRd*4zqI86<_S{Hs@ILiP ziJgE>HJw61^lGP15a=`t_l5wf0Ps`vK=6+Ax@t^nFL&GOV?;UGzJN3Y$!p98@3~Eh zE6!cKvrohHNE!F{TXF$-5v(~yak|7h=!VPfASUcT%{Npo_B3lVsl11|Z@|mka-#{U z_j{oae6tgg*OJeX&ozyW<|#T;Y-pt|J}mdP263COt%(*kpTXs|@LLaY57lQYq1O3- z(5Mu2KIEucKD#T5U3CXsu4AQ_LMcz1NS7w+K8KR~l&b?h2? zC7-YO)KYGI9Hv@m0T)Y0kUBQWH)h+o>1Ri+GSyUe7k*N{6S*`g43&S2sUDm4VGq|d zU%;xuOgri^%T8!(b5HNvQqv6QuU;|FA3G_P)(o3fev#{p9b z!P^b{vwTJCRwZ}_Ez~#{U-8Ymzx^gJsAY_k6eYdQSy}b`#spc$P`c=UVS->W?b>NQjj0KpqSdb%Y_7i3*bv-5}e^Ux3=Kg%a4hXcLCFIXL-tx_(amL7gGRtM)(rafN zQ~9oSVJ`g?#s&yP1h@P)7KYw4czJuledZ@qr5{yr(yc!dC;~K1> zrAMd2kxvhenKMxwCx_oiD8-DW&cYcS=`dO$kxu0e%o#I~Q|+N&ul*4FfhDjFE8L-8 z#C;-9P4PVl>?qlXx_8lj;9Xyfc2Kc`b7N;~vDdbzYE=!(rIO#f8?M_Uwv_Ng|9Nel z8#+klP2g<^+*n<~CNgLqAi zN3Jb^@V}=Y?Xo~pRR-t0%6}2~eEdDNk+^$AD5EI4H)5J+&h9f0SL8^)@i#+&5|v6f z=4dX|CFylnu|4m>X}(cWy=O8reydii8ijUr0b1ol6-{dp3(OPalhPq^Bc=rY5xDC~ z*&&PY8_8igOoyNQ~`aPIvdIRUZZ7fRj~Zy7tmNngQ5P zFfxvg!cAoqj3?p)29E15ySWMZ$b^G3uglj=TH1Q}JQy#HCOjoumZ zxh2xxC@{D(U+S3N+6^gv{p<+@OGhX}-|W%sBzw&3jkOntDp_#b6Zr$36#KjHB1P&* znzjCQb>P4=vL52aA62gzCApBicw;0La^ut39pF0mg4LQunmOaVd~aYw{nJPj32f$7 zoG<6ZCCSY1Za4O7#y2vLgvNqNZa$a#VgLnVH|k@#)hmSF*s7lfeLUS<2kS9xz0Kzm zI?9Qu0uXqCBH7(BwR5-88#T@X=VPmr%{;zz9nBZ4^K`Qrl#r|BDc_tZ+z(M`55&K+ z2^Oq+;zXn@5D|SKuT`Y8?NNJ2B-J==j^s0`%Ij7PnFDxy%Qikv6fp-o+2#w$u?s;J zgheL-BB{A*Ju@?;V|Fw-8eLZzeX<&HvBQ}>8k}qGVcyE+4CgpzO}|fq@ZP)H4@j48 zorGm=jv(MoilbQTgk|5Rziw2OiAcu7QnbKdw_dX>MDHcEbb2CXCwMlnMyEyyWg=qw zsepFk@tO2ar9X$&ndoz`9?iWRFqU_g{B;OG&N^! z!POIZS?u|XSI6P%MPam$0D2DX3SpWuyFa6zOe^pOX^V%VI`jc_;haWjB$?&j{whcG zg@RbLaDu*^7}?|0tQSjAZA^o*@U}xWP5#XSuXJy{Yo_d>kFxA@_5*4y-E^P%Y8GSAnd7c}o^u9`rPSu_F4%m&vJx+<9K!;;p2BM?o8p?B;PpoZS@6fbeWD z!r8KKBZN1w2$nslvNfWnA$3Fb=sNYMP?9#*w}4IY4y@{^*mv-m*=fudt?tb5YA;O( zaTB0&__*xK_{v`>y${~ntQK`9Wl5jl*K2dqHbhcTnY|D#jX69XGB#BPlR z7);g~)DpEwC)jfdpGsu9lJi(_Lxoh6bXVQ4Da<>c&C@3Io$bP#c#%7?m%ARm9Y@k~@UyXMie<0ep6O zq$X}ND*^dwQY%#bthfR$j`+>3pqe2H-64SY$pmO)nB*UxeeYU$zn2LahA!=uL?ONd9lunzJ zo0=1%@K_vApcti3ZE^+p#4k0RuEQ#Q8Sa2LR61gt`HnZ|fOdu_6d4JnBDr}-Orgry zt74>-KdLYjd~2L&Qtx^bB}_eA&Mxv3ozkKu!GnM=4Wk-l0~Aq zs9&H7Uw(1?@3fKAwYrF+x|bKKM!Ekm`IOFneB>AS$N1o_mk&#L9(1MnMmB&s!YS(K zn7i`Jv2OSA)k}56w*I$Y%n;vHCR95X(h#*|F0zY?b_y~D?G)q+*wx3~7EfNk{SB>p zdEKCqcim)#9)C~$yoA%v(mDN+Bz`b)4(a=$U~W8W@ZvcL`^WCxnMCWfbyd|HIMB zSNwb3IV5CPE zsLjd1D=SO1sTeh{$XejTS??h)@(9c)ILzu! z)Bd#Ilx6PosvEe5j|fcaZ~8Lpb5Y53hgw>E%{=^o@Infjeh@i`!+3;My|tO=7&tfH zYxVnv)Y<%_y6p3AMD#W%i+0iLO?q=-;+yf?;gh0WSl%S7mlK@@5L55l{3V^95=FSk zB79LuWqL0+h1tEncD@@l+O5tTo_UC&L~lh~b&{HM-$G#_ybM)!nhSnU{C9RkR8(Z4 z^kLt6rrvFDWYT8&fCe6%r*J1A2li&TrWgP5VyS8RY~&c^P47yiGwwyiqeH~#u`h&3 zkyW@;=~q_1>vwj3va^9{4sq={Z$qyZ2mhy-69tTo zV*OT&Pt%hIEJ}~OG}FmCUklEVpLeouwFPVRT*s{>rr()1LU&k#$9klI)Bs>z@fF|4 z&F#D>^FAViNA@mVmQ+!aG8o#U+S5=B67i*CA;lJM`B6+eJoKSTMU7Iw{@owOV9ZfC!sjYn+JJ*(TW1r&s%gyVLKd zuIuzY{AvTYl<%jW=^!3{wM$(rN7oh-rhlf|acrthR0&<5@|Vy2}OS;@;6utcyhGo!m|hoxmw%Q)1<@vYTF zUe2h5@T#1tR8KUCvz6?&h{;6CZ3a|XWlAjanMg_N+dS1x97~}YqnS5Yw8U9OypOR# zGb;T~9%4bs%B| z7d+u4b6L|by}KgoSs9LmDCdWUBXhOC%w0@`04Elv-Zl0t5iIf*s|AR$v44DzaO))H zz{)*t@FJM`MXJ5*#$s+-Z8Gw~HVSpp*V23G$Mf!R6GZA&Gf;5Shn@LImd?hL{6krF z1f3vpqH|=}ND1%B7yC1nTiV3&f_cK810CLoq~gh~&(2I-`;?~9ghZslHmZ-o(qiT- zwsaS2tz+#38l=0lE#`K&%-TBA_EX%x%13{DEz886Yyjp%(sJy`#T{YVYV#zX$YBEL z?7Q&Evpl;N-gpU2h1SpI{U#F|kV^eNRm%`co_f3u^5}`!;+%mKBEqtuj(s7GtC8Avsm)Dl zUFX=~@=?prdHwuf9JIC>D+WaFd;Rb^B0ObyO)^#7Ih9tJt ziZ2{4xJhug+%V$M5E2G?)DgkXVceA%g!@J6A7yk>95p~R|Da8H5i!Y8A?ovd!ydjbC3n&t)spbX zQiQy{9|LTI+Co=Z%t8>GxKc%=G&~Z>f+DVV^!KCrO%_(`)A&#wu~cUe_BHPK!PB+E zJ-U<9Uga6tb+9sSNi?r6dhHWF<2rh_d|c|^Wh`NtPRL*ayQY)+vN+sd%2IjOlpl@i~dk7n7Rio3@1oHr)Fpux-;qlD>J04@P!kfWgib*Le!Y6pe+S8;kIj z`nIaf&M?Q@l=&g)3q%)2CKMtKNLAXq~H_K=V}@d&q?f#QHg8>T&M& zBaO4DCp}5OmUBz!0%FzkgPjV^?I{)=y$wwOrsWU}vQFdR@PqAy;EtzhV2V zVas{33SOOCP<^}`Z|P9YY(Tgl&wtqfVF3N&lJ5x_Yu_s@g8hW7#~w|@3ZKbF)Ix>8 ze3YP}6`*=RDESQ%m+Z;Pawk!GM!{v_Xj@%-_06mGI z+VHRKl#TGRQJGAU3e$RLkEq)`GtQN^omVu{~%A9jM11sw9tky#|0A&cmY?X2ZMQk`8P}v}=`-AWe5kf2mL?6b zMsv{hxBTt)I-pdWQEKi^@yQd~!C zgxrtX$*Wo~j8G&vmYZihyaMqJ!A(ib<1Hz`wWA?}up3_Aw;BmjRs~)rbFSfj{YJGz zdGtGuCmjR3Hb^Im(1vxal$_(#xF0Z-TTqtnLS@|;CCH*1 zxnpoTQ-`n8ku)HlfcdnZ;D1U~UyDi?*CJrB)2S9+6(}I9UXtkqYEvndY*cp=@OfV> zM~cKwdm{` zSSX~Ch1d;aRx>F}7a_b0cw1D?{5$zhK2vpyTaI8+DQB+Lh{dB{F$XvX zjxkbOBA<)T zORUOgKTT@jYZ~^;F|{G)nI~asO9u=GmjvzG)IVBjD5p}v%IOQ%Y)&spI zSFIv{9$~u>0gXICn|G9Xk;lR~k?i7>NpLO4T>g6@F@%iix}ytg`*sG+$-k;i_gA(5 zpd}DE#s=?$2~0oHnFhL!vYv1Q5PNJ8`zsbp@&`VNKWJ#Vkud;la&mq=HHd0vvGZJ_ z{f8>%(}bt$ygZsd3d5OyC7p;AGD_K}j4j!zlx6M`fKEFsvM2#IXN%z&V6~Xs@FG^? zH?yvN-qqRqF0~dT^iFLN6zN$l&US1&JU|Yfp^ELRhXBwZ^O9HLFDw?AL8i#GJ+wWn zFlOR?Kjk39&)c|MqwW!NyQ^H&vA97#Z6~#=a6+!y2HcIM5_k-T28o05*lKLfa6tam z+%uP{BIexus3(T&Y-1nh)J1r`z{(YR-37MSG!OCoCen)4395GY$oQ|CLMa?vJ0J}; zs07=ZIdU|ur$u)oaG_K|Am^(hg{0Hm`(4mcN?5Rz5b~X@DmCeEJB@_=P}0}pJPDa< zP0Zx02y&_&`{`1I|Hv2rJcq--a08foJpE8qYPUdt|=LzbA;md;IY$adVt}3>fMj#=4j@Rbhw3`YJ=}cZ6B#?3Z;e}h%o7Z zmC^O7w{llyyd%G(Q%cb0`IkWRRe|h0$1=NZRMJ|L!EgfT-l|G!`}=(aR9fIVt!VpF z8A!J?`1QBL0$P*oM_z=DmrhLPj>1Sk^+YG;;Zg z4hqihRIC9sGZa2Gt%&LOC1ri%HK}5?6L)zog^3N#*L=DG^YYg2b;#MK#wz-Q_G;bt z9?3v7}IKGcNrFR%DA4}Gx@$%_6+0jAEW9HR5D9H z$uuv1m5IZVw(H|R-|<>NS9)352-s(9z?B)C-y-u6le?z&sb+6|CHfBRoOUA_YM=AF zQfn#(f{&!49;jpez;;d;Sb`%V%^TfJXv;2+(N^o~(3_wLNEO`JJx0}GUjZF-z?QaFy6@y>XGpm1NrtK0h$wuw>wmYrHCgG~Ja=OCg0S$SH4f6pJ!T)nsA@ z?3gd&wYAQrxEXn(BwJZ@BnP1MI^)1bylD}3D=>ABbg?hJE!tbX{ioEqRQuI?`4^gI zUQPS^=2F^If}%(Tx$nFtvHTYaI%Z<=Nq|V=`LKS=p)Zc^ukE2a3PZ#Cyy#K;fQ5cp z$bf>RUT?VR@H&Q+^UKx9>8Qh7n_LWmua4p3uJcwn` z(mCsg&~f=(B^6Y@o$;#T%0Fnh%&VSE7BEvoxZM3jwxqnQ2wVL7;dkxos2Tpos-;WmZ*yq+{#{$iW1*Cc^$*%MA|9oC-8$pq z-JiU^HTS~nHplxg2iN7FZo`eewMQI~M6j!`Z8TNE0r8z=@4^Q@%n_ysYAogLk=J7| zeVJ@MLw&m(cfr+Jl%L__AIuX{!qx9o=Y4Dx>P@_#x4&{zI*(dIHbf29dow+(h;z>) z13|~$AfPOdz0A?_k)SmzkFeJ$p8V9JCp|&GDx<)>{rx0SHr1!*I3^w6_F_0dS!Z!? z$JU`@`&e!#aPL^QxG1O=zlq6$c|SHW_XUg;>{S9AQkm$X!%nUI6ldr^nr;Pv9uU>4 zhA|F{Mq)Nf&<2|-@YNIuh(>xkzx0LG3l{yCFeHx_G33WTl%Sj(BJ%^O6CU~7Y~YqZ zXjjE}F#xx74ph&EX6LIiXP6D?@6IVPqe zrSz&r2An#7%Wmkvsb^7~zPlK^PTUHqiyF-Jr1u{3RJ8{prIiNNo0j0FBgi%-sQ@Sa z>OeE(fp*r4TZm*lca?ePstd|LUl)|jVjC-*%J~P4exnIM+Q5*gBQ@U5OKv$3LOw;s40S7glFs_nww~vAj{5EJdSd-Uje?hAy(irXaiu z^d6CnT0I4^M}D04;GV8u&9+t6H(lgjNEse#Gil|^SoIq4uoUfaPduBvjW}iH4zqwP zY=1&yZIsgpT7gW#Lez#S&Z9w6NE!v~-iT=T7745lBNKocu zQ?<&%o_Cx0QJMv(~uo-vvt>~l`AT4 zM0u3nYv|B9fAK0ax8&UxwRD#Fr`a;=QF;m%x2E}S1NZscgdbf{O}GvOO207 zQ`Ua&Df=YmUx*@M^SgonAins&0513k_#Nub31u_$tfrCs&No!{AZLmUSgf;!s{_=0 zZM3&`z89&JHmJ{?yV;BCwJ#)5<12>Z_~YWuJ0es{H5QxbZmoa%?A-h+QcQ+NGGlB3 zZ2UV1cH(S7h3K=gPMDCpsM~*h^1q?mspBuh|4qt&nEYRP2i^Z=7+x^on$txba6R1BTHOr{9!JR>e4Oz`YMsfzl~guT%U5dg z)ND?&&VN-KLcEEKy+lOA7ZJZ6L zl;RS`O_q5X7rm%~a4Oi3oCCf1_MvJZxG+MLGUEu$%QK!K%xT*;ufLU*pOQZm2C73V{NFz5yeKh$HmJ;ldyY;L45}Ge>+^O8-nimJzxqsXU(gICvcl!VSR8Yxe z#=p@g79{_qB2%6sbwSjK^jdk)!2T{<(iqf_XyQ8ls6}`T@jjt}=AoH4V26~{yN9+p z%a1_U)kYd$1b_izM4IADArhdwTOxhYr4kMs+tjI-KDBDbz@hxCo(kx{^huUPc6HMRE&_dRp-5 z;|twb;l12yX1d8|qBp=#s9jC8gY%0+E|OuG`|%uH@RIX_B{dVPIO9k%t4Q#IrOXqh ziF1qZ&eyYc(O_(|@_auRai3pMVn$z6T&h&0?;-Jsa}7b~Vs9d&G%GVq{^zqVcm{() zfKyw&Vb%et92E9T$N{WJ1F^E+@h;Z%y2(1ey$WcLvV3B7TDtUw@;85?6sLU#ZsWz0 z;o0%xYu4rryzvmjz!B)RSa`9QHt4;so~c44E3*F3jZ!Yq>B2K7B22Mv@Q52LmWoK_ zCC$X909St3+~d-sd9X^{FXY0y!V5=|k3fnI2P%wz^l<_EJ}mUc^b($BAL6WA1b1Zz zn(QDC)_O5(1PhLQmFMZm5O%iiIywIsE&Pj;Q}!`DixKYmzg1>y@=r3~C4?#RHMLcX zR~`khr-JOs@H!NZDeQRk_xv~jxR3{ED#i_%>uecJqr8+udp5&49?A}&vS9kA<3IZc z&6%E&=h`DIyGCEWOT?|tN==)$8uwLjI&`#NO7yS&q21R)*{OfWQN$9o zenSypDgMgOTEsOSa>(ht$sjI5q$;``6UEhZ6lyKy8fhsfj0II=wG-DrNPpBWlb`I{ zxl@gI#XgL(7IP#2Ttg^BMEq27^IPF-FmsxNu90{LQg>(wl^~ zgx1EJC%ZD0`|M6Vt4G-4jEhzCudHv`$554#{`$S@6YE>HyHuY6QO3E5_4-zD29Lu8 zo)VGV*#a5lMH0+?+I4M2zSb-g1v>KkLhEw>U);Yp`HRK*$^(Y}#RHwrhbI|Z0rg*6Xqs8Z|mHOUr zi^me*j(ct*G;YUkHxVCeVb2>xu6@rvEwfN()iiO#mMgVTXB{OMcd+cG=e-r3I*)JI z$4Gj&zK(hMgesE!LF>@}S`aUwA_ZfF0(AGi2b?=l5R24iWXRY`ip+>OW}sA?-+k!UWJB8_sNM>63|k zfPACO-kv^w@<)+ZY2dpr#qP@|Icn=~ucy2^gyT~}Zrm*lLeio!_$p_5SJwGJrzdLw zD{^kFoSkRQ?f<(+1OpMf>f*w*u4B0FPisj8hgbd@-?vX<8Y3w%FCt-APK{avw!Y=wN?Z=>MxF@%e9{NkQCds2i}tH{ z#4JRRr+TOSTqUN2r16Jqp*ARTTG)%X^gMUp_n0~1tb9^fMBxRdT01Rm&WR1S_pqB>ChR-Hp`oFpsZg%=2VgMcs%*qy-e3z45YsK8emh%ZbiHWI zt{{20ypo$DD1F>ocED0qT2rxd5k=1=(Aqr=h%09_l_Yk3-r+r6fW2mZ^#={2of)E- zkXCnjGh3`Kz-i@!%DVWaund-&K#OkVWM^2Eg1)t8cg3RfML7K9y9Wc<<7X`v%c?*s zVJ^Ad1m$wxrZ;ESw}nX}3!5Y~QIDC8zb+EYEvzeA-ty7i{j3Rf{!U9S6J)2!N_YI| zBm+Yk_JmU7Ms^R&QU$JW$)}qkwMiBS+i)fg&i@c*LDOG)OptTe2 zX+)jx3jXC$8VCyPPYqvj!tk&qG~C@$dXgEok%Nns->{5FC5ON4zkq4DDs>-3Md@O3 zpU7r~_Z<3uoq6Cl7+|Iq4(?wYdTl9dr!KQKG)%&IDY@}{b^;%Wr^-1D?qoo$J2dK+ zT^U=?g!rPRqM~eOj5w{($u67XYaK@h;xBR0+3cIN&`CM%)g+11iA;>{v zSHEAevFVe*FXWgtE{t70Qgpba98K#CDMoOpTupE2XMW%Rl5dojKIFT67=feEd_8h> z_C>x62_)RKYr1y-1*C;a7Yk9RE*-jUzgBO7N)sp#mHq53Y4Ld$^13|~mHq#(@Y3oA zHjS-**hFkOFTe+DaBT4v+(KeiH6PDzNp6x^{Xy%Zy>4I-DTJ`Vl^o{y03WQUw>4K- zVucrdfxxroM%kho7@7ZiAxuAv&Zo|Txo}K=2}VCP1Z%>ANYbjxCKmJ6H%f5WWDyi? zfgO$9%r7)=D^H&|7rZXmT)!THo3}3E78!fa_K*n7JuWME23FKd>F^)%r3O@Qb+ftm z3wD+-74HH-NxV0VJ6CH)RCJvvks(q02Vog2?=RDQYuW!}-|qFwwOo(JGEvj*9*yHr zw9g8Kl?%sVu3zp-r&5i_0W%zjZ-0Tzt~FhWM8k8g=#Ly?0bO?}7fT&wo$_ZJrOpwe zxlI27x-KhD`%#Q_5V8sGNZ0bTBcarBj(k*JR6@eA@192-^9SVZrpL>rfwcF6-G0m(? z;qv&p8GGvk++7M^OcIuJwwct@I6EAdI8@y&%o9Z&f8W686af#%Zu~q@$JDd6=17y> z`T4!vI3HZTUYdt~mUpeLpcC7GdZBHvvU0qSw8ImcFDrwPJA=4S9pGl0g#{<|aK!tr zl?@pCycPrgD6Z-Te~cq0p6|UXf~*vplFpC|Fl7G-K}Am^n-lYD99|@@SJF4-pjOg% zb<-bc1P06=Xz_f}3gb`nHTuJJ>3|@cvBF}7wiB?3+ZuYYmNw#%06QMUV0X0aCj({? z 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 s)?B_A}L1P zX0zYE@80kK-TQx@+cVEhcc1RA>Z)H?RiD!{cQbb%0CZVN8A$*F0)W(l)B=FJc_0vg zhlPcMg@K2IgF`@oM?}U(K}JGC#>d1$!zLymB_SptA|j(?rXd3{QV%guySy4 zkkarz;9(PBX6ImoKtMo1Mnc9zLBV4qCn9J2kJDW{fB_Hn9{hp;0gxCFP#6$*odCx# zY(jx~{XM}!z(PX9!+`4`fInOSU?8BNAz|<40Awie+|Z%W!F^i&e((4XGEk(vF}b5p zNj^`g;yNhGOYpK{uXm~@VEk`qN#rL81ZUTl?Ttr&#un7pt&j73j`d4>r8JW3pS1vh zU6&d49Uq1H2G0k0J7+vi?_&Slj>yAG`-Ex&F>ctcTyT>RrC7jw%rq}X*)&(;c^s~e zxV9z6|7KP<=uJXDy@vsHbRBtCK3Ln3JRaZ>AiJ4pNgimzUN5MhgV8}Z27eWjd%sQJd}mVe*bDI>}~%W z5-xD1efkXoF`e#bhT*H0MVC!>P%n{lc_GTd3%TX%9pS~IA}URXjnbz_CxTvmcK33G zw1X$TUxddY%{Vbat$gDje_YJ*x1zJf`0ox*?H#nZdhsu?NkStiv%ef`oKhPCZiNUQ>JZmREt{M{!1y8%EJHnM)(6?yCnUcfi2R=aKvimrWF> zmA%v*LcxmJo?mBB89H?|y?cw_lg=J_^RVCT0ov9R@t*SMs#bmgAd^!|yNBRWCcTr| z<*r!tXU-vEy#1eSjt1c;-yDc9ZD^qp0)W@xs$ss<%4sXhqI3BKl8I2&^mHB37e0^j zBOP>E4?CKDUu4$vKpo zo7C!HUjK>QAvI-Aw!D_xbVHp!^mgDI)9|lDJ^gU7OfDGf|8e>FDuYw|QSgDsiEV^k zAF99Q%<3cK7&eBwPrtuvmZHx z=y~vSDHF`cFtTng348%_>~!k;;)a~MoRzsAjDs|Oqv+A(wDstQbj`p|nSYiyndS`c ztu4HAs_)XN!oIrS(Hnq9d)d`Z<+HJK8So&V{n;>S^4yo!hsWj8ZRN+Gj(;-hN{C}wUKg$|i^N$qr%Sb(4#(OPpV-SDz z9gqsd6;c**Lyl5pIm6C#eZaIy zcorRX=zsvf>hr@UwvP)X#Lzj&MxRwLFu3h-e))Y*|FRmM5QX-ag^rzd^+EYzaDfVyyUS(Ea>_UFvZwKbBQUR3^~v5J>MP z^FlSAu-fl8F+f~h77qx%UOCuBeKh({C^H1|CwI|P#ifw1~0PgJT%-I z{RXk$=J@sdAKCry2ly@jj{%R?_R!guwEWqqXIDKx9;gnYD*V+;tHn3n(I1(+QMo1cy-@oegBVy{*i;a z_BGrbGM;Sl*nd%Cj#{Q0MALiLf{5)-%@LK>Q68nE`NcDp*V7Mx(v7y6_~@2S$a(o- z3yLJhqwVQ~Er^fj>2Ntq_WyNpKrmX)O2;kamBBy30mS%z%86OUr)7b2#VI&VTEw}x zT}yp^a{$1aPU1UOqCBS}=K3$QNK$f=oX_KV5&aJr6ja_K>sNtPkqjfiJlR)jzB1YP zYPO(A;SQ>{v}`sxgNOhA9l6_acr)k;{4-Sjpw?fD|3r2_9q8X!Lvl+Yb>c8r-a71z z^CM!d^!3Iw0Ff^)KZ@?E-I%z{rEc{sJMPT`qyB`O2RewCR!eg)ZD~s9qU= zB=Ywj6#(A7Nt5%)wW$~SH9+{9;P_5GUvb?5(pxI?g1#Rs1+D-Chos)8yxvB~s!Odp&4~}Jj+H(a z_lZrFQ=$E-n3OQJkm*+D^Aha2{?=jua08dD%+Ze{-V26oiH-is006-9b8De3uk1B$ zo!pRn1IQ*G&sKHTv2#xsgBSJP3|todr(NOnXNdop_0}b5I6Pl>a)qFdp2?}E+H9aG zGsjusEBI~Le|l!a@PV!T^lX!#9r3i&`TiH+cUd{P!Fa-xru(n*zb` zw*A_R`|)@c{j09AqUFd-5_@w}nG^q50G1^1l238(L`ad~%Ug~WKcH}Y(cOQb03XC-b#_$|4!rY zJ!7}MIA`gz$Cr;&Q0Dvq#0OlSq8H0q*T!7S9V6vDwNa12p*rjc(}s4E@=%xt!BZho z>k}pkWbib&ka9^5FsH#gjsJ+8Sj|wK%H_jUluKa%bts>3cJ0&YBbT9YlsllE-EL=p zVvA{O?_jZ6(MGOhV(j*!e6S9i4NL**KtKC=z3VX#EtvMV^!Fk(k=>eXpgMSkx4{g+>`$6@oPvG>XPWUfa!0SHO9z9=vc}cdpKHcK-7+JfVzk17kwhNJ7+eh{J zH{wjb?9(&Lld6j8%O|N{_4zG9$Ynf*wME^Rp8l-{7A*e1cQnQzOU_dplFo;TbVIPh zzx9P1Kgk$=^~F{2$u^Y6;cvA7@b_W@tUak8xSzNocE0-PW6#Io_pLKTq+_iYAF{4? zpJ2+r#sgSnDEXJdWM!K`bsj@34}Y@v2jEnz+a094haOq0ncPNL5u0OwWO_K8BW+)A4h`t1S)o*!u~XjteJ=<8diL#)dnducQuhtM zW9eHKEqRz?vgcxSLk{DHF0~Yda=pXPKT>5S=S8uQ^oQ*4l=j<`Gdb4TaBqsf8`C<1Eul#I7BZX%T z_vz~Xh3`XVX7FYOfH?9Q3$0swVmF4a+bQw{1EyIV_vkOOL-jIl`bld`Z0_LL>Bj_B zekoCR;-j;2N-7{&>u@>Ga|2H*Otfh5W5d5H^Q5Pdyq)ONx8gU8+OD}DQ!Z$uW^C0gPx+I#F1?l~{_P=@hU&d=mDc%6?1zis>LPJg(KV0!4Z3u12+eW|%oiljNV#9ZH`#jwh9Ak+|N&nFDzbK-OoMJArkJj&hh|*~C z*jgZXD*Rux{-3!71%7fOhz8v1vMX@NM~5SeiX)gRc$7-6Gl?`o$)J<<)FjAS?Y?|$sa4Q+JWi?7TQ<>}qI>z$ z8a1M)eTe_B=`Rux?@kdTq>z&KW9_n5pRH5ecFpcrNDl|9Xy|yPc|6~jG~t=iH~BOu ziEeg<;bYM*VWYmQET&&EW~_Xzu6J|7dcB>n%Pc2 zU!bmX?lD_ykHJjZq9mO(;@sMp+ci-YrhN0v-_^i>%bC_eh00< z-&RHAry9o>fvA)^u*$pzTq<XFLzCAoT;=CD};62>5QhDA`&y>Rw10@Ad^qlq(zbSMwwOz*oh}%aUcfg|W z`7#Mk3&hG7zpdkxsW1^9)8jjUDTUGjdo%0{9GcTL!@Y)et`!>ctW9Fkd}Y?5I5P+S zN_!K-QBa;?I2*JUq)0Ym9wGIdcDUkf@f_6x%YLv6e`pD4k#;iM%)LrLjV=lDZ8V$Z zn^;@W5Dk+CeeWJ?<#7HHex!9mty${Jk{;{{z7LvXn=;h*U-WWNfQ&vOlV__?_<&ZoAjO_baH<_Tk7D zA{r4PL$z067#Di*l80Pd2Wiu1ShKP85cOcKzt>QQZ(|7%R3wxsI$Kn3?7@1`8r2d` z@GN)`bB$AEKjrNRc^v{T3L0ZKWJu_Srb+9(8&oo2GddGr1S-UlUX@^)4H#;#eX5MI zSD(~RuVg@x^QLwg3DGjtc1D^q*=JOp zZRn#{*uIR9VldQ|qW&lmTjOkaS)mYp$NRC`AEiFIzsbHMHC=<^IH1XX zBrQhd%{tvcoF6nZ&#}b9li7TH8a=BCEj=BFa6B&usEo%pBshl0YG#AQ^s&qiWdRZA zNR_1Rt^yXVQKs5(2|v{{{az}(uD6>63*_<{@2(8hJ+07e5x!*gT5E?@1i!HO+29GI zVRh-Qhw&2Nc~@axNXUZKG?F$Xu~LV5p=pQb#2()1qoiqvf1>=63k|X5VqD5Lp)W4! zo66DRbNFMRxD+9u?I)XPh03#{XWt4w`l+_lk4q#|zHDbp<$I0K1|Qx5#2g+tNNL=G z7-oAL%JKoszPGy)>gZY#l!l<^r|+m&bRSR)85=!u?EQ?&{~m@%%O~ z0PBg0#Gn@E08MYqS{rV7np7sYfkd#U%e!Hq@?av7`Hs3zO&zr?n-LSW*?j z$|}jo(u&X_MYi1^IAVjSF~A8dqJVK_>6%F3>k6Kt!d9?gN&=(PDoe%ciu97pvLp{;?gR?K>h*Xs zGw(PR#LoU&pO~=K+`0Z9%DwDN=c+;49`N=Kv2~wKzMp!sux-!PP?sGs{=T?S55|Rv ze!cmSRAv@Yk)lGi+S78P0u>6f3mH1@hw8{NVjmzmpK#!6ta}>6N_L{Uc)xL159w3R z=n_!nq{XK-m|1=|2PJVWHqgB+fg;jjhTxEvc`pBGD|S9GJvbze?1pv}~tpe{^znJvb^ zY=qsg^teDRb%PJ8xfP1Sg?5E_Gn=Vz&8Xwq5NRd%I@;J~01z1fNns&&6J}RuVc+K^ zy#eDoUsa@A8St5FL(fHjSz0o&OS7BIAM_$|g8i7iKk*=WiF)7>*@PyPS++8EI6iL0 z@9W9jeu;`ei%nm6CU(8i`QgZ4%q#48Q{shIb&u_9w6n$b#fCNsK%Om2gmqOV3Q0{W z+$783{eo^br<*R*`Ug!4v+bm*P7USCPJIdl^$3@vBL(7(q(`+Q^GB=B0K{=v299EU zAS?EP7H&lbucGNEBb0D_bFH|3`U`0C_q@`n$WF7k(=!;^ZU8Bf00TPj*l(~wIhRZi zR(y^UM3l%x$r}sz7u<&1OfsCI)j7$T5s7*slfR-@hG`0BK6L0WFTeMRobEzqwD~4bzN)J zXjAxO8*b@%QyCU=VW7rU;-%4&b067|Azi2;T|X7KD;M2qlb^^l;}TEV-Y4(|t{>Z) zj+32vMh{Em!_@IrWMczNpn^c*(YPSm*nWm5Qpoa1)F9h6Tkb`I#%%SFdcABTcq)xk z*$GwK)LdoESODrTrbvf9Wf&g0nBaY{{?QF*$On?lB9;^r_ra_r!gwh zhAtAzO#7Z6ihVZ%6i|{=heL`)ik%V{p%#nWnvspeX(B#M&rqhCyCrLUnb1F;a+|wf zofo3wA<=Fb>0pgVpphIsM} zz;u7+XV&A!|EFee=BK{vFB&Ie^s*NcHo}rSNL&}I;Y4Lbrjotn{e&8^$MZ9i7wm-2 z>?Puz$~LB1nbYVFDVr22p>KTlnJ%Ab^YEcE@viE*&dZR|qTi1Fl(%>+0M;e6KJ0L{ znh~@%zdOLv|0XHBw8UAl03NQ|_(^tf=1LWG{g9Gp{(Yxj+jd1UvA&UB);cl1PCbb) z4my|^4qu{bsY2h5evsMMJc&aSObg2N($tB_j+ zdt7eLp>9kJ94!UtnH4;k_$_E&Zq+a9?t88cg`l>w@Y{nHdEtl!Mi=3z`8Om;z;EVy1aofmCX@8@>a}FZ{=l4kL{8LUIA{3aB4ZtudfyKIOdlTHrrF(} zqF@JN;hQ_zKO%5atgGx!&GcQ>ZxJ`q(AVjL(RKfKbUouLB>PG$mOKT;=o%|{qIWPc zfT{j^S4}syf$M0nmqz=AQI$o6qxbOU&+si!Tsda!BI(7pzVOHh{}ejDgc1{bYG2D0 zbS{?Z`c1Dk@hD>;I**4boN*S#nxPoZ#2?r1q75?Va0fU5=i(685 z313tHmSach^B`QeMfO~>DPuD{~tC?)WeB}f;}xBLvkDrsm0*c;v$)D z`l$(F-mTVJRBlCWa3hiz6ZB6|4-C*xn`e|m@yr_KcU8^99?C{e%YEi79sX$frvtF< zRBP!gx6Ss6N3^zI6~=FZ3sw zdSinl))cC4t7nU>o-f7hFdYd<-?0A7Ok6?zv@z{UKU18tX%fh?cMY3eeK3K#j1+gV zb4KP|+{}=lMgkrEkm%<1PrFiY^egBVH1@}F9PE6yPIY=?WK4sl2glb@*!9|GGn(jy zchAqQ>3VvDOtH&7by4b!D<1|Y9_42kY!3VaY)31>qXVL|QJD5(81`q8KZQ2oyNfMh z1EOouD%7iFI!?Rn9a`~;)+dv8=5Pj`^682Td>!T3W}9O~{e)S`)jwUIEqmiOX+;~x z5Eza3`*g$Rf@EBDq}XFUNj}`O*lR~zxdq|cAl;)kqM?L@B%Wqgv09XvQVPrSSq@(q zMtv8WN}Eyd65s+i^%`VyoXRQ#6&TL0H8$*Bk;VJO_c}`W?*K|L_eZoQ_?|!A;qdS4Qg1hgGAi+D zCZ&CP?*pm7kyHCG3ZOxMP+qi3uiqyAL^2(Roy!!jbP`y|j%BXM(7#eRlC-nDj~Og6 zF#l7WtjlzBCL0yg6k?c7gDFgS;Ugr+%&6i8L&dhMYPrWIMm+IL)8=@&oV;2`zh%@W zpZHbgtzTsh=31}$Mf<+Fr4-~&tcLvoc3H9<-ZsBXAt&|Tr_EBCrl$?XDt1d{R_|Zb z6{pqwpQ>c0($C}awT7Qe2!~?)%+r^sA~TBWz3@}Ku{lJU&J^^mN~pixFl(${h7~nk zYnpKhc|S%1?%kpUhyCm2{hx*t{RxrQ zj9nTJF+imU44jkscR*Nj^nCl99n>BV{Dx{TS^RtVY27s#b(kL!^KK&GtAf=TWFnOG z-~8sZm>VujH5rr7XP}Iyr%Mb3Al-h7iH*K)&gTZjG@%$8=U*}5+d0sq;Q?bfMI3$n z%Q65zS|3Coxm}vAJ$>e{|DK%+XW~jTi>*|s<|!8a|25gnH%taB@FHwO#az3w^eC}1 z*q-%23ouV8a|q;El@mI#w3~wwV|P-UJ_R5;K<+&*$Iy;)YwCFkGWLj;D4h ziLDmA7AxVKBCGV=It$esWp14k@vyLmN1W3tc)jmN={Y3!EbS*YnHF`WAeJR;YGLe1)l04FrF?|b_b`AL>Vye(~x z{(zw2K}CONxRN2LrLMVQoD06i!H_S9D5o=>V0rouYd`Z#PBVT;<@cVdK3W(=p&7KE zjwm|$CQfsoh;gq!nuyjE_rf_zxv^6l2RW_Q_M=ShyMzf0UdT{90XQMDZpb479BCp& zC6Za8_f)QZw^nfb9*gp_areHZo3=X%S;l zRUsl4J8gs<*jnc?5d4?!V^L13QZ_qvCVsj1N^-LWW^1gW+~4EW&icb6Z^E?y-bxER zK~}$9laC!HhQ;UO@VyFL{AR-VZr^VaN4+2`aNUj9AbA^(9ZtSHu}@ua$kybt2DTQ_ zhwFUfN|$#zn{I;ZeBVqRoDnSEcIdXwSi4=J*xdn!|L(P84w!+r->-Dz_H=r2-GZXm zkTm@Cx$g%;dPdx~R@NtjpWEEr$FN%AwoR}P^Xv13{SHD6F^HXEPo6#d0!c{a9fG_G zGsrjA|AG^0=6i26*bygL-XK(6_L@w2=)PfP?(IOM4pB$E#LDjXA@UaEY1|m<(ksSj zCt4?3hrv65RuIQRt2>4Kv5adNJJTybjh`&sy2(SUu!l=Smch5;SXf>aBAk~1 zwinZU2i!K7(7PmT~3~}2d|t%X-#6EsGPko z!Gl<;MVD$8#Q8Ql7Ih#QdJwH?68wf{(uZ_RYKv(X8Jy?p?VlJcgOwjMpla-vz3=-1 zH}bvVi9Ia-ay4Cic>JatgDQO0Pz54!5p6ZST4oMT#JN9P( z487vTu=fST`EAa4trDtH1NxH0%XVaRy?sM%U{&}Hejx-hl9Bm^P$u~3fhe!~nmY}2 zSXvEcA*iyyUSHz5Qa|0M_Vw~T2{g1<-`imdSW|*lgodZR9`dA(5nc)1R^iq<=VH1+ z(HBBfI}f5$v-cWCwb^Ks<}DeX)Z@$_&=FB2X>%#(MrzYh9$SdFfjvEdh-HHFb!MP% zGb{Txvs!Jpa=fqV3@YEj2KpmfTUm_ugX zDYH^snOe(Y7GdL_wvvuOE~ForkWwhjBkpi}>C|Fn7)o4iO{9X#etZe9-3QX@&4e!) zKQ{k>2)`-1TFl;_#(|CjgPZrd>r3ovDmv4R1PhFT8+U+iCefMc8*#{T!Y&?4iDD5L zN{!!!6u4%3GAA$SknHD1Wk(yz(VzQbT_?C;_Tmn>vYI3mDw-;oz0s(V9QjmJ#O14A zs$6whY8CZr(pt^twpb^}{Ikw~)+j9O%s&$1>jDf|+GkV8 zcESQ)-DryvZxz-!B{)kxvr5~06_s}v{B|*b;`Sp&1PrUuw%mXL`V|*kZ86%{y@uB0 zvIrAdz#!K`6|`U7l1w^xk*LIa@#j~9dIP*87bSN<+CTX06e6hEH+Svqf2gdq7=VOf z5}t+9toZ%ikA?;1JHT>&WHw~!=vs{Y1W(8X_cOQ6B=%Iz{_{E{`8E-5&yC`i6G;p# zP75P2Oh46Y{{quvA}GF^wcCL@ylV8`c?YKW@)D_aap;~AR?;MTDXlIUwy+r7ncX!4 zE}l)hKTu3(C!3tbW7XuJ`hZWhnOhV}tB$F(UErDTE$&Gn*Mx}>*bXwpAdRav_L-`m z<-pub6$S*Lm{)q$ks2NS12)@X5}k93z15^&CN8#0&Q<5{UrJ8qal4IkmC`k}#MM|T zES^Rc%i0~}+Ulsfc~e-8zi8Sf)JR-hmj1_$GutDom4mU06~Up;iZsJ@bimdQ|f6N@HvJvqVFO_ z=WDtNwTv3|#_fPGJr---6U0hEvxi#-S<@NG65+gwB|4z4Y}*&H@G)<`x0N-Ec9<&> ztcZZ794N;nI=nBH)5n%$xF|@04R5QeG+tQZ4tSG!UzqKT(S}!|n$6=5hy~xe73NhS zJVbLvAI!$_)?TAb-N2&+orL%uUA$^9VGte+mGOWMBuZpMGr|B?Im0I?6Po15DE#AR zpD}hZIILB{J~}i2clFeUtVntuE-vx->LRASH?@Y=p;~Y#8}XapAQ?`qpP9sk_?JSi}3C~CZj@22n+V*I#`*hV+*cjtW~0f z83C*OOI9PkTX4lAZ?|*w&csy|L7&;$$NFq}W~13^A68P|iPstQv4RA*?=w!3JkYdz z4@qVJu(94mV!(e<(a({nz2xc?S#2#J8{kE0#^1fB+rb@~&uGe(u4JFF$`&7wq}p^21IJeZ=a5NRzv)oC{b2?}-V2{LjvDtV$Qmy$E< z^|>@Seqs}J0H5m#FHMb(v0W7{jw!WBx&A`pjrx3z2#1|+)KC{016`W!*_=l9G*-)) z5}N~f_g0CJ8Rw=t|3xj4(W~)6!e*ydW&IwhbtojjvD&dqa}p|Txk}w7aDy57U#pMiux1o z&qW9P<1zS3;L~F4OY1ioO(qr7S4vhXZWC(kk+hRBdABed3eHZ)eV>vaYR-%|3l4gw zGF{{eu8XbY+6;s}X~JxkGDIrpD=bBBe>TW@SJK&<- zkR>!lT&iW#soFUqv4@q52=Jtg}3-#v%Y7I_66bc%6J41svvi< z0JMPp%^XD%IAl{|de@{1v5upzi9~Gr9vt+d%$Mx7+s@~k;#M_nfCUc#p|iM#M*Z6O*lmx&|7-zfJXXEOfwOL{t9^4xMN#2>$7hme zRqw|XQCer_@AhS7sP2G7rCum73nHo$LuAIo+hd=M3>VhJmYN04k8P|ORdzSl$H6;A zS%^Epw+Y%2`IDV#3FXu1MY=c|>ctW1)cG8D53UE#=@A`|Ex>m;<(jS{R5dAH+=nO^^6+NC5FG-&c+Nwna6^@@XT|FU^$ z8qd1MaitA-u~hSqH=5; zn~Z|R(aAY3>s>pDO;}XLz$k!SEPG`&{!mrTFZSr$S!)!buV<~_gYK6DG-T;-@&=xv zeny|gXkN}g@p&|H7;WBwAt2ZATKNTWGKl|7(A-4wNR$QXL>YT_nF1|1qP5 zqD8t*p;}Erp%fjDyZq63|HHsNGF;=_em+YADJKDiaoiwCYzAn<5YezH`9A5~=i*`! zM5LypC!DX|yktmn;&lc8OvWYLvFbfc)~dXye@f(i!-A|)SgD9qxnsP_d1?SwZJ=_} z{^2JGO?MO}$EwZ63|-x-Vry3yTf5=X(%H$HCcfG_bB(FpPjmmjxTNFvTJB+FN$T>< zrxr@nz#N`c!SG#`hxHBpZ1~dMDDK){aQPEdl6VI}w#`bS=y90YCIDNYS|uo1D4cbw zY@6j6RCmT+0sR)c5ENwen(-`yk!dan5tgEpGj`)X87)U-Hw9uIQYAKPJSGSIVhEg_ zkx)Sv-C)L(H;fhS#1L#o(n|Q)Xx>e@eJYZa1u$rs_C+?_2IeYJbSRo(m9!fAM2yKNwbIgN*ZjveD*fc(A(4WN*B|!#Xk?-=O zOkjbCH((4YXjD1!XTdjG6y@SpiHdrkV{B|66(x_UU)(J2&X$Y1+y+A=n{q@dc^V`S zWJuvYkMy70$7BQgSs0Wyh}WVhFihct5JJt3=ymdu(OQl^h&u__fi0ZI9_gpWoEzP0 z-(uI8&vs+CrE@})i3TJ%Fd&}6wT9x&`v)!(EmG4#?u4cEb?qcOPR|V+Skn1P&b^+s zEItSAa|jTMr-tCzI|m_*s~M7foOj>9j#Kdm&I9=CV<7eNL?dz_J#%a_g@iBF;Ne;* z8eS&yQL5$1tqhWy-dCqj-%|`GLl+4JP1MTTuy$e#A0lthJk7n4QzmnR zSR2xcxo=7H5kzK|=ydPAF?*R}r@a|vDoJ+*&84A}a3bytqEkxx*=S~Nfs%M;GngcK>m}ucQ_@_TluComExQ5rMC0%U*qrTHND5($0^I#eY3H0 zsfN%Ht>nd9%(QVz87687-NSfs5plJs{3P!4$|Kkv+0w(1#)ZYiC(Jv*RS(NJPpw|r zn%KK)3nICzQm!xbr?11>i7)0qgipym&al?S<(tCHHiuzhGqsDat;j=CqUefa=#fjZ zz8^XbYx*~i7kiQK*Kr$3^pmr) zp2c{j))y$LVAFz>v@M@?#v~a@pkBFs6^QyD1yc7;Gmq;NtKaV%9O5t@lyr=|a3ar;h z^s3MPFwqx|gsw^v^`nPqteLv4jT27-%4OZt$(J));7d(vE~aW)5Z}_RN)QArZoSv} zomh>)Zqs(GP}~p*1RXu6J~nVICzOF~=1!EqM)m z(|#G%yV22VQVJ~&kES2hJ&=nT>tiY(m;UZ0ZK)MUypci`BW5KB7ft_MXJ>*!6T_Sf z0STCVCab~C*n_ia<7Ozvkb%%9asWA5a;8H47sUO-gm5m}F7AP#X_@{fBsauqP0aO= zwIn%OPRT#wIa#mjgJ8~nWIGMdrhWkvVnLXQdFa`%Q<~vxp){6hJg(hvQ;K8`>@>$P z50HJAOAuNOrB!jyGS!+rE>vOJ4&E^V(R*!udP`^O3&Rjy?MDg81SPxyp{K6{)d|?) zkwi7zj5BGC1Lb~`03`v9Lgo?QyPTorrB4xFG3AJd+&bz{0v}9m5@nWZLkYS+V!*Z- z;%o`Cd0tto>-wPNB1u5-%zCBJ~i!!AxorM%H-hAlQfbzP(K^m%a6{{-Q(iO!3-a`lKf_%5R zGiysw<3f`UCr*_Flq-uKpGWjA5Ya~1^Z^?O@-8$02_TlzaocrS$%Nu>4)?LpfuIRQ z)+BY~K#r-;L?s}08#~k8_gZE3ONBj)<;VItblfu&$w5?-e#81eHl5m5=SzmJj0#2dRiqVzY)vPX*e`2nIt zro&0_cfc2iU}|m**{s+&T%~}Q&D?UJf>Bmf&}-3gY*eEj@UJpWDe5~p#mO!>G2Bi>zC|BJ3q(asjM(2_CIUWCQ#=?jKfp3)*5OVJerN_=mv#>0@Hl#4 zt}rD6fJgnOrLmj%g7uSKS??tl2W#pli~hZxz+9pr7V?ZXp^EoL`e|JTqwdGqeU(bS z(bbeU>S=64wsVCu{iLxV z0uFwdsKaiEtZR;%W9A;ZbS#&q%_zzHy1S}fSF zv*ItEi{5d~Y5Z?2s5+VAeX7KAF2+A)&XSSAr1hO~DF0z{* zoju8IhAarZIQkJnPRy%%+JL9xL~@jWXn@>L^%S9}(&M@YT*T7sU(FDZjxu6iAkL{ImJMYJ$c zpNuO^GEw94Oxgq=d0Q(>hT~b6Yq_PU=$Duz9`F(Ys|Ovdo^Zd-rTh)MDG)Z^9Uzb0 z3`NdXrxE;VbWw+}Sj@%3t`uc5tFfB}T+Wu0Y3j-v{)0L4){uS2P5P?4m|DTy3DWh#GolH#V_4j=&E%~UUj{35m zsb=tQCZb53Fi`NTEblabv?(Zavd?Q$p#6Cn!Aqx?VpDTXY1o0qTtbngWH>v4ykF$c zM&cbmjGWFS5KBWj+N&XEf^oudRAA0i+Nx3#lo_V#!ou?b7U553kNKU-R1MhZ^fB&f zqr^%LmUm~RuhxNft#OR3o>yd=^U(iji-^aOdcrv<;p5w4KlM0QzR1$)O-xsXl?m0Y z%OyQ-6Pa%#dqL*GfWx8Q?TYxkrU-&eQO1OxlaO5O*GcHcxpIbA52;KsfVpG-adeU+ zM!^?ZE-QqVWI<4nD2DN5+uO0)P<`%&uR?cmn5@+xh127E^{lu=NZDEmf}O$_A}UJM z!-{meT#&b5e>D7S%_5@X;^8u}!CpDS%5pO*<$k%#B36eoVbhD3(;D0T0v z;0C`AJUmSh^(P_di}Ajz`$0bqA%zV(*fcW^RU3p|acFz$vJiS^Yi}&vuU2#_4Wynm0)$jP!+gt^h@KvM__v2v}l*NwBy2ju3PBVxG48_ z>XZI{oyvhrW3p%If5%0g?3G!#EA8zDClmfNr6V;e(Ey9g)wLfbxA`cP2@z`-6flw~ z76BriD@uAD^D8PuzbZ~(F~+z_tU?^Vk*4{Jm!o%XoCs#Xh#tRtNgmltrXRexo#n@X z(JaA^s8_U0jG?|pMH(*|f6-bYlGZ5uONWW##rQo)`UZ>!E$ntmA|=jiry3}e){x3X z6^P5oHggldM1Nr{)ISYt`|&p&ps)6ZoYCi@5jrw=S7=b@G8qdGdu+(&B`eV|(%AXW zEke~a!dW|`PJJXU9p%@}bgdKK`-jztqpp=Ea&;C6(t|wHKcb>18%eY?#La}a ziSEW9V;Fz7QCJ63KXclNQBm~;!jE#kg8Xp>%-s87)Rwj%SM3?hHwhP&aGMZ($kSEb z$~C^{yY_o1q>D}HCx5?yJ)Z+FH0HRMMTI0cH?k`mYJkdeeKCAZrjwvYSk#!e+aZD3 z$NZCfX54DWhJ8RQezQ(pu`FDm?uklLMFlU7jNLF-EjOhFlR|%DMyZVR5d#T}vDhiL z`_O8)DZi4^;qU9B-MTtR>}_D1G#n9pya~V(ztPDo(w#%&tknl6N`D8$9YeId4yP9s z7)@1tui}O0Rh|Ij+w0L48SA}I6i#9fzM}z!E=dva&DVyA2a+9I@=PiUkHvMgn$8R< zx?e*Z=`+l^`s&9TQ>uY~UW{4L>p3$NaQ|259DJ(`Uuc#aE{A{LLU6oi-77HF2i;e~ zDhcX{!z48ahAfm0Hk-EX{8!O-JjA+Z2XVVW0Q;N`5i?Fj0%|2eIwd?b*T^KscF;tF zBTbU7CmQyf*zw(COsSQ2y=9NFt2%H>C8?xViC^S0QNvKb>BEOw(dy9}88T+Ei~V7Qv5_TGj!78J7a`YPgJ?T53+Tn|F2a|F_J1GiedyktNY0?X@YIJ{6uV_rN`Hu^cZ9Lk1_~QZN8y^CeF=(Nc($)kPtiQ!6)~wVYpV8~f zdu|@I;nGHA`=gR(?p zP4QsAtvroE^}fLK3i_sX{9(un9BlDBKta_FFM1d1x6r{@q@CTfr}^bB^z{;noM`@0 zFvK@(VB2p$q{{WDiJ(oQosz!;kWF3p*y#3oMl<*f;gcO6oK!VDn)$@q@bGw%=@e*gGN{b|HlF-h@lNdUbxSbLn^0v$%&wtO5ynRe?z!P{5+ z0KXAt2C5$rS>rd0M)k4QJPGE}$N&k*=({}9JiTSS@yvbBKRp^1X}G9m{UvM7u7N2QymRv%o;D1f7Yrn{ zNb}g;e{?9e5oP^1ElIK1T%`)2S3FA#&;#>9_2A4|SvejF9=SlrJ$(Kot5uztBJP|Gap#<~`k{p)=2=-4+dtjr7L;3}yB)e( zVALb}J5_@gf^0@58tN?X-y?gDp3k@gd}dR?w&eNm?X21L$r!JdM9XG~>L68>1)nXb z@iN@`Z^J?0pN69vJ*&S1D4?^yC$I92nh2-o#HH!(rq_}~?~w&)q7`<0sH|#QlX}$m zOCMqu&)fp;A9ijNeZp3_op6qXc<0z8=mFvnMKt!@8iUfx$0bnqIiJ_vh(!#Fp^d2f z1Mk*o?d$%{lV(be+k^y`1_d|-DW1R6Mj~)OE=G3os4c_YT#!VO5j{UNzFL8sz=ED0 z`pEzB>4j;z*3y{Y&aTJ>TeAkwktq8WP?7?>t>B@Fu6R$geb_Z_-cRoSO16CCd7mO! z)wox*?$^C8%CML<{|+s^kl}hYL-vLJ^v)GbY@M7GryCsRgEYh?x@!bG@;?$~gw^bQ zhF;~92b%MqkrjU#hJq6?eHdMQ_`7Antjyh4y~P}Z%olJ0EAunIo&%J4y!RznDP$6n zNCDTdJ?FnZ=DjVsSH;IX>URx^4;^_}Bqy-*a2C-6s-k1dXC)=)N)Df>vr%!+|34hG zpTu_$F1c>Z>@*7XaU?`4{+T!$QjLur!OfCa->8Trfr^#$W-!tKzjb@TblZVcBHvk87*%d=us?`eCY#&oVXRiO>T1^Ln&kT%d*K;Wb+HyT5ewd#*DL1~o1>q9wM+l!Mq_ z$v_Xu6}pT^vgzTuS_N$Fu~sZ2ytp^wWUSPlLC5$@+tay@J?czsU{i2vMI#9}{zs`D z%G+E4&iQOI`Qr+0Y;V~w5|{E?S2|D#{>n7j2^Czv&>;h8TXtW>PES(CY0Tde(Te!I z7K22AMbh98_S_Vw?MtWzCYYd&`40d8Q^f_iJB?;{cGr#$`I0>@5+aBLR2C=MgmF)n zQ6b6?lHnYxXx;p8e8aSov&6CCU9l%?BuJ61wq4H53tV%oP!Kum-9H8`cjxTblP5^e%6jCu4%Ilm$#iO2)6IWkiuS+ZY3=nKE zuqkHa0_LAFXe5Xc%(ZM!-uSGsNBA|q2C*+$Nt1gg6m!e?SjLECA;E4xF1aHp&*t~?qqLV|d$uoZRBHpK+ySOYYYHe1i+ z@cr)MsXNiy9g&sZv(HiJX%asW(a2exK(edF&cwCmeTp70Xse96agz?X=Oe}M$(xJ; zkeYSpNqj{Cpd8Izos2I2rrZ@8$%MdP-aeF7V1Md>L10kE%$4`nk78AY!cZ-b02gGKaVQxy7t9Y&2p%Rx57Clr zNI(`p2nasQ_M*#?)+^vVg2u6$=tJFa^En%3D$uJF zp&=j8&r6jg+6rL#p>M-T=#aE5Uep0fT^fVEca*{5N-WOMq$mLB1bhdy8HRD_J>G$v z4n$W1Fs@hR+%|k1ESVPb&oy2Qsz|DHY(I?IFS;r2x8)V*l~)@z5&Hjn%1C(<6<3+yv_aKJ51C1 z#fqI{KzH={VrVKf2S<1FCToe!?JNFG!ve*_fc(UV;H7SUpn6K#o-Cf@(*0xNasS&e zG1k-99U#|LMpL;^ZLp76esZI$f0-@1Vv4|hy{$Y>p2#B6OAwOWBIIf-*#8Cqh{iaK zh%GI+E*&wKPW`|s-GyiZQx3Lz@_=K|QV~}fOb@t$aTq&>PMdOUTD^k3Ma;uaS}wE# z1^4FLVOFc^DS4a>3}_tDKckgrE>O}4X3Xt`Fgh>TIE^f{yH-F}igwf_e~f8aOROzZ z`gk9T2 z3~UUXAA203_btN0Cb=O1x=u<)&nJV!z<-aC7rl_A>KPL_W<-!&*4Q!U?Zt`_Obm=4 zD@NMe)usCIRBw`{aD`NavS}LMHEXZt*W*}Y8g~Wch{zBal)(z-W3P=PuO`S4b`<;3XL0c zTd1+HZsCXX%URhn^Pe#xib!9Suw{>=RcdGqLWE?kA|eO26J}sX&S}wyJ5oob)lz{ zxxKL=k(Otf>(2sC{cZmotjDj%EOO>eg%`6)lCg!2!1sHPy)^_I3* z>>mal$xZTYczdLc=(M%a9<2#`x`WQ($fPHQgAPkL(r2CD0aOzArH>{tSXkn+AF-}D zENPn8ZQly9h#ETmAfAm>8K1Vf)afE1&Nw?*?#-J*vwRa<)rt~hS8i^WIT9xe5J{-) z#9RZ~9E8!)q-y$1FP{H#-qGYd<##}5uTSAQ=FZ*w{v2s{TAm_RxcvO5-vr6e`xWkK zTEIYw-i=QNV!eh~gF2Ak_J;iq3^^kbwzs-oi${%a_gqaQUJ}b`?NFNA6+ELg0fQ(| zug^;-O-&&|gznm!D$Gp-w90phV+N1To&yAR?~+E7zQg$g*_N%>-5YB&%ijTme={V9 z@H#$6!wV-;@r4x?T zzGbP39jQ-3m_PjupJ8@HYjL0C&v0JC@XL55tP7#EYAWoXWGy>O7>Uh){# zVtYCCB8s550jy{)B3XW^{f0vfc41l=Opsw=GnoEE;YDu* zH*vQJZ*eFQ1TGgbF}ZwWaNK=d4;3_&Nadkby+zCt78EDn){{H8IP%XY-+SXo1&?LFKdtWAC5ke|oAZ|)*krTcj(*8{!gGV84> z{%67kUo~>-lC-7gzFRPq`w+VQp3JN+;P|T=_EX`@Y6?Oa-+3i^hl!+2CAHmg6Ka~9sIR_{JLjcGwM_``Lh?S z$?HN(e`@>a%^IWob8TfX1p=oN`36O7-IB+q{;#4YRpprbjL|*%$SctH`~0q z9qREhL{K$u-XTWaEP3NSvDDy&H>O8M=V=aIzh!fANmLdJx8dMb{<>25@_3`7%jJcY&L$r=MTi6-mN=VqetjFzfv879|QF;7$CE0Job-+x_(7R-1WsnAn=N0dI?J&i1QZrOxsH6(Q4pGb8!Xyw7jUr`p_LGO7W^PC4rOp@f57EtoV`XbdW2aOxm z+pkG^f4V)oFw9=Sk3{}*%uWTDql*_z9cXFM^_PE!k!G;t$6Ta0`^jR!VtH#8EmV*M-N$7DzkE1{laihxc(P1G<|w&5`3C5f*Kv8%s2p#A`}nB z4RRA%+c(~`NcIBF!4FQpdlclsLt;!IDp#@I8gw zSUucAnBDH&tjmpk&^fy73c1l}Ww?Lvk!)9fl|!2J!1Fr*H=(i#g_|cyE#2F0nIm4v z^3_p&hazot53JMs9iSe|9Ou%DOKbA)7D#F_n`_>Uo-_hw;%o~~>`)GJhk=6i@X$Em z>lr-@=Al~{l96V0hFn_UMpg$Qc?fm*Op6*Ep5MPt6tFI+<#C=Lu!G3Ws21*mC$h?I zy!W+z2;iQ9xA$|)ub}9ql663I;!ngM-Y90kb zQGPF#lr4dS`-L@vy3XoA6T2Y@b7aiN!S%0R`kDe2z6?S+xoxq~kN6(x1qq`~&>5Y= zr~Th%jFaD>(JEpd6K2^GQxzz1-+Ns(H+ltGbGoa-X=d#bzt?EBMOO}bx8+0XHpv_j zQ7{N0CrWuQDb+ykQ$|%ZpsH~k)iL%RpzDvWlEwa1$$%?>tLP&6XO#>hArnBavc6m; zKVuSx6c34T>$)$>eEj$R2rb0pMXUk2Ks1M|- zao2nN1u-&dk%#D0ut<#T`%l;^Dvf-Wy?T81s|TBFY9tx)8-(G$nC^kdrzL8nM4P4R z=8v8Y7-)Y6I`fxs(mtYOXs3Dg%075g0G?m&orzw4AmJ`K7r`Ud{l4EQm_s~z#k1tA zrsx8)`i8%gDm+W!vt zRE>6*vz^cEymIJ0CakWOJ8C&vQ+ZABvB_K6&fVLcNp$9ix9aOpa~4Z1=R0Y(s@vUK zl#}#YG7|K8&YlDui})SflRHjBjuvDm@-@G=3hMuh()s{$yW;F6;kYzRNA?ieoVm^< z)Ut=T4P~LIWI6I_pY@3c1%IHBY~XOiE1aM~X=ipfVG7i?;+&;UF{_gXw9*BdT?eIW3!}U8IP{x%9iX;V;`Q zDh=>UoyH6jQY3H%DkNpO;ZJo1iJCn}e}5nx^=YpMUZGOc3 zA*!c>oi#C+9UOgxrZJmy|1`vM|OR@MhT$AD*!EN5D zBPM9aK!;FH9zf!6_^*(Q!!&Iof5!7JdN5mnl<)!x&0Gc z=xrNG?PY9O1N{5lb9LtTB-c<5NXBDP@7!kV%MJGAdE zT`!EB#Z~hTX-~j;AdO!;1d3{%PGevF4k$srx+KC`{mNGRbTAtS-;rTaao3!KYCJk) zjBo0(ni1hxPFkc{B-$nF)$08(B)a+vH~1Tz*SdNxx=pkGA@9>zO;Ea@FOwqn#$}Vi zeBjY^C)4L?SjcndVXq3RC-ruSx!S(Kk39dHth=1L{tnNxU!YJJ_G1z_iQ-V89#g+~ zHX#nb>Ehr)05)i4mnuPr_5w6S)PK|WKNC&YvsI)aa4q(Co@(K{=Fy5gbK zT99gmt>DvQ&b+wp<6o%GK(uS`!d_b_G3C4OMfDbz`#e(H!xN)o@<{b*62FL6Zv&k+ zs_^yifPzSA{>+@=CXx{aCUg5eC^MW;6eD!dH__!wb(Tf?DcAC(l~kYDf!+|}f|(ny z$VX*!q0tgV29_LI9IM8<4AR=pdbR>(i5P$y6!y7J+NW`kSu{SWQ%zh&poA}2;Lgv_ z)ut|lLm?vQH|tG!xJNOYnR!*epdQ_x7j=`2U`C4FYe_Qu3DKF-5mI=DCo;@?A+8|4 zW~4dUxcS)F@668QW96@Ak<%HU%0hO%uCVvJ5D`&0gFsvUB;SO!we7K}#P>YCtAchu zX#E_>=;Wr(ecb`0o+H$<{}VBU**@Z9#iYDK^O=(Pld`f;N0jfg+60G|e|OD3A51^%O-vL7U0Tlm^3#9I!S@HIfKAQdg%w_)T;v{;^pZf>$9PuFrvd>m%FsJ)6U%?GA!eC#|?j_vF#)H9-ItxQBM zjBIr5Z*w$3{C7p5naa6xenu|@m%47b$^dw2CLk&%4k*b@@34#szDVyng9-3hutCu4 zN$b3%ak5MbuS)cnj3;6rzp-`DuZ^AGB0ONzRNjnW&!iJ*JZ3|R3m-QPpl=cwy)sOg zKPo9RL~oZxLS!E&O`}|b+O|vB<(O`~Ul)3ng?=ZW5v{gj6ugp? zf~bQQKlQ6N&}%vqJvrUg>|e5i`4^V<}k&bh>4wBoUo%x(mxa>T0VQPgn9H zs_NWYbV^G}2;Y5`i*=HSmRZecBm)VvY}T!Py+Aa5ej#MNhkz&LW4T7_hqT6-(g`g` zy{Ja-FcfkV86&r9)Hs1n2x}@o=?d;Nd^5MvLv0`Pv3#v$^&`F`ogNdpj-1|3bzvvp iko&q9+{Uu?ljM2B-Js4xQzAS(?fd9VSxfhQ^1lGd$P5wy diff --git a/ingestion/data_ingestion.py b/ingestion/data_ingestion.py index f393fcc..94ba6b5 100644 --- a/ingestion/data_ingestion.py +++ b/ingestion/data_ingestion.py @@ -1,39 +1,74 @@ -""" -Data Ingestion Script for Legal Documents -This script performs the following steps: -1. Loads parameters from params.yaml. -2. Reads PDF files from the raw data directory. -3. Extracts text from each PDF, maintaining paragraph structure. -4. Splits the text into chunks, adding enriched context such as section titles and metadata. -5. Saves the chunks as a JSON file in the processed data directory. - """ +Legal Document Ingestion Pipeline + +This module orchestrates the end-to-end ingestion process for regulatory documents: + +Pipeline Steps: +1. Parse raw legal documents (AI Act HTML, ISO 42001 PDF) into structured JSON +2. Load requirement mapping that links ethical principles to regulatory references +3. Extract relevant sections from parsed documents based on requirement mapping +4. Generate requirement-centric chunks with associated regulatory content +5. Save chunks as JSON for downstream vectorization and RAG retrieval +Key Components: +- AI Act Parser: Extracts articles, paragraphs, points, annexes, and recitals +- ISO Parser: Extracts sections, controls, and implementation guidance +- Requirement Mapper: Links technical requirements to specific regulatory references +- Chunk Generator: Creates contextualized chunks for each compliance requirement + +Output: +- requirement_chunks.json: Structured JSON with {requirement → regulatory chunks} mapping +""" import os import re import json import yaml -from parse_aia import parse_ai_act_file_to_json # Custom parser for AI Act HTML -from parse_iso import parse_iso_file_to_json # Custom parser for ISO PDF +from typing import List, Dict, Any, Tuple +from parse_aia import parse_ai_act_file_to_json +from parse_iso import parse_iso_file_to_json -def load_params(): - with open("params.yaml", "r") as f: + +def load_params() -> Dict[str, Any]: + """Load configuration parameters from params.yaml.""" + with open("params.yaml", "r", encoding="utf-8") as f: return yaml.safe_load(f) -# --- Refactored Ingestion: Requirement-centric Extraction --- -def load_json(path): +def load_json(path: str) -> Any: + """Load and parse a JSON file.""" with open(path, "r", encoding="utf-8") as f: return json.load(f) -def extract_ai_act_section(ref, ai_act_sections): + +def extract_ai_act_section(ref: str, ai_act_sections: List[Dict[str, Any]]) -> str: """ - Extracts the full text for a given AI Act reference (e.g., 'Article 15 Para 1d', 'Annex XI Section 2'). - Handles paragraph/point extraction if specified. + Extract the full text for a given AI Act reference. + + Supports extraction of: + - Articles with optional paragraphs/points (e.g., 'Article 15', 'Article 15 Para 1d') + - Annexes with optional sections/paragraphs (e.g., 'Annex XI Section 2', 'Annex IV Para 2g') + - Recitals (e.g., 'Recital 47') + + Args: + ref: Reference string (e.g., 'Article 15 Para 1d', 'Annex XI Section 2') + ai_act_sections: List of parsed AI Act sections + + Returns: + Extracted text content, or empty string if not found + + Examples: + >>> extract_ai_act_section("Article 15 Para 1", ai_act_sections) + "1. High-risk AI systems shall be designed..." + + >>> extract_ai_act_section("Annex XI Section 2", ai_act_sections) + "Section 2\nDocumentation of risk management..." """ ref = ref.strip() - # Article extraction + + # ------------------------------------------------------------------------- + # Article Extraction (e.g., 'Article 15', 'Article 15 Para 1d') + # ------------------------------------------------------------------------- m = re.match(r"Article (\d+)(?:\s*Para\s*([\d\w/.,]+))?", ref, re.I) if m: art_num = m.group(1) @@ -43,14 +78,14 @@ def extract_ai_act_section(ref, ai_act_sections): content = section.get("content", "") if name == f"art_{art_num}": if para: - # Support for multiple paras/points (e.g. 1/3d) + # Support multiple paragraphs/points separated by / , . (e.g., '1/3d') paras = re.split(r"[ /,\.]+", para) found = [] for p in paras: p = p.strip() if not p: continue - # Paragraph extraction (e.g. 1.) + # Extract paragraph by number (e.g., '1.') para_regex = rf"\n\s*{re.escape(p)}\." matches = list(re.finditer(para_regex, content)) for match in matches: @@ -58,7 +93,7 @@ def extract_ai_act_section(ref, ai_act_sections): next_match = re.search(r"\n\s*[0-9a-zA-Z]+\." , content[start:]) end = start + next_match.start() if next_match else len(content) found.append(content[start:end].strip()) - # Point extraction (e.g. (d)) + # Extract point by letter (e.g., '(d)') point_match = re.search(rf"\({p}\)[^\n]*", content) if point_match: found.append(point_match.group(0).strip()) @@ -68,7 +103,10 @@ def extract_ai_act_section(ref, ai_act_sections): return content else: return content - # Annex extraction (e.g. 'Annex XI Section 2', 'Annex IV Para 2g') + + # ------------------------------------------------------------------------- + # Annex Extraction (e.g., 'Annex XI Section 2', 'Annex IV Para 2g') + # ------------------------------------------------------------------------- annex_match = re.match(r"Annex ([A-Z]+)\s*(Section\s*\d+)?\s*(Para\s*[\d/\w]+)?\s*(\([\w]+\))?", ref, re.I) if annex_match: annex_id = annex_match.group(1) @@ -81,21 +119,21 @@ def extract_ai_act_section(ref, ai_act_sections): annex_field = section.get("annex", "").lower() if f"anx_{annex_id.lower()}" == annex_name or f"annex {annex_id.lower()}" in annex_title or f"annex {annex_id.lower()}" in annex_field: content = section.get("content", "") - # Section extraction for Annex XI (and similar) + # Extract specific section within annex (e.g., 'Section 2' in Annex XI) if section_part: section_num = re.findall(r"\d+", section_part) if section_num: - # Find all Section headers + # Locate all section headers in the annex section_headers = list(re.finditer(r"Section\s*\d+", content, re.I)) - # Find the requested section + # Find and extract the requested section number for idx, header in enumerate(section_headers): header_num = re.findall(r"\d+", header.group()) if header_num and header_num[0] == section_num[0]: start = header.end() - # End at next section or end of content + # Section ends at next section header or end of document end = section_headers[idx+1].start() if idx+1 < len(section_headers) else len(content) section_content = content[start:end].strip() - # Para extraction + # Extract paragraph within the section if para_part: para_nums = re.findall(r"[\d\w]+", para_part) found = [] @@ -111,7 +149,7 @@ def extract_ai_act_section(ref, ai_act_sections): return "\n".join(found) else: return section_content - # Point extraction + # Extract point within the section if point_part: point_letter = re.findall(r"\w+", point_part) if point_letter: @@ -120,9 +158,9 @@ def extract_ai_act_section(ref, ai_act_sections): if point_match: return point_match.group(0).strip() return section_content - # If section not found, fallback to full annex content + # Fallback: return full annex if section not found return content - # Para extraction at annex level + # Extract paragraph at annex level (without section specification) if para_part: para_nums = re.findall(r"[\d\w]+", para_part) found = [] @@ -138,7 +176,7 @@ def extract_ai_act_section(ref, ai_act_sections): return "\n".join(found) else: return content - # Point extraction at annex level + # Extract point at annex level (without section specification) if point_part: point_letter = re.findall(r"\w+", point_part) if point_letter: @@ -147,7 +185,10 @@ def extract_ai_act_section(ref, ai_act_sections): if point_match: return point_match.group(0).strip() return content - # Recital extraction + + # ------------------------------------------------------------------------- + # Recital Extraction (e.g., 'Recital 47') + # ------------------------------------------------------------------------- if ref.lower().startswith("recital"): num = re.findall(r"\d+", ref) if num: @@ -155,30 +196,80 @@ def extract_ai_act_section(ref, ai_act_sections): for section in ai_act_sections: if section.get("name", "").lower() == name: return section.get("content", "") - # Fallback: match in title or name + + # Fallback: Fuzzy match in section title or name for section in ai_act_sections: if ref.lower() in section.get("title", "").lower() or ref.lower() in section.get("name", "").lower(): return section.get("content", "") return "" -def extract_iso_sections(ref, iso_sections): +def extract_iso_sections(ref: str, iso_sections: List[Dict[str, Any]]) -> List[Tuple[str, str, str]]: """ - For ISO: if ref is a section like '9.1', return all sections whose section_id starts with '9.1' (e.g. 9.1.1, 9.1.2, ...) - Otherwise, match by section_id or in section_title. - Returns a list of (section_id, section_title, content). + Extract ISO 42001 sections matching the given reference. + + Extraction Strategy: + - Exact match on section_id (e.g., '9.1' matches only '9.1', not '9.1.1') + - Returns all matching sections with their metadata + + Args: + ref: ISO section reference (e.g., '9.1', 'B.3.2') + iso_sections: List of parsed ISO sections + + Returns: + List of tuples: (section_id, section_title, content) + + Examples: + >>> extract_iso_sections("9.1", iso_sections) + [('9.1', 'Leadership and commitment', 'Top management shall...')] + + >>> extract_iso_sections("B.3.2", iso_sections) + [('B.3.2', 'AI system inventory', control_text, guidance_text)] """ ref = ref.strip() results = [] - # Solo match esatto tra i numeri di section (section_id) + # Exact match on section_id (no fuzzy matching) for section in iso_sections: if section.get("section_id", "").lower() == ref.lower(): results.append((section.get("section_id", ""), section.get("section_title", ""), section.get("content", ""))) return results -def collect_chunks_for_requirement(mapping, ai_act_sections, iso_sections): +def collect_chunks_for_requirement( + mapping: Dict[str, Any], + ai_act_sections: List[Dict[str, Any]], + iso_sections: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: """ - For each requirement in the mapping, collect the relevant AI Act and ISO sections as chunks. - Returns a list of dicts with requirement metadata and associated content chunks. + Generate requirement-centric chunks by linking requirements to their regulatory sources. + + Process: + 1. Iterate through ethical principles and technical requirements in mapping + 2. For each requirement, extract referenced AI Act articles and ISO sections + 3. Structure chunks with requirement metadata and regulatory content + 4. Handle special cases (ISO Annex B controls with implementation guidance) + + Args: + mapping: Requirement mapping linking ethical principles to regulatory references + ai_act_sections: Parsed AI Act sections + iso_sections: Parsed ISO 42001 sections + + Returns: + List of requirement chunks, each containing: + - id: Requirement identifier (e.g., 'HUM_AGENCY_01') + - ethicalPrinciple: Ethical principle category + - requirementName: Technical requirement name + - euAiActArticles: List of AI Act articles with content + - iso42001Reference: List of ISO sections with content/controls + + Example Output: + [ + { + "id": "HUM_AGENCY_01", + "ethicalPrinciple": "Human Agency & Oversight", + "requirementName": "Roles and Responsibilities", + "euAiActArticles": [{"reference": "Article 26", "content": "..."}], + "iso42001Reference": [{"reference": "5.3", "content": "..."}] + } + ] """ requirement_chunks = [] @@ -189,20 +280,24 @@ def collect_chunks_for_requirement(mapping, ai_act_sections, iso_sections): id = req.get("id", "") eu_refs = req.get("eu_ai_act_articles", []) iso_refs = req.get("iso_42001_sections", []) + + # Extract AI Act articles eu_contents = [] for ref in eu_refs: text = extract_ai_act_section(ref, ai_act_sections) eu_contents.append({ "reference": ref, - #"content": f"[SOURCE: AI ACT - {ref}]\n{text.strip()}" "content": text.strip() }) + + # Extract ISO 42001 sections iso_contents = [] for ref in iso_refs: iso_texts = extract_iso_sections(ref, iso_sections) for sid, stitle, scontent in iso_texts: - # Cerca la sezione corrispondente in iso_sections + # Find the full section object for Annex B controls section_obj = next((s for s in iso_sections if s.get("section_id", "") == sid), None) + # Special handling for Annex B controls (split control + guidance) if sid.startswith("B.") and section_obj: iso_contents.append({ "reference": sid, @@ -210,9 +305,9 @@ def collect_chunks_for_requirement(mapping, ai_act_sections, iso_sections): "implementation_guidance": section_obj.get("implementation_guidance", "") }) else: + # Standard sections with title and content iso_contents.append({ "reference": sid, - #"content": f"[SOURCE: ISO 42001 - {sid}] [TITLE: {stitle}]\n{scontent.strip()}" "content": f"[TITLE: {stitle}]\n{scontent.strip()}" }) requirement_chunks.append({ @@ -225,11 +320,26 @@ def collect_chunks_for_requirement(mapping, ai_act_sections, iso_sections): return requirement_chunks def main(): - # Refactored logic: only requirement-centric extraction + """ + Main ingestion pipeline execution. + + Pipeline Stages: + 1. Parse raw documents (AI Act HTML, ISO PDF) → structured JSON + 2. Load requirement mapping and parsed documents + 3. Extract and link regulatory chunks to requirements + 4. Save requirement_chunks.json for vectorization + + Output Files: + - data/processed/ai_act_parsed.json: Structured AI Act sections + - data/processed/iso_parsed.json: Structured ISO 42001 sections + - data/processed/requirement_chunks.json: Requirement-to-regulatory mapping + """ mapping_path = os.path.join("data", "mapping.json") processed_dir = os.path.join("data", "processed") - # Step 1: Parse raw files into structured JSON + # ------------------------------------------------------------------------- + # Stage 1: Parse Raw Legal Documents + # ------------------------------------------------------------------------- parse_ai_act_file_to_json( filepath="data/raw_data/ai_act.html", output_path=os.path.join(processed_dir, "ai_act_parsed.json") @@ -238,7 +348,10 @@ def main(): filepath="data/raw_data/iso.pdf", output_path=os.path.join(processed_dir, "iso_parsed.json") ) - + + # ------------------------------------------------------------------------- + # Stage 2: Load Structured Data + # ------------------------------------------------------------------------- ai_act_json_path = os.path.join(processed_dir, "ai_act_parsed.json") iso_json_path = os.path.join(processed_dir, "iso_parsed.json") requirement_output_path = os.path.join(processed_dir, "requirement_chunks.json") @@ -246,11 +359,23 @@ def main(): mapping = load_json(mapping_path) ai_act_sections = load_json(ai_act_json_path) iso_sections = load_json(iso_json_path) - + + # ------------------------------------------------------------------------- + # Stage 3: Generate Requirement-Centric Chunks + # ------------------------------------------------------------------------- requirement_chunks = collect_chunks_for_requirement(mapping, ai_act_sections, iso_sections) + # ------------------------------------------------------------------------- + # Stage 4: Save Output + # ------------------------------------------------------------------------- with open(requirement_output_path, "w", encoding="utf-8") as f: json.dump(requirement_chunks, f, indent=2, ensure_ascii=False) - print(f"✓ requirement_chunks.json generated with {len(requirement_chunks)} requirement.") -main() + print(f"✓ Ingestion complete: {len(requirement_chunks)} requirements processed") + print(f" → {ai_act_json_path}") + print(f" → {iso_json_path}") + print(f" → {requirement_output_path}") + + +if __name__ == "__main__": + main() diff --git a/ingestion/parse_aia.py b/ingestion/parse_aia.py index fa89cc2..fafe98d 100644 --- a/ingestion/parse_aia.py +++ b/ingestion/parse_aia.py @@ -1,10 +1,33 @@ -""" -This module contains functions to parse the AIA document and extract relevant information.""" - +""" +AI Act HTML Parser + +This module extracts structured sections from the EU AI Act HTML document, converting +the raw legislative text into a machine-readable JSON format. + +Extraction Capabilities: +- **Recitals**: Preamble paragraphs explaining legislative intent (e.g., Recital 1-180) +- **Articles**: Legal provisions organized by article number with optional titles +- **Annexes**: Supplementary requirements and lists (e.g., Annex I-XI) + +HTML Structure Parsing: +- Uses BeautifulSoup to parse HTML tags and classes +- Identifies sections by CSS classes and 'id' attributes +- Extracts hierarchical content (titles, paragraphs, tables) +- Cleans and normalizes text (removes excessive whitespace, deduplications) + +Output Format: +Each extracted section is a dictionary containing: +{ + "name": "art_15", # Section identifier + "type": "article", # Section type: recital|article|annex + "title": "Accuracy, robustness...", # Optional: Section title if present + "content": "High-risk AI systems..." # Main text content +} + +""" import json from pathlib import Path - from typing import Dict, Any, List, Optional diff --git a/ingestion/parse_iso.py b/ingestion/parse_iso.py index 9424e14..731272b 100644 --- a/ingestion/parse_iso.py +++ b/ingestion/parse_iso.py @@ -1,4 +1,28 @@ +""" +ISO 42001:2023 PDF Parser + +This module extracts structured sections from the ISO/IEC 42001:2023 standard PDF, +converting the management system requirements into a machine-readable JSON format. + +Output Format: +Each extracted section is a dictionary containing: +{ + "name": "Clause 6.1.1", + "section_id": "6.1.1", + "section_title": "6.1 Planning - 6.1.1 Actions to address risks", + "section_type": "management_requirement" | "implementation_guidance", + "content": "The organization shall...", # For normative clauses + "control": "AI system inventory shall...", # For Annex B controls + "implementation_guidance": "Consider...", # For Annex B guidance + "metadata": { + "normative": true, + "annex": null | "B" + } +} + +""" + from typing import Optional, List, Dict, Any import fitz import re @@ -99,7 +123,8 @@ def ingest_iso_advanced(pdf_path): }) # --- 2. PARSING ANNEX A (CONTROLS) --- - + # Removed + # --- 3. PARSING ANNEX B (GUIDANCE) --- annex_b_matches = re.finditer( r'\n(?![^\n]*\.{3,})(B\.\d+(?:\.\d+)*)\s+([^\n]+)\n(.*?)(?=\n(?![^\n]*\.{3,})B\.\d|\nAnnex [A-C]|\Z)', diff --git a/params.yaml b/params.yaml index 27f6109..e41bfa4 100644 --- a/params.yaml +++ b/params.yaml @@ -1,30 +1,25 @@ -# Parameters for LegalAIze Project (Audit & Search) +# Parameters for LegalAIze Project ingestion: - chunk_size: 1000 # number of characters per chunk - chunk_overlap: 150 # minimum overlap between chunks to preserve context data_dir: "data/" processed_data_dir: "data/processed" vectorization: - model_name: "all-MiniLM-L6-v2" - collection_name: "legal_docs" - #qdrant_host: "localhost" - #qdrant_port: 6333 - vector_index_path: "data/processed/vector_index" + model_name: "paraphrase-multilingual-MiniLM-L12-v2" # SentenceTransformer model for embeddings + collection_name: "legal_docs" # Qdrant collection name for storing vectors + vector_index_path: "data/processed/vector_index" # Path to save vector index (if using local file-based storage) rag: document_chunk_size: 300 document_chunk_overlap: 50 - pre_rerank_top_k: 3 - + document_chunks_top_k: 3 llm_model: "gpt-4o-mini" # RAG LLM model llm_temperature: 0.0 evaluation: llm_model: "gpt-4o-mini" # RAGAS evaluation LLM model - llm_temperature: 0.0 - mlflow_experiment: "rag_evaluation" + llm_temperature: 0.0 # RAGAS evaluation LLM temperature + mlflow_experiment: "rag_evaluation" # MLflow experiment name for tracking case_selector: [1] # e.g., 2 or [1,3] to evaluate specific cases; null runs all requirement_limit: null # Limit number of requirements to evaluate; null evaluates all ground_truth: @@ -41,11 +36,11 @@ evaluation: document_path: "data/ground_truth/raw_data/Evaluation4/documentation.txt" report_path: "data/ground_truth/raw_data/Evaluation4/report.CSV" - name: "Evaluation5" - document_path: "data/ground_truth/raw_data/Evaluation5/documentation.pdf" - - name: "Gullon" - document_path: "data/ground_truth/raw_data/Gullon/documentation.pdf" - - name: "Prysmian" - document_path: "data/ground_truth/raw_data/Prysmian/documentation.pdf" - - name: "Evaluation8" - document_path: "data/ground_truth/raw_data/Evaluation8/documentation.txt" + document_path: "data/ground_truth/raw_data/Evaluation5/documentation.txt" + #- name: "Gullon" + # document_path: "data/ground_truth/raw_data/Gullon/documentation.pdf" + #- name: "Prysmian" + # document_path: "data/ground_truth/raw_data/Prysmian/documentation.pdf" + #- name: "Evaluation8" + # document_path: "data/ground_truth/raw_data/Evaluation8/documentation.txt" diff --git a/requirements.txt b/requirements.txt index e152343..3c0e7b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,40 +1,43 @@ -# Needed for .github/scripts/check_and_issue.py +# ============================================================================== +# LegalAIze - AI Compliance Auditing System Dependencies +# ============================================================================== + +# GitHub Integration (for CI/CD automation) PyGithub -# For HTML parsing (specific, stable version) + +# HTML Parsing (for legal document ingestion from AI Act HTML) beautifulsoup4==4.12.3 -# Core ML +# Core Machine Learning & Data Processing scikit-learn==1.3.2 pandas==2.1.4 numpy==1.26.2 -# MLflow & Tracking +# Experiment Tracking & Model Registry mlflow==3.9.0 - -# DVC +# Data Version Control (DVC for tracking datasets and model artifacts) dvc==3.66.1 - -# Utils +# Configuration & Data Validation python-dotenv==1.0.0 pydantic==2.12.5 -# RAG and LLM -pymupdf==1.23.8 -langchain-text-splitters==0.3.11 -langchain==0.3.27 -langchain-community==0.3.31 -langchain-openai==0.3.32 -openai==1.99.9 -sentence-transformers==5.2.2 -qdrant-client==1.16.2 - -# Evaluation Metrics -datasets==4.0.0 -ragas==0.4.0 - - +# RAG Pipeline Components +pymupdf==1.23.8 # PDF parsing for ISO 42001 standard +langchain-text-splitters==0.3.11 # Document chunking strategies +langchain==0.3.27 # LLM orchestration framework +langchain-community==0.3.31 # Community integrations +langchain-openai==0.3.32 # OpenAI LLM integration +openai==1.99.9 # OpenAI API client +sentence-transformers==5.2.2 # Embedding models for semantic search +qdrant-client==1.16.2 # Vector database for regulatory chunks + +# RAG Evaluation Framework +datasets==4.0.0 # Dataset handling for RAGAS +ragas==0.4.0 # Faithfulness, Relevancy, and Correctness metrics + +# Supporting Dependencies attrs==23.2.0 dill==0.3.8 packaging==25.0 @@ -47,6 +50,5 @@ transformers==4.41.2 langchain-core==0.3.83 torch==2.9.1 - -# GitHub Integration +# File Pattern Matching pathspec==0.11.1 diff --git a/vectorize_data.py b/vectorize_data.py index 54cce45..07b12fd 100644 --- a/vectorize_data.py +++ b/vectorize_data.py @@ -20,7 +20,6 @@ def load_params(): with open("params.yaml", "r") as f: return yaml.safe_load(f) - def main(): params = load_params() # Load parameters from params.yaml @@ -88,6 +87,7 @@ def wait_for_qdrant(host: str, port: int, timeout: int = 60): if model is None: print(f"⚠ Failed to load embedding model '{vect_params['model_name']}'. Check model name and availability.") return + # Ensure collection exists vector_size = model.get_sentence_embedding_dimension() # Get vector size from model client.recreate_collection( @@ -95,6 +95,7 @@ def wait_for_qdrant(host: str, port: int, timeout: int = 60): vectors_config=VectorParams(size=vector_size, distance=Distance.COSINE), ) + print(f"Generating embeddings for {len(requirements)} requirements...") flat_chunks = [] @@ -141,12 +142,13 @@ def wait_for_qdrant(host: str, port: int, timeout: int = 60): for c in flat_chunks: content = c.get("content", "") if not content: - # If no content, try control + implementation_guidance + # If no content, try control + implementation_guidance (for ISO chunks) control = c.get("control", "") guidance = c.get("implementation_guidance", "") content = f"{control}\n{guidance}".strip() texts.append(content) + # Generate embeddings in batches to avoid memory issues with large datasets embeddings = model.encode(texts, convert_to_numpy=True, show_progress_bar=True) batch_size = vect_params.get('batch_size', 128) @@ -164,11 +166,12 @@ def wait_for_qdrant(host: str, port: int, timeout: int = 60): "implementation_guidance": chunk.get('implementation_guidance'), })) + # Upsert points in batches for i in range(0, len(points), batch_size): batch_points = points[i:i+batch_size] client.upsert(collection_name=collection_name, points=batch_points) - # Save indexing status locally if vector_index_path present + # Save indexing status locally if vector_index_path present (local file-based storage) if vector_index_path: status_path = os.path.join(vector_index_path, "status.json") with open(status_path, "w", encoding="utf-8") as f: From 1d4fdcd7ccd8a8bb739df93aa6bdcf1e0158ce27 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Mon, 2 Mar 2026 21:01:40 +0100 Subject: [PATCH 07/14] Change Chunk size --- params.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/params.yaml b/params.yaml index e41bfa4..6790548 100644 --- a/params.yaml +++ b/params.yaml @@ -10,8 +10,8 @@ vectorization: vector_index_path: "data/processed/vector_index" # Path to save vector index (if using local file-based storage) rag: - document_chunk_size: 300 - document_chunk_overlap: 50 + document_chunk_size: 1500 + document_chunk_overlap: 150 document_chunks_top_k: 3 llm_model: "gpt-4o-mini" # RAG LLM model llm_temperature: 0.0 From 96c72559190d912db3cda26c2032bb3fcf3242e9 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Tue, 3 Mar 2026 10:09:37 +0100 Subject: [PATCH 08/14] Update README --- README.md | 56 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 06a1d13..9d94fba 100644 --- a/README.md +++ b/README.md @@ -165,29 +165,50 @@ DAGSHUB_TOKEN=YOUR_TOKEN ## 5. Artifact Initialization -### A. Quick Demo Mode (uses precomputed artifacts) [RECOMMENDED] -The git repo is already set with the required dvc. configuration (pointing to our DVC repo). -Download all required artifacts: +**Configure DVC with DagsHub Token** + +The DagsHub token is provided with the project documentation. Initialize DVC with your credentials: + +```bash +dvc remote modify origin --local auth basic +dvc remote modify origin --local user davidedm_26 +dvc remote modify origin --local password YOUR_DAGSHUB_TOKEN +``` + +Replace `YOUR_DAGSHUB_TOKEN` with the token provided in the documentation. + +--- + +### A. Quick Demo Mode (uses precomputed artifacts) [RECOMMENDED] + +Pull precomputed artifacts: + ```bash dvc pull ``` -### B. Complete Demo Mode (recomputes all artifacts) +--- + +### B. Complete Demo Mode (recomputes all artifacts) Force full pipeline execution and artifact generation: + ```bash pip install -r requirements.txt dvc pull dvc repro --force ``` + > **Note:** Requirements download and artifacts initialization may take several minutes. + --- -**Collaboration Mode** +### C. Collaboration Mode + +> **Note:** You must have collaboration access to the DagsHub and GitHub repositories. -> **Note:** It is imperative that you have collaboration access to the dagshub and github repositories. +Update DVC remote with your credentials: -Initialize DVC: ```bash dvc remote modify origin --local auth basic dvc remote modify origin --local user YOUR_USERNAME @@ -280,13 +301,10 @@ Before running the evaluation, ensure your environment is fully configured by in pip install -r requirements.txt ``` **2. Artifact Retrieval** -Ensure you have the necessary artifacts. If you have not executed Section 5 yet (or if you are in a fresh environment), run the following command to download the artifacts: +Ensure you have the necessary artifacts. If you have not executed Section 5 yet (or if you are in a fresh environment), choose one of the artifact initialization options to generate or pull the required artifacts. -```bash -dvc pull -``` -**2. Execution** +**3. Execution** Once the environment is ready and artifacts are present, run the evaluation script: ```bash @@ -312,10 +330,16 @@ A: Verify your `OPENAI_API_KEY` is set correctly in the `.env` file and you have ## 12. Contributing / Development -GitHub Actions are configured for CI/CD: -- Linting and testing of Python code -- Docker image build checks -These actions run automatically on pushes to the repository. +GitHub Actions are configured for CI/CD with the following workflows: + +| Workflow | Trigger | Description | +|----------|---------|-------------| +| **Feature Branch Push Checks** | Push to `feat/**` | Quick linting with flake8 and dependency checks to ensure code quality standards in feature branches | +| **Feature → Develop PR Checks** | PR to `develop` | Builds and evaluates the RAG system, logs metrics to MLflow, ensuring feature branches meet performance requirements | +| **Develop → Main PR Checks** | PR to `main` | Comprehensive release gate: linting, full RAG evaluation, and metric threshold validation before production merge | +| **Daily Evaluation & Alert** | Daily schedule (manual trigger) | Runs scheduled RAG evaluation and opens GitHub issues if metrics fall below defined thresholds | + +These workflows ensure code quality, performance consistency, and safe deployments across the development pipeline. --- From 3ceacc984450b006326e7d252d1694f0110cda16 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 11:29:16 +0000 Subject: [PATCH 09/14] feat: Add sub-requirements toggle list to frontend UI Updates the backend API endpoints to properly expose `SubRequirements` by including `SubRequirementReportAPI` in the lightweight response model `RequirementReportAPI`. Modifies the frontend `Audit_Compliance.py` logic to parse the `sub_requirements` and renders them interactively using an `st.expander` toggle list under each top-level requirement to improve granular visibility of regulatory analysis. Co-authored-by: davidedm26 <117094949+davidedm26@users.noreply.github.com> --- backend/app.py | 19 +++++++++++++++---- backend/rag_engine.py | 9 +++++++++ frontend/pages/Audit_Compliance.py | 14 +++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/backend/app.py b/backend/app.py index 7b41b73..c634cb7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10,10 +10,10 @@ try: from . import rag_engine - from .rag_engine import AuditResponse, AuditResponseAPI, RequirementReportAPI + from .rag_engine import AuditResponse, AuditResponseAPI, RequirementReportAPI, SubRequirementReportAPI except ImportError: # Fallback when running as script (python app.py) import rag_engine # type: ignore - from rag_engine import AuditResponse, AuditResponseAPI, RequirementReportAPI # type: ignore + from rag_engine import AuditResponse, AuditResponseAPI, RequirementReportAPI, SubRequirementReportAPI # type: ignore load_dotenv() # Load environment variables from .env file @@ -63,7 +63,7 @@ async def audit(document_text: str = Body(..., embed=True)): # Audit endpoint debug_dump_path=DEBUG_DUMP_PATH, ) - # Convert to lightweight API response (exclude Prompt and SubRequirements) + # Convert to lightweight API response (exclude Prompt, keep SubRequirements) api_requirements = [ RequirementReportAPI( Requirement_ID=req.Requirement_ID, @@ -71,7 +71,18 @@ async def audit(document_text: str = Body(..., embed=True)): # Audit endpoint Requirement_Name=req.Requirement_Name, Score=req.Score, Rationale=req.Rationale, - Auditor_Notes=req.Auditor_Notes + Auditor_Notes=req.Auditor_Notes, + SubRequirements=[ + SubRequirementReportAPI( + Reference=sub.Reference, + Source=sub.Source, + Score=sub.Score, + Rationale=sub.Rationale, + Auditor_Notes=sub.Auditor_Notes, + Contexts=sub.Contexts + ) + for sub in req.SubRequirements + ] ) for req in full_response.requirements ] diff --git a/backend/rag_engine.py b/backend/rag_engine.py index 51fcffd..947d779 100644 --- a/backend/rag_engine.py +++ b/backend/rag_engine.py @@ -64,6 +64,14 @@ class RequirementReport(BaseModel): # Lightweight model for API responses (excludes verbose fields) +class SubRequirementReportAPI(BaseModel): + Reference: str + Source: str + Score: RequirementScore + Rationale: str + Auditor_Notes: str + Contexts: List[str] + class RequirementReportAPI(BaseModel): Requirement_ID: str Requirement_Category: str @@ -71,6 +79,7 @@ class RequirementReportAPI(BaseModel): Score: RequirementScore Rationale: Optional[str] = None Auditor_Notes: str + SubRequirements: List[SubRequirementReportAPI] = [] class AuditResponse(BaseModel): diff --git a/frontend/pages/Audit_Compliance.py b/frontend/pages/Audit_Compliance.py index 602db8e..feaab08 100644 --- a/frontend/pages/Audit_Compliance.py +++ b/frontend/pages/Audit_Compliance.py @@ -398,11 +398,13 @@ def get_reference_details(req_id, requirements_data): r_id = item.get("Requirement_ID", "N/A") r_notes = item.get("Auditor_Notes", "No notes available.") r_rationale = item.get("Rationale", "No rationale provided.") + r_sub_reqs = item.get("SubRequirements", []) score = item.get("Score", 0) processed_reqs.append({ "name": r_name, "id": r_id, "score_display": f"{score}/5", - "progress": score / 5.0, "notes": r_notes, "rationale": r_rationale + "progress": score / 5.0, "notes": r_notes, "rationale": r_rationale, + "sub_requirements": r_sub_reqs }) total += score maxim += 5 @@ -598,6 +600,16 @@ def get_reference_details(req_id, requirements_data): st.caption("Rationale") st.text(req.get("rationale", "No rationale provided.")) + + sub_reqs = req.get("sub_requirements", []) + if sub_reqs: + with st.expander("Show Sub-Requirements Details"): + for sub in sub_reqs: + st.markdown(f"#### {sub.get('Source', 'N/A')} - {sub.get('Reference', 'N/A')}") + st.markdown(f"**Score:** {sub.get('Score', 'N/A')}") + st.markdown(f"**Notes:** {sub.get('Auditor_Notes', 'N/A')}") + st.markdown(f"**Rationale:** {sub.get('Rationale', 'N/A')}") + st.markdown("---") elif analyze_btn and not doc_text: st.warning("Please upload a file or paste text.") From 182cea4132aae1bad27a92fe92f4647925a813cd Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Tue, 3 Mar 2026 16:22:56 +0100 Subject: [PATCH 10/14] Fix bug in mapping.json --- data/ground_truth/.gitignore | 1 + data/ground_truth/raw_data.dvc | 6 +++--- data/mapping.json.dvc | 4 ++-- dvc.lock | 28 +++++++++++++--------------- params.yaml | 3 ++- 5 files changed, 21 insertions(+), 21 deletions(-) create mode 100644 data/ground_truth/.gitignore diff --git a/data/ground_truth/.gitignore b/data/ground_truth/.gitignore new file mode 100644 index 0000000..215fee8 --- /dev/null +++ b/data/ground_truth/.gitignore @@ -0,0 +1 @@ +/raw_data diff --git a/data/ground_truth/raw_data.dvc b/data/ground_truth/raw_data.dvc index ed98817..e9a0bbc 100644 --- a/data/ground_truth/raw_data.dvc +++ b/data/ground_truth/raw_data.dvc @@ -1,6 +1,6 @@ outs: -- md5: fb49202c57f95c814626efc6a93eab87.dir - size: 7522953 - nfiles: 17 +- md5: 3d8cd55161687f0c247380c6bb72d557.dir + size: 963652 + nfiles: 19 hash: md5 path: raw_data diff --git a/data/mapping.json.dvc b/data/mapping.json.dvc index 776fc4f..46f77cf 100644 --- a/data/mapping.json.dvc +++ b/data/mapping.json.dvc @@ -1,5 +1,5 @@ outs: -- md5: ca560cb920ae54ed03e552adfb1fb06f - size: 6054 +- md5: 56ab47b3f3fcd12122836b11ba11d6ec + size: 6089 hash: md5 path: mapping.json diff --git a/dvc.lock b/dvc.lock index bd81369..9d8cfae 100644 --- a/dvc.lock +++ b/dvc.lock @@ -6,8 +6,8 @@ stages: deps: - path: data/mapping.json hash: md5 - md5: ca560cb920ae54ed03e552adfb1fb06f - size: 6054 + md5: 56ab47b3f3fcd12122836b11ba11d6ec + size: 6089 - path: data/raw_data/ hash: md5 md5: f941a48344d6b4813c536e7eeb258564.dir @@ -15,14 +15,12 @@ stages: nfiles: 4 - path: ingestion/ hash: md5 - md5: 42e0b300499a62bc6a968eef020ae108.dir - size: 42672 + md5: 4067ff2edd407658c571800d19f51ccd.dir + size: 51702 nfiles: 5 params: params.yaml: ingestion: - chunk_size: 1000 - chunk_overlap: 150 data_dir: data/ processed_data_dir: data/processed outs: @@ -36,30 +34,30 @@ stages: size: 97874 - path: data/processed/requirement_chunks.json hash: md5 - md5: 16b6d0d3c497bcc8e45c091ea69260af - size: 121766 + md5: 6a20994bdd1f0fb021a23b7a7c9ad2f0 + size: 123713 vectorize: cmd: python vectorize_data.py deps: - path: data/processed/requirement_chunks.json hash: md5 - md5: 16b6d0d3c497bcc8e45c091ea69260af - size: 121766 + md5: 6a20994bdd1f0fb021a23b7a7c9ad2f0 + size: 123713 - path: vectorize_data.py hash: md5 - md5: 1a20e218449ca0e8451247d73de33920 - size: 8127 + md5: 66d924c6a0aa8be66bebdf65ffd62a5e + size: 8124 params: params.yaml: vectorization: - model_name: all-MiniLM-L6-v2 + model_name: paraphrase-multilingual-MiniLM-L12-v2 collection_name: legal_docs vector_index_path: data/processed/vector_index outs: - path: data/processed/vector_index hash: md5 - md5: f7867f184b501f88194ec9f9d0f2c501.dir - size: 406061 + md5: 4738b20ce4b30402ed25c513c89cff6f.dir + size: 410157 nfiles: 4 precompute_rag: cmd: python precompute_rag.py diff --git a/params.yaml b/params.yaml index 6790548..7b5d031 100644 --- a/params.yaml +++ b/params.yaml @@ -20,7 +20,7 @@ evaluation: llm_model: "gpt-4o-mini" # RAGAS evaluation LLM model llm_temperature: 0.0 # RAGAS evaluation LLM temperature mlflow_experiment: "rag_evaluation" # MLflow experiment name for tracking - case_selector: [1] # e.g., 2 or [1,3] to evaluate specific cases; null runs all + case_selector: [5] # e.g., 2 or [1,3] to evaluate specific cases; null runs all requirement_limit: null # Limit number of requirements to evaluate; null evaluates all ground_truth: - name: "Evaluation1" @@ -37,6 +37,7 @@ evaluation: report_path: "data/ground_truth/raw_data/Evaluation4/report.CSV" - name: "Evaluation5" document_path: "data/ground_truth/raw_data/Evaluation5/documentation.txt" + report_path: "data/ground_truth/raw_data/Evaluation5/report_3.CSV" #- name: "Gullon" # document_path: "data/ground_truth/raw_data/Gullon/documentation.pdf" #- name: "Prysmian" From 48f1144adb1e0d4df687b9f46e44d4db9ded53a0 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Tue, 3 Mar 2026 18:48:57 +0100 Subject: [PATCH 11/14] Fix eval bugs --- README.md | 13 +++++-- backend/Dockerfile | 13 ++++--- data/ground_truth/raw_data.dvc | 4 +- dvc.lock | 58 +++++++++++++-------------- evaluate_rag.py | 17 ++++++++ evaluation/case_evaluation.py | 6 +++ evaluation/metrics.py | 71 ++++++++++++++++++++++++++-------- generate_report_from_audits.py | 6 ++- params.yaml | 7 ++-- 9 files changed, 132 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 9d94fba..7d239d3 100644 --- a/README.md +++ b/README.md @@ -248,15 +248,20 @@ You can run the backend and frontend separately without the use of docker: **Backend (FastAPI):** ```bash -cd backend -pip install -r requirements.txt -uvicorn app:app --reload --port 8000 +# Install backend dependencies +pip install -r backend/requirements.txt + +# Run from project root (not from backend directory) +uvicorn backend.app:app --reload --port 8000 ``` **Frontend (Streamlit):** ```bash +# Install frontend dependencies +pip install -r frontend/requirements.txt + +# Run from frontend directory cd frontend -pip install -r requirements.txt streamlit run app.py ``` diff --git a/backend/Dockerfile b/backend/Dockerfile index fd3c6ea..90996c0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,11 +10,12 @@ RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/wh # Copy requirements and install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY requirements.txt ./backend/requirements.txt +RUN pip install --no-cache-dir -r ./backend/requirements.txt -# Copy backend code (FastAPI app + rag engine) -COPY app.py rag_engine.py ./ +# Copy backend code (FastAPI app + rag engine + core modules) +COPY app.py rag_engine.py ./backend/ +COPY core/ ./backend/core/ # Create directory for the model RUN mkdir -p /app/models @@ -22,5 +23,5 @@ RUN mkdir -p /app/models # Expose port for FastAPI EXPOSE 8000 -# Run FastAPI -CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] +# Run FastAPI with full module path +CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/data/ground_truth/raw_data.dvc b/data/ground_truth/raw_data.dvc index e9a0bbc..fbfcf09 100644 --- a/data/ground_truth/raw_data.dvc +++ b/data/ground_truth/raw_data.dvc @@ -1,6 +1,6 @@ outs: -- md5: 3d8cd55161687f0c247380c6bb72d557.dir - size: 963652 +- md5: 82a51254a4e35eb6b6598a77bd0fd2f4.dir + size: 966333 nfiles: 19 hash: md5 path: raw_data diff --git a/dvc.lock b/dvc.lock index 9d8cfae..7a1b61e 100644 --- a/dvc.lock +++ b/dvc.lock @@ -96,25 +96,25 @@ stages: deps: - path: backend/rag_engine.py hash: md5 - md5: e6e210d4a7b4d932be8d7a45375c90f9 - size: 23992 + md5: 480cb762e34216218f34efb16be01929 + size: 24429 - path: data/ground_truth hash: md5 - md5: 576b8991f61e6383ebe3510b661eb7b0.dir - size: 7523078 - nfiles: 19 + md5: 0ea5dd6f2c6fe8a49d3871278294dbfd.dir + size: 966457 + nfiles: 21 - path: data/processed/requirement_chunks.json hash: md5 - md5: 16b6d0d3c497bcc8e45c091ea69260af - size: 121766 + md5: 6a20994bdd1f0fb021a23b7a7c9ad2f0 + size: 123713 - path: evaluate_rag.py hash: md5 - md5: 5c6a97586f709ef4f7143e6f73bcd262 - size: 18453 + md5: 947dbe13a783dfc09acb7c6df5c4a22a + size: 20220 - path: evaluation/ hash: md5 - md5: 808daa05e8c1605c693d03fe0b1a5b6d.dir - size: 66721 + md5: 585f15464e663aada498220c8951c3bf.dir + size: 68955 nfiles: 13 params: params.yaml: @@ -122,8 +122,10 @@ stages: llm_model: gpt-4o-mini llm_temperature: 0.0 mlflow_experiment: rag_evaluation + debug_output_dir: data/debug case_selector: - - 1 + - 3 + - 4 requirement_limit: ground_truth: - name: Evaluation1 @@ -137,37 +139,31 @@ stages: - name: Evaluation3 document_path: data/ground_truth/raw_data/Evaluation3/documentation.txt - report_path: data/ground_truth/raw_data/Evaluation3/report.CSV + report_path: data/ground_truth/raw_data/Evaluation3/report_2.CSV - name: Evaluation4 document_path: data/ground_truth/raw_data/Evaluation4/documentation.txt - report_path: data/ground_truth/raw_data/Evaluation4/report.CSV + report_path: data/ground_truth/raw_data/Evaluation4/report_2.CSV - name: Evaluation5 document_path: - data/ground_truth/raw_data/Evaluation5/documentation.pdf - - name: Gullon - document_path: data/ground_truth/raw_data/Gullon/documentation.pdf - - name: Prysmian - document_path: data/ground_truth/raw_data/Prysmian/documentation.pdf - - name: Evaluation8 - document_path: - data/ground_truth/raw_data/Evaluation8/documentation.txt + data/ground_truth/raw_data/Evaluation5/documentation.txt + report_path: data/ground_truth/raw_data/Evaluation5/report_3.CSV rag: - document_chunk_size: 300 - document_chunk_overlap: 50 - pre_rerank_top_k: 3 + document_chunk_size: 1500 + document_chunk_overlap: 150 + document_chunks_top_k: 3 llm_model: gpt-4o-mini llm_temperature: 0.0 outs: - path: metrics/rag_eval.json hash: md5 - md5: 0d9759fcce7757f983878b6e6265d6e4 - size: 14052 + md5: e43781016c051ab12d72069f026b2392 + size: 28118 - path: metrics/ragas_main_requirements.json hash: md5 - md5: e5d4449c59f5b0615dc2bf83ce253cd4 - size: 30041 + md5: 396c0e70c75a213886251045de314061 + size: 64725 - path: metrics/ragas_sub_requirements.json hash: md5 - md5: 14c986cbdbf933ffd820ef65d60ba9a3 - size: 235415 + md5: 6163431135f90e927db67f0272920e7f + size: 932118 diff --git a/evaluate_rag.py b/evaluate_rag.py index 1417ae6..1633ab0 100644 --- a/evaluate_rag.py +++ b/evaluate_rag.py @@ -86,6 +86,7 @@ def main() -> None: llm_model = eval_params.get("llm_model") llm_temperature = float(eval_params.get("llm_temperature")) metrics_output = eval_params.get("metrics_output", "metrics/rag_eval.json") + debug_output_dir = eval_params.get("debug_output_dir", "data/debug") gt_cases = eval_params.get("ground_truth", []) case_selector = normalize_case_selector(eval_params.get("case_selector")) requirement_limit = eval_params.get("requirement_limit", None) @@ -100,6 +101,11 @@ def main() -> None: metrics_dir = os.path.dirname(metrics_output) if metrics_dir: os.makedirs(metrics_dir, exist_ok=True) + + # Prepare debug output dir for saving audit reports + if debug_output_dir: + os.makedirs(debug_output_dir, exist_ok=True) + print(f"📁 Debug reports will be saved to: {debug_output_dir}") # Aggregate metrics across all cases all_results: List[Dict[str, Any]] = [] @@ -230,6 +236,17 @@ def main() -> None: ) # Evaluate this evaluation case res["name"] = name # Add case name to results all_results.append(res) # Append to all results + + # Save audit report to debug directory + if debug_output_dir and "artifacts" in res: + backend_pred_path = res["artifacts"].get("backend_predictions") + if backend_pred_path and os.path.exists(backend_pred_path): + debug_report_path = os.path.join(debug_output_dir, f"audit_{case_slug}.json") + with open(backend_pred_path, "r", encoding="utf-8") as src: + predictions_data = json.load(src) + with open(debug_report_path, "w", encoding="utf-8") as dst: + json.dump(predictions_data, dst, indent=2, ensure_ascii=False) + print(f" 💾 Saved audit report to: {debug_report_path}") # Collect RAGAS records for logging sub_ragas_records.extend(case_sub_ragas_records) # Sub-requirements diff --git a/evaluation/case_evaluation.py b/evaluation/case_evaluation.py index 313ebce..41f9f09 100644 --- a/evaluation/case_evaluation.py +++ b/evaluation/case_evaluation.py @@ -205,6 +205,12 @@ def extract_ground_truth_note(row: Dict[str, Any]) -> Optional[str]: # MAE only computed if ground truth available mae = compute_mae(gt_scores, pred_scores) if ground_truth else None + # Display MAE result + if ground_truth and mae is not None: + print(f"\n📊 MAE (Mean Absolute Error): {mae:.4f} (based on {len(gt_scores)} score pairs)") + elif ground_truth: + print("\n⚠ MAE could not be computed - no valid score pairs found") + # Check for critical failures if case_faithfulness_score is None: print("⚠ Faithfulness score is None, RAGAS evaluation may have failed.") diff --git a/evaluation/metrics.py b/evaluation/metrics.py index bb7ecd8..db6b67d 100644 --- a/evaluation/metrics.py +++ b/evaluation/metrics.py @@ -19,6 +19,39 @@ AnswerCorrectness = None +# ============================================================================ +# RAGAS 0.4.0 has issues parsing JSON wrapped in markdown code fences (```json...```) +# This patch intercepts json.loads calls to remove markdown formatting +import json as json_module + +def _clean_json_string(s: str) -> str: + """Remove markdown code fences from JSON strings.""" + if not isinstance(s, str): + return s + s = s.strip() + # Remove ```json and ``` markers + if s.startswith("```json"): + s = s[7:] # Remove ```json + elif s.startswith("```"): + s = s[3:] # Remove ``` + if s.endswith("```"): + s = s[:-3] # Remove trailing ``` + return s.strip() + +# Store original json.loads +_original_json_loads = json_module.loads + +def _patched_json_loads(s, *args, **kwargs): + """Patched json.loads that handles markdown code fences.""" + if isinstance(s, str): + s = _clean_json_string(s) + return _original_json_loads(s, *args, **kwargs) + +# Apply monkey-patch to json module +json_module.loads = _patched_json_loads +print("Applied JSON markdown code fence patch for RAGAS compatibility") +# ============================================================================ + RAGAS_FAITHFULNESS_AVAILABLE = ( Dataset is not None @@ -100,16 +133,19 @@ def compute_subrequirements_ragas_metrics(samples: List[Dict[str, Any]]) -> Dict ) df = ragas_result.to_pandas() - # Extract faithfulness score + # Extract faithfulness score (excluding zeros from average) if "faithfulness" in df.columns: - faithfulness = float(df["faithfulness"].mean()) - print(f" Faithfulness mean: {faithfulness:.4f}") + non_zero_values = df["faithfulness"][df["faithfulness"] > 0.001] + faithfulness = float(non_zero_values.mean()) if len(non_zero_values) > 0 else 0.0 + print(f" Faithfulness mean: {faithfulness:.4f} (non-zero count: {len(non_zero_values)}/{len(df)})") elif "nv_response_faithfulness" in df.columns: - faithfulness = float(df["nv_response_faithfulness"].mean()) - print(f" Faithfulness mean (nv): {faithfulness:.4f}") + non_zero_values = df["nv_response_faithfulness"][df["nv_response_faithfulness"] > 0.001] + faithfulness = float(non_zero_values.mean()) if len(non_zero_values) > 0 else 0.0 + print(f" Faithfulness mean (nv): {faithfulness:.4f} (non-zero count: {len(non_zero_values)}/{len(df)})") elif "response_faithfulness" in df.columns: - faithfulness = float(df["response_faithfulness"].mean()) - print(f" Faithfulness mean (response): {faithfulness:.4f}") + non_zero_values = df["response_faithfulness"][df["response_faithfulness"] > 0.001] + faithfulness = float(non_zero_values.mean()) if len(non_zero_values) > 0 else 0.0 + print(f" Faithfulness mean (response): {faithfulness:.4f} (non-zero count: {len(non_zero_values)}/{len(df)})") else: faithfulness = None print(" ⚠️ No faithfulness column found!") @@ -180,12 +216,12 @@ def compute_main_requirement_metrics( except AttributeError: pass # Fallback if prompt customization not available - # Compute on all samples (relevancy doesn't need GT) + # Compute on all samples (relevancy doesn't need GT or contexts) all_samples_dataset = Dataset.from_list([ { "question": sample['question'], "answer": sample["answer"], - "contexts": sample["contexts"], + "contexts": sample.get("contexts", []), "ground_truth": sample.get("ground_truth", ""), } for sample in main_requirement_samples @@ -199,13 +235,14 @@ def compute_main_requirement_metrics( ) relevancy_df = relevancy_result.to_pandas() - # Extract relevancy score with detailed debugging + # Extract relevancy score (excluding zeros from average) if "answer_relevancy" in relevancy_df.columns: relevancy_values = relevancy_df["answer_relevancy"].tolist() - relevancy = float(relevancy_df["answer_relevancy"].mean()) + non_zero_values = relevancy_df["answer_relevancy"][relevancy_df["answer_relevancy"] > 0.001] + relevancy = float(non_zero_values.mean()) if len(non_zero_values) > 0 else 0.0 print(f" AnswerRelevancy values: {relevancy_values}") - print(f" AnswerRelevancy mean: {relevancy:.4f}") - print(f" Non-zero count: {sum(1 for v in relevancy_values if v > 0.001)}/{len(relevancy_values)}") + print(f" AnswerRelevancy mean (non-zero only): {relevancy:.4f}") + print(f" Non-zero count: {len(non_zero_values)}/{len(relevancy_values)}") else: print(" ⚠️ No answer_relevancy column found!") relevancy = None @@ -217,7 +254,7 @@ def compute_main_requirement_metrics( { "question": sample['question'], "answer": sample["answer"], - "contexts": sample["contexts"], + "contexts": sample.get("contexts", []), "ground_truth": sample.get("ground_truth", ""), } for sample in samples_with_gt @@ -232,8 +269,10 @@ def compute_main_requirement_metrics( correctness_df = correctness_result.to_pandas() if "answer_correctness" in correctness_df.columns: - correctness = float(correctness_df["answer_correctness"].mean()) - print(f" AnswerCorrectness mean: {correctness:.4f}") + non_zero_values = correctness_df["answer_correctness"][correctness_df["answer_correctness"] > 0.001] + correctness = float(non_zero_values.mean()) if len(non_zero_values) > 0 else 0.0 + print(f" AnswerCorrectness mean (non-zero only): {correctness:.4f}") + print(f" Non-zero count: {len(non_zero_values)}/{len(correctness_df)}") else: print(" ⚠️ No answer_correctness column found!") else: diff --git a/generate_report_from_audits.py b/generate_report_from_audits.py index b81a953..a4b62ef 100644 --- a/generate_report_from_audits.py +++ b/generate_report_from_audits.py @@ -49,7 +49,11 @@ def generate_report_from_audit(audit_json_path: str, output_csv_path: str) -> No with open(audit_json_path, 'r', encoding='utf-8') as f: audit_data = json.load(f) - requirements = audit_data.get('requirements', []) + # Handle both list format (direct array) and dict format (with 'requirements' key) + if isinstance(audit_data, list): + requirements = audit_data + else: + requirements = audit_data.get('requirements', []) print(f" Found {len(requirements)} requirements") # Prepare CSV rows diff --git a/params.yaml b/params.yaml index 7b5d031..c126c12 100644 --- a/params.yaml +++ b/params.yaml @@ -20,7 +20,8 @@ evaluation: llm_model: "gpt-4o-mini" # RAGAS evaluation LLM model llm_temperature: 0.0 # RAGAS evaluation LLM temperature mlflow_experiment: "rag_evaluation" # MLflow experiment name for tracking - case_selector: [5] # e.g., 2 or [1,3] to evaluate specific cases; null runs all + debug_output_dir: "data/debug" # Directory for saving audit reports during evaluation + case_selector: [3,4] # e.g., 2 or [1,3] to evaluate specific cases; null runs all requirement_limit: null # Limit number of requirements to evaluate; null evaluates all ground_truth: - name: "Evaluation1" @@ -31,10 +32,10 @@ evaluation: report_path: "data/ground_truth/raw_data/Evaluation2/report_2.CSV" - name: "Evaluation3" document_path: "data/ground_truth/raw_data/Evaluation3/documentation.txt" - report_path: "data/ground_truth/raw_data/Evaluation3/report.CSV" + report_path: "data/ground_truth/raw_data/Evaluation3/report_2.CSV" - name: "Evaluation4" document_path: "data/ground_truth/raw_data/Evaluation4/documentation.txt" - report_path: "data/ground_truth/raw_data/Evaluation4/report.CSV" + report_path: "data/ground_truth/raw_data/Evaluation4/report_2.CSV" - name: "Evaluation5" document_path: "data/ground_truth/raw_data/Evaluation5/documentation.txt" report_path: "data/ground_truth/raw_data/Evaluation5/report_3.CSV" From 2a1f04950d315770377d559fce91fbe83718db61 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Tue, 3 Mar 2026 18:50:15 +0100 Subject: [PATCH 12/14] Add N/A management --- backend/core/evaluation.py | 56 ++++++++++++++++++++++++++------------ backend/rag_engine.py | 39 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/backend/core/evaluation.py b/backend/core/evaluation.py index 78b31a0..798e5a3 100644 --- a/backend/core/evaluation.py +++ b/backend/core/evaluation.py @@ -19,23 +19,25 @@ def _get_sub_prompt(self, main_req_name: str, reference: str, source: str, conte REGULATORY CONTEXT: {content} DOCUMENT CHUNKS: -{chr(10).join(relevant_chunks)} +{chr(10).join(relevant_chunks) if relevant_chunks else "[NO RELEVANT DOCUMENT CHUNKS FOUND]"} INSTRUCTIONS: 1. Analyze the chunks carefully. Look for ANY explicit mentions OR implicit evidence that addresses the regulatory context. 2. Be objective. If the chunks provide partial or related evidence, explain how it relates to the requirement rather than just saying "no evidence". 3. Support your reasoning by referencing specific parts of the text (e.g., "The document states that..."). -4. Only score as 0 if the chunks are completely irrelevant to the regulatory context. +4. If NO CHUNKS are provided, you MUST return 'N/A' and explain that evaluation is not applicable due to missing document content. +5. Score 0 if chunks exist but are completely irrelevant or contain no information related to the regulatory requirement. +6. Use 'N/A' only when the evaluation cannot be performed (no chunks). Use 0 when evaluation is performed but finds no evidence. Respond in JSON format: {{ - "rationale": "Detailed explanation of findings referencing the provided text.", - "score": "Integer 0-5 (0=No evidence, 5=Fully compliant) or 'N/A'", + "rationale": "Detailed explanation of findings referencing the provided text. If no chunks provided, state: 'Evaluation not applicable - no document content available for analysis.'", + "score": "Integer 0-5 (0=No evidence found in document, 5=Fully compliant) or 'N/A' (evaluation not applicable)", "auditor_notes": "Concise summary for the final report." }} """ - def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]], computed_score: float) -> str: + def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]], computed_score) -> str: simplified_results = [] for res in sub_results: simplified_results.append({ @@ -45,29 +47,38 @@ def _get_aggregate_prompt(self, sub_results: List[Dict[str, Any]], computed_scor "auditor_notes": res.get("auditor_notes", res.get("answer", "")) # Use concise auditor_notes }) + # Format computed score (handle both numeric and N/A) + score_display = computed_score if isinstance(computed_score, str) else f"{computed_score:.1f}" + return f""" You are a Lead AI Auditor consolidating findings for a main requirement based on several sub-requirement evaluations. SUB-REQUIREMENT FINDINGS: {json.dumps(simplified_results, indent=2, ensure_ascii=False)} -COMPUTED OVERALL SCORE: {computed_score:.1f} -(This score is the arithmetic mean of all sub-requirement scores and has already been calculated.) +COMPUTED OVERALL SCORE: {score_display} +(This score is the arithmetic mean of numeric sub-requirement scores. N/A scores are excluded from the average. If ALL sub-requirements are N/A, the overall score is also N/A.) TASK: Aggregate these findings into a final compliance assessment for the main requirement. INSTRUCTIONS: -1. Write 'auditor_notes' as an EXECUTIVE SUMMARY for management/stakeholders (NO technical legal references): +1. If the computed score is N/A (all sub-requirements are N/A): + - auditor_notes: "Evaluation not applicable: insufficient or improperly formatted documentation was provided for compliance assessment." + - rationale: Explain that all sub-requirements returned N/A due to lack of analyzable content. + +2. Write 'auditor_notes' as an EXECUTIVE SUMMARY for management/stakeholders (NO technical legal references): - Write in paragraph form (NOT a list, NO bullet points, NO semicolons separating items) - - Start with overall compliance status (e.g., "Partially compliant", "Non-compliant", "Fully compliant") + - Start with overall compliance status (e.g., "Partially compliant", "Non-compliant", "Fully compliant", "Evaluation not applicable") - Describe FUNCTIONALLY what was found and what gaps exist - Use business-friendly language: "accuracy measurements", "risk management processes", "oversight mechanisms" - DO NOT mention specific article numbers, paragraph numbers, or section codes - Focus on impact and actionable insights + - Note any N/A sub-requirements as "could not be evaluated due to insufficient documentation" -2. Write 'rationale' as a TECHNICAL ANALYSIS for compliance experts (WITH legal references): +3. Write 'rationale' as a TECHNICAL ANALYSIS for compliance experts (WITH legal references): - Reference specific sub-requirement codes and scores (e.g., "Article 15 Para 1 scored 2", "ISO 42001 section 6.1.1 scored 1") + - For N/A scores, note: "[Reference] received N/A (evaluation not applicable due to insufficient document content)" - Map findings to regulatory requirements precisely - Explain technical compliance implications - Be definitive: instead of "X is missing", say "The documentation does not provide X" @@ -111,18 +122,26 @@ def evaluate_sub_requirement(self, main_req_name: str, sub_req_name: str, source def aggregate_results(self, sub_results: List[Dict[str, Any]]) -> Dict[str, Any]: """ Aggregates multiple sub-requirement results into a final report. - Score is computed as the arithmetic mean of sub-requirement scores. + Score is computed as the arithmetic mean of numeric sub-requirement scores. + Returns N/A if all sub-requirements are N/A (evaluation not applicable). """ # Calculate average score from sub-requirements numeric_scores = [] + na_count = 0 for res in sub_results: score = res.get("score") - try: - numeric_scores.append(float(score)) - except (ValueError, TypeError): - pass # Skip non-numeric scores + if isinstance(score, str) and score.upper() == "N/A": + na_count += 1 + else: + try: + numeric_scores.append(float(score)) + except (ValueError, TypeError): + pass # Skip non-numeric scores - if numeric_scores: + # If all sub-requirements are N/A, return N/A for the main requirement + if na_count == len(sub_results) and na_count > 0: + computed_score = "N/A" + elif numeric_scores: computed_score = sum(numeric_scores) / len(numeric_scores) else: computed_score = 0.0 @@ -146,8 +165,11 @@ def aggregate_results(self, sub_results: List[Dict[str, Any]]) -> Dict[str, Any] else: notes_str = str(notes_val) + # Handle score formatting (keep N/A as string, round numeric scores) + final_score = computed_score if isinstance(computed_score, str) else round(computed_score, 1) + return { - "score": round(computed_score, 1), # Use computed average score + "score": final_score, "auditor_notes": notes_str, "rationale": agg_result.get("rationale", ""), "prompt": agg_prompt diff --git a/backend/rag_engine.py b/backend/rag_engine.py index 947d779..3d286f8 100644 --- a/backend/rag_engine.py +++ b/backend/rag_engine.py @@ -404,7 +404,46 @@ def audit_document( document_chunk_size = rag_params.get("document_chunk_size", 512) document_chunk_overlap = rag_params.get("document_chunk_overlap", 64) + # Validate document input + MIN_DOCUMENT_LENGTH = 50 # Minimum characters for meaningful analysis + if not document_text or len(document_text.strip()) < MIN_DOCUMENT_LENGTH: + print(f"⚠ Warning: Document is empty or too short ({len(document_text.strip())} chars). Returning N/A for all requirements.") + # Return report with all requirements scored N/A (evaluation not applicable) + empty_reports = [] + req_iter = requirement_chunks if requirement_limit is None else requirement_chunks[:requirement_limit] + for req_data in req_iter: + empty_reports.append(RequirementReport( + Requirement_ID=req_data.get("id", ""), + Requirement_Category=req_data.get("ethicalPrinciple", "unknown"), + Requirement_Name=req_data.get("requirementName", "unknown"), + Score="N/A", + Rationale="The provided document is empty or insufficient for compliance evaluation. A minimum of 50 characters is required for meaningful analysis.", + Auditor_Notes="Evaluation not applicable: no meaningful content found in the provided document. Adequate documentation is required to assess compliance.", + Prompt="", + SubRequirements=[] + )) + return AuditResponse(requirements=empty_reports) + doc_chunks = _chunk_document(document_text, chunk_size=document_chunk_size, chunk_overlap=document_chunk_overlap) + + # Additional check after chunking + if not doc_chunks or len(doc_chunks) == 0: + print(f"⚠ Warning: No chunks generated from document. Returning N/A for all requirements.") + empty_reports = [] + req_iter = requirement_chunks if requirement_limit is None else requirement_chunks[:requirement_limit] + for req_data in req_iter: + empty_reports.append(RequirementReport( + Requirement_ID=req_data.get("id", ""), + Requirement_Category=req_data.get("ethicalPrinciple", "unknown"), + Requirement_Name=req_data.get("requirementName", "unknown"), + Score="N/A", + Rationale="The provided document could not be processed into analyzable chunks. Document may be improperly formatted or lack sufficient text content.", + Auditor_Notes="Evaluation not applicable: document content is insufficient or improperly formatted for compliance evaluation.", + Prompt="", + SubRequirements=[] + )) + return AuditResponse(requirements=empty_reports) + doc_embs = _embed_chunks(doc_chunks, embedding_model) From 56cb40a82326efc256271471a8c6080abcf38830 Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Tue, 3 Mar 2026 19:17:40 +0100 Subject: [PATCH 13/14] Update readme --- README.md | 87 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7d239d3..ad7e426 100644 --- a/README.md +++ b/README.md @@ -60,37 +60,66 @@ flowchart TD --- ## Project Structure -(Da aggiornare alla fine) ```text LegalAIze/ -├── backend/ -│ ├── app/ -│ ├── requirements.txt -│ └── ... -├── frontend/ -│ ├── app.py -│ ├── requirements.txt -│ └── ... -├── data/ -│ └── ... -├── models/ -│ └── ... -├── notebooks/ -│ └── ... -├── evaluation/ -│ ├── evaluate_rag.py -│ └── ... -├── ingestion/ -│ └── ... -├── metrics/ -│ └── ... -├── qdrant_init/ -│ └── ... -├── params.yaml -├── dvc.yaml -├── docker-compose.yml -├── requirements.txt -└── .env.example +├── backend/ # FastAPI backend service +│ ├── app.py # FastAPI application entry point +│ ├── rag_engine.py # RAG system core logic +│ ├── core/ # Modular RAG components +│ │ ├── evaluation.py # Compliance evaluation engine +│ │ └── retrieval.py # Document retrieval system +│ ├── Dockerfile # Backend container configuration +│ └── requirements.txt # Backend Python dependencies +│ +├── frontend/ # Streamlit user interface +│ ├── app.py # Main Streamlit application +│ ├── pages/ # Multi-page app structure +│ │ └── Audit_Compliance.py # Compliance audit page +│ ├── Dockerfile # Frontend container configuration +│ └── requirements.txt # Frontend Python dependencies +│ +├── evaluation/ # RAG evaluation framework +│ ├── case_evaluation.py # Single case evaluation logic +│ ├── data_loading.py # Ground truth data loaders +│ ├── metrics.py # RAGAS metrics computation +│ ├── mlflow_utils.py # MLflow logging utilities +│ └── utils.py # Evaluation helper functions +│ +├── ingestion/ # Document ingestion pipeline +│ ├── data_ingestion.py # Main ingestion orchestrator +│ ├── parse_aia.py # EU AI Act HTML parser +│ └── parse_iso.py # ISO 42001 PDF parser +│ +├── data/ # Data artifacts (DVC-tracked) +│ ├── raw_data/ # Original regulatory documents +│ ├── processed/ # Parsed and chunked documents +│ │ ├── ai_act_parsed.json +│ │ ├── iso_parsed.json +│ │ ├── requirement_chunks.json +│ │ └── vector_index/ # Local Qdrant vector store +│ ├── ground_truth/ # Evaluation test cases +│ │ └── raw_data/ # Documentation + ground truth reports +│ ├── debug/ # Debug audit outputs +│ ├── qdrant_storage/ # Qdrant persistent storage (it appears after the docker activation) +│ └── mapping.json # Requirement mapping structure +│ +├── qdrant_init/ # Qdrant initialization service +│ ├── Dockerfile +│ └── transfer_qdrant.py # Vector DB seeding script +│ +├── metrics/ # Evaluation metrics output (DVC) +├── img/ # README images and assets +├── .github/workflows/ # CI/CD pipelines +│ +├── evaluate_rag.py # Main evaluation script +├── vectorize_data.py # Vectorization pipeline script +│ +├── params.yaml # Experiment parameters (DVC) +├── dvc.yaml # DVC pipeline definition +├── docker-compose.yml # Multi-container orchestration +├── requirements.txt # Root Python dependencies +├── .env.example # Environment variables template +└── README.md # This file ``` --- From d372c839df0e3ab78917f8414f9a32ae509087dd Mon Sep 17 00:00:00 2001 From: davidedm26 Date: Wed, 4 Mar 2026 08:47:26 +0100 Subject: [PATCH 14/14] Set mock test parameter to save resources --- params.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/params.yaml b/params.yaml index c126c12..a26e052 100644 --- a/params.yaml +++ b/params.yaml @@ -21,8 +21,8 @@ evaluation: llm_temperature: 0.0 # RAGAS evaluation LLM temperature mlflow_experiment: "rag_evaluation" # MLflow experiment name for tracking debug_output_dir: "data/debug" # Directory for saving audit reports during evaluation - case_selector: [3,4] # e.g., 2 or [1,3] to evaluate specific cases; null runs all - requirement_limit: null # Limit number of requirements to evaluate; null evaluates all + case_selector: [3] # e.g., 2 or [1,3] to evaluate specific cases; null runs all + requirement_limit: 1 # Limit number of requirements to evaluate; null evaluates all ground_truth: - name: "Evaluation1" document_path: "data/ground_truth/raw_data/Evaluation1/documentation.txt"