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
2 changes: 1 addition & 1 deletion crates/utopia-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 87;
const CURRENT_SCHEMA_VERSION: u32 = 88;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
6 changes: 6 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,12 @@ pub struct EntityFact {
pub stale: bool,
/// 修正行(supersedes 链上):区间闭合来自引擎对账/人工裁决而非抽取原文
pub corrected: bool,
/// 终点是时间线推出来的(`facts.end_derived`):后一条事实开始时引擎把它关上,原文没说
/// 这一天(#970)
pub end_derived: bool,
/// 区间是人改过的:supersedes 链上有一条 `fact.time_corrected` 审计(#970)。与
/// `corrected` 不同,那一位是任何一次改写:自动接续、审阅裁决、人手改
pub time_corrected: bool,
/// 证据集合里最新的文档时间——开放事实的"最后确认时间"(时效性透明化)
pub last_evidence_time: Option<DateTime<Utc>>,
/// 有争议(0017 §3):`{ kind, ref_id, derived? }`——哪一种(违规的 kind,或
Expand Down
3 changes: 3 additions & 0 deletions crates/utopia-server/src/api/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,9 @@ 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] \
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.";

Expand Down
156 changes: 156 additions & 0 deletions crates/utopia-server/src/api/chat_graph_citation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
//! 几句;块被新版本取代后换成仍有效的那一块,`as_of` 回到取代之前又是原来那块
//! - 检索和图谱工具在一轮里共用一个号:图谱引到检索登过的块,号不变、补上那句话;只补了
//! 引文、条数没变,来源帧也再发一次
//! - 原句里没有的日期在行上说出来(#970):时间线推出来的终点写 `end derived`,人改过的
//! 区间写 `corrected`,号照旧;对话与 MCP 一样,规则 4 说不许把它们归到原句
//!
//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。
use super::*;
Expand Down Expand Up @@ -619,3 +621,157 @@ async fn a_quote_added_to_a_search_hit_is_published() -> anyhow::Result<()> {
f.cleanup().await?;
run
}

