diff --git a/backend/rag/main.py b/backend/rag/main.py index 1a957ef384..c7cc3cf0f7 100644 --- a/backend/rag/main.py +++ b/backend/rag/main.py @@ -682,6 +682,10 @@ class DSPyQueryResponse(BaseModel): # Template SPARQL tracking (for monitoring template hit rate vs LLM fallback) template_used: bool = False # Whether template-based SPARQL was used (vs LLM generation) template_id: str | None = None # Which template was used (e.g., "institution_by_city", "person_by_name") + + # Factual query mode - skip LLM generation for count/list queries + factual_result: bool = False # True if this is a direct SPARQL result (no LLM prose generation) + sparql_query: str | None = None # The SPARQL query that was executed (for transparency) def extract_llm_response_metadata( @@ -1832,6 +1836,28 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger.info("✅ AtomicCacheManager initialized for sub-task caching") except Exception as e: logger.warning(f"Failed to initialize AtomicCacheManager: {e}") + + # === ONTOLOGY CACHE WARMUP: Pre-load KG values to avoid cold-start latency === + # The OntologyLoader queries the Knowledge Graph for valid slot values (cities, regions, types). + # These queries can take 1-3 seconds each on first access. + # By pre-loading at startup, we eliminate this delay for users. + ontology_warmup_start = time.perf_counter() + try: + from template_sparql import get_ontology_loader + + logger.info("Warming up ontology cache (pre-loading KG values)...") + ontology = get_ontology_loader() + ontology.load() # Triggers KG queries for institution_types, subregions, cities, etc. + + ontology_warmup_duration = time.perf_counter() - ontology_warmup_start + cache_stats = ontology.get_kg_cache_stats() + logger.info( + f"✅ Ontology cache warmed up in {ontology_warmup_duration:.2f}s " + f"({cache_stats['cache_size']} KG queries cached, TTL={cache_stats['ttl_seconds']}s)" + ) + except Exception as e: + ontology_warmup_duration = time.perf_counter() - ontology_warmup_start + logger.warning(f"Failed to warm up ontology cache: {e}") logger.info("Heritage RAG API started") @@ -2967,6 +2993,158 @@ async def dspy_query(request: DSPyQueryRequest) -> DSPyQueryResponse: except Exception as e: logger.warning(f"Atomic decomposition failed: {e}") + # ========================================================================== + # FACTUAL QUERY FAST PATH: Skip LLM for count/list queries + # ========================================================================== + # For factual queries (counts, lists, comparisons), the SPARQL results ARE + # the answer. No need for expensive LLM prose generation - just return the + # table directly. This can reduce latency from ~15s to ~2s. + # ========================================================================== + try: + from template_sparql import get_template_pipeline, is_factual_query + + template_pipeline = get_template_pipeline() + + # Build conversation history for template context resolution + history_for_template = [] + for turn in request.context: + if turn.get("question") and turn.get("answer"): + history_for_template.append({ + "question": turn["question"], + "answer": turn["answer"] + }) + + # Try template matching (this handles follow-up resolution internally) + template_result = template_pipeline( + question=request.question, + language=request.language, + history=history_for_template, + conversation_state=conversation_state, + ) + + # Check if this is a factual query that can skip LLM + if template_result.matched and is_factual_query(template_result.template_id): + logger.info( + f"[FACTUAL-QUERY] Template '{template_result.template_id}' is factual - " + f"skipping LLM generation (confidence={template_result.confidence:.2f})" + ) + + # Execute SPARQL directly + sparql_query = template_result.sparql + sparql_results: list[dict[str, Any]] = [] + sparql_error: str | None = None + + try: + if retriever: + client = await retriever._get_sparql_client() + response = await client.post( + settings.sparql_endpoint, + data={"query": sparql_query}, + headers={"Accept": "application/sparql-results+json"}, + timeout=30.0, + ) + + if response.status_code == 200: + data = response.json() + bindings = data.get("results", {}).get("bindings", []) + sparql_results = [ + {k: v.get("value") for k, v in binding.items()} + for binding in bindings + ] + else: + sparql_error = f"SPARQL returned {response.status_code}" + else: + sparql_error = "Retriever not available" + + except Exception as e: + sparql_error = str(e) + logger.warning(f"[FACTUAL-QUERY] SPARQL execution failed: {e}") + + elapsed_ms = (time.time() - start_time) * 1000 + + # Generate a simple summary answer based on result type + if sparql_error: + answer = f"Er is een fout opgetreden bij het uitvoeren van de query: {sparql_error}" + elif not sparql_results: + answer = "Geen resultaten gevonden." + elif template_result.template_id and "count" in template_result.template_id: + # Count query - format as count + count_value = sparql_results[0].get("count", len(sparql_results)) + answer = f"Aantal: {count_value}" + else: + # List query - just indicate result count + answer = f"Gevonden: {len(sparql_results)} resultaten. Zie de tabel hieronder." + + # Build response with factual_result=True + factual_response = DSPyQueryResponse( + question=request.question, + resolved_question=getattr(template_result, "resolved_question", None), + answer=answer, + sources_used=["SPARQL Knowledge Graph"], + visualization={"type": "table", "sparql_query": sparql_query}, + retrieved_results=sparql_results, + query_type="factual", + query_time_ms=round(elapsed_ms, 2), + conversation_turn=len(request.context), + cache_hit=False, + session_id=session_id, + template_used=True, + template_id=template_result.template_id, + factual_result=True, + sparql_query=sparql_query, + ) + + # Update session with this turn + if session_mgr and session_id: + try: + await session_mgr.add_turn_to_session( + session_id=session_id, + question=request.question, + answer=answer, + resolved_question=getattr(template_result, "resolved_question", None), + template_id=template_result.template_id, + slots=template_result.slots or {}, + ) + except Exception as e: + logger.warning(f"Failed to update session: {e}") + + # Record metrics + if METRICS_AVAILABLE and record_query: + try: + record_query( + endpoint="dspy_query", + template_used=True, + template_id=template_result.template_id, + cache_hit=False, + status="success", + duration_seconds=elapsed_ms / 1000, + intent="factual", + ) + except Exception as e: + logger.warning(f"Failed to record metrics: {e}") + + # Cache the response + if retriever: + await retriever.cache.set_dspy( + question=request.question, + language=request.language, + llm_provider="none", # No LLM used + embedding_model=request.embedding_model, + response=factual_response.model_dump(), + context=request.context if request.context else None, + ) + + logger.info(f"[FACTUAL-QUERY] Returned {len(sparql_results)} results in {elapsed_ms:.2f}ms (LLM skipped)") + return factual_response + + except ImportError as e: + logger.debug(f"Template SPARQL not available for factual query detection: {e}") + except Exception as e: + logger.warning(f"Factual query detection failed (continuing with full pipeline): {e}") + + # ========================================================================== + # FULL RAG PIPELINE: For non-factual queries or when factual detection fails + # ========================================================================== try: # Import DSPy pipeline and History import dspy diff --git a/backend/rag/template_sparql.py b/backend/rag/template_sparql.py index 107904920f..9ac6df38cf 100644 --- a/backend/rag/template_sparql.py +++ b/backend/rag/template_sparql.py @@ -107,6 +107,479 @@ def _find_data_path(filename: str) -> Path: TEMPLATES_PATH = _find_data_path("sparql_templates.yaml") VALIDATION_RULES_PATH = _find_data_path("validation/sparql_validation_rules.json") +# LinkML schema path for dynamic ontology loading +LINKML_SCHEMA_PATH = Path(__file__).parent.parent.parent / "schemas" / "20251121" / "linkml" + +# Oxigraph SPARQL endpoint for KG queries +SPARQL_ENDPOINT = "http://localhost:7878/query" + + +# ============================================================================= +# ONTOLOGY LOADER (Dynamic Schema Loading) +# ============================================================================= + +class OntologyLoader: + """Dynamically loads predicates and valid values from LinkML schema and Knowledge Graph. + + This class eliminates hardcoded heuristics by: + 1. Loading slot_uri definitions from LinkML YAML files + 2. Loading enum definitions from validation rules JSON + 3. Querying the Knowledge Graph for valid enum values + 4. Caching results for performance with TTL-based expiration + + Architecture: + LinkML Schema → slot_uri predicates → SPARQLValidator + Validation Rules JSON → enums, mappings → SynonymResolver + Knowledge Graph → SPARQL queries → valid slot values + + Caching: + - KG query results are cached with configurable TTL (default: 5 minutes) + - Use refresh_kg_cache() to force reload of KG data + - Use clear_cache() to reset all cached data + """ + + _instance = None + _predicates: set[str] = set() + _external_predicates: set[str] = set() + _classes: set[str] = set() + _slot_values: dict[str, set[str]] = {} + _synonyms: dict[str, dict[str, str]] = {} + _enums: dict[str, dict] = {} + _institution_type_codes: set[str] = set() + _institution_type_mappings: dict[str, str] = {} + _subregion_mappings: dict[str, str] = {} + _country_mappings: dict[str, str] = {} + _loaded: bool = False + + # TTL-based caching for KG queries + _kg_cache: dict[str, set[str]] = {} # query_hash → result set + _kg_cache_timestamps: dict[str, float] = {} # query_hash → timestamp + _kg_cache_ttl: float = 300.0 # 5 minutes default TTL + _kg_values_last_refresh: float = 0.0 # timestamp of last KG values refresh + + def __new__(cls): + """Singleton pattern.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def _load_from_validation_rules(self) -> None: + """Load enums, mappings and predicates from validation rules JSON.""" + if not VALIDATION_RULES_PATH.exists(): + logger.warning(f"Validation rules not found: {VALIDATION_RULES_PATH}") + return + + try: + with open(VALIDATION_RULES_PATH) as f: + rules = json.load(f) + + # Load enums (HeritageTypeEnum, etc.) + self._enums = rules.get("enums", {}) + + # Extract valid institution type codes from HeritageTypeEnum + heritage_enum = self._enums.get("HeritageTypeEnum", {}) + self._institution_type_codes = set(heritage_enum.get("values", [])) + + # Load institution type mappings (case-insensitive lookup) + for k, v in rules.get("institution_type_mappings", {}).items(): + self._institution_type_mappings[k.lower()] = v + + # Load subregion mappings + for k, v in rules.get("subregion_mappings", {}).items(): + self._subregion_mappings[k.lower()] = v + + # Load country mappings + for k, v in rules.get("country_mappings", {}).items(): + self._country_mappings[k.lower()] = v + + # Load property mappings to extract predicates + property_mappings = rules.get("property_mappings", {}) + for prop_name, prop_def in property_mappings.items(): + if isinstance(prop_def, dict) and "error" not in prop_def: + # Add the property name as a valid predicate + self._predicates.add(prop_name) + + # Load namespace prefixes to build external predicates + namespaces = rules.get("namespaces", {}) + + logger.info( + f"Loaded from validation rules: {len(self._enums)} enums, " + f"{len(self._institution_type_codes)} type codes, " + f"{len(self._institution_type_mappings)} type mappings, " + f"{len(self._subregion_mappings)} subregion mappings" + ) + + except Exception as e: + logger.warning(f"Failed to load validation rules: {e}") + + def _load_from_linkml(self) -> None: + """Load predicates from LinkML schema YAML files.""" + import yaml + + slots_dir = LINKML_SCHEMA_PATH / "modules" / "slots" + if not slots_dir.exists(): + logger.warning(f"LinkML slots directory not found: {slots_dir}") + return + + # Scan all slot YAML files for slot_uri definitions + for yaml_file in slots_dir.glob("*.yaml"): + try: + with open(yaml_file) as f: + data = yaml.safe_load(f) + + if not data or "slots" not in data: + continue + + for slot_name, slot_def in data.get("slots", {}).items(): + if isinstance(slot_def, dict) and "slot_uri" in slot_def: + uri = slot_def["slot_uri"] + self._predicates.add(uri) + + except Exception as e: + logger.debug(f"Error loading {yaml_file}: {e}") + + # Load classes from classes directory + classes_dir = LINKML_SCHEMA_PATH / "modules" / "classes" + if classes_dir.exists(): + for yaml_file in classes_dir.glob("*.yaml"): + try: + with open(yaml_file) as f: + data = yaml.safe_load(f) + + if not data or "classes" not in data: + continue + + for class_name, class_def in data.get("classes", {}).items(): + if isinstance(class_def, dict) and "class_uri" in class_def: + uri = class_def["class_uri"] + self._classes.add(uri) + + except Exception as e: + logger.debug(f"Error loading {yaml_file}: {e}") + + logger.info(f"Loaded {len(self._predicates)} predicates and {len(self._classes)} classes from LinkML") + + def _query_kg_for_values(self, sparql_query: str, use_cache: bool = True) -> set[str]: + """Execute SPARQL query against the Knowledge Graph with TTL-based caching. + + Args: + sparql_query: SPARQL query to execute + use_cache: Whether to use cached results (default: True) + + Returns: + Set of values from the query results, or empty set on failure. + + Caching: + Results are cached using query hash as key. Cached results are + returned if within TTL window, otherwise a fresh query is made. + """ + import hashlib + import urllib.request + import urllib.parse + + # Generate cache key from query hash + query_hash = hashlib.md5(sparql_query.encode()).hexdigest() + + # Check cache if enabled + if use_cache and query_hash in self._kg_cache: + cache_time = self._kg_cache_timestamps.get(query_hash, 0) + if time.time() - cache_time < self._kg_cache_ttl: + logger.debug(f"KG cache hit for query (age: {time.time() - cache_time:.1f}s)") + return self._kg_cache[query_hash] + else: + logger.debug(f"KG cache expired for query (age: {time.time() - cache_time:.1f}s)") + + try: + # Encode query + params = urllib.parse.urlencode({"query": sparql_query}) + url = f"{SPARQL_ENDPOINT}?{params}" + + req = urllib.request.Request(url) + req.add_header("Accept", "application/sparql-results+json") + + with urllib.request.urlopen(req, timeout=5) as response: + result = json.loads(response.read().decode()) + + values = set() + for binding in result.get("results", {}).get("bindings", []): + for var_name, var_data in binding.items(): + values.add(var_data.get("value", "")) + + # Cache the results + self._kg_cache[query_hash] = values + self._kg_cache_timestamps[query_hash] = time.time() + + return values + + except Exception as e: + logger.debug(f"KG query failed (using fallback): {e}") + # Return cached value if available, even if expired + if query_hash in self._kg_cache: + logger.debug("Returning stale cached KG results due to query failure") + return self._kg_cache[query_hash] + return set() + + def _load_institution_types_from_kg(self) -> None: + """Load valid institution types from the Knowledge Graph.""" + # Query for distinct institution types + query = """ + PREFIX hc: + SELECT DISTINCT ?type WHERE { + ?s hc:institutionType ?type . + } + """ + + values = self._query_kg_for_values(query) + if values: + self._slot_values["institution_type"] = values + logger.info(f"Loaded {len(values)} institution types from KG") + + def _load_subregions_from_kg(self) -> None: + """Load valid subregion codes from the Knowledge Graph.""" + query = """ + PREFIX hc: + SELECT DISTINCT ?code WHERE { + ?s hc:subregionCode ?code . + } + """ + + values = self._query_kg_for_values(query) + if values: + self._slot_values["subregion"] = values + logger.info(f"Loaded {len(values)} subregion codes from KG") + + def _load_countries_from_kg(self) -> None: + """Load valid country codes from the Knowledge Graph.""" + query = """ + PREFIX hc: + SELECT DISTINCT ?code WHERE { + ?s hc:countryCode ?code . + } + """ + + values = self._query_kg_for_values(query) + if values: + self._slot_values["country"] = values + logger.info(f"Loaded {len(values)} country codes from KG") + + def _load_cities_from_kg(self) -> None: + """Load valid city names from the Knowledge Graph.""" + query = """ + PREFIX hc: + SELECT DISTINCT ?city WHERE { + ?s hc:settlementName ?city . + } + """ + + values = self._query_kg_for_values(query) + if values: + self._slot_values["city"] = values + logger.info(f"Loaded {len(values)} cities from KG") + + def load(self) -> None: + """Load all ontology data from LinkML, validation rules, and KG.""" + if self._loaded: + return + + logger.info("Loading ontology from LinkML schema, validation rules, and Knowledge Graph...") + + # Load from validation rules JSON (enums, mappings) + self._load_from_validation_rules() + + # Load predicates from LinkML schema YAML files + self._load_from_linkml() + + # Load valid values from Knowledge Graph + self._load_institution_types_from_kg() + self._load_subregions_from_kg() + self._load_countries_from_kg() + self._load_cities_from_kg() + + self._loaded = True + + logger.info( + f"Ontology loaded: {len(self._predicates)} predicates, " + f"{len(self._classes)} classes, " + f"{len(self._slot_values)} slot value sets, " + f"{len(self._institution_type_codes)} institution type codes" + ) + + def get_predicates(self) -> set[str]: + """Get all valid predicates from the ontology.""" + self.load() + return self._predicates + + def get_classes(self) -> set[str]: + """Get all valid classes from the ontology.""" + self.load() + return self._classes + + def get_valid_values(self, slot_name: str) -> set[str]: + """Get valid values for a slot from the Knowledge Graph.""" + self.load() + return self._slot_values.get(slot_name, set()) + + def is_valid_value(self, slot_name: str, value: str) -> bool: + """Check if a value is valid for a slot.""" + valid_values = self.get_valid_values(slot_name) + if not valid_values: + return True # No KG data, assume valid + return value in valid_values or value.upper() in valid_values + + def get_institution_type_codes(self) -> set[str]: + """Get valid single-letter institution type codes from HeritageTypeEnum. + + Returns: + Set of valid codes: {"G", "L", "A", "M", "O", "R", "C", "U", "B", "E", "S", "F", "I", "X", "P", "H", "D", "N", "T"} + """ + self.load() + return self._institution_type_codes + + def get_institution_type_mappings(self) -> dict[str, str]: + """Get institution type mappings (name → code). + + Returns: + Dict mapping type names/synonyms to single-letter codes. + Example: {"museum": "M", "library": "L", "archive": "A", ...} + """ + self.load() + return self._institution_type_mappings + + def get_subregion_mappings(self) -> dict[str, str]: + """Get subregion mappings (name → ISO code). + + Returns: + Dict mapping region names to ISO 3166-2 codes. + Example: {"noord-holland": "NL-NH", "limburg": "NL-LI", ...} + """ + self.load() + return self._subregion_mappings + + def get_country_mappings(self) -> dict[str, str]: + """Get country mappings (ISO code → Wikidata ID). + + Returns: + Dict mapping ISO country codes to Wikidata entity IRIs. + Example: {"nl": "wd:Q55", "de": "wd:Q183", ...} + """ + self.load() + return self._country_mappings + + def get_enum_values(self, enum_name: str) -> list[str]: + """Get valid values for a specific enum from the schema. + + Args: + enum_name: Name of the enum (e.g., "HeritageTypeEnum", "OrganizationalChangeEventTypeEnum") + + Returns: + List of valid enum values, or empty list if not found. + """ + self.load() + enum_def = self._enums.get(enum_name, {}) + return enum_def.get("values", []) + + def set_kg_cache_ttl(self, ttl_seconds: float) -> None: + """Set the TTL for KG query cache. + + Args: + ttl_seconds: Time-to-live in seconds for cached KG query results. + Default is 300 seconds (5 minutes). + """ + self._kg_cache_ttl = ttl_seconds + logger.info(f"KG cache TTL set to {ttl_seconds} seconds") + + def get_kg_cache_ttl(self) -> float: + """Get the current TTL for KG query cache.""" + return self._kg_cache_ttl + + def clear_kg_cache(self) -> None: + """Clear the KG query cache, forcing fresh queries on next access.""" + self._kg_cache.clear() + self._kg_cache_timestamps.clear() + logger.info("KG query cache cleared") + + def refresh_kg_values(self) -> None: + """Force refresh of KG-loaded slot values (institution types, subregions, etc.). + + This clears the KG cache and reloads all slot values from the Knowledge Graph. + Useful when KG data has been updated and you need fresh values. + """ + # Clear KG cache + self.clear_kg_cache() + + # Clear slot values loaded from KG + kg_slots = ["institution_type", "subregion", "country", "city"] + for slot in kg_slots: + if slot in self._slot_values: + del self._slot_values[slot] + + # Reload from KG + self._load_institution_types_from_kg() + self._load_subregions_from_kg() + self._load_countries_from_kg() + self._load_cities_from_kg() + + self._kg_values_last_refresh = time.time() + logger.info("KG slot values refreshed") + + def get_kg_cache_stats(self) -> dict[str, Any]: + """Get statistics about the KG query cache. + + Returns: + Dict with cache statistics including size, age of entries, + and last refresh time. + """ + now = time.time() + stats = { + "cache_size": len(self._kg_cache), + "ttl_seconds": self._kg_cache_ttl, + "last_kg_refresh": self._kg_values_last_refresh, + "entries": {} + } + + for query_hash, timestamp in self._kg_cache_timestamps.items(): + age = now - timestamp + stats["entries"][query_hash[:8]] = { + "age_seconds": round(age, 1), + "expired": age >= self._kg_cache_ttl, + "result_count": len(self._kg_cache.get(query_hash, set())) + } + + return stats + + def clear_all_cache(self) -> None: + """Clear all cached data and reset to initial state. + + This resets the OntologyLoader to its initial state, requiring + a full reload on next access. Use with caution. + """ + self._predicates.clear() + self._external_predicates.clear() + self._classes.clear() + self._slot_values.clear() + self._synonyms.clear() + self._enums.clear() + self._institution_type_codes.clear() + self._institution_type_mappings.clear() + self._subregion_mappings.clear() + self._country_mappings.clear() + self._kg_cache.clear() + self._kg_cache_timestamps.clear() + self._loaded = False + self._kg_values_last_refresh = 0.0 + logger.info("All OntologyLoader cache cleared") + + +# Global ontology loader instance +_ontology_loader: Optional[OntologyLoader] = None + +def get_ontology_loader() -> OntologyLoader: + """Get or create the global ontology loader.""" + global _ontology_loader + if _ontology_loader is None: + _ontology_loader = OntologyLoader() + return _ontology_loader + # Standard SPARQL prefixes SPARQL_PREFIXES = """PREFIX hc: PREFIX hcc: @@ -254,11 +727,15 @@ class FykeResult(BaseModel): # ============================================================================= -# SYNONYM MAPPINGS (loaded from validation rules) +# SYNONYM MAPPINGS (loaded from OntologyLoader) # ============================================================================= class SynonymResolver: - """Resolves natural language terms to canonical slot values.""" + """Resolves natural language terms to canonical slot values. + + Uses OntologyLoader to get mappings from the validation rules JSON, + eliminating hardcoded heuristics. + """ def __init__(self): self._institution_types: dict[str, str] = {} @@ -266,36 +743,29 @@ class SynonymResolver: self._countries: dict[str, str] = {} self._cities: set[str] = set() self._budget_categories: dict[str, str] = {} + self._valid_type_codes: set[str] = set() # From HeritageTypeEnum self._loaded = False def load(self) -> None: - """Load synonym mappings from validation rules and templates.""" + """Load synonym mappings from OntologyLoader and templates.""" if self._loaded: return - - # Load from validation rules - if VALIDATION_RULES_PATH.exists(): - try: - with open(VALIDATION_RULES_PATH) as f: - rules = json.load(f) - - # Institution type mappings - if "institution_type_mappings" in rules: - for k, v in rules["institution_type_mappings"].items(): - self._institution_types[k.lower()] = v - - # Subregion mappings - if "subregion_mappings" in rules: - for k, v in rules["subregion_mappings"].items(): - self._subregions[k.lower()] = v - - # Country mappings - if "country_mappings" in rules: - for k, v in rules["country_mappings"].items(): - self._countries[k.lower()] = v - - except Exception as e: - logger.warning(f"Failed to load validation rules: {e}") + + # Get mappings from OntologyLoader (loads from validation rules JSON) + ontology = get_ontology_loader() + ontology.load() + + # Get institution type mappings from OntologyLoader + self._institution_types = dict(ontology.get_institution_type_mappings()) + + # Get valid type codes from HeritageTypeEnum (replaces hardcoded "MLAGORCUBESFIXPHDNT") + self._valid_type_codes = ontology.get_institution_type_codes() + + # Get subregion mappings from OntologyLoader + self._subregions = dict(ontology.get_subregion_mappings()) + + # Get country mappings from OntologyLoader + self._countries = dict(ontology.get_country_mappings()) # Load additional synonyms from templates YAML if TEMPLATES_PATH.exists(): @@ -306,20 +776,26 @@ class SynonymResolver: slot_types = templates.get("_slot_types", {}) - # Institution type synonyms + # Institution type synonyms (merge with ontology mappings) inst_synonyms = slot_types.get("institution_type", {}).get("synonyms", {}) for k, v in inst_synonyms.items(): - self._institution_types[k.lower().replace("_", " ")] = v + key = k.lower().replace("_", " ") + if key not in self._institution_types: + self._institution_types[key] = v - # Subregion synonyms + # Subregion synonyms (merge with ontology mappings) region_synonyms = slot_types.get("subregion", {}).get("synonyms", {}) for k, v in region_synonyms.items(): - self._subregions[k.lower().replace("_", " ")] = v + key = k.lower().replace("_", " ") + if key not in self._subregions: + self._subregions[key] = v - # Country synonyms + # Country synonyms (merge with ontology mappings) country_synonyms = slot_types.get("country", {}).get("synonyms", {}) for k, v in country_synonyms.items(): - self._countries[k.lower().replace("_", " ")] = v + key = k.lower().replace("_", " ") + if key not in self._countries: + self._countries[key] = v # Budget category synonyms budget_synonyms = slot_types.get("budget_category", {}).get("synonyms", {}) @@ -329,7 +805,8 @@ class SynonymResolver: except Exception as e: logger.warning(f"Failed to load template synonyms: {e}") - # Add common Dutch institution type synonyms + # Add common Dutch institution type synonyms (fallback for NLP variations) + # These are added as fallback if not already in the ontology mappings dutch_types = { "museum": "M", "musea": "M", "museums": "M", "bibliotheek": "L", "bibliotheken": "L", "library": "L", "libraries": "L", @@ -342,7 +819,8 @@ class SynonymResolver: self._loaded = True logger.info(f"Loaded {len(self._institution_types)} institution types, " - f"{len(self._subregions)} subregions, {len(self._countries)} countries") + f"{len(self._subregions)} subregions, {len(self._countries)} countries, " + f"{len(self._valid_type_codes)} valid type codes") def resolve_institution_type(self, term: str) -> Optional[str]: """Resolve institution type term to single-letter code.""" @@ -353,8 +831,8 @@ class SynonymResolver: if term_lower in self._institution_types: return self._institution_types[term_lower] - # Already a valid code - if term.upper() in "MLAGORCUBESFIXPHDNT": + # Already a valid code - use ontology-derived codes instead of hardcoded string + if term.upper() in self._valid_type_codes: return term.upper() # Fuzzy match @@ -531,6 +1009,454 @@ def get_synonym_resolver() -> SynonymResolver: return _synonym_resolver +# ============================================================================= +# SCHEMA-AWARE SLOT VALIDATOR (SOTA Pattern) +# ============================================================================= + +class SlotValidationResult(BaseModel): + """Result of slot value validation against ontology.""" + valid: bool + original_value: str + corrected_value: Optional[str] = None + slot_name: str + errors: list[str] = Field(default_factory=list) + suggestions: list[str] = Field(default_factory=list) + confidence: float = 1.0 + + +class SchemaAwareSlotValidator: + """Validates and auto-corrects slot values against the ontology schema. + + Based on KGQuest (arXiv:2511.11258) pattern for schema-aware slot filling. + Uses the validation rules JSON which contains: + - Valid enum values for each slot type + - Synonym mappings for fuzzy matching + - Property constraints from LinkML schema + + Architecture: + 1. Load valid values from validation rules JSON + 2. For each extracted slot, check if value is valid + 3. If invalid, attempt fuzzy match to find correct value + 4. Return validation result with corrections and suggestions + + Caching: + - KG validation results are cached with TTL (default: 5 minutes) + - Use clear_kg_validation_cache() to reset cache + """ + + _instance = None + _valid_values: dict[str, set[str]] = {} + _synonym_maps: dict[str, dict[str, str]] = {} + _loaded: bool = False + + # TTL-based caching for KG validation + _kg_validation_cache: dict[str, bool] = {} # (slot_name, value) hash → is_valid + _kg_validation_timestamps: dict[str, float] = {} # (slot_name, value) hash → timestamp + _kg_validation_ttl: float = 300.0 # 5 minutes default TTL + + def __new__(cls): + """Singleton pattern.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def _load_validation_rules(self) -> None: + """Load valid values and synonyms from validation rules JSON and SynonymResolver.""" + if self._loaded: + return + + # First, load from the SynonymResolver (has comprehensive Dutch mappings) + resolver = get_synonym_resolver() + resolver.load() + + # Copy institution type mappings from resolver + if resolver._institution_types: + self._synonym_maps["institution_type"] = dict(resolver._institution_types) + self._valid_values["institution_type"] = set(resolver._institution_types.values()) + + # Copy subregion mappings from resolver + if resolver._subregions: + self._synonym_maps["subregion"] = dict(resolver._subregions) + self._valid_values["subregion"] = set(resolver._subregions.values()) + + # Copy country mappings from resolver + if resolver._countries: + self._synonym_maps["country"] = dict(resolver._countries) + self._valid_values["country"] = set(resolver._countries.values()) + + # Copy budget category mappings from resolver + if resolver._budget_categories: + self._synonym_maps["budget_category"] = dict(resolver._budget_categories) + self._valid_values["budget_category"] = set(resolver._budget_categories.values()) + + # Then, augment with validation rules JSON (has enum definitions and slot constraints) + if not VALIDATION_RULES_PATH.exists(): + logger.warning(f"Validation rules not found: {VALIDATION_RULES_PATH}") + self._loaded = True + return + + try: + with open(VALIDATION_RULES_PATH) as f: + rules = json.load(f) + + # Augment institution type mappings (don't overwrite, merge) + if "institution_type_mappings" in rules: + mappings = rules["institution_type_mappings"] + if "institution_type" not in self._synonym_maps: + self._synonym_maps["institution_type"] = {} + for k, v in mappings.items(): + self._synonym_maps["institution_type"][k.lower()] = v + if "institution_type" not in self._valid_values: + self._valid_values["institution_type"] = set() + self._valid_values["institution_type"].update(mappings.values()) + + # Augment subregion mappings + if "subregion_mappings" in rules: + mappings = rules["subregion_mappings"] + if "subregion" not in self._synonym_maps: + self._synonym_maps["subregion"] = {} + for k, v in mappings.items(): + self._synonym_maps["subregion"][k.lower()] = v + if "subregion" not in self._valid_values: + self._valid_values["subregion"] = set() + self._valid_values["subregion"].update(mappings.values()) + + # Augment country mappings + if "country_mappings" in rules: + mappings = rules["country_mappings"] + if "country" not in self._synonym_maps: + self._synonym_maps["country"] = {} + for k, v in mappings.items(): + self._synonym_maps["country"][k.lower()] = v + if "country" not in self._valid_values: + self._valid_values["country"] = set() + self._valid_values["country"].update(mappings.values()) + + # Load enum valid values from the 'enums' section + if "enums" in rules: + for enum_name, enum_data in rules["enums"].items(): + if isinstance(enum_data, dict) and "permissible_values" in enum_data: + values = set(enum_data["permissible_values"].keys()) + self._valid_values[enum_name] = values + + # Load slot constraints + if "slots" in rules: + for slot_name, slot_data in rules["slots"].items(): + if isinstance(slot_data, dict): + # Extract range enum if present + if "range" in slot_data: + range_enum = slot_data["range"] + if range_enum in self._valid_values: + self._valid_values[slot_name] = self._valid_values[range_enum] + + self._loaded = True + logger.info( + f"Loaded schema validation rules: " + f"{len(self._valid_values)} value sets, " + f"{len(self._synonym_maps)} synonym maps" + ) + + except Exception as e: + logger.warning(f"Failed to load validation rules: {e}") + self._loaded = True + + def validate_slot( + self, + slot_name: str, + value: str, + auto_correct: bool = True + ) -> SlotValidationResult: + """Validate a slot value against the ontology schema. + + Args: + slot_name: Name of the slot (e.g., "institution_type", "subregion") + value: Extracted value to validate + auto_correct: Whether to attempt fuzzy matching for correction + + Returns: + SlotValidationResult with validation status and corrections + """ + self._load_validation_rules() + + result = SlotValidationResult( + valid=True, + original_value=value, + slot_name=slot_name + ) + + # Normalize value + value_normalized = value.strip() + value_lower = value_normalized.lower() + + # Check synonym maps first (most common case) + if slot_name in self._synonym_maps: + synonym_map = self._synonym_maps[slot_name] + if value_lower in synonym_map: + # Direct synonym match - return canonical value + result.corrected_value = synonym_map[value_lower] + result.confidence = 1.0 + return result + + # Check if value is already a valid canonical value + if slot_name in self._valid_values: + valid_set = self._valid_values[slot_name] + if value_normalized in valid_set or value_normalized.upper() in valid_set: + result.confidence = 1.0 + return result + + # Value not valid - attempt correction if enabled + result.valid = False + result.errors.append(f"Invalid value '{value}' for slot '{slot_name}'") + + if auto_correct: + # Try fuzzy matching against synonym keys + if slot_name in self._synonym_maps: + synonym_keys = list(self._synonym_maps[slot_name].keys()) + match = process.extractOne( + value_lower, + synonym_keys, + scorer=fuzz.ratio, + score_cutoff=70 + ) + if match: + corrected = self._synonym_maps[slot_name][match[0]] + result.corrected_value = corrected + result.confidence = match[1] / 100.0 + result.suggestions.append( + f"Did you mean '{match[0]}' → '{corrected}'?" + ) + return result + + # Try fuzzy matching against valid values directly + match = process.extractOne( + value_normalized, + list(valid_set), + scorer=fuzz.ratio, + score_cutoff=70 + ) + if match: + result.corrected_value = match[0] + result.confidence = match[1] / 100.0 + result.suggestions.append(f"Did you mean '{match[0]}'?") + return result + + # No correction found - provide suggestions + if slot_name in self._valid_values: + sample_values = list(self._valid_values[slot_name])[:5] + result.suggestions.append( + f"Valid values include: {', '.join(sample_values)}" + ) + + return result + + def validate_slot_against_kg( + self, + slot_name: str, + value: str, + use_cache: bool = True + ) -> bool: + """Validate a slot value against the Knowledge Graph with TTL-based caching. + + This is a fallback when local validation has no data for the slot. + Queries the Oxigraph endpoint to verify the value exists in the KG. + + The KG validation uses actual values stored in the Knowledge Graph, + which may differ from the static validation rules JSON. + + Args: + slot_name: Name of the slot (e.g., "institution_type", "city", "subregion") + value: Value to validate against the KG + use_cache: Whether to use cached validation results (default: True) + + Returns: + True if value exists in KG or if KG is unavailable, False if value is invalid. + + Note: + This method is non-blocking - if KG query fails, it returns True to avoid + rejecting potentially valid values. + + Caching: + Results are cached using (slot_name, value) as key. Cached results are + returned if within TTL window (default 5 minutes). + """ + # Generate cache key + cache_key = f"{slot_name}:{value}" + + # Check cache if enabled + if use_cache and cache_key in self._kg_validation_cache: + cache_time = self._kg_validation_timestamps.get(cache_key, 0) + if time.time() - cache_time < self._kg_validation_ttl: + logger.debug(f"KG validation cache hit for {slot_name}='{value}'") + return self._kg_validation_cache[cache_key] + + ontology = get_ontology_loader() + ontology.load() + + # Map slot names to OntologyLoader slot value keys + slot_key_map = { + "institution_type": "institution_type", + "type": "institution_type", + "city": "city", + "settlement": "city", + "subregion": "subregion", + "province": "subregion", + "country": "country", + } + + slot_key = slot_key_map.get(slot_name, slot_name) + + # Use OntologyLoader's KG-based validation + is_valid = ontology.is_valid_value(slot_key, value) + + # Cache the result + self._kg_validation_cache[cache_key] = is_valid + self._kg_validation_timestamps[cache_key] = time.time() + + return is_valid + + def validate_slot_with_kg_fallback( + self, + slot_name: str, + value: str, + auto_correct: bool = True + ) -> SlotValidationResult: + """Validate slot with KG fallback for values not in local validation rules. + + This method first tries local validation (fast, uses cached rules). + If local validation has no data for the slot, it falls back to + querying the Knowledge Graph for validation. + + Args: + slot_name: Name of the slot to validate + value: Value to validate + auto_correct: Whether to attempt fuzzy matching for correction + + Returns: + SlotValidationResult with validation status, corrections, and source + """ + # First try local validation + result = self.validate_slot(slot_name, value, auto_correct) + + # If local validation has no data for this slot, try KG validation + if slot_name not in self._valid_values and slot_name not in self._synonym_maps: + kg_valid = self.validate_slot_against_kg(slot_name, value) + if kg_valid: + result.valid = True + result.errors = [] + result.confidence = 0.85 # KG validation has slightly lower confidence + logger.debug(f"Slot '{slot_name}' validated against KG: {value}") + else: + result.valid = False + result.errors.append(f"Value '{value}' not found in Knowledge Graph for slot '{slot_name}'") + logger.debug(f"Slot '{slot_name}' failed KG validation: {value}") + + return result + + def validate_slots( + self, + slots: dict[str, str], + auto_correct: bool = True + ) -> dict[str, SlotValidationResult]: + """Validate multiple slots at once. + + Args: + slots: Dictionary of slot_name -> value + auto_correct: Whether to attempt corrections + + Returns: + Dictionary of slot_name -> SlotValidationResult + """ + results = {} + for slot_name, value in slots.items(): + results[slot_name] = self.validate_slot(slot_name, value, auto_correct) + return results + + def get_corrected_slots( + self, + slots: dict[str, str], + min_confidence: float = 0.7 + ) -> dict[str, str]: + """Get slots with auto-corrected values applied. + + Args: + slots: Original slot values + min_confidence: Minimum confidence for applying corrections + + Returns: + Dictionary with corrected values applied + """ + results = self.validate_slots(slots, auto_correct=True) + corrected = {} + + for slot_name, result in results.items(): + if result.corrected_value and result.confidence >= min_confidence: + corrected[slot_name] = result.corrected_value + if result.corrected_value != result.original_value: + logger.info( + f"Auto-corrected slot '{slot_name}': " + f"'{result.original_value}' → '{result.corrected_value}' " + f"(confidence={result.confidence:.2f})" + ) + else: + corrected[slot_name] = result.original_value + + return corrected + + def set_kg_validation_ttl(self, ttl_seconds: float) -> None: + """Set the TTL for KG validation cache. + + Args: + ttl_seconds: Time-to-live in seconds for cached validation results. + Default is 300 seconds (5 minutes). + """ + self._kg_validation_ttl = ttl_seconds + logger.info(f"KG validation cache TTL set to {ttl_seconds} seconds") + + def get_kg_validation_ttl(self) -> float: + """Get the current TTL for KG validation cache.""" + return self._kg_validation_ttl + + def clear_kg_validation_cache(self) -> None: + """Clear the KG validation cache, forcing fresh validations on next access.""" + self._kg_validation_cache.clear() + self._kg_validation_timestamps.clear() + logger.info("KG validation cache cleared") + + def get_kg_validation_cache_stats(self) -> dict[str, Any]: + """Get statistics about the KG validation cache. + + Returns: + Dict with cache statistics including size, hit rate, and age of entries. + """ + now = time.time() + valid_count = sum(1 for v in self._kg_validation_cache.values() if v) + invalid_count = len(self._kg_validation_cache) - valid_count + + expired_count = sum( + 1 for cache_key in self._kg_validation_timestamps + if now - self._kg_validation_timestamps[cache_key] >= self._kg_validation_ttl + ) + + return { + "cache_size": len(self._kg_validation_cache), + "valid_entries": valid_count, + "invalid_entries": invalid_count, + "expired_entries": expired_count, + "ttl_seconds": self._kg_validation_ttl, + } + + +# Global schema-aware slot validator instance +_schema_slot_validator: Optional[SchemaAwareSlotValidator] = None + +def get_schema_slot_validator() -> SchemaAwareSlotValidator: + """Get or create the schema-aware slot validator.""" + global _schema_slot_validator + if _schema_slot_validator is None: + _schema_slot_validator = SchemaAwareSlotValidator() + return _schema_slot_validator + + # ============================================================================= # DSPy SIGNATURES # ============================================================================= @@ -1305,41 +2231,128 @@ class SPARQLValidator: """Validates generated SPARQL against ontology schema. Based on SPARQL-LLM (arXiv:2512.14277) validation-correction pattern. - Checks predicates and classes against the LinkML schema. + + Dynamically loads predicates and classes from OntologyLoader, which + reads from LinkML schema files and validation rules JSON. + + Fallback hardcoded sets are kept for robustness when schema files + are unavailable. """ - # Known predicates from our ontology (hc: namespace) - VALID_HC_PREDICATES = { + # Fallback predicates (used when OntologyLoader can't load from schema) + # These are kept for robustness in case schema files are missing + _FALLBACK_HC_PREDICATES = { "hc:institutionType", "hc:settlementName", "hc:subregionCode", "hc:countryCode", "hc:ghcid", "hc:isil", "hc:validFrom", "hc:validTo", "hc:changeType", "hc:changeReason", "hc:eventType", "hc:eventDate", - "hc:affectedActor", "hc:resultingActor", "hc:refers_to_custodian", - "hc:fiscal_year_start", "hc:innovation_budget", "hc:digitization_budget", - "hc:preservation_budget", "hc:personnel_budget", "hc:acquisition_budget", - "hc:operating_budget", "hc:capital_budget", "hc:reporting_period_start", - "hc:innovation_expenses", "hc:digitization_expenses", "hc:preservation_expenses", } - # Known classes from our ontology - VALID_HC_CLASSES = { + _FALLBACK_CLASSES = { "hcc:Custodian", "hc:class/Budget", "hc:class/FinancialStatement", "hc:OrganizationalChangeEvent", } - # Standard schema.org, SKOS, FOAF predicates we use + # Standard external predicates from base ontologies (rarely change) VALID_EXTERNAL_PREDICATES = { + # Schema.org predicates "schema:name", "schema:description", "schema:foundingDate", - "schema:addressCountry", "schema:addressLocality", - "foaf:homepage", "skos:prefLabel", "skos:altLabel", - "dcterms:identifier", "org:memberOf", + "schema:addressCountry", "schema:addressLocality", "schema:affiliation", + "schema:about", "schema:archivedAt", "schema:areaServed", + "schema:authenticationType", "schema:conditionsOfAccess", + "schema:countryOfOrigin", "schema:dateAcquired", "schema:dateModified", + + # FOAF predicates + "foaf:homepage", "foaf:name", + + # SKOS predicates + "skos:prefLabel", "skos:altLabel", "skos:broader", "skos:notation", + "skos:mappingRelation", + + # Dublin Core predicates + "dcterms:identifier", "dcterms:accessRights", "dcterms:conformsTo", + "dcterms:hasPart", "dcterms:language", "dcterms:type", + + # W3C Org predicates + "org:memberOf", "org:hasSubOrganization", "org:subOrganizationOf", "org:hasSite", + + # PROV-O predicates + "prov:type", "prov:wasInfluencedBy", "prov:influenced", "prov:wasAttributedTo", + "prov:contributed", "prov:generatedAtTime", "prov:hadReason", + + # CIDOC-CRM predicates + "crm:P1_is_identified_by", "crm:P2_has_type", "crm:P11_had_participant", + "crm:P24i_changed_ownership_through", "crm:P82a_begin_of_the_begin", + "crm:P81b_begin_of_the_end", + + # RiC-O predicates + "rico:hasProvenance", "rico:hasRecordSetType", "rico:hasOrHadAllMembersWithRecordState", + + # DCAT predicates + "dcat:endpointURL", + + # WGS84 predicates + "wgs84:alt", + + # PiCo predicates + "pico:hasAge", + + # PNV predicates (Person Name Vocabulary) + "pnv:baseSurname", + + # Wikidata predicates + "wikidata:P1196", "wdt:P31", + + # RDF/RDFS predicates + "rdf:value", "rdfs:label", + + # SDO (Schema.org alternate prefix) + "sdo:birthDate", "sdo:birthPlace", } def __init__(self): - self._all_predicates = ( - self.VALID_HC_PREDICATES | - self.VALID_EXTERNAL_PREDICATES - ) - self._all_classes = self.VALID_HC_CLASSES + """Initialize SPARQLValidator with predicates from OntologyLoader. + + IMPORTANT: The RAG SPARQL queries use custom hc: prefixed predicates + (hc:institutionType, hc:settlementName, etc.) while the LinkML schema + uses semantic URIs from base ontologies (org:classification, schema:location). + + To support both, we ALWAYS include: + 1. Core RAG predicates (_FALLBACK_HC_PREDICATES) - used in actual queries + 2. Schema predicates from OntologyLoader - for validation flexibility + 3. External predicates (VALID_EXTERNAL_PREDICATES) - standard ontology URIs + """ + # Load predicates dynamically from OntologyLoader + ontology = get_ontology_loader() + ontology.load() + + # Get predicates from LinkML schema (semantic URIs like org:classification) + schema_predicates = ontology.get_predicates() + schema_classes = ontology.get_classes() + + # Start with core RAG predicates (these are what queries actually use) + # These are ALWAYS included regardless of schema loading + hc_predicates = set(self._FALLBACK_HC_PREDICATES) + + # Add schema predicates if available (for flexibility) + if schema_predicates: + hc_predicates = hc_predicates | schema_predicates + logger.info(f"SPARQLValidator: loaded {len(schema_predicates)} additional predicates from OntologyLoader") + + logger.info(f"SPARQLValidator: {len(hc_predicates)} total hc predicates (core + schema)") + + # Use schema classes if available, otherwise use fallback + if schema_classes: + self._all_classes = schema_classes | self._FALLBACK_CLASSES + logger.info(f"SPARQLValidator: loaded {len(schema_classes)} classes from OntologyLoader") + else: + self._all_classes = self._FALLBACK_CLASSES + logger.warning("SPARQLValidator: using fallback hardcoded classes") + + # Combine hc predicates with external predicates + self._all_predicates = hc_predicates | self.VALID_EXTERNAL_PREDICATES + + # Expose as VALID_HC_PREDICATES for backward compatibility with tests + self.VALID_HC_PREDICATES = hc_predicates def validate(self, sparql: str) -> SPARQLValidationResult: """Validate SPARQL query against schema. @@ -1358,8 +2371,12 @@ class SPARQLValidator: if "hc:" not in sparql and "hcc:" not in sparql: return SPARQLValidationResult(valid=True) - # Extract predicates used (hc:xxx, schema:xxx, etc.) - predicate_pattern = r'(hc:\w+|hcc:\w+|schema:\w+|foaf:\w+|skos:\w+|dcterms:\w+)' + # Extract predicates used - expanded to capture all known namespaces + predicate_pattern = ( + r'(hc:\w+|hcc:\w+|schema:\w+|foaf:\w+|skos:\w+|dcterms:\w+|' + r'org:\w+|prov:\w+|crm:\w+|rico:\w+|dcat:\w+|wgs84:\w+|' + r'pico:\w+|pnv:\w+|wikidata:\w+|wdt:\w+|rdf:\w+|rdfs:\w+|sdo:\w+)' + ) predicates = set(re.findall(predicate_pattern, sparql)) for pred in predicates: @@ -2188,6 +3205,57 @@ class TemplateSPARQLPipeline(dspy.Module): ) +# ============================================================================= +# FACTUAL QUERY DETECTION +# ============================================================================= + +# Templates whose results can be returned directly without LLM prose generation. +# These are COUNT and LIST queries where the SPARQL results ARE the answer. +FACTUAL_TEMPLATES: set[str] = { + # Count queries - return numeric results + "count_institutions_by_type_location", + "count_institutions_by_type", + "events_in_period", + "institutions_by_founding_decade", + + # List queries - return tabular results + "list_institutions_by_type_city", + "list_institutions_by_type_region", + "list_institutions_by_type_country", + "list_all_institutions_in_city", + "find_custodians_by_budget_threshold", + "find_institutions_by_founding_date", + "find_by_founding", + + # Comparison queries - return comparative stats + "compare_locations", +} + + +def is_factual_query(template_id: str | None) -> bool: + """Determine if a template query returns factual results that can skip LLM generation. + + Factual queries are COUNT, LIST, and COMPARISON templates where the SPARQL + results themselves ARE the answer. No prose generation is needed - just render + the results as a table. + + Args: + template_id: Template ID from template matching (may be None if no match) + + Returns: + True if results can be returned directly without LLM prose generation + + Example: + >>> is_factual_query("count_institutions_by_type_location") + True + >>> is_factual_query("find_institution_by_name") + False # Entity lookup needs prose explanation + """ + if template_id is None: + return False + return template_id in FACTUAL_TEMPLATES + + # ============================================================================= # FACTORY FUNCTION # ============================================================================= diff --git a/frontend/src/components/query/ConversationPanel.css b/frontend/src/components/query/ConversationPanel.css index 02c51911ac..16de29a697 100644 --- a/frontend/src/components/query/ConversationPanel.css +++ b/frontend/src/components/query/ConversationPanel.css @@ -1033,3 +1033,107 @@ font-size: 0.625rem; } } + +/* ============================================================================= + FACTUAL QUERY RESULTS TABLE + For direct SPARQL results when LLM generation is skipped (count/list queries) + ============================================================================= */ + +.conversation-panel__factual-results { + margin-top: 0.75rem; + border: 1px solid #10b981; + border-radius: 8px; + overflow: hidden; + background: white; +} + +.conversation-panel__factual-badge { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: linear-gradient(135deg, #dcfce7 0%, #d1fae5 100%); + border-bottom: 1px solid #10b981; + font-size: 0.75rem; + font-weight: 600; + color: #166534; +} + +.conversation-panel__factual-badge svg { + color: #10b981; +} + +.conversation-panel__results-table-wrapper { + overflow-x: auto; + max-height: 400px; + overflow-y: auto; +} + +.conversation-panel__results-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.conversation-panel__results-table th { + position: sticky; + top: 0; + background: #f0fdf4; + padding: 0.5rem 0.75rem; + text-align: left; + font-weight: 600; + color: #166534; + border-bottom: 2px solid #10b981; + white-space: nowrap; +} + +.conversation-panel__results-table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #e5e7eb; + vertical-align: top; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; +} + +.conversation-panel__results-table tr:hover td { + background: #f0fdf4; +} + +.conversation-panel__results-table a { + color: #10b981; + text-decoration: none; +} + +.conversation-panel__results-table a:hover { + text-decoration: underline; +} + +.conversation-panel__results-truncated { + padding: 0.5rem 0.75rem; + background: #fef3c7; + color: #92400e; + font-size: 0.75rem; + text-align: center; + border-top: 1px solid #fbbf24; +} + +/* Responsive: Factual Results Table */ +@media (max-width: 768px) { + .conversation-panel__results-table-wrapper { + max-height: 250px; + } + + .conversation-panel__results-table { + font-size: 0.75rem; + } + + .conversation-panel__results-table th, + .conversation-panel__results-table td { + padding: 0.375rem 0.5rem; + } + + .conversation-panel__results-table td { + max-width: 150px; + } +} diff --git a/frontend/src/components/query/ConversationPanel.tsx b/frontend/src/components/query/ConversationPanel.tsx index c4f8b72c86..9944d76861 100644 --- a/frontend/src/components/query/ConversationPanel.tsx +++ b/frontend/src/components/query/ConversationPanel.tsx @@ -213,6 +213,7 @@ interface Message { errorCode?: string; llmProviderUsed?: string; // Which LLM provider generated this response llmResponse?: LLMResponseMetadata; // Full LLM response metadata including chain-of-thought + factualResult?: boolean; // True if this is a direct SPARQL result (no LLM prose generation) } interface HistoryItem { @@ -374,6 +375,7 @@ export const ConversationPanel: React.FC = ({ onQueryGen sourcesUsed: string[]; llmProviderUsed?: string; llmResponse?: LLMResponseMetadata; // Full LLM response with reasoning_content + factualResult?: boolean; // True if LLM was skipped for factual query }> => { // Determine API endpoint based on environment const hostname = window.location.hostname; @@ -414,12 +416,13 @@ export const ConversationPanel: React.FC = ({ onQueryGen const data = await response.json(); return { - sparql: data.visualization?.sparql_query || data.sparql, // Get SPARQL from visualization if available + sparql: data.visualization?.sparql_query || data.sparql_query || data.sparql, // Get SPARQL from visualization or direct field sparqlResults: data.retrieved_results, // Raw results for debug display answer: data.answer || data.explanation || '', sourcesUsed: data.sources_used || selectedSources, llmProviderUsed: data.llm_provider_used, llmResponse: data.llm_response, // Pass through chain-of-thought metadata + factualResult: data.factual_result, // True if LLM was skipped for factual query }; }; @@ -472,6 +475,7 @@ export const ConversationPanel: React.FC = ({ onQueryGen sourcesUsed: result.sourcesUsed, llmProviderUsed: result.llmProviderUsed, llmResponse: result.llmResponse, + factualResult: result.factualResult, // Direct SPARQL result flag isLoading: false, } : msg @@ -965,6 +969,51 @@ export const ConversationPanel: React.FC = ({ onQueryGen <>

{message.content}

+ {/* Factual Query Results Table - shown when LLM was skipped */} + {message.factualResult && message.sparqlResults && message.sparqlResults.length > 0 && ( +
+
+ + {language === 'nl' ? 'Direct uit kennisgraaf' : 'Direct from knowledge graph'} +
+
+ + + + {Object.keys(message.sparqlResults[0]).map(key => ( + + ))} + + + + {message.sparqlResults.slice(0, 50).map((row, idx) => ( + + {Object.entries(row).map(([key, value]) => ( + + ))} + + ))} + +
{key}
+ {typeof value === 'string' && value.startsWith('http') ? ( + + {value.split('/').pop() || value} + + ) : ( + String(value ?? '') + )} +
+ {message.sparqlResults.length > 50 && ( +
+ {language === 'nl' + ? `Toont 50 van ${message.sparqlResults.length} resultaten` + : `Showing 50 of ${message.sparqlResults.length} results`} +
+ )} +
+
+ )} + {/* Chain-of-Thought Reasoning (GLM 4.7 Interleaved Thinking) */} {message.llmResponse?.reasoning_content && (