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}