decision_cockpit 0.1.0

Layer — product decision memory with MCP tools and an embedded review dashboard
Documentation
use serde::Serialize;
use serde_json::Value;
use sqlx::PgPool;
use uuid::Uuid;

use crate::db::repositories::{
    actions as actions_repo, assumptions as assumptions_repo, candidates as repo,
    decisions as decisions_repo, documents as documents_repo, evidence as evidence_repo,
};
use crate::domain::candidates::ExtractionCandidate;
use crate::domain::{CandidateStatus, CandidateType, EntityType, RelationType};
use crate::error::{AppError, AppResult};

use super::documents::{self, normalize_limit};
use super::payload;
use super::relations;

/// Result of accepting a candidate into the canonical graph.
#[derive(Debug, Serialize)]
pub struct AcceptOutcome {
    pub candidate_id: Uuid,
    pub created_entity_type: EntityType,
    pub created_entity_id: Uuid,
    pub entity: Value,
    /// The relation created if the candidate payload linked to a decision.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub linked_relation: Option<Value>,
}

pub async fn create_candidate(
    pool: &PgPool,
    document_id: Option<Uuid>,
    candidate_type: CandidateType,
    payload: Value,
) -> AppResult<ExtractionCandidate> {
    if !payload.is_object() {
        return Err(AppError::Validation(
            "payload must be a JSON object".into(),
        ));
    }

    if let Some(doc_id) = document_id {
        if documents_repo::get(pool, doc_id).await?.is_none() {
            return Err(AppError::NotFound(format!("document {doc_id} not found")));
        }
    }

    let candidate = repo::create(pool, document_id, candidate_type.as_str(), &payload).await?;

    // Once a document has produced candidates, drop it out of the extraction
    // queue while keeping it available as context.
    if let Some(doc_id) = document_id {
        documents::mark_extracted_if_new(pool, doc_id).await?;
    }

    Ok(candidate)
}

pub async fn list_candidates_for_document(
    pool: &PgPool,
    document_id: Uuid,
    status: Option<CandidateStatus>,
) -> AppResult<Vec<ExtractionCandidate>> {
    if documents_repo::get(pool, document_id).await?.is_none() {
        return Err(AppError::NotFound(format!(
            "document {document_id} not found"
        )));
    }
    let status = status.map(|s| s.as_str());
    Ok(repo::list_by_document(pool, document_id, status).await?)
}

pub async fn list_candidates(
    pool: &PgPool,
    status: Option<CandidateStatus>,
    limit: Option<i64>,
) -> AppResult<Vec<ExtractionCandidate>> {
    let status = status.map(|s| s.as_str());
    Ok(repo::list_all(pool, status, normalize_limit(limit)).await?)
}

pub async fn reject_candidate(
    pool: &PgPool,
    id: Uuid,
) -> AppResult<ExtractionCandidate> {
    let candidate = load_pending(pool, id).await?;
    let _ = candidate;
    repo::update_status(pool, id, CandidateStatus::Rejected.as_str())
        .await?
        .ok_or_else(|| AppError::NotFound(format!("candidate {id} not found")))
}

