# agent.py # --- CRITICAL FIX FOR CHROMADB SQLITE VERSION --- import sys try: __import__('pysqlite3') sys.modules['sqlite3'] = sys.modules.pop('pysqlite3') except ImportError: pass import os os.environ["ANONYMIZED_TELEMETRY"] = "False" os.environ["CHROMA_TELEMETRY"] = "false" # ----------------------------------------------- import re import json import time import hashlib from datetime import datetime, timezone from concurrent.futures import ThreadPoolExecutor, as_completed from functools import wraps import requests import dirtyjson import chromadb from chromadb.config import Settings from sentence_transformers import CrossEncoder from langchain_mistralai.chat_models import ChatMistralAI from langchain_core.messages import HumanMessage from markdown_it import MarkdownIt from rank_bm25 import BM25Okapi import numpy as np import markdown import trafilatura from bs4 import BeautifulSoup import spacy from config import ( MISTRAL_MODEL, MAX_RETRIES, DEBUG, MAX_CONCURRENT_CRAWLERS, TOP_N_URLS_TO_PROCESS, CRAWL_CACHE_DIR, SPACY_MODEL, VECTOR_DB_PATH, CROSS_ENCODER_MODEL, RAG_CANDIDATE_POOL_SIZE, RAG_FINAL_EVIDENCE_COUNT, SUPABASE_CONFIGURED, OPENROUTER_API_KEY, PERPLEXITY_SONAR_MODEL, QWEN_EMBEDDING_MODEL, LLAMA_VERIFIER_MODEL, MISTRAL_API_KEY, SEARCH_API_KEY, CSE_ID, SONAR_ANALYTICS_DIR ) from schemas import ( DEFAULT_BLANK_FIELDS, CHOICE_OPTIONS, INFERABLE_FIELDS, BLACKLISTED_DOMAINS ) # Imported the new DB2 fetcher from supabase_client import get_knowledge_cache, set_knowledge_cache, download_from_storage, upload_to_storage, fetch_races_db_fields class AgentInitializationError(Exception): pass def clean_markdown_with_trafilatura(md_text: str) -> str: try: md_text = re.sub(r'!\[.*?\]\(.*?\)', ' ', md_text) html = markdown.markdown(md_text) soup = BeautifulSoup(html, "html.parser") for tag in soup(["img", "nav", "footer", "header", "aside", "script", "style"]): tag.decompose() clean_html = str(soup) extracted = trafilatura.extract(clean_html, include_links=False, include_images=False, include_comments=False, favor_precision=True) if not extracted: return md_text extracted = re.sub(r'\n{3,}', '\n\n', extracted) extracted = re.sub(r'[ \t]+', ' ', extracted) return extracted except Exception as e: print(f" - [WARNING] Trafilatura text cleaning failed: {e}") return md_text def retry(retries=MAX_RETRIES, delay=5): def decorator(f): @wraps(f) def wrapper(*args, **kwargs): for i in range(retries): try: return f(*args, **kwargs) except Exception as e: if i < retries - 1: time.sleep(delay * (2 ** i)) else: print(f" [ERROR] Function '{f.__name__}' failed after {retries} retries."); return None return wrapper return decorator class Field: def __init__(self, value=None, confidence=0.0, sources=None, inferred_by=""): self.value = value self.confidence = confidence self.sources = sources or[] self.inferred_by = inferred_by self.last_updated = datetime.now(timezone.utc).isoformat() def to_dict(self): return { "value": self.value, "confidence": self.confidence, "sources": self.sources, "inferred_by": self.inferred_by, "last_updated": self.last_updated } @classmethod def from_dict(cls, data): return cls(value=data.get('value'), confidence=data.get('confidence', 0.0), sources=data.get('sources',[]), inferred_by=data.get('inferred_by', '')) class OpenRouterEmbedder: def __init__(self, api_key, model_name): self.api_key = api_key self.model_name = model_name self.url = "https://openrouter.ai/api/v1/embeddings" def encode(self, texts): if not self.api_key: raise Exception("OPENROUTER_API_KEY is missing for embeddings.") if isinstance(texts, str): texts = [texts] embeddings =[] headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} for i in range(0, len(texts), 50): batch = texts[i:i+50] data = {"model": self.model_name, "input": batch} resp = requests.post(self.url, headers=headers, json=data, timeout=60) if resp.status_code == 200: data_list = resp.json().get("data",[]) data_list = sorted(data_list, key=lambda x: x["index"]) embeddings.extend([item["embedding"] for item in data_list]) else: raise Exception(f"Embedding API Error: {resp.status_code} - {resp.text}") return np.array(embeddings) class MistralAnalystAgent: def __init__(self, mistral_key: str, search_key: str, cse_id: str, schema: list, enable_fallback: bool = False): if not all([mistral_key, search_key, cse_id]): raise AgentInitializationError("One or more API keys (Mistral, Search) are missing.") self.enable_fallback = enable_fallback self.llm_client = ChatMistralAI(api_key=mistral_key, model=MISTRAL_MODEL, temperature=0.0) self.search_api_key = search_key self.cse_id = cse_id self.schema = schema # Will raise error if missing context and fallback disabled self.field_instructions = self._generate_field_instructions() self.invalid_years =[str(y) for y in range(2015, 2025)] print("[INFO] Initializing ML models and VectorDB...") try: self.nlp = spacy.load(SPACY_MODEL) except (OSError, IOError) as e: raise AgentInitializationError(f"[FATAL] Failed to load spaCy model '{SPACY_MODEL}'. Error: {e}") self.chroma_client = chromadb.Client(Settings(persist_directory=VECTOR_DB_PATH, anonymized_telemetry=False, is_persistent=True, allow_reset=True)) self.embedding_model = OpenRouterEmbedder(OPENROUTER_API_KEY, QWEN_EMBEDDING_MODEL) self.cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL) self.md_parser = MarkdownIt() self.chroma_collection = None self.bm25_index = None self.mission_corpus =[] self.corpus_map = {} self.mission_inference_cache = {} print(" -[SUCCESS] Models initialized.") def shutdown(self): try: self.chroma_client.reset() except: pass def get_legacy_caching_key(self, event_name: str) -> str: base_name = re.sub(r'sprint|standard|olympic|full iron|half iron|70\.3', '', event_name, flags=re.IGNORECASE) return re.sub(r'[^a-z0-9]+', '-', base_name.lower()).strip('-') def get_caching_key(self, event_name: str, url: str = "") -> str: if not event_name: event_name = "unknown_event" base_name = re.sub(r'sprint|standard|olympic|full iron|half iron|70\.3', '', event_name, flags=re.IGNORECASE) slug = re.sub(r'[^a-z0-9]+', '-', base_name.lower()).strip('-') name_hash = hashlib.md5(slug.encode('utf-8')).hexdigest()[:8] url_hash = "" if url: url_hash = "-" + hashlib.md5(url.encode('utf-8')).hexdigest()[:8] safe_slug = slug[:40].strip('-') if not safe_slug: safe_slug = "event" return f"{safe_slug}-{name_hash}{url_hash}" def _generate_field_instructions(self) -> dict: instructions = {} db_fields = fetch_races_db_fields() # ENFORCING FALLBACK LOGIC if not db_fields and not self.enable_fallback: raise AgentInitializationError("Failed to fetch schema fields from Database2 and Fallback is Disabled. Check Database2 settings.") meta_map = {row['field']: row for row in db_fields} if db_fields else {} for key in self.schema: if key in DEFAULT_BLANK_FIELDS: continue meta = meta_map.get(key) if meta: inst = f"Extract '{key}'" if meta.get('display_name'): inst += f" (also referred to as '{meta['display_name']}')." else: inst += "." if meta.get('question_text'): inst += f" Context constraint: {meta['question_text']}." opts = meta.get('data_options') if opts: if isinstance(opts, str): if opts.strip().startswith('[') and opts.strip().endswith(']'): try: opt_list = json.loads(opts) opt_str = ', '.join([str(o) for o in opt_list]) except: opt_str = opts.replace('\n', ', ') else: opt_str = opts.replace('\n', ', ') else: opt_str = str(opts) inst += f" MUST be exactly one of: [{opt_str}]." elif key in CHOICE_OPTIONS: inst += f" MUST be exactly one of: {', '.join(CHOICE_OPTIONS[key])}." dt = meta.get('data_type') df = meta.get('data_format') if dt or df: inst += f" Required format/type: {dt or ''} {df or ''}." instructions[key] = inst.strip() else: # ENFORCING FALLBACK LOGIC if not self.enable_fallback: raise AgentInitializationError(f"Field '{key}' not found in Database2 schema and Fallback is Disabled.") if key in CHOICE_OPTIONS: instructions[key] = f"Extract '{key}'. MUST be one of: {', '.join(CHOICE_OPTIONS[key])}." else: instructions[key] = f"Extract '{key}'." return instructions def _normalize_key(self, key_str: str) -> str: return re.sub(r'[^a-z0-9]', '', str(key_str).lower()) def _clean_citations(self, text: str) -> str: if not isinstance(text, str): return text return re.sub(r'\[\d+\]', '', text).strip() def _clean_dirtyjson(self, obj): if isinstance(obj, dict): return {self._clean_citations(k): self._clean_dirtyjson(v) for k, v in obj.items()} elif isinstance(obj, list): return[self._clean_dirtyjson(v) for v in obj] elif isinstance(obj, str): return self._clean_citations(obj) else: return obj def _parse_json_response(self, response_text: str): if not response_text: return None clean_text = self._clean_citations(response_text) match = re.search(r'\{.*\}|\[.*\]', clean_text, re.DOTALL) if not match: return None raw = match.group(0) try: return self._clean_dirtyjson(json.loads(raw)) except json.JSONDecodeError: try: parsed = dirtyjson.loads(raw) return self._clean_dirtyjson(parsed) except Exception: return None @retry() def _call_llm(self, prompt: str) -> str: return self.llm_client.invoke([HumanMessage(content=prompt)]).content @retry() def _call_openrouter_gap_filler(self, prompt: str) -> str: if not OPENROUTER_API_KEY: return None headers = {"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json"} data = { "model": PERPLEXITY_SONAR_MODEL, "messages":[{"role": "user", "content": prompt}], "temperature": 0.1, "max_tokens": 2000 } try: resp = requests.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=data, timeout=60) if resp.status_code == 200: return resp.json()['choices'][0]['message']['content'] else: print(f" - [ERROR] Perplexity API Error: {resp.status_code} - {resp.text}") return None except Exception as e: print(f" -[ERROR] Perplexity connection failed: {e}") return None @retry() def _call_openrouter_llama(self, prompt: str) -> str: if not OPENROUTER_API_KEY: return None headers = {"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json"} data = { "model": LLAMA_VERIFIER_MODEL, "messages":[{"role": "user", "content": prompt}], "temperature": 0.0, "max_tokens": 3000 } try: resp = requests.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=data, timeout=60) if resp.status_code == 200: return resp.json()['choices'][0]['message']['content'] else: print(f" -[ERROR] Llama API Error: {resp.status_code} - {resp.text}") return None except Exception as e: print(f" - [ERROR] Llama connection failed: {e}") return None def _step_1a_initial_search(self, race_info: dict) -> list: event_name = race_info.get("Festival") print(f"\n[STEP 1A] Performing initial search for '{event_name}'") query = f'{event_name} 2025 OR 2026' try: url = "https://www.googleapis.com/customsearch/v1" params = {"key": self.search_api_key, "cx": self.cse_id, "q": query, "num": 10} response = requests.get(url, params=params) response.raise_for_status() raw_results =[{"title": i.get("title"), "link": i.get("link"), "snippet": i.get("snippet")} for i in response.json().get("items", [])] clean_results =[r for r in raw_results if not any(d in r['link'].lower() for d in BLACKLISTED_DOMAINS)] return clean_results except requests.HTTPError as e: return[] def _step_1b_validate_and_select_urls(self, event_name: str, search_results: list) -> list: print("[STEP 1B] Validating search results with LLM...") if not search_results: return[] prompt = f"Identify the most relevant websites for '{event_name}'. Select the single best 'primary_url' (official page) and up to three 'secondary_urls'.\n\nSearch Results:\n{json.dumps(search_results, indent=2)}\n\nResponse MUST be JSON: {{'primary_url': '...', 'secondary_urls': [...]}}" response_text = self._call_llm(prompt) parsed = self._parse_json_response(response_text) if parsed and isinstance(parsed, dict): primary = parsed.get("primary_url") secondaries = parsed.get("secondary_urls",[]) final_urls = list(dict.fromkeys([u for u in ([primary] + secondaries if primary else secondaries) if u])) return final_urls[:TOP_N_URLS_TO_PROCESS] if response_text: salvaged_urls = re.findall(r'https?://[^\s"\'\)\],]+', response_text) if salvaged_urls: return list(dict.fromkeys(salvaged_urls))[:TOP_N_URLS_TO_PROCESS] return [r['link'] for r in search_results[:TOP_N_URLS_TO_PROCESS] if r.get('link')] @retry(retries=2, delay=10) def _get_content_from_url(self, url: str, is_web_research: bool) -> str | None: if not url.strip(): return None url_hash = hashlib.md5((url + str(is_web_research)).encode()).hexdigest() storage_file_path = f"{url_hash}.md" if SUPABASE_CONFIGURED: content = download_from_storage(storage_file_path) if content: return content local_cache_path = os.path.join(CRAWL_CACHE_DIR, f"{url_hash}.md") if os.path.exists(local_cache_path): with open(local_cache_path, 'r', encoding='utf-8') as f: return f.read() print(f" - No cache found. Crawling: {url}") try: api_url = f"https://r.jina.ai/{url}" response = requests.get(api_url, timeout=60) if response.status_code == 200 and response.text: content = response.text if is_web_research: print(" - Applying Trafilatura semantic extraction...") content = clean_markdown_with_trafilatura(content) with open(local_cache_path, 'w', encoding='utf-8') as f: f.write(content) if SUPABASE_CONFIGURED: upload_to_storage(storage_file_path, content) return content return None except requests.RequestException: return None def _chunk_and_index_text(self, text: str, url: str, event_id_str: str): if not text: return from langchain_text_splitters import RecursiveCharacterTextSplitter chunks = RecursiveCharacterTextSplitter(chunk_size=768, chunk_overlap=100).split_text(text) if not chunks: return chunk_ids =[f"{event_id_str}_{hashlib.md5(chunk.encode()).hexdigest()}" for chunk in chunks] unique_chunk_ids = list(set(chunk_ids)) existing_chunks = self.chroma_collection.get(ids=unique_chunk_ids) existing_ids = set(existing_chunks['ids']) new_chunks_to_add =[] start_idx = len(self.mission_corpus) for i, chunk_id in enumerate(chunk_ids): self.mission_corpus.append(chunks[i]) self.corpus_map[start_idx + i] = {'id': chunk_id, 'snippet': chunks[i]} if chunk_id not in existing_ids: new_chunks_to_add.append({'id': chunk_id, 'chunk': chunks[i]}) existing_ids.add(chunk_id) if new_chunks_to_add: print(f" - Indexing {len(new_chunks_to_add)} new passages from {url}") new_ids = [item['id'] for item in new_chunks_to_add] new_documents =[item['chunk'] for item in new_chunks_to_add] try: new_embeddings = self.embedding_model.encode(new_documents).tolist() new_metadatas =[{"source_url": url, "event_id": event_id_str} for _ in new_ids] if new_ids: self.chroma_collection.add(ids=new_ids, embeddings=new_embeddings, documents=new_documents, metadatas=new_metadatas) except Exception as e: print(f" -[ERROR] Failed to embed documents: {e}") def _build_bm25_index(self): if self.mission_corpus: tokenized_corpus =[doc.lower().split() for doc in self.mission_corpus] self.bm25_index = BM25Okapi(tokenized_corpus) def _retrieve_and_fuse_evidence(self, query: str, top_k: int) -> list[dict]: collection_count = self.chroma_collection.count() if collection_count == 0: return[] vector_hits = {} try: query_embedding = self.embedding_model.encode([query]).tolist() n_results = min(top_k * 2, collection_count) chroma_results = self.chroma_collection.query(query_embeddings=query_embedding, n_results=n_results) if chroma_results['ids']: for rank, (doc_id, doc) in enumerate(zip(chroma_results['ids'][0], chroma_results['documents'][0])): vector_hits[doc_id] = {'snippet': doc, 'rank': rank} except Exception: pass keyword_hits = {} if self.bm25_index: tokenized_query = query.lower().split() doc_scores = self.bm25_index.get_scores(tokenized_query) top_indices = sorted(range(len(doc_scores)), key=lambda i: doc_scores[i], reverse=True)[:top_k*2] for rank, idx in enumerate(top_indices): if idx in self.corpus_map: item = self.corpus_map[idx] keyword_hits[item['id']] = {'snippet': item['snippet'], 'rank': rank} fused_scores = {} k = 60 for doc_id, hit in vector_hits.items(): if doc_id not in fused_scores: fused_scores[doc_id] = 0 fused_scores[doc_id] += 1 / (k + hit['rank'] + 1) for doc_id, hit in keyword_hits.items(): if doc_id not in fused_scores: fused_scores[doc_id] = 0 fused_scores[doc_id] += 1 / (k + hit['rank'] + 1) sorted_ids = sorted(fused_scores.keys(), key=lambda x: fused_scores[x], reverse=True)[:top_k] final_results =[] for doc_id in sorted_ids: if doc_id in vector_hits: final_results.append({'id': doc_id, 'snippet': vector_hits[doc_id]['snippet']}) elif doc_id in keyword_hits: final_results.append({'id': doc_id, 'snippet': keyword_hits[doc_id]['snippet']}) return final_results def _rerank_evidence_with_cross_encoder(self, query: str, evidence: list[dict]) -> list[dict]: if not evidence: return [] pairs =[(query, item['snippet']) for item in evidence] scores = self.cross_encoder.predict(pairs) for i, item in enumerate(evidence): item['rerank_score'] = float(scores[i]) return sorted(evidence, key=lambda x: x['rerank_score'], reverse=True) def _perform_holistic_extraction(self, knowledge_base: dict, event_name: str, variant_name: str): print(f" - Pass 1: Holistic RAG extraction for '{variant_name}'...") all_fields_query = f"Extract all available information for the event '{event_name}', specifically for the '{variant_name}' category." candidate_evidence = self._retrieve_and_fuse_evidence(all_fields_query, top_k=RAG_CANDIDATE_POOL_SIZE) reranked_evidence = self._rerank_evidence_with_cross_encoder(all_fields_query, candidate_evidence) final_evidence = reranked_evidence[:RAG_FINAL_EVIDENCE_COUNT + 5] if not final_evidence: return knowledge_base evidence_prompt = "\n".join([f"Evidence Snippet:\n---\n{e['snippet']}\n---" for e in final_evidence]) fields_to_extract =[f"- {key}: {desc}" for key, desc in self.field_instructions.items()] fields_prompt = "\n".join(fields_to_extract) prompt = f"""You are a strict data extraction assistant. Based on the evidence below, extract all specified fields for '{variant_name}' of '{event_name}'. CRITICAL RULES: - Extract ONLY verifiable information directly from the provided source. - DO NOT infer, assume, estimate, or create missing values. - If a value is not explicitly present, omit the key or set it to null. - **IMPORTANT**: For prices, distances, and cutoffs, ensure they match the EXACT variant '{variant_name}'. If the evidence shows data for a DIFFERENT distance, return null. - Return ONLY flat strings. NO nested JSON/dictionaries inside values. ## Evidence {evidence_prompt} ## Task Extract these fields into a JSON object: {fields_prompt} ## Response JSON object only.""" response_text = self._call_llm(prompt) extracted_data = self._parse_json_response(response_text) if extracted_data and isinstance(extracted_data, dict): norm_schema_keys = {self._normalize_key(k): k for k in self.schema} filled = 0 confidence = 0.85 for key, value in extracted_data.items(): norm_key = norm_schema_keys.get(self._normalize_key(key)) if norm_key and value and str(value).lower() not in["null", "none", "unknown", ""]: if not knowledge_base[variant_name].get(norm_key, Field()).value: val_str = json.dumps(value) if isinstance(value, (dict, list)) else str(value) knowledge_base[variant_name][norm_key] = Field(value=val_str, confidence=confidence, inferred_by="rag_holistic", sources=final_evidence) filled += 1 print(f" - [SUCCESS] Holistic extracted {filled} fields.") return knowledge_base def _perform_targeted_recovery(self, knowledge_base: dict, event_name: str, variant_name: str): print(f" - Pass 2: Targeted Recovery for missing fields...") missing_fields =[k for k in self.schema if k not in DEFAULT_BLANK_FIELDS and not knowledge_base[variant_name].get(k, Field()).value] if not missing_fields: return knowledge_base for field in missing_fields: query = f"What is the {field} for {variant_name} in {event_name}?" evidence = self._retrieve_and_fuse_evidence(query, top_k=10) reranked = self._rerank_evidence_with_cross_encoder(query, evidence)[:3] if not reranked: continue evidence_text = "\n".join([e['snippet'] for e in reranked]) instruction = self.field_instructions.get(field, f"Extract '{field}'.") prompt = f"""Based on the evidence, find the exact value for '{field}' for the race '{variant_name}'. {instruction} CRITICAL RULES: - Extract ONLY verifiable information directly from the provided source. - Ensure the value applies EXACTLY to the '{variant_name}'. If the evidence is for a different distance/variant, return null. - Return flat strings only. No dicts. Evidence: {evidence_text} Return a JSON object: {{"answer": "value"}} or {{"answer": null}} if not found.""" response = self._call_llm(prompt) parsed = self._parse_json_response(response) if parsed and isinstance(parsed, dict): ans = parsed.get('answer') if ans and str(ans).lower() not in['null', 'none', 'unknown', '']: val_str = json.dumps(ans) if isinstance(ans, (dict, list)) else str(ans) knowledge_base[variant_name][field] = Field(value=val_str, confidence=0.9, inferred_by="rag_targeted", sources=reranked) return knowledge_base def _perform_batched_gap_fill(self, knowledge_base: dict, event_name: str): if not OPENROUTER_API_KEY: return knowledge_base print(f" - Pass 3: Batched Gap Filling ({PERPLEXITY_SONAR_MODEL})...") missing_data_map = {} for variant, fields in knowledge_base.items(): missing_fields =[k for k in self.schema if k not in DEFAULT_BLANK_FIELDS and not fields.get(k, Field()).value] if missing_fields: missing_data_map[variant] = missing_fields if not missing_data_map: return knowledge_base records_str = "" all_missing_fields_set = set() for i, (variant, m_fields) in enumerate(missing_data_map.items()): field_list_str = "\n - ".join(m_fields) records_str += f"\n{i+1}. Record ID: \"{variant}\"\n Missing fields:\n - {field_list_str}\n" for f in m_fields: all_missing_fields_set.add(f) fields_to_extract =[f"- {key}: {self.field_instructions.get(key, 'Extract exact value')}" for key in all_missing_fields_set] field_specific_instructions = "\n".join(fields_to_extract) prompt = f"""Search the live web to fill the following missing fields for the athletic event '{event_name}'. Records: {records_str} Search Methodology: - Use your ability to search the web to find official, accurate data for this specific event. - Do not repeat or re-fetch fields not listed. CRITICAL Extraction Rules: - DIFFERENT VARIANTS HAVE DIFFERENT PRICES AND CUTOFFS. You MUST extract the specific registrationCost and Cutoff time for EACH individual variant. Do not blindly copy one variant's fee to another. - Extract ONLY verifiable information found online. - DO NOT infer, assume, estimate, or create missing values. - If specific information is unavailable on the web, return: NULL - Return ONLY flat strings. NO nested objects! Field Definitions (Strictly follow these definitions): {field_specific_instructions} Output Format: Return strict JSON matching exactly this schema: {{ "": {{ "": "" }} }}""" response_text = self._call_openrouter_gap_filler(prompt) if response_text: os.makedirs(SONAR_ANALYTICS_DIR, exist_ok=True) clean_name = re.sub(r'[^a-zA-Z0-9]+', '_', event_name[:20]).strip('_') analytics_file = os.path.join(SONAR_ANALYTICS_DIR, f"sonar_gap_{clean_name}_{int(time.time())}.json") with open(analytics_file, 'w', encoding='utf-8') as f: f.write(response_text) filled_data = self._parse_json_response(response_text) if filled_data and isinstance(filled_data, dict): filled_count = 0 norm_kb_keys = {self._normalize_key(k): k for k in knowledge_base.keys()} norm_schema_keys = {self._normalize_key(k): k for k in self.schema} for variant, fields in filled_data.items(): norm_var = self._normalize_key(variant) actual_var = norm_kb_keys.get(norm_var) if actual_var and isinstance(fields, dict): for key, value in fields.items(): norm_key = norm_schema_keys.get(self._normalize_key(key)) if norm_key and value and str(value).lower() not in["null", "none", "unknown", ""]: val_str = json.dumps(value) if isinstance(value, (dict, list)) else str(value) knowledge_base[actual_var][norm_key] = Field(value=val_str, confidence=0.95, inferred_by="sonar_gap_fill") filled_count += 1 print(f" - [SUCCESS] Perplexity Sonar filled {filled_count} missing gaps.") return knowledge_base def _verify_hallucinations(self, knowledge_base: dict): if not OPENROUTER_API_KEY: return knowledge_base print(f" - Pass 4: Hallucination Check ({LLAMA_VERIFIER_MODEL})...") kb_json = {} for variant, fields in knowledge_base.items(): kb_json[variant] = {} for k, f in fields.items(): if f.value and k not in DEFAULT_BLANK_FIELDS: kb_json[variant][k] = f.value if not kb_json: return knowledge_base prompt = f"""You are a strict Data Integrity Verifier. Review the following extracted event data for hallucinations, logical impossibilities, or invalid formats. Data: {json.dumps(kb_json, indent=2)} Rules for Rejection (Set to NULL if violated): - Dates must be logically possible. - Prices must be realistic. - Distances must match reality (e.g., ultras can be 50k, 75k, 100k, 100M). Do not reject standard ultra distances. - Text fields must not contain conversational filler (e.g., "The price is $50"). Return ONLY a JSON object with the exact same structure. Replace any hallucinated or invalid values with NULL. Do not add any extra text or markdown outside the JSON. """ response = self._call_openrouter_llama(prompt) if response: verified_data = self._parse_json_response(response) if verified_data and isinstance(verified_data, dict): rejected_count = 0 norm_kb_keys = {self._normalize_key(k): k for k in knowledge_base.keys()} norm_schema_keys = {self._normalize_key(k): k for k in self.schema} for variant, fields in verified_data.items(): actual_var = norm_kb_keys.get(self._normalize_key(variant)) if actual_var and isinstance(fields, dict): for key, val in fields.items(): actual_key = norm_schema_keys.get(self._normalize_key(key)) if actual_key and (val is None or str(val).lower() == "null"): knowledge_base[actual_var][actual_key] = Field() rejected_count += 1 if rejected_count > 0: print(f" - [WARNING] Llama rejected {rejected_count} fields as hallucinations.") else: print(f" -[SUCCESS] Llama verified data integrity.") return knowledge_base def _discover_and_validate_variants_from_content(self, content: str, knowledge_base: dict): print(" - Discovering all race variants from content...") prompt = f"""You are a strict data extractor. Identify the specific, official race categories or distances explicitly available for registration in the text provided. CRITICAL RULES: 1. Extract ONLY variants explicitly mentioned in the text. 2. DO NOT invent, assume, or infer distances (e.g., do not add "Half Marathon" if only "50K" is listed). 3. DO NOT list general distances, only those meant for this specific event. Return ONLY a JSON list of strings. If no variants are found, return an empty list[]. Text: {content[:15000]}...""" response_text = self._call_llm(prompt) variants = self._parse_json_response(response_text) if isinstance(variants, list): cleaned_variants =[v for v in variants if isinstance(v, str) and len(v) < 100] if cleaned_variants: print(f" - [SUCCESS] Found variants: {', '.join(cleaned_variants)}") for variant_name in cleaned_variants: if variant_name not in knowledge_base: knowledge_base[variant_name] = {field: Field() for field in self.schema} return print(" - [WARNING] Variant discovery failed. Using default.") def _inject_pre_filled_data(self, knowledge_base: dict, pre_filled_data: dict, pre_filled_confidence: float = 0.3): if not pre_filled_data: return print(f" - Injecting pre-filled data (confidence={pre_filled_confidence})...") for variant_name in knowledge_base.keys(): for key, value in pre_filled_data.items(): if key in self.schema and value: if knowledge_base[variant_name].get(key, Field()).confidence < pre_filled_confidence: val_str = json.dumps(value) if isinstance(value, (dict, list)) else str(value) knowledge_base[variant_name][key] = Field(value=val_str, confidence=pre_filled_confidence, inferred_by="pre_processed_data") def _run_inferential_filling(self, knowledge_base: dict): print("\n[INFERENCE] Running final analysis to infer missing data...") for variant, data in knowledge_base.items(): city = data.get('city', Field()).value country = data.get('country', Field()).value if city and not country: resp = self._call_llm(f"What country is {city} in? Return ONLY the country name.") if resp: knowledge_base[variant]['country'] = Field(value=resp.strip(), confidence=0.8, inferred_by="inference") return knowledge_base def determine_event_type(self, url: str) -> str | None: print(f" - Determining event type for URL: {url}") content = self._get_content_from_url(url, is_web_research=False) if not content: return None valid_types = ", ".join(CHOICE_OPTIONS["type"]) prompt = f"Analyze the text and determine the athletic event type. Answer MUST be one of: {valid_types}.\nText: {content[:2000]}" response = self._call_llm(prompt) if not response: return None for et in CHOICE_OPTIONS["type"]: if et.lower() in response.lower(): print(f" - [SUCCESS] Determined type: {et}") return et return None def _check_supabase_for_cache(self, event_key: str, legacy_key: str = None) -> dict | None: if not SUPABASE_CONFIGURED: return None data = get_knowledge_cache(event_key) if data: print(f"[SUCCESS] Found cache for '{event_key}' in Supabase.") return data if legacy_key and legacy_key != event_key: print(f" - Primary key missing. Checking legacy key: '{legacy_key}'...") data = get_knowledge_cache(legacy_key) if data: print(f"[SUCCESS] Found legacy cache for '{legacy_key}' in Supabase.") return data return None def run(self, race_info: dict) -> dict | None: """Called by Web Research Agent.""" event_name = race_info.get("Festival") event_key = self.get_caching_key(event_name) legacy_key = self.get_legacy_caching_key(event_name) cached_data = self._check_supabase_for_cache(event_key, legacy_key) if cached_data: return {v: {f: Field.from_dict(d) for f, d in fs.items()} for v, fs in cached_data.items()} search_results = self._step_1a_initial_search(race_info) if not search_results: return None validated_urls = self._step_1b_validate_and_select_urls(event_name, search_results) if not validated_urls: return None knowledge_base = self._crawl_and_extract(validated_urls, race_info, is_web_research=True) if knowledge_base and SUPABASE_CONFIGURED: serializable_kb = {v: {f: field.to_dict() for f, field in fs.items()} for v, fs in knowledge_base.items()} set_knowledge_cache(event_key, serializable_kb) return knowledge_base def run_direct(self, race_info: dict, direct_urls: list, pre_filled_data: dict = None, pre_filled_confidence: float = 0.3) -> dict | None: """Called by Prescraped Data Processor.""" event_name = race_info.get("Festival") url_part = hashlib.md5(direct_urls[0].encode()).hexdigest()[:8] event_key = self.get_caching_key(f"{event_name}-{url_part}") legacy_key = self.get_legacy_caching_key(event_name) cached_data = self._check_supabase_for_cache(event_key, legacy_key) if cached_data: return {v: {f: Field.from_dict(d) for f, d in fs.items()} for v, fs in cached_data.items()} validated_urls =[url for url in direct_urls if self._is_valid_url(url)] if not validated_urls: return None knowledge_base = self._crawl_and_extract(validated_urls, race_info, pre_filled_data, pre_filled_confidence, is_web_research=False) if knowledge_base and SUPABASE_CONFIGURED: serializable_kb = {v: {f: field.to_dict() for f, field in fs.items()} for v, fs in knowledge_base.items()} set_knowledge_cache(event_key, serializable_kb) return knowledge_base def _is_valid_url(self, url: str) -> bool: if not url or not url.startswith(('http://', 'https://')): return False if any(year in url for year in self.invalid_years): return False return True def _crawl_and_extract(self, urls: list, race_info: dict, pre_filled_data: dict = None, pre_filled_confidence: float = 0.3, is_web_research: bool = False) -> dict: event_name = race_info.get("Festival") event_id_str = self.get_caching_key(event_name) self.chroma_collection = self.chroma_client.get_or_create_collection(name=f"coll_{event_id_str}") print(f"\n[STEP 2] Starting RAG processing for '{event_name}' (Collection: coll_{event_id_str})") self.mission_corpus =[] self.corpus_map = {} self.bm25_index = None knowledge_base = {} all_content =[] with ThreadPoolExecutor(max_workers=MAX_CONCURRENT_CRAWLERS) as executor: futures = {executor.submit(self._get_content_from_url, url, is_web_research): url for url in urls} for future in as_completed(futures): if content := future.result(): all_content.append(content) self._chunk_and_index_text(content, futures[future], event_id_str) if not all_content: print(" -[WARNING] No content crawled.") return {} self._build_bm25_index() combined_content = "\n\n".join(all_content) self._discover_and_validate_variants_from_content(combined_content, knowledge_base) if not knowledge_base: knowledge_base[event_name] = {field: Field() for field in self.schema} if pre_filled_data: self._inject_pre_filled_data(knowledge_base, pre_filled_data, pre_filled_confidence) for variant in list(knowledge_base.keys()): self._perform_holistic_extraction(knowledge_base, event_name, variant) self._perform_targeted_recovery(knowledge_base, event_name, variant) self._perform_batched_gap_fill(knowledge_base, event_name) self._verify_hallucinations(knowledge_base) self._run_inferential_filling(knowledge_base) print("\n[SUCCESS] Extraction complete.") return knowledge_base