Skip to main content

mneme/store/
entities.rs

1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use chrono::Utc;
5use rusqlite::{params, Connection};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use std::str::FromStr;
10
11use crate::store::memory::Memory;
12
13/// Categoría de entidad extraída del contenido de una memoria.
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15#[serde(rename_all = "snake_case")]
16pub enum EntityType {
17    Concept,
18    Person,
19    Library,
20    Technology,
21    Framework,
22    FilePath,
23    Url,
24    Command,
25    Configuration,
26    Workflow,
27    Convention,
28    Architecture,
29}
30
31impl std::fmt::Display for EntityType {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        let s = match self {
34            EntityType::Concept => "concept",
35            EntityType::Person => "person",
36            EntityType::Library => "library",
37            EntityType::Technology => "technology",
38            EntityType::Framework => "framework",
39            EntityType::FilePath => "file_path",
40            EntityType::Url => "url",
41            EntityType::Command => "command",
42            EntityType::Configuration => "configuration",
43            EntityType::Workflow => "workflow",
44            EntityType::Convention => "convention",
45            EntityType::Architecture => "architecture",
46        };
47        write!(f, "{}", s)
48    }
49}
50
51impl std::str::FromStr for EntityType {
52    type Err = crate::error::MnemeError;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        match s.to_lowercase().as_str() {
56            "concept" => Ok(EntityType::Concept),
57            "person" => Ok(EntityType::Person),
58            "library" => Ok(EntityType::Library),
59            "technology" => Ok(EntityType::Technology),
60            "framework" => Ok(EntityType::Framework),
61            "file_path" => Ok(EntityType::FilePath),
62            "url" => Ok(EntityType::Url),
63            "command" => Ok(EntityType::Command),
64            "configuration" => Ok(EntityType::Configuration),
65            "workflow" => Ok(EntityType::Workflow),
66            "convention" => Ok(EntityType::Convention),
67            "architecture" => Ok(EntityType::Architecture),
68            other => Err(crate::error::MnemeError::InvalidMemoryType(
69                other.to_string(),
70            )),
71        }
72    }
73}
74
75/// Entidad extraída del contenido de una memoria.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct MemoryEntity {
78    pub id: i64,
79    pub memory_id: Uuid,
80    pub entity_name: String,
81    pub entity_type: EntityType,
82    pub confidence: f32,
83    pub context: Option<String>,
84    pub created_at: chrono::DateTime<Utc>,
85}
86
87/// Link entre dos memorias que comparten una entidad.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct EntityLink {
90    pub id: i64,
91    pub entity_name: String,
92    pub entity_type: EntityType,
93    pub source_memory_id: Uuid,
94    pub target_memory_id: Uuid,
95    pub link_strength: f32,
96}
97
98/// Resultado de búsqueda por entidad.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct EntitySearchResult {
101    pub entity: MemoryEntity,
102    pub memory_title: String,
103    pub memory_type: String,
104    pub memory_importance: String,
105}
106
107/// Peso de entidad para boosting en búsqueda.
108#[derive(Debug, Clone)]
109pub struct EntityMatch {
110    pub entity_name: String,
111    pub entity_type: EntityType,
112    pub score: f32,
113}
114
115/// Store para operaciones con entidades.
116pub struct EntityStore {
117    conn: Arc<Mutex<Connection>>,
118}
119
120impl EntityStore {
121    pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
122        Self { conn }
123    }
124
125    /// Extrae y guarda entidades del contenido de una memoria.
126    /// Usa heurísticas basadas en patrones (no LLM) para extracción inicial.
127    pub fn extract_and_save(&self, memory: &Memory) -> crate::error::Result<Vec<MemoryEntity>> {
128        let entities = Self::extract_entities(memory);
129        let saved = self.save_entities(memory.id, &entities)?;
130
131        // Create entity links between memories that share entities
132        for entity in &saved {
133            self.create_links_for_entity(&entity.entity_name, &entity.entity_type, memory.id)?;
134        }
135
136        Ok(saved)
137    }
138
139    /// Extrae entidades del contenido de una memoria usando heurísticas.
140    /// Extrae entidades de un texto plano (sin guardar).
141    pub fn extract_entities_from_text(text: &str) -> Vec<(String, String, f32)> {
142        // Build a minimal Memory wrapper to reuse the extraction logic
143        let memory = Memory {
144            id: uuid::Uuid::nil(),
145            project: String::new(),
146            scope: crate::store::memory::Scope::Project,
147            title: String::new(),
148            content: text.to_string(),
149            what: None,
150            why: None,
151            context: None,
152            learned: None,
153            memory_type: crate::store::memory::MemoryType::Note,
154            importance: crate::store::memory::Importance::Medium,
155            tags: Vec::new(),
156            topic_key: None,
157            access_count: 0,
158            revision_count: 0,
159            duplicate_count: 0,
160            normalized_hash: None,
161            created_at: chrono::DateTime::UNIX_EPOCH,
162            updated_at: chrono::DateTime::UNIX_EPOCH,
163            last_accessed_at: None,
164            last_seen_at: None,
165            deleted_at: None,
166            deprecated_at: None,
167            deprecated_reason: None,
168            supersedes_id: None,
169            context_inject_count: 0,
170            origin_peer: None,
171            is_encrypted: false,
172            encrypted_for: None,
173            valid_from: None,
174            valid_until: None,
175            provenance: None,
176        };
177        Self::extract_entities(&memory)
178            .into_iter()
179            .map(|(name, etype, conf, _ctx)| (name, etype.to_string(), conf))
180            .collect()
181    }
182
183    pub fn extract_entities(memory: &Memory) -> Vec<(String, EntityType, f32, Option<String>)> {
184        let mut entities: HashMap<String, (EntityType, f32, Option<String>)> = HashMap::new();
185        let content = &memory.content;
186        let _text = content.to_lowercase();
187
188        // 1. Detect URLs
189        for url in Self::find_urls(content) {
190            let entry = entities
191                .entry(url.clone())
192                .or_insert_with(|| (EntityType::Url, 0.5, None));
193            entry.1 = (entry.1 + 1.0).min(1.0);
194        }
195
196        // 2. Detect file paths (patterns like /path/to/file or path/to/file.ext)
197        for path in Self::find_file_paths(content) {
198            let entry = entities
199                .entry(path.clone())
200                .or_insert_with(|| (EntityType::FilePath, 0.5, None));
201            entry.1 = (entry.1 + 1.0).min(1.0);
202        }
203
204        // 3. Detect library/framework mentions (camelCase, hyphenated tech names in code context)
205        for tech in Self::find_technologies(content) {
206            let entry = entities
207                .entry(tech.clone())
208                .or_insert_with(|| (EntityType::Technology, 0.5, None));
209            entry.1 = (entry.1 + 1.0).min(1.0);
210        }
211
212        // 4. Detect dependency names from Cargo.toml / package.json style mentions
213        for dep in Self::find_dependencies(content) {
214            let entry = entities
215                .entry(dep.clone())
216                .or_insert_with(|| (EntityType::Library, 0.6, None));
217            entry.1 = (entry.1 + 1.0).min(1.0);
218        }
219
220        // 5. Extract named entities from "what", "why", "context", "learned" fields
221        for field in [&memory.what, &memory.why, &memory.context, &memory.learned] {
222            if let Some(field_text) = field {
223                for concept in Self::find_key_concepts(field_text) {
224                    let entry = entities.entry(concept.clone()).or_insert_with(|| {
225                        (
226                            EntityType::Concept,
227                            0.4,
228                            Some(field_text[..field_text.len().min(100)].to_string()),
229                        )
230                    });
231                    entry.1 = (entry.1 + 0.5).min(1.0);
232                    // Update context if we have a better one
233                    if entry.2.is_none() {
234                        entry.2 = Some(field_text[..field_text.len().min(100)].to_string());
235                    }
236                }
237            }
238        }
239
240        // 6. Architectures and conventions from title + type
241        let _title_lower = memory.title.to_lowercase();
242        if matches!(
243            memory.memory_type,
244            crate::store::memory::MemoryType::Architecture
245        ) {
246            for concept in Self::find_key_concepts(&memory.title) {
247                let entry = entities.entry(concept.clone()).or_insert_with(|| {
248                    (
249                        EntityType::Architecture,
250                        0.7,
251                        Some(memory.title[..memory.title.len().min(100)].to_string()),
252                    )
253                });
254                entry.1 = (entry.1 + 0.5).min(1.0);
255            }
256        }
257
258        // Filter low-confidence entities
259        entities
260            .into_iter()
261            .filter(|(_, (_, confidence, _))| *confidence >= 0.3)
262            .map(|(name, (etype, conf, ctx))| (name, etype, conf, ctx))
263            .collect()
264    }
265
266    fn find_urls(content: &str) -> Vec<String> {
267        // Simple URL detection: https?://... patterns
268        let mut urls = Vec::new();
269        for word in content.split_whitespace() {
270            if word.starts_with("http://") || word.starts_with("https://") {
271                let clean = word.trim_end_matches(['.', ',', ')', ']', '>']);
272                if !clean.is_empty() {
273                    urls.push(clean.to_string());
274                }
275            }
276        }
277        urls
278    }
279
280    fn find_file_paths(content: &str) -> Vec<String> {
281        let mut paths = Vec::new();
282        for line in content.lines() {
283            let line = line.trim();
284            // Detect paths like src/main.rs, ./path/to/file, /absolute/path
285            if line.contains('/') && !line.starts_with("http") && !line.starts_with('#') {
286                // Check if it looks like a file path (has extension or common dir patterns)
287                let has_ext = line.contains('.') && line.len() - line.rfind('.').unwrap() <= 6;
288                let has_src = line.starts_with("src/") || line.contains("/src/");
289                let is_abs = line.starts_with('/');
290                if has_ext || has_src || is_abs {
291                    let clean = line.trim_end_matches(['.', ',', ')', ']']);
292                    if !clean.is_empty() && clean.len() > 3 {
293                        paths.push(clean.to_string());
294                    }
295                }
296            }
297        }
298        paths
299    }
300
301    fn find_technologies(content: &str) -> Vec<String> {
302        let mut techs = Vec::new();
303        let known_techs = [
304            "rust",
305            "python",
306            "typescript",
307            "javascript",
308            "go",
309            "react",
310            "vue",
311            "angular",
312            "node",
313            "deno",
314            "bun",
315            "sqlite",
316            "postgresql",
317            "mysql",
318            "redis",
319            "mongodb",
320            "docker",
321            "kubernetes",
322            "aws",
323            "gcp",
324            "azure",
325            "terraform",
326            "ansible",
327            "graphql",
328            "rest",
329            "grpc",
330            "websocket",
331            "tcp",
332            "udp",
333            "http",
334            "linux",
335            "macos",
336            "windows",
337            "nixos",
338            "ubuntu",
339            "debian",
340            "alpine",
341            "git",
342            "github",
343            "gitlab",
344            "ci/cd",
345            "github actions",
346            "llm",
347            "gpt",
348            "claude",
349            "gemini",
350            "openai",
351            "anthropic",
352            "ollama",
353            "mcp",
354            "api",
355            "sdk",
356            "cli",
357            "tui",
358            "gui",
359            "wasm",
360            "webassembly",
361            "docker compose",
362            "nginx",
363            "caddy",
364            "tokio",
365            "axum",
366            "actix",
367            "rocket",
368            "diesel",
369            "sqlx",
370            "seaorm",
371            "serde",
372            "clap",
373            "ratatui",
374            "crossterm",
375            "egui",
376            "tauri",
377        ];
378        let lower = content.to_lowercase();
379        for tech in &known_techs {
380            // Match as whole word or hyphenated
381            if lower.contains(tech) {
382                // Check boundaries to avoid partial matches
383                for window in lower.split_whitespace() {
384                    let clean = window.trim_matches(|c: char| {
385                        !c.is_alphanumeric() && c != '-' && c != '/' && c != '.'
386                    });
387                    let clean_lower = clean.to_lowercase();
388                    if clean_lower == *tech
389                        || clean_lower.starts_with(&format!("{}-", tech))
390                        || clean_lower.starts_with(&format!("{}_", tech))
391                    {
392                        techs.push(clean.to_string());
393                        break;
394                    }
395                }
396            }
397        }
398        techs
399    }
400
401    fn find_dependencies(content: &str) -> Vec<String> {
402        let mut deps = Vec::new();
403        // Detect patterns like "dependency: foo", "crate: bar", "package: baz"
404        let dep_indicators = [
405            "dependency:",
406            "crate:",
407            "package:",
408            "library:",
409            "npm:",
410            "gem:",
411            "cargo:",
412        ];
413        let text_lower = content.to_lowercase();
414        for indicator in &dep_indicators {
415            if let Some(pos) = text_lower.find(indicator) {
416                let after = &text_lower[pos + indicator.len()..];
417                let dep_name = after
418                    .split_whitespace()
419                    .next()
420                    .map(|s| {
421                        s.trim_matches(|c: char| {
422                            c == '`' || c == '"' || c == '\'' || c == ',' || c == '.'
423                        })
424                    })
425                    .unwrap_or("")
426                    .to_string();
427                if dep_name.len() > 2 {
428                    deps.push(dep_name);
429                }
430            }
431        }
432        // Also detect from Cargo.toml style: name = "x.y.z"
433        for line in content.lines() {
434            let line = line.trim();
435            if line.contains(" = ") && (line.contains('"') || line.contains('\'')) {
436                if let Some(eq_pos) = line.find(" = ") {
437                    let name = line[..eq_pos].trim().to_string();
438                    if name.len() > 2 && !name.starts_with('#') {
439                        deps.push(name);
440                    }
441                }
442            }
443        }
444        deps
445    }
446
447    fn find_key_concepts(text: &str) -> Vec<String> {
448        let mut concepts = Vec::new();
449        // Extract CamelCase and SCREAMING_SNAKE_CASE identifiers as potential concepts
450        for word in text.split_whitespace() {
451            let clean = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
452            if clean.is_empty() || clean.len() < 4 {
453                continue;
454            }
455            // CamelCase detection
456            let upper_count = clean.chars().filter(|c| c.is_uppercase()).count();
457            if upper_count >= 1 && clean.len() >= 4 && clean != clean.to_lowercase() {
458                concepts.push(clean.to_string());
459            }
460            // SCREAMING_SNAKE_CASE
461            if clean == clean.to_uppercase() && clean.contains('_') {
462                concepts.push(clean.to_string());
463            }
464        }
465        concepts
466    }
467
468    /// Guarda entidades en la base de datos.
469    pub fn save_entities(
470        &self,
471        memory_id: Uuid,
472        entities: &[(String, EntityType, f32, Option<String>)],
473    ) -> crate::error::Result<Vec<MemoryEntity>> {
474        let conn = self
475            .conn
476            .lock()
477            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
478        let mut saved = Vec::new();
479
480        for (name, etype, confidence, context) in entities {
481            let now = Utc::now().to_rfc3339();
482            conn.execute(
483                "INSERT OR IGNORE INTO memory_entities (memory_id, entity_name, entity_type, confidence, context, created_at)
484                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
485                params![
486                    memory_id.to_string(),
487                    name,
488                    etype.to_string(),
489                    confidence,
490                    context.as_deref(),
491                    now,
492                ],
493            )?;
494
495            let id = conn.last_insert_rowid();
496            saved.push(MemoryEntity {
497                id,
498                memory_id,
499                entity_name: name.clone(),
500                entity_type: etype.clone(),
501                confidence: *confidence,
502                context: context.clone(),
503                created_at: Utc::now(),
504            });
505        }
506
507        Ok(saved)
508    }
509
510    /// Crea links entre memorias que comparten una entidad.
511    fn create_links_for_entity(
512        &self,
513        entity_name: &str,
514        entity_type: &EntityType,
515        source_memory_id: Uuid,
516    ) -> crate::error::Result<u32> {
517        let conn = self
518            .conn
519            .lock()
520            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
521
522        // Find other memories that have the same entity
523        let mut stmt = conn.prepare(
524            "SELECT memory_id FROM memory_entities
525             WHERE entity_name = ?1 AND entity_type = ?2 AND memory_id != ?3
526             GROUP BY memory_id",
527        )?;
528
529        let rows = stmt.query_map(
530            params![
531                entity_name,
532                entity_type.to_string(),
533                source_memory_id.to_string()
534            ],
535            |row| row.get::<_, String>(0),
536        )?;
537
538        let mut link_count = 0u32;
539        for row in rows {
540            let target_id_str: String = row?;
541            if let Ok(_target_id) = Uuid::parse_str(&target_id_str) {
542                // Count co-occurrences for link strength
543                let count: u32 = conn
544                    .query_row(
545                        "SELECT COUNT(*) FROM memory_entities
546                         WHERE entity_name = ?1 AND entity_type = ?2
547                         AND memory_id IN (?3, ?4)",
548                        params![
549                            entity_name,
550                            entity_type.to_string(),
551                            source_memory_id.to_string(),
552                            target_id_str,
553                        ],
554                        |row| row.get(0),
555                    )
556                    .unwrap_or(1);
557
558                let strength = (count as f32).min(5.0) / 5.0; // Normalize to 0-1
559
560                conn.execute(
561                    "INSERT OR REPLACE INTO entity_links (entity_name, entity_type, source_memory_id, target_memory_id, link_strength, created_at)
562                     VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
563                    params![
564                        entity_name,
565                        entity_type.to_string(),
566                        source_memory_id.to_string(),
567                        target_id_str,
568                        strength,
569                        Utc::now().to_rfc3339(),
570                    ],
571                )?;
572                link_count += 1;
573            }
574        }
575
576        Ok(link_count)
577    }
578
579    /// Busca entidades por nombre (parcial).
580    pub fn search_entities(
581        &self,
582        query: &str,
583        entity_type: Option<&EntityType>,
584        limit: u32,
585    ) -> crate::error::Result<Vec<EntitySearchResult>> {
586        let conn = self
587            .conn
588            .lock()
589            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
590
591        let like_pattern = format!("%{}%", query);
592        let limit_i64 = limit as i64;
593
594        let sql = if let Some(_etype) = entity_type {
595            "SELECT e.id, e.memory_id, e.entity_name, e.entity_type, e.confidence, e.context, e.created_at,
596                        m.title, m.memory_type, m.importance
597                 FROM memory_entities e
598                 JOIN memories m ON m.id = e.memory_id
599                 WHERE e.entity_name LIKE ?1 AND e.entity_type = ?2 AND m.deleted_at IS NULL
600                 ORDER BY e.confidence DESC, LENGTH(e.entity_name) ASC
601                 LIMIT ?3".to_string()
602        } else {
603            "SELECT e.id, e.memory_id, e.entity_name, e.entity_type, e.confidence, e.context, e.created_at,
604                        m.title, m.memory_type, m.importance
605                 FROM memory_entities e
606                 JOIN memories m ON m.id = e.memory_id
607                 WHERE e.entity_name LIKE ?1 AND m.deleted_at IS NULL
608                 ORDER BY e.confidence DESC, LENGTH(e.entity_name) ASC
609                 LIMIT ?2".to_string()
610        };
611
612        let mut stmt = conn.prepare(&sql)?;
613        let rows: Vec<EntitySearchResult>;
614
615        if let Some(etype) = entity_type {
616            rows = stmt
617                .query_map(params![like_pattern, etype.to_string(), limit_i64], |row| {
618                    Ok(EntitySearchResult {
619                        entity: MemoryEntity {
620                            id: row.get(0)?,
621                            memory_id: Uuid::parse_str(&row.get::<_, String>(1)?).map_err(|e| {
622                                rusqlite::Error::FromSqlConversionFailure(
623                                    1,
624                                    rusqlite::types::Type::Text,
625                                    Box::new(e),
626                                )
627                            })?,
628                            entity_name: row.get(2)?,
629                            entity_type: EntityType::from_str(&row.get::<_, String>(3)?).map_err(
630                                |e| {
631                                    rusqlite::Error::FromSqlConversionFailure(
632                                        3,
633                                        rusqlite::types::Type::Text,
634                                        Box::new(e),
635                                    )
636                                },
637                            )?,
638                            confidence: row.get(4)?,
639                            context: row.get(5)?,
640                            created_at: chrono::DateTime::parse_from_rfc3339(
641                                &row.get::<_, String>(6)?,
642                            )
643                            .map_err(|e| {
644                                rusqlite::Error::FromSqlConversionFailure(
645                                    6,
646                                    rusqlite::types::Type::Text,
647                                    Box::new(e),
648                                )
649                            })?
650                            .with_timezone(&Utc),
651                        },
652                        memory_title: row.get(7)?,
653                        memory_type: row.get(8)?,
654                        memory_importance: row.get(9)?,
655                    })
656                })?
657                .collect::<Result<Vec<_>, _>>()?;
658        } else {
659            rows = stmt
660                .query_map(params![like_pattern, limit_i64], |row| {
661                    Ok(EntitySearchResult {
662                        entity: MemoryEntity {
663                            id: row.get(0)?,
664                            memory_id: Uuid::parse_str(&row.get::<_, String>(1)?).map_err(|e| {
665                                rusqlite::Error::FromSqlConversionFailure(
666                                    1,
667                                    rusqlite::types::Type::Text,
668                                    Box::new(e),
669                                )
670                            })?,
671                            entity_name: row.get(2)?,
672                            entity_type: EntityType::from_str(&row.get::<_, String>(3)?).map_err(
673                                |e| {
674                                    rusqlite::Error::FromSqlConversionFailure(
675                                        3,
676                                        rusqlite::types::Type::Text,
677                                        Box::new(e),
678                                    )
679                                },
680                            )?,
681                            confidence: row.get(4)?,
682                            context: row.get(5)?,
683                            created_at: chrono::DateTime::parse_from_rfc3339(
684                                &row.get::<_, String>(6)?,
685                            )
686                            .map_err(|e| {
687                                rusqlite::Error::FromSqlConversionFailure(
688                                    6,
689                                    rusqlite::types::Type::Text,
690                                    Box::new(e),
691                                )
692                            })?
693                            .with_timezone(&Utc),
694                        },
695                        memory_title: row.get(7)?,
696                        memory_type: row.get(8)?,
697                        memory_importance: row.get(9)?,
698                    })
699                })?
700                .collect::<Result<Vec<_>, _>>()?;
701        }
702
703        Ok(rows)
704    }
705
706    /// Obtiene todas las entidades de una memoria.
707    pub fn get_memory_entities(&self, memory_id: Uuid) -> crate::error::Result<Vec<MemoryEntity>> {
708        let conn = self
709            .conn
710            .lock()
711            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
712        let mut stmt = conn.prepare(
713            "SELECT id, memory_id, entity_name, entity_type, confidence, context, created_at
714             FROM memory_entities WHERE memory_id = ?1 ORDER BY confidence DESC",
715        )?;
716
717        let rows = stmt.query_map(params![memory_id.to_string()], |row| {
718            Ok(MemoryEntity {
719                id: row.get(0)?,
720                memory_id: Uuid::parse_str(&row.get::<_, String>(1)?).map_err(|e| {
721                    rusqlite::Error::FromSqlConversionFailure(
722                        1,
723                        rusqlite::types::Type::Text,
724                        Box::new(e),
725                    )
726                })?,
727                entity_name: row.get(2)?,
728                entity_type: EntityType::from_str(&row.get::<_, String>(3)?).map_err(|e| {
729                    rusqlite::Error::FromSqlConversionFailure(
730                        3,
731                        rusqlite::types::Type::Text,
732                        Box::new(e),
733                    )
734                })?,
735                confidence: row.get(4)?,
736                context: row.get(5)?,
737                created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(6)?)
738                    .map_err(|e| {
739                        rusqlite::Error::FromSqlConversionFailure(
740                            6,
741                            rusqlite::types::Type::Text,
742                            Box::new(e),
743                        )
744                    })?
745                    .with_timezone(&Utc),
746            })
747        })?;
748
749        let mut entities = Vec::new();
750        for row in rows {
751            entities.push(row?);
752        }
753        Ok(entities)
754    }
755
756    /// Obtiene los entity links de una memoria.
757    pub fn get_memory_links(
758        &self,
759        memory_id: Uuid,
760        limit: u32,
761    ) -> crate::error::Result<Vec<(EntityLink, String)>> {
762        let conn = self
763            .conn
764            .lock()
765            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
766        let mut stmt = conn.prepare(
767            "SELECT l.id, l.entity_name, l.entity_type, l.source_memory_id, l.target_memory_id,
768                    l.link_strength, m.title
769             FROM entity_links l
770             JOIN memories m ON m.id = l.target_memory_id
771             WHERE l.source_memory_id = ?1 AND m.deleted_at IS NULL
772             ORDER BY l.link_strength DESC
773             LIMIT ?2",
774        )?;
775
776        let limit_i64 = limit as i64;
777        let rows = stmt.query_map(params![memory_id.to_string(), limit_i64], |row| {
778            let link = EntityLink {
779                id: row.get(0)?,
780                entity_name: row.get(1)?,
781                entity_type: EntityType::from_str(&row.get::<_, String>(2)?).map_err(|e| {
782                    rusqlite::Error::FromSqlConversionFailure(
783                        2,
784                        rusqlite::types::Type::Text,
785                        Box::new(e),
786                    )
787                })?,
788                source_memory_id: Uuid::parse_str(&row.get::<_, String>(3)?).map_err(|e| {
789                    rusqlite::Error::FromSqlConversionFailure(
790                        3,
791                        rusqlite::types::Type::Text,
792                        Box::new(e),
793                    )
794                })?,
795                target_memory_id: Uuid::parse_str(&row.get::<_, String>(4)?).map_err(|e| {
796                    rusqlite::Error::FromSqlConversionFailure(
797                        4,
798                        rusqlite::types::Type::Text,
799                        Box::new(e),
800                    )
801                })?,
802                link_strength: row.get(5)?,
803            };
804            let target_title: String = row.get(6)?;
805            Ok((link, target_title))
806        })?;
807
808        let mut results = Vec::new();
809        for row in rows {
810            results.push(row?);
811        }
812        Ok(results)
813    }
814
815    /// Obtiene los nombres de entidad más frecuentes en un proyecto.
816    pub fn frequent_entities(
817        &self,
818        project: &str,
819        limit: u32,
820    ) -> crate::error::Result<Vec<(String, EntityType, u32)>> {
821        let conn = self
822            .conn
823            .lock()
824            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
825        let mut stmt = conn.prepare(
826            "SELECT e.entity_name, e.entity_type, COUNT(DISTINCT e.memory_id) as memory_count
827             FROM memory_entities e
828             JOIN memories m ON m.id = e.memory_id
829             WHERE m.project = ?1 AND m.deleted_at IS NULL
830             GROUP BY e.entity_name, e.entity_type
831             ORDER BY memory_count DESC
832             LIMIT ?2",
833        )?;
834
835        let limit_i64 = limit as i64;
836        let rows = stmt.query_map(params![project, limit_i64], |row| {
837            let entity_type = EntityType::from_str(&row.get::<_, String>(1)?).map_err(|e| {
838                rusqlite::Error::FromSqlConversionFailure(
839                    1,
840                    rusqlite::types::Type::Text,
841                    Box::new(e),
842                )
843            })?;
844            Ok((row.get::<_, String>(0)?, entity_type, row.get::<_, u32>(2)?))
845        })?;
846
847        let mut results = Vec::new();
848        for row in rows {
849            results.push(row?);
850        }
851        Ok(results)
852    }
853}