Skip to content
Merged
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
45 changes: 45 additions & 0 deletions crates/utopia-ingest/src/ontology_rdf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ pub struct OwlProperty {
/// `rdfs:subPropertyOf` 的父属性 IRI。多写几条只留第一条——
/// OWL 允许多父,而 R1 的规则一次只升一级,多父要另一套形状
pub sub_property_of: Option<String>,
/// `schema:supersededBy` 的对端 IRI:这条属性已经被另一条取代。schema.org 里
/// `employees` 让位给 `employee`、`founders` 让位给 `founder`,两条都还在词表里。
/// 不读这一位,两条都建成关系,抽取时精确 key 各自命中,一个关系永久分成两个(#560)
pub superseded_by: Option<String>,
pub domains: Vec<String>,
pub ranges: Vec<String>,
/// **多条 range 是并集还是交集**。`rdfs:range` 写多条是交集
Expand Down Expand Up @@ -229,6 +233,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
// 属性之间的两条关系(不是类型声明,所以单独收)
let mut inverse_of: BTreeMap<String, String> = BTreeMap::new();
let mut sub_property_of: BTreeMap<String, String> = BTreeMap::new();
let mut superseded_by: BTreeMap<String, String> = BTreeMap::new();
let mut asymmetric: BTreeSet<String> = BTreeSet::new();
let mut irreflexive: BTreeSet<String> = BTreeSet::new();
let mut plain_props: BTreeSet<String> = BTreeSet::new();
Expand Down Expand Up @@ -315,6 +320,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
|| 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();
Expand Down Expand Up @@ -352,6 +358,11 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
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 的本体进来,那部分信息当场消失且没有提示
Expand Down Expand Up @@ -509,6 +520,7 @@ pub fn project(bytes: &[u8], format: RdfFormat) -> anyhow::Result<OwlProjection>
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,
Expand Down Expand Up @@ -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: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
@prefix schema: <https://schema.org/> .
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()` 白名单里——认得出、不报警、
Expand Down
117 changes: 117 additions & 0 deletions crates/utopia-server/src/bootstrap_ontology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> = 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<String> = 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?;
Expand Down Expand Up @@ -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。
Expand Down Expand Up @@ -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());
}
}
37 changes: 32 additions & 5 deletions crates/utopia-server/src/owl_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ pub enum Disposition {
/// key 被另一个 IRI 占着,但对齐表说那两个是**同一个东西** → 跳过且不算冲突。
/// 与 KeyTaken 分开,是因为它不需要人裁:少建一个重复的类正是想要的结果
Aligned,
/// 词表自己说这条属性已被另一条取代(`schema:supersededBy`),而取代它的那条
/// 在这个文件里或库里 → 不建。建了就是两个 key 指同一个关系:抽取的精确 key
/// 命中让 `employees` 与 `employee` 永久分家,屈折归一根本没机会开口(#560)。
/// 不建之后,模型说 `employees` 会折到 `employee` 上
Superseded,
}

/// 一个属性在这次导入里会不会被建出来,以及为什么。
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -490,7 +508,8 @@ pub async fn apply(
}
}
}
Disposition::KeyTaken => {}
// 类没有让位一说(schema:supersededBy 只标属性),穷举是为了编译器替我们看着
Disposition::KeyTaken | Disposition::Superseded => {}
}
}

Expand Down Expand Up @@ -551,7 +570,10 @@ pub async fn apply(
// (key, domains, ranges):关系 id 要等批量插完才有,先按 key 记着
let mut pending_links: Vec<(String, Vec<Uuid>, Vec<Uuid>)> = 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 {
Expand Down Expand Up @@ -657,7 +679,10 @@ pub async fn apply(
let mut new_attrs: Vec<utopia_store::ontology::BulkRelation> = Vec::new();
let mut pending_attr_domains: Vec<(String, Vec<Uuid>)> = 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 {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 不在这个库里时会静默跳过 */
Expand Down
Loading