From 42958c2a7f7f0743c28c4205ef6f975f5d127ef0 Mon Sep 17 00:00:00 2001 From: lvbaiqing Date: Mon, 9 Nov 2026 00:19:58 +0800 Subject: [PATCH] fix(core): exclude factual and sensitive vocabulary edits --- docs/awareness-follow-up.md | 2 +- .../app/crates/openless-core/src/api.rs | 24 ++++ .../src/correction_observation.rs | 122 ++++++++++++++++++ .../src/expression_observation.rs | 5 + .../app/crates/openless-core/src/lib.rs | 1 + 5 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 openless-all/app/crates/openless-core/src/correction_observation.rs diff --git a/docs/awareness-follow-up.md b/docs/awareness-follow-up.md index 3a9dec2bf..0abd9c671 100644 --- a/docs/awareness-follow-up.md +++ b/docs/awareness-follow-up.md @@ -13,5 +13,5 @@ The reviewed implementation separates app/control context, cursor context, corre - Define independently controlled, opt-in learning and capture boundaries; keep secure fields, sensitive apps, uncertain target changes and blacklist entries excluded. Describe when contextual text may be sent to the configured model. - The initial implementation accepts only uniquely anchored formatting edits, rejects sensitive or content-changing observations, and stores bounded text-free global evidence. Opt-out stops observation and new-session application; users can clear the profile. Mixed edits and richer classification remain unsupported. - Extend the profile to app/category scopes with explicit precedence and decay, plus a user-maintained exclusion list. Validate reset and opt-out races across all hosts, and ensure form directives cannot override facts, explicit corrections or user instructions. -- Separate correction suggestion detection from style learning; retain explicit user confirmation for learned vocabulary. Cover conflicting candidates and repeated sessions without treating one edit as a stable habit. +- Correction suggestions now reject changes to numeric or negation signatures and sensitive contexts, while retaining explicit user confirmation. Add a richer lexical affinity model, conflicting-candidate handling and repeated-session evidence without treating one edit as a stable habit. - Integrate the controls into the single Awareness page and exercise opt-out, clearing, app exclusion, restart and prompt behavior across supported hosts. Add deterministic classification, persistence, safety, stale-observer and native integration tests before enabling the feature. diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 1af8a63f5..cbbded797 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2743,6 +2743,9 @@ impl EditObservationSink for CoreEditObservationSink { if self.generation.load(Ordering::Acquire) != self.expected_generation { return false; } + if !crate::correction_observation::permits_vocabulary_suggestion(&self.typed_text, &edit) { + return false; + } let rules = crate::host_document::learned_rules_with_max_chars( &edit, self.settings.max_phrase_chars as usize, @@ -9802,6 +9805,27 @@ mod tests { assert_eq!(backend.pending_corrections().len(), 1); } + #[test] + fn vocabulary_observer_does_not_offer_fact_changes_as_words() { + let (backend, _) = backend(); + let sink = CoreEditObservationSink { + settings: crate::shared_types::VocabularyLearningSettings::default(), + expected_generation: backend.edit_observation_generation.load(Ordering::Acquire), + generation: Arc::clone(&backend.edit_observation_generation), + typed_text: "今天买了十个苹果".into(), + pending: Arc::clone(&backend.pending_corrections), + events: Arc::clone(&backend.events), + }; + let edit = crate::host_document::EditPair { + before: "今天买了".into(), + source: "十".into(), + target: "九".into(), + after: "个苹果".into(), + }; + assert!(!sink.publish(edit)); + assert!(backend.pending_corrections().is_empty()); + } + #[test] fn vocabulary_learning_config_change_invalidates_pending_and_stale_sink() { let (backend, _) = backend(); diff --git a/openless-all/app/crates/openless-core/src/correction_observation.rs b/openless-all/app/crates/openless-core/src/correction_observation.rs new file mode 100644 index 000000000..d68ba3ba5 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/correction_observation.rs @@ -0,0 +1,122 @@ +//! Conservative safeguards for locally observed vocabulary suggestions. + +use crate::host_document::EditPair; +use regex::Regex; +use std::sync::OnceLock; +use unicode_normalization::UnicodeNormalization; + +fn normalized(value: &str) -> String { + value.nfkc().flat_map(char::to_lowercase).collect() +} + +fn number_signature(value: &str) -> String { + value + .chars() + .filter(|character| { + character.is_numeric() + || "零〇一二两兩三四五六七八九十百千万萬亿億壹贰貳叁參肆伍陆陸柒捌玖拾佰仟" + .contains(*character) + }) + .collect() +} + +fn negation_pattern() -> &'static Regex { + static PATTERN: OnceLock = OnceLock::new(); + PATTERN.get_or_init(|| { + Regex::new(r"(?-u:\b)(?:no|not|never|neither|nor)(?-u:\b)|n['’]t|[不没沒無无非别別]") + .expect("fixed negation expression is valid") + }) +} + +fn negation_signature(value: &str) -> Vec<&str> { + negation_pattern() + .find_iter(value) + .map(|found| found.as_str()) + .collect() +} + +/// Refuse suggestions when the observed edit could alter a number, negation, +/// or sensitive content. The caller still requires explicit user confirmation. +pub(crate) fn permits_vocabulary_suggestion(inserted: &str, edit: &EditPair) -> bool { + if crate::expression_observation::contains_sensitive_text(inserted) { + return false; + } + let before = normalized(&format!("{}{}{}", edit.before, edit.source, edit.after)); + let after = normalized(&format!("{}{}{}", edit.before, edit.target, edit.after)); + if crate::expression_observation::contains_sensitive_text(&before) + || crate::expression_observation::contains_sensitive_text(&after) + { + return false; + } + let source = normalized(&edit.source); + let target = normalized(&edit.target); + if [&source, &target].iter().any(|changed| { + !number_signature(changed).is_empty() || !negation_signature(changed).is_empty() + }) { + return false; + } + number_signature(&before) == number_signature(&after) + && negation_signature(&before) == negation_signature(&after) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn edit(before: &str, source: &str, target: &str, after: &str) -> EditPair { + EditPair { + before: before.into(), + source: source.into(), + target: target.into(), + after: after.into(), + } + } + + #[test] + fn permits_ordinary_technical_word_corrections() { + assert!(permits_vocabulary_suggestion( + "请导出 ZIP 文件", + &edit("请导出 ", "ZIP", "VIP", " 文件") + )); + assert!(permits_vocabulary_suggestion( + "使用 codx 完成任务", + &edit("使用 ", "codx", "codex", " 完成任务") + )); + } + + #[test] + fn rejects_fact_and_negation_changes() { + assert!(!permits_vocabulary_suggestion( + "今天买了十个苹果", + &edit("今天买了", "十", "九", "个苹果") + )); + assert!(!permits_vocabulary_suggestion( + "订单编号是 10", + &edit("订单编号是 ", "10", "11", "") + )); + assert!(!permits_vocabulary_suggestion( + "这件事情不能做", + &edit("这件事情", "不", "", "能做") + )); + assert!(!permits_vocabulary_suggestion( + "I can't send this", + &edit("I can", "'t", "", " send this") + )); + assert!(!permits_vocabulary_suggestion( + "回复no即可", + &edit("回复", "no", "yes", "即可") + )); + } + + #[test] + fn rejects_sensitive_context_without_persisting_it() { + assert!(!permits_vocabulary_suggestion( + "联系 user@example.com 处理 ZIP", + &edit("处理 ", "ZIP", "VIP", "") + )); + assert!(!permits_vocabulary_suggestion( + "无敏感文本", + &edit("账户 ", "12345678", "87654321", "") + )); + } +} diff --git a/openless-all/app/crates/openless-core/src/expression_observation.rs b/openless-all/app/crates/openless-core/src/expression_observation.rs index 5f4cee41a..42a75d7e1 100644 --- a/openless-all/app/crates/openless-core/src/expression_observation.rs +++ b/openless-all/app/crates/openless-core/src/expression_observation.rs @@ -366,6 +366,11 @@ fn normalized(text: &str) -> String { text.nfkc().flat_map(char::to_lowercase).collect() } +pub(crate) fn contains_sensitive_text(text: &str) -> bool { + text.chars().take(MAX_CHARS + 1).count() > MAX_CHARS + || sensitive_patterns().is_match(&normalized(text)) +} + fn skeleton(text: &str) -> String { text.chars() .filter(|character| !character.is_whitespace() && !",.;:!,。;:!、".contains(*character)) diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 2f4892f00..fbe028ec2 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -24,6 +24,7 @@ mod cloud_sync_transaction; mod cloud_sync_types; mod cloud_sync_validation; pub mod coding_agent; +mod correction_observation; pub mod coding_agent_guard; pub mod config; pub mod correction;