From 60efbb8a36c252dfe11a81331dc008fa24925e97 Mon Sep 17 00:00:00 2001 From: wangzifei Date: Tue, 22 Sep 2026 06:36:01 +0800 Subject: [PATCH] Keep passage-resolved phrases out of known_as Signed-off-by: wangzifei --- crates/utopia-extract/src/open.rs | 23 +++++++++++++++++-- crates/utopia-store/src/names.rs | 16 +++++++++++++ ...ribed_thing_is_an_entity_without_a_name.rs | 6 +++++ docs/design/extraction.md | 5 +++- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/utopia-extract/src/open.rs b/crates/utopia-extract/src/open.rs index df5b92c35..dd277f46d 100644 --- a/crates/utopia-extract/src/open.rs +++ b/crates/utopia-extract/src/open.rs @@ -184,8 +184,10 @@ the passage does not. Each is null when the passage gives none.\n\ slot of an \"s\" entry, the last slot of an \"n\" entry.\n\ 7. \"n\" lists other names, one entry each: [name as listed, other name, quote] — a short form, \ a former name, a spelling in another script that this passage uses for a thing in \"e\" or a \ -thing already recorded. Only names actually written in the passage; never a pronoun or a \ -description.\n\ +thing already recorded. Only a proper name or a fixed term may become another name; a role or \ +generic phrase whose referent the passage decides is never another name, even when it refers to \ +a thing in \"e\" or a thing already recorded; it stays the description or the qualifier words. \ +Only names actually written in the passage; never a pronoun or a description.\n\ 8. State nothing the passage does not state, and state each thing once: with a value or with \ an object, not both. The Document line, the opening of the document and the list of things \ already recorded only say where the passage comes from; write nothing about them. If the \ @@ -732,6 +734,23 @@ mod tests { assert!(!x.truncated); } + /// `named = 0` only matters if that decision follows the thing into `n`: an + /// anaphoric role can pass both server checks because its words and referent + /// really are in the passage. + #[test] + fn a_passage_resolved_phrase_is_not_another_name() { + let msgs = build_open_messages("filing.txt", &[], None, "text"); + let system = &msgs[0].content; + assert!(system.contains("Only a proper name or a fixed term may become another name")); + assert!(system.contains( + "a role or generic phrase whose referent the passage decides is never another name" + )); + assert!( + system.contains("even when it refers to a thing in \"e\" or a thing already recorded") + ); + assert!(system.contains("it stays the description or the qualifier words")); + } + fn known(handle: &str, name: &str, type_key: &str) -> KnownEntity { KnownEntity { handle: handle.into(), diff --git a/crates/utopia-store/src/names.rs b/crates/utopia-store/src/names.rs index 1ba7afcf8..942e86418 100644 --- a/crates/utopia-store/src/names.rs +++ b/crates/utopia-store/src/names.rs @@ -75,6 +75,9 @@ pub struct NameSource<'a> { /// /// 同一个实体同一个名字只有一行(`insert_value_fact` 按主语、谓词、值去重), /// 再被提到只是多一条证据、证据日期往早挪。 +/// +/// 被描述的东西一个别名也不收(#770):`description` 是它的身份,名字事实是召回 +/// 的桥;模型违反契约把描述写进 `n` 时,这里是最后一道结构闸门。 pub async fn record( pool: &PgPool, kb_id: Uuid, @@ -87,6 +90,19 @@ pub async fn record( if name.is_empty() { return Ok(None); } + let described: bool = sqlx::query_scalar( + "SELECT EXISTS ( + SELECT 1 FROM entities + WHERE id = $1 AND kb_id = $2 AND description IS NOT NULL + )", + ) + .bind(entity_id) + .bind(kb_id) + .fetch_one(pool) + .await?; + if described { + return Ok(None); + } let attr = ensure_known_as(pool, kb_id).await?; let (fact_id, _) = insert_value_fact( pool, diff --git a/crates/utopia-store/tests/a_described_thing_is_an_entity_without_a_name.rs b/crates/utopia-store/tests/a_described_thing_is_an_entity_without_a_name.rs index b7719202b..2e8591990 100644 --- a/crates/utopia-store/tests/a_described_thing_is_an_entity_without_a_name.rs +++ b/crates/utopia-store/tests/a_described_thing_is_an_entity_without_a_name.rs @@ -70,6 +70,12 @@ async fn a_described_thing_has_a_description_and_no_name() -> anyhow::Result<()> assert!(type_id.is_none(), "文档的类别词不是本体的类"); assert_eq!(specific.as_deref(), Some("subsidiary")); + // 一条迟到的别名也不能把描述变成召回桥:record 得拒绝它,而不是照单全收 + assert_eq!( + names::record(&pool, f.kb, id, "the Shanghai buyer", None, None).await?, + None + ); + // 没有一条以它为主语的事实——尤其没有 known_as let facts: i64 = sqlx::query_scalar("SELECT count(*) FROM facts WHERE subject_id = $1") .bind(id) diff --git a/docs/design/extraction.md b/docs/design/extraction.md index 0b8819e64..56bd3815c 100644 --- a/docs/design/extraction.md +++ b/docs/design/extraction.md @@ -18,7 +18,10 @@ are extraction's rules [0039]. description, the document's kind word, and whether it is named or described. `s`: statements, each opening with its own verbatim quote and then naming its subject and object in words, the relation phrase as written, an object name or a literal value, qualifiers keyed by the document's role -words, and `when` / `ended` time words. `n`: other names the document gives a thing. Statements name their sides in words +words, and `when` / `ended` time words. `n`: other names the document gives a thing, and only a +proper name or fixed term may become one. A role or generic phrase whose referent the passage +decides stays description or qualifier words even when it points at a listed or already recorded +thing [#770]. Statements name their sides in words and carry their own quote because a numbered contract mixed the ids up on dense passages (misworded 10 to 14% with ids, 2 to 7% with names) [#731]. Parsing is per item: a malformed item is counted and a truncated reply is repaired to its last complete item.