fix: enable LLM entity extraction + handle reasoning model output

Root causes of zero entity extraction:
1. IngestWorker used WikiLinkFallbackExtractor (wiki links only)
   Fix: Use LlmEntityExtractor when LLM_ENDPOINT is set
2. ExtractedEntity.entity_type vs LLM returning "type"
   Fix: serde alias "type" -> entity_type, default confidence
3. Reasoning models output <think>...</think> before JSON
   Fix: strip_thinking_tags() extracts JSON from response
4. Reflection verification crashes pipeline on parse failure
   Fix: graceful fallback, keep all entities if reflection fails

Tested with reasoning-predictor (qwen2.5:3b) via port-forward.
This commit is contained in:
2026-09-09 17:08:07 +09:00
parent 99803f5ff8
commit 3184c39b79
2 changed files with 56 additions and 9 deletions
+46 -6
View File
@@ -22,11 +22,15 @@ use tokio::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedEntity {
pub name: String,
#[serde(alias = "type")]
pub entity_type: EntityType,
pub summary: String,
#[serde(default = "default_confidence")]
pub confidence: f32,
}
fn default_confidence() -> f32 { 0.8 }
impl ExtractedEntity {
/// Convert to domain model (Phase 1 type)
pub fn to_domain(&self, project_id: &str) -> Entity {
@@ -62,6 +66,25 @@ impl LlmEntityExtractor {
/// Parse extraction response JSON
/// Format: { "entities": [{ "name": "...", "type": "...", "summary": "..." }, ...] }
/// Strip <think>...</think> tags from reasoning model output and extract JSON
fn strip_thinking_tags(text: &str) -> String {
let mut result = text.to_string();
// Remove <think>...</think> blocks
if let Some(start) = result.find("<think>") {
if let Some(end) = result.find("</think>") {
result = format!("{}{}", &result[..start], &result[end + 8..]);
}
}
// Try to find JSON object in remaining text
let trimmed = result.trim();
if let Some(start) = trimmed.find('{') {
if let Some(end) = trimmed.rfind('}') {
return trimmed[start..=end].to_string();
}
}
trimmed.to_string()
}
fn parse_extraction(response: &str) -> Result<Vec<ExtractedEntity>> {
#[derive(Deserialize)]
struct Response {
@@ -146,12 +169,16 @@ impl LlmEntityExtractor {
}
let data: serde_json::Value = response.json().await?;
let content = data["choices"][0]["message"]["content"]
let raw_content = data["choices"][0]["message"]["content"]
.as_str()
.unwrap_or("{}")
.to_string();
tracing::debug!("LLM response (via Authentik JWT): {}", content);
// Strip <think>...</think> tags from reasoning models
let content = Self::strip_thinking_tags(&raw_content);
tracing::debug!("LLM raw response length={}, cleaned length={}", raw_content.len(), content.len());
tracing::debug!("LLM cleaned content: {}", content);
Ok(content)
}
@@ -233,14 +260,27 @@ Respond in JSON:
);
let reflection = if std::env::var("LLM_ENDPOINT").is_ok() {
self.call_llm_endpoint(&reflection_prompt).await.unwrap_or_else(|_| self.simulate_llm(&reflection_prompt).unwrap_or_default())
self.call_llm_endpoint(&reflection_prompt).await.unwrap_or_else(|e| {
tracing::warn!("Reflection LLM call failed: {}, skipping verification", e);
String::new()
})
} else {
self.simulate_llm(&reflection_prompt)?
};
let verified = Self::parse_reflection(&reflection)?;
// Filter: keep only entities marked present
entities.retain(|e| verified.iter().any(|(name, present)| name == &e.name && *present));
// If reflection succeeded, filter entities; otherwise keep all
if !reflection.is_empty() {
match Self::parse_reflection(&reflection) {
Ok(verified) => {
entities.retain(|e| verified.iter().any(|(name, present)| name == &e.name && *present));
}
Err(e) => {
tracing::warn!("Reflection parse failed: {}, keeping all entities", e);
}
}
} else {
tracing::info!("Reflection skipped, keeping {} unverified entities", entities.len());
}
// Adjust confidence for reflected entities (slight penalty for needing verification)
for entity in &mut entities {