Skip to main content

post_cortex_mcp/
update_context.rs

1//! MCP-side adapter for context-update writes.
2//!
3//! Phase 6 of the single-entrypoint migration: every MCP-driven write
4//! flows through [`post_cortex_memory::services::MemoryServiceImpl`] —
5//! the canonical [`PostCortexService`](post_cortex_core::services::PostCortexService)
6//! implementation. This module only
7//! translates the LLM-friendly wire format (HashMap content + typed
8//! `entities` / `relations` arrays) into the canonical
9//! [`UpdateContextRequest`](post_cortex_core::services::UpdateContextRequest);
10//! validation, persistence, and metadata
11//! shaping all happen inside the service.
12
13use anyhow::{Result, anyhow};
14use post_cortex_core::core::context_update::{
15    CodeReference, EntityData, EntityRelationship, EntityType, RelationType, UpdateContent,
16    UpdateType,
17};
18use post_cortex_core::core::timeout_utils::with_mcp_timeout;
19use post_cortex_core::services::{
20    BulkUpdateContextRequest as ServiceBulkRequest, PostCortexService,
21    UpdateContextRequest as ServiceUpdateRequest,
22};
23use std::collections::HashMap;
24use tracing::{debug, error, info, instrument, warn};
25use uuid::Uuid;
26
27use crate::{ContextUpdateItem, EntityItem, MCPToolResult, RelationItem, get_service};
28
29/// Parse the LLM-provided `interaction_type + content` HashMap into a
30/// typed [`UpdateContent`] using the same key-resolution conventions the
31/// old MCP path used. Returns `Err` if the `interaction_type` is unknown
32/// so callers can surface a precise error.
33fn build_content(
34    interaction_type: &str,
35    content: &HashMap<String, String>,
36) -> Result<(UpdateType, UpdateContent)> {
37    let extract_extras = |exclude_keys: &[&str]| -> Vec<String> {
38        content
39            .iter()
40            .filter(|(k, _)| !exclude_keys.contains(&k.as_str()))
41            .map(|(k, v)| format!("{}: {}", k, v))
42            .collect()
43    };
44
45    let resolve_slot = |preferred: &[&str], fallback_keys: &[&str]| -> String {
46        for k in preferred.iter().chain(fallback_keys.iter()) {
47            if let Some(v) = content.get(*k)
48                && !v.trim().is_empty()
49            {
50                return v.clone();
51            }
52        }
53        String::new()
54    };
55
56    let (update_type, title, description, details, implications) = match interaction_type {
57        "qa" => {
58            let title = resolve_slot(&["question"], &["title"]);
59            let description = resolve_slot(&["answer"], &["description"]);
60            let details = extract_extras(&["question", "answer", "title", "description"]);
61            (
62                UpdateType::QuestionAnswered,
63                title,
64                description,
65                details,
66                vec![],
67            )
68        }
69        "code_change" => {
70            let title = resolve_slot(&["file_path", "file"], &["title", "description"]);
71            let description = resolve_slot(
72                &["changes", "diff", "change_type", "change"],
73                &["description"],
74            );
75            let details = extract_extras(&[
76                "file_path",
77                "file",
78                "title",
79                "description",
80                "changes",
81                "diff",
82                "change_type",
83                "change",
84            ]);
85            (
86                UpdateType::CodeChanged,
87                title,
88                description,
89                details,
90                vec!["Code functionality updated".to_string()],
91            )
92        }
93        "problem_solved" => {
94            let title = resolve_slot(&["problem"], &["title"]);
95            let description = resolve_slot(&["solution"], &["description"]);
96            let details = extract_extras(&["problem", "solution", "title", "description"]);
97            (
98                UpdateType::ProblemSolved,
99                title,
100                description,
101                details,
102                vec!["Problem resolved".to_string()],
103            )
104        }
105        "decision_made" => {
106            let title = resolve_slot(&["decision"], &["title"]);
107            let description = resolve_slot(&["rationale"], &["description"]);
108            let details = extract_extras(&["decision", "rationale", "title", "description"]);
109            (
110                UpdateType::DecisionMade,
111                title,
112                description,
113                details,
114                vec![],
115            )
116        }
117        "requirement_added" => {
118            let title = resolve_slot(&["requirement"], &["title"]);
119            let description = resolve_slot(&["description"], &[]);
120            let details = extract_extras(&["requirement", "priority", "title", "description"]);
121            (
122                UpdateType::RequirementAdded,
123                title,
124                description,
125                details,
126                vec![],
127            )
128        }
129        "concept_defined" => {
130            let title = resolve_slot(&["concept"], &["title"]);
131            let description = resolve_slot(&["definition"], &["description"]);
132            let details = extract_extras(&["concept", "definition", "title", "description"]);
133            (
134                UpdateType::ConceptDefined,
135                title,
136                description,
137                details,
138                vec![],
139            )
140        }
141        other => return Err(anyhow!("Unknown interaction type: {}", other)),
142    };
143
144    Ok((
145        update_type,
146        UpdateContent {
147            title,
148            description,
149            details,
150            examples: vec![],
151            implications,
152        },
153    ))
154}
155
156/// Parse an MCP `entity_type` string (lowercase) into [`EntityType`].
157/// Unknown values default to `Concept` to match the gRPC parser.
158fn parse_entity_type(s: &str) -> EntityType {
159    match s.to_lowercase().as_str() {
160        "technology" => EntityType::Technology,
161        "concept" => EntityType::Concept,
162        "problem" => EntityType::Problem,
163        "solution" => EntityType::Solution,
164        "decision" => EntityType::Decision,
165        "code_component" | "codecomponent" => EntityType::CodeComponent,
166        _ => EntityType::Concept,
167    }
168}
169
170/// Parse an MCP `relation_type` string (lowercase) into [`RelationType`].
171/// Returns `None` for unknown values — the caller surfaces this as an
172/// `InvalidArgument` error rather than silently defaulting.
173fn parse_relation_type(s: &str) -> Option<RelationType> {
174    match s.to_lowercase().as_str() {
175        "required_by" | "requiredby" => Some(RelationType::RequiredBy),
176        "leads_to" | "leadsto" => Some(RelationType::LeadsTo),
177        "related_to" | "relatedto" => Some(RelationType::RelatedTo),
178        "conflicts_with" | "conflictswith" => Some(RelationType::ConflictsWith),
179        "depends_on" | "dependson" => Some(RelationType::DependsOn),
180        "implements" => Some(RelationType::Implements),
181        "caused_by" | "causedby" => Some(RelationType::CausedBy),
182        "solves" => Some(RelationType::Solves),
183        _ => None,
184    }
185}
186
187fn entities_to_domain(items: &[EntityItem]) -> Vec<EntityData> {
188    let now = chrono::Utc::now();
189    items
190        .iter()
191        .map(|e| EntityData {
192            name: e.name.clone(),
193            entity_type: parse_entity_type(&e.entity_type),
194            first_mentioned: now,
195            last_mentioned: now,
196            mention_count: 1,
197            importance_score: 1.0,
198            description: None,
199        })
200        .collect()
201}
202
203fn relations_to_domain(items: &[RelationItem]) -> Result<Vec<EntityRelationship>> {
204    let mut out = Vec::with_capacity(items.len());
205    for (i, r) in items.iter().enumerate() {
206        let rt = parse_relation_type(&r.relation_type).ok_or_else(|| {
207            anyhow!(
208                "relation[{i}]: unknown relation_type {:?}; valid values are: \
209                 required_by, leads_to, related_to, conflicts_with, depends_on, implements, caused_by, solves",
210                r.relation_type
211            )
212        })?;
213        out.push(EntityRelationship {
214            from_entity: r.from_entity.clone(),
215            to_entity: r.to_entity.clone(),
216            relation_type: rt,
217            context: r.context.clone(),
218        });
219    }
220    Ok(out)
221}
222
223/// Build a canonical [`ServiceUpdateRequest`] from the MCP wire payload.
224fn build_request(
225    session_id: Uuid,
226    interaction_type: &str,
227    content: &HashMap<String, String>,
228    entities: &[EntityItem],
229    relations: &[RelationItem],
230    code_reference: Option<CodeReference>,
231) -> Result<ServiceUpdateRequest> {
232    let (update_type, update_content) = build_content(interaction_type, content)?;
233    Ok(ServiceUpdateRequest {
234        session_id,
235        interaction_type: update_type,
236        content: update_content,
237        entities: entities_to_domain(entities),
238        relations: relations_to_domain(relations)?,
239        code_reference,
240    })
241}
242
243/// Record a single context update via the canonical
244/// [`PostCortexService::update_context`] path.
245#[instrument(skip(content, entities, relations), fields(
246    session_id = %session_id,
247    interaction_type = %interaction_type,
248    entities_count = entities.len(),
249    relations_count = relations.len(),
250    has_code_reference = code_reference.is_some()
251))]
252pub async fn update_conversation_context(
253    interaction_type: String,
254    content: HashMap<String, String>,
255    entities: Vec<EntityItem>,
256    relations: Vec<RelationItem>,
257    code_reference: Option<CodeReference>,
258    session_id: Uuid,
259) -> Result<MCPToolResult> {
260    info!("MCP-TOOLS: update_conversation_context() called");
261    let service = get_service().await?;
262
263    let req = match build_request(
264        session_id,
265        &interaction_type,
266        &content,
267        &entities,
268        &relations,
269        code_reference,
270    ) {
271        Ok(r) => r,
272        Err(e) => {
273            error!("update_conversation_context: bad input — {}", e);
274            return Ok(MCPToolResult::error(e.to_string()));
275        }
276    };
277
278    let result = with_mcp_timeout(async {
279        match service.update_context(req).await {
280            Ok(resp) => {
281                debug!(
282                    "update_conversation_context: persisted entry {} in session {}",
283                    resp.entry_id, resp.session_id
284                );
285                Ok(MCPToolResult::success(
286                    "Context updated successfully".to_string(),
287                    None,
288                ))
289            }
290            Err(e) => {
291                warn!("update_conversation_context: service rejected — {}", e);
292                Ok(MCPToolResult::error(e.to_string()))
293            }
294        }
295    })
296    .await;
297
298    match result {
299        Ok(r) => r,
300        Err(timeout_error) => {
301            error!(
302                "TIMEOUT: update_conversation_context — session: {}, error: {}",
303                session_id, timeout_error
304            );
305            Ok(MCPToolResult::error(format!(
306                "Operation timed out: {}",
307                timeout_error
308            )))
309        }
310    }
311}
312
313/// Record multiple context updates in a single batch via the canonical
314/// service. Items that fail translation or persistence are reported in
315/// the response payload — the rest still land, matching the legacy
316/// gRPC bulk semantics.
317pub async fn bulk_update_conversation_context(
318    updates: Vec<ContextUpdateItem>,
319    session_id: Uuid,
320) -> Result<MCPToolResult> {
321    info!(
322        "MCP-TOOLS: bulk_update_conversation_context() called with {} updates for session {}",
323        updates.len(),
324        session_id
325    );
326
327    let service = get_service().await?;
328
329    let mut requests = Vec::with_capacity(updates.len());
330    let mut error_count = 0;
331    let mut errors: Vec<String> = Vec::new();
332    for (index, item) in updates.iter().enumerate() {
333        match build_request(
334            session_id,
335            &item.interaction_type,
336            &item.content,
337            &item.entities,
338            &item.relations,
339            item.code_reference.clone(),
340        ) {
341            Ok(req) => requests.push(req),
342            Err(e) => {
343                error_count += 1;
344                errors.push(format!("Update {}: {}", index, e));
345            }
346        }
347    }
348
349    // Persist via the canonical bulk method when every translation
350    // succeeded; otherwise fall back to per-item calls so partial
351    // failure semantics are preserved.
352    let success_count = if errors.is_empty() {
353        match service
354            .bulk_update_context(ServiceBulkRequest {
355                session_id,
356                updates: requests,
357            })
358            .await
359        {
360            Ok(resp) => resp.entry_ids.len(),
361            Err(e) => {
362                errors.push(format!("Bulk persist failed: {}", e));
363                error_count += 1;
364                0
365            }
366        }
367    } else {
368        // At least one item failed translation: keep the legacy
369        // "best effort" behaviour and persist the good ones one at a
370        // time so the caller still gets partial progress.
371        let mut count = 0;
372        for (offset, req) in requests.into_iter().enumerate() {
373            match service.update_context(req).await {
374                Ok(_) => count += 1,
375                Err(e) => {
376                    error_count += 1;
377                    errors.push(format!("Update (translated index {offset}): {}", e));
378                }
379            }
380        }
381        count
382    };
383
384    let message = if error_count == 0 {
385        format!(
386            "Bulk update completed successfully: {} updates added",
387            success_count
388        )
389    } else {
390        format!(
391            "Bulk update completed with errors: {} succeeded, {} failed",
392            success_count, error_count
393        )
394    };
395
396    Ok(MCPToolResult::success(
397        message,
398        Some(serde_json::json!({
399            "success_count": success_count,
400            "error_count": error_count,
401            "errors": errors,
402        })),
403    ))
404}