feat: manual entity creation

chore: clippy
This commit is contained in:
Per Stark
2025-10-15 21:00:17 +02:00
parent 2964f1a5a5
commit 35ff4e1464
14 changed files with 653 additions and 43 deletions

1
Cargo.lock generated
View File

@@ -2536,6 +2536,7 @@ dependencies = [
"tower-serve-static",
"tracing",
"url",
"uuid",
]
[[package]]

View File

@@ -28,7 +28,7 @@ You may switch and choose between models used, and have the possiblity to change
The application is built for speed and efficiency using Rust with a Server-Side Rendered (SSR) frontend (HTMX and minimal JavaScript). It's fully responsive, offering a complete mobile interface for reading, editing, and managing your content, including the graph database itself. **PWA (Progressive Web App) support** means you can "install" Minne to your device for a native-like experience. For quick capture on the go on iOS, a [**Shortcut**](https://www.icloud.com/shortcuts/e433fbd7602f4e2eaa70dca162323477) makes sending content to your Minne instance a breeze.
A hybrid retrieval layer blends embeddings, full-text search, and graph signals to surface the best context when augmenting chat responses and when building new relationships during ingestion.
A hybrid retrieval layer blends embeddings, full-text search, and graph signals to surface the best context when augmenting chat responses and when analyzing new content during ingestion.
Minne is open source (AGPL), self-hostable, and can be deployed flexibly: via Nix, Docker Compose, pre-built binaries, or by building from source. It can run as a single `main` binary or as separate `server` and `worker` processes for optimized resource allocation.

View File

