Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ struct ManifestDataDir {
/// not a side effect of a code change.
// 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current`
// 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71
const CURRENT_SCHEMA_VERSION: u32 = 104;
const CURRENT_SCHEMA_VERSION: u32 = 105;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
5 changes: 5 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,8 @@ pub struct KnowledgeBase {
/// 治理开关(0025,**缺省开**,见 0050):开着,govern 任务按先进先出过等人的
/// 重复对,先读台账里人的先例再裁;关掉,任务在两簇之间看到就停
pub governance: bool,
/// Opt-in confirmation of well-grounded exploration definitions (0011, #504).
pub auto_confirm_mappings: bool,
/// 这次打开治理的时刻;保险丝只数它之后的撤回(0025 决定 9)。
/// 新库生下来就开着治理,这一格于是等于建库的时刻(0050)
pub governance_since: Option<DateTime<Utc>>,
Expand Down Expand Up @@ -1265,6 +1267,9 @@ pub struct ConceptMapping {
/// 人从零写的口径记写的人;探索提的为空(#562)。`decided_by` 分不出这件事——
/// 探索提的经人确认之后同样有 decided_by
pub written_by: Option<Uuid>,
pub decided_by: Option<Uuid>,
pub confidence: Option<f64>,
pub rationale: Option<String>,
}

/// 一处公理违规,配好展示所需的三元组文本(见 `axiom_violations`)。
Expand Down
108 changes: 107 additions & 1 deletion crates/utopia-server/src/api/kbs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ pub struct UpdateKbReq {
/// 打开那一刻就排一轮,关掉后任务在两簇之间看到就停。见 docs/decisions/0025
#[serde(default)]
pub governance: Option<bool>,
#[serde(default)]
pub auto_confirm_mappings: Option<bool>,
/// 人写的数据约定(「测试单不算数」这类 schema 里没有的规则),问数与探索的
/// 提示词都读它。探索生成的描述在另一个字段,PATCH 不了。见 #570
#[serde(default)]
Expand Down Expand Up @@ -125,6 +127,7 @@ pub async fn create(
None,
None,
None,
None,
)
.await?;
}
Expand Down Expand Up @@ -193,7 +196,29 @@ pub async fn update(
Path(id): Path<Uuid>,
Json(req): Json<UpdateKbReq>,
) -> ApiResult<Json<KnowledgeBase>> {
kb_with_role(&state, &user, id, Role::Admin).await?;
// Editors may opt in; a mixed request still needs the usual Admin role.
let setting_only = req.auto_confirm_mappings.is_some()
&& req.name.is_none()
&& req.description.is_none()
&& req.visibility.is_none()
&& req.auto_extend_ontology.is_none()
&& req.ontology_lang.is_none()
&& req.materialize_inferences.is_none()
&& req.inference_interval_minutes.is_none()
&& req.auto_type_resolution.is_none()
&& req.governance.is_none()
&& req.data_conventions.is_none();
kb_with_role(
&state,
&user,
id,
if setting_only {
Role::Editor
} else {
Role::Admin
},
)
.await?;
let kb = utopia_store::kbs::update(
&state.pool,
id,
Expand All @@ -207,6 +232,7 @@ pub async fn update(
req.auto_type_resolution,
req.governance,
req.data_conventions.as_deref().map(str::trim),
req.auto_confirm_mappings,
)
.await?;
// 打开开关就自动开始处理:排一轮,同库已排着的不重复
Expand All @@ -227,6 +253,7 @@ pub async fn update(
"name": req.name,
"visibility": req.visibility,
"governance": req.governance,
"auto_confirm_mappings": req.auto_confirm_mappings,
"data_conventions": req.data_conventions.is_some(),
}),
)
Expand Down Expand Up @@ -460,3 +487,82 @@ pub async fn list_packs(AuthUser(_): AuthUser) -> ApiResult<Json<serde_json::Val
.collect();
Ok(Json(json!({ "packs": packs })))
}

#[cfg(test)]
mod mapping_confirmation_tests {
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use serde_json::json;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;

#[tokio::test]
async fn an_editor_can_opt_in_but_other_settings_still_need_an_admin() -> anyhow::Result<()> {
let Some(url) = utopia_store::test_db::url() else {
return Ok(());
};
let pool = sqlx::PgPool::connect(&url).await?;
utopia_store::db::migrate(&pool).await?;
let [org, ws, kb, other, admin, editor, viewer] = std::array::from_fn(|_| Uuid::now_v7());
sqlx::raw_sql(&format!(
"INSERT INTO organizations(id,name) VALUES ('{org}','auto-mapping-route');
INSERT INTO workspaces(id,org_id,name) VALUES ('{ws}','{org}','auto-mapping-route');
INSERT INTO knowledge_bases(id,workspace_id,name,visibility) VALUES
('{kb}','{ws}','mappings','restricted'), ('{other}','{ws}','other','restricted');
INSERT INTO users(id,org_id,email,display_name,password_hash) VALUES
('{admin}','{org}','{admin}@map.test','Admin','unused'),
('{editor}','{org}','{editor}@map.test','Editor','unused'),
('{viewer}','{org}','{viewer}@map.test','Viewer','unused');
INSERT INTO kb_members(kb_id,user_id,role) VALUES
('{kb}','{admin}','admin'), ('{kb}','{editor}','editor'), ('{kb}','{viewer}','viewer');"
)).execute(&pool).await?;
let result = async {
let dir = tempfile::tempdir()?;
let cfg = utopia_core::config::AppConfig {
data_dir: dir.path().to_string_lossy().into_owned(), ..Default::default()
};
let state = crate::state::AppState::new(pool.clone(), &cfg,
Arc::new(utopia_search::SearchIndex::open(&dir.path().join("search"))?), "test-only".into());
let patch = |user, base, body: serde_json::Value| {
let state = state.clone();
let cfg = cfg.clone();
async move {
let token = crate::auth::issue_token(&state, user)?;
let response = crate::api::router(state, &cfg).oneshot(
Request::patch(format!("/api/v1/kbs/{base}"))
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))?
).await?;
let status = response.status();
let body: serde_json::Value = serde_json::from_slice(&to_bytes(response.into_body(), 1024*1024).await?)?;
anyhow::Ok((status, body))
}
};
assert!(!utopia_store::kbs::get(&pool, kb).await?.auto_confirm_mappings);
let (status, body) = patch(editor, kb, json!({"auto_confirm_mappings": true})).await?;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["auto_confirm_mappings"], true);
assert_eq!(patch(viewer, kb, json!({"auto_confirm_mappings": false})).await?.0, StatusCode::FORBIDDEN);
assert_eq!(patch(editor, other, json!({"auto_confirm_mappings": true})).await?.0, StatusCode::NOT_FOUND);
assert_eq!(patch(editor, kb, json!({"auto_confirm_mappings": false, "name": "Changed"})).await?.0, StatusCode::FORBIDDEN);
assert!(utopia_store::kbs::get(&pool, kb).await?.auto_confirm_mappings);
assert_eq!(patch(admin, kb, json!({"description": "Changed"})).await?.0, StatusCode::OK);
assert!(utopia_store::kbs::get(&pool, kb).await?.auto_confirm_mappings);
assert_eq!(patch(editor, kb, json!({"auto_confirm_mappings": false})).await?.0, StatusCode::OK);
let events: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT detail FROM audit_events WHERE kb_id = $1 AND actor_id = $2 AND action = 'kb.updated' ORDER BY created_at"
).bind(kb).bind(editor).fetch_all(&pool).await?;
assert_eq!(events.len(), 2);
assert_eq!(events[0]["auto_confirm_mappings"], true);
assert_eq!(events[1]["auto_confirm_mappings"], false);
anyhow::Ok(())
}.await;
sqlx::query("DELETE FROM knowledge_bases WHERE id = ANY($1)")
.bind(&[kb, other][..])
.execute(&pool)
.await?;
result
}
}
132 changes: 126 additions & 6 deletions crates/utopia-server/src/mappings.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
//! 问数语义映射的 Agentic 探索:读挂载源的 schema + KB 既有概念,让 LLM 提议
//! "业务概念(Metric/Dimension 实体)→ 数据资产定义" 的映射。
//!
//! 提议写进 `concept_mappings`(status = proposed)→ Review 页自成一档,
//! Confirm / Reject 之后 status 变 confirmed,问数只读确认过的那些。
//! 提议写进 `concept_mappings`,数据映射页确认或拒绝;问数只读已确认的定义。
//! 库可选择自动确认有置信度与理由支撑的定义(0011,#504,默认关)。
//!
//! **从前它是一条 0.6 置信的 `mapped_to` 事实**,借「低置信事实」那一档露面。
//! 搬出来的理由见 0011:它不是关于世界的断言,是配置——而「确认」这个动作
//! 当时是 `UPDATE facts SET confidence = 1.0`,原地改一张不许原地改的表。
//! agent 只提议,口径生效权在人——与消解"宁分勿合"同一哲学。
//! 人的决定与修改不会被后续探索覆盖;自动确认保留模型的理由与审计。

