# codex-lens/src/codexlens/search/enrichment.py """Relationship enrichment for search results.""" import sqlite3 from pathlib import Path from typing import List, Dict, Any, Optional from codexlens.config import Config from codexlens.entities import SearchResult from codexlens.search.graph_expander import GraphExpander from codexlens.storage.path_mapper import PathMapper class RelationshipEnricher: """Enriches search results with code graph relationships.""" def __init__(self, index_path: Path): """Initialize with path to index database. Args: index_path: Path to _index.db SQLite database """ self.index_path = index_path self.db_conn: Optional[sqlite3.Connection] = None self._connect() def _connect(self) -> None: """Establish read-only database connection.""" if self.index_path.exists(): self.db_conn = sqlite3.connect( f"file:{self.index_path}?mode=ro", uri=True, check_same_thread=False ) self.db_conn.row_factory = sqlite3.Row def enrich(self, results: List[Dict[str, Any]], limit: int = 10) -> List[Dict[str, Any]]: """Add relationship data to search results. Args: results: List of search result dictionaries limit: Maximum number of results to enrich Returns: Results with relationships field added """ if not self.db_conn: return results for result in results[:limit]: file_path = result.get('file') or result.get('path') symbol_name = result.get('symbol') result['relationships'] = self._find_relationships(file_path, symbol_name) return results def _find_relationships(self, file_path: Optional[str], symbol_name: Optional[str]) -> List[Dict[str, Any]]: """Query relationships for a symbol. Args: file_path: Path to file containing the symbol symbol_name: Name of the symbol Returns: List of relationship dictionaries with type, direction, target/source, file, line """ if not self.db_conn or not symbol_name: return [] relationships = [] cursor = self.db_conn.cursor() try: # Find symbol ID(s) by name and optionally file if file_path: cursor.execute( 'SELECT id FROM symbols WHERE name = ? AND file_path = ?', (symbol_name, file_path) ) else: cursor.execute('SELECT id FROM symbols WHERE name = ?', (symbol_name,)) symbol_ids = [row[0] for row in cursor.fetchall()] if not symbol_ids: return [] # Query outgoing relationships (symbol is source) placeholders = ','.join('?' * len(symbol_ids)) cursor.execute(f''' SELECT sr.relationship_type, sr.target_symbol_fqn, sr.file_path, sr.line FROM symbol_relationships sr WHERE sr.source_symbol_id IN ({placeholders}) ''', symbol_ids) for row in cursor.fetchall(): relationships.append({ 'type': row[0], 'direction': 'outgoing', 'target': row[1], 'file': row[2], 'line': row[3], }) # Query incoming relationships (symbol is target) # Match against symbol name or qualified name patterns cursor.execute(''' SELECT sr.relationship_type, s.name AS source_name, sr.file_path, sr.line FROM symbol_relationships sr JOIN symbols s ON sr.source_symbol_id = s.id WHERE sr.target_symbol_fqn = ? OR sr.target_symbol_fqn LIKE ? ''', (symbol_name, f'%.{symbol_name}')) for row in cursor.fetchall(): rel_type = row[0] # Convert to incoming type incoming_type = self._to_incoming_type(rel_type) relationships.append({ 'type': incoming_type, 'direction': 'incoming', 'source': row[1], 'file': row[2], 'line': row[3], }) except sqlite3.Error: return [] return relationships def _to_incoming_type(self, outgoing_type: str) -> str: """Convert outgoing relationship type to incoming type. Args: outgoing_type: The outgoing relationship type (e.g., 'calls', 'imports') Returns: Corresponding incoming type (e.g., 'called_by', 'imported_by') """ type_map = { 'calls': 'called_by', 'imports': 'imported_by', 'extends': 'extended_by', } return type_map.get(outgoing_type, f'{outgoing_type}_by') def close(self) -> None: """Close database connection.""" if self.db_conn: self.db_conn.close() self.db_conn = None def __enter__(self) -> 'RelationshipEnricher': return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: self.close() class SearchEnrichmentPipeline: """Search post-processing pipeline (optional enrichments).""" def __init__(self, mapper: PathMapper, *, config: Optional[Config] = None) -> None: self._config = config self._graph_expander = GraphExpander(mapper, config=config) def expand_related_results(self, results: List[SearchResult]) -> List[SearchResult]: """Expand base results with related symbols when enabled in config.""" if self._config is None or not getattr(self._config, "enable_graph_expansion", False): return [] depth = int(getattr(self._config, "graph_expansion_depth", 2) or 2) return self._graph_expander.expand(results, depth=depth)