Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions crates/utopia-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
}
19 changes: 16 additions & 3 deletions crates/utopia-extract/src/governor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -149,8 +156,13 @@ pub fn messages(pair: &AdjudicationPair, earlier: &EarlierLook) -> Vec<Value> {
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,
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 63 additions & 1 deletion crates/utopia-extract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,23 @@ pub struct AdjudicationPair {
pub right: AdjudicationSide,
/// 这个库里的人对这一对、这个名字、这种类型对做过什么(0025)。空就不提
pub precedents: Vec<String>,
/// 这一对是**怎么**被提出来的,只在提议依据不是「同名」时写:名字向量召回(0041 第 2 刀)
/// 提的是两个**不同的字符串**,裁决器不知道这一点就会把「张伟」当成「财务部总监张伟」
/// 去掉限定词后的同一个人——测量台上那次错合就是这么来的。空就不提,同名对照旧
pub proposed_because: Option<String>,
}

/// 名字向量召回提的对,写成裁决器读得懂的一句提议依据;`cosine` 是召回记下的余弦文本
/// (`utopia_core::review_reasons::name_vector_cosine` 从审核对的 reason 里取)。其余原因
/// (同名灰区、同名并列、包含)都是「同一个字符串」的家族,裁决器的规则本来就是为它们写的,
/// 传 None
pub fn proposed_because(cosine: Option<&str>) -> Option<String> {
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)]
Expand Down Expand Up @@ -291,8 +308,13 @@ pub fn build_adjudication_messages(pairs: &[AdjudicationPair]) -> Vec<ChatMessag
.join("\n");
format!("Precedents (decided by people in this base):\n{lines}\n")
};
let why_paired = p
.proposed_because
.as_deref()
.map(|w| format!("Why paired: {w}\n"))
.unwrap_or_default();
user.push_str(&format!(
"Pair {i}:\nRecord A: {}\nRecord B: {}\n{precedents}\n",
"Pair {i}:\nRecord A: {}\nRecord B: {}\n{why_paired}{precedents}\n",
fmt(&p.left),
fmt(&p.right)
));
Expand Down Expand Up @@ -1067,6 +1089,46 @@ mod tests {
);
}

#[test]
fn a_similarity_proposed_pair_says_so_in_the_prompt() {
let side = |name: &str| AdjudicationSide {
name: name.into(),
type_label: "person".into(),
facts: vec![],
};
let pairs = vec![
AdjudicationPair {
left: side("张伟"),
right: side("财务部总监张伟"),
precedents: vec![],
proposed_because: proposed_because(Some("0.78")),
},
AdjudicationPair {
left: side("张伟"),
right: side("张伟"),
precedents: vec![],
proposed_because: proposed_because(None),
},
];
let user = &build_adjudication_messages(&pairs)[1].content;
let first = &user[..user.find("Pair 1:").unwrap()];
let second = &user[user.find("Pair 1:").unwrap()..];
assert!(
first.contains("Why paired:") && first.contains("cosine 0.78"),
"{first}"
);
assert!(
!second.contains("Why paired:"),
"同名对不该带这一行:{second}"
);
}

#[test]
fn a_proposal_note_needs_a_cosine() {
assert!(proposed_because(Some("0.62")).is_some());
assert!(proposed_because(None).is_none());
}

#[test]
fn parse_adjudication_reply() {
let raw = "```json\n{\"verdicts\":[{\"i\":0,\"verdict\":\"same\",\"confidence\":0.92},{\"i\":1,\"verdict\":\"unsure\"}]}\n```";
Expand Down
117 changes: 116 additions & 1 deletion crates/utopia-server/src/adjudication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,35 @@ fn wants_another_look(
unsettled && !ruled && p.reverts.is_empty()
}

/// 名字向量召回提的对(0041 第 2 刀):两个**不同的字符串**因相近而被放到一起。
/// 「因相似而提议」和「因同名而提议」是两种证据强度,不该用同一根线自动合:批量裁决只看
/// 名字和几条事实,测量台上它把「张伟」并进了「财务部总监张伟」(0.85,过了线)。所以这类
/// 对的 same 一律走带工具的第二眼——它能读两边的事实与原文再定;different 与 unsure
/// 照旧,分开是安全的方向。不看 `ruled` 与撤回:那两条是「再看也改不了」的省事,这里要的
/// 恰恰是再看
fn similarity_proposed(item: &ReviewItem) -> bool {
utopia_core::review_reasons::similarity_proposed(item.reason.as_deref())
}

fn needs_second_look(
item: &ReviewItem,
p: &gov::Precedents,
same: Option<bool>,
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>) -> 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),
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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), "不到线才再看");
}
}
Loading
Loading