use crate::llm_util;
use crate::state::AppState;
Expand Down Expand Up @@ -311,9 +311,11 @@ async fn explore(state: &AppState, kb_id: Uuid, run: Uuid) -> anyhow::Result<()>
\"definition\": {{\"table\": \"schema.table\", \"expr\": \"SQL expression\", \
\"sql\": \"full SELECT if joins are needed (optional)\", \"unit\": \"optional\"}}, \
\"summary\": \"one line: source + expression, shown to reviewers\", \
\"rationale\": \"why this mapping, citing column comments\"}}\n\
\"confidence\": 0.0, \"rationale\": \"why this mapping, citing column comments\"}}\n\
Metrics are aggregatable quantities (use sum/count/avg in expr); dimensions are \
group-by columns. Propose at most {cap}, only well-grounded ones.",
group-by columns. Confidence is a number from 0 to 1 measuring whether the exact \
definition follows the schema and stated conventions; reduce it when business meaning \
is uncertain. Propose at most {cap}, only well-grounded ones.",
if existing_names.is_empty() {
"(none)".into()
} else {
Expand Down Expand Up @@ -428,7 +430,7 @@ async fn explore(state: &AppState, kb_id: Uuid, run: Uuid) -> anyhow::Result<()>
if let Some(t) = s("table") {
covered.insert(t);
}
let (_, written) = utopia_store::mappings::propose(
let (_, written) = utopia_store::mappings::propose_with_evidence(
&state.pool,
kb_id,
resolved.entity_id,
Expand All @@ -440,6 +442,8 @@ async fn explore(state: &AppState, kb_id: Uuid, run: Uuid) -> anyhow::Result<()>
// summary 是给人看的那句:Review 列表与问数 prompt 都靠它
p["summary"].as_str().or(def["summary"].as_str()),
def["derived"].as_bool().unwrap_or(false),
p["confidence"].as_f64(),
p["rationale"].as_str(),
)
.await?;
if written {
Expand Down Expand Up @@ -619,4 +623,120 @@ mod tests {
// **0 条上限会把「连不上」变成「模型什么都没提」**,两件事又混在一起了
assert_eq!(proposal_cap(0), 12);
}
#[tokio::test]
async fn exploration_keeps_the_models_evidence_and_confirms_only_after_opt_in(
) -> anyhow::Result<()> {
use serde_json::json;
use std::sync::Arc;
use uuid::Uuid;
use wiremock::{
matchers::{body_string_contains, method, path},
Mock, MockServer, ResponseTemplate,
};
let Some(url) = utopia_store::test_db::url() else {
return Ok(());
};
let pool = sqlx::PgPool::connect(&url).await?;
utopia_store::db::migrate(&pool).await?;
let [org, ws, kb, user] = std::array::from_fn(|_| Uuid::now_v7());
sqlx::raw_sql(&format!(
"INSERT INTO organizations(id,name) VALUES ('{org}','mapping-exploration');
INSERT INTO workspaces(id,org_id,name) VALUES ('{ws}','{org}','mapping-exploration');
INSERT INTO knowledge_bases(id,workspace_id,name) VALUES ('{kb}','{ws}','mapping-exploration');
INSERT INTO users(id,org_id,email,display_name,password_hash)
VALUES ('{user}','{org}','{user}@mapping.test','Editor','unused');"
)).execute(&pool).await?;
let model = MockServer::start().await;
let proposals = json!([{"name":"Source rows", "kind":"metric", "source":"test",
"definition":{"table":"public.entities", "expr":"COUNT(*)"},
"confidence":0.8, "rationale":"Count each entity row"}])
.to_string();
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(body_string_contains("ONLY a JSON array"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices":[{"message":{"role":"assistant","content":proposals}}]
})))
.mount(&model)
.await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.and(body_string_contains("ONLY a JSON object"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices":[{"message":{"role":"assistant","content":"{}"}}]
})))
.mount(&model)
.await;
let source = utopia_store::datasources::create(
&pool,
&format!("mapping-{}", kb.simple()),
"postgres",
&url,
user,
)
.await?;
let result = async {
sqlx::query("INSERT INTO kb_data_sources(kb_id,data_source_id) VALUES ($1,$2)")
.bind(kb)
.bind(source)
.execute(&pool)
.await?;
utopia_store::settings::upsert(
&pool,
ws,
Some(&model.uri()),
None,
Some("scripted"),
None,
None,
None,
None,
)
.await?;
let dir = tempfile::tempdir()?;
let cfg = utopia_core::config::AppConfig {
data_dir: dir.path().to_string_lossy().into_owned(),
..Default::default()
};
let state = crate::state::AppState::new(
pool.clone(),
&cfg,
Arc::new(utopia_search::SearchIndex::open(
&dir.path().join("search"),
)?),
"test-only".into(),
);
super::explore_mappings(&state, kb).await?;
let pending = utopia_store::mappings::proposed(&pool, kb, 10, 0)
.await?
.remove(0);
assert_eq!(pending.confidence, Some(0.8));
assert_eq!(pending.rationale.as_deref(), Some("Count each entity row"));
sqlx::query("UPDATE knowledge_bases SET auto_confirm_mappings = true WHERE id = $1")
.bind(kb)
.execute(&pool)
.await?;
super::explore_mappings(&state, kb).await?;
let effective = utopia_store::mappings::confirmed(&pool, kb, 10)
.await?
.remove(0);
assert_eq!(effective.id, pending.id);
assert_eq!(effective.decided_by, None);
let requests = model.received_requests().await.unwrap();
let prompt = requests
.iter()
.find(|r| String::from_utf8_lossy(&r.body).contains("ONLY a JSON array"))
.unwrap();
assert!(String::from_utf8_lossy(&prompt.body).contains("confidence"));
assert_eq!(requests.len(), 4);
anyhow::Ok(())
}
.await;
sqlx::query("DELETE FROM knowledge_bases WHERE id = $1")
.bind(kb)
.execute(&pool)
.await?;
utopia_store::datasources::delete(&pool, source).await?;
result
}
}
3 changes: 3 additions & 0 deletions crates/utopia-store/src/kbs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ pub async fn update(
auto_type_resolution: Option<bool>,
governance: Option<bool>,
data_conventions: Option<&str>,
auto_confirm_mappings: Option<bool>,
) -> AppResult<KnowledgeBase> {
// 改语言不回头重写已有的类——它们已经是这个库的数据,可能有人手工调过。
// 这一列往后管的是**新**描述(自动扩本体、AI 建议)写成什么语言
Expand Down Expand Up @@ -151,6 +152,7 @@ pub async fn update(
ELSE governance_since END,
-- 人写的约定;清空要送空串,送 null 等于不改(与 description 同一约定)
data_conventions = COALESCE($11, data_conventions),
auto_confirm_mappings = COALESCE($12, auto_confirm_mappings),
updated_at = now()
WHERE id = $1 RETURNING *",
)
Expand All @@ -165,6 +167,7 @@ pub async fn update(
.bind(auto_type_resolution)
.bind(governance)
.bind(data_conventions)
.bind(auto_confirm_mappings)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound)
Expand Down
Loading
Loading