mirror of
https://github.com/perstarkse/minne.git
synced 2026-04-24 09:48:32 +02:00
feat: category filtering knowledge and content
This commit is contained in:
@@ -11,7 +11,7 @@ use tokio::fs::remove_dir_all;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{storage::db::SurrealDbClient, stored_object};
|
use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum FileError {
|
pub enum FileError {
|
||||||
@@ -221,15 +221,15 @@ impl FileInfo {
|
|||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// `Result<(), FileError>`
|
/// `Result<(), FileError>`
|
||||||
pub async fn delete_by_id(id: &str, db_client: &SurrealDbClient) -> Result<(), FileError> {
|
pub async fn delete_by_id(id: &str, db_client: &SurrealDbClient) -> Result<(), AppError> {
|
||||||
// Get the FileInfo from the database
|
// Get the FileInfo from the database
|
||||||
let file_info = match db_client.get_item::<FileInfo>(id).await? {
|
let file_info = match db_client.get_item::<FileInfo>(id).await? {
|
||||||
Some(info) => info,
|
Some(info) => info,
|
||||||
None => {
|
None => {
|
||||||
return Err(FileError::FileNotFound(format!(
|
return Err(AppError::from(FileError::FileNotFound(format!(
|
||||||
"File with id {} was not found",
|
"File with id {} was not found",
|
||||||
id
|
id
|
||||||
)))
|
))))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -242,15 +242,15 @@ impl FileInfo {
|
|||||||
remove_dir_all(parent_dir).await?;
|
remove_dir_all(parent_dir).await?;
|
||||||
info!("Removed directory {:?} and its contents", parent_dir);
|
info!("Removed directory {:?} and its contents", parent_dir);
|
||||||
} else {
|
} else {
|
||||||
return Err(FileError::FileNotFound(
|
return Err(AppError::from(FileError::FileNotFound(
|
||||||
"File has no parent directory".to_string(),
|
"File has no parent directory".to_string(),
|
||||||
));
|
)));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(FileError::FileNotFound(format!(
|
return Err(AppError::from(FileError::FileNotFound(format!(
|
||||||
"File at path {:?} was not found",
|
"File at path {:?} was not found",
|
||||||
file_path
|
file_path
|
||||||
)));
|
))));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the FileInfo from the database
|
// Delete the FileInfo from the database
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::{error::AppError, storage::types::file_info::FileInfo};
|
use crate::{error::AppError, storage::types::file_info::FileInfo};
|
||||||
use chrono::Utc;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|||||||
@@ -227,6 +227,50 @@ impl User {
|
|||||||
Ok(entities)
|
Ok(entities)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_knowledge_entities_by_type(
|
||||||
|
user_id: &str,
|
||||||
|
entity_type: &str,
|
||||||
|
db: &SurrealDbClient,
|
||||||
|
) -> Result<Vec<KnowledgeEntity>, AppError> {
|
||||||
|
let entities: Vec<KnowledgeEntity> = db
|
||||||
|
.client
|
||||||
|
.query("SELECT * FROM type::table($table) WHERE user_id = $user_id AND entity_type = $entity_type")
|
||||||
|
.bind(("table", KnowledgeEntity::table_name()))
|
||||||
|
.bind(("user_id", user_id.to_owned()))
|
||||||
|
.bind(("entity_type", entity_type.to_owned()))
|
||||||
|
.await?
|
||||||
|
.take(0)?;
|
||||||
|
|
||||||
|
Ok(entities)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_entity_types(
|
||||||
|
user_id: &str,
|
||||||
|
db: &SurrealDbClient,
|
||||||
|
) -> Result<Vec<String>, AppError> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct EntityTypeResponse {
|
||||||
|
entity_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query to select distinct entity types for the user
|
||||||
|
let response: Vec<EntityTypeResponse> = db
|
||||||
|
.client
|
||||||
|
.query("SELECT entity_type FROM type::table($table_name) WHERE user_id = $user_id GROUP BY entity_type")
|
||||||
|
.bind(("user_id", user_id.to_owned()))
|
||||||
|
.bind(("table_name", KnowledgeEntity::table_name()))
|
||||||
|
.await?
|
||||||
|
.take(0)?;
|
||||||
|
|
||||||
|
// Extract the entity types from the response
|
||||||
|
let entity_types: Vec<String> = response
|
||||||
|
.into_iter()
|
||||||
|
.map(|item| format!("{:?}", item.entity_type))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(entity_types)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_knowledge_relationships(
|
pub async fn get_knowledge_relationships(
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
db: &SurrealDbClient,
|
db: &SurrealDbClient,
|
||||||
@@ -272,6 +316,23 @@ impl User {
|
|||||||
Ok(items)
|
Ok(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_text_contents_by_category(
|
||||||
|
user_id: &str,
|
||||||
|
category: &str,
|
||||||
|
db: &SurrealDbClient,
|
||||||
|
) -> Result<Vec<TextContent>, AppError> {
|
||||||
|
let items: Vec<TextContent> = db
|
||||||
|
.client
|
||||||
|
.query("SELECT * FROM type::table($table_name) WHERE user_id = $user_id AND category = $category ORDER BY created_at DESC")
|
||||||
|
.bind(("user_id", user_id.to_owned()))
|
||||||
|
.bind(("category", category.to_owned()))
|
||||||
|
.bind(("table_name", TextContent::table_name()))
|
||||||
|
.await?
|
||||||
|
.take(0)?;
|
||||||
|
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_latest_knowledge_entities(
|
pub async fn get_latest_knowledge_entities(
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
db: &SurrealDbClient,
|
db: &SurrealDbClient,
|
||||||
@@ -413,6 +474,34 @@ impl User {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_knowledge_entities_by_content_category(
|
||||||
|
user_id: &str,
|
||||||
|
category: &str,
|
||||||
|
db: &SurrealDbClient,
|
||||||
|
) -> Result<Vec<KnowledgeEntity>, AppError> {
|
||||||
|
// First, find all text content items with the specified category
|
||||||
|
let text_contents = Self::get_text_contents_by_category(user_id, category, db).await?;
|
||||||
|
|
||||||
|
if text_contents.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract source_ids
|
||||||
|
let source_ids: Vec<String> = text_contents.iter().map(|tc| tc.id.clone()).collect();
|
||||||
|
|
||||||
|
// Find all knowledge entities with matching source_ids
|
||||||
|
let entities: Vec<KnowledgeEntity> = db
|
||||||
|
.client
|
||||||
|
.query("SELECT * FROM type::table($table) WHERE user_id = $user_id AND source_id IN $source_ids")
|
||||||
|
.bind(("table", KnowledgeEntity::table_name()))
|
||||||
|
.bind(("user_id", user_id.to_owned()))
|
||||||
|
.bind(("source_ids", source_ids))
|
||||||
|
.await?
|
||||||
|
.take(0)?;
|
||||||
|
|
||||||
|
Ok(entities)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Form,
|
Form,
|
||||||
};
|
};
|
||||||
|
use axum_htmx::{HxBoosted, HxRequest};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use common::storage::types::{text_content::TextContent, user::User};
|
use common::storage::types::{file_info::FileInfo, text_content::TextContent, user::User};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
html_state::HtmlState,
|
html_state::HtmlState,
|
||||||
@@ -19,21 +20,52 @@ use crate::{
|
|||||||
pub struct ContentPageData {
|
pub struct ContentPageData {
|
||||||
user: User,
|
user: User,
|
||||||
text_contents: Vec<TextContent>,
|
text_contents: Vec<TextContent>,
|
||||||
|
categories: Vec<String>,
|
||||||
|
selected_category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FilterParams {
|
||||||
|
category: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_content_page(
|
pub async fn show_content_page(
|
||||||
State(state): State<HtmlState>,
|
State(state): State<HtmlState>,
|
||||||
RequireUser(user): RequireUser,
|
RequireUser(user): RequireUser,
|
||||||
|
Query(params): Query<FilterParams>,
|
||||||
|
HxRequest(is_htmx): HxRequest,
|
||||||
|
HxBoosted(is_boosted): HxBoosted,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
|
// Normalize empty strings to None
|
||||||
|
let has_category_param = params.category.is_some();
|
||||||
|
let category_filter = params.category.as_deref().unwrap_or("").trim();
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
// load categories and filtered/all contents
|
||||||
"content/base.html",
|
let categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
ContentPageData {
|
let text_contents = if !category_filter.is_empty() {
|
||||||
user,
|
User::get_text_contents_by_category(&user.id, category_filter, &state.db).await?
|
||||||
text_contents,
|
} else {
|
||||||
},
|
User::get_text_contents(&user.id, &state.db).await?
|
||||||
))
|
};
|
||||||
|
|
||||||
|
let data = ContentPageData {
|
||||||
|
user,
|
||||||
|
text_contents,
|
||||||
|
categories,
|
||||||
|
selected_category: params.category.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_htmx && !is_boosted && has_category_param {
|
||||||
|
// If HTMX partial request with filter applied, return partial content list update
|
||||||
|
return Ok(TemplateResponse::new_partial(
|
||||||
|
"content/base.html",
|
||||||
|
"main",
|
||||||
|
data,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise full page response including layout
|
||||||
|
Ok(TemplateResponse::new_template("content/base.html", data))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_text_content_edit_form(
|
pub async fn show_text_content_edit_form(
|
||||||
@@ -79,12 +111,47 @@ pub async fn patch_text_content(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
|
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
|
||||||
|
let categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(TemplateResponse::new_partial(
|
||||||
|
"content/base.html",
|
||||||
|
"main",
|
||||||
|
ContentPageData {
|
||||||
|
user,
|
||||||
|
text_contents,
|
||||||
|
categories,
|
||||||
|
selected_category: None,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_text_content(
|
||||||
|
State(state): State<HtmlState>,
|
||||||
|
RequireUser(user): RequireUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
|
// Get and validate the text content
|
||||||
|
let text_content = User::get_and_validate_text_content(&id, &user.id, &state.db).await?;
|
||||||
|
|
||||||
|
// If it has file info, delete that too
|
||||||
|
if let Some(file_info) = &text_content.file_info {
|
||||||
|
FileInfo::delete_by_id(&file_info.id, &state.db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the text content
|
||||||
|
state.db.delete_item::<TextContent>(&id).await?;
|
||||||
|
|
||||||
|
// Get updated content, categories and return the refreshed list
|
||||||
|
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
|
||||||
|
let categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"content/content_list.html",
|
"content/content_list.html",
|
||||||
ContentPageData {
|
ContentPageData {
|
||||||
user,
|
user,
|
||||||
text_contents,
|
text_contents,
|
||||||
|
categories,
|
||||||
|
selected_category: None,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
mod handlers;
|
mod handlers;
|
||||||
|
|
||||||
use axum::{extract::FromRef, routing::get, Router};
|
use axum::{extract::FromRef, routing::get, Router};
|
||||||
use handlers::{patch_text_content, show_content_page, show_text_content_edit_form};
|
use handlers::{
|
||||||
|
delete_text_content, patch_text_content, show_content_page, show_text_content_edit_form,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::html_state::HtmlState;
|
use crate::html_state::HtmlState;
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ where
|
|||||||
.route("/content", get(show_content_page))
|
.route("/content", get(show_content_page))
|
||||||
.route(
|
.route(
|
||||||
"/content/:id",
|
"/content/:id",
|
||||||
get(show_text_content_edit_form).patch(patch_text_content),
|
get(show_text_content_edit_form)
|
||||||
|
.patch(patch_text_content)
|
||||||
|
.delete(delete_text_content),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Form,
|
Form,
|
||||||
};
|
};
|
||||||
|
use axum_htmx::{HxBoosted, HxRequest};
|
||||||
use plotly::{
|
use plotly::{
|
||||||
common::{Line, Marker, Mode},
|
common::{Line, Marker, Mode},
|
||||||
layout::{Axis, Camera, LayoutScene, ProjectionType},
|
layout::{Axis, LayoutScene},
|
||||||
Layout, Plot, Scatter3D,
|
Layout, Plot, Scatter, Scatter3D,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -24,45 +27,178 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub struct FilterParams {
|
||||||
|
entity_type: Option<String>,
|
||||||
|
content_category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct KnowledgeBaseData {
|
||||||
|
entities: Vec<KnowledgeEntity>,
|
||||||
|
relationships: Vec<KnowledgeRelationship>,
|
||||||
|
user: User,
|
||||||
|
plot_html: String,
|
||||||
|
entity_types: Vec<String>,
|
||||||
|
content_categories: Vec<String>,
|
||||||
|
selected_entity_type: Option<String>,
|
||||||
|
selected_content_category: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn show_knowledge_page(
|
pub async fn show_knowledge_page(
|
||||||
State(state): State<HtmlState>,
|
State(state): State<HtmlState>,
|
||||||
RequireUser(user): RequireUser,
|
RequireUser(user): RequireUser,
|
||||||
|
Query(mut params): Query<FilterParams>,
|
||||||
|
HxRequest(is_htmx): HxRequest,
|
||||||
|
HxBoosted(is_boosted): HxBoosted,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> Result<impl IntoResponse, HtmlError> {
|
||||||
#[derive(Serialize)]
|
// Normalize filters
|
||||||
pub struct KnowledgeBaseData {
|
params.entity_type = params.entity_type.take().filter(|s| !s.trim().is_empty());
|
||||||
entities: Vec<KnowledgeEntity>,
|
params.content_category = params
|
||||||
relationships: Vec<KnowledgeRelationship>,
|
.content_category
|
||||||
user: User,
|
.take()
|
||||||
plot_html: String,
|
.filter(|s| !s.trim().is_empty());
|
||||||
}
|
|
||||||
|
|
||||||
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
|
// Load relevant data
|
||||||
|
let entity_types = User::get_entity_types(&user.id, &state.db).await?;
|
||||||
|
let content_categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
// Load entities based on filters
|
||||||
|
let entities = match ¶ms.content_category {
|
||||||
|
Some(cat) => {
|
||||||
|
User::get_knowledge_entities_by_content_category(&user.id, cat, &state.db).await?
|
||||||
|
}
|
||||||
|
None => match ¶ms.entity_type {
|
||||||
|
Some(etype) => User::get_knowledge_entities_by_type(&user.id, etype, &state.db).await?,
|
||||||
|
None => User::get_knowledge_entities(&user.id, &state.db).await?,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
let relationships = User::get_knowledge_relationships(&user.id, &state.db).await?;
|
let relationships = User::get_knowledge_relationships(&user.id, &state.db).await?;
|
||||||
|
let plot_html = get_plot_html(&entities, &relationships)?;
|
||||||
|
|
||||||
let mut plot = Plot::new();
|
let kb_data = KnowledgeBaseData {
|
||||||
|
entities,
|
||||||
|
relationships,
|
||||||
|
user,
|
||||||
|
plot_html,
|
||||||
|
entity_types,
|
||||||
|
content_categories,
|
||||||
|
selected_entity_type: params.entity_type.clone(),
|
||||||
|
selected_content_category: params.content_category.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
// Fibonacci sphere distribution
|
// Determine response type:
|
||||||
let node_count = entities.len();
|
// If it is an HTMX request but NOT a boosted navigation, send partial update (main block only)
|
||||||
let golden_ratio = (1.0 + 5.0_f64.sqrt()) / 2.0;
|
// Otherwise send full page including navbar/base for direct and boosted reloads
|
||||||
let node_positions: Vec<(f64, f64, f64)> = (0..node_count)
|
if is_htmx && !is_boosted {
|
||||||
.map(|i| {
|
// Partial update (just main block)
|
||||||
let i = i as f64;
|
Ok(TemplateResponse::new_partial(
|
||||||
let theta = 2.0 * std::f64::consts::PI * i / golden_ratio;
|
"knowledge/base.html",
|
||||||
let phi = (1.0 - 2.0 * (i + 0.5) / node_count as f64).acos();
|
"main",
|
||||||
let x = phi.sin() * theta.cos();
|
&kb_data,
|
||||||
let y = phi.sin() * theta.sin();
|
))
|
||||||
let z = phi.cos();
|
} else {
|
||||||
(x, y, z)
|
// Full page (includes navbar etc.)
|
||||||
})
|
Ok(TemplateResponse::new_template(
|
||||||
|
"knowledge/base.html",
|
||||||
|
kb_data,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_plot_html(
|
||||||
|
entities: &[KnowledgeEntity],
|
||||||
|
relationships: &[KnowledgeRelationship],
|
||||||
|
) -> Result<String, HtmlError> {
|
||||||
|
if entities.is_empty() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let id_to_idx: HashMap<_, _> = entities
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, e)| (e.id.clone(), i))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let node_x: Vec<f64> = node_positions.iter().map(|(x, _, _)| *x).collect();
|
// Build adjacency list
|
||||||
let node_y: Vec<f64> = node_positions.iter().map(|(_, y, _)| *y).collect();
|
let mut graph: Vec<Vec<usize>> = vec![Vec::new(); entities.len()];
|
||||||
let node_z: Vec<f64> = node_positions.iter().map(|(_, _, z)| *z).collect();
|
for rel in relationships {
|
||||||
|
if let (Some(&from_idx), Some(&to_idx)) = (id_to_idx.get(&rel.out), id_to_idx.get(&rel.in_))
|
||||||
|
{
|
||||||
|
graph[from_idx].push(to_idx);
|
||||||
|
graph[to_idx].push(from_idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find clusters (connected components)
|
||||||
|
let mut visited = vec![false; entities.len()];
|
||||||
|
let mut clusters: Vec<Vec<usize>> = Vec::new();
|
||||||
|
|
||||||
|
for i in 0..entities.len() {
|
||||||
|
if !visited[i] {
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
let mut cluster = Vec::new();
|
||||||
|
queue.push_back(i);
|
||||||
|
visited[i] = true;
|
||||||
|
while let Some(node) = queue.pop_front() {
|
||||||
|
cluster.push(node);
|
||||||
|
for &nbr in &graph[node] {
|
||||||
|
if !visited[nbr] {
|
||||||
|
visited[nbr] = true;
|
||||||
|
queue.push_back(nbr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clusters.push(cluster);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout params
|
||||||
|
let cluster_spacing = 20.0; // Distance between clusters
|
||||||
|
let node_spacing = 3.0; // Distance between nodes within cluster
|
||||||
|
|
||||||
|
// Arrange clusters on a Fibonacci sphere (uniform 3D positioning on unit sphere)
|
||||||
|
let cluster_count = clusters.len();
|
||||||
|
let golden_angle = std::f64::consts::PI * (3.0 - (5.0f64).sqrt());
|
||||||
|
|
||||||
|
// Will hold final positions of nodes: (x,y,z)
|
||||||
|
let mut nodes_pos = vec![(0.0f64, 0.0f64, 0.0f64); entities.len()];
|
||||||
|
|
||||||
|
for (i, cluster) in clusters.iter().enumerate() {
|
||||||
|
// Position cluster center on unit sphere scaled by cluster_spacing
|
||||||
|
let theta = golden_angle * i as f64;
|
||||||
|
let z = 1.0 - (2.0 * i as f64 + 1.0) / cluster_count as f64;
|
||||||
|
let radius = (1.0 - z * z).sqrt();
|
||||||
|
|
||||||
|
let cluster_center = (
|
||||||
|
radius * theta.cos() * cluster_spacing,
|
||||||
|
radius * theta.sin() * cluster_spacing,
|
||||||
|
z * cluster_spacing,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Layout nodes within cluster as small 3D grid (cube)
|
||||||
|
// Calculate cube root to determine grid side length
|
||||||
|
let cluster_size = cluster.len();
|
||||||
|
let side_len = (cluster_size as f64).cbrt().ceil() as usize;
|
||||||
|
|
||||||
|
for (pos_in_cluster, &node_idx) in cluster.iter().enumerate() {
|
||||||
|
let x_in_cluster = (pos_in_cluster % side_len) as f64;
|
||||||
|
let y_in_cluster = ((pos_in_cluster / side_len) % side_len) as f64;
|
||||||
|
let z_in_cluster = (pos_in_cluster / (side_len * side_len)) as f64;
|
||||||
|
|
||||||
|
nodes_pos[node_idx] = (
|
||||||
|
cluster_center.0 + x_in_cluster * node_spacing,
|
||||||
|
cluster_center.1 + y_in_cluster * node_spacing,
|
||||||
|
cluster_center.2 + z_in_cluster * node_spacing,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (node_x, node_y, node_z): (Vec<_>, Vec<_>, Vec<_>) = nodes_pos.iter().cloned().unzip3();
|
||||||
|
|
||||||
// Nodes trace
|
// Nodes trace
|
||||||
let nodes = Scatter3D::new(node_x.clone(), node_y.clone(), node_z.clone())
|
let nodes_trace = Scatter3D::new(node_x, node_y, node_z)
|
||||||
.mode(Mode::Markers)
|
.mode(Mode::Markers)
|
||||||
.marker(Marker::new().size(8).color("#1f77b4"))
|
.marker(Marker::new().size(8).color("#1f77b4"))
|
||||||
.text_array(
|
.text_array(
|
||||||
@@ -71,31 +207,32 @@ pub async fn show_knowledge_page(
|
|||||||
.map(|e| e.description.clone())
|
.map(|e| e.description.clone())
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
)
|
)
|
||||||
.hover_template("Entity: %{text}<br>");
|
.hover_template("Entity: %{text}<extra></extra>");
|
||||||
|
|
||||||
// Edges traces
|
// Edges traces
|
||||||
for rel in &relationships {
|
let mut plot = Plot::new();
|
||||||
let from_idx = entities.iter().position(|e| e.id == rel.out).unwrap_or(0);
|
for rel in relationships {
|
||||||
let to_idx = entities.iter().position(|e| e.id == rel.in_).unwrap_or(0);
|
if let (Some(&from_idx), Some(&to_idx)) = (id_to_idx.get(&rel.out), id_to_idx.get(&rel.in_))
|
||||||
|
{
|
||||||
|
let edge_x = vec![nodes_pos[from_idx].0, nodes_pos[to_idx].0];
|
||||||
|
let edge_y = vec![nodes_pos[from_idx].1, nodes_pos[to_idx].1];
|
||||||
|
let edge_z = vec![nodes_pos[from_idx].2, nodes_pos[to_idx].2];
|
||||||
|
|
||||||
let edge_x = vec![node_x[from_idx], node_x[to_idx]];
|
let edge_trace = Scatter3D::new(edge_x, edge_y, edge_z)
|
||||||
let edge_y = vec![node_y[from_idx], node_y[to_idx]];
|
.mode(Mode::Lines)
|
||||||
let edge_z = vec![node_z[from_idx], node_z[to_idx]];
|
.line(Line::new().color("#888").width(2.0))
|
||||||
|
.hover_template(format!(
|
||||||
let edge_trace = Scatter3D::new(edge_x, edge_y, edge_z)
|
"Relationship: {}<extra></extra>",
|
||||||
.mode(Mode::Lines)
|
rel.metadata.relationship_type
|
||||||
.line(Line::new().color("#888").width(2.0))
|
))
|
||||||
.hover_template(format!(
|
.show_legend(false);
|
||||||
"Relationship: {}<br>",
|
plot.add_trace(edge_trace);
|
||||||
rel.metadata.relationship_type
|
}
|
||||||
))
|
|
||||||
.show_legend(false);
|
|
||||||
|
|
||||||
plot.add_trace(edge_trace);
|
|
||||||
}
|
}
|
||||||
plot.add_trace(nodes);
|
|
||||||
|
|
||||||
// Layout
|
plot.add_trace(nodes_trace);
|
||||||
|
|
||||||
|
// Layout scene configuration
|
||||||
let layout = Layout::new()
|
let layout = Layout::new()
|
||||||
.scene(
|
.scene(
|
||||||
LayoutScene::new()
|
LayoutScene::new()
|
||||||
@@ -103,29 +240,37 @@ pub async fn show_knowledge_page(
|
|||||||
.y_axis(Axis::new().visible(false))
|
.y_axis(Axis::new().visible(false))
|
||||||
.z_axis(Axis::new().visible(false))
|
.z_axis(Axis::new().visible(false))
|
||||||
.camera(
|
.camera(
|
||||||
Camera::new()
|
plotly::layout::Camera::new()
|
||||||
.projection(ProjectionType::Perspective.into())
|
.projection(plotly::layout::ProjectionType::Perspective.into())
|
||||||
.eye((1.5, 1.5, 1.5).into()),
|
.eye((2.0, 2.0, 2.0).into()),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.show_legend(false)
|
.show_legend(false)
|
||||||
.paper_background_color("rbga(250,100,0,0)")
|
.paper_background_color("rgba(255,255,255,0)")
|
||||||
.plot_background_color("rbga(0,0,0,0)");
|
.plot_background_color("rgba(255,255,255,0)");
|
||||||
|
|
||||||
plot.set_layout(layout);
|
plot.set_layout(layout);
|
||||||
|
|
||||||
// Convert to HTML
|
Ok(plot.to_html())
|
||||||
let html = plot.to_html();
|
}
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
// Small utility to unzip tuple3 vectors from iterators (add this helper)
|
||||||
"knowledge/base.html",
|
trait Unzip3<A, B, C> {
|
||||||
KnowledgeBaseData {
|
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>);
|
||||||
entities,
|
}
|
||||||
relationships,
|
impl<I, A, B, C> Unzip3<A, B, C> for I
|
||||||
user,
|
where
|
||||||
plot_html: html,
|
I: Iterator<Item = (A, B, C)>,
|
||||||
},
|
{
|
||||||
))
|
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>) {
|
||||||
|
let (mut va, mut vb, mut vc) = (Vec::new(), Vec::new(), Vec::new());
|
||||||
|
for (a, b, c) in self {
|
||||||
|
va.push(a);
|
||||||
|
vb.push(b);
|
||||||
|
vc.push(c);
|
||||||
|
}
|
||||||
|
(va, vb, vc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_edit_knowledge_entity_form(
|
pub async fn show_edit_knowledge_entity_form(
|
||||||
@@ -171,6 +316,10 @@ pub struct PatchKnowledgeEntityParams {
|
|||||||
pub struct EntityListData {
|
pub struct EntityListData {
|
||||||
entities: Vec<KnowledgeEntity>,
|
entities: Vec<KnowledgeEntity>,
|
||||||
user: User,
|
user: User,
|
||||||
|
entity_types: Vec<String>,
|
||||||
|
content_categories: Vec<String>,
|
||||||
|
selected_entity_type: Option<String>,
|
||||||
|
selected_content_category: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn patch_knowledge_entity(
|
pub async fn patch_knowledge_entity(
|
||||||
@@ -197,10 +346,23 @@ pub async fn patch_knowledge_entity(
|
|||||||
// Get updated list of entities
|
// Get updated list of entities
|
||||||
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
|
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
// Get entity types
|
||||||
|
let entity_types = User::get_entity_types(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
// Get content categories
|
||||||
|
let content_categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
|
|
||||||
// Render updated list
|
// Render updated list
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"knowledge/entity_list.html",
|
"knowledge/entity_list.html",
|
||||||
EntityListData { entities, user },
|
EntityListData {
|
||||||
|
entities,
|
||||||
|
user,
|
||||||
|
entity_types,
|
||||||
|
content_categories,
|
||||||
|
selected_entity_type: None,
|
||||||
|
selected_content_category: None,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,9 +380,22 @@ pub async fn delete_knowledge_entity(
|
|||||||
// Get updated list of entities
|
// Get updated list of entities
|
||||||
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
|
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
// Get entity types
|
||||||
|
let entity_types = User::get_entity_types(&user.id, &state.db).await?;
|
||||||
|
|
||||||
|
// Get content categories
|
||||||
|
let content_categories = User::get_user_categories(&user.id, &state.db).await?;
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
"knowledge/entity_list.html",
|
"knowledge/entity_list.html",
|
||||||
EntityListData { entities, user },
|
EntityListData {
|
||||||
|
entities,
|
||||||
|
user,
|
||||||
|
entity_types,
|
||||||
|
content_categories,
|
||||||
|
selected_entity_type: None,
|
||||||
|
selected_content_category: None,
|
||||||
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends 'body_base.html' %}
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<main class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
|
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="text-2xl font-bold mb-2">Text Contents</h2>
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
|
||||||
{% include "content/content_list.html" %}
|
<h2 class="text-2xl font-bold">Text Contents</h2>
|
||||||
|
<form hx-get="/content" hx-target="#main_section" hx-swap="outerHTML" hx-push-url="true"
|
||||||
|
class="flex items-center gap-2 mt-2 sm:mt-0">
|
||||||
|
<div class="form-control">
|
||||||
|
<select name="category" class="select select-bordered">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category }}" {% if selected_category==category %}selected{% endif %}>{{ category }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="text_content_cards">
|
||||||
|
{% include "content/content_list.html" %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -2,16 +2,7 @@
|
|||||||
{% for text_content in text_contents %}
|
{% for text_content in text_contents %}
|
||||||
<div class="card min-w-72 bg-base-100 shadow">
|
<div class="card min-w-72 bg-base-100 shadow">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex justify-between space-x-2">
|
||||||
<div class="flex-shrink-0">
|
|
||||||
{% if text_content.url %}
|
|
||||||
{% include "icons/globe_icon.html" %}
|
|
||||||
{% elif text_content.file_info %}
|
|
||||||
{% include "icons/document_icon.html" %}
|
|
||||||
{% else %}
|
|
||||||
{% include "icons/chat_icon.html" %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<h2 class="card-title truncate">
|
<h2 class="card-title truncate">
|
||||||
{% if text_content.url %}
|
{% if text_content.url %}
|
||||||
<a href="{{ text_content.url}}">{{text_content.url}}</a>
|
<a href="{{ text_content.url}}">{{text_content.url}}</a>
|
||||||
@@ -21,11 +12,21 @@
|
|||||||
{{text_content.text}}
|
{{text_content.text}}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if text_content.url %}
|
||||||
|
{% include "icons/globe_icon.html" %}
|
||||||
|
{% elif text_content.file_info %}
|
||||||
|
{% include "icons/document_icon.html" %}
|
||||||
|
{% else %}
|
||||||
|
{% include "icons/chat_icon.html" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-xs opacity-60">
|
<p class="text-xs opacity-60">
|
||||||
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="badge badge-soft badge-secondary mr-2">{{ text_content.category }}</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button hx-get="/content/{{ text_content.id }}" hx-target="#modal" hx-swap="innerHTML"
|
<button hx-get="/content/{{ text_content.id }}" hx-target="#modal" hx-swap="innerHTML"
|
||||||
class="btn btn-square btn-ghost btn-sm">
|
class="btn btn-square btn-ghost btn-sm">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block form_attributes %}
|
{% block form_attributes %}
|
||||||
hx-patch="/content/{{text_content.id}}"
|
hx-patch="/content/{{text_content.id}}"
|
||||||
hx-target="#text_content_cards"
|
hx-target="#main_section"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-semibold opacity-60">
|
<div class="text-xs font-semibold opacity-60">
|
||||||
{{item.created_at|datetimeformat(format="short", tz=user.timezone)}} </div>
|
{{item.created_at|datetimeformat(format="short", tz=user.timezone)}}
|
||||||
|
<span class="badge badge-xs badge-accent ml-1">{{item.category}}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="list-col-wrap text-xs [&:before]:content-['Instructions:_'] [&:before]:opacity-60">
|
<p class="list-col-wrap text-xs [&:before]:content-['Instructions:_'] [&:before]:opacity-60">
|
||||||
{{item.instructions}}
|
{{item.instructions}}
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
{% extends 'body_base.html' %}
|
{% extends 'body_base.html' %}
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<main class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
|
<main id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="text-2xl font-bold mb-2">Entities</h2>
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
|
||||||
{% include "knowledge/entity_list.html" %}
|
<h2 class="text-2xl font-bold">Entities</h2>
|
||||||
|
<form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML"
|
||||||
|
class="flex items-center gap-4 mt-2 sm:mt-0">
|
||||||
|
<div class="form-control">
|
||||||
<h2 class="text-2xl font-bold mb-2 mt-10">Relationships</h2>
|
<select name="entity_type" class="select select-bordered">
|
||||||
{% include "knowledge/relationship_table.html" %}
|
<option value="">All Types</option>
|
||||||
|
{% for type in entity_types %}
|
||||||
<div class="rounded-box overflow-clip mt-10 shadow">
|
<option value="{{ type }}" {% if selected_entity_type==type %}selected{% endif %}>{{ type }}</option>
|
||||||
{{plot_html|safe}}
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<select name="content_category" class="select select-bordered">
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{% for category in content_categories %}
|
||||||
|
<option value="{{ category }}" {% if selected_content_category==category %}selected{% endif %}>{{ category
|
||||||
|
}}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include "knowledge/entity_list.html" %}
|
||||||
|
<h2 class="text-2xl font-bold mb-2 mt-10">Relationships</h2>
|
||||||
|
{% include "knowledge/relationship_table.html" %}
|
||||||
|
<div class="rounded-box overflow-clip mt-10 shadow">
|
||||||
|
{{ plot_html | safe }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
<span class="badge badge-xs badge-primary">{{entity.entity_type}}</span>
|
<span class="badge badge-xs badge-primary">{{entity.entity_type}}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<p>{{entity.updated_at | datetimeformat(format="short", tz=user.timezeone)}}</p>
|
<p class="text-xs opacity-60">{{entity.updated_at | datetimeformat(format="short", tz=user.timezone)}}</p>
|
||||||
<div>
|
<div>
|
||||||
<button hx-get="/knowledge-entity/{{entity.id}}" hx-target="#modal" hx-swap="innerHTML"
|
<button hx-get="/knowledge-entity/{{entity.id}}" hx-target="#modal" hx-swap="innerHTML"
|
||||||
class="btn btn-square btn-ghost btn-sm">
|
class="btn btn-square btn-ghost btn-sm">
|
||||||
{% include "icons/edit_icon.html" %}
|
{% include "icons/edit_icon.html" %}
|
||||||
</button>
|
</button>
|
||||||
<button hx-delete="/knowledge-entity/{{entity.id}}" hx-target="#entity-list" hx-swap="outerHTML"
|
<button hx-delete="/knowledge-entity/{{entity.id}}" hx-target="#entity-list" hx-swap="outerHTML"
|
||||||
|
|||||||
4
todo.md
4
todo.md
@@ -1,6 +1,4 @@
|
|||||||
\[\] archive ingressed webpage
|
\[\] archive ingressed webpage
|
||||||
\[\] filtering on categories
|
|
||||||
\[\] testing core functions
|
|
||||||
\[\] three js graph explorer
|
\[\] three js graph explorer
|
||||||
\[\] three js vector explorer
|
\[\] three js vector explorer
|
||||||
\[x\] add user_id to ingress objects
|
\[x\] add user_id to ingress objects
|
||||||
@@ -9,6 +7,7 @@
|
|||||||
\[x\] chat history
|
\[x\] chat history
|
||||||
\[x\] chat styling overhaul
|
\[x\] chat styling overhaul
|
||||||
\[x\] configs primarily get envs
|
\[x\] configs primarily get envs
|
||||||
|
\[x\] filtering on categories
|
||||||
\[x\] fix patch_text_content
|
\[x\] fix patch_text_content
|
||||||
\[x\] gdpr
|
\[x\] gdpr
|
||||||
\[x\] html ingression
|
\[x\] html ingression
|
||||||
@@ -29,6 +28,7 @@
|
|||||||
\[x\] smoothie_dom test
|
\[x\] smoothie_dom test
|
||||||
\[x\] template customization?
|
\[x\] template customization?
|
||||||
\[x\] templating
|
\[x\] templating
|
||||||
|
\[x\] testing core functions
|
||||||
\[x\] user id to fileinfo and data path?
|
\[x\] user id to fileinfo and data path?
|
||||||
\[x\] view content
|
\[x\] view content
|
||||||
\[x\] view graph map
|
\[x\] view graph map
|
||||||
|
|||||||
Reference in New Issue
Block a user