From 627913802482060dd7d865fe73f3ab4b7d0ecc1a Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 11 Sep 2026 00:36:49 +0800 Subject: [PATCH 1/4] A unit is read, not assumed Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- crates/utopia-extract/src/lib.rs | 238 ++++++++++++------ crates/utopia-server/src/extraction.rs | 106 +++++++- crates/utopia-store/src/ontology.rs | 42 ++++ .../a_qualifier_is_not_the_edges_identity.rs | 17 ++ ...7-a-relation-carries-its-own-attributes.md | 29 ++- 5 files changed, 349 insertions(+), 83 deletions(-) diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index 0c90a398b..9f73d1bc9 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -310,7 +310,7 @@ pub fn build_messages( units and all. **A stated figure left out is the loss that costs most**: the reader \ came for those numbers, and no later step can recover one that was never written \ down.\n\ - 8c. A listed relation followed by {{…}} can carry those **qualifiers on the edge**: when the same sentence gives both the other entity and a figure for it — an amount, a stake, a price, a share count — write the relation with its \"object\" and put the figure in \"qualifiers\" keyed exactly as listed: {{\"subject\":\"Vega Capital\",\"predicate\":\"invested_in\",\"object\":\"Northwind\", \"qualifiers\":{{\"amount\":\"$5 billion\"}},…}}. Never invent a key that is not listed for that relation, and never drop the figure to keep the edge — a relation without its amount is half the sentence. + 8c. A listed relation followed by {{…}} can carry those **qualifiers on the edge**: when the same sentence gives both the other entity and a figure for it — an amount, a stake, a price, a share count — write the relation with its \"object\" and put the figure in \"qualifiers\" keyed exactly as listed, **as written in the text, currency and all** (\"€30 million\", \"15亿元人民币\", never a bare number): {{\"subject\":\"Vega Capital\",\"predicate\":\"invested_in\",\"object\":\"Northwind\", \"qualifiers\":{{\"amount\":\"$5 billion\"}},…}}. Never invent a key that is not listed for that relation, and never drop the figure to keep the edge — a relation without its amount is half the sentence. 8b. A **listed** relation also takes \"value\" when what the text gives is a \ string rather than another entity — a job title, a designation, a ticker, a \ model number. Never invent an entity for a string. And when the text introduces \ @@ -773,6 +773,57 @@ pub fn parse_adjudication(raw: &str) -> anyhow::Result> /// **单位照抄符号,不猜币种。** `$` 可能是美元、加元、澳元,`¥` 可能是日元或 /// 人民币。猜出来的 "USD" 是一条没人负责的断言,而原文写的 `$` 是事实。 pub fn parse_quantity(s: &str) -> Option<(f64, Option)> { + scan_quantity(s, true) +} + +/// 开头是一个量、后面还挂着词的 → 那个量。`"1,250 people"` → (1250, "people")。 +/// +/// **这是给已经知道要什么的地方用的**,与 `parse_quantity` 的严不是一回事。 +/// `parse_quantity` 要判「这串字是不是一个东西」,判错就把一个真实体吃掉, +/// 所以尾巴上有实词一律不认。而这里的调用方手上已经有一条声明了 +/// `datatype = number` 的属性——问的不再是「是不是数」,是「那个数是多少」, +/// 判错的代价只是一个值不对,量级差着好几档。 +pub fn parse_leading_quantity(s: &str) -> Option<(f64, Option)> { + scan_quantity(s, false) +} + +/// 货币:符号、ISO 码、中英文单词,统一成符号。**只认这张表**,认不出的不猜。 +pub fn currency_unit(tok: &str) -> Option<&'static str> { + Some( + match tok.trim_matches(|c: char| c == ',' || c == '.' || c == ';') { + "$" | "USD" | "usd" | "US$" | "dollar" | "dollars" | "美元" => "$", + "€" | "EUR" | "eur" | "euro" | "euros" | "欧元" => "€", + "£" | "GBP" | "gbp" | "pound" | "pounds" | "英镑" => "£", + "¥" | "JPY" | "jpy" | "yen" | "日元" => "¥", + "CNY" | "cny" | "RMB" | "rmb" | "yuan" | "人民币" | "元" | "元人民币" | "人民币元" => { + "¥" + } + "HKD" | "hkd" | "HK$" | "港元" | "港币" => "HK$", + "₩" | "KRW" | "won" | "韩元" => "₩", + "₹" | "INR" | "rupee" | "rupees" | "卢比" => "₹", + _ => return None, + }, + ) +} + +/// 量级词:英文全写,中文千/万/亿。**不认单字母**(`3M` 是一家公司)。 +fn magnitude(tok: &str) -> Option { + Some(match tok { + "thousand" | "千" => 1e3, + "万" => 1e4, + "million" | "百万" => 1e6, + "千万" => 1e7, + "亿" => 1e8, + "billion" | "十亿" => 1e9, + "trillion" | "万亿" => 1e12, + _ => return None, + }) +} + +/// 把 `2亿美元`、`15亿元人民币`、`€30 million`、`30 million euros`、`USD 30m`(不认 m) +/// 这类写法拆成 [前缀货币] 数字 [量级] [后缀货币/单位] [其余]。 +/// `strict` = 整体必须就是一个量:其余部分非空就不认。 +fn scan_quantity(s: &str, strict: bool) -> Option<(f64, Option)> { let s = s.trim(); if s.is_empty() { return None; @@ -781,96 +832,104 @@ pub fn parse_quantity(s: &str) -> Option<(f64, Option)> { 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())) + // 1. 前缀货币:符号紧贴,或 ISO 码/单词后跟空格 + let mut rest = body; + let mut currency: Option<&'static str> = None; + if let Some(c) = rest.chars().next() { + if let Some(u) = currency_unit(&c.to_string()) { + currency = Some(u); + rest = rest[c.len_utf8()..].trim_start(); } - _ => (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; + if currency.is_none() { + if let Some((head, tail)) = rest.split_once(char::is_whitespace) { + if let Some(u) = currency_unit(head) { + currency = Some(u); + rest = tail.trim_start(); + } + } } + // 2. 数字:前导的 [-+0-9.,_] + let num_end = rest + .char_indices() + .find(|(_, c)| !matches!(c, '0'..='9' | '.' | ',' | '_' | '-' | '+')) + .map(|(i, _)| i) + .unwrap_or(rest.len()); + let (num, after) = rest.split_at(num_end); let cleaned: String = num.chars().filter(|c| !matches!(c, ',' | '_')).collect(); - let n: f64 = cleaned.parse().ok()?; - let n = n * scale; + let mut n: f64 = cleaned.parse().ok()?; + // 3. 数字后面:紧贴或空格隔开的量级词、货币词,逐个吃;吃不动的就是「其余」 + let mut tail = after.trim_start(); + let mut unit: Option = None; + let mut ate_magnitude = false; + loop { + if tail.is_empty() { + break; + } + // 取下一个记号:中文按字(量级/货币词最长两三个字),其它按空白分词 + let (tok, next) = next_token(tail); + if !ate_magnitude { + if let Some(m) = magnitude(tok) { + n *= m; + ate_magnitude = true; + tail = next.trim_start(); + continue; + } + } + if unit.is_none() && currency.is_none() { + if let Some(u) = currency_unit(tok) { + unit = Some(u.to_string()); + tail = next.trim_start(); + continue; + } + } + break; + } + if percent && (currency.is_some() || unit.is_some()) { + return None; + } if !n.is_finite() { return None; } let unit = if percent { Some("%".to_string()) } else { - currency + currency.map(str::to_string).or(unit) }; + if strict { + return tail.is_empty().then_some((n, unit)); + } + // 宽松:其余部分的第一个词当单位(`1,250 people` → people),没有货币时才用 + if unit.is_none() && !tail.is_empty() { + let (tok, _) = next_token(tail); + return Some((n, Some(tok.to_string()))); + } Some((n, unit)) } -/// 开头是一个量、后面还挂着词的 → 那个量。`"1,250 people"` → (1250, "people")。 -/// -/// **这是给已经知道要什么的地方用的**,与 `parse_quantity` 的严不是一回事。 -/// `parse_quantity` 要判「这串字是不是一个东西」,判错就把一个真实体吃掉, -/// 所以尾巴上有实词一律不认。而这里的调用方手上已经有一条声明了 -/// `datatype = number` 的属性——问的不再是「是不是数」,是「那个数是多少」, -/// 判错的代价只是一个值不对,量级差着好几档。 -/// -/// 实测卡住的正是这一格:本体里有 `employeeCount (number)`、事实写着 -/// `employee_count → "1,250 people"`,词对得上、属性也在,只因为模型把单位 -/// 写进了值里就一直换不动,那条事实永远拿不到谓词。 -pub fn parse_leading_quantity(s: &str) -> Option<(f64, Option)> { - let s = s.trim(); - // 整体就是一个量的先按严的那套解——`$5 billion` 的单位是 `$` 不是 `billion` - if let Some(hit) = parse_quantity(s) { - return Some(hit); +/// 下一个记号:ASCII 按空白切;CJK 试最长三字、两字、一字里能认出的量级/货币词, +/// 都认不出就取到下一个空白为止 +fn next_token(s: &str) -> (&str, &str) { + let first = s.chars().next().unwrap_or(' '); + if first.is_ascii() { + let end = s.find(char::is_whitespace).unwrap_or(s.len()); + return (&s[..end], &s[end..]); } - let mut parts = s.split_whitespace(); - let head = parts.next()?; - // 数字与紧跟着的百分号/单位可能不分家:`42%`、`8GW` - let split = head + let idx: Vec = s .char_indices() - .find(|(_, c)| !matches!(c, '0'..='9' | '.' | ',' | '_' | '-' | '+')) .map(|(i, _)| i) - .unwrap_or(head.len()); - let (num, glued) = head.split_at(split); - let cleaned: String = num.chars().filter(|c| !matches!(c, ',' | '_')).collect(); - let n: f64 = cleaned.parse().ok()?; - if !n.is_finite() { - return None; - } - // 紧贴着的记号优先当单位(`42%` → `%`),否则取后面第一个词 - let mut rest = parts; - let (scale, unit) = if glued.is_empty() { - match rest.next() { - None => (1.0, None), - Some(w) => match w.to_ascii_lowercase().as_str() { - "thousand" => (1e3, rest.next().map(str::to_string)), - "million" => (1e6, rest.next().map(str::to_string)), - "billion" => (1e9, rest.next().map(str::to_string)), - "trillion" => (1e12, rest.next().map(str::to_string)), - _ => (1.0, Some(w.to_string())), - }, + .chain(std::iter::once(s.len())) + .collect(); + for len in [4usize, 3, 2, 1] { + if idx.len() > len { + let cand = &s[..idx[len]]; + if magnitude(cand).is_some() || currency_unit(cand).is_some() { + return (cand, &s[idx[len]..]); + } } - } else { - (1.0, Some(glued.to_string())) - }; - let n = n * scale; - n.is_finite().then_some((n, unit)) + } + let end = s.find(char::is_whitespace).unwrap_or(s.len()); + (&s[..end], &s[end..]) } /// 属性值按 datatype 归一。失败返回 None——宁缺勿脏,调用方跳过并记日志。 @@ -1243,6 +1302,26 @@ mod tests { 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))); + // 币种:符号、ISO 码、中英文单词,统一成符号;量级:英文全写与中文千万亿 + assert_eq!( + parse_quantity("EUR 30 million"), + Some((3e7, Some("€".into()))) + ); + assert_eq!( + parse_quantity("30 million euros"), + Some((3e7, Some("€".into()))) + ); + assert_eq!( + parse_quantity("USD 5 billion"), + Some((5e9, Some("$".into()))) + ); + assert_eq!(parse_quantity("2亿美元"), Some((2e8, Some("$".into())))); + assert_eq!( + parse_quantity("15亿元人民币"), + Some((1.5e9, Some("¥".into()))) + ); + assert_eq!(parse_quantity("3000万元"), Some((3e7, Some("¥".into())))); + assert_eq!(parse_quantity("1.5亿"), Some((1.5e8, None))); // 尾巴上还有实词:含义不再只是那个数,宁可当实体也不当量 assert_eq!(parse_quantity("900 million weekly active users"), None); @@ -1284,6 +1363,19 @@ mod tests { Some((5e9, Some("$".into()))) ); // 开头不是数就还是不认 + // 币种在尾巴上也认;认不出的词才落到「单位是第一个词」 + assert_eq!( + parse_leading_quantity("30 million euros in cash"), + Some((3e7, Some("€".into()))) + ); + assert_eq!( + parse_leading_quantity("15亿元人民币的投资"), + Some((1.5e9, Some("¥".into()))) + ); + assert_eq!( + parse_leading_quantity("30 million francs"), + Some((3e7, Some("francs".into()))) + ); assert_eq!(parse_leading_quantity("about ten"), None); assert_eq!(parse_leading_quantity(""), None); diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index cfcde55bf..d6bcba604 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -904,7 +904,12 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: 写入时按这里的 key 对模型给的 qualifiers,对不上的进丢弃表让人看见 */ let rtype_by_id: HashMap = rtypes.iter().map(|r| (r.id, r)).collect(); - let qualifier_defs: HashMap> = rtypes + let attr_by_key: HashMap = rtypes + .iter() + .filter(|r| r.kind == "attribute") + .map(|r| (r.key.to_lowercase(), r)) + .collect(); + let mut qualifier_defs: HashMap> = rtypes .iter() .filter(|r| r.kind != "attribute" && !r.qualifiers.is_empty()) .map(|r| { @@ -2300,11 +2305,78 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: 值照 datatype 换算,单位另记一格(与 object_value 同形); key 不在声明里、换不动、与已记的不一致——三种都进丢弃表,不静默 */ if let (Some(pid), Some(quals)) = (predicate_id, f.qualifiers.as_ref()) { - let defs = qualifier_defs.get(&pid); + // 克隆出这一组引用:下面撞上已有属性时要往 qualifier_defs 里追加声明 + let defs: Vec<&utopia_core::models::RelationType> = + qualifier_defs.get(&pid).cloned().unwrap_or_default(); + /* 模型常把币种单独写成一个键(`"amount": "1500000000", "currency": "CNY"`), + 而不是写进数额里。那不是一个属性,是数额的单位——先把它拿出来, + 数值属性解不出单位时用它,别让它作为未知 key 进丢弃表 */ + let sibling_currency: Option<&'static str> = quals + .iter() + .find(|(k, _)| { + matches!( + k.trim().to_lowercase().as_str(), + "currency" | "币种" | "货币" | "unit" | "单位" + ) + }) + .and_then(|(_, v)| v.as_str()) + .and_then(utopia_extract::currency_unit); for (key, raw) in quals { - let Some(def) = defs.and_then(|d| { - d.iter().find(|q| q.key.eq_ignore_ascii_case(key.trim())) - }) else { + // 模型对没提到的属性会写 null:那是「原文没说」,不是坏值,不记 + if raw.is_null() { + continue; + } + if matches!( + key.trim().to_lowercase().as_str(), + "currency" | "币种" | "货币" | "unit" | "单位" + ) { + continue; + } + let declared = defs + .iter() + .find(|q| q.key.eq_ignore_ascii_case(key.trim())) + .copied(); + /* **未知 key 撞上本库已有的属性定义 → 补一条声明,不丢值。** + 实测不声明时模型照样写 `amount`、`stake`、`round`,八条全进 + 丢弃表——而这三个属性定义明明都在库里,缺的只是关系上的一条 + 声明。补声明不新建任何东西、可撤(本体页取消勾选即可), + 所以跟自动扩本体走同一个开关 */ + let adopted = match declared { + Some(d) => Some(d), + None if kb.auto_extend_ontology => { + match attr_by_key.get(&key.trim().to_lowercase()).copied() { + Some(attr) => { + match utopia_store::ontology::add_relation_qualifier( + &state.pool, + doc.kb_id, + pid, + attr.id, + ) + .await + { + Ok(()) => { + tracing::info!(kb_id = %doc.kb_id, relation = %f.predicate, qualifier = %attr.key, "边上的属性按语料补了声明"); + qualifier_defs.entry(pid).or_default().push(attr); + Some(attr) + } + Err(e) => { + tracing::warn!(kb_id = %doc.kb_id, error = %e, "补声明失败"); + None + } + } + } + None => { + tracing::debug!(kb_id = %doc.kb_id, relation = %f.predicate, key = %key, attrs = attr_by_key.len(), "边上的属性:key 撞不上本库任何属性定义"); + None + } + } + } + None => { + tracing::debug!(kb_id = %doc.kb_id, relation = %f.predicate, key = %key, auto_extend = kb.auto_extend_ontology, "边上的属性:未声明且不自动扩本体"); + None + } + }; + let Some(def) = adopted else { drop_signal( state, doc.kb_id, @@ -2330,11 +2402,27 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: continue; }; let mut value = serde_json::json!({ "value": normalized }); - let unit = raw + /* 单位:原文里认得出的用原文的(€、¥、%),原文里**没有任何单位记号** + 才落回属性声明的缺省。原文带着一个认不出的单位("francs")时 + **不能**拿缺省顶上——实测 `EUR 30 million` 被存成了 `$`, + `15亿元人民币` 也是;币种写错比不写更糟 */ + let parsed = raw .as_str() - .and_then(utopia_extract::parse_leading_quantity) - .and_then(|(_, u)| u) - .or_else(|| def.unit.clone().filter(|u| !u.is_empty())); + .and_then(utopia_extract::parse_leading_quantity); + let raw_has_unit_token = raw.as_str().is_some_and(|t| { + t.chars().any(|c| { + c.is_alphabetic() + || matches!(c, '$' | '€' | '£' | '¥' | '₩' | '₹' | '%') + }) + }); + let unit = match parsed.and_then(|(_, u)| u) { + Some(u) => Some(u), + None => match sibling_currency { + Some(c) if dt == "number" => Some(c.to_string()), + _ if raw_has_unit_token => None, + _ => def.unit.clone().filter(|u| !u.is_empty()), + }, + }; if let Some(u) = unit { value["unit"] = serde_json::Value::String(u); } diff --git a/crates/utopia-store/src/ontology.rs b/crates/utopia-store/src/ontology.rs index 704692796..5b9b9e77c 100644 --- a/crates/utopia-store/src/ontology.rs +++ b/crates/utopia-store/src/ontology.rs @@ -547,6 +547,48 @@ pub async fn set_relation_qualifiers( Ok(()) } +/// 给一条关系**追加**一个边上的属性声明(0037)。 +/// +/// 与 `set_relation_qualifiers` 的覆盖式不同:抽取时几篇文档并行,各自从语料里 +/// 补声明,覆盖式写入会把别人刚补的冲掉(实测 `round` 补过又没了)。 +/// 这里只加不删,撞上已有的什么都不做。校验与覆盖式同一套。 +pub async fn add_relation_qualifier( + pool: &PgPool, + kb_id: Uuid, + relation_type_id: Uuid, + qualifier_type_id: Uuid, +) -> AppResult<()> { + if relation_type_id == qualifier_type_id { + return Err(AppError::invalid( + "qualifier_is_self", + "A relation cannot be its own qualifier", + )); + } + let (ok,): (i64,) = sqlx::query_as( + "SELECT count(*) FROM relation_types + WHERE kb_id = $1 AND kind = 'attribute' AND id = $2", + ) + .bind(kb_id) + .bind(qualifier_type_id) + .fetch_one(pool) + .await?; + if ok != 1 { + return Err(AppError::invalid( + "qualifier_not_attribute", + "Every qualifier must be an attribute of this base", + )); + } + sqlx::query( + "INSERT INTO relation_type_qualifiers (relation_type_id, qualifier_type_id) + VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(relation_type_id) + .bind(qualifier_type_id) + .execute(pool) + .await?; + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub async fn update_relation_type( pool: &PgPool, diff --git a/crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs b/crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs index 0f6dbc0c9..fcc294404 100644 --- a/crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs +++ b/crates/utopia-store/tests/a_qualifier_is_not_the_edges_identity.rs @@ -107,6 +107,23 @@ async fn a_qualifier_is_added_to_the_same_edge_and_never_overwritten() -> anyhow "只有属性能当限定项" ); + // 追加式声明:只加不删、撞上已有的不报错、同样的校验 + utopia_store::ontology::add_relation_qualifier(&pool, kb, invested, amount).await?; + utopia_store::ontology::add_relation_qualifier(&pool, kb, invested, amount).await?; + let again = utopia_store::graph::relation_types(&pool, kb) + .await? + .into_iter() + .find(|r| r.id == invested) + .map(|r| r.qualifiers) + .unwrap_or_default(); + assert_eq!(again, vec![amount], "追加同一个不重复、不覆盖"); + assert!( + utopia_store::ontology::add_relation_qualifier(&pool, kb, invested, other_rel) + .await + .is_err(), + "追加式同样只认属性" + ); + // 一条边 let (a, b, fact) = (Uuid::now_v7(), Uuid::now_v7(), Uuid::now_v7()); for (id, name) in [(a, "Vega"), (b, "Northwind")] { diff --git a/docs/decisions/0037-a-relation-carries-its-own-attributes.md b/docs/decisions/0037-a-relation-carries-its-own-attributes.md index 9701050f1..872edd976 100644 --- a/docs/decisions/0037-a-relation-carries-its-own-attributes.md +++ b/docs/decisions/0037-a-relation-carries-its-own-attributes.md @@ -1,6 +1,6 @@ # 0037 · A relation carries its own attributes -- **Status**: written · cut 1 in progress: `relation_type_qualifiers` and `fact_qualifiers` +- **Status**: cut 1 merged (#598) · cut 1b (units, auto-declaration, sibling currency; #TBD) · `relation_type_qualifiers` and `fact_qualifiers` (migration 0049), a relation declares its qualifiers, extraction writes them, the panel and the export read them · not in this cut: an entity-valued qualifier (the column is reserved, nothing writes it), a second row plus a conflict when two mentions of one edge disagree @@ -97,6 +97,33 @@ instead of a class. one more triple on it, predicate = the attribute's IRI, object = the literal typed by the attribute's datatype. RDF 1.2's reifier is the same shape. +## What the test waves found (2026-09-11) + +Four waves on an isolated base, each document pressing one rule (repeatability ×3, the +rules corpus declared and undeclared, a Chinese corpus declared and undeclared): + +- **Repeatable.** Three runs of the original corpus, both investment edges carry their + amount every time; the model's `stake: "minority"` is refused by the number datatype and + lands in the drop report, not in the graph. +- **A qualifier the relation never declared, but the base already defines** (`amount`, + `stake`, `round` exist as attributes) is now declared from the corpus and written, under + the same `auto_extend_ontology` switch as the rest of the growth loop. The declaration is + additive (`add_relation_qualifier`): documents extract in parallel and a replace-all write + clobbered one document's declaration with another's. +- **Currency.** The model normalises `€30 million` and `15亿元人民币` to a bare number or + writes the currency as a sibling key (`"currency": "CNY"`). The prompt now asks for the + figure as written, the scanner reads ISO codes, currency words and CJK magnitudes (万, 亿), + a sibling `currency` key becomes the unit, and the attribute's default unit is used only + when the text carries no unit token at all — a wrong currency is worse than none. +- **Two mentions of one edge in parallel can both insert.** The dedup in `insert_fact_inner` + is a read-then-write with no unique index behind it; two documents describing the same + `(subject, predicate, object, moment)` extracted at the same time produced two rows with + different amounts and no conflict. The next cut (two rows plus `fact_conflicts` for a + disagreement) has to close this first — a per-base advisory lock around the insert, or a + partial unique index on live rows. +- The model dates "earlier this year" to a concrete day and so mints a moment the text never + gave; identity follows the model's date. Visible on the timeline, not a qualifier defect. + ## Open questions - Where does the amount go when the relation is not adopted yet (`predicate_id` is null, From 1725f4915bab3be078512d3ca9828cf0a7514402 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 11 Sep 2026 00:37:31 +0800 Subject: [PATCH 2/4] 0037 names the cut that read the unit Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- docs/decisions/0037-a-relation-carries-its-own-attributes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/decisions/0037-a-relation-carries-its-own-attributes.md b/docs/decisions/0037-a-relation-carries-its-own-attributes.md index 872edd976..90232ed65 100644 --- a/docs/decisions/0037-a-relation-carries-its-own-attributes.md +++ b/docs/decisions/0037-a-relation-carries-its-own-attributes.md @@ -1,6 +1,6 @@ # 0037 · A relation carries its own attributes -- **Status**: cut 1 merged (#598) · cut 1b (units, auto-declaration, sibling currency; #TBD) · `relation_type_qualifiers` and `fact_qualifiers` +- **Status**: cut 1 merged (#598) · cut 1b (units, auto-declaration, sibling currency; #600) · `relation_type_qualifiers` and `fact_qualifiers` (migration 0049), a relation declares its qualifiers, extraction writes them, the panel and the export read them · not in this cut: an entity-valued qualifier (the column is reserved, nothing writes it), a second row plus a conflict when two mentions of one edge disagree From d454857cec7bae53f71539f4522284cfe57a1aa3 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 11 Sep 2026 06:26:20 +0800 Subject: [PATCH 3/4] A listed attribute keeps its magnitude and currency Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- crates/utopia-extract/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index 9f73d1bc9..1e9852178 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -245,8 +245,7 @@ pub fn build_messages( let attr_rules = if attributes.is_empty() { String::new() } else { - "\n10. Attribute facts carry \"value\" (no \"object\"): number = plain number without \ - thousands separators or unit symbols; date = \"YYYY[-MM[-DD]]\" (a zoned clock time only when the text gives one); bool = true/false; \ + "\n10. Attribute facts carry \"value\" (no \"object\"): number = the figure **as the text writes it, magnitude and currency included** \n (\"86亿元\", \"$5 billion\", \"4,300 人\") — never reduce it to a bare number, the server converts; date = \"YYYY[-MM[-DD]]\" (a zoned clock time only when the text gives one); bool = true/false; \ text = a short string. Only attach an attribute to a subject of its listed class. \ valid_from = when this value took effect, if the text says so." .to_string() From 4dd6af5eaaac7c7c58b36f8198a0ce2a36ebdbf5 Mon Sep 17 00:00:00 2001 From: WaylandYang Date: Fri, 11 Sep 2026 06:29:10 +0800 Subject: [PATCH 4/4] A magnitude multiplies to a whole number Co-Authored-By: Claude Fable 5.1 Signed-off-by: WaylandYang --- crates/utopia-extract/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index 1e9852178..f596f7dea 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -890,6 +890,10 @@ fn scan_quantity(s: &str, strict: bool) -> Option<(f64, Option)> { if !n.is_finite() { return None; } + // 9.2 × 1e8 在二进制浮点里是 919999999.9999999;乘过量级词的数本来就是整数,收回去 + if ate_magnitude && (n - n.round()).abs() < 1e-6 * n.abs().max(1.0) { + n = n.round(); + } let unit = if percent { Some("%".to_string()) } else { @@ -1321,6 +1325,15 @@ mod tests { ); assert_eq!(parse_quantity("3000万元"), Some((3e7, Some("¥".into())))); assert_eq!(parse_quantity("1.5亿"), Some((1.5e8, None))); + // 乘过量级的数收成整数:9.2 亿不是 919999999.9999999 + assert_eq!( + parse_quantity("9.2亿元"), + Some((920000000.0, Some("¥".into()))) + ); + assert_eq!( + parse_quantity("$2.5 billion"), + Some((2500000000.0, Some("$".into()))) + ); // 尾巴上还有实词:含义不再只是那个数,宁可当实体也不当量 assert_eq!(parse_quantity("900 million weekly active users"), None);