feat: v0.4.0 — rich content support with typed blocks and loss visibility
Extracts per-message content into a typed `blocks` list (text, code, thinking, tool_use, tool_result, image_placeholder, file_placeholder, unknown) and renders them at exporter write time. Voice transcripts, Custom Instructions, and image references now appear in exports instead of being silently dropped. Foundation: - src/blocks.py: pure block constructors, _safe_fence (fence-corruption defense, verified live in Joplin), _blockquote_prefix, render - src/loss_report.py: per-run tally surfaced as INFO summary at end of export so silently-dropped data becomes visible Providers: - ChatGPT: dispatch on content_type produces typed blocks; voice shapes (audio_transcription, audio_asset_pointer, real_time_user_audio_video_ asset_pointer) locked from live DevTools capture; Custom Instructions bug fix (parts-vs-direct-fields); role filter lifted; hidden-context marker driven by is_visually_hidden_from_conversation flag - Claude: defensive dispatch for text/thinking/tool_use/tool_result/image with recursive nested-block flattening; untested against real rich- content data — fix-forward in v0.4.1 Exporter: - Markdown renders from blocks at write time via render_blocks_to_markdown; backward-compat fallback to content for any pre-v0.4.0 cached data Tests: - 27 new tests across providers, exporters, CLI; fixtures rebuilt with real-shape ChatGPT voice + Custom Instructions cases - 181/181 pass Behavior changes (intentional): - JSON output omits content; consumers should read blocks - Per-conversation message counts increase (Custom Instructions, image- only, tool-only messages now appear) - Existing exports not auto-re-rendered; users wanting fresh output run cache --clear then export Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4798edcea7
commit
473d02f71a
+417
-83
@@ -25,6 +25,19 @@ from typing import Any
|
||||
|
||||
from curl_cffi import requests as curl_requests
|
||||
|
||||
from src.blocks import (
|
||||
UNKNOWN_REASON_EXTRACTION_FAILED,
|
||||
UNKNOWN_REASON_UNKNOWN_FIELD_IN_KNOWN_TYPE,
|
||||
UNKNOWN_REASON_UNKNOWN_TYPE,
|
||||
make_code_block,
|
||||
make_file_placeholder,
|
||||
make_hidden_context_marker,
|
||||
make_image_placeholder,
|
||||
make_text_block,
|
||||
make_thinking_block,
|
||||
make_unknown_block,
|
||||
)
|
||||
from src.loss_report import LossReport
|
||||
from src.providers.base import BaseProvider, ProviderError, REQUEST_TIMEOUT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -551,7 +564,7 @@ class ChatGPTProvider(BaseProvider):
|
||||
# Normalization
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def normalize_conversation(self, raw: dict) -> dict:
|
||||
def normalize_conversation(self, raw: dict, loss_report: LossReport | None = None) -> dict:
|
||||
"""Transform ChatGPT raw schema to the common normalized schema.
|
||||
|
||||
ChatGPT stores messages in a nested ``mapping`` dict where each node
|
||||
@@ -562,6 +575,7 @@ class ChatGPTProvider(BaseProvider):
|
||||
fetch_all_conversations). The conversation detail endpoint does not
|
||||
include project information.
|
||||
"""
|
||||
report = loss_report if loss_report is not None else LossReport()
|
||||
conv_id = raw.get("id", "")
|
||||
title = raw.get("title") or "Untitled"
|
||||
created_at = _ts_to_iso(raw.get("create_time"))
|
||||
@@ -580,7 +594,10 @@ class ChatGPTProvider(BaseProvider):
|
||||
)
|
||||
|
||||
mapping: dict = raw.get("mapping", {})
|
||||
messages = _extract_messages(mapping, raw, conv_id)
|
||||
messages = _extract_messages(mapping, raw, conv_id, report)
|
||||
for _ in messages:
|
||||
report.record_message()
|
||||
report.record_conversation()
|
||||
|
||||
return {
|
||||
"id": conv_id,
|
||||
@@ -610,14 +627,18 @@ def _ts_to_iso(ts: float | int | str | None) -> str:
|
||||
|
||||
|
||||
def _extract_messages(
|
||||
mapping: dict[str, Any], raw: dict, conv_id: str
|
||||
mapping: dict[str, Any], raw: dict, conv_id: str, report: LossReport
|
||||
) -> list[dict]:
|
||||
"""Walk the ChatGPT conversation mapping tree to produce an ordered message list."""
|
||||
"""Walk the ChatGPT conversation mapping tree to produce an ordered message list.
|
||||
|
||||
All roles (user/assistant/system/tool) are processed; the prior filter that
|
||||
dropped non-user/assistant messages is lifted in v0.4.0 — truly empty
|
||||
messages skip via the empty-content guard, anything with content renders.
|
||||
"""
|
||||
if not mapping:
|
||||
logger.warning("[chatgpt] Conversation %s has empty mapping", conv_id[:8])
|
||||
return []
|
||||
|
||||
# Find the root node (the one that has no parent, or whose parent is None/not in mapping)
|
||||
root_id = _find_root(mapping)
|
||||
if root_id is None:
|
||||
logger.warning(
|
||||
@@ -635,68 +656,12 @@ def _extract_messages(
|
||||
|
||||
node = mapping.get(node_id, {})
|
||||
msg_data = node.get("message")
|
||||
|
||||
if msg_data:
|
||||
role = msg_data.get("author", {}).get("role", "")
|
||||
# Skip system/tool messages silently unless they have visible content
|
||||
if role in ("user", "assistant"):
|
||||
content_obj = msg_data.get("content", {})
|
||||
content_type = content_obj.get("content_type", "text")
|
||||
ts = msg_data.get("create_time")
|
||||
built = _build_message(msg_data, conv_id, node_id, report)
|
||||
if built is not None:
|
||||
messages.append(built)
|
||||
|
||||
# Content types whose parts[] contain plain text strings.
|
||||
# model_editable_context / user_editable_context = project instructions
|
||||
# thoughts / reasoning_recap = o1/o3 reasoning traces
|
||||
_TEXT_PARTS_TYPES = {
|
||||
"text",
|
||||
"model_editable_context",
|
||||
"user_editable_context",
|
||||
"thoughts",
|
||||
"reasoning_recap",
|
||||
}
|
||||
|
||||
if content_type in _TEXT_PARTS_TYPES:
|
||||
text = _extract_text(content_obj, conv_id, node_id)
|
||||
if text:
|
||||
messages.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": text,
|
||||
"content_type": "text",
|
||||
"timestamp": _ts_to_iso(ts) if ts else None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"[chatgpt] Skipping empty %s message in conversation %s",
|
||||
content_type,
|
||||
conv_id[:8],
|
||||
)
|
||||
elif content_type == "code":
|
||||
# Inline code response — extract and wrap in a fenced code block
|
||||
code_text = content_obj.get("text") or "\n".join(
|
||||
p for p in content_obj.get("parts", []) if isinstance(p, str)
|
||||
)
|
||||
language = content_obj.get("language", "")
|
||||
if code_text:
|
||||
messages.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": f"```{language}\n{code_text}\n```",
|
||||
"content_type": "code",
|
||||
"timestamp": _ts_to_iso(ts) if ts else None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[chatgpt] Skipping %s content in conversation %s message %s "
|
||||
"— rich content not yet supported (see FUTURE.md)",
|
||||
content_type,
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
|
||||
# Walk children in order (ChatGPT typically has one child per node in a linear chat)
|
||||
# Walk children in order (linear in typical conversations)
|
||||
for child_id in node.get("children", []):
|
||||
walk(child_id)
|
||||
|
||||
@@ -718,36 +683,405 @@ def _find_root(mapping: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(content_obj: dict, conv_id: str, node_id: str) -> str:
|
||||
"""Extract plain text from a ChatGPT content object.
|
||||
def _build_message(
|
||||
msg_data: dict, conv_id: str, node_id: str, report: LossReport
|
||||
) -> dict | None:
|
||||
"""Construct a normalized message dict (with ``blocks``) for one ChatGPT node.
|
||||
|
||||
Handles three part shapes:
|
||||
- str — plain text (most messages)
|
||||
- dict with content_type="text" — wrapped text part
|
||||
- dict with "content" key — o1/o3 thoughts/reasoning parts
|
||||
Returns None for messages that should be skipped (truly empty). Otherwise
|
||||
returns a dict with ``role``, ``content_type``, ``timestamp``, ``blocks``.
|
||||
"""
|
||||
parts = content_obj.get("parts", [])
|
||||
if not parts:
|
||||
return ""
|
||||
author = msg_data.get("author") or {}
|
||||
role = author.get("role", "") or ""
|
||||
if role not in ("user", "assistant", "system", "tool"):
|
||||
# Unrecognised role — log and surface, but pass through so role metadata
|
||||
# is preserved for the reader.
|
||||
logger.debug(
|
||||
"[chatgpt] Unrecognised role %r in conversation %s message %s",
|
||||
role,
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
|
||||
content_obj = msg_data.get("content") or {}
|
||||
content_type = content_obj.get("content_type", "text")
|
||||
ts = msg_data.get("create_time")
|
||||
metadata = msg_data.get("metadata") or {}
|
||||
is_hidden = bool(metadata.get("is_visually_hidden_from_conversation"))
|
||||
|
||||
blocks = _extract_blocks_for_content(
|
||||
content_type, content_obj, role, conv_id, node_id, report
|
||||
)
|
||||
|
||||
if not blocks:
|
||||
logger.debug(
|
||||
"[chatgpt] Skipping empty %s message in conversation %s",
|
||||
content_type,
|
||||
conv_id[:8],
|
||||
)
|
||||
return None
|
||||
|
||||
if is_hidden:
|
||||
# Prepend a marker so the reader knows this message is hidden in the
|
||||
# source UI. The marker is content-type-agnostic.
|
||||
blocks = [make_hidden_context_marker(content_type)] + blocks
|
||||
|
||||
# Vestigial content_type: "code" for code-only messages, otherwise "text"
|
||||
msg_content_type = "code" if (
|
||||
len(blocks) == 1 and blocks[0].get("type") == "code"
|
||||
) else "text"
|
||||
|
||||
return {
|
||||
"role": role or "user",
|
||||
"content_type": msg_content_type,
|
||||
"timestamp": _ts_to_iso(ts) if ts else None,
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
|
||||
# Content types whose ``parts`` are plain text strings.
|
||||
_PLAIN_TEXT_PARTS_TYPES = {"text"}
|
||||
# Content types that carry inline reasoning/thoughts.
|
||||
_THINKING_TYPES = {"thoughts", "reasoning_recap"}
|
||||
# Custom-Instructions / model-context types — direct fields, NOT parts.
|
||||
_DIRECT_FIELD_CONTEXT_TYPES = {
|
||||
"user_editable_context",
|
||||
"model_editable_context",
|
||||
}
|
||||
# Known direct fields per context type. Anything not listed but non-null
|
||||
# becomes an `unknown` block per the no-silent-drop-of-non-null-fields rule.
|
||||
_USER_EDITABLE_CONTEXT_KNOWN_FIELDS = ("user_profile", "user_instructions")
|
||||
_MODEL_EDITABLE_CONTEXT_KNOWN_FIELDS = (
|
||||
"model_set_context",
|
||||
"repository",
|
||||
"repo_summary",
|
||||
"structured_context",
|
||||
)
|
||||
|
||||
|
||||
def _extract_blocks_for_content(
|
||||
content_type: str,
|
||||
content_obj: dict,
|
||||
role: str,
|
||||
conv_id: str,
|
||||
node_id: str,
|
||||
report: LossReport,
|
||||
) -> list[dict]:
|
||||
"""Dispatch on content_type and return a list of blocks for one message."""
|
||||
|
||||
if content_type in _PLAIN_TEXT_PARTS_TYPES:
|
||||
return _extract_text_content_type_blocks(content_obj, conv_id, node_id, report)
|
||||
|
||||
if content_type == "multimodal_text":
|
||||
return _extract_multimodal_blocks(content_obj, role, conv_id, node_id, report)
|
||||
|
||||
if content_type == "code":
|
||||
code_text = content_obj.get("text") or "\n".join(
|
||||
p for p in content_obj.get("parts", []) if isinstance(p, str)
|
||||
)
|
||||
language = content_obj.get("language", "") or ""
|
||||
block = make_code_block(code_text, language)
|
||||
return [block] if block else []
|
||||
|
||||
if content_type in _THINKING_TYPES:
|
||||
text = _join_string_parts(content_obj)
|
||||
block = make_thinking_block(text)
|
||||
return [block] if block else []
|
||||
|
||||
if content_type in _DIRECT_FIELD_CONTEXT_TYPES:
|
||||
return _extract_editable_context_blocks(
|
||||
content_type, content_obj, conv_id, node_id, report
|
||||
)
|
||||
|
||||
if content_type == "image_asset_pointer":
|
||||
# Top-level image (rare — usually nested inside multimodal_text).
|
||||
ref = content_obj.get("asset_pointer", "")
|
||||
source = "user_upload" if role == "user" else "model_generated"
|
||||
return [make_image_placeholder(ref=ref, source=source)]
|
||||
|
||||
# Unknown content_type → visible unknown block + WARNING + tally
|
||||
keys = list(content_obj.keys())
|
||||
logger.warning(
|
||||
"[chatgpt] Unknown content_type %r in conversation %s message %s "
|
||||
"— see plan §Data-loss visibility (rendering as unknown block)",
|
||||
content_type,
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
report.record_unknown(content_type or "?")
|
||||
return [
|
||||
make_unknown_block(
|
||||
raw_type=content_type or "?",
|
||||
observed_keys=keys,
|
||||
reason=UNKNOWN_REASON_UNKNOWN_TYPE,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _extract_text_content_type_blocks(
|
||||
content_obj: dict, conv_id: str, node_id: str, report: LossReport
|
||||
) -> list[dict]:
|
||||
"""Extract blocks for ``content_type == "text"``.
|
||||
|
||||
Plural-parts rule: emit ONE text block per message with all string parts
|
||||
joined by ``\\n``. Don't emit one block per part.
|
||||
|
||||
Dict parts inside a text content_type message (the suspected o1/o3 reasoning
|
||||
subpart shape ``{"summary": ..., "content": ...}``) are preserved as text
|
||||
today — defensive behavior pending real-data capture in v0.4.1.
|
||||
"""
|
||||
parts = content_obj.get("parts", []) or []
|
||||
string_chunks: list[str] = []
|
||||
|
||||
text_parts = []
|
||||
for part in parts:
|
||||
if isinstance(part, str):
|
||||
text_parts.append(part)
|
||||
string_chunks.append(part)
|
||||
elif isinstance(part, dict):
|
||||
part_type = part.get("content_type", "")
|
||||
if part_type == "text":
|
||||
text_parts.append(part.get("text", ""))
|
||||
txt = part.get("text", "") or ""
|
||||
if txt:
|
||||
string_chunks.append(txt)
|
||||
elif "content" in part:
|
||||
# o1/o3 thoughts parts: {"summary": "...", "content": "..."}
|
||||
text_parts.append(part["content"])
|
||||
# Suspected o1/o3 reasoning subpart. Defensive: preserve as text
|
||||
# block (matches current behavior). v0.4.1 reclassifies once
|
||||
# the real shape is captured live.
|
||||
content_val = part.get("content", "") or ""
|
||||
if content_val:
|
||||
string_chunks.append(content_val)
|
||||
elif part_type:
|
||||
# Image, file, or other binary attachment — skip and warn
|
||||
# Non-text dict part inside a text content_type — surface it.
|
||||
logger.warning(
|
||||
"[chatgpt] Skipping %s attachment in conversation %s "
|
||||
"— rich content not yet supported (see FUTURE.md)",
|
||||
"[chatgpt] Unexpected %s part inside text content_type "
|
||||
"in conversation %s message %s — rendering as unknown block",
|
||||
part_type,
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
report.record_unknown(part_type)
|
||||
# Inline mark in the joined text so order is preserved.
|
||||
string_chunks.append(
|
||||
f"\n[Unknown part: type={part_type}; "
|
||||
f"keys={list(part.keys())[:10]}]\n"
|
||||
)
|
||||
|
||||
return "\n".join(t for t in text_parts if t)
|
||||
joined = "\n".join(c for c in string_chunks if c)
|
||||
block = make_text_block(joined)
|
||||
return [block] if block else []
|
||||
|
||||
|
||||
def _join_string_parts(content_obj: dict) -> str:
|
||||
"""Helper: join all string parts in ``parts`` with newlines."""
|
||||
parts = content_obj.get("parts", []) or []
|
||||
return "\n".join(p for p in parts if isinstance(p, str) and p)
|
||||
|
||||
|
||||
def _extract_multimodal_blocks(
|
||||
content_obj: dict, role: str, conv_id: str, node_id: str, report: LossReport
|
||||
) -> list[dict]:
|
||||
"""Extract blocks from a ``multimodal_text`` content object.
|
||||
|
||||
Walks ``parts`` in array order — order varies between user and assistant
|
||||
turns, and the extractor preserves source ordering. Emits text +
|
||||
image_placeholder + file_placeholder blocks per part.
|
||||
"""
|
||||
parts = content_obj.get("parts", []) or []
|
||||
blocks: list[dict] = []
|
||||
|
||||
for part in parts:
|
||||
if isinstance(part, str):
|
||||
block = make_text_block(part)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
continue
|
||||
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
|
||||
part_type = part.get("content_type", "")
|
||||
|
||||
if part_type == "audio_transcription":
|
||||
txt = part.get("text", "") or ""
|
||||
block = make_text_block(txt)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
elif "text" not in part:
|
||||
logger.warning(
|
||||
"[chatgpt] audio_transcription part missing 'text' key "
|
||||
"in conversation %s message %s",
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
report.record_extraction_failure("audio_transcription")
|
||||
blocks.append(
|
||||
make_unknown_block(
|
||||
raw_type="audio_transcription",
|
||||
observed_keys=list(part.keys()),
|
||||
reason=UNKNOWN_REASON_EXTRACTION_FAILED,
|
||||
summary="expected key 'text' not found",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if part_type == "image_asset_pointer":
|
||||
ref = part.get("asset_pointer", "")
|
||||
source = "user_upload" if role == "user" else "model_generated"
|
||||
mime = None
|
||||
blocks.append(make_image_placeholder(ref=ref, source=source, mime=mime))
|
||||
continue
|
||||
|
||||
if part_type == "audio_asset_pointer":
|
||||
blocks.append(_audio_asset_placeholder(part))
|
||||
continue
|
||||
|
||||
if part_type == "real_time_user_audio_video_asset_pointer":
|
||||
# Wrapper carrying a nested audio_asset_pointer + optional video frames.
|
||||
nested_audio = part.get("audio_asset_pointer")
|
||||
if isinstance(nested_audio, dict):
|
||||
blocks.append(_audio_asset_placeholder(nested_audio))
|
||||
else:
|
||||
logger.warning(
|
||||
"[chatgpt] real_time_user_audio_video_asset_pointer missing "
|
||||
"nested audio_asset_pointer in conversation %s message %s",
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
report.record_extraction_failure(
|
||||
"real_time_user_audio_video_asset_pointer"
|
||||
)
|
||||
blocks.append(
|
||||
make_unknown_block(
|
||||
raw_type="real_time_user_audio_video_asset_pointer",
|
||||
observed_keys=list(part.keys()),
|
||||
reason=UNKNOWN_REASON_EXTRACTION_FAILED,
|
||||
summary="expected nested 'audio_asset_pointer' not found",
|
||||
)
|
||||
)
|
||||
|
||||
frames = part.get("frames_asset_pointers") or []
|
||||
if frames:
|
||||
# Defensive: empty in all observed cases, but if non-empty
|
||||
# surface as a separate file placeholder.
|
||||
video_ref = part.get("video_container_asset_pointer") or "(video frames)"
|
||||
blocks.append(
|
||||
make_file_placeholder(
|
||||
ref=str(video_ref),
|
||||
mime="video/unknown",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Anything else inside multimodal_text — visible unknown block
|
||||
logger.warning(
|
||||
"[chatgpt] Unknown multimodal_text part type %r in conversation %s message %s",
|
||||
part_type,
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
report.record_unknown(part_type or "?")
|
||||
blocks.append(
|
||||
make_unknown_block(
|
||||
raw_type=part_type or "?",
|
||||
observed_keys=list(part.keys()),
|
||||
reason=UNKNOWN_REASON_UNKNOWN_TYPE,
|
||||
)
|
||||
)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _audio_asset_placeholder(audio_part: dict) -> dict:
|
||||
"""Build a file_placeholder for an audio_asset_pointer dict.
|
||||
|
||||
Handles missing/zero metadata defensively.
|
||||
"""
|
||||
ref = audio_part.get("asset_pointer", "") or ""
|
||||
fmt = audio_part.get("format") or "unknown"
|
||||
size_bytes = audio_part.get("size_bytes")
|
||||
if not isinstance(size_bytes, int) or size_bytes <= 0:
|
||||
size_bytes = None
|
||||
metadata = audio_part.get("metadata") or {}
|
||||
start = metadata.get("start") if isinstance(metadata, dict) else None
|
||||
end = metadata.get("end") if isinstance(metadata, dict) else None
|
||||
duration: float | None = None
|
||||
if isinstance(start, (int, float)) and isinstance(end, (int, float)):
|
||||
diff = float(end) - float(start)
|
||||
if diff > 0:
|
||||
duration = diff
|
||||
return make_file_placeholder(
|
||||
ref=ref,
|
||||
mime=f"audio/{fmt}" if fmt else "audio/unknown",
|
||||
size_bytes=size_bytes,
|
||||
duration_seconds=duration,
|
||||
)
|
||||
|
||||
|
||||
def _extract_editable_context_blocks(
|
||||
content_type: str, content_obj: dict, conv_id: str, node_id: str, report: LossReport
|
||||
) -> list[dict]:
|
||||
"""Extract blocks from user_editable_context / model_editable_context messages.
|
||||
|
||||
These have no ``parts`` field — they carry direct keys. Read all known
|
||||
fields, emit one labeled fenced block per non-null known field, and emit an
|
||||
``unknown`` block for any unrecognised non-null direct field (no-silent-drop
|
||||
rule).
|
||||
"""
|
||||
if content_type == "user_editable_context":
|
||||
known_fields: tuple[str, ...] = _USER_EDITABLE_CONTEXT_KNOWN_FIELDS
|
||||
elif content_type == "model_editable_context":
|
||||
known_fields = _MODEL_EDITABLE_CONTEXT_KNOWN_FIELDS
|
||||
else:
|
||||
known_fields = ()
|
||||
|
||||
blocks: list[dict] = []
|
||||
label_kind = "Custom Instructions" if content_type == "user_editable_context" else "Model Context"
|
||||
|
||||
for field in known_fields:
|
||||
value = content_obj.get(field)
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
continue
|
||||
if isinstance(value, (dict, list)):
|
||||
# Render as a JSON-rendered text block. _safe_fence will wrap it.
|
||||
import json as _json
|
||||
rendered = _json.dumps(value, indent=2, default=str, ensure_ascii=False)
|
||||
else:
|
||||
rendered = str(value)
|
||||
label = f"**{label_kind} — {field}:**"
|
||||
# Emit as text block; the renderer's _safe_fence wraps the raw value.
|
||||
# We use a "labeled fenced block" pattern: header line + raw content
|
||||
# joined inside one text block, where the renderer will leave it alone.
|
||||
# To get the safe-fence wrap we use a code block (which calls _safe_fence
|
||||
# internally and renders without language-hint corruption risk).
|
||||
blocks.append(make_text_block(label))
|
||||
code_block = make_code_block(rendered, language="")
|
||||
if code_block:
|
||||
blocks.append(code_block)
|
||||
|
||||
# Catch unknown non-null direct fields (no-silent-drop rule).
|
||||
structural_keys = {"content_type", "parts"}
|
||||
for key, value in content_obj.items():
|
||||
if key in structural_keys or key in known_fields:
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
# Reject null/empty containers.
|
||||
if isinstance(value, (str, list, dict)) and not value:
|
||||
continue
|
||||
logger.warning(
|
||||
"[chatgpt] Unknown non-null field %r in %s message %s/%s",
|
||||
key,
|
||||
content_type,
|
||||
conv_id[:8],
|
||||
node_id[:8],
|
||||
)
|
||||
report.record_unknown(f"{content_type}.{key}")
|
||||
blocks.append(
|
||||
make_unknown_block(
|
||||
raw_type=f"{content_type}.{key}",
|
||||
observed_keys=list(content_obj.keys()),
|
||||
reason=UNKNOWN_REASON_UNKNOWN_FIELD_IN_KNOWN_TYPE,
|
||||
summary=f"unknown non-null field '{key}' in {content_type}",
|
||||
)
|
||||
)
|
||||
|
||||
return blocks
|
||||
|
||||
Reference in New Issue
Block a user