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 = 101;
const CURRENT_SCHEMA_VERSION: u32 = 102;

fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
Expand Down
3 changes: 3 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,9 @@ pub struct EntityFact {
/// 终点是时间线推出来的(`facts.end_derived`):后一条事实开始时引擎把它关上,原文没说
/// 这一天(#970)
pub end_derived: bool,
/// `valid_to` 仍是存的边界;true 说原文讲的是在它之前结束的那个时段(`facts.end_after_period`,
/// #994):「2024 年离开」存 2025-01-01,原文说的是 2024
pub end_after_period: bool,
/// 区间是人改过的(#970):`corrected_ends` 有值。与 `corrected` 不同,那一位是任何一次
/// 改写:自动接续、审阅裁决、人手改
pub time_corrected: bool,
Expand Down
2 changes: 2 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 @@ -696,6 +696,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<()
to_precision: Some("day"),
attested_at: None,
from_grade: None,
end_after_period: false,
},
)
.await?
Expand Down Expand Up @@ -820,6 +821,7 @@ async fn a_fact_line_says_when_a_date_is_not_the_passages() -> anyhow::Result<()
to_precision: Some("day"),
attested_at: None,
from_grade: None,
end_after_period: false,
},
)
.await?
Expand Down
84 changes: 76 additions & 8 deletions crates/utopia-server/src/api/graph_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ use crate::auth::AuthUser;
use crate::error::ApiResult;
use crate::state::AppState;

#[cfg(test)]
#[path = "graph_routes_time_tests.rs"]
mod time_tests;

