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
23 changes: 23 additions & 0 deletions crates/utopia-core/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,19 @@ pub struct RelationTypeView {
pub usage: i64,
}

/// 一条边上挂的一个属性值(0037)。`value` 与 `entity` 二选一:
/// 金额、比例、日期是字面值;「经 C 撮合」里的 C 是实体(这一格这一刀还不写,位置留着)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FactQualifier {
pub qualifier_type_id: Uuid,
pub key: String,
pub label: String,
/// 形状与 `facts.object_value` 一致:{"value": …, "unit": …}
pub value: Option<serde_json::Value>,
pub entity_id: Option<Uuid>,
pub entity_name: Option<String>,
}

/// 抽取未匹配统计(本体扩展建议的信号源)。
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct OntologyMiss {
Expand Down Expand Up @@ -605,6 +618,10 @@ pub struct RelationType {
/// attribute 专用:text | number | date | bool
pub datatype: Option<String>,
pub unit: Option<String>,
/// **这条关系的边能带哪些属性**(0037):指向 kind='attribute' 的行。
/// `A invested B` 上的「金额」是边自己的属性,不是第二个宾语;金额的
/// datatype / unit / 换算全复用属性定义,只是它的 domain 是一条关系而不是一个类
pub qualifiers: Vec<Uuid>,
}

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
Expand Down Expand Up @@ -655,6 +672,9 @@ pub struct GraphEdge {
/// **与 `inferred` 不是一回事**,尽管两个词很近:那一位说的是「名字来自原文
/// 而不是本体」,这一位说的是「这条边根本不是谁说的,是引擎推的」
pub derived: bool,
/// 边上的属性(0037):画布把金额写到边的标签上要靠它
#[sqlx(skip)]
pub qualifiers: Vec<FactQualifier>,
/// 推它出来的那条规则(`transitive` / `symmetric` / `inverse` / `sub_property`);
/// 断言的边为 None。
///
Expand Down Expand Up @@ -704,6 +724,9 @@ pub struct EntityFact {
pub other_type: Option<String>,
/// 字面值宾语(属性事实/问数映射):{"value":…,"unit":…} 或 {"summary":…}
pub object_value: Option<serde_json::Value>,
/// 边上的属性(0037)。不在行里——`fact_qualifiers` 另一张表,加载后按事实 id 补
#[sqlx(skip)]
pub qualifiers: Vec<FactQualifier>,
pub valid_from: Option<DateTime<Utc>>,
pub valid_to: Option<DateTime<Utc>>,
/// 精度描述的是这条事实**有的那些日期**的粒度。两端都没有日期时为 None——
Expand Down
78 changes: 78 additions & 0 deletions crates/utopia-extract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ pub struct ExtractedFact {
/// 属性事实的字面值(谓词是 attribute 时)
#[serde(default)]
pub value: Option<serde_json::Value>,
/// **边上的属性**(0037):`{"amount": "$5 billion", "stake": "20%"}`。
/// 只对关系事实有意义,key 必须是清单里这条关系声明过的;值照原文写,换算在服务端
#[serde(default)]
pub qualifiers: Option<serde_json::Map<String, serde_json::Value>>,
#[serde(default)]
pub valid_from: Option<String>,
#[serde(default)]
Expand Down Expand Up @@ -93,6 +97,8 @@ pub struct PromptRelation {
/// 时间语义(`relation_types.temporal`):`state` / `event` / `eternal`(0031)。
/// 只有 event 与 eternal 会在清单里带标记——状态是默认,写出来只多花 token
pub temporal: String,
/// 这条关系的边能带的属性,已排好版:`amount: number $`(0037)。空 = 不带
pub qualifiers: Vec<String>,
}

/// Response-scoped reference to a persistent entity; database UUIDs must never enter prompts.
Expand Down Expand Up @@ -154,6 +160,12 @@ pub fn build_messages(
let mark = temporal_mark(&r.temporal)
.map(|m| format!(" [{m}]"))
.unwrap_or_default();
// 边上能带的属性跟在标记后面:`{amount: number $, stake: number %}`
let mark = if r.qualifiers.is_empty() {
mark
} else {
format!("{mark} {{{}}}", r.qualifiers.join(", "))
};
match (paren.is_empty(), d.is_empty()) {
(false, false) => format!("- {} ({paren}){mark}: {d}", r.key),
(false, true) => format!("- {} ({paren}){mark}", r.key),
Expand Down Expand Up @@ -298,6 +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.
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 \
Expand Down Expand Up @@ -985,6 +998,7 @@ mod prompt_shape_tests {
description: description.into(),
signature: signature.into(),
temporal: "state".into(),
qualifiers: vec![],
}
}

Expand Down Expand Up @@ -1153,6 +1167,70 @@ mod prompt_shape_tests {
mod tests {
use super::*;

/// 边上的属性(0037):清单里跟在关系后面,回复里挂在事实上。
#[test]
fn a_relation_lists_its_qualifiers_and_a_fact_carries_them() {
use serde_json::json;
let mut r = PromptRelation {
key: "invested_in".into(),
label: "invested in".into(),
description: "money into a company".into(),
signature: "organization → organization".into(),
temporal: "event".into(),
qualifiers: vec!["amount: number $".into(), "stake: number %".into()],
};
let msgs = build_messages(
&[],
std::slice::from_ref(&r),
&[],
None,
"a.txt",
&[],
"text",
);
let prompt = format!("{:?}", msgs);
// 签名、标记、属性清单三段顺序固定:`(签名) [event] {属性}`
assert!(prompt.contains(
"- invested_in (organization → organization) [event] {amount: number $, stake: number %}: money into a company"
), "{prompt}");
// 不带属性的关系不多一个花括号
r.qualifiers.clear();
let prompt = format!(
"{:?}",
build_messages(
&[],
std::slice::from_ref(&r),
&[],
None,
"a.txt",
&[],
"text"
)
);
assert!(
prompt.contains("- invested_in (organization → organization) [event]: money"),
"{prompt}"
);
assert!(!prompt.contains("[event] {"));

// 回复:qualifiers 挂在关系事实上;没写的是 None,旧回复不受影响
let reply = r#"{"entities":[],"facts":[
{"subject":"Vega","predicate":"invested_in","object":"Northwind",
"qualifiers":{"amount":"$5 billion"},"confidence":0.9},
{"subject":"Vega","predicate":"invested_in","object":"Kestrel","confidence":0.9}
]}"#;
let parsed = parse_response(reply).unwrap();
assert_eq!(parsed.facts.len(), 2);
assert_eq!(
parsed.facts[0]
.qualifiers
.as_ref()
.and_then(|q| q.get("amount")),
Some(&json!("$5 billion"))
);
assert!(parsed.facts[1].qualifiers.is_none());
}

#[test]
fn a_quantity_is_the_whole_string_or_nothing() {
// 整体就是一个量:符号、量级词、千分位都读得动
Expand Down
9 changes: 9 additions & 0 deletions crates/utopia-server/src/api/ontology_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ pub struct RelationTypeReq {
/// 调用方(属性表单)不该因为一次改名就把 domain 清空
#[serde(default)]
pub domains: Option<Vec<Uuid>>,
/// 这条关系的边能带哪些属性(0037):属性定义的 id。None = 不动
#[serde(default)]
pub qualifiers: Option<Vec<Uuid>>,
/// 可以当宾语的类。只对 relation 有意义
#[serde(default)]
pub ranges: Option<Vec<Uuid>>,
Expand Down Expand Up @@ -303,6 +306,9 @@ pub async fn create_relation_type(
req.unit.as_deref().map(str::trim).filter(|s| !s.is_empty()),
)
.await?;
if let Some(q) = req.qualifiers.as_deref() {
utopia_store::ontology::set_relation_qualifiers(&state.pool, kb_id, id, q).await?;
}
let _ = utopia_store::audit::record(
&state.pool,
Some(kb_id),
Expand Down Expand Up @@ -338,6 +344,9 @@ pub async fn update_relation_type(
req.ranges.as_deref(),
)
.await?;
if let Some(q) = req.qualifiers.as_deref() {
utopia_store::ontology::set_relation_qualifiers(&state.pool, kb_id, id, q).await?;
}
let _ = utopia_store::audit::record(
&state.pool,
Some(kb_id),
Expand Down
21 changes: 21 additions & 0 deletions crates/utopia-server/src/api/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,26 @@ pub(super) fn fact_line(f: &EntityFact) -> String {
.as_deref()
.or(literal.as_deref())
.unwrap_or("?");
// 边上的属性(0037)跟在对端后面:`invested_in → Kestrel [amount: 4000000000 $]`。
// 模型读事实行时最常问的就是"投了多少",数不在行里它就答"没有金额信息"
let quals: Vec<String> = f
.qualifiers
.iter()
.map(|q| {
let v = q
.value
.as_ref()
.and_then(literal_text)
.or_else(|| q.entity_name.clone())
.unwrap_or_else(|| "?".to_string());
format!("{}: {v}", q.key)
})
.collect();
let other = if quals.is_empty() {
other.to_string()
} else {
format!("{other} [{}]", quals.join(", "))
};
// 本体没认下、原文说法也没留下时用 "?"——与 other 同一个约定。
// 不编一个"相关"出来:那正是删掉 related_to 要消灭的东西
let pred = f.predicate_label.as_deref().unwrap_or("?");
Expand Down Expand Up @@ -1046,6 +1066,7 @@ mod tests {
other_id: None,
other_name: None,
other_type: None,
qualifiers: Vec::new(),
object_value: Some(value),
valid_from: Some(t("2023-06-01T00:00:00Z")),
valid_to: Some(t("2024-02-20T00:00:00Z")),
Expand Down
34 changes: 33 additions & 1 deletion crates/utopia-server/src/api/tools_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,36 @@ pub async fn find_entities(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value)
// ---- entity_facts ----------------------------------------------------------------

/// 事实的另一端:对端实体,或属性值
/// 边上的属性(0037)跟在对端后面:`Vega Capital [amount: 5000000000 $]`。
/// 模型读事实行时最常问的就是"投了多少",数不在行里它就答"没有金额信息"
fn qualifiers_text(f: &EntityFact) -> String {
if f.qualifiers.is_empty() {
return String::new();
}
let parts: Vec<String> = f
.qualifiers
.iter()
.map(|q| {
let v = q
.value
.as_ref()
.and_then(|v| v.get("value"))
.map(|v| v.to_string().trim_matches('"').to_string())
.or_else(|| q.entity_name.clone())
.unwrap_or_else(|| "?".to_string());
let u = q
.value
.as_ref()
.and_then(|v| v.get("unit"))
.and_then(|u| u.as_str())
.map(|u| format!(" {u}"))
.unwrap_or_default();
format!("{}: {v}{u}", q.key)
})
.collect();
format!(" [{}]", parts.join(", "))
}

fn other_text(f: &EntityFact) -> String {
let literal = f
.object_value
Expand Down Expand Up @@ -586,8 +616,9 @@ pub async fn entity_facts(ctx: &ToolCtx<'_>, sink: &mut ToolSink, args: &Value)
lines.push(format!("## {key} ({})", group.len()));
for f in group {
lines.push(format!(
"{}{} {}",
"{}{}{} {}",
other_text(f),
qualifiers_text(f),
range_text(f),
confidence_text(f)
));
Expand Down Expand Up @@ -1035,6 +1066,7 @@ mod tests {
other_id: Some(Uuid::now_v7()),
other_name: Some(other.into()),
other_type: other_type.map(String::from),
qualifiers: Vec::new(),
object_value: None,
valid_from: Some("2021-01-01T00:00:00Z".parse().unwrap()),
valid_to: None,
Expand Down
Loading
Loading