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;
#[derive(Debug, Serialize)]
pub struct AcceptOutcome {
pub candidate_id: Uuid,
pub created_entity_type: EntityType,
pub created_entity_id: Uuid,
pub entity: Value,
#[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?;
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")))
}
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?;
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,
})
}
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),
};
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)?))
}
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)
}