1use polyc_llm::{CompletionRequest, Content, LlmProvider, Message, Role, turn::collect_turn};
17use polyc_proto::proto::polychrome::events::v1::MemoryDurability;
18use serde::Deserialize;
19
20use crate::participation::ParticipationMsg;
21
22pub const MAX_FACTS_PER_TURN: usize = 8;
25
26pub const MIN_CONFIDENCE_BPS: u32 = 6_000;
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct CandidateFact {
37 pub text: String,
39 pub entities: Vec<String>,
41 pub confidence_bps: u32,
43 pub replaces: Option<String>,
47 pub durability: MemoryDurability,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Invalidation {
62 pub fact_id: String,
64 pub reason: String,
66}
67
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
70pub struct ExtractedMemories {
71 pub added: Vec<CandidateFact>,
73 pub invalidated: Vec<Invalidation>,
75 pub corroborated: Vec<String>,
81}
82
83#[derive(Debug, Clone)]
86pub struct ExistingFact {
87 pub fact_id: String,
89 pub text: String,
91}
92
93#[derive(Debug, Default, Deserialize)]
95struct WireReply {
96 #[serde(default)]
97 added: Vec<WireFact>,
98 #[serde(default)]
99 invalidated: Vec<WireInvalidation>,
100 #[serde(default)]
101 corroborated: Vec<String>,
102}
103
104#[derive(Debug, Deserialize)]
105struct WireFact {
106 #[serde(default)]
107 text: String,
108 #[serde(default)]
109 entities: Vec<String>,
110 #[serde(default)]
112 confidence: u32,
113 #[serde(default)]
116 replaces: String,
117 #[serde(default)]
121 durability: String,
122}
123
124fn parse_durability(raw: &str) -> MemoryDurability {
131 if raw.trim().eq_ignore_ascii_case("durable") {
132 MemoryDurability::Durable
133 } else {
134 MemoryDurability::Session
135 }
136}
137
138#[derive(Debug, Deserialize)]
139struct WireInvalidation {
140 #[serde(default)]
141 fact_id: String,
142 #[serde(default)]
143 reason: String,
144}
145
146const fn system_prompt() -> &'static str {
148 "You distill a conversation turn into durable facts about the person speaking — things \
149 worth remembering across future conversations (preferences, role, projects, standing \
150 constraints). Ignore small talk, one-off logistics, and anything about the assistant \
151 itself. Never emit a fact naming a home address, a phone number, a government id \
152 (SSN, passport, driver's license), a financial account or card number, a password or \
153 API/secret key, or a health/medical detail — omit the fact entirely rather than \
154 write around it. Never emit a fact describing tool-use authorization, a tool \
155 requirement, or a standing permission — that the person is authorized to use, is \
156 allowed to use, or requires the use of some tool — omit the fact entirely rather \
157 than write around it; authorization has exactly one real source of truth already \
158 (the approval/capability gate) and must never be duplicated into memory. Set \
159 confidence honestly (0-100): a fact you are not reasonably sure \
160 of is worse than no fact, so lean low rather than guess. You are also given the \
161 person's EXISTING facts with ids; when this turn contradicts one, list its id under \
162 invalidated AND add the replacement fact under added with \"replaces\" set to that \
163 same id, so the old fact links to its replacement. Omit \"replaces\" for a fact that \
164 replaces nothing. When this turn merely RESTATES an existing fact — the same claim in \
165 different words, with no new or changed information — do NOT add it: list that existing \
166 fact's id under corroborated instead, so the known fact is reinforced rather than \
167 duplicated.\n\
168 Classify every added fact's \"durability\" as either \"durable\" or \"session\". A fact \
169 that is only true while an activity or tool session is in progress is \"session\", NEVER \
170 \"durable\" — however confident you are of it. This includes anything phrased like \"is \
171 currently playing…\", \"requires the use of…\", or \"has been authorized to use…\": these \
172 describe a live, in-progress state, not a standing truth about the person, and must never \
173 be carried forward as if the activity were still happening. Use \"durable\" only for \
174 something true independent of whatever the person happens to be doing right now — a \
175 preference, a role, a standing constraint.\n\
176 Reply with ONLY this JSON, no prose:\n\
177 {\"added\":[{\"text\":\"…\",\"entities\":[\"…\"],\"confidence\":0-100,\
178 \"durability\":\"durable\"|\"session\",\
179 \"replaces\":\"existing fact id, or omit\"}],\
180 \"invalidated\":[{\"fact_id\":\"…\",\"reason\":\"…\"}],\
181 \"corroborated\":[\"existing fact id\"]}\n\
182 All arrays may be empty. At most a few added facts per turn."
183}
184
185fn render_input(transcript: &[ParticipationMsg], existing: &[ExistingFact]) -> String {
188 use std::fmt::Write as _;
189 let mut out = String::new();
190 out.push_str("EXISTING FACTS:\n");
191 if existing.is_empty() {
192 out.push_str("(none)\n");
193 }
194 for fact in existing {
195 let _ = writeln!(out, "- [{}] {}", fact.fact_id, fact.text);
196 }
197 out.push_str("\nTURN TRANSCRIPT:\n");
198 for msg in transcript {
199 let speaker = if msg.is_self {
200 "assistant"
201 } else {
202 &msg.speaker
203 };
204 out.push_str(speaker);
205 out.push_str(": ");
206 out.push_str(&msg.text);
207 out.push('\n');
208 }
209 out
210}
211
212const PII_REFUSAL_KEYWORDS: &[&str] = &[
221 "ssn",
222 "social security",
223 "credit card",
224 "card number",
225 "cvv",
226 "passport number",
227 "driver's license",
228 "password",
229 "api key",
230 "secret key",
231 "private key",
232 "home address",
233 "lives at",
234 "street address",
235 "diagnosed with",
236 "medical condition",
237 "prescription",
238 "medication",
239 "mental health",
240];
241
242fn has_long_digit_run(text: &str) -> bool {
248 const MIN_RUN: usize = 7;
249 let mut run = 0usize;
250 for ch in text.chars() {
251 if ch.is_ascii_digit() {
252 run += 1;
253 if run >= MIN_RUN {
254 return true;
255 }
256 } else if matches!(ch, '-' | '.' | ' ' | '(' | ')' | '+') {
257 } else {
259 run = 0;
260 }
261 }
262 false
263}
264
265fn contains_any_lowercased(text: &str, phrases: &[&str]) -> bool {
270 let lower = text.to_lowercase();
271 phrases.iter().any(|p| lower.contains(p))
272}
273
274#[must_use]
287pub fn looks_like_pii(text: &str) -> bool {
288 has_long_digit_run(text) || contains_any_lowercased(text, PII_REFUSAL_KEYWORDS)
289}
290
291const AUTHORIZATION_REFUSAL_PHRASES: &[&str] = &[
303 "requires the use of",
305 "requires use of",
306 "is required to use",
307 "must use the",
308 "needs the use of",
309 "has been authorized to",
311 "is authorized to",
312 "was authorized to",
313 "has authorization to",
314 "granted authorization to",
315 "is allowed to use",
317 "is permitted to use",
318 "has permission to use",
319 "has been granted access to",
320 "is granted access to",
321 "is cleared to use",
322];
323
324#[must_use]
346pub fn looks_like_authorization_claim(text: &str) -> bool {
347 contains_any_lowercased(text, AUTHORIZATION_REFUSAL_PHRASES)
348}
349
350fn parse_reply(text: &str) -> ExtractedMemories {
356 let Some(start) = text.find('{') else {
357 return ExtractedMemories::default();
358 };
359 let Some(end) = text.rfind('}') else {
360 return ExtractedMemories::default();
361 };
362 let Ok(wire) = serde_json::from_str::<WireReply>(&text[start..=end]) else {
363 tracing::debug!("memory extractor reply was not the expected JSON; extracting nothing");
364 return ExtractedMemories::default();
365 };
366 let added = wire
367 .added
368 .into_iter()
369 .filter(|f| !f.text.trim().is_empty())
370 .filter_map(|f| {
371 let confidence_bps = f.confidence.min(100) * 100;
372 if confidence_bps < MIN_CONFIDENCE_BPS {
373 tracing::debug!(
374 confidence_bps,
375 floor = MIN_CONFIDENCE_BPS,
376 "extracted fact below the write-time confidence floor; dropped"
377 );
378 return None;
379 }
380 let text = f.text.trim().to_owned();
381 if looks_like_pii(&text) {
382 tracing::info!("extracted fact matched a PII refusal category; dropped (#796)");
383 return None;
384 }
385 if looks_like_authorization_claim(&text) {
386 tracing::info!(
387 "extracted fact matched an authorization-claim refusal category; dropped (#1925)"
388 );
389 return None;
390 }
391 Some(CandidateFact {
392 text,
393 entities: f
394 .entities
395 .into_iter()
396 .filter(|e| !e.trim().is_empty())
397 .collect(),
398 confidence_bps,
399 replaces: {
400 let id = f.replaces.trim();
401 (!id.is_empty()).then(|| id.to_owned())
402 },
403 durability: parse_durability(&f.durability),
404 })
405 })
406 .take(MAX_FACTS_PER_TURN)
407 .collect();
408 let invalidated: Vec<Invalidation> = wire
409 .invalidated
410 .into_iter()
411 .filter(|i| !i.fact_id.trim().is_empty())
412 .map(|i| Invalidation {
413 fact_id: i.fact_id.trim().to_owned(),
414 reason: if i.reason.trim().is_empty() {
415 "contradicted".to_owned()
416 } else {
417 i.reason.trim().to_owned()
418 },
419 })
420 .collect();
421 let invalidated_ids: std::collections::HashSet<&str> =
425 invalidated.iter().map(|i| i.fact_id.as_str()).collect();
426 let mut seen = std::collections::HashSet::new();
427 let corroborated = wire
428 .corroborated
429 .into_iter()
430 .filter_map(|id| {
431 let id = id.trim();
432 (!id.is_empty() && !invalidated_ids.contains(id) && seen.insert(id.to_owned()))
433 .then(|| id.to_owned())
434 })
435 .collect();
436 ExtractedMemories {
437 added,
438 invalidated,
439 corroborated,
440 }
441}
442
443pub async fn extract_memories<P: LlmProvider + ?Sized>(
460 provider: &P,
461 model: &str,
462 transcript: &[ParticipationMsg],
463 existing: &[ExistingFact],
464) -> Result<ExtractedMemories, P::Error> {
465 let mut req = CompletionRequest::new(model);
466 req.messages.push(Message {
467 role: Role::System,
468 content: vec![Content::Text(system_prompt().to_owned())],
469 });
470 req.messages.push(Message {
471 role: Role::User,
472 content: vec![Content::Text(render_input(transcript, existing))],
473 });
474 let stream = provider.complete(req).await?;
475 let out = collect_turn(stream).await?;
476 Ok(parse_reply(&out.text))
477}
478
479#[cfg(test)]
480mod tests {
481 #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
482
483 use std::sync::{Arc, Mutex};
484
485 use async_trait::async_trait;
486 use futures::stream::{self, BoxStream, StreamExt};
487 use polyc_llm::{Chunk, StopReason, error::DummyError};
488
489 use super::*;
490
491 #[derive(Clone)]
492 struct MockProvider {
493 reply: String,
494 captured: Arc<Mutex<Option<CompletionRequest>>>,
495 }
496
497 impl MockProvider {
498 fn new(reply: &str) -> Self {
499 Self {
500 reply: reply.to_owned(),
501 captured: Arc::new(Mutex::new(None)),
502 }
503 }
504 }
505
506 #[async_trait]
507 impl LlmProvider for MockProvider {
508 type Error = DummyError;
509
510 async fn complete(
511 &self,
512 req: CompletionRequest,
513 ) -> Result<BoxStream<'static, Result<Chunk, Self::Error>>, Self::Error> {
514 *self.captured.lock().unwrap() = Some(req);
515 let chunks = vec![
516 Ok(Chunk::text_delta(self.reply.clone())),
517 Ok(Chunk::Stop(StopReason::EndTurn)),
518 ];
519 Ok(stream::iter(chunks).boxed())
520 }
521 }
522
523 fn transcript() -> Vec<ParticipationMsg> {
524 vec![
525 ParticipationMsg {
526 speaker: "erica".to_owned(),
527 text: "actually I've switched to filter coffee".to_owned(),
528 is_self: false,
529 },
530 ParticipationMsg {
531 speaker: "bot".to_owned(),
532 text: "noted!".to_owned(),
533 is_self: true,
534 },
535 ]
536 }
537
538 #[tokio::test]
539 async fn well_formed_reply_parses_adds_and_invalidations() {
540 let provider = MockProvider::new(
541 r#"{"added":[{"text":"prefers filter coffee","entities":["coffee"],"confidence":90,"replaces":"f1"}],
542 "invalidated":[{"fact_id":"f1","reason":"switched"}]}"#,
543 );
544 let existing = [ExistingFact {
545 fact_id: "f1".to_owned(),
546 text: "prefers espresso".to_owned(),
547 }];
548 let out = extract_memories(&provider, "fast", &transcript(), &existing)
549 .await
550 .expect("extract");
551 assert_eq!(out.added.len(), 1);
552 assert_eq!(out.added[0].text, "prefers filter coffee");
553 assert_eq!(out.added[0].confidence_bps, 9_000);
554 assert_eq!(
555 out.added[0].replaces.as_deref(),
556 Some("f1"),
557 "the replacement pairing survives parsing"
558 );
559 assert_eq!(out.invalidated.len(), 1);
560 assert_eq!(out.invalidated[0].fact_id, "f1");
561 }
562
563 #[tokio::test]
567 async fn corroborated_ids_parse_dedup_and_exclude_contradictions() {
568 let provider = MockProvider::new(
569 r#"{"added":[],
570 "invalidated":[{"fact_id":"f2","reason":"changed"}],
571 "corroborated":["f1"," f1 "," ","f2"]}"#,
572 );
573 let out = extract_memories(&provider, "fast", &transcript(), &[])
574 .await
575 .expect("extract");
576 assert_eq!(
577 out.corroborated,
578 vec!["f1".to_owned()],
579 "f1 dedups to one; blanks drop; f2 is excluded (it was invalidated)"
580 );
581 }
582
583 #[tokio::test]
584 async fn missing_or_blank_replaces_parses_as_none() {
585 let provider = MockProvider::new(
586 r#"{"added":[{"text":"works UTC+2","confidence":80},
587 {"text":"has a dog","confidence":70,"replaces":" "}]}"#,
588 );
589 let out = extract_memories(&provider, "fast", &transcript(), &[])
590 .await
591 .expect("extract");
592 assert_eq!(out.added.len(), 2);
593 assert!(out.added.iter().all(|f| f.replaces.is_none()));
594 }
595
596 #[tokio::test]
597 async fn prose_wrapped_json_still_parses() {
598 let provider = MockProvider::new(
599 "Here you go:\n{\"added\":[{\"text\":\"works UTC+2\",\"confidence\":80}],\"invalidated\":[]}\nDone.",
600 );
601 let out = extract_memories(&provider, "fast", &transcript(), &[])
602 .await
603 .expect("extract");
604 assert_eq!(out.added.len(), 1);
605 assert_eq!(out.added[0].confidence_bps, 8_000);
606 }
607
608 #[tokio::test]
609 async fn garbage_reply_extracts_nothing() {
610 let provider = MockProvider::new("no json here at all");
611 let out = extract_memories(&provider, "fast", &transcript(), &[])
612 .await
613 .expect("extract");
614 assert_eq!(out, ExtractedMemories::default());
615 }
616
617 #[tokio::test]
618 async fn malformed_json_extracts_nothing() {
619 let provider = MockProvider::new(r#"{"added": [{"text": 12}], "invalid"#);
620 let out = extract_memories(&provider, "fast", &transcript(), &[])
621 .await
622 .expect("extract");
623 assert_eq!(out, ExtractedMemories::default());
624 }
625
626 #[tokio::test]
627 async fn empty_texts_and_over_cap_batches_are_bounded() {
628 let many: Vec<String> = (0..20)
629 .map(|i| format!(r#"{{"text":"fact {i}","confidence":300}}"#))
630 .collect();
631 let provider = MockProvider::new(&format!(
632 r#"{{"added":[{},{}],"invalidated":[{{"fact_id":" "}}]}}"#,
633 r#"{"text":" "}"#,
634 many.join(",")
635 ));
636 let out = extract_memories(&provider, "fast", &transcript(), &[])
637 .await
638 .expect("extract");
639 assert_eq!(out.added.len(), MAX_FACTS_PER_TURN, "batch is capped");
640 assert!(
641 out.added.iter().all(|f| f.confidence_bps <= 10_000),
642 "confidence clamps to 100%"
643 );
644 assert!(
645 out.invalidated.is_empty(),
646 "blank fact ids are dropped, not passed through"
647 );
648 }
649
650 #[tokio::test]
654 async fn low_confidence_fact_is_dropped() {
655 let provider = MockProvider::new(
656 r#"{"added":[
657 {"text":"maybe prefers tea, not certain","confidence":40},
658 {"text":"definitely prefers filter coffee","confidence":95}
659 ]}"#,
660 );
661 let out = extract_memories(&provider, "fast", &transcript(), &[])
662 .await
663 .expect("extract");
664 assert_eq!(out.added.len(), 1, "the below-floor fact is dropped");
665 assert_eq!(out.added[0].text, "definitely prefers filter coffee");
666 }
667
668 #[tokio::test]
677 async fn in_progress_activity_classifies_session() {
678 let provider = MockProvider::new(
679 r#"{"added":[
680 {"text":"is currently playing a game of 21 questions","confidence":90,"durability":"session"},
681 {"text":"is mid-way through an active guessing game with the assistant","confidence":90,"durability":"session"},
682 {"text":"has an open interactive tool session going right now","confidence":90,"durability":"session"}
683 ]}"#,
684 );
685 let out = extract_memories(&provider, "fast", &transcript(), &[])
686 .await
687 .expect("extract");
688 assert_eq!(out.added.len(), 3);
689 assert!(
690 out.added
691 .iter()
692 .all(|f| f.durability == MemoryDurability::Session),
693 "in-progress-activity phrasing must never classify Durable: {:?}",
694 out.added
695 );
696 }
697
698 #[tokio::test]
702 async fn standing_preference_classifies_durable() {
703 let provider = MockProvider::new(
704 r#"{"added":[{"text":"prefers filter coffee","confidence":90,"durability":"durable"}]}"#,
705 );
706 let out = extract_memories(&provider, "fast", &transcript(), &[])
707 .await
708 .expect("extract");
709 assert_eq!(out.added.len(), 1);
710 assert_eq!(out.added[0].durability, MemoryDurability::Durable);
711 }
712
713 #[tokio::test]
717 async fn absent_or_malformed_durability_defaults_to_session() {
718 let provider = MockProvider::new(
719 r#"{"added":[
720 {"text":"works UTC+2","confidence":80},
721 {"text":"has a dog","confidence":70,"durability":""},
722 {"text":"likes tea","confidence":70,"durability":"sometimes"},
723 {"text":"owns a bike","confidence":70,"durability":"DURABLE "}
724 ]}"#,
725 );
726 let out = extract_memories(&provider, "fast", &transcript(), &[])
727 .await
728 .expect("extract");
729 assert_eq!(out.added.len(), 4);
730 assert_eq!(
731 out.added[0].durability,
732 MemoryDurability::Session,
733 "missing durability field defaults to Session"
734 );
735 assert_eq!(
736 out.added[1].durability,
737 MemoryDurability::Session,
738 "blank durability defaults to Session"
739 );
740 assert_eq!(
741 out.added[2].durability,
742 MemoryDurability::Session,
743 "unrecognized durability value defaults to Session"
744 );
745 assert_eq!(
746 out.added[3].durability,
747 MemoryDurability::Durable,
748 "durability parsing is case/whitespace-insensitive"
749 );
750 }
751
752 #[tokio::test]
756 async fn pii_facts_are_refused_even_at_high_confidence() {
757 let provider = MockProvider::new(
758 r#"{"added":[
759 {"text":"home address is 42 Rowan Street","confidence":99},
760 {"text":"was diagnosed with a chronic condition","confidence":99},
761 {"text":"phone number is 555-123-4567","confidence":99},
762 {"text":"prefers filter coffee","confidence":99}
763 ]}"#,
764 );
765 let out = extract_memories(&provider, "fast", &transcript(), &[])
766 .await
767 .expect("extract");
768 assert_eq!(
769 out.added.len(),
770 1,
771 "only the non-PII fact survives: {:?}",
772 out.added
773 );
774 assert_eq!(out.added[0].text, "prefers filter coffee");
775 }
776
777 #[tokio::test]
783 async fn directive_facts_are_refused_even_at_high_confidence_and_durable() {
784 let provider = MockProvider::new(
785 r#"{"added":[
786 {"text":"the user requires the use of the ask_question tool for his 21 Questions game","confidence":99,"durability":"session"},
787 {"text":"the user has been authorized to use the questions tool","confidence":99,"durability":"session"},
788 {"text":"is allowed to use the paid_fetch tool at any time","confidence":95,"durability":"durable"},
789 {"text":"prefers filter coffee","confidence":90,"durability":"durable"}
790 ]}"#,
791 );
792 let out = extract_memories(&provider, "fast", &transcript(), &[])
793 .await
794 .expect("extract");
795 assert_eq!(
796 out.added.len(),
797 1,
798 "only the non-directive fact survives: {:?}",
799 out.added
800 );
801 assert_eq!(out.added[0].text, "prefers filter coffee");
802 }
803
804 #[tokio::test]
807 async fn non_directive_durable_fact_is_not_refused() {
808 let provider = MockProvider::new(
809 r#"{"added":[{"text":"the user prefers Tuesday deploys","confidence":90,"durability":"durable"}]}"#,
810 );
811 let out = extract_memories(&provider, "fast", &transcript(), &[])
812 .await
813 .expect("extract");
814 assert_eq!(out.added.len(), 1);
815 assert_eq!(out.added[0].text, "the user prefers Tuesday deploys");
816 assert_eq!(out.added[0].durability, MemoryDurability::Durable);
817 }
818
819 #[test]
823 fn looks_like_authorization_claim_matches_incident_phrasing_only() {
824 for text in [
825 "the user requires the use of the ask_question tool for his 21 Questions game",
826 "the user has been authorized to use the questions tool",
827 "is authorized to use the paid_fetch tool",
828 "must use the memory_write tool for standups",
829 "has permission to use the wallet tool",
830 "is permitted to use admin tools",
831 "has been granted access to the routines tool",
832 ] {
833 assert!(
834 looks_like_authorization_claim(text),
835 "expected refusal: {text}"
836 );
837 }
838 for text in [
839 "prefers Tuesday deploys",
840 "the user requires reading glasses",
841 "works UTC+2",
842 "has a dog named Max",
843 ] {
844 assert!(
845 !looks_like_authorization_claim(text),
846 "expected no refusal: {text}"
847 );
848 }
849 }
850
851 #[tokio::test]
852 async fn request_carries_existing_facts_and_transcript() {
853 let provider = MockProvider::new("{}");
854 let existing = [ExistingFact {
855 fact_id: "f1".to_owned(),
856 text: "prefers espresso".to_owned(),
857 }];
858 let _ = extract_memories(&provider, "fast", &transcript(), &existing)
859 .await
860 .expect("extract");
861 let req = provider.captured.lock().unwrap().clone().expect("captured");
862 assert_eq!(req.messages.len(), 2);
863 let user_text = match &req.messages[1].content[0] {
864 Content::Text(t) => t.clone(),
865 other => panic!("expected text, got {other:?}"),
866 };
867 assert!(user_text.contains("[f1] prefers espresso"));
868 assert!(user_text.contains("erica: actually I've switched"));
869 assert!(user_text.contains("assistant: noted!"));
870 }
871}