Skip to main content

sqlite_graphrag/i18n/validation/
messages_enrich_skip.rs

1//! Reasons an enrich item was SKIPPED rather than processed (GAP-SG-279).
2//!
3//! These strings are not diagnostics. They travel in the `reason` field of the
4//! item envelope, which is the only channel telling the caller why an item that
5//! cost nothing produced nothing. Leaving them as English literals scattered
6//! across the extraction modules made the same sentence exist in five places at
7//! once — `"body is empty"` was duplicated verbatim in five files and
8//! `"embedding backend returned an empty vector"` in three — so a wording fix
9//! could land in one of them and silently disagree with the rest.
10
11use crate::i18n::{current, Language};
12
13/// The memory body is empty, so there is nothing to extract from.
14pub fn body_is_empty() -> String {
15    match current() {
16        Language::English => "body is empty".to_string(),
17        Language::Portuguese => "corpo vazio".to_string(),
18    }
19}
20
21/// The chunk text is empty, so there is nothing to embed.
22pub fn chunk_text_is_empty() -> String {
23    match current() {
24        Language::English => "chunk text is empty".to_string(),
25        Language::Portuguese => "texto do chunk vazio".to_string(),
26    }
27}
28
29/// The embedding chain resolved to no backend and produced no vector.
30///
31/// An empty vector is never persisted, so the item is skipped instead of
32/// writing a row that would read as embedded while carrying nothing.
33pub fn embedding_backend_returned_empty_vector() -> String {
34    match current() {
35        Language::English => {
36            "embedding backend returned an empty vector (chain resolved to none)".to_string()
37        }
38        Language::Portuguese => {
39            "backend de embedding devolveu vetor vazio (a cadeia resolveu para nenhum)".to_string()
40        }
41    }
42}
43
44/// The batched re-embed path produced no outcome for this key.
45pub fn reembed_batch_no_outcome() -> String {
46    match current() {
47        Language::English => "re-embed batch produced no outcome for this key".to_string(),
48        Language::Portuguese => {
49            "o lote de re-embed não produziu resultado para esta chave".to_string()
50        }
51    }
52}
53
54/// Re-embed is claimed in batches, so the per-item path declines it.
55pub fn reembed_served_by_batch_path() -> String {
56    match current() {
57        Language::English => "re-embed is served by the batched claim path".to_string(),
58        Language::Portuguese => "o re-embed é atendido pelo caminho de claim em lote".to_string(),
59    }
60}
61
62/// The entity pair was already judged and recorded in `entity_connect_seen`.
63pub fn pair_already_seen() -> String {
64    match current() {
65        Language::English => "pair already in entity_connect_seen".to_string(),
66        Language::Portuguese => "par já registrado em entity_connect_seen".to_string(),
67    }
68}
69
70/// The entity pair already carries an edge in the graph.
71pub fn pair_already_related() -> String {
72    match current() {
73        Language::English => "pair already related".to_string(),
74        Language::Portuguese => "par já relacionado".to_string(),
75    }
76}
77
78/// The model read the pair and reported no relationship between them.
79pub fn llm_found_no_relationship() -> String {
80    match current() {
81        Language::English => "LLM determined no relationship".to_string(),
82        Language::Portuguese => "o LLM determinou que não há relação".to_string(),
83    }
84}
85
86/// GAP-SG-279: the entity carries no description, no linked corpus and no
87/// typed neighbour, so its type cannot be judged from anything but its name.
88///
89/// This is the honest answer, not a failure. `entity-type-validate` used to
90/// send the model two lines — the name and the type under dispute — and write
91/// whatever came back. For an opaque name that is a guess wearing the costume
92/// of an audit, and the guess reached `UPDATE entities SET type`. Abstaining
93/// before the request also costs nothing, so the caller pays for evidence or
94/// pays for nothing at all.
95pub fn entity_type_no_evidence(corpus_chars: usize, min_corpus_chars: usize) -> String {
96    match current() {
97        Language::English => format!(
98            "insufficient_evidence: entity has no description and only {corpus_chars} chars of \
99             linked corpus, minimum is {min_corpus_chars} (bind it to a memory or describe it \
100             before asking for its type)"
101        ),
102        Language::Portuguese => format!(
103            "insufficient_evidence: a entidade não tem descrição e tem só {corpus_chars} \
104             caracteres de corpus ligado, o mínimo é {min_corpus_chars} (ligue-a a uma memória \
105             ou descreva-a antes de pedir o tipo)"
106        ),
107    }
108}
109
110/// GAP-SG-279: the model read the evidence and declined to judge the type.
111pub fn entity_type_model_abstained(corpus_chars: usize) -> String {
112    match current() {
113        Language::English => format!(
114            "insufficient_evidence: model declined to judge the type from {corpus_chars} chars \
115             of evidence"
116        ),
117        Language::Portuguese => format!(
118            "insufficient_evidence: o modelo recusou julgar o tipo a partir de {corpus_chars} \
119             caracteres de evidência"
120        ),
121    }
122}
123
124/// GAP-SG-279: the suggested label failed shape normalisation.
125///
126/// The entity keeps its current type. Turning one unusable suggestion into a
127/// failed item would have the queue retry it and eventually mark it dead,
128/// trading a harmless no-op for a permanent failure.
129pub fn entity_type_suggestion_unusable(suggested: &str) -> String {
130    match current() {
131        Language::English => {
132            format!("unusable_suggestion: `{suggested}` failed shape normalisation; type kept")
133        }
134        Language::Portuguese => {
135            format!(
136                "unusable_suggestion: `{suggested}` falhou na normalização de forma; tipo mantido"
137            )
138        }
139    }
140}
141
142/// GAP-SG-279: the model confirmed the type that was already stored.
143pub fn entity_type_confirmed(current_type: &str) -> String {
144    match current() {
145        Language::English => format!("confirmed: `{current_type}` is already correct"),
146        Language::Portuguese => format!("confirmado: `{current_type}` já está correto"),
147    }
148}
149
150/// The model returned an explicit null where a description was expected.
151///
152/// The schema admits null precisely so the model has somewhere to put "the
153/// evidence does not support any statement about this entity", so this is the
154/// abstention path and not a malformed reply.
155pub fn description_returned_null() -> String {
156    match current() {
157        Language::English => "insufficient_evidence: model returned a null description".to_string(),
158        Language::Portuguese => {
159            "insufficient_evidence: o modelo devolveu descrição nula".to_string()
160        }
161    }
162}
163
164/// The model returned a description made only of whitespace.
165///
166/// Kept distinct from the null case: an empty string is the model answering
167/// while saying nothing, which is worth telling apart from it declining.
168pub fn description_returned_empty() -> String {
169    match current() {
170        Language::English => {
171            "insufficient_evidence: model returned an empty description".to_string()
172        }
173        Language::Portuguese => {
174            "insufficient_evidence: o modelo devolveu descrição vazia".to_string()
175        }
176    }
177}
178
179/// Bounded busy-retry gave up while CLAIMING the next item to work on.
180///
181/// Distinct from the write-back message below on purpose: nothing was
182/// processed, so nothing was lost. The operator's move is to retry later or
183/// reduce concurrency, not to hunt for a half-applied change.
184pub fn sqlite_busy_exhausted_on_dequeue() -> String {
185    match current() {
186        Language::English => {
187            "SQLITE_BUSY exhausted bounded retries while dequeuing (parallel worker)".to_string()
188        }
189        Language::Portuguese => {
190            "SQLITE_BUSY esgotou as tentativas limitadas ao retirar da fila (worker paralelo)"
191                .to_string()
192        }
193    }
194}
195
196/// Bounded busy-retry gave up while CLAIMING a batch for re-embedding.
197pub fn sqlite_busy_exhausted_on_reembed_claim() -> String {
198    match current() {
199        Language::English => {
200            "SQLITE_BUSY exhausted bounded retries while claiming a re-embed batch".to_string()
201        }
202        Language::Portuguese => {
203            "SQLITE_BUSY esgotou as tentativas limitadas ao reservar um lote de re-embed"
204                .to_string()
205        }
206    }
207}
208
209/// The chat client was dispatched before it had been initialised.
210///
211/// Reachable only through a programming error in the dispatch order, which is
212/// why the text says so: an operator who sees this cannot fix it by retrying or
213/// by changing a flag, and telling them otherwise wastes their time.
214pub fn chat_client_not_initialised() -> String {
215    match current() {
216        Language::English => {
217            "OpenRouter chat client not initialised before dispatch (internal error)".to_string()
218        }
219        Language::Portuguese => {
220            "cliente de chat da OpenRouter não inicializado antes do despacho (erro interno)"
221                .to_string()
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    /// The two abstention reasons must name the numbers that produced them.
231    ///
232    /// A reason that says only "insufficient evidence" tells the operator that
233    /// something was missing without saying how much was there, which is the
234    /// difference between a report and a shrug.
235    #[test]
236    fn the_abstention_reason_carries_the_measurement() {
237        let msg = entity_type_no_evidence(12, 40);
238        assert!(
239            msg.contains("12"),
240            "reason must carry the measured size: {msg}"
241        );
242        assert!(msg.contains("40"), "reason must carry the threshold: {msg}");
243    }
244
245    /// No reason may be empty, because an empty `reason` reaches the caller as
246    /// a skip with no explanation at all — indistinguishable from a bug.
247    ///
248    /// The active language comes from a process-wide `OnceLock`, so this test
249    /// exercises whichever one the harness resolved rather than looping over
250    /// both. Every arm is a literal in the same `match`, so a missing
251    /// translation is a compile error, not something a test could catch.
252    #[test]
253    fn no_reason_is_empty() {
254        assert!(!body_is_empty().is_empty());
255        assert!(!chunk_text_is_empty().is_empty());
256        assert!(!embedding_backend_returned_empty_vector().is_empty());
257        assert!(!reembed_batch_no_outcome().is_empty());
258        assert!(!reembed_served_by_batch_path().is_empty());
259        assert!(!pair_already_seen().is_empty());
260        assert!(!pair_already_related().is_empty());
261        assert!(!llm_found_no_relationship().is_empty());
262        assert!(!entity_type_no_evidence(1, 2).is_empty());
263        assert!(!entity_type_model_abstained(1).is_empty());
264        assert!(!entity_type_suggestion_unusable("x").is_empty());
265        assert!(!entity_type_confirmed("concept").is_empty());
266    }
267
268    /// The abstention reasons must carry the `insufficient_evidence` marker.
269    ///
270    /// The queue records the reason verbatim and operators grep it to tell an
271    /// abstention from a refusal; a reworded prefix breaks that silently.
272    #[test]
273    fn the_abstention_reasons_carry_their_marker() {
274        assert!(entity_type_no_evidence(0, 40).starts_with("insufficient_evidence:"));
275        assert!(entity_type_model_abstained(0).starts_with("insufficient_evidence:"));
276        assert!(entity_type_suggestion_unusable("x").starts_with("unusable_suggestion:"));
277    }
278}