Files
DevFlow/src-tauri/src/commands/idea.rs
T
lxy 093fe172f8 新增: 项目状态机分层+任务状态枚举合并
真分层(df-project):
- ProjectManager 加 can_transition/transition/is_terminal 状态机
- 创建加 EmptyName 校验,返回 Result 而非裸实体
- 新增 ProjectTransitionError 三种错误变体
- 调用方(idea.rs promote_idea)适配 Result 传播

双源合并:
- TaskStatus::as_str 改为 const fn(允许 const 上下文调用)
- task_state_machine 字符串常量从 TaskStatus enum 派生
- IdeaStatus/ProjectStatus as_str 同步改为 const fn(一致性)

架构债设计方案:
- #8 IPC 错误结构化迁移路径(IpcError enum + 前端消费)
- #15 AI 状态机抽离 df-ai-session(4 阶段)
- #18 配置四源统一(AppConfig 单例 + 环境变量覆盖)
- #19 SQLite 连接池(r2d2)
2026-06-29 22:16:45 +08:00

540 lines
21 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, IdeaStatus, Priority, ProjectStatus};
use df_ideas::capture::Idea;
use df_storage::crud::{is_unique_constraint_err, IdeaQuery};
use df_storage::models::{IdeaEvaluationRecord, IdeaRecord, ProjectEventRecord, 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> {
// IDEA-FIX-03: priority 值域校验 ∈ 0..=3 (对标 tasks B-260615-15)。
// priority_from_i32 对越界值兜底归并(>=3→Critical),但 IPC 入口应显式拒非法值,
// 防 LLM/前端传 99 等被静默吞为 Critical。
if !(0..=3).contains(&input.priority) {
return Err(format!(
"priority 必须在 0..=3 (0=critical/1=high/2=medium/3=low,对齐前端约定 api/types.ts:141),收到 {}",
input.priority
));
}
let now = now_millis();
let record = IdeaRecord {
id: new_id(),
title: input.title,
description: input.description,
status: IdeaStatus::Draft,
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)?;
// 知识图谱 Phase 2(对标设计 §2.4):idea_created 事件**暂不埋点**。
// 原因:project_events.project_id 是 NOT NULL + FK(PRAGMA foreign_keys=ON),
// 而 idea 无 project_id(立项前不属于任何项目)。设计 §2.5 计划用系统初始化创建的
// Inbox 项目作为无主 idea 的归属,但 Inbox 项目尚未实现(独立任务)。
// 写 NULL 会违反 NOT NULL,写不存在的 project_id 会违反 FK——两者都会让 best-effort
// 退化成「写失败 warn」,无实际价值且噪音。Inbox 项目落地后此处补 idea_created 埋点。
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)
}
/// 删除灵感(软删 → 回收站,可恢复)。对标 delete_task(SET deleted_at=now)。
#[tauri::command]
pub async fn delete_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.ideas.soft_delete(&id).await.map_err(err_str)
}
/// 恢复灵感(从回收站还原,清 deleted_at)。对标 restore_task / restore_project。
#[tauri::command]
pub async fn restore_idea(state: State<'_, AppState>, id: String) -> Result<bool, String> {
state.ideas.restore(&id).await.map_err(err_str)
}
/// 双向同步关联关系:原子地设置主体灵感的关联目标列表,并自动添加/移除反向关联。
/// 主体灵感 + 所有受影响的关联目标在同一 SQLite 事务中更新,保证原子性。
#[tauri::command]
pub async fn relate_ideas(
state: State<'_, AppState>,
subject_id: String,
target_ids: Vec<String>,
) -> Result<(), String> {
state
.ideas
.sync_related_ids(&subject_id, &target_ids)
.await
.map_err(err_str)
}
/// 列出回收站灵感(deleted_at IS NOT NULL,按更新时间降序)。对标 list_deleted_projects。
#[tauri::command]
pub async fn list_deleted_ideas(state: State<'_, AppState>) -> Result<Vec<IdeaRecord>, String> {
state.ideas.list_deleted().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)。
// create_from_idea 返回 Result(任务 #16: 名称空校验下沉领域层)。
let project = df_project::manager::ProjectManager::create_from_idea(
record.title.clone(),
record.description.clone(),
id.clone(),
).map_err(|e| e.to_string())?;
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: ProjectStatus::Planning,
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: IdeaStatus::Promoted,
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.soft_delete(&project_id).await {
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
}
return Err(format!("灵感立项回写失败(已回滚项目创建): {}", e));
}
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):idea_promoted 事件。best-effort 不阻断。
// 灵感此时已立项为新项目(project_id 存在,FK 满足),事件挂在 project_id 下,
// entity 指向 idea,from_state=draft→to_state=promoted。
let event = ProjectEventRecord {
id: new_id(),
project_id: project_id.clone(),
event_type: "idea_promoted".to_string(),
entity_type: Some("idea".to_string()),
entity_id: Some(id.clone()),
from_state: Some("draft".to_string()),
to_state: Some("promoted".to_string()),
context_json: None,
source: Some("human".to_string()),
conversation_id: None,
created_at: now_millis(),
};
if let Err(e) = state.project_events.insert(event).await {
tracing::warn!(
idea_id = %id,
project_id = %project_id,
error = %e,
"[事件流] idea_promoted 埋点写入失败(不阻断立项)"
);
}
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: IdeaStatus::PendingReview,
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-FIX-05: 读真实 related_ids(原硬编码 Vec::new() 丢关联上下文)。解析同 tags 模式。
let related_ids: Vec<String> = match record.related_ids.as_deref() {
Some(r) => match serde_json::from_str::<Vec<String>>(r) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, idea_id = %record.id, "[ideas] related_ids JSON 解析失败,降级空");
Vec::new()
}
},
None => Vec::new(),
};
Idea {
id: record.id.clone(),
title: record.title.clone(),
description: record.description.clone(),
// IDEA-FIX-05: 读真实 status(原硬编码 Draft 丢真实状态,评估上下文完整性)
status: status_from_str(record.status.as_str()),
priority: priority_from_i32(record.priority),
scores: None,
tags,
source: record.source.clone(),
related_ids,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
}
}
/// i32 优先级 → Priority 枚举(对齐**前端约定** 0=critical/1=high/2=medium/3=low,
/// 见 api/types.ts:141 + Tasks.vue option + constants/project.ts PRIORITY_CLASS)。
/// 注:df-types Priority 枚举 discriminant 是 Low=0/Critical=3(历史定义,与前端相反),
/// 故本函数手动映射对齐前端语义,不靠 discriminant。Priority as_str 序列化("low"/"critical")
/// 不受影响。verify agent 发现的预存跨层 bug(父⑤⑤.2 收尾揪出):原 0=>Low 致 Ideas
/// 表单选 Critical(P0) 存 Low,标签与实际存储/评分相反。
fn priority_from_i32(p: i32) -> Priority {
match p {
0 => Priority::Critical,
1 => Priority::High,
2 => Priority::Medium,
_ => Priority::Low,
}
}
/// IDEA-FIX-05: idea status 字符串(DB snake_case)→ IdeaStatus 枚举。
/// record_to_idea 读真实 status 用(原硬编码 Draft 丢真实状态)。未知值 fallback Draft(同原行为)。
fn status_from_str(s: &str) -> df_types::types::IdeaStatus {
use df_types::types::IdeaStatus;
match s {
"draft" => IdeaStatus::Draft,
"pending_review" => IdeaStatus::PendingReview,
"approved" => IdeaStatus::Approved,
"rejected" => IdeaStatus::Rejected,
"promoted" => IdeaStatus::Promoted,
"archived" => IdeaStatus::Archived,
_ => IdeaStatus::Draft,
}
}
/// 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",
}
}