Skip to content

The RDF export writes a summary-shaped value as its own JSON text #831

Closed

Description

@WaylandYang

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.rs documents it on the field itself: /// 字面值宾语(属性事实/问数映射):{"value":…,"unit":…} 或 {"summary":…}
  • api/tools.rs renders it correctly for chat and MCP — if let Some(summary) = v.get("summary") returns the summary text, with a test asserting salary → orders.total
  • store/graph.rs reads 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

Activity

  1. rollroyces commented on Sep 20, 2026

    @rollroyces
    Contributor

    Picking this up. The diagnosis is exact — v.get("value").unwrap_or(v) returns the whole object when the key is absent, the match falls through to other => 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:

    1. {"value": null} with no summary. Today the literal is "". Maintainer Preserve literal objects when exporting unbound statements #821's an_absent_object_is_not_an_empty_literal says a missing object_value should produce no rdf:object at all; the adjacent case here is {"value": null} with no summary. Three options I can see:

      • Emit "" (today's behaviour, simplest).
      • Emit no rdf:object triple (skip the whole literal).
      • Distinguish the two: skip when object_value is 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.
    2. 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

    • Rewrite literal_value so the text resolution happens once, before the JSON-type branch: pull summary whenever value is absent or null, fall back to value as a string/number/bool otherwise.
    • Tests pinned on the table the issue body gives: {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).
    • No schema or write-path change. No migration. The export is what changes.
  2. rollroyces commented on Sep 23, 2026

    @rollroyces
    Contributor

    Update: shipped as #865. I went with the answer the maintainer's own #821 invariant already implies — an unresolvable literal shape emits no rdf:object triple, same as the object_value == None path. The reasoning:

    • {"value": null} with no summary is the same shape as object_value == None from the exporter's perspective (no text, no entity).
    • The audit invariant an_absent_object_is_not_an_empty_literal says don't emit rdf: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.
    • summary wins over value when both are present — summary is 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() into literal_text() -> Option<String> plus a thin literal_value() -> Option<Literal> wrapper, and updates the three call sites (emit_fact main object, qualifier branch, emit_derived conclusion) to skip the triple when the result is None.

    There's a follow-up: the test a_rule_conclusion_reaches_the_export_as_a_literal originally asserted the conclusion reaches the export as a literal. I updated it to accept that no rdf:object is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions