From b99bcefa9c78ddb10529a41f4e06606da42f88ce Mon Sep 17 00:00:00 2001 From: WaylandYang <145302500+WaylandYang@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:03:23 +0800 Subject: [PATCH 01/74] An event holds at the moment it names (#487) Signed-off-by: WaylandYang Co-authored-by: Claude Fable 5.1 --- crates/utopia-extract/src/lib.rs | 77 +- crates/utopia-server/src/extraction.rs | 1 + crates/utopia-server/src/rdf.rs | 6 + crates/utopia-store/src/graph.rs | 192 ++++- crates/utopia-store/src/reasoning.rs | 60 +- crates/utopia-store/src/temporal.rs | 9 +- crates/utopia-store/src/world_axis.rs | 37 +- .../an_event_holds_at_the_moment_it_names.rs | 697 ++++++++++++++++++ ...8-an-event-holds-at-the-moment-it-names.md | 65 ++ docs/decisions/README.md | 1 + 10 files changed, 1124 insertions(+), 21 deletions(-) create mode 100644 crates/utopia-store/tests/an_event_holds_at_the_moment_it_names.rs create mode 100644 docs/decisions/0028-an-event-holds-at-the-moment-it-names.md diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index 0e7358bc7..02c4e1e6e 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -83,6 +83,9 @@ pub struct PromptRelation { /// **一律用 key**:模型要输出的就是 key,中文库里 person 的 label 是"人物", /// 写进签名等于教它输出一个不存在的类型(docs/decisions/0004) pub signature: String, + /// 时间语义(`relation_types.temporal`):`state` / `event` / `eternal`(0028)。 + /// 只有 event 与 eternal 会在清单里带标记——状态是默认,写出来只多花 token + pub temporal: String, } /// Response-scoped reference to a persistent entity; database UUIDs must never enter prompts. @@ -140,15 +143,32 @@ pub fn build_messages( } else { String::new() }; + // 事件与恒常带方括号标记;状态是默认,不标(0028) + let mark = temporal_mark(&r.temporal) + .map(|m| format!(" [{m}]")) + .unwrap_or_default(); match (paren.is_empty(), d.is_empty()) { - (false, false) => format!("- {} ({paren}): {d}", r.key), - (false, true) => format!("- {} ({paren})", r.key), - (true, false) => format!("- {}: {d}", r.key), - (true, true) => format!("- {}", r.key), + (false, false) => format!("- {} ({paren}){mark}: {d}", r.key), + (false, true) => format!("- {} ({paren}){mark}", r.key), + (true, false) => format!("- {}{mark}: {d}", r.key), + (true, true) => format!("- {}{mark}", r.key), } }) .collect::>() .join("\n"); + // 标记也只在真有事件或恒常关系时解释一次;全是状态的库,提示词一字不变。 + // 说的是**写什么**而不是「它是什么」:事件的那一刻进 valid_from、valid_to 留空 + // ——不然模型照状态的样子填一个起点,账本就把一次收购读成从那天起一直持续 + let temporal_note = if relations + .iter() + .any(|r| temporal_mark(&r.temporal).is_some()) + { + "\n 3b. A relation marked [event] happens at one moment: put the date it happened in \ + valid_from and leave valid_to null — it has no span and does not end. A relation \ + marked [eternal] holds regardless of time: leave both dates null." + } else { + "" + }; // 记号只在真有签名时解释一次;没有签名的库,提示词一字不变。 // 说明用英文——提示词的**指令语言**是英文,只有 description 跟语料走 // @@ -253,7 +273,7 @@ pub fn build_messages( company\", \"no longer available\", \"until recently\". Use null only for something \ still going on. These are not interchangeable: null asserts it still holds, and \ writing null for a relation the text says is over makes us claim the opposite of \ - the source.\n\ + the source.{temporal_note}\n\ 4. {time_ctx}\n\ 5. quote must be a contiguous excerpt from the source text; every fact needs one.\n\ 6. confidence in 0~1: 0.9 explicitly stated, 0.7 inferred, 0.5 uncertain.\n\ @@ -291,6 +311,16 @@ pub fn build_messages( ] } +/// 清单里给关系带的标记:事件 `[event]`、恒常 `[eternal]`;状态不标。 +/// 认不出的值当状态——数据库的 CHECK 只放这三个进来,这里不再报错 +fn temporal_mark(temporal: &str) -> Option<&'static str> { + match temporal { + "event" => Some("event"), + "eternal" => Some("eternal"), + _ => None, + } +} + /// 已在本文档中出现过的实体,放进提示词的字符预算。 /// /// 超出就截断(保留先出现的)。中文商业文本先出全称、主角先出场,所以 @@ -791,9 +821,46 @@ mod prompt_shape_tests { label: key.replace('_', " "), description: description.into(), signature: signature.into(), + temporal: "state".into(), + } + } + + fn timed(key: &str, description: &str, temporal: &str) -> PromptRelation { + PromptRelation { + temporal: temporal.into(), + ..rel(key, description, "") } } + /// 事件与恒常在清单里带标记,说明只出现一次(0028) + #[test] + fn an_event_and_an_eternal_relation_are_marked() { + let rels = vec![ + rel("works_at", "受雇于某个组织。", "person → organization"), + timed("acquired", "One company buys another.", "event"), + timed("capital_of", "", "eternal"), + ]; + let msgs = build_messages(&[], &rels, &[], None, "a.txt", &[], "text"); + let s = &msgs[0].content; + assert!(s.contains("- works_at (person → organization): 受雇于某个组织。")); + assert!(s.contains("- acquired [event]: One company buys another.")); + // 没有描述时括号里是 label,标记跟在括号后面 + assert!(s.contains("- capital_of (capital of) [eternal]")); + assert!(s.contains("A relation marked [event] happens at one moment")); + assert!(s.contains("leave valid_to null")); + } + + /// **全是状态的库,提示词一字不变**:不标、不解释 + #[test] + fn a_base_of_states_pays_nothing_for_the_marks() { + let rels = vec![rel("works_at", "d", "")]; + let msgs = build_messages(&[], &rels, &[], None, "a.txt", &[], "text"); + let s = &msgs[0].content; + assert!(!s.contains("[event]")); + assert!(!s.contains("[eternal]")); + assert!(!s.contains("happens at one moment")); + } + /// 签名进括号,而且**一律是 key**:中文库的 label 是"人物", /// 写进提示词等于教模型输出一个不存在的类型。 #[test] diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index 685a24c9b..17ad17262 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -1901,6 +1901,7 @@ fn build_lists( label: r.label.clone(), description: r.description.clone(), signature, + temporal: r.temporal.clone(), } }) .collect(); diff --git a/crates/utopia-server/src/rdf.rs b/crates/utopia-server/src/rdf.rs index 945d8814d..16e0d3e86 100644 --- a/crates/utopia-server/src/rdf.rs +++ b/crates/utopia-server/src/rdf.rs @@ -378,6 +378,12 @@ pub fn emit_relation( sink.r(&iri, &nn(rdf::TYPE.as_str()), &owl(term))?; } } + // 时间语义也照抄(0028):一个 event 谓词的事实两端是同一刻,一个 eternal 谓词的 + // 事实没有日期——读的人不看这一条,会把前者读成一天的状态、后者读成从不知何时起。 + // 状态是默认,不写 + if r.temporal != "state" { + sink.l(&iri, &utopia("temporal"), &text(r.temporal.clone()))?; + } for d in &r.domains { if let Some(c) = vocab.class(*d) { let c = c.clone(); diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index ae7c62ae7..39af0d68d 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -188,6 +188,62 @@ pub fn truncate_to( chrono::Utc.from_utc_datetime(&date.and_time(time)) } +/// 一个桶的尽头:值加一个精度单位。`2024-03-15`(day)→ `2024-03-16`;`2024-03`(month) +/// → `2024-04`。事件在它命名的那个桶里成立(0028),读出来的终点就是这个;没有精度 +/// (锚点)原样返回——锚点是一刻,不是一个桶 +pub fn bucket_end( + t: chrono::DateTime, + precision: Option<&str>, +) -> chrono::DateTime { + use chrono::{Duration, Months}; + match precision { + Some("year") => t.checked_add_months(Months::new(12)).unwrap_or(t), + Some("month") => t.checked_add_months(Months::new(1)).unwrap_or(t), + Some("day") => t + Duration::days(1), + Some("hour") => t + Duration::hours(1), + Some("minute") => t + Duration::minutes(1), + Some("second") => t + Duration::seconds(1), + _ => t, + } +} + +/// 关系的时间语义(`relation_types.temporal`,0028)。状态有区间;事件是一刻——两端写 +/// 同一个值,在它命名的那个桶里成立;恒常没有日期,每一刻都成立。 +/// +/// 从图谱层第一份迁移起这一列就在,界面也一直给选;但直到 0028 之前只有 state 驱动 +/// 引擎,event 与 eternal 写进去、读出来都还是区间 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Temporal { + #[default] + State, + Event, + Eternal, +} + +impl Temporal { + /// 认不出的值当状态:数据库的 CHECK 只放这三个进来,这里不再报错 + pub fn parse(s: &str) -> Self { + match s { + "event" => Self::Event, + "eternal" => Self::Eternal, + _ => Self::State, + } + } +} + +/// 谓词的时间语义。没有谓词(0010)按状态——三者里唯一不丢信息的那个,与导入本体时 +/// 的判断一致 +pub async fn predicate_temporal(pool: &PgPool, predicate_id: Option) -> AppResult { + let Some(id) = predicate_id else { + return Ok(Temporal::State); + }; + let t: Option = sqlx::query_scalar("SELECT temporal FROM relation_types WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + Ok(t.as_deref().map(Temporal::parse).unwrap_or_default()) +} + impl<'a> Validity<'a> { /// 起始端已知、结束端未知或不适用。 pub fn starting( @@ -216,6 +272,42 @@ impl<'a> Validity<'a> { self } + /// 按谓词的时间语义归一(0028)。写入路径进库前都走一遍,与读出侧 `world_axis` + /// 说同一句话。 + /// + /// - 事件:那一刻写在**两端**。原文给了起点用起点;只给了终点,那就是它发生的 + /// 时候;给了一段,取起点——收购不会持续到明年。没日期就两端都空;「结束了 + /// 不知哪天」对一刻没有意义,一并抹掉。两端同值是这一行自己就能说清的形状: + /// 不看谓词的读者顶多把它读成一天的状态,读不成「从那天起一直如此」 + /// - 恒常:日期全抹。原文里的日期说的是别的事,不是这条关系何时成立 + /// - 状态:原样 + pub fn under(mut self, temporal: Temporal) -> Self { + match temporal { + Temporal::State => {} + Temporal::Eternal => { + self.from = None; + self.from_precision = None; + self.to = None; + self.to_precision = None; + } + Temporal::Event => { + let moment = self + .from + .map(|t| (t, self.from_precision)) + .or_else(|| self.to.map(|t| (t, self.to_precision))); + let (t, p) = match moment { + Some((t, p)) => (Some(t), p), + None => (None, None), + }; + self.from = t; + self.from_precision = p; + self.to = t; + self.to_precision = p; + } + } + self + } + /// 原文说它结束了,但没说哪天。 pub fn ended_when_unknown(mut self) -> Self { self.to = None; @@ -244,7 +336,10 @@ async fn insert_fact_inner( validity: Validity<'_>, confidence: f32, ) -> AppResult<(Uuid, bool)> { - let validity = validity.truncated(); + // 按谓词的时间语义归一(0028):事件两端同一刻,恒常无日期。写在这里而不是各个 + // 写入者那儿——抽取、点头、人自己写的事实都经过这一个门 + let temporal = predicate_temporal(pool, predicate_id).await?; + let validity = validity.under(temporal).truncated(); let same_sql = match object { FactObject::Entity(_) => { "SELECT id, valid_from, valid_to, valid_to_precision FROM facts @@ -303,11 +398,13 @@ async fn insert_fact_inner( attest_earlier(pool, *existing, validity.attested_at).await?; return Ok((*existing, false)); } - // 弱化陈述:新观察无时间,同断言已有开放行 → 并入(取起点最新的开放行) + // 弱化陈述:新观察无时间,同断言已有开放行 → 并入(取起点最新的开放行)。 + // 事件没有「开放」一说——它的两端总是同一刻——所以没日期的再观察并进已有的 + // 那一刻(0028):说过一次「三月收购了」,再听到一句没日期的「收购了」,不是第二次收购 if validity.from.is_none() && !validity.has_ended() { if let Some((existing, _, _, _)) = same .iter() - .filter(|(_, _, vt, _)| vt.is_none()) + .filter(|(_, _, vt, _)| vt.is_none() || temporal == Temporal::Event) .max_by_key(|(_, vf, _, _)| *vf) { attest_earlier(pool, *existing, validity.attested_at).await?; @@ -1937,3 +2034,92 @@ pub async fn adopt_value_facts( ) .await } + +#[cfg(test)] +mod temporal_shape_tests { + use super::*; + + fn at(s: &str) -> chrono::DateTime { + s.parse().unwrap() + } + + /// 桶的尽头是加一个精度单位;没有精度(锚点)原样 + #[test] + fn a_bucket_ends_one_unit_later() { + let d = at("2024-03-15T00:00:00Z"); + assert_eq!(bucket_end(d, Some("day")), at("2024-03-16T00:00:00Z")); + assert_eq!( + bucket_end(at("2024-03-01T00:00:00Z"), Some("month")), + at("2024-04-01T00:00:00Z") + ); + assert_eq!( + bucket_end(at("2024-12-01T00:00:00Z"), Some("month")), + at("2025-01-01T00:00:00Z"), + "跨年" + ); + assert_eq!( + bucket_end(at("2024-01-01T00:00:00Z"), Some("year")), + at("2025-01-01T00:00:00Z") + ); + assert_eq!( + bucket_end(at("2024-03-15T14:32:00Z"), Some("minute")), + at("2024-03-15T14:33:00Z") + ); + assert_eq!(bucket_end(d, None), d); + } + + /// 事件:起点优先,只有终点取终点,一段取起点,「结束了不知哪天」抹掉 + #[test] + fn an_event_collapses_to_one_moment() { + let span = Validity { + from: Some(at("2024-03-15T00:00:00Z")), + from_precision: Some("day"), + to: Some(at("2025-01-01T00:00:00Z")), + to_precision: Some("day"), + attested_at: None, + } + .under(Temporal::Event); + assert_eq!(span.from, Some(at("2024-03-15T00:00:00Z"))); + assert_eq!(span.to, Some(at("2024-03-15T00:00:00Z"))); + assert_eq!( + (span.from_precision, span.to_precision), + (Some("day"), Some("day")) + ); + + let end_only = Validity { + from: None, + from_precision: None, + to: Some(at("2024-05-01T00:00:00Z")), + to_precision: Some("month"), + attested_at: None, + } + .under(Temporal::Event); + assert_eq!(end_only.from, Some(at("2024-05-01T00:00:00Z"))); + assert_eq!(end_only.from_precision, Some("month")); + + let unknown = Validity::default() + .ended_when_unknown() + .under(Temporal::Event); + assert_eq!( + (unknown.from, unknown.to, unknown.to_precision), + (None, None, None) + ); + assert!(!unknown.has_ended(), "一刻没有「结束」可言"); + } + + /// 恒常抹掉日期;状态原样 + #[test] + fn an_eternal_fact_keeps_no_dates_and_a_state_keeps_its_own() { + let dated = Validity::starting(Some(at("1990-01-01T00:00:00Z")), Some("year")) + .attested(Some(at("2024-04-01T00:00:00Z"))); + let eternal = dated.under(Temporal::Eternal); + assert_eq!((eternal.from, eternal.from_precision), (None, None)); + assert_eq!( + eternal.attested_at, + Some(at("2024-04-01T00:00:00Z")), + "证据日期照记——读出侧不用它,账本仍知道" + ); + let state = dated.under(Temporal::State); + assert_eq!(state.from, Some(at("1990-01-01T00:00:00Z"))); + } +} diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 286b5e1da..ecd76a9af 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -850,12 +850,38 @@ type TimedEdges = ( /// 锚点为止。两端都不知道的行读成空区间,求交时自然掉出去——它支撑不了任何派生。 /// 返回 `(from, to, from_anchored, to_anchored)`。 fn read_span( + temporal: crate::graph::Temporal, from: Option>, + from_precision: Option<&str>, to: Option>, to_precision: Option<&str>, attested_from: chrono::DateTime, attested_to: Option>, ) -> (Option, Option, bool, bool) { + use crate::graph::Temporal; + match temporal { + // 恒常每一刻都成立,证据日期不闸它(0028) + Temporal::Eternal => return (None, None, false, false), + // 事件在它命名的那个桶里成立;没日期的事件区间为空——`overlap` 对空交集不推, + // 所以一条经过「不知何时收购」的链推不出东西,与读出侧一致(0028)。 + // 0028 之前写下的事件行终点是空的,按起点那个桶读 + Temporal::Event => { + return match from { + None => { + let a = attested_from.timestamp(); + (Some(a), Some(a), true, true) + } + Some(f) => { + let precision = to_precision + .filter(|p| *p != crate::graph::ENDED_UNKNOWN) + .or(from_precision); + let end = crate::graph::bucket_end(to.unwrap_or(f), precision); + (Some(f.timestamp()), Some(end.timestamp()), false, false) + } + }; + } + Temporal::State => {} + } let (f, from_anchored) = match from { Some(x) => (Some(x.timestamp()), false), None => (Some(attested_from.timestamp()), true), @@ -886,6 +912,16 @@ async fn timed_edges(pool: &PgPool, kb_id: Uuid) -> AppResult { .bind(kb_id) .fetch_all(pool) .await?; + // 谓词的时间语义(0028):事件按它的桶读,恒常两端开放。一次取全,按谓词查 + let temporal_of: HashMap = sqlx::query_as::<_, (Uuid, String)>( + "SELECT id, temporal FROM relation_types WHERE kb_id = $1", + ) + .bind(kb_id) + .fetch_all(pool) + .await? + .into_iter() + .map(|(id, t)| (id, crate::graph::Temporal::parse(&t))) + .collect(); let mut edges = Vec::with_capacity(rows.len()); let mut meta: HashMap = HashMap::new(); @@ -894,7 +930,16 @@ async fn timed_edges(pool: &PgPool, kb_id: Uuid) -> AppResult { // 按读出来的区间推(0022):没起点的前提从最早的证据起,结束了不知哪天的 // 到说出它的那份文档为止。读成开放的话,一条经过 "former CEO" 的链会推出 // 一条今天还成立的边 - let (f, t, fa, ta) = read_span(from, to, tp.as_deref(), attested_from, attested_to); + let temporal = temporal_of.get(&pred).copied().unwrap_or_default(); + let (f, t, fa, ta) = read_span( + temporal, + from, + fp.as_deref(), + to, + tp.as_deref(), + attested_from, + attested_to, + ); edges.push(TimedEdge { edge: Edge { fact: id, @@ -1326,8 +1371,17 @@ async fn attribute_facts( attested_to, ) in rows { - // 与公理那一路同一种读法(0022):读数没日期就从它的文档起算 - let (f, t, fa, ta) = read_span(from, to, tp.as_deref(), attested_from, attested_to); + // 与公理那一路同一种读法(0022):读数没日期就从它的文档起算。 + // 属性一律是状态(建属性时固定 state,界面也不给改) + let (f, t, fa, ta) = read_span( + crate::graph::Temporal::State, + from, + fp.as_deref(), + to, + tp.as_deref(), + attested_from, + attested_to, + ); // 属性事实的字面值是 `{"value": …, "unit": …}`;比较的是里面那个 value。 // 取不到就把整个对象交给求值器——它对认不出的形状一律判不满足 let inner = value.get("value").cloned().unwrap_or_else(|| value.clone()); diff --git a/crates/utopia-store/src/temporal.rs b/crates/utopia-store/src/temporal.rs index 13bb7fed3..2b20de006 100644 --- a/crates/utopia-store/src/temporal.rs +++ b/crates/utopia-store/src/temporal.rs @@ -438,7 +438,14 @@ pub async fn correct_interval( fact_id: Uuid, validity: crate::graph::Validity<'_>, ) -> AppResult> { - let validity = validity.truncated(); + // 人改区间也按谓词的时间语义归一(0028):给一个事件填了一段,落下的仍是它的那一刻 + let predicate: Option> = + sqlx::query_scalar("SELECT predicate_id FROM facts WHERE id = $1") + .bind(fact_id) + .fetch_optional(pool) + .await?; + let temporal = crate::graph::predicate_temporal(pool, predicate.flatten()).await?; + let validity = validity.under(temporal).truncated(); let mut tx = pool.begin().await?; let corrected = Uuid::now_v7(); let inserted: Option<(Uuid,)> = sqlx::query_as( diff --git a/crates/utopia-store/src/world_axis.rs b/crates/utopia-store/src/world_axis.rs index d2237416f..45c0b3051 100644 --- a/crates/utopia-store/src/world_axis.rs +++ b/crates/utopia-store/src/world_axis.rs @@ -21,28 +21,47 @@ //! 的「NULL 即现在」不同:没有人持有一个晚于此刻的信念,而没有时刻的图是全部 //! 时间的图。 +/// 谓词的时间语义(0028)。一行自己说不出它是状态、事件还是恒常——它的谓词说; +/// 没有谓词(0010)读作状态,与写入侧 `predicate_temporal` 同一判断 +fn temporal_of(alias: &str) -> String { + format!("(SELECT r.temporal FROM relation_types r WHERE r.id = {alias}.predicate_id)") +} + /// `facts`:读出来的下界——原文给了起点用起点,否则从最早的证据起。 +/// 恒常没有下界(NULL 即开放):证据日期闸的是「从何时起知道」,恒常的东西不从何时起 pub fn facts_holds_from(alias: &str) -> String { - format!("COALESCE({alias}.valid_from, {alias}.attested_from)") + format!( + "CASE WHEN {temporal} = 'eternal' THEN NULL \ + ELSE COALESCE({alias}.valid_from, {alias}.attested_from) END", + temporal = temporal_of(alias), + ) } /// `facts`:读出来的上界——原文给了终点用终点;说结束了但不知哪天,到最早说出它 /// 的那份文档为止;否则开放(NULL)。 +/// +/// 事件(0028)在它命名的那个桶里成立:上界是那一刻加一个精度单位——`2024-03-15` +/// 到 `2024-03-16` 为止,`2024-03` 到四月为止。没日期的事件上界与下界同为锚点, +/// 区间为空:它发生过,但不知何时,任何时刻都不算成立(0022 对未知的收法)。 +/// 0028 之前写下的事件行终点是空的,按起点那个桶读——不必回填。恒常没有上界 pub fn facts_holds_to(alias: &str) -> String { format!( - "CASE WHEN {alias}.valid_to IS NOT NULL THEN {alias}.valid_to \ - WHEN {alias}.valid_to_precision = 'unknown' THEN {alias}.attested_to END" + "CASE WHEN {temporal} = 'eternal' THEN NULL \ + WHEN {temporal} = 'event' THEN \ + CASE WHEN {alias}.valid_from IS NULL THEN {alias}.attested_from \ + ELSE COALESCE({alias}.valid_to, {alias}.valid_from) \ + + ('1 ' || COALESCE(NULLIF({alias}.valid_to_precision, 'unknown'), \ + {alias}.valid_from_precision))::interval END \ + WHEN {alias}.valid_to IS NOT NULL THEN {alias}.valid_to \ + WHEN {alias}.valid_to_precision = 'unknown' THEN {alias}.attested_to END", + temporal = temporal_of(alias), ) } /// `facts`:断言在 T 时刻成立。`$param` 为 NULL 即不过滤。 +/// 两端都可能开放(恒常),所以是纯粹的区间包含 pub fn facts_hold_at(alias: &str, param: usize) -> String { - format!( - "(${param}::timestamptz IS NULL \ - OR ({from} <= ${param} AND ({to} IS NULL OR {to} > ${param})))", - from = facts_holds_from(alias), - to = facts_holds_to(alias), - ) + interval_holds_at(&facts_holds_from(alias), &facts_holds_to(alias), param) } /// 纯粹的区间包含,NULL 一端即开放。派生行与幽灵边(0017 §3,区间在 `detail` 里) diff --git a/crates/utopia-store/tests/an_event_holds_at_the_moment_it_names.rs b/crates/utopia-store/tests/an_event_holds_at_the_moment_it_names.rs new file mode 100644 index 000000000..cbb83581e --- /dev/null +++ b/crates/utopia-store/tests/an_event_holds_at_the_moment_it_names.rs @@ -0,0 +1,697 @@ +//! 事件在它命名的那一刻成立,恒常每一刻都成立(0028 / #486),打在真库上。 +//! +//! 为什么非要连库:写入侧的归一(`Validity::under`)和读出侧的谓词(`world_axis`) +//! 各在一处,两处必须说同一句话;而读出侧整个活在 SQL 字符串里——一个写反的 CASE、 +//! 一个拼错的 interval 字面量,`cargo check` 都看不见。 +//! +//! 钉住的事: +//! - 事件写进库是两端同一个值;在它命名的那个桶里成立(那一天、那个月),桶外不成立 +//! - 给事件一段、只给终点、说它「结束了不知哪天」——落下的仍是一刻或没有日期 +//! - 没日期的事件任何时刻都不成立,但实体上仍列着它;之后来了日期就精化,再来一句 +//! 没日期的并进去 +//! - 恒常抹掉原文里的日期,证据日期也不闸它——1800 年和 2100 年都成立 +//! - 0028 之前写下的事件行(终点空)按起点那个桶读,不必回填 +//! - 人改一个事件的区间,落下的仍是一刻 +//! - 派生经过事件取它的桶(两端带精度),经过恒常两端开放 +//! +//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 + +use std::collections::HashSet; + +use sqlx::PgPool; +use utopia_store::graph::Validity; +use uuid::Uuid; + +fn t(s: &str) -> chrono::DateTime { + s.parse().unwrap() +} + +/// 一行事实的两端与精度 +type Span = ( + Option>, + Option, + Option>, + Option, +); + +struct Fixture { + org: Uuid, + kb: Uuid, + nova: Uuid, + orion: Uuid, + vega: Uuid, + paris: Uuid, + france: Uuid, + /// 状态:领导某组织 + leads: Uuid, + /// 事件:收购 + acquired: Uuid, + /// 事件,对称:合并 + merged_with: Uuid, + /// 恒常:首都 + capital_of: Uuid, + /// 恒常,对称:接壤 + borders: Uuid, +} + +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (organization, city, country) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (nova, orion, vega) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (paris, france) = (Uuid::now_v7(), Uuid::now_v7()); + let (leads, acquired, merged_with) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let (capital_of, borders) = (Uuid::now_v7(), Uuid::now_v7()); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'event-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'event-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'event-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + for (id, key, label) in [ + (organization, "organization", "Organization"), + (city, "city", "City"), + (country, "country", "Country"), + ] { + 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?; + } + for (id, key, temporal, symmetric) in [ + (leads, "leads", "state", false), + (acquired, "acquired", "event", false), + (merged_with, "merged_with", "event", true), + (capital_of, "capital_of", "eternal", false), + (borders, "borders", "eternal", true), + ] { + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, temporal, is_symmetric) + VALUES ($1, $2, $3, $3, $4, $5)", + ) + .bind(id) + .bind(kb) + .bind(key) + .bind(temporal) + .bind(symmetric) + .execute(pool) + .await?; + } + for (id, ty, name) in [ + (nova, organization, "Nova Systems"), + (orion, organization, "Orion Labs"), + (vega, organization, "Vega Analytics"), + (paris, city, "Paris"), + (france, country, "France"), + ] { + 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 { + org, + kb, + nova, + orion, + vega, + paris, + france, + leads, + acquired, + merged_with, + capital_of, + borders, + }) +} + +async fn teardown(pool: &PgPool, f: &Fixture) -> anyhow::Result<()> { + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(f.kb) + .execute(pool) + .await?; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(pool) + .await?; + Ok(()) +} + +async fn fact( + pool: &PgPool, + f: &Fixture, + subject: Uuid, + predicate: Uuid, + object: Uuid, + validity: Validity<'_>, +) -> anyhow::Result<(Uuid, bool)> { + Ok(utopia_store::graph::insert_fact( + pool, + f.kb, + subject, + Some(predicate), + object, + validity, + 0.9, + ) + .await?) +} + +async fn span(pool: &PgPool, id: Uuid) -> anyhow::Result { + Ok(sqlx::query_as( + "SELECT valid_from, valid_from_precision, valid_to, valid_to_precision FROM facts WHERE id = $1", + ) + .bind(id) + .fetch_one(pool) + .await?) +} + +/// T 时刻某实体面板上的事实(按 id)。`None` = 每一刻 +async fn facts_at( + pool: &PgPool, + f: &Fixture, + entity: Uuid, + at: Option<&str>, +) -> anyhow::Result> { + let (_, facts) = + utopia_store::graph::entity_detail(pool, f.kb, entity, at.map(t), None).await?; + Ok(facts.into_iter().map(|x| x.id).collect()) +} + +async fn holds( + pool: &PgPool, + f: &Fixture, + entity: Uuid, + id: Uuid, + at: &str, +) -> anyhow::Result { + Ok(facts_at(pool, f, entity, Some(at)).await?.contains(&id)) +} + +#[tokio::test] +async fn an_event_is_one_moment_and_holds_through_the_bucket_it_names() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + // 2024-04-01 的文档说:Nova 于 2024-03-15 收购 Orion + let (day, _) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.orion, + Validity::starting(Some(t("2024-03-15T00:00:00Z")), Some("day")) + .attested(Some(t("2024-04-01T00:00:00Z"))), + ) + .await?; + assert_eq!( + span(&pool, day).await?, + ( + Some(t("2024-03-15T00:00:00Z")), + Some("day".into()), + Some(t("2024-03-15T00:00:00Z")), + Some("day".into()), + ), + "事件写进库是两端同一个值、同一个精度" + ); + // 那一天里的每一刻成立;前一天、后一天都不;证据日期(4 月)更不 + assert!(!holds(&pool, &f, f.nova, day, "2024-03-14T23:59:59Z").await?); + assert!(holds(&pool, &f, f.nova, day, "2024-03-15T00:00:00Z").await?); + assert!(holds(&pool, &f, f.nova, day, "2024-03-15T23:59:59Z").await?); + assert!(!holds(&pool, &f, f.nova, day, "2024-03-16T00:00:00Z").await?); + assert!( + !holds(&pool, &f, f.nova, day, "2024-04-01T00:00:00Z").await?, + "证据日期不是事件的日期,状态那套「从证据起」不适用" + ); + assert!( + facts_at(&pool, &f, f.nova, None).await?.contains(&day), + "不限时刻时列出" + ); + + // 读出来的区间跟着桶走:从那一天起,到下一天为止 + let (_, all) = utopia_store::graph::entity_detail(&pool, f.kb, f.nova, None, None).await?; + let read = all.iter().find(|x| x.id == day).expect("在面板上"); + assert_eq!(read.holds_from, Some(t("2024-03-15T00:00:00Z"))); + assert_eq!(read.holds_to, Some(t("2024-03-16T00:00:00Z"))); + assert_eq!(read.temporal.as_deref(), Some("event")); + + // 月精度的桶是整个月 + let (month, _) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.vega, + Validity::starting(Some(t("2024-06-01T00:00:00Z")), Some("month")), + ) + .await?; + assert!(holds(&pool, &f, f.nova, month, "2024-06-30T12:00:00Z").await?); + assert!(!holds(&pool, &f, f.nova, month, "2024-07-01T00:00:00Z").await?); + + // 状态照旧:领导从 3 月起、开放——4 月也成立 + let (lead, _) = fact( + &pool, + &f, + f.nova, + f.leads, + f.orion, + Validity::starting(Some(t("2024-03-15T00:00:00Z")), Some("day")), + ) + .await?; + assert!(holds(&pool, &f, f.nova, lead, "2024-04-01T00:00:00Z").await?); + Ok::<_, anyhow::Error>(()) + } + .await; + + teardown(&pool, &f).await?; + run +} + +#[tokio::test] +async fn a_span_an_ending_or_an_unknown_end_given_to_an_event_collapses() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + // 模型给了一段:取起点 + let (ranged, _) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.orion, + Validity { + from: Some(t("2024-03-15T00:00:00Z")), + from_precision: Some("day"), + to: Some(t("2025-01-01T00:00:00Z")), + to_precision: Some("day"), + attested_at: None, + }, + ) + .await?; + let s = span(&pool, ranged).await?; + assert_eq!( + (s.0, s.2), + ( + Some(t("2024-03-15T00:00:00Z")), + Some(t("2024-03-15T00:00:00Z")) + ) + ); + + // 只给了终点:那就是它发生的时候 + let (ended, _) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.vega, + Validity { + from: None, + from_precision: None, + to: Some(t("2024-05-01T00:00:00Z")), + to_precision: Some("month"), + attested_at: None, + }, + ) + .await?; + assert_eq!( + span(&pool, ended).await?, + ( + Some(t("2024-05-01T00:00:00Z")), + Some("month".into()), + Some(t("2024-05-01T00:00:00Z")), + Some("month".into()), + ) + ); + + // 「结束了不知哪天」对一刻没有意义:落下的是没日期的事件,不是待关的行 + let (unknown, _) = fact( + &pool, + &f, + f.orion, + f.acquired, + f.vega, + Validity::default() + .ended_when_unknown() + .attested(Some(t("2024-04-01T00:00:00Z"))), + ) + .await?; + assert_eq!(span(&pool, unknown).await?, (None, None, None, None)); + let attested_to: Option> = + sqlx::query_scalar("SELECT attested_to FROM facts WHERE id = $1") + .bind(unknown) + .fetch_one(&pool) + .await?; + assert_eq!(attested_to, None, "没有「结束」这回事,也就没有结束的锚点"); + Ok::<_, anyhow::Error>(()) + } + .await; + + teardown(&pool, &f).await?; + run +} + +#[tokio::test] +async fn an_undated_event_holds_at_no_moment_and_takes_a_date_when_one_arrives( +) -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + // 2024-04-01 的文档说 Nova 收购了 Orion,没说哪天 + let (bare, created) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.orion, + Validity::default().attested(Some(t("2024-04-01T00:00:00Z"))), + ) + .await?; + assert!(created); + assert!( + facts_at(&pool, &f, f.nova, None).await?.contains(&bare), + "实体上列着" + ); + for at in [ + "2024-04-01T00:00:00Z", + "2024-04-02T00:00:00Z", + "2030-01-01T00:00:00Z", + ] { + assert!( + !holds(&pool, &f, f.nova, bare, at).await?, + "不知何时发生的事件,{at} 不算成立(状态会从证据起算,事件不)" + ); + } + + // 后来的文档给了日期:精化——裸行作废,新行两端是那一刻 + let (dated, created) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.orion, + Validity::starting(Some(t("2024-03-15T00:00:00Z")), Some("day")) + .attested(Some(t("2024-05-01T00:00:00Z"))), + ) + .await?; + assert!(created); + assert_ne!(dated, bare); + let (supersedes, old_dead): (Option, bool) = sqlx::query_as( + "SELECT supersedes, (SELECT invalidated_at IS NOT NULL FROM facts WHERE id = $2) + FROM facts WHERE id = $1", + ) + .bind(dated) + .bind(bare) + .fetch_one(&pool) + .await?; + assert_eq!(supersedes, Some(bare)); + assert!(old_dead); + assert!(holds(&pool, &f, f.nova, dated, "2024-03-15T12:00:00Z").await?); + + // 再听到一句没日期的「收购了」:并进已有的那一刻,不是第二次收购 + let (again, created) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.orion, + Validity::default().attested(Some(t("2024-06-01T00:00:00Z"))), + ) + .await?; + assert!(!created); + assert_eq!(again, dated); + Ok::<_, anyhow::Error>(()) + } + .await; + + teardown(&pool, &f).await?; + run +} + +#[tokio::test] +async fn an_eternal_relation_drops_its_dates_and_holds_at_every_moment() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + // 原文:「1990 年起巴黎是法国首都」——日期说的是别的事;文档是 2024 年的 + let (cap, _) = fact( + &pool, + &f, + f.paris, + f.capital_of, + f.france, + Validity::starting(Some(t("1990-01-01T00:00:00Z")), Some("year")) + .attested(Some(t("2024-04-01T00:00:00Z"))), + ) + .await?; + assert_eq!( + span(&pool, cap).await?, + (None, None, None, None), + "恒常不存日期" + ); + for at in [ + "1800-01-01T00:00:00Z", + "2023-12-31T00:00:00Z", + "2100-01-01T00:00:00Z", + ] { + assert!( + holds(&pool, &f, f.paris, cap, at).await?, + "{at} 成立——证据日期不闸恒常" + ); + } + let (_, all) = utopia_store::graph::entity_detail(&pool, f.kb, f.paris, None, None).await?; + let read = all.iter().find(|x| x.id == cap).expect("在面板上"); + assert_eq!((read.holds_from, read.holds_to), (None, None), "两端开放"); + + // 再说一遍并进同一行 + let (again, created) = fact( + &pool, + &f, + f.paris, + f.capital_of, + f.france, + Validity::starting(Some(t("2000-01-01T00:00:00Z")), Some("year")), + ) + .await?; + assert!(!created); + assert_eq!(again, cap); + Ok::<_, anyhow::Error>(()) + } + .await; + + teardown(&pool, &f).await?; + run +} + +#[tokio::test] +async fn a_row_written_before_the_rule_reads_as_its_start_bucket() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + // 0028 之前的形状:事件谓词、起点有、终点空——当年被读成「从那天起一直如此」 + let legacy = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, + valid_from, valid_from_precision, confidence, attested_from) + VALUES ($1, $2, $3, $4, $5, '2024-03-15T00:00:00Z', 'day', 0.9, '2024-04-01T00:00:00Z')", + ) + .bind(legacy) + .bind(f.kb) + .bind(f.nova) + .bind(f.acquired) + .bind(f.orion) + .execute(&pool) + .await?; + assert!(holds(&pool, &f, f.nova, legacy, "2024-03-15T12:00:00Z").await?); + assert!(!holds(&pool, &f, f.nova, legacy, "2024-03-16T00:00:00Z").await?); + assert!(!holds(&pool, &f, f.nova, legacy, "2025-01-01T00:00:00Z").await?, "不再是开放的"); + Ok::<_, anyhow::Error>(()) + } + .await; + + teardown(&pool, &f).await?; + run +} + +#[tokio::test] +async fn a_person_correcting_an_event_leaves_it_one_moment() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + let (original, _) = fact( + &pool, + &f, + f.nova, + f.acquired, + f.orion, + Validity::starting(Some(t("2024-03-15T00:00:00Z")), Some("day")), + ) + .await?; + // 人在面板上填了一段 3-20 ~ 6-01:落下的是 3-20 这一刻 + let corrected = utopia_store::temporal::correct_interval( + &pool, + original, + Validity { + from: Some(t("2024-03-20T00:00:00Z")), + from_precision: Some("day"), + to: Some(t("2024-06-01T00:00:00Z")), + to_precision: Some("day"), + attested_at: None, + }, + ) + .await? + .expect("改得动"); + assert_eq!( + span(&pool, corrected).await?, + ( + Some(t("2024-03-20T00:00:00Z")), + Some("day".into()), + Some(t("2024-03-20T00:00:00Z")), + Some("day".into()), + ) + ); + assert!(holds(&pool, &f, f.nova, corrected, "2024-03-20T08:00:00Z").await?); + assert!(!holds(&pool, &f, f.nova, corrected, "2024-04-15T00:00:00Z").await?); + Ok::<_, anyhow::Error>(()) + } + .await; + + teardown(&pool, &f).await?; + run +} + +#[tokio::test] +async fn a_derivation_reads_an_event_as_its_bucket_and_an_eternal_as_open() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + // merged_with 对称、事件:Nova 与 Orion 于 2024-03 合并 + fact( + &pool, + &f, + f.nova, + f.merged_with, + f.orion, + Validity::starting(Some(t("2024-03-01T00:00:00Z")), Some("month")) + .attested(Some(t("2024-04-01T00:00:00Z"))), + ) + .await?; + // borders 对称、恒常:法国与……巴黎接壤(测试用的荒唐事实,文档是 2024 年的) + fact( + &pool, + &f, + f.france, + f.borders, + f.paris, + Validity::starting(Some(t("2020-01-01T00:00:00Z")), Some("year")) + .attested(Some(t("2024-04-01T00:00:00Z"))), + ) + .await?; + // 没日期的事件:Orion 与 Vega 合并,不知何时——推不出反向那条 + fact( + &pool, + &f, + f.orion, + f.merged_with, + f.vega, + Validity::default().attested(Some(t("2024-04-01T00:00:00Z"))), + ) + .await?; + + utopia_store::reasoning::materialize(&pool, f.kb).await?; + + let derived = |s: Uuid, p: Uuid, o: Uuid| { + let pool = pool.clone(); + async move { + let row: Option = sqlx::query_as( + "SELECT valid_from, valid_from_precision, valid_to, valid_to_precision + FROM derived_facts + WHERE kb_id = $1 AND subject_id = $2 AND predicate_id = $3 AND object_id = $4 + AND invalidated_at IS NULL", + ) + .bind(f.kb) + .bind(s) + .bind(p) + .bind(o) + .fetch_optional(&pool) + .await?; + Ok::<_, anyhow::Error>(row) + } + }; + + // 反向的合并:那一个月,两端带月精度 + let back = derived(f.orion, f.merged_with, f.nova) + .await? + .expect("对称推得出"); + assert_eq!( + back, + ( + Some(t("2024-03-01T00:00:00Z")), + Some("month".into()), + Some(t("2024-04-01T00:00:00Z")), + Some("month".into()), + ), + "派生取事件的桶:从 3 月起,到 4 月为止" + ); + // 反向的接壤:两端开放,证据日期不进来 + let back = derived(f.paris, f.borders, f.france) + .await? + .expect("对称推得出"); + assert_eq!(back, (None, None, None, None)); + // 不知何时的合并:空区间,不推 + assert!( + derived(f.vega, f.merged_with, f.orion).await?.is_none(), + "没日期的事件任何时刻都不成立,链经过它推不出东西" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + + teardown(&pool, &f).await?; + run +} diff --git a/docs/decisions/0028-an-event-holds-at-the-moment-it-names.md b/docs/decisions/0028-an-event-holds-at-the-moment-it-names.md new file mode 100644 index 000000000..e0cdd983f --- /dev/null +++ b/docs/decisions/0028-an-event-holds-at-the-moment-it-names.md @@ -0,0 +1,65 @@ +# 0028 · An event holds at the moment it names + +- **Status**: implemented (#486) · `Validity::under` normalises every write by the predicate's `temporal` — in `insert_fact_inner`, so extraction, the nod and a person's own fact all pass through it, and in `correct_interval` · `world_axis` reads an event as the bucket it names and an eternal fact as open at both ends, and the evaluator's `read_span` says the same · the prompt marks `[event]` and `[eternal]` relations and tells the model what to write, for a base that has any · the export carries `utopia:temporal` on a property that is not a state · no schema change, and a row written before this reads correctly · the panel still prints an event as `T ~ T` and the ontology page still does not say what the three values do — that is the UI cut +- **Written**: 2026-09-08 (conventions in the [README](README.md)) +- **Related**: [0003](0003-ontology-growth-loop.md)'s graph migration gave a relation three temporal semantics and gave the engine one. [0022](0022-an-unknown-date-is-not-an-open-one.md) put the world-axis read in one place; this record adds two branches there and nowhere else. [0024](0024-the-world-axis-reaches-the-second.md)'s ladder is what "the bucket it names" is measured on. [0013](0013-a-source-should-hand-over-its-history.md) hands ticket events over at day precision; those were the first rows this rule was wrong for. From #486. + +> `relation_types.temporal` has been `state`, `event` or `eternal` since the first graph migration, and the ontology page offers all three — "State (has interval)", "Event (point in time)", "Eternal (timeless)". Only `state` did anything. "Nova acquired Orion on 2024-03-15" landed as *holds from 2024-03-15, still going on*; "Paris is the capital of France" picked up whatever year sat in the sentence and, having no start, held from its first evidence and not before. The ledger promised a moment and a timeless fact, and wrote and read both as intervals. + +## What the ledger did + +Three consumers read the column, and each read one value of it. + +- **The temporal engine** reconciles only `state` (`reconcile_new_fact`, `reconcile_predicate`). Right — a moment is not closed by a later moment — but that was the whole of it. +- **The world-axis reads** (`world_axis`, the evaluator's `read_span`) treated every row as an interval: a NULL end read as *still holds* (0022 keeps that for states, on purpose), and a NULL start read as *from the first evidence*. An event with a date therefore held forever after it; an eternal fact held from the day of the document that stated it. +- **The prompt** never mentioned it. The model filled `valid_from` / `valid_to` for an event the way it does for a state, and for an eternal relation copied whatever date the sentence carried. +- **The panel** hid dates on `eternal` and nothing else. + +So choosing `event` was the same as choosing `state` without a uniqueness axiom, and choosing `eternal` was a display hint. + +## Decisions + +### 1. An event is one moment, written at both ends + +`Validity::under(temporal)` runs on every write, keyed on the predicate's declared semantics. For an event: the start if the text gave one, else the end (only an end means *that is when it happened*), and a span collapses to its start — an acquisition does not run until next year. Both ends get that value and that precision. No date means no dates; "ended, date unknown" means nothing for a moment and is dropped, so an event row never carries `'unknown'` and never gets an `attested_to`. + +The rule sits in `insert_fact_inner` and `correct_interval`, not in the writers. Extraction, the pending-facts nod (0015), a person's own fact from the API and a person's edit of an interval all pass through those two doors, and nothing outside the store has to know the rule exists. + +Both ends carry the value because that is the one shape the row can explain on its own. A reader that does not consult the predicate — an old client, an export consumer, a hand-written query — sees a fact that held on one day. It cannot see *holds from that day on*, which is what an open end would have told it. The representation fails safe. + +An undated observation of an event merges into an existing dated one, the mirror of the refinement path that already lets a dated observation supersede a bare row: "acquired in March" followed by "acquired" is one acquisition, not two. + +### 2. An event holds through the bucket it names, and an undated event at no moment + +A day-precision event on 2024-03-15 holds at every instant of 2024-03-15 and at no other: `[valid_from, valid_to + one unit of its precision)`. The precision already says "the day is known, the instant is not" (0024); reading the row as holding through the day is what that sentence means. A month-precision event holds through the month. + +An event with no date holds at no moment. The read interval is `[attested_from, attested_from)`, empty. This is 0022's rule for the unknown, applied to a moment instead of a bound: a state with no start holds from its first evidence because the document asserts it as current; a document that says "Nova acquired Orion" asserts that it happened, not that it is happening. The row stays on the entity's panel under "undated", and a chain of derivations through it derives nothing, because `overlap` refuses an empty intersection. + +The read lives in `world_axis::facts_holds_to` beside the two rules 0022 put there, and `read_span` mirrors it for the evaluator. The predicate's semantics come from a correlated subquery on `relation_types` — a row cannot say what it is, but its predicate can — and a fact with no predicate (0010) reads as a state, the one value that loses nothing, the same judgment the ontology import makes when it writes `state` for every OWL property. + +### 3. An eternal fact has no dates and every moment + +Under `Eternal`, `Validity::under` clears both ends whatever the text said. A date in a sentence about the capital of France is about something else — when the document was written, when the author learned it — not about when the relation held. And the read opens both ends: `facts_holds_from` is NULL for an eternal row, so the evidence anchor no longer gates it. 0022's anchor answers "since when do we have grounds to say this holds"; for a fact that holds at every moment the question has no bearing on the answer. + +### 4. The prompt says what to write, and only when it matters + +A relation in the list gets ` [event]` or ` [eternal]` after its signature, and one rule (3b) says what those marks mean for `valid_from` and `valid_to`: an event's date goes in `valid_from` and `valid_to` stays null; an eternal relation gets no dates. A base whose relations are all states sees neither the marks nor the rule — the same discipline as the type-signature note, which also costs nothing to a base that declared none. The instruction is about what to *write*, not what the relation *is*: told only that a relation is a point in time, a model still fills a start the way it does for a state, and the write rule then collapses a span it never needed to produce. + +### 5. Rows written before this are read, not rewritten + +An event row from before this record has a start and an open end. `facts_holds_to` reads it as its start's bucket — the end falls back to the start, the precision to the start's — and `read_span` does the same. No migration touches the ledger: the rows are what they were, and the new reading is right for them. A base that wants the stored shape can re-extract. + +## Dead ends + +- **Store the bucket end.** `valid_to = valid_from + one unit` with the existing read predicate unchanged. Nothing to add to `world_axis` — and an auditor reading the export (0020) sees `validFrom 2024-03-15; validThrough 2024-03-16`, a one-day state, for a fact the text placed at a moment. The row would say something the source did not. +- **Leave the end NULL and let the read handle it.** The smallest write. It is also the shape that reads as *still holds* in every consumer that does not check the predicate — the one that was wrong before this record. A representation that is only correct through the predicate is one forgotten join from being wrong again. +- **A read rule that ignores the predicate**: "a row whose two ends are equal holds through that bucket", for every row. It would give events the right reading without the subquery. It would also change the meaning of a state row that starts and ends in one bucket, and it sits beside a convention this record does not touch: a state ending "2024-07" has always read as ended at the start of July, not the end. Changing that is a different record, about states. +- **An `attested_from = -infinity` sentinel for eternal rows.** One column, no subquery — and a magic value in a column whose meaning (0022) is "the earliest evidence", which `attest_earlier` and any future "attested" display would have to know to skip. +- **A migration that rewrites old event rows.** The read is correct for them as they are (§5), and rewriting a row's stated interval to match a rule it predates is the ledger editing history. + +## Open questions + +- **A point on the panel.** `fmtInterval` prints an event as `2024-03-15 ~ 2024-03-15` and the timeline draws it as a bar of no length. The point rendering, and a line on the ontology page saying what the three values do, are the UI cut. +- **The wording the tools give the model.** `time_text` phrases an event's interval the way it phrases a state's. Whether "on 2024-03-15" reads better than "from 2024-03-15 to 2024-03-15" for a timed answer is a prompt question, to be looked at with the tool traces. +- **An attribute taken at a moment.** Attributes are created as `state` and the UI does not offer otherwise (0021's readings are states that a later reading closes). A reading that is a measurement *at* a time rather than a value *from* a time is not expressible today; nothing has asked for it. +- **The end convention for states.** Noted above. "Until 2024-07" ends at the start of July under the current read; the bucket reading this record gives events would end it at the start of August. Left alone here. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index d3f87bbc4..1fc3b5faa 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -49,6 +49,7 @@ The test for writing one: if someone (including us) looks at a piece of code in | 0023 | [RSS observations are not documents](0023-rss-observations-are-not-documents.md) | Implemented in #326 | | 0024 | [The world axis reaches the second](0024-the-world-axis-reaches-the-second.md) | Implemented · the precision ladder runs year → second (not below: no source states less), a stored value is truncated to its precision by CHECK on all three tables, a clock time without a zone is a date, one list spells the ladder in the extractor, the renderers, the export and the evaluator; a derived bound takes the precision of the premise that set it | | 0025 | [Governance reads the ledger before it decides](0025-governance-reads-the-ledger-before-it-decides.md) | Cut 1 implemented · a per-base `governance` switch, a `govern` job that works the duplicates queue first in, first out with each head's cluster, precedents pulled from the ledger into the prompt, a gate where the agent's own confidence decides and history lowers the bar or blocks (revised after the first big-file run), every look a row in `agent_decisions`, answers through the person's own decide path · the switch, the Agent queue, the proposal chip and the Overview section in the UI · a pair the batch cannot settle is looked at again with tools and then decided or asked about, within a daily budget · two reverts in a week turn the switch off and raise an alert · identity rules the model reads and two it cannot argue with (name shapes, type families), measured against a hand-labeled set of 411 name pairs by `scripts/bench/govern.mjs` | +| 0028 | [An event holds at the moment it names](0028-an-event-holds-at-the-moment-it-names.md) | Implemented (#486) · `Validity::under` normalises every write by the predicate's `temporal` (an event is one moment written at both ends, an eternal fact has no dates), `world_axis` and `read_span` read an event as the bucket it names and an undated event at no moment, an eternal fact at every moment · the prompt marks `[event]` / `[eternal]` and says what to write · no schema change, old rows read correctly · the panel's point rendering and the ontology hint are the UI cut | ## Not a decision record From 0fffff4daef8521fd1484bffef20aeb2d6f15e07 Mon Sep 17 00:00:00 2001 From: WaylandYang <145302500+WaylandYang@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:35:25 +0800 Subject: [PATCH 02/74] Classes, properties, attributes and knowledge edit in dialogs; the panel shows (#480) * Classes, properties, attributes and knowledge edit in dialogs; the panel shows Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang * Groups fold, entries are rows, the hover card is a tooltip, every edge kind fans out Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang * Only the Danger zone icon carries the warning colour Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang * Relations reads From and To, the past folds in place, evidence is a source count Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang * The entity panel's rows carry the direction arrow, as on the ontology page Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang * A fact is one row: the relation on the left, the entity on the right Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --------- Signed-off-by: WaylandYang Co-authored-by: Claude Fable 5.1 --- web/DESIGN.md | 2 + web/src/i18n/en.ts | 17 +- web/src/i18n/zh.ts | 15 +- web/src/pages/Graph.tsx | 795 ++++--------- web/src/pages/KbSettings.tsx | 7 +- web/src/pages/Ontology.tsx | 1562 +++++++------------------ web/src/pages/OntologySchemaGraph.tsx | 40 +- web/src/pages/graphDialogs.tsx | 225 ++++ web/src/pages/graphVisuals.ts | 62 +- web/src/pages/ontologyDialogs.tsx | 831 +++++++++++++ web/src/ui/dialog.tsx | 91 ++ web/src/ui/index.tsx | 2 +- 12 files changed, 1846 insertions(+), 1803 deletions(-) create mode 100644 web/src/pages/graphDialogs.tsx create mode 100644 web/src/pages/ontologyDialogs.tsx diff --git a/web/DESIGN.md b/web/DESIGN.md index 17ff48756..dc67a1581 100644 --- a/web/DESIGN.md +++ b/web/DESIGN.md @@ -52,6 +52,8 @@ Settings and other read-a-column-of-fields pages are centred and width-limited ( Exempt: the floating panels on Graph and Ontology. Those are `glass-strong` surfaces over a canvas, and their job is to hold the canvas down so they can be read — a different problem from this one. +A floating panel **shows**; it does not edit. A class, a property, an entity, a fact's interval are read there, and every change — creating, editing, deleting, connecting — opens a `FormDialog`: one title, one form, Cancel and Save at the bottom right, Delete on its own at the bottom left. The pencil beside the panel's close key is the way in. A form inside the panel put half-edited fields next to the definition being read and Save beside Delete; a dialog gives the change its own frame, its own Esc, and leaves the panel to say what the thing is. The dedicated dialogs live in `pages/ontologyDialogs.tsx` and `pages/graphDialogs.tsx`. + ## How this is enforced `web/scripts/style-guard.mjs` scans `web/src/**/*.tsx` for the patterns above and fails CI on any hit. It runs first in `pnpm build`. While the pages were being migrated, `web/style-guard.baseline.json` listed the ones not yet done; every page passes now and the file is gone. A new file is checked from its first commit. diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index fbc266e54..48aad6af6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -881,6 +881,11 @@ export const en = { historicalNote: (n: number) => `${n} past fact${n === 1 ? "" : "s"} not shown — see Timeline →`, undated: "Undated", + /* 实体面板的 Relations:两节的标题、组尾的折、行上的证据开关 */ + fromEntity: (name: string) => `From ${name}`, + toEntity: (name: string) => `To ${name}`, + past: (n: number) => (n === 1 ? "1 past" : `${n} past`), + sources: (n: number) => (n === 1 ? "1 source" : `${n} sources`), timelineEmpty: "No dated facts yet.", lastConfirmed: (d: string) => `confirmed ${d}`, /* 三种来源共用一个标记(引擎接任对账、Review 裁决、有人手改),所以这句 @@ -892,6 +897,7 @@ export const en = { "The superseded assertion stays in the ledger — see History for who and when.", /* ---- 人工修正有效区间(302) ---- */ editTime: "Correct the interval", + editTitle: "Edit entity", timeStart: "Start", timeEnd: "End", /* 结束端的三态,与账本里的三种写法一一对应(见迁移 0003 的注释) */ @@ -1091,7 +1097,7 @@ export const en = { tabClasses: "Classes", tabProperties: "Properties", newClass: "New class", - newSubClass: "+ Sub-class", + newSubClass: "New sub-class", newProperty: "New property", filter: "Filter…", missesShort: "Unmatched", @@ -1191,6 +1197,15 @@ export const en = { attributesHint: "Literal-valued fields of this class (a person's salary, a contract's amount). Extracted with evidence and history, like any fact.", newAttribute: "New attribute", + /* 编辑弹窗(面板只展示,改动在弹窗里):标题与面板里的入口 */ + edit: "Edit", + editClass: "Edit class", + editProperty: "Edit property", + editAttribute: "Edit attribute", + connectTitle: "Connect a relationship", + connectOpen: "Connect an existing relationship…", + noDescription: "No description yet.", + axiomsNone: "None declared.", attrDatatype: "Value type", attrUnit: "Unit", attrUnitHint: "optional — e.g. CNY, %", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 146822659..121634153 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -791,12 +791,17 @@ export const zh: Strings = { historyOngoing: "未闭合", historicalNote: (n: number) => `另有 ${n} 条历史事实未显示——见时间线 →`, undated: "无日期", + fromEntity: (name) => `从 ${name} 出发`, + toEntity: (name) => `指向 ${name}`, + past: (n) => `${n} 条已结束`, + sources: (n) => `${n} 处来源`, timelineEmpty: "还没有带日期的事实。", lastConfirmed: (d: string) => `${d} 确认`, correctedHint: "这个区间来自一次修正,并非文档里逐字这么写:自动接续、审阅决定,或有人手工改过。" + "被取代的那条断言仍留在台账里——谁改的、何时改的见「变更」。", editTime: "修正区间", + editTitle: "编辑实体", timeStart: "起点", timeEnd: "终点", timeEndOpen: "仍在持续", @@ -977,7 +982,7 @@ export const zh: Strings = { tabClasses: "类", tabProperties: "属性", newClass: "新建类", - newSubClass: "+ 子类", + newSubClass: "新建子类", newProperty: "新建属性", filter: "筛选…", missesShort: "未匹配", @@ -1065,6 +1070,14 @@ export const zh: Strings = { attributesHint: "这个类的字面值字段(一个人的薪资、一份合同的金额)。和任何事实一样带证据与历史。", newAttribute: "新建属性", + edit: "编辑", + editClass: "编辑类", + editProperty: "编辑关系", + editAttribute: "编辑属性", + connectTitle: "连接一个关系", + connectOpen: "用已有的关系连接…", + noDescription: "还没有描述。", + axiomsNone: "没有声明。", attrDatatype: "值类型", attrUnit: "单位", attrUnitHint: "可选——如 CNY、%", diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 6e6fb55d3..2b4dc270f 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { useCallback, useEffect, @@ -38,7 +39,8 @@ import { TRANSPARENT, } from "./graphVisuals"; import { EntityHistory } from "./EntityHistory"; -import { fmtTime, parseDateInput } from "../time"; +import { EntityDialog, FactTimeDialog } from "./graphDialogs"; +import { fmtTime } from "../time"; import { NextStep, nextStep, useReadiness } from "./NextStep"; import { ArrowLeft, @@ -56,6 +58,7 @@ import { X, ZoomIn, ZoomOut, + ChevronRight, } from "lucide-react"; import { api, @@ -72,22 +75,20 @@ import { Button, DangerConfirm, ExpandCard, - Field, HOVER_ROW, IconButton, Input, + LinkButton, Pill, - Radio, REVEAL, + ROW_TRAILING, Row, Segmented, ToolButton, ToolDivider, ToolTower, cn, - localDate, GroupLabel, - SearchSelect, } from "../ui"; import { usePopoverFlip } from "../ui/popoverFlip"; import { useKb, useKbId } from "../kb"; @@ -2657,9 +2658,9 @@ function EntityPanel({ }, [derived, entityId]); // Relations = 按关系分组(查关系);Timeline = 有效时间轴(事情何时成立); // History = 记录时间轴(我们何时这么认为、又何时改了主意) - const [view, setView] = useState< - "relations" | "timeline" | "history" | "derived" - >("relations"); + const [view, setView] = useState<"relations" | "history" | "derived">( + "relations", + ); useEffect(() => { const it = intent?.current; if (!it) return; @@ -2670,11 +2671,9 @@ function EntityPanel({ const e: GraphNode | undefined = detail.data?.entity; - // 实体修正:抽取给的是初判,判错此前只能整库重抽 + // 实体修正(名字、类型)在弹窗里:面板只展示 const qc = useQueryClient(); const [editing, setEditing] = useState(false); - const [draftName, setDraftName] = useState(""); - const [draftType, setDraftType] = useState(""); // 同名的其他实体:详情接口打开就给。改名之后再用响应里的那份覆盖—— // 改完名可能撞上一批新的同名,那时候的答案比打开时的新 const [renamedPeers, setRenamedPeers] = useState(null); @@ -2701,98 +2700,32 @@ function EntityPanel({ }, onError: (err: Error) => toast.error(err.message), }); - // 类型下拉要的是全量本体,不是当前视图里出现过的那几个 - const ontology = useQuery({ - queryKey: ["ontology", kbId], - queryFn: () => api.ontology(kbId), - enabled: editing, - }); - const types = ontology.data?.entity_types ?? []; - const openEdit = () => { if (!e) return; - setDraftName(e.name); - setDraftType(types.find((t) => t.key === e.type_key)?.id ?? ""); - setSameName([]); setEditing(true); }; - // 本体是异步来的:它到齐时把类型下拉对到当前类型上 - useEffect(() => { - if (editing && !draftType && e) - setDraftType(types.find((t) => t.key === e.type_key)?.id ?? ""); - }, [editing, draftType, e, types]); - - const save = useMutation({ - mutationFn: () => { - const body: { type_id?: string; canonical_name?: string } = {}; - if (draftName.trim() && draftName.trim() !== e?.name) - body.canonical_name = draftName.trim(); - const curId = types.find((t) => t.key === e?.type_key)?.id; - if (draftType && draftType !== curId) body.type_id = draftType; - return api.updateEntity(kbId, entityId, body); - }, - onSuccess: (r) => { - setEditing(false); - setSameName(r.same_name); - toast.success(S.graph.editSaved); - // 改了类型/名字,图谱节点与本体计数都要跟着动 - qc.invalidateQueries({ queryKey: ["entity", kbId, entityId] }); - qc.invalidateQueries({ queryKey: ["graph", kbId] }); - qc.invalidateQueries({ queryKey: ["ontology", kbId] }); - }, - onError: (err: Error) => toast.error(err.message), - }); - const dirty = - !!e && - (draftName.trim() !== e.name || - draftType !== (types.find((t) => t.key === e.type_key)?.id ?? "")); - - // Relations = 当下有效的快照(as-of now);已闭合的历史只出现在 Timeline。 - // 按「方向 + 谓词」分组:实体自身名不再逐行重复,谓词只出现在小节标题里 - const { groups, historicalCount } = useMemo(() => { + /* Relations 是一张表,不再分「现行」和「年表」两页:**从这个实体出发 / 指向这个实体** + 两节,节里一行一条——左边关系名、右边实体名,与本体页那张同一副(不再按谓词 + 分二级);按关系名、再按起点排。此刻不成立的(0022 的口径按读出来的区间判)折在 + 节尾的「N past」里——Wikidata 把历史值留在同一列表里靠结束时间区分,是同一个道理 */ + const sections = useMemo(() => { const all = detail.data?.facts ?? []; const nowIso = new Date().toISOString(); - // 「此刻成立」按读出来的区间判(0022):结束了不知哪天的那条不再混进现行里 - const current = all.filter( - (f) => - (!f.holds_from || f.holds_from <= nowIso) && - (!f.holds_to || f.holds_to > nowIso), - ); - const map = new Map< - string, - { - key: string; - label: string | null; - inferred: boolean; - direction: string; - rows: EntityFact[]; - } - >(); - for (const f of current) { - // 谓词为空的事实归到同一组:它们的共同点就是「说不出是什么关系」 - const k = `${f.direction}:${f.predicate_key ?? ""}`; - if (!map.has(k)) - map.set(k, { - key: k, - label: f.predicate_label, - inferred: f.inferred, - direction: f.direction, - rows: [], - }); - map.get(k)!.rows.push(f); - } - const arr = [...map.values()]; - for (const gr of arr) - gr.rows.sort((a, b) => - (a.valid_from ?? "9999") < (b.valid_from ?? "9999") ? -1 : 1, - ); - arr.sort( - (a, b) => - b.rows.length - a.rows.length || - (a.label ?? "").localeCompare(b.label ?? ""), - ); - return { groups: arr, historicalCount: all.length - current.length }; + const current = (f: EntityFact) => + (!f.holds_from || f.holds_from <= nowIso) && + (!f.holds_to || f.holds_to > nowIso); + const order = (a: EntityFact, b: EntityFact) => + (a.predicate_label ?? "\uffff").localeCompare(b.predicate_label ?? "\uffff") || + ((a.valid_from ?? "9999") < (b.valid_from ?? "9999") ? -1 : 1); + const split = (dir: "out" | "in") => { + const mine = all.filter((f) => f.direction === dir); + return { + rows: mine.filter(current).sort(order), + past: mine.filter((f) => !current(f)).sort(order), + }; + }; + return { out: split("out"), in: split("in") }; }, [detail.data]); return ( @@ -2842,59 +2775,20 @@ function EntityPanel({ {editing && e && ( -
- - setDraftName(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === "Enter" && dirty && draftName.trim()) - save.mutate(); - if (ev.key === "Escape") setEditing(false); - }} - className="w-full" - /> - - - {/* 类可能上千个(schema.org 一装就是 1010 个):这一格要能打字过滤, - 所以是 SearchSelect 而不是下拉——下拉是给小而有界的枚举的 */} - ({ - value: t.id, - label: t.label, - hint: t.key, - }))} - /> - -
- - - {!draftName.trim() && ( - - {S.graph.editEmptyName} - - )} -
-
+ setEditing(false)} + onSaved={(peers) => { + setEditing(false); + setSameName(peers); + }} + /> )} {/* 同名不是错误——两个张伟可以并存。只提示,判定是不是同一个是人的事 */} - {sameName.length > 0 && !editing && ( + {sameName.length > 0 && (

@@ -2960,13 +2854,13 @@ function EntityPanel({ /> )} - {/* 视图切换:Relations(分组)| Timeline(年表) */} + {/* 视图切换:Relations(一张表,过去的折在组尾)| History(记录轴)| Derived */}

- {view === "relations" && historicalCount > 0 && ( - - )} {view === "relations" && - groups.map((gr) => ( -
- - ) : ( - - ) - } - count={gr.rows.length > 1 ? gr.rows.length : undefined} + (["out", "in"] as const).map((dir) => { + const { rows, past } = sections[dir]; + if (rows.length === 0 && past.length === 0) return null; + const name = e?.name ?? ""; + return ( + - - {gr.label ?? S.graph.unknownPredicate} - - -
- {gr.rows.map((f) => ( + {rows.map((f) => ( - setOpenFact(openFact === f.id ? null : f.id) - } + onToggle={() => setOpenFact(openFact === f.id ? null : f.id)} onNavigate={onNavigate} /> ))} -
-
- ))} - {view === "timeline" && ( - setOpenFact(openFact === id ? null : id)} - onNavigate={onNavigate} - /> - )} + {past.length > 0 && ( + + {past.map((f) => ( + setOpenFact(openFact === f.id ? null : f.id)} + onNavigate={onNavigate} + /> + ))} + + )} + + ); + })} {view === "history" && ( )} @@ -3135,398 +3007,130 @@ function EntityPanel({ ); } -/** 年表视图:带区间的事实按起点摊开成竖直时间线;无时间的沉到底部 undated。 */ -function TimelineView({ - kbId, - facts, - openFact, - onToggle, - onNavigate, -}: { - kbId: string; - facts: EntityFact[]; - openFact: string | null; - onToggle: (id: string) => void; - onNavigate: (entityId: string) => void; -}) { - const dated = facts - .filter((f) => f.temporal !== "eternal" && (f.valid_from || f.valid_to)) - .sort((a, b) => - (a.valid_from ?? a.valid_to ?? "") < (b.valid_from ?? b.valid_to ?? "") - ? -1 - : 1, - ); - const undated = facts.filter((f) => !dated.includes(f)); - - return ( -
- {/* 与 Relations 同一种行:chevron + 两行头(区间在上,谓词和值在下)。 - 年表的次序靠排序和第一行的区间说话,不另画一条线 */} -
- {dated.map((f) => ( - onToggle(f.id)} - onNavigate={onNavigate} - /> - ))} - {dated.length === 0 && ( -

- {S.graph.timelineEmpty} -

- )} -
- {undated.length > 0 && ( -
- {S.graph.undated} - {undated.map((f) => ( - onToggle(f.id)} - onNavigate={onNavigate} - /> - ))} -
- )} -
- ); +/** 字面值宾语的显示:属性 {value,unit} / 问数映射 {summary} / 其他 JSON 兜底。 */ +function fmtObjectValue(v: Record | null): string | null { + if (!v) return null; + if (v.value !== undefined) { + const val = + typeof v.value === "boolean" ? (v.value ? "✓" : "✗") : String(v.value); + return typeof v.unit === "string" && v.unit ? `${val} ${v.unit}` : val; + } + if (typeof v.summary === "string") return v.summary; + return JSON.stringify(v); } -/** 年表条目:区间 + 闭合方式标记 + 开放事实的最后确认时间;点击展开证据。 */ -function TimelineRow({ - kbId, - fact, - open, - onToggle, - onNavigate, +/** 一节(从这个实体出发 / 指向这个实体):可折叠——折叠柄占图标格,正文缩进同样的 24, + * 于是每一行的方向箭头正好落在节标题的箭头底下(本体页 Relations 的组同一副) */ +function FactSection({ + dir, + title, + count, + children, }: { - kbId: string; - fact: EntityFact; - open: boolean; - onToggle: () => void; - onNavigate: (entityId: string) => void; + dir: "out" | "in"; + title: string; + count: number; + children: ReactNode; }) { - const interval = fmtInterval(fact); - const isOpenEnded = !fact.valid_to; - const literal = fmtObjectValue(fact.object_value); - const [editing, setEditing] = useState(false); + const [open, setOpen] = useState(true); return ( - -
- {interval || "—"} - {fact.corrected && ( - - ⟲ - - )} - - {isOpenEnded && fact.last_evidence_time && ( - - {S.graph.lastConfirmed(localDate(fact.last_evidence_time))} - - )} - {/* 这一档只有断言事实:派生的区间是算出来的,走 Derived 那条路径, - 改了下一轮推理也会覆盖(服务端另有 derived_by_rule 的防线) */} - { - ev.stopPropagation(); - setEditing((v) => !v); - }} - onKeyDown={(ev) => { - if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); - ev.stopPropagation(); - setEditing((v) => !v); - } - }} - className={cn(REVEAL, "cursor-pointer rounded-cell p-1", editing && "is-on")} - > - - +
+ + -
-
- - {fact.direction === "in" ? "←" : "→"}{" "} - - {fact.predicate_label ?? S.graph.unknownPredicate} - - - {fact.other_id ? ( - { - ev.stopPropagation(); - onNavigate(fact.other_id!); - }} - onKeyDown={(ev) => { - if (ev.key === "Enter") { - ev.stopPropagation(); - onNavigate(fact.other_id!); - } - }} - className="u-inline-link truncate" - > - {fact.other_name ?? "?"} - - ) : ( - - {fact.other_name ?? literal ?? "?"} - - )} - {fact.stale && ( - - {S.graph.staleFactChip} - - )} - {fact.contested && ( - - )} -
- - } - > - {editing && ( - setEditing(false)} - /> - )} - {open && } - + } + onClick={() => setOpen((v) => !v)} + > + + {dir === "out" ? : } + {title} + {count} + + + {open &&
{children}
} +
); } -/** 有效区间的人工修正表单(302)。 - * - * 两端一起提交而不是逐端改:区间的两端互相定义,「清空结束端」与「这次不动 - * 结束端」得能分辨。结束端的三个选项与账本里的三种写法一一对应,所以这里 - * 没有「留空即至今」这种隐含约定——那正是 valid_to IS NULL 一度承载两个意思 - * 的老毛病。 */ -function TimeEditor({ - kbId, - fact, - onDone, -}: { - kbId: string; - fact: EntityFact; - onDone: () => void; -}) { - const qc = useQueryClient(); - const [from, setFrom] = useState( - fmtTime(fact.valid_from, fact.valid_from_precision) ?? "", - ); - const [to, setTo] = useState( - fmtTime(fact.valid_to, fact.valid_to_precision) ?? "", - ); - const [endMode, setEndMode] = useState<"open" | "unknown" | "date">( - fact.valid_to - ? "date" - : fact.valid_to_precision === "unknown" - ? "unknown" - : "open", - ); - const [note, setNote] = useState(""); - - const save = useMutation({ - mutationFn: () => { - const f = from.trim() ? parseDateInput(from) : null; - if (from.trim() && !f) throw new Error(S.graph.timeBadDate); - const t = endMode === "date" ? parseDateInput(to) : null; - if (endMode === "date" && !t) throw new Error(S.graph.timeBadDate); - return api.updateFactTime(kbId, fact.id, { - valid_from: f?.iso ?? null, - valid_from_precision: f?.precision ?? null, - valid_to: t?.iso ?? null, - valid_to_precision: - endMode === "date" - ? (t?.precision ?? null) - : endMode === "unknown" - ? "unknown" - : null, - note: note.trim() || undefined, - }); - }, - onSuccess: (r) => { - // 对账的后果要说出来:改了起点可能顺手闭合了继任者的开放区间, - // 也可能撞出一条需要人裁的冲突。不说的话图会自己变而没人知道为什么 - if (r.conflicts) toast.success(S.graph.timeSavedConflicts(r.conflicts)); - else if (r.closed) toast.success(S.graph.timeSavedClosed(r.closed)); - else toast.success(S.graph.timeSaved); - qc.invalidateQueries({ queryKey: ["entity", kbId] }); - qc.invalidateQueries({ queryKey: ["graph"] }); - qc.invalidateQueries({ queryKey: ["review", kbId] }); - onDone(); - }, - onError: (e: Error) => toast.error(e.message), - }); - +/** 已结束的留在同一节里,折起来:默认看现行的,要看来路展开它 */ +function PastFold({ n, children }: { n: number; children: ReactNode }) { + const [open, setOpen] = useState(false); return ( -
ev.stopPropagation()} - > -
- - setFrom(e.target.value)} - placeholder={S.graph.timeFormat} - size="sm" - className="u-num flex-1" - /> -
-
- -
- {( - [ - ["open", S.graph.timeEndOpen], - ["unknown", S.graph.timeEndUnknown], - ["date", S.graph.timeEndDate], - ] as const - ).map(([mode, label]) => ( - setEndMode(mode)} - label={label} - > - {mode === "date" && endMode === "date" && ( - setTo(e.target.value)} - placeholder={S.graph.timeFormat} - className="u-num ml-1 flex-1" - /> - )} - - ))} -
-
-
- - setNote(e.target.value)} - placeholder={S.graph.timeNotePlaceholder} - size="sm" - className="flex-1" - /> -
-
- - -
-
+ <> + + + + } + onClick={() => setOpen((v) => !v)} + > + {S.graph.past(n)} + + {open &&
{children}
} + ); } -/** 字面值宾语的显示:属性 {value,unit} / 问数映射 {summary} / 其他 JSON 兜底。 */ -function fmtObjectValue(v: Record | null): string | null { - if (!v) return null; - if (v.value !== undefined) { - const val = - typeof v.value === "boolean" ? (v.value ? "✓" : "✗") : String(v.value); - return typeof v.unit === "string" && v.unit ? `${val} ${v.unit}` : val; - } - if (typeof v.summary === "string") return v.summary; - return JSON.stringify(v); -} - +/** 一条事实是一行,与本体页 Relations 的行同一副:图标格里是方向箭头,左边关系名 + * (和 disputed 之类的标记),右边那个实体的名字(区间的小字在名字前);整行点了 + * 跳到那个实体。指着这一行才露出「N sources」(在行下摊开证据)与铅笔(区间修正 + * 弹窗)。行首没有折叠柄——折叠柄在这块面板上只属于节与「过去」的折 */ function FactRow({ kbId, + dir, fact, + past, open, onToggle, onNavigate, }: { kbId: string; + dir: "out" | "in"; fact: EntityFact; + /** 已结束的那些:压淡 */ + past?: boolean; open: boolean; onToggle: () => void; onNavigate: (entityId: string) => void; }) { + const [editing, setEditing] = useState(false); const interval = fmtInterval(fact); // 与 Review 的低置信口径一致:只有低到需要怀疑才挂 chip,常规置信保持沉默 const lowConfidence = fact.confidence < 0.75; + const go = fact.other_id ? () => onNavigate(fact.other_id!) : undefined; return ( - - {fact.other_id ? ( - { - ev.stopPropagation(); - onNavigate(fact.other_id!); - }} - onKeyDown={(ev) => { - if (ev.key === "Enter") { - ev.stopPropagation(); - onNavigate(fact.other_id!); - } - }} - className="u-inline-link truncate text-body text-ink" - > - {fact.other_name ?? "?"} - - ) : ( - - {fact.other_name ?? fmtObjectValue(fact.object_value) ?? "?"} - - )} + > +
{ + if (go && ev.key === "Enter") go(); + }} + className={cn(HOVER_ROW, go && "cursor-pointer")} + > + + {dir === "out" ? : } + + + {fact.predicate_label ?? S.graph.unknownPredicate} + {lowConfidence && ( {Math.round(fact.confidence * 100)}% @@ -3538,20 +3142,65 @@ function FactRow({ )} {fact.contested && } - {interval && ( - - {interval} + {fact.corrected && ( + + ⟲ )} + + {interval && ( + {interval} + )} + + {fact.other_name ?? fmtObjectValue(fact.object_value) ?? "?"} + + {fact.evidence_count > 0 && ( + { + ev.stopPropagation(); + onToggle(); + }} + > + {S.graph.sources(fact.evidence_count)} + + )} + {/* 这一档只有断言事实:派生的区间是算出来的,走 Derived 那条路径 */} + { + ev.stopPropagation(); + setEditing(true); + }} + onKeyDown={(ev) => { + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + ev.stopPropagation(); + setEditing(true); + } + }} + className={cn(REVEAL, "cursor-pointer rounded-cell p-1 text-ink-2")} + > + + + +
+ {open && ( +
+
- } - > - {open && } -
+ )} + {editing && ( + setEditing(false)} /> + )} +
); } -/** 证据展开区(FactRow 与 TimelineRow 共用):quote + 跳原文 + 版本角标 + 置信。 */ +/** 证据展开区(FactRow 在行下摊开):quote + 跳原文 + 版本角标 + 置信。 */ function EvidenceList({ kbId, fact }: { kbId: string; fact: EntityFact }) { const evidence = useQuery({ queryKey: ["evidence", fact.id], diff --git a/web/src/pages/KbSettings.tsx b/web/src/pages/KbSettings.tsx index ef257bdd4..73b8d1fe9 100644 --- a/web/src/pages/KbSettings.tsx +++ b/web/src/pages/KbSettings.tsx @@ -180,8 +180,8 @@ export function KbSettings() { { key: "general", label: S.kbset.general, Icon: Settings2 }, { key: "members", label: S.kbset.members, Icon: Users }, { key: "activity", label: S.kbset.activity, Icon: HistoryIcon }, - // 默认库不可删除:danger 节整个不出现。入口用警示色:这一条只是去往危险区, - // 真正删库的那个按钮才是危险色 + // 默认库不可删除:danger 节整个不出现。入口只把图标染成警示色,文字与别的 + // 节一样:这一条只是去往危险区,真正删库的那个按钮才是危险色 ...(isDefault ? [] : [ @@ -203,8 +203,7 @@ export function KbSettings() { key={key} density="nav" active={section === key} - tone={tone} - icon={} + icon={} onClick={() => setSection(key)} > {label} diff --git a/web/src/pages/Ontology.tsx b/web/src/pages/Ontology.tsx index ab0ae083a..33b26b116 100644 --- a/web/src/pages/Ontology.tsx +++ b/web/src/pages/Ontology.tsx @@ -1,6 +1,7 @@ // 本体编辑器:master-detail 双栏(与 Library 的 SourcesRail 同构)。 // 左栏 = filter + Classes/Properties 两小节 + 底部 Unmatched 入口; -// 右侧 = 选中项的表单 / 未匹配信号面板 / 概览。 +// 右侧 = 选中项的详情面板(只展示;建、改、删、连都在弹窗里,见 ontologyDialogs) +// / 未匹配信号面板 / 概览。 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -9,8 +10,10 @@ import { ArrowRight, ChevronRight, Inbox, + Link2, Scale, Network, + Pencil, Plus, Search, Split, @@ -35,28 +38,27 @@ import { useKb } from "../kb"; import { toast } from "../toast"; import { OntologySchemaGraph, type SchemaSelection } from "./OntologySchemaGraph"; import { RulesPanel } from "./RulesPanel"; +import { + AttributeDialog, + ClassDialog, + ConnectDialog, + PropertyDialog, +} from "./ontologyDialogs"; import { Button, - Checkbox, Chip, - ColorPicker, - colorForKey, DangerConfirm, Disclosure, - Dropdown, IconButton, Input, Loading, - MultiSearchSelect, Pager, RailItem, Row, ROW_TRAILING, rowClass, Segmented, - Textarea, RAIL_CLS, - SearchSelect, cn, pageSlice, GroupLabel, @@ -73,18 +75,15 @@ const RAIL_PAGE_MIXED = 6; /** 右侧详情区当前展示什么。 * - * class / relation / new-class / new-relation / schema / null 这一组共享 - * 同一块工作区:模式图常驻做背景,表单(如果有)停靠在右侧——左栏点一个 - * 类名、画布上点一个节点、模式图自己的搜索框选中一个关系,三条路径落到 - * 的是同一个 `sel`,因此也落到同一份表单,不必再各画一遍。 + * class / relation / schema / null 这一组共享同一块工作区:模式图常驻做 + * 背景,详情面板停靠在右侧——左栏点一个类名、画布上点一个节点、模式图 + * 自己的搜索框选中一个关系,三条路径落到的是同一个 `sel`,因此也落到同一 + * 块面板,不必再各画一遍。面板只展示;改动在 `Edit` 的弹窗里。 * import / refine / misses 仍是独立的整页视图:那三个不是「关于某个类 * 或关系」的事,跟模式图没有共同的背景可言。 */ type Sel = | { kind: "class"; id: string } | { kind: "relation"; id: string } - | { kind: "new-class"; parentId: string | null } - // 从模式图上一个类发起「新建关系」时带上它的 id,表单里 domain 预填成它 - | { kind: "new-relation"; initialDomain?: string | null } | { kind: "misses" } | { kind: "uniqueness" } // 类型消解:把「大致对」的类换成更具体的那个 @@ -95,18 +94,28 @@ type Sel = | { kind: "schema" } | null; -/** 这次选中会不会在模式图右侧停靠一张表单 */ -const onPanel = (s: Sel) => - s?.kind === "class" || - s?.kind === "relation" || - s?.kind === "new-class" || - s?.kind === "new-relation"; +/** 这次选中会不会在模式图右侧停靠一块面板 */ +const onPanel = (s: Sel) => s?.kind === "class" || s?.kind === "relation"; + +/** 打开着的编辑弹窗。面板只展示;建、改、删、连都在弹窗里发生 */ +type Edit = + | { kind: "class"; existing: EntityTypeView | null; parentId: string | null } + | { + kind: "property"; + existing: RelationTypeView | null; + // 从一个类出发新建关系时带上它的 id,domain 预填成它 + initialDomain: string | null; + } + | { kind: "attribute"; typeId: string; existing: RelationTypeView | null } + | { kind: "connect"; cls: EntityTypeView } + | null; export function Ontology() { const { kb } = useKb(); const queryClient = useQueryClient(); const navigate = useNavigate(); const [sel, setSel] = useState(null); + const [edit, setEdit] = useState(null); const [railTab, setRailTab] = useState<"classes" | "properties">("classes"); // 模式图详情面板停在哪一段。**跨选中保留**:在实例上挨个类看下去, // 是一种真实的读法,每换一个类就被弹回定义页会打断它 @@ -214,8 +223,6 @@ export function Ontology() { (r) => r.kind === "attribute" && r.domains.includes(selectedClass.id), ) : []; - // 新建的类还没有关系也没有实例,只剩定义这一段 - const classTab = selectedClass ? panelTab : "definition"; return (
@@ -269,9 +276,11 @@ export function Ontology() { className="mb-1" icon={} onClick={() => - railTab === "classes" - ? setSel({ kind: "new-class", parentId: null }) - : setSel({ kind: "new-relation" }) + setEdit( + railTab === "classes" + ? { kind: "class", existing: null, parentId: null } + : { kind: "property", existing: null, initialDomain: null }, + ) } > {railTab === "classes" @@ -450,169 +459,211 @@ export function Ontology() { // 退场动画,而点画布正是最常用的那种关法 onSelect={(next) => (next ? setSel(next) : closePanel())} /> - {(panelSel?.kind === "new-class" || selectedClass) && ( + {selectedClass && ( + setEdit({ + kind: "class", + existing: selectedClass, + parentId: selectedClass.primary_parent ?? null, + }) + } + > + + + } header={ } tabs={ - selectedClass && ( - - ) + } > - {/* 三段用 hidden 藏,不卸载:表单里改了一半的字段、实例列表翻到的 - 第几页,都该在切回来的时候还在 */} -
-
+
+ - setSel({ - kind: "new-class", - parentId: selectedClass.id, - }) - : undefined + onSelect={(id) => setSel({ kind: "relation", id })} + onConnect={() => setEdit({ kind: "connect", cls: selectedClass })} + onAddNew={() => + setEdit({ + kind: "property", + existing: null, + initialDomain: selectedClass.id, + }) } - onDone={(createdId) => { - // 新建成功即选中它:立刻能看到、能继续编辑 - if (sel?.kind === "new-class") - setSel( - createdId - ? { kind: "class", id: createdId } - : { kind: "schema" }, - ); - afterOntologyChange(); - }} - onError={onError} />
- {selectedClass && ( - <> -
- setSel({ kind: "relation", id })} - onAddNew={() => - setSel({ - kind: "new-relation", - initialDomain: selectedClass.id, - }) - } - /> -
-
- -
-
- -
- - )} +
+ + setEdit({ kind: "attribute", typeId: selectedClass.id, existing: a }) + } + onNew={() => + setEdit({ kind: "attribute", typeId: selectedClass.id, existing: null }) + } + /> +
+
+ +
)} - {(panelSel?.kind === "new-relation" || selectedProp) && ( + {selectedProp && ( + setEdit({ kind: "property", existing: selectedProp, initialDomain: null }) + } + > + + + } header={ } > -
- { - if (sel?.kind === "new-relation") - setSel( - createdId - ? { kind: "relation", id: createdId } - : { kind: "schema" }, - ); - afterOntologyChange(); - }} - onError={onError} - /> -
+
)}
)} + + {/* 编辑弹窗:面板只展示,建、改、删、连都在这里发生。建成的立刻选中—— + 能看到、能接着改;删掉的把面板收起来 */} + {edit?.kind === "class" && ( + setEdit(null)} + onSaved={(createdId) => { + setEdit(null); + if (createdId) setSel({ kind: "class", id: createdId }); + afterOntologyChange(); + }} + onDeleted={() => { + setEdit(null); + closePanel(); + afterOntologyChange(); + }} + onError={onError} + /> + )} + {edit?.kind === "property" && ( + setEdit(null)} + onSaved={(createdId) => { + setEdit(null); + if (createdId) setSel({ kind: "relation", id: createdId }); + afterOntologyChange(); + }} + onDeleted={() => { + setEdit(null); + closePanel(); + afterOntologyChange(); + }} + onError={onError} + /> + )} + {edit?.kind === "attribute" && ( + setEdit(null)} + onSaved={() => { + setEdit(null); + afterOntologyChange(); + }} + onDeleted={() => { + setEdit(null); + afterOntologyChange(); + }} + onError={onError} + /> + )} + {edit?.kind === "connect" && ( + setEdit(null)} + onConnected={(id) => { + setEdit(null); + afterOntologyChange(); + // 连完直接跳到那条关系:域/值域改没改、改对了没有,一眼可见 + setSel({ kind: "relation", id }); + }} + onError={onError} + /> + )}
); } @@ -632,12 +683,15 @@ export function Ontology() { function DockedPanel({ header, + actions, tabs, exiting, onClose, children, }: { header: React.ReactNode; + /** 关闭键左边的动作(编辑):面板只展示,改动从这里开弹窗 */ + actions?: React.ReactNode; /** 分段控件,跟着标题一起固定在顶上——它要能一直点得到 */ tabs?: React.ReactNode; /** 正在退场:演动画,期间不再接受点击(u-dock-out 里带了 pointer-events) */ @@ -653,14 +707,12 @@ function DockedPanel({ >
{header}
- - - +
+ {actions} + + + +
{tabs &&
{tabs}
}
@@ -761,23 +813,20 @@ function InstancesCard({ kbId, type }: { kbId: string; type: EntityTypeView }) { * 走的是 PropertyForm 保存时同一个 updateRelationType,只是把「打开表单、 * 找到多选框、加一个类」压缩成一步。 */ function RelationshipsCard({ - kbId, cls, relations, allTypes, - onChanged, - onError, onSelect, + onConnect, onAddNew, }: { - kbId: string; cls: EntityTypeView; /** kind === "relation" 的那些——attribute 的宾语是字面值,谈不上「连着」 */ relations: RelationTypeView[]; allTypes: EntityTypeView[]; - onChanged: () => void; - onError: (e: unknown) => void; onSelect: (relationId: string) => void; + /** 用一个已有的关系连接这个类:开弹窗 */ + onConnect: () => void; onAddNew: () => void; }) { const labelOf = (id: string) => @@ -786,53 +835,47 @@ function RelationshipsCard({ const outgoing = relations.filter((r) => r.domains.includes(cls.id)); const incoming = relations.filter((r) => r.ranges.includes(cls.id)); - const [connectId, setConnectId] = useState(""); - const [connectSide, setConnectSide] = useState<"domain" | "range">("domain"); - useEffect(() => { - setConnectId(""); - }, [cls.id]); - - const connect = useMutation({ - mutationFn: () => { - const rel = relations.find((r) => r.id === connectId); - if (!rel) return Promise.reject(new Error("relation not found")); - // 只加不减:已经连着的那一侧原样保留,另一侧才可能被追加。 - // Set 去重——挑到已经连过的关系时这是个无害的空操作,不必先禁用按钮 - const domains = - connectSide === "domain" - ? [...new Set([...rel.domains, cls.id])] - : rel.domains; - const ranges = - connectSide === "range" - ? [...new Set([...rel.ranges, cls.id])] - : rel.ranges; - return api.updateRelationType(kbId, rel.id, { - label: rel.label, - temporal: rel.temporal, - functional: rel.functional, - inverse_functional: rel.inverse_functional, - is_transitive: rel.is_transitive, - is_symmetric: rel.is_symmetric, - is_asymmetric: rel.is_asymmetric, - is_irreflexive: rel.is_irreflexive, - inverse_of: rel.inverse_of, - sub_property_of: rel.sub_property_of, - description: rel.description, - domains, - ranges, - }); - }, - onSuccess: () => { - const rel = relations.find((r) => r.id === connectId); - toast.success(S.ontology.schemaConnected(rel?.label ?? "")); - const id = connectId; - setConnectId(""); - onChanged(); - // 连完直接跳到那条关系:域/值域改没改、改对了没有,一眼可见 - onSelect(id); - }, - onError, - }); + /** 一组可折叠(#455 的解剖:折叠柄、头、缩进的正文)。缺省展开—— + * 折起来是为了在长列表里跳过一段,不是为了藏 */ + const Group = ({ + dir, + rows, + }: { + dir: "out" | "in"; + rows: RelationTypeView[]; + }) => { + const [open, setOpen] = useState(true); + return ( +
+ {/* 头是一行 Row:折叠柄占图标格(16 宽),正文缩进同样的 24, + 于是下面每一行的方向箭头正好落在组标题的箭头底下 */} + + + + } + onClick={() => setOpen((v) => !v)} + > + + {dir === "out" ? : } + + {dir === "out" ? S.ontology.schemaOutgoing : S.ontology.schemaIncoming} + + {rows.length > 1 && {rows.length}} + + + {open && ( +
+ {rows.map((r) => ( + + ))} +
+ )} +
+ ); + }; const RelationRow = ({ r, dir }: { r: RelationTypeView; dir: "out" | "in" }) => ( // 悬停要有底色:这一行整条可点,只把文字提亮半级在深底上几乎看不出来。 @@ -868,349 +911,62 @@ function RelationshipsCard({

) : (
- {outgoing.length > 0 && ( - <> - } - count={outgoing.length > 1 ? outgoing.length : undefined} - > - {S.ontology.schemaOutgoing} - -
- {outgoing.map((r) => ( - - ))} -
- - )} - {incoming.length > 0 && ( - <> - } - count={incoming.length > 1 ? incoming.length : undefined} - > - {S.ontology.schemaIncoming} - -
- {incoming.map((r) => ( - - ))} -
- - )} + {outgoing.length > 0 && } + {incoming.length > 0 && }
)} -
-

- {S.ontology.schemaConnectHint} -

-
- ({ - value: r.id, - label: r.label, - hint: r.key, - }))} - size="sm" - className="flex-1 min-w-0" - placeholder={S.ontology.schemaConnectPlaceholder} - /> -
-
- - {S.ontology.schemaConnectAs} - - -
- -
-
- -
+ {/* 改动开弹窗:连一个已有的关系、或新建一条。面板这一段只展示。 + 两行与上面的关系行同一副身材 */} + } onClick={onConnect}> + {S.ontology.connectOpen} + + } onClick={onAddNew}> + {S.ontology.schemaAddRelationship} +
); } -/* ---------- 属性卡片:选中类的字面值字段(行内增改删) ---------- */ +/* ---------- 属性卡片:选中类的字面值字段(改动开弹窗) ---------- */ -/** 导出给模式图复用:选中一个类时,检查器里嵌的就是这一张卡片本身, - * 不是另一份只读摘要——编辑发生在同一处,不必跳回本体主视图 */ -export function AttributesCard({ - kbId, - type, +function AttributesCard({ attributes, - onChanged, - onError, + onEdit, + onNew, }: { - kbId: string; - type: EntityTypeView; attributes: RelationTypeView[]; - onChanged: () => void; - onError: (e: unknown) => void; + onEdit: (attribute: RelationTypeView) => void; + onNew: () => void; }) { - // 行内编辑:一次只展开一行(属性 id 或 "new") - const [editing, setEditing] = useState(null); - useEffect(() => setEditing(null), [type.id]); - return (

{S.ontology.attributesHint}

- {attributes.map((a) => - editing === a.id ? ( - { - setEditing(null); - onChanged(); - }} - onCancel={() => setEditing(null)} - onError={onError} - /> - ) : ( - {S.ontology.usage(a.usage)}} - onClick={() => setEditing(a.id)} - > - - {a.label} - - {S.ontology.datatypeNames[a.datatype ?? "text"]} - - {a.unit && ( - {a.unit} - )} - {a.functional && 1:1} - - - ), - )} -
- {editing === "new" ? ( -
- { - setEditing(null); - onChanged(); - }} - onCancel={() => setEditing(null)} - onError={onError} - /> -
- ) : ( - - )} -
- ); -} - -function AttributeForm({ - kbId, - typeId, - existing, - onDone, - onCancel, - onError, -}: { - kbId: string; - typeId: string; - existing: RelationTypeView | null; - onDone: () => void; - onCancel: () => void; - onError: (e: unknown) => void; -}) { - const [key, setKey] = useState(existing?.key ?? ""); - const [label, setLabel] = useState(existing?.label ?? ""); - const [datatype, setDatatype] = useState(existing?.datatype ?? "text"); - const [unit, setUnit] = useState(existing?.unit ?? ""); - // 单值 = functional:新值经时态引擎闭合旧值(属性历史的来源)。多数属性如此,默认开 - const [single, setSingle] = useState(existing?.functional ?? true); - const [description, setDescription] = useState(existing?.description ?? ""); - - const save = useMutation({ - mutationFn: async (): Promise => - existing - ? api.updateRelationType(kbId, existing.id, { - label, - temporal: existing.temporal, - functional: single, - inverse_functional: false, - description, - datatype, - unit, - }) - : api.createRelationType(kbId, { - key, - label, - kind: "attribute", - domains: [typeId], - temporal: "state", - functional: single, - inverse_functional: false, - description, - datatype, - unit, - }), - onSuccess: () => { - toast.success(existing ? S.toast.saved : S.toast.created); - onDone(); - }, - onError, - }); - const remove = useMutation({ - mutationFn: () => api.deleteRelationType(kbId, existing!.id), - onSuccess: () => { - toast.success(S.toast.deleted); - onDone(); - }, - onError, - }); - - const lbl = "block text-small font-medium text-ink-2 mb-1"; - return ( -
- {!existing && ( -
-
- - setKey(e.target.value)} - className="w-full" - placeholder="salary" - /> -
-
- - setLabel(e.target.value)} - className="w-full" - /> -
-
- )} - {existing && ( -
- - setLabel(e.target.value)} - className="w-full" - /> -
- )} -
-
- - setDatatype(v as typeof datatype)} - className="w-full" - options={(["text", "number", "date", "bool"] as const).map((d) => ({ - value: d, - label: S.ontology.datatypeNames[d], - }))} - /> -
-
- - setUnit(e.target.value)} - className="w-full" - /> -
-
- setSingle(e.target.checked)} - label={S.ontology.attrSingle} - /> -
- -