//! 灵感相关命令 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, pub source: Option, } 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, query: Option, ) -> Result, 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, 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 { 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 { 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 { 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 { 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_to(update_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 { // 取出灵感 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-10,IPC 层 *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, Vec)> { 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 = match record.tags.as_deref() { Some(t) => match serde_json::from_str::>(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 { 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", } }