Skip to main content

locus_mcp/
shared.rs

1use std::collections::HashSet;
2
3use chrono::{DateTime, Utc};
4use locus_core_rs::EmbeddingMigrationMode;
5use serde_json::{Value, json};
6
7pub(crate) fn parse_utc_required(value: &str, field: &str) -> Result<DateTime<Utc>, String> {
8    DateTime::parse_from_rfc3339(value)
9        .map(|parsed| parsed.with_timezone(&Utc))
10        .map_err(|_| format!("{field} must be an ISO8601 UTC datetime"))
11}
12
13pub(crate) fn parse_utc_optional(
14    value: Option<&str>,
15    field: &str,
16) -> Result<Option<DateTime<Utc>>, String> {
17    match value {
18        Some(raw) => parse_utc_required(raw, field).map(Some),
19        None => Ok(None),
20    }
21}
22
23pub(crate) fn validate_limit(limit: usize, field: &str) -> Result<usize, String> {
24    if (1..=200).contains(&limit) {
25        Ok(limit)
26    } else {
27        Err(format!("{field} must be between 1 and 200"))
28    }
29}
30
31pub(crate) fn validate_batch_size(batch_size: usize) -> Result<usize, String> {
32    if (1..=500).contains(&batch_size) {
33        Ok(batch_size)
34    } else {
35        Err("batch_size must be between 1 and 500".to_string())
36    }
37}
38
39pub(crate) fn validate_max_nodes(max_nodes: usize) -> Result<usize, String> {
40    if (1..=50000).contains(&max_nodes) {
41        Ok(max_nodes)
42    } else {
43        Err("max_nodes must be between 1 and 50000".to_string())
44    }
45}
46
47pub(crate) fn parse_migration_mode(value: Option<&str>) -> Result<EmbeddingMigrationMode, String> {
48    match value
49        .unwrap_or("missing_only")
50        .trim()
51        .to_ascii_lowercase()
52        .as_str()
53    {
54        "missing_only" => Ok(EmbeddingMigrationMode::MissingOnly),
55        "reindex_all" => Ok(EmbeddingMigrationMode::ReindexAll),
56        _ => Err("mode must be one of: missing_only, reindex_all, tags, both".to_string()),
57    }
58}
59
60pub(crate) fn mode_to_string(mode: EmbeddingMigrationMode) -> &'static str {
61    match mode {
62        EmbeddingMigrationMode::MissingOnly => "missing_only",
63        EmbeddingMigrationMode::ReindexAll => "reindex_all",
64    }
65}
66
67pub(crate) fn expanded_limit(limit: usize) -> usize {
68    limit.saturating_mul(5).clamp(1, 200)
69}
70
71pub(crate) fn normalize_tiers(tiers: &[String]) -> Vec<String> {
72    tiers
73        .iter()
74        .map(|value| value.trim().to_ascii_lowercase())
75        .filter(|value| !value.is_empty())
76        .collect::<Vec<_>>()
77}
78
79pub(crate) fn normalize_context_keywords(keywords: Option<&[String]>) -> Vec<String> {
80    keywords
81        .unwrap_or(&[])
82        .iter()
83        .map(|value| value.trim().to_ascii_lowercase())
84        .filter(|value| !value.is_empty())
85        .collect::<Vec<_>>()
86}
87
88fn context_keyword_score(node: &locus_core_rs::SttpNode, keywords: &[String]) -> usize {
89    let node_tags = node
90        .semantic_tags
91        .as_deref()
92        .unwrap_or_default()
93        .iter()
94        .map(|tag| tag.to_ascii_lowercase())
95        .collect::<HashSet<_>>();
96
97    let tag_matches = keywords
98        .iter()
99        .filter(|keyword| {
100            let needle = keyword.as_str();
101            node_tags.contains(needle)
102        })
103        .count();
104
105    if tag_matches > 0 {
106        return tag_matches.saturating_mul(10);
107    }
108
109    let summary = node
110        .context_summary
111        .as_deref()
112        .map(|value| value.to_ascii_lowercase())
113        .unwrap_or_default();
114    let session_id = node.session_id.to_ascii_lowercase();
115
116    keywords
117        .iter()
118        .filter(|keyword| {
119            let needle = keyword.as_str();
120            summary.contains(needle) || session_id.contains(needle)
121        })
122        .count()
123}
124
125pub(crate) fn filter_nodes_by_context_keywords(
126    nodes: &[locus_core_rs::SttpNode],
127    keywords: &[String],
128    limit: usize,
129) -> Vec<locus_core_rs::SttpNode> {
130    let mut scored = nodes
131        .iter()
132        .filter_map(|node| {
133            let score = context_keyword_score(node, keywords);
134            if score == 0 {
135                None
136            } else {
137                Some((score, node.timestamp, node.clone()))
138            }
139        })
140        .collect::<Vec<_>>();
141
142    scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
143
144    scored
145        .into_iter()
146        .take(limit)
147        .map(|(_, _, node)| node)
148        .collect::<Vec<_>>()
149}
150
151pub(crate) fn to_json_string(value: Value) -> String {
152    match serde_json::to_string(&value) {
153        Ok(serialized) => serialized,
154        Err(err) => tool_error("SerializationFailure", &err.to_string()),
155    }
156}
157
158pub(crate) fn tool_error(code: &str, message: &str) -> String {
159    to_json_string(json!({
160        "error": {
161            "code": code,
162            "message": message,
163            "model_guidance": schema_first_guidance_payload(
164                "If this error happened during payload construction, call get_schema first and align request shape."
165            ),
166        }
167    }))
168}
169
170pub(crate) fn infer_store_error_code(message: &str) -> &'static str {
171    let normalized = message.trim().to_ascii_lowercase();
172
173    if normalized.starts_with("parsefailure") {
174        "StrictTypedIrParseFailure"
175    } else if normalized.starts_with("ratelimited") {
176        "StoreRateLimited"
177    } else if normalized.starts_with("storefailure") {
178        "StoreFailure"
179    } else if normalized.contains("strict profile") {
180        "StrictTypedIrPolicyViolation"
181    } else {
182        "StoreContextFailure"
183    }
184}
185
186pub(crate) fn strict_typed_ir_profile_name() -> &'static str {
187    "strict_typed_ir"
188}
189
190fn schema_tool_name() -> &'static str {
191    "get_schema"
192}
193
194pub(crate) fn schema_first_guidance_payload(summary: &str) -> Value {
195    json!({
196        "summary": summary,
197        "recommended_first_tool": schema_tool_name(),
198        "recommended_next_steps": [
199            "call get_schema",
200            "verify payload layers provenance->envelope->content->metrics",
201            "ensure required typed-ir keys/enums/numerics are present before retry"
202        ],
203        "ingest_profile_policy": strict_typed_ir_profile_name(),
204    })
205}
206
207pub(crate) fn avec_to_json(avec: locus_core_rs::AvecState) -> Value {
208    json!({
209        "stability": avec.stability,
210        "friction": avec.friction,
211        "logic": avec.logic,
212        "autonomy": avec.autonomy,
213        "psi": avec.psi(),
214    })
215}
216
217pub(crate) fn sttp_node_to_json(node: &locus_core_rs::SttpNode) -> Value {
218    json!({
219        "raw": node.raw,
220        "session_id": node.session_id,
221        "tier": node.tier,
222        "timestamp": node.timestamp.to_rfc3339(),
223        "compression_depth": node.compression_depth,
224        "parent_node_id": node.parent_node_id,
225        "sync_key": node.sync_key,
226        "updated_at": node.updated_at.to_rfc3339(),
227        "source_metadata": node.source_metadata,
228        "context_summary": node.context_summary,
229        "semantic_tags": node.semantic_tags,
230        "semantic_links": node
231            .semantic_links
232            .as_ref()
233            .map(|links| {
234                links
235                    .iter()
236                    .map(|link| {
237                        json!({
238                            "rel": link.rel,
239                            "target": link.target,
240                            "confidence": link.confidence,
241                        })
242                    })
243                    .collect::<Vec<_>>()
244            }),
245        "has_embedding": node.embedding.as_ref().map(|values| !values.is_empty()).unwrap_or(false),
246        "embedding_model": node.embedding_model,
247        "embedding_dimensions": node.embedding_dimensions,
248        "embedded_at": node.embedded_at.map(|value| value.to_rfc3339()),
249        "user_avec": avec_to_json(node.user_avec),
250        "model_avec": avec_to_json(node.model_avec),
251        "compression_avec": node.compression_avec.map(avec_to_json),
252        "rho": node.rho,
253        "kappa": node.kappa,
254        "psi": node.psi,
255    })
256}