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 7700a17d8..f5c23b284 100644 --- a/crates/utopia-server/src/api/chat_graph_citation_tests.rs +++ b/crates/utopia-server/src/api/chat_graph_citation_tests.rs @@ -15,8 +15,9 @@ //! - 检索和图谱工具在一轮里共用一个号:图谱引到检索登过的块,号不变、补上那句话;只补了 //! 引文、条数没变,来源帧也再发一次 //! - 原句里没有的日期在行上说出来(#970):时间线推出来的终点写出关上它的那一行、带那一行 -//! 的号,人改过的区间写出修正备注、不带号;本行的号照旧。对话与 MCP 一样(MCP 不带号), -//! 规则 4 说推出的终点引关它的那一行,两种日期都不归到本行的原句 +//! 的号(那一行没有有效证据时不带号,清单里也不多一条),人改过的区间写出修正备注、不带号; +//! 本行的号照旧。对话与 MCP 一样(MCP 不带号),规则 4 说推出的终点引关它的那一行,两种 +//! 日期都不归到本行的原句 //! //! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 use super::*; @@ -625,7 +626,8 @@ async fn a_quote_added_to_a_search_hit_is_published() -> anyhow::Result<()> { /// #970 的两处:周七 2025-09-01 接手,时间线把李四那一段关在那天;人把张三的起点从章程的 /// 2023-01-10 改成 2023-02-01。两行的号仍打开它们读出来的原句,而原句里没有这两个日期, -/// 所以行上说出日期从哪来 +/// 所以行上说出日期从哪来。周七那篇删掉以后,关上李四那段的那一行没有有效证据:行上照旧 +/// 写出是他接手,不带号 #[tokio::test] async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<()> { let Some(f) = fixture(Scripted::new(vec![])).await? else { @@ -845,6 +847,44 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<() .contains("2023-02-01 → 2024-07-01, corrected: The handover was on 1 July)"), "{text}" ); + + // 关上李四那段的那一行没有有效证据了(周七那篇删了,#986 的评审):行上照旧写出是 + // 谁接手,只是不带号,清单里也不多一条;本行的号照旧打开交接那篇 + sqlx::query("UPDATE documents SET deleted_at = now() WHERE id = $1") + .bind(d_update) + .execute(&f.pool) + .await?; + let mut sink = ToolSink::default(); + for tool in ["entity_facts", "neighbors", "timeline"] { + let text = dispatch(&chat, &mut sink, tool, &json!({"entity": "Project Aurora"})) + .await + .text; + let li_line = line(&text, "Li Si"); + assert!( + li_line.contains("2024-07-05 → 2025-09-01, superseded by Zhou Qi)"), + "{tool}: {text}" + ); + let l = marks(li_line); + assert_eq!(l.len(), 1, "{tool}: {text}"); + assert_eq!(chunk_of(&sink, l[0]), s.handover, "{tool}: the line's own passage"); + } + let path = dispatch( + &chat, + &mut sink, + "paths_between", + &json!({"from": "Wang Wu", "to": "Li Si"}), + ) + .await + .text; + assert!( + path.contains("2024-07-05 → 2025-09-01, superseded by Zhou Qi)"), + "{path}" + ); + assert!( + !sink.source_ids.contains(&update.to_string()), + "a closer without live evidence adds no entry: {:?}", + sink.sources + ); anyhow::Ok(()) } .await; 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 5c8031856..a2857ee0a 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 @@ -306,3 +306,108 @@ async fn a_fact_says_where_its_dates_came_from() -> anyhow::Result<()> { .await?; run } + +/// 关闭者的两种情形(#986 合并时的评审): +/// - 同一天开始的两个后任:时间线按 id 取最先的那一行关上前任,行上说的也是它; +/// - 关闭者自己后来又被下一任关上,改写成了新的一行:说的是活着的那一版,名字不变 +#[tokio::test] +async fn a_closer_is_the_row_the_timeline_closed_with() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let ids: Vec = (0..12).map(|_| Uuid::now_v7()).collect(); + let (org, ws, kb, person, project, leads) = (ids[0], ids[1], ids[2], ids[3], ids[4], ids[5]); + let (li, zhou, sun, qian, aurora, helios) = (ids[6], ids[7], ids[8], ids[9], ids[10], ids[11]); + // Only locally generated UUIDs are interpolated into fixture SQL. + sqlx::raw_sql(&format!( + "INSERT INTO organizations (id, name) VALUES ('{org}', 'closers'); + INSERT INTO workspaces (id, org_id, name) VALUES ('{ws}', '{org}', 'closers'); + INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ('{kb}', '{ws}', 'closers'); + 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, inverse_functional) + VALUES ('{leads}', '{kb}', 'leads', 'leads', 'state', TRUE); + INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES + ('{li}', '{kb}', '{person}', 'Li Si'), ('{zhou}', '{kb}', '{person}', 'Zhou Qi'), + ('{sun}', '{kb}', '{person}', 'Sun Ba'), ('{qian}', '{kb}', '{person}', 'Qian Qi'), + ('{aurora}', '{kb}', '{project}', 'Aurora'), ('{helios}', '{kb}', '{project}', 'Helios');" + )) + .execute(&pool) + .await?; + + let run = async { + let lead = |who: Uuid, project: Uuid, at: &'static str| { + let pool = pool.clone(); + async move { + graph::insert_fact(&pool, kb, who, Some(leads), project, since(at), 0.9) + .await + .map(|(id, _)| id) + } + }; + let live = |who: Uuid, project: Uuid| { + let pool = pool.clone(); + async move { + sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM facts WHERE kb_id = $1 AND subject_id = $2 AND object_id = $3 + AND invalidated_at IS NULL", + ) + .bind(kb) + .bind(who) + .bind(project) + .fetch_one(&pool) + .await + } + }; + + // Aurora:周七与孙八同一天接手。一个项目同时只有一个 lead,李四那段关在那一天, + // 关它的是 id 在前的周七那一行 + let li_a = lead(li, aurora, "2024-07-05").await?; + let zhou_a = lead(zhou, aurora, "2025-09-01").await?; + let sun_a = lead(sun, aurora, "2025-09-01").await?; + assert!(zhou_a < sun_a); + utopia_store::temporal::reconcile_moved_facts(&pool, kb, &[li_a, zhou_a, sun_a]).await?; + let (_, facts) = graph::entity_detail(&pool, kb, aurora, None, None).await?; + assert!(flags(&facts, "Li Si").0, "the timeline closed it"); + assert_eq!(fact_of(&facts, "Li Si").valid_to, Some(day("2025-09-01"))); + assert_eq!( + closer(&facts, "Li Si"), + (Some(zhou_a), Some("Zhou Qi".to_string())), + "the first of the two by id, as the engine orders them" + ); + + // Helios:周七接手之后又被钱七接手,周七那一行被关上、改写成新的一行。李四那段的 + // 关闭者是周七活着的那一版 + let li_h = lead(li, helios, "2024-07-05").await?; + let zhou_h = lead(zhou, helios, "2025-09-01").await?; + utopia_store::temporal::reconcile_moved_facts(&pool, kb, &[li_h, zhou_h]).await?; + let qian_h = lead(qian, helios, "2026-03-01").await?; + utopia_store::temporal::reconcile_moved_facts(&pool, kb, &[qian_h]).await?; + let zhou_now = live(zhou, helios).await?; + assert_ne!( + zhou_now, zhou_h, + "the closer was rewritten when Qian Qi took over" + ); + let (_, facts) = graph::entity_detail(&pool, kb, helios, None, None).await?; + assert_eq!(fact_of(&facts, "Zhou Qi").valid_to, Some(day("2026-03-01"))); + assert_eq!(fact_of(&facts, "Li Si").valid_to, Some(day("2025-09-01"))); + assert_eq!( + closer(&facts, "Li Si"), + (Some(zhou_now), Some("Zhou Qi".to_string())), + "the live version of the closer" + ); + assert_eq!( + closer(&facts, "Zhou Qi"), + (Some(qian_h), Some("Qian Qi".to_string())) + ); + 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_moment_marks_its_state.rs b/crates/utopia-store/tests/store/a_moment_marks_its_state.rs index a756b6a27..7f2634eb8 100644 --- a/crates/utopia-store/tests/store/a_moment_marks_its_state.rs +++ b/crates/utopia-store/tests/store/a_moment_marks_its_state.rs @@ -170,6 +170,16 @@ impl Base { /// 签名 (phrase, person, organization) 绑到 works_for,照原文方向,说那个日期标什么 async fn bind(&self, phrase: &str, marks: Option<&str>) -> anyhow::Result<()> { + self.bind_to(phrase, self.works_for, marks).await + } + + /// 同 [`Base::bind`],绑到这条属性 + async fn bind_to( + &self, + phrase: &str, + property: Uuid, + marks: Option<&str>, + ) -> anyhow::Result<()> { sqlx::query( "INSERT INTO phrase_bindings (id, kb_id, phrase, subject_type_id, object_type_id, object_is_value, @@ -183,7 +193,7 @@ impl Base { .bind(phrase) .bind(self.person) .bind(self.company) - .bind(self.works_for) + .bind(property) .bind(marks) .execute(&self.pool) .await?; @@ -1189,3 +1199,79 @@ async fn closing_a_simultaneous_conflict_at_its_own_start_is_refused() -> anyhow base.remove().await?; run } + +/// 人写的行不被第 0 步作废(#975 合并时补的守卫)。照它的来路造:属性原是事件,人写下一刻; +/// 一条只说了那一刻的陈述物化时并进这一行,留下来源链接,行却不因此变成算出来的 +/// (`from_statement_id` 仍空)。后来本体页上把属性改成状态:这一行两端相等、有一条两端同值 +/// 的来源陈述,可它是人写的,不作废。同一句话另算出的那一行(带 `from_statement_id`)照旧作废 +#[tokio::test] +async fn a_row_a_person_wrote_is_kept_when_a_single_date_merged_into_it() -> anyhow::Result<()> { + let Some(pool) = pool().await? else { + return Ok(()); + }; + let base = Base::new(&pool).await?; + let run = async { + let (by_hand, _) = graph::insert_fact( + &pool, + base.kb, + base.lin, + Some(base.acquired), + base.meridian, + moment(), + 0.9, + ) + .await?; + base.statement("signed with", Some(day()), Some(day())) + .await?; + let to_aster = base + .statement_to("signed with", base.aster, Some(day()), Some(day()), "day") + .await?; + base.bind_to("signed with", base.acquired, None).await?; + let outcome = materialize(&pool, base.kb).await?; + assert_eq!( + (outcome.added, outcome.merged), + (1, 1), + "one merged into the person's row, one computed: {outcome:?}" + ); + let computed: Uuid = + sqlx::query_scalar("SELECT fact_id FROM typed_fact_sources WHERE statement_id = $1") + .bind(to_aster) + .fetch_one(&pool) + .await?; + let (from_statement, links): (Option, i64) = sqlx::query_as( + "SELECT from_statement_id, + (SELECT count(*) FROM typed_fact_sources WHERE fact_id = f.id) + FROM facts f WHERE id = $1", + ) + .bind(by_hand) + .fetch_one(&pool) + .await?; + assert_eq!( + (from_statement, links), + (None, 1), + "the statement is a source of the person's row, which stays a person's" + ); + + // 本体页上把 acquired 改成状态 + sqlx::query("UPDATE relation_types SET temporal = 'state' WHERE id = $1") + .bind(base.acquired) + .execute(&pool) + .await?; + let outcome = materialize(&pool, base.kb).await?; + assert_eq!(outcome.retired, 1, "only the computed row: {outcome:?}"); + assert_eq!( + base.live(by_hand).await?, + (true, Some(day())), + "a row a person wrote is kept" + ); + assert_eq!( + base.live(computed).await?, + (false, Some(day())), + "the computed one is retired" + ); + anyhow::Ok(()) + } + .await; + base.remove().await?; + run +}