diff --git a/crates/utopia-server/src/api/kbs.rs b/crates/utopia-server/src/api/kbs.rs index 06a38163..bffba332 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/extraction.rs b/crates/utopia-server/src/extraction.rs index 087ff453..235a8bad 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/owl_import.rs b/crates/utopia-server/src/owl_import.rs index 40624bb7..cf6edcf5 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/ontology.rs b/crates/utopia-store/src/ontology.rs index 883c9b39..1b9350b9 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/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 00000000..cf68cd9a --- /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(()) +}