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:
JesseMarkowitz
2026-05-04 23:17:18 -04:00
co-authored by Claude Opus 4.7
parent 4798edcea7
commit 473d02f71a
16 changed files with 1786 additions and 232 deletions
+417 -83
View File
@@ -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