From f2ac4e5a2061c74ee40d84e1ebf72dd62ddaec94 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 18 Sep 2026 03:27:41 +0800 Subject: [PATCH 1/5] A phrase binds to a property Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- crates/utopia-extract/src/align.rs | 4 +- crates/utopia-extract/src/lib.rs | 1 + crates/utopia-extract/src/phrase_align.rs | 394 ++++++++++++++++++ .../utopia-server/src/api/ontology_routes.rs | 14 + crates/utopia-server/src/main.rs | 11 + crates/utopia-server/src/phrase_alignment.rs | 382 +++++++++++++++++ crates/utopia-server/src/type_alignment.rs | 21 +- crates/utopia-store/src/lib.rs | 1 + crates/utopia-store/src/ontology.rs | 2 +- crates/utopia-store/src/phrase_bindings.rs | 276 ++++++++++++ docs/design/ontology.md | 30 +- docs/design/prior-work.md | 5 +- .../0066_a_phrase_binds_to_a_property.sql | 56 +++ 13 files changed, 1186 insertions(+), 11 deletions(-) create mode 100644 crates/utopia-extract/src/phrase_align.rs create mode 100644 crates/utopia-server/src/phrase_alignment.rs create mode 100644 crates/utopia-store/src/phrase_bindings.rs create mode 100644 migrations/0066_a_phrase_binds_to_a_property.sql diff --git a/crates/utopia-extract/src/align.rs b/crates/utopia-extract/src/align.rs index b8cb76b1a..9d0678923 100644 --- a/crates/utopia-extract/src/align.rs +++ b/crates/utopia-extract/src/align.rs @@ -169,7 +169,7 @@ pub fn parse_kind_word_response( /// 先按常规取块(第一个 `{` 到最后一个 `}`);解不开才从第一个 `{` 取到结尾去修补。 /// 取块与修补的分工同 `open.rs`:紧凑回复里 `}` 只在结尾出现,截断的回复要么没有 `}`, /// 要么最后一个 `}` 不是结尾 -fn parse_value(raw: &str) -> anyhow::Result { +pub(crate) fn parse_value(raw: &str) -> anyhow::Result { let block = json_block(raw) .and_then(|b| serde_json::from_str::(&b).map_err(anyhow::Error::from)); match block { @@ -212,7 +212,7 @@ fn parse_pair(v: &Value, by_id: &HashMap>) -> Option Option { +pub(crate) fn item_id(v: &Value) -> Option { match v { Value::Number(n) => n.as_i64(), Value::String(s) => s.trim().parse().ok(), diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index cb53522cb..09b0a6d63 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -12,6 +12,7 @@ use utopia_llm::ChatMessage; pub mod align; pub mod governor; pub mod open; +pub mod phrase_align; pub mod time; /// Response-scoped reference to a persistent entity; database UUIDs must never enter prompts. diff --git a/crates/utopia-extract/src/phrase_align.rs b/crates/utopia-extract/src/phrase_align.rs new file mode 100644 index 000000000..7c1578071 --- /dev/null +++ b/crates/utopia-extract/src/phrase_align.rs @@ -0,0 +1,394 @@ +//! 关系短语按签名绑到属性(0044 决定 3 的第二片)。签名 = 短语 × 主语的类 × 宾语的类 +//! (宾语是字面值时记「值」)。一个库里 distinct 的签名比陈述少得多,每条只判一次,绑定按 +//! 库缓存、按签名复用。 +//! +//! 对齐读来源(决定 3):每条签名带着它下面的几条陈述和各自的引文进提示词,模型判的是 +//! 「**每一条**这个签名下的陈述都在陈述这个属性吗」,还要说方向:forward 是陈述的主语 +//! 就是属性的主语,reverse 是反过来(「owns」绑到 subsidiary_of)。属性可以比短语宽 +//! (「opened a plant in」是 located_in),不能比短语窄、不能只是沾边(「announced the +//! acquisition of」不是 acquired);只报告、只评价的短语(said、is expected to)答 null; +//! 值不是属性量的东西也答 null(股数不是营收)。候选属性的定义、定义域、值域照库里的 +//! 写法给,答案里的键照抄。 +//! +//! 回复是紧凑 JSON:`{"b": [[id, "key" | null, "forward" | "reverse" | null]]}`。解析同 +//! 类别词那边:坏的一条计数、不毁掉整批;键不在候选里、id 不在批里、绑了却没方向、 +//! 同一个 id 的第二次都算坏;没答到的 id 是「再问」,不是 null。 + +use std::collections::{HashMap, HashSet}; + +use serde_json::Value; +use utopia_llm::ChatMessage; + +use crate::align::{item_id, parse_value}; + +/// 一个候选属性:键照抄进答案;其余是模型判断的依据,照库里的语言给 +#[derive(Debug, Clone)] +pub struct PropertyCandidate<'a> { + pub key: &'a str, + pub label: &'a str, + pub description: &'a str, + /// relation(两样东西之间)或 attribute(宾语是值) + pub kind: &'a str, + /// 定义域、值域的类键;空表示没声明 + pub domains: Vec<&'a str>, + pub ranges: Vec<&'a str>, +} + +/// 一条待绑定的签名:短语、两端的类、例句与引文、候选属性 +#[derive(Debug, Clone)] +pub struct PhraseItem<'a> { + pub id: i64, + /// the normalised phrase ("acquired") + pub phrase: &'a str, + /// the subject's class key; None when its kind word is bound to no class yet + pub subject_class: Option<&'a str>, + /// the object's class key; None when unbound, or when the object is a value + pub object_class: Option<&'a str>, + pub object_is_value: bool, + pub statement_count: i64, + /// rendered statements ("Brightway Builders —acquired→ Harbor Estates") with their quotes + pub examples: &'a [String], + pub quotes: &'a [String], + pub candidates: Vec>, +} + +/// 模型对一条签名的裁决:Some = (候选的键, 方向);None = 不绑 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PhraseChoice { + pub id: i64, + pub property: Option<(String, Direction)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Forward, + Reverse, +} + +impl Direction { + pub fn as_str(self) -> &'static str { + match self { + Direction::Forward => "forward", + Direction::Reverse => "reverse", + } + } +} + +/// 系统消息。例子是中性的,不出自任何测量语料;规则 1 的「每一条」是整条路的判据 +const PHRASE_SYSTEM: &str = "\ +You bind the relation phrases documents use to the properties of a knowledge base's ontology. \ +Each numbered item is one signature: a phrase as the documents wrote it, the class of the thing \ +it is said of (its subject) and the class of what it points at (its object), or \"value\" when \ +the object is a figure, a title or a status; a few statements with that signature, each with the \ +sentence it was taken from; and the candidate properties, each with its key, its label, its \ +definition, its kind (a relation between two things, or an attribute whose object is a value), \ +its domain and its range. A class written as \"?\" means the documents' kind word for that side \ +is bound to no class yet.\n\ +For each item, answer with the key of the one property that every statement of this signature \ +states by that property's definition, and the direction: \"forward\" when the statement's \ +subject is the property's subject, \"reverse\" when the statement's object is; or null.\n\ +\n\ +Output exactly one JSON object and nothing else, one triple per item:\n\ +{\"b\": [[12, \"headquartered_in\", \"forward\"], [13, null, null]]}\n\ +\n\ +1. Choose a property only when each of the statements under this signature states that \ +property, by the definition as given. The property may be broader than the phrase: \"opened a \ +plant in\" states located_in; \"is the chief executive of\" states officer_of. It is never \ +narrower and never merely related: \"announced the acquisition of\" is not acquired when the \ +sentence says it was announced, not completed; \"revenue grew 18%\" is a change from the prior \ +period, not revenue.\n\ +2. Judge by the definition and by the sentences, not by the label. Labels, definitions and \ +phrases may be in any language.\n\ +3. Answer null when no candidate fits; when the sentences show the phrase meaning different \ +things under this signature; when the phrase only reports, introduces or evaluates (\"said\", \ +\"announced\", \"is expected to\") unless a candidate is about that; or when a value is not \ +what the attribute measures (a share count is not revenue, a date is not an amount).\n\ +4. The direction follows the definition: for \"X —is a subsidiary of→ Y\" subsidiary_of is \ +forward; for \"X —owns→ Y\", if subsidiary_of is the only fitting candidate, it is reverse.\n\ +5. Never invent a key, never answer with a label, never choose for an item a key that is not \ +among its candidates. One triple per item, every item answered."; + +fn candidate_line(c: &PropertyCandidate<'_>) -> String { + let mut line = format!("- {} · {} · {}", c.key, c.label, c.kind); + if !c.domains.is_empty() { + line.push_str(&format!(" · domain: {}", c.domains.join(", "))); + } + if !c.ranges.is_empty() { + line.push_str(&format!(" · range: {}", c.ranges.join(", "))); + } + line.push_str(&format!(" · {}", c.description)); + line +} + +/// 构造两条消息:常量系统消息 + 逐项的用户消息。每项:id、短语、两端的类、例句与引文、候选。 +pub fn build_phrase_messages(items: &[PhraseItem<'_>]) -> Vec { + let mut user = String::new(); + for item in items { + let object = if item.object_is_value { + "value".to_string() + } else { + item.object_class.unwrap_or("?").to_string() + }; + let mut examples = String::new(); + for (i, ex) in item.examples.iter().enumerate() { + let quote = item.quotes.get(i).map(String::as_str).unwrap_or(""); + examples.push_str(&format!("\n · {ex}\n \"{}\"", quote.trim())); + } + if examples.is_empty() { + examples.push_str(" (none)"); + } + let candidates = if item.candidates.is_empty() { + " (none)".to_string() + } else { + let lines: Vec = item.candidates.iter().map(candidate_line).collect(); + format!("\n{}", lines.join("\n")) + }; + user.push_str(&format!( + "Item {}: phrase \"{}\" · subject class: {} · object: {} · {} statements\nStatements:{examples}\nCandidates:{candidates}\n\n", + item.id, + item.phrase, + item.subject_class.unwrap_or("?"), + object, + item.statement_count, + )); + } + vec![ + ChatMessage { + role: "system".into(), + content: PHRASE_SYSTEM.to_string(), + }, + ChatMessage { + role: "user".into(), + content: user.trim_end().to_string(), + }, + ] +} + +/// 解析回复:`(裁决, 坏项数)`。同一个 id 只收第一次;没答到的 id 不出现。 +pub fn parse_phrase_response( + raw: &str, + items: &[PhraseItem<'_>], +) -> anyhow::Result<(Vec, usize)> { + let value = parse_value(raw)?; + let by_id: HashMap> = items.iter().map(|i| (i.id, i)).collect(); + let triples = value + .get("b") + .and_then(Value::as_array) + .map_or(&[][..], Vec::as_slice); + let mut choices = Vec::new(); + let mut malformed = 0usize; + let mut seen = HashSet::new(); + for t in triples { + match parse_triple(t, &by_id) { + Some(choice) if seen.insert(choice.id) => choices.push(choice), + _ => malformed += 1, + } + } + Ok((choices, malformed)) +} + +/// `[id, key | null, direction | null]`:绑了就得有方向,方向不认识算坏 +fn parse_triple(v: &Value, by_id: &HashMap>) -> Option { + let arr = v.as_array()?; + if arr.len() < 2 { + return None; + } + let id = item_id(&arr[0])?; + let item = by_id.get(&id)?; + let property = match &arr[1] { + Value::Null => None, + Value::String(written) => { + let key = candidate_key(item, written)?; + let direction = match arr.get(2).and_then(Value::as_str).map(str::trim) { + Some("forward") | Some("Forward") => Direction::Forward, + Some("reverse") | Some("Reverse") => Direction::Reverse, + _ => return None, + }; + Some((key, direction)) + } + _ => return None, + }; + Some(PhraseChoice { id, property }) +} + +/// 模型写的键对回这一项的候选:先原样,再不分大小写(只在唯一命中时) +fn candidate_key(item: &PhraseItem<'_>, written: &str) -> Option { + let written = written.trim(); + if written.is_empty() { + return None; + } + let keys = || item.candidates.iter().map(|c| c.key.trim()); + if let Some(exact) = keys().find(|k| *k == written) { + return Some(exact.to_string()); + } + let lower = written.to_lowercase(); + let mut hits = keys().filter(|k| k.to_lowercase() == lower); + let first = hits.next()?; + hits.next().is_none().then(|| first.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn strings(values: &[&str]) -> Vec { + values.iter().map(|s| s.to_string()).collect() + } + + fn candidates<'a>() -> Vec> { + vec![ + PropertyCandidate { + key: "headquartered_in", + label: "headquartered in", + description: "The organization's principal office is at the place.", + kind: "relation", + domains: vec!["organization"], + ranges: vec!["place"], + }, + PropertyCandidate { + key: "subsidiary_of", + label: "subsidiary of", + description: "The organization is owned or controlled by the other organization.", + kind: "relation", + domains: vec!["organization"], + ranges: vec!["organization"], + }, + PropertyCandidate { + key: "revenue", + label: "revenue", + description: "Total income from sales for a period, as an amount of money.", + kind: "attribute", + domains: vec!["organization"], + ranges: vec![], + }, + ] + } + + #[test] + fn the_prompt_lists_the_signature_its_sentences_and_the_candidates() { + let examples = strings(&["Harbor Bakery —is based in→ Port Ellen"]); + let quotes = strings(&["Harbor Bakery is based in Port Ellen."]); + let items = vec![PhraseItem { + id: 3, + phrase: "is based in", + subject_class: Some("organization"), + object_class: None, + object_is_value: false, + statement_count: 4, + examples: &examples, + quotes: "es, + candidates: candidates(), + }]; + let msgs = build_phrase_messages(&items); + let user = &msgs[1].content; + assert!(user.contains("Item 3: phrase \"is based in\" · subject class: organization · object: ? · 4 statements"), "{user}"); + assert!(user.contains("· Harbor Bakery —is based in→ Port Ellen\n \"Harbor Bakery is based in Port Ellen.\""), "{user}"); + assert!(user.contains("- headquartered_in · headquartered in · relation · domain: organization · range: place · The organization's"), "{user}"); + assert!( + user.contains("- revenue · revenue · attribute · domain: organization · Total income"), + "{user}" + ); + assert!(msgs[0] + .content + .contains("every statement of this signature")); + } + + #[test] + fn a_value_signature_says_value_for_its_object() { + let examples = strings(&["Harbor Bakery —revenue→ $2 million"]); + let quotes = strings(&["Harbor Bakery's revenue was $2 million."]); + let items = vec![PhraseItem { + id: 0, + phrase: "revenue", + subject_class: Some("organization"), + object_class: None, + object_is_value: true, + statement_count: 1, + examples: &examples, + quotes: "es, + candidates: candidates(), + }]; + let user = &build_phrase_messages(&items)[1].content; + assert!(user.contains("· object: value ·"), "{user}"); + } + + /// 绑上带方向;null 不绑;键照候选抄回;绑了没方向、键不在候选里、id 不在批里都算坏 + #[test] + fn triples_parse_and_bad_ones_are_counted() { + let examples = strings(&[]); + let quotes = strings(&[]); + let mk = |id: i64, phrase: &'static str| PhraseItem { + id, + phrase, + subject_class: Some("organization"), + object_class: Some("organization"), + object_is_value: false, + statement_count: 1, + examples: &examples, + quotes: "es, + candidates: candidates(), + }; + let items = vec![ + mk(0, "owns"), + mk(1, "said"), + mk(2, "acquired"), + mk(3, "employs"), + mk(4, "x"), + ]; + let raw = r#"{"b": [[0, "Subsidiary_Of", "reverse"], [1, null, null], [2, "acquired", "forward"], [3, "subsidiary_of"], ["4", "revenue", "forward"], [9, null, null], [0, null, null]]}"#; + let (choices, malformed) = parse_phrase_response(raw, &items).unwrap(); + assert_eq!( + choices, + vec![ + PhraseChoice { + id: 0, + property: Some(("subsidiary_of".into(), Direction::Reverse)) + }, + PhraseChoice { + id: 1, + property: None + }, + PhraseChoice { + id: 4, + property: Some(("revenue".into(), Direction::Forward)) + }, + ] + ); + // 2:键不在候选里;3:绑了没方向;9:id 不在批里;0 的第二次 + assert_eq!(malformed, 4); + } + + #[test] + fn a_truncated_reply_keeps_the_complete_triples() { + let examples = strings(&[]); + let quotes = strings(&[]); + let items = vec![ + PhraseItem { + id: 0, + phrase: "a", + subject_class: None, + object_class: None, + object_is_value: false, + statement_count: 1, + examples: &examples, + quotes: "es, + candidates: candidates(), + }, + PhraseItem { + id: 1, + phrase: "b", + subject_class: None, + object_class: None, + object_is_value: false, + statement_count: 1, + examples: &examples, + quotes: "es, + candidates: candidates(), + }, + ]; + let raw = r#"{"b": [[0, "revenue", "forward"], [1, "subsid"#; + let (choices, _) = parse_phrase_response(raw, &items).unwrap(); + assert_eq!(choices.len(), 1); + assert_eq!(choices[0].id, 0); + } +} diff --git a/crates/utopia-server/src/api/ontology_routes.rs b/crates/utopia-server/src/api/ontology_routes.rs index ea124b0b6..f6770bef8 100644 --- a/crates/utopia-server/src/api/ontology_routes.rs +++ b/crates/utopia-server/src/api/ontology_routes.rs @@ -323,6 +323,13 @@ pub async fn create_relation_type( if let Some(q) = req.qualifiers.as_deref() { utopia_store::ontology::set_relation_qualifiers(&state.pool, kb_id, id, q).await?; } + // 多了一个属性:判成 none / undecided 的签名也许对得上了(0044 对齐第二片) + utopia_store::jobs::enqueue_unless_queued( + &state.pool, + "align_phrases", + serde_json::json!({ "kb_id": kb_id }), + ) + .await?; let _ = utopia_store::audit::record( &state.pool, Some(kb_id), @@ -361,6 +368,13 @@ pub async fn update_relation_type( if let Some(q) = req.qualifiers.as_deref() { utopia_store::ontology::set_relation_qualifiers(&state.pool, kb_id, id, q).await?; } + // 属性改了定义或域/值域:绑到它的签名过期,判成 none 的也许对得上了 + utopia_store::jobs::enqueue_unless_queued( + &state.pool, + "align_phrases", + serde_json::json!({ "kb_id": kb_id }), + ) + .await?; let _ = utopia_store::audit::record( &state.pool, Some(kb_id), diff --git a/crates/utopia-server/src/main.rs b/crates/utopia-server/src/main.rs index 3087e44de..6c7e585fd 100644 --- a/crates/utopia-server/src/main.rs +++ b/crates/utopia-server/src/main.rs @@ -24,6 +24,7 @@ mod ontology_index; mod ontology_packs; mod owl_import; mod pack_alignment; +mod phrase_alignment; mod pipeline; mod predicate_match; mod query_engine; @@ -506,6 +507,16 @@ async fn dispatch(st: &state::AppState, job: &utopia_store::jobs::Job) -> anyhow .ok_or_else(|| anyhow::anyhow!("payload 缺少 kb_id"))?; type_alignment::align_types(st, kb_id).await } + // 关系短语按签名绑到属性(0044 对齐的第二片):类别词绑完排一个,属性改了再排 + "align_phrases" => { + let kb_id: Uuid = job + .payload + .get("kb_id") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse().ok()) + .ok_or_else(|| anyhow::anyhow!("payload 缺少 kb_id"))?; + phrase_alignment::align_phrases(st, kb_id).await + } "resolve_time" => { let id = payload_document_id(&job.payload)?; time_resolution::resolve_document(st, id).await diff --git a/crates/utopia-server/src/phrase_alignment.rs b/crates/utopia-server/src/phrase_alignment.rs new file mode 100644 index 000000000..283027edb --- /dev/null +++ b/crates/utopia-server/src/phrase_alignment.rs @@ -0,0 +1,382 @@ +//! 关系短语按签名绑到属性(0044 决定 3 的第二片;账本侧见 `utopia_store::phrase_bindings`, +//! 合同见 `utopia_extract::phrase_align`)。 +//! +//! 签名 = 短语 × 主语的类 × 宾语的类(宾语是字面值时记「值」)。两端的类来自类别词绑定 +//! 写到实体上的 `type_id`,所以这一步排在类别词对齐之后。一个库里 distinct 的签名比陈述 +//! 少得多,每条只判一次:**两票一致**才绑(第二票候选倒序,防「选第一个」冒充一致), +//! 不一致记 undecided 留给审核(#725 对齐队列),没有属性对得上记 none——陈述留在开放 +//! 图谱,什么都不丢,签名计入工作台的建议。绑定按属性的 `updated_at` 与库里最新的属性 +//! 判过期,本体一改只重判过期的。这一片只记判定;按绑定把陈述算成类型化事实是下一片。 +//! +//! 候选属性怎么来:先按声明的定义域/值域筛——签名两端的类落在属性的域/值域里的,或者 +//! 属性没声明域/值域的(两个方向都算,绑定可以是反向的);筛完仍超过上限就不判, +//! 瞎判比不判糟。 + +use crate::extraction::chat_retrying_rate_limits_at; +use crate::llm_util; +use crate::state::AppState; +use std::collections::{HashMap, HashSet}; +use utopia_core::models::RelationTypeView; +use utopia_extract::phrase_align::{ + build_phrase_messages, parse_phrase_response, Direction, PhraseItem, PropertyCandidate, +}; +use utopia_store::phrase_bindings::{self, Decision, PhraseSignature}; +use uuid::Uuid; + +/// 一次问多少条签名。 +const BATCH: usize = 12; +/// 筛过定义域/值域之后候选属性最多这么多,再多就不判。 +const CANDIDATE_LIMIT: usize = 60; + +/// 一票:这条签名选了哪个属性、哪个方向(None = 没有属性对得上)。 +type Vote = Option<(String, Direction)>; + +/// 签名两端的类落在属性声明的域/值域里(没声明的不限);正反两个方向都算。 +fn fits(p: &RelationTypeView, sig: &PhraseSignature) -> bool { + let within = |declared: &[Uuid], class: Option| -> bool { + declared.is_empty() || class.is_none_or(|c| declared.contains(&c)) + }; + if sig.object_is_value { + p.kind == "attribute" && within(&p.domains, sig.subject_type_id) + } else { + p.kind == "relation" + && ((within(&p.domains, sig.subject_type_id) && within(&p.ranges, sig.object_type_id)) + || (within(&p.domains, sig.object_type_id) + && within(&p.ranges, sig.subject_type_id))) + } +} + +/// 对一个库跑一遍:新出现的和过期的签名各判一次。 +pub async fn align_phrases(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> { + let pool = &state.pool; + let kb = utopia_store::kbs::get(pool, kb_id).await?; + let settings = utopia_store::settings::get(pool, kb.workspace_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Chat model not configured; cannot align phrases"))?; + let client = llm_util::chat_client(&settings) + .ok_or_else(|| anyhow::anyhow!("Chat model not configured; cannot align phrases"))?; + // 一个库同时只跑一份,理由同类别词对齐(并行跑会把端点打出 502) + let mut guard = pool.acquire().await?; + let locked: bool = + sqlx::query_scalar("SELECT pg_try_advisory_lock(hashtext('align_phrases'), hashtext($1))") + .bind(kb_id.to_string()) + .fetch_one(&mut *guard) + .await?; + if !locked { + tracing::info!(%kb_id, "短语对齐已有一份在跑,这次跳过"); + return Ok(()); + } + let result = align_phrases_locked(state, kb_id, &settings, &client).await; + let _ = sqlx::query("SELECT pg_advisory_unlock(hashtext('align_phrases'), hashtext($1))") + .bind(kb_id.to_string()) + .execute(&mut *guard) + .await; + result +} + +async fn align_phrases_locked( + state: &AppState, + kb_id: Uuid, + settings: &utopia_core::models::LlmSettings, + client: &utopia_llm::LlmClient, +) -> anyhow::Result<()> { + let pool = &state.pool; + let props = utopia_store::ontology::relation_type_views(pool, kb_id).await?; + let classes = utopia_store::graph::entity_types(pool, kb_id).await?; + let class_key: HashMap = classes.iter().map(|c| (c.id, c.key.as_str())).collect(); + let by_key: HashMap<&str, &RelationTypeView> = + props.iter().map(|p| (p.key.as_str(), p)).collect(); + let sigs = phrase_bindings::signatures(pool, kb_id).await?; + let existing: HashMap<_, _> = phrase_bindings::bindings(pool, kb_id) + .await? + .into_iter() + .map(|b| (b.key(), b)) + .collect(); + let stale: HashSet<_> = phrase_bindings::stale(pool, kb_id) + .await? + .into_iter() + .map(|b| b.key()) + .collect(); + let todo: Vec<&PhraseSignature> = sigs + .iter() + .filter(|s| match existing.get(&s.key()) { + None => true, + Some(b) => b.decided_by != "person" && stale.contains(&s.key()), + }) + .collect(); + tracing::info!(%kb_id, signatures = sigs.len(), to_decide = todo.len(), properties = props.len(), "短语对齐开始"); + + // 没有属性可绑:每条都是「没有」;属性出现后 `stale` 会把它们再交回来 + if props.is_empty() { + for s in &todo { + phrase_bindings::decide( + pool, + kb_id, + s, + Decision { + relation_type_id: None, + direction: None, + status: "none", + votes: &serde_json::json!({ "reason": "no properties" }), + decided_by: "agent", + }, + ) + .await?; + } + return Ok(()); + } + + let keys_of = |ids: &[Uuid]| -> Vec<&str> { + ids.iter() + .filter_map(|id| class_key.get(id).copied()) + .collect() + }; + let (mut bound, mut none, mut undecided, mut skipped) = (0usize, 0usize, 0usize, 0usize); + // 调用或解析失败的批次:这轮跳过,结束时自己再排一次 + let mut failed = 0usize; + for batch in todo.chunks(BATCH) { + let cands: Vec> = batch + .iter() + .map(|s| { + let fitting: Vec<&RelationTypeView> = props.iter().filter(|p| fits(p, s)).collect(); + if fitting.len() > CANDIDATE_LIMIT { + Vec::new() + } else { + fitting + } + }) + .collect(); + let mut votes: Vec<(Vote, Vote)> = vec![(None, None); batch.len()]; + let mut answered = vec![(false, false); batch.len()]; + for pass in 0..2 { + let items: Vec> = batch + .iter() + .enumerate() + .filter(|(i, _)| !cands[*i].is_empty()) + .map(|(i, s)| { + let mut list: Vec<&RelationTypeView> = cands[i].clone(); + if pass == 1 { + list.reverse(); + } + PhraseItem { + id: i as i64, + phrase: &s.phrase, + subject_class: s.subject_type_key.as_deref(), + object_class: s.object_type_key.as_deref(), + object_is_value: s.object_is_value, + statement_count: s.count, + examples: &s.examples, + quotes: &s.quotes, + candidates: list + .iter() + .map(|p| PropertyCandidate { + key: &p.key, + label: &p.label, + description: &p.description, + kind: &p.kind, + domains: keys_of(&p.domains), + ranges: keys_of(&p.ranges), + }) + .collect(), + } + }) + .collect(); + if items.is_empty() { + continue; + } + let messages = build_phrase_messages(&items); + let reply = + match chat_retrying_rate_limits_at(state, settings, client, &messages, Some(0.0)) + .await + { + Ok(r) => r, + Err(e) => { + tracing::warn!(%kb_id, error = %e, "短语对齐调用失败,这一批留到下次"); + failed += 1; + continue; + } + }; + let (choices, malformed) = match parse_phrase_response(&reply, &items) { + Ok(x) => x, + Err(e) => { + tracing::warn!(%kb_id, error = %e, "短语对齐回复解析失败,这一批留到下次"); + failed += 1; + continue; + } + }; + skipped += malformed; + for c in choices { + let Ok(i) = usize::try_from(c.id) else { + continue; + }; + if let Some(slot) = votes.get_mut(i) { + if pass == 0 { + slot.0 = c.property; + answered[i].0 = true; + } else { + slot.1 = c.property; + answered[i].1 = true; + } + } + } + } + for (i, s) in batch.iter().enumerate() { + if cands[i].is_empty() { + skipped += 1; + continue; + } + let (a, b) = &votes[i]; + let (ans_a, ans_b) = answered[i]; + if !ans_a || !ans_b { + // 有一票没答到:不下结论,下次再问 + continue; + } + let show = |v: &Vote| { + v.as_ref() + .map(|(k, d)| serde_json::json!({ "property": k, "direction": d.as_str() })) + .unwrap_or(serde_json::Value::Null) + }; + let record = serde_json::json!({ "first": show(a), "second": show(b) }); + if a != b { + phrase_bindings::decide( + pool, + kb_id, + s, + Decision { + relation_type_id: None, + direction: None, + status: "undecided", + votes: &record, + decided_by: "agent", + }, + ) + .await?; + undecided += 1; + continue; + } + match a + .as_ref() + .and_then(|(k, d)| by_key.get(k.as_str()).map(|p| (p, *d))) + { + Some((p, d)) => { + if phrase_bindings::decide( + pool, + kb_id, + s, + Decision { + relation_type_id: Some(p.id), + direction: Some(d.as_str()), + status: "bound", + votes: &record, + decided_by: "agent", + }, + ) + .await? + { + bound += 1; + } + } + None => { + if phrase_bindings::decide( + pool, + kb_id, + s, + Decision { + relation_type_id: None, + direction: None, + status: "none", + votes: &record, + decided_by: "agent", + }, + ) + .await? + { + none += 1; + } + } + } + } + } + tracing::info!(%kb_id, bound, none, undecided, skipped, failed, "短语对齐完成"); + if failed > 0 { + utopia_store::jobs::enqueue_unless_queued( + pool, + "align_phrases", + serde_json::json!({ "kb_id": kb_id }), + ) + .await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn view(kind: &str, domains: Vec, ranges: Vec) -> RelationTypeView { + RelationTypeView { + id: Uuid::now_v7(), + key: "p".into(), + label: "p".into(), + temporal: "state".into(), + functional: false, + inverse_functional: false, + is_transitive: false, + is_symmetric: false, + is_asymmetric: false, + is_irreflexive: false, + inverse_of: None, + sub_property_of: None, + builtin: false, + description: String::new(), + kind: kind.into(), + domains, + ranges, + datatype: None, + unit: None, + qualifiers: Vec::new(), + usage: 0, + } + } + + fn sig(subject: Option, object: Option, value: bool) -> PhraseSignature { + PhraseSignature { + phrase: "x".into(), + subject_type_id: subject, + subject_type_key: None, + object_type_id: object, + object_type_key: None, + object_is_value: value, + count: 1, + examples: Vec::new(), + quotes: Vec::new(), + } + } + + /// 域/值域筛候选:声明了的要落在里面(正反都算),没声明的不限,类为空的一端不限 + #[test] + fn a_property_fits_a_signature_by_its_declared_ends_in_either_direction() { + let (org, place, person) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let hq = view("relation", vec![org], vec![place]); + assert!(fits(&hq, &sig(Some(org), Some(place), false))); + assert!(fits(&hq, &sig(Some(place), Some(org), false)), "反向也算"); + assert!(!fits(&hq, &sig(Some(person), Some(place), false))); + assert!( + fits(&hq, &sig(None, Some(place), false)), + "没绑到类的一端不限" + ); + assert!(!fits(&hq, &sig(Some(org), None, true)), "关系不接字面值"); + let open = view("relation", vec![], vec![]); + assert!( + fits(&open, &sig(Some(person), Some(person), false)), + "没声明就不限" + ); + let revenue = view("attribute", vec![org], vec![]); + assert!(fits(&revenue, &sig(Some(org), None, true))); + assert!(!fits(&revenue, &sig(Some(person), None, true))); + assert!( + !fits(&revenue, &sig(Some(org), Some(place), false)), + "属性只接字面值" + ); + } +} diff --git a/crates/utopia-server/src/type_alignment.rs b/crates/utopia-server/src/type_alignment.rs index a032db485..fa5d60558 100644 --- a/crates/utopia-server/src/type_alignment.rs +++ b/crates/utopia-server/src/type_alignment.rs @@ -157,6 +157,8 @@ async fn align_types_locked( } let (mut bound, mut none, mut undecided, mut skipped) = (0usize, 0usize, 0usize, 0usize); + // 调用或解析失败的批次:这轮跳过,结束时自己再排一次,不等下一篇文档来排 + let mut failed = 0usize; for batch in todo.chunks(BATCH) { let cands = candidates_for(state, kb_id, batch, &classes).await?; // 两票:第二票把候选倒过来给,防止「选第一个」这种顺序偏好冒充一致 @@ -201,6 +203,7 @@ async fn align_types_locked( Ok(r) => r, Err(e) => { tracing::warn!(%kb_id, error = %e, "类别词对齐调用失败,这一批留到下次"); + failed += 1; continue; } }; @@ -208,6 +211,7 @@ async fn align_types_locked( Ok(x) => x, Err(e) => { tracing::warn!(%kb_id, error = %e, "类别词对齐回复解析失败,这一批留到下次"); + failed += 1; continue; } }; @@ -299,9 +303,24 @@ async fn align_types_locked( } } } - tracing::info!(%kb_id, bound, none, undecided, skipped, "类别词对齐完成"); + tracing::info!(%kb_id, bound, none, undecided, skipped, failed, "类别词对齐完成"); if bound > 0 { state.emit_graph(kb_id); } + if failed > 0 { + utopia_store::jobs::enqueue_unless_queued( + pool, + "align_types", + serde_json::json!({ "kb_id": kb_id }), + ) + .await?; + } + // 两端的类定了,短语的签名才定:短语对齐排在它后面 + utopia_store::jobs::enqueue_unless_queued( + pool, + "align_phrases", + serde_json::json!({ "kb_id": kb_id }), + ) + .await?; Ok(()) } diff --git a/crates/utopia-store/src/lib.rs b/crates/utopia-store/src/lib.rs index bafa7f741..93af005ee 100644 --- a/crates/utopia-store/src/lib.rs +++ b/crates/utopia-store/src/lib.rs @@ -27,6 +27,7 @@ pub mod ontology; pub mod palette; pub mod paths; pub mod pending; +pub mod phrase_bindings; pub mod reasoning; pub mod record_axis; pub mod resolution; diff --git a/crates/utopia-store/src/ontology.rs b/crates/utopia-store/src/ontology.rs index cc425e418..5b96f2eba 100644 --- a/crates/utopia-store/src/ontology.rs +++ b/crates/utopia-store/src/ontology.rs @@ -661,7 +661,7 @@ pub async fn update_relation_type( unit = CASE WHEN kind = 'attribute' THEN $9 ELSE unit END, is_transitive = $10, is_symmetric = $11, is_asymmetric = $12, is_irreflexive = $13, - inverse_of = $14, sub_property_of = $15 + inverse_of = $14, sub_property_of = $15, updated_at = now() WHERE id = $2 AND kb_id = $1", ) .bind(kb_id) diff --git a/crates/utopia-store/src/phrase_bindings.rs b/crates/utopia-store/src/phrase_bindings.rs new file mode 100644 index 000000000..09fb2227d --- /dev/null +++ b/crates/utopia-store/src/phrase_bindings.rs @@ -0,0 +1,276 @@ +//! 关系短语按签名绑到属性的账本侧(0044 决定 3 的第二片,见 0066)。 +//! +//! 开放陈述的关系是文档自己的短语(`facts.phrase`),不选属性。这里做三件事:数出一个 +//! 库里有哪些签名(短语 × 主语的类 × 宾语的类或「值」),各带几条例句;记下每个签名判成 +//! 了什么(绑定)并判哪些过期了;给下一片(物化)一张「签名 → 属性、方向」的表。判定 +//! 本身——问模型、两票一致才绑——在 server 的 `phrase_alignment` 里,这里不认识模型。 +//! +//! 短语的归一同类别词:空白折成一个空格、去两端、小写;[`normalize`] 与 [`PHRASE_SQL`] +//! 必须说同一件事。 + +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use utopia_core::{AppError, AppResult}; +use uuid::Uuid; + +/// SQL 侧的归一:与 [`normalize`] 一致。`$col` 由调用处替换成列名。 +const PHRASE_SQL: &str = "lower(btrim(regexp_replace($col, '\\s+', ' ', 'g')))"; + +fn phrase_sql(col: &str) -> String { + PHRASE_SQL.replace("$col", col) +} + +/// 归一一个短语:空白折成一个空格、去两端、小写。"Acquired " 与 "acquired" 是一个短语。 +pub fn normalize(phrase: &str) -> String { + phrase + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +/// 一条签名:判它该绑到哪个属性时给模型看的全部。 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct PhraseSignature { + /// 归一过的短语 + pub phrase: String, + /// 主语的类(类别词还没绑到类时为空) + pub subject_type_id: Option, + pub subject_type_key: Option, + /// 宾语的类;宾语是字面值时为空且 `object_is_value` 为真 + pub object_type_id: Option, + pub object_type_key: Option, + pub object_is_value: bool, + /// 这个签名下活着的开放陈述数 + pub count: i64, + /// 例句:最多 3 条「主语 —短语→ 宾语」,每条跟着它自己的引文 + pub examples: Vec, + pub quotes: Vec, +} + +/// 库里每条 distinct 的签名:活着的开放陈述,按短语、两端的类、宾语是不是字面值分组。 +pub async fn signatures(pool: &PgPool, kb_id: Uuid) -> AppResult> { + let sql = format!( + "WITH live AS ( + SELECT f.id, {phrase} AS phrase, + s.type_id AS subject_type_id, o.type_id AS object_type_id, + (f.object_id IS NULL) AS object_is_value, + s.canonical_name AS subject_name, + coalesce(o.canonical_name, f.object_value #>> '{{value}}', '') AS object_name, + f.phrase AS spelling, fe.chunk_id, fe.quote_start, fe.quote_end, f.recorded_at + FROM facts f + JOIN entities s ON s.id = f.subject_id + LEFT JOIN entities o ON o.id = f.object_id + LEFT JOIN fact_evidence fe ON fe.fact_id = f.id + WHERE f.kb_id = $1 AND f.layer = 'open' AND f.invalidated_at IS NULL + AND f.phrase IS NOT NULL AND btrim(f.phrase) <> '' + ), + grouped AS ( + SELECT phrase, subject_type_id, object_type_id, object_is_value, count(*) AS count + FROM live GROUP BY phrase, subject_type_id, object_type_id, object_is_value + ), + picked AS ( + SELECT g.phrase, g.subject_type_id, g.object_type_id, g.object_is_value, + l.subject_name || ' —' || l.spelling || '→ ' || l.object_name AS example, + coalesce(substr(c.text, l.quote_start + 1, l.quote_end - l.quote_start), '') AS quote, + row_number() OVER (PARTITION BY g.phrase, g.subject_type_id, g.object_type_id, g.object_is_value + ORDER BY l.recorded_at, l.id) AS rn + FROM grouped g + JOIN live l ON l.phrase = g.phrase + AND l.subject_type_id IS NOT DISTINCT FROM g.subject_type_id + AND l.object_type_id IS NOT DISTINCT FROM g.object_type_id + AND l.object_is_value = g.object_is_value + LEFT JOIN chunks c ON c.id = l.chunk_id + ) + SELECT g.phrase, g.subject_type_id, st.key AS subject_type_key, + g.object_type_id, ot.key AS object_type_key, g.object_is_value, g.count, + ARRAY(SELECT p.example FROM picked p + WHERE p.phrase = g.phrase AND p.subject_type_id IS NOT DISTINCT FROM g.subject_type_id + AND p.object_type_id IS NOT DISTINCT FROM g.object_type_id + AND p.object_is_value = g.object_is_value AND p.rn <= 3 ORDER BY p.rn) AS examples, + ARRAY(SELECT p.quote FROM picked p + WHERE p.phrase = g.phrase AND p.subject_type_id IS NOT DISTINCT FROM g.subject_type_id + AND p.object_type_id IS NOT DISTINCT FROM g.object_type_id + AND p.object_is_value = g.object_is_value AND p.rn <= 3 ORDER BY p.rn) AS quotes + FROM grouped g + LEFT JOIN entity_types st ON st.id = g.subject_type_id + LEFT JOIN entity_types ot ON ot.id = g.object_type_id + ORDER BY g.count DESC, g.phrase", + phrase = phrase_sql("f.phrase") + ); + Ok(sqlx::query_as(&sql).bind(kb_id).fetch_all(pool).await?) +} + +/// 一条签名判成了什么。 +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct Binding { + pub phrase: String, + pub subject_type_id: Option, + pub object_type_id: Option, + pub object_is_value: bool, + /// `status = 'bound'` 时有值 + pub relation_type_id: Option, + /// forward / reverse,`status = 'bound'` 时有值 + pub direction: Option, + /// bound / none / undecided + pub status: String, + pub decided_at: DateTime, + /// agent / person + pub decided_by: String, +} + +impl Binding { + /// 和签名对上的键:短语 + 两端的类 + 宾语是不是字面值 + pub fn key(&self) -> (String, Option, Option, bool) { + ( + self.phrase.clone(), + self.subject_type_id, + self.object_type_id, + self.object_is_value, + ) + } +} + +impl PhraseSignature { + pub fn key(&self) -> (String, Option, Option, bool) { + ( + self.phrase.clone(), + self.subject_type_id, + self.object_type_id, + self.object_is_value, + ) + } +} + +/// 库里全部绑定。 +pub async fn bindings(pool: &PgPool, kb_id: Uuid) -> AppResult> { + Ok(sqlx::query_as( + "SELECT phrase, subject_type_id, object_type_id, object_is_value, + relation_type_id, direction, status, decided_at, decided_by + FROM phrase_bindings WHERE kb_id = $1 ORDER BY phrase", + ) + .bind(kb_id) + .fetch_all(pool) + .await?) +} + +/// 不再成立的绑定:绑到的属性在判定之后改过;或判成 none / undecided 之后库里长出了 +/// 新属性。属性或类被删了的,行已随级联消失。 +pub async fn stale(pool: &PgPool, kb_id: Uuid) -> AppResult> { + Ok(sqlx::query_as( + "SELECT b.phrase, b.subject_type_id, b.object_type_id, b.object_is_value, + b.relation_type_id, b.direction, b.status, b.decided_at, b.decided_by + FROM phrase_bindings b + LEFT JOIN relation_types r ON r.id = b.relation_type_id + WHERE b.kb_id = $1 + AND ((b.status = 'bound' AND r.updated_at > b.decided_at) + OR (b.status IN ('none', 'undecided') + AND b.decided_at < (SELECT max(created_at) FROM relation_types + WHERE kb_id = $1))) + ORDER BY b.phrase", + ) + .bind(kb_id) + .fetch_all(pool) + .await?) +} + +/// 一次判定要写的东西。 +pub struct Decision<'a> { + pub relation_type_id: Option, + /// forward / reverse + pub direction: Option<&'a str>, + /// bound / none / undecided + pub status: &'a str, + pub votes: &'a serde_json::Value, + /// agent / person + pub decided_by: &'a str, +} + +/// 记下一条签名的判定(有则改)。返回是否写入了。 +/// +/// **人的判定不被代理覆盖**:已有行是人判的而这次是代理,原样留着、返回 false。 +/// 反过来人可以改代理的。 +pub async fn decide( + pool: &PgPool, + kb_id: Uuid, + sig: &PhraseSignature, + d: Decision<'_>, +) -> AppResult { + if !matches!(d.status, "bound" | "none" | "undecided") { + return Err(AppError::Validation(format!( + "unknown binding status {:?}", + d.status + ))); + } + let bound = d.status == "bound"; + if bound != d.relation_type_id.is_some() || bound != d.direction.is_some() { + return Err(AppError::Validation( + "a bound signature needs a property and a direction and an unbound one must not have them".into(), + )); + } + if let Some(dir) = d.direction { + if !matches!(dir, "forward" | "reverse") { + return Err(AppError::Validation(format!("unknown direction {dir:?}"))); + } + } + if !matches!(d.decided_by, "agent" | "person") { + return Err(AppError::Validation(format!( + "unknown decider {:?}", + d.decided_by + ))); + } + let phrase = normalize(&sig.phrase); + if phrase.is_empty() { + return Err(AppError::Validation("an empty phrase binds nothing".into())); + } + let res = sqlx::query( + "INSERT INTO phrase_bindings + (id, kb_id, phrase, subject_type_id, object_type_id, object_is_value, + relation_type_id, direction, status, votes, statement_count, examples, + decided_at, decided_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13) + ON CONFLICT (kb_id, phrase, subject_type_id, object_type_id, object_is_value) DO UPDATE + SET relation_type_id = EXCLUDED.relation_type_id, + direction = EXCLUDED.direction, + status = EXCLUDED.status, + votes = EXCLUDED.votes, + statement_count = EXCLUDED.statement_count, + examples = EXCLUDED.examples, + decided_at = now(), + decided_by = EXCLUDED.decided_by + WHERE NOT (phrase_bindings.decided_by = 'person' AND EXCLUDED.decided_by = 'agent')", + ) + .bind(Uuid::now_v7()) + .bind(kb_id) + .bind(&phrase) + .bind(sig.subject_type_id) + .bind(if sig.object_is_value { + None + } else { + sig.object_type_id + }) + .bind(sig.object_is_value) + .bind(d.relation_type_id) + .bind(d.direction) + .bind(d.status) + .bind(d.votes) + .bind(i32::try_from(sig.count).unwrap_or(i32::MAX)) + .bind(&sig.examples) + .bind(d.decided_by) + .execute(pool) + .await?; + Ok(res.rows_affected() > 0) +} + +#[cfg(test)] +mod tests { + use super::normalize; + + #[test] + fn a_phrase_is_one_phrase_however_spaced_or_cased() { + assert_eq!(normalize(" Was Designed By "), "was designed by"); + assert_eq!(normalize("Revenue"), "revenue"); + assert_eq!(normalize("细化解读"), "细化解读"); + assert_eq!(normalize(" "), ""); + } +} diff --git a/docs/design/ontology.md b/docs/design/ontology.md index bd9007700..141b07d02 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -49,6 +49,28 @@ classes of context neighbours, interleaved and never scored together; a class in subtree applied on its own, a cross-axis class sent to a person, each pair acknowledged once) runs from the ontology page; extraction no longer enqueues it [0001, 0016, #736]. +**A relation phrase binds to a property per signature** [0044 cut 2, #PRN]. A signature is a +phrase as the documents wrote it, the class of its subject and the class of its object, or "value" +when the object is a figure, a title or a status; the classes come from the kind-word bindings, and +a side whose kind word is bound to no class is its own signature. Each signature is decided once, +after the kind words, by the same shape as [#741]: candidates are the properties whose declared +domain and range admit the two ends in either direction (an undeclared end admits anything), the +model sees the signature with three of its statements and their quotes, and two votes with the +candidates in opposite orders must agree on the property and the direction (forward when the +statement's subject is the property's subject, reverse when its object is) for the signature to +bind. A signature the votes disagree on is `undecided` for the alignment queue of #725; one with no +fitting property is `none`, its statements stay in the open graph and it counts toward the +workbench's suggestions. Bindings live in `phrase_bindings` and go stale when the property they +bound to changes or a property is added; a person's decision is never overwritten by the agent. On +the 25-document batch with a hand-written ontology of 14 classes and 28 properties, 423 +signatures cover 861 statements: 90 bind (covering 311 statements), +305 bind to nothing, 28 split the votes; a judge reading the chunk finds 80.5% +of the resulting typed facts stated by the document, 16.8% worded wrongly and +2.7% not stated, most of the wrong ones a copula phrase ("was") whose objects mix +figures with words, and a phrase that carries part of the value ("下降 1.4%" bound to a change +property loses its sign). Nothing is materialised yet: the typed rows with `from_statement_id` are +the next slice. + **Argument order is enforced, participation is guided.** A declared domain or range shapes candidates and never discards a fact; argument order is the key's encoding convention, so a fact whose subject violates the domain while the object fits is swapped by the signature and marked @@ -102,10 +124,10 @@ the prompt, a description is read by people and by the aligner. ## Proposed and not built -- **Alignment** (0044 cut 2): a table of signature (phrase, subject classes, object classes) to - property and direction with confidence and ontology version; implication rules proposed by the - aligner, approved on the workbench, executed by code with cached readings; typed rows carrying - `from_statement_id`; recomputation per changed signature. The prototype aligner reached 14.7% and +- **Alignment** (0044 cut 2), the rest: typed rows carrying `from_statement_id`, materialised + from bound signatures and recomputed per changed signature; implication rules proposed by the + aligner, approved on the workbench, executed by code with cached readings (the sign of "下降 + 1.4%" is such a reading); a signature that tells a figure from words on the value side. The prototype aligner reached 14.7% and 12.1% of gold recall in two runs against 15.5% for the withdrawn bound pass, so the bar for cut 2 is parity over two clean runs [0044, #729]. - **The workbench** (0044 cut 5): the ontology page fed by suggestions from the open graph (frequent diff --git a/docs/design/prior-work.md b/docs/design/prior-work.md index 9ad313334..d29895da9 100644 --- a/docs/design/prior-work.md +++ b/docs/design/prior-work.md @@ -189,9 +189,8 @@ built. argument types [Dutta 2015]; Angeli's KBP mapping is conditioned on the type signature [Angeli 2015]; typed markers lift relation extraction [Ling 2012, Zhong 2021]; ODKE+ exposes only the type's slice of the ontology [Khorshidi 2025]; iText2KG lists typing as its missing - ingredient [Lairgi 2024]. Kind words bind before relation phrases [0044 cut 2]. *Open:* the - phrase binding reads the classes at both ends, and the (class, class) signature is part of a - phrase binding's key. + ingredient [Lairgi 2024]. Kind words bind before relation phrases [0044 cut 2]; the phrase binding reads the classes at + both ends and the (class, class) signature is part of its key (#PRN). 12. **Precision is lost in the binding, not in the extraction.** Of the mapping errors in the three-hour KBP system, 31% were definition mismatch and 23% over-generalized rules against 15% open-extraction errors [Soderland 2013]; the top hundred predicates cover only 57 to 82% of an diff --git a/migrations/0066_a_phrase_binds_to_a_property.sql b/migrations/0066_a_phrase_binds_to_a_property.sql new file mode 100644 index 000000000..0f0c11910 --- /dev/null +++ b/migrations/0066_a_phrase_binds_to_a_property.sql @@ -0,0 +1,56 @@ +-- 一条关系短语按签名绑到一个属性(0044 决定 3 的第二片:签名 = 短语 × 主语的类 × +-- 宾语的类,宾语是字面值时记「值」)。 +-- +-- 开放陈述的关系是文档自己的短语(`facts.phrase`:acquired、「细化解读」、Revenue), +-- 不选属性。属性是本体的事。一个库里 distinct 的签名比陈述少得多——同一个短语在同一对 +-- 类之间出现多少次,只判一次:判了记在 `phrase_bindings` 里,本体一改只重判过期的 +-- (绑到的属性判定后改过 `relation_types.updated_at`;判成 none / undecided 之后长出了 +-- 新属性 `relation_types.created_at`)。绑上的签名下的陈述算成类型化事实,那是下一片 +-- (物化视图,`facts.from_statement_id`);这一片只记判定。 +-- +-- 两端的类来自类别词绑定(`entities.type_id`);一端没有类的签名照样判——文档的例句 +-- 和引文就是证据——但类为空的签名和类为某个类的签名是两条不同的签名。 +-- `direction`:forward 是陈述的主语就是属性的主语,reverse 是反过来(「owns」绑到 +-- subsidiary_of)。人的判定不被代理覆盖。 + +-- 属性改过没有,从前也只有 created_at;绑定按它判过期,每条 UPDATE relation_types 都摸一下 +ALTER TABLE relation_types ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now(); + +CREATE TABLE phrase_bindings ( + id UUID PRIMARY KEY, + kb_id UUID NOT NULL REFERENCES knowledge_bases(id) ON DELETE CASCADE, + -- 归一过的短语:空白折成一个空格、小写、去两端空白(同 type_bindings.kind_word) + phrase TEXT NOT NULL, + -- 两端的类;空表示那一端的类别词还没绑到类(或宾语是字面值)。类删了绑定跟着走 + subject_type_id UUID REFERENCES entity_types(id) ON DELETE CASCADE, + object_type_id UUID REFERENCES entity_types(id) ON DELETE CASCADE, + -- 宾语是字面值(金额、百分比、称号)而不是一样东西 + object_is_value BOOLEAN NOT NULL DEFAULT false, + -- status 为 none / undecided 时为空;属性删了绑定跟着走 + relation_type_id UUID REFERENCES relation_types(id) ON DELETE CASCADE, + -- forward:陈述的主语是属性的主语;reverse:陈述的宾语是 + direction TEXT CHECK (direction IN ('forward', 'reverse')), + -- bound 绑上了 + -- none 没有属性对得上:陈述留在开放图谱,签名计入工作台的建议 + -- undecided 两票不一致,留给审核(#725 对齐队列) + status TEXT NOT NULL CHECK (status IN ('bound', 'none', 'undecided')), + -- 得出这个判定的那几票 + votes JSONB, + -- 判定时这个签名下有几条陈述,三条例句——工作台按它排建议 + statement_count INTEGER NOT NULL DEFAULT 0, + examples TEXT[] NOT NULL DEFAULT '{}', + decided_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- 人的判定不被代理覆盖 + decided_by TEXT NOT NULL DEFAULT 'agent' CHECK (decided_by IN ('agent', 'person')), + -- 一个库里一条签名一行;类为空也是一种签名(NULLS NOT DISTINCT) + UNIQUE NULLS NOT DISTINCT (kb_id, phrase, subject_type_id, object_type_id, object_is_value), + -- 绑上了就得有属性和方向,没绑就都不能有 + CONSTRAINT phrase_bindings_shape + CHECK ((status = 'bound') = (relation_type_id IS NOT NULL) + AND (status = 'bound') = (direction IS NOT NULL)), + -- 宾语是字面值的签名没有宾语类 + CONSTRAINT phrase_bindings_value_has_no_class + CHECK (NOT object_is_value OR object_type_id IS NULL) +); + +CREATE INDEX phrase_bindings_kb_status_idx ON phrase_bindings (kb_id, status); From b1bc2801af233eb22a167730fc2c6e7f0673863e Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 18 Sep 2026 04:11:36 +0800 Subject: [PATCH 2/5] An unbound end fits only an undeclared one, and a skipped run queues behind the running one Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- crates/utopia-server/src/phrase_alignment.rs | 29 +++++++++++++++----- crates/utopia-server/src/type_alignment.rs | 9 +++++- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/utopia-server/src/phrase_alignment.rs b/crates/utopia-server/src/phrase_alignment.rs index 283027edb..ddade6e85 100644 --- a/crates/utopia-server/src/phrase_alignment.rs +++ b/crates/utopia-server/src/phrase_alignment.rs @@ -9,8 +9,10 @@ //! 判过期,本体一改只重判过期的。这一片只记判定;按绑定把陈述算成类型化事实是下一片。 //! //! 候选属性怎么来:先按声明的定义域/值域筛——签名两端的类落在属性的域/值域里的,或者 -//! 属性没声明域/值域的(两个方向都算,绑定可以是反向的);筛完仍超过上限就不判, -//! 瞎判比不判糟。 +//! 属性没声明域/值域的(两个方向都算,绑定可以是反向的)。**一端没绑到类的签名,只有 +//! 没声明那一端的属性才算候选**:类别词还没绑上时把声明了域的属性也给模型,NVDA 四篇上 +//! 现金流量表的每一行都绑到了泛泛的 value(主语 NVIDIA 没类,value 的域是指标)——裁判 +//! 判成写错的一半是它。筛完仍超过上限就不判,瞎判比不判糟。 use crate::extraction::chat_retrying_rate_limits_at; use crate::llm_util; @@ -31,10 +33,11 @@ const CANDIDATE_LIMIT: usize = 60; /// 一票:这条签名选了哪个属性、哪个方向(None = 没有属性对得上)。 type Vote = Option<(String, Direction)>; -/// 签名两端的类落在属性声明的域/值域里(没声明的不限);正反两个方向都算。 +/// 签名两端的类落在属性声明的域/值域里(没声明的不限,没绑到类的一端只被没声明的 +/// 一端接受);正反两个方向都算。 fn fits(p: &RelationTypeView, sig: &PhraseSignature) -> bool { let within = |declared: &[Uuid], class: Option| -> bool { - declared.is_empty() || class.is_none_or(|c| declared.contains(&c)) + declared.is_empty() || class.is_some_and(|c| declared.contains(&c)) }; if sig.object_is_value { p.kind == "attribute" && within(&p.domains, sig.subject_type_id) @@ -63,7 +66,14 @@ pub async fn align_phrases(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> .fetch_one(&mut *guard) .await?; if !locked { - tracing::info!(%kb_id, "短语对齐已有一份在跑,这次跳过"); + // 正在跑的那份看不见这次触发带来的变化(新属性、改过的定义):排回去,它完了再跑一遍 + tracing::info!(%kb_id, "短语对齐已有一份在跑,排到它后面"); + utopia_store::jobs::enqueue_unless_queued( + pool, + "align_phrases", + serde_json::json!({ "kb_id": kb_id }), + ) + .await?; return Ok(()); } let result = align_phrases_locked(state, kb_id, &settings, &client).await; @@ -362,8 +372,13 @@ mod tests { assert!(fits(&hq, &sig(Some(place), Some(org), false)), "反向也算"); assert!(!fits(&hq, &sig(Some(person), Some(place), false))); assert!( - fits(&hq, &sig(None, Some(place), false)), - "没绑到类的一端不限" + !fits(&hq, &sig(None, Some(place), false)), + "没绑到类的一端不算落在声明的域里" + ); + let any_to_place = view("relation", vec![], vec![place]); + assert!( + fits(&any_to_place, &sig(None, Some(place), false)), + "没声明的一端接受没绑到类的" ); assert!(!fits(&hq, &sig(Some(org), None, true)), "关系不接字面值"); let open = view("relation", vec![], vec![]); diff --git a/crates/utopia-server/src/type_alignment.rs b/crates/utopia-server/src/type_alignment.rs index fa5d60558..7803c5737 100644 --- a/crates/utopia-server/src/type_alignment.rs +++ b/crates/utopia-server/src/type_alignment.rs @@ -90,7 +90,14 @@ pub async fn align_types(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> { .fetch_one(&mut *guard) .await?; if !locked { - tracing::info!(%kb_id, "类别词对齐已有一份在跑,这次跳过"); + // 正在跑的那份看不见这次触发带来的变化(新类、新文档的词):排回去,它完了再跑一遍 + tracing::info!(%kb_id, "类别词对齐已有一份在跑,排到它后面"); + utopia_store::jobs::enqueue_unless_queued( + pool, + "align_types", + serde_json::json!({ "kb_id": kb_id }), + ) + .await?; return Ok(()); } let result = align_types_locked(state, kb_id, &settings, &client).await; From 7e4c21d407e4cc2b1179d0d76fcc64b1e07124d9 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 18 Sep 2026 04:16:34 +0800 Subject: [PATCH 3/5] An alignment run looks for new work when it ends Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- crates/utopia-server/src/phrase_alignment.rs | 27 ++++++++++++------ crates/utopia-server/src/type_alignment.rs | 30 ++++++++++++++------ 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/crates/utopia-server/src/phrase_alignment.rs b/crates/utopia-server/src/phrase_alignment.rs index ddade6e85..adcea627a 100644 --- a/crates/utopia-server/src/phrase_alignment.rs +++ b/crates/utopia-server/src/phrase_alignment.rs @@ -66,14 +66,9 @@ pub async fn align_phrases(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> .fetch_one(&mut *guard) .await?; if !locked { - // 正在跑的那份看不见这次触发带来的变化(新属性、改过的定义):排回去,它完了再跑一遍 - tracing::info!(%kb_id, "短语对齐已有一份在跑,排到它后面"); - utopia_store::jobs::enqueue_unless_queued( - pool, - "align_phrases", - serde_json::json!({ "kb_id": kb_id }), - ) - .await?; + // 正在跑的那份结束时会自己看一眼有没有新东西(见 align_phrases_locked 末尾);这里 + // 不排——排回去会和跑着的那份互相踢成死循环 + tracing::info!(%kb_id, "短语对齐已有一份在跑,这次跳过"); return Ok(()); } let result = align_phrases_locked(state, kb_id, &settings, &client).await; @@ -91,6 +86,7 @@ async fn align_phrases_locked( client: &utopia_llm::LlmClient, ) -> anyhow::Result<()> { let pool = &state.pool; + let run_started = chrono::Utc::now(); let props = utopia_store::ontology::relation_type_views(pool, kb_id).await?; let classes = utopia_store::graph::entity_types(pool, kb_id).await?; let class_key: HashMap = classes.iter().map(|c| (c.id, c.key.as_str())).collect(); @@ -114,6 +110,7 @@ async fn align_phrases_locked( Some(b) => b.decided_by != "person" && stale.contains(&s.key()), }) .collect(); + let attempted: HashSet<_> = todo.iter().map(|s| s.key()).collect(); tracing::info!(%kb_id, signatures = sigs.len(), to_decide = todo.len(), properties = props.len(), "短语对齐开始"); // 没有属性可绑:每条都是「没有」;属性出现后 `stale` 会把它们再交回来 @@ -308,7 +305,19 @@ async fn align_phrases_locked( } } tracing::info!(%kb_id, bound, none, undecided, skipped, failed, "短语对齐完成"); - if failed > 0 { + // 这一轮跑着的时候世界没停:新文档带来新签名,改了的属性让刚判的绑定过期,本轮没排上 + // 的触发也都落在这里。有失败的批次、有没试过的新签名、有本轮判完又过期的绑定,就再排 + // 一次 + let again = failed > 0 + || phrase_bindings::signatures(pool, kb_id) + .await? + .iter() + .any(|s| !attempted.contains(&s.key()) && !existing.contains_key(&s.key())) + || phrase_bindings::stale(pool, kb_id) + .await? + .iter() + .any(|b| b.decided_by != "person" && b.decided_at >= run_started); + if again { utopia_store::jobs::enqueue_unless_queued( pool, "align_phrases", diff --git a/crates/utopia-server/src/type_alignment.rs b/crates/utopia-server/src/type_alignment.rs index 7803c5737..975987d45 100644 --- a/crates/utopia-server/src/type_alignment.rs +++ b/crates/utopia-server/src/type_alignment.rs @@ -90,14 +90,8 @@ pub async fn align_types(state: &AppState, kb_id: Uuid) -> anyhow::Result<()> { .fetch_one(&mut *guard) .await?; if !locked { - // 正在跑的那份看不见这次触发带来的变化(新类、新文档的词):排回去,它完了再跑一遍 - tracing::info!(%kb_id, "类别词对齐已有一份在跑,排到它后面"); - utopia_store::jobs::enqueue_unless_queued( - pool, - "align_types", - serde_json::json!({ "kb_id": kb_id }), - ) - .await?; + // 正在跑的那份结束时会自己看一眼有没有新东西(见 align_types_locked 末尾);这里不排 + tracing::info!(%kb_id, "类别词对齐已有一份在跑,这次跳过"); return Ok(()); } let result = align_types_locked(state, kb_id, &settings, &client).await; @@ -115,6 +109,7 @@ async fn align_types_locked( client: &utopia_llm::LlmClient, ) -> anyhow::Result<()> { let pool = &state.pool; + let run_started = chrono::Utc::now(); let classes = utopia_store::graph::entity_types(pool, kb_id).await?; let by_id: HashMap = classes.iter().map(|c| (c.id, c)).collect(); let by_key: HashMap<&str, &EntityType> = classes.iter().map(|c| (c.key.as_str(), c)).collect(); @@ -136,6 +131,7 @@ async fn align_types_locked( Some(b) => b.decided_by != "person" && stale.contains(&s.kind_word), }) .collect(); + let attempted: HashSet = todo.iter().map(|s| s.kind_word.clone()).collect(); tracing::info!(%kb_id, kind_words = sigs.len(), to_decide = todo.len(), classes = classes.len(), "类别词对齐开始"); // 没有类可绑:每个词都是「没有」,并提成建议;类出现后 `stale` 会把它们再交回来 @@ -314,7 +310,23 @@ async fn align_types_locked( if bound > 0 { state.emit_graph(kb_id); } - if failed > 0 { + // 同短语对齐:失败过、来了没试过的新词、本轮判完的又过期了,就再排一次 + let again = failed > 0 || { + let stale_now: HashSet = type_bindings::stale(pool, kb_id) + .await? + .into_iter() + .collect(); + type_bindings::signatures(pool, kb_id) + .await? + .iter() + .any(|s| !attempted.contains(&s.kind_word) && !existing.contains_key(&s.kind_word)) + || type_bindings::bindings(pool, kb_id).await?.iter().any(|b| { + b.decided_by != "person" + && b.decided_at >= run_started + && stale_now.contains(&b.kind_word) + }) + }; + if again { utopia_store::jobs::enqueue_unless_queued( pool, "align_types", From 35e8806e1ced6a0c8493e0b7af00d77702d4d660 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 18 Sep 2026 04:31:20 +0800 Subject: [PATCH 4/5] The ontology page says what phrase binding measured Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- docs/design/ontology.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/design/ontology.md b/docs/design/ontology.md index 141b07d02..c64b60ebe 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -62,13 +62,16 @@ bind. A signature the votes disagree on is `undecided` for the alignment queue o fitting property is `none`, its statements stay in the open graph and it counts toward the workbench's suggestions. Bindings live in `phrase_bindings` and go stale when the property they bound to changes or a property is added; a person's decision is never overwritten by the agent. On -the 25-document batch with a hand-written ontology of 14 classes and 28 properties, 423 -signatures cover 861 statements: 90 bind (covering 311 statements), -305 bind to nothing, 28 split the votes; a judge reading the chunk finds 80.5% -of the resulting typed facts stated by the document, 16.8% worded wrongly and -2.7% not stated, most of the wrong ones a copula phrase ("was") whose objects mix -figures with words, and a phrase that carries part of the value ("下降 1.4%" bound to a change -property loses its sign). Nothing is materialised yet: the typed rows with `from_statement_id` are +the 25-document batch with a hand-written ontology of 14 classes and 28 properties, and 60 of +400 kind words bound, 423 signatures cover 861 statements: 36 bind (184 statements), 110 bind to +nothing, 1 splits the votes and 276 have no admissible property because an end is unbound; a +judge reading the chunk finds 95% of the resulting typed facts stated by the document, 4% worded +wrongly and 1% not stated. Offering every property to an unbound end raised coverage to 85 +signatures and dropped the judge to 87%, and on the NVDA releases, where 15 of 230 kind words +bind, to 59%: the generic "value" attribute swallowed every cash-flow row. Coverage therefore +follows the kind-word bindings and the ontology's size, not the binder. What still goes wrong is +a phrase that carries part of the value ("下降 1.4%" bound to a change property loses its sign) +and a table section read as a change ("changes in operating assets › accounts payable"). Nothing is materialised yet: the typed rows with `from_statement_id` are the next slice. **Argument order is enforced, participation is guided.** A declared domain or range shapes From 9583224c36d192dcd6de48d46289449c095ee57d Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 18 Sep 2026 04:31:28 +0800 Subject: [PATCH 5/5] The design pages point at their pull request Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- docs/design/ontology.md | 2 +- docs/design/prior-work.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/design/ontology.md b/docs/design/ontology.md index c64b60ebe..6ea78dea9 100644 --- a/docs/design/ontology.md +++ b/docs/design/ontology.md @@ -49,7 +49,7 @@ classes of context neighbours, interleaved and never scored together; a class in subtree applied on its own, a cross-axis class sent to a person, each pair acknowledged once) runs from the ontology page; extraction no longer enqueues it [0001, 0016, #736]. -**A relation phrase binds to a property per signature** [0044 cut 2, #PRN]. A signature is a +**A relation phrase binds to a property per signature** [0044 cut 2, #751]. A signature is a phrase as the documents wrote it, the class of its subject and the class of its object, or "value" when the object is a figure, a title or a status; the classes come from the kind-word bindings, and a side whose kind word is bound to no class is its own signature. Each signature is decided once, diff --git a/docs/design/prior-work.md b/docs/design/prior-work.md index d29895da9..3593d2224 100644 --- a/docs/design/prior-work.md +++ b/docs/design/prior-work.md @@ -190,7 +190,7 @@ built. [Angeli 2015]; typed markers lift relation extraction [Ling 2012, Zhong 2021]; ODKE+ exposes only the type's slice of the ontology [Khorshidi 2025]; iText2KG lists typing as its missing ingredient [Lairgi 2024]. Kind words bind before relation phrases [0044 cut 2]; the phrase binding reads the classes at - both ends and the (class, class) signature is part of its key (#PRN). + both ends and the (class, class) signature is part of its key (#751). 12. **Precision is lost in the binding, not in the extraction.** Of the mapping errors in the three-hour KBP system, 31% were definition mismatch and 23% over-generalized rules against 15% open-extraction errors [Soderland 2013]; the top hundred predicates cover only 57 to 82% of an