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
6 changes: 5 additions & 1 deletion crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,9 @@ pub struct GraphChange {
pub predicate_label: Option<String>,
pub object_name: Option<String>,
pub object_value: Option<serde_json::Value>,
/// corrected 事件的前值,来自 supersedes 指向的行;那次断言可能在查询窗口之外。
pub previous_object_name: Option<String>,
pub previous_object_value: Option<serde_json::Value>,
/// 这条断言说的是**世界轴**上的哪一段——与 `at` 正交,别读混
pub valid_from: Option<DateTime<Utc>>,
pub valid_to: Option<DateTime<Utc>>,
Expand Down Expand Up @@ -1394,7 +1397,8 @@ pub struct DerivedFactView {
pub predicate: String,
/// transitive | symmetric | inverse | sub_property,或 `business`(业务规则)
pub rule: String,
/// 业务规则的名字。公理推的为 None——公理没有名字,`rule` 那一列就是它的全部身份
/// 业务规则的名字,或公理的种类及声明谓词(如 `inverse of works_for`)。
/// 逆关系等公理的声明谓词可以不同于结论谓词,不能拿结论的谓词来充当解释。
pub rule_name: Option<String>,
/// 凭业务规则定义的哪一版推出的(0060),和那一版的定义本身。公理推的为 None
pub rule_version: Option<i32>,
Expand Down
15 changes: 9 additions & 6 deletions crates/utopia-server/src/api/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,8 +430,9 @@ pub(super) fn base_tools() -> serde_json::Value {
The best tool for who/when/history questions. Use after find_entities. \
Pass `at` to see the world as of that date (server-side filter) — \
always do this for \"who was X in <year/month>\" questions. \
Conclusions a business rule reached about this entity come back too, \
marked `[rule: <name>]` with the readings that made them true — take \
Conclusions from business rules and ontology axioms come back too, \
marked `[rule: <name>]`; an axiom names its defining property. \
The readings that made them true are included — take \
those as given rather than re-deriving them from the readings yourself. \
Output is grouped by predicate and cut at `limit`; use `offset` to continue. \
Narrow with predicate, object_type, since / until, or use timeline / neighbors / paths_between.",
Expand Down Expand Up @@ -524,12 +525,14 @@ pub(super) fn base_tools() -> serde_json::Value {
"type": "function",
"function": {
"name": "list_rules",
"description": "The business rules this base runs: what each one concludes and \
the exact conditions it tests, thresholds included. A rule is written by a \
person, and its conclusions are already in the graph — read the rule to \
"description": "The business rules and declared ontology axioms of this base. \
Business rules show what each one concludes and the exact conditions it \
tests, thresholds included. Ontology axioms name the defining properties \
and the relations they imply, and say when derivation is off for this base. Read the rule to \
explain WHY something was concluded, or to answer \"what counts as X here\". \
Do not re-implement a rule's comparison yourself; ask entity_facts or \
rule_matches for what it actually concluded.",
rule_matches for what it actually concluded. rule_matches takes business \
rule ids only.",
"parameters": { "type": "object", "properties": {} }
}
},
Expand Down
219 changes: 218 additions & 1 deletion crates/utopia-server/src/api/mcp_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1195,6 +1195,39 @@ async fn entity_facts_keeps_identity_values_filters_and_both_clocks() -> anyhow:
f.clean().await
}

#[tokio::test]
async fn changes_shows_the_superseded_value_even_outside_the_window() -> anyhow::Result<()> {
let Some(f) = Fixture::new().await? else {
return Ok(());
};
let previous_employer = Uuid::now_v7();
sqlx::query("INSERT INTO entities(id,kb_id,canonical_name) VALUES ($1,$2,'Former employer')")
.bind(previous_employer)
.bind(f.kb)
.execute(&f.state.pool)
.await?;
sqlx::query("UPDATE facts SET object_id=$1 WHERE id=$2")
.bind(previous_employer)
.bind(f.fact)
.execute(&f.state.pool)
.await?;

// 3 月 10 日的前一次断言既在窗口外,也被 kinds 排除,不能靠相邻事件找前值。
let args = json!({"since":"2026-03-20","until":"2026-03-20","kinds":["corrected"]});
let result = f.call("changes", args).await?;
let changes = result["structuredContent"]["changes"].as_array().unwrap();
assert_eq!(changes.len(), 1);
let text = result["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("Alice works for old: Former employer → new: Acme"),
"{text}"
);
assert_eq!(changes[0]["previous_object_name"], "Former employer");
assert_eq!(changes[0]["object_name"], "Acme");
assert_eq!(changes[0].get("previous_object_value"), Some(&Value::Null));
f.clean().await
}

#[tokio::test]
async fn entity_facts_pages_filtered_rows_without_repeating_time_ties() -> anyhow::Result<()> {
let Some(f) = Fixture::new().await? else {
Expand Down Expand Up @@ -2699,6 +2732,190 @@ async fn computed_rule_descriptions_keep_the_expression_tree_and_identity() -> a
f.clean().await
}

#[tokio::test]
async fn rule_reads_include_active_ontology_axioms_without_compiling_them() -> anyhow::Result<()> {
let Some(f) = Fixture::new().await? else {
return Ok(());
};
let result: anyhow::Result<()> = async {
let (employs, connected, mentors, foreign) = (
Uuid::now_v7(),
Uuid::now_v7(),
Uuid::now_v7(),
Uuid::now_v7(),
);
sqlx::raw_sql(&format!(
"INSERT INTO relation_types(id,kb_id,key,label,is_transitive,is_symmetric,sub_property_of)
VALUES ('{employs}','{kb}','employs','employs',false,false,NULL),
('{connected}','{kb}','connected_to','connected to',true,true,NULL),
('{mentors}','{kb}','mentors','mentors',false,false,'{connected}'),
('{foreign}','{other_kb}','foreign_axiom','Other base',true,false,NULL);
UPDATE relation_types SET inverse_of='{employs}'
WHERE kb_id='{kb}' AND key='works_for';",
kb = f.kb,
other_kb = f.other_kb,
))
.execute(&f.state.pool)
.await?;
let expected = [
"inverse of works_for: X works_for Y ⇒ Y employs X",
"inverse of employs: X employs Y ⇒ Y works_for X",
"symmetric of connected_to: X connected_to Y ⇒ Y connected_to X",
"transitive of connected_to: X connected_to Y AND Y connected_to Z ⇒ X connected_to Z",
"sub_property of mentors: X mentors Y ⇒ X connected_to Y",
];
let listed = f.call("list_rules", json!({})).await?;
assert_eq!(listed["isError"], false);
let text = listed["content"][0]["text"].as_str().unwrap();
assert!(text.contains("Weight rule"), "{text}");
assert!(
text.contains("Ontology axioms (from property declarations):"),
"{text}"
);
for line in expected {
assert!(text.contains(line), "{line} missing from {text}");
}
assert!(!text.contains("foreign_axiom"), "{text}");
// 夹具保留了一条旧对称规则,但它的声明已经撤销,不能把历史规则列成现行公理。
assert!(!text.contains("symmetric of works_for"), "{text}");
step_has(
&step_of(&f, &[], "list_rules", json!({})).await,
json!({"count":6}),
)?;
let compiled: i64 = sqlx::query_scalar("SELECT count(*) FROM rules WHERE kb_id=$1")
.bind(f.kb)
.fetch_one(&f.state.pool)
.await?;
assert_eq!(compiled, 1, "Reading must not compile the new axioms");

sqlx::query("DELETE FROM attribute_rules WHERE kb_id=$1")
.bind(f.kb)
.execute(&f.state.pool)
.await?;
let listed = f.call("list_rules", json!({})).await?;
let text = listed["content"][0]["text"].as_str().unwrap();
for line in expected {
assert!(text.contains(line), "{line} missing from {text}");
}
sqlx::query(
"UPDATE relation_types SET is_transitive=false,is_symmetric=false,
inverse_of=NULL,sub_property_of=NULL WHERE kb_id=$1",
)
.bind(f.kb)
.execute(&f.state.pool)
.await?;
let empty = f.call("list_rules", json!({})).await?;
assert_eq!(
empty["content"][0]["text"],
"This base has no business rules or active ontology axioms."
);
Ok(())
}
.await;
f.clean().await?;
result
}

#[tokio::test]
async fn rule_reads_mark_axioms_as_inactive_when_derivation_is_off() -> anyhow::Result<()> {
let Some(f) = Fixture::new().await? else {
return Ok(());
};
let result: anyhow::Result<()> = async {
sqlx::query("UPDATE knowledge_bases SET materialize_inferences=false WHERE id=$1")
.bind(f.kb)
.execute(&f.state.pool)
.await?;
sqlx::query(
"UPDATE relation_types SET is_transitive=true WHERE kb_id=$1 AND key='works_for'",
)
.bind(f.kb)
.execute(&f.state.pool)
.await?;
let listed = f.call("list_rules", json!({})).await?;
assert_eq!(listed["isError"], false);
let text = listed["content"][0]["text"].as_str().unwrap();
assert!(
text.contains(
"Ontology axioms (derivation is off for this base; nothing is derived from these):"
),
"{text}"
);
assert!(
text.contains(
"transitive of works_for: X works_for Y AND Y works_for Z ⇒ X works_for Z"
),
"{text}"
);
Ok(())
}
.await;
f.clean().await?;
result
}

#[tokio::test]
async fn derived_axioms_name_the_source_property_even_after_retirement() -> anyhow::Result<()> {
let Some(f) = Fixture::new().await? else {
return Ok(());
};
let result: anyhow::Result<()> = async {
sqlx::query("DELETE FROM attribute_rules WHERE kb_id=$1")
.bind(f.kb)
.execute(&f.state.pool)
.await?;
let employs = Uuid::now_v7();
sqlx::query(
"INSERT INTO relation_types(id,kb_id,key,label) VALUES ($1,$2,'employs','employs')",
)
.bind(employs)
.bind(f.kb)
.execute(&f.state.pool)
.await?;
sqlx::query("UPDATE relation_types SET inverse_of=$2 WHERE kb_id=$1 AND key='works_for'")
.bind(f.kb)
.bind(employs)
.execute(&f.state.pool)
.await?;
utopia_store::reasoning::materialize(&f.state.pool, f.kb).await?;
let recorded: chrono::DateTime<chrono::Utc> =
sqlx::query_scalar("SELECT clock_timestamp()")
.fetch_one(&f.state.pool)
.await?;
let current = f
.call("entity_facts", json!({"entity_id":f.object}))
.await?;
sqlx::query("UPDATE relation_types SET inverse_of=NULL WHERE kb_id=$1")
.bind(f.kb)
.execute(&f.state.pool)
.await?;
utopia_store::reasoning::materialize(&f.state.pool, f.kb).await?;
let historical = f
.call(
"entity_facts",
json!({"entity_id":f.object,"as_of":recorded}),
)
.await?;
for response in [current, historical] {
assert_eq!(response["isError"], false, "{response}");
let text = response["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("Acme · employs · Alice [rule: inverse of works_for]"),
"{text}"
);
assert!(response["structuredContent"]["derived_facts"]
.as_array()
.unwrap()
.iter()
.any(|d| d["predicate"] == "employs" && d["rule_name"] == "inverse of works_for"));
}
Ok(())
}
.await;
f.clean().await?;
result
}

#[tokio::test]
async fn rule_reads_preserve_matches_and_empty_results() -> anyhow::Result<()> {
let Some(f) = Fixture::new().await? else {
Expand Down Expand Up @@ -2737,7 +2954,7 @@ async fn rule_reads_preserve_matches_and_empty_results() -> anyhow::Result<()> {
assert_eq!(empty["isError"], false);
assert_eq!(
empty["content"][0]["text"],
"This base has no business rules."
"This base has no business rules or active ontology axioms."
);
f.clean().await
}
Expand Down
Loading
Loading