diff --git a/src/openhuman/memory_tree/score/extract/llm.rs b/src/openhuman/memory_tree/score/extract/llm.rs index 69adc5567..de97d89b1 100644 --- a/src/openhuman/memory_tree/score/extract/llm.rs +++ b/src/openhuman/memory_tree/score/extract/llm.rs @@ -150,15 +150,20 @@ impl EntityExtractor for LlmEntityExtractor { for attempt in 0..MAX_ATTEMPTS { match self.try_extract(text).await { Some(extracted) => { - // #002 (T013): a completed extraction that yielded - // structure means the extraction model is working — - // clear any prior "structure degraded" flag so the - // status/doctor surface recovers. (An empty-but-valid - // result, e.g. genuinely entity-free text, is left - // alone — it isn't evidence the model is broken.) - if !extracted.entities.is_empty() || !extracted.topics.is_empty() { - crate::openhuman::memory_tree::health::clear_structure_degraded(); - } + // #3365: the structure-degraded latch means "the extraction + // model is timing out / unreachable" — set ONLY after + // MAX_ATTEMPTS transport failures (below). A *completed* call + // disproves that: `try_extract` returns `Some` whenever the + // provider responded, including the valid-but-empty and + // malformed-JSON-as-empty cases. So a completed extraction + // clears the latch regardless of whether this particular text + // yielded entities — liveness, not content, is what it tracks. + // + // (Clearing only on a non-empty result left the latch stuck + // after the model recovered whenever later texts were + // entity-light, so the surface kept warning "extraction model + // is timing out" long after it had stopped — #3365.) + crate::openhuman::memory_tree::health::clear_structure_degraded(); return Ok(extracted); } None => { diff --git a/src/openhuman/memory_tree/score/extract/llm_tests.rs b/src/openhuman/memory_tree/score/extract/llm_tests.rs index b6e29ee33..9d375edde 100644 --- a/src/openhuman/memory_tree/score/extract/llm_tests.rs +++ b/src/openhuman/memory_tree/score/extract/llm_tests.rs @@ -291,6 +291,49 @@ async fn extract_returns_empty_on_malformed_provider_response() { assert!(out.llm_importance.is_none()); } +#[tokio::test] +async fn extract_clears_structure_latch_on_completed_empty_extraction() { + // #3365: the structure-degraded latch is a LIVENESS signal ("the extraction + // model is timing out"), set only after transport-failure retries are + // exhausted. A *completed* call — even one that yields no entities (a + // valid-but-empty result for entity-free text) — proves the model is + // responding, so it must clear the latch. Previously only a NON-empty result + // cleared it, so after the model recovered the latch stayed stuck whenever + // later texts were entity-light. + let _health_guard = crate::openhuman::memory_tree::health::test_guard(); + use crate::openhuman::memory::chat::{ChatPrompt, ChatProvider}; + use async_trait::async_trait; + use std::sync::Arc; + + struct EmptyButOkProvider; + #[async_trait] + impl ChatProvider for EmptyButOkProvider { + fn name(&self) -> &str { + "test:empty-ok" + } + async fn chat_for_json(&self, _p: &ChatPrompt) -> anyhow::Result { + Ok(r#"{"entities": [], "topics": []}"#.to_string()) + } + } + + crate::openhuman::memory_tree::health::mark_structure_degraded( + crate::openhuman::memory_tree::health::FailureCode::ExtractionTimeout, + ); + assert!( + crate::openhuman::memory_tree::health::current_degraded_state().structure, + "precondition: structure-degraded latch is set" + ); + + let ex = LlmEntityExtractor::new(LlmExtractorConfig::default(), Arc::new(EmptyButOkProvider)); + let out = ex.extract("entity-free text").await.unwrap(); + assert!(out.entities.is_empty(), "no entities in the response"); + + assert!( + !crate::openhuman::memory_tree::health::current_degraded_state().structure, + "a completed extraction must clear the structure-degraded latch even when empty" + ); +} + #[test] fn into_extracted_entities_drops_hallucinations() { let out = LlmExtractionOutput {