/// #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 {
return Ok(());
};
let run = async {
let s = seed(&f.pool, f.kb).await?;
let (leads, person, aurora): (Uuid, Uuid, Uuid) = sqlx::query_as(
"SELECT r.id, t.id, e.id FROM relation_types r, entity_types t, entities e
WHERE r.kb_id = $1 AND r.key = 'leads' AND t.kb_id = $1 AND t.key = 'person'
AND e.kb_id = $1 AND e.canonical_name = 'Project Aurora'",
)
.bind(f.kb)
.fetch_one(&f.pool)
.await?;
let row_of = |name: &'static str| {
let pool = f.pool.clone();
let kb = f.kb;
async move {
sqlx::query_scalar::<_, Uuid>(
"SELECT f.id FROM facts f JOIN entities e ON e.id = f.subject_id
WHERE f.kb_id = $1 AND e.canonical_name = $2 AND f.predicate_id = $3
AND f.invalidated_at IS NULL",
)
.bind(kb)
.bind(name)
.bind(leads)
.fetch_one(&pool)
.await
}
};
let (zhang, li) = (row_of("Zhang San").await?, row_of("Li Si").await?);
let (zhou, d_update, update, zhou_fact) =
(Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7());
// 插进去的只有本地生成的 UUID 和写死的日期
sqlx::raw_sql(&format!(
r#"
INSERT INTO entities(id,kb_id,type_id,canonical_name,created_at) VALUES
('{zhou}','{kb}','{person}','Zhou Qi','2026-01-01');
INSERT INTO documents(id,kb_id,filename,sha256,created_at,doc_time) VALUES
('{d_update}','{kb}','leadership-update.md',repeat('5',64),'2026-01-01','2025-09-01');
INSERT INTO chunks(id,kb_id,document_id,seq,text,created_at) VALUES
('{update}','{kb}','{d_update}',0,'Zhou Qi leads Project Aurora from 2025-09-01.','2026-01-01');
INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_id,valid_from,valid_from_precision,
recorded_at,confidence) VALUES
('{zhou_fact}','{kb}','{zhou}','{leads}','{aurora}','2025-09-01','day','2026-01-01',0.9);
INSERT INTO fact_evidence(fact_id,chunk_id,document_id,doc_version,quote) VALUES
('{zhou_fact}','{update}','{d_update}',1,'Zhou Qi leads Project Aurora from 2025-09-01.');
UPDATE relation_types SET inverse_functional = TRUE WHERE id = '{leads}';
"#,
kb = f.kb
))
.execute(&f.pool)
.await?;
// 一个项目同时只有一个 lead:对账把李四那一段关在周七接手那天
utopia_store::temporal::reconcile_moved_facts(&f.pool, f.kb, &[li, zhou_fact]).await?;
// 人改张三的起点,终点照旧;审计记在被改的那一行上(PATCH /facts/{id} 同一条路)
let day = |s: &str| s.parse::<chrono::DateTime<chrono::Utc>>().unwrap();
utopia_store::temporal::correct_interval(
&f.pool,
zhang,
utopia_store::graph::Validity {
from: Some(day("2023-02-01T00:00:00Z")),
from_precision: Some("day"),
to: Some(day("2024-07-05T00: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),
json!({ "note": "The charter date was the approval date" }),
)
.await?;

let chat = ctx(&f, None);
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 zhang_line = line(&text, "Zhang San");
assert!(
zhang_line.contains("2023-02-01 → 2024-07-05, corrected)"),
"{tool}: {text}"
);
let li_line = line(&text, "Li Si");
assert!(
li_line.contains("2024-07-05 → 2025-09-01, end derived)"),
"{tool}: {text}"
);
assert!(
line(&text, "Zhou Qi").contains("2025-09-01 → now)"),
"{tool}: {text}"
);
// 号照旧:打开的仍是那条事实读出来的原句
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,
&mut sink,
"paths_between",
&json!({"from": "Wang Wu", "to": "Li Si"}),
)
.await
.text;
assert!(
path.contains("2024-07-05 → 2025-09-01, end derived)"),
"{path}"
);

// MCP 的文字带着同样的标记:它们是这一行的事实,不是引用
let mcp = ctx(&f, Some(Uuid::now_v7()));
let plain = dispatch(
&mcp,
&mut ToolSink::default(),
"entity_facts",
&json!({"entity": "Project Aurora"}),
)
.await
.text;
assert!(
plain.contains(", corrected)") && plain.contains(", end derived)"),
"{plain}"
);
assert!(marks(&plain).is_empty(), "{plain}");

// 规则 4 说这两种日期不在原句里
assert!(
SYSTEM_PROMPT.contains("An end marked `end derived`")
&& SYSTEM_PROMPT.contains("a range marked `corrected`"),
"{SYSTEM_PROMPT}"
);
anyhow::Ok(())
}
.await;
f.cleanup().await?;
run
}
7 changes: 7 additions & 0 deletions crates/utopia-server/src/api/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1084,6 +1084,8 @@ pub(super) fn fact_line(f: &EntityFact) -> String {
to_precision: f.valid_to_precision.as_deref(),
holds_from: f.holds_from,
holds_to: f.holds_to,
end_derived: f.end_derived,
corrected: f.time_corrected,
});
let range = if range.is_empty() {
range
Expand Down Expand Up @@ -1249,6 +1251,9 @@ fn change_line(c: &GraphChange) -> String {
to_precision: c.valid_to_precision.as_deref(),
holds_from: None,
holds_to: None,
// 记录轴上的一次变更,不是事实行:它自己就说了改的是什么
end_derived: false,
corrected: false,
});
let range = if range.is_empty() {
range
Expand Down Expand Up @@ -1481,6 +1486,8 @@ mod tests {
evidence_count: 1,
stale: false,
corrected: false,
end_derived: false,
time_corrected: false,
last_evidence_time: None,
contested: None,
}
Expand Down
8 changes: 8 additions & 0 deletions crates/utopia-server/src/api/tools_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,8 @@ fn range_text(f: &EntityFact) -> String {
to_precision: f.valid_to_precision.as_deref(),
holds_from: f.holds_from,
holds_to: f.holds_to,
end_derived: f.end_derived,
corrected: f.time_corrected,
});
if range.is_empty() {
range
Expand Down Expand Up @@ -1133,6 +1135,8 @@ pub(super) fn edge_text(prev: Uuid, e: &PathEdge) -> String {
to_precision: e.valid_to_precision.as_deref(),
holds_from: e.holds_from,
holds_to: e.holds_to,
end_derived: e.end_derived,
corrected: e.time_corrected,
});
let range = if range.is_empty() {
range
Expand Down Expand Up @@ -1460,6 +1464,8 @@ mod tests {
evidence_count: 1,
stale: false,
corrected: false,
end_derived: false,
time_corrected: false,
last_evidence_time: None,
contested: None,
}
Expand Down Expand Up @@ -1587,6 +1593,8 @@ mod tests {
holds_from: Some("2021-01-01T00:00:00Z".parse().unwrap()),
holds_to: None,
confidence: 0.9,
end_derived: false,
time_corrected: false,
};
let p = Path {
nodes: vec![a, x, b],
Expand Down
Loading