Implement semantic embeddings and duplicate review

This commit is contained in:
2026-07-19 18:06:47 +02:00
parent fd4fd744e0
commit e8764262a3
42 changed files with 3808 additions and 79 deletions

View File

@@ -0,0 +1,182 @@
use std::collections::HashSet;
use bds_core::engine::embedding::DuplicateSearchResult;
use bds_core::i18n::UiLocale;
use iced::widget::text::Shaping;
use iced::widget::{Space, button, checkbox, column, container, row, scrollable, text};
use iced::{Color, Element, Length};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::{t, tw};
#[derive(Debug, Clone, Default)]
pub struct DuplicatesState {
pub enabled: bool,
pub is_loading: bool,
pub has_run: bool,
pub page: usize,
pub result: DuplicateSearchResult,
pub selected: HashSet<(String, String)>,
pub error: Option<String>,
}
pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> {
let refresh = if state.is_loading {
button(text(t(locale, "duplicates.searching")).size(13)).style(inputs::secondary_button)
} else {
button(text(t(locale, "common.refresh")).size(13))
.on_press(Message::DuplicatesRefresh)
.style(inputs::secondary_button)
}
.padding([6, 16]);
let dismiss_checked = if state.selected.is_empty() || state.is_loading {
button(text(t(locale, "duplicates.dismissChecked")).size(13))
.style(inputs::secondary_button)
} else {
button(
text(tw(
locale,
"duplicates.dismissCheckedCount",
&[("count", &state.selected.len().to_string())],
))
.size(13),
)
.on_press(Message::DuplicatesDismissSelected)
.style(inputs::primary_button)
}
.padding([6, 16]);
let toolbar = inputs::toolbar(
vec![
text(t(locale, "duplicates.title"))
.size(20)
.shaping(Shaping::Advanced)
.into(),
text(tw(
locale,
"duplicates.count",
&[("count", &state.result.pairs.len().to_string())],
))
.size(12)
.color(inputs::LABEL_COLOR)
.into(),
],
vec![
button(text(t(locale, "duplicates.checkAll")).size(13))
.on_press(Message::DuplicatesCheckAll)
.padding([6, 12])
.style(inputs::secondary_button)
.into(),
button(text(t(locale, "duplicates.uncheckAll")).size(13))
.on_press(Message::DuplicatesUncheckAll)
.padding([6, 12])
.style(inputs::secondary_button)
.into(),
dismiss_checked.into(),
refresh.into(),
],
);
let body: Element<'_, Message> = if !state.enabled {
inputs::card(
text(t(locale, "duplicates.disabled"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else if state.is_loading && !state.has_run {
inputs::card(
text(t(locale, "duplicates.searching"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else if let Some(error) = &state.error {
inputs::card(
text(error.clone())
.size(14)
.color(Color::from_rgb(0.90, 0.38, 0.38)),
)
.into()
} else if state.has_run && state.result.pairs.is_empty() {
inputs::card(
text(t(locale, "duplicates.empty"))
.size(14)
.color(inputs::LABEL_COLOR),
)
.into()
} else {
let mut pairs = column!().spacing(8);
for pair in &state.result.pairs {
let key = (pair.post_id_a.clone(), pair.post_id_b.clone());
let checked = state.selected.contains(&key);
let badge = if pair.exact_match {
t(locale, "duplicates.exactMatch")
} else {
format!("{:.1}%", pair.similarity * 100.0)
};
pairs = pairs.push(inputs::card(
row![
checkbox("", checked)
.on_toggle({
let a = pair.post_id_a.clone();
let b = pair.post_id_b.clone();
move |_| Message::DuplicatesToggle(a.clone(), b.clone())
})
.size(16),
button(
text(pair.title_a.clone())
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DuplicatesOpenPost(pair.post_id_a.clone()))
.padding([5, 8])
.style(inputs::disclosure_button),
text("").size(14).color(inputs::LABEL_COLOR),
button(
text(pair.title_b.clone())
.size(13)
.shaping(Shaping::Advanced)
)
.on_press(Message::DuplicatesOpenPost(pair.post_id_b.clone()))
.padding([5, 8])
.style(inputs::disclosure_button),
Space::with_width(Length::Fill),
text(badge).size(12).color(if pair.exact_match {
Color::from_rgb(0.96, 0.68, 0.28)
} else {
Color::from_rgb(0.55, 0.76, 0.92)
}),
button(text(t(locale, "duplicates.dismiss")).size(12))
.on_press(Message::DuplicatesDismiss(
pair.post_id_a.clone(),
pair.post_id_b.clone()
))
.padding([5, 12])
.style(inputs::secondary_button),
]
.spacing(8)
.align_y(iced::Alignment::Center),
));
}
if state.result.has_more {
pairs = pairs.push(
button(text(t(locale, "duplicates.showMore")).size(13))
.on_press(Message::DuplicatesShowMore)
.padding([7, 16])
.style(inputs::secondary_button),
);
}
scrollable(container(pairs).padding(2))
.height(Length::Fill)
.into()
};
container(column![toolbar, body].spacing(12))
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into()
}

View File

@@ -2,6 +2,7 @@ pub mod activity_bar;
pub mod chat_surfaces;
pub mod chat_view;
pub mod dashboard;
pub mod duplicates;
pub mod git;
pub mod import_editor;
pub mod media_editor;

View File

@@ -76,6 +76,7 @@ pub struct PostEditorState {
pub quick_actions_open: bool,
pub tags_input: String,
pub categories_input: String,
pub semantic_tag_suggestions: Vec<String>,
pub active_language: String,
pub canonical_language: String,
pub blog_languages: Vec<String>,
@@ -123,6 +124,7 @@ impl Clone for PostEditorState {
quick_actions_open: self.quick_actions_open,
tags_input: self.tags_input.clone(),
categories_input: self.categories_input.clone(),
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
active_language: self.active_language.clone(),
canonical_language: self.canonical_language.clone(),
blog_languages: self.blog_languages.clone(),
@@ -188,6 +190,7 @@ impl PostEditorState {
quick_actions_open: false,
tags_input: String::new(),
categories_input: String::new(),
semantic_tag_suggestions: Vec::new(),
active_language: canonical_lang.clone(),
canonical_language: canonical_lang,
blog_languages: blog_languages.to_vec(),
@@ -342,6 +345,7 @@ pub enum PostEditorMsg {
SwitchLanguage(String),
TagsInputChanged(String),
TagsInputSubmit,
AddSuggestedTag(String),
RemoveTag(String),
CategoriesInputChanged(String),
CategoriesInputSubmit,
@@ -619,6 +623,28 @@ pub fn view<'a>(
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)),
);
let semantic_tags: Element<'a, Message> = if state.semantic_tag_suggestions.is_empty() {
Space::new(0, 0).into()
} else {
let mut chips = row![
text(t(locale, "editor.semanticTagSuggestions"))
.size(11)
.color(inputs::LABEL_COLOR)
]
.spacing(6)
.align_y(iced::Alignment::Center);
for tag in &state.semantic_tag_suggestions {
chips = chips.push(
button(text(format!("+ {tag}")).size(11))
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
tag.clone(),
)))
.padding([4, 8])
.style(inputs::secondary_button),
);
}
chips.into()
};
// Categories chip input
let categories_section = chip_input_field(
@@ -776,6 +802,7 @@ pub fn view<'a>(
meta_row1,
meta_row2,
tags_section,
semantic_tags,
categories_section,
outlinks_section,
backlinks_section,

View File

@@ -18,6 +18,7 @@ use crate::views::{
activity_bar,
chat_view::{self, ChatEditorState},
dashboard::DashboardState,
duplicates::{self, DuplicatesState},
git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState},
metadata_diff::{self, MetadataDiffState},
@@ -128,6 +129,7 @@ pub fn view<'a>(
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_state: &'a GitUiState,
@@ -158,6 +160,7 @@ pub fn view<'a>(
settings_state,
dashboard_state,
site_validation_state,
duplicates_state,
metadata_diff_state,
translation_validation_state,
git_diffs,
@@ -400,6 +403,7 @@ fn route_content_area<'a>(
settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>,
site_validation_state: &'a SiteValidationState,
duplicates_state: &'a DuplicatesState,
metadata_diff_state: &'a MetadataDiffState,
translation_validation_state: &'a TranslationValidationState,
git_diffs: &'a HashMap<String, GitDiffState>,
@@ -489,6 +493,7 @@ fn route_content_area<'a>(
}
}
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale),
ContentRoute::TranslationValidation => {
translation_validation::view(translation_validation_state, locale)
@@ -524,6 +529,7 @@ enum ContentRoute<'a> {
Tags,
Settings,
SiteValidation,
FindDuplicates,
MetadataDiff,
TranslationValidation,
GitDiff(&'a str),
@@ -612,11 +618,11 @@ fn route_kind<'a>(
TabType::SiteValidation => ContentRoute::SiteValidation,
TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::FindDuplicates => ContentRoute::FindDuplicates,
TabType::Style
| TabType::MenuEditor
| TabType::Documentation
| TabType::ApiDocumentation
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
| TabType::ApiDocumentation => ContentRoute::Placeholder(&tab.title),
TabType::TranslationValidation => ContentRoute::TranslationValidation,
}
}
@@ -678,7 +684,6 @@ mod tests {
TabType::MenuEditor,
TabType::Documentation,
TabType::ApiDocumentation,
TabType::FindDuplicates,
];
for tab_type in unsupported {
@@ -703,6 +708,35 @@ mod tests {
}
}
#[test]
fn duplicate_tab_routes_to_real_review_surface() {
let tabs = vec![tab(
"find_duplicates",
TabType::FindDuplicates,
"Duplicates",
)];
let empty_posts = HashMap::new();
let empty_media = HashMap::new();
let empty_templates = HashMap::new();
let empty_scripts = HashMap::new();
let empty_imports = HashMap::new();
let site_validation = SiteValidationState::default();
let route = route_kind(
&tabs,
Some("find_duplicates"),
&empty_posts,
&empty_media,
&empty_templates,
&empty_scripts,
&empty_imports,
None,
None,
None,
&site_validation,
);
assert!(matches!(route, ContentRoute::FindDuplicates));
}
#[test]
fn git_diff_tab_routes_to_real_view() {
let empty_posts = HashMap::new();