diff --git a/crates/utopia-ingest/src/ontology_rdf.rs b/crates/utopia-ingest/src/ontology_rdf.rs index e926aad20..1f7fd1d4c 100644 --- a/crates/utopia-ingest/src/ontology_rdf.rs +++ b/crates/utopia-ingest/src/ontology_rdf.rs @@ -50,6 +50,10 @@ pub struct OwlProperty { /// `rdfs:subPropertyOf` 的父属性 IRI。多写几条只留第一条—— /// OWL 允许多父,而 R1 的规则一次只升一级,多父要另一套形状 pub sub_property_of: Option, + /// `schema:supersededBy` 的对端 IRI:这条属性已经被另一条取代。schema.org 里 + /// `employees` 让位给 `employee`、`founders` 让位给 `founder`,两条都还在词表里。 + /// 不读这一位,两条都建成关系,抽取时精确 key 各自命中,一个关系永久分成两个(#560) + pub superseded_by: Option, pub domains: Vec, pub ranges: Vec, /// **多条 range 是并集还是交集**。`rdfs:range` 写多条是交集 @@ -229,6 +233,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result // 属性之间的两条关系(不是类型声明,所以单独收) let mut inverse_of: BTreeMap = BTreeMap::new(); let mut sub_property_of: BTreeMap = BTreeMap::new(); + let mut superseded_by: BTreeMap = BTreeMap::new(); let mut asymmetric: BTreeSet = BTreeSet::new(); let mut irreflexive: BTreeSet = BTreeSet::new(); let mut plain_props: BTreeSet = BTreeSet::new(); @@ -315,6 +320,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result || is_schema(p, "rangeIncludes") || p == format!("{OWL}disjointWith") || p == format!("{OWL}inverseOf") + || is_schema(p, "supersededBy") }; for t in &triples { let p = t.predicate.as_str(); @@ -352,6 +358,11 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result if let Some(o) = &t.object_iri { inverse_of.entry(t.subject.clone()).or_insert(o.clone()); } + } else if is_schema(p, "supersededBy") { + // 取代关系只在 schema.org 词表里出现;多写几条只留第一条 + if let Some(o) = &t.object_iri { + superseded_by.entry(t.subject.clone()).or_insert(o.clone()); + } } else if p == format!("{RDFS}subPropertyOf") { // 从前这条只出现在 `known()` 白名单里——认得出、不报警、**然后扔掉**。 // 导一份带 subPropertyOf 的本体进来,那部分信息当场消失且没有提示 @@ -509,6 +520,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result asymmetric: asymmetric.contains(iri), inverse_of: inverse_of.get(iri).cloned(), sub_property_of: sub_property_of.get(iri).cloned(), + superseded_by: superseded_by.get(iri).cloned(), irreflexive: irreflexive.contains(iri), domains: domains.get(iri).cloned().unwrap_or_default(), ranges: rs, @@ -1480,6 +1492,39 @@ mod against_real_packs { mod property_axiom_tests { use super::*; + /// `schema:supersededBy` 要被读出来:让位的属性带着它让给了谁 + #[test] + fn a_superseded_property_names_its_successor() { + let ttl = r#" +@prefix rdf: . +@prefix rdfs: . +@prefix schema: . +schema:Organization a rdfs:Class ; rdfs:label "Organization" . +schema:Person a rdfs:Class ; rdfs:label "Person" . +schema:employee a rdf:Property ; rdfs:label "employee" ; + schema:domainIncludes schema:Organization ; schema:rangeIncludes schema:Person . +schema:employees a rdf:Property ; rdfs:label "employees" ; + schema:domainIncludes schema:Organization ; schema:rangeIncludes schema:Person ; + schema:supersededBy schema:employee . +"#; + let p = project(ttl.as_bytes(), RdfFormat::Turtle).expect("解析"); + let by = |k: &str| { + p.properties + .iter() + .find(|x| x.key == k) + .unwrap_or_else(|| panic!("没有 {k}")) + }; + assert_eq!( + by("employees").superseded_by.as_deref(), + Some("https://schema.org/employee") + ); + assert!(by("employee").superseded_by.is_none()); + assert!( + !p.unprojected.keys().any(|k| k.contains("supersededBy")), + "认了就不该再算进「暂未投影」" + ); + } + /// `owl:inverseOf` 与 `rdfs:subPropertyOf` 要被读出来。 /// /// 从前 `subPropertyOf` 只出现在 `known()` 白名单里——认得出、不报警、 diff --git a/crates/utopia-server/src/bootstrap_ontology.rs b/crates/utopia-server/src/bootstrap_ontology.rs index c0240f683..628245c6d 100644 --- a/crates/utopia-server/src/bootstrap_ontology.rs +++ b/crates/utopia-server/src/bootstrap_ontology.rs @@ -109,10 +109,84 @@ async fn counted_relation_groups( existing, }); } + fold_by_meaning(state, kb_id, &mut out).await; out.sort_by(|a, b| b.facts.cmp(&a.facts).then(a.key.cmp(&b.key))); Ok(out) } +/// 拼写对不上、意思对得上的,也不新建(#560)。 +/// +/// `PredicateIndex` 只认写法、屈折与被动;`comprised` 对 `has_member`、`ceo` 对 +/// `chief_executive` 它看不出来,于是本体里长出第二个关系,图上同一件事两种谓词, +/// 查询按谓词分组时它们各站一边。这里把没落地的说法嵌一次,与已有关系的向量比, +/// 够近的就落到那一条上。阈值取得紧:折错一次是把两个关系永久并成一个, +/// 而不折只是多一个关系,后者便宜得多。每个候选的距离都进日志,好在真语料上校 +const FOLD_DISTANCE: f32 = 0.20; + +async fn fold_by_meaning(state: &AppState, kb_id: Uuid, groups: &mut [RelationGroup]) { + let unmatched: Vec = groups + .iter() + .enumerate() + .filter(|(_, g)| g.existing.is_none()) + .map(|(i, _)| i) + .collect(); + if unmatched.is_empty() { + return; + } + let Ok(kb) = utopia_store::kbs::get(&state.pool, kb_id).await else { + return; + }; + let Ok(Some(settings)) = utopia_store::settings::get(&state.pool, kb.workspace_id).await else { + return; + }; + let Some(client) = crate::llm_util::embed_client(&settings) else { + return; + }; + let texts: Vec = unmatched + .iter() + .map(|i| groups[*i].key.replace('_', " ")) + .collect(); + let vectors = match client.embed(&texts).await { + Ok(v) if v.len() == texts.len() => v, + Ok(_) => return, + Err(e) => { + tracing::warn!(%kb_id, error = %e, "说法嵌入失败,按拼写采纳"); + return; + } + }; + for (i, vec) in unmatched.into_iter().zip(vectors) { + let near = utopia_store::ontology::nearest_relation_types( + &state.pool, + kb_id, + &vec, + 3, + Some("relation"), + ) + .await + .unwrap_or_default(); + for c in &near { + tracing::debug!(%kb_id, form = %groups[i].key, key = %c.key, distance = c.distance, "同义候选"); + } + if let Some(hit) = fold_pick(&near, FOLD_DISTANCE) { + tracing::info!( + %kb_id, form = %groups[i].key, onto = %hit.key, distance = hit.distance, + "说法按意思落到已有关系上" + ); + groups[i].existing = Some((hit.id, false)); + } + } +} + +/// 最近的一个在阈值内就是它;候选按距离升序来 +fn fold_pick( + near: &[utopia_core::models::TypeCandidate], + max_distance: f32, +) -> Option<&utopia_core::models::TypeCandidate> { + near.iter() + .min_by(|a, b| a.distance.total_cmp(&b.distance)) + .filter(|c| c.distance <= max_distance) +} + pub async fn bootstrap_ontology(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> { // 并发的两个抽取任务可能都看到"空闲"而各入队一次;开关也可能刚被关掉 let kb = utopia_store::kbs::get(&state.pool, kb_id).await?; @@ -298,6 +372,20 @@ pub async fn bootstrap_ontology(state: &AppState, kb_id: Uuid) -> anyhow::Result } } + // 新建的关系要有向量:抽取按分块检索候选靠它,谓词对齐(#558)靠它,下一次 + // 同义归并也靠它。从前 `embed_ontology` 只在导入时跑一次,冷启动建的关系 + // 永远没有向量——这个库里 76 个,`comprised` 是其中之一 + if !added_relations.is_empty() { + if let Err(e) = crate::ontology_index::refresh_scoped( + state, + kb_id, + Some(utopia_store::ontology::TypeKind::Relation), + ) + .await + { + tracing::warn!(%kb_id, error = %e, "新建关系的向量没补上"); + } + } // 属性那一档:宾语是字面值的说法。 // // domain 不从提案里读——它从这些事实的主语类型里取,见 adopt_attribute。 @@ -477,3 +565,32 @@ fn str_of<'a>(v: &'a serde_json::Value, key: &str) -> Option<&'a str> { .map(str::trim) .filter(|s| !s.is_empty()) } + +#[cfg(test)] +mod tests { + use super::*; + use utopia_core::models::TypeCandidate; + + fn cand(key: &str, distance: f32) -> TypeCandidate { + TypeCandidate { + id: Uuid::now_v7(), + key: key.into(), + label: key.into(), + description: String::new(), + kind: Some("relation".into()), + distance, + } + } + + /// 只认阈值内最近的那个;差一点也不折——折错比不折贵 + #[test] + fn a_form_folds_only_onto_a_close_enough_relation() { + let near = vec![cand("has_member", 0.18), cand("member_of", 0.31)]; + assert_eq!( + fold_pick(&near, 0.20).map(|c| c.key.as_str()), + Some("has_member") + ); + assert!(fold_pick(&near, 0.15).is_none()); + assert!(fold_pick(&[], 0.20).is_none()); + } +} diff --git a/crates/utopia-server/src/owl_import.rs b/crates/utopia-server/src/owl_import.rs index cf6edcf57..1734ecbbd 100644 --- a/crates/utopia-server/src/owl_import.rs +++ b/crates/utopia-server/src/owl_import.rs @@ -27,6 +27,11 @@ pub enum Disposition { /// key 被另一个 IRI 占着,但对齐表说那两个是**同一个东西** → 跳过且不算冲突。 /// 与 KeyTaken 分开,是因为它不需要人裁:少建一个重复的类正是想要的结果 Aligned, + /// 词表自己说这条属性已被另一条取代(`schema:supersededBy`),而取代它的那条 + /// 在这个文件里或库里 → 不建。建了就是两个 key 指同一个关系:抽取的精确 key + /// 命中让 `employees` 与 `employee` 永久分家,屈折归一根本没机会开口(#560)。 + /// 不建之后,模型说 `employees` 会折到 `employee` 上 + Superseded, } /// 一个属性在这次导入里会不会被建出来,以及为什么。 @@ -143,6 +148,10 @@ impl ImportPlan { *out.entry("key_taken").or_default() += 1; continue; } + if a.disposition == Disposition::Superseded { + *out.entry("superseded").or_default() += 1; + continue; + } let reason = match a.attr.as_ref() { Some(AttrNote::Datatype(_)) | Some(AttrNote::NoRange) @@ -272,8 +281,17 @@ pub async fn plan( let mut relations = Vec::new(); let mut attributes = Vec::new(); + // 让位的属性只在取代它的那条**能落地**时才跳过:取代者在这个文件里,或库里已有。 + // 取代者不在场时照建——否则一份只带了让位方的小词表会一条都建不出来 + let prop_iris: HashSet<&str> = proj.properties.iter().map(|p| p.iri.as_str()).collect(); for p in &proj.properties { - let (disposition, conflict_with) = if let Some(prev) = claimed_prop.get(p.key.as_str()) { + let successor = p + .superseded_by + .as_deref() + .filter(|s| prop_iris.contains(s) || r_by_iri.contains_key(s)); + let (disposition, conflict_with) = if let Some(s) = successor { + (Disposition::Superseded, Some(s.to_string())) + } else if let Some(prev) = claimed_prop.get(p.key.as_str()) { (Disposition::KeyTaken, Some((*prev).to_string())) } else if r_by_iri.contains_key(p.iri.as_str()) { (Disposition::Update, None) @@ -282,7 +300,7 @@ pub async fn plan( } else { (Disposition::Create, None) }; - if disposition != Disposition::KeyTaken { + if !matches!(disposition, Disposition::KeyTaken | Disposition::Superseded) { claimed_prop.insert(p.key.as_str(), p.iri.as_str()); } let mut item = PlannedItem { @@ -490,7 +508,8 @@ pub async fn apply( } } } - Disposition::KeyTaken => {} + // 类没有让位一说(schema:supersededBy 只标属性),穷举是为了编译器替我们看着 + Disposition::KeyTaken | Disposition::Superseded => {} } } @@ -551,7 +570,10 @@ pub async fn apply( // (key, domains, ranges):关系 id 要等批量插完才有,先按 key 记着 let mut pending_links: Vec<(String, Vec, Vec)> = Vec::new(); for item in &plan.relations { - if item.disposition == Disposition::KeyTaken { + if matches!( + item.disposition, + Disposition::KeyTaken | Disposition::Superseded + ) { continue; } let Some(p) = by_prop_iri.get(item.iri.as_str()) else { @@ -657,7 +679,10 @@ pub async fn apply( let mut new_attrs: Vec = Vec::new(); let mut pending_attr_domains: Vec<(String, Vec)> = Vec::new(); for item in &plan.attributes { - if item.disposition == Disposition::KeyTaken { + if matches!( + item.disposition, + Disposition::KeyTaken | Disposition::Superseded + ) { continue; } let (Some(note), Some(p)) = (item.attr.as_ref(), by_prop_iri.get(item.iri.as_str())) else { @@ -716,6 +741,8 @@ pub async fn apply( "inverse_linked": linked_inv, "sub_property_linked": linked_sub, "relations_created": created_rels, + // 词表自己让位的那些:不建,也不算冲突 + "relations_superseded": plan.relations.iter().filter(|r| r.disposition == Disposition::Superseded).count(), "relations_updated": updated_rels, "attributes_seen": plan.attributes.len(), "attributes_created": created_attrs, diff --git a/web/src/api.ts b/web/src/api.ts index 66ac15940..9bdc760e8 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1110,7 +1110,7 @@ export interface PlannedItem { key: string; label: string; has_description: boolean; - disposition: "create" | "update" | "key_taken"; + disposition: "create" | "update" | "key_taken" | "aligned" | "superseded"; functional?: boolean; conflict_with?: string | null; } @@ -1137,6 +1137,7 @@ export interface OntologyImportSummary { classes_without_description?: number; relations_seen?: number; relations_created?: number; + relations_superseded?: number; relations_updated?: number; functional_relations?: number; /** 逆属性 / 父属性连上了几条——目标 IRI 不在这个库里时会静默跳过 */