From cac424bff996a7d108c48ffb3d44b5492ea633de Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Thu, 10 Sep 2026 20:24:51 +0800 Subject: [PATCH] A relation carries its own attributes Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- crates/utopia-core/src/models.rs | 23 +++ crates/utopia-extract/src/lib.rs | 78 ++++++++ .../utopia-server/src/api/ontology_routes.rs | 9 + crates/utopia-server/src/api/tools.rs | 21 +++ crates/utopia-server/src/api/tools_graph.rs | 34 +++- crates/utopia-server/src/extraction.rs | 100 +++++++++++ crates/utopia-server/src/predicate_match.rs | 1 + crates/utopia-server/src/rdf.rs | 13 ++ crates/utopia-store/src/export.rs | 18 +- crates/utopia-store/src/extraction_drops.rs | 6 + crates/utopia-store/src/graph.rs | 124 ++++++++++++- crates/utopia-store/src/ontology.rs | 51 ++++++ .../a_qualifier_is_not_the_edges_identity.rs | 167 ++++++++++++++++++ ...7-a-relation-carries-its-own-attributes.md | 112 ++++++++++++ docs/decisions/README.md | 1 + ...049_a_relation_declares_its_qualifiers.sql | 42 +++++ web/src/api.ts | 16 ++ web/src/i18n/en.ts | 3 + web/src/i18n/zh.ts | 3 + web/src/pages/Graph.tsx | 10 ++ web/src/pages/Ontology.tsx | 7 + web/src/pages/ontologyDialogs.tsx | 17 ++ 22 files changed, 848 insertions(+), 8 deletions(-) create mode 100644 crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs create mode 100644 docs/decisions/0037-a-relation-carries-its-own-attributes.md create mode 100644 migrations/0049_a_relation_declares_its_qualifiers.sql diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 391ec6797..7ed2aa240 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -508,6 +508,19 @@ pub struct RelationTypeView { pub usage: i64, } +/// 一条边上挂的一个属性值(0037)。`value` 与 `entity` 二选一: +/// 金额、比例、日期是字面值;「经 C 撮合」里的 C 是实体(这一格这一刀还不写,位置留着) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FactQualifier { + pub qualifier_type_id: Uuid, + pub key: String, + pub label: String, + /// 形状与 `facts.object_value` 一致:{"value": …, "unit": …} + pub value: Option, + pub entity_id: Option, + pub entity_name: Option, +} + /// 抽取未匹配统计(本体扩展建议的信号源)。 #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct OntologyMiss { @@ -605,6 +618,10 @@ pub struct RelationType { /// attribute 专用:text | number | date | bool pub datatype: Option, pub unit: Option, + /// **这条关系的边能带哪些属性**(0037):指向 kind='attribute' 的行。 + /// `A invested B` 上的「金额」是边自己的属性,不是第二个宾语;金额的 + /// datatype / unit / 换算全复用属性定义,只是它的 domain 是一条关系而不是一个类 + pub qualifiers: Vec, } #[derive(Debug, Clone, Serialize, sqlx::FromRow)] @@ -655,6 +672,9 @@ pub struct GraphEdge { /// **与 `inferred` 不是一回事**,尽管两个词很近:那一位说的是「名字来自原文 /// 而不是本体」,这一位说的是「这条边根本不是谁说的,是引擎推的」 pub derived: bool, + /// 边上的属性(0037):画布把金额写到边的标签上要靠它 + #[sqlx(skip)] + pub qualifiers: Vec, /// 推它出来的那条规则(`transitive` / `symmetric` / `inverse` / `sub_property`); /// 断言的边为 None。 /// @@ -704,6 +724,9 @@ pub struct EntityFact { pub other_type: Option, /// 字面值宾语(属性事实/问数映射):{"value":…,"unit":…} 或 {"summary":…} pub object_value: Option, + /// 边上的属性(0037)。不在行里——`fact_qualifiers` 另一张表,加载后按事实 id 补 + #[sqlx(skip)] + pub qualifiers: Vec, pub valid_from: Option>, pub valid_to: Option>, /// 精度描述的是这条事实**有的那些日期**的粒度。两端都没有日期时为 None—— diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index 199a48aec..0c90a398b 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -59,6 +59,10 @@ pub struct ExtractedFact { /// 属性事实的字面值(谓词是 attribute 时) #[serde(default)] pub value: Option, + /// **边上的属性**(0037):`{"amount": "$5 billion", "stake": "20%"}`。 + /// 只对关系事实有意义,key 必须是清单里这条关系声明过的;值照原文写,换算在服务端 + #[serde(default)] + pub qualifiers: Option>, #[serde(default)] pub valid_from: Option, #[serde(default)] @@ -93,6 +97,8 @@ pub struct PromptRelation { /// 时间语义(`relation_types.temporal`):`state` / `event` / `eternal`(0031)。 /// 只有 event 与 eternal 会在清单里带标记——状态是默认,写出来只多花 token pub temporal: String, + /// 这条关系的边能带的属性,已排好版:`amount: number $`(0037)。空 = 不带 + pub qualifiers: Vec, } /// Response-scoped reference to a persistent entity; database UUIDs must never enter prompts. @@ -154,6 +160,12 @@ pub fn build_messages( let mark = temporal_mark(&r.temporal) .map(|m| format!(" [{m}]")) .unwrap_or_default(); + // 边上能带的属性跟在标记后面:`{amount: number $, stake: number %}` + let mark = if r.qualifiers.is_empty() { + mark + } else { + format!("{mark} {{{}}}", r.qualifiers.join(", ")) + }; match (paren.is_empty(), d.is_empty()) { (false, false) => format!("- {} ({paren}){mark}: {d}", r.key), (false, true) => format!("- {} ({paren}){mark}", r.key), @@ -298,6 +310,7 @@ pub fn build_messages( units and all. **A stated figure left out is the loss that costs most**: the reader \ came for those numbers, and no later step can recover one that was never written \ down.\n\ + 8c. A listed relation followed by {{…}} can carry those **qualifiers on the edge**: when the same sentence gives both the other entity and a figure for it — an amount, a stake, a price, a share count — write the relation with its \"object\" and put the figure in \"qualifiers\" keyed exactly as listed: {{\"subject\":\"Vega Capital\",\"predicate\":\"invested_in\",\"object\":\"Northwind\", \"qualifiers\":{{\"amount\":\"$5 billion\"}},…}}. Never invent a key that is not listed for that relation, and never drop the figure to keep the edge — a relation without its amount is half the sentence. 8b. A **listed** relation also takes \"value\" when what the text gives is a \ string rather than another entity — a job title, a designation, a ticker, a \ model number. Never invent an entity for a string. And when the text introduces \ @@ -985,6 +998,7 @@ mod prompt_shape_tests { description: description.into(), signature: signature.into(), temporal: "state".into(), + qualifiers: vec![], } } @@ -1153,6 +1167,70 @@ mod prompt_shape_tests { mod tests { use super::*; + /// 边上的属性(0037):清单里跟在关系后面,回复里挂在事实上。 + #[test] + fn a_relation_lists_its_qualifiers_and_a_fact_carries_them() { + use serde_json::json; + let mut r = PromptRelation { + key: "invested_in".into(), + label: "invested in".into(), + description: "money into a company".into(), + signature: "organization → organization".into(), + temporal: "event".into(), + qualifiers: vec!["amount: number $".into(), "stake: number %".into()], + }; + let msgs = build_messages( + &[], + std::slice::from_ref(&r), + &[], + None, + "a.txt", + &[], + "text", + ); + let prompt = format!("{:?}", msgs); + // 签名、标记、属性清单三段顺序固定:`(签名) [event] {属性}` + assert!(prompt.contains( + "- invested_in (organization → organization) [event] {amount: number $, stake: number %}: money into a company" + ), "{prompt}"); + // 不带属性的关系不多一个花括号 + r.qualifiers.clear(); + let prompt = format!( + "{:?}", + build_messages( + &[], + std::slice::from_ref(&r), + &[], + None, + "a.txt", + &[], + "text" + ) + ); + assert!( + prompt.contains("- invested_in (organization → organization) [event]: money"), + "{prompt}" + ); + assert!(!prompt.contains("[event] {")); + + // 回复:qualifiers 挂在关系事实上;没写的是 None,旧回复不受影响 + let reply = r#"{"entities":[],"facts":[ + {"subject":"Vega","predicate":"invested_in","object":"Northwind", + "qualifiers":{"amount":"$5 billion"},"confidence":0.9}, + {"subject":"Vega","predicate":"invested_in","object":"Kestrel","confidence":0.9} + ]}"#; + let parsed = parse_response(reply).unwrap(); + assert_eq!(parsed.facts.len(), 2); + assert_eq!( + parsed.facts[0] + .qualifiers + .as_ref() + .and_then(|q| q.get("amount")), + Some(&json!("$5 billion")) + ); + assert!(parsed.facts[1].qualifiers.is_none()); + } + #[test] fn a_quantity_is_the_whole_string_or_nothing() { // 整体就是一个量:符号、量级词、千分位都读得动 diff --git a/crates/utopia-server/src/api/ontology_routes.rs b/crates/utopia-server/src/api/ontology_routes.rs index 6a215b600..3ed5e64e8 100644 --- a/crates/utopia-server/src/api/ontology_routes.rs +++ b/crates/utopia-server/src/api/ontology_routes.rs @@ -243,6 +243,9 @@ pub struct RelationTypeReq { /// 调用方(属性表单)不该因为一次改名就把 domain 清空 #[serde(default)] pub domains: Option>, + /// 这条关系的边能带哪些属性(0037):属性定义的 id。None = 不动 + #[serde(default)] + pub qualifiers: Option>, /// 可以当宾语的类。只对 relation 有意义 #[serde(default)] pub ranges: Option>, @@ -303,6 +306,9 @@ pub async fn create_relation_type( req.unit.as_deref().map(str::trim).filter(|s| !s.is_empty()), ) .await?; + if let Some(q) = req.qualifiers.as_deref() { + utopia_store::ontology::set_relation_qualifiers(&state.pool, kb_id, id, q).await?; + } let _ = utopia_store::audit::record( &state.pool, Some(kb_id), @@ -338,6 +344,9 @@ pub async fn update_relation_type( req.ranges.as_deref(), ) .await?; + if let Some(q) = req.qualifiers.as_deref() { + utopia_store::ontology::set_relation_qualifiers(&state.pool, kb_id, id, q).await?; + } let _ = utopia_store::audit::record( &state.pool, Some(kb_id), diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index ab4f11e7a..5ac977953 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -650,6 +650,26 @@ pub(super) fn fact_line(f: &EntityFact) -> String { .as_deref() .or(literal.as_deref()) .unwrap_or("?"); + // 边上的属性(0037)跟在对端后面:`invested_in → Kestrel [amount: 4000000000 $]`。 + // 模型读事实行时最常问的就是"投了多少",数不在行里它就答"没有金额信息" + let quals: Vec = f + .qualifiers + .iter() + .map(|q| { + let v = q + .value + .as_ref() + .and_then(literal_text) + .or_else(|| q.entity_name.clone()) + .unwrap_or_else(|| "?".to_string()); + format!("{}: {v}", q.key) + }) + .collect(); + let other = if quals.is_empty() { + other.to_string() + } else { + format!("{other} [{}]", quals.join(", ")) + }; // 本体没认下、原文说法也没留下时用 "?"——与 other 同一个约定。 // 不编一个"相关"出来:那正是删掉 related_to 要消灭的东西 let pred = f.predicate_label.as_deref().unwrap_or("?"); @@ -1046,6 +1066,7 @@ mod tests { other_id: None, other_name: None, other_type: None, + qualifiers: Vec::new(), object_value: Some(value), valid_from: Some(t("2023-06-01T00:00:00Z")), valid_to: Some(t("2024-02-20T00:00:00Z")), diff --git a/crates/utopia-server/src/api/tools_graph.rs b/crates/utopia-server/src/api/tools_graph.rs index 242df3d7c..39d1ff0b7 100644 --- a/crates/utopia-server/src/api/tools_graph.rs +++ b/crates/utopia-server/src/api/tools_graph.rs @@ -211,6 +211,36 @@ pub async fn find_entities(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) // ---- entity_facts ---------------------------------------------------------------- /// 事实的另一端:对端实体,或属性值 +/// 边上的属性(0037)跟在对端后面:`Vega Capital [amount: 5000000000 $]`。 +/// 模型读事实行时最常问的就是"投了多少",数不在行里它就答"没有金额信息" +fn qualifiers_text(f: &EntityFact) -> String { + if f.qualifiers.is_empty() { + return String::new(); + } + let parts: Vec = f + .qualifiers + .iter() + .map(|q| { + let v = q + .value + .as_ref() + .and_then(|v| v.get("value")) + .map(|v| v.to_string().trim_matches('"').to_string()) + .or_else(|| q.entity_name.clone()) + .unwrap_or_else(|| "?".to_string()); + let u = q + .value + .as_ref() + .and_then(|v| v.get("unit")) + .and_then(|u| u.as_str()) + .map(|u| format!(" {u}")) + .unwrap_or_default(); + format!("{}: {v}{u}", q.key) + }) + .collect(); + format!(" [{}]", parts.join(", ")) +} + fn other_text(f: &EntityFact) -> String { let literal = f .object_value @@ -586,8 +616,9 @@ pub async fn entity_facts(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) lines.push(format!("## {key} ({})", group.len())); for f in group { lines.push(format!( - "{}{} {}", + "{}{}{} {}", other_text(f), + qualifiers_text(f), range_text(f), confidence_text(f) )); @@ -1035,6 +1066,7 @@ mod tests { other_id: Some(Uuid::now_v7()), other_name: Some(other.into()), other_type: other_type.map(String::from), + qualifiers: Vec::new(), object_value: None, valid_from: Some("2021-01-01T00:00:00Z".parse().unwrap()), valid_to: None, diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index c89c92544..cfcde55bf 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -899,6 +899,24 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: .filter(|r| r.kind == "attribute") .map(|r| (r.key.as_str(), r)) .collect(); + /* **边上的属性**(0037):一条关系声明过的属性定义,按关系 id 取。 + 属性定义就是 kind='attribute' 的行,datatype / unit / 换算全复用; + 写入时按这里的 key 对模型给的 qualifiers,对不上的进丢弃表让人看见 */ + let rtype_by_id: HashMap = + rtypes.iter().map(|r| (r.id, r)).collect(); + let qualifier_defs: HashMap> = rtypes + .iter() + .filter(|r| r.kind != "attribute" && !r.qualifiers.is_empty()) + .map(|r| { + let defs = r + .qualifiers + .iter() + .filter_map(|q| rtype_by_id.get(q).copied()) + .filter(|q| q.kind == "attribute") + .collect(); + (r.id, defs) + }) + .collect(); let type_ids: HashMap<&str, Uuid> = etypes.iter().map(|t| (t.key.as_str(), t.id)).collect(); let rel_ids: HashMap<&str, Uuid> = rtypes.iter().map(|r| (r.key.as_str(), r.id)).collect(); // 模型说出的谓词往本体已有关系上落:写法、时态、被动都对齐(见 predicate_match)。 @@ -2277,6 +2295,69 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: ) .await?; touched_facts.push(fact_id); + /* **边上的属性落笔**(0037)。属性不进去重键:`insert_fact` 复用了旧行也照写—— + 同一条边再听到一次带了金额的,是同一条边补上金额。 + 值照 datatype 换算,单位另记一格(与 object_value 同形); + key 不在声明里、换不动、与已记的不一致——三种都进丢弃表,不静默 */ + if let (Some(pid), Some(quals)) = (predicate_id, f.qualifiers.as_ref()) { + let defs = qualifier_defs.get(&pid); + for (key, raw) in quals { + let Some(def) = defs.and_then(|d| { + d.iter().find(|q| q.key.eq_ignore_ascii_case(key.trim())) + }) else { + drop_signal( + state, + doc.kb_id, + document_id, + utopia_store::extraction_drops::reason::QUALIFIER_UNKNOWN, + &format!("{}.{}", f.predicate, key), + Some(&raw.to_string()), + ) + .await; + continue; + }; + let dt = def.datatype.as_deref().unwrap_or("text"); + let Some(normalized) = utopia_extract::normalize_attr_value(dt, raw) else { + drop_signal( + state, + doc.kb_id, + document_id, + utopia_store::extraction_drops::reason::QUALIFIER_DATATYPE, + &format!("{}.{} ({dt})", f.predicate, def.key), + Some(&raw.to_string()), + ) + .await; + continue; + }; + let mut value = serde_json::json!({ "value": normalized }); + let unit = raw + .as_str() + .and_then(utopia_extract::parse_leading_quantity) + .and_then(|(_, u)| u) + .or_else(|| def.unit.clone().filter(|u| !u.is_empty())); + if let Some(u) = unit { + value["unit"] = serde_json::Value::String(u); + } + let write = utopia_store::graph::upsert_fact_qualifier( + &state.pool, + fact_id, + def.id, + &value, + ) + .await?; + if write == utopia_store::graph::QualifierWrite::Conflict { + drop_signal( + state, + doc.kb_id, + document_id, + utopia_store::extraction_drops::reason::QUALIFIER_CONFLICT, + &format!("{}.{}", f.predicate, def.key), + Some(&raw.to_string()), + ) + .await; + } + } + } // 重复观察也要挂证据:多来源相互印证,任一来源删除后事实不孤儿化。 // 表层谓词随每次观察落笔——甲块说 "runs on"、乙块说 "optimized for" // 会并进同一条事实,放事实上就是先写者胜,放证据上两个都留着 @@ -2526,6 +2607,23 @@ fn build_lists( rels: Option<&HashSet>, ) -> PromptLists { let picked_class = |id: &Uuid| classes.is_none_or(|s| s.contains(id)); + // 边上能带的属性:关系.qualifiers → 属性行(0037)。这里只排版,写入侧另有一份同样的查法 + let rtype_by_id: HashMap = + rtypes.iter().map(|r| (r.id, r)).collect(); + let qualifier_line = |r: &utopia_core::models::RelationType| -> Vec { + r.qualifiers + .iter() + .filter_map(|q| rtype_by_id.get(q).copied()) + .filter(|q| q.kind == "attribute") + .map(|q| { + let dt = q.datatype.as_deref().unwrap_or("text"); + match q.unit.as_deref().filter(|u| !u.is_empty()) { + Some(u) => format!("{}: {dt} {u}", q.key), + None => format!("{}: {dt}", q.key), + } + }) + .collect() + }; let picked_rel = |id: &Uuid| rels.is_none_or(|s| s.contains(id)); let key_of: HashMap = etypes .iter() @@ -2567,6 +2665,8 @@ fn build_lists( description: r.description.clone(), signature, temporal: r.temporal.clone(), + // `amount: number $`——模型要按这个 key 写,单位提醒它别换算 + qualifiers: qualifier_line(r), } }) .collect(); diff --git a/crates/utopia-server/src/predicate_match.rs b/crates/utopia-server/src/predicate_match.rs index fafb8780e..eb0e06cac 100644 --- a/crates/utopia-server/src/predicate_match.rs +++ b/crates/utopia-server/src/predicate_match.rs @@ -301,6 +301,7 @@ mod tests { ranges: Vec::new(), datatype: None, unit: None, + qualifiers: vec![], } } diff --git a/crates/utopia-server/src/rdf.rs b/crates/utopia-server/src/rdf.rs index f48a3d80a..fc18a998c 100644 --- a/crates/utopia-server/src/rdf.rs +++ b/crates/utopia-server/src/rdf.rs @@ -496,6 +496,18 @@ pub fn emit_fact( for quote in &f.quotes { sink.l(&stmt, &utopia("quote"), &text(quote.clone()))?; } + // 边上的属性(0037):陈述节点上各多一行,谓词是属性的 IRI,字面量按它的 datatype + for q in &f.qualifiers { + let Some(p) = vocab.relation(q.qualifier_type_id) else { + continue; + }; + if let Some(v) = &q.value { + let (datatype, _) = vocab.literal_shape(q.qualifier_type_id); + sink.l(&stmt, p, &literal_value(v, datatype))?; + } else if let Some(e) = q.entity_id { + sink.r(&stmt, p, &names.entity(e))?; + } + } // 现行三元组:**仍被持有,且现在仍成立**。区间已闭合或已撤回的不写这一条, // 否则一个忽略具体化的消费者会读到「张三现在还管着那个项目」。 @@ -684,6 +696,7 @@ mod tests { fn fact(n: u8) -> ExportFact { ExportFact { + qualifiers: Vec::new(), id: id(n), subject_id: id(10), predicate_id: Some(id(2)), diff --git a/crates/utopia-store/src/export.rs b/crates/utopia-store/src/export.rs index 06b688fd2..27b4b8f13 100644 --- a/crates/utopia-store/src/export.rs +++ b/crates/utopia-store/src/export.rs @@ -67,6 +67,9 @@ pub struct ExportFact { pub surface_predicate: Option, pub object_id: Option, pub object_value: Option, + /// 边上的属性(0037),加载后按事实 id 补 + #[sqlx(skip)] + pub qualifiers: Vec, pub valid_from: Option>, pub valid_from_precision: Option, pub valid_to: Option>, @@ -179,7 +182,7 @@ pub async fn facts_page( kb_id: Uuid, after: Option, ) -> AppResult> { - Ok(sqlx::query_as(&format!( + let mut facts: Vec = sqlx::query_as(&format!( "SELECT f.id, f.subject_id, f.predicate_id, fact_surface_predicate(f.id) AS surface_predicate, f.object_id, f.object_value, @@ -202,7 +205,18 @@ pub async fn facts_page( .bind(after) .bind(PAGE) .fetch_all(pool) - .await?) + .await?; + // 边上的属性另一张表(0037),按事实 id 一次取回补上 + { + let ids: Vec = facts.iter().map(|f| f.id).collect(); + let mut by_fact = crate::graph::fact_qualifiers_for(pool, &ids).await?; + for f in facts.iter_mut() { + if let Some(q) = by_fact.remove(&f.id) { + f.qualifiers = q; + } + } + } + Ok(facts) } pub async fn derived_page( diff --git a/crates/utopia-store/src/extraction_drops.rs b/crates/utopia-store/src/extraction_drops.rs index b265f1304..22ce69e35 100644 --- a/crates/utopia-store/src/extraction_drops.rs +++ b/crates/utopia-store/src/extraction_drops.rs @@ -22,6 +22,12 @@ pub mod reason { pub const ATTR_NO_VALUE: &str = "attr_no_value"; /// 值不合 datatype,归一化失败 pub const ATTR_DATATYPE: &str = "attr_datatype"; + /// 模型在边上写了一个这条关系没声明过的属性 key(0037) + pub const QUALIFIER_UNKNOWN: &str = "qualifier_unknown"; + /// 边上属性的值换不成它声明的 datatype + pub const QUALIFIER_DATATYPE: &str = "qualifier_datatype"; + /// 同一条边再听到一次,属性值与已记的不一致——先记下,不覆盖 + pub const QUALIFIER_CONFLICT: &str = "qualifier_conflict"; /// 模型自报置信度低于阈值 pub const LOW_CONFIDENCE: &str = "low_confidence"; /// 关系事实缺宾语 diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index d28d623d1..266f381ad 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -1,10 +1,11 @@ //! 图谱仓储:本体、实体消解(P2 第一刀:同 KB 同类型同名合一)、事实账本、图查询。 use sqlx::PgPool; +use std::collections::HashMap; use std::collections::HashSet; use utopia_core::models::{ - ChunkFactView, EntityFact, EntityHistoryEvent, EntityType, EvidenceView, FactReviewItem, - GraphChange, GraphEdge, GraphNode, ProposedPredicate, RelationType, + ChunkFactView, EntityFact, EntityHistoryEvent, EntityType, EvidenceView, FactQualifier, + FactReviewItem, GraphChange, GraphEdge, GraphNode, ProposedPredicate, RelationType, }; use utopia_core::{AppError, AppResult}; use uuid::Uuid; @@ -65,7 +66,9 @@ pub async fn relation_types(pool: &PgPool, kb_id: Uuid) -> AppResult { Value(&'a serde_json::Value), } +/// 往一条边上写一个属性值的结果(0037)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QualifierWrite { + /// 这条边之前没有这个属性,写上了 + Set, + /// 已经有了、值一样:再一次观察,什么都不用改 + Same, + /// 已经有了、值**不一样**。不覆盖——先写者留着,调用方记一笔让人看见。 + /// 账本里两次观察不一致从来是两行 + 一条冲突,这里还没走到另立一行那一步 + Conflict, +} + +/// 往一条边上写一个字面值属性。**属性不进事实的去重键**:同一条边再听到一次带了 +/// 金额的,是同一条边补上金额,不是第二条边。 +pub async fn upsert_fact_qualifier( + pool: &PgPool, + fact_id: Uuid, + qualifier_type_id: Uuid, + value: &serde_json::Value, +) -> AppResult { + let existing: Option<(serde_json::Value,)> = sqlx::query_as( + "SELECT value FROM fact_qualifiers WHERE fact_id = $1 AND qualifier_type_id = $2", + ) + .bind(fact_id) + .bind(qualifier_type_id) + .fetch_optional(pool) + .await?; + match existing { + Some((v,)) if &v == value => Ok(QualifierWrite::Same), + Some(_) => Ok(QualifierWrite::Conflict), + None => { + sqlx::query( + "INSERT INTO fact_qualifiers (fact_id, qualifier_type_id, value) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(fact_id) + .bind(qualifier_type_id) + .bind(value) + .execute(pool) + .await?; + Ok(QualifierWrite::Set) + } + } +} + +/// `fact_qualifiers` 连上定义与实体名之后的一行 +#[derive(sqlx::FromRow)] +struct QualifierRow { + fact_id: Uuid, + qualifier_type_id: Uuid, + key: String, + label: String, + value: Option, + entity_id: Option, + entity_name: Option, +} + +/// 一批事实各自带的属性,按事实 id 取回。读边的两条路(面板、画布)加载完行后都过这里 +pub async fn fact_qualifiers_for( + pool: &PgPool, + fact_ids: &[Uuid], +) -> AppResult>> { + let mut out: HashMap> = HashMap::new(); + if fact_ids.is_empty() { + return Ok(out); + } + let rows: Vec = sqlx::query_as( + "SELECT q.fact_id, q.qualifier_type_id, r.key, r.label, q.value, q.entity_id, + e.canonical_name AS entity_name + FROM fact_qualifiers q + JOIN relation_types r ON r.id = q.qualifier_type_id + LEFT JOIN entities e ON e.id = q.entity_id + WHERE q.fact_id = ANY($1) + ORDER BY q.fact_id, r.key", + ) + .bind(fact_ids) + .fetch_all(pool) + .await?; + for r in rows { + out.entry(r.fact_id).or_default().push(FactQualifier { + qualifier_type_id: r.qualifier_type_id, + key: r.key, + label: r.label, + value: r.value, + entity_id: r.entity_id, + entity_name: r.entity_name, + }); + } + Ok(out) +} + #[allow(clippy::too_many_arguments)] pub async fn insert_fact( pool: &PgPool, @@ -688,7 +782,7 @@ async fn edges_among( // // 断言那一段多算一位 `contested`:有 open 的违规或时态冲突指着它。派生撞断言 // 时被撞的是 left;right 只是最后一条前提,它本身没有争议 - let edges: Vec = sqlx::query_as(&format!( + let mut edges: Vec = sqlx::query_as(&format!( "SELECT f.id, {subject} AS source, {object} AS target, COALESCE(r.key, fact_surface_predicate(f.id)) AS predicate, COALESCE(r.label, fact_surface_predicate(f.id)) AS label, @@ -767,6 +861,16 @@ async fn edges_among( .bind(as_of) .fetch_all(pool) .await?; + // 边上的属性另一张表(0037),按 id 一次取回补上 + { + let ids: Vec = edges.iter().map(|x| x.id).collect(); + let mut by_fact = fact_qualifiers_for(pool, &ids).await?; + for x in edges.iter_mut() { + if let Some(q) = by_fact.remove(&x.id) { + x.qualifiers = q; + } + } + } Ok(edges) } @@ -911,7 +1015,7 @@ pub async fn entity_detail( .await? .ok_or(AppError::NotFound)?; - let facts: Vec = sqlx::query_as(&format!( + let mut facts: Vec = sqlx::query_as(&format!( "SELECT f.id, CASE WHEN {subject} = $2 THEN 'out' ELSE 'in' END AS direction, COALESCE(r.key, fact_surface_predicate(f.id)) AS predicate_key, @@ -973,6 +1077,16 @@ pub async fn entity_detail( .bind(at) .fetch_all(pool) .await?; + // 边上的属性另一张表(0037),按 id 一次取回补上 + { + let ids: Vec = facts.iter().map(|x| x.id).collect(); + let mut by_fact = fact_qualifiers_for(pool, &ids).await?; + for x in facts.iter_mut() { + if let Some(q) = by_fact.remove(&x.id) { + x.qualifiers = q; + } + } + } Ok((node, facts)) } diff --git a/crates/utopia-store/src/ontology.rs b/crates/utopia-store/src/ontology.rs index f70697b80..704692796 100644 --- a/crates/utopia-store/src/ontology.rs +++ b/crates/utopia-store/src/ontology.rs @@ -496,6 +496,57 @@ async fn set_domains_ranges( Ok(()) } +/// 一条关系声明自己的边能带哪些属性(0037)。覆盖式写入,与 domain / range 同一套。 +/// +/// 两条校验都在这里,CHECK 引不到别的行:同库,且每一个都是 `kind = 'attribute'` +/// ——边上的属性是字面值,复用的正是属性定义的 datatype / unit / 换算。 +/// 一个关系不能把自己声明成自己的属性(DB 有 CHECK,这里给人话)。 +pub async fn set_relation_qualifiers( + pool: &PgPool, + kb_id: Uuid, + relation_type_id: Uuid, + qualifier_type_ids: &[Uuid], +) -> AppResult<()> { + if qualifier_type_ids.contains(&relation_type_id) { + return Err(AppError::invalid( + "qualifier_is_self", + "A relation cannot be its own qualifier", + )); + } + if !qualifier_type_ids.is_empty() { + let (ok,): (i64,) = sqlx::query_as( + "SELECT count(*) FROM relation_types + WHERE kb_id = $1 AND kind = 'attribute' AND id = ANY($2)", + ) + .bind(kb_id) + .bind(qualifier_type_ids) + .fetch_one(pool) + .await?; + if ok as usize != qualifier_type_ids.len() { + return Err(AppError::invalid( + "qualifier_not_attribute", + "Every qualifier must be an attribute of this base", + )); + } + } + sqlx::query("DELETE FROM relation_type_qualifiers WHERE relation_type_id = $1") + .bind(relation_type_id) + .execute(pool) + .await?; + if !qualifier_type_ids.is_empty() { + sqlx::query( + "INSERT INTO relation_type_qualifiers (relation_type_id, qualifier_type_id) + SELECT $1, x FROM unnest($2::uuid[]) AS x + ON CONFLICT DO NOTHING", + ) + .bind(relation_type_id) + .bind(qualifier_type_ids) + .execute(pool) + .await?; + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub async fn update_relation_type( pool: &PgPool, diff --git a/crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs b/crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs new file mode 100644 index 000000000..0f6dbc0c9 --- /dev/null +++ b/crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs @@ -0,0 +1,167 @@ +//! 边上的属性不进边的身份(0037)。 +//! +//! 同一条边再听到一次带了金额的,是同一条边补上金额;同一条边两次金额打架, +//! 先写者留着、报冲突,不覆盖。声明那一侧:只有本库的属性能当限定项,自己不行。 + +use sqlx::PgPool; +use utopia_store::graph::QualifierWrite; +use uuid::Uuid; + +#[tokio::test] +async fn a_qualifier_is_added_to_the_same_edge_and_never_overwritten() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let tag = Uuid::now_v7(); + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, $2)") + .bind(org) + .bind(format!("qual-{tag}")) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, $3)") + .bind(ws) + .bind(org) + .bind(format!("qual-{tag}")) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, $3)") + .bind(kb) + .bind(ws) + .bind(format!("qual-{tag}")) + .execute(&pool) + .await?; + let class = Uuid::now_v7(); + sqlx::query("INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'org', 'Org')") + .bind(class) + .bind(kb) + .execute(&pool) + .await?; + // 一个属性定义(金额)、一条关系(投资) + let amount = utopia_store::ontology::create_relation_type( + &pool, + kb, + "amount", + "amount", + "state", + Default::default(), + "", + "attribute", + &[class], + &[], + Some("number"), + Some("$"), + ) + .await?; + let invested = utopia_store::ontology::create_relation_type( + &pool, + kb, + "invested_in", + "invested in", + "event", + Default::default(), + "", + "relation", + &[], + &[], + None, + None, + ) + .await?; + + // 声明:属性能当限定项;关系不能;自己不能 + utopia_store::ontology::set_relation_qualifiers(&pool, kb, invested, &[amount]).await?; + let declared = utopia_store::graph::relation_types(&pool, kb) + .await? + .into_iter() + .find(|r| r.id == invested) + .map(|r| r.qualifiers) + .unwrap_or_default(); + assert_eq!(declared, vec![amount], "声明要能从关系上读回来"); + assert!( + utopia_store::ontology::set_relation_qualifiers(&pool, kb, invested, &[invested]) + .await + .is_err(), + "关系不能把自己声明成自己的属性" + ); + let other_rel = utopia_store::ontology::create_relation_type( + &pool, + kb, + "owns", + "owns", + "state", + Default::default(), + "", + "relation", + &[], + &[], + None, + None, + ) + .await?; + assert!( + utopia_store::ontology::set_relation_qualifiers(&pool, kb, invested, &[other_rel]) + .await + .is_err(), + "只有属性能当限定项" + ); + + // 一条边 + let (a, b, fact) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + for (id, name) in [(a, "Vega"), (b, "Northwind")] { + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(kb) + .bind(class) + .bind(format!("{name}-{tag}")) + .execute(&pool) + .await?; + } + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $5, 0.9)", + ) + .bind(fact) + .bind(kb) + .bind(a) + .bind(invested) + .bind(b) + .execute(&pool) + .await?; + + // 第一次:写上;第二次同值:什么都不做;第三次不同值:报冲突、不覆盖 + let five = serde_json::json!({ "value": 5000000000.0, "unit": "$" }); + let twenty = serde_json::json!({ "value": 20000000000.0, "unit": "$" }); + assert_eq!( + utopia_store::graph::upsert_fact_qualifier(&pool, fact, amount, &five).await?, + QualifierWrite::Set + ); + assert_eq!( + utopia_store::graph::upsert_fact_qualifier(&pool, fact, amount, &five).await?, + QualifierWrite::Same + ); + assert_eq!( + utopia_store::graph::upsert_fact_qualifier(&pool, fact, amount, &twenty).await?, + QualifierWrite::Conflict + ); + let read = utopia_store::graph::fact_qualifiers_for(&pool, &[fact]).await?; + let q = &read[&fact]; + assert_eq!(q.len(), 1); + assert_eq!(q[0].key, "amount"); + assert_eq!(q[0].value, Some(five), "打架时先写者留着,不覆盖"); + assert!(q[0].entity_id.is_none()); + + // 收拾:库删了,级联带走全部 + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(kb) + .execute(&pool) + .await?; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + Ok(()) +} diff --git a/docs/decisions/0037-a-relation-carries-its-own-attributes.md b/docs/decisions/0037-a-relation-carries-its-own-attributes.md new file mode 100644 index 000000000..9701050f1 --- /dev/null +++ b/docs/decisions/0037-a-relation-carries-its-own-attributes.md @@ -0,0 +1,112 @@ +# 0037 · A relation carries its own attributes + +- **Status**: written · cut 1 in progress: `relation_type_qualifiers` and `fact_qualifiers` + (migration 0049), a relation declares its qualifiers, extraction writes them, the panel and + the export read them · not in this cut: an entity-valued qualifier (the column is reserved, + nothing writes it), a second row plus a conflict when two mentions of one edge disagree + (today the first value stays and the disagreement goes to the drop report), the canvas + label, the bootstrap proposing qualifiers for a relation it adopts +- **Written**: 2026-09-10 (conventions in the [README](README.md)) +- **Related**: [0031](0031-an-event-holds-at-the-moment-it-names.md) is what makes the same + edge repeatable — an event's key includes its moment — and this record leans on it for + identity. [0022](0022-an-unknown-date-is-not-an-open-one.md) put the reading of a row's + shape in one place; this record adds a table beside the row and changes no shape. + [0010](0010-no-relation-is-no-relation.md) keeps the wording of an unadopted relation on the + evidence; a qualifier on such an edge is out of scope here. #586 and #587 stopped a written + quantity from becoming an entity, which is what exposed the gap below. + +> A document says *Meridian Partners invested $4 billion in Kestrel Dynamics*. The extractor +> has one shape for a fact — a subject, a predicate, and either an entity or a literal on the +> other side — so the sentence has to lose one half. It keeps the edge, `Meridian invested +> Kestrel`, and the four billion is nowhere. In the next document, *Vega Capital invested +> $5 billion in Northwind Robotics*, the model keeps the amount instead: `Northwind +> investment_amount $5 billion`, on the company, with no way back to which investment it was. +> Northwind has two five-billion mentions in the corpus; the graph cannot say which is which. +> Before #586 the amount had a third fate: it became a node called `$5 billion`, shared by +> every company that ever raised that sum. + +## The problem + +A fact row is `(subject, predicate, object | value)`. The check on the table is an `OR`, so +both columns may be set, but nothing writes both and every reader picks one. The amount on +an investment is not the object of the edge and not the value of an attribute of the +company. It is a property **of the edge**, and the ledger has no place for that. + +Everything else an edge might need is already there. A fact has an id. Evidence, conflicts +and derivations hang off that id. The RDF export reifies every fact as an `rdf:Statement` and +hangs time, confidence, supersession and the quote off the statement node. The edge already +has the standing of a node in every sense but one: it cannot carry an attribute. + +## Dead ends + +**The amount as a second object.** Put `$4 billion` in `object_value` next to `object_id = +Kestrel`. The database allows it. But `object_value` means *the literal this attribute +predicate takes as its object*, and every consumer reads it that way — the export chooses +the entity and drops the literal, the tools print one or the other, the datatype for the +literal is read off the predicate, which for a relation has none. It also holds exactly one +figure with no name: is it the amount, the stake, the price? Half a day was spent on this +branch before the question "what is the key?" showed it was not a shortcut. + +**The event as an entity.** Make `Investment #1` a node with `investor`, `target`, `amount`, +`date`. It needs no schema change and inherits entity resolution for free, which is why it +looked right for an hour. It also puts a node on the canvas that no one wants to look at — +the plan already included collapsing it back into an edge — and it answers a question nobody +asked. Standard vocabularies do reify events as classes (`schema:PublicationEvent`, +`prov:Generation`); that is a reason their object properties are all states (see +[0031](0031-an-event-holds-at-the-moment-it-names.md), 2026-09-10 note), not a reason to +copy the shape. + +**A qualifier as a new kind of thing.** A parallel type system for edge attributes — its own +table of definitions, its own datatypes, its own normalisation. The existing attribute +definition already has a key, a label, a datatype, a unit, `normalize_attr_value`, and an +IRI for export. The only thing a qualifier definition lacks is a domain that is a relation +instead of a class. + +## Decisions + +1. **A relation declares which attributes its edges may carry.** `relation_type_qualifiers` + links a relation to attribute definitions (rows of `relation_types` with + `kind = 'attribute'`). Same base, attribute kind, not itself: checked in the store, since a + `CHECK` cannot see another row. The prompt lists them after the relation — + `invested_in (organization → organization) [event] {amount: number $}` — and the model is + told to write them under `"qualifiers"` on the fact, keyed exactly as listed, never as a + separate fact and never dropped to keep the edge. + +2. **A qualifier value lives beside the fact, not in it.** `fact_qualifiers (fact_id, + qualifier_type_id, value | entity_id)`, one row per attribute per edge, the value in the + same `{"value", "unit"}` shape as `object_value`, converted by the attribute's datatype at + write time (`$4 billion` → `4000000000` with unit `$`). The `facts` row does not change. + Readers load the rows they always did and attach qualifiers by fact id afterwards; the + `FromRow` structs carry the field with `#[sqlx(skip)]`. + +3. **A qualifier is not part of the edge's identity.** The dedup key of a fact stays + `(subject, predicate, object, moment)` — [0031](0031-an-event-holds-at-the-moment-it-names.md) + already makes two investments at different moments two rows, and a dateless re-mention + fold into the known one. A second mention that adds an amount adds it to the same row. A + second mention that gives a *different* amount for the same row does not overwrite the + first; in this cut it is recorded in the drop report as `qualifier_conflict`. The ledger's + answer to two observations that disagree is two rows and a `fact_conflicts` entry; wiring + that requires a write path that can bypass dedup on purpose, and is the next cut. + +4. **An entity-valued qualifier is reserved, not built.** "A invested in B *through* C" needs a + qualifier that points at a node. The table has the column and the `CHECK` that says one of + value or entity, so the second migration is never needed; nothing writes it yet, and the + prompt does not offer it. + +5. **Export needs no new vocabulary.** The statement node already exists; each qualifier is + one more triple on it, predicate = the attribute's IRI, object = the literal typed by the + attribute's datatype. RDF 1.2's reifier is the same shape. + +## Open questions + +- Where does the amount go when the relation is not adopted yet (`predicate_id` is null, + wording on the evidence)? This cut drops it with a reason. Keeping it keyed by wording + until adoption would preserve the figure; the bootstrap would then have to propose the + qualifier along with the relation. +- The canvas. An edge label with the amount is a rendering change and belongs with the + parallel-edge work; the timeline reading an event as a point is + [0031](0031-an-event-holds-at-the-moment-it-names.md)'s UI cut. +- Whether a qualifier should be offered on a relation the ontology packs bring in. Their + relations are states pointing at reified event nodes, so the natural home of an amount in + schema.org is the event class, not the edge. No pack relation declares a qualifier by + default. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 13e43913d..d5538f9a9 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -60,6 +60,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0034 | [An action is a declared call](0034-an-action-is-a-declared-call.md) | Cut 1 · the record. A base can conclude but cannot act: nothing holds a call it may make, a typed parameter, or what it sent. An action is **data** (method, URL, headers, a sealed auth block, a JSON body template, scalar parameters with bounds or a value set), substituted and never scripted, so the page renders what runs and an unknown placeholder is refused at save. Registered once for the deployment and **granted** to a base, one layer where warehouses have two because nothing is mounted. Every run is a row read by two log pages. The reach is the operator's, with no placeholder in the host. Rules fire actions in the next record | | 0035 | [A vector index is built by a job](0035-a-vector-index-is-built-by-a-job.md) | Implemented · a partial HNSW index per dimension on `chunks.embedding` and `entities.profile_embedding`, requested by the first write of that dimension and built by a `build_vector_index` job outside any transaction · the two nearest-neighbour reads write the dimension as a literal, cast both sides and set `hnsw.iterative_scan = relaxed_order` · type resolution gathers its neighbours eight at a time in order and remembers descendant sets per batch (#512, #514) · dimensions above 2000 stay on the exact path | | 0036 | [Exploration aligns a schema to the ontology](0036-exploration-aligns-a-schema-to-the-ontology.md) | Written, not implemented · exploration proposes an alignment per table (the class it is a table of, its attributes, its relations, a conversion tree per column) through `ontology_proposals`, adopted as one thing · a definition is a rule over aligned attributes, written by a person; a shared convention is one rule others read · conversions are 0032's expression tree, text parsed by `sqlparser`, never stored as SQL · `Metric` / `Dimension` retire and `concept_mappings` becomes rendered · the schema document leaves extraction · where a rule runs against the source stays open | +| 0037 | [A relation carries its own attributes](0037-a-relation-carries-its-own-attributes.md) | Written · cut 1 in progress: qualifier tables (0049), declaration, extraction, panel, export · entity-valued qualifier reserved, conflict-as-two-rows and the canvas label next | ## Not a decision record diff --git a/migrations/0049_a_relation_declares_its_qualifiers.sql b/migrations/0049_a_relation_declares_its_qualifiers.sql new file mode 100644 index 000000000..93cf6499d --- /dev/null +++ b/migrations/0049_a_relation_declares_its_qualifiers.sql @@ -0,0 +1,42 @@ +-- 谓语带属性(0037)。 +-- +-- `A invested B` 这条边上的「金额 50 亿」不是第二个宾语——宾语是 B,金额是这条边 +-- 自己的属性。从前它没地方放:`object_value` 是属性事实的宾语(`valuation → 5B` +-- 里的 5B),拿它放边上的金额,等于说这条边的宾语既是 B 又是 5B。 +-- +-- 两张表,都是纯增量,`facts` 一列不动: +-- relation_type_qualifiers 一个关系声明自己能带哪些属性。属性定义复用 +-- `relation_types` 里 kind='attribute' 的行——datatype、 +-- unit、换算都是现成的,只是它的 domain 是一个关系而不是 +-- 一个类。同库、且必须是 attribute,这两条在 store 里校验 +-- (CHECK 引不到别的行)。 +-- fact_qualifiers 一条事实上挂的属性值。形状与 object_value 一致: +-- {"value": …, "unit": …},单位随事实落笔。 +-- +-- 身份:属性**不进**事实的去重键。同一条边再听到一次带了金额的,是同一条边补上 +-- 金额;同一条边两次金额打架,另立一行并记 fact_conflicts,交给人裁——账本里两次 +-- 观察不一致从来都是两行 + 一条冲突,这里不例外。 + +CREATE TABLE relation_type_qualifiers ( + relation_type_id UUID NOT NULL REFERENCES relation_types(id) ON DELETE CASCADE, + qualifier_type_id UUID NOT NULL REFERENCES relation_types(id) ON DELETE CASCADE, + PRIMARY KEY (relation_type_id, qualifier_type_id), + CHECK (relation_type_id <> qualifier_type_id) +); + +CREATE TABLE fact_qualifiers ( + fact_id UUID NOT NULL REFERENCES facts(id) ON DELETE CASCADE, + qualifier_type_id UUID NOT NULL REFERENCES relation_types(id) ON DELETE CASCADE, + -- 二选一:字面值(金额、比例、日期),或指向一个实体(「经 C 撮合」里的 C)。 + -- 这一刀只写字面值;实体那一格是「边能指向节点」这层地位的位置,先留着, + -- 免得下一次再迁。哪一格有值由 qualifier_type 的 kind 决定(attribute / relation) + value JSONB, + entity_id UUID REFERENCES entities(id) ON DELETE CASCADE, + PRIMARY KEY (fact_id, qualifier_type_id), + CHECK ((value IS NOT NULL) <> (entity_id IS NOT NULL)) +); + +-- 读边时要把它的属性一起带出来,按事实取 +CREATE INDEX fact_qualifiers_fact_idx ON fact_qualifiers (fact_id); +-- 本体页要列「哪些关系带这个属性」,按属性取 +CREATE INDEX relation_type_qualifiers_qualifier_idx ON relation_type_qualifiers (qualifier_type_id); diff --git a/web/src/api.ts b/web/src/api.ts index 3d3425e0e..3ae0cb8af 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -826,6 +826,16 @@ export interface MergeLog { reverted_at: string | null; } +/** 边上的属性(0037):`{ key: "amount", value: { value: 4e9, unit: "$" } }` */ +export interface FactQualifier { + qualifier_type_id: string; + key: string; + label: string; + value: { value?: unknown; unit?: string } | null; + entity_id: string | null; + entity_name: string | null; +} + export interface GraphEdge { id: string; source: string; @@ -858,6 +868,8 @@ export interface GraphEdge { /** 幽灵边(0017 §3):没落地的派生。`id` 是那条 `derived_contradiction` 违规的 id; * `derived` 同时为 true,跟着派生开关走。点它打开主语的面板 */ blocked: boolean; + /** 边上的属性(0037) */ + qualifiers: FactQualifier[]; } export interface EntityFact { @@ -874,6 +886,8 @@ export interface EntityFact { other_name: string | null; /** 字面值宾语(属性事实/问数映射):{"value":…} 或 {"summary":…} */ object_value: Record | null; + /** 边上的属性(0037) */ + qualifiers: FactQualifier[]; valid_from: string | null; valid_to: string | null; /** 读出来的区间(0022),与 GraphEdge 同义:「此刻成立」按它判 */ @@ -1004,6 +1018,8 @@ export interface RelationTypeView { domains: string[]; /** 可以当宾语的类。只对 relation 有意义——attribute 的值域是 datatype */ ranges: string[]; + /** 这条关系的边能带哪些属性(0037):属性定义的 id */ + qualifiers: string[]; datatype: "text" | "number" | "date" | "bool" | null; unit: string | null; usage: number; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 375cc8910..7f3bfc907 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1280,6 +1280,9 @@ export const en = { parent: "Parent class", noParent: "(top level)", subclasses: "Subclasses", + qualifiers: "Edge attributes", + noQualifiers: "None", + qualifiersHint: "Attributes an edge of this relation may carry, e.g. amount on invested_in", noSubclasses: "None", disjoint: "Cannot also be", disjointHint: diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 7581bda38..331a2f8cc 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1137,6 +1137,9 @@ export const zh: Strings = { parent: "父类", noParent: "(顶层)", subclasses: "子类", + qualifiers: "边上的属性", + noQualifiers: "无", + qualifiersHint: "这条关系的边能带的属性,例如 invested_in 上的金额", noSubclasses: "无", disjoint: "不可能同时是", disjointHint: diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 650da184b..b51c39402 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -3193,6 +3193,16 @@ function FactRow({ {fact.other_name ?? fmtObjectValue(fact.object_value) ?? "?"} + {/* 边上的属性(0037):`amount $4B`——跟在宾语后面,不另起一行。 + 投了谁和投了多少是同一句话,拆开就读不成一句了 */} + {fact.qualifiers?.map((q) => ( + + {q.label || q.key}{" "} + + {q.entity_name ?? fmtObjectValue(q.value) ?? "?"} + + + ))} {/* 第二行:何时成立、要不要留神、以及看证据与改期的入口。 **没有日期也要说一句**——空着的时候,「原文没写日期」和 diff --git a/web/src/pages/Ontology.tsx b/web/src/pages/Ontology.tsx index 56589a8c7..bb3fe5b77 100644 --- a/web/src/pages/Ontology.tsx +++ b/web/src/pages/Ontology.tsx @@ -1361,6 +1361,12 @@ function PropertyDefinition({ {rel.ranges.length > 0 ? rel.ranges.map(typeName).join(", ") : S.ontology.anyType} + {/* 边上的属性(0037):这条关系的边能带哪些属性,按属性的 label 列 */} + + {rel.qualifiers.length > 0 + ? rel.qualifiers.map(typeName).join(", ") + : S.ontology.noQualifiers} + {temporal} {axioms.length > 0 ? ( @@ -1679,6 +1685,7 @@ function UniquenessPanel({ description: rel.description, domains: rel.domains, ranges: rel.ranges, + qualifiers: rel.qualifiers, }); } const r = await api.reconcileRelationType(kbId, c.predicate_id); diff --git a/web/src/pages/ontologyDialogs.tsx b/web/src/pages/ontologyDialogs.tsx index 20b88a931..4b2a8ea47 100644 --- a/web/src/pages/ontologyDialogs.tsx +++ b/web/src/pages/ontologyDialogs.tsx @@ -337,6 +337,8 @@ export function PropertyDialog({ existing?.domains ?? (initialDomain ? [initialDomain] : []), ); const [ranges, setRanges] = useState(existing?.ranges ?? []); + // 边上的属性(0037):这条关系的边能带哪些属性,选的是本库里 kind=attribute 的定义 + const [qualifiers, setQualifiers] = useState(existing?.qualifiers ?? []); // 显示标签,不显示 key。**进提示词的 key 由服务端从库里取**,与界面显示什么无关 const typeOpts = useMemo(() => parentOptions(allTypes, undefined), [allTypes]); // 两个下拉的选项:本库的其它关系。**自己不进列表**,两条都是:子属性指向自己 @@ -374,6 +376,7 @@ export function PropertyDialog({ description, domains, ranges, + qualifiers, }; return existing ? api.updateRelationType(kbId, existing.id, body) @@ -468,6 +471,20 @@ export function PropertyDialog({ emptyHint={S.ontology.anyType} /> +
+ {/* 边上的属性(0037):选项是本库的属性定义,不是类。 + 关系不能把自己声明成自己的属性,列表里也不列自己 */} +
{S.ontology.qualifiers}
+ r.kind === "attribute" && r.id !== existing?.id) + .map((r) => ({ value: r.id, label: r.label, indent: 0 }))} + onToggle={(id) => toggleIn(setQualifiers, id)} + placeholder={S.ontology.searchTypes} + emptyHint={S.ontology.noQualifiers} + /> +