diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b5f4dce6..05b2c6d91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,12 @@ jobs: failed=$(grep -o '[0-9]* failed' store-tests.log | awk '{s+=$1} END {print s+0}') echo "utopia-store against Postgres: **${passed} passed**, ${failed} failed — a missing database fails this job instead of skipping" >> "$GITHUB_STEP_SUMMARY" + - name: MCP structured reads against Postgres + run: cargo test -p utopia-server api::mcp::tests + env: + UTOPIA_DATABASE_URL: postgres://utopia:utopia@localhost:5432/utopia + UTOPIA_TEST_REQUIRE_DB: "1" + web: runs-on: ubuntu-latest defaults: diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 7ed2aa240..92ba67961 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -709,6 +709,10 @@ pub struct GraphEdge { #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct EntityFact { pub id: Uuid, + pub recorded_at: DateTime, + pub invalidated_at: Option>, + pub supersedes: Option, + pub document_ids: Vec, /// out = 该实体为主语;in = 为宾语 pub direction: String, /// 本体没认下这条关系时回落到原文说法;两者都拿不出时为 None(更早的历史数据长这样) @@ -1217,6 +1221,13 @@ pub struct OntologyDefect { #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct DerivedFactView { pub id: Uuid, + pub predicate_id: Uuid, + pub object_value: Option, + pub rule_id: Option, + pub attribute_rule_id: Option, + pub invalidated_at: Option>, + pub valid_from_precision: Option, + pub valid_to_precision: Option, pub subject_id: Uuid, pub subject: String, /// 字面值结论(业务规则的归类与属性)没有实体宾语(0021) diff --git a/crates/utopia-server/src/api/chat.rs b/crates/utopia-server/src/api/chat.rs index e8a2f68ae..d582b5164 100644 --- a/crates/utopia-server/src/api/chat.rs +++ b/crates/utopia-server/src/api/chat.rs @@ -199,7 +199,7 @@ pub(super) fn tools_schema(can_write: bool, data_source_names: &[String]) -> ser /// /// 判据直接取自工具表里的 `required`:加一个必填参数,这里自动跟上, /// 不必记得来改第二处。 -fn check_call( +pub(super) fn check_call( tools: &serde_json::Value, name: &str, raw_args: &str, @@ -980,7 +980,8 @@ pub async fn chat( via_token: None, question: Some(&query), }; - let (result, step) = tools::dispatch(&ctx, &mut sink, &call.name, &args).await; + let tools::ToolResult { text: result, step, .. } = + tools::dispatch(&ctx, &mut sink, &call.name, &args).await; // **这一步发生在正文的哪个位置。** // // 模型是边说边调的:说一句、查一下、再说一句。SSE 上 `delta` 与 diff --git a/crates/utopia-server/src/api/mcp.rs b/crates/utopia-server/src/api/mcp.rs index 6c15e76f1..68daac9d0 100644 --- a/crates/utopia-server/src/api/mcp.rs +++ b/crates/utopia-server/src/api/mcp.rs @@ -148,6 +148,17 @@ fn ok(id: Option, result: Value) -> Json { Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })) } +fn tool_result(result: tools::ToolResult) -> Value { + let mut response = json!({ + "content": [{ "type": "text", "text": result.text }], + "isError": result.is_error, + }); + if let Some(content) = result.structured_content { + response["structuredContent"] = content; + } + response +} + /// JSON-RPC 的错误不是 HTTP 的错误:**传输成功了,方法失败了**。 /// 回 200 带 error 体,客户端才解析得动。 fn rpc_err(id: Option, code: i64, message: &str) -> Json { @@ -207,6 +218,17 @@ pub async fn handle( }; return Ok(rpc_err(id, -32601, &message)); } + // 与聊天共用参数守卫:缺少 query 不能变成一次成功的空搜索。 + if let Err((text, step)) = super::chat::check_call( + &super::chat::tools_schema(can_write, &[]), + name, + &args.to_string(), + ) { + return Ok(ok( + id, + tool_result(tools::ToolResult::new(text, step).error()), + )); + } // `mounted_sources` 仍旧空着:`query_data` 没放出来,给了也没人用。 // `can_write` 不再写死 false——它现在是令牌与角色一起算出来的 let ctx = ToolCtx { @@ -222,7 +244,7 @@ pub async fn handle( question: None, }; let mut sink = ToolSink::default(); - let (text, _step) = tools::dispatch(&ctx, &mut sink, name, &args).await; + let result = tools::dispatch(&ctx, &mut sink, name, &args).await; let _ = utopia_store::audit::record( &state.pool, Some(kb_id), @@ -233,14 +255,12 @@ pub async fn handle( json!({ "tool": name }), ) .await; - ok( - id, - json!({ - "content": [{ "type": "text", "text": text }], - "isError": false, - }), - ) + ok(id, tool_result(result)) } other => rpc_err(id, -32601, &format!("Unknown method: {other}")), }) } + +#[cfg(test)] +#[path = "mcp_tests.rs"] +mod tests; diff --git a/crates/utopia-server/src/api/mcp_tests.rs b/crates/utopia-server/src/api/mcp_tests.rs new file mode 100644 index 000000000..d1b72e2cf --- /dev/null +++ b/crates/utopia-server/src/api/mcp_tests.rs @@ -0,0 +1,506 @@ +//! #550: read IDs through the actual authenticated MCP handler, against PostgreSQL. +use super::*; +use std::sync::Arc; +use tools::{ToolCtx, ToolResult}; + +const CORRECTION: &str = "2026-03-20T12:00:00.123456Z"; + +struct Fixture { + state: AppState, + org: Uuid, + ws: Uuid, + kb: Uuid, + other_kb: Uuid, + subject: Uuid, + object: Uuid, + fact: Uuid, + corrected: Uuid, + attribute: Uuid, + derived: Uuid, + derived_value: Uuid, + document: Uuid, + chunk: Uuid, + token: String, + dir: std::path::PathBuf, +} + +impl Fixture { + async fn new() -> anyhow::Result> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(None); + }; + let pool = sqlx::PgPool::connect(&url).await?; + utopia_store::db::migrate(&pool).await?; + let dir = std::env::temp_dir().join(format!("utopia-mcp-{}", Uuid::now_v7())); + let search = Arc::new(utopia_search::SearchIndex::open(&dir.join("search"))?); + let config = utopia_core::config::AppConfig { + data_dir: dir.to_string_lossy().into_owned(), + ..Default::default() + }; + let mut f = Self { + state: AppState::new(pool.clone(), &config, search, "test-only".into()), + org: Uuid::now_v7(), + ws: Uuid::now_v7(), + kb: Uuid::now_v7(), + other_kb: Uuid::now_v7(), + subject: Uuid::now_v7(), + object: Uuid::now_v7(), + fact: Uuid::now_v7(), + corrected: Uuid::now_v7(), + attribute: Uuid::now_v7(), + derived: Uuid::now_v7(), + derived_value: Uuid::now_v7(), + document: Uuid::now_v7(), + chunk: Uuid::now_v7(), + token: String::new(), + dir, + }; + let (user, ty, relation, attr, rule, business, chunk2) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + // Interpolation is limited to locally generated UUIDs and the fixed timestamp. + sqlx::raw_sql(&format!( + r#" + INSERT INTO organizations(id,name) VALUES ('{org}','mcp-test'); + INSERT INTO workspaces(id,org_id,name) VALUES ('{ws}','{org}','mcp-test'); + INSERT INTO users(id,org_id,email,password_hash,display_name) + VALUES ('{user}','{org}','{user}@example.test','unused','MCP reader'); + INSERT INTO knowledge_bases(id,workspace_id,name) VALUES + ('{kb}','{ws}','mcp-test'), ('{other_kb}','{ws}','other-base'); + INSERT INTO kb_members(kb_id,user_id,role) VALUES ('{kb}','{user}','viewer'); + INSERT INTO entity_types(id,kb_id,key,label) VALUES ('{ty}','{kb}','thing','Thing'); + INSERT INTO relation_types(id,kb_id,key,label,kind,datatype) VALUES + ('{relation}','{kb}','works_for','works for','relation',NULL), + ('{attr}','{kb}','weight','weight','attribute','number'); + INSERT INTO entities(id,kb_id,type_id,canonical_name,created_at) VALUES + ('{subject}','{kb}','{ty}','Alice','2026-01-01'), + ('{object}','{kb}','{ty}','Acme','2026-01-01'); + INSERT INTO documents(id,kb_id,filename,sha256,created_at) + VALUES ('{document}','{kb}','orchard.md',repeat('0',64),'2026-01-01'); + INSERT INTO chunks(id,kb_id,document_id,seq,text,created_at) VALUES + ('{chunk}','{kb}','{document}',0,repeat('orchard ',120),'2026-01-01'), + ('{chunk2}','{kb}','{document}',1,'Alice works for Acme.','2026-01-01'); + INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_id,valid_from, + valid_from_precision,recorded_at,invalidated_at) VALUES + ('{fact}','{kb}','{subject}','{relation}','{object}','2026-01-01', + 'day','2026-03-10','{correction}'); + INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_id,valid_from, + valid_from_precision,recorded_at,supersedes) VALUES + ('{corrected}','{kb}','{subject}','{relation}','{object}','2026-02-01', + 'day','{correction}','{fact}'); + INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_value,valid_from, + valid_from_precision,recorded_at) VALUES + ('{attribute}','{kb}','{subject}','{attr}','{{"value":7,"unit":"kg"}}', + '2026-01-01','year','2026-03-10'); + INSERT INTO fact_evidence(fact_id,chunk_id,document_id,doc_version,quote) VALUES + ('{fact}','{chunk}','{document}',1,'Alice works for Acme.'), + ('{corrected}','{chunk}','{document}',1,'Alice works for Acme.'), + ('{corrected}','{chunk2}','{document}',1,'Alice works for Acme.'); + INSERT INTO fact_qualifiers(fact_id,qualifier_type_id,value) + VALUES ('{corrected}','{attr}','{{"value":3,"unit":"kg"}}'); + INSERT INTO rules(id,kb_id,predicate_id,kind) + VALUES ('{rule}','{kb}','{relation}','symmetric'); + INSERT INTO derived_facts(id,kb_id,subject_id,predicate_id,object_id,rule_id, + derived_at,invalidated_at,valid_from,valid_from_precision) VALUES + ('{derived}','{kb}','{object}','{relation}','{subject}','{rule}', + '2026-03-15','2026-04-01','2026-01-01','day'); + INSERT INTO fact_derivations(derived_fact_id,premise_fact_id,seq) + VALUES ('{derived}','{fact}',0); + INSERT INTO attribute_rules(id,kb_id,name,subject_type_id,conclusion, + conclude_predicate_id,conclude_value) VALUES + ('{business}','{kb}','Weight rule','{ty}','attribute','{attr}', + '{{"value":8,"unit":"kg"}}'); + INSERT INTO derived_facts(id,kb_id,subject_id,predicate_id,object_value, + attribute_rule_id,derived_at,valid_from,valid_from_precision) VALUES + ('{derived_value}','{kb}','{subject}','{attr}','{{"value":8,"unit":"kg"}}', + '{business}','2026-03-15','2026-01-01','day'); + INSERT INTO fact_derivations(derived_fact_id,premise_fact_id,seq) + VALUES ('{derived_value}','{attribute}',0); + "#, + org = f.org, + ws = f.ws, + kb = f.kb, + other_kb = f.other_kb, + subject = f.subject, + object = f.object, + document = f.document, + chunk = f.chunk, + fact = f.fact, + corrected = f.corrected, + attribute = f.attribute, + derived = f.derived, + derived_value = f.derived_value, + correction = CORRECTION + )) + .execute(&pool) + .await?; + f.token = utopia_store::tokens::issue(&pool, user, "MCP test", "read", Some(&[f.kb]), None) + .await? + .1; + f.state.search.reindex_document( + &f.kb.to_string(), + &f.document.to_string(), + &[(f.chunk.to_string(), "orchard ".repeat(120))], + )?; + Ok(Some(f)) + } + + async fn request( + &self, + kb: Uuid, + method: &str, + params: Value, + ) -> crate::error::ApiResult> { + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + format!("Bearer {}", self.token).parse().unwrap(), + ); + handle( + State(self.state.clone()), + Path(kb), + headers, + Json(json!({"jsonrpc":"2.0","id":1,"method":method,"params":params})), + ) + .await + } + + async fn call(&self, name: &str, args: Value) -> anyhow::Result { + let response = self + .request(self.kb, "tools/call", json!({"name":name,"arguments":args})) + .await + .map_err(|_| anyhow::anyhow!("MCP request failed"))? + .0; + assert!(response.get("error").is_none(), "{response}"); + Ok(response["result"].clone()) + } + + async fn clean(self) -> anyhow::Result<()> { + sqlx::query("DELETE FROM organizations WHERE id=$1") + .bind(self.org) + .execute(&self.state.pool) + .await?; + let dir = self.dir.clone(); + drop(self); + std::fs::remove_dir_all(dir)?; + Ok(()) + } +} + +fn uuid(value: &Value) -> Uuid { + value.as_str().unwrap().parse().unwrap() +} + +#[tokio::test] +async fn find_entities_returns_ranked_ids_and_keeps_text() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = f.call("find_entities", json!({"name":"Alice"})).await?; + assert_eq!(result["isError"], false); + assert_eq!( + uuid(&result["structuredContent"]["entities"][0]["id"]), + f.subject + ); + assert_eq!( + result["structuredContent"]["entities"][0]["type_key"], + "thing" + ); + assert_eq!( + result["content"][0]["text"], + format!("Best match: {} | Alice | Thing | 2 facts", f.subject) + ); + let empty = f.call("find_entities", json!({"name":"Nobody"})).await?; + assert_eq!(empty["structuredContent"]["entities"], json!([])); + assert_eq!(empty["content"][0]["text"], "No matching entities."); + let invalid = f.call("find_entities", json!({})).await?; + assert_eq!(invalid["isError"], true); + assert!(invalid.get("structuredContent").is_none()); + assert!(f + .request( + f.other_kb, + "tools/call", + json!({"name":"find_entities","arguments":{"name":"Alice"}}) + ) + .await + .is_err()); + let listed = f + .request(f.kb, "tools/list", json!({})) + .await + .map_err(|e| e.0)? + .0; + assert!(!listed["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|t| t["name"] == "remember")); + f.clean().await +} + +#[tokio::test] +async fn search_chunks_returns_chunk_and_document_ids_with_the_same_excerpt() -> anyhow::Result<()> +{ + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = f.call("search_chunks", json!({"query":"orchard"})).await?; + let chunk = &result["structuredContent"]["chunks"][0]; + assert_eq!(uuid(&chunk["chunk_id"]), f.chunk); + assert_eq!(uuid(&chunk["document_id"]), f.document); + assert_eq!(chunk["seq"], 0); + assert_eq!(chunk["truncated"], true); + assert_eq!( + result["content"][0]["text"], + format!( + "[1] \"orchard.md\" section 1 (document_id: {}):\n{}", + f.document, + chunk["text"].as_str().unwrap() + ) + ); + let empty = f + .call( + "search_chunks", + json!({"query":"orchard","as_of":"2025-01-01"}), + ) + .await?; + assert_eq!(empty["structuredContent"]["chunks"], json!([])); + assert_eq!(empty["content"][0]["text"], "No results."); + f.clean().await +} + +#[tokio::test] +async fn entity_facts_keeps_identity_values_filters_and_both_clocks() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = f + .call("entity_facts", json!({"entity_id":f.subject})) + .await?; + let data = &result["structuredContent"]; + assert_eq!(uuid(&data["entity"]["id"]), f.subject); + let facts = data["facts"].as_array().unwrap(); + let corrected = facts + .iter() + .find(|r| uuid(&r["id"]) == f.corrected) + .unwrap(); + assert_eq!(corrected["recorded_at"], CORRECTION); + assert_eq!( + corrected["qualifiers"][0]["value"], + json!({"value":3,"unit":"kg"}) + ); + assert_eq!(uuid(&corrected["supersedes"]), f.fact); + assert_eq!( + corrected["document_ids"], + json!([f.document]), + "same source is deduplicated" + ); + let attribute = facts + .iter() + .find(|r| uuid(&r["id"]) == f.attribute) + .unwrap(); + assert_eq!(attribute["object_value"], json!({"value":7,"unit":"kg"})); + assert_eq!(attribute["valid_from_precision"], "year"); + let derived = &data["derived_facts"][0]; + assert_eq!(uuid(&derived["id"]), f.derived_value); + assert_eq!(derived["object_value"], json!({"value":8,"unit":"kg"})); + assert_eq!(derived["rule"], "business"); + assert!(derived["rule_id"].is_null()); + uuid(&derived["attribute_rule_id"]); + // The same UUID is the RDF statement's identity, not a newly minted response ID. + let exported = utopia_store::export::facts_page(&f.state.pool, f.kb, None).await?; + assert!(exported + .iter() + .any(|r| r.id == uuid(&corrected["id"]) && r.documents == vec![f.document])); + let incoming = f + .call("entity_facts", json!({"entity_id":f.object})) + .await?; + assert_eq!(incoming["structuredContent"]["facts"][0]["direction"], "in"); + assert_eq!( + uuid(&incoming["structuredContent"]["facts"][0]["other_id"]), + f.subject + ); + let limited = f + .call("entity_facts", json!({"entity_id":f.subject,"limit":1})) + .await?; + assert_eq!( + limited["structuredContent"]["facts"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!(limited["structuredContent"]["truncated"], true); + let filtered = f + .call( + "entity_facts", + json!({"entity_id":f.subject,"predicate":"weight"}), + ) + .await?; + assert_eq!( + filtered["structuredContent"]["facts"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + uuid(&filtered["structuredContent"]["facts"][0]["id"]), + f.attribute + ); + let empty = f + .call( + "entity_facts", + json!({"entity_id":f.subject,"at":"2025-01-01","as_of":CORRECTION}), + ) + .await?; + assert_eq!(empty["structuredContent"]["facts"], json!([])); + assert_eq!(empty["structuredContent"]["derived_facts"], json!([])); + let history = f + .call( + "entity_facts", + json!({"entity_id":f.subject,"before":CORRECTION,"as_of":"2026-05-01"}), + ) + .await?; + let old = history["structuredContent"]["facts"] + .as_array() + .unwrap() + .iter() + .find(|r| uuid(&r["id"]) == f.fact) + .unwrap(); + assert_eq!(old["invalidated_at"], CORRECTION); + assert_eq!( + history["structuredContent"]["as_of"], + "2026-03-20T12:00:00.123455Z" + ); + assert!(history["structuredContent"]["derived_facts"] + .as_array() + .unwrap() + .iter() + .any(|r| uuid(&r["id"]) == f.derived)); + let early = f + .call( + "entity_facts", + json!({"entity_id":f.subject,"as_of":"2026-03-12"}), + ) + .await?; + assert_eq!(early["structuredContent"]["derived_facts"], json!([])); + // Supplying a foreign entity UUID must not reveal its name or facts. + sqlx::query("INSERT INTO entities(id,kb_id,canonical_name) VALUES ($1,$2,'Hidden')") + .bind(f.other_kb) + .bind(f.other_kb) + .execute(&f.state.pool) + .await?; + let foreign = f + .call("entity_facts", json!({"entity_id":f.other_kb})) + .await?; + assert_eq!(foreign["isError"], true); + assert_eq!(foreign["content"][0]["text"], "Entity not found."); + assert!(foreign.get("structuredContent").is_none()); + f.clean().await +} + +#[tokio::test] +async fn changes_returns_fact_ids_and_a_reusable_correction_timestamp() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + let result = f + .call( + "changes", + json!({"since":"2026-03-20","until":"2026-03-20","kinds":["corrected"]}), + ) + .await?; + let change = &result["structuredContent"]["changes"][0]; + assert_eq!(uuid(&change["fact_id"]), f.corrected); + assert_eq!(uuid(&change["document_id"]), f.document); + assert_eq!(change["at"], CORRECTION); + assert_eq!(result["structuredContent"]["until"], "2026-03-21T00:00:00Z"); + assert!(result["content"][0]["text"] + .as_str() + .unwrap() + .contains(CORRECTION)); + let before = f + .call( + "entity_facts", + json!({"entity_id":f.object,"before":change["at"]}), + ) + .await?; + assert_eq!(uuid(&before["structuredContent"]["facts"][0]["id"]), f.fact); + let empty = f + .call("changes", json!({"since":"2025","until":"2025"})) + .await?; + assert_eq!(empty["structuredContent"]["changes"], json!([])); + assert_eq!(empty["structuredContent"]["limit_reached"], false); + sqlx::query( + "INSERT INTO facts(id,kb_id,subject_id,predicate_id,object_value,recorded_at) + SELECT gen_random_uuid(),kb_id,subject_id,predicate_id, + jsonb_build_object('value',n),'2026-03-21'::timestamptz + FROM facts CROSS JOIN generate_series(1,41) n WHERE id=$1", + ) + .bind(f.attribute) + .execute(&f.state.pool) + .await?; + let capped = f + .call( + "changes", + json!({"since":"2026-03-21","until":"2026-03-21"}), + ) + .await?; + assert_eq!( + capped["structuredContent"]["changes"] + .as_array() + .unwrap() + .len(), + 40 + ); + assert_eq!(capped["structuredContent"]["limit_reached"], true); + f.clean().await +} + +#[tokio::test] +async fn failed_reads_do_not_become_successful_empty_results() -> anyhow::Result<()> { + let Some(f) = Fixture::new().await? else { + return Ok(()); + }; + f.state.pool.close().await; + let ctx = ToolCtx { + state: &f.state, + kb_id: f.kb, + workspace_id: f.ws, + mounted_sources: &[], + can_write: false, + actor: None, + via_token: None, + question: None, + }; + for (name, args) in [ + ("find_entities", json!({"name":"Alice"})), + ("search_chunks", json!({"query":"orchard"})), + ("entity_facts", json!({"entity_id":f.subject})), + ("changes", json!({"since":"2026"})), + ] { + let result = + tool_result(tools::dispatch(&ctx, &mut ToolSink::default(), name, &args).await); + assert_eq!(result["isError"], true, "{name}: {result}"); + assert!(result.get("structuredContent").is_none()); + } + // Reconnect only for fixture cleanup. + let mut f = f; + f.state.pool = sqlx::PgPool::connect(&utopia_store::test_db::url().unwrap()).await?; + f.clean().await +} + +#[test] +fn text_only_results_do_not_acquire_a_structured_payload() { + let result = tool_result(ToolResult::new("existing text".into(), json!({}))); + assert_eq!( + result, + json!({"content":[{"type":"text","text":"existing text"}],"isError":false}) + ); +} diff --git a/crates/utopia-server/src/api/tools.rs b/crates/utopia-server/src/api/tools.rs index 5ac977953..321cf4423 100644 --- a/crates/utopia-server/src/api/tools.rs +++ b/crates/utopia-server/src/api/tools.rs @@ -81,8 +81,34 @@ pub struct ToolSink { pub resolved: Vec, } -/// 一次工具调用的产出:给模型的文本 + 给界面的一步。 -pub type ToolResult = (String, serde_json::Value); +/// 一次调用的文本、界面步骤与可选机器读取结果;不放进跨调用累计的 ToolSink。 +pub struct ToolResult { + pub text: String, + pub step: serde_json::Value, + pub structured_content: Option, + pub is_error: bool, +} + +impl ToolResult { + pub fn new(text: String, step: serde_json::Value) -> Self { + Self { + text, + step, + structured_content: None, + is_error: false, + } + } + + pub fn structured(mut self, content: serde_json::Value) -> Self { + self.structured_content = Some(content); + self + } + + pub fn error(mut self) -> Self { + self.is_error = true; + self + } +} /// 按名字派发。**未知工具不是错误**——模型偶尔会编一个名字出来, /// 告诉它没有这个工具,它下一轮就换一个,比中断整场对话好。 @@ -109,7 +135,7 @@ pub async fn dispatch( "rule_matches" => rule_matches(ctx, args).await, "query_data" if !ctx.mounted_sources.is_empty() => query_data(ctx, args).await, "remember" if ctx.can_write => remember(ctx, args).await, - other => ( + other => ToolResult::new( format!("Unknown tool: {other}"), json!({ "kind": "tool", "label": other, "detail": "unknown" }), ), @@ -140,7 +166,7 @@ pub async fn search_chunks( // 记录轴(0019 / #347):只搜那一刻库里有的东西。全文那一路仍是"现在", // 命中不会错但会缺——retrieval.rs 的头上写了 let as_of = args["as_of"].as_str().and_then(parse_when); - let chunks = retrieval::hybrid( + let chunks = match retrieval::hybrid( ctx.state, ctx.kb_id, ctx.workspace_id, @@ -149,7 +175,17 @@ pub async fn search_chunks( as_of, ) .await - .unwrap_or_default(); + { + Ok(chunks) => chunks, + Err(e) => { + tracing::warn!(error = %e, "MCP chunk search failed"); + return ToolResult::new( + "Could not search the documents.".into(), + json!({"kind": "search", "label": q, "detail": "failed"}), + ) + .error(); + } + }; let mut lines = Vec::new(); for c in &chunks { let n = cite(sink, c.id.to_string(), |n| source_json(n, c)); @@ -167,10 +203,20 @@ pub async fn search_chunks( } else { lines.join("\n\n") }; - ( + ToolResult::new( text, json!({ "kind": "search", "label": q, "detail": format!("{} sources", chunks.len()) }), ) + .structured(json!({ + "kb_id": ctx.kb_id, "as_of": as_of, + "limit": SEARCH_TOP_K, "limit_reached": chunks.len() == SEARCH_TOP_K, + "chunks": chunks.iter().map(|c| json!({ + "chunk_id": c.id, "document_id": c.document_id, + "seq": c.seq, "filename": c.filename, + "text": truncate(&c.text, TOOL_CHUNK_CHARS), + "truncated": c.text.trim().chars().count() > TOOL_CHUNK_CHARS, + })).collect::>() + })) } /// 一篇文档的全文。**search_chunks 够不到的东西全在这里**:它只回前六条命中、 @@ -182,7 +228,7 @@ pub async fn get_document( args: &serde_json::Value, ) -> ToolResult { let refuse = |detail: &str| { - ( + ToolResult::new( "No document with that id in this knowledge base.".to_string(), json!({ "kind": "document", "label": "?", "detail": detail }), ) @@ -247,7 +293,7 @@ pub async fn get_document( } else { format!("{header}\n\n{}", lines.join("\n\n")) }; - ( + ToolResult::new( text, json!({ "kind": "document", "label": doc.filename, @@ -290,7 +336,7 @@ pub async fn search_docs( } else { lines.join("\n\n") }; - ( + ToolResult::new( text, json!({ "kind": "docs", "label": q, "detail": format!("{} sections", hits.len()) }), ) @@ -300,13 +346,13 @@ pub async fn search_docs( /// 而不是猜一个听起来合理的门槛。 pub async fn list_rules(ctx: &ToolCtx<'_>) -> ToolResult { let Ok(rules) = utopia_store::business_rules::list(&ctx.state.pool, ctx.kb_id).await else { - return ( + return ToolResult::new( "Could not read the rules.".to_string(), json!({ "kind": "tool", "label": "list_rules", "detail": "failed" }), ); }; if rules.is_empty() { - return ( + return ToolResult::new( "This base has no business rules.".to_string(), json!({ "kind": "tool", "label": "list_rules", "detail": "none" }), ); @@ -356,7 +402,7 @@ pub async fn list_rules(ctx: &ToolCtx<'_>) -> ToolResult { .collect::>() .join("\n"); let n = rules.len(); - ( + ToolResult::new( text, json!({ "kind": "tool", "label": "list_rules", "detail": format!("{n} rules") }), ) @@ -368,7 +414,7 @@ pub async fn rule_matches(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolRe .as_str() .and_then(|s| s.parse::().ok()) else { - return ( + return ToolResult::new( "Invalid rule_id (expected the uuid returned by list_rules).".to_string(), json!({ "kind": "tool", "label": "rule_matches", "detail": "invalid id" }), ); @@ -377,13 +423,13 @@ pub async fn rule_matches(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolRe let Ok((rows, total)) = utopia_store::business_rules::matches(&ctx.state.pool, ctx.kb_id, rule_id, limit, 0).await else { - return ( + return ToolResult::new( "Could not read what that rule marks.".to_string(), json!({ "kind": "tool", "label": "rule_matches", "detail": "failed" }), ); }; if rows.is_empty() { - return ( + return ToolResult::new( "That rule marks nothing right now.".to_string(), json!({ "kind": "tool", "label": "rule_matches", "detail": "0" }), ); @@ -419,7 +465,7 @@ pub async fn rule_matches(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolRe } else { text }; - ( + ToolResult::new( text, json!({ "kind": "tool", "label": "rule_matches", "detail": format!("{total} entities") }), ) @@ -437,10 +483,11 @@ pub async fn changes(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult .and_then(utopia_extract::parse_time) .map(|(t, p)| period_last_day(t.date_naive(), p)); let Some((since, until, window)) = changes_window(since, until, chrono::Utc::now()) else { - return ( + return ToolResult::new( "Invalid or missing `since` (expected YYYY-MM-DD).".to_string(), json!({ "kind": "changes", "label": "?", "detail": "invalid since" }), - ); + ) + .error(); }; let entity = args["entity_id"] .as_str() @@ -451,7 +498,7 @@ pub async fn changes(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult .collect() }); let kinds = kinds.filter(|k: &Vec| !k.is_empty()); - let rows = utopia_store::graph::graph_changes( + let rows = match utopia_store::graph::graph_changes( &ctx.state.pool, ctx.kb_id, since, @@ -461,7 +508,17 @@ pub async fn changes(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult CHANGES_LIMIT, ) .await - .unwrap_or_default(); + { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(error = %e, "Graph changes lookup failed"); + return ToolResult::new( + "Could not read the graph changes.".into(), + json!({"kind": "changes", "label": window, "detail": "failed"}), + ) + .error(); + } + }; let text = if rows.is_empty() { format!("No recorded changes in {window}.") } else { @@ -472,10 +529,24 @@ pub async fn changes(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult } else { format!("{} changes", rows.len()) }; - ( + ToolResult::new( text, json!({ "kind": "changes", "label": window, "detail": detail }), ) + .structured(json!({ + "kb_id": ctx.kb_id, "since": since, "until": until, + "limit": CHANGES_LIMIT, "limit_reached": rows.len() as i64 == CHANGES_LIMIT, + "changes": rows.iter().map(|r| json!({ + "fact_id": r.fact_id, "at": r.at, "kind": r.kind, + "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, + "valid_from": r.valid_from, "valid_to": r.valid_to, + "valid_from_precision": r.valid_from_precision, + "valid_to_precision": r.valid_to_precision, + "document_id": r.document_id, "filename": r.filename, "quote": r.quote, + })).collect::>() + })) } pub async fn query_data(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult { @@ -507,7 +578,7 @@ pub async fn query_data(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResu } else { purpose.to_string() }; - ( + ToolResult::new( text, json!({ "kind": "query", "label": ds_name, "detail": detail }), ) @@ -537,7 +608,7 @@ pub async fn remember(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult } }; if text.is_empty() { - return ( + return ToolResult::new( "remember requires non-empty text.".to_string(), json!({ "kind": "tool", "label": "remember", "detail": "empty" }), ); @@ -559,7 +630,7 @@ pub async fn remember(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult ) .await; ctx.state.emit_document(ctx.kb_id, doc_id); - ( + ToolResult::new( format!( "Recorded the sentence (effective {}): {text}\n\ Facts extracted from it will be shown to the user for confirmation \ @@ -576,7 +647,7 @@ pub async fn remember(ctx: &ToolCtx<'_>, args: &serde_json::Value) -> ToolResult }), ) } - Err(e) => ( + Err(e) => ToolResult::new( format!("Failed to record: {e}"), json!({ "kind": "tool", "label": "remember", "detail": "failed" }), ), @@ -1057,6 +1128,10 @@ mod tests { fn attribute_fact(value: serde_json::Value) -> EntityFact { EntityFact { + recorded_at: chrono::Utc::now(), + invalidated_at: None, + supersedes: None, + document_ids: vec![], id: Uuid::nil(), direction: "out".into(), predicate_key: Some("salary".into()), diff --git a/crates/utopia-server/src/api/tools_graph.rs b/crates/utopia-server/src/api/tools_graph.rs index 39d1ff0b7..b3898b39c 100644 --- a/crates/utopia-server/src/api/tools_graph.rs +++ b/crates/utopia-server/src/api/tools_graph.rs @@ -105,7 +105,10 @@ async fn resolve(ctx: &ToolCtx<'_>, sink: &mut ToolSink, raw: &str) -> Result, needle: &str) -> bool { pub async fn find_entities(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> ToolResult { let name = args["name"].as_str().unwrap_or("").to_string(); - let hits = lookup(ctx, &name).await; + let hits = match lookup(ctx, &name).await { + Ok(hits) => hits, + Err(e) => { + tracing::warn!(error = %e, "Entity lookup failed"); + return ToolResult::new( + "Could not look up entities.".into(), + json!({"kind": "entity", "label": name, "detail": "failed"}), + ) + .error(); + } + }; let (ranked, by_question) = rank_by_question(ctx, rank(hits, &name), &name).await; let text = if ranked.is_empty() { "No matching entities.".to_string() @@ -202,10 +215,18 @@ pub async fn find_entities(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) for n in &ranked { remember(sink, n); } - ( + ToolResult::new( text, json!({ "kind": "entity", "label": name, "detail": format!("{} matches", ranked.len()) }), ) + .structured(json!({ + "kb_id": ctx.kb_id, + "entities": ranked.iter().map(|n| json!({ + "id": n.id, "name": n.name, "type_key": n.type_key, + "type_label": n.type_label, "disambiguator": n.disambiguator, + "fact_count": n.degree, + })).collect::>() + })) } // ---- entity_facts ---------------------------------------------------------------- @@ -315,22 +336,20 @@ pub(super) fn predicates_of(facts: &[EntityFact]) -> String { /// 子串找不到时按词找:每个词各去库里捞一把,名字里含的词数达到「全部减一、至少两个」 /// 的候选算命中("OpenAI board members" → "OpenAI's board of directors")。 /// 模型给的名字常带一个库里没有的词(members、公司、这个),全词命中会把它们全漏掉 -async fn lookup(ctx: &ToolCtx<'_>, raw: &str) -> Vec { - let (hits, _) = utopia_store::graph::search_entities(&ctx.state.pool, ctx.kb_id, raw, 8, 0) - .await - .unwrap_or_default(); +async fn lookup(ctx: &ToolCtx<'_>, raw: &str) -> utopia_core::AppResult> { + let (hits, _) = + utopia_store::graph::search_entities(&ctx.state.pool, ctx.kb_id, raw, 8, 0).await?; if !hits.is_empty() { - return hits; + return Ok(hits); } let words: Vec<&str> = raw.split_whitespace().filter(|w| w.len() >= 2).collect(); if words.len() < 2 { - return hits; + return Ok(hits); } let mut pool: Vec = Vec::new(); for w in &words { - let (found, _) = utopia_store::graph::search_entities(&ctx.state.pool, ctx.kb_id, w, 40, 0) - .await - .unwrap_or_default(); + let (found, _) = + utopia_store::graph::search_entities(&ctx.state.pool, ctx.kb_id, w, 40, 0).await?; for n in found { if !pool.iter().any(|p| p.id == n.id) { pool.push(n); @@ -344,7 +363,7 @@ async fn lookup(ctx: &ToolCtx<'_>, raw: &str) -> Vec { .filter(|(s, _)| *s >= need) .collect(); scored.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.degree.cmp(&a.1.degree))); - scored.into_iter().map(|(_, n)| n).take(8).collect() + Ok(scored.into_iter().map(|(_, n)| n).take(8).collect()) } /// 名字里含了几个词(大小写不敏感) @@ -528,28 +547,40 @@ pub async fn entity_facts(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) let who = match resolve(ctx, sink, raw).await { Ok(r) => r, Err(e) => { - return ( + return ToolResult::new( format!( "Invalid entity: {e} (expected a name, or the uuid returned by find_entities)." ), json!({ "kind": "facts", "label": "?", "detail": "invalid id" }), ) + .error() } }; let m = moments(args); let filter = FactFilter::from_args(args); let limit = limit_of(args, FACTS_DEFAULT); - let Ok((node, facts)) = - utopia_store::graph::entity_detail(&ctx.state.pool, ctx.kb_id, who.id, m.at, m.as_of).await - else { - return ( - "Entity not found.".to_string(), - json!({ "kind": "facts", "label": "?", "detail": "not found" }), - ); - }; + let (node, facts) = + match utopia_store::graph::entity_detail(&ctx.state.pool, ctx.kb_id, who.id, m.at, m.as_of) + .await + { + Ok(detail) => detail, + Err(e) => { + let text = if matches!(e, utopia_core::AppError::NotFound) { + "Entity not found." + } else { + tracing::warn!(error = %e, "Entity facts lookup failed"); + "Could not read the entity facts." + }; + return ToolResult::new( + text.into(), + json!({"kind": "facts", "label": "?", "detail": "failed"}), + ) + .error(); + } + }; // 规则的结论也是这个实体的一部分(0021)。**不给的话模型会拿那些读数自己再判 // 一遍**——而阈值写在规则里,它看不见,于是两处判断迟早不一致 - let derived = + let derived = match // 两根轴一起传(#549):as_of 回到三月,派生也回到三月 utopia_store::reasoning::derived_for_entity( &ctx.state.pool, @@ -558,9 +589,15 @@ pub async fn entity_facts(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) m.at, m.as_of, ) - .await - .unwrap_or_default(); - let derived: Vec = derived + .await { + Ok(derived) => derived, + Err(e) => { + tracing::warn!(error = %e, "Derived facts lookup failed"); + return ToolResult::new("Could not read the derived facts.".into(), + json!({"kind": "facts", "label": node.name, "detail": "failed"})).error(); + } + }; + let derived_lines: Vec = derived .iter() .map(|d| { format!( @@ -624,13 +661,50 @@ pub async fn entity_facts(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) )); } } - lines.extend(derived); + lines.extend(derived_lines); } let detail = entity_facts_detail(shown.len(), m.at, m.as_of, m.before); - ( + ToolResult::new( lines.join("\n"), json!({ "kind": "facts", "label": node.name, "detail": detail }), ) + .structured(json!({ + "kb_id": ctx.kb_id, + "entity": {"id": node.id, "name": node.name, + "type_key": node.type_key, "type_label": node.type_label}, + "at": m.at, "as_of": m.as_of, "before": m.before, + "total_facts": facts.len(), "matched_facts": kept.len(), + "limit": limit, "truncated": shown.len() < kept.len(), + "facts": shown.iter().map(|f| json!({ + "id": f.id, "direction": f.direction, + "predicate_key": f.predicate_key, "predicate_label": f.predicate_label, + "inferred": f.inferred, "temporal": f.temporal, + "other_id": f.other_id, "other_name": f.other_name, + "object_value": f.object_value, "confidence": f.confidence, + "qualifiers": f.qualifiers.iter().map(|q| json!({ + "qualifier_type_id": q.qualifier_type_id, "key": q.key, "label": q.label, + "value": q.value, "entity_id": q.entity_id, "entity_name": q.entity_name, + })).collect::>(), + "valid_from": f.valid_from, "valid_to": f.valid_to, + "valid_from_precision": f.valid_from_precision, + "valid_to_precision": f.valid_to_precision, + "holds_from": f.holds_from, "holds_to": f.holds_to, + "recorded_at": f.recorded_at, "invalidated_at": f.invalidated_at, + "supersedes": f.supersedes, "document_ids": f.document_ids, + })).collect::>(), + "derived_facts": derived.iter().map(|d| json!({ + "id": d.id, "subject_id": d.subject_id, "subject": d.subject, + "predicate_id": d.predicate_id, "predicate": d.predicate, + "object_id": d.object_id, "object": d.object, "object_value": d.object_value, + "rule": d.rule, "rule_name": d.rule_name, + "rule_id": d.rule_id, "attribute_rule_id": d.attribute_rule_id, + "valid_from": d.valid_from, "valid_to": d.valid_to, + "valid_from_precision": d.valid_from_precision, + "valid_to_precision": d.valid_to_precision, + "derived_at": d.derived_at, "invalidated_at": d.invalidated_at, + "confidence": d.confidence, + })).collect::>() + })) } // ---- neighbors ------------------------------------------------------------------- @@ -643,7 +717,7 @@ pub async fn neighbors(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> let who = match resolve(ctx, sink, raw).await { Ok(r) => r, Err(e) => { - return ( + return ToolResult::new( format!("Unknown entity: {e}."), json!({ "kind": "neighbors", "label": "?", "detail": "unknown entity" }), ) @@ -655,7 +729,7 @@ pub async fn neighbors(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> let Ok((node, facts)) = utopia_store::graph::entity_detail(&ctx.state.pool, ctx.kb_id, who.id, m.at, m.as_of).await else { - return ( + return ToolResult::new( "Entity not found.".to_string(), json!({ "kind": "neighbors", "label": "?", "detail": "not found" }), ); @@ -734,7 +808,7 @@ pub async fn neighbors(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> } } let detail = format!("{} of {} linked", shown.len(), linked.len()); - ( + ToolResult::new( lines.join("\n"), json!({ "kind": "neighbors", "label": node.name, "detail": detail }), ) @@ -750,7 +824,7 @@ pub async fn timeline(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> T let who = match resolve(ctx, sink, raw).await { Ok(r) => r, Err(e) => { - return ( + return ToolResult::new( format!("Unknown entity: {e}."), json!({ "kind": "timeline", "label": "?", "detail": "unknown entity" }), ) @@ -762,7 +836,7 @@ pub async fn timeline(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> T let Ok((node, facts)) = utopia_store::graph::entity_detail(&ctx.state.pool, ctx.kb_id, who.id, None, m.as_of).await else { - return ( + return ToolResult::new( "Entity not found.".to_string(), json!({ "kind": "timeline", "label": "?", "detail": "not found" }), ); @@ -821,7 +895,7 @@ pub async fn timeline(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) -> T } } let detail = format!("{} of {} dated", shown.len(), dated.len()); - ( + ToolResult::new( lines.join("\n"), json!({ "kind": "timeline", "label": node.name, "detail": detail }), ) @@ -879,7 +953,7 @@ pub async fn paths_between(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) ends.push(r); } Err(e) => { - return ( + return ToolResult::new( format!("Unknown `{key}`: {e}."), json!({ "kind": "path", "label": "?", "detail": "unknown entity" }), ) @@ -910,7 +984,7 @@ pub async fn paths_between(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) { Ok(p) => p, Err(e) => { - return ( + return ToolResult::new( format!("Path search failed: {e}"), json!({ "kind": "path", "label": "?", "detail": "failed" }), ) @@ -932,7 +1006,7 @@ pub async fn paths_between(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) from.name, to.name )); - return ( + return ToolResult::new( lines.join("\n"), json!({ "kind": "path", "label": label, "detail": "no path" }), ); @@ -954,7 +1028,7 @@ pub async fn paths_between(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value) if paths.len() == 1 { "" } else { "s" }, if shortest == 1 { "" } else { "s" } ); - ( + ToolResult::new( lines.join("\n"), json!({ "kind": "path", "label": label, "detail": detail }), ) @@ -1057,6 +1131,10 @@ mod tests { fn fact(direction: &str, pred: &str, other: &str, other_type: Option<&str>) -> EntityFact { EntityFact { + recorded_at: chrono::Utc::now(), + invalidated_at: None, + supersedes: None, + document_ids: vec![], id: Uuid::now_v7(), direction: direction.into(), predicate_key: Some(pred.into()), diff --git a/crates/utopia-server/src/rdf.rs b/crates/utopia-server/src/rdf.rs index fc18a998c..824d420e6 100644 --- a/crates/utopia-server/src/rdf.rs +++ b/crates/utopia-server/src/rdf.rs @@ -590,6 +590,10 @@ pub fn emit_derived( let p = names.fact(*premise); sink.r(&stmt, &prov("used"), &p)?; } + for premise in &d.premises_derived { + let p = names.derived(*premise); + sink.r(&stmt, &prov("used"), &p)?; + } Ok(()) } @@ -1037,26 +1041,31 @@ mod tests { rule: "transitive".into(), rule_name: None, premises: vec![id(5)], - premises_derived: Vec::new(), + premises_derived: vec![id(6)], }; - let quads = export(Format::Turtle, |sink, names, vocab| { - emit_derived(sink, names, vocab, &derived).unwrap(); - }); - let stmt = ""; - assert!(has( - &quads, - stmt, - "urn:utopia:ns:derived", - "\"true\"^^" - )); - assert_eq!( - objects(&quads, stmt, "http://www.w3.org/ns/prov#used"), - vec![STMT] - ); - assert!( - !has(&quads, SUBJ, WORKS_FOR, OBJ), - "推出来的边不写成平铺三元组:那会让人把引擎的结论当成文档里的话" - ); + for format in [Format::Turtle, Format::JsonLd] { + let quads = export(format, |sink, names, vocab| { + emit_derived(sink, names, vocab, &derived).unwrap(); + }); + let stmt = ""; + assert!(has( + &quads, + stmt, + "urn:utopia:ns:derived", + "\"true\"^^" + )); + assert_eq!( + objects(&quads, stmt, "http://www.w3.org/ns/prov#used"), + vec![ + STMT.to_string(), + Names::new(kb(), None).unwrap().derived(id(6)).to_string() + ] + ); + assert!( + !has(&quads, SUBJ, WORKS_FOR, OBJ), + "推出来的边不写成平铺三元组:那会让人把引擎的结论当成文档里的话" + ); + } } #[test] diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index 3561cc76e..ad6e74c70 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -1016,7 +1016,10 @@ pub async fn entity_detail( .ok_or(AppError::NotFound)?; let mut facts: Vec = sqlx::query_as(&format!( - "SELECT f.id, + "SELECT f.id, f.recorded_at, f.invalidated_at, f.supersedes, + ARRAY(SELECT DISTINCT fe.document_id FROM fact_evidence fe + WHERE fe.fact_id = f.id AND fe.document_id IS NOT NULL + ORDER BY fe.document_id) AS document_ids, CASE WHEN {subject} = $2 THEN 'out' ELSE 'in' END AS direction, COALESCE(r.key, fact_surface_predicate(f.id)) AS predicate_key, COALESCE(r.label, fact_surface_predicate(f.id)) AS predicate_label, diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 3a691a911..174f80b67 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -2407,7 +2407,8 @@ async fn derived_one( derived_id: Uuid, ) -> AppResult> { Ok(sqlx::query_as( - "SELECT d.id, + "SELECT d.id, d.predicate_id, d.object_value, d.rule_id, d.attribute_rule_id, + d.invalidated_at, d.valid_from_precision, d.valid_to_precision, d.subject_id, s.canonical_name AS subject, d.object_id, COALESCE(o.canonical_name, ct.label, @@ -2470,7 +2471,8 @@ pub async fn derived_for_entity( // // 宾语的显示文本因此有三个来源:实体名、归类结论里的类标签、属性结论的值。 Ok(sqlx::query_as(&format!( - "SELECT d.id, + "SELECT d.id, d.predicate_id, d.object_value, d.rule_id, d.attribute_rule_id, + d.invalidated_at, d.valid_from_precision, d.valid_to_precision, d.subject_id, s.canonical_name AS subject, d.object_id, COALESCE(o.canonical_name, diff --git a/docs/decisions/0020-an-auditor-reads-it-without-us.md b/docs/decisions/0020-an-auditor-reads-it-without-us.md index d4e6332cc..f47041c56 100644 --- a/docs/decisions/0020-an-auditor-reads-it-without-us.md +++ b/docs/decisions/0020-an-auditor-reads-it-without-us.md @@ -12,6 +12,13 @@ Not another Utopia. A regulator, an auditor, or the team's own graph tooling: so ## Identity +The RDF mapping is the supported external machine-readable read contract +(clarified in [#550](https://github.com/deeplethe/utopia/issues/550)). A breaking +change needs a decision record explaining why; the internal tables and UI API +shapes are not that boundary. MCP may return the same UUIDs alongside its prose +so a caller can join an agent result to this export. The user-facing contract is +documented in [Agents over MCP](../../web/src/docs/mcp.md#the-external-read-contract). + Classes and relations that came in from an import **keep the IRI they came with**. A base built from the schema.org pack exports `schema:Organization`, not a Utopia mint of it, so the file lines up with the vocabulary the reader already has. That is the payoff for having stored `iri` on `entity_types` and `relation_types` since the first import. Everything else is minted under a base namespace: entities, documents, and one IRI per fact. Facts get their own IRI rather than blank nodes because a statement someone may have to cite has to be addressable, and because `supersedes` needs somewhere to point. A class or relation the ontology grew itself is minted from its **key** rather than its uuid — the key is what the base already uses as its identifier (`UNIQUE (kb_id, key)`, and it is what the extraction prompt and the API speak), so the file stays readable; a rename is the one event that moves such an IRI, and renaming a class is rarer than reading the export. @@ -42,6 +49,13 @@ One of the five built-in packs is PROV-O, so a base that has it loaded already k ## What is not here +- **Conflict/review state and chunk identity behind a quote.** Quotes and source + documents are exported, but these are separate gaps; conflict state is tracked + in [#564](https://github.com/deeplethe/utopia/issues/564). +- **Historical proof snapshots.** Premise links can be rewritten when a conclusion + is reproved. The record-time lifetime survives; earlier versions of the proof + do not (0019). RDF's `prov:used` edges identify premises, not their sequence. + - **A SPARQL endpoint.** The escape hatch over an in-memory Oxigraph projection was the original plan and stays a later cut. Someone who asked for "the reasoning behind this decision" wants a file they can keep; a query endpoint is the second thing they ask for, not the first. - **Import of our own export.** The export is not a backup format, and reading it back would need entity resolution to be told "these IRIs are already resolved". Nothing stops it later; nothing depends on it now. - **A button.** The API is the deliverable; where the download lives in the interface is a separate cut. diff --git a/web/src/docs/mcp.md b/web/src/docs/mcp.md index 2fa0204b4..ea763bc3e 100644 --- a/web/src/docs/mcp.md +++ b/web/src/docs/mcp.md @@ -50,6 +50,89 @@ Three methods are served: 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. +## The external read contract + +**RDF export is the supported machine-readable read contract.** +`GET /api/v1/kbs/{kb_id}/export?format=turtle|jsonld` streams the whole base, +including retracted and corrected assertions, both time axes, quoted evidence, +source documents, and derivations linked to their rules and premise statements. +It uses RDF reification, PROV-O and schema.org. This mapping is the compatibility +boundary: a breaking change needs a decision record explaining why. + +Entities, assertions, derivations and documents have UUID-based IRIs: +`urn:utopia:kb:{kb_id}:entity:{id}`, `…:fact:{id}`, `…:derived:{id}` and +`…:document:{id}`. The same stored object keeps its identity across exports; +rebuilding a graph is not an identity-preserving operation. `?base=https://example.org/` +instead mints `https://example.org/kb/{kb_id}/{kind}/{id}`. Keep the same base when +joining exports. Imported classes and relations retain their original IRIs. + +MCP remains the agent-facing surface. The structured results below use the same +UUIDs, so an integration can join a selected result to the exported ledger. +Ordinary `/api/v1` UI response shapes are **not** a compatibility promise: they +have no OpenAPI contract or deprecation policy. The export route above is the +explicit exception, not a promise covering every route with that prefix. + +The export does not yet include conflict/review state ([#564](https://github.com/deeplethe/utopia/issues/564)) +or the chunk identity behind a quote. Per-entity export and a SPARQL endpoint are +also not implemented. Neither MCP nor the export promises historical proof +snapshots: `as_of` selects the derivations held then, but proofs use the current +premise links. Source-document links are the stored provenance, not a separately +versioned snapshot of the evidence set. + +## Structured results alongside the text + +`find_entities`, `search_chunks`, `entity_facts` and `changes` return a JSON object +in `result.structuredContent`, beside the unchanged human-readable `content`. +No extra argument or protocol upgrade is needed. Other tools remain text-only. +Read the JSON field directly; the text is presentation, not a format to parse. + +Every structured result includes `kb_id`. IDs are opaque UUID strings, timestamps +are RFC3339 with fractional seconds preserved, unknown optional values are `null`, +and empty collections are `[]`. Clients should tolerate additional fields. +Failures have `isError: true` and no structured success payload; an empty result +is a successful read, not an error. + +| Tool | Structured fields | +|---|---| +| `find_entities` | `entities[]`, in the same ranked order as the text: `id`, `name`, `type_key`, `type_label`, `disambiguator`, `fact_count`. 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`, `truncated`, `facts[]`, `derived_facts[]` | +| `changes` | Effective `since` (inclusive) and `until` (exclusive), `limit`, `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` | + +An asserted `facts[]` entry contains `id`, `direction`, `predicate_key`, +`predicate_label`, `inferred`, `temporal`, `other_id`, `other_name`, `object_value`, +`confidence`, `valid_from`, `valid_to`, `valid_from_precision`, `valid_to_precision`, +`holds_from`, `holds_to`, `recorded_at`, `invalidated_at`, `supersedes`, and +deduplicated `document_ids`. `direction=out` means the requested entity is the +subject; `in` means it is the object. `object_value` retains the JSON value and +unit rather than formatting them into a string. `inferred` describes an +unaccepted predicate name; it does **not** mean the fact is derived. +`qualifiers[]` preserves attributes attached to the relation itself: each has +`qualifier_type_id`, `key`, `label`, raw `value`, `entity_id`, and `entity_name`. + +A `derived_facts[]` entry contains `id`, `subject_id`, `subject`, `predicate_id`, +`predicate`, `object_id`, `object`, raw `object_value`, `rule`, `rule_name`, +`rule_id`, `attribute_rule_id`, `confidence`, `valid_from`, `valid_to`, both +validity precisions, `derived_at`, and `invalidated_at`. Rule IDs distinguish +axiom rules from business rules. The derivation ID joins to `…:derived:{id}` in +RDF to follow its proof; this result does not expand the proof tree. + +`valid_*` describes stated world time; `holds_*` is the interpreted interval used +for filtering assertions. `recorded_at` (or `derived_at`) and `invalidated_at` +describe the stored record-time lifetime. A later invalidation can be present +even when reading a row held at an earlier `as_of`. `before=T` takes precedence +over `as_of` and reports the effective cutoff `T − 1µs`. A `changes[].at` can be +passed to `before` without losing precision. Events have no separate event UUID; +`fact_id` identifies the affected assertion, and can appear in multiple events. + +The structure follows the current text selection: assertion filters and the +default 80-fact limit (maximum 300) apply to `facts[]`; derived conclusions only +use `at`/`as_of`/`before` and are listed separately without that limit. `truncated` +reports omitted matching assertions. Search returns at most six chunks and +changes at most 40 events; `limit_reached` means the cap was reached, not that +another result is known to exist. Narrow the query/window to inspect more. +Historical full-text search retains the recall limitation described above. + ## What an agent records waits for a nod `remember` stores the sentence immediately — searchable at once, attributed to the token's owner. The facts extracted from it do **not** enter the graph. They queue in Review as proposals, each shown beneath the sentence it came from, and a person confirms or rejects them one at a time.