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
124 changes: 124 additions & 0 deletions crates/utopia-extract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,78 @@ pub fn parse_adjudication(raw: &str) -> anyhow::Result<Vec<AdjudicationVerdict>>
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<String>)> {
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<serde_json::Value> {
Expand All @@ -749,6 +821,10 @@ pub fn normalize_attr_value(datatype: &str, raw: &serde_json::Value) -> Option<s
cleaned
.parse::<f64>()
.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)
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 14 additions & 2 deletions crates/utopia-server/src/api/ontology_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1407,7 +1407,10 @@ pub(crate) async fn adopt_attribute_core(
} else {
// **domain 从数据里取。** 属性必须声明能挂在哪些类下,猜错的代价是硬的:
// 主语类型对不上就整条丢弃。这些事实的主语现在是什么类是事实,不是判断
let mut domains: Vec<Uuid> = facts.iter().map(|(_, type_id, _)| *type_id).collect();
let mut domains: Vec<Uuid> = facts
.iter()
.filter_map(|(_, type_id, _)| *type_id)
.collect();
domains.sort_unstable();
domains.dedup();
if domains.is_empty() {
Expand Down Expand Up @@ -1446,7 +1449,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,
Expand Down
56 changes: 49 additions & 7 deletions crates/utopia-server/src/extraction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2134,9 +2148,19 @@ fn looks_literal(s: &str) -> bool {
if s.is_empty() {
return false;
}
// 纯数字(含小数与正负号)。用 f64 解而不是自己扫字符:
// "3M"、"V3"、"2015"(全角)都会失败,正是想要的
if s.parse::<f64>().is_ok() {
/* **整体是一个量**:可选货币符号 + 数字 + 可选量级词 + 可选百分号,
此外一个词都不许有(判据与例子见 `parse_quantity`)。

从前这里只认裸数字(`s.parse::<f64>()`),于是 `$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 等
Expand Down Expand Up @@ -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} 该认成字面值");
}
// 不认:判错的代价是把一个真实体降成一段文本,所以宁可漏
Expand All @@ -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} 不该认成字面值");
}
Expand Down
9 changes: 8 additions & 1 deletion crates/utopia-store/src/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2024,10 +2024,17 @@ pub async fn value_facts_for_forms(
pool: &PgPool,
kb_id: Uuid,
forms: &[String],
) -> AppResult<Vec<(Uuid, Uuid, serde_json::Value)>> {
) -> AppResult<Vec<(Uuid, Option<Uuid>, 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
Expand Down
Loading
Loading