+
diff --git a/web/src/pages/OntologyRegressionsPanel.test.ts b/web/src/pages/OntologyRegressionsPanel.test.ts
index d8622a822..649d9b207 100644
--- a/web/src/pages/OntologyRegressionsPanel.test.ts
+++ b/web/src/pages/OntologyRegressionsPanel.test.ts
@@ -8,7 +8,7 @@ import { OntologyRegressionsPanel } from "./OntologyRegressionsPanel";
const base: OntologyRegressionCase = {
id: "case", kb_id: "kb", statement_id: "statement",
- expected_property_id: "works-for", expected_direction: "forward",
+ expected_binding: "bound", expected_property_id: "works-for", expected_direction: "forward",
created_by: "actor", created_at: "2026-10-10T00:00:00Z", origin: "adoption",
last_checked_at: "2026-10-10T01:00:00Z",
last_result: {
@@ -63,3 +63,24 @@ describe("stored property regression comparisons", () => {
expect(html).not.toContain(`>${S.ontology.casePassed}<`);
});
});
+
+
+it("shows negative expectations and a failing manual conflict without a fabricated property or direction", () => {
+ const negative: OntologyRegressionCase = {
+ ...base, expected_binding: "none", expected_property_id: null, expected_direction: null,
+ expected_property_label: null, expected_property_key: null, actual_property_label: null,
+ last_result: { ...base.last_result!, status: "none", actual_property_id: null, actual_direction: null },
+ };
+ const passing = render([negative]);
+ expect(passing).toContain(S.ontology.caseUnbound);
+ expect(passing).toContain(S.ontology.casePassed);
+ expect(passing).not.toContain(S.ontology.caseForward);
+ const conflict = render([{ ...negative, actual_property_label: "Works for", last_result: {
+ ...base.last_result!, passed: false, human_bound: false, conflict: "human_binding",
+ } }]);
+ expect(conflict).toContain(S.ontology.caseHumanConflict);
+ expect(conflict).toContain(S.ontology.caseHumanConflictHint);
+ expect(conflict).toContain("Works for");
+ expect(conflict).not.toContain(S.ontology.caseHumanHint);
+ expect(conflict).not.toContain(`>${S.ontology.casePassed}<`);
+});
diff --git a/web/src/pages/OntologyRegressionsPanel.tsx b/web/src/pages/OntologyRegressionsPanel.tsx
index 8a73aa8b8..51a05a252 100644
--- a/web/src/pages/OntologyRegressionsPanel.tsx
+++ b/web/src/pages/OntologyRegressionsPanel.tsx
@@ -100,8 +100,8 @@ function CaseRow({ kbId, item }: { kbId: string; item: OntologyRegressionCase })
- {item.expected_property_label}
- {directionText(item.expected_direction)}
+ {item.expected_binding === "none" ? S.ontology.caseUnbound : item.expected_property_label}
+ {item.expected_direction && {directionText(item.expected_direction)} }
|
@@ -119,11 +119,14 @@ function CaseRow({ kbId, item }: { kbId: string; item: OntologyRegressionCase })
|
- {!result ? S.ontology.caseNotChecked : result.human_bound ? S.ontology.caseHumanBound : result.passed ? S.ontology.casePassed : S.ontology.caseMismatch}
+ {!result ? S.ontology.caseNotChecked : result.conflict === "human_binding" ? S.ontology.caseHumanConflict : result.human_bound ? S.ontology.caseHumanBound : result.passed ? S.ontology.casePassed : S.ontology.caseMismatch}
{result?.human_bound && (
{S.ontology.caseHumanHint}
)}
+ {result?.conflict === "human_binding" && (
+ {S.ontology.caseHumanConflictHint}
+ )}
|
{item.last_checked_at ? localDateTime(item.last_checked_at) : "—"}
From 50904e9fdf2e6aef6eae07d6aa17e599da715f8c Mon Sep 17 00:00:00 2001
From: Szabo Bogdan
Date: Fri, 23 Oct 2026 12:06:44 +0200
Subject: [PATCH 4/9] A statements source that declares its names identities
attaches them by exact name and computes no name vectors for them (#1333)
* A statements source that declares its names identities attaches them by exact name and computes no name vectors for them
Co-Authored-By: Claude Sonnet 5.5
Signed-off-by: Bogdan Szabo
* The identity record separates the class note from decision 6, and the may_attach comment is in Chinese
Co-Authored-By: Claude Sonnet 5.5
Signed-off-by: Bogdan Szabo
---------
Signed-off-by: Bogdan Szabo
Co-authored-by: Claude Sonnet 5.5
Co-authored-by: WaylandYang <145302500+WaylandYang@users.noreply.github.com>
---
crates/utopia-core/src/models.rs | 26 ++++
crates/utopia-server/src/extraction.rs | 39 ++++--
crates/utopia-server/src/extraction_open.rs | 13 +-
.../extraction_open_identity_names_tests.rs | 126 ++++++++++++++++++
crates/utopia-store/src/resolution.rs | 59 +++++++-
...dentity_name_attaches_by_its_exact_name.rs | 123 +++++++++++++++++
crates/utopia-store/tests/store/main.rs | 1 +
...source-may-declare-its-names-identities.md | 4 +-
docs/decisions/README.md | 2 +-
web/src/docs/ingest.md | 2 +
10 files changed, 382 insertions(+), 13 deletions(-)
create mode 100644 crates/utopia-server/src/extraction_open_identity_names_tests.rs
create mode 100644 crates/utopia-store/tests/store/an_identity_name_attaches_by_its_exact_name.rs
diff --git a/crates/utopia-core/src/models.rs b/crates/utopia-core/src/models.rs
index 15dbb2fbc..0178d60a6 100644
--- a/crates/utopia-core/src/models.rs
+++ b/crates/utopia-core/src/models.rs
@@ -181,6 +181,13 @@ impl Source {
.and_then(serde_json::Value::as_bool)
.unwrap_or(true)
}
+
+ /// statements 来源的 config 写了 `{"names": "identity"}`:它推来的每个名字各指一个
+ /// 确定的东西(0071),按字面归并、不算名字向量。其余来源照旧。
+ pub fn names_are_identities(&self) -> bool {
+ self.kind == "statements"
+ && self.config.get("names").and_then(serde_json::Value::as_str) == Some("identity")
+ }
}
#[cfg(test)]
@@ -217,6 +224,25 @@ mod source_extracts_tests {
assert!(with(serde_json::json!({ "extract": 0 })).extracts());
assert!(!with(serde_json::json!({ "extract": false })).extracts());
}
+
+ fn of_kind(kind: &str, config: serde_json::Value) -> Source {
+ Source {
+ kind: kind.into(),
+ ..with(config)
+ }
+ }
+
+ #[test]
+ fn only_a_statements_source_with_the_string_identity_has_identity_names() {
+ let identity = serde_json::json!({ "names": "identity" });
+ assert!(of_kind("statements", identity.clone()).names_are_identities());
+ assert!(!of_kind("api", identity).names_are_identities());
+ assert!(!of_kind("statements", serde_json::json!({})).names_are_identities());
+ assert!(!of_kind("statements", serde_json::json!({ "names": true })).names_are_identities());
+ assert!(
+ !of_kind("statements", serde_json::json!({ "names": "prose" })).names_are_identities()
+ );
+ }
}
/// 来源配置里**用来鉴权**的那几个键。凭据只进不出:列表与创建 / 更新的响应都剔掉,
diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs
index 6b7ae44fb..993655e63 100644
--- a/crates/utopia-server/src/extraction.rs
+++ b/crates/utopia-server/src/extraction.rs
@@ -209,12 +209,23 @@ async fn resolve_uncached(
name_vec: Option<&[f32]>,
text: Option<&str>,
exclude: &[Uuid],
+ identity: bool,
needs_adjudication: &mut bool,
) -> anyhow::Result {
- let r = utopia_store::resolution::resolve_mention(
- pool, kb_id, type_id, name, ctx, name_vec, text, exclude,
- )
- .await?;
+ let r = match identity {
+ true => {
+ utopia_store::resolution::resolve_identity_name(
+ pool, kb_id, type_id, name, ctx, text, exclude,
+ )
+ .await?
+ }
+ false => {
+ utopia_store::resolution::resolve_mention(
+ pool, kb_id, type_id, name, ctx, name_vec, text, exclude,
+ )
+ .await?
+ }
+ };
// 疑似重复对(画像灰区 / 类型漂移 / 同名并列)入审核队列。多数走批量裁决器,
// 同名并列(`ReviewStage::Human`)分不出谁是谁,只能等人裁——它自己带着 stage。
for review in &r.reviews {
@@ -268,6 +279,7 @@ pub(crate) async fn resolve_handle(
ctx: Option<&[f32]>,
name_vec: Option<&[f32]>,
text: Option<&str>,
+ identity: bool,
response_claims: &mut HashMap>,
handled_by_name: &mut HashMap>,
ambiguous_bare_cache: &mut HashMap,
@@ -309,6 +321,7 @@ pub(crate) async fn resolve_handle(
name_vec,
text,
&excluded,
+ identity,
needs_adjudication,
)
.await?;
@@ -343,10 +356,14 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow:
}
let kb = utopia_store::kbs::get(&state.pool, doc.kb_id).await?;
// 推送来的陈述(0054):块就是契约,抽取按契约解析、不问模型,没配对话模型也照抽
- let pushed = crate::pipeline::source_kind(state, doc.source_id)
- .await?
- .as_deref()
- == Some("statements");
+ let source = match doc.source_id {
+ Some(id) => Some(utopia_store::sources::get(&state.pool, id).await?),
+ None => None,
+ };
+ let pushed = source.as_ref().is_some_and(|s| s.kind == "statements");
+ let identity = source
+ .as_ref()
+ .is_some_and(utopia_core::models::Source::names_are_identities);
// settings 有就传:推送路径不问对话模型,但名字向量的嵌入模型(#877)仍从它来
let settings = utopia_store::settings::get(&state.pool, kb.workspace_id).await?;
let client = if pushed {
@@ -376,6 +393,7 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow:
proposer,
await_nod,
pushed,
+ identity,
)
.await
}
@@ -496,6 +514,7 @@ mod tests {
None,
None,
None,
+ false,
&mut response_claims,
&mut document_claims,
&mut bare_cache,
@@ -510,6 +529,7 @@ mod tests {
None,
None,
None,
+ false,
&mut response_claims,
&mut document_claims,
&mut bare_cache,
@@ -588,6 +608,7 @@ mod tests {
None,
None,
None,
+ false,
&mut later_response_claims,
&mut document_claims,
&mut bare_cache,
@@ -616,6 +637,7 @@ mod tests {
None,
None,
None,
+ false,
&mut another_response_claims,
&mut document_claims,
&mut bare_cache,
@@ -743,6 +765,7 @@ mod tests {
Some(&ctx),
None,
None,
+ false,
&mut response_claims,
&mut document_claims,
&mut bare_cache,
diff --git a/crates/utopia-server/src/extraction_open.rs b/crates/utopia-server/src/extraction_open.rs
index 9422929ef..4df943a94 100644
--- a/crates/utopia-server/src/extraction_open.rs
+++ b/crates/utopia-server/src/extraction_open.rs
@@ -286,6 +286,7 @@ async fn apply_open_extraction(
attested_at: Option>,
bound_types: &HashMap,
pushed: bool,
+ identity: bool,
await_nod: bool,
proposer: Proposer,
pending_count: &mut usize,
@@ -330,6 +331,7 @@ async fn apply_open_extraction(
ctx,
name_vecs.get(&key).map(Vec::as_slice),
evidence.get(&key).map(String::as_str),
+ identity,
&mut response_claims,
handled_by_name,
ambiguous_bare_cache,
@@ -1010,6 +1012,8 @@ pub(crate) async fn run_open(
proposer: Proposer,
await_nod: bool,
pushed: bool,
+ // 0071: the source's names are identities, so they get no name vectors
+ identity: bool,
) -> anyhow::Result<()> {
let pool = &state.pool;
let document_id = doc.id;
@@ -1044,7 +1048,9 @@ pub(crate) async fn run_open(
// 名字向量的嵌入客户端(0041 决定 3 通道 2)。没配嵌入模型就是 None:召回退回
// 字面相等,抽取照常
- let embed = settings.and_then(crate::llm_util::embed_client);
+ let embed = settings
+ .and_then(crate::llm_util::embed_client)
+ .filter(|_| !identity);
// 文档自己说到的日期(0064 决定 1):每一块报上来的,连它所在的标题路径。块与块之间
// 不传——一条日期管到哪,看的是它自己那一块正文里的标题,块并行抽的时候照样成立(#588)
let mut time_entries: Vec = Vec::new();
@@ -1140,6 +1146,7 @@ pub(crate) async fn run_open(
attested_at,
&bound_types,
pushed,
+ identity,
await_nod,
proposer,
&mut pending_count,
@@ -1282,6 +1289,10 @@ pub(crate) async fn run_open(
#[path = "extraction_open_known_flow_tests.rs"]
mod known_flow_tests;
+#[cfg(test)]
+#[path = "extraction_open_identity_names_tests.rs"]
+mod identity_names_tests;
+
#[cfg(test)]
#[path = "extraction_open_out_of_credit_tests.rs"]
mod out_of_credit_tests;
diff --git a/crates/utopia-server/src/extraction_open_identity_names_tests.rs b/crates/utopia-server/src/extraction_open_identity_names_tests.rs
new file mode 100644
index 000000000..352f0853f
--- /dev/null
+++ b/crates/utopia-server/src/extraction_open_identity_names_tests.rs
@@ -0,0 +1,126 @@
+//! A statements source with `{"names": "identity"}` (0071) gets no embedding call and no
+//! `name_vector|` pair. The fake endpoint returns one vector for every name.
+use super::*;
+use axum::{routing::post, Json, Router};
+use serde_json::{json, Value};
+use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::Arc;
+
+/// Push `PUSHED` into a base holding `KNOWN` (with a name vector); answers (embedding calls, review reasons).
+async fn push(config: Value) -> anyhow::Result |