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
+147
-47
@@ -5,6 +5,17 @@ import os
|
||||
|
||||
from curl_cffi import requests as curl_requests
|
||||
|
||||
from src.blocks import (
|
||||
UNKNOWN_REASON_EXTRACTION_FAILED,
|
||||
UNKNOWN_REASON_UNKNOWN_TYPE,
|
||||
make_image_placeholder,
|
||||
make_text_block,
|
||||
make_thinking_block,
|
||||
make_tool_result_block,
|
||||
make_tool_use_block,
|
||||
make_unknown_block,
|
||||
)
|
||||
from src.loss_report import LossReport
|
||||
from src.providers.base import BaseProvider, ProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -161,8 +172,9 @@ class ClaudeProvider(BaseProvider):
|
||||
|
||||
return data
|
||||
|
||||
def normalize_conversation(self, raw: dict) -> dict:
|
||||
def normalize_conversation(self, raw: dict, loss_report: LossReport | None = None) -> dict:
|
||||
"""Transform Claude raw schema to the common normalized schema."""
|
||||
report = loss_report if loss_report is not None else LossReport()
|
||||
conv_id = raw.get("uuid") or raw.get("id", "")
|
||||
title = raw.get("name") or raw.get("title") or "Untitled"
|
||||
created_at = raw.get("created_at") or raw.get("create_time") or ""
|
||||
@@ -178,40 +190,37 @@ class ClaudeProvider(BaseProvider):
|
||||
|
||||
# Messages
|
||||
raw_messages = raw.get("chat_messages") or raw.get("messages") or []
|
||||
messages = []
|
||||
messages: list[dict] = []
|
||||
|
||||
for msg in raw_messages:
|
||||
role = _map_role(msg.get("sender") or msg.get("role", ""))
|
||||
if not role:
|
||||
continue
|
||||
|
||||
# Content can be a string or a list of content blocks
|
||||
content_raw = msg.get("content") or msg.get("text") or ""
|
||||
content, skipped_types = _extract_claude_text(content_raw, conv_id)
|
||||
|
||||
for ctype in skipped_types:
|
||||
logger.warning(
|
||||
"[claude] Skipping %s content in conversation %s "
|
||||
"— rich content not yet supported (see FUTURE.md)",
|
||||
ctype,
|
||||
conv_id[:8],
|
||||
)
|
||||
content_raw = msg.get("content") if "content" in msg else msg.get("text", "")
|
||||
blocks = _extract_claude_blocks(content_raw, conv_id, report)
|
||||
|
||||
timestamp = msg.get("created_at") or msg.get("timestamp") or None
|
||||
|
||||
if content is None:
|
||||
if not blocks:
|
||||
logger.debug("[claude] Skipping empty message in conversation %s", conv_id[:8])
|
||||
continue
|
||||
|
||||
content_type = "text"
|
||||
|
||||
messages.append(
|
||||
{
|
||||
"role": role,
|
||||
"content": content,
|
||||
"content_type": "text",
|
||||
"content_type": content_type,
|
||||
"timestamp": timestamp,
|
||||
"blocks": blocks,
|
||||
}
|
||||
)
|
||||
|
||||
for _ in messages:
|
||||
report.record_message()
|
||||
report.record_conversation()
|
||||
|
||||
return {
|
||||
"id": conv_id,
|
||||
"title": title,
|
||||
@@ -242,43 +251,134 @@ def _map_role(sender: str) -> str | None:
|
||||
return mapping.get(sender.lower()) if sender else None
|
||||
|
||||
|
||||
def _extract_claude_text(
|
||||
content: str | list | dict, conv_id: str
|
||||
) -> tuple[str | None, list[str]]:
|
||||
"""Extract plain text from a Claude content field.
|
||||
def _extract_claude_blocks(
|
||||
content: str | list | dict | None, conv_id: str, report: LossReport
|
||||
) -> list[dict]:
|
||||
"""Extract typed blocks from a Claude content field.
|
||||
|
||||
Returns:
|
||||
(text_or_None, list_of_skipped_content_types)
|
||||
Defensive dispatch — zero observed cases of rich Claude content in the
|
||||
user's archive at planning time, so this is theory-only. Real shapes
|
||||
will be locked in v0.4.1 once captured. Any unrecognised block type
|
||||
surfaces as an `unknown` block + WARNING + tally.
|
||||
"""
|
||||
skipped: list[str] = []
|
||||
if content is None:
|
||||
return []
|
||||
|
||||
if isinstance(content, str):
|
||||
text = content.strip()
|
||||
return (text if text else None), skipped
|
||||
block = make_text_block(content)
|
||||
return [block] if block else []
|
||||
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, str):
|
||||
parts.append(block)
|
||||
elif isinstance(block, dict):
|
||||
btype = block.get("type", "text")
|
||||
if btype == "text":
|
||||
t = block.get("text", "").strip()
|
||||
if t:
|
||||
parts.append(t)
|
||||
else:
|
||||
skipped.append(btype)
|
||||
text = "\n".join(parts).strip()
|
||||
return (text if text else None), skipped
|
||||
blocks: list[dict] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
block = make_text_block(item)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
elif isinstance(item, dict):
|
||||
blocks.extend(_dispatch_claude_block(item, conv_id, report))
|
||||
return blocks
|
||||
|
||||
if isinstance(content, dict):
|
||||
btype = content.get("type", "text")
|
||||
if btype == "text":
|
||||
text = content.get("text", "").strip()
|
||||
return (text if text else None), skipped
|
||||
else:
|
||||
skipped.append(btype)
|
||||
return None, skipped
|
||||
return _dispatch_claude_block(content, conv_id, report)
|
||||
|
||||
return None, skipped
|
||||
return []
|
||||
|
||||
|
||||
def _dispatch_claude_block(block: dict, conv_id: str, report: LossReport) -> list[dict]:
|
||||
"""Translate one raw Claude content block into normalized blocks."""
|
||||
btype = block.get("type", "text")
|
||||
|
||||
if btype == "text":
|
||||
block_obj = make_text_block(block.get("text", "") or "")
|
||||
return [block_obj] if block_obj else []
|
||||
|
||||
if btype == "thinking":
|
||||
# Claude extended-thinking blocks may use 'thinking' or 'text' field.
|
||||
text = block.get("thinking") or block.get("text") or ""
|
||||
block_obj = make_thinking_block(text)
|
||||
return [block_obj] if block_obj else []
|
||||
|
||||
if btype == "tool_use":
|
||||
return [
|
||||
make_tool_use_block(
|
||||
name=block.get("name", "") or "",
|
||||
input_data=block.get("input"),
|
||||
tool_id=block.get("id"),
|
||||
)
|
||||
]
|
||||
|
||||
if btype == "tool_result":
|
||||
# ``content`` may be a string or a list of nested blocks (recursive).
|
||||
nested = block.get("content")
|
||||
output = _flatten_tool_result_content(nested, conv_id, report)
|
||||
return [
|
||||
make_tool_result_block(
|
||||
output=output,
|
||||
tool_name=None,
|
||||
is_error=bool(block.get("is_error")),
|
||||
)
|
||||
]
|
||||
|
||||
if btype == "image":
|
||||
# Source shape is unverified; try the most likely fields.
|
||||
source = block.get("source") or {}
|
||||
ref = ""
|
||||
if isinstance(source, dict):
|
||||
ref = (
|
||||
source.get("file_uuid")
|
||||
or source.get("media_type")
|
||||
or source.get("url")
|
||||
or ""
|
||||
)
|
||||
return [make_image_placeholder(ref=ref or "(unknown)", source="user_upload")]
|
||||
|
||||
# Unknown block type
|
||||
keys = list(block.keys())
|
||||
logger.warning(
|
||||
"[claude] Unknown block type %r in conversation %s "
|
||||
"— see plan §Data-loss visibility (rendering as unknown block)",
|
||||
btype,
|
||||
conv_id[:8],
|
||||
)
|
||||
report.record_unknown(btype or "?")
|
||||
return [
|
||||
make_unknown_block(
|
||||
raw_type=btype or "?",
|
||||
observed_keys=keys,
|
||||
reason=UNKNOWN_REASON_UNKNOWN_TYPE,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _flatten_tool_result_content(
|
||||
nested: object, conv_id: str, report: LossReport
|
||||
) -> str:
|
||||
"""Flatten Claude tool_result content (string OR list of nested blocks) to text.
|
||||
|
||||
Recurses into nested text blocks; any non-text nested block becomes a
|
||||
visible inline marker so non-text content isn't silently dropped.
|
||||
"""
|
||||
if nested is None:
|
||||
return ""
|
||||
if isinstance(nested, str):
|
||||
return nested
|
||||
if isinstance(nested, list):
|
||||
chunks: list[str] = []
|
||||
for item in nested:
|
||||
if isinstance(item, str):
|
||||
chunks.append(item)
|
||||
elif isinstance(item, dict):
|
||||
btype = item.get("type", "text")
|
||||
if btype == "text":
|
||||
chunks.append(item.get("text", "") or "")
|
||||
else:
|
||||
keys = list(item.keys())[:10]
|
||||
report.record_extraction_failure(f"tool_result.{btype}")
|
||||
chunks.append(
|
||||
f"[Unsupported nested {btype} block; keys={keys}]"
|
||||
)
|
||||
return "\n".join(c for c in chunks if c)
|
||||
if isinstance(nested, dict):
|
||||
return _flatten_tool_result_content([nested], conv_id, report)
|
||||
return str(nested)
|
||||
|
||||
Reference in New Issue
Block a user