diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs index 63b195c2..459459fb 100644 --- a/crates/utopia-core/src/models.rs +++ b/crates/utopia-core/src/models.rs @@ -987,6 +987,16 @@ pub struct AxiomViolation { /// 审核线索(0017 §2):`stale`(旧断言没写结束日期)、`duplicate`(有同名 /// 实体)、`unsure`(抽取置信度低)。只给一条,没有就空 pub hint: Option, + /// 环上的每一条事实,按顺序(其余种类为空)。**逐条给 id**:撤事实要说撤哪条, + /// 而环上哪条错了只有人看了才知道(#202) + pub path: Vec, +} + +/// 违规里的一条事实:id 与三元组文本 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ViolationFact { + pub id: Uuid, + pub text: String, } /// 本体自己的一处自相矛盾(见 `ontology_defects`)。 diff --git a/crates/utopia-server/src/api/review_routes.rs b/crates/utopia-server/src/api/review_routes.rs index c6fc951d..e44aef68 100644 --- a/crates/utopia-server/src/api/review_routes.rs +++ b/crates/utopia-server/src/api/review_routes.rs @@ -502,6 +502,9 @@ pub struct DecideViolationReq { /// `fact_closed` 必填:旧断言在哪一天结束 #[serde(default)] pub close_at: Option>, + /// `fact_retracted` 时撤哪条:双事实与环上的违规必填,单事实的可省(#202) + #[serde(default)] + pub fact_id: Option, } /// 人裁决一处公理违规。 @@ -510,8 +513,9 @@ pub struct DecideViolationReq { /// 而不是数据——用户导的本体把某个属性声明成反对称,而他自己的语料里那关系 /// 其实双向。这时该改的是本体,不是二十条事实。 /// -/// 端点只记决定,不替人执行。撤事实走 `reject_fact`,改公理走本体页—— -/// 那两个动作各有自己的权限与台账,塞进这里会变成一个什么都能干的端点。 +/// **「数据错了」真的撤事实**(#202):此前只记决定,事实照样活在图里,而队列又 +/// 不再提它。撤哪条由请求指名(`fact_id`),单事实的种类可省;撤完重算一遍检查, +/// 那条事实牵连的其它违规一并清掉。改公理仍然走本体页——那是另一个页面的事。 pub async fn decide_violation( State(state): State, AuthUser(user): AuthUser, @@ -529,8 +533,8 @@ pub async fn decide_violation( ) .into()); } - let row: Option<(String, Uuid)> = sqlx::query_as( - "SELECT kind, left_fact FROM axiom_violations + let row: Option<(String, Uuid, Uuid, Vec)> = sqlx::query_as( + "SELECT kind, left_fact, right_fact, path FROM axiom_violations WHERE id = $1 AND kb_id = $2 AND status = 'open'", ) .bind(violation_id) @@ -538,16 +542,33 @@ pub async fn decide_violation( .fetch_optional(&state.pool) .await .map_err(utopia_core::AppError::Db)?; - let Some((kind, left)) = row else { + let Some((kind, left, right, path)) = row else { return Err(utopia_core::AppError::NotFound.into()); }; - // 派生撞断言那一类(0017)的修法就在卡片上,端点替人执行:撤旧断言、或给它一个 - // 结束日期。其它几类仍只记决定——那些卡片上两条都是断言,撤哪条端点判不了 let repaired = kind == "derived_contradiction"; + // 撤事实的那条路自己会把违规标成 resolved;其余出路在下面统一记 + let mut decided = false; match (repaired, req.resolution.as_str()) { - (true, "fact_retracted") => { - let snap = fact_snapshot(&state, kb_id, left).await; - utopia_store::graph::reject_fact(&state.pool, kb_id, left).await?; + (_, "fact_retracted") => { + let Some(target) = + utopia_store::reasoning::pick_retraction(left, right, &path, req.fact_id) + else { + return Err(utopia_core::AppError::invalid( + "fact_required", + "这处违规涉及多条事实,要说撤哪一条,且只能是它列出的那几条", + ) + .into()); + }; + let snap = fact_snapshot(&state, kb_id, target).await; + utopia_store::reasoning::retract_from_violation( + &state.pool, + kb_id, + violation_id, + Some(target), + user.id, + ) + .await?; + decided = true; if let Some(d) = snap { let _ = utopia_store::audit::record( &state.pool, @@ -555,7 +576,7 @@ pub async fn decide_violation( user.id, "fact.reject", "fact", - Some(left), + Some(target), d, ) .await; @@ -609,13 +630,19 @@ pub async fn decide_violation( } _ => {} } - utopia_store::reasoning::decide(&state.pool, kb_id, violation_id, &req.resolution, user.id) - .await?; + if !decided { + utopia_store::reasoning::decide(&state.pool, kb_id, violation_id, &req.resolution, user.id) + .await?; + } // 路清了就让派生落地,人不必再去点一次「推一遍」。撤与闭合把断言挪开了, // 认可则在 materialize 里放行 if repaired { utopia_store::reasoning::materialize(&state.pool, kb_id).await?; } + // 撤掉的事实可能还挂在别的违规里:重算一遍,那些行随之清掉,队列不留死账 + if req.resolution == "fact_retracted" { + utopia_store::reasoning::run(&state.pool, kb_id).await?; + } let _ = utopia_store::audit::record( &state.pool, Some(kb_id), diff --git a/crates/utopia-store/src/reasoning.rs b/crates/utopia-store/src/reasoning.rs index 1e7b799c..755a3bff 100644 --- a/crates/utopia-store/src/reasoning.rs +++ b/crates/utopia-store/src/reasoning.rs @@ -43,6 +43,9 @@ pub struct Report { pub contradictions_capped: usize, /// 互撞的规则对数——进 `ontology_defects`,不进这张表 pub rules_disagree: usize, + /// 重新打开的 resolved 行:人曾说撤了、闭合了、要去改本体,而违规又算出来了—— + /// 承诺没兑现,队列不替人沉默(#202) + pub reopened: usize, } /// 单个谓词上进队列的矛盾上限(0017 §1)。超出的部分只计数。 @@ -318,9 +321,14 @@ pub async fn run(pool: &PgPool, kb_id: Uuid) -> AppResult { if id.is_some() { report.inserted += 1; } - // 无论新插的还是本来就在的,都算「这一轮仍然成立」 - let keep: (Uuid,) = sqlx::query_as( - "SELECT id FROM axiom_violations + // 无论新插的还是本来就在的,都算「这一轮仍然成立」。 + // + // 本来就在而且 resolved 的要再看一眼:`fact_retracted` / `fact_closed` / + // `axiom_relaxed` 都是「世界会变」的承诺——事实没了、区间闭了、公理放宽了, + // 违规就不该再算出来。又算出来了,承诺就是没兑现,那行回到 open,人再看一次。 + // `accepted` 是有意并存,重算多少次都沉默(#202) + let (keep, status, resolution): (Uuid, String, Option) = sqlx::query_as( + "SELECT id, status, resolution FROM axiom_violations WHERE kb_id = $1 AND kind = $2 AND left_fact = $3 AND right_fact = $4", ) .bind(kb_id) @@ -329,7 +337,24 @@ pub async fn run(pool: &PgPool, kb_id: Uuid) -> AppResult { .bind(right) .fetch_one(&mut *tx) .await?; - fresh.push(keep.0); + if status == "resolved" + && matches!( + resolution.as_deref(), + Some("fact_retracted" | "fact_closed" | "axiom_relaxed") + ) + { + sqlx::query( + "UPDATE axiom_violations + SET status = 'open', resolution = NULL, decided_by = NULL, + decided_at = NULL, detected_at = now() + WHERE id = $1", + ) + .bind(keep) + .execute(&mut *tx) + .await?; + report.reopened += 1; + } + fresh.push(keep); } // 这一轮没算出来的 open 行是陈的:事实被撤了,或者公理放宽了。 @@ -511,6 +536,10 @@ pub async fn open_violations( v.right_fact, rt.text AS right_text, coalesce(array_length(v.path, 1), 0) AS path_len, v.detected_at, v.detail, + COALESCE((SELECT jsonb_agg(jsonb_build_object('id', x.id, 'text', pt.text) + ORDER BY x.ord) + FROM unnest(v.path) WITH ORDINALITY AS x(id, ord) + JOIN triple pt ON pt.id = x.id), '[]'::jsonb) AS path, lf.valid_to IS NULL AS left_open, lf.confidence AS left_confidence, EXISTS ( @@ -552,6 +581,7 @@ pub async fn open_violations( detected_at: r.detected_at, detail: r.detail, hint, + path: serde_json::from_value(r.path).unwrap_or_default(), } }) .collect()) @@ -569,6 +599,7 @@ struct ViolationRow { path_len: i32, detected_at: chrono::DateTime, detail: serde_json::Value, + path: serde_json::Value, left_open: bool, left_confidence: f32, same_name_peers: bool, @@ -596,6 +627,61 @@ fn hint_for(r: &ViolationRow) -> Option<&'static str> { /// /// 改状态不删行,与账本同一个规矩:表过态这件事本身要留痕,而且 `run` 靠 /// `status = 'open'` 判断哪些是派生的、可以重算掉——人的决定必须活过重跑。 +/// 一处违规里该撤哪条事实。 +/// +/// 单事实的种类(自环、签名、派生撞断言)只有一条,不用说;双事实与环上的要人指名, +/// 而且只能指违规自己列出的那几条——撤一条不相干的事实不是裁决,是误操作 +pub fn pick_retraction( + left: Uuid, + right: Uuid, + path: &[Uuid], + requested: Option, +) -> Option { + if left == right { + return match requested { + None => Some(left), + Some(r) if r == left => Some(left), + Some(_) => None, + }; + } + let r = requested?; + (r == left || r == right || path.contains(&r)).then_some(r) +} + +/// 「数据错了」:**真的撤掉那条事实**,再把违规标成 resolved(#202)。 +/// +/// 此前只改 `axiom_violations`,事实照样活在图里;重跑撞上 resolved 行又什么都不做, +/// 违规既没消失也不再出现。撤走的是 `reject_fact` 那条路——`invalidated_at`, +/// 证据不动,账本留痕。回撤掉的那条 id,调用方据此记审计 +pub async fn retract_from_violation( + pool: &PgPool, + kb_id: Uuid, + violation_id: Uuid, + requested: Option, + actor: Uuid, +) -> AppResult { + let row: Option<(Uuid, Uuid, Vec)> = sqlx::query_as( + "SELECT left_fact, right_fact, path FROM axiom_violations + WHERE id = $1 AND kb_id = $2 AND status = 'open'", + ) + .bind(violation_id) + .bind(kb_id) + .fetch_optional(pool) + .await?; + let Some((left, right, path)) = row else { + return Err(utopia_core::AppError::NotFound); + }; + let Some(target) = pick_retraction(left, right, &path, requested) else { + return Err(utopia_core::AppError::invalid( + "fact_required", + "这处违规涉及多条事实,要说撤哪一条,且只能是它列出的那几条", + )); + }; + crate::graph::reject_fact(pool, kb_id, target).await?; + decide(pool, kb_id, violation_id, "fact_retracted", actor).await?; + Ok(target) +} + pub async fn decide( pool: &PgPool, kb_id: Uuid, diff --git a/crates/utopia-store/tests/a_retraction_leaves_the_graph.rs b/crates/utopia-store/tests/a_retraction_leaves_the_graph.rs new file mode 100644 index 00000000..59662aae --- /dev/null +++ b/crates/utopia-store/tests/a_retraction_leaves_the_graph.rs @@ -0,0 +1,239 @@ +//! #202:「数据错了」要真的把事实撤掉,队列也得诚实。 +//! +//! 此前 `decide` 只改 `axiom_violations`,事实还活在图里;重跑检查撞上 resolved 行 +//! 又 `DO NOTHING`,违规既没消失也不再出现。这里守四件事: +//! +//! 1. **撤指定的那一条。** 双事实的违规(asymmetry)要说撤哪条,撤完事实 `invalidated_at` +//! 非空、违规 resolved、重跑不再报。 +//! 2. **不在违规里的事实撤不了。** +//! 3. **单事实的违规不用说。** 自环只有一条,直接撤 left。 +//! 4. **承诺没兑现就重开。** `axiom_relaxed` 说要去改本体,本体没改、违规又算出来, +//! 那行回到 open;`accepted` 是有意并存,重跑照旧沉默。 +//! +//! 没有 `UTOPIA_DATABASE_URL` 时跳过而不是失败。自建自拆,绝不碰已有的库。 + +use sqlx::PgPool; +use utopia_store::reasoning; +use uuid::Uuid; + +struct Fx { + org: Uuid, + user: Uuid, + kb: Uuid, + reports_to: Uuid, + etype: Uuid, +} + +async fn seed(pool: &PgPool) -> anyhow::Result { + let (org, ws, kb, user) = ( + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + ); + let (etype, reports_to) = (Uuid::now_v7(), Uuid::now_v7()); + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'retract-test')") + .bind(org) + .execute(pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'retract-test')") + .bind(ws) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO users (id, org_id, email, display_name, password_hash) + VALUES ($1, $2, $1 || '@retract.test', 'r', 'x')", + ) + .bind(user) + .bind(org) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'retract-test')", + ) + .bind(kb) + .bind(ws) + .execute(pool) + .await?; + sqlx::query( + "INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'thing', 'Thing')", + ) + .bind(etype) + .bind(kb) + .execute(pool) + .await?; + // 反对称且非自反:双向断言是矛盾,自环也是 + sqlx::query( + "INSERT INTO relation_types (id, kb_id, key, label, is_asymmetric, is_irreflexive) + VALUES ($1, $2, 'reports_to', 'reports to', TRUE, TRUE)", + ) + .bind(reports_to) + .bind(kb) + .execute(pool) + .await?; + Ok(Fx { + org, + user, + kb, + reports_to, + etype, + }) +} + +async fn entity(pool: &PgPool, f: &Fx, name: &str) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1, $2, $3, $4)", + ) + .bind(id) + .bind(f.kb) + .bind(f.etype) + .bind(name) + .execute(pool) + .await?; + Ok(id) +} + +async fn fact(pool: &PgPool, f: &Fx, s: Uuid, o: Uuid) -> anyhow::Result { + let id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, confidence) + VALUES ($1, $2, $3, $4, $5, 0.9)", + ) + .bind(id) + .bind(f.kb) + .bind(s) + .bind(f.reports_to) + .bind(o) + .execute(pool) + .await?; + Ok(id) +} + +/// (id, kind, status, resolution, left, right) 按检出时间 +async fn violations( + pool: &PgPool, + f: &Fx, +) -> anyhow::Result, Uuid, Uuid)>> { + Ok(sqlx::query_as( + "SELECT id, kind, status, resolution, left_fact, right_fact FROM axiom_violations + WHERE kb_id = $1 ORDER BY detected_at, id", + ) + .bind(f.kb) + .fetch_all(pool) + .await?) +} + +async fn retracted(pool: &PgPool, id: Uuid) -> anyhow::Result { + Ok( + sqlx::query_scalar("SELECT invalidated_at IS NOT NULL FROM facts WHERE id = $1") + .bind(id) + .fetch_one(pool) + .await?, + ) +} + +#[tokio::test] +async fn a_retraction_leaves_the_graph() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let f = seed(&pool).await?; + + let run = async { + let (a, b) = (entity(&pool, &f, "A").await?, entity(&pool, &f, "B").await?); + let ab = fact(&pool, &f, a, b).await?; + let ba = fact(&pool, &f, b, a).await?; + reasoning::run(&pool, f.kb).await?; + let rows = violations(&pool, &f).await?; + assert_eq!(rows.len(), 1); + let (vid, kind, ..) = &rows[0]; + assert_eq!(kind, "asymmetry"); + + // 2. 不在违规里的撤不了;双事实的不说撤哪条也不行 + let stranger = fact(&pool, &f, a, entity(&pool, &f, "Z").await?).await?; + assert!( + reasoning::retract_from_violation(&pool, f.kb, *vid, Some(stranger), f.user) + .await + .is_err() + ); + assert!( + reasoning::retract_from_violation(&pool, f.kb, *vid, None, f.user) + .await + .is_err() + ); + + // 1. 撤 B→A:事实作废、违规 resolved、重跑不再报 + let gone = reasoning::retract_from_violation(&pool, f.kb, *vid, Some(ba), f.user).await?; + assert_eq!(gone, ba); + assert!(retracted(&pool, ba).await?, "the button does what it says"); + assert!(!retracted(&pool, ab).await?, "the other fact stays"); + let r = reasoning::run(&pool, f.kb).await?; + assert_eq!(r.reopened, 0); + let rows = violations(&pool, &f).await?; + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].2, "resolved"); + assert_eq!(rows[0].3.as_deref(), Some("fact_retracted")); + + // 3. 自环只有一条事实,不用说撤哪条 + let e = entity(&pool, &f, "E").await?; + let ee = fact(&pool, &f, e, e).await?; + reasoning::run(&pool, f.kb).await?; + let (loop_id, ..) = violations(&pool, &f) + .await? + .into_iter() + .find(|v| v.1 == "self_loop") + .expect("the self loop is reported"); + assert_eq!( + reasoning::retract_from_violation(&pool, f.kb, loop_id, None, f.user).await?, + ee + ); + assert!(retracted(&pool, ee).await?); + + // 4. 承诺没兑现就重开:axiom_relaxed 后本体没改,重跑回到 open;accepted 沉默 + let (c, d) = (entity(&pool, &f, "C").await?, entity(&pool, &f, "D").await?); + fact(&pool, &f, c, d).await?; + fact(&pool, &f, d, c).await?; + reasoning::run(&pool, f.kb).await?; + let (cd_id, ..) = violations(&pool, &f) + .await? + .into_iter() + .find(|v| v.2 == "open") + .expect("the new pair is open"); + reasoning::decide(&pool, f.kb, cd_id, "axiom_relaxed", f.user).await?; + let r = reasoning::run(&pool, f.kb).await?; + assert_eq!( + r.reopened, 1, + "the axiom is still declared, so the promise did not hold" + ); + let row = violations(&pool, &f) + .await? + .into_iter() + .find(|v| v.0 == cd_id) + .unwrap(); + assert_eq!(row.2, "open"); + assert_eq!(row.3, None); + reasoning::decide(&pool, f.kb, cd_id, "accepted", f.user).await?; + let r = reasoning::run(&pool, f.kb).await?; + assert_eq!( + r.reopened, 0, + "accepted means both stand; the queue stays quiet" + ); + let row = violations(&pool, &f) + .await? + .into_iter() + .find(|v| v.0 == cd_id) + .unwrap(); + assert_eq!(row.2, "resolved"); + Ok::<_, anyhow::Error>(()) + } + .await; + + let _ = sqlx::query("DELETE FROM organizations WHERE id = $1") + .bind(f.org) + .execute(&pool) + .await; + run +} diff --git a/docs/decisions/0002-reasoning-engine.md b/docs/decisions/0002-reasoning-engine.md index 3363ae5b..530ee90e 100644 --- a/docs/decisions/0002-reasoning-engine.md +++ b/docs/decisions/0002-reasoning-engine.md @@ -81,6 +81,7 @@ corpus is cleaned, then materialization is switched on. declarations; with the seeds gone, a KB without a pack reports zero. - 2026-09-03: the contradiction signals promised in decision 4 exist (0017); the `related_to` obstacle (39% empty edges) vanished with 0010, since null-predicate edges never enter reasoning. +- 2026-09-03: "Data is wrong" retracts the fact (#202). Until now `decide` only marked the violation resolved; the fact stayed in the graph, and a rerun hit the resolved row and stayed silent. The decision now names the fact (single-fact kinds pick it themselves; asymmetry, functional and cycle cards offer a button per fact), retracts it through `reject_fact`, and reruns the check. A resolved row whose violation is computed again is reopened when its resolution promised a change (`fact_retracted`, `fact_closed`, `axiom_relaxed`); `accepted` stays quiet. ## Open questions diff --git a/web/src/api.ts b/web/src/api.ts index 1ed58609..8161099a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -519,6 +519,8 @@ export interface AxiomViolation { /** 审核线索(0017 §2),一次只给一条:旧断言没写结束日期、有同名实体、 * 抽取置信度低。没有就空 */ hint: "stale" | "duplicate" | "unsure" | null; + /** 环上的每一条事实,按顺序;其余种类为空。撤事实要指名撤哪条(#202) */ + path: { id: string; text: string }[]; } /** 本体自己的一处自相矛盾。**与 AxiomViolation 不是一回事**:那个说 * 「事实与定义抵触」,这个说「定义自己站不住」,后者更根本 */ @@ -1789,13 +1791,17 @@ export const api = { kbId: string, violationId: string, resolution: ViolationResolution, - closeAt?: string, + opts: { closeAt?: string; factId?: string } = {}, ) => request<{ ok: boolean }>( `/api/v1/kbs/${kbId}/review/violations/${violationId}`, { method: "POST", - body: JSON.stringify({ resolution, close_at: closeAt ?? null }), + body: JSON.stringify({ + resolution, + close_at: opts.closeAt ?? null, + fact_id: opts.factId ?? null, + }), }, ), confirmFact: (kbId: string, factId: string) => diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e5fb9403..c430f17e 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1375,6 +1375,9 @@ export const en = { violationVia: (p: string) => `via ${p}`, violationPath: (n: number) => `${n} facts in the cycle`, retractFact: "Data is wrong", + /** 双事实与环上的违规:撤具体哪一条(#202) */ + retractThis: "Retract", + retractThisHint: "Withdraw this fact from the graph; the other one stays.", relaxAxiom: "Axiom is wrong", acceptBoth: "Both are right", runCheck: "Run check", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 73a027d6..afa26cd4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1237,6 +1237,8 @@ export const zh: Strings = { violationVia: (p: string) => `依据 ${p}`, violationPath: (n: number) => `环上 ${n} 条事实`, retractFact: "数据错了", + retractThis: "撤这条", + retractThisHint: "把这条事实撤出图谱,另一条不动。", relaxAxiom: "公理错了", acceptBoth: "两边都对", runCheck: "跑一遍检查", diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index 47e7d565..954d4d4c 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -549,6 +549,9 @@ function DefectRow({ /** 一处公理违规。**三个按钮而不是两个**——第三个是这一档独有的出路: * 矛盾可能出在定义上(用户导的本体把某个属性声明成反对称,而他的语料里 * 那关系其实双向),这时该改的是本体,不是二十条事实。 */ +/** 裁决的附加参数:闭合日期(fact_closed)、撤哪条(fact_retracted) */ +type DecideOpts = { closeAt?: string; factId?: string }; + function ViolationRow({ violation: v, busy, @@ -558,7 +561,7 @@ function ViolationRow({ }: { violation: AxiomViolation; busy: boolean; - onDecide: (resolution: ViolationResolution, closeAt?: string) => void; + onDecide: (resolution: ViolationResolution, opts?: DecideOpts) => void; onDuplicates: () => void; onOntology: () => void; }) { @@ -575,6 +578,16 @@ function ViolationRow({ } // 自反那一类两条事实是同一条——显示一遍就够,显示两遍像个 bug const single = v.left_fact === v.right_fact; + // 「数据错了」要撤具体哪一条:环逐条列,双事实的两条各一个按钮,单事实的不用问(#202) + const facts = + v.path.length > 0 + ? v.path + : single + ? [{ id: v.left_fact, text: v.left_text }] + : [ + { id: v.left_fact, text: v.left_text }, + { id: v.right_fact, text: v.right_text }, + ]; return (
@@ -591,10 +604,21 @@ function ViolationRow({ )}
-
{v.left_text}
- {!single && ( -
{v.right_text}
- )} + {facts.map((f) => ( +
+ {f.text} + {!single && ( + + )} +
+ ))}
- + {single && ( + + )}
); @@ -638,7 +664,7 @@ function ContradictionRow({ v: AxiomViolation; what: string; busy: boolean; - onDecide: (resolution: ViolationResolution, closeAt?: string) => void; + onDecide: (resolution: ViolationResolution, opts?: DecideOpts) => void; onDuplicates: () => void; onOntology: () => void; }) { @@ -688,7 +714,7 @@ function ContradictionRow({ className="u-btn u-btn-primary px-3 py-1.5 text-xs" disabled={busy || !closeAt} onClick={() => - onDecide("fact_closed", new Date(closeAt).toISOString()) + onDecide("fact_closed", { closeAt: new Date(closeAt).toISOString() }) } > {S.review.closeAssertion} @@ -921,12 +947,12 @@ export function Review() { mutationFn: ({ id, resolution, - closeAt, + opts, }: { id: string; resolution: ViolationResolution; - closeAt?: string; - }) => api.decideViolation(kb!.id, id, resolution, closeAt), + opts?: DecideOpts; + }) => api.decideViolation(kb!.id, id, resolution, opts), onSettled: invalidate, }); // 检查是同步的纯计算,所以直接 mutate 不排队。跑完把报告留在按钮旁边—— @@ -1319,8 +1345,8 @@ export function Review() { violationAction.isPending && violationAction.variables?.id === v.id } - onDecide={(resolution, closeAt) => - violationAction.mutate({ id: v.id, resolution, closeAt }) + onDecide={(resolution, opts) => + violationAction.mutate({ id: v.id, resolution, opts }) } onDuplicates={() => select("duplicates")} onOntology={() => navigate({ to: "/ontology" })}