Files
DevFlow/crates/df-ideas/src/scoring.rs
绝尘 9c3f27f4ca 修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导
- 删除 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 + 前端全链路
- 设置面板新增数据目录显示
2026-06-27 21:32:36 +08:00

330 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 评分引擎 — 基于想法内容的多维度启发式评分
//!
//! 各维度分数均为 0-10IPC 层会 *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
);
}
}