diff --git a/crates/utopia-core/src/lib.rs b/crates/utopia-core/src/lib.rs index 70e46f478..165c0e0fe 100644 --- a/crates/utopia-core/src/lib.rs +++ b/crates/utopia-core/src/lib.rs @@ -8,3 +8,43 @@ pub mod text; pub use error::{is_deferred, is_terminal, AppError, AppResult, Deferred, Terminal}; pub use text::without_nul; + +/// 审核对的 `reason` 里,召回通道留下的记号。名字向量召回(0041 第 2 刀通道 2)提的是两个 +/// **不同的字符串**,和同名家族(`shared_name|`、`ambiguous_name|`、`namesake_tie|`)是两种 +/// 证据强度:裁决器读它、治理闸门看它、召回写它,都从这里认,不各自拼前缀 +pub mod review_reasons { + /// `name_vector|<余弦>`:名字向量召回提的对 + pub const NAME_VECTOR: &str = "name_vector|"; + + /// 这一对是名字向量召回提出来的(两个相近但不同的字符串) + pub fn similarity_proposed(reason: Option<&str>) -> bool { + reason.is_some_and(|r| r.starts_with(NAME_VECTOR)) + } + + /// 名字向量召回记下的余弦文本;不是这种对时为 None + pub fn name_vector_cosine(reason: Option<&str>) -> Option<&str> { + reason?.strip_prefix(NAME_VECTOR) + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn only_the_name_vector_prefix_counts() { + assert!(similarity_proposed(Some("name_vector|0.78"))); + assert_eq!(name_vector_cosine(Some("name_vector|0.78")), Some("0.78")); + for r in [ + "ambiguous_name|0.41", + "namesake_tie|0.55", + "shared_name|张伟", + "contains", + "", + ] { + assert!(!similarity_proposed(Some(r)), "{r}"); + assert_eq!(name_vector_cosine(Some(r)), None, "{r}"); + } + assert!(!similarity_proposed(None)); + } + } +} diff --git a/crates/utopia-extract/src/governor.rs b/crates/utopia-extract/src/governor.rs index 475e38453..76ffb0f87 100644 --- a/crates/utopia-extract/src/governor.rs +++ b/crates/utopia-extract/src/governor.rs @@ -10,8 +10,15 @@ use serde_json::{json, Value}; use crate::{AdjudicationPair, IDENTITY_RULES}; -/// 一对最多查几次再表态。够看两侧的事实与原文各一次、翻一次台账,还剩一次 -pub const MAX_STEPS: usize = 6; +/// 一对最多查几次再表态。模型按菜单走:两侧的事实、两侧的原文、两个名字的台账、 +/// 同名者、合并会碰到什么——八次。原来给六次,identity bench 上第二眼三分之一 +/// 「看了没收尾」:轨迹显示它把最后两次花在被拒的同名者与后果查询上,没回合表态 +pub const MAX_STEPS: usize = 8; + +/// 查够了还想查时的回话:不给结果,只提醒收尾 +pub const LIMIT_REACHED: &str = "Lookup limit reached: you have seen what can be seen. \ + Answer now with decide (same or different, with your confidence) or defer (with the \ + question a person should answer)."; /// 攒批那一眼说了什么:带进第二眼,模型知道自己上次为什么没定 pub struct EarlierLook<'a> { @@ -149,8 +156,13 @@ pub fn messages(pair: &AdjudicationPair, earlier: &EarlierLook) -> Vec { format!("Precedents (decided by people in this base):\n{lines}\n") }; let why = earlier.why.map(|w| format!(" — {w}")).unwrap_or_default(); + let why_paired = pair + .proposed_because + .as_deref() + .map(|w| format!("Why paired: {w}\n")) + .unwrap_or_default(); let user = format!( - "{}\n{}\n{precedents}The earlier look said: {} ({:.2}){why}.", + "{}\n{}\n{why_paired}{precedents}The earlier look said: {} ({:.2}){why}.", side("A", &pair.left), side("B", &pair.right), earlier.verdict, @@ -288,6 +300,7 @@ mod tests { facts: vec![], }, precedents: vec!["this same pair was kept apart by a person on 2026-09-01".into()], + proposed_because: None, }; let m = messages( &pair, diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index a1b5e05db..8af91dff7 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -146,6 +146,23 @@ pub struct AdjudicationPair { pub right: AdjudicationSide, /// 这个库里的人对这一对、这个名字、这种类型对做过什么(0025)。空就不提 pub precedents: Vec, + /// 这一对是**怎么**被提出来的,只在提议依据不是「同名」时写:名字向量召回(0041 第 2 刀) + /// 提的是两个**不同的字符串**,裁决器不知道这一点就会把「张伟」当成「财务部总监张伟」 + /// 去掉限定词后的同一个人——测量台上那次错合就是这么来的。空就不提,同名对照旧 + pub proposed_because: Option, +} + +/// 名字向量召回提的对,写成裁决器读得懂的一句提议依据;`cosine` 是召回记下的余弦文本 +/// (`utopia_core::review_reasons::name_vector_cosine` 从审核对的 reason 里取)。其余原因 +/// (同名灰区、同名并列、包含)都是「同一个字符串」的家族,裁决器的规则本来就是为它们写的, +/// 传 None +pub fn proposed_because(cosine: Option<&str>) -> Option { + let cosine = cosine?; + Some(format!( + "the names are similar but NOT the same string (name-vector cosine {cosine}): this may be a \ + short form, another script, or a different thing with a similar name; a dropped qualifier is \ + not evidence here, the facts are" + )) } #[derive(Debug, Deserialize)] @@ -291,8 +308,13 @@ pub fn build_adjudication_messages(pairs: &[AdjudicationPair]) -> Vec bool { + utopia_core::review_reasons::similarity_proposed(item.reason.as_deref()) +} + +fn needs_second_look( + item: &ReviewItem, + p: &gov::Precedents, + same: Option, + conf: f32, +) -> bool { + wants_another_look(item, p, same, conf) || !batch_verdict_may_apply(item, same) +} + +/// 攒批那一眼的看法能不能不经第二眼就落地。名字向量提的对说 same 不能:第二眼没跑成 +/// (预算用完、模型出错)就上交给人,不照攒批的看法合,也不把那个看法记进缓存——记了 +/// 之后这一对每次再来都从缓存直接合,第二眼永远轮不到(#889 评审) +pub(crate) fn batch_verdict_may_apply(item: &ReviewItem, same: Option) -> bool { + !(similarity_proposed(item) && same == Some(true)) +} + +/// 第二眼没跑成时上交的理由 +pub(crate) const SECOND_LOOK_UNAVAILABLE: &str = "escalate_unsure|second_look_unavailable"; + /// 一次裁决落地成了什么:第二层的行按它记 applied 还是 proposed enum Outcome { Merged(Uuid), @@ -201,6 +230,9 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul facts: item.right.top_facts.clone(), }, precedents: precedents.clone(), + proposed_because: utopia_extract::proposed_because( + utopia_core::review_reasons::name_vector_cosine(item.reason.as_deref()), + ), }, ) .collect(); @@ -227,7 +259,7 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul let conf = v.confidence.unwrap_or(0.5).clamp(0.0, 1.0); // 第二层(0028):攒批没定的,带工具再看一遍再落地。预算用完或 // 循环没跑成就照攒批的看法办 - if wants_another_look(item, p, same, conf) { + if needs_second_look(item, p, same, conf) { let earlier = Look::from_batch(same, conf, v.why.clone()); if let Some(look) = look_again( state, @@ -273,6 +305,16 @@ pub async fn adjudicate_entities(state: &AppState, kb_id: Uuid) -> anyhow::Resul record_look(state, kb_id, run_id, item, p, &look, &outcome).await?; continue; } + // 第二眼没跑成:相似提的 same 不能照攒批的看法合,也不进缓存 + if !batch_verdict_may_apply(item, same) { + utopia_store::resolution::escalate_review( + &state.pool, + item.id, + SECOND_LOOK_UNAVAILABLE, + ) + .await?; + continue; + } } utopia_store::resolution::put_verdict( &state.pool, @@ -432,3 +474,76 @@ async fn apply_verdict( }; Ok(outcome) } + +#[cfg(test)] +mod tests { + use super::*; + use utopia_core::models::{ReviewItem, ReviewSide}; + + fn item(reason: &str) -> ReviewItem { + let side = |name: &str| ReviewSide { + id: Uuid::now_v7(), + name: name.into(), + type_label: Some("person".into()), + color: String::new(), + disambiguator: None, + degree: 0, + top_facts: vec![], + }; + ReviewItem { + id: Uuid::now_v7(), + score: 0.78, + reason: Some(reason.into()), + stage: "adjudicating".into(), + created_at: chrono::Utc::now(), + left: side("张伟"), + right: side("财务部总监张伟"), + proposal: None, + } + } + + /// 名字向量提的对:批量说 same 再有把握也要第二眼;说 different / unsure 照旧 + #[test] + fn a_similarity_proposed_same_always_gets_the_second_look() { + let p = gov::Precedents::default(); + let it = item("name_vector|0.78"); + assert!(needs_second_look(&it, &p, Some(true), 0.99)); + assert!(needs_second_look(&it, &p, Some(true), 0.85)); + assert!( + !needs_second_look(&it, &p, Some(false), 0.9), + "分开是安全方向,照旧落地" + ); + assert!(needs_second_look(&it, &p, None, 0.5), "没定的本来就要再看"); + } + + /// 第二眼没跑成时:相似提的 same 不落地也不进缓存;其余照攒批的看法办 + #[test] + fn a_similarity_proposed_same_never_applies_on_the_batch_verdict_alone() { + assert!(!batch_verdict_may_apply( + &item("name_vector|0.78"), + Some(true) + )); + assert!(batch_verdict_may_apply( + &item("name_vector|0.78"), + Some(false) + )); + assert!(batch_verdict_may_apply(&item("name_vector|0.78"), None)); + assert!(batch_verdict_may_apply( + &item("ambiguous_name|0.41"), + Some(true) + )); + assert!(batch_verdict_may_apply( + &item("shared_name|张伟"), + Some(true) + )); + } + + /// 同名家族的对不受影响:够线就照旧自动落地 + #[test] + fn a_same_name_pair_keeps_the_old_rule() { + let p = gov::Precedents::default(); + let it = item("ambiguous_name|0.41"); + assert!(!needs_second_look(&it, &p, Some(true), 0.9)); + assert!(needs_second_look(&it, &p, Some(true), 0.6), "不到线才再看"); + } +} diff --git a/crates/utopia-server/src/governance.rs b/crates/utopia-server/src/governance.rs index 367250fca..34334da68 100644 --- a/crates/utopia-server/src/governance.rs +++ b/crates/utopia-server/src/governance.rs @@ -366,6 +366,9 @@ fn pair_of(item: &ReviewItem, p: &Precedents) -> utopia_extract::AdjudicationPai left: side(&item.left), right: side(&item.right), precedents: gov::render_lines(p), + proposed_because: utopia_extract::proposed_because( + utopia_core::review_reasons::name_vector_cosine(item.reason.as_deref()), + ), } } @@ -386,10 +389,15 @@ fn wants_second_look(item: &ReviewItem, p: &Precedents, look: &Look) -> bool { && look.calls == 0; let doubted_merge = name_doubts(shape) && !types_conflict && look.same == Some(true) && look.calls == 0; + // 名字向量提的对说 same:不论把握多高都先带工具看一遍(与不带治理的裁决同一条规矩, + // `adjudication::batch_verdict_may_apply`);只看第一层的,第二层看过的不再看 + let similarity_same = + !crate::adjudication::batch_verdict_may_apply(item, look.same) && look.calls == 0; ((gov::gate(look.same, look.conf, types_conflict, shape, p) == Gate::Propose && look.uncertain()) || doubted_split - || doubted_merge) + || doubted_merge + || similarity_same) && p.reverts.is_empty() } @@ -494,6 +502,29 @@ async fn apply(ctx: &Ctx<'_>, item: &ReviewItem, p: &Precedents, look: Look) -> calls: look.calls, }; + // 第二眼没跑成(预算用完、模型出错)的相似提的 same:过闸也不合,上交给人, + // 看法照记成建议——名字相近不是同一个东西的证据,事实才是 + if look.same == Some(true) + && look.calls == 0 + && !crate::adjudication::batch_verdict_may_apply(item, look.same) + { + utopia_store::resolution::escalate_review( + pool, + item.id, + crate::adjudication::SECOND_LOOK_UNAVAILABLE, + ) + .await?; + gov::record( + pool, + kb_id, + NewDecision { + reason: Some("held for a person: the names are similar, not the same string, and the second look did not run"), + ..decision("proposed", None) + }, + ) + .await?; + return Ok(()); + } match gov::gate(look.same, look.conf, types_conflict, shape, p) { Gate::Apply if look.same == Some(true) => { let reason = format!("governed|{conf:.2}"); @@ -607,9 +638,13 @@ async fn investigate( let mut trace: Vec = Vec::new(); let mut calls = 0; let mut nudged = false; + let mut walls = 0; + let mut lookups = 0; - // 回合上限 = 查询次数 + 收尾那一次 + 一次提醒 - for _ in 0..(governor::MAX_STEPS + 2) { + // 回合上限 = 查询次数 + 撞两次上限 + 一次提醒 + 收尾那一次。模型多半一回合只查一件事, + // 查够 MAX_STEPS 次常常还想再查:撞上限的那一回合得算在预算外,不然它连收尾的机会都没有 + // (identity bench 上,第二眼「看了没收尾」九次里有五次是这么来的) + for _ in 0..(governor::MAX_STEPS + 4) { let turn = { let _permit = permit(ctx).await; ctx.client.chat_tools(&messages, &tools).await? @@ -617,6 +652,8 @@ async fn investigate( calls += 1; messages.push(turn.to_message()); if turn.tool_calls.is_empty() { + // 没调工具就说话:记下它说了什么,下次看轨迹能知道它卡在哪 + trace.push(json!({ "said": turn.content.as_deref().unwrap_or("").chars().take(200).collect::() })); if nudged { break; } @@ -651,9 +688,14 @@ async fn investigate( }); } Step::Lookup { tool, args } => { - let out = if trace.len() >= governor::MAX_STEPS { - "Lookup limit reached; finish with decide or defer.".to_string() + let out = if lookups >= governor::MAX_STEPS { + walls += 1; + trace.push( + json!({ "tool": tool, "args": args, "note": "refused: lookup limit" }), + ); + governor::LIMIT_REACHED.to_string() } else { + lookups += 1; let (out, note) = lookup(ctx, item, &tool, &args).await?; trace.push(json!({ "tool": tool, "args": args, "note": note })); out @@ -665,6 +707,10 @@ async fn investigate( } } } + // 撞了两次上限还在查:不会收尾了,别再花回合 + if walls >= 2 { + break; + } } // 看了,没收尾:当没定,轨迹留下 Ok(Look { diff --git a/crates/utopia-store/src/resolution.rs b/crates/utopia-store/src/resolution.rs index 14549d698..fc4c706ae 100644 --- a/crates/utopia-store/src/resolution.rs +++ b/crates/utopia-store/src/resolution.rs @@ -290,7 +290,11 @@ pub async fn resolve_mention( r.reviews.push(ReviewRequest { other_id: near.entity_id, score: near.similarity, - reason: format!("name_vector|{:.2}", near.similarity), + reason: format!( + "{}{:.2}", + utopia_core::review_reasons::NAME_VECTOR, + near.similarity + ), stage: ReviewStage::Adjudicating, }); } diff --git a/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md b/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md index 14dff1554..5405633f5 100644 --- a/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md +++ b/docs/decisions/0041-a-name-is-a-claim-about-an-entity.md @@ -1,6 +1,6 @@ # 0041 · A name is a claim about an entity -- **Status**: decision 1 settled 2026-09-13 (names are facts) · cut 0 built: `scripts/bench/identity.mjs`, baseline on `dev` forward F1 0.43, reverse 0.54 · cut 1 implemented (#670): migration 0055 and `names` in the store, the extractor's `names`, shared-name pairs to the adjudicator; forward 0.68, reverse 0.68, the two orders agree on all 210 pairs (one run each; two cut-1 runs differed by 0.07 forward) · re-measured on DeepSeek-V3 after the review fixes: dev 0.46 / 0.40 (2 of 21 anchors unresolved), cut 1 0.61 / 0.44–0.53 over three runs; on V3 the adjudicator keeps 海洋探测器1号 and 海探1 apart in reverse order even with the shared name listed, which is cut 3's question · decisions 2 and 5 revised by cut 1 · cut 2 channel 2 (name vectors) built 2026-09-23: migration 0080 `name_vectors`, recall proposes a `name_vector|` pair for the adjudicator and never merges; channel 3 (neighbours) and the retirement of `recall_keys` wait for the bench · cuts 3–4 not started +- **Status**: decision 1 settled 2026-09-13 (names are facts) · cut 0 built: `scripts/bench/identity.mjs`, baseline on `dev` forward F1 0.43, reverse 0.54 · cut 1 implemented (#670): migration 0055 and `names` in the store, the extractor's `names`, shared-name pairs to the adjudicator; forward 0.68, reverse 0.68, the two orders agree on all 210 pairs (one run each; two cut-1 runs differed by 0.07 forward) · re-measured on DeepSeek-V3 after the review fixes: dev 0.46 / 0.40 (2 of 21 anchors unresolved), cut 1 0.61 / 0.44–0.53 over three runs; on V3 the adjudicator keeps 海洋探测器1号 and 海探1 apart in reverse order even with the shared name listed, which is cut 3's question · decisions 2 and 5 revised by cut 1 · cut 2 channel 2 (name vectors) built 2026-09-23: migration 0080 `name_vectors`, recall proposes a `name_vector|` pair for the adjudicator and never merges; revised 2026-09-25 (#889): a batch *same* on such a pair is never applied on its own, it takes the tool-using second look or goes to a person when that look cannot run; channel 3 (neighbours) and the retirement of `recall_keys` wait for the bench · cuts 3–4 not started - **Written**: 2026-09-13 (conventions in the [README](README.md)) - **Related**: [0009](0009-no-type-is-a-type.md) made an undecided type an honest state, and [0016](0016-close-the-open-seams-before-cutting-new-ones.md) B3 let a declared `disjointWith` keep names apart; #270 stopped a namesake tie from being settled by candidate order and #331 let facts break it; #428 and [0025](0025-governance-reads-the-ledger-before-it-decides.md) moved duplicates through a queue an agent works; #582 and #583 made the extractor copy the words that name each side of a fact; [0037](0037-a-relation-carries-its-own-attributes.md) put attributes on edges. diff --git a/docs/design/identity.md b/docs/design/identity.md index d12bafb89..36d8b87c7 100644 --- a/docs/design/identity.md +++ b/docs/design/identity.md @@ -23,7 +23,12 @@ vectors of the base (`name_vectors`, one row per name fact, embedded after each 0080): the nearest few within the same type family at cosine 0.60 or above are *proposed* as a `name_vector` pair for the adjudicator and never attached, so a short form or a name in another script meets its entity through a question rather than a silent second entity [0041 d3 channel 2, -#709]. Only the first channel decides anything: a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine +#709]. Such a pair says in the adjudicator's prompt that its names are similar, not the same +string, and a batch verdict of *same* on it is never applied directly: it takes the tool-using +second look first, whatever its confidence, and when that look cannot run (the daily loop budget +is spent, the model fails) the pair goes to a person as `second_look_unavailable` instead, with the +batch verdict left out of the verdict cache; the same rule holds with governance on. The identity +bench showed the batch step merging 张伟 into 财务部总监张伟 on name alone. Only the first channel decides anything: a context vector (`profile_embedding`, the running mean of chunk vectors) attaches at cosine 0.55 or above, makes a new entity below 0.35, and in between makes a new entity and a pair for the adjudicator; two same-name candidates within a tie margin go to a person unless a candidate's object name appears in the chunk [0041, #270, #331]. A name another entity of a compatible type already