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, tags, both".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
86pub(crate) fn filter_nodes_by_context_keywords(
87 nodes: &[locus_core_rs::SttpNode],
88 keywords: &[String],
89 limit: usize,
90) -> Vec<locus_core_rs::SttpNode> {
91 use locus_sdk::application::memory_lexical::{
92 LexicalFields, legacy_phrase_filter, parse_lexical_query, select_lexical_matches,
93 };
94 use locus_sdk::domain::memory::StrictnessMode;
95
96 let query = parse_lexical_query(&keywords.join(" "));
97 let ranked = if query.term_count() == 0 {
98 legacy_phrase_filter(nodes.to_vec(), &keywords.join(" "))
99 } else {
100 select_lexical_matches(
101 nodes.to_vec(),
102 &query,
103 StrictnessMode::Recall,
104 LexicalFields::INVENTORY,
105 )
106 };
107
108 ranked.into_iter().take(limit).collect()
109}
110
111pub(crate) fn to_json_string(value: Value) -> String {
112 match serde_json::to_string(&value) {
113 Ok(serialized) => serialized,
114 Err(err) => tool_error("SerializationFailure", &err.to_string()),
115 }
116}
117
118pub(crate) fn tool_error(code: &str, message: &str) -> String {
119 to_json_string(json!({
120 "error": {
121 "code": code,
122 "message": message,
123 "model_guidance": schema_first_guidance_payload(
124 "If this error happened during payload construction, call get_schema first and align request shape."
125 ),
126 }
127 }))
128}
129
130pub(crate) fn infer_store_error_code(message: &str) -> &'static str {
131 let normalized = message.trim().to_ascii_lowercase();
132
133 if normalized.starts_with("parsefailure") {
134 "StrictTypedIrParseFailure"
135 } else if normalized.starts_with("ratelimited") {
136 "StoreRateLimited"
137 } else if normalized.starts_with("storefailure") {
138 "StoreFailure"
139 } else if normalized.contains("strict profile") {
140 "StrictTypedIrPolicyViolation"
141 } else {
142 "StoreContextFailure"
143 }
144}
145
146pub(crate) fn strict_typed_ir_profile_name() -> &'static str {
147 "strict_typed_ir"
148}
149
150fn schema_tool_name() -> &'static str {
151 "get_schema"
152}
153
154pub(crate) fn schema_first_guidance_payload(summary: &str) -> Value {
155 json!({
156 "summary": summary,
157 "recommended_first_tool": schema_tool_name(),
158 "recommended_next_steps": [
159 "call get_schema",
160 "verify payload layers provenance->envelope->content->metrics",
161 "ensure required typed-ir keys/enums/numerics are present before retry"
162 ],
163 "ingest_profile_policy": strict_typed_ir_profile_name(),
164 })
165}
166
167pub(crate) fn avec_to_json(avec: locus_core_rs::AvecState) -> Value {
168 json!({
169 "stability": avec.stability,
170 "friction": avec.friction,
171 "logic": avec.logic,
172 "autonomy": avec.autonomy,
173 "psi": avec.psi(),
174 })
175}
176
177pub(crate) fn sttp_node_to_json(node: &locus_core_rs::SttpNode) -> Value {
178 json!({
179 "raw": node.raw,
180 "session_id": node.session_id,
181 "tier": node.tier,
182 "timestamp": node.timestamp.to_rfc3339(),
183 "compression_depth": node.compression_depth,
184 "parent_node_id": node.parent_node_id,
185 "sync_key": node.sync_key,
186 "updated_at": node.updated_at.to_rfc3339(),
187 "source_metadata": node.source_metadata,
188 "context_summary": node.context_summary,
189 "semantic_tags": node.semantic_tags,
190 "semantic_links": node
191 .semantic_links
192 .as_ref()
193 .map(|links| {
194 links
195 .iter()
196 .map(|link| {
197 json!({
198 "rel": link.rel,
199 "target": link.target,
200 "confidence": link.confidence,
201 })
202 })
203 .collect::<Vec<_>>()
204 }),
205 "has_embedding": node.embedding.as_ref().map(|values| !values.is_empty()).unwrap_or(false),
206 "embedding_model": node.embedding_model,
207 "embedding_dimensions": node.embedding_dimensions,
208 "embedded_at": node.embedded_at.map(|value| value.to_rfc3339()),
209 "user_avec": avec_to_json(node.user_avec),
210 "model_avec": avec_to_json(node.model_avec),
211 "compression_avec": node.compression_avec.map(avec_to_json),
212 "rho": node.rho,
213 "kappa": node.kappa,
214 "psi": node.psi,
215 })
216}