diff --git a/crates/utopia-extract/src/lib.rs b/crates/utopia-extract/src/lib.rs index 6478789d4..199a48aec 100644 --- a/crates/utopia-extract/src/lib.rs +++ b/crates/utopia-extract/src/lib.rs @@ -809,6 +809,57 @@ pub fn parse_quantity(s: &str) -> Option<(f64, Option)> { 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); + } + let mut parts = s.split_whitespace(); + let head = parts.next()?; + // 数字与紧跟着的百分号/单位可能不分家:`42%`、`8GW` + let split = head + .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())), + }, + } + } else { + (1.0, Some(glued.to_string())) + }; + let n = n * scale; + n.is_finite().then_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 { @@ -826,7 +877,11 @@ pub fn normalize_attr_value(datatype: &str, raw: &serde_json::Value) -> Option, serde_json::Value)]) -> Option { + if facts.is_empty() { + return None; + } + let mut unit: Option = None; + let mut agreed = true; + for (_, _, v) in facts { + let raw = v.get("value").unwrap_or(v); + let hit = match raw { + serde_json::Value::Number(_) => Some((0.0, None)), + serde_json::Value::String(s) => utopia_extract::parse_leading_quantity(s), + _ => None, + }?; + // 抽取那一步单记的单位更可信(`$5 billion` → `$`),没有再退回解出来的 + let u = v + .get("unit") + .and_then(|u| u.as_str()) + .map(str::to_string) + .or(hit.1); + match (&unit, u) { + (None, Some(u)) => unit = Some(u), + (Some(a), Some(b)) if *a != b => agreed = false, + _ => {} + } + } + // 全体都是量才走到这里;单位不一致就当没有单位,别挑一个塞上去 + Some(if agreed { + unit.unwrap_or_default() + } else { + String::new() + }) +} + pub(crate) async fn adopt_attribute_core( state: &AppState, kb_id: Uuid, @@ -1419,7 +1456,16 @@ pub(crate) async fn adopt_attribute_core( "nothing is waiting on those wordings", )); } - utopia_store::ontology::create_relation_type( + /* **datatype 由等着的那些值定,不听模型的。** + 实测模型给 `valuation` 报的是 text,而等它的四个值全是 + `$12 billion` 这样的量;存成 text 就比不了大小——而"能比大小" + 正是这些数不该当节点的理由。手里有事实的时候不必去问判断题, + `keep_forms` 那里已经是同一个原则。 + 单位同理:几条值的单位一致就落在属性上(`$`、`%`、`people`)。 */ + let inferred = quantity_shape(&facts); + let datatype = inferred.as_ref().map_or(spec.datatype, |_| "number"); + let unit = inferred.as_deref().filter(|u| !u.is_empty()).or(spec.unit); + match utopia_store::ontology::create_relation_type( &state.pool, kb_id, spec.key, @@ -1432,10 +1478,34 @@ pub(crate) async fn adopt_attribute_core( "attribute", &domains, &[], - Some(spec.datatype), - spec.unit, + Some(datatype), + unit, ) - .await? + .await + { + Ok(id) => id, + /* 键被一个**空的**同名关系占着:改判它,别放弃。 + 实测就是这么卡住的——`Relation key 'valuation' already exists`, + 然后 valuation 那几个数额永远没有谓词,而那条关系自己一条事实 + 都没有。有事实的不会被改判(判据在 store 那一侧的 SQL 里), + 那种是本体与语料的真分歧,照旧报错留给人看 */ + Err(e) => match utopia_store::ontology::attribute_from_unused_relation( + &state.pool, + kb_id, + spec.key, + &domains, + datatype, + unit, + ) + .await? + { + Some(id) => { + tracing::info!(%kb_id, key = spec.key, "空关系改判成属性"); + id + } + None => return Err(e), + }, + } }; // 换算按**库里那一条**的 datatype,不按请求——指向已有属性时请求里根本 diff --git a/crates/utopia-server/src/bootstrap_ontology.rs b/crates/utopia-server/src/bootstrap_ontology.rs index 628245c6d..1c0922b05 100644 --- a/crates/utopia-server/src/bootstrap_ontology.rs +++ b/crates/utopia-server/src/bootstrap_ontology.rs @@ -203,9 +203,21 @@ pub async fn bootstrap_ontology(state: &AppState, kb_id: Uuid) -> anyhow::Result .filter(|f| f.doc_count >= MIN_DOCS) .collect(); let types = utopia_store::resolution::proposed_types(&state.pool, kb_id).await?; - if forms.len() + types.len() < MIN_SIGNALS { + /* **字面值那一档也要算进来。** + `proposed_predicates` 只数宾语是实体的事实(它数的是"采纳要改写的东西"), + 于是一个满是数额的语料在这里算出 predicates=0,整个自动扩本体被跳过, + 下面那段建属性的代码一次都跑不到。实测:12 条带着 `valuation`、 + `investment_amount` 等说法的值事实在库里等着,日志里只有一行 + 「够格的信号太少」。它们同样是"够不够一次 LLM 调用的量"的信号 */ + let value_forms: Vec<_> = utopia_store::graph::proposed_attributes(&state.pool, kb_id) + .await? + .into_iter() + .filter(|f| f.doc_count >= MIN_DOCS) + .collect(); + if forms.len() + types.len() + value_forms.len() < MIN_SIGNALS { tracing::debug!( %kb_id, predicates = forms.len(), types = types.len(), + values = value_forms.len(), "够格的信号太少,跳过自动扩本体" ); return Ok(()); diff --git a/crates/utopia-server/src/extraction.rs b/crates/utopia-server/src/extraction.rs index 4ec9453d2..c89c92544 100644 --- a/crates/utopia-server/src/extraction.rs +++ b/crates/utopia-server/src/extraction.rs @@ -426,6 +426,21 @@ fn is_entity_name(name: &str) -> bool { { return false; } + /* **整体就是一个量的,不是一个东西**:`$5 billion`、`52%`、`3.5 million`。 + 上面那条部分格只接住 `745 of …`,接不住这些。 + + 这道闸装在**这里**才管用。抽取那边也有一道(`looks_literal`),但它前面 + 挂着 `!known_predicate`:谓词一旦是本体里列出来的关系,整段判断直接跳过。 + 实测就是这么漏的——`hasAmount` 来自 FIBO 包、抽取前就在本体里,于是 + `Microsoft hasAmount $1 billion` 里那个数额照样被造成了节点, + 而同一个库里 `invested`(语料自己长的、当时还未知)走到了那道闸、被拦下。 + `is_entity_name` 不问谓词,主语宾语一视同仁,两条路都过它。 + + 判据仍旧从严(见 `parse_quantity`):尾巴上有实词就不算, + `3M`、`7-Eleven`、`23andMe`、`2025 Atlantic hurricane season` 一个都不误伤。 */ + if utopia_extract::parse_quantity(name).is_some() { + return false; + } true } @@ -1556,6 +1571,23 @@ async fn run(state: &AppState, document_id: Uuid, proposer: Proposer) -> anyhow: // 值在手上就收下,原词进 proposed_predicate,等本体采纳时再换谓词, // 形状已经是对的(0010) (Some(v), None | Some("")) => Some(v.clone()), + /* **宾语整体是一个量:一律当值收下**,不问谓词认不认识、 + 也不问模型有没有把它声明成实体。 + + 下面那一档卡着 `!known_predicate`,理由是本体说得上话的时候 + 别去二猜模型。可量值这里没有可猜的余地:一个数额不会因为 + 谓词恰好在本体里就变成一个东西。实测漏的正是这一格—— + `hasAmount` 来自 FIBO 包、抽取前就在本体里, + `Microsoft hasAmount $1 billion` 于是绕过下面那一档, + 把数额造成了节点;同一个库里 `invested` 当时还未知, + 走到下面那一档、被拦住了。同一个数额,两种下场。 + + 收下而不是丢掉:`is_entity_name` 那道闸现在也拦纯量值, + 不在这里接住的话,这条事实会连同那个数一起进丢弃表。 + 原词照旧进 `proposed_predicate`,采纳时再换谓词 */ + (_, Some(o)) if utopia_extract::parse_quantity(o).is_some() => { + Some(serde_json::Value::String(o.to_string())) + } (_, Some(o)) if !o.is_empty() && !known_predicate(f.predicate.as_str()) @@ -2736,6 +2768,32 @@ mod name_tests { } } + /// 一个数额不是一个东西。 + /// + /// 样本取自实跑出来的库:`$5 billion`、`$1 billion`、`$30 billion` 各自成过节点, + /// 而且同名的会并成一个点——SSI Inc. 与 Nvidia 因为都出现过「$5 billion」 + /// 在图上相连,那条路径没有任何含义。判据的窄处在**尾巴**: + /// 后面还有实词的一律放行,因为那时它说的就不再只是那个数。 + #[test] + fn a_quantity_is_not_a_thing() { + for s in ["$5 billion", "€1.5 million", "52%", "3.5 million", "35,000"] { + assert!(!is_entity_name(s), "这是一个量,不该当成实体名:{s}"); + } + // **以数字开头的真实体一个都不能误伤。** 量级词只认全写,所以 `3M` 解不动; + // 后面挂着实词的,尾巴那一条接住 + for s in [ + "3M", + "7-Eleven", + "23andMe", + "2025 Atlantic hurricane season", + "900 million weekly active users", + "1000 Islands", + "$10 billion investment", + ] { + assert!(is_entity_name(s), "这是真实体,不该被挡:{s}"); + } + } + /// **真实体会长,但不带谓语。** 判据是词数 + 限定动词,不是字符数—— /// 下面第一个 57 字符,比上面那条 65 字符的从句还短不了多少 #[test] diff --git a/crates/utopia-store/src/ontology.rs b/crates/utopia-store/src/ontology.rs index 9c5e21f4e..f70697b80 100644 --- a/crates/utopia-store/src/ontology.rs +++ b/crates/utopia-store/src/ontology.rs @@ -1251,6 +1251,45 @@ pub async fn relation_type_id_by_key( Ok(row.map(|(id,)| id)) } +/// 一个**从没当关系用过**的关系,改判成属性。改成了返回 true。 +/// +/// 冷启动认出某个说法该是属性、去建的时候,键可能已经被一个同名关系占着 +/// (实测:`Relation key 'valuation' already exists`,然后整批放弃,那些数 +/// 永远拿不到谓词)。可占着这个键的关系常常是空的——本体包带进来的、或者 +/// 早先按票数建的,一条事实都没挂上。空的关系改判不破坏任何东西: +/// 没有边会因此断,撤销也只是再改回去。 +/// +/// **有事实的一律不动**。`invested`、`raised` 这类既连实体又带数额的, +/// 改判会把已有的边连根拔起;那是本体与语料的真分歧,该留给人看, +/// 不该由冷启动替人决定。 +pub async fn attribute_from_unused_relation( + pool: &PgPool, + kb_id: Uuid, + key: &str, + domains: &[Uuid], + datatype: &str, + unit: Option<&str>, +) -> AppResult> { + validate_attribute_fields("attribute", domains, Some(datatype))?; + let row: Option<(Uuid,)> = sqlx::query_as( + "UPDATE relation_types SET kind = 'attribute', datatype = $3, unit = $4, + inverse_of = NULL, sub_property_of = NULL + WHERE kb_id = $1 AND key = $2 AND kind = 'relation' + AND NOT EXISTS (SELECT 1 FROM facts f WHERE f.predicate_id = relation_types.id) + RETURNING id", + ) + .bind(kb_id) + .bind(key) + .bind(datatype) + .bind(unit) + .fetch_optional(pool) + .await?; + let Some((id,)) = row else { return Ok(None) }; + // domain / range 在各自的表里,不是列。属性没有 range——值域落在 datatype 上 + set_domains_ranges(pool, id, domains, &[]).await?; + Ok(Some(id)) +} + /// 一个属性声明的 datatype。改写字面值事实时要按它换算。 /// /// 以**库里这一条**为准而不是以请求为准:指向已有属性时请求里根本没有