microresolve 0.2.2

System 1 relay for LLM apps — sub-millisecond intent classification, safety gating, tool selection. CPU-only, continuous learning from corrections.
Documentation
//! Intent management endpoints.

use crate::state::*;
use axum::{
    extract::{Path, State},
    http::{HeaderMap, StatusCode},
    routing::{get, patch, post},
    Extension, Json,
};
pub fn routes() -> axum::Router<AppState> {
    axum::Router::new()
        .route("/api/intents", get(list_intents).post(add_intent))
        .route(
            "/api/intents/{id}",
            patch(patch_intent).delete(delete_intent_by_id),
        )
        .route(
            "/api/intents/{id}/phrases",
            post(add_phrase_to_intent).delete(remove_phrase_from_intent),
        )
}

// ── New RESTful handlers ────────────────────────────────────────────────────

/// Partial update of an intent. Any subset of fields may be provided.
#[derive(serde::Deserialize)]
pub struct PatchIntentRequest {
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default)]
    pub instructions: Option<String>,
    #[serde(default)]
    pub persona: Option<String>,
    #[serde(default)]
    pub guardrails: Option<Vec<String>>,
    #[serde(default)]
    pub target: Option<microresolve::IntentTarget>,
}

pub async fn patch_intent(
    State(state): State<AppState>,
    headers: HeaderMap,
    Extension(KeyName(kid)): Extension<KeyName>,
    Path(id): Path<String>,
    Json(req): Json<PatchIntentRequest>,
) -> Result<StatusCode, (StatusCode, String)> {
    let app_id = app_id_from_headers(&headers);
    let h = state.engine.try_namespace(&app_id).ok_or((
        StatusCode::NOT_FOUND,
        format!("namespace '{}' not found", app_id),
    ))?;

    let fields_changed: Vec<&str> = [
        ("description", req.description.is_some()),
        ("instructions", req.instructions.is_some()),
        ("persona", req.persona.is_some()),
        ("guardrails", req.guardrails.is_some()),
        ("target", req.target.is_some()),
    ]
    .iter()
    .filter(|(_, set)| *set)
    .map(|(name, _)| *name)
    .collect();

    let edit = microresolve::IntentEdit {
        description: req.description,
        instructions: req.instructions,
        persona: req.persona,
        guardrails: req.guardrails,
        target: req.target,
        ..Default::default()
    };
    h.update_intent(&id, edit)
        .map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))?;

    audit_mutation(
        &state,
        &kid,
        &app_id,
        "intent.update",
        serde_json::json!({
            "intent_id": id,
            "fields": fields_changed,
        }),
    );

    maybe_commit(&state, &app_id);
    Ok(StatusCode::NO_CONTENT)
}

pub async fn delete_intent_by_id(
    State(state): State<AppState>,
    headers: HeaderMap,
    Extension(KeyName(kid)): Extension<KeyName>,
    Path(id): Path<String>,
) -> StatusCode {
    let app_id = app_id_from_headers(&headers);
    let Some(h) = state.engine.try_namespace(&app_id) else {
        return StatusCode::NOT_FOUND;
    };
    h.remove_intent(&id)
        .expect("server is standalone; ConnectMode unreachable");
    audit_mutation(
        &state,
        &kid,
        &app_id,
        "intent.delete",
        serde_json::json!({ "intent_id": id }),
    );
    maybe_commit(&state, &app_id);
    StatusCode::NO_CONTENT
}

#[derive(serde::Deserialize)]
pub struct PhrasePayload {
    pub phrase: String,
    #[serde(default = "default_lang")]
    pub lang: String,
}

pub fn default_lang() -> String {
    "en".to_string()
}

pub async fn add_phrase_to_intent(
    State(state): State<AppState>,
    headers: HeaderMap,
    Extension(KeyName(kid)): Extension<KeyName>,
    Path(id): Path<String>,
    Json(req): Json<PhrasePayload>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
    let app_id = app_id_from_headers(&headers);
    let h = state
        .engine
        .try_namespace(&app_id)
        .ok_or_else(|| (StatusCode::NOT_FOUND, format!("app '{}' not found", app_id)))?;

    let exists = h.training(&id).is_some();
    if !exists {
        return Err((StatusCode::NOT_FOUND, format!("intent '{}' not found", id)));
    }

    let result = h
        .add_phrase(&id, &req.phrase, &req.lang)
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

    if result.added {
        audit_mutation(
            &state,
            &kid,
            &app_id,
            "phrase.add",
            serde_json::json!({
                "intent_id": id,
                "phrase_hash": crate::audit_log::hash_query(&req.phrase),
                "lang": req.lang,
            }),
        );
        maybe_commit(&state, &app_id);
    }

    let counts: std::collections::HashMap<String, usize> = h
        .training_by_lang(&id)
        .map(|m| {
            m.iter()
                .map(|(lang, ps)| (lang.clone(), ps.len()))
                .collect()
        })
        .unwrap_or_default();

    Ok(Json(serde_json::json!({
        "added": result.added,
        "counts": counts,
        "redundant": result.redundant,
        "reason": result.warning,
    })))
}

