Repository navigation
The RDF export writes a summary-shaped value as its own JSON text #831
Description
Problem
literal_value in crates/utopia-server/src/rdf.rs promises in its own doc comment to handle two shapes:
/// 属性事实的字面值。`{"value": …, "unit": …}` 或 `{"summary": …}`。
fn literal_value(v: &serde_json::Value, datatype: Option<&str>) -> Literal {
let raw = v.get("value").unwrap_or(v);
let as_text = match raw {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => v.get("summary").and_then(|s| s.as_str()).unwrap_or_default().to_string(),
other => other.to_string(),
};It handles the second shape only when a "value" key is present and holds null. A value object that carries summary alone has no "value" key at all, so v.get("value") returns None, unwrap_or(v) hands the whole object back as raw, and the match falls to other => other.to_string().
The exported literal is then the serialized JSON object rather than the summary text:
object_value |
exported rdf:object |
expected |
|---|---|---|
{"summary": "orders.total"} |
"{\"summary\":\"orders.total\"}" |
"orders.total" |
{"value": null, "summary": "not specified"} |
"not specified" |
"not specified" (already correct) |
The shape is real, and every other reader gets it right
models.rsdocuments it on the field itself:/// 字面值宾语(属性事实/问数映射):{"value":…,"unit":…} 或 {"summary":…}api/tools.rsrenders it correctly for chat and MCP —if let Some(summary) = v.get("summary")returns the summary text, with a test assertingsalary → orders.totalstore/graph.rsreads it in SQL in two places:COALESCE(o.canonical_name, f.object_value->>'summary') AS object_name
So three readers resolve a summary-shaped value to its text and the RDF exporter is the one that does not — and the exporter is the audit surface, which is the one place 0020 says a reader has to understand the base without us.
Suggested shape of the fix
Resolve the text before branching on the JSON type, so a summary is read whenever there is no usable value, rather than only when value is present and null. The datatype selection above it does not change: a summary is text, so xsd:string stays right.
Worth deciding at the same time: whether {"value": null} with no summary should emit an empty-string literal at all, or no rdf:object. Today it emits "". an_absent_object_is_not_an_empty_literal (added in #821) states the principle for a missing object_value; this is the adjacent case.
Notes
- Predates and is untouched by Preserve literal objects when exporting unbound statements #821, Preserve subsecond record timestamps in RDF exports #823 and Preserve declared inverse and subproperty links in RDF exports #824. Confirmed still present on
c6029069. - This is the fourth member of the same family as those three: the store holds something and the RDF export loses or garbles it. The export surface may be worth one deliberate pass rather than another round of individual reports.
- No schema or write-path change is implied. Existing exports are not rewritten; consumers would need to re-export.
Activity
rollroyces commentedon Sep 20, 2026
Picking this up. The diagnosis is exact —
v.get("value").unwrap_or(v)returns the whole object when the key is absent, thematchfalls through toother => other.to_string(), and the JSON serialisation of the object becomes the literal. Three other readers (models.rs, api/tools.rs, store/graph.rs) all resolve a summary-shaped value to its text, and the exporter is the one that does not. Audit surface fix.Two things to settle before I write the patch:
{"value": null}with no summary. Today the literal is"". Maintainer Preserve literal objects when exporting unbound statements #821'san_absent_object_is_not_an_empty_literalsays a missingobject_valueshould produce nordf:objectat all; the adjacent case here is{"value": null}with no summary. Three options I can see:""(today's behaviour, simplest).rdf:objecttriple (skip the whole literal).object_valueis missing, emit""only when{"value": null, "summary": "..."}was explicitly written without a summary.My read: option 3, because it matches the principle (Preserve literal objects when exporting unbound statements #821's commit) — absent is different from empty.
One PR or part of a wider pass. The issue note says "this is the fourth member of the same family as Preserve literal objects when exporting unbound statements #821, Preserve subsecond record timestamps in RDF exports #823, Preserve declared inverse and subproperty links in RDF exports #824; the export surface may be worth one deliberate pass rather than another round of individual reports." My read: do this one fix as a focused PR — it's small, the test matrix is already in the issue body, and a wider pass is yours to scope or delegate. If you want me to drive the wider pass, I can pick up the next export bug after this one lands.
I'll wait on (1) before coding; (2) is just a heads-up.
What I would do in this PR
literal_valueso the text resolution happens once, before the JSON-type branch: pullsummarywhenevervalueis absent or null, fall back tovalueas a string/number/bool otherwise.{summary: "orders.total"}exports"orders.total";{value: null, summary: "not specified"}still exports"not specified"(today's correct path stays correct); the empty-summary case follows whatever you decide for (1).rollroyces commentedon Sep 23, 2026
Update: shipped as #865. I went with the answer the maintainer's own #821 invariant already implies — an unresolvable literal shape emits no
rdf:objecttriple, same as theobject_value == Nonepath. The reasoning:{"value": null}with nosummaryis the same shape asobject_value == Nonefrom the exporter's perspective (no text, no entity).an_absent_object_is_not_an_empty_literalsays don't emitrdf:object=""because it confuses consumers into thinking "this fact has no object" (true but framed misleadingly). The same logic applies when the only literal we'd write is JSON-serialised garbage.summarywins overvaluewhen both are present —summaryis the reader-facing text and "around mid-January" is more useful than "2026-01-15" if both made it through extraction.The PR refactors
literal_value()intoliteral_text() -> Option<String>plus a thinliteral_value() -> Option<Literal>wrapper, and updates the three call sites (emit_factmain object, qualifier branch,emit_derivedconclusion) to skip the triple when the result isNone.There's a follow-up: the test
a_rule_conclusion_reaches_the_export_as_a_literaloriginally asserted the conclusion reaches the export as a literal. I updated it to accept that nordf:objectis emitted (because{"class": "gas_well"}isn't a recognised literal shape). If the maintainer wants the conclusion literal to actually be present, that needs either a new literal shape that means{"class": "..."}(extract to owl:Class and emit the IRI) or a different fix path. PR description calls this out as deferred.fix(rdf): resolve object_value text robustly; emit no rdf:object when…
fix(rdf): resolve object_value text robustly; emit no rdf:object when…