use axum::{
extract::{Query, State},
http::{header, HeaderValue},
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use trusty_common::memory_core::community::KnowledgeGap;
use trusty_common::memory_core::palace::PalaceId;
use trusty_common::memory_core::store::kg::Triple;
use crate::AppState;
use super::error::{open_handle, ApiError};
#[allow(unused_imports)]
pub(crate) use crate::service::refresh_gaps_cache;
#[derive(Serialize, Debug, Clone)]
pub struct KnowledgeGapResponse {
pub entities: Vec<String>,
pub internal_density: f32,
pub external_bridges: usize,
pub suggested_exploration: String,
}
impl From<KnowledgeGap> for KnowledgeGapResponse {
fn from(g: KnowledgeGap) -> Self {
Self {
entities: g.entities,
internal_density: g.internal_density,
external_bridges: g.external_bridges,
suggested_exploration: g.suggested_exploration,
}
}
}
#[derive(Deserialize)]
pub(super) struct KgGapsQuery {
#[serde(default)]
palace: Option<String>,
}
pub(super) async fn kg_gaps_handler(
State(state): State<AppState>,
Query(q): Query<KgGapsQuery>,
) -> Result<Json<Vec<KnowledgeGapResponse>>, ApiError> {
let palace_name = q
.palace
.clone()
.or_else(|| state.default_palace.clone())
.ok_or_else(|| {
ApiError::bad_request("missing 'palace' query parameter (no default palace configured)")
})?;
let _handle = open_handle(&state, &palace_name)?;
let pid = PalaceId::new(&palace_name);
let gaps = state.registry.get_gaps(&pid).unwrap_or_default();
let body: Vec<KnowledgeGapResponse> =
gaps.into_iter().map(KnowledgeGapResponse::from).collect();
Ok(Json(body))
}
#[derive(Deserialize)]
pub(super) struct PromptFactsQuery {
#[serde(default)]
#[allow(dead_code)]
palace: Option<String>,
}
#[derive(Deserialize)]
pub(super) struct AddAliasRequest {
short: String,
full: String,
#[serde(default)]
palace: Option<String>,
}
#[derive(Serialize)]
pub(super) struct PromptFactRow {
subject: String,
predicate: String,
object: String,
}
#[derive(Deserialize)]
pub(super) struct RemovePromptFactQuery {
subject: String,
predicate: String,
#[serde(default)]
#[allow(dead_code)]
object: Option<String>,
#[serde(default)]
#[allow(dead_code)]
palace: Option<String>,
}
pub(super) async fn prompt_context_handler(
State(state): State<AppState>,
Query(_q): Query<PromptFactsQuery>,
) -> Result<Response, ApiError> {
let cache_snapshot = {
let guard = state.prompt_context_cache.read().await;
guard.clone()
};
let body = if cache_snapshot.formatted.is_empty() {
"No prompt facts stored yet.".to_string()
} else {
cache_snapshot.formatted
};
let mut resp = body.into_response();
resp.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
Ok(resp)
}
pub(super) async fn add_alias_handler(
State(state): State<AppState>,
Json(req): Json<AddAliasRequest>,
) -> Result<Json<Value>, ApiError> {
if req.short.is_empty() || req.full.is_empty() {
return Err(ApiError::bad_request("short and full are required"));
}
let palace_name = req
.palace
.clone()
.or_else(|| state.default_palace.clone())
.ok_or_else(|| ApiError::bad_request("missing 'palace' (no default palace configured)"))?;
let handle = open_handle(&state, &palace_name)?;
let triple = Triple {
subject: req.short.clone(),
predicate: "is_alias_for".to_string(),
object: req.full.clone(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: Some("add_alias_http".to_string()),
};
handle
.kg
.assert(triple)
.await
.map_err(|e| ApiError::internal(format!("kg.assert failed: {e:#}")))?;
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
tracing::warn!("rebuild_prompt_cache after HTTP add_alias failed: {e:#}");
}
Ok(Json(json!({
"subject": req.short,
"predicate": "is_alias_for",
"object": req.full,
"palace": palace_name,
})))
}
pub(super) async fn list_prompt_facts_handler(
State(state): State<AppState>,
Query(_q): Query<PromptFactsQuery>,
) -> Result<Json<Vec<PromptFactRow>>, ApiError> {
let triples = crate::prompt_facts::gather_hot_triples(&state)
.await
.map_err(|e| ApiError::internal(format!("gather_hot_triples: {e:#}")))?;
let rows: Vec<PromptFactRow> = triples
.into_iter()
.map(|(subject, predicate, object)| PromptFactRow {
subject,
predicate,
object,
})
.collect();
Ok(Json(rows))
}
pub(super) async fn remove_prompt_fact_handler(
State(state): State<AppState>,
Query(q): Query<RemovePromptFactQuery>,
) -> Result<Json<Value>, ApiError> {
if q.subject.is_empty() || q.predicate.is_empty() {
return Err(ApiError::bad_request("subject and predicate are required"));
}
let mut closed_total: usize = 0;
for palace_id in state.registry.list() {
if let Some(handle) = state.registry.get(&palace_id) {
match handle.kg.retract(&q.subject, &q.predicate).await {
Ok(n) => closed_total += n,
Err(e) => tracing::warn!(
palace = %palace_id.as_str(),
"HTTP retract failed: {e:#}",
),
}
}
}
if closed_total > 0 {
if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&state).await {
tracing::warn!("rebuild_prompt_cache after HTTP remove_prompt_fact failed: {e:#}");
}
Ok(Json(json!({"removed": true, "closed": closed_total})))
} else {
Ok(Json(json!({"removed": false, "reason": "not found"})))
}
}