diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index eab2af2a3..4f6489b0e 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -185,7 +185,7 @@ pub async fn entity_detail( let same_name = utopia_store::graph::same_name_peers(&state.pool, kb_id, entity_id).await?; // 没落地的派生(0017 §3)也单独一个键:它们连 `derived_facts` 都不在 let blocked = - utopia_store::reasoning::blocked_for_entity(&state.pool, kb_id, entity_id).await?; + utopia_store::reasoning::blocked_for_entity(&state.pool, kb_id, entity_id, as_of).await?; // 名字也单独一个键(0041):本名、简称、曾用名,各带出处与有效期 let names = utopia_store::names::for_entity(&state.pool, kb_id, entity_id, as_of).await?; Ok(Json(json!({ diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 0eb088156..8c2b2e823 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -2430,14 +2430,26 @@ async fn steps_for( Ok(steps) } -/// 没落地的派生里,与这个实体有关的那些(0017 §3)——面板「推出来的」一档的 -/// 「没落地的」小节。 +/// 没落地的派生里,与这个实体有关、**当时**还开着的那些(0017 §3)——面板 +/// 「推出来的」一档的「没落地的」小节。 +/// +/// 面板上的 `blocked` 那一档跟其它键走同一个 `as_of`(`#549` 把 `derived` +/// 那一档接通、`#307` 把剩下的接上):一个三月被推翻的违规在回放中的面板 +/// 上不该留着幽灵边。`v.status = 'open'` 不区分「现在开着」与「三月还开 +/// 着、四月才被人关了」——`violation_open_at` 是答案。 +/// +/// 写路径不走这里(`decide_violation` 等)。 pub async fn blocked_for_entity( pool: &PgPool, kb_id: Uuid, entity_id: Uuid, + as_of: Option>, ) -> AppResult> { - Ok(sqlx::query_as( + let violation_open = match as_of { + Some(_) => crate::record_axis::violation_open_at("v", 3), + None => "v.status = 'open'".to_string(), + }; + Ok(sqlx::query_as(&format!( "SELECT v.id AS violation_id, (v.detail->>'subject_id')::uuid AS subject_id, COALESCE(v.detail->>'subject', '?') AS subject, @@ -2458,12 +2470,13 @@ pub async fn blocked_for_entity( JOIN entities s ON s.id = f.subject_id LEFT JOIN relation_types r ON r.id = f.predicate_id LEFT JOIN entities o ON o.id = f.object_id - WHERE v.kb_id = $1 AND v.kind = 'derived_contradiction' AND v.status = 'open' + WHERE v.kb_id = $1 AND v.kind = 'derived_contradiction' AND {violation_open} AND (v.detail->>'subject_id' = $2::text OR v.detail->>'object_id' = $2::text) ORDER BY v.detected_at DESC", - ) + )) .bind(kb_id) .bind(entity_id) + .bind(as_of) .fetch_all(pool) .await?) } diff --git a/crates/utopia-store/tests/a_contradiction_points_upstream.rs b/crates/utopia-store/tests/a_contradiction_points_upstream.rs index 9ab4af5f7..365589758 100644 --- a/crates/utopia-store/tests/a_contradiction_points_upstream.rs +++ b/crates/utopia-store/tests/a_contradiction_points_upstream.rs @@ -258,7 +258,7 @@ async fn a_contradiction_points_upstream() -> anyhow::Result<()> { assert_eq!((ghost.source, ghost.target), (f.mira, f.acme)); assert!(edges.iter().find(|e| e.id == old).unwrap().contested); assert!(!edges.iter().find(|e| e.id == ceo).unwrap().contested); - let blocked = reasoning::blocked_for_entity(&pool, f.kb, f.acme).await?; + let blocked = reasoning::blocked_for_entity(&pool, f.kb, f.acme, None).await?; assert_eq!(blocked.len(), 1); assert_eq!(blocked[0].violation_id, *vid); assert_eq!(blocked[0].against_fact, old); diff --git a/crates/utopia-store/tests/blocked_for_entity_respects_as_of.rs b/crates/utopia-store/tests/blocked_for_entity_respects_as_of.rs new file mode 100644 index 000000000..20a692da9 --- /dev/null +++ b/crates/utopia-store/tests/blocked_for_entity_respects_as_of.rs @@ -0,0 +1,230 @@ +//! 面板的「没落地的派生」按记录轴回放(#307 / 0019)。 +//! +//! 之前 `blocked_for_entity` 写死 `v.status = 'open'`:三月被发现、四月被人 +//! 裁掉的违规,在回放中的面板上仍然挂着——而该行此刻在 `axiom_violations` +//! 上已经是 `resolved`。`violation_open_at` 是答案:和 0031 同款的「判定 +//! 在 T 时刻还开着」问法。 +//! +//! 三个方向都要钉: +//! +//! - **当下还开着的** 在所有时刻都出现(`status = 'open'` 与 held_at 同真) +//! - **当下被裁的** 在 `decided_at` 之前仍出现,之后消失 +//! - **当下还没检测到的** 在 `detected_at` 之前不出现 +//! +//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 + +use sqlx::PgPool; +use uuid::Uuid; + +fn t(s: &str) -> chrono::DateTime { + s.parse().unwrap() +} + +#[tokio::test] +async fn blocked_for_entity_respects_as_of() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let (org, ws, kb, user, person_type, works_at) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + let mira = Uuid::now_v7(); + let acme = Uuid::now_v7(); + let ceo_of = Uuid::now_v7(); + let ceo_fact_a = Uuid::now_v7(); + let ceo_fact_b = Uuid::now_v7(); + let works_at_fact = Uuid::now_v7(); + let still_open_violation = Uuid::now_v7(); + let resolved_violation = Uuid::now_v7(); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'as-of-blocked-test')") + .bind(org) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'as-of-blocked-test')") + .bind(ws) + .bind(org) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'as-of-blocked-test')", + ) + .bind(kb) + .bind(ws) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO users (id, org_id, email, display_name, password_hash) + VALUES ($1, $2, $3, 'Blocked Tester', 'x')", + ) + .bind(user) + .bind(org) + .bind(format!("blocked-{}@test.local", user.simple())) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'person', 'Person')", + ) + .bind(person_type) + .bind(kb) + .execute(&pool) + .await?; + for (id, name) in [(mira, "Mira"), (acme, "Acme")] { + sqlx::query( + "INSERT INTO entities (id, kb_id, canonical_name, created_at) + VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(kb) + .bind(name) + .bind(t("2026-01-01T00:00:00Z")) + .execute(&pool) + .await?; + } + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label) VALUES ($1, $2, 'works_at', 'works at')", + ) + .bind(works_at) + .bind(kb) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label) VALUES ($1, $2, 'ceo_of', 'ceo of')", + ) + .bind(ceo_of) + .bind(kb) + .execute(&pool) + .await?; + // Mira 上的断言事实:被两条违规拿来当 left_fact + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $5, 0.9)", + ) + .bind(works_at_fact) + .bind(kb) + .bind(mira) + .bind(works_at) + .bind(acme) + .execute(&pool) + .await?; + // 派生事实:被两条违规拿来当 right_fact(得是事实才能填 FK) + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $5, 0.9)", + ) + .bind(ceo_fact_a) + .bind(kb) + .bind(mira) + .bind(ceo_of) + .bind(acme) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $5, 0.9)", + ) + .bind(ceo_fact_b) + .bind(kb) + .bind(mira) + .bind(ceo_of) + .bind(acme) + .execute(&pool) + .await?; + // 「现在还开着」:3 月检测至今未裁 + sqlx::query( + "INSERT INTO axiom_violations (id, kb_id, kind, left_fact, right_fact, status, + detail, detected_at) + VALUES ($1, $2, 'derived_contradiction', $3, $4, 'open', + jsonb_build_object('subject_id', $5::text, 'object_id', $6::text, + 'predicate', 'works at'), + $7)", + ) + .bind(still_open_violation) + .bind(kb) + .bind(works_at_fact) + .bind(ceo_fact_a) + .bind(mira) + .bind(acme) + .bind(t("2026-03-15T00:00:00Z")) + .execute(&pool) + .await?; + // 「三月检测、四月被人裁掉」:当前 status=resolved,decided_at=4 月 + sqlx::query( + "INSERT INTO axiom_violations (id, kb_id, kind, left_fact, right_fact, status, + resolution, decided_at, + detail, detected_at) + VALUES ($1, $2, 'derived_contradiction', $3, $4, 'resolved', + 'fact_retracted', $7, + jsonb_build_object('subject_id', $5::text, 'object_id', $6::text, + 'predicate', 'works at'), + $8)", + ) + .bind(resolved_violation) + .bind(kb) + .bind(works_at_fact) + .bind(ceo_fact_b) + .bind(mira) + .bind(acme) + .bind(t("2026-04-15T00:00:00Z")) + .bind(t("2026-03-20T00:00:00Z")) + .execute(&pool) + .await?; + + let ids = |rows: Vec| -> Vec { + rows.into_iter().map(|r| r.violation_id).collect() + }; + let ids_sorted = |rows: Vec| -> Vec { + let mut v = ids(rows); + v.sort(); + v + }; + + let blocks = |when: Option>| { + let pool = pool.clone(); + async move { utopia_store::reasoning::blocked_for_entity(&pool, kb, mira, when).await } + }; + + // 当下:resolved 那条已裁,open 那条留着 → 只有 still_open + let now = blocks(None).await?; + assert_eq!( + ids_sorted(now), + vec![still_open_violation], + "当下:open 一条" + ); + + // 2 月:两条都还没检测到 → 都没有 + let feb = blocks(Some(t("2026-02-01T00:00:00Z"))).await?; + assert!(feb.is_empty(), "2 月:检测之前都不到"); + + // 3 月 25 日:resolved 在 decided_at 之前仍开着;still_open 在 → 两条 + let late_mar = blocks(Some(t("2026-03-25T00:00:00Z"))).await?; + assert_eq!( + ids_sorted(late_mar), + vec![still_open_violation, resolved_violation], + "3 月 25 日:resolved 还在、still_open 还在" + ); + + // 5 月:resolved 在 decided_at 之后消失、still_open 还在 → 一条 + let may = blocks(Some(t("2026-05-01T00:00:00Z"))).await?; + assert_eq!( + ids_sorted(may), + vec![still_open_violation], + "5 月:resolved 已裁、still_open 还在" + ); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user) + .execute(&pool) + .await?; + sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(org) + .execute(&pool) + .await?; + Ok(()) +}