Files
DevFlow/src-tauri/src/commands/idea.rs
绝尘 c9b6e28433 新增: 三实体列表查询维度补全(status/关键词/排序/分页下沉后端)
任务/项目/灵感三实体新增 list_by_query 动态 WHERE(累积式 where_clauses
+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制。命令层
list_{tasks,projects,ideas} 吃 Option<XxxQuery> 双参向后兼容(旧无参/单参
路径等价全量)。前端 Tasks/Ideas status/keyword 筛选下沉后端 query。

F-260621-02
2026-06-22 01:03:37 +08:00

440 lines
16 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.
//! 灵感相关命令
use std::sync::Arc;
use serde::Deserialize;
use tauri::State;
use df_ai::provider::LlmProvider;
use df_types::types::{new_id, Priority};
use df_ideas::capture::Idea;
use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectRecord};
use crate::state::AppState;
use super::{err_str, now_millis};
/// 创建灵感入参
#[derive(Debug, Deserialize)]
pub struct CreateIdeaInput {
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_priority")]
pub priority: i32,
/// 标签 JSON 数组字符串
pub tags: Option<String>,
pub source: Option<String>,
}
fn default_priority() -> i32 {
1
}
/// 列出灵感。
///
/// **双路径向后兼容**(F-260621-02):
/// - 旧调用方仅传 `status`(`ideaApi.list(status)`)→ 转 IdeaQuery 仅带 status,走
/// `list_by_query`(白名单 status 列 WHERE),与原 `query("status", s)` 等价。
/// - 新调用方传 `query`(`ideaApi.list(query)`)→ 多条件(status/keyword/order_by/limit/offset)。
/// - 两者都不传 → 等价全量(`list_by_query` 空 query 走默认 created_at DESC,与 list_all 等价)。
///
/// `query` 优先于 `status`(二者同传时以 query 为准,避免重复过滤语义冲突)。
#[tauri::command]
pub async fn list_ideas(
state: State<'_, AppState>,
status: Option<String>,
query: Option<IdeaQuery>,
) -> Result<Vec<IdeaRecord>, String> {
let q = match query {
Some(q) => q,
None => IdeaQuery {
status,
..Default::default()
},
};
state.ideas.list_by_query(&q).await.map_err(err_str)
}
/// 列出指定灵感的评估历史(version DESC,最新版本在前)。
/// IdeaEvaluationRecord 已 Serialize,直接返回前端供历史面板渲染。
#[tauri::command]
pub async fn list_idea_evaluations(
state: State<'_, AppState>,
idea_id: String,
) -> Result<Vec<IdeaEvaluationRecord>, String> {
state.idea_evaluations.list_by_idea(&idea_id).await.map_err(err_str)
}
/// 创建灵感,返回完整记录
#[tauri::command]
pub async fn create_idea(
state: State<'_, AppState>,
input: CreateIdeaInput,
) -> Result<IdeaRecord, String> {
let now = now_millis();
let record = IdeaRecord {
id: new_id(),
title: input.title,
description: input.description,
status: "draft".to_string(),
priority: input.priority,
score: None,
tags: input.tags,
source: input.source,
promoted_to: None,
ai_analysis: None,
scores: None,
related_ids: None,
created_at: now.clone(),
updated_at: now,
};
state
.ideas
.insert(record.clone())
.await
.map_err(err_str)?;
Ok(record)
}
/// 更新灵感单个字段(字段名走 df-storage 白名单校验)
#[tauri::command]
pub async fn update_idea(
state: State<'_, AppState>,
id: String,
field: String,
value: String,
) -> Result<bool, String> {
state
.ideas
.update_field(&id, &field, &value)
.await
.map_err(err_str)
}
/// 删除灵感
#[tauri::command]
pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.ideas.delete(&id).await.map_err(err_str)
}
/// 将灵感晋升为项目 — 复用 df-project 领域逻辑创建项目,回写灵感 status=promoted/promoted_to
#[tauri::command]
pub async fn promote_idea(
state: State<'_, AppState>,
id: String,
) -> Result<df_ideas::promotion::PromotionResult, String> {
let record = state
.ideas
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("灵感不存在: {id}"))?;
if let Some(promoted_to) = &record.promoted_to {
return Err(format!("灵感已立项: {}", promoted_to));
}
// 复用 df-project 领域逻辑构造项目实体create_from_idea
let project = df_project::manager::ProjectManager::create_from_idea(
record.title.clone(),
record.description.clone(),
id.clone(),
);
let project_id = project.id.clone();
let now = now_millis();
let project_record = ProjectRecord {
id: project_id.clone(),
name: project.name,
description: project.description,
status: "planning".to_string(),
idea_id: Some(id.clone()),
path: None,
stack: None,
created_at: now.clone(),
updated_at: now.clone(),
};
state
.projects
.insert(project_record)
.await
.map_err(err_str)?;
// 回写灵感status=promoted + promoted_toupdate_full 单事务覆盖可变字段)
// 补偿删除:第二步失败时回滚第一步已建的 project,保证最终一致性(非原子,但防项目存留而
// 灵感状态未变的数据不一致)。Repository 方法各自持锁不支持跨 repo 共享事务对象,故选补偿
// 删除而非真事务(改动最小,工程投入产出比最高)。
let updated = IdeaRecord {
status: "promoted".to_string(),
promoted_to: Some(project_id.clone()),
updated_at: now,
..record
};
if let Err(e) = state.ideas.update_full(&updated).await {
// 回写失败:补偿删除已建项目,避免悬空项目(idea.promoted_to 仍空,可重试立项)
tracing::error!("灵感 {id} 回写失败,补偿删除已建项目 {project_id}: {e}");
if let Err(del_err) = state.projects.purge_with_descendants(&project_id).await {
tracing::error!("补偿删除项目 {project_id} 也失败(需人工清理): {del_err}");
}
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
}
Ok(df_ideas::promotion::PromotionResult {
idea_id: id,
project_id: project_id,
promoted: true,
reason: "手动立项".to_string(),
})
}
// ============================================================
// 灵感评估 — 多维评分 + 对抗式评估
// ============================================================
/// 评估灵感:多维评分 + 对抗式评估,结果写回 scores/score/ai_analysis状态置 pending_review返回更新后的记录
#[tauri::command]
pub async fn evaluate_idea(
state: State<'_, AppState>,
id: String,
) -> Result<IdeaRecord, String> {
// 取出灵感
let record = state
.ideas
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("灵感不存在: {id}"))?;
let idea = record_to_idea(&record);
// 多维评分0-10IPC 层 *10 缩放为 0-100
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
// 对抗式评估(构造注入:从 DB 读默认 provider 装配 LLM无 provider/构造失败 → 启发式兜底)
// F-01 阶段5: 透传 model_configs 池,evaluate_with_llm 经路由选模型(池空兜底 default_model)。
let provider = build_default_provider(&state).await;
let engine = match provider {
Some((p, pool)) => {
df_ideas::adversarial::AdversarialEngine::with_pool(Arc::from(p), pool)
}
None => df_ideas::adversarial::AdversarialEngine::heuristic(),
};
let eval = engine.evaluate(&idea).await.map_err(err_str)?;
// 组装前端扁平结构(与 Ideas.vue 的 AdversarialEval interface 对齐)
let positive_strength = eval.positive.confidence;
let negative_strength = eval.negative.confidence;
let net_sentiment = positive_strength - negative_strength;
let recommendation = recommendation_str(&eval.recommendation).to_string();
let final_score = eval.final_score;
let analyst_summary = eval.analyst.summary.clone();
let action_items = action_items_for(&eval.recommendation);
let positive = serde_json::json!({
"thesis": eval.positive.thesis,
"evidence": eval.positive.evidence,
});
let negative = serde_json::json!({
"thesis": eval.negative.thesis,
"evidence": eval.negative.evidence,
});
let ai_analysis = serde_json::json!({
"positive_strength": positive_strength,
"negative_strength": negative_strength,
"net_sentiment": net_sentiment,
"recommendation": recommendation,
"evaluated_by": eval.evaluated_by,
"final_score": final_score,
"summary": analyst_summary,
"action_items": action_items,
"positive": positive,
"negative": negative,
"analyst": { "summary": analyst_summary },
})
.to_string();
// scores JSON中文维度 key + 0-100 值(前端雷达图直接当百分比用)
let scores_json = serde_json::json!({
"可行性": (scores.feasibility * 10.0).round() as i64,
"影响力": (scores.impact * 10.0).round() as i64,
"紧急度": (scores.urgency * 10.0).round() as i64,
"综合": (scores.overall * 10.0).round() as i64,
})
.to_string();
let score_value = (scores.overall * 10.0).round() as i64;
// 构造完整记录后单次原子写回update_full 保留 id 与 created_at
// ai_analysis/scores_json 按值 move 进主表记录后,下方历史快照仍需复用 → 此处 clone 保留绑定。
let updated = IdeaRecord {
scores: Some(scores_json.clone()),
ai_analysis: Some(ai_analysis.clone()),
score: Some(score_value as f64),
status: "pending_review".to_string(),
updated_at: now_millis(),
..record
};
state
.ideas
.update_full(&updated)
.await
.map_err(err_str)?;
// 追加一条评估历史快照(idea_evaluations 审计表,version 单调递增)。
// 主表 update_full 成功后再追加,保证主表先落;历史表为额外冗余列(evaluated_by
// 独立冗余,ai_analysis JSON 内的 evaluated_by 字段保留不删)。
//
// version 并发重复兜底(V25 唯一约束 + 重试):version 此前由
// `list_by_idea().first().version + 1` 算出,读-改-写非原子,并发评估同一灵感
// 可能写出相同 version。V25 在 idea_evaluations(idea_id, version) 上加了唯一索引,
// 此处捕获唯一约束冲突 → 重新查最新 version 重算并重试(上限 3 次防死循环)。
// 单用户桌面应用并发概率极低,但唯一约束 + 重试是数据完整性兜底,值得做。
let mut attempt = 0;
let max_attempts = 3;
loop {
attempt += 1;
let version = state
.idea_evaluations
.list_by_idea(&id)
.await
.map_err(err_str)?
.first()
.map(|r| r.version + 1)
.unwrap_or(1);
let eval_record = IdeaEvaluationRecord {
id: new_id(),
idea_id: id.clone(),
version,
ai_analysis: Some(ai_analysis.clone()),
scores: Some(scores_json.clone()),
score: Some(score_value as f64),
evaluated_by: Some(evaluated_by_str(&eval.evaluated_by).to_string()),
evaluated_at: now_millis(),
};
match state.idea_evaluations.insert(eval_record).await {
Ok(_) => break,
Err(e) => {
// 唯一约束冲突(SQLite extended code 2067 / SQLITE_CONSTRAINT_UNIQUE)
// → version 并发重复,命中且未达上限则重试(重新查 version);否则向上抛错。
// 检测逻辑收口到 df_storage::crud::is_unique_constraint_err,集中维护、
// 大小写不敏感,不再散落脆弱的英文文案 contains。
if is_unique_constraint_err(&e) && attempt < max_attempts {
tracing::warn!(
"灵感 {id} 评估历史 version 唯一约束冲突,重试 {}/{}",
attempt,
max_attempts
);
continue;
}
return Err(e.to_string());
}
}
}
Ok(updated)
}
/// 从 DB 读取默认 provider 配置is_default 优先,否则首个)+ build_provider 构造实例。
///
/// 返回 `None` 的两种情况(统一走启发式评估兜底):
/// - DB 未配置任何 provider`list_all` 空或全无 is_default 且无首条)
/// - provider 密钥不可用keyring 无记录 / 纯空白),`build_provider_for` 返 Err
///
/// 复用 `commands::ai::secret::build_provider_for`resolve→ensure→build 三步),
/// 与 AI Chat / 项目扫描的 provider 构造路径统一FR-S1 密钥解析一致)。
///
/// 返回 (provider, model_pool):model_pool = 选中 provider 的 model_configs(F-01 阶段5,
/// 供对抗评估路由)。池空(用户未拉取)→ 调用方兜底 default_model。
async fn build_default_provider(
state: &State<'_, AppState>,
) -> Option<(Box<dyn LlmProvider>, Vec<df_ai::df_ai_core::model::ModelConfig>)> {
let providers = state.ai_providers.list_all().await.ok()?;
let pc = providers
.iter()
.find(|p| p.is_default)
.cloned()
.or_else(|| providers.into_iter().next())?;
match crate::commands::ai::secret::build_provider_for(&pc) {
Ok(p) => Some((p, pc.model_configs.clone())),
Err(e) => {
// 密钥不可用:启发式兜底,不阻断评估(与 evaluate_idea LLM 失败降级语义一致)
tracing::warn!("默认 provider 密钥不可用,对抗评估走启发式: {e}");
None
}
}
}
/// IdeaRecord → df_ideas::Idea评估用status/time 不影响评分)
fn record_to_idea(record: &IdeaRecord) -> Idea {
let tags: Vec<String> = match record.tags.as_deref() {
Some(t) => match serde_json::from_str::<Vec<String>>(t) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, idea_id = %record.id, "[ideas] tags JSON 解析失败,降级空 tags 继续评估");
Vec::new()
}
},
None => Vec::new(),
};
Idea {
id: record.id.clone(),
title: record.title.clone(),
description: record.description.clone(),
status: df_types::types::IdeaStatus::Draft,
priority: priority_from_i32(record.priority),
scores: None,
tags,
source: record.source.clone(),
related_ids: Vec::new(),
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
/// i32 优先级 → Priority 枚举(与 df-types 枚举值一致Low=0/Medium=1/High=2/Critical=3
fn priority_from_i32(p: i32) -> Priority {
match p {
0 => Priority::Low,
2 => Priority::High,
x if x >= 3 => Priority::Critical,
_ => Priority::Medium,
}
}
/// Recommendation → 前端 assessmentLabel 期望的全小写空格分隔(匹配 map key
fn recommendation_str(r: &df_ideas::adversarial::Recommendation) -> &'static str {
use df_ideas::adversarial::Recommendation::*;
match r {
ImmediateAction => "immediate action",
Soon => "soon",
WithResources => "with resources",
ResearchMore => "research more",
Monitor => "monitor",
}
}
/// 行动建议 — 按推荐等级返回
fn action_items_for(r: &df_ideas::adversarial::Recommendation) -> Vec<String> {
use df_ideas::adversarial::Recommendation::*;
match r {
ImmediateAction => vec!["立即组建项目团队".into(), "制定详细执行计划".into(), "分配必要资源".into()],
Soon => vec!["下周启动项目".into(), "准备资源需求".into(), "制定时间表".into()],
WithResources => vec!["确认资源预算".into(), "评估 ROI".into(), "制定风险预案".into()],
ResearchMore => vec!["进行市场调研".into(), "收集用户反馈".into(), "验证技术可行性".into()],
Monitor => vec!["持续跟踪相关指标".into(), "定期评估进展".into(), "等待更好时机".into()],
}
}
/// EvaluatedBy 枚举 → 评估历史表 evaluated_by 列的字符串冗余值。
/// (ai_analysis JSON 内的 evaluated_by 字段保留不删;此处为历史表独立冗余列,
/// 便于不解析 JSON 即可直接按评估来源过滤/统计历史。)
fn evaluated_by_str(e: &df_ideas::adversarial::EvaluatedBy) -> &'static str {
use df_ideas::adversarial::EvaluatedBy::*;
match e {
Llm => "Llm",
Heuristic => "Heuristic",
HeuristicFallback => "HeuristicFallback",
}
}