pub async fn remove_phrase_from_intent(
    State(state): State<AppState>,
    headers: HeaderMap,
    Extension(KeyName(kid)): Extension<KeyName>,
    Path(id): Path<String>,
    Json(req): Json<PhrasePayload>,
) -> Result<StatusCode, (StatusCode, String)> {
    let app_id = app_id_from_headers(&headers);
    let h = state
        .engine
        .try_namespace(&app_id)
        .ok_or_else(|| (StatusCode::NOT_FOUND, format!("app '{}' not found", app_id)))?;
    let removed = h
        .remove_phrase(&id, &req.phrase)
        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
    if removed {
        audit_mutation(
            &state,
            &kid,
            &app_id,
            "phrase.remove",
            serde_json::json!({
                "intent_id": id,
                "phrase_hash": crate::audit_log::hash_query(&req.phrase),
            }),
        );
        maybe_commit(&state, &app_id);
        Ok(StatusCode::NO_CONTENT)
    } else {
        Err((StatusCode::NOT_FOUND, "phrase not found".to_string()))
    }
}

pub async fn list_intents(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Json<serde_json::Value> {
    let app_id = app_id_from_headers(&headers);
    ensure_app(&state, &app_id);
    let h = state.engine.namespace(&app_id);
    let mut ids = h.intent_ids();
    ids.sort();
    let intents: Vec<serde_json::Value> = ids
        .iter()
        .filter_map(|id| h.intent(id))
        .map(|info| {
            let seeds: Vec<String> = info.training.values().flatten().cloned().collect();
            serde_json::json!({
                "id": info.id,
                "description": info.description,
                "phrases": seeds,
                "phrases_by_lang": info.training,
                "learned_count": 0usize,
                "instructions": info.instructions,
                "persona": info.persona,
                "source": info.source,
                "target": info.target,
                "schema": info.schema,
                "guardrails": info.guardrails,
            })
        })
        .collect();
    Json(serde_json::json!(intents))
}

/// Create an intent. Accepts either a flat phrase list (English) or a
/// `phrases_by_lang` map for multilingual seeding. When both are provided,
/// `phrases_by_lang` wins.
#[derive(serde::Deserialize)]
pub struct AddIntentRequest {
    id: String,
    #[serde(default)]
    phrases: Vec<String>,
    #[serde(default)]
    phrases_by_lang: Option<std::collections::HashMap<String, Vec<String>>>,
    #[serde(default)]
    description: Option<String>,
}

pub async fn add_intent(
    State(state): State<AppState>,
    headers: HeaderMap,
    Extension(KeyName(kid)): Extension<KeyName>,
    Json(req): Json<AddIntentRequest>,
) -> StatusCode {
    let app_id = app_id_from_headers(&headers);
    let h = state.engine.namespace(&app_id);

    let phrase_count = req
        .phrases_by_lang
        .as_ref()
        .map(|m| m.values().map(|v| v.len()).sum::<usize>())
        .unwrap_or(req.phrases.len());

    if let Some(by_lang) = req.phrases_by_lang {
        let _ = h.add_intent(&req.id, by_lang);
    } else {
        let seed_refs: Vec<&str> = req.phrases.iter().map(|s| s.as_str()).collect();
        let _ = h.add_intent(&req.id, seed_refs.as_slice());
    }

    let desc_set = req
        .description
        .as_ref()
        .map(|d| !d.is_empty())
        .unwrap_or(false);
    if let Some(desc) = req.description.filter(|d| !d.is_empty()) {
        let _ = h.update_intent(
            &req.id,
            microresolve::IntentEdit {
                description: Some(desc),
                ..Default::default()
            },
        );
    }
    audit_mutation(
        &state,
        &kid,
        &app_id,
        "intent.add",
        serde_json::json!({
            "intent_id": req.id,
            "phrase_count": phrase_count,
            "has_description": desc_set,
        }),
    );
    maybe_commit(&state, &app_id);
    StatusCode::CREATED
}