From 8db3baf6b9a6329eddbb2b3109e1422ec5adc7bc Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Thu, 10 Sep 2026 13:51:07 +0800 Subject: [PATCH 1/2] A written quantity is a value, not a node Co-Authored-By: Claude Opus 5 Signed-off-by: WaylandYang --- crates/utopia-extract/src/lib.rs | 124 ++++++++++++++++++ .../utopia-server/src/api/ontology_routes.rs | 11 +- crates/utopia-server/src/extraction.rs | 56 +++++++- web/src/pages/Graph.tsx | 18 ++- 4 files changed, 198 insertions(+), 11 deletions(-) diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index a7ad77510..6fa3ad5e0 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -735,6 +735,78 @@ pub fn parse_adjudication(raw: &str) -> anyhow::Result> Ok(reply.verdicts) } +/// 一个**整体就是一个量**的字符串 → (数值, 单位)。 +/// +/// 判据从严:可选货币符号 + 数字 + 可选量级词 + 可选百分号,此外**一个词都不许有**。 +/// 尾巴上还挂着实词的,含义就不再只是那个数: +/// +/// ```text +/// "$5 billion" → (5e9, Some("$")) +/// "52%" → (52.0, Some("%")) +/// "3.5 million" → (3.5e6, None) +/// "35,000" → (35000.0, None) +/// "900 million weekly active users" → None 后面还有实词 +/// "2025 Atlantic hurricane season" → None 那是一场赛事,不是 2025 +/// "8GW data center" → None +/// "3M" → None 那是一家公司 +/// ``` +/// +/// **量级词只认全写**。单字母后缀(`3M`、`5k`、`2B`)看着省事,代价是把 3M、 +/// K2、B1 这些名字读成数字——一个真实体被读成量值,事实的形状就错了, +/// 而错的那一头是不可逆的:节点没建,名字也没留下。 +/// +/// **单位照抄符号,不猜币种。** `$` 可能是美元、加元、澳元,`¥` 可能是日元或 +/// 人民币。猜出来的 "USD" 是一条没人负责的断言,而原文写的 `$` 是事实。 +pub fn parse_quantity(s: &str) -> Option<(f64, Option)> { + let s = s.trim(); + if s.is_empty() { + return None; + } + let (body, percent) = match s.strip_suffix('%') { + Some(b) => (b.trim_end(), true), + None => (s, false), + }; + let mut chars = body.chars(); + let (body, currency) = match chars.next() { + Some(c) if matches!(c, '$' | '€' | '£' | '¥' | '₩' | '₹') => { + (chars.as_str().trim_start(), Some(c.to_string())) + } + _ => (body, None), + }; + // `$5%` 不是一个量,是两个记号撞在一起 + if percent && currency.is_some() { + return None; + } + let mut parts = body.split_whitespace(); + let num = parts.next()?; + let scale = match parts.next() { + None => 1.0, + Some(w) => match w.to_ascii_lowercase().as_str() { + "thousand" => 1e3, + "million" => 1e6, + "billion" => 1e9, + "trillion" => 1e12, + _ => return None, + }, + }; + // 量级词后面还有词:那就不是纯量了 + if parts.next().is_some() { + return None; + } + let cleaned: String = num.chars().filter(|c| !matches!(c, ',' | '_')).collect(); + let n: f64 = cleaned.parse().ok()?; + let n = n * scale; + if !n.is_finite() { + return None; + } + let unit = if percent { + Some("%".to_string()) + } else { + currency + }; + Some((n, unit)) +} + /// 属性值按 datatype 归一。失败返回 None——宁缺勿脏,调用方跳过并记日志。 /// number 容忍千分位/空格;date 要求 YYYY[-MM[-DD]] 且保留原精度;bool 宽容 yes/no。 pub fn normalize_attr_value(datatype: &str, raw: &serde_json::Value) -> Option { @@ -749,6 +821,10 @@ pub fn normalize_attr_value(datatype: &str, raw: &serde_json::Value) -> Option() .ok() + // 清洗解不动的再当量解:`$5 billion`、`52%` 这些整体就是数, + // 只是带着符号与量级词。单位不在这里落笔——它随事实走 + // (见 `parse_quantity`),这一档只负责把值变成可比的数 + .or_else(|| parse_quantity(s).map(|(n, _)| n)) .filter(|f| f.is_finite()) .and_then(serde_json::Number::from_f64) .map(serde_json::Value::Number) @@ -1020,6 +1096,54 @@ mod prompt_shape_tests { mod tests { use super::*; + #[test] + fn a_quantity_is_the_whole_string_or_nothing() { + // 整体就是一个量:符号、量级词、千分位都读得动 + assert_eq!(parse_quantity("$5 billion"), Some((5e9, Some("$".into())))); + assert_eq!( + parse_quantity("€1.5 million"), + Some((1.5e6, Some("€".into()))) + ); + assert_eq!(parse_quantity("52%"), Some((52.0, Some("%".into())))); + assert_eq!(parse_quantity("3.5 million"), Some((3.5e6, None))); + assert_eq!(parse_quantity("35,000"), Some((35000.0, None))); + assert_eq!(parse_quantity(" 42 "), Some((42.0, None))); + + // 尾巴上还有实词:含义不再只是那个数,宁可当实体也不当量 + assert_eq!(parse_quantity("900 million weekly active users"), None); + assert_eq!(parse_quantity("2025 Atlantic hurricane season"), None); + assert_eq!(parse_quantity("$10 billion investment"), None); + assert_eq!(parse_quantity("8GW data center"), None); + // 单字母后缀不认:3M 是一家公司,读成三百万就把一个真实体吃掉了 + assert_eq!(parse_quantity("3M"), None); + assert_eq!(parse_quantity("5k"), None); + // 两个记号撞一起,不是量 + assert_eq!(parse_quantity("$5%"), None); + assert_eq!(parse_quantity(""), None); + assert_eq!(parse_quantity("杭州"), None); + } + + #[test] + fn a_number_attribute_takes_a_written_quantity() { + use serde_json::json; + // 采纳属性时按 datatype 换算,量也要换得动——否则 `$5 billion` + // 会一路「换不动」,事实永远拿不到谓词 + assert_eq!( + normalize_attr_value("number", &json!("$5 billion")), + Some(json!(5e9)) + ); + assert_eq!( + normalize_attr_value("number", &json!("52%")), + Some(json!(52.0)) + ); + // 原来就认的两种写法不受影响 + assert_eq!( + normalize_attr_value("number", &json!("35,000")), + Some(json!(35000.0)) + ); + assert_eq!(normalize_attr_value("number", &json!("about ten")), None); + } + #[test] fn parse_time_precisions() { assert_eq!(parse_time("2024").unwrap().1, "year"); diff --git a/crates/utopia-server/src/api/ontology_routes.rs b/crates/utopia-server/src/api/ontology_routes.rs index 22e46fc3a..47fc8cd5d 100644 --- a/crates/utopia-server/src/api/ontology_routes.rs +++ b/crates/utopia-server/src/api/ontology_routes.rs @@ -1446,7 +1446,16 @@ pub(crate) async fn adopt_attribute_core( // 抽取写进去的形状是 {"value": …},取里面那一层来换算 let raw = object_value.get("value").unwrap_or(object_value); match utopia_extract::normalize_attr_value(&datatype, raw) { - Some(v) => rewrites.push((*fact_id, json!({ "value": v }))), + // **单位跟着值走**。抽取那一步把 `$5 billion` 的 `$` 单记了一格 + // (见 extraction 里那段说明);换算成 5e9 之后符号丢掉的话, + // 剩下的数就不知道是钱还是别的什么了 + Some(v) => { + let mut next = json!({ "value": v }); + if let Some(u) = object_value.get("unit") { + next["unit"] = u.clone(); + } + rewrites.push((*fact_id, next)) + } // 换不动的**不改写**:宁可让它继续没有谓词,等下一次, // 也不把一个换不动的值硬塞进类型化的属性里 None => unconvertible += 1, diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index bbffe7def..b5d9c9d03 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -1386,7 +1386,9 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: // 原词进 proposed_predicate,消解那一遍只需换谓词,形状已经是对的。 // // object 里的东西算不算字面值,判据从严:**模型没把它声明成实体**, - // 且**它本身解得出数字或日期**。"杭州"两条都不满足,"2015"都满足。 + // 且**它整体就是一个量或一个日期**。"杭州"两条都不满足,"2015"、 + // "$5 billion"、"52%" 都满足;"900 million weekly active users" + // 不满足——尾巴上还有实词,它说的就不再只是那个数了。 // 文本值的属性(schema.org 里 323 个)在这一档仍会变成实体—— // 那里没有可靠判据,猜错会吃掉真实体,不猜 let literal = match (&f.value, f.object.as_deref().map(str::trim)) { @@ -1470,7 +1472,19 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: Some(&format!("{subject_name} → {value}")), ) .await; - let literal = serde_json::json!({ "value": value }); + /* 值照原文落笔(提示词 8a 要的就是「units and all」),**单位另记一格**。 + 采纳成属性时按 datatype 把 `$5 billion` 换算成 5e9,那一步只看得懂 + 数;符号丢在原文里就再也取不出来了,而「5000000000」少了那个 `$` + 就不知道是钱还是别的什么 */ + let unit = value + .as_str() + .and_then(utopia_extract::parse_quantity) + .and_then(|(_, u)| u); + let mut literal = serde_json::json!({ "value": value }); + if let Some(unit) = unit { + literal["unit"] = serde_json::Value::String(unit); + } + let literal = literal; if await_nod { if let utopia_store::pending::Outcome::Proposed(_) = utopia_store::pending::propose( @@ -2134,9 +2148,19 @@ fn looks_literal(s: &str) -> bool { if s.is_empty() { return false; } - // 纯数字(含小数与正负号)。用 f64 解而不是自己扫字符: - // "3M"、"V3"、"2015"(全角)都会失败,正是想要的 - if s.parse::().is_ok() { + /* **整体是一个量**:可选货币符号 + 数字 + 可选量级词 + 可选百分号, + 此外一个词都不许有(判据与例子见 `parse_quantity`)。 + + 从前这里只认裸数字(`s.parse::()`),于是 `$5 billion` 两头不着: + 它不是裸数字、也解不成日期,掉进关系那条路,凭空长出一个叫「$5 billion」 + 的节点。同名的又会并成一个点,于是 SSI Inc. 与 Nvidia 因为都出现过这个 + 数额而在图上相连——一条没有含义的路径。实测一个 1415 实体的库里,8 个 + 这样的点、15 条事实指着它们,而**没有任何一条拿它们当主语**: + 一个从不当主语、只当宾语、名字整体是个量的东西,是值,不是实体。 + + `parse_quantity` 已经把裸数字那一档包含在内(`"42"` → 42), + 所以这里不必再单留一条。全角「2015」仍旧解不动,仍旧是想要的 */ + if utopia_extract::parse_quantity(s).is_some() { return true; } // 日期:复用抽取侧那个解析器,它认 2015 / 2015-03 / 2015-03-01 等 @@ -2564,9 +2588,22 @@ mod tests { use uuid::Uuid; #[test] - fn only_numbers_and_dates_count_as_literals() { + fn quantities_and_dates_count_as_literals() { // 认:这些出现在宾语位上时是值,不是实体 - for yes in ["2015", "2023-03", "2024-01-15", "1200", "62.5", "-3"] { + for yes in [ + "2015", + "2023-03", + "2024-01-15", + "1200", + "62.5", + "-3", + // 带符号与量级词的量。从前这一档不认,于是图上长出一个叫 + // 「$5 billion」的节点,同名的还并成一个,把毫不相干的两家公司连起来 + "$5 billion", + "€1.5 million", + "52%", + "35,000", + ] { assert!(looks_literal(yes), "{yes} 该认成字面值"); } // 不认:判错的代价是把一个真实体降成一段文本,所以宁可漏 @@ -2579,6 +2616,11 @@ mod tests { "", " ", "2015", // 全角数字:不是我们要处理的形态,交给实体路径 + // 尾巴上还有实词:它说的不再只是那个数 + "900 million weekly active users", + "2025 Atlantic hurricane season", + "$10 billion investment", + "8GW data center", ] { assert!(!looks_literal(no), "{no} 不该认成字面值"); } diff --git a/web/src/pages/Graph.tsx b/web/src/pages/Graph.tsx index 1d193da0c..650da184b 100644 --- a/web/src/pages/Graph.tsx +++ b/web/src/pages/Graph.tsx @@ -3029,9 +3029,21 @@ function EntityPanel({ function fmtObjectValue(v: Record | null): string | null { if (!v) return null; if (v.value !== undefined) { - const val = - typeof v.value === "boolean" ? (v.value ? "✓" : "✗") : String(v.value); - return typeof v.unit === "string" && v.unit ? `${val} ${v.unit}` : val; + if (typeof v.value === "boolean") return v.value ? "✓" : "✗"; + const unit = typeof v.unit === "string" && v.unit ? v.unit : ""; + /* 大数收成 `$5B`。金额存进来是**乘开的数**(`$5 billion` → 5000000000), + 因为值要能比大小才有资格不当节点;可原样念出来是「5000000000 $」, + 比原文那句「$5 billion」难读得多。符号在前、数收成紧凑写法,两头都要 */ + if (typeof v.value === "number" && unit && unit !== "%") { + const n = new Intl.NumberFormat(undefined, { + notation: Math.abs(v.value) >= 10000 ? "compact" : "standard", + maximumFractionDigits: 2, + }).format(v.value); + return `${unit}${n}`; + } + const val = String(v.value); + // 百分号紧贴着数,别的单位空一格 + return unit ? (unit === "%" ? `${val}%` : `${val} ${unit}`) : val; } if (typeof v.summary === "string") return v.summary; return JSON.stringify(v); From ab6f99248cee42c5a7350e76de26b3319a05758c Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Thu, 10 Sep 2026 14:12:02 +0800 Subject: [PATCH 2/2] An untyped subject does not stall the batch Co-Authored-By: Claude Opus 5 Signed-off-by: WaylandYang --- .../utopia-server/src/api/ontology_routes.rs | 5 +- crates/utopia-store/src/graph.rs | 9 +- ...ntyped_subject_does_not_stall_the_batch.rs | 119 ++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 crates/utopia-store/tests/an_untyped_subject_does_not_stall_the_batch.rs diff --git a/crates/utopia-server/src/api/ontology_routes.rs b/crates/utopia-server/src/api/ontology_routes.rs index 47fc8cd5d..435cca911 100644 --- a/crates/utopia-server/src/api/ontology_routes.rs +++ b/crates/utopia-server/src/api/ontology_routes.rs @@ -1407,7 +1407,10 @@ pub(crate) async fn adopt_attribute_core( } else { // **domain 从数据里取。** 属性必须声明能挂在哪些类下,猜错的代价是硬的: // 主语类型对不上就整条丢弃。这些事实的主语现在是什么类是事实,不是判断 - let mut domains: Vec = facts.iter().map(|(_, type_id, _)| *type_id).collect(); + let mut domains: Vec = facts + .iter() + .filter_map(|(_, type_id, _)| *type_id) + .collect(); domains.sort_unstable(); domains.dedup(); if domains.is_empty() { diff --git a/crates/utopia-store/src/graph.rs b/crates/utopia-store/src/graph.rs index 37234e7e1..d28d623d1 100644 --- a/crates/utopia-store/src/graph.rs +++ b/crates/utopia-store/src/graph.rs @@ -2024,10 +2024,17 @@ pub async fn value_facts_for_forms( pool: &PgPool, kb_id: Uuid, forms: &[String], -) -> AppResult> { +) -> AppResult, serde_json::Value)>> { if forms.is_empty() { return Ok(Vec::new()); } + // **主语类型是 Option**。列本来就可空——「抽取器抽到了东西,但本体里没有 + // 对应的类」是一个正常状态(0009),不是异常。解成裸 `Uuid` 的时候,批里 + // 只要有一条主语没类型,整次采纳就在解码那一步报错退出: + // `decoding column 1: unexpected null`,一条也改写不了。实测一个库里攒着 + // 2454 条等谓词的值事实,其中 58 条主语无类型,够把好几个说法卡死。 + // 没类型的那些不参与 domain(属性得声明挂在哪些类下),但照样跟着改写—— + // 把它们一起丢掉等于让一条有名有姓的事实继续没有谓词 Ok(sqlx::query_as( "SELECT DISTINCT f.id, s.type_id, f.object_value FROM facts f diff --git a/crates/utopia-store/tests/an_untyped_subject_does_not_stall_the_batch.rs b/crates/utopia-store/tests/an_untyped_subject_does_not_stall_the_batch.rs new file mode 100644 index 000000000..529589c76 --- /dev/null +++ b/crates/utopia-store/tests/an_untyped_subject_does_not_stall_the_batch.rs @@ -0,0 +1,119 @@ +//! 主语没有类型的值事实,不该把整批采纳卡死。 +//! +//! `entities.type_id` 可空——「抽取器抽到了东西,但本体里没有对应的类」是一个 +//! 正常状态(0009)。可 `value_facts_for_forms` 一度把它解成裸 `Uuid`:批里 +//! 只要有一条主语没类型,采纳就在**解码那一步**报错退出,一条也改写不了。 +//! +//! 这条只能真跑:`cargo check` 看不见 sqlx 的行解码,而那正是出事的地方。 +//! 实测一个库里攒着 2454 条等谓词的值事实,其中 58 条主语无类型。 + +use sqlx::PgPool; +use uuid::Uuid; + +#[tokio::test] +async fn an_untyped_subject_still_comes_back_with_the_batch() -> anyhow::Result<()> { + let Some(url) = utopia_store::test_db::url() else { + return Ok(()); + }; + let pool = PgPool::connect(&url).await?; + let (org, ws, kb) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); + let etype = Uuid::now_v7(); + let (infosys, mystery) = (Uuid::now_v7(), Uuid::now_v7()); + let (doc, chunk) = (Uuid::now_v7(), Uuid::now_v7()); + let (f_typed, f_untyped) = (Uuid::now_v7(), Uuid::now_v7()); + + sqlx::query("INSERT INTO organizations (id, name) VALUES ($1, 'untyped-subject')") + .bind(org) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO workspaces (id, org_id, name) VALUES ($1, $2, 'untyped-subject')") + .bind(ws) + .bind(org) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO knowledge_bases (id, workspace_id, name) VALUES ($1, $2, 'untyped-subject')", + ) + .bind(kb) + .bind(ws) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO entity_types (id, kb_id, key, label) VALUES ($1, $2, 'org', 'Organization')", + ) + .bind(etype) + .bind(kb) + .execute(&pool) + .await?; + // 一个有类型、一个没有——后者正是从前把整批打翻的那种 + sqlx::query("INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1,$2,$3,$4)") + .bind(infosys) + .bind(kb) + .bind(etype) + .bind("Infosys") + .execute(&pool) + .await?; + sqlx::query("INSERT INTO entities (id, kb_id, type_id, canonical_name) VALUES ($1,$2,NULL,$3)") + .bind(mystery) + .bind(kb) + .bind("Some Startup") + .execute(&pool) + .await?; + sqlx::query("INSERT INTO documents (id, kb_id, filename, sha256) VALUES ($1,$2,'a.txt','a')") + .bind(doc) + .bind(kb) + .execute(&pool) + .await?; + sqlx::query("INSERT INTO chunks (id, kb_id, document_id, seq, text) VALUES ($1,$2,$3,0,'x')") + .bind(chunk) + .bind(kb) + .bind(doc) + .execute(&pool) + .await?; + for (fact, subject) in [(f_typed, infosys), (f_untyped, mystery)] { + // 值事实:没有谓词、没有宾语实体,原词记在证据上 + sqlx::query( + "INSERT INTO facts (id, kb_id, subject_id, predicate_id, object_id, object_value) + VALUES ($1, $2, $3, NULL, NULL, $4)", + ) + .bind(fact) + .bind(kb) + .bind(subject) + .bind(serde_json::json!({ "value": "$1 billion", "unit": "$" })) + .execute(&pool) + .await?; + sqlx::query( + "INSERT INTO fact_evidence (fact_id, chunk_id, document_id, proposed_predicate) + VALUES ($1, $2, $3, 'pledged_amount')", + ) + .bind(fact) + .bind(chunk) + .bind(doc) + .execute(&pool) + .await?; + } + + let run = async { + let rows = + utopia_store::graph::value_facts_for_forms(&pool, kb, &["pledged_amount".to_string()]) + .await?; + assert_eq!(rows.len(), 2, "两条都该回来,没类型的那条不该把批次打翻"); + let typed = rows.iter().filter(|(_, t, _)| t.is_some()).count(); + assert_eq!(typed, 1, "只有一条主语有类型,它是 domain 的唯一来源"); + // 没类型的那条照样在名单里:它不贡献 domain,但要跟着改写, + // 否则一条有名有姓的事实会继续没有谓词 + assert!( + rows.iter() + .any(|(id, t, _)| *id == f_untyped && t.is_none()), + "没类型的那条该在名单里,且类型是 None" + ); + Ok::<_, anyhow::Error>(()) + } + .await; + + sqlx::query("DELETE FROM knowledge_bases WHERE id = $1") + .bind(kb) + .execute(&pool) + .await?; + run +}