use anyhow::{anyhow, Context, Result};
use log::debug;
use serde::{Deserialize, Serialize};
use crate::chunking::slugify;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub id: String,
pub label: String,
pub type_: String,
pub score: f64,
}
pub const DEFAULT_LABELS: &[&str] = &[
"technology",
"framework",
"programming language",
"database",
"tool",
"person",
"organization",
"concept",
"protocol",
"operating system",
"library",
"format",
];
fn call_ollama(ollama_url: &str, model: &str, prompt: &str) -> Result<String> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.context("Failed to create HTTP client")?;
let body = serde_json::json!({
"model": model,
"prompt": prompt,
"stream": false,
"options": {
"temperature": 0.1,
"num_predict": 4096
}
});
let resp = client
.post(format!("{}/api/generate", ollama_url.trim_end_matches('/')))
.json(&body)
.send()
.context("Error connecting to Ollama for entity extraction")?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(anyhow!(
"Ollama returned HTTP {}: {}",
status.as_u16(),
body.trim()
));
}
let resp_text = resp.text().context("Failed to read response body")?;
let preview: String = resp_text.chars().take(200).collect();
debug!(
"NER respuesta cruda ({} chars): {}",
resp_text.len(),
preview
);
let data: OllamaResponse =
serde_json::from_str(&resp_text).context("Failed to parse Ollama response JSON")?;
let raw = if !data.response.trim().is_empty() {
data.response
} else if !data.thinking.trim().is_empty() {
debug!(
"NER: response vacío, usando campo 'thinking' ({} chars)",
data.thinking.len()
);
data.thinking
} else {
debug!("NER respuesta vacía para modelo '{}'.", model);
return Ok(String::new());
};
Ok(raw)
}
#[derive(Deserialize)]
struct OllamaResponse {
response: String,
#[serde(default)]
thinking: String,
}
fn parse_entities_json(raw: &str) -> Vec<serde_json::Value> {
let raw = raw.trim();
if raw.is_empty() {
return Vec::new();
}
let cleaned = raw
.strip_prefix("```json")
.or_else(|| raw.strip_prefix("```"))
.map(|s| s.strip_suffix("```").unwrap_or(s))
.unwrap_or(raw)
.trim();
if let Ok(entities) = serde_json::from_str::<Vec<serde_json::Value>>(cleaned) {
return entities;
}
if let Ok(wrapper) = serde_json::from_str::<serde_json::Value>(cleaned) {
return wrapper
.get("entities")
.or_else(|| wrapper.get("results"))
.and_then(|arr| serde_json::from_value::<Vec<serde_json::Value>>(arr.clone()).ok())
.unwrap_or_default();
}
let re = regex::Regex::new(r"(?s)\[.*?\]").ok();
if let Some(re) = re {
if let Some(mat) = re.find(cleaned) {
let json_str = mat.as_str();
if let Ok(entities) = serde_json::from_str::<Vec<serde_json::Value>>(json_str) {
return entities;
}
}
}
Vec::new()
}
fn raw_entities_to_entities(raw_entities: Vec<serde_json::Value>) -> Vec<Entity> {
let mut seen = std::collections::HashSet::new();
let mut entities = Vec::new();
for raw in raw_entities {
let label = raw
.get("label")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if label.is_empty() {
continue;
}
let key = label.to_lowercase();
if seen.contains(&key) {
continue;
}
let type_ = raw
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("concept")
.to_string();
let score = raw.get("score").and_then(|v| v.as_f64()).unwrap_or(0.5);
if score < 0.5 {
continue;
}
seen.insert(key);
entities.push(Entity {
id: slugify(&label),
label,
type_,
score,
});
}
entities
}
#[allow(dead_code)]
pub fn extract_entities(
ollama_url: &str,
model: &str,
text: &str,
labels: &[&str],
) -> Result<Vec<Entity>> {
if text.trim().is_empty() {
return Ok(Vec::new());
}
let labels_str = labels.join(", ");
let prompt = format!(
r#"Extract named entities from the following text. Only extract entities that match these categories: {labels_str}.
Return a JSON array of objects with fields:
- "label": the entity text exactly as written
- "type": one of the categories listed above
- "score": confidence score between 0.0 and 1.0 (be honest, use 0.5 if unsure)
Rules:
- Deduplicate: if the same entity appears multiple times, list it once with the highest score
- Return ONLY the raw JSON array. NO markdown, NO code fences, NO ```json, NO explanations.
- Start with [ and end with ]. Nothing else.
- If no entities found, return an empty array []
Text:
---
{text}
---"#,
labels_str = labels_str,
text = text,
);
let raw = call_ollama(ollama_url, model, &prompt)?;
if raw.is_empty() {
return Ok(Vec::new());
}
let raw_entities = parse_entities_json(&raw);
let entities = raw_entities_to_entities(raw_entities);
let preview: String = text.chars().take(40).collect();
debug!(
"NER extraídas: {} entidades de '{}'...",
entities.len(),
preview
);
Ok(entities)
}
pub fn extract_entities_batch(
ollama_url: &str,
model: &str,
texts: &[&str],
labels: &[&str],
) -> Result<Vec<Vec<Entity>>> {
if texts.is_empty() {
return Ok(Vec::new());
}
let labels_str = labels.join(", ");
let mut prompt_parts = Vec::new();
prompt_parts.push(
r#"Extract named entities from each section of the following text. Sections are separated by "---SECTION N---" markers.
Only extract entities that match these categories: {labels_str}
Return a JSON array of objects with fields:
- "label": the entity text exactly as written
- "type": one of the categories listed above
- "score": confidence score between 0.0 and 1.0 (be honest, use 0.5 if unsure)
- "section": the section number (1-based integer) this entity belongs to
Rules:
- Deduplicate within each section: if the same entity appears multiple times in a section, list it once with the highest score
- Return ONLY the raw JSON array. NO markdown, NO code fences, NO ```json, NO explanations, NO extra text.
- Start with [ and end with ]. Nothing else.
- If no entities found in any section, return an empty array []
Text:
---"#.replace("{labels_str}", &labels_str),
);
for (i, text) in texts.iter().enumerate() {
prompt_parts.push(format!("---SECTION {}---\n{}", i + 1, text));
}
prompt_parts.push("---".to_string());
let prompt = prompt_parts.join("\n");
let raw = call_ollama(ollama_url, model, &prompt)?;
if raw.is_empty() {
return Ok(texts.iter().map(|_| Vec::new()).collect());
}
let raw_entities = parse_entities_json(&raw);
let mut result: Vec<Vec<Entity>> = texts.iter().map(|_| Vec::new()).collect();
struct RawEntity {
value: serde_json::Value,
section: usize,
}
let raw_with_sections: Vec<RawEntity> = raw_entities
.into_iter()
.map(|v| {
let section = v
.get("section")
.and_then(|s| s.as_i64())
.map(|s| (s as usize).saturating_sub(1)) .unwrap_or(0);
RawEntity { value: v, section }
})
.collect();
use std::collections::HashMap;
let mut by_section: HashMap<usize, Vec<serde_json::Value>> = HashMap::new();
for re in raw_with_sections {
by_section.entry(re.section).or_default().push(re.value);
}
let num_sections = by_section.len();
for (section_idx, raw_group) in by_section {
if section_idx < result.len() {
let entities = raw_entities_to_entities(raw_group);
result[section_idx] = entities;
}
}
debug!(
"NER batch: {} textos, {} secciones con entidades",
texts.len(),
num_sections,
);
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_text_returns_empty() {
let result = extract_entities("http://localhost:11434", "llama3", "", DEFAULT_LABELS);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_whitespace_text_returns_empty() {
let result = extract_entities(
"http://localhost:11434",
"llama3",
" \n ",
DEFAULT_LABELS,
);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_slugify_is_accessible() {
let id = slugify("FastAPI Framework");
assert_eq!(id, "fastapi-framework");
}
#[test]
fn test_entity_serde_roundtrip() {
let entity = Entity {
id: "python".to_string(),
label: "Python".to_string(),
type_: "programming language".to_string(),
score: 0.95,
};
let json = serde_json::to_string(&entity).unwrap();
let deserialized: Entity = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.id, "python");
assert_eq!(deserialized.label, "Python");
assert_eq!(deserialized.type_, "programming language");
assert!((deserialized.score - 0.95).abs() < f64::EPSILON);
}
#[test]
fn test_default_labels_are_not_empty() {
assert!(!DEFAULT_LABELS.is_empty());
assert!(DEFAULT_LABELS.contains(&"technology"));
assert!(DEFAULT_LABELS.contains(&"person"));
}
}