- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
330 lines
14 KiB
Rust
330 lines
14 KiB
Rust
//! 评分引擎 — 基于想法内容的多维度启发式评分
|
||
//!
|
||
//! 各维度分数均为 0-10(IPC 层会 *10 缩放为 0-100 以匹配前端)。
|
||
//! 启发式依据:优先级、描述充实度、标签、关键词信号——保证稳定且有区分度。
|
||
//! TODO: 接入 AI 做语义级深度评分。
|
||
|
||
use crate::capture::Idea;
|
||
|
||
/// 想法评分详情(重新导出 capture 模块中的定义)
|
||
pub use crate::capture::IdeaScores;
|
||
|
||
use df_types::types::Priority;
|
||
|
||
// ─── 启发式关键词(从 `scoring_keywords` 模块导入)───
|
||
//
|
||
// 关键词常量已拆至独立文件 `scoring_keywords.rs`。
|
||
// 启发式是过渡方案,对抗式评估上线后按需升级为 JSON 配置。
|
||
pub use crate::scoring_keywords::{
|
||
FEASIBILITY_POSITIVE_KEYWORDS, FEASIBILITY_NEGATIVE_KEYWORDS,
|
||
IMPACT_KEYWORDS, URGENCY_KEYWORDS,
|
||
};
|
||
|
||
/// 评分权重配置
|
||
#[derive(Debug, Clone)]
|
||
pub struct ScoringWeights {
|
||
pub feasibility: f64,
|
||
pub impact: f64,
|
||
pub urgency: f64,
|
||
}
|
||
|
||
impl Default for ScoringWeights {
|
||
fn default() -> Self {
|
||
Self {
|
||
feasibility: 0.4,
|
||
impact: 0.4,
|
||
urgency: 0.2,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 评分引擎
|
||
pub struct ScoringEngine;
|
||
|
||
impl ScoringEngine {
|
||
/// 使用默认权重计算评分
|
||
pub fn compute_default(idea: &Idea) -> IdeaScores {
|
||
Self::compute(idea, &ScoringWeights::default())
|
||
}
|
||
|
||
/// 使用指定权重计算评分
|
||
pub fn compute(idea: &Idea, weights: &ScoringWeights) -> IdeaScores {
|
||
let feasibility = Self::heuristic_feasibility(idea);
|
||
let impact = Self::heuristic_impact(idea);
|
||
let urgency = Self::heuristic_urgency(idea);
|
||
|
||
let overall = feasibility * weights.feasibility
|
||
+ impact * weights.impact
|
||
+ urgency * weights.urgency;
|
||
|
||
IdeaScores {
|
||
feasibility,
|
||
impact,
|
||
urgency,
|
||
overall,
|
||
}
|
||
}
|
||
|
||
/// 启发式可行性评分(描述充实度 + 技术/资源信号词)
|
||
fn heuristic_feasibility(idea: &Idea) -> f64 {
|
||
let mut score = 5.0_f64;
|
||
let desc = idea.description.trim();
|
||
if !desc.is_empty() {
|
||
score += 1.5;
|
||
}
|
||
let len = desc.chars().count();
|
||
if (50..=500).contains(&len) {
|
||
score += 1.0;
|
||
} else if len > 500 {
|
||
// 过长描述通常意味着实现复杂度上升
|
||
score -= 0.5;
|
||
}
|
||
// 可行性正向信号
|
||
let pos = count_any(desc, FEASIBILITY_POSITIVE_KEYWORDS);
|
||
score += (pos as f64) * 0.5;
|
||
// 复杂度负向信号
|
||
let neg = count_any(desc, FEASIBILITY_NEGATIVE_KEYWORDS);
|
||
score -= (neg as f64) * 0.6;
|
||
score.clamp(0.0, 10.0)
|
||
}
|
||
|
||
/// 启发式影响力评分(优先级 + 价值信号词 + 标签广度)
|
||
fn heuristic_impact(idea: &Idea) -> f64 {
|
||
let mut score = match idea.priority {
|
||
Priority::Critical => 8.0,
|
||
Priority::High => 6.5,
|
||
Priority::Medium => 5.0,
|
||
Priority::Low => 3.5,
|
||
};
|
||
if !idea.tags.is_empty() {
|
||
score += 0.5;
|
||
// 标签越多影响面越广,上限 +1.0
|
||
score += (idea.tags.len().min(4) as f64) * 0.25;
|
||
}
|
||
let desc = idea.description.trim();
|
||
let value_hits = count_any(desc, IMPACT_KEYWORDS);
|
||
score += (value_hits as f64) * 0.5;
|
||
if desc.chars().count() > 100 {
|
||
score += 0.5;
|
||
}
|
||
score.clamp(0.0, 10.0)
|
||
}
|
||
|
||
/// 启发式紧急度评分(优先级 + 时效信号词)
|
||
fn heuristic_urgency(idea: &Idea) -> f64 {
|
||
let mut score = match idea.priority {
|
||
Priority::Critical => 9.0,
|
||
Priority::High => 7.0,
|
||
Priority::Medium => 5.0,
|
||
Priority::Low => 3.0,
|
||
};
|
||
let desc = idea.description.trim();
|
||
let time_hits = count_any(desc, URGENCY_KEYWORDS);
|
||
score += (time_hits as f64) * 0.5;
|
||
score.clamp(0.0, 10.0)
|
||
}
|
||
}
|
||
|
||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
|
||
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
|
||
/// 单字否定前缀:关键词紧邻这些字时该命中不计(「不复用」不计入「复用」正向命中)。
|
||
///
|
||
/// 注意「别」单字歧义大(别具/别的/特别 均非否定),故不进单字清单——
|
||
/// 祈使否定「别」改用两字清单兜底(别去/别再/别要/别用 等,见 NEGATION_PREFIXES_2)。
|
||
const NEGATION_PREFIXES_1: &[char] = &['不', '无', '非', '未', '没', '勿'];
|
||
/// 两字否定前缀:紧邻关键词的前两字符为这些时该命中不计(「没有复用」「并非简单」「别去复用」)。
|
||
const NEGATION_PREFIXES_2: &[&str] = &[
|
||
"没有", "并非", "毫无", "不用", "不是",
|
||
// 「别X」祈使否定:双字形式可精确匹配,规避「别具/别的/特别」等单字误伤
|
||
"别去", "别再", "别要", "别用", "别做", "别给",
|
||
];
|
||
|
||
/// 关键词在 text 的 `end` 字节位置前是否处于否定上下文(紧邻否定词)。
|
||
///
|
||
/// `end` 是关键词起始字节位置(由 `str::find` 返回,必在 char 边界)。取 `text[..end]`
|
||
/// 末尾 1-2 个 char 判否定:单字否定(不/无/非/...)或两字否定(没有/并非/...)。
|
||
///
|
||
/// 仅取末尾 ≤2 个 char(rev().take(2) 流式),省去每次命中的 `Vec<char>` 分配。
|
||
fn is_negated_before(text: &str, end: usize) -> bool {
|
||
if end == 0 {
|
||
return false;
|
||
}
|
||
let before = &text[..end];
|
||
// 末尾最多 2 char,按 [次末, 末] 顺序收集(n=1 时仅末位)
|
||
let tail: Vec<char> = before.chars().rev().take(2).collect();
|
||
if tail.is_empty() {
|
||
return false;
|
||
}
|
||
// 单字否定:末位 char(tail[0]) 命中
|
||
if NEGATION_PREFIXES_1.contains(&tail[0]) {
|
||
return true;
|
||
}
|
||
// 两字否定:需凑齐 2 char,顺序为 [末, 次末] 反转回原序 [次末, 末]
|
||
if tail.len() == 2 {
|
||
let last2: String = [tail[1], tail[0]].iter().collect();
|
||
if NEGATION_PREFIXES_2.contains(&last2.as_str()) {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)。
|
||
///
|
||
/// 否定前缀处理:关键词紧邻否定词(不复用/无增长/非紧急...)时该命中不计,
|
||
/// 避免「不复用」反向计入「复用」正向分。双重否定(「不是不复用」)属罕见边缘,
|
||
/// 不处理(接 LLM 后语义层兜底)。
|
||
fn count_any(text: &str, keywords: &[&str]) -> usize {
|
||
let lower = text.to_lowercase();
|
||
keywords
|
||
.iter()
|
||
.filter(|kw| {
|
||
// 至少一个非否定上下文的出现即计为命中(同一词多次出现,一次有效即可)
|
||
let mut idx = 0usize;
|
||
while let Some(rel) = lower[idx..].find(*kw) {
|
||
let abs = idx + rel;
|
||
if !is_negated_before(&lower, abs) {
|
||
return true;
|
||
}
|
||
idx = abs + kw.len();
|
||
}
|
||
false
|
||
})
|
||
.count()
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::capture::Idea;
|
||
use df_types::types::{IdeaStatus, Priority};
|
||
|
||
/// 辅助工厂:构造测试用 Idea(时间/ID 用默认值,不影响评分)
|
||
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
|
||
Idea {
|
||
id: "test-id".to_string(),
|
||
title: title.to_string(),
|
||
description: desc.to_string(),
|
||
status: IdeaStatus::Draft,
|
||
priority,
|
||
scores: None,
|
||
tags: tags.into_iter().map(String::from).collect(),
|
||
source: None,
|
||
related_ids: Vec::new(),
|
||
created_at: chrono::Utc::now(),
|
||
updated_at: chrono::Utc::now(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn s1_empty_idea_baseline() {
|
||
let idea = make_idea("测试想法", "", Priority::Medium, vec![]);
|
||
let s = ScoringEngine::compute_default(&idea);
|
||
println!("\n[s1] 空想法 (Medium / 无描述 / 无标签)");
|
||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||
assert!((s.overall - 5.0).abs() < 0.01, "空想法 overall 应为 5.0, 实际 {:.2}", s.overall);
|
||
}
|
||
|
||
#[test]
|
||
fn s2_high_priority_value_desc() {
|
||
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件,快速交付价值。".repeat(3);
|
||
let idea = make_idea("增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
|
||
let s = ScoringEngine::compute_default(&idea);
|
||
println!("\n[s2] 高优先级 + 价值描述 (Critical / ~120字 / 含价值词)");
|
||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||
assert!(s.impact >= 7.0, "impact 应≥7.0, 实际 {:.2}", s.impact);
|
||
assert!(s.urgency >= 8.0, "urgency 应≥8.0, 实际 {:.2}", s.urgency);
|
||
}
|
||
|
||
#[test]
|
||
fn s3_low_priority_short_desc() {
|
||
let idea = make_idea("小优化", "一句话", Priority::Low, vec![]);
|
||
let s = ScoringEngine::compute_default(&idea);
|
||
println!("\n[s3] 低优先级 + 短描述 (Low / 3字)");
|
||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||
assert!((s.overall - 4.6).abs() < 0.01, "overall 应为 4.6, 实际 {:.2}", s.overall);
|
||
}
|
||
|
||
#[test]
|
||
fn s4_feasibility_positive_signals() {
|
||
let idea = make_idea("复用方案", "复用已有组件,简单集成现成脚手架", Priority::Medium, vec![]);
|
||
let s = ScoringEngine::compute_default(&idea);
|
||
println!("\n[s4] 可行性正向信号 (含 复用/已有/简单/集成/现成/脚手架)");
|
||
println!(" 可行性={:.2} (预期 9.5)", s.feasibility);
|
||
assert!((s.feasibility - 9.5).abs() < 0.01, "正向信号 feasibility 应为 9.5, 实际 {:.2}", s.feasibility);
|
||
}
|
||
|
||
#[test]
|
||
fn s5_feasibility_negative_signals() {
|
||
let idea = make_idea("大重构", "大规模重构迁移,分布式重写从零开始", Priority::Medium, vec![]);
|
||
let s = ScoringEngine::compute_default(&idea);
|
||
println!("\n[s5] 可行性负向信号 (含 大规模/重构/迁移/分布式/重写/从零)");
|
||
println!(" 可行性={:.2} (预期 ≤5.0)", s.feasibility);
|
||
assert!(s.feasibility <= 5.0, "负向信号 feasibility 应≤5.0, 实际 {:.2}", s.feasibility);
|
||
}
|
||
|
||
#[test]
|
||
fn s6_custom_weights() {
|
||
let idea = make_idea("高可行低影响", "复用已有简单集成现成", Priority::Low, vec![]);
|
||
let custom = ScoringWeights { feasibility: 0.7, impact: 0.2, urgency: 0.1 };
|
||
let s_custom = ScoringEngine::compute(&idea, &custom);
|
||
let s_default = ScoringEngine::compute_default(&idea);
|
||
let manual = s_custom.feasibility * 0.7 + s_custom.impact * 0.2 + s_custom.urgency * 0.1;
|
||
println!("\n[s6] 自定义权重 (feas:0.7 / impact:0.2 / urg:0.1)");
|
||
println!(" 自定义综合={:.2} 默认综合={:.2} 手算加权={:.2}", s_custom.overall, s_default.overall, manual);
|
||
assert!((s_custom.overall - manual).abs() < 0.01, "overall 应等于手算加权");
|
||
assert!(s_custom.overall > s_default.overall, "高 feas 配高权重应让综合更高");
|
||
}
|
||
|
||
#[test]
|
||
fn s7_clamp_upper_bound() {
|
||
let desc = "复用已有简单集成现成成熟基于脚手架模板支持".repeat(20);
|
||
let idea = make_idea("满分想法", &desc, Priority::Critical, vec!["a", "b", "c", "d"]);
|
||
let s = ScoringEngine::compute_default(&idea);
|
||
println!("\n[s7] clamp 上限 (堆正向词 + 超长描述 + Critical)");
|
||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||
assert!(s.feasibility <= 10.0 && s.impact <= 10.0 && s.urgency <= 10.0, "所有维度应≤10");
|
||
}
|
||
|
||
#[test]
|
||
fn s8_clamp_lower_bound() {
|
||
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".repeat(20);
|
||
let idea = make_idea("灾难想法", &desc, Priority::Low, vec![]);
|
||
let s = ScoringEngine::compute_default(&idea);
|
||
println!("\n[s8] clamp 下限 (堆负向词 + Low)");
|
||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||
assert!(s.feasibility >= 0.0 && s.impact >= 0.0 && s.urgency >= 0.0, "所有维度应≥0");
|
||
}
|
||
|
||
#[test]
|
||
fn s9_single_char_negation_suppresses_hit() {
|
||
// 「不复用已有组件」:复用 被「不」否定不计,已有 命中 → feasibility 低于「复用已有组件」(复用+已有 双命中)
|
||
let neg = make_idea("不复用", "不复用已有组件", Priority::Medium, vec![]);
|
||
let pos = make_idea("复用", "复用已有组件", Priority::Medium, vec![]);
|
||
let s_neg = ScoringEngine::compute_default(&neg);
|
||
let s_pos = ScoringEngine::compute_default(&pos);
|
||
println!("\n[s9] 单字否定前缀抑制命中");
|
||
println!(" 不复用已有组件 feasibility={:.2} 复用已有组件 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||
assert!(
|
||
s_neg.feasibility < s_pos.feasibility,
|
||
"单字否定应抑制正向命中致 feasibility 更低: neg={} pos={}",
|
||
s_neg.feasibility, s_pos.feasibility
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn s10_two_char_negation_suppresses_hit() {
|
||
// 「没有复用」:两字否定 没有 抑制 复用 命中;「确实复用」无否定 复用 命中
|
||
let neg = make_idea("t", "没有复用", Priority::Medium, vec![]);
|
||
let pos = make_idea("t", "确实复用", Priority::Medium, vec![]);
|
||
let s_neg = ScoringEngine::compute_default(&neg);
|
||
let s_pos = ScoringEngine::compute_default(&pos);
|
||
println!("\n[s10] 两字否定前缀抑制命中");
|
||
println!(" 没有复用 feasibility={:.2} 确实复用 feasibility={:.2}", s_neg.feasibility, s_pos.feasibility);
|
||
assert!(
|
||
s_neg.feasibility < s_pos.feasibility,
|
||
"两字否定「没有」应抑制命中: neg={} pos={}", s_neg.feasibility, s_pos.feasibility
|
||
);
|
||
}
|
||
}
|