Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
8e38053
feat(srs): upgrade challenge scheduling to SM-2 algorithm
donfaquir Jul 9, 2026
63fe430
Merge branch 'main' into feature/20260708-product-m-u-2
donfaquir Jul 9, 2026
a408882
feat(ai-guide): show setup guidance when LLM is not configured
donfaquir Jul 9, 2026
3e59153
feat(latent-paragraphs): add embedding-based paragraph candidate engine
donfaquir Jul 9, 2026
dd94dea
feat(onboarding): replace step 4 tips with dynamic discovery card
donfaquir Jul 9, 2026
5ade8ad
chore(ui): freeze graph, topic-network and skills modules
donfaquir Jul 9, 2026
ecdb2b2
refactor(cognitive-report): split panel into card-based subcomponents
donfaquir Jul 9, 2026
758ca02
feat(challenge): add question quality feedback loop
donfaquir Jul 10, 2026
14f20e8
feat(challenge-review): support latent paragraph candidates in review…
donfaquir Jul 10, 2026
5e0123f
feat(cognitive): add review push notifications and thought growth sto…
donfaquir Jul 13, 2026
50dddfd
feat(ui): complete spec-1c AI degraded guidance and spec-3b export en…
donfaquir Jul 13, 2026
4e6d16d
fix(latent): trigger scan on workspace open when candidates table is …
donfaquir Jul 13, 2026
7f428ca
feat(review): improve latent paragraph UX and add content filters
donfaquir Jul 13, 2026
26c60d8
chore: suppress 5 compiler warnings (unused imports, variables, dead …
donfaquir Jul 13, 2026
adeb284
feat(review): show related documents for latent paragraph candidates
donfaquir Jul 13, 2026
160a40f
perf(review): pre-generate challenge questions to eliminate wait time
donfaquir Jul 13, 2026
73a4208
fix(review): deduplicate prefetch and on-demand question generation
donfaquir Jul 13, 2026
6909378
feat(review): add abandon button in QA phase to return to pick
donfaquir Jul 13, 2026
77fd519
fix(latent): add filter versioning to invalidate stale candidates
donfaquir Jul 13, 2026
7e9f1af
revert(ui): restore graph/topic-network entry in ActivityBar
donfaquir Jul 14, 2026
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
426 changes: 426 additions & 0 deletions src-tauri/Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ tauri-build = { version = "2.6", features = [] }
# 与前端 @tauri-apps/api 保持同一 minor(Tauri CLI 会校验),勿只写 major=2 导致与 npm 漂移
tauri = { version = "2.11", features = ["macos-private-api"] }
tauri-plugin-dialog = "2.7"
tauri-plugin-notification = "2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
url = "2.5"
Expand Down Expand Up @@ -49,6 +50,7 @@ async-trait = "0.1"
semver = { version = "1", features = ["serde"] }
jsonschema = "0.28"
dashmap = "6"
base64 = "0.22"

[dev-dependencies]
tempfile = "3"
3 changes: 2 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-toggle-maximize",
"dialog:default"
"dialog:default",
"notification:default"
]
}
325 changes: 325 additions & 0 deletions src-tauri/src/challenge_feedback.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
use chrono::Utc;
use rusqlite::{params, Connection};
use serde::Serialize;

pub fn init_feedback_table(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS challenge_feedback (
id TEXT PRIMARY KEY,
thought_id TEXT,
question_text TEXT NOT NULL,
question_template TEXT,
rating TEXT NOT NULL,
rating_reason TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_cf_rating ON challenge_feedback(rating);
CREATE INDEX IF NOT EXISTS idx_cf_template ON challenge_feedback(question_template);
CREATE INDEX IF NOT EXISTS idx_cf_thought_id ON challenge_feedback(thought_id);
"#,
)
.map_err(|e| format!("init challenge_feedback table: {e}"))?;
Ok(())
}

pub fn insert_feedback(
conn: &Connection,
thought_id: Option<&str>,
question_text: &str,
question_template: Option<&str>,
rating: &str,
rating_reason: Option<&str>,
) -> Result<(), String> {
let id = format!("cf-{}", uuid::Uuid::new_v4());
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO challenge_feedback (id, thought_id, question_text, question_template, rating, rating_reason, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![id, thought_id, question_text, question_template, rating, rating_reason, now],
)
.map_err(|e| format!("insert challenge feedback: {e}"))?;
Ok(())
}