/// Accept a candidate: validate its payload by type, create the matching
/// canonical entity, and mark the candidate accepted.
pub async fn accept_candidate(pool: &PgPool, id: Uuid) -> AppResult<AcceptOutcome> {
    let candidate = load_pending(pool, id).await?;

    let candidate_type: CandidateType = serde_json::from_value(Value::String(
        candidate.candidate_type.clone(),
    ))
    .map_err(|_| {
        AppError::Validation(format!(
            "unknown candidate_type `{}`",
            candidate.candidate_type
        ))
    })?;

    let p = &candidate.payload;

    let (entity_type, entity_id, entity) = match candidate_type {
        CandidateType::Decision => {
            let title = payload::require_str(p, "title")?;
            let summary = payload::require_str(p, "summary")?;
            let rationale = payload::require_str(p, "rationale")?;
            let status = payload::require_str(p, "status")?;
            let owner = payload::opt_str(p, "owner");
            let decided_at = payload::opt_datetime(p, "decided_at")?;

            let decision = decisions_repo::create(
                pool,
                decisions_repo::NewDecision {
                    title: &title,
                    summary: &summary,
                    status: &status,
                    owner: owner.as_deref(),
                    rationale: Some(&rationale),
                    decided_at,
                },
            )
            .await?;
            (
                EntityType::Decision,
                decision.id,
                serde_json::to_value(&decision)?,
            )
        }
        CandidateType::Assumption => {
            let statement = payload::require_str(p, "statement")?;
            let confidence = payload::require_str(p, "confidence")?;
            let status = payload::opt_str(p, "status").unwrap_or_else(|| "active".to_string());

            let assumption = assumptions_repo::create(
                pool,
                assumptions_repo::NewAssumption {
                    statement: &statement,
                    status: &status,
                    confidence: &confidence,
                },
            )
            .await?;
            (
                EntityType::Assumption,
                assumption.id,
                serde_json::to_value(&assumption)?,
            )
        }
        CandidateType::Action => {
            let title = payload::require_str(p, "title")?;
            let description = payload::opt_str(p, "description");
            let owner = payload::opt_str(p, "owner");
            let due_at = payload::opt_datetime(p, "due_date")?;
            let status = payload::opt_str(p, "status").unwrap_or_else(|| "open".to_string());

            let action = actions_repo::create(
                pool,
                actions_repo::NewAction {
                    title: &title,
                    description: description.as_deref(),
                    status: &status,
                    owner: owner.as_deref(),
                    due_at,
                },
            )
            .await?;
            (
                EntityType::Action,
                action.id,
                serde_json::to_value(&action)?,
            )
        }
        CandidateType::Evidence => {
            let text = payload::require_str(p, "text")?;
            let evidence_type = payload::require_str(p, "evidence_type")?;
            let source_label = payload::opt_str(p, "source_label");

            let evidence = evidence_repo::create(
                pool,
                evidence_repo::NewEvidence {
                    source_document_id: candidate.document_id,
                    text: &text,
                    source_label: source_label.as_deref(),
                    evidence_type: &evidence_type,
                },
            )
            .await?;
            (
                EntityType::Evidence,
                evidence.id,
                serde_json::to_value(&evidence)?,
            )
        }
        other => {
            return Err(AppError::Validation(format!(
                "candidate type `{}` cannot be accepted into a canonical entity",
                other.as_str()
            )));
        }
    };

    repo::update_status(pool, id, CandidateStatus::Accepted.as_str()).await?;

    // If the payload names a decision to link to, wire up the relation now so
    // the new entity shows up under that decision's context.
    let linked_relation = maybe_link_to_decision(pool, p, entity_type, entity_id).await?;

    Ok(AcceptOutcome {
        candidate_id: id,
        created_entity_type: entity_type,
        created_entity_id: entity_id,
        entity,
        linked_relation,
    })
}

/// Inspect the payload for `relates_to_decision_id` (+ optional `relation_type`)
/// and create a relation between the decision and the new entity.
async fn maybe_link_to_decision(
    pool: &PgPool,
    payload: &Value,
    entity_type: EntityType,
    entity_id: Uuid,
) -> AppResult<Option<Value>> {
    let Some(raw_id) = payload::opt_str(payload, "relates_to_decision_id") else {
        return Ok(None);
    };
    let decision_id = Uuid::parse_str(&raw_id).map_err(|_| {
        AppError::Validation(format!(
            "payload field `relates_to_decision_id` is not a valid UUID: `{raw_id}`"
        ))
    })?;

    let relation_type = match payload::opt_str(payload, "relation_type") {
        Some(rt) => serde_json::from_value::<RelationType>(Value::String(rt.clone()))
            .map_err(|_| AppError::Validation(format!("unknown relation_type `{rt}`")))?,
        None => relations::default_relation_for(entity_type),
    };

    // Evidence "supports" a decision (evidence -> decision); everything else
    // hangs off the decision (decision -> entity).
    let input = if entity_type == EntityType::Evidence {
        relations::NewRelationInput {
            from_entity_id: entity_id,
            from_entity_type: entity_type,
            to_entity_id: decision_id,
            to_entity_type: EntityType::Decision,
            relation_type,
        }
    } else {
        relations::NewRelationInput {
            from_entity_id: decision_id,
            from_entity_type: EntityType::Decision,
            to_entity_id: entity_id,
            to_entity_type: entity_type,
            relation_type,
        }
    };

    let relation = relations::create_relation(pool, input).await?;
    Ok(Some(serde_json::to_value(relation)?))
}

/// Load a candidate and ensure it is still pending (acceptable / rejectable).
async fn load_pending(pool: &PgPool, id: Uuid) -> AppResult<ExtractionCandidate> {
    let candidate = repo::get(pool, id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("candidate {id} not found")))?;

    if candidate.status != CandidateStatus::Pending.as_str() {
        return Err(AppError::Conflict(format!(
            "candidate {id} is `{}`, only pending candidates can be acted on",
            candidate.status
        )));
    }
    Ok(candidate)
}