From 51fbcf7f268f219d309d706b9d7b8e8c238a7c71 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 25 Oct 2026 01:40:35 +0800 Subject: [PATCH 1/4] A correction labels old and new values and includes both in structured changes (#1348) (#1350) * fix: label correction values so changes cannot read as reversals Signed-off-by: Aschenbath * A correction exposes its previous value in structured changes so callers can tell the direction Signed-off-by: Aschenbath --------- Signed-off-by: Aschenbath Co-authored-by: Aschenbath --- crates/utopia-core/src/models.rs | 3 ++ crates/utopia-server/src/api/mcp_tests.rs | 33 +++++++++++++++++++++ crates/utopia-server/src/api/tools.rs | 35 ++++++++++++++++++++++- crates/utopia-store/src/graph.rs | 11 +++++-- web/src/docs/mcp.md | 8 +++++- 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 0178d60a6..9f71ab05c 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -967,6 +967,9 @@ pub struct GraphChange { pub predicate_label: Option, pub object_name: Option, pub object_value: Option, + /// corrected 事件的前值,来自 supersedes 指向的行;那次断言可能在查询窗口之外。 + pub previous_object_name: Option, + pub previous_object_value: Option, /// 这条断言说的是**世界轴**上的哪一段——与 `at` 正交,别读混 pub valid_from: Option>, pub valid_to: Option>, diff --git a/crates/utopia-server/src/api/mcp_tests.rs b/crates/utopia-server/src/api/mcp_tests.rs index 173069cf3..1fa9e2b97 100644 --- a/crates/utopia-server/src/api/mcp_tests.rs +++ b/crates/utopia-server/src/api/mcp_tests.rs @@ -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 { diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index b64eda518..846d09876 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -893,6 +893,8 @@ pub async fn changes(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult "subject_id": r.subject_id, "subject_name": r.subject_name, "predicate_label": r.predicate_label, "object_name": r.object_name, "object_value": r.object_value, "confidence": r.confidence, + "previous_object_name": r.previous_object_name, + "previous_object_value": r.previous_object_value, "valid_from": r.valid_from, "valid_to": r.valid_to, "valid_from_precision": r.valid_from_precision, "valid_to_precision": r.valid_to_precision, @@ -1314,11 +1316,21 @@ fn changes_window( fn change_line(c: &GraphChange) -> String { // 字面值宾语与 `fact_line` 同一条规则(`literal_text`):两个工具看到的 // 不能是两种写法,否则 `{value, unit}` 在这里会印成一段 JSON - let object = match (&c.object_name, &c.object_value) { + let object_text = |name: &Option, value: &Option| match (name, value) + { (Some(name), _) => name.clone(), (None, Some(v)) => literal_text(v).unwrap_or_else(|| "?".to_string()), (None, None) => "?".to_string(), }; + let object = object_text(&c.object_name, &c.object_value); + let object = if c.kind == "corrected" { + format!( + "old: {} → new: {object}", + object_text(&c.previous_object_name, &c.previous_object_value) + ) + } else { + object + }; // 世界轴两端按自己的精度写(time_text);事件行上没有锚点,结束未知只能写成话 let range = crate::time_text::span(crate::time_text::Span { valid_from: c.valid_from, @@ -1429,6 +1441,25 @@ mod tests { // --- change_line -------------------------------------------------------- + #[test] + fn a_correction_labels_the_old_value_before_the_new_value() { + let mut c = change("corrected"); + c.subject_name = "acme-api".into(); + c.predicate_label = Some("owned by".into()); + c.previous_object_name = Some("team-a".into()); + c.object_name = Some("team-b".into()); + assert_eq!( + change_line(&c), + "2026-08-28T10:00:00Z corrected: acme-api owned by old: team-a → new: team-b" + ); + + c.previous_object_name = None; + c.object_name = None; + c.previous_object_value = Some(serde_json::json!({"value": 7, "unit": "kg"})); + c.object_value = Some(serde_json::json!({"value": 8, "unit": "kg"})); + assert!(change_line(&c).ends_with("old: 7 kg → new: 8 kg")); + } + /// 年精度的起点写成年,不再印成 1 月 1 日;结束了不知哪天绝不写 now #[test] fn a_change_line_shows_each_end_at_its_precision() { @@ -1469,6 +1500,8 @@ mod tests { predicate_label: Some("founded in".to_string()), object_name: None, object_value: None, + previous_object_name: None, + previous_object_value: None, valid_from: None, valid_to: None, // 两端都没日期就没有精度——夹具也得守这条不变量(见 `facts.valid_from_precision`) diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index e96a58540..d30f1d251 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -2510,7 +2510,7 @@ pub async fn graph_changes( // 一条 2 月写入、8 月被推翻的事实,在"3–4 月"窗口里两个事件都不该出现 const EVENTS: &str = " WITH ev AS ( - SELECT f.id, f.subject_id, f.predicate_id, f.object_id, f.object_value, + SELECT f.id, f.subject_id, f.predicate_id, f.object_id, f.object_value, f.supersedes, f.valid_from, f.valid_from_precision, f.valid_to, f.valid_to_precision, f.confidence, f.recorded_at AS at, CASE WHEN f.supersedes IS NULL THEN 'asserted' ELSE 'corrected' END AS kind @@ -2518,7 +2518,7 @@ pub async fn graph_changes( WHERE f.kb_id = $1 AND f.recorded_at >= $2 AND f.recorded_at < $3 AND ($4::uuid IS NULL OR f.subject_id = $4 OR f.object_id = $4) UNION ALL - SELECT f.id, f.subject_id, f.predicate_id, f.object_id, f.object_value, + SELECT f.id, f.subject_id, f.predicate_id, f.object_id, f.object_value, f.supersedes, f.valid_from, f.valid_from_precision, f.valid_to, f.valid_to_precision, f.confidence, f.invalidated_at AS at, CASE WHEN EXISTS (SELECT 1 FROM fact_adoptions fa @@ -2537,11 +2537,16 @@ pub async fn graph_changes( COALESCE(r.label, fact_surface_predicate(ev.id)) AS predicate_label, o.canonical_name AS object_name, ev.object_value, ev.valid_from, ev.valid_from_precision, ev.valid_to, ev.valid_to_precision, - ev.confidence, src.document_id, src.filename, src.quote, src.quote_origin + ev.confidence, src.document_id, src.filename, src.quote, src.quote_origin, + po.canonical_name AS previous_object_name, previous.object_value AS previous_object_value FROM ev LEFT JOIN relation_types r ON r.id = ev.predicate_id JOIN entities s ON s.id = ev.subject_id LEFT JOIN entities o ON o.id = ev.object_id + -- 前一次断言可能不在本次窗口里,不能靠相邻事件猜订正方向(#1348)。 + LEFT JOIN facts previous ON ev.kind = 'corrected' AND previous.id = ev.supersedes + AND previous.kb_id = $1 + LEFT JOIN entities po ON po.id = previous.object_id LEFT JOIN LATERAL ( SELECT d.id AS document_id, d.filename, fe.quote, c.origin AS quote_origin FROM fact_evidence fe diff --git a/web/src/docs/mcp.md b/web/src/docs/mcp.md index aeb0e931e..d6292848a 100644 --- a/web/src/docs/mcp.md +++ b/web/src/docs/mcp.md @@ -115,7 +115,13 @@ is a successful read, not an error. | `find_entities` | `matched_entities`, `truncated`, `entities[]`, in the same ranked order as the text: `id`, `name`, `type_key`, `type_label`, `disambiguator`, `fact_count`. The count is for the partial-name lookup, or `null` when the fallback combined individual words without counting all matches. `truncated` reports the partial-name cap; this is a candidate list, not an exhaustive entity export | | `search_chunks` | `as_of`, `limit`, `limit_reached`, `chunks[]`. Each chunk has `chunk_id`, `document_id`, zero-based `seq`, `filename`, `text`, `truncated`. The text uses the same 800-character cutoff as the prose | | `entity_facts` | `entity` (`id`, `name`, `type_key`, `type_label`), effective `at`/`as_of`/`before`, `total_facts`, `matched_facts`, `limit`, `offset`, `next_offset`, `truncated`, `facts[]`, `derived_facts[]` | -| `changes` | Effective `since` (inclusive) and `until` (exclusive), `limit`, `offset`, `next_offset`, `limit_reached`, `changes[]`. Events carry `fact_id`, `at`, `kind`, `subject_id`, `subject_name`, `predicate_label`, `object_name`, `object_value`, `confidence`, `valid_from`, `valid_to`, both validity precisions, `document_id`, `filename`, `quote` | +| `changes` | Effective `since` (inclusive) and `until` (exclusive), `limit`, `offset`, `next_offset`, `limit_reached`, `changes[]`. Events carry `fact_id`, `at`, `kind`, `subject_id`, `subject_name`, `predicate_label`, `object_name`, `object_value`, `previous_object_name`, `previous_object_value`, `confidence`, `valid_from`, `valid_to`, both validity precisions, `document_id`, `filename`, `quote` | + +For a `corrected` event, `object_name` / `object_value` describe the new object; +`previous_object_name` / `previous_object_value` describe the superseded one, +even when that assertion lies outside the requested window. For other event +kinds, both previous fields are `null`. Value fields retain raw JSON, including +units. An asserted `facts[]` entry contains `id`, `direction`, `predicate_key`, `predicate_label`, `inferred`, `temporal`, `other_id`, `other_name`, `object_value`, From 050b0b30db2b540eddd0a5f54a4a84e8791b1820 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 25 Oct 2026 01:46:01 +0800 Subject: [PATCH 2/4] list_rules lists ontology axioms with their derivation status, and a derived fact names the axiom's property (#1347) (#1387) * fix: show the ontology axioms behind derived facts Signed-off-by: Aschenbath * list_rules lists ontology axioms with their derivation status, and a derived fact names the axiom's property (#1347) Signed-off-by: Aschenbath --------- Signed-off-by: Aschenbath Co-authored-by: Aschenbath Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com> --- crates/utopia-core/src/models.rs | 3 +- crates/utopia-server/src/api/chat.rs | 15 +- crates/utopia-server/src/api/mcp_tests.rs | 186 +++++++++++++++++++++- crates/utopia-server/src/api/tools.rs | 51 +++++- crates/utopia-store/src/reasoning.rs | 80 ++++++++-- docs/design/rules.md | 9 +- web/src/docs/mcp.md | 8 + 7 files changed, 323 insertions(+), 29 deletions(-) diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 9f71ab05c..8eab5f642 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -1378,7 +1378,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, /// 凭业务规则定义的哪一版推出的(0060),和那一版的定义本身。公理推的为 None pub rule_version: Option, diff --git a/crates/utopia-server/src/api/chat.rs b/crates/utopia-server/src/api/chat.rs index 13c0f9a85..37607464d 100644 --- a/crates/utopia-server/src/api/chat.rs +++ b/crates/utopia-server/src/api/chat.rs @@ -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 \" questions. \ - Conclusions a business rule reached about this entity come back too, \ - marked `[rule: ]` with the readings that made them true — take \ + Conclusions from business rules and ontology axioms come back too, \ + marked `[rule: ]`; 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.", @@ -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": {} } } }, diff --git a/crates/utopia-server/src/api/mcp_tests.rs b/crates/utopia-server/src/api/mcp_tests.rs index 1fa9e2b97..ca6f3e280 100644 --- a/crates/utopia-server/src/api/mcp_tests.rs +++ b/crates/utopia-server/src/api/mcp_tests.rs @@ -2732,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 = + 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 { @@ -2770,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 } diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index 846d09876..05a5b3329 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -558,9 +558,19 @@ pub async fn list_rules(ctx: &ToolCtx<'_>) -> ToolResult { ) .error(); }; - if rules.is_empty() { + let Ok(axioms) = utopia_store::reasoning::active_axiom_rules(&ctx.state.pool, ctx.kb_id).await + else { + return ToolResult::new( + "Could not read the rules.".to_string(), + Step::new("tool", "list_rules", "failed") + .status("failed") + .json(), + ) + .error(); + }; + if rules.is_empty() && axioms.is_empty() { return ToolResult::new( - "This base has no business rules.".to_string(), + "This base has no business rules or active ontology axioms.".to_string(), Step::new("tool", "list_rules", "none").count(0).json(), ); } @@ -585,7 +595,7 @@ pub async fn list_rules(ctx: &ToolCtx<'_>) -> ToolResult { .error(); }; let mut descriptions = descriptions.into_iter(); - let text = rules + let mut text = rules .iter() .map(|r| { let conditions = r["conditions"] @@ -668,7 +678,40 @@ pub async fn list_rules(ctx: &ToolCtx<'_>) -> ToolResult { }) .collect::>() .join("\n"); - let n = rules.len(); + if rules.is_empty() { + text = "This base has no business rules.".to_string(); + } + if !axioms.is_empty() { + // 声明存在不等于本库正在据此派生;关闭开关时仍列定义,但必须说明没有执行。 + let Ok(kb) = utopia_store::kbs::get(&ctx.state.pool, ctx.kb_id).await else { + return ToolResult::new( + "Could not read the rules.".to_string(), + Step::new("tool", "list_rules", "failed") + .status("failed") + .json(), + ) + .error(); + }; + text.push_str(if kb.materialize_inferences { + "\n\nOntology axioms (from property declarations):\n" + } else { + "\n\nOntology axioms (derivation is off for this base; nothing is derived from these):\n" + }); + let lines: Vec<_> = axioms + .iter() + .map(|a| { + let (p, q) = (&a.predicate, &a.target); + let conclusion = match a.kind { + "transitive" => format!("X {p} Y AND Y {p} Z ⇒ X {p} Z"), + "symmetric" | "inverse" => format!("X {p} Y ⇒ Y {q} X"), + _ => format!("X {p} Y ⇒ X {q} Y"), + }; + format!("{} of {p}: {conclusion}", a.kind) + }) + .collect(); + text.push_str(&lines.join("\n")); + } + let n = rules.len() + axioms.len(); ToolResult::new( text, Step::new("tool", "list_rules", format!("{n} rules")) diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 607b3f5bb..802e02a54 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -1448,19 +1448,7 @@ fn coarsest(a: Option<&str>, b: Option<&str>) -> Option { } } -/// 按本体公理重编译规则,返回 `(谓词, 种类) → 规则 id`。 -/// -/// **幂等**:身份取 `(kb, 谓词, 种类)`,重编译认得出「还是那条规则」——否则每跑 -/// 一次 `derived_facts.rule_id` 就指向一个新 id,历史全断。 -/// -/// **公理撤了的规则不删。** 已失效的派生行仍指着它,解释「当时是靠哪条规则推的」 -/// 需要它还在;而它不再出现在返回值里,据它推出来的事实由下面的对账作废。 -/// 规则一个库也就几条,留着不占地方。 -async fn compile_rules( - conn: &mut PgConnection, - kb_id: Uuid, - ax: &HashMap, -) -> AppResult> { +fn axiom_rule_keys(ax: &HashMap) -> Vec<(Uuid, RuleKind)> { let mut want: Vec<(Uuid, RuleKind)> = Vec::new(); for (&pred, a) in ax { if a.transitive { @@ -1480,8 +1468,24 @@ async fn compile_rules( } } want.sort(); + want +} + +/// 按本体公理重编译规则,返回 `(谓词, 种类) → 规则 id`。 +/// +/// **幂等**:身份取 `(kb, 谓词, 种类)`,重编译认得出「还是那条规则」——否则每跑 +/// 一次 `derived_facts.rule_id` 就指向一个新 id,历史全断。 +/// +/// **公理撤了的规则不删。** 已失效的派生行仍指着它,解释「当时是靠哪条规则推的」 +/// 需要它还在;而它不再出现在返回值里,据它推出来的事实由下面的对账作废。 +/// 规则一个库也就几条,留着不占地方。 +async fn compile_rules( + conn: &mut PgConnection, + kb_id: Uuid, + ax: &HashMap, +) -> AppResult> { let mut out = HashMap::new(); - for (pred, kind) in want { + for (pred, kind) in axiom_rule_keys(ax) { sqlx::query( "INSERT INTO rules (id, kb_id, predicate_id, kind) VALUES ($1, $2, $3, $4) ON CONFLICT (kb_id, predicate_id, kind) DO NOTHING", @@ -1505,6 +1509,45 @@ async fn compile_rules( Ok(out) } +pub struct AxiomRule { + pub kind: &'static str, + pub predicate: String, + pub target: String, +} + +/// 读编译器采用的声明,包含补齐的逆关系对。 +/// 已存规则还保留撤销的声明,新声明则可能尚未编译;列出它们不能物化或写规则。 +pub async fn active_axiom_rules(pool: &PgPool, kb_id: Uuid) -> AppResult> { + let mut conn = pool.acquire().await?; + let ax = axioms(&mut conn, kb_id).await?; + let names: HashMap = + sqlx::query_as("SELECT id, key FROM relation_types WHERE kb_id=$1") + .bind(kb_id) + .fetch_all(&mut *conn) + .await? + .into_iter() + .collect(); + let mut rules = Vec::new(); + for (predicate, kind) in axiom_rule_keys(&ax) { + let target = match kind { + "inverse" => ax[&predicate].inverse_of, + "sub_property" => ax[&predicate].sub_property_of, + _ => Some(predicate), + }; + if let (Some(predicate), Some(target)) = + (names.get(&predicate), target.and_then(|id| names.get(&id))) + { + rules.push(AxiomRule { + kind, + predicate: predicate.clone(), + target: target.clone(), + }); + } + } + rules.sort_by(|a, b| a.predicate.cmp(&b.predicate).then(a.kind.cmp(b.kind))); + Ok(rules) +} + /// 取边时一并拿回来的随行信息(精度与置信度,落库要用)。 type EdgeRow = ( Uuid, @@ -3110,6 +3153,7 @@ pub async fn blocked_proof( } /// 按 id 取一条派生(失效的也取:证明要能回看)。 +/// 公理名字取声明它的谓词,不取结论谓词;撤销的公理也仍要能解释历史事实。 async fn derived_one( pool: &PgPool, kb_id: Uuid, @@ -3126,7 +3170,7 @@ async fn derived_one( d.object_value #>> '{}') AS object, r.label AS predicate, COALESCE(ru.kind, 'business') AS rule, - ar.name AS rule_name, + COALESCE(ar.name, ru.kind || ' of ' || rp.key) AS rule_name, v.seq AS rule_version, v.definition AS rule_definition, d.valid_from, d.valid_to, d.confidence, d.derived_at, COALESCE( @@ -3150,6 +3194,8 @@ async fn derived_one( LEFT JOIN entities o ON o.id = d.object_id JOIN relation_types r ON r.id = d.predicate_id LEFT JOIN rules ru ON ru.id = d.rule_id + LEFT JOIN relation_types rp ON rp.id = ru.predicate_id + AND rp.kb_id = d.kb_id AND ru.kb_id = d.kb_id LEFT JOIN attribute_rules ar ON ar.id = d.attribute_rule_id LEFT JOIN attribute_rule_versions v ON v.id = d.attribute_rule_version_id LEFT JOIN entity_types ct ON ct.id = ar.conclude_type_id @@ -3193,7 +3239,7 @@ pub async fn derived_for_entity( d.object_value #>> '{{}}') AS object, r.label AS predicate, COALESCE(ru.kind, 'business') AS rule, - ar.name AS rule_name, + COALESCE(ar.name, ru.kind || ' of ' || rp.key) AS rule_name, v.seq AS rule_version, v.definition AS rule_definition, d.valid_from, d.valid_to, d.confidence, d.derived_at, COALESCE( @@ -3217,6 +3263,8 @@ pub async fn derived_for_entity( LEFT JOIN entities o ON o.id = d.object_id JOIN relation_types r ON r.id = d.predicate_id LEFT JOIN rules ru ON ru.id = d.rule_id + LEFT JOIN relation_types rp ON rp.id = ru.predicate_id + AND rp.kb_id = d.kb_id AND ru.kb_id = d.kb_id LEFT JOIN attribute_rules ar ON ar.id = d.attribute_rule_id LEFT JOIN attribute_rule_versions v ON v.id = d.attribute_rule_version_id LEFT JOIN entity_types ct ON ct.id = ar.conclude_type_id diff --git a/docs/design/rules.md b/docs/design/rules.md index 987cd47f1..dc0370313 100644 --- a/docs/design/rules.md +++ b/docs/design/rules.md @@ -80,7 +80,14 @@ invalidates what is not in it [0030]. **Explanation.** The entity panel shows a conclusion with its rule and each premise back to its passage; derived edges are gold behind a toggle; blocked derivations are ghost edges; MCP has -`list_rules` and `rule_matches`, read-only [0002, 0017, 0021]. +`list_rules` and `rule_matches`, read-only [0002, 0017, 0021]. `list_rules` includes the +active ontology axioms, using the same inverse normalization as the reasoner; retired +declarations are excluded even though their compiled rules remain for historical proofs. +When `materialize_inferences` is off, the section says that derivation is off for this +base and nothing is derived from these declarations. +In chat and MCP, a derived fact names the property its axiom was declared on (for example, +`inverse of works_at`), which can differ from the conclusion's property. `rule_matches` +continues to take business rule IDs. **A definition has a history.** Every edit that changes what a rule says opens a version (a full snapshot with a record time) and closes the previous one; renaming or switching a rule off does diff --git a/web/src/docs/mcp.md b/web/src/docs/mcp.md index d6292848a..3f2eded6b 100644 --- a/web/src/docs/mcp.md +++ b/web/src/docs/mcp.md @@ -45,11 +45,19 @@ Three methods are served: | `timeline` | One entity's dated facts in world-time order; `since` / `until` narrow the window | | `paths_between` | The chains of facts joining two entities, up to three hops, shortest first. With `at`, every edge must hold at that moment; with `as_of`, the chains as the base held them then | | `changes` | What the graph learned or revised in a window of **record** time: asserted, corrected, rejected, merged. Needs no entity; use it when the question names a period, not a subject. Returns up to 40 events per call; use `offset` to continue | +| `list_rules` | Business rules with their conditions and conclusions, followed by declared ontology axioms (transitive, symmetric, inverse and sub-property) with the properties and relations they imply; the section says when derivation is off for this base | +| `rule_matches` | The entities a business rule currently marks, with its premises. Takes a business rule ID from `list_rules` | | `search_docs` | Utopia's own manual, for questions about how the platform works. Never the user's documents | | `remember` | Record one sentence into the base's memory. **Needs a `write` token held by an editor**; a token without it does not see this tool in `tools/list`, and calling it anyway says why | The two time axes matter here. `at` reads **world time** (when something was true); `as_of` reads **record time** (what Utopia held at that moment, before it revised it), and `changes` lists what moved on that axis in a window. They are separate parameters on purpose: folded into one they would answer "what happened in March" with "what we learned in March", and both look plausible. +A derived fact returned by `entity_facts` names the rule that produced it. For an ontology +axiom, the text includes its defining property, such as `[rule: inverse of works_at]`. +The structured result's `rule_name` carries the same explanation. That property can +differ from the relation in the derived fact itself, including for historical facts +whose axiom has since been withdrawn. + ## The external read contract **RDF export is the supported machine-readable read contract.** From ead46cbafea89a6d16d4ef0351c0173448bea4d0 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 25 Oct 2026 01:51:30 +0800 Subject: [PATCH 3/4] A statements document is found by keyword and not embedded, so an embedding model's input limit cannot fail it (#1375) (#1389) * fix: keep statement ingestion independent of embedding limits Signed-off-by: Aschenbath * A statements document is found by keyword and not embedded, so an embedding model's input limit cannot fail it (#1375) Signed-off-by: Aschenbath --------- Signed-off-by: Aschenbath Co-authored-by: Aschenbath Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com> --- crates/utopia-server/src/pipeline.rs | 14 +++-- crates/utopia-server/src/pipeline_tests.rs | 57 +++++++++++++++++++ ...ay-push-statements-in-the-open-contract.md | 3 +- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/crates/utopia-server/src/pipeline.rs b/crates/utopia-server/src/pipeline.rs index c012352ef..2db5f5472 100644 --- a/crates/utopia-server/src/pipeline.rs +++ b/crates/utopia-server/src/pipeline.rs @@ -228,12 +228,16 @@ async fn run(state: &AppState, input: &ProcessingInput) -> anyhow::Result<()> { tokio::task::spawn_blocking(move || search.reindex_document(&kb, &did, &chunk_pairs)).await??; // 4. embedding(工作区配置了 embedding 模型才做;没配也算 ready,先享受 BM25 搜索) - if let Some((settings, client)) = embedder(settings.as_ref()) { - if !documents::set_status_if_current(&state.pool, input, "embedding").await? { - return Ok(()); + // statements 单块保留完整 JSON 契约,可能超过 embedding 模型的输入上限; + // 确定性抽取不应因此被阻塞(#1375)。 + if !pushed_statements { + if let Some((settings, client)) = embedder(settings.as_ref()) { + if !documents::set_status_if_current(&state.pool, input, "embedding").await? { + return Ok(()); + } + state.emit_document(doc.kb_id, document_id); + embed_pending(state, settings, &client, input).await?; } - state.emit_document(doc.kb_id, document_id); - embed_pending(state, settings, &client, input).await?; } // 来源说了不抽取的,到这里为止:可搜、可问,不进图。 diff --git a/crates/utopia-server/src/pipeline_tests.rs b/crates/utopia-server/src/pipeline_tests.rs index c88e92d7f..d7252dc00 100644 --- a/crates/utopia-server/src/pipeline_tests.rs +++ b/crates/utopia-server/src/pipeline_tests.rs @@ -347,6 +347,63 @@ async fn the_embedding_gate_is_never_held_beyond_its_ceiling() -> anyhow::Result f.cleanup().await } +#[tokio::test] +async fn a_large_statements_payload_is_searchable_without_embedding() -> anyhow::Result<()> { + let Some(f) = fixture(FakeEmbed::new(Duration::ZERO)).await? else { + return Ok(()); + }; + f.server.reset().await; + Mock::given(method("POST")) + .and(path("/embeddings")) + .respond_with( + ResponseTemplate::new(400).set_body_string("maximum input length is 8192 tokens"), + ) + .expect(0) + .mount(&f.server) + .await; + + // 保持在 push 路由 64 KiB 上限以内,同时超过常见 embedding 模型的输入上限。 + // 完整契约必须能进入确定性抽取,不能先被 embedding 拦住(#1375)。 + let payload = serde_json::json!({ + "external_id": "large-observation", + "e": [["cup-7", "cup", true], ["kitchen table", "table", true]], + "s": [[null, "cup-7", "is on", "kitchen table", null, + {"note": "sensor ".repeat(9000)}, null, null]], + "n": [] + }); + let doc = f + .document_with_bytes("observation.json", payload.to_string().as_bytes()) + .await?; + let source = Uuid::now_v7(); + sqlx::query("INSERT INTO sources(id,kb_id,kind,name) VALUES($1,$2,'statements','sensor')") + .bind(source) + .bind(f.kb) + .execute(&f.pool) + .await?; + sqlx::query("UPDATE documents SET source_id = $2 WHERE id = $1") + .bind(doc) + .bind(source) + .execute(&f.pool) + .await?; + + super::process_document(&f.state, doc).await?; + let row = utopia_store::documents::get(&f.pool, doc).await?; + assert_eq!(row.status, "ready"); + assert_eq!(row.graph_status, "queued"); + let stored = f.stored(doc).await?; + assert_eq!(stored.len(), 1, "the contract stays in one chunk"); + assert!(stored[0].1.is_none()); + assert_eq!( + serde_json::from_str::(&stored[0].0)?, + payload + ); + let chunks = utopia_store::documents::chunks_full(&f.pool, doc).await?; + let hits = f.state.search.search(&f.kb.to_string(), "sensor", 1)?; + assert_eq!(hits.len(), 1, "the payload remains searchable by keyword"); + assert_eq!(hits[0].chunk_id, chunks[0].id.to_string()); + f.cleanup().await +} + #[tokio::test] async fn a_failed_batch_does_not_strand_the_document() -> anyhow::Result<()> { let mut fake = FakeEmbed::new(Duration::from_millis(20)); diff --git a/docs/decisions/0054-a-source-may-push-statements-in-the-open-contract.md b/docs/decisions/0054-a-source-may-push-statements-in-the-open-contract.md index ecccb4b72..5d3c36f68 100644 --- a/docs/decisions/0054-a-source-may-push-statements-in-the-open-contract.md +++ b/docs/decisions/0054-a-source-may-push-statements-in-the-open-contract.md @@ -1,6 +1,6 @@ # 0054 · A source may push statements in the open contract -- **Status**: Implemented · cut 1 (#884) +- **Status**: Implemented · cut 1 (#884) · revised 2026-10-23 (#1375) - **Written**: 2026-09-23 - **Related**: [0044](0044-the-ontology-is-a-view-over-what-documents-say.md) owns the contract this reuses and the rule that typed facts come only from alignment; [0001](0001-extraction.md) is why every statement has evidence; [0022](0022-a-fact-has-two-clocks.md) is the two clocks a pushed item lands on; [0036](0036-exploration-aligns-a-schema-to-the-ontology.md) is where structured *state* lives, which this record leaves alone; [0015](0015-recording-a-sentence-is-not-asserting-a-fact.md) is why a person's `remember` needs a nod and a source's document does not; #875 is the case that surfaced it. @@ -95,3 +95,4 @@ Content-Type: application/json - 2026-10-15 ([#1242](https://github.com/deeplethe/utopia/issues/1242)): a pushed signature whose phrase equals exactly one declared relation, not a state, that admits both of its ends is bound by the ontology when the document's extraction finishes, and that extraction queues `materialize`, so a base with no chat model types what it is pushed. It binds the signature as [0044](0044-the-ontology-is-a-view-over-what-documents-say.md)'s revision of decision 3 describes, reviewable like any binding; it is not a typed write, decisions 3 and 5 stand, and no other pushed phrase binds before alignment reads it. - 2026-10-16 ([#1267](https://github.com/deeplethe/utopia/issues/1267)): decision 7 keeps a re-pushed statement's identity, but its role words stayed first-write-wins, so a re-push with a new qualifier value kept the old one; [0070](0070-a-role-word-speaks-while-its-chunk-is-live.md) proposes that a role word is recorded against its evidence chunk and speaks while that chunk is live. - 2026-10-19 ([#1293](https://github.com/deeplethe/utopia/issues/1293)): proposed in [0071](0071-a-source-may-declare-its-names-identities.md): a `statements` source whose config says `{"names": "identity"}` attaches its named entities by exact name and gives their names no vectors. Decision 3's "same path" stands for every other source. +- 2026-10-23 ([#1375](https://github.com/deeplethe/utopia/issues/1375)): decision 2's intact `statements` chunk is indexed for keyword search but is not embedded, so an embedding model's input limit cannot block its deterministic extraction; vectors already written for statements chunks are left in place. From fc21f46de641e4b2a53849ed6b234ec52e429e03 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Sun, 25 Oct 2026 01:56:58 +0800 Subject: [PATCH 4/4] fix: use the last opened permitted base for MCP snippets (#1384) Signed-off-by: Aschenbath Co-authored-by: Aschenbath Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com> --- web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- web/src/pages/Tokens.test.ts | 25 +++++++++++++++++++++++++ web/src/pages/Tokens.tsx | 19 ++++++++++++------- 4 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 web/src/pages/Tokens.test.ts diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f7dafe2f3..709965c24 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -245,7 +245,7 @@ export const en = { copied: "Copied", mcpTitle: "MCP client configuration", mcpHint: - "For Claude Code, Claude Desktop and other Streamable HTTP clients. Each base has its own endpoint, so pick the one the agent should talk to. Other clients spell the keys differently.", + "For Claude Code, Claude Desktop and other Streamable HTTP clients. This snippet connects to one knowledge base, even if the token allows all bases. Check the selected base before copying. Other clients spell the keys differently.", mcpBase: "Base", yourTokens: "Your tokens", noTokens: "No tokens yet.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 8156dbbb6..6f6d0a3bb 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -220,7 +220,7 @@ export const zh: Strings = { copied: "已复制", mcpTitle: "MCP 客户端配置", mcpHint: - "适用于 Claude Code、Claude Desktop 及其它 Streamable HTTP 客户端。每个库有自己的端点,选 agent 要对话的那个库。别的客户端键名可能不同。", + "适用于 Claude Code、Claude Desktop 及其它 Streamable HTTP 客户端。即使令牌允许访问全部库,这段配置也只连接一个知识库,复制前请确认选中的库。别的客户端键名可能不同。", mcpBase: "库", yourTokens: "你的令牌", noTokens: "还没有令牌。", diff --git a/web/src/pages/Tokens.test.ts b/web/src/pages/Tokens.test.ts new file mode 100644 index 000000000..8be1adac3 --- /dev/null +++ b/web/src/pages/Tokens.test.ts @@ -0,0 +1,25 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { afterAll, expect, it, vi } from "vitest"; +import type { TokenView } from "../api"; +import { IssuedBody } from "./Tokens"; + +vi.stubGlobal("window", { location: { origin: "https://utopia.test" } }); +afterAll(() => vi.unstubAllGlobals()); + +const kbs = [{ id: "general", name: "General" }, { id: "research", name: "Research" }]; +const info: TokenView = { + id: "token", name: "Agent", token_prefix: "utp_pat_test", scope: "read", kb_ids: null, + created_at: "2026-10-21T00:00:00Z", expires_at: null, last_used_at: null, revoked_at: null, +}; + +it.each([ + { allowed: null, expected: "research" }, + { allowed: ["general", "research"], expected: "research" }, + { allowed: ["general"], expected: "general" }, +])("defaults the copied endpoint to a permitted last-opened base ($allowed)", ({ allowed, expected }) => { + const html = renderToStaticMarkup(createElement(IssuedBody, { + token: "utp_pat_test", info: { ...info, kb_ids: allowed }, kbs, preferredKbId: "research", + })); + expect(html).toContain(`https://utopia.test/api/v1/kbs/${expected}/mcp`); +}); diff --git a/web/src/pages/Tokens.tsx b/web/src/pages/Tokens.tsx index e0d05e161..ed845783e 100644 --- a/web/src/pages/Tokens.tsx +++ b/web/src/pages/Tokens.tsx @@ -76,21 +76,26 @@ function CopyButton({ text, small }: { text: string; small?: boolean }) { /** 刚发出来的那一枚:明文 + 配置片段,关掉就再也看不到。 住在发令牌那个弹窗里——发放的下一秒就是"把它抄走",中间不该有一次关窗 */ -function IssuedBody({ +export function IssuedBody({ token, info, kbs, + preferredKbId, }: { token: string; info: TokenView; kbs: { id: string; name: string }[]; + preferredKbId?: string; }) { - // 片段默认指向令牌限定的第一个库;没限定就取列表第一个 + // 账户页仍记着上次打开的库;只在令牌允许的库里选,免得默认连到空的 General。 const candidates = info.kb_ids?.length ? kbs.filter((k) => info.kb_ids!.includes(k.id)) : kbs; - const [kbId, setKbId] = useState(candidates[0]?.id ?? kbs[0]?.id ?? ""); - const kb = kbs.find((k) => k.id === kbId); + const [pickedKbId, setPickedKbId] = useState(); + const kb = candidates.find((k) => k.id === pickedKbId) + ?? candidates.find((k) => k.id === preferredKbId) + ?? candidates[0]; + const kbId = kb?.id ?? ""; const snippet = kb ? mcpSnippet(kb.id, kb.name, token) : ""; return (
@@ -110,7 +115,7 @@ function IssuedBody({ size="sm" className="w-40" value={kbId} - onChange={setKbId} + onChange={setPickedKbId} options={candidates.map((k) => ({ value: k.id, label: k.name }))} /> @@ -198,7 +203,7 @@ function TokenRow({ export function Tokens() { const queryClient = useQueryClient(); - const { kbs } = useKb(); + const { kb, kbs } = useKb(); const list = useQuery({ queryKey: ["tokens"], queryFn: api.tokens }); // 发令牌是个动作,不是这一页的常驻内容:表单住在弹窗里,页面上只有名单 @@ -327,7 +332,7 @@ export function Tokens() { } > {issued ? ( - + ) : (