#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TemplateStats {
pub template: String,
pub total: usize,
pub helpful: usize,
pub not_helpful: usize,
pub helpful_rate: f64,
}

#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct IssueCount {
pub reason: String,
pub count: usize,
}

#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct FeedbackStats {
pub total_ratings: usize,
pub helpful_count: usize,
pub not_helpful_count: usize,
pub helpful_rate: f64,
pub by_template: Vec<TemplateStats>,
pub common_issues: Vec<IssueCount>,
}

pub fn query_feedback_stats(conn: &Connection) -> Result<FeedbackStats, String> {
let helpful_count: usize = conn
.query_row(
"SELECT COUNT(*) FROM challenge_feedback WHERE rating = 'helpful'",
[],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let not_helpful_count: usize = conn
.query_row(
"SELECT COUNT(*) FROM challenge_feedback WHERE rating = 'not_helpful'",
[],
|r| r.get(0),
)
.map_err(|e| e.to_string())?;
let total_ratings = helpful_count + not_helpful_count;
let helpful_rate = if total_ratings > 0 {
helpful_count as f64 / total_ratings as f64
} else {
0.0
};

let mut stmt = conn
.prepare(
"SELECT question_template, rating, COUNT(*) as cnt
FROM challenge_feedback
WHERE question_template IS NOT NULL
GROUP BY question_template, rating
ORDER BY question_template",
)
.map_err(|e| e.to_string())?;
let rows: Vec<(String, String, usize)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, usize>(2)?,
))
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;

let mut template_map: std::collections::HashMap<String, (usize, usize)> =
std::collections::HashMap::new();
for (tmpl, rating, cnt) in &rows {
let entry = template_map.entry(tmpl.clone()).or_insert((0, 0));
match rating.as_str() {
"helpful" => entry.0 += cnt,
"not_helpful" => entry.1 += cnt,
_ => {}
}
}
let mut by_template: Vec<TemplateStats> = template_map
.into_iter()
.map(|(template, (h, nh))| {
let total = h + nh;
TemplateStats {
template,
total,
helpful: h,
not_helpful: nh,
helpful_rate: if total > 0 { h as f64 / total as f64 } else { 0.0 },
}
})
.collect();
by_template.sort_by(|a, b| a.template.cmp(&b.template));

let mut issue_stmt = conn
.prepare(
"SELECT rating_reason, COUNT(*) as cnt
FROM challenge_feedback
WHERE rating_reason IS NOT NULL AND rating_reason != ''
GROUP BY rating_reason
ORDER BY cnt DESC",
)
.map_err(|e| e.to_string())?;
let common_issues: Vec<IssueCount> = issue_stmt
.query_map([], |row| {
Ok(IssueCount {
reason: row.get(0)?,
count: row.get(1)?,
})
})
.map_err(|e| e.to_string())?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;

Ok(FeedbackStats {
total_ratings,
helpful_count,
not_helpful_count,
helpful_rate,
by_template,
common_issues,
})
}

