Files
DevFlow/src-tauri/src/commands/idea.rs
T

640 lines
24 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> {
// BE-CMD-4:status 值合法性校验(防任意值进库)+ 拒绝经 update_field 直达 promoted
// (半立项:绕过 promote_idea 不建项目不写 promoted_to,须走立项流程)。
if field == "status" {
if IdeaStatus::from_db_str(value.trim()).is_none() {
return Err(format!(
"非法 status 值 {:?},合法值: draft/pending_review/approved/rejected/promoted/archived",
value
));
}
if value.trim() == "promoted" {
return Err(
"status 不能直接置为 promoted:立项须走 promote_idea(会创建项目并回写 promoted_to)"
.to_string(),
);
}
}
// BE-CMD-4(含 BE-CMD-23):related_ids/scores 是 JSON 字段,补合法性校验(防脏 JSON 落库)。
if field == "related_ids" || field == "scores" {
serde_json::from_str::<serde_json::Value>(&value)
.map_err(|e| format!("{field} 不是合法 JSON: {e}"))?;
}
// LW-6(BE-CMD-5):update_field_active 过滤软删(deleted_at IS NULL),回收站灵感不可改字段。
let updated = state
.ideas
.update_field_active(&id, &field, &value)
.await
.map_err(err_str)?;
if !updated {
return Err(format!("灵感 ID {id} 不存在或已删除"));
}
Ok(true)
}
/// 删除灵感(软删 → 回收站,可恢复)。对标 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)?;
// LW-8(BE-CMD-7):CAS 回写灵感(status=promoted + promoted_to,WHERE id AND promoted_to IS NULL)。
// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;本方法原子「立项认领」,
// 仅首个 affected=1 成功,第二个 affected=0 → 判定「已立项」并补偿软删刚建项目(回滚)。
// 替代原 update_full(无条件覆盖):并发下两个项目都保留、灵感只指向一个,留悬空项目。
if !state
.ideas
.claim_promotion(&id, &project_id)
.await
.map_err(err_str)?
{
tracing::warn!("灵感 {id} 已被并发立项,回滚本次新建项目 {project_id}");
if let Err(del_err) = state.projects.soft_delete(&project_id).await {
tracing::error!("补偿软删项目 {project_id} 也失败(需人工清理): {del_err}");
}
return Err(format!("灵感 {id} 已立项(并发双击),本次立项已回滚"));
}
// 知识图谱 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}"))?;
// 构造 engine(单次评估)
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(),
};
evaluate_one(&state, record, &engine).await
}
/// 批量评估灵感:复用同一 AdversarialEngine 实例(同 provider/model_pool),逐条评估并持久化。
///
/// - 成功的灵感返回更新后的记录,失败的灵感记录错误信息,不中断后续评估。
/// - provider 构造仅一次(批量场景减少重复初始化开销)。
/// - 返回 BatchEvalResult { success, errors },前端据此展示部分成功/失败。
#[tauri::command]
pub async fn evaluate_ideas_batch(
state: State<'_, AppState>,
ids: Vec<String>,
) -> Result<BatchEvalResult, String> {
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 mut success = Vec::new();
let mut errors = Vec::new();
for id in ids {
let record = match state.ideas.get_by_id(&id).await {
Ok(Some(r)) => r,
Ok(None) => {
errors.push(BatchEvalError {
id: id.clone(),
error: format!("灵感不存在: {id}"),
});
continue;
}
Err(e) => {
errors.push(BatchEvalError {
id: id.clone(),
error: e.to_string(),
});
continue;
}
};
match evaluate_one(&state, record, &engine).await {
Ok(updated) => success.push(updated),
Err(e) => errors.push(BatchEvalError { id, error: e }),
}
}
Ok(BatchEvalResult { success, errors })
}
/// 批量评估结果
#[derive(Debug, serde::Serialize)]
pub struct BatchEvalResult {
/// 成功评估的灵感记录
pub success: Vec<IdeaRecord>,
/// 失败的灵感 ID + 错误信息
pub errors: Vec<BatchEvalError>,
}
/// 批量评估单项错误
#[derive(Debug, serde::Serialize)]
pub struct BatchEvalError {
pub id: String,
pub error: String,
}
/// 单条灵感评估内部函数(evaluate_idea / evaluate_ideas_batch 共用)。
///
/// 接收已构造的 AdversarialEngine(批量场景复用同一实例),完成:
/// 1. 多维评分 + 对抗评估
/// 2. 组装 ai_analysis / scores JSON
/// 3. 原子写回主表(update_full
/// 4. 追加评估历史快照(idea_evaluations,含 version 唯一约束重试)
async fn evaluate_one(
state: &State<'_, AppState>,
record: IdeaRecord,
engine: &df_ideas::adversarial::AdversarialEngine,
) -> Result<IdeaRecord, String> {
let id = record.id.clone();
// LW-7(BE-CMD-6):终态灵感不可再评估(防无条件覆盖 pending_review 打回终态)。
// promoted(已立项)/archived(已归档)是终态,评估会把 status 覆盖回 pending_review,
// 破坏「已立项/已归档不可回退」语义。软删灵感由 evaluate_idea/batch 的存在性检查已过滤。
if matches!(record.status, IdeaStatus::Promoted | IdeaStatus::Archived) {
return Err(format!(
"灵感 {id} 已是终态({}),不可再评估",
record.status.as_str()
));
}
let idea = record_to_idea(&record);
// 多维评分(0-10IPC 层 *10 缩放为 0-100
let scores = df_ideas::scoring::ScoringEngine::compute_default(&idea);
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)。
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 单调递增)。
// version 并发重复兜底(V25 唯一约束 + 重试)。
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) => {
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",
}
}