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