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 = 89;
const CURRENT_SCHEMA_VERSION: u32 = 90;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
15 changes: 13 additions & 2 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -842,9 +842,20 @@ 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<String>,
/// 时间线把它关上时接的那一行(#970 第二步):只在 `end_derived`、而且找得到时有
pub closed_by_id: Option<Uuid>,
/// 接的那一行变了的那一端:接任者,或新的宾语实体的名字
pub closed_by: Option<String>,
/// 接的那一行的新值(属性事实)
pub closed_by_value: Option<serde_json::Value>,
/// 人改区间时写下的备注:链上最近那一次修正的
pub correction_note: Option<String>,
/// 证据集合里最新的文档时间——开放事实的"最后确认时间"(时效性透明化)
pub last_evidence_time: Option<DateTime<Utc>>,
/// 有争议(0017 §3):`{ kind, ref_id, derived? }`——哪一种(违规的 kind,或
Expand Down
9 changes: 6 additions & 3 deletions crates/utopia-server/src/api/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -656,9 +656,12 @@ 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. \
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 \
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.";

Expand Down
108 changes: 92 additions & 16 deletions crates/utopia-server/src/api/chat_graph_citation_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
//! 几句;块被新版本取代后换成仍有效的那一块,`as_of` 回到取代之前又是原来那块
//! - 检索和图谱工具在一轮里共用一个号:图谱引到检索登过的块,号不变、补上那句话;只补了
//! 引文、条数没变,来源帧也再发一次
//! - 原句里没有的日期在行上说出来(#970):时间线推出来的终点写 `end derived`,人改过的
//! 区间写 `corrected`,号照旧;对话与 MCP 一样,规则 4 说不许把它们归到原句
//! - 原句里没有的日期在行上说出来(#970):时间线推出来的终点写出关上它的那一行、带那一行
//! 的号,人改过的区间写出修正备注、不带号;本行的号照旧。对话与 MCP 一样(MCP 不带号),
//! 规则 4 说推出的终点引关它的那一行,两种日期都不归到本行的原句
//!
//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。
use super::*;
Expand Down Expand Up @@ -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, start 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, superseded by Zhou Qi ["),
"{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,
Expand All @@ -743,7 +760,22 @@ 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, superseded by Zhou Qi ["),
"{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}"
);

Expand All @@ -758,17 +790,61 @@ 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(", start corrected: The charter date was the approval date)")
&& plain.contains(", superseded by Zhou Qi)"),
"{plain}"
);
assert!(marks(&plain).is_empty(), "{plain}");

// 规则 4 说这两种日期不在原句里
// 规则 4:推出的终点引关它的那一行;推出的与人改的日期都不归到本行的原句,没标记的
// 那个日期仍是
assert!(
SYSTEM_PROMPT.contains("An end marked `end derived`")
&& SYSTEM_PROMPT.contains("a range marked `corrected`"),
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"),
"{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;
Expand Down
51 changes: 39 additions & 12 deletions crates/utopia-server/src/api/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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` 一致
Expand Down Expand Up @@ -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(),
Expand All @@ -1085,7 +1097,9 @@ pub(super) fn fact_line(f: &EntityFact) -> 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(),
});
let range = if range.is_empty() {
range
Expand Down Expand Up @@ -1253,7 +1267,9 @@ fn change_line(c: &GraphChange) -> String {
holds_to: None,
// 记录轴上的一次变更,不是事实行:它自己就说了改的是什么
end_derived: false,
corrected: false,
corrected: None,
closed_by: None,
correction_note: None,
});
let range = if range.is_empty() {
range
Expand Down Expand Up @@ -1488,6 +1504,11 @@ mod tests {
corrected: false,
end_derived: false,
time_corrected: false,
corrected_ends: None,
closed_by_id: None,
closed_by: None,
closed_by_value: None,
correction_note: None,
last_evidence_time: None,
contested: None,
}
Expand All @@ -1497,18 +1518,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}");
}

Expand All @@ -1517,7 +1540,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()` 会把字符串连引号一起印出来,
Expand Down
Loading