fix: parse Claude.ai privacy export with messages key and sender field (#677) (#685)

* fix: parse Claude.ai privacy export with messages key and sender field (#677)

The privacy-export branch in _try_claude_ai_json only checked for the
"chat_messages" key, missing exports that use "messages" instead.  It
also only read the "role" field while real privacy exports use "sender".
Both gaps caused the file to fall through to plain-text, producing a
single giant drawer.

Changes:
- Accept "messages" alongside "chat_messages" in the conversation-object
  guard and inner extraction.
- Accept "sender" alongside "role" as the author field.
- Fall back to a top-level "text" key when content blocks are empty.
- Produce one transcript per conversation instead of concatenating all
  conversations into a single blob.
- Extract shared logic into _collect_claude_messages helper.
- Add 6 regression tests covering each variant.

* style: apply ruff format to normalize.py

* fix: guard against null text field in Claude.ai export parsing

item.get("text", "").strip() crashes when "text" is explicitly null
in the JSON (legal and observed in some exports). Use
(item.get("text") or "").strip() and add a regression test.

---------

Co-authored-by: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
This commit is contained in:
Mikhail Valentsev
2026-04-13 10:11:03 +05:00
committed by GitHub
parent e200ce2c8a
commit a2432a3245
2 changed files with 141 additions and 22 deletions
+113
View File
@@ -524,6 +524,119 @@ def test_claude_ai_privacy_export_non_dict_items():
assert result is not None
def test_claude_ai_privacy_export_messages_key():
"""Privacy export using 'messages' key instead of 'chat_messages'."""
data = [
{
"uuid": "abc-123",
"name": "Test convo",
"messages": [
{"role": "human", "content": "Q1"},
{"role": "ai", "content": "A1"},
],
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_sender_field():
"""Privacy export using 'sender' instead of 'role'."""
data = [
{
"chat_messages": [
{"sender": "human", "content": "Q1"},
{"sender": "assistant", "content": "A1"},
]
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_text_fallback():
"""Privacy export where content is empty but text field has the message."""
data = [
{
"chat_messages": [
{"sender": "human", "text": "Q1", "content": []},
{"sender": "assistant", "text": "A1", "content": []},
]
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_null_text():
"""Privacy export where text field is explicitly null must not crash."""
data = [
{
"chat_messages": [
{"sender": "human", "text": None, "content": "Q1"},
{"sender": "assistant", "text": None, "content": "A1"},
]
}
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
def test_claude_ai_privacy_export_per_conversation():
"""Multiple conversations produce separate transcripts."""
data = [
{
"uuid": "convo-1",
"chat_messages": [
{"role": "human", "content": "Q1"},
{"role": "ai", "content": "A1"},
],
},
{
"uuid": "convo-2",
"chat_messages": [
{"role": "human", "content": "Q2"},
{"role": "ai", "content": "A2"},
],
},
]
result = _try_claude_ai_json(data)
assert result is not None
assert "> Q1" in result
assert "> Q2" in result
# each conversation is a separate transcript block
parts = result.split("\n\n")
q1_parts = [p for p in parts if "> Q1" in p]
q2_parts = [p for p in parts if "> Q2" in p]
assert len(q1_parts) >= 1
assert len(q2_parts) >= 1
def test_claude_ai_privacy_export_skips_empty_conversations():
"""Conversations with <2 messages are skipped."""
data = [
{
"chat_messages": [
{"role": "human", "content": "lonely message"},
],
},
{
"chat_messages": [
{"role": "human", "content": "Q1"},
{"role": "ai", "content": "A1"},
],
},
]
result = _try_claude_ai_json(data)
assert result is not None
assert "lonely message" not in result
assert "> Q1" in result
# ── _try_chatgpt_json ─────────────────────────────────────────────────