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/// Write-time confidence floor (`#796`).
26///
27/// Basis points, the same scale as [`CandidateFact::confidence_bps`]: a fact
28/// the classifier itself is not reasonably sure of must never become a
29/// durable, cross-conversation "fact" just because the model emitted
30/// well-formed JSON. 6000 = 60%.
31pub const MIN_CONFIDENCE_BPS: u32 = 6_000;
32
33/// One fact the extractor proposes to remember.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CandidateFact {
36    /// The fact, one self-contained sentence.
37    pub text: String,
38    /// Entities the fact mentions.
39    pub entities: Vec<String>,
40    /// Extractor confidence in basis points (0–10000).
41    pub confidence_bps: u32,
42    /// The id of the existing fact this one supersedes, when the extractor
43    /// paired the add with a contradiction — what lets the store link the
44    /// closed interval to its replacement (`superseded_by`).
45    pub replaces: Option<String>,
46}
47
48/// One existing fact the extractor proposes to invalidate (contradicted by
49/// this turn).
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Invalidation {
52    /// The id of the existing fact whose validity interval should close.
53    pub fact_id: String,
54    /// Why (one short phrase).
55    pub reason: String,
56}
57
58/// What one extraction pass proposes.
59#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct ExtractedMemories {
61    /// New facts to append.
62    pub added: Vec<CandidateFact>,
63    /// Existing facts this turn contradicted.
64    pub invalidated: Vec<Invalidation>,
65    /// Ids of existing facts this turn merely RESTATED — the same claim in
66    /// different words, no new information. The semantic merge pass (#860) folds
67    /// each into its existing entry as a corroboration instead of appending a
68    /// near-duplicate (INV-P26), never crossing scope (INV-P24). Empty when the
69    /// turn restated nothing.
70    pub corroborated: Vec<String>,
71}
72
73/// An existing active fact, as shown to the extractor for contradiction
74/// checks.
75#[derive(Debug, Clone)]
76pub struct ExistingFact {
77    /// The fact's journal id (what an invalidation must reference).
78    pub fact_id: String,
79    /// The fact text.
80    pub text: String,
81}
82
83/// The extractor's JSON reply shape (tolerantly deserialized).
84#[derive(Debug, Default, Deserialize)]
85struct WireReply {
86    #[serde(default)]
87    added: Vec<WireFact>,
88    #[serde(default)]
89    invalidated: Vec<WireInvalidation>,
90    #[serde(default)]
91    corroborated: Vec<String>,
92}
93
94#[derive(Debug, Deserialize)]
95struct WireFact {
96    #[serde(default)]
97    text: String,
98    #[serde(default)]
99    entities: Vec<String>,
100    /// 0–100; clamped and scaled to basis points.
101    #[serde(default)]
102    confidence: u32,
103    /// The id of the existing fact this one replaces; empty when the add is
104    /// not a replacement.
105    #[serde(default)]
106    replaces: String,
107}
108
109#[derive(Debug, Deserialize)]
110struct WireInvalidation {
111    #[serde(default)]
112    fact_id: String,
113    #[serde(default)]
114    reason: String,
115}
116
117/// System prompt for the extraction pass.
118const fn system_prompt() -> &'static str {
119    "You distill a conversation turn into durable facts about the person speaking — things \
120     worth remembering across future conversations (preferences, role, projects, standing \
121     constraints). Ignore small talk, one-off logistics, and anything about the assistant \
122     itself. Never emit a fact naming a home address, a phone number, a government id \
123     (SSN, passport, driver's license), a financial account or card number, a password or \
124     API/secret key, or a health/medical detail — omit the fact entirely rather than \
125     write around it. Set confidence honestly (0-100): a fact you are not reasonably sure \
126     of is worse than no fact, so lean low rather than guess. You are also given the \
127     person's EXISTING facts with ids; when this turn contradicts one, list its id under \
128     invalidated AND add the replacement fact under added with \"replaces\" set to that \
129     same id, so the old fact links to its replacement. Omit \"replaces\" for a fact that \
130     replaces nothing. When this turn merely RESTATES an existing fact — the same claim in \
131     different words, with no new or changed information — do NOT add it: list that existing \
132     fact's id under corroborated instead, so the known fact is reinforced rather than \
133     duplicated.\n\
134     Reply with ONLY this JSON, no prose:\n\
135     {\"added\":[{\"text\":\"…\",\"entities\":[\"…\"],\"confidence\":0-100,\
136     \"replaces\":\"existing fact id, or omit\"}],\
137     \"invalidated\":[{\"fact_id\":\"…\",\"reason\":\"…\"}],\
138     \"corroborated\":[\"existing fact id\"]}\n\
139     All arrays may be empty. At most a few added facts per turn."
140}
141
142/// Render the extractor's user message: existing facts (with ids), then the
143/// turn transcript.
144fn render_input(transcript: &[ParticipationMsg], existing: &[ExistingFact]) -> String {
145    use std::fmt::Write as _;
146    let mut out = String::new();
147    out.push_str("EXISTING FACTS:\n");
148    if existing.is_empty() {
149        out.push_str("(none)\n");
150    }
151    for fact in existing {
152        let _ = writeln!(out, "- [{}] {}", fact.fact_id, fact.text);
153    }
154    out.push_str("\nTURN TRANSCRIPT:\n");
155    for msg in transcript {
156        let speaker = if msg.is_self {
157            "assistant"
158        } else {
159            &msg.speaker
160        };
161        out.push_str(speaker);
162        out.push_str(": ");
163        out.push_str(&msg.text);
164        out.push('\n');
165    }
166    out
167}
168
169/// Refused-category keywords for the post-parse PII heuristic (`#796`):
170/// lowercased substrings that, anywhere in a candidate fact's text, mean the
171/// fact names an identifier or category durable memory must never carry —
172/// a home address, a health/medical detail, or a credential/identifier.
173/// Defense in depth: the extractor prompt already instructs the model to
174/// omit these, but a model that ignores the instruction gets no second
175/// chance at the parser. Deliberately over-inclusive (a false positive costs
176/// one skipped fact; a false negative persists PII).
177const PII_REFUSAL_KEYWORDS: &[&str] = &[
178    "ssn",
179    "social security",
180    "credit card",
181    "card number",
182    "cvv",
183    "passport number",
184    "driver's license",
185    "password",
186    "api key",
187    "secret key",
188    "private key",
189    "home address",
190    "lives at",
191    "street address",
192    "diagnosed with",
193    "medical condition",
194    "prescription",
195    "medication",
196    "mental health",
197];
198
199/// Whether `text` carries a long digit run (7+ digits, allowing the
200/// separators a phone number, SSN, or card number is typically written
201/// with — spaces, `-`, `.`, parens). Any non-digit, non-separator character
202/// resets the run, so an ordinary sentence with a short number ("3 dogs")
203/// never trips it.
204fn has_long_digit_run(text: &str) -> bool {
205    const MIN_RUN: usize = 7;
206    let mut run = 0usize;
207    for ch in text.chars() {
208        if ch.is_ascii_digit() {
209            run += 1;
210            if run >= MIN_RUN {
211                return true;
212            }
213        } else if matches!(ch, '-' | '.' | ' ' | '(' | ')' | '+') {
214            // Separator: keep accumulating across it.
215        } else {
216            run = 0;
217        }
218    }
219    false
220}
221
222/// The post-parse PII heuristic (`#796`): a refused-category keyword or a
223/// long digit run anywhere in the text.
224///
225/// Runs on every candidate fact, every compaction-digest line, and every
226/// deliberate memory-write note regardless of confidence — a confident PII
227/// hit is exactly the dangerous case, not an exception to it.
228///
229/// Public because it is the ONE PII refusal predicate for durable memory:
230/// fact extraction, the compaction digest pass (`#1149`), and the
231/// deliberate `memory_write` path (`#1139`, INV-C9) all refuse through this
232/// same function, so no write path can ever drift on what memory must never
233/// carry.
234#[must_use]
235pub fn looks_like_pii(text: &str) -> bool {
236    if has_long_digit_run(text) {
237        return true;
238    }
239    let lower = text.to_lowercase();
240    PII_REFUSAL_KEYWORDS.iter().any(|kw| lower.contains(kw))
241}
242
243/// Parse the model reply tolerantly: take the outermost `{…}` slice, decode,
244/// drop empty/oversized entries, clamp confidence, apply the write-time
245/// confidence floor and the PII heuristic (`#796`), and cap the batch. ANY
246/// parse failure extracts nothing — never an error the pipeline would
247/// propagate.
248fn parse_reply(text: &str) -> ExtractedMemories {
249    let Some(start) = text.find('{') else {
250        return ExtractedMemories::default();
251    };
252    let Some(end) = text.rfind('}') else {
253        return ExtractedMemories::default();
254    };
255    let Ok(wire) = serde_json::from_str::<WireReply>(&text[start..=end]) else {
256        tracing::debug!("memory extractor reply was not the expected JSON; extracting nothing");
257        return ExtractedMemories::default();
258    };
259    let added = wire
260        .added
261        .into_iter()
262        .filter(|f| !f.text.trim().is_empty())
263        .filter_map(|f| {
264            let confidence_bps = f.confidence.min(100) * 100;
265            if confidence_bps < MIN_CONFIDENCE_BPS {
266                tracing::debug!(
267                    confidence_bps,
268                    floor = MIN_CONFIDENCE_BPS,
269                    "extracted fact below the write-time confidence floor; dropped"
270                );
271                return None;
272            }
273            let text = f.text.trim().to_owned();
274            if looks_like_pii(&text) {
275                tracing::info!("extracted fact matched a PII refusal category; dropped (#796)");
276                return None;
277            }
278            Some(CandidateFact {
279                text,
280                entities: f
281                    .entities
282                    .into_iter()
283                    .filter(|e| !e.trim().is_empty())
284                    .collect(),
285                confidence_bps,
286                replaces: {
287                    let id = f.replaces.trim();
288                    (!id.is_empty()).then(|| id.to_owned())
289                },
290            })
291        })
292        .take(MAX_FACTS_PER_TURN)
293        .collect();
294    let invalidated: Vec<Invalidation> = wire
295        .invalidated
296        .into_iter()
297        .filter(|i| !i.fact_id.trim().is_empty())
298        .map(|i| Invalidation {
299            fact_id: i.fact_id.trim().to_owned(),
300            reason: if i.reason.trim().is_empty() {
301                "contradicted".to_owned()
302            } else {
303                i.reason.trim().to_owned()
304            },
305        })
306        .collect();
307    // A restated fact is corroboration, a contradiction is invalidation — never
308    // both. If the model listed an id under both, invalidation wins: the merge
309    // pass must not reinforce a fact this same turn also contradicted.
310    let invalidated_ids: std::collections::HashSet<&str> =
311        invalidated.iter().map(|i| i.fact_id.as_str()).collect();
312    let mut seen = std::collections::HashSet::new();
313    let corroborated = wire
314        .corroborated
315        .into_iter()
316        .filter_map(|id| {
317            let id = id.trim();
318            (!id.is_empty() && !invalidated_ids.contains(id) && seen.insert(id.to_owned()))
319                .then(|| id.to_owned())
320        })
321        .collect();
322    ExtractedMemories {
323        added,
324        invalidated,
325        corroborated,
326    }
327}
328
329/// Distill one committed turn into memory operations.
330///
331/// Builds a [`CompletionRequest`] for `model` carrying the extraction
332/// instructions, the persona's existing active facts, and the turn
333/// transcript; runs it through `provider`; and parses the JSON reply
334/// tolerantly (an unparseable reply extracts nothing). An empty `model`
335/// defers to the provider's configured default — the right call for a
336/// dedicated classifier provider; a non-empty value overrides it
337/// per-request. Keep whichever applies pointed at a fast, inexpensive
338/// backend — this runs after every committed turn.
339///
340/// # Errors
341///
342/// Propagates `P::Error` from [`LlmProvider::complete`] (pre-stream
343/// failures) and from [`collect_turn`] (mid-stream faults) — the caller
344/// logs and drops; extraction is best-effort by design.
345pub async fn extract_memories<P: LlmProvider + ?Sized>(
346    provider: &P,
347    model: &str,
348    transcript: &[ParticipationMsg],
349    existing: &[ExistingFact],
350) -> Result<ExtractedMemories, P::Error> {
351    let mut req = CompletionRequest::new(model);
352    req.messages.push(Message {
353        role: Role::System,
354        content: vec![Content::Text(system_prompt().to_owned())],
355    });
356    req.messages.push(Message {
357        role: Role::User,
358        content: vec![Content::Text(render_input(transcript, existing))],
359    });
360    let stream = provider.complete(req).await?;
361    let out = collect_turn(stream).await?;
362    Ok(parse_reply(&out.text))
363}
364
365#[cfg(test)]
366mod tests {
367    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
368
369    use std::sync::{Arc, Mutex};
370
371    use async_trait::async_trait;
372    use futures::stream::{self, BoxStream, StreamExt};
373    use polyc_llm::{Chunk, StopReason, error::DummyError};
374
375    use super::*;
376
377    #[derive(Clone)]
378    struct MockProvider {
379        reply: String,
380        captured: Arc<Mutex<Option<CompletionRequest>>>,
381    }
382
383    impl MockProvider {
384        fn new(reply: &str) -> Self {
385            Self {
386                reply: reply.to_owned(),
387                captured: Arc::new(Mutex::new(None)),
388            }
389        }
390    }
391
392    #[async_trait]
393    impl LlmProvider for MockProvider {
394        type Error = DummyError;
395
396        async fn complete(
397            &self,
398            req: CompletionRequest,
399        ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
400            *self.captured.lock().unwrap() = Some(req);
401            let chunks = vec![
402                Ok(Chunk::text_delta(self.reply.clone())),
403                Ok(Chunk::Stop(StopReason::EndTurn)),
404            ];
405            Ok(stream::iter(chunks).boxed())
406        }
407    }
408
409    fn transcript() -> Vec<ParticipationMsg> {
410        vec![
411            ParticipationMsg {
412                speaker: "erica".to_owned(),
413                text: "actually I've switched to filter coffee".to_owned(),
414                is_self: false,
415            },
416            ParticipationMsg {
417                speaker: "bot".to_owned(),
418                text: "noted!".to_owned(),
419                is_self: true,
420            },
421        ]
422    }
423
424    #[tokio::test]
425    async fn well_formed_reply_parses_adds_and_invalidations() {
426        let provider = MockProvider::new(
427            r#"{"added":[{"text":"prefers filter coffee","entities":["coffee"],"confidence":90,"replaces":"f1"}],
428                "invalidated":[{"fact_id":"f1","reason":"switched"}]}"#,
429        );
430        let existing = [ExistingFact {
431            fact_id: "f1".to_owned(),
432            text: "prefers espresso".to_owned(),
433        }];
434        let out = extract_memories(&provider, "fast", &transcript(), &existing)
435            .await
436            .expect("extract");
437        assert_eq!(out.added.len(), 1);
438        assert_eq!(out.added[0].text, "prefers filter coffee");
439        assert_eq!(out.added[0].confidence_bps, 9_000);
440        assert_eq!(
441            out.added[0].replaces.as_deref(),
442            Some("f1"),
443            "the replacement pairing survives parsing"
444        );
445        assert_eq!(out.invalidated.len(), 1);
446        assert_eq!(out.invalidated[0].fact_id, "f1");
447    }
448
449    /// The `corroborated` restatement signal (#860) parses, dedups, and never
450    /// overlaps with `invalidated`: a fact the same turn contradicted is never
451    /// also reinforced (invalidation wins).
452    #[tokio::test]
453    async fn corroborated_ids_parse_dedup_and_exclude_contradictions() {
454        let provider = MockProvider::new(
455            r#"{"added":[],
456                "invalidated":[{"fact_id":"f2","reason":"changed"}],
457                "corroborated":["f1"," f1 ","  ","f2"]}"#,
458        );
459        let out = extract_memories(&provider, "fast", &transcript(), &[])
460            .await
461            .expect("extract");
462        assert_eq!(
463            out.corroborated,
464            vec!["f1".to_owned()],
465            "f1 dedups to one; blanks drop; f2 is excluded (it was invalidated)"
466        );
467    }
468
469    #[tokio::test]
470    async fn missing_or_blank_replaces_parses_as_none() {
471        let provider = MockProvider::new(
472            r#"{"added":[{"text":"works UTC+2","confidence":80},
473                          {"text":"has a dog","confidence":70,"replaces":"  "}]}"#,
474        );
475        let out = extract_memories(&provider, "fast", &transcript(), &[])
476            .await
477            .expect("extract");
478        assert_eq!(out.added.len(), 2);
479        assert!(out.added.iter().all(|f| f.replaces.is_none()));
480    }
481
482    #[tokio::test]
483    async fn prose_wrapped_json_still_parses() {
484        let provider = MockProvider::new(
485            "Here you go:\n{\"added\":[{\"text\":\"works UTC+2\",\"confidence\":80}],\"invalidated\":[]}\nDone.",
486        );
487        let out = extract_memories(&provider, "fast", &transcript(), &[])
488            .await
489            .expect("extract");
490        assert_eq!(out.added.len(), 1);
491        assert_eq!(out.added[0].confidence_bps, 8_000);
492    }
493
494    #[tokio::test]
495    async fn garbage_reply_extracts_nothing() {
496        let provider = MockProvider::new("no json here at all");
497        let out = extract_memories(&provider, "fast", &transcript(), &[])
498            .await
499            .expect("extract");
500        assert_eq!(out, ExtractedMemories::default());
501    }
502
503    #[tokio::test]
504    async fn malformed_json_extracts_nothing() {
505        let provider = MockProvider::new(r#"{"added": [{"text": 12}], "invalid"#);
506        let out = extract_memories(&provider, "fast", &transcript(), &[])
507            .await
508            .expect("extract");
509        assert_eq!(out, ExtractedMemories::default());
510    }
511
512    #[tokio::test]
513    async fn empty_texts_and_over_cap_batches_are_bounded() {
514        let many: Vec<String> = (0..20)
515            .map(|i| format!(r#"{{"text":"fact {i}","confidence":300}}"#))
516            .collect();
517        let provider = MockProvider::new(&format!(
518            r#"{{"added":[{},{}],"invalidated":[{{"fact_id":"  "}}]}}"#,
519            r#"{"text":"   "}"#,
520            many.join(",")
521        ));
522        let out = extract_memories(&provider, "fast", &transcript(), &[])
523            .await
524            .expect("extract");
525        assert_eq!(out.added.len(), MAX_FACTS_PER_TURN, "batch is capped");
526        assert!(
527            out.added.iter().all(|f| f.confidence_bps <= 10_000),
528            "confidence clamps to 100%"
529        );
530        assert!(
531            out.invalidated.is_empty(),
532            "blank fact ids are dropped, not passed through"
533        );
534    }
535
536    /// Write-time confidence floor (`#796`, defect #2): a low-confidence fact
537    /// is dropped at parse time even though it is otherwise well-formed — the
538    /// extractor never gets to persist something it wasn't sure of.
539    #[tokio::test]
540    async fn low_confidence_fact_is_dropped() {
541        let provider = MockProvider::new(
542            r#"{"added":[
543                {"text":"maybe prefers tea, not certain","confidence":40},
544                {"text":"definitely prefers filter coffee","confidence":95}
545            ]}"#,
546        );
547        let out = extract_memories(&provider, "fast", &transcript(), &[])
548            .await
549            .expect("extract");
550        assert_eq!(out.added.len(), 1, "the below-floor fact is dropped");
551        assert_eq!(out.added[0].text, "definitely prefers filter coffee");
552    }
553
554    /// The PII heuristic (`#796`, defect #2): a private-address or
555    /// health/credential fact is refused regardless of confidence — a
556    /// confident PII fact is the dangerous case, not an exception.
557    #[tokio::test]
558    async fn pii_facts_are_refused_even_at_high_confidence() {
559        let provider = MockProvider::new(
560            r#"{"added":[
561                {"text":"home address is 42 Rowan Street","confidence":99},
562                {"text":"was diagnosed with a chronic condition","confidence":99},
563                {"text":"phone number is 555-123-4567","confidence":99},
564                {"text":"prefers filter coffee","confidence":99}
565            ]}"#,
566        );
567        let out = extract_memories(&provider, "fast", &transcript(), &[])
568            .await
569            .expect("extract");
570        assert_eq!(
571            out.added.len(),
572            1,
573            "only the non-PII fact survives: {:?}",
574            out.added
575        );
576        assert_eq!(out.added[0].text, "prefers filter coffee");
577    }
578
579    #[tokio::test]
580    async fn request_carries_existing_facts_and_transcript() {
581        let provider = MockProvider::new("{}");
582        let existing = [ExistingFact {
583            fact_id: "f1".to_owned(),
584            text: "prefers espresso".to_owned(),
585        }];
586        let _ = extract_memories(&provider, "fast", &transcript(), &existing)
587            .await
588            .expect("extract");
589        let req = provider.captured.lock().unwrap().clone().expect("captured");
590        assert_eq!(req.messages.len(), 2);
591        let user_text = match &req.messages[1].content[0] {
592            Content::Text(t) => t.clone(),
593            other => panic!("expected text, got {other:?}"),
594        };
595        assert!(user_text.contains("[f1] prefers espresso"));
596        assert!(user_text.contains("erica: actually I've switched"));
597        assert!(user_text.contains("assistant: noted!"));
598    }
599}