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
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct CandidateFact {
28 pub text: String,
30 pub entities: Vec<String>,
32 pub confidence_bps: u32,
34 pub replaces: Option<String>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Invalidation {
44 pub fact_id: String,
46 pub reason: String,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct ExtractedMemories {
53 pub added: Vec<CandidateFact>,
55 pub invalidated: Vec<Invalidation>,
57}
58
59#[derive(Debug, Clone)]
62pub struct ExistingFact {
63 pub fact_id: String,
65 pub text: String,
67}
68
69#[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 #[serde(default)]
86 confidence: u32,
87 #[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
101const 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
117fn 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
144fn 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
194pub 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}