fix(frontmatter-read): split key/value rendering so keys are not JSON-quoted

The round-2 change sent every scalar (including string keys) through
json.dumps, which added quotes around string keys. The rendered answer
came out as '- "name": "Cobalt"' instead of '- name: "Cobalt"' on
the round-2 head; the 5 new and updated tests in test_frontmatter_steps
all failed for that reason on Unit Tests - py3.11 through py3.14.

Split the renderer:
- _render_key: pass-through str(key) so the answer reads as '- key:'.
- _render_value: still json.dumps (with default=str and ensure_ascii=False)
  for the structural pinning the round-2 review asked for (literal
  '\n' vs actual newline, the string 'null' vs YAML null, mixed mapping
  key types). Caller formats the line as '- {key}: {value}'.

Updated the three existing answer assertions to match the key-unchanged,
value-quoted shape (e.g. '- name: "Cobalt"'). The two new tests added
in the round-2 commit (newline, mixed keys) were already pinned on the
right shape; the round-1 key-quote was the only bug.
This commit is contained in:
Ziyang Guo 2026-10-17 16:51:45 +08:00
parent 7c6ed1e2a0
commit e6f6e027cb
2 changed files with 23 additions and 15 deletions

View file

@ -22,19 +22,17 @@ from ...components import R
def _render_value(value) -> str:
"""Render one frontmatter key or value on a single answer line.
"""Render one frontmatter value on a single answer line.
``frontmatter_read`` is served straight to the LLM, so the answer has
to carry the frontmatter itself. ``json.dumps`` (with ``default=str``
for YAML-only types such as dates, and ``ensure_ascii=False`` for
non-ASCII) preserves the structural distinction between a literal
``"\\n"`` two-character sequence and an actual newline, between the
string ``"null"`` and YAML ``null``, and between mixed mapping key
types (an int ``1`` and a string ``"1"`` round-trip with their
JSON-form distinction). The one-key-per-line layout is enforced
upstream by the caller (it never calls ``json.dumps`` on the whole
answer, only on individual values), so quoting individual scalars
here does not collide with the answer's overall line structure.
``frontmatter_read`` is served straight to the LLM, so the answer
has to carry the frontmatter itself. ``json.dumps`` (with
``default=str`` for YAML-only types such as dates, and
``ensure_ascii=False`` for non-ASCII) preserves the structural
distinction between a literal ``"\\n"`` two-character sequence and
an actual newline, between the string ``"null"`` and YAML ``null``,
and between mixed mapping key types (an int ``1`` and a string
``"1"`` round-trip with their JSON-form distinction). The
one-key-per-line layout is enforced upstream by the caller.
"""
try:
return json.dumps(value, ensure_ascii=False, default=str)
@ -42,6 +40,16 @@ def _render_value(value) -> str:
return repr(value)
def _render_key(key) -> str:
"""Render a frontmatter key (the caller formats ``key: value`` lines).
Keys are always strings in YAML frontmatter, so they do not need
the json.dumps structural pinning. Return them as-is so the answer
reads as ``- key: value`` rather than ``- "key": value``.
"""
return str(key)
@R.register("frontmatter_read_step")
class FrontmatterReadStep(BaseStep):
"""Read a Markdown file's frontmatter (YAML metadata only)."""
@ -88,7 +96,7 @@ class FrontmatterReadStep(BaseStep):
return
self.context.response.success = True
header = f"Read frontmatter from {probed} ({len(meta)} key(s))"
rendered = [f"- {_render_value(key)}: {_render_value(value)}" for key, value in meta.items()]
rendered = [f"- {_render_key(key)}: {_render_value(value)}" for key, value in meta.items()]
self.context.response.answer = f"{header}:\n" + "\n".join(rendered) if rendered else f"{header}: (no keys)"
self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta, **resolved})
self.logger.info(f"[{self.name}] path={path} keys={len(meta)}")

View file

@ -281,12 +281,12 @@ async def test_read_answer_tolerates_rich_yaml_types():
assert resp.success is True
assert resp.answer == (
"Read frontmatter from notes/types.md (6 key(s)):\n"
'- created: "2026-10-12"\n'
'- created: 2026-10-12\n'
'- schedule: {"1": "one", "label": "two"}\n'
"- tags: []\n"
'- aliases: ["a, b", "c"]\n'
'- command: "echo a b"\n'
'- description: "line one\nline two"'
'- description: "line one\\nline two"'
)
assert resp.metadata["frontmatter"] == {
"created": date(2026, 10, 12),