Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions crates/utopia-server/src/api/chat_graph_citation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
//! - 检索和图谱工具在一轮里共用一个号:图谱引到检索登过的块,号不变、补上那句话;只补了
//! 引文、条数没变,来源帧也再发一次
//! - 原句里没有的日期在行上说出来(#970):时间线推出来的终点写出关上它的那一行、带那一行
//! 的号,人改过的区间写出修正备注、不带号;本行的号照旧。对话与 MCP 一样(MCP 不带号),
//! 规则 4 说推出的终点引关它的那一行,两种日期都不归到本行的原句
//! 的号(那一行没有有效证据时不带号,清单里也不多一条),人改过的区间写出修正备注、不带号;
//! 本行的号照旧。对话与 MCP 一样(MCP 不带号),规则 4 说推出的终点引关它的那一行,两种
//! 日期都不归到本行的原句
//!
//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。
use super::*;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uuid> = (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
}
88 changes: 87 additions & 1 deletion crates/utopia-store/tests/store/a_moment_marks_its_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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?;
Expand Down Expand Up @@ -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<Uuid>, 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
}