1use mentedb_cognitive::stream::StreamAlert;
23use mentedb_cognitive::trajectory::{DecisionState, TrajectoryNode};
24use mentedb_consolidation::FactExtractor;
25use mentedb_context::{DeltaTracker, ScoredMemory};
26use mentedb_core::edge::EdgeType;
27use mentedb_core::memory::MemoryType;
28use mentedb_core::types::{AgentId, MemoryId, Timestamp};
29use mentedb_core::{MemoryEdge, MemoryNode};
30use tracing::{debug, warn};
31use uuid::Uuid;
32
33use crate::MenteDb;
34
35#[derive(Debug, Clone)]
37pub struct ProcessTurnInput {
38 pub user_message: String,
40 pub assistant_response: Option<String>,
42 pub turn_id: u64,
44 pub project_context: Option<String>,
46 pub agent_id: Option<Uuid>,
48 pub session_id: Option<String>,
51}
52
53#[derive(Debug, Clone)]
55pub struct DetectedAction {
56 pub action_type: String,
57 pub detail: String,
58}
59
60#[derive(Debug, Clone)]
62pub struct PainWarning {
63 pub signal_id: MemoryId,
64 pub intensity: f32,
65 pub description: String,
66}
67
68#[derive(Debug, Clone)]
70pub struct ProactiveRecall {
71 pub memory_id: MemoryId,
72 pub content: String,
73 pub relevance: f32,
74 pub action_type: String,
75}
76
77#[derive(Debug, Clone)]
79pub struct ProcessTurnResult {
80 pub context: Vec<ScoredMemory>,
82 pub stored_ids: Vec<MemoryId>,
84 pub episodic_id: Option<MemoryId>,
86 pub pain_warnings: Vec<PainWarning>,
88 pub cache_hit: bool,
90 pub inference_actions: u32,
92 pub detected_actions: Vec<DetectedAction>,
94 pub proactive_recalls: Vec<ProactiveRecall>,
96 pub correction_id: Option<MemoryId>,
98 pub sentiment: f32,
100 pub phantom_count: usize,
102 pub contradiction_count: usize,
104 pub predicted_topics: Vec<String>,
106 pub facts_extracted: usize,
108 pub edges_created: u32,
110 pub enrichment_pending: bool,
112 pub delta_added: Vec<MemoryId>,
114 pub delta_removed: Vec<MemoryId>,
116}
117
118const ACTION_KEYWORDS: &[(&str, &str)] = &[
121 ("deploy", "deployment"),
122 ("release", "release"),
123 ("migrate", "migration"),
124 ("refactor", "refactoring"),
125 ("debug", "debugging"),
126 ("install", "installation"),
127 ("configure", "configuration"),
128 ("test", "testing"),
129 ("build", "building"),
130 ("commit", "version_control"),
131 ("merge", "version_control"),
132 ("review", "code_review"),
133];
134
135const CORRECTION_INDICATORS: &[&str] = &[
138 "actually",
139 "correction",
140 "i was wrong",
141 "that's not right",
142 "let me correct",
143 "i meant",
144 "not quite",
145 "to clarify",
146 "i misspoke",
147 "update:",
148 "scratch that",
149];
150
151const SPECULATION_INDICATORS: &[&str] = &[
154 "might be",
155 "probably",
156 "seems like",
157 "i think",
158 "looks like",
159 "considering",
160 "planning to",
161 "thinking about",
162 "maybe",
163];
164
165const POSITIVE_WORDS: &[&str] = &[
168 "great",
169 "excellent",
170 "perfect",
171 "thanks",
172 "awesome",
173 "love",
174 "good",
175 "nice",
176 "wonderful",
177 "fantastic",
178 "helpful",
179 "amazing",
180 "brilliant",
181 "works",
182 "solved",
183];
184
185const NEGATIVE_WORDS: &[&str] = &[
186 "bad",
187 "terrible",
188 "awful",
189 "hate",
190 "wrong",
191 "broken",
192 "fail",
193 "error",
194 "bug",
195 "frustrating",
196 "annoying",
197 "useless",
198 "horrible",
199 "worst",
200 "disappointed",
201];
202
203impl MenteDb {
204 pub fn process_turn(
221 &self,
222 input: &ProcessTurnInput,
223 delta_tracker: &mut DeltaTracker,
224 ) -> crate::MenteResult<ProcessTurnResult> {
225 let agent_id = AgentId(input.agent_id.unwrap_or(Uuid::nil()));
226 let assistant_resp = input.assistant_response.as_deref().unwrap_or("");
227 let conversation = format!(
228 "User: {}\nAssistant: {}",
229 input.user_message, assistant_resp
230 );
231
232 let query_embedding = self.embed_or_empty(&input.user_message)?;
234
235 let (context, current_ids, cache_hit) = self.retrieve_context(
237 &input.user_message,
238 &query_embedding,
239 input.agent_id.map(AgentId),
240 delta_tracker,
241 )?;
242
243 let pain_warnings = self.check_pain_signals(&input.user_message);
245
246 let delta = delta_tracker.compute_delta(¤t_ids, &delta_tracker.last_served.clone());
248 delta_tracker.update(¤t_ids);
249
250 let (stored_ids, episodic_id) = self.store_episodic(
252 &conversation,
253 agent_id,
254 &input.project_context,
255 &input.session_id,
256 )?;
257
258 let inference_actions = if let Some(eid) = episodic_id {
260 self.run_explicit_write_inference(eid)?
261 } else {
262 0
263 };
264
265 let (facts_extracted, edges_created) = if let Some(eid) = episodic_id {
267 self.extract_and_link_facts(eid)?
268 } else {
269 (0, 0)
270 };
271
272 let combined_text = format!("{} {}", input.user_message, assistant_resp);
274 let detected_actions = detect_actions(&combined_text);
275
276 let proactive_recalls = self.proactive_recall(&detected_actions)?;
278
279 let correction_id = self.auto_detect_correction(&input.user_message, episodic_id)?;
281
282 let sentiment = analyze_sentiment(&input.user_message);
284
285 let (phantom_count, contradiction_count) = self.detect_phantoms_and_check_stream(
287 &conversation,
288 assistant_resp,
289 &context,
290 input.turn_id,
291 );
292
293 let predicted_topics = self.update_trajectory(
295 input,
296 agent_id,
297 &stored_ids,
298 &detected_actions,
299 &combined_text,
300 )?;
301
302 self.update_speculative_cache_from_predictions(&predicted_topics);
304
305 self.maybe_run_maintenance(input.turn_id);
307
308 Ok(ProcessTurnResult {
309 context,
310 stored_ids: stored_ids.clone(),
311 episodic_id,
312 pain_warnings,
313 cache_hit,
314 inference_actions,
315 detected_actions,
316 proactive_recalls,
317 correction_id,
318 sentiment,
319 phantom_count,
320 contradiction_count,
321 predicted_topics,
322 facts_extracted,
323 edges_created,
324 enrichment_pending: self.needs_enrichment(),
325 delta_added: delta.added,
326 delta_removed: delta.removed,
327 })
328 }
329
330 fn retrieve_context(
333 &self,
334 user_message: &str,
335 query_embedding: &[f32],
336 agent: Option<AgentId>,
337 _delta_tracker: &DeltaTracker,
338 ) -> crate::MenteResult<(Vec<ScoredMemory>, Vec<MemoryId>, bool)> {
339 if let Some(entry) = self.try_speculative_hit(user_message, Some(query_embedding)) {
341 let matched: Vec<ScoredMemory> = entry
342 .memory_ids
343 .iter()
344 .filter_map(|id| self.get_memory(*id).ok())
345 .filter(|m| crate::agent_visible(m.agent_id, agent))
346 .map(|m| ScoredMemory {
347 memory: m,
348 score: 0.9,
349 })
350 .collect();
351 if matched.len() >= entry.memory_ids.len() / 2 {
352 let ids: Vec<MemoryId> = matched.iter().map(|sm| sm.memory.id).collect();
353 return Ok((matched, ids, true));
354 }
355 }
356
357 let now = now_us();
359 let results = self.recall_hybrid_scoped_at_mode(
360 query_embedding,
361 Some(user_message),
362 10,
363 now,
364 None,
365 false,
366 None,
367 agent,
368 )?;
369 let mut scored: Vec<ScoredMemory> = results
370 .iter()
371 .filter_map(|(mid, score)| {
372 self.get_memory(*mid).ok().map(|m| ScoredMemory {
373 memory: m,
374 score: *score,
375 })
376 })
377 .collect();
378
379 let hybrid_ids: std::collections::HashSet<MemoryId> =
381 scored.iter().map(|sm| sm.memory.id).collect();
382 let always_scope_tag = "scope:always";
383 let pm = self.page_map.read();
384 for pid in pm.values() {
385 if let Ok(mem) = self.storage.load_memory(*pid)
386 && mem.tags.iter().any(|t| t == always_scope_tag)
387 && !hybrid_ids.contains(&mem.id)
388 && crate::agent_visible(mem.agent_id, agent)
389 {
390 scored.push(ScoredMemory {
391 memory: mem,
392 score: 0.85,
393 });
394 }
395 }
396 drop(pm);
397
398 let ids: Vec<MemoryId> = scored.iter().map(|sm| sm.memory.id).collect();
399 Ok((scored, ids, false))
400 }
401
402 fn check_pain_signals(&self, user_message: &str) -> Vec<PainWarning> {
403 let context_words: Vec<String> = user_message
404 .split_whitespace()
405 .map(|w| w.to_lowercase())
406 .collect();
407 let all_signals = self.all_pain_signals();
408 let mut scored: Vec<_> = all_signals
409 .iter()
410 .filter_map(|s| {
411 let matched = s
412 .trigger_keywords
413 .iter()
414 .filter(|trigger| {
415 context_words
416 .iter()
417 .any(|ctx| ctx.contains(&trigger.to_lowercase()))
418 })
419 .count();
420 if matched > 0 {
421 let relevance = matched as f32 / s.trigger_keywords.len().max(1) as f32;
422 let score = s.intensity * relevance;
423 Some((s, score))
424 } else {
425 None
426 }
427 })
428 .collect();
429 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
430 scored
431 .iter()
432 .map(|(s, _)| PainWarning {
433 signal_id: s.id,
434 intensity: s.intensity,
435 description: s.description.clone(),
436 })
437 .collect()
438 }
439
440 fn embed_or_empty(&self, text: &str) -> crate::MenteResult<Vec<f32>> {
447 match self.embed_text(text)? {
448 Some(embedding) => Ok(embedding),
449 None => {
450 static NO_EMBEDDER_WARN: std::sync::Once = std::sync::Once::new();
451 NO_EMBEDDER_WARN.call_once(|| {
452 warn!(
453 "no embedding provider configured: semantic search, auto-linking, and \
454 contradiction detection are DISABLED; retrieval falls back to keyword \
455 (BM25) matching only. Configure a provider via open_with_embedder()."
456 );
457 });
458 Ok(Vec::new())
459 }
460 }
461 }
462
463 fn store_episodic(
464 &self,
465 conversation: &str,
466 agent_id: AgentId,
467 project_context: &Option<String>,
468 session_id: &Option<String>,
469 ) -> crate::MenteResult<(Vec<MemoryId>, Option<MemoryId>)> {
470 let embedding = self.embed_or_empty(conversation)?;
471 let mut node = MemoryNode::new(
472 agent_id,
473 MemoryType::Episodic,
474 conversation.to_string(),
475 embedding,
476 );
477 node.tags.push("turn".to_string());
478 if let Some(ctx) = project_context {
479 node.tags.push(format!("scope:project:{}", ctx));
480 }
481 if let Some(session) = session_id {
482 node.tags.push(format!("session:{}", session));
483 }
484 let id = node.id;
485 self.store(node)?;
486 Ok((vec![id], Some(id)))
487 }
488
489 fn run_explicit_write_inference(&self, id: MemoryId) -> crate::MenteResult<u32> {
490 let target = self.get_memory(id)?;
491 let similar_ids = self
492 .recall_similar(&target.embedding, 50)
493 .unwrap_or_default();
494 let existing: Vec<MemoryNode> = similar_ids
495 .iter()
496 .filter_map(|(mid, _)| {
497 if *mid != id {
498 self.get_memory(*mid).ok()
499 } else {
500 None
501 }
502 })
503 .collect();
504
505 let engine = crate::WriteInferenceEngine::new();
506 let actions = engine.infer_on_write(&target, &existing, &[]);
507 let now = now_us();
508 let mut applied = 0u32;
509
510 for action in &actions {
511 match action {
512 crate::InferredAction::FlagContradiction {
513 existing: ex, new, ..
514 } => {
515 let edge = MemoryEdge {
516 source: *new,
517 target: *ex,
518 edge_type: EdgeType::Contradicts,
519 weight: 1.0,
520 created_at: now,
521 valid_from: None,
522 valid_until: None,
523 label: None,
524 };
525 if let Err(e) = self.relate(edge) {
526 tracing::warn!("failed to create inferred edge: {e}");
527 }
528 applied += 1;
529 }
530 crate::InferredAction::MarkObsolete {
531 memory,
532 superseded_by,
533 } => {
534 let edge = MemoryEdge {
535 source: *superseded_by,
536 target: *memory,
537 edge_type: EdgeType::Supersedes,
538 weight: 1.0,
539 created_at: now,
540 valid_from: None,
541 valid_until: None,
542 label: None,
543 };
544 if let Err(e) = self.relate(edge) {
545 tracing::warn!("failed to create inferred edge: {e}");
546 }
547 applied += 1;
548 }
549 crate::InferredAction::CreateEdge {
550 source,
551 target,
552 edge_type,
553 weight,
554 } => {
555 let edge = MemoryEdge {
556 source: *source,
557 target: *target,
558 edge_type: *edge_type,
559 weight: *weight,
560 created_at: now,
561 valid_from: None,
562 valid_until: None,
563 label: None,
564 };
565 if let Err(e) = self.relate(edge) {
566 tracing::warn!("failed to create inferred edge: {e}");
567 }
568 applied += 1;
569 }
570 crate::InferredAction::UpdateConfidence {
571 memory,
572 new_confidence,
573 } => {
574 if let Ok(mut mem) = self.get_memory(*memory) {
575 mem.confidence = *new_confidence;
576 let _ = self.store(mem);
577 applied += 1;
578 }
579 }
580 crate::InferredAction::InvalidateMemory {
581 memory,
582 valid_until,
583 ..
584 } => {
585 if let Ok(mut mem) = self.get_memory(*memory) {
586 mem.valid_until = Some(*valid_until);
587 let _ = self.store(mem);
588 applied += 1;
589 }
590 }
591 crate::InferredAction::UpdateContent {
592 memory,
593 new_content,
594 ..
595 } => {
596 if let Ok(mut mem) = self.get_memory(*memory) {
597 mem.content = new_content.clone();
598 let _ = self.store(mem);
599 applied += 1;
600 }
601 }
602 crate::InferredAction::PropagateBeliefChange { .. } => {
603 applied += 1;
604 }
605 }
606 }
607 Ok(applied)
608 }
609
610 fn extract_and_link_facts(&self, id: MemoryId) -> crate::MenteResult<(usize, u32)> {
611 let target = self.get_memory(id)?;
612 let extractor = FactExtractor::new();
613 let facts = extractor.extract_facts(&target);
614 if facts.is_empty() {
615 return Ok((0, 0));
616 }
617
618 let similar_ids = self
619 .recall_similar(&target.embedding, 50)
620 .unwrap_or_default();
621 let nearby: Vec<MemoryNode> = similar_ids
622 .iter()
623 .filter_map(|(mid, _)| {
624 if *mid != id {
625 self.get_memory(*mid).ok()
626 } else {
627 None
628 }
629 })
630 .collect();
631
632 let now = now_us();
633 let mut edges_created = 0u32;
634 for fact in &facts {
635 for other in &nearby {
636 if other.content.contains(&fact.subject) || other.content.contains(&fact.object) {
637 let edge = MemoryEdge {
638 source: id,
639 target: other.id,
640 edge_type: EdgeType::Related,
641 weight: 0.5,
642 created_at: now,
643 valid_from: None,
644 valid_until: None,
645 label: None,
646 };
647 if let Err(e) = self.relate(edge) {
648 tracing::warn!("failed to create inferred edge: {e}");
649 }
650 edges_created += 1;
651 }
652 }
653 }
654 Ok((facts.len(), edges_created))
655 }
656
657 fn proactive_recall(
658 &self,
659 actions: &[DetectedAction],
660 ) -> crate::MenteResult<Vec<ProactiveRecall>> {
661 let mut recalls = Vec::new();
662 for action in actions {
663 let search_query = format!("{} {}", action.action_type, action.detail);
664 let Some(emb) = self.embed_text(&search_query)? else {
665 continue;
666 };
667 let results = self.recall_similar(&emb, 3).unwrap_or_default();
668 for (mid, score) in &results {
669 if let Ok(mem) = self.get_memory(*mid) {
670 recalls.push(ProactiveRecall {
671 memory_id: *mid,
672 content: mem.content.clone(),
673 relevance: *score,
674 action_type: action.action_type.clone(),
675 });
676 }
677 }
678 }
679 Ok(recalls)
680 }
681
682 fn auto_detect_correction(
692 &self,
693 user_message: &str,
694 episodic_id: Option<MemoryId>,
695 ) -> crate::MenteResult<Option<MemoryId>> {
696 let lowered = user_message.to_lowercase();
697 let is_correction = CORRECTION_INDICATORS
698 .iter()
699 .any(|ind| lowered.contains(ind));
700 if !is_correction {
701 return Ok(None);
702 }
703 let Some(eid) = episodic_id else {
704 return Ok(None);
705 };
706 let pid = {
707 let pm = self.page_map.read();
708 match pm.get(&eid) {
709 Some(p) => *p,
710 None => return Ok(None),
711 }
712 };
713 let Ok(mut node) = self.storage.load_memory(pid) else {
714 return Ok(None);
715 };
716 if !node.tags.iter().any(|t| t == "auto-correction") {
717 node.tags.push("auto-correction".to_string());
718 self.storage.update_memory(pid, &node)?;
719 }
720 Ok(Some(eid))
721 }
722
723 fn detect_phantoms_and_check_stream(
724 &self,
725 conversation: &str,
726 assistant_resp: &str,
727 context: &[ScoredMemory],
728 turn_id: u64,
729 ) -> (usize, usize) {
730 let known: Vec<String> = context
732 .iter()
733 .flat_map(|sm| {
734 sm.memory
735 .content
736 .split_whitespace()
737 .map(|w| w.to_lowercase())
738 })
739 .collect();
740 let phantoms = self.detect_phantoms(conversation, &known, turn_id);
741 let phantom_count = phantoms.len();
742
743 let known_facts: Vec<(MemoryId, String)> = context
745 .iter()
746 .map(|sm| (sm.memory.id, sm.memory.content.clone()))
747 .collect();
748 let contradiction_count = if !known_facts.is_empty() {
749 self.feed_stream_token(assistant_resp);
750 let alerts = self.check_stream_alerts(&known_facts);
751 alerts
752 .iter()
753 .filter(|a| matches!(a, StreamAlert::Contradiction { .. }))
754 .count()
755 } else {
756 0
757 };
758
759 (phantom_count, contradiction_count)
760 }
761
762 fn update_trajectory(
763 &self,
764 input: &ProcessTurnInput,
765 agent_id: AgentId,
766 stored_ids: &[MemoryId],
767 detected_actions: &[DetectedAction],
768 combined_text: &str,
769 ) -> crate::MenteResult<Vec<String>> {
770 let decision_state = if stored_ids.is_empty() {
771 DecisionState::Investigating
772 } else {
773 DecisionState::Completed
774 };
775
776 let raw_topic = if input.user_message.chars().count() > 100 {
777 format!(
778 "{}...",
779 input.user_message.chars().take(100).collect::<String>()
780 )
781 } else {
782 input.user_message.clone()
783 };
784
785 let topic_embedding = self.embed_or_empty(&raw_topic)?;
786
787 let node = TrajectoryNode {
788 turn_id: input.turn_id,
789 topic_summary: raw_topic,
790 topic_embedding,
791 decision_state,
792 open_questions: Vec::new(),
793 timestamp: now_us(),
794 };
795 self.record_trajectory_turn(node);
796 let predictions = self.predict_next_topics();
797
798 let has_speculation = SPECULATION_INDICATORS
800 .iter()
801 .any(|s| combined_text.contains(s));
802 if has_speculation && !detected_actions.is_empty() {
803 let ghost_content = format!(
804 "Unconfirmed: {}",
805 input.user_message.chars().take(300).collect::<String>()
806 );
807 if let Ok(Some(ghost_emb)) = self.embed_text(&ghost_content) {
808 let mut ghost_node =
809 MemoryNode::new(agent_id, MemoryType::Semantic, ghost_content, ghost_emb);
810 ghost_node.confidence = 0.3;
811 ghost_node.tags = vec!["ghost-memory".to_string(), "unconfirmed".to_string()];
812 if let Some(ctx) = &input.project_context {
813 ghost_node.tags.push(format!("scope:project:{}", ctx));
814 }
815 let _ = self.store(ghost_node);
816 }
817 }
818
819 Ok(predictions)
820 }
821
822 fn update_speculative_cache_from_predictions(&self, predictions: &[String]) {
823 if predictions.is_empty() {
824 return;
825 }
826 let predictions_owned = predictions.to_vec();
829 self.pre_assemble_speculative(predictions_owned, |topic| {
830 let topic_emb = self.embed_text(topic).ok()??;
831 let similar_ids = self.recall_similar(&topic_emb, 5).ok()?;
832 if similar_ids.is_empty() {
833 return None;
834 }
835 let mut context_parts = Vec::new();
836 let mut memory_ids = Vec::new();
837 for (mid, _) in &similar_ids {
838 if let Ok(mem) = self.get_memory(*mid) {
839 context_parts.push(mem.content.clone());
840 memory_ids.push(*mid);
841 }
842 }
843 if memory_ids.is_empty() {
844 return None;
845 }
846 Some((context_parts.join("\n---\n"), memory_ids, None))
847 });
848 }
849
850 fn maybe_run_maintenance(&self, turn_id: u64) {
851 if turn_id == 0 {
852 return;
853 }
854
855 if turn_id.is_multiple_of(50) {
857 match self.apply_decay_global() {
858 Ok(updated) => {
859 debug!(turn_id, updated, "auto-maintenance: decay applied");
860 }
861 Err(e) => {
862 warn!(turn_id, error = %e, "auto-maintenance: decay failed");
863 }
864 }
865 }
866
867 if turn_id.is_multiple_of(100) {
869 match self.evaluate_archival_global() {
870 Ok(decisions) => {
871 let mut archived = 0u64;
872 for (id, decision) in &decisions {
873 if matches!(
874 decision,
875 crate::ArchivalDecision::Delete | crate::ArchivalDecision::Archive
876 ) {
877 let _ = self.forget(*id);
878 archived += 1;
879 }
880 }
881 debug!(turn_id, archived, "auto-maintenance: archival evaluated");
882 }
883 Err(e) => {
884 warn!(turn_id, error = %e, "auto-maintenance: archival failed");
885 }
886 }
887 }
888
889 if turn_id.is_multiple_of(200) {
891 match self.find_consolidation_candidates(2, 0.85) {
892 Ok(candidates) => {
893 for candidate in &candidates {
894 let _ = self.consolidate_cluster(&candidate.memories);
895 }
896 debug!(
897 turn_id,
898 clusters = candidates.len(),
899 "auto-maintenance: consolidation"
900 );
901 }
902 Err(e) => {
903 warn!(turn_id, error = %e, "auto-maintenance: consolidation failed");
904 }
905 }
906 }
907
908 let enrichment = &self.cognitive_config.enrichment_config;
910 if enrichment.enabled && enrichment.trigger_interval > 0 {
911 let last = self.last_enrichment_turn();
912 let turns_since = turn_id.saturating_sub(last);
913 if turns_since >= enrichment.trigger_interval && !self.needs_enrichment() {
914 *self.enrichment_pending.write() = true;
915 debug!(
916 turn_id,
917 last_enrichment = last,
918 turns_since,
919 "enrichment trigger: marked pending"
920 );
921 }
922 }
923 }
924}
925
926fn detect_actions(combined_text: &str) -> Vec<DetectedAction> {
929 let lower = combined_text.to_lowercase();
930 ACTION_KEYWORDS
931 .iter()
932 .filter(|(kw, _)| lower.contains(kw))
933 .map(|(kw, action_type)| {
934 let detail = extract_sentence_containing(&lower, kw);
935 DetectedAction {
936 action_type: action_type.to_string(),
937 detail,
938 }
939 })
940 .collect()
941}
942
943fn analyze_sentiment(text: &str) -> f32 {
944 let lower = text.to_lowercase();
945 let words: Vec<&str> = lower.split_whitespace().collect();
946 let total = words.len().max(1) as f32;
947
948 let positive = words
949 .iter()
950 .filter(|w| POSITIVE_WORDS.iter().any(|p| w.contains(p)))
951 .count() as f32;
952 let negative = words
953 .iter()
954 .filter(|w| NEGATIVE_WORDS.iter().any(|n| w.contains(n)))
955 .count() as f32;
956
957 ((positive - negative) / total).clamp(-1.0, 1.0)
958}
959
960fn extract_sentence_containing(text: &str, keyword: &str) -> String {
961 text.split('.')
962 .find(|s| s.contains(keyword))
963 .unwrap_or(keyword)
964 .trim()
965 .chars()
966 .take(200)
967 .collect()
968}
969
970fn now_us() -> Timestamp {
971 std::time::SystemTime::now()
972 .duration_since(std::time::UNIX_EPOCH)
973 .unwrap_or_default()
974 .as_micros() as u64
975}