新增: F-01模型能力阶段1+2(ModelConfig数据模型+model_probe探测器+df-storage兼容V18)

This commit is contained in:
2026-06-16 23:33:45 +08:00
parent d3e6f80d2b
commit b3e78e6061
13 changed files with 1216 additions and 7 deletions

View File

@@ -5,6 +5,8 @@ edition = "2021"
[dependencies]
df-core = { path = "../df-core" }
# F-01 阶段1:ModelConfig/deserialize_model_configs 向后兼容反序列化(老字符串数组/新对象数组/null/空)
df-ai-core = { path = "../df-ai-core" }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }

View File

@@ -1032,6 +1032,28 @@ impl_repo!(
// ============================================================
fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord, rusqlite::Error> {
// model_configs:DB TEXT 列存 JSON 字符串。读 Option<String> 兼容老库 NULL,
// 再经 deserialize_model_configs 解析(老字符串数组/新对象数组/空 → Vec<ModelConfig>)。
// 解析失败不致命:降级为空 Vec(防单行坏数据拖垮 list_all)。
let model_configs: Vec<df_ai_core::model::ModelConfig> = {
let raw: Option<String> = row.get("model_configs").ok();
match raw {
None => Vec::new(),
Some(s) => serde_json::from_str::<ModelConfigsWrap>(&format!(
r#"{{"v":{}}}"#,
if s.trim().is_empty() {
"null".to_string()
} else if s.trim_start().starts_with('[') || s.trim_start().starts_with('{') {
s
} else {
// 非 JSON 字面文本(理论不会出现)→ 包装为 JSON 字符串让 deserialize 兜底
serde_json::to_string(&s).unwrap_or_else(|_| "null".into())
}
))
.map(|w| w.v)
.unwrap_or_default(),
}
};
Ok(AiProviderRecord {
id: row.get("id")?,
name: row.get("name")?,
@@ -1040,6 +1062,7 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
base_url: row.get("base_url")?,
default_model: row.get("default_model")?,
models: row.get("models")?,
model_configs,
is_default: row.get::<_, i32>("is_default")? != 0,
config: row.get("config")?,
created_at: row.get("created_at")?,
@@ -1047,6 +1070,14 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
})
}
/// from_row 内部辅助:复用 deserialize_model_configs 解析 DB TEXT 列 JSON。
/// 包一层 { "v": <原始值> } 把任意 JSON 值送进 deserialize_model_configs。
#[derive(serde::Deserialize)]
struct ModelConfigsWrap {
#[serde(default, deserialize_with = "df_ai_core::model::deserialize_model_configs")]
v: Vec<df_ai_core::model::ModelConfig>,
}
fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversationRecord, rusqlite::Error> {
Ok(AiConversationRecord {
id: row.get("id")?,
@@ -1118,22 +1149,25 @@ impl_repo!(
from_row => |row| ai_provider_from_row(row),
insert => |conn, rec| {
let is_default = if rec.is_default { 1i32 } else { 0i32 };
// model_configs:Vec<ModelConfig> → JSON 字符串落 TEXT 列
let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into());
conn.execute(
"INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, is_default, config, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
"INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_key, base_url, default_model, models, model_configs, is_default, config, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
rec.id, rec.name, rec.provider_type, rec.api_key, rec.base_url,
rec.default_model, rec.models, is_default, rec.config, rec.created_at, rec.updated_at
rec.default_model, rec.models, model_configs_json, is_default, rec.config, rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
let is_default = if rec.is_default { 1i32 } else { 0i32 };
let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into());
conn.execute(
"UPDATE ai_providers SET name = ?1, provider_type = ?2, api_key = ?3, base_url = ?4, default_model = ?5, models = ?6, is_default = ?7, config = ?8, updated_at = ?9 WHERE id = ?10",
"UPDATE ai_providers SET name = ?1, provider_type = ?2, api_key = ?3, base_url = ?4, default_model = ?5, models = ?6, model_configs = ?7, is_default = ?8, config = ?9, updated_at = ?10 WHERE id = ?11",
params![
rec.name, rec.provider_type, rec.api_key, rec.base_url,
rec.default_model, rec.models, is_default, rec.config, rec.updated_at, rec.id
rec.default_model, rec.models, model_configs_json, is_default, rec.config, rec.updated_at, rec.id
],
)
}
@@ -1693,6 +1727,83 @@ mod tests {
// ---------- COLS 漂移防护(CR-260615-03) ----------
/// model_configs DB roundtrip + 老库空兼容(F-01 阶段1)
#[tokio::test]
async fn ai_provider_model_configs_roundtrip_and_old_db_compat() {
let db = Database::open_in_memory().await.expect("open_in_memory");
let repo = AiProviderRepo::new(&db);
use crate::models::AiProviderRecord;
use df_ai_core::model::{Capability, IntelligenceTier, Modality, ModelConfig};
// 新格式:带多模型 + 多维度配置
let configs = vec![
ModelConfig::with_defaults("glm-4-flash"),
ModelConfig {
model_id: "glm-4v".into(),
modalities: vec![Modality::Text, Modality::Vision],
capabilities: vec![Capability::ToolUse],
intelligence: IntelligenceTier::Plus,
weight: 70,
context_window: 128_000,
..ModelConfig::with_defaults("glm-4v")
},
];
let rec = AiProviderRecord {
id: "p1".into(),
name: "测试".into(),
provider_type: "openai_compat".into(),
api_key: String::new(),
base_url: "https://x".into(),
default_model: "glm-4-flash".into(),
models: None,
model_configs: configs.clone(),
is_default: false,
config: None,
created_at: "0".into(),
updated_at: "0".into(),
};
repo.insert(rec).await.expect("insert");
let got = repo.get_by_id("p1").await.expect("get").expect("row exists");
assert_eq!(got.model_configs.len(), 2);
assert_eq!(got.model_configs[0].model_id, "glm-4-flash");
assert_eq!(got.model_configs[1].model_id, "glm-4v");
assert_eq!(got.model_configs[1].intelligence, IntelligenceTier::Plus);
assert_eq!(got.model_configs[1].context_window, 128_000);
// 老库空兼容:直接写 model_configs=NULL 的行(模拟 V18 之前的老库行)
// 然后 from_row 应得空 Vec
{
let conn = db.conn();
let g = conn.lock().await;
g.execute(
"INSERT OR REPLACE INTO ai_providers \
(id,name,provider_type,api_key,base_url,default_model,models,model_configs,is_default,config,created_at,updated_at) \
VALUES ('old','','openai_compat','','','','{}',NULL,0,NULL,'0','0')",
[],
)
.expect("raw insert old row");
}
let old = repo.get_by_id("old").await.expect("get").expect("old row");
assert!(old.model_configs.is_empty(), "NULL 列应得空 Vec");
// 老格式字符串数组 JSON(向后兼容 deserialize_model_configs)
{
let conn = db.conn();
let g = conn.lock().await;
g.execute(
"INSERT OR REPLACE INTO ai_providers \
(id,name,provider_type,api_key,base_url,default_model,models,model_configs,is_default,config,created_at,updated_at) \
VALUES ('legacy','','openai_compat','','','','{}','[\"glm-4-flash\",\"glm-4v\"]',0,NULL,'0','0')",
[],
)
.expect("raw insert legacy row");
}
let legacy = repo.get_by_id("legacy").await.expect("get").expect("legacy row");
assert_eq!(legacy.model_configs.len(), 2, "老字符串数组应转 2 个默认 ModelConfig");
assert_eq!(legacy.model_configs[0].model_id, "glm-4-flash");
}
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
#[test]

View File

@@ -34,7 +34,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
let steps: [(i32, fn(&Connection) -> Result<()>); 17] = [
let steps: [(i32, fn(&Connection) -> Result<()>); 18] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -52,6 +52,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(15, migrate_v15),
(16, migrate_v16),
(17, migrate_v17),
(18, migrate_v18),
];
for (version, migrate_fn) in steps {
@@ -312,6 +313,21 @@ fn migrate_v17(conn: &Connection) -> Result<()> {
Ok(())
}
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置,F-01 阶段1)
///
/// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容:
/// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17 模式),对新库/老库均安全。
fn migrate_v18(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_providers", "model_configs") {
conn.execute("ALTER TABLE ai_providers ADD COLUMN model_configs TEXT", [])?;
tracing::info!("v18: 补建 ai_providers.model_configs 列(模型能力配置)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [18])?;
tracing::info!("迁移 v18 完成");
Ok(())
}
/// V1 建表 SQL
const V1_SQL: &str = "
-- 想法表

View File

@@ -1,5 +1,6 @@
//! 数据模型定义 — 与数据库表对应的 Rust 结构体
use df_ai_core::model::{deserialize_model_configs, ModelConfig};
use serde::{Deserialize, Serialize};
// ============================================================
@@ -152,6 +153,11 @@ pub struct AiProviderRecord {
pub base_url: String,
pub default_model: String,
pub models: Option<String>, // JSON array of model names
/// 模型能力配置数组(F-01 阶段1,4 维度 + 路由控制)。
/// DB TEXT 列存 JSON 字符串,from_row 经 deserialize_model_configs 解析。
/// 向后兼容:老库 NULL/空/老字符串数组 → 空 Vec 或转默认 ModelConfig。default_model 保留过渡。
#[serde(default, deserialize_with = "deserialize_model_configs")]
pub model_configs: Vec<ModelConfig>,
pub is_default: bool,
pub config: Option<String>, // JSON extra config
pub created_at: String,

View File

@@ -209,7 +209,7 @@ mod tests {
let rec = AiProviderRecord {
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
api_key: "sk-db-fallback".into(), base_url: "https://x".into(),
default_model: "m".into(), models: None, is_default: false,
default_model: "m".into(), models: None, model_configs: Vec::new(), is_default: false,
config: None, created_at: "0".into(), updated_at: "0".into(),
};
assert_eq!(resolve_provider_secret(&rec), "sk-db-fallback");