Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/awareness-follow-up.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 24 additions & 0 deletions openless-all/app/crates/openless-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
122 changes: 122 additions & 0 deletions openless-all/app/crates/openless-core/src/correction_observation.rs
Original file line number Diff line number Diff line change
@@ -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<Regex> = 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", "")
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/crates/openless-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading