640 lines
24 KiB
Rust
640 lines
24 KiB
Rust
//! 灵感相关命令
|
||
|
||
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-10,IPC 层 *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",
|
||
}
|
||
}
|