diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13f82ba1f..40a04bf4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: inputs: tag: - description: "Image tag to publish (e.g. 0.1.0-rc1)" + description: "Image tag to publish (e.g. 0.1.0-rc2)" required: true permissions: diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 69cd16736..f4fb8879b 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -194,6 +194,9 @@ pub struct AuditEventView { pub target_kind: String, pub target_id: Option, pub detail: serde_json::Value, + /// NULL = 引擎自动(裁决器合并、一致性检查、推理物化……)。界面靠它把 + /// 「没有人」和「人已被移除」分开:后者 actor_id 还在,只是查不到显示名 + pub actor_id: Option, pub actor_name: Option, pub created_at: DateTime, } diff --git a/crates/utopia-server/src/api/kbs.rs b/crates/utopia-server/src/api/kbs.rs index 06a381632..bffba3326 100644 --- a/crates/utopia-server/src/api/kbs.rs +++ b/crates/utopia-server/src/api/kbs.rs @@ -359,14 +359,30 @@ async fn install_packs( if pack_ids.is_empty() { return Ok(()); } + let mut packs = Vec::with_capacity(pack_ids.len()); for id in pack_ids { let pack = crate::ontology_packs::get(id) .ok_or_else(|| AppError::invalid("unknown_pack", format!("未知的本体包:{id}")))?; - let bytes = crate::ontology_packs::bytes(pack)?; - crate::owl_import::apply(state, kb_id, actor, pack.filename, &bytes) + packs.push((pack, crate::ontology_packs::bytes(pack)?)); + } + for (pack, bytes) in &packs { + crate::owl_import::apply(state, kb_id, actor, pack.filename, bytes) .await .map_err(|e| AppError::Other(anyhow::anyhow!("装本体包 {} 失败:{e}", pack.id)))?; } + // 第二遍:跨包的 domain / range。包是挨个装的,先装的看不见后装的类—— + // W3C Org 的 headOf 要等 FOAF 的 Agent(#222)。只装一个包时没有"别的包" + if packs.len() > 1 { + for (pack, bytes) in &packs { + let (d, r) = + crate::owl_import::relink_domains_ranges(state, kb_id, pack.filename, bytes) + .await + .map_err(|e| { + AppError::Other(anyhow::anyhow!("补本体包 {} 的签名失败:{e}", pack.id)) + })?; + tracing::debug!(%kb_id, pack = pack.id, domains = d, ranges = r, "跨包签名补链"); + } + } Ok(()) } diff --git a/crates/utopia-server/src/api/sources_routes.rs b/crates/utopia-server/src/api/sources_routes.rs index 2c35a66bb..baaae99fd 100644 --- a/crates/utopia-server/src/api/sources_routes.rs +++ b/crates/utopia-server/src/api/sources_routes.rs @@ -285,6 +285,10 @@ pub async fn sync_now( #[derive(Deserialize)] pub struct IngestBody { pub filename: String, + /// 墓碑推送(`deleted: true`)可以不带 content——指南一直这么写,而字段 + /// 从前是必填,缺了就在反序列化那一步被拒。其余情况仍然必填:空串在 + /// 下面的校验里挡 + #[serde(default)] pub content: String, #[serde(default)] pub doc_time: Option>, @@ -341,14 +345,33 @@ pub async fn ingest( Ok(Json(json!({ "action": action_str(action) }))) } +/// 推送失败的两种性质。**调用方发错了**和**我们这边没接住**得分开:前者记进 +/// run 供集成调试,但不算来源同步失败——来源没坏,是那一次请求不合格; +/// 后者才该把来源标成 failed 并进告警中心。从前两者都走 `finish_sync(error)`, +/// 一次格式错误就让铃铛说"来源同步失败,没有新内容进来" +enum PushError { + /// 4xx:负载不合格(JSON 解析、缺字段) + Rejected(String), + /// 摄入本身失败 + Failed(String), +} + +impl PushError { + fn message(&self) -> &str { + match self { + PushError::Rejected(m) | PushError::Failed(m) => m, + } + } +} + /// 认证之后的推送处理:解析 + 校验 + 摄入/墓碑。错误一律返回文字(记进 run)。 async fn handle_push( state: &AppState, source: &utopia_core::models::Source, bytes: &[u8], -) -> Result { - let body: IngestBody = - serde_json::from_slice(bytes).map_err(|e| format!("Invalid JSON payload: {e}"))?; +) -> Result { + let body: IngestBody = serde_json::from_slice(bytes) + .map_err(|e| PushError::Rejected(format!("Invalid JSON payload: {e}")))?; let identity = body .external_id .as_deref() @@ -357,7 +380,9 @@ async fn handle_push( .unwrap_or_else(|| body.filename.trim()) .to_string(); if identity.is_empty() { - return Err("external_id or filename is required".into()); + return Err(PushError::Rejected( + "external_id or filename is required".into(), + )); } let key = format!("api:{identity}"); @@ -365,12 +390,14 @@ async fn handle_push( if body.deleted { utopia_store::documents::mark_missing_keys(&state.pool, source.id, &[key]) .await - .map_err(|e| e.to_string())?; + .map_err(|e| PushError::Failed(e.to_string()))?; return Ok(crate::ingest_sources::IngestAction::Tombstoned); } if body.filename.trim().is_empty() || body.content.trim().is_empty() { - return Err("filename and content are required".into()); + return Err(PushError::Rejected( + "filename and content are required".into(), + )); } let action = crate::ingest_sources::ingest_item( state, @@ -383,11 +410,11 @@ async fn handle_push( body.doc_time, ) .await - .map_err(|e| e.to_string())?; + .map_err(|e| PushError::Failed(e.to_string()))?; // 失而复得:曾被墓碑标记的身份再次正常推送,摘掉 missing 标记 utopia_store::documents::clear_missing_keys(&state.pool, source.id, &[key]) .await - .map_err(|e| e.to_string())?; + .map_err(|e| PushError::Failed(e.to_string()))?; Ok(action) } @@ -434,10 +461,14 @@ pub async fn push( state.emit_source(source.kb_id); Ok(Json(json!({ "action": action_str(action) }))) } - Err(msg) => { + Err(err) => { + let msg = err.message().to_string(); utopia_store::sources::finish_run(&state.pool, run, source.id, Some(&msg), 0, 0) .await?; - utopia_store::sources::finish_sync(&state.pool, source.id, Some(&msg), 0).await?; + // 只有我们这边没接住才算来源失败;调用方发错了留在 run 历史里就够 + if let PushError::Failed(_) = err { + utopia_store::sources::finish_sync(&state.pool, source.id, Some(&msg), 0).await?; + } state.emit_source(source.kb_id); Err(utopia_core::AppError::Validation(msg).into()) } diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index 087ff453e..235a8bad2 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -300,6 +300,8 @@ async fn run(state: &AppState, document_id: Uuid, proposed_by: Option) -> utopia_store::documents::set_graph_status(&state.pool, document_id, "extracting").await?; state.emit_document(doc.kb_id, document_id); let etypes = utopia_store::graph::entity_types(&state.pool, doc.kb_id).await?; + // 这一轮落过的事实(新建或重复观察):结尾对它们跑一遍签名检查 + let mut touched_facts: Vec = Vec::new(); let rtypes = utopia_store::graph::relation_types(&state.pool, doc.kb_id).await?; // 关系与属性分道:属性走字面值通道,不进关系清单。 // @@ -762,6 +764,7 @@ async fn run(state: &AppState, document_id: Uuid, proposed_by: Option) -> confidence, ) .await?; + touched_facts.push(fact_id); // 属性谓词也留原词:模型偶尔照抄 "person.salary" 全限定名, // 命中的是剥掉前缀后的 key,原样是什么值得留着 utopia_store::graph::add_evidence( @@ -882,6 +885,7 @@ async fn run(state: &AppState, document_id: Uuid, proposed_by: Option) -> confidence, ) .await?; + touched_facts.push(fact_id); utopia_store::graph::add_evidence( &state.pool, fact_id, @@ -1126,6 +1130,7 @@ async fn run(state: &AppState, document_id: Uuid, proposed_by: Option) -> confidence, ) .await?; + touched_facts.push(fact_id); // 重复观察也要挂证据:多来源相互印证,任一来源删除后事实不孤儿化。 // 表层谓词随每次观察落笔——甲块说 "runs on"、乙块说 "optimized for" // 会并进同一条事实,放事实上就是先写者胜,放证据上两个都留着 @@ -1221,6 +1226,36 @@ async fn run(state: &AppState, document_id: Uuid, proposed_by: Option) -> if let Some(msg) = incomplete_reason(&unextracted, chunks.len()) { return Err(anyhow::anyhow!(msg)); } + // 刚落的事实立刻过一遍签名。写入时只掰方向(judge_direction);掰不动的 + // ——两个方向都对不上、或宾语没类型判不了——从前要等人按 Review 里的 + // Run check 才露面,Axioms 一直是 0,图里却躺着反向事实(#222)。 + // 检查失败不影响抽取本身:事实已经在库里,下一次 Run check 仍然查得到 + if !touched_facts.is_empty() { + match utopia_store::reasoning::signature_breaks( + &state.pool, + doc.kb_id, + Some(&touched_facts), + ) + .await + { + Ok(broken) if !broken.is_empty() => { + match utopia_store::reasoning::record_signature_breaks( + &state.pool, + doc.kb_id, + &broken, + ) + .await + { + Ok(_) => state.emit_review(doc.kb_id), + Err(e) => { + tracing::warn!(%document_id, error = %e, "抽取后的签名违规没记进队列") + } + } + } + Ok(_) => {} + Err(e) => tracing::warn!(%document_id, error = %e, "抽取后的签名检查失败"), + } + } utopia_store::documents::set_graph_status(&state.pool, document_id, "done").await?; state.emit_document(doc.kb_id, document_id); diff --git a/crates/utopia-server/src/mappings.rs b/crates/utopia-server/src/mappings.rs index 209be09e8..ec66432c1 100644 --- a/crates/utopia-server/src/mappings.rs +++ b/crates/utopia-server/src/mappings.rs @@ -15,6 +15,39 @@ use uuid::Uuid; const MAX_SCHEMA_CHARS: usize = 12_000; +/// 探索把 schema 里的量与维度落成 Metric / Dimension 实体,而这两个类不在任何 +/// 内置本体包里——0009 之后建库不再自带类。没有它们,下面的 `type_id` 查不到, +/// 每条提议都被 `continue` 吞掉,页面只说"已排队"就再无下文(#223)。 +/// 所以探索前把两个类补上:builtin,描述给抽取提示词,本体页可以改 +async fn ensure_concept_types(pool: &sqlx::PgPool, kb_id: Uuid) -> anyhow::Result<()> { + for (key, label, description) in [ + ( + "metric", + "Metric", + "An aggregatable business quantity (revenue, order count, average ticket) that maps to a definition in a mounted database.", + ), + ( + "dimension", + "Dimension", + "A group-by attribute (region, month, product line) that maps to a column in a mounted database.", + ), + ] { + sqlx::query( + "INSERT INTO entity_types (id, kb_id, key, label, builtin, description) + SELECT $1, $2, $3, $4, TRUE, $5 + WHERE NOT EXISTS (SELECT 1 FROM entity_types WHERE kb_id = $2 AND key = $3)", + ) + .bind(Uuid::now_v7()) + .bind(kb_id) + .bind(key) + .bind(label) + .bind(description) + .execute(pool) + .await?; + } + Ok(()) +} + pub async fn explore_mappings(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> { let kb = utopia_store::kbs::get(&state.pool, kb_id).await?; let settings = utopia_store::settings::get(&state.pool, kb.workspace_id) @@ -27,6 +60,7 @@ pub async fn explore_mappings(state: &AppState, kb_id: Uuid) -> anyhow::Result<( if sources.is_empty() { anyhow::bail!("No data sources mounted"); } + ensure_concept_types(&state.pool, kb_id).await?; // 各源 schema(引擎直读,保证新鲜;限量防 prompt 爆炸) let mut schema_txt = String::new(); @@ -168,6 +202,27 @@ pub async fn explore_mappings(state: &AppState, kb_id: Uuid) -> anyhow::Result<( } tracing::info!(%kb_id, proposals = accepted, "映射探索完成,提议已入审核队列"); + // 一条都没提出来时页面上什么都不会变——Pending 还是 0,而"已排队"那句 + // 早就翻篇了。走告警中心说一声,人才知道该去刷新结构或给列加注释 + if accepted == 0 { + if let Err(e) = utopia_store::alerts::raise( + &state.pool, + utopia_store::alerts::NewAlert { + kb_id: Some(kb_id), + severity: "info", + kind: utopia_store::alerts::kind::MAPPING_EXPLORATION_EMPTY, + min_role: utopia_core::models::Role::Editor, + subject_type: None, + subject_id: None, + detail: serde_json::json!({ "proposals": 0, "sources": source_names }), + }, + ) + .await + { + tracing::warn!(%kb_id, error = %e, "映射探索空结果的告警没写进去"); + } + state.emit_alert(); + } state.emit_review(kb_id); Ok(()) } diff --git a/crates/utopia-server/src/owl_import.rs b/crates/utopia-server/src/owl_import.rs index 40624bb75..cf6edcf57 100644 --- a/crates/utopia-server/src/owl_import.rs +++ b/crates/utopia-server/src/owl_import.rs @@ -323,6 +323,64 @@ pub async fn plan( Ok((plan, proj, format)) } +/// 装完一组包之后再过一遍:把 domain / range 指向**别的包里的类**的那些关系接上。 +/// +/// 单次导入认本文件里的类和库里已有的类(见 [`apply`] 里的 resolve),但包是挨个 +/// 装的:装 W3C Org 时 FOAF 还没来,`headOf` 的 `rdfs:domain foaf:Agent` 就落了空; +/// 等 FOAF 装好,没人回头补。没有 domain 的谓词 `judge_direction` 不判方向, +/// 反向的 `Project Aurora head_of Li Ting` 就原样进图(#222)。 +/// +/// 只补不删,关联表 ON CONFLICT DO NOTHING,重复跑无害。属性不在这里:属性的 +/// 去向在计划阶段就定了(没有 domain 的根本建不出来),事后补 domain 改不了它 +/// 已经是不是一列的事实 +pub async fn relink_domains_ranges( + state: &AppState, + kb_id: Uuid, + filename: &str, + bytes: &[u8], +) -> AppResult<(usize, usize)> { + let format = RdfFormat::detect(filename, bytes); + let proj = ontology_rdf::project(bytes, format).map_err(|e| { + utopia_core::AppError::invalid_detail( + "bad_ontology_file", + "Could not parse this ontology file", + e.to_string(), + ) + })?; + let classes: HashMap = utopia_store::graph::entity_types(&state.pool, kb_id) + .await? + .into_iter() + .filter_map(|t| t.iri.clone().map(|i| (i, t.id))) + .collect(); + let relations: HashMap = utopia_store::graph::relation_types(&state.pool, kb_id) + .await? + .into_iter() + .filter_map(|r| r.iri.clone().map(|i| (i, r.id))) + .collect(); + let mut link_d: Vec<(Uuid, Uuid)> = Vec::new(); + let mut link_r: Vec<(Uuid, Uuid)> = Vec::new(); + for p in &proj.properties { + if p.is_datatype { + continue; + } + let Some(&rid) = relations.get(&p.iri) else { + continue; + }; + link_d.extend( + p.domains + .iter() + .filter_map(|d| classes.get(d).map(|&t| (rid, t))), + ); + link_r.extend( + p.ranges + .iter() + .filter_map(|r| classes.get(r).map(|&t| (rid, t))), + ); + } + utopia_store::ontology::link_domains_ranges_bulk(&state.pool, &link_d, &link_r).await?; + Ok((link_d.len(), link_r.len())) +} + /// 执行计划。属性在类之后落库——它们要挂在 domain 上,而 domain 要等 /// 类先建好并解析 IRI → id(就是下面那个 `id_of`)。 pub async fn apply( @@ -500,9 +558,20 @@ pub async fn apply( continue; }; // domain/range 指向没被建出来的类时只丢那一个,不丢整条关系: - // 关系不像属性那样必须挂在类上,没有 domain 就是"不限主语类型" + // 关系不像属性那样必须挂在类上,没有 domain 就是"不限主语类型"。 + // + // **库里已有的类也算数。** 从前只认本文件里的类,于是 W3C Org 的 + // `headOf rdfs:domain foaf:Agent` 在 FOAF 已经装好的库里照样丢 domain, + // 而没有 domain 的谓词 `judge_direction` 根本不判方向(#222) let resolve = |iris: &[String]| -> Vec { - iris.iter().filter_map(|i| id_of.get(i).copied()).collect() + iris.iter() + .filter_map(|i| { + id_of + .get(i) + .copied() + .or_else(|| existing_by_iri.get(i).copied()) + }) + .collect() }; let domains = resolve(&p.domains); let ranges = resolve(&p.ranges); diff --git a/crates/utopia-store/src/alerts.rs b/crates/utopia-store/src/alerts.rs index bc67a75ef..d4bbfe97a 100644 --- a/crates/utopia-store/src/alerts.rs +++ b/crates/utopia-store/src/alerts.rs @@ -51,6 +51,9 @@ pub mod kind { /// 哪些表——`query_data` 照样入列,模型却只能瞎猜列名。挂载那一刻的报错 /// 只有点按钮的人看得见,此后这个库就一直这样静默地缺着。 pub const SCHEMA_SYNC_FAILED: &str = "data_source.schema_sync_failed"; + /// 库级:映射探索跑完了,一条口径都没提出来。`severity = info`,`min_role = editor`—— + /// 不是故障,是"你在等的那件事没有结果",而页面上没有别的地方能说这句话(#223) + pub const MAPPING_EXPLORATION_EMPTY: &str = "mapping.exploration_empty"; } /// 一次故障。打包成结构体不只是为了参数个数——调用点写 `severity: "error"` diff --git a/crates/utopia-store/src/audit.rs b/crates/utopia-store/src/audit.rs index c2c480001..9cd1e63ef 100644 --- a/crates/utopia-store/src/audit.rs +++ b/crates/utopia-store/src/audit.rs @@ -101,7 +101,7 @@ pub async fn review_history( OR e.action LIKE 'conflict.%' OR e.action LIKE 'merge.%')"; let rows: Vec = sqlx::query_as(&format!( "SELECT e.id, e.action, e.target_kind, e.target_id, e.detail, - u.display_name AS actor_name, e.created_at + e.actor_id, u.display_name AS actor_name, e.created_at FROM audit_events e LEFT JOIN users u ON u.id = e.actor_id WHERE {COND} ORDER BY e.created_at DESC LIMIT $2 OFFSET $3" )) @@ -152,7 +152,7 @@ pub async fn list_for_kb( let rows: Vec = sqlx::query_as(&format!( "SELECT e.id, e.action, e.target_kind, e.target_id, e.detail, - u.display_name AS actor_name, e.created_at + e.actor_id, u.display_name AS actor_name, e.created_at FROM audit_events e LEFT JOIN users u ON u.id = e.actor_id {WHERE} ORDER BY e.created_at DESC LIMIT $6 OFFSET $7" diff --git a/crates/utopia-store/src/ontology.rs b/crates/utopia-store/src/ontology.rs index 883c9b395..1b9350b97 100644 --- a/crates/utopia-store/src/ontology.rs +++ b/crates/utopia-store/src/ontology.rs @@ -1784,23 +1784,63 @@ pub enum Fit { /// **三条写谓词的路共用的那一道判断**(#190 / #196):抽取落新事实、采纳把谓词 /// 挂回旧事实、合并换掉主语——从前只有抽取查,另外两条各自绕了过去。 /// -/// 判据刻意窄(0012):只看 domain,只在**主语违反且宾语符合**时对调,两边都对不上 -/// 就留空谓词。参数顺序不是关于世界的断言,是这个 key 的编码约定,所以本体在这一处 -/// 是执法的;哪些类型能参与仍是引导,不在这里裁。 +/// 判据刻意窄(0012):只看签名,只在**正向违反而反向成立**时对调,两个方向都 +/// 对不上就留空谓词。参数顺序不是关于世界的断言,是这个 key 的编码约定,所以本体 +/// 在这一处是执法的;哪些类型能参与仍是引导,不在这里裁。 +/// +/// **range 也算进来**(#222)。从前只看 domain:`headOf` 的 domain 是 Agent, +/// schema.org 里 Project 也是 Organization 也是 Agent,于是 `Project Aurora head_of +/// Li Ting` 主语过关就 Keep,宾语是个人、range 要 Organization 这件事没人看。 +/// 现在两端各看各的:正向两端都不违反才 Keep;否则反过来两端都不违反才 Swap。 +/// 没判出类型的实体在 range 这一端不算违反("不知道"不是"不符合",与 +/// `signature_breaks` 同一条纪律);domain 那一端沿用旧规矩,那是 0012 定下的 pub async fn judge_direction( pool: &PgPool, relation_type_id: Uuid, subject_id: Uuid, object_id: Uuid, ) -> AppResult { - match entity_fits_domain(pool, relation_type_id, subject_id).await? { - None => Ok(Fit::Unchecked), - Some(true) => Ok(Fit::Keep), - Some(false) => match entity_fits_domain(pool, relation_type_id, object_id).await? { - Some(true) => Ok(Fit::Swap), - _ => Ok(Fit::Neither), - }, + let subject_in_domain = entity_fits_domain(pool, relation_type_id, subject_id).await?; + let object_in_range = entity_fits_range(pool, relation_type_id, object_id).await?; + if subject_in_domain.is_none() && object_in_range.is_none() { + return Ok(Fit::Unchecked); + } + if subject_in_domain != Some(false) && object_in_range != Some(false) { + return Ok(Fit::Keep); + } + let object_in_domain = entity_fits_domain(pool, relation_type_id, object_id).await?; + let subject_in_range = entity_fits_range(pool, relation_type_id, subject_id).await?; + if object_in_domain != Some(false) && subject_in_range != Some(false) { + return Ok(Fit::Swap); } + Ok(Fit::Neither) +} + +/// [`entity_fits_domain`] 的 range 版。多一条规矩:实体还没判出类型 → None, +/// 不当违反——range 这一端是新加的判据(#222),不该让未分类实体的事实因此 +/// 丢掉谓词 +pub async fn entity_fits_range( + pool: &PgPool, + relation_type_id: Uuid, + entity_id: Uuid, +) -> AppResult> { + let (declared, typed, ok): (i64, bool, i64) = sqlx::query_as( + "WITH RECURSIVE up(id) AS ( + SELECT type_id FROM entities WHERE id = $2 + UNION + SELECT p.parent_id FROM entity_type_parents p JOIN up ON p.child_id = up.id + ) + SELECT (SELECT count(*) FROM relation_type_ranges WHERE relation_type_id = $1), + (SELECT type_id IS NOT NULL FROM entities WHERE id = $2), + (SELECT count(*) FROM relation_type_ranges g + JOIN up ON up.id = g.entity_type_id + WHERE g.relation_type_id = $1)", + ) + .bind(relation_type_id) + .bind(entity_id) + .fetch_one(pool) + .await?; + Ok((declared > 0 && typed).then_some(ok > 0)) } /// 把 `owl:inverseOf` / `rdfs:subPropertyOf` 从 IRI 解析成 id。 diff --git a/crates/utopia-store/src/resolution.rs b/crates/utopia-store/src/resolution.rs index 730b687ea..f82377d9a 100644 --- a/crates/utopia-store/src/resolution.rs +++ b/crates/utopia-store/src/resolution.rs @@ -445,6 +445,8 @@ struct CrossCandidate { canonical_name: String, // None = 这个候选还没判出类型(0009) type_key: Option, + // 同上;`types_are_kin` 要按 id 走类层级 + type_id: Option, // 抽取升格要看它:人说过「就是没有类型」时,那也是一个决定 type_source: String, profile_embedding: Option, @@ -464,6 +466,40 @@ fn drift_reason(mention_key: Option<&str>, other_key: Option<&str>, sim: Option< } } +/// 两个类是不是一家的:一方是另一方的祖先,或者两者共有一个**不是根**的祖先。 +/// +/// `CONFUSABLE_TYPE_KEYS` 那张三 key 的硬表是给没装包的库准备的;装了 schema.org +/// 之后同一家公司会被抽成 Organization / Corporation / OnlineBusiness 三种类型, +/// 全都在 Organization 之下,却一对都过不了硬表,于是三个同名实体并存、 +/// 审阅队列里一条都没有,面板还指着 Review 说"去那里合并"(#226)。 +/// +/// 根不算共同祖先:schema.org 里万物皆 Thing,算上它 Person 与 Organization +/// 也成了一家。没有根的词汇表(W3C Org 的 Organization 自己就是顶)靠 +/// 祖先/后代那一半接住 +async fn types_are_kin(pool: &PgPool, a: Uuid, b: Uuid) -> AppResult { + let (kin,): (bool,) = sqlx::query_as( + "WITH RECURSIVE up_a(id) AS ( + SELECT $1::uuid + UNION + SELECT p.parent_id FROM entity_type_parents p JOIN up_a ON p.child_id = up_a.id + ), up_b(id) AS ( + SELECT $2::uuid + UNION + SELECT p.parent_id FROM entity_type_parents p JOIN up_b ON p.child_id = up_b.id + ) + SELECT EXISTS (SELECT 1 FROM up_a WHERE id = $2) + OR EXISTS (SELECT 1 FROM up_b WHERE id = $1) + OR EXISTS (SELECT 1 FROM up_a JOIN up_b USING (id) + WHERE EXISTS (SELECT 1 FROM entity_type_parents p + WHERE p.child_id = up_a.id))", + ) + .bind(a) + .bind(b) + .fetch_one(pool) + .await?; + Ok(kin) +} + fn confusable_reviews( // None = 这一侧还没判出类型(0009) mention_key: Option<&str>, @@ -510,7 +546,7 @@ async fn resolve_type_drift( None => None, }; let cross: Vec = sqlx::query_as( - "SELECT e.id, e.canonical_name, t.key AS type_key, e.type_source, + "SELECT e.id, e.canonical_name, t.key AS type_key, e.type_id, e.type_source, e.profile_embedding, e.profile_n FROM entities e LEFT JOIN entity_types t ON t.id = e.type_id -- IS DISTINCT FROM 而不是 <>:后者遇 NULL 返回 NULL,被 WHERE 当假, @@ -528,7 +564,16 @@ async fn resolve_type_drift( let mut recall_cands: Vec<&CrossCandidate> = Vec::new(); let mut review_cands: Vec<&CrossCandidate> = Vec::new(); for c in &cross { - match classify_type_drift(mention_key.as_deref(), c.type_key.as_deref()) { + let mut drift = classify_type_drift(mention_key.as_deref(), c.type_key.as_deref()); + // 硬表判不上的,再看类层级:同一支系下的同名当易混,进审阅队列 + if drift == TypeDrift::Disjoint { + if let (Some(a), Some(b)) = (type_id, c.type_id) { + if types_are_kin(pool, a, b).await? { + drift = TypeDrift::Review; + } + } + } + match drift { TypeDrift::Recall => recall_cands.push(c), TypeDrift::Review => review_cands.push(c), TypeDrift::Disjoint => {} diff --git a/crates/utopia-store/tests/a_direction_is_judged_by_range_too.rs b/crates/utopia-store/tests/a_direction_is_judged_by_range_too.rs new file mode 100644 index 000000000..cf68cd9a7 --- /dev/null +++ b/crates/utopia-store/tests/a_direction_is_judged_by_range_too.rs @@ -0,0 +1,140 @@ +//! `judge_direction` 看 range,不只看 domain(#222)。 +//! +//! 从前只要主语过了 domain 就 Keep,宾语违反 range 没人看:`headOf` 的 domain 是 +//! Agent,schema.org 里 Project 也是 Agent,于是 `Project Aurora head_of Li Ting` +//! 原样进图。这里用最小的本体复现那一形:一个两端都允许的 domain,一个只认 +//! 公司的 range,反过来读才成立的事实必须被对调。 + +use sqlx::PgPool; +use utopia_store::ontology::{judge_direction, Fit}; +use uuid::Uuid; + +struct Fixture { + /// domain = person | company,range = company + leads: Uuid, + alice: Uuid, + acme: Uuid, + globex: Uuid, + /// 还没判出类型的实体 + mystery: Uuid, +} + +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (person, company, leads) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (alice, acme, globex, mystery) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'direction-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'direction-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'direction-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + for (id, key, label) in [ + (person, "person", "Person"), + (company, "company", "Company"), + ] { + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, $3, $4)") + .bind(id) + .bind(kb) + .bind(key) + .bind(label) + .execute(pool) + .await?; + } + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label) VALUES ($1, $2, 'leads', 'leads')", + ) + .bind(leads) + .bind(kb) + .execute(pool) + .await?; + for ty in [person, company] { + sqlx::query( + "INSERT INTO relation_type_domains (relation_type_id, entity_type_id) VALUES ($1, $2)", + ) + .bind(leads) + .bind(ty) + .execute(pool) + .await?; + } + sqlx::query( + "INSERT INTO relation_type_ranges (relation_type_id, entity_type_id) VALUES ($1, $2)", + ) + .bind(leads) + .bind(company) + .execute(pool) + .await?; + for (id, ty, name) in [ + (alice, Some(person), "Alice"), + (acme, Some(company), "Acme"), + (globex, Some(company), "Globex"), + (mystery, None, "Mystery"), + ] { + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(kb) + .bind(ty) + .bind(name) + .execute(pool) + .await?; + } + Ok(Fixture { + leads, + alice, + acme, + globex, + mystery, + }) +} + +#[tokio::test] +async fn direction_is_judged_by_range_too() -> anyhow::Result<()> { + let Ok(url) = std::env::var("UTOPIA_DATABASE_URL") else { + eprintln!("跳过:未设 UTOPIA_DATABASE_URL"); + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + // 正向两端都符合:照旧 + assert_eq!( + judge_direction(&pool, f.leads, f.alice, f.acme).await?, + Fit::Keep + ); + // 主语过了 domain(公司也允许当主语),宾语却是个人、range 只认公司。 + // 从前这里是 Keep——正是 Project Aurora head_of Li Ting 那一形; + // 反过来读两端都成立,所以对调 + assert_eq!( + judge_direction(&pool, f.leads, f.acme, f.alice).await?, + Fit::Swap + ); + // 宾语还没判出类型:range 这一端"不知道"不算违反,不能因此丢谓词 + assert_eq!( + judge_direction(&pool, f.leads, f.alice, f.mystery).await?, + Fit::Keep + ); + // 两个公司:正向宾语 range 符合、主语 domain 也符合 → Keep + assert_eq!( + judge_direction(&pool, f.leads, f.acme, f.globex).await?, + Fit::Keep + ); + Ok(()) +} diff --git a/docker-compose.yml b/docker-compose.yml index f1fd68634..b97180b30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: # 写一个不存在的标签,clone 下来第一步就是 manifest unknown。 # 所以撤掉一个版本时必须连它一起改——0.1.0 撤了,现在指向 rc。 # 转正之后改回 0.1.0(或改成 latest 让它自动跟随)。 - image: ${UTOPIA_IMAGE:-ghcr.io/deeplethe/utopia:0.1.0-rc1} + image: ${UTOPIA_IMAGE:-ghcr.io/deeplethe/utopia:0.1.0-rc2} profiles: ["app"] environment: # 默认以 owner 身份运行。要启用受限角色(业务表随便读写、台账只增不改), diff --git a/web/src/api.ts b/web/src/api.ts index 59302ca0e..4b835cad6 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -132,6 +132,8 @@ export interface AuditEvent { target_kind: string; target_id: string | null; detail: Record; + /** null = 引擎自动(裁决器、一致性检查、推理);有 id 没名字 = 账号已移除 */ + actor_id: string | null; actor_name: string | null; created_at: string; } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 05150fae6..613c9ad58 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -201,6 +201,10 @@ export const en = { title: "A data source is mounted, but its schema is not", hint: "Ask cannot see which tables exist, so it will guess column names. Check the connection, then use Refresh schema.", }, + "mapping.exploration_empty": { + title: "Mapping exploration proposed nothing", + hint: "The model read the mounted schema but found no metric or dimension to propose. Refresh the schema under Data mapping > Data sources, add column comments if you can, then run Explore again.", + }, "llm.unreachable": { title: "The model endpoint gave no usable answer", hint: "Extraction and embedding are stopped. Check the endpoint URL in system settings.", @@ -1214,7 +1218,8 @@ export const en = { explore: "Explore mappings", exploreHint: "An agent reads these schemas and proposes metric and dimension definitions. Proposals land in Pending; Ask uses them only once confirmed.", - exploreQueued: "Exploration queued — proposals will appear under Pending.", + exploreQueued: + "Exploration queued — proposals will appear under Pending. If nothing can be proposed, the alert bell will say so.", sourcesEmpty: "No data sources mounted.", sourcesNoneAvailable: "No data sources registered yet — ask a deployment admin to register one.", @@ -1448,6 +1453,9 @@ export const en = { auditTotal: (n: number) => `${n} events`, activityEmpty: "Nothing recorded yet.", deletedUser: "a removed user", + // actor_id 为空的两种引擎动作:审阅队列里的自动裁决,和其余后台工作 + adjudicator: "AI adjudicator", + engine: "the engine", auditActions: { "entity_type.created": "created entity type", "entity_type.updated": "updated entity type", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 68d904235..14c01d8e0 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -180,6 +180,10 @@ export const zh: Strings = { title: "数据源挂上了,库表结构没进来", hint: "问数看不见有哪些表,只能猜列名。检查连接串,然后点「刷新结构」。", }, + "mapping.exploration_empty": { + title: "映射探索没有提出任何口径", + hint: "模型读了挂载的库表结构,但没找到可提的指标或维度。到「数据映射 > 数据源」刷新结构、给列加注释,再探索一次。", + }, "llm.unreachable": { title: "模型端点没有给出可用的回答", hint: "抽取与向量化已停摆。去系统设置里检查端点地址。", @@ -1095,7 +1099,7 @@ export const zh: Strings = { explore: "探查映射", exploreHint: "一个智能体读这些库表结构,提出指标/维度的口径。提出来的落在「待审批」,确认之后问数才会用。", - exploreQueued: "探查已排队——提案稍后出现在「待审批」。", + exploreQueued: "探索已排队,提议会出现在「待确认」里;一条都提不出来时,铃铛会告诉你。", sourcesEmpty: "没有挂载任何数据源。", sourcesNoneAvailable: "还没有登记数据源——请部署管理员登记一个。", newConn: "登记新连接", @@ -1306,6 +1310,8 @@ export const zh: Strings = { auditTotal: (n: number) => `共 ${n} 条`, activityEmpty: "还没有记录。", deletedUser: "一位已移除的用户", + adjudicator: "AI 裁决器", + engine: "引擎", auditActions: { "entity_type.created": "创建了实体类型", "entity_type.updated": "更新了实体类型", diff --git a/web/src/pages/KbSettings.tsx b/web/src/pages/KbSettings.tsx index bed499992..d6eccbbf6 100644 --- a/web/src/pages/KbSettings.tsx +++ b/web/src/pages/KbSettings.tsx @@ -492,7 +492,12 @@ function KbActivity({ kbId }: { kbId: string }) { - {e.actor_name ?? S.kbset.deletedUser} + {e.actor_name ?? + (e.actor_id + ? S.kbset.deletedUser + : e.action.startsWith("review.") + ? S.kbset.adjudicator + : S.kbset.engine)} {" "} {S.kbset.auditActions[e.action] ?? e.action} diff --git a/web/src/pages/Library.tsx b/web/src/pages/Library.tsx index 1d732a288..e5764ca83 100644 --- a/web/src/pages/Library.tsx +++ b/web/src/pages/Library.tsx @@ -483,17 +483,22 @@ export function Library() { [kb, upload, canUpload], ); - if (!kb) return {S.nav.loading}; - - // 服务端已经切好了这一页:筛选、作用域、页码都在请求里 - const pagedDocs = docs.data?.docs ?? []; - const totalDocs = docs.data?.total ?? 0; // 整库可抽的篇数。**重建是库级动作**,不该显示当前来源的数字 const kbStats = useQuery({ queryKey: ["docCount", kb?.id], queryFn: () => api.documents(kb!.id, { limit: 1, offset: 0 }), enabled: !!kb, }); + + // **最后一个 hook 之后才能提前返回。** 这一行从前排在 kbStats 前面:整页 + // 刷新或直接打开链接时第一次渲染 kb 还没到,提前返回跳过了后面的 hook, + // 下一次渲染多出一个 hook,React 直接抛 "Rendered more hooks"。站内导航 + // 时 kb 已在缓存里,所以只有深链和刷新会撞上 + if (!kb) return {S.nav.loading}; + + // 服务端已经切好了这一页:筛选、作用域、页码都在请求里 + const pagedDocs = docs.data?.docs ?? []; + const totalDocs = docs.data?.total ?? 0; const kbReady = kbStats.data?.ready ?? 0; const sourceList = sources.data?.sources ?? []; const selectedSource = sourceList.find((s) => s.id === selection); diff --git a/web/src/useKbEvents.ts b/web/src/useKbEvents.ts index 66b0e2322..c1d72f2b5 100644 --- a/web/src/useKbEvents.ts +++ b/web/src/useKbEvents.ts @@ -18,6 +18,8 @@ export function useKbEvents(kbId: string | undefined) { }); es.addEventListener("review", () => { queryClient.invalidateQueries({ queryKey: ["review", kbId] }); + // 映射探索跑完发的也是 review:Pending 那一栏得跟着刷新 + queryClient.invalidateQueries({ queryKey: ["mappings", kbId] }); }); // 一句记忆抽出了等人点头的事实(0015):对话里那张确认卡跟着长出来 es.addEventListener("pending", () => {