pub fn query_recent_questions(
conn: &Connection,
thought_id: &str,
limit: usize,
) -> Result<Vec<String>, String> {
let cap = limit.min(20);
let mut stmt = conn
.prepare(
"SELECT question_text FROM challenge_feedback
WHERE thought_id = ?1
ORDER BY created_at DESC
LIMIT ?2",
)
.map_err(|e| format!("prepare recent questions: {e}"))?;
let rows = stmt
.query_map(params![thought_id, cap as i64], |row| row.get::<_, String>(0))
.map_err(|e| format!("query recent questions: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("read recent questions: {e}"))
}

#[tauri::command]
pub async fn submit_challenge_feedback(
state: tauri::State<'_, crate::WorkspaceState>,
thought_id: Option<String>,
question_text: String,
question_template: Option<String>,
rating: String,
rating_reason: Option<String>,
) -> Result<(), String> {
let root = crate::lock_workspace_root(&state)?;
tauri::async_runtime::spawn_blocking(move || {
let conn = crate::vault_thoughts_db::open_thoughts_db(&root)?;
insert_feedback(
&conn,
thought_id.as_deref(),
&question_text,
question_template.as_deref(),
&rating,
rating_reason.as_deref(),
)
})
.await
.map_err(|e| e.to_string())?
}

#[tauri::command]
pub async fn get_feedback_stats(
state: tauri::State<'_, crate::WorkspaceState>,
) -> Result<FeedbackStats, String> {
let root = crate::lock_workspace_root(&state)?;
tauri::async_runtime::spawn_blocking(move || {
let conn = crate::vault_thoughts_db::open_thoughts_db(&root)?;
query_feedback_stats(&conn)
})
.await
.map_err(|e| e.to_string())?
}

#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;

fn setup_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
init_feedback_table(&conn).unwrap();
conn
}

#[test]
fn insert_and_query_stats() {
let conn = setup_db();
insert_feedback(&conn, Some("t1"), "What is X?", Some("compare"), "helpful", None).unwrap();
insert_feedback(&conn, Some("t2"), "Explain Y", Some("apply"), "not_helpful", Some("too_easy")).unwrap();
insert_feedback(&conn, Some("t3"), "Compare A", Some("compare"), "helpful", None).unwrap();

let stats = query_feedback_stats(&conn).unwrap();
assert_eq!(stats.total_ratings, 3);
assert_eq!(stats.helpful_count, 2);
assert_eq!(stats.not_helpful_count, 1);
assert!((stats.helpful_rate - 2.0 / 3.0).abs() < 0.01);

let compare = stats.by_template.iter().find(|t| t.template == "compare").unwrap();
assert_eq!(compare.helpful, 2);
assert_eq!(compare.not_helpful, 0);

let apply = stats.by_template.iter().find(|t| t.template == "apply").unwrap();
assert_eq!(apply.helpful, 0);
assert_eq!(apply.not_helpful, 1);

assert_eq!(stats.common_issues.len(), 1);
assert_eq!(stats.common_issues[0].reason, "too_easy");
assert_eq!(stats.common_issues[0].count, 1);
}

#[test]
fn empty_stats() {
let conn = setup_db();
let stats = query_feedback_stats(&conn).unwrap();
assert_eq!(stats.total_ratings, 0);
assert_eq!(stats.helpful_rate, 0.0);
assert!(stats.by_template.is_empty());
assert!(stats.common_issues.is_empty());
}

#[test]
fn query_recent_questions_returns_latest_n() {
let conn = setup_db();
for i in 0..7 {
insert_feedback(
&conn,
Some("t1"),
&format!("Question {i}"),
Some("apply"),
"helpful",
None,
)
.unwrap();
}
let qs = query_recent_questions(&conn, "t1", 5).unwrap();
assert_eq!(qs.len(), 5);
assert_eq!(qs[0], "Question 6");
assert_eq!(qs[4], "Question 2");
}

#[test]
fn query_recent_questions_filters_by_thought_id() {
let conn = setup_db();
insert_feedback(&conn, Some("t1"), "Q for t1", Some("apply"), "helpful", None).unwrap();
insert_feedback(&conn, Some("t2"), "Q for t2", Some("apply"), "helpful", None).unwrap();
insert_feedback(&conn, Some("t1"), "Q2 for t1", Some("compare"), "helpful", None).unwrap();

let qs = query_recent_questions(&conn, "t1", 10).unwrap();
assert_eq!(qs.len(), 2);
assert!(qs.iter().all(|q| q.contains("t1")));

let empty = query_recent_questions(&conn, "unknown", 10).unwrap();
assert!(empty.is_empty());
}

#[test]
fn multiple_reasons() {
let conn = setup_db();
insert_feedback(&conn, None, "Q1", None, "not_helpful", Some("too_vague")).unwrap();
insert_feedback(&conn, None, "Q2", None, "not_helpful", Some("too_vague")).unwrap();
insert_feedback(&conn, None, "Q3", None, "not_helpful", Some("irrelevant")).unwrap();

let stats = query_feedback_stats(&conn).unwrap();
assert_eq!(stats.common_issues[0].reason, "too_vague");
assert_eq!(stats.common_issues[0].count, 2);
assert_eq!(stats.common_issues[1].reason, "irrelevant");
assert_eq!(stats.common_issues[1].count, 1);
}
}
Loading