From c0d2eac3f6f84c8556657b4445c52f39401b4009 Mon Sep 17 00:00:00 2001 From: dada-yan Date: Sun, 27 Sep 2026 19:30:50 +1000 Subject: [PATCH 1/3] An end the timeline derived cites the fact that closed the row, and a corrected interval carries the person's note Signed-off-by: dada-yan --- crates/utopia-core/src/models.rs | 8 ++ crates/utopia-server/src/api/chat.rs | 6 +- .../src/api/chat_graph_citation_tests.rs | 49 +++++--- crates/utopia-server/src/api/tools.rs | 46 +++++-- crates/utopia-server/src/api/tools_graph.rs | 66 ++++++++-- crates/utopia-server/src/time_text.rs | 118 ++++++++++++++++-- crates/utopia-store/src/graph.rs | 75 ++++++++++- crates/utopia-store/src/paths.rs | 12 +- crates/utopia-store/src/temporal.rs | 13 ++ .../a_fact_says_where_its_dates_came_from.rs | 87 +++++++++++-- docs/design/chat-and-mcp.md | 6 +- 11 files changed, 425 insertions(+), 61 deletions(-) diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index ce6d25276..1d2a81802 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -845,6 +845,14 @@ pub struct EntityFact { /// 区间是人改过的:supersedes 链上有一条 `fact.time_corrected` 审计(#970)。与 /// `corrected` 不同,那一位是任何一次改写:自动接续、审阅裁决、人手改 pub time_corrected: bool, + /// 时间线把它关上时接的那一行(#970 第二步):只在 `end_derived`、而且找得到时有 + pub closed_by_id: Option, + /// 接的那一行变了的那一端:接任者,或新的宾语实体的名字 + pub closed_by: Option, + /// 接的那一行的新值(属性事实) + pub closed_by_value: Option, + /// 人改区间时写下的备注:链上最近那一次修正的 + pub correction_note: Option, /// 证据集合里最新的文档时间——开放事实的"最后确认时间"(时效性透明化) pub last_evidence_time: Option>, /// 有争议(0017 §3):`{ kind, ref_id, derived? }`——哪一种(违规的 kind,或 diff --git a/crates/utopia-server/src/api/chat.rs b/crates/utopia-server/src/api/chat.rs index aeaf6e5ff..acc5157f3 100644 --- a/crates/utopia-server/src/api/chat.rs +++ b/crates/utopia-server/src/api/chat.rs @@ -630,8 +630,10 @@ const SYSTEM_PROMPT: &str = "You are the assistant of Utopia, a temporal knowled results carry them, and so does each fact line of entity_facts, neighbors, timeline and \ paths_between: its [n] opens the passage that fact was read from. A fact line without \ a number (a derived fact, or one whose documents are gone) is stated without a bracket. \ - An end marked `end derived` was set by the timeline when a later fact began, and a range \ - marked `corrected` was changed by a person: neither is in the passage the line's [n] \ + An end written `closed when X took over [m]` was set by the timeline when that later fact \ + began: cite [m] for that end, never the line's own [n]. An end marked `end derived` came \ + from the timeline too, and a range marked `corrected` was changed by a person (the note \ + after the colon is theirs, not a document's): neither is in the passage the line's [n] \ opens, so never attribute it to that passage. \ If the evidence is insufficient, say so explicitly — never fabricate.\n\ 5. Always respond in the same language as the user's question."; diff --git a/crates/utopia-server/src/api/chat_graph_citation_tests.rs b/crates/utopia-server/src/api/chat_graph_citation_tests.rs index 00a09c66d..b0da32914 100644 --- a/crates/utopia-server/src/api/chat_graph_citation_tests.rs +++ b/crates/utopia-server/src/api/chat_graph_citation_tests.rs @@ -14,8 +14,9 @@ //! 几句;块被新版本取代后换成仍有效的那一块,`as_of` 回到取代之前又是原来那块 //! - 检索和图谱工具在一轮里共用一个号:图谱引到检索登过的块,号不变、补上那句话;只补了 //! 引文、条数没变,来源帧也再发一次 -//! - 原句里没有的日期在行上说出来(#970):时间线推出来的终点写 `end derived`,人改过的 -//! 区间写 `corrected`,号照旧;对话与 MCP 一样,规则 4 说不许把它们归到原句 +//! - 原句里没有的日期在行上说出来(#970):时间线推出来的终点写出关上它的那一行、带那一行 +//! 的号,人改过的区间写出修正备注、不带号;本行的号照旧。对话与 MCP 一样(MCP 不带号), +//! 规则 4 说推出的终点引关它的那一行,两种日期都不归到本行的原句 //! //! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 use super::*; @@ -716,23 +717,39 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() .text; let zhang_line = line(&text, "Zhang San"); assert!( - zhang_line.contains("2023-02-01 → 2024-07-05, corrected)"), + zhang_line.contains( + "2023-02-01 → 2024-07-05, corrected: The charter date was the approval date)" + ), "{tool}: {text}" ); let li_line = line(&text, "Li Si"); assert!( - li_line.contains("2024-07-05 → 2025-09-01, end derived)"), + li_line.contains("2024-07-05 → 2025-09-01, closed when Zhou Qi took over ["), "{tool}: {text}" ); + // 李四那一行也写着周七(关它的是他),所以按周七那一行自己的区间找 + let zhou_line = line(&text, "2025-09-01 → now)"); + assert!(zhou_line.contains("Zhou Qi"), "{tool}: {text}"); + // 本行的号照旧:打开的仍是那条事实读出来的原句。关上李四那段的号打开周七接手的 + // 那一块,那里写着 2025-09-01;周七自己那一行也是它,同一块只有一个号。备注不进清单 + let (z, l, q) = (marks(zhang_line), marks(li_line), marks(zhou_line)); + assert_eq!((z.len(), l.len(), q.len()), (1, 2, 1), "{tool}: {text}"); + assert_eq!(chunk_of(&sink, z[0]), s.charter, "{tool}"); + assert_eq!(chunk_of(&sink, l[0]), update, "{tool}: the closing fact's passage"); + // 那一条带着周七那句原话(#968 的后续):号打开的就是写着 2025-09-01 的那句 + assert_eq!( + quotes_of(&sink, l[0]), + ["Zhou Qi leads Project Aurora from 2025-09-01."], + "{tool}: the closer's entry carries the sentence that states the date" + ); + assert_eq!(chunk_of(&sink, l[1]), s.handover, "{tool}: the line's own passage"); + assert_eq!(q[0], l[0], "{tool}: one number for one chunk"); assert!( - line(&text, "Zhou Qi").contains("2025-09-01 → now)"), - "{tool}: {text}" + sink.sources + .iter() + .all(|x| !x.to_string().contains("approval date")), + "the note is not a source" ); - // 号照旧:打开的仍是那条事实读出来的原句 - let (z, l) = (marks(zhang_line), marks(li_line)); - assert_eq!((z.len(), l.len()), (1, 1), "{tool}: {text}"); - assert_eq!(chunk_of(&sink, z[0]), s.charter, "{tool}"); - assert_eq!(chunk_of(&sink, l[0]), s.handover, "{tool}"); } let path = dispatch( &chat, @@ -743,7 +760,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() .await .text; assert!( - path.contains("2024-07-05 → 2025-09-01, end derived)"), + path.contains("2024-07-05 → 2025-09-01, closed when Zhou Qi took over ["), "{path}" ); @@ -758,14 +775,16 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() .await .text; assert!( - plain.contains(", corrected)") && plain.contains(", end derived)"), + plain.contains(", corrected: The charter date was the approval date)") + && plain.contains(", closed when Zhou Qi took over)"), "{plain}" ); assert!(marks(&plain).is_empty(), "{plain}"); - // 规则 4 说这两种日期不在原句里 + // 规则 4:推出的终点引关它的那一行;两种日期都不归到本行的原句 assert!( - SYSTEM_PROMPT.contains("An end marked `end derived`") + SYSTEM_PROMPT.contains("An end written `closed when X took over [m]`") + && SYSTEM_PROMPT.contains("cite [m] for that end, never the line's own [n]") && SYSTEM_PROMPT.contains("a range marked `corrected`"), "{SYSTEM_PROMPT}" ); diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index 10b741b39..42efa14ae 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -1032,8 +1032,19 @@ fn query_text(result: &crate::query_engine::QueryResult) -> String { out } -/// 事实行:"works at → 星云科技 (2023-08 → now) [90%]",in 方向用 ←。 -pub(super) fn fact_line(f: &EntityFact) -> String { +/// 时间线关上一行时接任的那一端怎么写(#970 第二步):接任者或新宾语的名字,属性事实是 +/// 新的值,写法同事实行上的字面值(`literal_text`) +pub(super) fn successor_text( + name: Option<&str>, + value: Option<&serde_json::Value>, +) -> Option { + name.map(str::to_string) + .or_else(|| value.and_then(literal_text)) +} + +/// 事实行:"works at → 星云科技 (2023-08 → now) [90%]",in 方向用 ←。`closer_mark` 是关上 +/// 它的那一行证据的号(对话里 ` [n]`,MCP 与没有证据时为空) +pub(super) fn fact_line(f: &EntityFact, closer_mark: &str) -> String { // 属性事实没有对端实体,值在 `object_value` 里(0004)。从前这里只看 `other_name`, // 于是薪资、职位到了模型眼前是 `salary → ?`——区间和置信度都在,唯独值没到, // 模型只能说"没有薪资信息"(#348)。渲染规则与客户端 `fmtObjectValue` 一致 @@ -1077,6 +1088,7 @@ pub(super) fn fact_line(f: &EntityFact) -> String { }; // 两端各按自己的精度写;没起点的从证据起,结束了不知哪天的到说出它的那份文档为止 //(time_text,0022)。从前一律 %Y-%m-%d,年精度印成 1 月 1 日、结束未知印成 now + let successor = successor_text(f.closed_by.as_deref(), f.closed_by_value.as_ref()); let range = crate::time_text::span(crate::time_text::Span { valid_from: f.valid_from, from_precision: f.valid_from_precision.as_deref(), @@ -1086,6 +1098,8 @@ pub(super) fn fact_line(f: &EntityFact) -> String { holds_to: f.holds_to, end_derived: f.end_derived, corrected: f.time_corrected, + closed_by: successor.as_deref().map(|who| (who, closer_mark)), + correction_note: f.correction_note.as_deref(), }); let range = if range.is_empty() { range @@ -1254,6 +1268,8 @@ fn change_line(c: &GraphChange) -> String { // 记录轴上的一次变更,不是事实行:它自己就说了改的是什么 end_derived: false, corrected: false, + closed_by: None, + correction_note: None, }); let range = if range.is_empty() { range @@ -1488,6 +1504,10 @@ mod tests { corrected: false, end_derived: false, time_corrected: false, + closed_by_id: None, + closed_by: None, + closed_by_value: None, + correction_note: None, last_evidence_time: None, contested: None, } @@ -1497,18 +1517,20 @@ mod tests { /// 区间和置信度都在,唯独值没到,模型只能说"没有薪资信息"——而账本里明明有 #[test] fn an_attribute_fact_shows_the_model_its_value() { - let line = fact_line(&attribute_fact( - serde_json::json!({ "value": 28000, "unit": "CNY" }), - )); + let line = fact_line( + &attribute_fact(serde_json::json!({ "value": 28000, "unit": "CNY" })), + "", + ); assert!(line.starts_with("salary → 28000 CNY"), "{line}"); assert!(line.contains("(2023-06-01 → 2024-02-20)"), "{line}"); // 与客户端 fmtObjectValue 同一条规则:布尔画成 ✓/✗,映射摘要读摘要本身 - let flag = fact_line(&attribute_fact(serde_json::json!({ "value": true }))); + let flag = fact_line(&attribute_fact(serde_json::json!({ "value": true })), ""); assert!(flag.starts_with("salary → ✓"), "{flag}"); - let mapped = fact_line(&attribute_fact( - serde_json::json!({ "summary": "orders.total" }), - )); + let mapped = fact_line( + &attribute_fact(serde_json::json!({ "summary": "orders.total" })), + "", + ); assert!(mapped.starts_with("salary → orders.total"), "{mapped}"); } @@ -1517,7 +1539,11 @@ mod tests { fn a_missing_object_still_reads_as_a_question_mark() { let mut f = attribute_fact(serde_json::json!(null)); f.object_value = None; - assert!(fact_line(&f).starts_with("salary → ?"), "{}", fact_line(&f)); + assert!( + fact_line(&f, "").starts_with("salary → ?"), + "{}", + fact_line(&f, "") + ); } /// 字面值要读成它自己。走 `Value::to_string()` 会把字符串连引号一起印出来, diff --git a/crates/utopia-server/src/api/tools_graph.rs b/crates/utopia-server/src/api/tools_graph.rs index 2829e521a..2d5a02a0a 100644 --- a/crates/utopia-server/src/api/tools_graph.rs +++ b/crates/utopia-server/src/api/tools_graph.rs @@ -243,6 +243,13 @@ impl Evidence { } } + /// 关上这一行的那一行的号(#970 第二步):推出来的终点在那条事实的原句里写着 + fn closer_mark(&self, sink: &mut ToolSink, f: &EntityFact) -> String { + f.closed_by_id + .map(|id| self.mark(sink, id)) + .unwrap_or_default() + } + /// ` [n]`:这条事实的证据登进清单后的号。没有有效证据的事实(派生事实也是)不带号 fn mark(&self, sink: &mut ToolSink, fact_id: Uuid) -> String { match self.0.get(&fact_id) { @@ -260,6 +267,14 @@ impl Evidence { } } +/// 显示的事实加上关上它们的那几行(#970 第二步):一次证据查询把两边的块都读回来 +fn with_closers<'f>(facts: impl IntoIterator) -> Vec { + facts + .into_iter() + .flat_map(|f| std::iter::once(f.id).chain(f.closed_by_id)) + .collect() +} + // ---- find_entities --------------------------------------------------------------- pub async fn find_entities(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> ToolResult { @@ -373,7 +388,10 @@ fn other_text(f: &EntityFact) -> String { .to_string() } -fn range_text(f: &EntityFact) -> String { +/// `closer_mark` 是关上它的那一行证据的号(#970 第二步;MCP 与没有证据时为空) +fn range_text(f: &EntityFact, closer_mark: &str) -> String { + let successor = + super::tools::successor_text(f.closed_by.as_deref(), f.closed_by_value.as_ref()); let range = crate::time_text::span(crate::time_text::Span { valid_from: f.valid_from, from_precision: f.valid_from_precision.as_deref(), @@ -383,6 +401,8 @@ fn range_text(f: &EntityFact) -> String { holds_to: f.holds_to, end_derived: f.end_derived, corrected: f.time_corrected, + closed_by: successor.as_deref().map(|who| (who, closer_mark)), + correction_note: f.correction_note.as_deref(), }); if range.is_empty() { range @@ -791,17 +811,18 @@ pub async fn entity_facts(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) )); } lines.push(head); - let ids: Vec = shown.iter().map(|f| f.id).collect(); + let ids = with_closers(shown.iter().copied()); let evidence = Evidence::load(ctx, ids, m.as_of).await; let shown_owned: Vec = shown.iter().map(|f| (*f).clone()).collect(); for (key, group) in grouped(&shown_owned) { lines.push(format!("## {key} ({})", group.len())); for f in group { + let closer = evidence.closer_mark(sink, f); lines.push(format!( "{}{}{} {}{}", other_text(f), qualifiers_text(f), - range_text(f), + range_text(f, &closer), confidence_text(f), evidence.mark(sink, f.id) )); @@ -953,7 +974,7 @@ pub async fn neighbors(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> } )); } else { - let ids: Vec = shown.iter().map(|f| f.id).collect(); + let ids = with_closers(shown.iter()); let evidence = Evidence::load(ctx, ids, m.as_of).await; let groups = grouped(&shown); // 数的是对端实体,不是事实:同一条边常是两条事实(一条带日期一条不带) @@ -980,11 +1001,12 @@ pub async fn neighbors(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> .as_deref() .map(|t| format!(" [{t}]")) .unwrap_or_default(); + let closer = evidence.closer_mark(sink, f); format!( "{}{}{} {}{}", other_text(f), ty, - range_text(f), + range_text(f, &closer), confidence_text(f), evidence.mark(sink, f.id) ) @@ -1099,16 +1121,17 @@ pub async fn timeline(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> T )); } lines.push(head); - let ids: Vec = shown.iter().map(|f| f.id).collect(); + let ids = with_closers(shown.iter().copied()); let evidence = Evidence::load(ctx, ids, m.as_of).await; for f in &shown { let stamp = crate::time_text::world( f.valid_from.expect("dated"), f.valid_from_precision.as_deref(), ); + let closer = evidence.closer_mark(sink, f); lines.push(format!( "{stamp} {}{}", - fact_line(f), + fact_line(f, &closer), evidence.mark(sink, f.id) )); } @@ -1126,8 +1149,10 @@ pub async fn timeline(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> T // ---- paths_between --------------------------------------------------------------- /// 一条边在链上的写法:顺着走 `A —pred→ B`,逆着走 `A ←pred— B` -pub(super) fn edge_text(prev: Uuid, e: &PathEdge) -> String { +pub(super) fn edge_text(prev: Uuid, e: &PathEdge, closer_mark: &str) -> String { let pred = e.predicate.as_deref().unwrap_or("?"); + let successor = + super::tools::successor_text(e.closed_by.as_deref(), e.closed_by_value.as_ref()); let range = crate::time_text::span(crate::time_text::Span { valid_from: e.valid_from, from_precision: e.valid_from_precision.as_deref(), @@ -1137,6 +1162,8 @@ pub(super) fn edge_text(prev: Uuid, e: &PathEdge) -> String { holds_to: e.holds_to, end_derived: e.end_derived, corrected: e.time_corrected, + closed_by: successor.as_deref().map(|who| (who, closer_mark)), + correction_note: e.correction_note.as_deref(), }); let range = if range.is_empty() { range @@ -1162,11 +1189,14 @@ pub(super) fn path_text(p: &Path) -> String { path_text_marked(p, |_| String::new()) } -/// 一条路径,每一跳后面跟着 `mark` 给那条事实的东西(对话里是它证据的 `[n]`,#935) +/// 一条路径,每一跳后面跟着 `mark` 给那条事实的东西(对话里是它证据的 `[n]`,#935); +/// 时间线关上的那一跳,关它的那一行也由 `mark` 给号(#970 第二步) fn path_text_marked(p: &Path, mut mark: impl FnMut(Uuid) -> String) -> String { let mut parts = Vec::new(); for (i, e) in p.edges.iter().enumerate() { - parts.push(format!("{}{}", edge_text(p.nodes[i], e), mark(e.fact_id))); + let closer = e.closed_by_id.map(&mut mark).unwrap_or_default(); + let own = mark(e.fact_id); + parts.push(format!("{}{own}", edge_text(p.nodes[i], e, &closer))); } parts.join("; ") } @@ -1309,10 +1339,14 @@ pub async fn paths_between(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) from.name, to.name )); - // 每一跳是一条事实,各带各的号 + // 每一跳是一条事实,各带各的号;时间线关上的那一跳,关它的那一行也要号 let hops: Vec = paths .iter() - .flat_map(|p| p.edges.iter().map(|e| e.fact_id)) + .flat_map(|p| { + p.edges + .iter() + .flat_map(|e| std::iter::once(e.fact_id).chain(e.closed_by_id)) + }) .collect(); let evidence = Evidence::load(ctx, hops, m.as_of).await; for (i, p) in paths.iter().enumerate() { @@ -1466,6 +1500,10 @@ mod tests { corrected: false, end_derived: false, time_corrected: false, + closed_by_id: None, + closed_by: None, + closed_by_value: None, + correction_note: None, last_evidence_time: None, contested: None, } @@ -1595,6 +1633,10 @@ mod tests { confidence: 0.9, end_derived: false, time_corrected: false, + closed_by_id: None, + closed_by: None, + closed_by_value: None, + correction_note: None, }; let p = Path { nodes: vec![a, x, b], diff --git a/crates/utopia-server/src/time_text.rs b/crates/utopia-server/src/time_text.rs index d9d1cdfd4..0ecefbf3b 100644 --- a/crates/utopia-server/src/time_text.rs +++ b/crates/utopia-server/src/time_text.rs @@ -46,6 +46,25 @@ pub struct Span<'a> { pub end_derived: bool, /// 区间是人改过的(supersedes 链上有 `fact.time_corrected`) pub corrected: bool, + /// 关上它的那一行(#970 第二步):接任的那一端,和它证据的号(对话里是 ` [n]`,MCP 里空) + pub closed_by: Option<(&'a str, &'a str)>, + /// 人改区间时写下的备注 + pub correction_note: Option<&'a str>, +} + +/// 备注在行上最多这么多字:再长就是一段话,不是一个说明 +const NOTE_CHARS: usize = 120; + +/// 备注折成一行、去掉两端空白,过长截断。空的就是没写 +fn note_text(note: &str) -> Option { + let flat = note.split_whitespace().collect::>().join(" "); + if flat.is_empty() { + return None; + } + if flat.chars().count() > NOTE_CHARS { + return Some(flat.chars().take(NOTE_CHARS).collect::() + "…"); + } + Some(flat) } /// `from → to`,给模型看。 @@ -55,9 +74,11 @@ pub struct Span<'a> { /// `ended by <锚点>`(没有锚点时写 `ended, date unknown`);否则 `now`。 /// 两端都无可写时返回空串,调用方据此决定不带括号。 /// -/// 日期不是原文说的就跟在后面说出来(#970):人改过的区间写 `corrected`,时间线推出来的 -/// 终点写 `end derived`(只在有终点时)。这是这一行的事实,不是引用:`[n]` 仍是那条事实 -/// 读出来的原句,而原句里没有这两种日期 +/// 日期不是原文说的就跟在后面说出来(#970):人改过的区间写 `corrected`,写了备注就带上 +/// 备注(`corrected: …`,人的话不是原文段落,不带号);时间线推出来的终点写 +/// `closed when X took over [m]`——`[m]` 打开的是接任那条事实读出来的原句,那里写着这个 +/// 日期——找不到接任的那一行时写 `end derived`。这两样只在有终点时说。行尾的 `[n]` 仍是 +/// 这条事实自己读出来的原句 pub fn span(s: Span<'_>) -> String { let from = match (s.valid_from, s.holds_from) { (Some(t), _) => Some(world(t, s.from_precision)), @@ -83,10 +104,19 @@ pub fn span(s: Span<'_>) -> String { (None, Some(t)) => format!("→ {t}"), (Some(f), Some(t)) => format!("{f} → {t}"), }; - let marks: Vec<&str> = [(s.corrected, "corrected"), (derived, "end derived")] - .into_iter() - .filter_map(|(on, mark)| on.then_some(mark)) - .collect(); + let mut marks: Vec = Vec::new(); + if s.corrected { + marks.push(match s.correction_note.and_then(note_text) { + Some(note) => format!("corrected: {note}"), + None => "corrected".to_string(), + }); + } + if derived { + marks.push(match s.closed_by { + Some((who, mark)) => format!("closed when {who} took over{mark}"), + None => "end derived".to_string(), + }); + } match (range.is_empty(), marks.is_empty()) { (_, true) => range, (true, false) => marks.join(", "), @@ -246,4 +276,78 @@ mod tests { "corrected" ); } + /// 找得到关上它的那一行时,推出来的终点说出谁接任、带上那一行的号(#970 第二步); + /// MCP 没有号。人改过的区间带着备注:折成一行,过长截断,空的等于没写 + #[test] + fn a_derived_end_names_what_closed_it_and_a_correction_its_note() { + let day = |s: &str| Some(t(s)); + let closed = Span { + valid_from: day("2024-07-05T00:00:00Z"), + from_precision: Some("day"), + valid_to: day("2025-09-01T00:00:00Z"), + to_precision: Some("day"), + end_derived: true, + ..Span::default() + }; + assert_eq!( + span(Span { + closed_by: Some(("Zhou Qi", " [3]")), + ..closed + }), + "2024-07-05 → 2025-09-01, closed when Zhou Qi took over [3]" + ); + assert_eq!( + span(Span { + closed_by: Some(("Zhou Qi", "")), + ..closed + }), + "2024-07-05 → 2025-09-01, closed when Zhou Qi took over" + ); + // 开着的行没有终点,也就没有谁接任 + assert_eq!( + span(Span { + valid_to: None, + to_precision: None, + closed_by: Some(("Zhou Qi", " [3]")), + ..closed + }), + "2024-07-05 → now" + ); + let corrected = Span { + corrected: true, + end_derived: false, + ..closed + }; + assert_eq!( + span(Span { + correction_note: Some(" The charter date\n was the approval date "), + ..corrected + }), + "2024-07-05 → 2025-09-01, corrected: The charter date was the approval date" + ); + assert_eq!( + span(Span { + correction_note: Some(" "), + ..corrected + }), + "2024-07-05 → 2025-09-01, corrected" + ); + let long = "x".repeat(200); + assert_eq!( + span(Span { + correction_note: Some(&long), + ..corrected + }), + format!("2024-07-05 → 2025-09-01, corrected: {}…", "x".repeat(120)) + ); + assert_eq!( + span(Span { + corrected: true, + correction_note: Some("approval date"), + closed_by: Some(("Zhou Qi", " [3]")), + ..closed + }), + "2024-07-05 → 2025-09-01, corrected: approval date, closed when Zhou Qi took over [3]" + ); + } } diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index 905107984..1b80b630f 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -1532,12 +1532,14 @@ pub async fn entity_node( .await?) } -/// 本库被人改过区间的行(`fact.time_corrected` 审计记在被改的那一行上,#970)。查询开头 -/// 取一次,每一行只顺着自己的 supersedes 链对它。取的这一次走部分索引 -/// `audit_events_time_corrected_idx`(迁移 0097):只收这一种动作,不读这个库别的审计。`$1` 是库 +/// 本库被人改过区间的行(`fact.time_corrected` 审计记在被改的那一行上,#970),每行带着 +/// 最近那次修正写下的备注。查询开头取一次,每一行只顺着自己的 supersedes 链对它。取的这一次 +/// 走部分索引 `audit_events_time_corrected_idx`(迁移 0097):只收这一种动作,不读这个库别的 +/// 审计。`$1` 是库 pub(crate) const TIME_CORRECTED_TARGETS: &str = "time_corrected_targets AS MATERIALIZED ( - SELECT DISTINCT target_id FROM audit_events - WHERE kb_id = $1 AND action = 'fact.time_corrected' AND target_id IS NOT NULL)"; + SELECT DISTINCT ON (target_id) target_id, detail->>'note' AS note FROM audit_events + WHERE kb_id = $1 AND action = 'fact.time_corrected' AND target_id IS NOT NULL + ORDER BY target_id, created_at DESC)"; /// 这一行的区间是不是人改过的:它的 supersedes 链上有一行在 [`TIME_CORRECTED_TARGETS`] 里。 /// 改完之后时间线再关它、合并再搬它,都是在链上往下接,往上仍找得到那一次人改 @@ -1552,6 +1554,60 @@ pub(crate) fn time_corrected_sql(alias: &str) -> String { ) } +/// 人改区间时写下的备注(#970 第二步):链上最近那一次修正的。那一次没写备注就是空的—— +/// 更早一次的备注说的是被它取代的那个区间 +pub(crate) fn correction_note_sql(alias: &str) -> String { + format!( + "(WITH RECURSIVE chain(id, depth) AS ( + SELECT {alias}.supersedes, 1 WHERE {alias}.supersedes IS NOT NULL + UNION ALL + SELECT p.supersedes, chain.depth + 1 FROM facts p JOIN chain ON p.id = chain.id + WHERE p.supersedes IS NOT NULL) + SELECT t.note FROM chain JOIN time_corrected_targets t ON t.target_id = chain.id + ORDER BY chain.depth LIMIT 1)" + ) +} + +/// 时间线把 `f` 关上时接的那一行(#970 第二步),作为 `closer` 接进查询。 +/// +/// 与引擎关它时同一判法(`temporal::desired_ends`):同一谓词、同一条时间线——functional +/// 按主语,inverse functional 按宾语——起点正好是这一行的终点,值不同。起点锚不到的、看图 +/// 描述出来的后任引擎不拿来关,这里也不认;几行都合时取 id 最小的,与引擎的排序一样。 +/// 只找 `end_derived` 的行;后任没有起点(结束了不知哪天)的找不到。 +/// +/// 接任的那一端:同一宾语换了主语(一个项目换了 lead)是新的主语;同一主语换了宾语是新的 +/// 宾语,属性事实是新的值。`subject` / `object` 是 `f` 按记录轴算的属主,`as_of` 是参数位 +pub(crate) fn closed_by_join(subject: &str, object: &str, as_of: Option) -> String { + let n_subject = crate::record_axis::owner_at("n", "subject_id", as_of, false); + let n_object = crate::record_axis::owner_at("n", "object_id", as_of, true); + let n_held = crate::record_axis::facts_held_at("n", as_of); + let described = crate::temporal::described_sql("n"); + format!( + "LEFT JOIN LATERAL ( + SELECT n.id AS closed_by_id, + CASE WHEN rn.inverse_functional AND {n_object} = {object} + THEN n_s.canonical_name ELSE n_o.canonical_name END AS closed_by, + CASE WHEN rn.inverse_functional AND {n_object} = {object} + THEN NULL ELSE n.object_value END AS closed_by_value + FROM facts n + JOIN relation_types rn ON rn.id = n.predicate_id + LEFT JOIN entities n_s ON n_s.id = {n_subject} + LEFT JOIN entities n_o ON n_o.id = {n_object} + WHERE f.end_derived AND f.valid_to IS NOT NULL + AND n.kb_id = f.kb_id AND n.predicate_id = f.predicate_id AND n.id <> f.id + AND {n_held} + AND n.valid_from = f.valid_to + AND n.valid_from_grade IS DISTINCT FROM 'C' + AND NOT {described} + AND ((rn.functional AND {n_subject} = {subject} + AND ({n_object} IS DISTINCT FROM {object} + OR n.object_value IS DISTINCT FROM f.object_value)) + OR (rn.inverse_functional AND {n_object} = {object} + AND {n_subject} <> {subject})) + ORDER BY n.id LIMIT 1) closer ON true" + ) +} + /// 实体详情:节点信息 + 事实时间线。 pub async fn entity_detail( pool: &PgPool, @@ -1586,6 +1642,8 @@ pub async fn entity_detail( ) AS stale, (f.supersedes IS NOT NULL) AS corrected, f.end_derived, {time_corrected} AS time_corrected, + closer.closed_by_id, closer.closed_by, closer.closed_by_value, + {correction_note} AS correction_note, (SELECT MAX(COALESCE(d.doc_time, d.created_at)) FROM fact_evidence fe JOIN documents d ON d.id = fe.document_id WHERE fe.fact_id = f.id) AS last_evidence_time, @@ -1612,6 +1670,7 @@ pub async fn entity_detail( LEFT JOIN entities o ON o.id = CASE WHEN {subject} = $2 THEN {object} ELSE {subject} END LEFT JOIN entity_types ot ON ot.id = o.type_id + {closer} WHERE f.kb_id = $1 AND {facts_held} AND {facts_hold} AND NOT {represented} AND ({subject} = $2 OR {object} = $2) @@ -1619,6 +1678,12 @@ pub async fn entity_detail( ORDER BY f.valid_from NULLS LAST, f.recorded_at", targets = TIME_CORRECTED_TARGETS, time_corrected = time_corrected_sql("f"), + correction_note = correction_note_sql("f"), + closer = closed_by_join( + &crate::record_axis::owner_at("f", "subject_id", as_of.map(|_| 3), false), + &crate::record_axis::owner_at("f", "object_id", as_of.map(|_| 3), true), + as_of.map(|_| 3), + ), not_name = crate::names::not_a_name("f"), said_as = said_as("f"), represented = represented_by_typed("f"), diff --git a/crates/utopia-store/src/paths.rs b/crates/utopia-store/src/paths.rs index b44d3c558..5711da8fa 100644 --- a/crates/utopia-store/src/paths.rs +++ b/crates/utopia-store/src/paths.rs @@ -74,6 +74,11 @@ pub struct PathEdge { /// 终点是时间线推出来的;区间是人改过的(#970,同 `EntityFact`) pub end_derived: bool, pub time_corrected: bool, + /// 时间线关上它时接的那一行、接任的那一端,与修正备注(#970 第二步,同 `EntityFact`) + pub closed_by_id: Option, + pub closed_by: Option, + pub closed_by_value: Option, + pub correction_note: Option, } #[derive(Debug, Clone)] @@ -395,16 +400,21 @@ async fn details( COALESCE(r.label, fact_surface_predicate(f.id)) AS predicate, f.valid_from, f.valid_from_precision, f.valid_to, f.valid_to_precision, {holds_from} AS holds_from, {holds_to} AS holds_to, f.confidence, - f.end_derived, {time_corrected} AS time_corrected + f.end_derived, {time_corrected} AS time_corrected, + closer.closed_by_id, closer.closed_by, closer.closed_by_value, + {correction_note} AS correction_note FROM facts f LEFT JOIN relation_types r ON r.id = f.predicate_id JOIN entities s ON s.id = {subject} JOIN entities o ON o.id = {object} + {closer} WHERE f.kb_id = $1 AND f.id = ANY($2)", holds_from = world_axis::facts_holds_from("f"), holds_to = world_axis::facts_holds_to("f"), targets = crate::graph::TIME_CORRECTED_TARGETS, time_corrected = crate::graph::time_corrected_sql("f"), + correction_note = crate::graph::correction_note_sql("f"), + closer = crate::graph::closed_by_join(&subject, &object, as_of.map(|_| 3)), )) .bind(kb_id) .bind(fact_ids) diff --git a/crates/utopia-store/src/temporal.rs b/crates/utopia-store/src/temporal.rs index cf8ff8af9..753e8e2da 100644 --- a/crates/utopia-store/src/temporal.rs +++ b/crates/utopia-store/src/temporal.rs @@ -54,6 +54,11 @@ const DESCRIBED: &str = "(EXISTS (SELECT 1 FROM fact_evidence fe JOIN chunks ch ON ch.id = fe.chunk_id WHERE ts.fact_id = f.id AND ch.origin = 'described'))"; +/// [`DESCRIBED`] 换成另一个别名:读的时候找关上一行的后任(#970),与引擎排除同样的行 +pub(crate) fn described_sql(alias: &str) -> String { + DESCRIBED.replace("= f.id", &format!("= {alias}.id")) +} + /// 唯一性方向:functional = 主语侧(张三同时只 reports_to 一人); /// inverse functional = 宾语侧(一个项目同时只有一个 leads 它的人)。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -1828,6 +1833,14 @@ mod tests { assert!(plan.held.is_empty(), "锚不到的一对没有什么可判的"); } + /// 读的时候找关上一行的后任,用的是引擎同一条「看图描述出来的」判据,只换了别名(#970) + #[test] + fn the_described_check_takes_another_alias() { + let n = described_sql("n"); + assert!(!n.contains("= f.id"), "{n}"); + assert_eq!(n.matches("= n.id").count(), 2, "{n}"); + } + /// 看图描述出来的后任不许单独关上前任,这一对交给人(0040 决定 4); /// 前任止于它之后第一个不是描述出来的后任 #[test] diff --git a/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs b/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs index f33a124aa..cf4f28ae6 100644 --- a/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs +++ b/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs @@ -8,6 +8,10 @@ //! //! 人手关上的一段两个都不是;它仍是一条修正行(`corrected`,网页证据栏那句提示按它说)。 //! +//! 第二步:推出来的终点找得到关上它的那一行(李四那段是周七那一行;functional 的雇主是后 +//! 一个雇主),人改的区间带着修正时写下的备注。记录轴回到接手之前,李四那段还开着, +//! 没有谁关它。 +//! //! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 use chrono::{DateTime, Utc}; @@ -38,25 +42,36 @@ fn between(from: &str, to: &str) -> Validity<'static> { } } -/// Aurora 身上那个人的那一行:(终点是推出来的, 区间是人改过的, 是修正行) -fn flags(facts: &[EntityFact], who: &str) -> (bool, bool, bool) { - let f = facts +fn fact_of<'f>(facts: &'f [EntityFact], who: &str) -> &'f EntityFact { + facts .iter() .find(|f| f.other_name.as_deref() == Some(who)) - .unwrap_or_else(|| panic!("no fact of {who}")); + .unwrap_or_else(|| panic!("no fact of {who}")) +} + +/// 那个人的那一行:(终点是推出来的, 区间是人改过的, 是修正行) +fn flags(facts: &[EntityFact], who: &str) -> (bool, bool, bool) { + let f = fact_of(facts, who); (f.end_derived, f.time_corrected, f.corrected) } +/// 那个人的那一行被谁关上:(关它的那一行, 接任的那一端) +fn closer(facts: &[EntityFact], who: &str) -> (Option, Option) { + let f = fact_of(facts, who); + (f.closed_by_id, f.closed_by.clone()) +} + #[tokio::test] async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { let Some(url) = utopia_store::test_db::url() else { return Ok(()); }; let pool = PgPool::connect(&url).await?; - let ids: Vec = (0..13).map(|_| Uuid::now_v7()).collect(); + let ids: Vec = (0..17).map(|_| Uuid::now_v7()).collect(); let (org, ws, kb, user, person, project) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[5]); let (leads, advises, zhang, li, zhou, wang, aurora) = (ids[6], ids[7], ids[8], ids[9], ids[10], ids[11], ids[12]); + let (company, employer, acme, globex) = (ids[13], ids[14], ids[15], ids[16]); // Only locally generated UUIDs are interpolated into fixture SQL. sqlx::raw_sql(&format!( "INSERT INTO organizations (id, name) VALUES ('{org}', 'dates-came-from'); @@ -66,14 +81,18 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { VALUES ('{user}', '{org}', '{user}@dates.test', 'editor', 'unused'); INSERT INTO entity_types (id, kb_id, key, label, color, shape) VALUES ('{person}', '{kb}', 'person', 'person', '#7fd0ff', 'circle'), - ('{project}', '{kb}', 'project', 'project', '#7fd0ff', 'circle'); + ('{project}', '{kb}', 'project', 'project', '#7fd0ff', 'circle'), + ('{company}', '{kb}', 'organization', 'organization', '#7fd0ff', 'circle'); INSERT INTO relation_types (id, kb_id, key, label, temporal, inverse_functional) VALUES ('{leads}', '{kb}', 'leads', 'leads', 'state', TRUE), ('{advises}', '{kb}', 'advises', 'advises', 'state', FALSE); + INSERT INTO relation_types (id, kb_id, key, label, temporal, functional) VALUES + ('{employer}', '{kb}', 'employer', 'employer', 'state', TRUE); INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ('{zhang}', '{kb}', '{person}', 'Zhang San'), ('{li}', '{kb}', '{person}', 'Li Si'), ('{zhou}', '{kb}', '{person}', 'Zhou Qi'), ('{wang}', '{kb}', '{person}', 'Wang Wu'), - ('{aurora}', '{kb}', '{project}', 'Aurora');" + ('{aurora}', '{kb}', '{project}', 'Aurora'), + ('{acme}', '{kb}', '{company}', 'Acme'), ('{globex}', '{kb}', '{company}', 'Globex');" )) .execute(&pool) .await?; @@ -90,8 +109,35 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { // 周七接手:一个项目同时只有一个 lead,对账把李四那一段关在 2025-09-01 let li_row = fact(li, leads, since("2024-07-05")).await?; let zhou_row = fact(zhou, leads, since("2025-09-01")).await?; + let before_close: DateTime = + sqlx::query_scalar("SELECT now()").fetch_one(&pool).await?; utopia_store::temporal::reconcile_moved_facts(&pool, kb, &[li_row, zhou_row]).await?; + // 王五换了雇主:一个人同时只有一个雇主(functional),对账把 Acme 那段关在换的那天 + let acme_row = graph::insert_fact( + &pool, + kb, + wang, + Some(employer), + acme, + since("2020-01-01"), + 0.9, + ) + .await? + .0; + let globex_row = graph::insert_fact( + &pool, + kb, + wang, + Some(employer), + globex, + since("2022-03-01"), + 0.9, + ) + .await? + .0; + utopia_store::temporal::reconcile_moved_facts(&pool, kb, &[acme_row, globex_row]).await?; + // 人改张三的起点,终点照旧;审计记在被改的那一行上(与 PATCH /facts/{id} 同一条路) let zhang_row = fact(zhang, leads, between("2023-01-10", "2024-07-05")).await?; let corrected = utopia_store::temporal::correct_interval( @@ -144,6 +190,28 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { (false, false, true), "closed by hand: rewritten, but neither mark" ); + // 第二步:谁关的它,人改时说了什么 + assert_eq!( + closer(&facts, "Li Si"), + (Some(zhou_row), Some("Zhou Qi".to_string())), + "Zhou Qi's row took over" + ); + assert_eq!(closer(&facts, "Zhou Qi"), (None, None), "still open"); + assert_eq!(closer(&facts, "Wang Wu"), (None, None), "closed by hand"); + assert_eq!( + fact_of(&facts, "Zhang San").correction_note.as_deref(), + Some("The charter date was the approval date") + ); + let (_, wang_facts) = graph::entity_detail(&pool, kb, wang, None, None).await?; + assert_eq!( + closer(&wang_facts, "Acme"), + (Some(globex_row), Some("Globex".to_string())), + "a functional property: the next employer took over" + ); + // 记录轴回到接手之前:李四那段还开着,没有谁关它 + let (_, then) = graph::entity_detail(&pool, kb, aurora, None, Some(before_close)).await?; + assert!(!flags(&then, "Li Si").0, "not closed yet"); + assert_eq!(closer(&then, "Li Si"), (None, None)); // 改过的区间再被改写一次(这里是关上),往上的链里仍有那一次人改 utopia_store::temporal::close_superseded( @@ -155,6 +223,11 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { .await?; let (_, facts) = graph::entity_detail(&pool, kb, aurora, None, None).await?; assert_eq!(flags(&facts, "Zhang San"), (false, true, true)); + assert_eq!( + fact_of(&facts, "Zhang San").correction_note.as_deref(), + Some("The charter date was the approval date"), + "the note is found up the chain" + ); anyhow::Ok(()) } .await; diff --git a/docs/design/chat-and-mcp.md b/docs/design/chat-and-mcp.md index cf43a22e5..97f9d2078 100644 --- a/docs/design/chat-and-mcp.md +++ b/docs/design/chat-and-mcp.md @@ -55,8 +55,10 @@ entry a graph fact cites carries the sentence the fact was read from (`quotes`, one number however many of its sentences are cited, a search hit gaining the sentence when a graph tool cites it), and the preview and the document page mark it, so the number opens the sentence itself (#968's follow-up). A date that passage does not state says so on the line, in chat and MCP -alike: an end the timeline derived reads `…, end derived`, an interval a person corrected -`…, corrected`, and the prompt says neither is attributed to the passage (#970). The +alike: an end the timeline derived names the fact that closed the row and cites its sentence, +`…, closed when Zhou Qi took over [3]` (`…, end derived` when that fact cannot be found), and an +interval a person corrected reads `…, corrected: `, uncited. The prompt says to cite the +closing fact for such an end and to attribute neither to the line's own passage (#970). The previous turn's tool calls are replayed so the model knows what it did, not only what it said [0015]; a turn that gathers nothing, such as a restatement, keeps the previous answer's sources for the citation numbers it repeats (#943). From f9869d023f78ec34854d2fe4bc6fc768dc1de842 Mon Sep 17 00:00:00 2001 From: dada-yan Date: Mon, 28 Sep 2026 00:17:00 +1000 Subject: [PATCH 2/3] A corrected row records which end a person changed, written with the row instead of read from the audit, and its fact line names that end Signed-off-by: dada-yan --- crates/utopia-cli/src/main.rs | 2 +- crates/utopia-core/src/models.rs | 7 +- crates/utopia-server/src/api/chat.rs | 7 +- .../src/api/chat_graph_citation_tests.rs | 65 ++++- crates/utopia-server/src/api/tools.rs | 5 +- crates/utopia-server/src/api/tools_graph.rs | 6 +- crates/utopia-server/src/time_text.rs | 68 ++++- crates/utopia-store/src/graph.rs | 37 +-- crates/utopia-store/src/paths.rs | 11 +- crates/utopia-store/src/temporal.rs | 36 ++- .../a_corrected_row_says_which_end_changed.rs | 247 ++++++++++++++++++ .../a_fact_says_where_its_dates_came_from.rs | 74 +++++- docs/design/chat-and-mcp.md | 10 +- ...ed_row_says_which_end_a_person_changed.sql | 58 ++++ 14 files changed, 572 insertions(+), 61 deletions(-) create mode 100644 crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs create mode 100644 migrations/0099_a_corrected_row_says_which_end_a_person_changed.sql diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index 47e8fb19c..ef44a9a81 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 88; +const CURRENT_SCHEMA_VERSION: u32 = 89; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 1d2a81802..62f529623 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -842,9 +842,12 @@ pub struct EntityFact { /// 终点是时间线推出来的(`facts.end_derived`):后一条事实开始时引擎把它关上,原文没说 /// 这一天(#970) pub end_derived: bool, - /// 区间是人改过的:supersedes 链上有一条 `fact.time_corrected` 审计(#970)。与 - /// `corrected` 不同,那一位是任何一次改写:自动接续、审阅裁决、人手改 + /// 区间是人改过的(#970):`corrected_ends` 有值。与 `corrected` 不同,那一位是任何一次 + /// 改写:自动接续、审阅裁决、人手改 pub time_corrected: bool, + /// 人改过哪一端:`start` / `end` / `both`(`facts.corrected_ends`,随修正行写下)。终点后来 + /// 是时间线推出来的,就不再算终点被人改过 + pub corrected_ends: Option, /// 时间线把它关上时接的那一行(#970 第二步):只在 `end_derived`、而且找得到时有 pub closed_by_id: Option, /// 接的那一行变了的那一端:接任者,或新的宾语实体的名字 diff --git a/crates/utopia-server/src/api/chat.rs b/crates/utopia-server/src/api/chat.rs index acc5157f3..b17b7e887 100644 --- a/crates/utopia-server/src/api/chat.rs +++ b/crates/utopia-server/src/api/chat.rs @@ -632,9 +632,10 @@ const SYSTEM_PROMPT: &str = "You are the assistant of Utopia, a temporal knowled a number (a derived fact, or one whose documents are gone) is stated without a bracket. \ An end written `closed when X took over [m]` was set by the timeline when that later fact \ began: cite [m] for that end, never the line's own [n]. An end marked `end derived` came \ - from the timeline too, and a range marked `corrected` was changed by a person (the note \ - after the colon is theirs, not a document's): neither is in the passage the line's [n] \ - opens, so never attribute it to that passage. \ + from the timeline too. A line marked `start corrected` or `end corrected` had that date \ + changed by a person, and one marked `corrected` had both (the note after the colon is \ + theirs, not a document's). A derived or changed date is not in the passage the line's [n] \ + opens, so never attribute it to that passage; a date the line does not mark still is. \ If the evidence is insufficient, say so explicitly — never fabricate.\n\ 5. Always respond in the same language as the user's question."; diff --git a/crates/utopia-server/src/api/chat_graph_citation_tests.rs b/crates/utopia-server/src/api/chat_graph_citation_tests.rs index b0da32914..7fe6d7134 100644 --- a/crates/utopia-server/src/api/chat_graph_citation_tests.rs +++ b/crates/utopia-server/src/api/chat_graph_citation_tests.rs @@ -718,7 +718,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() let zhang_line = line(&text, "Zhang San"); assert!( zhang_line.contains( - "2023-02-01 → 2024-07-05, corrected: The charter date was the approval date)" + "2023-02-01 → 2024-07-05, start corrected: The charter date was the approval date)" ), "{tool}: {text}" ); @@ -763,6 +763,21 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() path.contains("2024-07-05 → 2025-09-01, closed when Zhou Qi took over ["), "{path}" ); + // 路径上被人改过的那条边(#976 的评审):同样只说被改的那一端 + let path = dispatch( + &chat, + &mut sink, + "paths_between", + &json!({"from": "Wang Wu", "to": "Zhang San"}), + ) + .await + .text; + assert!( + path.contains( + "2023-02-01 → 2024-07-05, start corrected: The charter date was the approval date" + ), + "{path}" + ); // MCP 的文字带着同样的标记:它们是这一行的事实,不是引用 let mcp = ctx(&f, Some(Uuid::now_v7())); @@ -775,19 +790,61 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() .await .text; assert!( - plain.contains(", corrected: The charter date was the approval date)") + plain.contains(", start corrected: The charter date was the approval date)") && plain.contains(", closed when Zhou Qi took over)"), "{plain}" ); assert!(marks(&plain).is_empty(), "{plain}"); - // 规则 4:推出的终点引关它的那一行;两种日期都不归到本行的原句 + // 规则 4:推出的终点引关它的那一行;推出的与人改的日期都不归到本行的原句,没标记的 + // 那个日期仍是 assert!( SYSTEM_PROMPT.contains("An end written `closed when X took over [m]`") && SYSTEM_PROMPT.contains("cite [m] for that end, never the line's own [n]") - && SYSTEM_PROMPT.contains("a range marked `corrected`"), + && SYSTEM_PROMPT.contains("A line marked `start corrected` or `end corrected`") + && SYSTEM_PROMPT.contains("a date the line does not mark still is"), "{SYSTEM_PROMPT}" ); + + // 张三再改一次,这回改终点(#976 的评审):两端都是人改的,备注取最近那一次 + let zhang_now = row_of("Zhang San").await?; + utopia_store::temporal::correct_interval( + &f.pool, + zhang_now, + utopia_store::graph::Validity { + from: Some(day("2023-02-01T00:00:00Z")), + from_precision: Some("day"), + to: Some(day("2024-07-01T00:00:00Z")), + to_precision: Some("day"), + attested_at: None, + from_grade: None, + }, + ) + .await? + .expect("the row was live"); + utopia_store::audit::record( + &f.pool, + Some(f.kb), + f.user.id, + "fact.time_corrected", + "fact", + Some(zhang_now), + json!({ "note": "The handover was on 1 July" }), + ) + .await?; + let text = dispatch( + &chat, + &mut ToolSink::default(), + "entity_facts", + &json!({"entity": "Project Aurora"}), + ) + .await + .text; + assert!( + line(&text, "Zhang San") + .contains("2023-02-01 → 2024-07-01, corrected: The handover was on 1 July)"), + "{text}" + ); anyhow::Ok(()) } .await; diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index 42efa14ae..685ad453b 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -1097,7 +1097,7 @@ pub(super) fn fact_line(f: &EntityFact, closer_mark: &str) -> String { holds_from: f.holds_from, holds_to: f.holds_to, end_derived: f.end_derived, - corrected: f.time_corrected, + corrected: f.corrected_ends.as_deref(), closed_by: successor.as_deref().map(|who| (who, closer_mark)), correction_note: f.correction_note.as_deref(), }); @@ -1267,7 +1267,7 @@ fn change_line(c: &GraphChange) -> String { holds_to: None, // 记录轴上的一次变更,不是事实行:它自己就说了改的是什么 end_derived: false, - corrected: false, + corrected: None, closed_by: None, correction_note: None, }); @@ -1504,6 +1504,7 @@ mod tests { corrected: false, end_derived: false, time_corrected: false, + corrected_ends: None, closed_by_id: None, closed_by: None, closed_by_value: None, diff --git a/crates/utopia-server/src/api/tools_graph.rs b/crates/utopia-server/src/api/tools_graph.rs index 2d5a02a0a..fd2c82063 100644 --- a/crates/utopia-server/src/api/tools_graph.rs +++ b/crates/utopia-server/src/api/tools_graph.rs @@ -400,7 +400,7 @@ fn range_text(f: &EntityFact, closer_mark: &str) -> String { holds_from: f.holds_from, holds_to: f.holds_to, end_derived: f.end_derived, - corrected: f.time_corrected, + corrected: f.corrected_ends.as_deref(), closed_by: successor.as_deref().map(|who| (who, closer_mark)), correction_note: f.correction_note.as_deref(), }); @@ -1161,7 +1161,7 @@ pub(super) fn edge_text(prev: Uuid, e: &PathEdge, closer_mark: &str) -> String { holds_from: e.holds_from, holds_to: e.holds_to, end_derived: e.end_derived, - corrected: e.time_corrected, + corrected: e.corrected_ends.as_deref(), closed_by: successor.as_deref().map(|who| (who, closer_mark)), correction_note: e.correction_note.as_deref(), }); @@ -1500,6 +1500,7 @@ mod tests { corrected: false, end_derived: false, time_corrected: false, + corrected_ends: None, closed_by_id: None, closed_by: None, closed_by_value: None, @@ -1633,6 +1634,7 @@ mod tests { confidence: 0.9, end_derived: false, time_corrected: false, + corrected_ends: None, closed_by_id: None, closed_by: None, closed_by_value: None, diff --git a/crates/utopia-server/src/time_text.rs b/crates/utopia-server/src/time_text.rs index 0ecefbf3b..cac05d574 100644 --- a/crates/utopia-server/src/time_text.rs +++ b/crates/utopia-server/src/time_text.rs @@ -44,8 +44,8 @@ pub struct Span<'a> { pub holds_to: Option>, /// 终点是后一条事实开始时时间线关上的(`facts.end_derived`) pub end_derived: bool, - /// 区间是人改过的(supersedes 链上有 `fact.time_corrected`) - pub corrected: bool, + /// 人改过哪一端:`start` / `end` / `both`(`facts.corrected_ends`);None 是没改过 + pub corrected: Option<&'a str>, /// 关上它的那一行(#970 第二步):接任的那一端,和它证据的号(对话里是 ` [n]`,MCP 里空) pub closed_by: Option<(&'a str, &'a str)>, /// 人改区间时写下的备注 @@ -74,8 +74,9 @@ fn note_text(note: &str) -> Option { /// `ended by <锚点>`(没有锚点时写 `ended, date unknown`);否则 `now`。 /// 两端都无可写时返回空串,调用方据此决定不带括号。 /// -/// 日期不是原文说的就跟在后面说出来(#970):人改过的区间写 `corrected`,写了备注就带上 -/// 备注(`corrected: …`,人的话不是原文段落,不带号);时间线推出来的终点写 +/// 日期不是原文说的就跟在后面说出来(#970):人改过的那一端写 `start corrected` 或 +/// `end corrected`,两端都改过写 `corrected`——没改的那一端仍是原文说的;写了备注就带上 +/// 备注(`start corrected: …`,人的话不是原文段落,不带号);时间线推出来的终点写 /// `closed when X took over [m]`——`[m]` 打开的是接任那条事实读出来的原句,那里写着这个 /// 日期——找不到接任的那一行时写 `end derived`。这两样只在有终点时说。行尾的 `[n]` 仍是 /// 这条事实自己读出来的原句 @@ -105,10 +106,15 @@ pub fn span(s: Span<'_>) -> String { (Some(f), Some(t)) => format!("{f} → {t}"), }; let mut marks: Vec = Vec::new(); - if s.corrected { + if let Some(ends) = s.corrected { + let what = match ends { + "start" => "start corrected", + "end" => "end corrected", + _ => "corrected", + }; marks.push(match s.correction_note.and_then(note_text) { - Some(note) => format!("corrected: {note}"), - None => "corrected".to_string(), + Some(note) => format!("{what}: {note}"), + None => what.to_string(), }); } if derived { @@ -235,14 +241,14 @@ mod tests { ); assert_eq!( span(Span { - corrected: true, + corrected: Some("both"), ..closed }), "2024-07-05 → 2025-09-01, corrected" ); assert_eq!( span(Span { - corrected: true, + corrected: Some("both"), end_derived: true, ..closed }), @@ -270,7 +276,7 @@ mod tests { ); assert_eq!( span(Span { - corrected: true, + corrected: Some("both"), ..Span::default() }), "corrected" @@ -314,7 +320,7 @@ mod tests { "2024-07-05 → now" ); let corrected = Span { - corrected: true, + corrected: Some("both"), end_derived: false, ..closed }; @@ -342,7 +348,7 @@ mod tests { ); assert_eq!( span(Span { - corrected: true, + corrected: Some("both"), correction_note: Some("approval date"), closed_by: Some(("Zhou Qi", " [3]")), ..closed @@ -350,4 +356,42 @@ mod tests { "2024-07-05 → 2025-09-01, corrected: approval date, closed when Zhou Qi took over [3]" ); } + + /// 人只改了一端就只说那一端(#976 的评审):没改的那一端仍是原文说的,终点仍可以是 + /// 时间线推出来的 + #[test] + fn a_correction_names_the_end_a_person_changed() { + let day = |s: &str| Some(t(s)); + let row = Span { + valid_from: day("2023-02-01T00:00:00Z"), + from_precision: Some("day"), + valid_to: day("2024-07-05T00:00:00Z"), + to_precision: Some("day"), + ..Span::default() + }; + assert_eq!( + span(Span { + corrected: Some("start"), + correction_note: Some("The charter date was the approval date"), + ..row + }), + "2023-02-01 → 2024-07-05, start corrected: The charter date was the approval date" + ); + assert_eq!( + span(Span { + corrected: Some("end"), + ..row + }), + "2023-02-01 → 2024-07-05, end corrected" + ); + assert_eq!( + span(Span { + corrected: Some("start"), + end_derived: true, + closed_by: Some(("Li Si", " [2]")), + ..row + }), + "2023-02-01 → 2024-07-05, start corrected, closed when Li Si took over [2]" + ); + } } diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index 1b80b630f..fea76b579 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -1532,25 +1532,23 @@ pub async fn entity_node( .await?) } -/// 本库被人改过区间的行(`fact.time_corrected` 审计记在被改的那一行上,#970),每行带着 -/// 最近那次修正写下的备注。查询开头取一次,每一行只顺着自己的 supersedes 链对它。取的这一次 -/// 走部分索引 `audit_events_time_corrected_idx`(迁移 0097):只收这一种动作,不读这个库别的 -/// 审计。`$1` 是库 +/// 本库人改区间时写下的备注(`fact.time_corrected` 审计记在被改的那一行上,#970):每个被改 +/// 的行取最近那次的。查询开头取一次,只有带着人改标记([`corrected_ends_sql`])的行才顺着 +/// 自己的 supersedes 链对它。取的这一次走部分索引 `audit_events_time_corrected_idx`(迁移 +/// 0097):只收这一种动作,不读这个库别的审计。`$1` 是库 pub(crate) const TIME_CORRECTED_TARGETS: &str = "time_corrected_targets AS MATERIALIZED ( SELECT DISTINCT ON (target_id) target_id, detail->>'note' AS note FROM audit_events WHERE kb_id = $1 AND action = 'fact.time_corrected' AND target_id IS NOT NULL ORDER BY target_id, created_at DESC)"; -/// 这一行的区间是不是人改过的:它的 supersedes 链上有一行在 [`TIME_CORRECTED_TARGETS`] 里。 -/// 改完之后时间线再关它、合并再搬它,都是在链上往下接,往上仍找得到那一次人改 -pub(crate) fn time_corrected_sql(alias: &str) -> String { +/// 人改过这一行的哪一端:`start` / `end` / `both`,NULL 是没改过(`facts.corrected_ends`, +/// 随修正行在同一个事务里写下,关上、搬移时随行带着,#970)。终点是时间线推出来的 +/// (`end_derived`)就不再说终点是人改的:那一端后来是时间线定的 +pub(crate) fn corrected_ends_sql(alias: &str) -> String { format!( - "EXISTS (WITH RECURSIVE chain(id) AS ( - SELECT {alias}.supersedes WHERE {alias}.supersedes IS NOT NULL - UNION - SELECT p.supersedes FROM facts p JOIN chain ON p.id = chain.id - WHERE p.supersedes IS NOT NULL) - SELECT 1 FROM chain JOIN time_corrected_targets t ON t.target_id = chain.id)" + "(CASE WHEN {alias}.end_derived AND {alias}.corrected_ends = 'end' THEN NULL + WHEN {alias}.end_derived AND {alias}.corrected_ends = 'both' THEN 'start' + ELSE {alias}.corrected_ends END)" ) } @@ -1641,9 +1639,11 @@ pub async fn entity_detail( WHERE fe.fact_id = f.id AND {chunk_live}) ) AS stale, (f.supersedes IS NOT NULL) AS corrected, - f.end_derived, {time_corrected} AS time_corrected, + f.end_derived, {corrected_ends} AS corrected_ends, + {corrected_ends} IS NOT NULL AS time_corrected, closer.closed_by_id, closer.closed_by, closer.closed_by_value, - {correction_note} AS correction_note, + CASE WHEN {corrected_ends} IS NOT NULL THEN {correction_note} END + AS correction_note, (SELECT MAX(COALESCE(d.doc_time, d.created_at)) FROM fact_evidence fe JOIN documents d ON d.id = fe.document_id WHERE fe.fact_id = f.id) AS last_evidence_time, @@ -1677,7 +1677,7 @@ pub async fn entity_detail( AND {not_name} ORDER BY f.valid_from NULLS LAST, f.recorded_at", targets = TIME_CORRECTED_TARGETS, - time_corrected = time_corrected_sql("f"), + corrected_ends = corrected_ends_sql("f"), correction_note = correction_note_sql("f"), closer = closed_by_join( &crate::record_axis::owner_at("f", "subject_id", as_of.map(|_| 3), false), @@ -2673,11 +2673,12 @@ async fn adopt( "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, object_value, valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, supersedes, - attested_from, attested_to, end_derived) + attested_from, attested_to, end_derived, + corrected_ends) SELECT $1, kb_id, $6, $3, $4, $5, valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, id, - attested_from, attested_to, end_derived + attested_from, attested_to, end_derived, corrected_ends FROM facts WHERE id = $2 AND invalidated_at IS NULL RETURNING id", ) diff --git a/crates/utopia-store/src/paths.rs b/crates/utopia-store/src/paths.rs index 5711da8fa..511664490 100644 --- a/crates/utopia-store/src/paths.rs +++ b/crates/utopia-store/src/paths.rs @@ -71,9 +71,10 @@ pub struct PathEdge { pub holds_from: Option>, pub holds_to: Option>, pub confidence: f32, - /// 终点是时间线推出来的;区间是人改过的(#970,同 `EntityFact`) + /// 终点是时间线推出来的;区间是人改过的、改的是哪一端(#970,同 `EntityFact`) pub end_derived: bool, pub time_corrected: bool, + pub corrected_ends: Option, /// 时间线关上它时接的那一行、接任的那一端,与修正备注(#970 第二步,同 `EntityFact`) pub closed_by_id: Option, pub closed_by: Option, @@ -400,9 +401,11 @@ async fn details( COALESCE(r.label, fact_surface_predicate(f.id)) AS predicate, f.valid_from, f.valid_from_precision, f.valid_to, f.valid_to_precision, {holds_from} AS holds_from, {holds_to} AS holds_to, f.confidence, - f.end_derived, {time_corrected} AS time_corrected, + f.end_derived, {corrected_ends} AS corrected_ends, + {corrected_ends} IS NOT NULL AS time_corrected, closer.closed_by_id, closer.closed_by, closer.closed_by_value, - {correction_note} AS correction_note + CASE WHEN {corrected_ends} IS NOT NULL THEN {correction_note} END + AS correction_note FROM facts f LEFT JOIN relation_types r ON r.id = f.predicate_id JOIN entities s ON s.id = {subject} @@ -412,7 +415,7 @@ async fn details( holds_from = world_axis::facts_holds_from("f"), holds_to = world_axis::facts_holds_to("f"), targets = crate::graph::TIME_CORRECTED_TARGETS, - time_corrected = crate::graph::time_corrected_sql("f"), + corrected_ends = crate::graph::corrected_ends_sql("f"), correction_note = crate::graph::correction_note_sql("f"), closer = crate::graph::closed_by_join(&subject, &object, as_of.map(|_| 3)), )) diff --git a/crates/utopia-store/src/temporal.rs b/crates/utopia-store/src/temporal.rs index 753e8e2da..eec0b5b97 100644 --- a/crates/utopia-store/src/temporal.rs +++ b/crates/utopia-store/src/temporal.rs @@ -983,11 +983,11 @@ async fn rewrite_end_tx( valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, supersedes, attested_from, attested_to, end_derived, - from_statement_id, implied) + from_statement_id, implied, corrected_ends) SELECT $1, kb_id, subject_id, predicate_id, object_id, object_value, valid_from, valid_from_precision, $3, $4, confidence, id, attested_from, CASE WHEN $4::text = 'unknown' THEN COALESCE($5, now()) END, $6, - from_statement_id, implied + from_statement_id, implied, corrected_ends FROM facts WHERE id = $2", ) .bind(corrected) @@ -1062,11 +1062,11 @@ pub async fn rehome_tx( valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, derived_by_rule, supersedes, attested_from, attested_to, end_derived, - from_statement_id, implied) + from_statement_id, implied, corrected_ends) SELECT $1, kb_id, COALESCE($3, subject_id), predicate_id, COALESCE($4, object_id), object_value, valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, derived_by_rule, id, attested_from, attested_to, end_derived, - from_statement_id, implied + from_statement_id, implied, corrected_ends FROM facts WHERE id = $2", ) .bind(moved) @@ -1173,6 +1173,12 @@ async fn copy_evidence( /// 改写同一条规矩([`copy_materialization_links`])。不带过去的话,陈述不再被活着的 /// 类型化行代表,画面上它按原话、原来的区间回来,下一轮物化还照它再算一行。 /// +/// 改了哪一端记在修正行上(`corrected_ends`,#970):值或精度变了的那一端,与旧行已有的 +/// 取并集,与修正同一个事务——事实行据此说「这个日期是人改的」,不再靠事后那条审计。 +/// 人交来的是整段区间,改完两端都是人的(`end_derived` 清掉,时间线不再重画它):原来 +/// 是时间线推出来的终点,人没改它的值也是把它钉住了,一样算人的;原来就是原文说的那一端 +/// 没改,仍是原文的 +/// /// 返回修正行 id;`None` 表示这条已被并发改写或作废,本次没有动手。 pub async fn correct_interval( pool: &PgPool, @@ -1194,13 +1200,29 @@ pub async fn correct_interval( valid_from, valid_from_precision, valid_to, valid_to_precision, confidence, supersedes, attested_from, attested_to, - from_statement_id, implied) + from_statement_id, implied, corrected_ends) SELECT $1, kb_id, subject_id, predicate_id, object_id, object_value, $3, $4, $5, $6, confidence, id, attested_from, CASE WHEN $6::text = 'unknown' THEN COALESCE(attested_to, now()) END, - from_statement_id, implied - FROM facts WHERE id = $2 AND invalidated_at IS NULL + from_statement_id, implied, + CASE WHEN start_marked AND end_marked THEN 'both' + WHEN start_marked THEN 'start' + WHEN end_marked THEN 'end' END + FROM (SELECT f.*, + c.start_changed OR COALESCE(f.corrected_ends IN ('start', 'both'), false) + AS start_marked, + c.end_changed OR f.end_derived + OR COALESCE(f.corrected_ends IN ('end', 'both'), false) + AS end_marked + FROM facts f, + LATERAL (SELECT f.valid_from IS DISTINCT FROM $3::timestamptz + OR f.valid_from_precision IS DISTINCT FROM $4::text + AS start_changed, + f.valid_to IS DISTINCT FROM $5::timestamptz + OR f.valid_to_precision IS DISTINCT FROM $6::text + AS end_changed) c + WHERE f.id = $2 AND f.invalidated_at IS NULL) old RETURNING id", ) .bind(corrected) diff --git a/crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs b/crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs new file mode 100644 index 000000000..581d46b22 --- /dev/null +++ b/crates/utopia-store/tests/a_corrected_row_says_which_end_changed.rs @@ -0,0 +1,247 @@ +//! 迁移 0099:这一列之前人改过区间的行,从审计里算出改的是哪一端。 +//! +//! 照修正路由的来路造:`correct_interval` 改区间,审计记在被改的那一行上,`detail` 带改前 +//! (`from`)与改后(`to`)的区间;再把这一列抹掉——那正是这一列出现之前写下的样子。 +//! +//! - 张三:改了起点 → start;之后人手关上(一次改写)仍是 start +//! - 李四:先改起点、再改终点 → both +//! - 王五:改了终点 → end +//! - 赵六:只是人手关上,没有改区间的审计 → 空 +//! - 钱七:审计没记改前改后 → both(说不清改了哪端,两端都不归原句) +//! - 孙八:只改了起点,可原来的终点是时间线推出来的 → both(人把那个终点钉住了) +//! +//! 只填还空着的:已经有标记的行,审计说什么都不改它。再跑一遍迁移,一样不动。 +//! +//! 迁移的语句不按 kb 过滤,改的是全库的行,所以这个测试在 tests/ 顶层自成一个二进制 +//! (与 0096 同理)。没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 + +use chrono::{DateTime, Utc}; +use serde_json::{json, Value}; +use sqlx::PgPool; +use utopia_store::graph::{self, Validity}; +use uuid::Uuid; + +const MIGRATION: &str = + include_str!("../../../migrations/0099_a_corrected_row_says_which_end_a_person_changed.sql"); + +fn day(s: &str) -> DateTime { + format!("{s}T00:00:00Z").parse().expect("fixed day") +} + +fn between(from: &str, to: Option<&str>) -> Validity<'static> { + Validity { + from: Some(day(from)), + from_precision: Some("day"), + to: to.map(day), + to_precision: to.map(|_| "day"), + attested_at: None, + from_grade: None, + } +} + +/// 修正路由记下的一端:与它一样用 RFC 3339 写日期 +fn side(v: &Validity<'_>) -> Value { + json!({ + "valid_from": v.from.map(|t| t.to_rfc3339()), + "valid_from_precision": v.from_precision, + "valid_to": v.to.map(|t| t.to_rfc3339()), + "valid_to_precision": v.to_precision, + }) +} + +/// 一行活着的那一版的标记 +async fn ends(pool: &PgPool, kb: Uuid, subject: Uuid) -> anyhow::Result> { + Ok(sqlx::query_scalar( + "SELECT corrected_ends FROM facts + WHERE kb_id = $1 AND subject_id = $2 AND invalidated_at IS NULL", + ) + .bind(kb) + .bind(subject) + .fetch_one(pool) + .await?) +} + +#[tokio::test] +async fn a_row_corrected_before_the_column_gets_its_ends_back() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let ids: Vec = (0..11).map(|_| Uuid::now_v7()).collect(); + let (org, ws, kb, user, person, project, leads) = + (ids[0], ids[1], ids[2], ids[3], ids[4], ids[5], ids[6]); + let people = [ + ids[7], + ids[8], + ids[9], + ids[10], + Uuid::now_v7(), + Uuid::now_v7(), + ]; + let aurora = Uuid::now_v7(); + // Only locally generated UUIDs are interpolated into fixture SQL. + sqlx::raw_sql(&format!( + "INSERT INTO organizations (id, name) VALUES ('{org}', 'corrected-ends'); + INSERT INTO workspaces (id, org_id, name) VALUES ('{ws}', '{org}', 'corrected-ends'); + INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ('{kb}', '{ws}', 'corrected-ends'); + INSERT INTO users (id, org_id, email, display_name, password_hash) + VALUES ('{user}', '{org}', '{user}@ends.test', 'editor', 'unused'); + INSERT INTO entity_types (id, kb_id, key, label, color, shape) VALUES + ('{person}', '{kb}', 'person', 'person', '#7fd0ff', 'circle'), + ('{project}', '{kb}', 'project', 'project', '#7fd0ff', 'circle'); + INSERT INTO relation_types (id, kb_id, key, label, temporal) + VALUES ('{leads}', '{kb}', 'leads', 'leads', 'state'); + INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES + ('{aurora}', '{kb}', '{project}', 'Aurora'), + ('{zhang}', '{kb}', '{person}', 'Zhang San'), ('{li}', '{kb}', '{person}', 'Li Si'), + ('{wang}', '{kb}', '{person}', 'Wang Wu'), ('{zhao}', '{kb}', '{person}', 'Zhao Liu'), + ('{qian}', '{kb}', '{person}', 'Qian Qi'), ('{sun}', '{kb}', '{person}', 'Sun Ba');", + zhang = people[0], + li = people[1], + wang = people[2], + zhao = people[3], + qian = people[4], + sun = people[5], + )) + .execute(&pool) + .await?; + let [zhang, li, wang, zhao, qian, sun] = people; + + let run = async { + let row = |who: Uuid, v: Validity<'static>| { + let pool = pool.clone(); + async move { + anyhow::Ok( + graph::insert_fact(&pool, kb, who, Some(leads), aurora, v, 0.9) + .await? + .0, + ) + } + }; + // 人改一次区间:修正行,加上修正路由记在被改的那一行上的审计 + let correct = + |fact: Uuid, before: Validity<'static>, after: Validity<'static>, detail: bool| { + let pool = pool.clone(); + async move { + let corrected = utopia_store::temporal::correct_interval(&pool, fact, after) + .await? + .expect("the row was live"); + let detail = if detail { + json!({ "from": side(&before), "to": side(&after) }) + } else { + json!({}) + }; + utopia_store::audit::record( + &pool, + Some(kb), + user, + "fact.time_corrected", + "fact", + Some(fact), + detail, + ) + .await?; + anyhow::Ok(corrected) + } + }; + + let first = between("2023-01-10", Some("2024-07-05")); + let z = row(zhang, first).await?; + let z = correct(z, first, between("2023-02-01", Some("2024-07-05")), true).await?; + utopia_store::temporal::close_superseded(&pool, z, day("2024-06-30"), "day").await?; + + let l = row(li, first).await?; + let moved = between("2023-02-01", Some("2024-07-05")); + let l = correct(l, first, moved, true).await?; + correct(l, moved, between("2023-02-01", Some("2024-08-01")), true).await?; + + let w = row(wang, first).await?; + correct(w, first, between("2023-01-10", Some("2024-06-01")), true).await?; + + let open = between("2023-01-10", None); + let h = row(zhao, open).await?; + utopia_store::temporal::close_superseded(&pool, h, day("2024-01-01"), "day").await?; + + let q = row(qian, first).await?; + correct(q, first, between("2023-03-01", Some("2024-07-05")), false).await?; + + // 时间线推出来的终点(这里直接标上),人只改起点、终点照抄 + let s = row(sun, first).await?; + sqlx::query("UPDATE facts SET end_derived = TRUE WHERE id = $1") + .bind(s) + .execute(&pool) + .await?; + correct(s, first, between("2023-02-01", Some("2024-07-05")), true).await?; + + let now: Vec> = { + let mut v = Vec::new(); + for who in people { + v.push(ends(&pool, kb, who).await?); + } + v + }; + assert_eq!( + now, + [ + Some("start"), + Some("both"), + Some("end"), + None, + Some("start"), + Some("both") + ] + .map(|e| e.map(str::to_string)), + "what correct_interval wrote" + ); + + // 这一列出现之前的样子:没有标记。王五那一行已经有一个(与审计说的不一样)的标记, + // 回填不动它 + sqlx::query("UPDATE facts SET corrected_ends = NULL WHERE kb_id = $1 AND subject_id <> $2") + .bind(kb) + .bind(wang) + .execute(&pool) + .await?; + sqlx::query( + "UPDATE facts SET corrected_ends = 'both' + WHERE kb_id = $1 AND subject_id = $2 AND invalidated_at IS NULL", + ) + .bind(kb) + .bind(wang) + .execute(&pool) + .await?; + + sqlx::raw_sql(MIGRATION).execute(&pool).await?; + assert_eq!(ends(&pool, kb, zhang).await?.as_deref(), Some("start")); + assert_eq!(ends(&pool, kb, li).await?.as_deref(), Some("both")); + assert_eq!( + ends(&pool, kb, wang).await?.as_deref(), + Some("both"), + "a row that has its mark keeps it" + ); + assert_eq!(ends(&pool, kb, zhao).await?, None, "closed by hand"); + assert_eq!( + ends(&pool, kb, qian).await?.as_deref(), + Some("both"), + "an audit without the two intervals cannot say which end" + ); + assert_eq!( + ends(&pool, kb, sun).await?.as_deref(), + Some("both"), + "the derived end the person kept" + ); + + // 再跑一遍,一样不动 + sqlx::raw_sql(MIGRATION).execute(&pool).await?; + assert_eq!(ends(&pool, kb, zhang).await?.as_deref(), Some("start")); + assert_eq!(ends(&pool, kb, li).await?.as_deref(), Some("both")); + assert_eq!(ends(&pool, kb, zhao).await?, None); + anyhow::Ok(()) + } + .await; + + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + run +} diff --git a/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs b/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs index cf4f28ae6..5c8031856 100644 --- a/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs +++ b/crates/utopia-store/tests/store/a_fact_says_where_its_dates_came_from.rs @@ -3,8 +3,10 @@ //! //! - 时间线推出来的终点:李四自 2024-07-05 领 Aurora,周七 2025-09-01 接手,`leads` 一个项目 //! 同时只有一个,对账把李四那一段关在 2025-09-01——`end_derived`; -//! - 人改过的区间:张三的起点被人从 2023-01-10 改成 2023-02-01,`fact.time_corrected` 记在 -//! 被改的那一行上——`time_corrected`,之后这一行再被改写也仍然是。 +//! - 人改过的区间:张三的起点被人从 2023-01-10 改成 2023-02-01——`time_corrected`,改的是 +//! 起点(`corrected_ends`,随修正行写下),之后这一行再被改写也仍然是;再改终点就是两端。 +//! 标记在行上,不靠修正路由事后写的那条审计:李四的起点被改时没有审计,照样有标记。 +//! 人交来的是整段区间:李四那个推出来的终点,人没改它的值也是钉住了,两端都算人的。 //! //! 人手关上的一段两个都不是;它仍是一条修正行(`corrected`,网页证据栏那句提示按它说)。 //! @@ -55,6 +57,11 @@ fn flags(facts: &[EntityFact], who: &str) -> (bool, bool, bool) { (f.end_derived, f.time_corrected, f.corrected) } +/// 那个人的那一行,人改过哪一端 +fn ends(facts: &[EntityFact], who: &str) -> Option { + fact_of(facts, who).corrected_ends.clone() +} + /// 那个人的那一行被谁关上:(关它的那一行, 接任的那一端) fn closer(facts: &[EntityFact], who: &str) -> (Option, Option) { let f = fact_of(facts, who); @@ -202,6 +209,12 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { fact_of(&facts, "Zhang San").correction_note.as_deref(), Some("The charter date was the approval date") ); + assert_eq!(ends(&facts, "Zhang San").as_deref(), Some("start")); + assert_eq!( + ends(&facts, "Wang Wu"), + None, + "closed by hand, not corrected" + ); let (_, wang_facts) = graph::entity_detail(&pool, kb, wang, None, None).await?; assert_eq!( closer(&wang_facts, "Acme"), @@ -213,7 +226,7 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { assert!(!flags(&then, "Li Si").0, "not closed yet"); assert_eq!(closer(&then, "Li Si"), (None, None)); - // 改过的区间再被改写一次(这里是关上),往上的链里仍有那一次人改 + // 改过的区间再被改写一次(这里是关上),标记随行带着,备注仍在链上找得到 utopia_store::temporal::close_superseded( &pool, corrected, @@ -223,11 +236,66 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { .await?; let (_, facts) = graph::entity_detail(&pool, kb, aurora, None, None).await?; assert_eq!(flags(&facts, "Zhang San"), (false, true, true)); + assert_eq!(ends(&facts, "Zhang San").as_deref(), Some("start")); assert_eq!( fact_of(&facts, "Zhang San").correction_note.as_deref(), Some("The charter date was the approval date"), "the note is found up the chain" ); + + // 人只改了李四的起点、终点照抄:交来的是整段区间,那个推出来的终点从此是人钉住的 + // (时间线不再重画它),两端都算人的——行上不能说终点是原句的。这一次没有审计, + // 标记照样在行上 + let li_now = fact_of(&facts, "Li Si").id; + utopia_store::temporal::correct_interval( + &pool, + li_now, + between("2024-07-08", "2025-09-01"), + ) + .await? + .expect("the row was live"); + let (_, facts) = graph::entity_detail(&pool, kb, aurora, None, None).await?; + assert_eq!( + flags(&facts, "Li Si"), + (false, true, true), + "a person's interval: the timeline no longer owns its end" + ); + assert_eq!( + ends(&facts, "Li Si").as_deref(), + Some("both"), + "the derived end the person kept is theirs too" + ); + assert_eq!( + fact_of(&facts, "Li Si").correction_note, + None, + "no note written" + ); + + // 张三再改一次,这回改终点:两端都是人改的了,备注取最近那一次 + let zhang_now = fact_of(&facts, "Zhang San").id; + utopia_store::temporal::correct_interval( + &pool, + zhang_now, + between("2023-02-01", "2024-07-01"), + ) + .await? + .expect("the row was live"); + utopia_store::audit::record( + &pool, + Some(kb), + user, + "fact.time_corrected", + "fact", + Some(zhang_now), + json!({ "note": "The handover was on 1 July" }), + ) + .await?; + let (_, facts) = graph::entity_detail(&pool, kb, aurora, None, None).await?; + assert_eq!(ends(&facts, "Zhang San").as_deref(), Some("both")); + assert_eq!( + fact_of(&facts, "Zhang San").correction_note.as_deref(), + Some("The handover was on 1 July") + ); anyhow::Ok(()) } .await; diff --git a/docs/design/chat-and-mcp.md b/docs/design/chat-and-mcp.md index 97f9d2078..f28124e10 100644 --- a/docs/design/chat-and-mcp.md +++ b/docs/design/chat-and-mcp.md @@ -56,9 +56,13 @@ one number however many of its sentences are cited, a search hit gaining the sen tool cites it), and the preview and the document page mark it, so the number opens the sentence itself (#968's follow-up). A date that passage does not state says so on the line, in chat and MCP alike: an end the timeline derived names the fact that closed the row and cites its sentence, -`…, closed when Zhou Qi took over [3]` (`…, end derived` when that fact cannot be found), and an -interval a person corrected reads `…, corrected: `, uncited. The prompt says to cite the -closing fact for such an end and to attribute neither to the line's own passage (#970). The +`…, closed when Zhou Qi took over [3]` (`…, end derived` when that fact cannot be found), and a +date a person corrected reads `…, start corrected: ` (`end corrected`, or `corrected` +for both ends), uncited. Which end a person changed is written with the row +(`facts.corrected_ends`, in the correction's own transaction, carried by every rewrite), and an end +the timeline later derived is no longer marked as a person's. The prompt says to cite the closing +fact for such an end, to attribute neither a derived nor a changed date to the line's own passage, +and that an unmarked date on the same line still is the passage's (#970). The previous turn's tool calls are replayed so the model knows what it did, not only what it said [0015]; a turn that gathers nothing, such as a restatement, keeps the previous answer's sources for the citation numbers it repeats (#943). diff --git a/migrations/0099_a_corrected_row_says_which_end_a_person_changed.sql b/migrations/0099_a_corrected_row_says_which_end_a_person_changed.sql new file mode 100644 index 000000000..985bba4d9 --- /dev/null +++ b/migrations/0099_a_corrected_row_says_which_end_a_person_changed.sql @@ -0,0 +1,58 @@ +-- 人改过区间的行自己记着改的是哪一端(#970,#976 合并时的评审)。 +-- +-- #976 让事实行说出「这一段是人改过的」:顺着 supersedes 链找 `fact.time_corrected` 审计。 +-- 那条审计是修正路由在改完之后尽力写的(写失败不回滚,拿不到快照就不写),它一丢, +-- 行上就不再说这一段是人改的,而模型会把人改的日期当成原句说的。人也可能只改了一端: +-- 另一端仍是原句说的,行上却只能说「改过」。 +-- +-- `corrected_ends` 与 `end_derived` 一样随行写:`correct_interval` 在修正的同一个事务里 +-- 比较旧行与新区间,哪一端(值或精度)变了记哪一端,与旧行已有的取并集;关上、改终点、 +-- 搬主宾、认领属性这些改写原样带过去。人交来的是整段区间,改完时间线不再重画它:原来是 +-- 时间线推出来的终点,人没改值也是把它钉住了,算人的那一端。 +-- start 人改过起点 +-- end 人改过终点(或钉住了时间线推出来的终点) +-- both 两端都是人的 +-- NULL 是没人改过。 +-- +-- 回填:这一列之前改过的行,从审计里算。审计记在被改的那一行(改之前的行)上,`detail` +-- 自 #311 起记着改前的区间(`from`)与改后的(`to`);被改的那一行还在表里(作废,不删), +-- 它的终点是不是推出来的也看得到。从每一条审计的那一行顺着 supersedes 往下走,修正行和 +-- 它之后改写出来的每一行都算上这一次改了哪端,几次取并集。只填还空着的行,再跑一遍什么 +-- 都不改。 +ALTER TABLE facts ADD COLUMN IF NOT EXISTS corrected_ends TEXT + CHECK (corrected_ends IN ('start', 'end', 'both')); + +WITH RECURSIVE down (fact_id, audit_id) AS ( + SELECT f.id, a.id + FROM audit_events a + JOIN facts f ON f.supersedes = a.target_id + WHERE a.action = 'fact.time_corrected' AND a.target_id IS NOT NULL + UNION + SELECT f.id, down.audit_id + FROM facts f + JOIN down ON f.supersedes = down.fact_id +), +changed AS ( + SELECT down.fact_id, + bool_or(a.detail->'from' IS NULL OR a.detail->'to' IS NULL + OR a.detail->'from'->'valid_from' IS DISTINCT FROM a.detail->'to'->'valid_from' + OR a.detail->'from'->'valid_from_precision' + IS DISTINCT FROM a.detail->'to'->'valid_from_precision') AS start_changed, + bool_or(a.detail->'from' IS NULL OR a.detail->'to' IS NULL + OR a.detail->'from'->'valid_to' IS DISTINCT FROM a.detail->'to'->'valid_to' + OR a.detail->'from'->'valid_to_precision' + IS DISTINCT FROM a.detail->'to'->'valid_to_precision' + OR COALESCE(t.end_derived, false)) AS end_changed + FROM down + JOIN audit_events a ON a.id = down.audit_id + LEFT JOIN facts t ON t.id = a.target_id + GROUP BY down.fact_id +) +UPDATE facts f + SET corrected_ends = CASE WHEN c.start_changed AND c.end_changed THEN 'both' + WHEN c.start_changed THEN 'start' + ELSE 'end' END + FROM changed c + WHERE f.id = c.fact_id + AND f.corrected_ends IS NULL + AND (c.start_changed OR c.end_changed); From 63949b617064554aed4187bd747b03332a5845ba Mon Sep 17 00:00:00 2001 From: Wayland Yang Date: Mon, 28 Sep 2026 12:42:57 +0800 Subject: [PATCH 3/3] A rewritten end drops the mark that a person changed it, and the closer is named without a verb that fits only people Maintainer edits on #986. When the end of a row is written again, by a document stating it or by the timeline deriving one, the row no longer says a person corrected that end; the start's mark stays. The derived end reads superseded by X: took over was right for an inverse-functional property and wrong for a functional one or a value. Square brackets in a person's note are replaced, since a [2] there would read as a citation. CURRENT_SCHEMA_VERSION is 90. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Wayland Yang --- crates/utopia-cli/src/main.rs | 2 +- crates/utopia-server/src/api/chat.rs | 2 +- .../src/api/chat_graph_citation_tests.rs | 8 ++++---- crates/utopia-server/src/time_text.rs | 20 ++++++++++++------- crates/utopia-store/src/temporal.rs | 6 +++++- docs/design/chat-and-mcp.md | 2 +- 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/crates/utopia-cli/src/main.rs b/crates/utopia-cli/src/main.rs index ef44a9a81..97fa065f6 100644 --- a/crates/utopia-cli/src/main.rs +++ b/crates/utopia-cli/src/main.rs @@ -82,7 +82,7 @@ struct ManifestDataDir { /// not a side effect of a code change. // 是迁移文件的**个数**,不是最大的编号(守卫 `schema_version_policy_compares_against_current` // 按个数比):编号有空缺时两者不同——0071 由一个开放 PR 占着,0072 先落,个数是 71 -const CURRENT_SCHEMA_VERSION: u32 = 89; +const CURRENT_SCHEMA_VERSION: u32 = 90; fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); diff --git a/crates/utopia-server/src/api/chat.rs b/crates/utopia-server/src/api/chat.rs index 02a44e2e8..668cb519f 100644 --- a/crates/utopia-server/src/api/chat.rs +++ b/crates/utopia-server/src/api/chat.rs @@ -656,7 +656,7 @@ const SYSTEM_PROMPT: &str = "You are the assistant of Utopia, a temporal knowled results carry them, and so does each fact line of entity_facts, neighbors, timeline and \ paths_between: its [n] opens the passage that fact was read from. A fact line without \ a number (a derived fact, or one whose documents are gone) is stated without a bracket. \ - An end written `closed when X took over [m]` was set by the timeline when that later fact \ + An end written `superseded by X [m]` was set by the timeline when that later fact \ began: cite [m] for that end, never the line's own [n]. An end marked `end derived` came \ from the timeline too. A line marked `start corrected` or `end corrected` had that date \ changed by a person, and one marked `corrected` had both (the note after the colon is \ diff --git a/crates/utopia-server/src/api/chat_graph_citation_tests.rs b/crates/utopia-server/src/api/chat_graph_citation_tests.rs index 7fe6d7134..7700a17d8 100644 --- a/crates/utopia-server/src/api/chat_graph_citation_tests.rs +++ b/crates/utopia-server/src/api/chat_graph_citation_tests.rs @@ -724,7 +724,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() ); let li_line = line(&text, "Li Si"); assert!( - li_line.contains("2024-07-05 → 2025-09-01, closed when Zhou Qi took over ["), + li_line.contains("2024-07-05 → 2025-09-01, superseded by Zhou Qi ["), "{tool}: {text}" ); // 李四那一行也写着周七(关它的是他),所以按周七那一行自己的区间找 @@ -760,7 +760,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() .await .text; assert!( - path.contains("2024-07-05 → 2025-09-01, closed when Zhou Qi took over ["), + path.contains("2024-07-05 → 2025-09-01, superseded by Zhou Qi ["), "{path}" ); // 路径上被人改过的那条边(#976 的评审):同样只说被改的那一端 @@ -791,7 +791,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() .text; assert!( plain.contains(", start corrected: The charter date was the approval date)") - && plain.contains(", closed when Zhou Qi took over)"), + && plain.contains(", superseded by Zhou Qi)"), "{plain}" ); assert!(marks(&plain).is_empty(), "{plain}"); @@ -799,7 +799,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() // 规则 4:推出的终点引关它的那一行;推出的与人改的日期都不归到本行的原句,没标记的 // 那个日期仍是 assert!( - SYSTEM_PROMPT.contains("An end written `closed when X took over [m]`") + SYSTEM_PROMPT.contains("An end written `superseded by X [m]`") && SYSTEM_PROMPT.contains("cite [m] for that end, never the line's own [n]") && SYSTEM_PROMPT.contains("A line marked `start corrected` or `end corrected`") && SYSTEM_PROMPT.contains("a date the line does not mark still is"), diff --git a/crates/utopia-server/src/time_text.rs b/crates/utopia-server/src/time_text.rs index cac05d574..2d563601c 100644 --- a/crates/utopia-server/src/time_text.rs +++ b/crates/utopia-server/src/time_text.rs @@ -57,7 +57,13 @@ const NOTE_CHARS: usize = 120; /// 备注折成一行、去掉两端空白,过长截断。空的就是没写 fn note_text(note: &str) -> Option { - let flat = note.split_whitespace().collect::>().join(" "); + // 方括号换掉:备注是人写的话,里面一个 `[2]` 落在行上就成了一个像引用的号 + let flat = note + .replace('[', "(") + .replace(']', ")") + .split_whitespace() + .collect::>() + .join(" "); if flat.is_empty() { return None; } @@ -77,7 +83,7 @@ fn note_text(note: &str) -> Option { /// 日期不是原文说的就跟在后面说出来(#970):人改过的那一端写 `start corrected` 或 /// `end corrected`,两端都改过写 `corrected`——没改的那一端仍是原文说的;写了备注就带上 /// 备注(`start corrected: …`,人的话不是原文段落,不带号);时间线推出来的终点写 -/// `closed when X took over [m]`——`[m]` 打开的是接任那条事实读出来的原句,那里写着这个 +/// `superseded by X [m]`——`[m]` 打开的是接任那条事实读出来的原句,那里写着这个 /// 日期——找不到接任的那一行时写 `end derived`。这两样只在有终点时说。行尾的 `[n]` 仍是 /// 这条事实自己读出来的原句 pub fn span(s: Span<'_>) -> String { @@ -119,7 +125,7 @@ pub fn span(s: Span<'_>) -> String { } if derived { marks.push(match s.closed_by { - Some((who, mark)) => format!("closed when {who} took over{mark}"), + Some((who, mark)) => format!("superseded by {who}{mark}"), None => "end derived".to_string(), }); } @@ -300,14 +306,14 @@ mod tests { closed_by: Some(("Zhou Qi", " [3]")), ..closed }), - "2024-07-05 → 2025-09-01, closed when Zhou Qi took over [3]" + "2024-07-05 → 2025-09-01, superseded by Zhou Qi [3]" ); assert_eq!( span(Span { closed_by: Some(("Zhou Qi", "")), ..closed }), - "2024-07-05 → 2025-09-01, closed when Zhou Qi took over" + "2024-07-05 → 2025-09-01, superseded by Zhou Qi" ); // 开着的行没有终点,也就没有谁接任 assert_eq!( @@ -353,7 +359,7 @@ mod tests { closed_by: Some(("Zhou Qi", " [3]")), ..closed }), - "2024-07-05 → 2025-09-01, corrected: approval date, closed when Zhou Qi took over [3]" + "2024-07-05 → 2025-09-01, corrected: approval date, superseded by Zhou Qi [3]" ); } @@ -391,7 +397,7 @@ mod tests { closed_by: Some(("Li Si", " [2]")), ..row }), - "2023-02-01 → 2024-07-05, start corrected, closed when Li Si took over [2]" + "2023-02-01 → 2024-07-05, start corrected, superseded by Li Si [2]" ); } } diff --git a/crates/utopia-store/src/temporal.rs b/crates/utopia-store/src/temporal.rs index c2a7da71a..4da289098 100644 --- a/crates/utopia-store/src/temporal.rs +++ b/crates/utopia-store/src/temporal.rs @@ -999,7 +999,11 @@ async fn rewrite_end_tx( SELECT $1, kb_id, subject_id, predicate_id, object_id, object_value, valid_from, valid_from_precision, $3, $4, confidence, id, attested_from, CASE WHEN $4::text = 'unknown' THEN COALESCE($5, now()) END, $6, - from_statement_id, implied, corrected_ends + from_statement_id, implied, + -- 终点在这里被重新写下(文档说了终点,或时间线推了一个),人改过的终点就不在了; + -- 起点那一半照旧 + CASE corrected_ends WHEN 'both' THEN 'start' WHEN 'end' THEN NULL + ELSE corrected_ends END FROM facts WHERE id = $2", ) .bind(corrected) diff --git a/docs/design/chat-and-mcp.md b/docs/design/chat-and-mcp.md index f28124e10..d29b880cf 100644 --- a/docs/design/chat-and-mcp.md +++ b/docs/design/chat-and-mcp.md @@ -56,7 +56,7 @@ one number however many of its sentences are cited, a search hit gaining the sen tool cites it), and the preview and the document page mark it, so the number opens the sentence itself (#968's follow-up). A date that passage does not state says so on the line, in chat and MCP alike: an end the timeline derived names the fact that closed the row and cites its sentence, -`…, closed when Zhou Qi took over [3]` (`…, end derived` when that fact cannot be found), and a +`…, superseded by Zhou Qi [3]` (`…, end derived` when that fact cannot be found), and a date a person corrected reads `…, start corrected: ` (`end corrected`, or `corrected` for both ends), uncited. Which end a person changed is written with the row (`facts.corrected_ends`, in the correction's own transaction, carried by every rewrite), and an end