@@ -305,9 +305,7 @@ async fn enrich_entities_from_graph(
}
let existing_graph = entry.scores.graph.unwrap_or(f32::MIN);
if graph_score > existing_graph {
entry.scores.graph = Some(graph_score);
} else if entry.scores.graph.is_none() {
if graph_score > existing_graph || entry.scores.graph.is_none() {
entry.scores.graph = Some(graph_score);
}

View File

@@ -68,7 +68,7 @@ impl Default for FusionWeights {
}
pub fn clamp_unit(value: f32) -> f32 {
value.max(0.0).min(1.0)
value.clamp(0.0, 1.0)
}
pub fn distance_to_similarity(distance: f32) -> f32 {

View File

@@ -32,6 +32,7 @@ tower-serve-static = { workspace = true }
tokio-util = { workspace = true }
chrono = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
common = { path = "../common" }
composite-retrieval = { path = "../composite-retrieval" }

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,6 @@
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::fmt;
use axum::{
extract::{Path, Query, State},
@@ -6,15 +8,25 @@ use axum::{
Form, Json,
};
use axum_htmx::{HxBoosted, HxRequest};
use serde::{Deserialize, Serialize};
use common::storage::types::{
conversation::Conversation,
knowledge_entity::{KnowledgeEntity, KnowledgeEntityType},
knowledge_relationship::KnowledgeRelationship,
user::User,
use serde::{
de::{self, Deserializer, MapAccess, Visitor},
Deserialize, Serialize,
};
use common::{
error::AppError,
storage::types::{
conversation::Conversation,
knowledge_entity::{KnowledgeEntity, KnowledgeEntityType},
knowledge_relationship::KnowledgeRelationship,
user::User,
},
utils::embedding::generate_embedding,
};
use composite_retrieval::{retrieve_entities, RetrievedEntity};
use tracing::debug;
use uuid::Uuid;
use crate::{
html_state::HtmlState,
middlewares::{
@@ -26,6 +38,9 @@ use crate::{
use url::form_urlencoded;
const KNOWLEDGE_ENTITIES_PER_PAGE: usize = 12;
const DEFAULT_RELATIONSHIP_TYPE: &str = "relates_to";
const MAX_RELATIONSHIP_SUGGESTIONS: usize = 10;
const SUGGESTION_MIN_SCORE: f32 = 0.5;
#[derive(Deserialize, Default)]
pub struct FilterParams {
@@ -34,6 +49,195 @@ pub struct FilterParams {
page: Option<usize>,
}
pub async fn show_new_knowledge_entity_form(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
) -> Result<impl IntoResponse, HtmlError> {
let entity_types: Vec<String> = KnowledgeEntityType::variants()
.iter()
.map(|s| s.to_string())
.collect();
let existing_entities = User::get_knowledge_entities(&user.id, &state.db).await?;
let empty_selected: HashSet<String> = HashSet::new();
let empty_scores: HashMap<String, f32> = HashMap::new();
let relationship_options =
build_relationship_options(existing_entities, &empty_selected, &empty_scores);
Ok(TemplateResponse::new_template(
"knowledge/new_knowledge_entity_modal.html",
NewEntityModalData {
entity_types,
relationship_list: RelationshipListData {
relationship_options,
relationship_type: DEFAULT_RELATIONSHIP_TYPE.to_string(),
suggestion_count: 0,
},
},
))
}
pub async fn create_knowledge_entity(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(form): Form<CreateKnowledgeEntityParams>,
) -> Result<impl IntoResponse, HtmlError> {
let name = form.name.trim().to_string();
if name.is_empty() {
return Err(AppError::Validation("Name is required".into()).into());
}
let description = form.description.trim().to_string();
let entity_type = KnowledgeEntityType::from(form.entity_type.trim().to_string());
let embedding_input = format!(
"name: {}, description: {}, type: {:?}",
name, description, entity_type
);
let embedding = generate_embedding(&state.openai_client, &embedding_input, &state.db).await?;
let source_id = format!("manual::{}", Uuid::new_v4());
let new_entity = KnowledgeEntity::new(
source_id,
name.clone(),
description.clone(),
entity_type,
None,
embedding,
user.id.clone(),
);
state.db.store_item(new_entity.clone()).await?;
let relationship_type = form
.relationship_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_RELATIONSHIP_TYPE)
.to_string();
debug!("form: {:?}", form);
if !form.relationship_ids.is_empty() {
let existing_entities = User::get_knowledge_entities(&user.id, &state.db).await?;
let valid_ids: HashSet<String> = existing_entities
.into_iter()
.map(|entity| entity.id)
.collect();
let mut unique_ids: HashSet<String> = HashSet::new();
for target_id in form.relationship_ids.into_iter() {
if target_id == new_entity.id {
continue;
}
if !valid_ids.contains(&target_id) {
continue;
}
if !unique_ids.insert(target_id.clone()) {
continue;
}
let relationship = KnowledgeRelationship::new(
new_entity.id.clone(),
target_id,
user.id.clone(),
format!("manual::{}", new_entity.id),
relationship_type.clone(),
);
relationship.store_relationship(&state.db).await?;
}
}
let default_params = FilterParams::default();
let kb_data = build_knowledge_base_data(&state, &user, &default_params).await?;
Ok(TemplateResponse::new_partial(
"knowledge/base.html",
"main",
kb_data,
))
}
pub async fn suggest_knowledge_relationships(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Form(form): Form<SuggestRelationshipsParams>,
) -> Result<impl IntoResponse, HtmlError> {
let entity_lookup: HashMap<String, KnowledgeEntity> =
User::get_knowledge_entities(&user.id, &state.db)
.await?
.into_iter()
.map(|entity| (entity.id.clone(), entity))
.collect();
let mut selected_ids: HashSet<String> = form
.relationship_ids
.into_iter()
.filter(|id| entity_lookup.contains_key(id))
.collect();
let mut suggestion_scores: HashMap<String, f32> = HashMap::new();
let mut query_parts = Vec::new();
if let Some(name) = form
.name
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
query_parts.push(name.to_string());
}
if let Some(description) = form
.description
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
{
query_parts.push(description.to_string());
}
if !query_parts.is_empty() {
let query = query_parts.join(" ");
if let Ok(results) =
retrieve_entities(&state.db, &state.openai_client, &query, &user.id).await
{
for RetrievedEntity { entity, score, .. } in results {
if suggestion_scores.len() >= MAX_RELATIONSHIP_SUGGESTIONS {
break;
}
if score.is_nan() || score < SUGGESTION_MIN_SCORE {
continue;
}
if !entity_lookup.contains_key(&entity.id) {
continue;
}
suggestion_scores.insert(entity.id.clone(), score);
selected_ids.insert(entity.id.clone());
}
}
}
let relationship_type = form
.relationship_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_RELATIONSHIP_TYPE)
.to_string();
let entities: Vec<KnowledgeEntity> = entity_lookup.into_values().collect();
let relationship_options =
build_relationship_options(entities, &selected_ids, &suggestion_scores);
Ok(TemplateResponse::new_template(
"knowledge/relationship_selector.html",
RelationshipListData {
relationship_options,
relationship_type,
suggestion_count: suggestion_scores.len(),
},
))
}
#[derive(Serialize)]
pub struct KnowledgeBaseData {
entities: Vec<KnowledgeEntity>,
@@ -49,22 +253,61 @@ pub struct KnowledgeBaseData {
page_query: String,
}
pub async fn show_knowledge_page(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
HxRequest(is_htmx): HxRequest,
HxBoosted(is_boosted): HxBoosted,
Query(mut params): Query<FilterParams>,
) -> Result<impl IntoResponse, HtmlError> {
// Normalize filters: treat empty or "none" as no filter
params.entity_type = normalize_filter(params.entity_type.take());
params.content_category = normalize_filter(params.content_category.take());
#[derive(Serialize)]
pub struct RelationshipOption {
entity: KnowledgeEntity,
is_selected: bool,
is_suggested: bool,
score: Option<f32>,
}
// Load relevant data
fn build_relationship_options(
entities: Vec<KnowledgeEntity>,
selected_ids: &HashSet<String>,
suggestion_scores: &HashMap<String, f32>,
) -> Vec<RelationshipOption> {
let mut options: Vec<RelationshipOption> = entities
.into_iter()
.map(|entity| {
let id = entity.id.clone();
let score = suggestion_scores.get(&id).copied();
RelationshipOption {
entity,
is_selected: selected_ids.contains(&id),
is_suggested: score.is_some(),
score,
}
})
.collect();
options.sort_by(|a, b| match (a.is_suggested, b.is_suggested) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => match (a.score, b.score) {
(Some(a_score), Some(b_score)) => {
b_score.partial_cmp(&a_score).unwrap_or(Ordering::Equal)
}
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
_ => a
.entity
.name
.to_lowercase()
.cmp(&b.entity.name.to_lowercase()),
},
});
options
}
async fn build_knowledge_base_data(
state: &HtmlState,
user: &User,
params: &FilterParams,
) -> Result<KnowledgeBaseData, AppError> {
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 &params.content_category {
Some(cat) => {
User::get_knowledge_entities_by_content_category(&user.id, cat, &state.db).await?
@@ -102,11 +345,11 @@ pub async fn show_knowledge_page(
.collect();
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
let kb_data = KnowledgeBaseData {
Ok(KnowledgeBaseData {
entities,
visible_entities,
relationships,
user,
user: user.clone(),
entity_types,
content_categories,
selected_entity_type: params.entity_type.clone(),
@@ -114,7 +357,244 @@ pub async fn show_knowledge_page(
conversation_archive,
pagination,
page_query,
};
})
}
#[derive(Serialize)]
pub struct RelationshipListData {
relationship_options: Vec<RelationshipOption>,
relationship_type: String,
suggestion_count: usize,
}
#[derive(Serialize)]
pub struct NewEntityModalData {
entity_types: Vec<String>,
relationship_list: RelationshipListData,
}
#[derive(Debug)]
pub struct CreateKnowledgeEntityParams {
pub name: String,
pub entity_type: String,
pub description: String,
pub relationship_type: Option<String>,
pub relationship_ids: Vec<String>,
}
impl<'de> Deserialize<'de> for CreateKnowledgeEntityParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(field_identifier, rename_all = "snake_case")]
enum Field {
Name,
EntityType,
Description,
RelationshipType,
#[serde(alias = "relationship_ids[]")]
RelationshipIds,
}
struct ParamsVisitor;
impl<'de> Visitor<'de> for ParamsVisitor {
type Value = CreateKnowledgeEntityParams;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("struct CreateKnowledgeEntityParams")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut name: Option<String> = None;
let mut entity_type: Option<String> = None;
let mut description: Option<String> = None;
let mut relationship_type: Option<String> = None;
let mut relationship_ids: Vec<String> = Vec::new();
while let Some(key) = map.next_key::<Field>()? {
match key {
Field::Name => {
if name.is_some() {
return Err(de::Error::duplicate_field("name"));
}
name = Some(map.next_value()?);
}
Field::EntityType => {
if entity_type.is_some() {
return Err(de::Error::duplicate_field("entity_type"));
}
entity_type = Some(map.next_value()?);
}
Field::Description => {
description = Some(map.next_value()?);
}
Field::RelationshipType => {
relationship_type = Some(map.next_value()?);
}
Field::RelationshipIds => {
let value: String = map.next_value()?;
let trimmed = value.trim();
if !trimmed.is_empty() {
relationship_ids.push(trimmed.to_owned());
}
}
}
}
let name = name.ok_or_else(|| de::Error::missing_field("name"))?;
let entity_type =
entity_type.ok_or_else(|| de::Error::missing_field("entity_type"))?;
let description = description.unwrap_or_default();
let relationship_type = relationship_type
.map(|value: String| value.trim().to_owned())
.filter(|value| !value.is_empty());
Ok(CreateKnowledgeEntityParams {
name,
entity_type,
description,
relationship_type,
relationship_ids,
})
}
}
const FIELDS: &[&str] = &[
"name",
"entity_type",
"description",
"relationship_type",
"relationship_ids",
];
deserializer.deserialize_struct("CreateKnowledgeEntityParams", FIELDS, ParamsVisitor)
}
}
#[derive(Debug)]
pub struct SuggestRelationshipsParams {
pub name: Option<String>,
pub description: Option<String>,
pub relationship_type: Option<String>,
pub relationship_ids: Vec<String>,
}
impl<'de> Deserialize<'de> for SuggestRelationshipsParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(field_identifier, rename_all = "snake_case")]
enum Field {
Name,
Description,
RelationshipType,
EntityType,
#[serde(alias = "relationship_ids[]")]
RelationshipIds,
}
struct ParamsVisitor;
impl<'de> Visitor<'de> for ParamsVisitor {
type Value = SuggestRelationshipsParams;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("struct SuggestRelationshipsParams")
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut name: Option<String> = None;
let mut description: Option<String> = None;
let mut relationship_type: Option<String> = None;
let mut relationship_ids: Vec<String> = Vec::new();
while let Some(key) = map.next_key::<Field>()? {
match key {
Field::Name => {
if name.is_some() {
return Err(de::Error::duplicate_field("name"));
}
let value: String = map.next_value()?;
let trimmed = value.trim();
if !trimmed.is_empty() {
name = Some(trimmed.to_owned());
}
}
Field::Description => {
let value: String = map.next_value()?;
let trimmed = value.trim();
if trimmed.is_empty() {
description = None;
} else {
description = Some(trimmed.to_owned());
}
}
Field::RelationshipType => {
let value: String = map.next_value()?;
let trimmed = value.trim();
if trimmed.is_empty() {
relationship_type = None;
} else {
relationship_type = Some(trimmed.to_owned());
}
}
Field::EntityType => {
map.next_value::<de::IgnoredAny>()?;
}
Field::RelationshipIds => {
let value: String = map.next_value()?;
let trimmed = value.trim();
if !trimmed.is_empty() {
relationship_ids.push(trimmed.to_owned());
}
}
}
}
Ok(SuggestRelationshipsParams {
name,
description,
relationship_type,
relationship_ids,
})
}
}
const FIELDS: &[&str] = &[
"name",
"description",
"relationship_type",
"entity_type",
"relationship_ids",
];
deserializer.deserialize_struct("SuggestRelationshipsParams", FIELDS, ParamsVisitor)
}
}
pub async fn show_knowledge_page(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
HxRequest(is_htmx): HxRequest,
HxBoosted(is_boosted): HxBoosted,
Query(mut params): Query<FilterParams>,
) -> Result<impl IntoResponse, HtmlError> {
// Normalize filters: treat empty or "none" as no filter
params.entity_type = normalize_filter(params.entity_type.take());
params.content_category = normalize_filter(params.content_category.take());
let kb_data = build_knowledge_base_data(&state, &user, &params).await?;
// Determine response type:
// If it is an HTMX request but NOT a boosted navigation, send partial update (main block only)

View File

@@ -6,9 +6,10 @@ use axum::{
Router,
};
use handlers::{
delete_knowledge_entity, delete_knowledge_relationship, get_knowledge_graph_json,
patch_knowledge_entity, save_knowledge_relationship, show_edit_knowledge_entity_form,
show_knowledge_page,
create_knowledge_entity, delete_knowledge_entity, delete_knowledge_relationship,
get_knowledge_graph_json, patch_knowledge_entity, save_knowledge_relationship,
show_edit_knowledge_entity_form, show_knowledge_page, show_new_knowledge_entity_form,
suggest_knowledge_relationships,
};
use crate::html_state::HtmlState;
@@ -21,12 +22,18 @@ where
Router::new()
.route("/knowledge", get(show_knowledge_page))
.route("/knowledge/graph.json", get(get_knowledge_graph_json))
.route("/knowledge-entity/new", get(show_new_knowledge_entity_form))
.route("/knowledge-entity", post(create_knowledge_entity))
.route(
"/knowledge-entity/{id}",
get(show_edit_knowledge_entity_form)
.delete(delete_knowledge_entity)
.patch(patch_knowledge_entity),
)
.route(
"/knowledge-entity/suggestions",
post(suggest_knowledge_relationships),
)
.route("/knowledge-relationship", post(save_knowledge_relationship))
.route(
"/knowledge-relationship/{id}",

View File

@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}Minne{% endblock %}</title>
<!-- Preload critical assets -->
<link rel="preload" href="/assets/htmx.min.js" as="script">
<link rel="preload" href="/assets/style.css" as="style">
@@ -77,4 +78,4 @@
window.renderAllMarkdown = renderAllMarkdown;
</script>
</html>
</html>

View File

@@ -5,8 +5,16 @@
{% block main %}
<div id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6">
<div class="container">
<div class="nb-panel p-3 mb-4 flex flex-col sm:flex-row justify-between items-start sm:items-center">
<h2 class="text-xl font-extrabold tracking-tight">Knowledge Entities</h2>
<div class="nb-panel p-3 mb-4 space-y-3 sm:space-y-0 sm:flex sm:flex-row sm:justify-between sm:items-center">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<h2 class="text-xl font-extrabold tracking-tight">Knowledge Entities</h2>
<button type="button" class="nb-btn nb-cta btn-sm"
hx-get="/knowledge-entity/new"
hx-target="#modal"
hx-swap="innerHTML">
New Entity
</button>
</div>
<form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML"
class="flex items-center gap-2 mt-2 sm:mt-0">
<input type="hidden" name="page" value="1" />
@@ -43,4 +51,4 @@
{% include "knowledge/relationship_table.html" %}
</div>
</div>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,74 @@
{% extends "modal_base.html" %}
{% block modal_class %}max-w-4xl w-full{% endblock %}
{% block form_attributes %}
hx-post="/knowledge-entity"
hx-target="#knowledge_pane"
hx-swap="outerHTML"
{% endblock %}
{% block modal_content %}
<h3 class="text-xl font-extrabold tracking-tight">Create Knowledge Entity</h3>
<div class="flex flex-col gap-3">
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Name</div>
<input type="text" name="name" class="nb-input w-full" placeholder="Entity title" required>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Type</div>
<select name="entity_type" class="nb-select w-full">
{% for et in entity_types %}
<option value="{{ et }}">{{ et }}</option>
{% endfor %}
</select>
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Description</div>
<textarea name="description" class="nb-input w-full h-32"
placeholder="Describe this entity so it can be found later"></textarea>
</label>
</div>
<div class="u-hairline pt-3 mt-4 space-y-3">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<div class="text-xs uppercase tracking-wide opacity-70">Relationships</div>
<p class="text-xs opacity-70 max-w-md">
Select existing entities to link. Suggestions will pre-select likely matches.
</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<label class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide opacity-70">Type</span>
<input type="text" name="relationship_type" value="{{ relationship_list.relationship_type }}"
class="nb-input w-28" placeholder="relates_to">
</label>
<button type="button" class="nb-btn btn-sm nb-cta sm:ml-2" hx-post="/knowledge-entity/suggestions"
hx-target="#relationship-list" hx-swap="outerHTML" hx-include="#modal_form">
Suggest Relationships
</button>
</div>
</div>
{% if relationship_list.relationship_options|length == 0 %}
<div id="relationship-list" class="nb-card p-4 text-sm opacity-70">
You need at least one existing entity before creating relationships.
</div>
{% else %}
{% set relationship_options = relationship_list.relationship_options %}
{% set relationship_type = relationship_list.relationship_type %}
{% set suggestion_count = relationship_list.suggestion_count %}
{% include "knowledge/relationship_selector.html" %}
{% endif %}
</div>
{% endblock %}
{% block primary_actions %}
<button type="submit" class="nb-btn nb-cta">
Create Entity
</button>
{% endblock %}

View File

@@ -0,0 +1,39 @@
<div id="relationship-list" class="space-y-3">
{% if suggestion_count > 0 %}
<div class="text-xs opacity-70">
Applied {{ suggestion_count }} suggestion{% if suggestion_count != 1 %}s{% endif %}. Toggle any you don't need.
</div>
{% endif %}
{% if relationship_options|length == 0 %}
<div class="nb-card p-4 text-sm opacity-70">
No entities available to relate yet.
</div>
{% else %}
<div class="nb-card max-h-56 overflow-y-auto divide-y">
{% for option in relationship_options %}
<label class="flex items-start gap-3 p-3 hover:bg-base-200 transition-colors cursor-pointer">
<input type="checkbox" name="relationship_ids" value="{{ option.entity.id }}" class="nb-checkbox mt-1" {% if
option.is_selected %}checked{% endif %}>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium truncate">{{ option.entity.name }}</span>
<span class="badge badge-xs badge-outline">{{ option.entity.entity_type }}</span>
{% if option.is_suggested %}
<span class="badge badge-xs badge-primary uppercase tracking-wide">Suggested</span>
{% endif %}
</div>
{% if option.entity.description %}
<p class="text-xs opacity-70 mt-1 truncate">{{ option.entity.description }}</p>
{% endif %}
{% if option.is_suggested and option.score is not none %}
<div class="text-[0.65rem] opacity-60 mt-1">
Match score {{ option.score | round(2) }}
</div>
{% endif %}
</div>
</label>
{% endfor %}
</div>
{% endif %}
</div>

View File

@@ -1,5 +1,6 @@
<dialog id="body_modal" class="modal">
<div class="modal-box rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] {% block modal_class %}{% endblock %}">
<div
class="modal-box rounded-none border-2 border-neutral bg-base-100 shadow-[8px_8px_0_0_#000] {% block modal_class %}{% endblock %}">
<form id="modal_form" {% block form_attributes %}{% endblock %}>
<div class="flex flex-col flex-1 gap-4">
{% block modal_content %}{% endblock %}
@@ -21,11 +22,11 @@
document.getElementById('body_modal').showModal();
// Close modal on successful form submission
document.getElementById('modal_form').addEventListener('htmx:afterRequest', (evt) => {
if (evt.detail.successful) {
document.getElementById('body_modal').close();
}
});
document.getElementById('modal_form')
.addEventListener('htmx:afterRequest', (evt) => {
if (evt.detail.elt !== evt.currentTarget) return; // ignore inner htmx requests
if (evt.detail.successful) document.getElementById('body_modal').close();
});
// Clear modal content on close to prevent browser back from reopening it
document.getElementById('body_modal').addEventListener('close', (evt) => {
@@ -35,4 +36,4 @@
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</dialog>

View File

@@ -111,7 +111,7 @@ impl IngestionPipeline {
const BASE_SECONDS: u64 = 30;
const MAX_SECONDS: u64 = 15 * 60;
let capped_attempt = attempt.saturating_sub(1).min(5) as u32;
let capped_attempt = attempt.saturating_sub(1).min(5);
let multiplier = 2_u64.pow(capped_attempt);
let delay = BASE_SECONDS * multiplier;