pub(super) async fn require_kb(
state: &AppState,
user: &utopia_core::models::User,
Expand Down Expand Up @@ -273,7 +277,8 @@ pub async fn update_entity(

/// 一条事实的新有效区间。**整体替换,不是部分更新**:区间的两端互相定义,
/// 「把结束端清空」与「这次不动结束端」必须能分辨,而缺字段与 null 在 JSON 里
/// 分不开。界面提交的是表单里的四个值,不是差异。
/// 分不开。界面提交的是表单里的四个值,不是差异。`end_after_period` 是例外:缺省即
/// 保留,见那一格。
#[derive(Deserialize)]
pub struct FactTimePatch {
#[serde(default)]
Expand All @@ -289,6 +294,13 @@ pub struct FactTimePatch {
/// 这里升格成字段
#[serde(default)]
pub note: Option<String>,
/// 终点是不是原文所说时段之后的那一刻(`facts.end_after_period`,#994)。**唯一一个
/// 缺省即保留的字段**:读出来什么就发回什么的客户端(不认识这个字段的也一样)不该
/// 改掉它。不给时,终点(截到精度之后的值与精度)没变、起点还在,就留着存的标记;清掉
/// 起点的保留时刻、丢掉标记(没有起点的行第一刀不标);别的终点照给的收下。路由从不
/// 换算:存的、收的都是边界本身,按时段显示与换算是界面的事
#[serde(default)]
pub end_after_period: Option<bool>,
}

// 梯子只有一张(0024):与数据库的 CHECK、抽取端、显示端同一列
Expand All @@ -301,9 +313,21 @@ struct FactInterval {
valid_from_precision: Option<String>,
valid_to: Option<chrono::DateTime<chrono::Utc>>,
valid_to_precision: Option<String>,
end_after_period: bool,
/// 谓词声明成状态:标记只能落在这样的行上
declared_state: bool,
}

/// 显式要标记,却不是一个能带它的终点
fn bad_end_after_period() -> AppError {
AppError::invalid(
"bad_end_after_period",
"An end that names a period needs a state property, a start and an end date.",
)
}

/// 复刻 `facts` 表的两条 CHECK,外加一条数据库没有的:起点不能晚于终点。
/// 复刻 `facts` 表的 CHECK:两端的精度,标记要两端带得了它(`period_mark_fits`,属性是不是
/// 状态读到行之后再查);外加一条数据库没有的:截到各自精度之后,起点不能晚于终点。
///
/// 在这里挡住是为了让错误说人话。交给数据库挡会得到一句约束名,而这三态
/// (仍在持续 / 结束了不知哪天 / 某日结束)本来就是账本里最容易记混的地方。
Expand All @@ -330,8 +354,19 @@ fn check_interval(p: &FactTimePatch) -> Result<(), AppError> {
))
}
}
// 显式要标记:两端得带得了它。属性是不是状态要读到行才知道,这里先当它是,只看两端
if p.end_after_period == Some(true)
&& !utopia_store::graph::period_mark_fits(true, p.valid_from, p.valid_to, false)
{
return Err(bad_end_after_period());
}
// 比截到各自精度之后的两端:存下的就是截过的。比原值时,小时精度的 08:30 终点与分钟精度
// 的 08:10 起点放得过去,截完成了 08:10 → 08:00,存下一段倒着的区间
let truncate = utopia_store::graph::truncate_to;
if let (Some(from), Some(to)) = (p.valid_from, p.valid_to) {
if from > to {
if truncate(from, p.valid_from_precision.as_deref())
> truncate(to, p.valid_to_precision.as_deref())
{
return Err(AppError::invalid(
"interval_inverted",
"The start of the interval is after its end.",
Expand Down Expand Up @@ -360,9 +395,12 @@ pub async fn update_fact_time(
// 归属与状态校验:本 KB 的、未作废的事实才可改。派生事实不在此列——
// 它的区间来自前提,改它等于改一个算出来的值,下一轮推理就会覆盖
let before: Option<FactInterval> = sqlx::query_as(
"SELECT valid_from, valid_from_precision, valid_to, valid_to_precision
FROM facts
WHERE id = $1 AND kb_id = $2 AND invalidated_at IS NULL AND derived_by_rule IS NULL",
"SELECT f.valid_from, f.valid_from_precision, f.valid_to, f.valid_to_precision,
f.end_after_period, COALESCE(r.temporal = 'state', false) AS declared_state
FROM facts f
LEFT JOIN relation_types r ON r.id = f.predicate_id
WHERE f.id = $1 AND f.kb_id = $2 AND f.invalidated_at IS NULL
AND f.derived_by_rule IS NULL",
)
.bind(fact_id)
.bind(kb_id)
Expand All @@ -372,12 +410,39 @@ pub async fn update_fact_time(
let Some(before) = before else {
return Err(AppError::NotFound.into());
};
// 存下的是截到精度之后的值:比较、判标记都按它
let truncate = utopia_store::graph::truncate_to;
let from = req
.valid_from
.map(|t| truncate(t, req.valid_from_precision.as_deref()));
let to = req
.valid_to
.map(|t| truncate(t, req.valid_to_precision.as_deref()));
// 读到了行,属性是不是状态也知道了:显式要的标记按整条规矩再判一遍
if req.end_after_period == Some(true)
&& !utopia_store::graph::period_mark_fits(before.declared_state, from, to, false)
{
return Err(bad_end_after_period().into());
}
// 不给标记:发回存的终点(时刻与精度)、这次的两端还带得了它,就是没动它,留着;清掉起点
// 的带不了,丢掉(时刻照旧)。属性这里不再判:存的标记是按当时的属性写下的,原样发回的
// 请求不改写这一行
let end_after_period = match req.end_after_period {
Some(mark) => mark,
None => {
before.end_after_period
&& to == before.valid_to
&& req.valid_to_precision == before.valid_to_precision
&& utopia_store::graph::period_mark_fits(true, from, to, false)
}
};
// 一字未改就不写一条修正进账本:History 上多一行「改过」而区间没动,
// 读者会去找那个不存在的差异
if before.valid_from == req.valid_from
if before.valid_from == from
&& before.valid_from_precision == req.valid_from_precision
&& before.valid_to == req.valid_to
&& before.valid_to == to
&& before.valid_to_precision == req.valid_to_precision
&& before.end_after_period == end_after_period
{
return Ok(Json(json!({ "ok": true, "unchanged": true })));
}
Expand All @@ -393,6 +458,7 @@ pub async fn update_fact_time(
// 修正行从被替代的行继承锚点(correct_interval 的 SQL 里):改区间是重述
// 同一份证据,不是更新的证据
attested_at: None,
end_after_period,
};
let Some(corrected) =
utopia_store::temporal::correct_interval(&state.pool, fact_id, validity).await?
Expand All @@ -417,12 +483,14 @@ pub async fn update_fact_time(
"valid_from_precision": before.valid_from_precision,
"valid_to": before.valid_to.map(|t| t.to_rfc3339()),
"valid_to_precision": before.valid_to_precision,
"end_after_period": before.end_after_period,
});
d["to"] = json!({
"valid_from": req.valid_from.map(|t| t.to_rfc3339()),
"valid_from_precision": req.valid_from_precision,
"valid_to": req.valid_to.map(|t| t.to_rfc3339()),
"valid_to_precision": req.valid_to_precision,
"end_after_period": end_after_period,
});
if let Some(note) = req.note.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
d["note"] = json!(note);
Expand Down
168 changes: 168 additions & 0 deletions crates/utopia-server/src/api/graph_routes_time_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//! 改区间的路由收发的是存的边界(#994):`end_after_period` 是唯一一个缺省即保留的字段。
//!
//! 实体详情读得到标记;不带它的请求,终点(时刻与精度)没动、起点还在就留着,改了终点或清掉
//! 起点就丢掉(时刻照旧);显式的值照收,只改标记也算改了终点;显式 true 得是状态属性、有起点、
//! 终点有日期;倒置按截到精度之后的两端判。每一步的结果收进一张表一起比,dev 上哪几步不同一次
//! 看全。没有 `UTOPIA_DATABASE_URL` 时跳过。
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Router;
use chrono::{DateTime, Utc};
use serde_json::{json, Value};
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;

const MARCH: &str = "2024-03-01T00:00:00Z";
const FEB: &str = "2024-02-01T00:00:00Z";
const END: &str = "2025-01-01T00:00:00Z";
const LATER: &str = "2026-01-01T00:00:00Z";

async fn send(
app: &Router,
token: &str,
method: &str,
uri: String,
body: &Value,
) -> anyhow::Result<(StatusCode, Value)> {
let response = app
.clone()
.oneshot(
Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body.to_string()))?,
)
.await?;
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await?;
Ok((status, serde_json::from_slice(&bytes)?))
}

/// 一个请求体:起点日精度、终点年精度,空串是没有;标记给了才带
fn span(from: &str, to: &str, mark: Option<bool>) -> Value {
let from = Some(from).filter(|t| !t.is_empty());
let to = Some(to).filter(|t| !t.is_empty());
let mut body = json!({
"valid_from": from, "valid_from_precision": from.map(|_| "day"),
"valid_to": to, "valid_to_precision": to.map(|_| "year"),
});
if let Some(mark) = mark {
body["end_after_period"] = json!(mark);
}
body
}

#[tokio::test]
async fn a_correction_keeps_the_mark_unless_the_request_changes_it() -> anyhow::Result<()> {
let Some(url) = utopia_store::test_db::url() else {
return Ok(());
};
let pool = sqlx::PgPool::connect(&url).await?;
utopia_store::db::migrate(&pool).await?;
let [org, ws, kb, editor, works_for, lin, meridian, marked, loose] =
std::array::from_fn(|_| Uuid::now_v7());
// Only locally generated UUIDs and the constants above are interpolated into fixture SQL.
sqlx::raw_sql(&format!(
"INSERT INTO organizations(id,name) VALUES ('{org}','fact-time-route');
INSERT INTO workspaces(id,org_id,name) VALUES ('{ws}','{org}','fact-time-route');
INSERT INTO users(id,org_id,email,display_name,password_hash)
VALUES ('{editor}','{org}','{editor}@time.test','editor','unused');
INSERT INTO knowledge_bases(id,workspace_id,name) VALUES ('{kb}','{ws}','times');
INSERT INTO kb_members(kb_id,user_id,role) VALUES ('{kb}','{editor}','editor');
INSERT INTO relation_types(id,kb_id,key,label,temporal)
VALUES ('{works_for}','{kb}','works_for','works for','state');
INSERT INTO entities(id,kb_id,canonical_name)
VALUES ('{lin}','{kb}','Lin Zhao'), ('{meridian}','{kb}','Meridian Systems');
-- 「left in 2024」关上的一行,记着说的是 2024;同样的区间、没有谓词的一行
INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_id,layer,confidence,
valid_from,valid_from_precision,valid_to,valid_to_precision,
end_after_period) VALUES
('{marked}','{kb}','{lin}','{works_for}','{meridian}','typed',0.9,
'{MARCH}','day','{END}','year',TRUE),
('{loose}','{kb}','{lin}',NULL,'{meridian}','typed',0.9,
'{MARCH}','day','{END}','year',FALSE);"
))
.execute(&pool)
.await?;
let result = async {
let dir = tempfile::tempdir()?;
let config = utopia_core::config::AppConfig {
data_dir: dir.path().to_string_lossy().into_owned(),
..Default::default()
};
let search = Arc::new(utopia_search::SearchIndex::open(&dir.path().join("search"))?);
let state = crate::state::AppState::new(pool.clone(), &config, search, "test-only".into());
let token = crate::auth::issue_token(&state, editor)?;
let app = crate::api::router(state, &config);

let detail = format!("/api/v1/kbs/{kb}/entities/{lin}");
let (_, detail) = send(&app, &token, "GET", detail, &Value::Null).await?;
let mut read = detail["facts"].as_array().into_iter().flatten();
let read = read.find(|x| x["id"] == json!(marked));
let mut seen = vec![("entity detail", json!(read.map(|x| &x["end_after_period"])))];
let mut wanted = vec![("entity detail", json!(true))];
// 每一步:改哪一行(None 是上一步写下的那行)、请求体、要的结果——写下的那一行(标记、
// 人改过哪一端、终点),或拒绝的代码。不带标记的:只改起点留着,改了终点的时刻或精度、
// 清掉起点都丢掉;显式的照收,只改标记把终点那一端记成人改的;显式 true 缺起点、缺终点、
// 不是状态的都不收
let bad = json!([422, "bad_end_after_period"]);
let steps = [
("start only", None, span(FEB, END, None), json!([true, "start", END])),
("mark false", None, span(FEB, END, Some(false)), json!([false, "both", END])),
("mark true", None, span(FEB, END, Some(true)), json!([true, "both", END])),
("new end", None, span(FEB, LATER, None), json!([false, "both", LATER])),
("mark true again", None, span(FEB, END, Some(true)), json!([true, "both", END])),
(
"precision only",
None,
json!({"valid_from": FEB, "valid_from_precision": "day",
"valid_to": END, "valid_to_precision": "month"}),
json!([false, "both", END]),
),
("marked once more", None, span(FEB, END, Some(true)), json!([true, "both", END])),
("start cleared", None, span("", END, None), json!([false, "both", END])),
("true, no start", None, span("", END, Some(true)), bad.clone()),
("true, no end", None, span(FEB, "", Some(true)), bad.clone()),
("true, no state", Some(loose), span(MARCH, END, Some(true)), bad),
// 小时精度的 08:30 是 08:00,早于分钟精度的起点 08:10
(
"inverted once truncated",
Some(loose),
json!({"valid_from": "2026-06-01T08:10:00Z", "valid_from_precision": "minute",
"valid_to": "2026-06-01T08:30:00Z", "valid_to_precision": "hour"}),
json!([422, "interval_inverted"]),
),
];
let mut fact = marked;
for (label, target, body, want) in steps {
let uri = format!("/api/v1/kbs/{kb}/facts/{}", target.unwrap_or(fact));
let (status, reply) = send(&app, &token, "PATCH", uri, &body).await?;
let got = match reply["fact_id"].as_str() {
Some(id) => {
fact = id.parse()?;
let row: (bool, Option<String>, Option<DateTime<Utc>>) = sqlx::query_as(
"SELECT end_after_period, corrected_ends, valid_to FROM facts WHERE id = $1",
)
.bind(fact)
.fetch_one(&pool)
.await?;
json!(row)
}
None => json!([status.as_u16(), reply["code"]]),
};
seen.push((label, got));
wanted.push((label, want));
}
assert_eq!(seen, wanted);
anyhow::Ok(())
}
.await;
sqlx::query("DELETE FROM organizations WHERE id = $1")
.bind(org)
.execute(&pool)
.await?;
result
}
1 change: 1 addition & 0 deletions crates/utopia-server/src/api/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,7 @@ mod tests {
stale: false,
corrected: false,
end_derived: false,
end_after_period: false,
time_corrected: false,
corrected_ends: None,
closed_by_id: None,
Expand Down
1 change: 1 addition & 0 deletions crates/utopia-server/src/api/tools_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1500,6 +1500,7 @@ mod tests {
stale: false,
corrected: false,
end_derived: false,
end_after_period: false,
time_corrected: false,
corrected_ends: None,
closed_by_id: None,
Expand Down
Loading
Loading