| import json |
| import asyncio |
| import re |
| import time as _time |
| from typing import Dict, List, Any, Optional |
| from ..base import BaseGraphStorage, BaseVectorStorage |
| from ..utils import logger, compute_mdhash_id |
| from ..prompts import get_prompts |
|
|
| PROMPTS = get_prompts("text2sql") |
| import os |
|
|
|
|
| def detect_schema_type(schema_data: Dict[str, Any]) -> str: |
| """ |
| Detect if schema has pre-built relationships or needs inference |
| |
| Args: |
| schema_data: Parsed JSON schema data |
| |
| Returns: |
| "db_summary" if has explicit relationships, "reconstruct" if needs inference |
| """ |
| |
| if "relationships" in schema_data: |
| return "db_summary" |
| |
| if "foreign_keys" in schema_data: |
| return "db_summary" |
| |
| |
| tables = schema_data.get("tables", {}) |
| for table_data in tables.values(): |
| if "foreign_keys" in table_data: |
| return "db_summary" |
| if "relationships" in table_data: |
| return "db_summary" |
| |
| columns = table_data.get("columns", []) |
| if columns and isinstance(columns, list): |
| for col in columns[:3]: |
| if isinstance(col, dict) and ("references_table" in col or "is_foreign_key" in col): |
| return "db_summary" |
| |
| |
| return "reconstruct" |
|
|
|
|
| class DatabaseSchemaBuilder: |
| """ |
| Database Schema Builder for QAFD_RAG with LLM Enhancement |
| |
| This class handles the manual construction of knowledge graphs from database schema JSON files, |
| with LLM-powered description enhancement and relationship weight optimization. |
| """ |
| |
| def __init__(self, |
| graph_storage: BaseGraphStorage, |
| entities_vdb: BaseVectorStorage, |
| relationships_vdb: BaseVectorStorage, |
| llm_model_func: callable, |
| schema_file_path: Optional[str] = None): |
| self.graph_storage = graph_storage |
| self.entities_vdb = entities_vdb |
| self.relationships_vdb = relationships_vdb |
| self.llm_model_func = llm_model_func |
| self.schema_file_path = schema_file_path |
| self._tables_info_cache = {} |
| |
| async def build_from_json_schema(self, |
| schema_file_path: str, |
| metadata_file_path: Optional[str] = None, |
| language: str = "English") -> Dict[str, Any]: |
| """ |
| Build knowledge graph from JSON schema file with LLM enhancement |
| |
| Args: |
| schema_file_path: Path to the JSON schema file |
| metadata_file_path: Optional path to metadata file |
| language: Output language for descriptions |
| |
| Returns: |
| Dictionary containing build statistics |
| """ |
| logger.info(f"Building knowledge graph from schema: {schema_file_path}") |
| |
| |
| self.schema_file_path = schema_file_path |
| |
| |
| with open(schema_file_path, 'r', encoding='utf-8') as f: |
| schema_data = json.load(f) |
| |
| |
| schema_type = detect_schema_type(schema_data) |
| logger.info(f"Detected schema type: {schema_type}") |
| |
| |
| metadata_content = None |
| if metadata_file_path: |
| with open(metadata_file_path, 'r', encoding='utf-8') as f: |
| metadata_content = f.read() |
| |
| |
| if schema_type == "db_summary": |
| logger.info("Processing DB summary with existing relationships") |
| tables_info = self._extract_tables_from_schema(schema_data) |
| else: |
| logger.info("Processing reconstruct data - will infer relationships from schema structure") |
| tables_info = self._extract_tables_from_reconstruct_schema(schema_data) |
| |
| self._tables_info_cache = tables_info |
| |
| |
| _t0 = _time.time() |
| _build_start = _t0 |
| print(f" [1/4] Inserting entities for {len(tables_info)} tables...", flush=True) |
| entities_added = await self._insert_schema_entities_minimal(tables_info) |
| print(f" [1/4] Done ({entities_added} entities, {_time.time()-_t0:.1f}s)", flush=True) |
|
|
| |
| _t0 = _time.time() |
| print(f" [2/4] Creating relationships...", flush=True) |
| if schema_type == "db_summary": |
| relationships_added = await self._create_schema_relationships(tables_info) |
| else: |
| relationships_added = await self._create_inferred_relationships(tables_info, schema_data) |
| print(f" [2/4] Done ({relationships_added} relationships, {_time.time()-_t0:.1f}s)", flush=True) |
|
|
| |
| duplicates_removed = await self.graph_storage.remove_duplicate_nodes() |
| if duplicates_removed > 0: |
| logger.info(f"Cleaned up {duplicates_removed} duplicate nodes") |
|
|
| |
| graph_stats = await self.graph_storage.get_graph_stats() |
| logger.info(f"Graph statistics: {graph_stats['total_nodes']} nodes, {graph_stats['total_edges']} edges") |
| logger.info(f"Node types: {graph_stats['node_types']}") |
|
|
| |
| if self.llm_model_func: |
| logger.info("Starting LLM enhancement phase...") |
|
|
| |
| self.edge_descriptions = {} |
| _t0 = _time.time() |
| print(f" [3/4] Enhancing descriptions with LLM...", flush=True) |
| await self._enhance_descriptions_with_llm_chunked(tables_info, metadata_content, language) |
| print(f" [3/4] Done ({_time.time()-_t0:.1f}s)", flush=True) |
| _t0 = _time.time() |
| print(f" [4/4] Enhancing relationship weights with LLM...", flush=True) |
| await self._enhance_relationship_weights_with_llm_chunked(metadata_content, language) |
| print(f" [4/4] Done ({_time.time()-_t0:.1f}s, total: {_time.time()-_build_start:.1f}s)", flush=True) |
| else: |
| logger.warning("No LLM function provided - skipping description enhancement") |
| return { |
| "schema_type": schema_type, |
| "tables_added": len(tables_info), |
| "entities_added": entities_added, |
| "relationships_added": relationships_added, |
| "duplicates_removed": duplicates_removed, |
| "graph_stats": graph_stats |
| } |
|
|
| def _extract_tables_from_reconstruct_schema(self, schema_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: |
| """Extract table and column information from reconstruct-style JSON schema""" |
| tables_info = {} |
| |
| |
| metadata = schema_data.get("metadata", {}) |
| |
| |
| tables = schema_data.get("tables", {}) |
| |
| for table_name, table_data in tables.items(): |
| table_info = { |
| "name": table_name, |
| "column_count": len(table_data.get("columns", [])), |
| "row_count": len(table_data.get("sample_data", [])), |
| "columns": [] |
| } |
| |
| |
| columns = table_data.get("columns", []) |
| for col in columns: |
| column_info = { |
| "name": col.get("name", ""), |
| "type": col.get("type", ""), |
| "description": col.get("description", ""), |
| "is_primary_key": False, |
| "is_foreign_key": False, |
| "not_null": False, |
| "default": None, |
| "references_table": None, |
| "references_column": None |
| } |
| |
| |
| if col.get("name", "").endswith("_id") and "id" in col.get("name", ""): |
| if col.get("name", "") == f"{table_name.split('.')[-1]}_id": |
| column_info["is_primary_key"] = True |
| |
| |
| if col.get("name", "").endswith("_id") and not column_info["is_primary_key"]: |
| potential_ref_table = col.get("name", "").replace("_id", "") |
| |
| for other_table in tables.keys(): |
| if potential_ref_table in other_table: |
| column_info["is_foreign_key"] = True |
| column_info["references_table"] = other_table |
| column_info["references_column"] = col.get("name", "") |
| break |
| |
| table_info["columns"].append(column_info) |
| |
| |
| if "sample_data" in table_data: |
| table_info["sample_rows"] = table_data["sample_data"] |
| |
| |
| if "project" in table_data: |
| table_info["project"] = table_data["project"] |
| if "dataset" in table_data: |
| table_info["dataset"] = table_data["dataset"] |
| |
| tables_info[table_name] = table_info |
| |
| return tables_info |
|
|
| async def _create_inferred_relationships(self, tables_info: Dict[str, Dict[str, Any]], |
| schema_data: Dict[str, Any]) -> int: |
| """Create inferred relationships for reconstruct data based on schema analysis""" |
| relationships_added = 0 |
| |
| |
| for table_name, table_data in tables_info.items(): |
| table_id = f'"{table_name}"' |
| |
| for column in table_data.get("columns", []): |
| column_name = column.get("name", "") |
| if not column_name: |
| continue |
| |
| col_id = f'"{table_name}.{column_name}"' |
| |
| |
| edge_data = { |
| "weight": 8.0, |
| "description": f"Table '{table_name}' contains column '{column_name}'", |
| "keywords": "table_structure, contains_column", |
| "source_id": "reconstruct_inference" |
| } |
| |
| await self.graph_storage.upsert_edge(table_id, col_id, edge_data=edge_data) |
| relationships_added += 1 |
| |
| |
| table_names = list(tables_info.keys()) |
| for i, table1 in enumerate(table_names): |
| for j, table2 in enumerate(table_names): |
| if i >= j: |
| continue |
| |
| table1_data = tables_info[table1] |
| table2_data = tables_info[table2] |
| |
| relationship_weight = 0.0 |
| relationship_type = "related_to" |
| |
| |
| if (table1_data.get("project") == table2_data.get("project") and |
| table1_data.get("dataset") == table2_data.get("dataset")): |
| relationship_weight = 6.0 |
| relationship_type = "same_dataset" |
| |
| |
| table1_base = table1.split('.')[-1] |
| table2_base = table2.split('.')[-1] |
| if any(word in table1_base for word in table2_base.split('_')) or \ |
| any(word in table2_base for word in table1_base.split('_')): |
| if relationship_weight < 5.0: |
| relationship_weight = 5.0 |
| relationship_type = "semantically_related" |
| |
| |
| if relationship_weight > 4.0: |
| table1_id = f'"{table1}"' |
| table2_id = f'"{table2}"' |
| |
| edge_data = { |
| "weight": relationship_weight, |
| "description": f"Inferred {relationship_type} relationship between '{table1}' and '{table2}'", |
| "keywords": f"inferred_relationship, {relationship_type}", |
| "source_id": "reconstruct_inference" |
| } |
| |
| await self.graph_storage.upsert_edge(table1_id, table2_id, edge_data=edge_data) |
| relationships_added += 1 |
| |
| logger.info(f"Created {relationships_added} inferred relationships for reconstruct data") |
| return relationships_added |
| |
| def _extract_tables_from_schema(self, schema_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: |
| """Extract table and column information from JSON schema""" |
| tables_info = {} |
| |
| |
| tables = schema_data.get("tables", {}) |
| |
| for table_name, table_data in tables.items(): |
| table_info = { |
| "name": table_name, |
| "column_count": table_data.get("column_count", 0), |
| "row_count": table_data.get("row_count", 0), |
| "columns": [] |
| } |
| |
| |
| columns = table_data.get("columns", []) |
| for col in columns: |
| column_info = { |
| "name": col.get("name", ""), |
| "type": col.get("type", ""), |
| "is_primary_key": col.get("is_primary_key", False), |
| "is_foreign_key": col.get("is_foreign_key", False), |
| "not_null": col.get("not_null", False), |
| "default": col.get("default", None), |
| "references_table": col.get("references_table", None), |
| "references_column": col.get("references_column", None) |
| } |
| table_info["columns"].append(column_info) |
| |
| |
| table_level_fks = table_data.get("foreign_keys", []) |
| if table_level_fks: |
| logger.info(f"Processing {len(table_level_fks)} table-level FKs for {table_name}") |
| |
| for fk in table_level_fks: |
| col_name = fk.get("column") |
| ref_info = fk.get("references", {}) |
| ref_table = ref_info.get("table") |
| ref_column = ref_info.get("column") |
| |
| if not col_name or not ref_table or not ref_column: |
| continue |
| |
| |
| for col_info in table_info["columns"]: |
| if col_info["name"] == col_name: |
| col_info["is_foreign_key"] = True |
| |
| if not col_info["references_table"]: |
| col_info["references_table"] = ref_table |
| logger.info(f"Set FK: {table_name}.{col_name} -> {ref_table}.{ref_column}") |
| if not col_info["references_column"]: |
| col_info["references_column"] = ref_column |
| break |
| |
| tables_info[table_name] = table_info |
| |
| return tables_info |
| |
| async def _insert_schema_entities_minimal(self, tables_info: Dict[str, Dict[str, Any]]) -> int: |
| """ |
| Insert tables and columns as entities with MINIMAL descriptions for LLM enhancement |
| |
| Args: |
| tables_info: Dictionary of table information |
| |
| Returns: |
| Number of entities added |
| """ |
| entities_added = 0 |
| entities_for_vdb = {} |
| |
| for table_name, table_data in tables_info.items(): |
| |
| table_id = f'"{table_name}"' |
| table_node_data = { |
| "entity_type": "complete_table", |
| "description": f"Table: {table_name}", |
| "source_id": "schema_extraction", |
| "table_name": table_name, |
| "column_count": table_data["column_count"], |
| "row_count": table_data["row_count"] |
| } |
| |
| await self.graph_storage.upsert_node(table_id, node_data=table_node_data) |
| |
| |
| table_vdb_id = compute_mdhash_id(table_id, prefix="ent-") |
| entities_for_vdb[table_vdb_id] = { |
| "content": table_id + " " + table_node_data["description"], |
| "entity_name": table_id |
| } |
| |
| entities_added += 1 |
| |
| |
| for col in table_data["columns"]: |
| col_name = col["name"] |
| if not col_name: |
| continue |
| |
| col_id = f'"{table_name}.{col_name}"' |
| col_node_data = { |
| "entity_type": "column", |
| "description": f"Column: {table_name}.{col_name}", |
| "source_id": "schema_extraction", |
| "table_name": table_name, |
| "column_name": col_name, |
| "data_type": col["type"], |
| "is_primary_key": col["is_primary_key"], |
| "is_foreign_key": col["is_foreign_key"], |
| "not_null": col["not_null"] |
| } |
| |
| |
| if col["default"] is not None: |
| col_node_data["default"] = col["default"] |
| if col["references_table"] is not None: |
| col_node_data["references_table"] = col["references_table"] |
| if col["references_column"] is not None: |
| col_node_data["references_column"] = col["references_column"] |
| |
| await self.graph_storage.upsert_node(col_id, node_data=col_node_data) |
| |
| |
| col_vdb_id = compute_mdhash_id(col_id, prefix="ent-") |
| entities_for_vdb[col_vdb_id] = { |
| "content": col_id + " " + col_node_data["description"], |
| "entity_name": col_id |
| } |
| |
| entities_added += 1 |
| |
| |
| if entities_for_vdb and self.entities_vdb: |
| await self.entities_vdb.upsert(entities_for_vdb) |
| logger.info(f"Inserted {len(entities_for_vdb)} entities into vector database") |
| |
| return entities_added |
| |
| async def _create_schema_relationships(self, tables_info: Dict[str, Dict[str, Any]]) -> int: |
| """Create relationships between tables and columns with fully qualified names""" |
| relationships_added = 0 |
| relationships_for_vdb = {} |
|
|
| |
| logger.info(f"Creating relationships for {len(tables_info)} tables") |
| available_tables = set(tables_info.keys()) |
| logger.info(f"Available tables: {sorted(available_tables)}") |
|
|
| for table_name, table_data in tables_info.items(): |
| table_id = f'"{table_name}"' |
| logger.info(f"Processing table: {table_name}") |
|
|
| for col in table_data["columns"]: |
| col_name = col["name"] |
| if not col_name: |
| continue |
|
|
| col_id = f'"{table_name}.{col_name}"' |
|
|
| |
| edge_data = { |
| "weight": 10.0, |
| "description": f"Table '{table_name}' contains column '{col_name}'", |
| "keywords": "table_structure, contains_column", |
| "source_id": "schema_extraction" |
| } |
|
|
| await self.graph_storage.upsert_edge(table_id, col_id, edge_data=edge_data) |
|
|
| |
| edge_vdb_id = compute_mdhash_id(f"{table_id}->{col_id}", prefix="rel-") |
| relationships_for_vdb[edge_vdb_id] = { |
| "src_id": table_id, |
| "tgt_id": col_id, |
| "content": edge_data["keywords"] + " " + table_id + " " + col_id + " " + edge_data["description"] |
| } |
|
|
| relationships_added += 1 |
|
|
| |
| if col["is_foreign_key"] and col["references_table"] and col["references_column"]: |
| ref_table_full = col["references_table"] |
| ref_column = col["references_column"] |
|
|
| logger.info(f"Processing FK: {col_name} -> {ref_table_full}.{ref_column}") |
|
|
| |
| if ref_table_full in available_tables: |
| ref_table_id = f'"{ref_table_full}"' |
| ref_col_id = f'"{ref_table_full}.{ref_column}"' |
|
|
| logger.info(f"Creating FK edge: {col_id} -> {ref_col_id}") |
|
|
| fk_edge_data = { |
| "weight": 15.0, |
| "description": f"Foreign key: '{col_name}' in '{table_name}' " |
| f"references '{ref_column}' in '{ref_table_full}'", |
| "keywords": "foreign_key, references, data_integrity", |
| "source_id": "schema_extraction" |
| } |
|
|
| await self.graph_storage.upsert_edge(col_id, ref_col_id, edge_data=fk_edge_data) |
|
|
| |
| fk_edge_vdb_id = compute_mdhash_id(f"{col_id}->{ref_col_id}", prefix="rel-") |
| relationships_for_vdb[fk_edge_vdb_id] = { |
| "src_id": col_id, |
| "tgt_id": ref_col_id, |
| "content": fk_edge_data["keywords"] + " " + col_id + " " + |
| ref_col_id + " " + fk_edge_data["description"] |
| } |
|
|
| relationships_added += 1 |
| logger.info(f"Successfully created FK relationship: {col_id} -> {ref_col_id}") |
| else: |
| logger.warning(f"Referenced table '{ref_table_full}' not found in schema for FK {col_name}") |
| logger.warning(f"Available tables: {sorted(available_tables)}") |
|
|
| elif col["is_foreign_key"]: |
| logger.warning( |
| f"FK column {col_name} missing reference info: " |
| f"references_table={col.get('references_table')}, " |
| f"references_column={col.get('references_column')}" |
| ) |
|
|
| |
| if relationships_for_vdb and self.relationships_vdb: |
| await self.relationships_vdb.upsert(relationships_for_vdb) |
| logger.info(f"Inserted {len(relationships_for_vdb)} relationships into vector database") |
|
|
| logger.info(f"Total relationships created: {relationships_added}") |
| return relationships_added |
|
|
| |
| async def _enhance_descriptions_with_llm_chunked(self, |
| tables_info: Dict[str, Dict[str, Any]], |
| metadata_content: Optional[str], |
| language: str) -> None: |
| """ |
| Use LLM to enhance entity descriptions with chunking to avoid token limits |
| """ |
| logger.info("Enhancing descriptions with LLM (chunked)...") |
| |
| from ..utils import encode_string_by_tiktoken |
| |
| |
| max_tokens_per_chunk = 8000 |
| base_prompt_tokens = 1000 |
| response_buffer_tokens = 1000 |
| available_tokens = max_tokens_per_chunk - base_prompt_tokens - response_buffer_tokens |
| |
| |
| table_chunks = self._chunk_tables_by_tokens(tables_info, available_tokens) |
| |
| logger.info(f"Processing {len(tables_info)} tables in {len(table_chunks)} chunks") |
| _chunk_start = _time.time() |
|
|
| |
| for i, chunk_tables in enumerate(table_chunks, 1): |
| logger.info(f"Processing chunk {i}/{len(table_chunks)} with {len(chunk_tables)} tables") |
| elapsed = _time.time() - _chunk_start |
| if i > 1: |
| eta = elapsed / (i - 1) * (len(table_chunks) - i + 1) |
| print(f"\r [3/4] Enhancing descriptions: chunk {i}/{len(table_chunks)} (ETA: {eta:.0f}s)", end='', flush=True) |
| else: |
| print(f"\r [3/4] Enhancing descriptions: chunk {i}/{len(table_chunks)}", end='', flush=True) |
|
|
| try: |
| |
| chunk_schema_text = self._format_schema_for_llm({"tables": chunk_tables}) |
| prompt = self._create_description_enhancement_prompt(chunk_schema_text, metadata_content, language) |
| |
| |
| prompt_tokens = len(encode_string_by_tiktoken(prompt)) |
| |
| if prompt_tokens > max_tokens_per_chunk: |
| logger.warning(f"Chunk {i} still exceeds token limit ({prompt_tokens} tokens), skipping") |
| continue |
| |
| logger.info(f"Chunk {i}: Sending {prompt_tokens} tokens to LLM...") |
| |
| |
| enhanced_result = await self.llm_model_func(prompt) |
| |
| logger.info(f"Chunk {i}: Received LLM response, applying enhancements...") |
| |
| |
| await self._apply_enhanced_descriptions(enhanced_result, chunk_tables) |
| |
| except Exception as e: |
| logger.error(f"Error enhancing descriptions for chunk {i}: {e}") |
| continue |
| print(flush=True) |
|
|
| async def _enhance_relationship_weights_with_llm_chunked(self, |
| metadata_content: Optional[str], |
| language: str = "English") -> None: |
| """ |
| Use LLM to enhance relationship weights with chunking to avoid token limits |
| """ |
| logger.info("Enhancing relationship weights with LLM (chunked)...") |
| |
| from ..utils import encode_string_by_tiktoken |
| |
| |
| max_tokens_per_chunk = 100000 |
| base_prompt_tokens = 3000 |
| response_buffer_tokens = 2000 |
| available_tokens = max_tokens_per_chunk - base_prompt_tokens - response_buffer_tokens |
| |
| |
| relationships_list = await self._get_all_relationships() |
| if not relationships_list.strip(): |
| logger.warning("No relationships found for weight enhancement") |
| return |
| |
| |
| relationship_chunks = self._chunk_relationships_by_tokens(relationships_list, available_tokens) |
| |
| logger.info(f"Processing relationships in {len(relationship_chunks)} chunks") |
| _chunk_start = _time.time() |
|
|
| |
| for i, chunk_relationships in enumerate(relationship_chunks, 1): |
| logger.info(f"Processing relationship chunk {i}/{len(relationship_chunks)}") |
| elapsed = _time.time() - _chunk_start |
| if i > 1: |
| eta = elapsed / (i - 1) * (len(relationship_chunks) - i + 1) |
| print(f"\r [4/4] Enhancing weights: chunk {i}/{len(relationship_chunks)} (ETA: {eta:.0f}s)", end='', flush=True) |
| else: |
| print(f"\r [4/4] Enhancing weights: chunk {i}/{len(relationship_chunks)}", end='', flush=True) |
| |
| try: |
| |
| involved_tables = self._extract_tables_from_relationships(chunk_relationships) |
| minimal_schema = self._create_minimal_schema_for_tables(involved_tables) |
| |
| |
| prompt = self._create_weight_enhancement_prompt(minimal_schema, metadata_content, chunk_relationships, language) |
| |
| |
| prompt_tokens = len(encode_string_by_tiktoken(prompt)) |
| if prompt_tokens > max_tokens_per_chunk: |
| logger.warning(f"Relationship chunk {i} exceeds token limit ({prompt_tokens} tokens), skipping") |
| continue |
| |
| logger.info(f"Relationship chunk {i}: Sending {prompt_tokens} tokens to LLM...") |
| |
| |
| enhanced_result = await self.llm_model_func(prompt) |
| |
| logger.info(f"Relationship chunk {i}: Received LLM response, applying weight enhancements...") |
| |
| |
| await self._apply_enhanced_weights(enhanced_result) |
| |
| except Exception as e: |
| logger.error(f"Error enhancing weights for chunk {i}: {e}") |
| continue |
| |
| async def _get_all_relationships(self) -> str: |
| """Get all relationships from the graph for LLM processing""" |
| relationships = [] |
| |
| try: |
| |
| all_edges = await self.graph_storage.edges() |
| |
| if all_edges: |
| |
| edges_list = list(all_edges) |
| logger.info(f"Found {len(edges_list)} edges in graph") |
| |
| for source, target in edges_list: |
| |
| edge_data = await self.graph_storage.get_edge(source, target) |
| if edge_data: |
| current_weight = edge_data.get('weight', 1.0) |
| description = edge_data.get('description', 'unknown') |
| relationship_info = f"- {source} -> {target} (current_weight: {current_weight}, description: {description})" |
| relationships.append(relationship_info) |
| else: |
| logger.warning(f"Could not get edge data for {source} -> {target}") |
| else: |
| |
| logger.warning("Graph is empty, falling back to schema-based relationships") |
| for table_name, table_data in self._tables_info_cache.items(): |
| table_id = f'"{table_name}"' |
| |
| for col in table_data.get("columns", []): |
| col_name = col.get("name", "") |
| if col_name: |
| col_id = f'"{table_name}.{col_name}"' |
| |
| |
| relationship_info = f"- {table_id} -> {col_id} (current_weight: 10.0, description: table_structure)" |
| relationships.append(relationship_info) |
| |
| |
| if col.get("is_foreign_key") and col.get("references_table") and col.get("references_column"): |
| ref_table_id = f'"{col["references_table"]}"' |
| ref_col_id = f'"{col["references_table"]}.{col["references_column"]}"' |
| |
| fk_relationship_info = f"- {col_id} -> {ref_col_id} (current_weight: 9.0, description: foreign_key)" |
| relationships.append(fk_relationship_info) |
| |
| except Exception as e: |
| logger.error(f"Error getting relationships from graph: {e}") |
| |
| relationships = [] |
| |
| return "\n".join(relationships) |
| |
| def _create_weight_enhancement_prompt(self, |
| schema_text: str, |
| metadata_content: Optional[str], |
| relationships_list: str, |
| language: str) -> str: |
| """Create prompt for LLM weight enhancement""" |
| |
| try: |
| |
| if self.schema_file_path and os.path.exists(self.schema_file_path): |
| with open(self.schema_file_path, 'r', encoding='utf-8') as f: |
| complete_schema_data = json.load(f) |
| complete_schema_text = json.dumps(complete_schema_data, indent=2, ensure_ascii=False) |
| else: |
| complete_schema_text = schema_text |
| except Exception as e: |
| logger.warning(f"Could not load complete schema file: {e}") |
| complete_schema_text = schema_text |
| |
| |
| prompt_template = PROMPTS["enhanced_graph_weight_assignment"] |
| |
| |
| prompt = prompt_template.format( |
| language=language, |
| schema_text=complete_schema_text, |
| metadata_content=metadata_content or "No additional metadata provided.", |
| edge_list=relationships_list |
| ) |
| |
| return prompt |
| |
| async def _apply_enhanced_weights(self, enhanced_result: str) -> None: |
| """Apply enhanced weights from LLM to graph relationships with improved debugging""" |
| try: |
| logger.info(f"Raw LLM weight response (first 500 chars): {enhanced_result[:500]}...") |
| |
| |
| import re |
| |
| |
| json_patterns = [ |
| r'\{.*\}', |
| r'```json\s*(\{.*?\})\s*```', |
| r'```\s*(\{.*?\})\s*```', |
| r'```\s*(\{.*\})\s*```', |
| ] |
|
|
| enhanced_data = None |
| for pattern in json_patterns: |
| json_match = re.search(pattern, enhanced_result, re.DOTALL) |
|
|
| if json_match: |
| try: |
| json_str = json_match.group(1) if len(json_match.groups()) > 0 else json_match.group(0) |
| enhanced_data = json.loads(json_str) |
| logger.info(f"Successfully parsed JSON with keys: {list(enhanced_data.keys())}") |
| break |
| except json.JSONDecodeError: |
| continue |
|
|
| if not enhanced_data: |
| logger.warning("No valid JSON found in enhanced result, skipping weight enhancement") |
| return |
|
|
| |
| relationship_weights = enhanced_data.get("relationship_weights", {}) |
| weighting_rationale = enhanced_data.get("weighting_rationale", {}) |
| |
| logger.info(f"Found {len(relationship_weights)} relationship weights to apply") |
| |
| weights_updated = 0 |
| for relationship_key, llm_score in relationship_weights.items(): |
| if '->' in relationship_key: |
| source, target = relationship_key.split('->', 1) |
| |
| |
| source = source.strip().strip('"') |
| target = target.strip().strip('"') |
| |
| |
| source_with_quotes = f'"{source}"' |
| target_with_quotes = f'"{target}"' |
| |
| |
| edge_data = await self.graph_storage.get_edge(source_with_quotes, target_with_quotes) |
| actual_source = source_with_quotes |
| actual_target = target_with_quotes |
| |
| if not edge_data: |
| |
| edge_data = await self.graph_storage.get_edge(source, target) |
| actual_source = source |
| actual_target = target |
| |
| |
| if edge_data: |
| |
| |
| if relationship_key in self.edge_descriptions: |
| if 'original_description' not in edge_data: |
| edge_data['original_description'] = edge_data.get('description', '') |
| edge_data['description'] = self.edge_descriptions[relationship_key] |
| |
| |
| original_weight = edge_data.get('weight', 1.0) |
| |
| |
| enhanced_weight = original_weight * llm_score |
| |
| |
| edge_data['weight'] = enhanced_weight |
| edge_data['llm_enhanced'] = True |
| edge_data['llm_score'] = llm_score |
| edge_data['original_weight'] = original_weight |
| |
| |
| if relationship_key in weighting_rationale: |
| edge_data['weighting_rationale'] = weighting_rationale[relationship_key] |
| |
| await self.graph_storage.upsert_edge(actual_source, actual_target, edge_data=edge_data) |
| weights_updated += 1 |
| logger.info(f"Updated weight for {relationship_key}: {original_weight} * {llm_score} = {enhanced_weight}") |
| else: |
| logger.warning(f"Edge not found in graph: {source} -> {target} (tried both quoted and unquoted formats)") |
| |
| logger.info(f"Successfully updated {weights_updated} relationship weights with LLM enhancement") |
| |
| except json.JSONDecodeError as e: |
| logger.error(f"Error parsing JSON from enhanced result: {e}") |
| except Exception as e: |
| logger.error(f"Error applying enhanced weights: {e}") |
| |
| def _format_schema_for_llm(self, tables_info: Dict[str, Dict[str, Any]]) -> str: |
| """Format schema information for LLM processing""" |
| schema_text = "JSON Schema:\n" |
| schema_text += json.dumps({"tables": tables_info}, indent=2, ensure_ascii=False) |
| return schema_text |
| |
| def _create_description_enhancement_prompt(self, |
| schema_text: str, |
| metadata_content: Optional[str], |
| language: str) -> str: |
| """Create prompt for LLM description enhancement using only chunked data""" |
| |
| |
| prompt_template = PROMPTS["enhanced_graph_description"] |
| |
| prompt = prompt_template.format( |
| language=language, |
| schema_text=schema_text, |
| metadata_content=metadata_content or "No additional metadata provided." |
| ) |
| |
| return prompt |
| |
| async def _apply_enhanced_descriptions(self, |
| enhanced_result: str, |
| tables_info: Dict[str, Dict[str, Any]]) -> None: |
| """Apply enhanced descriptions from LLM to graph entities and edges""" |
| try: |
| logger.info(f"Raw LLM description response (first 500 chars): {enhanced_result[:500]}...") |
| |
| |
| all_nodes = await self.graph_storage.nodes() |
| existing_node_ids = list(all_nodes) if all_nodes else [] |
| logger.info(f"Total nodes in graph: {len(existing_node_ids)}") |
| |
| |
| import re |
| |
| |
| json_patterns = [ |
| r'\{.*\}', |
| r'```json\s*(\{.*?\})\s*```', |
| r'```\s*(\{.*?\})\s*```', |
| r'```\s*(\{.*\})\s*```', |
| ] |
|
|
| enhanced_data = None |
| for pattern in json_patterns: |
| json_match = re.search(pattern, enhanced_result, re.DOTALL) |
|
|
| if json_match: |
| try: |
| json_str = json_match.group(1) if len(json_match.groups()) > 0 else json_match.group(0) |
| enhanced_data = json.loads(json_str) |
| logger.info(f"Successfully parsed JSON with keys: {list(enhanced_data.keys())}") |
| break |
| except json.JSONDecodeError as e: |
| logger.warning(f"JSON parse failed for pattern {pattern}: {e}") |
| continue |
| |
| if not enhanced_data: |
| logger.warning("No valid JSON found in enhanced result, skipping description enhancement") |
| return |
| |
| |
| |
| |
| |
| |
| table_descriptions = enhanced_data.get("table_descriptions", {}) |
| logger.info(f"Found {len(table_descriptions)} table descriptions to apply") |
| |
| for table_name, description in table_descriptions.items(): |
| |
| table_ids_to_try = [ |
| f'"{table_name}"', |
| f'"{table_name.lower()}"', |
| f'"{table_name.upper()}"' |
| ] |
| |
| description_applied = False |
| for table_id in table_ids_to_try: |
| if await self.graph_storage.has_node(table_id): |
| |
| existing_data = await self.graph_storage.get_node(table_id) |
| if existing_data: |
| existing_data["description"] = description |
| existing_data["llm_enhanced"] = True |
| await self.graph_storage.upsert_node(table_id, existing_data) |
| logger.info(f"Updated table description for {table_name} (ID: {table_id})") |
| description_applied = True |
| break |
| |
| if not description_applied: |
| logger.warning(f"Could not find table node for: {table_name}") |
| |
| |
| column_descriptions = enhanced_data.get("column_descriptions", {}) |
| logger.info(f"Found {len(column_descriptions)} column descriptions to apply") |
| |
| for column_name, description in column_descriptions.items(): |
| |
| column_ids_to_try = [ |
| f'"{column_name}"', |
| f'"{column_name.lower()}"', |
| f'"{column_name.upper()}"' |
| ] |
| |
| description_applied = False |
| for column_id in column_ids_to_try: |
| if await self.graph_storage.has_node(column_id): |
| |
| existing_data = await self.graph_storage.get_node(column_id) |
| if existing_data: |
| existing_data["description"] = description |
| existing_data["llm_enhanced"] = True |
| await self.graph_storage.upsert_node(column_id, existing_data) |
| logger.info(f"Updated column description for {column_name} (ID: {column_id})") |
| description_applied = True |
| break |
| |
| if not description_applied: |
| logger.warning(f"Could not find column node for: {column_name}") |
| |
| |
| |
| |
| |
| entities_added = 0 |
| entities_for_vdb = {} |
| |
| for table_name, description in table_descriptions.items(): |
| table_id = f'"{table_name}"' |
| |
| table_vdb_id = compute_mdhash_id(table_id, prefix="ent-") |
| entities_for_vdb[table_vdb_id] = { |
| "content": table_id + " " + description, |
| "entity_name": table_id |
| } |
| entities_added += 1 |
| |
| for col_name, description in column_descriptions.items(): |
| col_id = f'"{col_name}"' |
| |
| col_vdb_id = compute_mdhash_id(col_id, prefix="ent-") |
| entities_for_vdb[col_vdb_id] = { |
| "content": col_id + " " + description, |
| "entity_name": col_id |
| } |
| entities_added += 1 |
| |
| if entities_for_vdb and self.entities_vdb: |
| await self.entities_vdb.upsert(entities_for_vdb) |
| logger.info(f"Inserted llm enhanced {len(entities_for_vdb)} entities into vector database") |
| |
| |
| |
| |
| |
| |
| table_column_descriptions = enhanced_data.get("table_column_relationship_descriptions", {}) |
| column_column_descriptions = enhanced_data.get("column_relationship_descriptions", {}) |
| |
| logger.info(f"Found {len(table_column_descriptions)} table-column relationship descriptions") |
| logger.info(f"Found {len(column_column_descriptions)} column-column relationship descriptions") |
| |
| |
| edges_enhanced = 0 |
| for rel_key, description in table_column_descriptions.items(): |
| if '->' in rel_key: |
| source, target = rel_key.split('->', 1) |
| source = source.strip().strip('"') |
| target = target.strip().strip('"') |
| |
| source_quoted = f'"{source}"' |
| target_quoted = f'"{target}"' |
| |
| |
| edge_data = await self.graph_storage.get_edge(source_quoted, target_quoted) |
| actual_source = source_quoted |
| actual_target = target_quoted |
| |
| if not edge_data: |
| edge_data = await self.graph_storage.get_edge(source, target) |
| actual_source = source |
| actual_target = target |
| |
| if edge_data: |
| |
| if 'description' in edge_data and 'original_description' not in edge_data: |
| edge_data['original_description'] = edge_data['description'] |
| |
| edge_data['description'] = description |
| edge_data['llm_enhanced'] = True |
| await self.graph_storage.upsert_edge(actual_source, actual_target, edge_data=edge_data) |
| edges_enhanced += 1 |
| logger.info(f"Enhanced table-column edge: {source} -> {target}") |
| else: |
| logger.warning(f"Could not find graph edge for: {source} -> {target}") |
| |
| |
| for rel_key, description in column_column_descriptions.items(): |
| if '->' in rel_key: |
| source, target = rel_key.split('->', 1) |
| source = source.strip().strip('"') |
| target = target.strip().strip('"') |
| |
| source_quoted = f'"{source}"' |
| target_quoted = f'"{target}"' |
| |
| edge_data = await self.graph_storage.get_edge(source_quoted, target_quoted) |
| actual_source = source_quoted |
| actual_target = target_quoted |
| |
| if not edge_data: |
| edge_data = await self.graph_storage.get_edge(source, target) |
| actual_source = source |
| actual_target = target |
| |
| if edge_data: |
| |
| if 'description' in edge_data and 'original_description' not in edge_data: |
| edge_data['original_description'] = edge_data['description'] |
| |
| edge_data['description'] = description |
| edge_data['llm_enhanced'] = True |
| await self.graph_storage.upsert_edge(actual_source, actual_target, edge_data=edge_data) |
| edges_enhanced += 1 |
| logger.info(f"Enhanced col-col edge: {source} -> {target}") |
| else: |
| logger.warning(f"Could not find graph edge for: {source} -> {target}") |
| |
| logger.info(f"Enhanced {edges_enhanced} graph edges with LLM descriptions") |
| |
| |
| |
| |
| |
| relationships_added = 0 |
| relationships_for_vdb = {} |
|
|
| for relationship_key, description in table_column_descriptions.items(): |
| if '->' in relationship_key: |
| table_name, column_name = relationship_key.split('->') |
| table_name = table_name.strip() |
| column_name = column_name.strip() |
| |
| table_id = f'"{table_name}"' |
| col_id = f'"{column_name}"' |
|
|
| |
| edge_vdb_id = compute_mdhash_id(f"{table_id}->{col_id}", prefix="rel-") |
| relationships_for_vdb[edge_vdb_id] = { |
| "src_id": table_id, |
| "tgt_id": col_id, |
| "content": "table_structure, contains_column" + " " + table_id + " " + col_id + " " + description |
| } |
| relationships_added += 1 |
|
|
| for relationship_key, description in column_column_descriptions.items(): |
| if '->' in relationship_key: |
| col_name, ref_col_name = relationship_key.split('->') |
| col_name = col_name.strip() |
| ref_col_name = ref_col_name.strip() |
| |
| col_id = f'"{col_name}"' |
| ref_col_id = f'"{ref_col_name}"' |
|
|
| |
| fk_edge_vdb_id = compute_mdhash_id(f"{col_id}->{ref_col_id}", prefix="rel-") |
| relationships_for_vdb[fk_edge_vdb_id] = { |
| "src_id": col_id, |
| "tgt_id": ref_col_id, |
| "content": "foreign_key, references, data_integrity" + " " + col_id + " " + ref_col_id + " " + description |
| } |
| relationships_added += 1 |
|
|
| if relationships_for_vdb and self.relationships_vdb: |
| await self.relationships_vdb.upsert(relationships_for_vdb) |
| logger.info(f"Inserted llm enhanced {len(relationships_for_vdb)} relationships into vector database") |
| |
| |
| |
| |
| |
| |
| temp_d = enhanced_data.get("relationship_descriptions", {}) |
| if len(temp_d) > 0: |
| self.edge_descriptions = self.edge_descriptions | temp_d |
| |
| |
| self.edge_descriptions = self.edge_descriptions | table_column_descriptions |
| self.edge_descriptions = self.edge_descriptions | column_column_descriptions |
| |
| logger.info(f"Stored {len(self.edge_descriptions)} edge descriptions for weight enhancement phase") |
| |
| |
| |
| |
| |
| |
| data_insights = enhanced_data.get("data_insights", {}) |
| if data_insights: |
| logger.info(f"Data insights extracted: {data_insights}") |
| |
| logger.info("Finished applying enhanced descriptions") |
| |
| except json.JSONDecodeError as e: |
| logger.error(f"Error parsing JSON from enhanced result: {e}") |
| except Exception as e: |
| logger.error(f"Error applying enhanced descriptions: {e}") |
| import traceback |
| logger.error(f"Full traceback: {traceback.format_exc()}") |
| |
| |
| def _chunk_tables_by_tokens(self, tables_info: Dict[str, Dict[str, Any]], max_tokens: int) -> List[Dict[str, Dict[str, Any]]]: |
| """Split tables into chunks based on token limits, with column-level chunking for large tables""" |
| from ..utils import encode_string_by_tiktoken |
| |
| all_chunks = [] |
| current_chunk = {} |
| current_tokens = 0 |
| |
| for table_name, table_data in tables_info.items(): |
| |
| table_text = json.dumps({table_name: table_data}, indent=2) |
| table_tokens = len(encode_string_by_tiktoken(table_text)) |
| |
| |
| if table_tokens > max_tokens: |
| logger.info(f"Table {table_name} too large ({table_tokens} tokens), splitting by columns") |
| |
| |
| columns = table_data.get("columns", []) |
| max_columns_per_chunk = 15 |
| |
| for i in range(0, len(columns), max_columns_per_chunk): |
| chunk_columns = columns[i:i + max_columns_per_chunk] |
| |
| |
| chunk_table_data = table_data.copy() |
| chunk_table_data["columns"] = chunk_columns |
| chunk_table_data["column_count"] = len(chunk_columns) |
| |
| |
| chunk_table_data.pop("sample_data", None) |
| chunk_table_data.pop("sample_rows", None) |
| |
| |
| chunk_text = json.dumps({table_name: chunk_table_data}, indent=2) |
| chunk_tokens = len(encode_string_by_tiktoken(chunk_text)) |
| |
| if chunk_tokens > max_tokens: |
| |
| smaller_max = 5 |
| for j in range(i, min(i + max_columns_per_chunk, len(columns)), smaller_max): |
| smaller_chunk_columns = columns[j:j + smaller_max] |
| smaller_chunk_data = table_data.copy() |
| smaller_chunk_data["columns"] = smaller_chunk_columns |
| smaller_chunk_data["column_count"] = len(smaller_chunk_columns) |
| smaller_chunk_data.pop("sample_data", None) |
| smaller_chunk_data.pop("sample_rows", None) |
| |
| all_chunks.append({table_name: smaller_chunk_data}) |
| else: |
| |
| all_chunks.append({table_name: chunk_table_data}) |
| |
| else: |
| |
| if current_tokens + table_tokens > max_tokens and current_chunk: |
| all_chunks.append(current_chunk) |
| current_chunk = {table_name: table_data} |
| current_tokens = table_tokens |
| else: |
| current_chunk[table_name] = table_data |
| current_tokens += table_tokens |
| |
| |
| if current_chunk: |
| all_chunks.append(current_chunk) |
| |
| return all_chunks |
|
|
| def _chunk_relationships_by_tokens(self, relationships_list: str, max_tokens: int) -> List[str]: |
| """Split relationships into chunks based on token limits""" |
| from ..utils import encode_string_by_tiktoken |
| |
| relationships = relationships_list.strip().split('\n') |
| chunks = [] |
| current_chunk_lines = [] |
| current_tokens = 0 |
| |
| for relationship in relationships: |
| if not relationship.strip(): |
| continue |
| |
| relationship_tokens = len(encode_string_by_tiktoken(relationship)) |
| |
| |
| if current_tokens + relationship_tokens > max_tokens and current_chunk_lines: |
| chunks.append('\n'.join(current_chunk_lines)) |
| current_chunk_lines = [relationship] |
| current_tokens = relationship_tokens |
| else: |
| current_chunk_lines.append(relationship) |
| current_tokens += relationship_tokens |
| |
| |
| if current_chunk_lines: |
| chunks.append('\n'.join(current_chunk_lines)) |
| |
| return chunks |
|
|
| def _extract_tables_from_relationships(self, relationships_chunk: str) -> List[str]: |
| """Extract fully qualified table names from this chunk of relationships""" |
| import re |
| |
| tables = set() |
| |
| for line in relationships_chunk.split('\n'): |
| |
| matches = re.findall(r'"([^"]+)"', line) |
| for match in matches: |
| if '.' in match: |
| parts = match.split('.') |
| if len(parts) > 1: |
| |
| table_name = '.'.join(parts[:-1]) |
| tables.add(table_name) |
| else: |
| tables.add(match) |
| else: |
| tables.add(match) |
| |
| return list(tables) |
|
|
|
|
| def _create_minimal_schema_for_tables(self, table_names: List[str]) -> str: |
| """Create minimal schema containing only the specified tables""" |
| minimal_tables = {} |
| |
| |
| for table_name in table_names: |
| if table_name in self._tables_info_cache: |
| |
| table_data = self._tables_info_cache[table_name] |
| minimal_tables[table_name] = { |
| "name": table_data["name"], |
| "column_count": table_data["column_count"], |
| "columns": [ |
| { |
| "name": col["name"], |
| "type": col["type"], |
| "is_primary_key": col.get("is_primary_key", False), |
| "is_foreign_key": col.get("is_foreign_key", False) |
| } |
| for col in table_data.get("columns", []) |
| ] |
| } |
| |
| return json.dumps({"tables": minimal_tables}, indent=2, ensure_ascii=False) |