Skip to main content

polyc_agent/
extraction.rs

1//! The provider-agnostic post-turn memory extractor
2//! (docs/design/personas.md §5, issue #214).
3//!
4//! After a turn commits, a cheap model distills the transcript into durable
5//! facts about the person — never in the turn's hot path. The extractor is
6//! handed the persona's existing ACTIVE facts so contradictions come back as
7//! invalidations (paired with the replacing fact) rather than duplicates;
8//! the store then closes the old fact's validity interval — invalidate,
9//! never delete.
10//!
11//! Like the participation classifier next door, this is deliberately
12//! provider-agnostic and **tolerant on the way out**: a reply that isn't
13//! the expected JSON extracts nothing (memory is best-effort enrichment; a
14//! flaky cheap model must never break the pipeline).
15
16use polyc_llm::{CompletionRequest, Content, LlmProvider, Message, Role, turn::collect_turn};
17use serde::Deserialize;
18
19use crate::participation::ParticipationMsg;
20
21/// Most facts one turn may add. A chatty turn distills to a few durable
22/// facts; dozens means the model is transcribing, not distilling.
23pub const MAX_FACTS_PER_TURN: usize = 8;
24
25/// One fact the extractor proposes to remember.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CandidateFact {
28    /// The fact, one self-contained sentence.
29    pub text: String,
30    /// Entities the fact mentions.
31    pub entities: Vec<String>,
32    /// Extractor confidence in basis points (0–10000).
33    pub confidence_bps: u32,
34    /// The id of the existing fact this one supersedes, when the extractor
35    /// paired the add with a contradiction — what lets the store link the
36    /// closed interval to its replacement (`superseded_by`).
37    pub replaces: Option<String>,
38}
39
40/// One existing fact the extractor proposes to invalidate (contradicted by
41/// this turn).
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Invalidation {
44    /// The id of the existing fact whose validity interval should close.
45    pub fact_id: String,
46    /// Why (one short phrase).
47    pub reason: String,
48}
49
50/// What one extraction pass proposes.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct ExtractedMemories {
53    /// New facts to append.
54    pub added: Vec<CandidateFact>,
55    /// Existing facts this turn contradicted.
56    pub invalidated: Vec<Invalidation>,
57}
58
59/// An existing active fact, as shown to the extractor for contradiction
60/// checks.
61#[derive(Debug, Clone)]
62pub struct ExistingFact {
63    /// The fact's journal id (what an invalidation must reference).
64    pub fact_id: String,
65    /// The fact text.
66    pub text: String,
67}
68
69/// The extractor's JSON reply shape (tolerantly deserialized).
70#[derive(Debug, Default, Deserialize)]
71struct WireReply {
72    #[serde(default)]
73    added: Vec<WireFact>,
74    #[serde(default)]
75    invalidated: Vec<WireInvalidation>,
76}
77
78#[derive(Debug, Deserialize)]
79struct WireFact {
80    #[serde(default)]
81    text: String,
82    #[serde(default)]
83    entities: Vec<String>,
84    /// 0–100; clamped and scaled to basis points.
85    #[serde(default)]
86    confidence: u32,
87    /// The id of the existing fact this one replaces; empty when the add is
88    /// not a replacement.
89    #[serde(default)]
90    replaces: String,
91}
92
93#[derive(Debug, Deserialize)]
94struct WireInvalidation {
95    #[serde(default)]
96    fact_id: String,
97    #[serde(default)]
98    reason: String,
99}
100
101/// System prompt for the extraction pass.
102const fn system_prompt() -> &'static str {
103    "You distill a conversation turn into durable facts about the person speaking — things \
104     worth remembering across future conversations (preferences, role, projects, standing \
105     constraints). Ignore small talk, one-off logistics, and anything about the assistant \
106     itself. You are also given the person's EXISTING facts with ids; when this turn \
107     contradicts one, list its id under invalidated AND add the replacement fact under \
108     added with \"replaces\" set to that same id, so the old fact links to its \
109     replacement. Omit \"replaces\" for a fact that replaces nothing.\n\
110     Reply with ONLY this JSON, no prose:\n\
111     {\"added\":[{\"text\":\"…\",\"entities\":[\"…\"],\"confidence\":0-100,\
112     \"replaces\":\"existing fact id, or omit\"}],\
113     \"invalidated\":[{\"fact_id\":\"…\",\"reason\":\"…\"}]}\n\
114     Both arrays may be empty. At most a few added facts per turn."
115}
116
117/// Render the extractor's user message: existing facts (with ids), then the
118/// turn transcript.
119fn render_input(transcript: &[ParticipationMsg], existing: &[ExistingFact]) -> String {
120    use std::fmt::Write as _;
121    let mut out = String::new();
122    out.push_str("EXISTING FACTS:\n");
123    if existing.is_empty() {
124        out.push_str("(none)\n");
125    }
126    for fact in existing {
127        let _ = writeln!(out, "- [{}] {}", fact.fact_id, fact.text);
128    }
129    out.push_str("\nTURN TRANSCRIPT:\n");
130    for msg in transcript {
131        let speaker = if msg.is_self {
132            "assistant"
133        } else {
134            &msg.speaker
135        };
136        out.push_str(speaker);
137        out.push_str(": ");
138        out.push_str(&msg.text);
139        out.push('\n');
140    }
141    out
142}
143
144/// Parse the model reply tolerantly: take the outermost `{…}` slice, decode,
145/// drop empty/oversized entries, clamp confidence, and cap the batch. ANY
146/// parse failure extracts nothing — never an error the pipeline would
147/// propagate.
148fn parse_reply(text: &str) -> ExtractedMemories {
149    let Some(start) = text.find('{') else {
150        return ExtractedMemories::default();
151    };
152    let Some(end) = text.rfind('}') else {
153        return ExtractedMemories::default();
154    };
155    let Ok(wire) = serde_json::from_str::<WireReply>(&text[start..=end]) else {
156        tracing::debug!("memory extractor reply was not the expected JSON; extracting nothing");
157        return ExtractedMemories::default();
158    };
159    let added = wire
160        .added
161        .into_iter()
162        .filter(|f| !f.text.trim().is_empty())
163        .take(MAX_FACTS_PER_TURN)
164        .map(|f| CandidateFact {
165            text: f.text.trim().to_owned(),
166            entities: f
167                .entities
168                .into_iter()
169                .filter(|e| !e.trim().is_empty())
170                .collect(),
171            confidence_bps: f.confidence.min(100) * 100,
172            replaces: {
173                let id = f.replaces.trim();
174                (!id.is_empty()).then(|| id.to_owned())
175            },
176        })
177        .collect();
178    let invalidated = wire
179        .invalidated
180        .into_iter()
181        .filter(|i| !i.fact_id.trim().is_empty())
182        .map(|i| Invalidation {
183            fact_id: i.fact_id.trim().to_owned(),
184            reason: if i.reason.trim().is_empty() {
185                "contradicted".to_owned()
186            } else {
187                i.reason.trim().to_owned()
188            },
189        })
190        .collect();
191    ExtractedMemories { added, invalidated }
192}
193
194/// Distill one committed turn into memory operations.
195///
196/// Builds a [`CompletionRequest`] for `model` carrying the extraction
197/// instructions, the persona's existing active facts, and the turn
198/// transcript; runs it through `provider`; and parses the JSON reply
199/// tolerantly (an unparseable reply extracts nothing). An empty `model`
200/// defers to the provider's configured default — the right call for a
201/// dedicated classifier provider; a non-empty value overrides it
202/// per-request. Keep whichever applies pointed at a fast, inexpensive
203/// backend — this runs after every committed turn.
204///
205/// # Errors
206///
207/// Propagates `P::Error` from [`LlmProvider::complete`] (pre-stream
208/// failures) and from [`collect_turn`] (mid-stream faults) — the caller
209/// logs and drops; extraction is best-effort by design.
210pub async fn extract_memories<P: LlmProvider + ?Sized>(
211    provider: &P,
212    model: &str,
213    transcript: &[ParticipationMsg],
214    existing: &[ExistingFact],
215) -> Result<ExtractedMemories, P::Error> {
216    let mut req = CompletionRequest::new(model);
217    req.messages.push(Message {
218        role: Role::System,
219        content: vec![Content::Text(system_prompt().to_owned())],
220    });
221    req.messages.push(Message {
222        role: Role::User,
223        content: vec![Content::Text(render_input(transcript, existing))],
224    });
225    let stream = provider.complete(req).await?;
226    let out = collect_turn(stream).await?;
227    Ok(parse_reply(&out.text))
228}
229
230#[cfg(test)]
231mod tests {
232    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
233
234    use std::sync::{Arc, Mutex};
235
236    use async_trait::async_trait;
237    use futures::stream::{self, BoxStream, StreamExt};
238    use polyc_llm::{Chunk, StopReason, error::DummyError};
239
240    use super::*;
241
242    #[derive(Clone)]
243    struct MockProvider {
244        reply: String,
245        captured: Arc<Mutex<Option<CompletionRequest>>>,
246    }
247
248    impl MockProvider {
249        fn new(reply: &str) -> Self {
250            Self {
251                reply: reply.to_owned(),
252                captured: Arc::new(Mutex::new(None)),
253            }
254        }
255    }
256
257    #[async_trait]
258    impl LlmProvider for MockProvider {
259        type Error = DummyError;
260
261        async fn complete(
262            &self,
263            req: CompletionRequest,
264        ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
265            *self.captured.lock().unwrap() = Some(req);
266            let chunks = vec![
267                Ok(Chunk::text_delta(self.reply.clone())),
268                Ok(Chunk::Stop(StopReason::EndTurn)),
269            ];
270            Ok(stream::iter(chunks).boxed())
271        }
272    }
273
274    fn transcript() -> Vec<ParticipationMsg> {
275        vec![
276            ParticipationMsg {
277                speaker: "erica".to_owned(),
278                text: "actually I've switched to filter coffee".to_owned(),
279                is_self: false,
280            },
281            ParticipationMsg {
282                speaker: "bot".to_owned(),
283                text: "noted!".to_owned(),
284                is_self: true,
285            },
286        ]
287    }
288
289    #[tokio::test]
290    async fn well_formed_reply_parses_adds_and_invalidations() {
291        let provider = MockProvider::new(
292            r#"{"added":[{"text":"prefers filter coffee","entities":["coffee"],"confidence":90,"replaces":"f1"}],
293                "invalidated":[{"fact_id":"f1","reason":"switched"}]}"#,
294        );
295        let existing = [ExistingFact {
296            fact_id: "f1".to_owned(),
297            text: "prefers espresso".to_owned(),
298        }];
299        let out = extract_memories(&provider, "fast", &transcript(), &existing)
300            .await
301            .expect("extract");
302        assert_eq!(out.added.len(), 1);
303        assert_eq!(out.added[0].text, "prefers filter coffee");
304        assert_eq!(out.added[0].confidence_bps, 9_000);
305        assert_eq!(
306            out.added[0].replaces.as_deref(),
307            Some("f1"),
308            "the replacement pairing survives parsing"
309        );
310        assert_eq!(out.invalidated.len(), 1);
311        assert_eq!(out.invalidated[0].fact_id, "f1");
312    }
313
314    #[tokio::test]
315    async fn missing_or_blank_replaces_parses_as_none() {
316        let provider = MockProvider::new(
317            r#"{"added":[{"text":"works UTC+2","confidence":80},
318                          {"text":"has a dog","confidence":70,"replaces":"  "}]}"#,
319        );
320        let out = extract_memories(&provider, "fast", &transcript(), &[])
321            .await
322            .expect("extract");
323        assert_eq!(out.added.len(), 2);
324        assert!(out.added.iter().all(|f| f.replaces.is_none()));
325    }
326
327    #[tokio::test]
328    async fn prose_wrapped_json_still_parses() {
329        let provider = MockProvider::new(
330            "Here you go:\n{\"added\":[{\"text\":\"works UTC+2\",\"confidence\":80}],\"invalidated\":[]}\nDone.",
331        );
332        let out = extract_memories(&provider, "fast", &transcript(), &[])
333            .await
334            .expect("extract");
335        assert_eq!(out.added.len(), 1);
336        assert_eq!(out.added[0].confidence_bps, 8_000);
337    }
338
339    #[tokio::test]
340    async fn garbage_reply_extracts_nothing() {
341        let provider = MockProvider::new("no json here at all");
342        let out = extract_memories(&provider, "fast", &transcript(), &[])
343            .await
344            .expect("extract");
345        assert_eq!(out, ExtractedMemories::default());
346    }
347
348    #[tokio::test]
349    async fn malformed_json_extracts_nothing() {
350        let provider = MockProvider::new(r#"{"added": [{"text": 12}], "invalid"#);
351        let out = extract_memories(&provider, "fast", &transcript(), &[])
352            .await
353            .expect("extract");
354        assert_eq!(out, ExtractedMemories::default());
355    }
356
357    #[tokio::test]
358    async fn empty_texts_and_over_cap_batches_are_bounded() {
359        let many: Vec<String> = (0..20)
360            .map(|i| format!(r#"{{"text":"fact {i}","confidence":300}}"#))
361            .collect();
362        let provider = MockProvider::new(&format!(
363            r#"{{"added":[{},{}],"invalidated":[{{"fact_id":"  "}}]}}"#,
364            r#"{"text":"   "}"#,
365            many.join(",")
366        ));
367        let out = extract_memories(&provider, "fast", &transcript(), &[])
368            .await
369            .expect("extract");
370        assert_eq!(out.added.len(), MAX_FACTS_PER_TURN, "batch is capped");
371        assert!(
372            out.added.iter().all(|f| f.confidence_bps <= 10_000),
373            "confidence clamps to 100%"
374        );
375        assert!(
376            out.invalidated.is_empty(),
377            "blank fact ids are dropped, not passed through"
378        );
379    }
380
381    #[tokio::test]
382    async fn request_carries_existing_facts_and_transcript() {
383        let provider = MockProvider::new("{}");
384        let existing = [ExistingFact {
385            fact_id: "f1".to_owned(),
386            text: "prefers espresso".to_owned(),
387        }];
388        let _ = extract_memories(&provider, "fast", &transcript(), &existing)
389            .await
390            .expect("extract");
391        let req = provider.captured.lock().unwrap().clone().expect("captured");
392        assert_eq!(req.messages.len(), 2);
393        let user_text = match &req.messages[1].content[0] {
394            Content::Text(t) => t.clone(),
395            other => panic!("expected text, got {other:?}"),
396        };
397        assert!(user_text.contains("[f1] prefers espresso"));
398        assert!(user_text.contains("erica: actually I've switched"));
399        assert!(user_text.contains("assistant: noted!"));
400    }
401}