1use polyc_llm::{CompletionRequest, Content, LlmProvider, Message, Role, turn::collect_turn};
17use serde::Deserialize;
18
19use crate::participation::ParticipationMsg;
20
21pub const MAX_FACTS_PER_TURN: usize = 8;
24
25pub const MIN_CONFIDENCE_BPS: u32 = 6_000;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct CandidateFact {
36 pub text: String,
38 pub entities: Vec<String>,
40 pub confidence_bps: u32,
42 pub replaces: Option<String>,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Invalidation {
52 pub fact_id: String,
54 pub reason: String,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
60pub struct ExtractedMemories {
61 pub added: Vec<CandidateFact>,
63 pub invalidated: Vec<Invalidation>,
65 pub corroborated: Vec<String>,
71}
72
73#[derive(Debug, Clone)]
76pub struct ExistingFact {
77 pub fact_id: String,
79 pub text: String,
81}
82
83#[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 #[serde(default)]
102 confidence: u32,
103 #[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
117const 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
142fn 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
169const 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
199fn 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 } else {
216 run = 0;
217 }
218 }
219 false
220}
221
222#[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
243fn 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 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
329pub 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 #[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 #[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 #[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}