decision_cockpit 0.1.0

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

use crate::db::repositories::{
    actions as actions_repo, assumptions as assumptions_repo, decisions as decisions_repo,
    drift as drift_repo, evidence as evidence_repo, memos as memos_repo,
    relations as relations_repo,
};
use crate::domain::actions::Action;
use crate::domain::assumptions::Assumption;
use crate::domain::decisions::Decision;
use crate::domain::drift::DriftSignal;
use crate::domain::evidence::Evidence;
use crate::domain::relations::EntityRelation;
use crate::domain::EntityType;
use crate::error::{AppError, AppResult};

use super::documents::normalize_limit;

const NODE_LABEL_MAX: usize = 80;

#[derive(Debug, Serialize)]
pub struct GraphNode {
    pub id: Uuid,
    #[serde(rename = "type")]
    pub node_type: String,
    pub label: String,
    pub status: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct GraphEdge {
    pub id: Uuid,
    pub source: Uuid,
    pub target: Uuid,
    #[serde(rename = "type")]
    pub edge_type: String,
}

#[derive(Debug, Serialize)]
pub struct Graph {
    pub nodes: Vec<GraphNode>,
    pub edges: Vec<GraphEdge>,
}

#[derive(Debug, Serialize)]
pub struct DecisionContext {
    pub decision: Decision,
    pub assumptions: Vec<Assumption>,
    pub actions: Vec<Action>,
    pub evidence: Vec<Evidence>,
    pub relations: Vec<EntityRelation>,
    pub drift_signals: Vec<DriftSignal>,
}

pub async fn list_decisions(
    pool: &PgPool,
    status: Option<&str>,
    limit: Option<i64>,
) -> AppResult<Vec<Decision>> {
    Ok(decisions_repo::list(pool, status, normalize_limit(limit)).await?)
}

pub async fn list_assumptions(pool: &PgPool, limit: Option<i64>) -> AppResult<Vec<Assumption>> {
    Ok(assumptions_repo::list(pool, normalize_limit(limit)).await?)
}

pub async fn list_actions(pool: &PgPool, limit: Option<i64>) -> AppResult<Vec<Action>> {
    Ok(actions_repo::list(pool, normalize_limit(limit)).await?)
}

pub async fn list_evidence(pool: &PgPool, limit: Option<i64>) -> AppResult<Vec<Evidence>> {
    Ok(evidence_repo::list(pool, normalize_limit(limit)).await?)
}

pub async fn get_decision(pool: &PgPool, id: Uuid) -> AppResult<Decision> {
    decisions_repo::get(pool, id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("decision {id} not found")))
}

/// Assemble a decision with everything linked to it: related assumptions,
/// actions, and evidence (via `entity_relations`), plus drift signals.
pub async fn get_decision_context(pool: &PgPool, id: Uuid) -> AppResult<DecisionContext> {
    let decision = get_decision(pool, id).await?;
    let relations = relations_repo::list_for_entity(pool, id).await?;

    let mut assumptions = Vec::new();
    let mut actions = Vec::new();
    let mut evidence = Vec::new();

    for rel in &relations {
        // Determine the endpoint that is not the decision itself.
        let (other_id, other_type) = if rel.from_entity_id == id {
            (rel.to_entity_id, rel.to_entity_type.as_str())
        } else {
            (rel.from_entity_id, rel.from_entity_type.as_str())
        };

        match other_type {
            "assumption" => {
                if let Some(a) = assumptions_repo::get(pool, other_id).await? {
                    assumptions.push(a);
                }
            }
            "action" => {
                if let Some(a) = actions_repo::get(pool, other_id).await? {
                    actions.push(a);
                }
            }
            "evidence" => {
                if let Some(e) = evidence_repo::get(pool, other_id).await? {
                    evidence.push(e);
                }
            }
            _ => {}
        }
    }

    let drift_signals = drift_repo::list_for_entity(pool, id).await?;

    Ok(DecisionContext {
        decision,
        assumptions,
        actions,
        evidence,
        relations,
        drift_signals,
    })
}

/// Build the full decision graph from canonical entities and their relations.
pub async fn build_graph(pool: &PgPool) -> AppResult<Graph> {
    let mut nodes: Vec<GraphNode> = Vec::new();

    for d in decisions_repo::list(pool, None, 1000).await? {
        nodes.push(GraphNode {
            id: d.id,
            node_type: EntityType::Decision.as_str().to_string(),
            label: truncate(&d.title),
            status: Some(d.status),
        });
    }
    for a in assumptions_repo::list(pool, 1000).await? {
        nodes.push(GraphNode {
            id: a.id,
            node_type: EntityType::Assumption.as_str().to_string(),
            label: truncate(&a.statement),
            status: Some(a.status),
        });
    }
    for a in actions_repo::list(pool, 1000).await? {
        nodes.push(GraphNode {
            id: a.id,
            node_type: EntityType::Action.as_str().to_string(),
            label: truncate(&a.title),
            status: Some(a.status),
        });
    }
    for e in evidence_repo::list(pool, 1000).await? {
        nodes.push(GraphNode {
            id: e.id,
            node_type: EntityType::Evidence.as_str().to_string(),
            label: truncate(&e.text),
            status: None,
        });
    }
    for s in drift_repo::list(pool, None, 1000).await? {
        nodes.push(GraphNode {
            id: s.id,
            node_type: EntityType::DriftSignal.as_str().to_string(),
            label: truncate(&s.summary),
            status: Some(s.status),
        });
    }
    for m in memos_repo::list(pool, 1000).await? {
        nodes.push(GraphNode {
            id: m.id,
            node_type: EntityType::Memo.as_str().to_string(),
            label: truncate(&m.title),
            status: None,
        });
    }

    let edges = relations_repo::list_all(pool)
        .await?
        .into_iter()
        .map(|r| GraphEdge {
            id: r.id,
            source: r.from_entity_id,
            target: r.to_entity_id,
            edge_type: r.relation_type,
        })
        .collect();

    Ok(Graph { nodes, edges })
}

fn truncate(s: &str) -> String {
    let s = s.trim();
    if s.chars().count() <= NODE_LABEL_MAX {
        s.to_string()
    } else {
        let truncated: String = s.chars().take(NODE_LABEL_MAX).collect();
        format!("{truncated}")
    }
}