Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion crates/utopia-extract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,57 @@ pub fn parse_quantity(s: &str) -> Option<(f64, Option<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<String>)> {
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<serde_json::Value> {
Expand All @@ -826,7 +877,11 @@ pub fn normalize_attr_value(datatype: &str, raw: &serde_json::Value) -> Option<s
// 清洗解不动的再当量解:`$5 billion`、`52%` 这些整体就是数,
// 只是带着符号与量级词。单位不在这里落笔——它随事实走
// (见 `parse_quantity`),这一档只负责把值变成可比的数
.or_else(|| parse_quantity(s).map(|(n, _)| n))
// 清洗解不动的再当量解。**这一档已经声明了 datatype = number**,
// 问的不是「是不是数」而是「那个数是多少」,所以用宽的那套:
// `$5 billion` → 5e9,`1,250 people` → 1250,
// `42% from customers in Europe` → 42
.or_else(|| parse_leading_quantity(s).map(|(n, _)| n))
.filter(|f| f.is_finite())
.and_then(serde_json::Number::from_f64)
.map(serde_json::Value::Number)
Expand Down Expand Up @@ -1125,6 +1180,40 @@ mod tests {
assert_eq!(parse_quantity("杭州"), None);
}

#[test]
fn a_declared_number_reads_past_the_unit() {
// 属性已经声明了 datatype = number,问的是「那个数是多少」。
// 卡住过的两条都在这里
assert_eq!(
parse_leading_quantity("1,250 people"),
Some((1250.0, Some("people".into())))
);
assert_eq!(
parse_leading_quantity("42% from customers in Europe"),
Some((42.0, Some("%".into())))
);
assert_eq!(
parse_leading_quantity("3,400 people worldwide"),
Some((3400.0, Some("people".into())))
);
assert_eq!(
parse_leading_quantity("900 million weekly active users"),
Some((9e8, Some("weekly".into())))
);
// 整体就是量的仍走严的那套:单位是 `$`,不是 `billion`
assert_eq!(
parse_leading_quantity("$5 billion"),
Some((5e9, Some("$".into())))
);
// 开头不是数就还是不认
assert_eq!(parse_leading_quantity("about ten"), None);
assert_eq!(parse_leading_quantity(""), None);

// **严的那套一点没松**:它要判「是不是一个东西」,判错会吃掉真实体
assert_eq!(parse_quantity("1,250 people"), None);
assert_eq!(parse_quantity("2025 Atlantic hurricane season"), None);
}

#[test]
fn a_number_attribute_takes_a_written_quantity() {
use serde_json::json;
Expand Down
78 changes: 74 additions & 4 deletions crates/utopia-server/src/api/ontology_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1391,6 +1391,43 @@ pub(crate) struct AttributeAdopted {
}

/// 建(或指向已有的)属性,并把等着它的字面值事实改挂过去。人工与自动共用。
/// 等着这个说法的值**是不是清一色的量**;是的话给出它们共同的单位。
///
/// 判据要全体一致:混着 `$12 billion` 与 `chief executive` 的一批,
/// 按 number 落地会把后者整条丢掉(换不动的不改写),那不如老实当 text。
fn quantity_shape(facts: &[(Uuid, Option<Uuid>, serde_json::Value)]) -> Option<String> {
if facts.is_empty() {
return None;
}
let mut unit: Option<String> = 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,不按请求——指向已有属性时请求里根本
Expand Down
14 changes: 13 additions & 1 deletion crates/utopia-server/src/bootstrap_ontology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(());
Expand Down
58 changes: 58 additions & 0 deletions crates/utopia-server/src/extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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]
Expand Down
39 changes: 39 additions & 0 deletions crates/utopia-store/src/ontology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Uuid>> {
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。改写字面值事实时要按它换算。
///
/// 以**库里这一条**为准而不是以请求为准:指向已有属性时请求里根本没有
Expand Down
Loading