thread-archive 0.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChatGPT conversation export parser.
|
|
3
|
+
|
|
4
|
+
Parses the conversations.json export file from ChatGPT.
|
|
5
|
+
|
|
6
|
+
## Format
|
|
7
|
+
|
|
8
|
+
Export source: ChatGPT web UI → Settings → Data controls → Export data
|
|
9
|
+
File structure: ZIP archive containing ``conversations.json``
|
|
10
|
+
|
|
11
|
+
The ``conversations.json`` contains an array of conversation objects, each with:
|
|
12
|
+
- ``mapping``: Dict of message_id → message node (tree structure)
|
|
13
|
+
- ``current_node``: ID of the leaf node in the "active" conversation path
|
|
14
|
+
- ``title``, ``create_time``, ``update_time``: Conversation metadata
|
|
15
|
+
|
|
16
|
+
## Edge Cases
|
|
17
|
+
|
|
18
|
+
Tree Structure via ``mapping``
|
|
19
|
+
ChatGPT stores messages as a tree, not a linear list. Each message has a
|
|
20
|
+
``parent`` field pointing to its predecessor. Branching occurs when users
|
|
21
|
+
edit messages or regenerate responses, creating alternate paths.
|
|
22
|
+
|
|
23
|
+
Coalescing Tool Messages
|
|
24
|
+
ChatGPT exports tool calls, thinking, and results as separate messages
|
|
25
|
+
linked via parent_id. This parser coalesces them into ``content_blocks``
|
|
26
|
+
on the parent assistant message. Original message boundaries are preserved
|
|
27
|
+
via ``provider_message_id`` on each block.
|
|
28
|
+
|
|
29
|
+
Active Path Detection
|
|
30
|
+
``current_node`` identifies the conversation's "main" thread. Messages not
|
|
31
|
+
on this path are marked ``is_active_path=False``. The path is computed by
|
|
32
|
+
walking from ``current_node`` back to root.
|
|
33
|
+
|
|
34
|
+
Thinking Content Types
|
|
35
|
+
o1/o1-pro models emit thinking as separate messages with content_type in:
|
|
36
|
+
``thoughts``, ``analysis``, ``reasoning_recap``. These are coalesced as
|
|
37
|
+
``thinking`` blocks on the parent assistant message.
|
|
38
|
+
|
|
39
|
+
System Context Messages
|
|
40
|
+
Custom instructions appear as messages with content_type in:
|
|
41
|
+
``user_editable_context``, ``model_editable_context``. Converted to
|
|
42
|
+
``system_context`` blocks.
|
|
43
|
+
|
|
44
|
+
## Validation Notes
|
|
45
|
+
|
|
46
|
+
Thinking blocks
|
|
47
|
+
Model-specific. Required for o1/o1-pro models, not expected for GPT-4/GPT-3.5.
|
|
48
|
+
See ``ProviderConfig`` for exempt models.
|
|
49
|
+
|
|
50
|
+
Parent references
|
|
51
|
+
Always valid within a conversation. Missing parents indicate data corruption.
|
|
52
|
+
|
|
53
|
+
Branching
|
|
54
|
+
Full tree structure preserved. Use ``is_active_path`` to filter to main thread.
|
|
55
|
+
|
|
56
|
+
## Field Mapping
|
|
57
|
+
|
|
58
|
+
- ``create_time`` → ``created_at`` (Unix timestamp, immutable)
|
|
59
|
+
- ``update_time`` → ``updated_at`` (mutable, changes on edits)
|
|
60
|
+
- ``mapping[id].parent`` → ``provider_parent_id`` (tree structure)
|
|
61
|
+
- ``current_node`` → Used to compute ``is_active_path``
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from typing import Any, Dict, List, Optional, Set, Union, cast
|
|
65
|
+
|
|
66
|
+
from thread_archive._thread_import.timestamps import parse_timestamp_iso
|
|
67
|
+
|
|
68
|
+
from .base import (
|
|
69
|
+
ContentBlock,
|
|
70
|
+
FieldMapping,
|
|
71
|
+
NormalizedMessage,
|
|
72
|
+
ProviderParser,
|
|
73
|
+
SemanticCheck,
|
|
74
|
+
ValidationSeverity,
|
|
75
|
+
)
|
|
76
|
+
from .chatgpt_content import (
|
|
77
|
+
append_image_parts,
|
|
78
|
+
append_metadata_tool_calls,
|
|
79
|
+
append_text_content,
|
|
80
|
+
build_content_blocks,
|
|
81
|
+
collect_coalesce_candidates,
|
|
82
|
+
extract_content_blocks,
|
|
83
|
+
extract_special_block,
|
|
84
|
+
extract_text_from_content,
|
|
85
|
+
is_primary_message,
|
|
86
|
+
)
|
|
87
|
+
from .config import CHATGPT_CONFIG, ProviderConfig
|
|
88
|
+
from .types.chatgpt import ChatGPTConversation, ChatGPTExport
|
|
89
|
+
from .validators import (
|
|
90
|
+
ContentValidator,
|
|
91
|
+
ReferentialIntegrityValidator,
|
|
92
|
+
ThinkingBlockValidator,
|
|
93
|
+
TypeValidator,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Content types that indicate thinking/reasoning messages
|
|
97
|
+
THINKING_CONTENT_TYPES = {"thoughts", "analysis", "reasoning_recap"}
|
|
98
|
+
|
|
99
|
+
# Content types that indicate structural/scaffolding messages (not displayed)
|
|
100
|
+
STRUCTURAL_CONTENT_TYPES = {"code", "commentary", "metadata", "system"}
|
|
101
|
+
|
|
102
|
+
# Content types that represent system context
|
|
103
|
+
CONTEXT_CONTENT_TYPES = {"user_editable_context", "model_editable_context"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_create_time_iso(raw_msg: Dict[str, Any]) -> Optional[str]:
|
|
107
|
+
"""Extract create_time and convert to ISO format."""
|
|
108
|
+
create_time = raw_msg.get("create_time")
|
|
109
|
+
if create_time is None:
|
|
110
|
+
return None
|
|
111
|
+
# Epoch is an absolute instant — parse to aware UTC, not naive-local (which
|
|
112
|
+
# would silently skew every ChatGPT timestamp by the host's UTC offset).
|
|
113
|
+
return parse_timestamp_iso(create_time)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_update_time_iso(raw_msg: Dict[str, Any]) -> Optional[str]:
|
|
117
|
+
"""Extract update_time and convert to ISO format."""
|
|
118
|
+
update_time = raw_msg.get("update_time")
|
|
119
|
+
if update_time is None:
|
|
120
|
+
return None
|
|
121
|
+
# Aware UTC (see _extract_create_time_iso) — never naive-local.
|
|
122
|
+
return parse_timestamp_iso(update_time)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _extract_message_order(raw_msg: Dict[str, Any]) -> Optional[int]:
|
|
126
|
+
"""Convert create_time to microsecond-precision message order."""
|
|
127
|
+
create_time = raw_msg.get("create_time")
|
|
128
|
+
if create_time is None or not isinstance(create_time, (int, float)):
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
return int(create_time * 1_000_000)
|
|
132
|
+
except (ValueError, OverflowError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _extract_role(raw_msg: Dict[str, Any]) -> Optional[str]:
|
|
137
|
+
"""Extract role from nested author structure."""
|
|
138
|
+
return raw_msg.get("author", {}).get("role")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_author_name(raw_msg: Dict[str, Any]) -> Optional[str]:
|
|
142
|
+
"""Extract author name (for tool attribution)."""
|
|
143
|
+
return raw_msg.get("author", {}).get("name")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _extract_model(raw_msg: Dict[str, Any]) -> Optional[str]:
|
|
147
|
+
"""Extract model name from message metadata.model_slug."""
|
|
148
|
+
return (raw_msg.get("metadata") or {}).get("model_slug")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ChatGPTParser(ProviderParser):
|
|
152
|
+
"""Parser for ChatGPT conversations.json export.
|
|
153
|
+
|
|
154
|
+
Implements coalescing: tool calls, thinking, and results from child messages
|
|
155
|
+
are merged into content_blocks on their parent message.
|
|
156
|
+
|
|
157
|
+
## Architecture
|
|
158
|
+
|
|
159
|
+
Uses the pipeline architecture with:
|
|
160
|
+
- PROVIDER_CONFIG: ChatGPT-specific configuration (thinking expectations, etc.)
|
|
161
|
+
- Validators: ThinkingBlockValidator, ReferentialIntegrityValidator, etc.
|
|
162
|
+
- Types: ChatGPTExport, ChatGPTConversation for typed input
|
|
163
|
+
|
|
164
|
+
## Explicit Field Mappings
|
|
165
|
+
|
|
166
|
+
This parser uses explicit semantic mappings to ensure we correctly interpret
|
|
167
|
+
ChatGPT's JSON structure. Each mapping documents what the field MEANS in
|
|
168
|
+
our model, not just where it comes from in the provider JSON.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
PROVIDER_NAME = "chatgpt"
|
|
172
|
+
|
|
173
|
+
# Provider configuration (replaces centralized PROVIDER_EXPECTATIONS)
|
|
174
|
+
PROVIDER_CONFIG: ProviderConfig = CHATGPT_CONFIG
|
|
175
|
+
|
|
176
|
+
# Explicit field mappings with semantic documentation
|
|
177
|
+
# These are applied to the raw message dict (from mapping[id].message)
|
|
178
|
+
MESSAGE_FIELD_MAPPINGS = [
|
|
179
|
+
FieldMapping(
|
|
180
|
+
model_field="created_at",
|
|
181
|
+
extractor=_extract_create_time_iso,
|
|
182
|
+
required=False,
|
|
183
|
+
semantic_doc=(
|
|
184
|
+
"When the message was originally SENT by the user/assistant. "
|
|
185
|
+
"This is create_time in ChatGPT, which is immutable and represents "
|
|
186
|
+
"the user-facing timestamp. NOT the same as when the DB record was created."
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
FieldMapping(
|
|
190
|
+
model_field="updated_at",
|
|
191
|
+
extractor=_extract_update_time_iso,
|
|
192
|
+
required=False,
|
|
193
|
+
semantic_doc=(
|
|
194
|
+
"When the message was last EDITED. This is update_time in ChatGPT. "
|
|
195
|
+
"May be null if message was never edited. Different from DB update timestamps."
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
FieldMapping(
|
|
199
|
+
model_field="message_order",
|
|
200
|
+
extractor=_extract_message_order,
|
|
201
|
+
required=False,
|
|
202
|
+
semantic_doc=(
|
|
203
|
+
"Microsecond-precision ordering value derived from create_time. "
|
|
204
|
+
"Used for consistent message ordering within a conversation."
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
FieldMapping(
|
|
208
|
+
model_field="role",
|
|
209
|
+
extractor=_extract_role,
|
|
210
|
+
required=False,
|
|
211
|
+
semantic_doc=(
|
|
212
|
+
"The actor role: 'user', 'assistant', 'system', or 'tool'. "
|
|
213
|
+
"Extracted from message.author.role in ChatGPT's structure."
|
|
214
|
+
),
|
|
215
|
+
),
|
|
216
|
+
FieldMapping(
|
|
217
|
+
model_field="author_name",
|
|
218
|
+
extractor=_extract_author_name,
|
|
219
|
+
required=False,
|
|
220
|
+
semantic_doc=(
|
|
221
|
+
"The tool/plugin name when role='tool'. "
|
|
222
|
+
"Used to identify which tool produced a tool_result block."
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
FieldMapping(
|
|
226
|
+
model_field="model",
|
|
227
|
+
extractor=_extract_model,
|
|
228
|
+
required=False,
|
|
229
|
+
semantic_doc=(
|
|
230
|
+
"The model that generated this message (e.g. 'gpt-4', 'gpt-4o'). "
|
|
231
|
+
"Extracted from message.metadata.model_slug in ChatGPT's structure. "
|
|
232
|
+
"Only present on assistant messages."
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
# Semantic checks for ChatGPT-specific requirements
|
|
238
|
+
SEMANTIC_CHECKS = [
|
|
239
|
+
SemanticCheck(
|
|
240
|
+
name="user_context_on_first_message",
|
|
241
|
+
description=(
|
|
242
|
+
"First user message should have user context (custom instructions, "
|
|
243
|
+
"memory, etc.) from user_editable_context or model_editable_context blocks"
|
|
244
|
+
),
|
|
245
|
+
applies_to="first_user_message",
|
|
246
|
+
severity=ValidationSeverity.error,
|
|
247
|
+
),
|
|
248
|
+
SemanticCheck(
|
|
249
|
+
name="thinking_on_assistant_messages",
|
|
250
|
+
description=(
|
|
251
|
+
"Assistant messages from o1/o3 models should have thinking blocks. "
|
|
252
|
+
"Not required for older models, so this is a warning not an error."
|
|
253
|
+
),
|
|
254
|
+
applies_to="assistant_messages",
|
|
255
|
+
severity=ValidationSeverity.warning,
|
|
256
|
+
),
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
def __init__(self, strict: bool = False):
|
|
260
|
+
"""Initialize the ChatGPT parser."""
|
|
261
|
+
super().__init__(strict=strict)
|
|
262
|
+
# Initialize validators from the new architecture
|
|
263
|
+
self._validators = [
|
|
264
|
+
ThinkingBlockValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
265
|
+
ReferentialIntegrityValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
266
|
+
TypeValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
267
|
+
ContentValidator(self.PROVIDER_CONFIG, strict=strict),
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
def parse_export(
|
|
271
|
+
self, data: Union[ChatGPTExport, List[ChatGPTConversation], Any]
|
|
272
|
+
) -> List[NormalizedMessage]:
|
|
273
|
+
"""
|
|
274
|
+
Parse ChatGPT export data into normalized message format.
|
|
275
|
+
|
|
276
|
+
The coalescing strategy:
|
|
277
|
+
1. First pass: Parse all messages, build parent->children map
|
|
278
|
+
2. Second pass: For each primary message (user/assistant with text content),
|
|
279
|
+
collect child tool/thinking messages and convert to content_blocks
|
|
280
|
+
3. Only emit separate rows for true branch points (multiple children with different paths)
|
|
281
|
+
"""
|
|
282
|
+
# Handle both formats: raw list or bundled {"conversations": [...]}
|
|
283
|
+
if isinstance(data, dict) and "conversations" in data:
|
|
284
|
+
conversations = data["conversations"]
|
|
285
|
+
elif isinstance(data, list):
|
|
286
|
+
conversations = data
|
|
287
|
+
else:
|
|
288
|
+
conversations = [data]
|
|
289
|
+
|
|
290
|
+
all_messages: List[NormalizedMessage] = []
|
|
291
|
+
|
|
292
|
+
for conv in conversations:
|
|
293
|
+
conv_messages = self._parse_conversation(cast(Dict[str, Any], conv))
|
|
294
|
+
all_messages.extend(conv_messages)
|
|
295
|
+
|
|
296
|
+
return all_messages
|
|
297
|
+
|
|
298
|
+
def _parse_conversation(self, conv: Dict) -> List[NormalizedMessage]:
|
|
299
|
+
"""Parse a single ChatGPT conversation with coalescing."""
|
|
300
|
+
conv_id = cast(str, conv.get("id"))
|
|
301
|
+
conv_title = conv.get("title", "Untitled")
|
|
302
|
+
mapping = conv.get("mapping", {}) or {}
|
|
303
|
+
|
|
304
|
+
# Conversation-level metadata
|
|
305
|
+
conv_metadata = {
|
|
306
|
+
"title": conv_title,
|
|
307
|
+
"current_node": conv.get("current_node"),
|
|
308
|
+
"moderation_results": conv.get("moderation_results"),
|
|
309
|
+
"plugin_ids": conv.get("plugin_ids"),
|
|
310
|
+
"gizmo_id": conv.get("gizmo_id"),
|
|
311
|
+
"conversation_template_id": conv.get("conversation_template_id"),
|
|
312
|
+
"safe_urls": conv.get("safe_urls"),
|
|
313
|
+
"is_archived": conv.get("is_archived"),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
# Build active path set
|
|
317
|
+
active_path_ids = self._build_active_path(conv.get("current_node"), mapping)
|
|
318
|
+
|
|
319
|
+
# First pass: Parse all raw messages
|
|
320
|
+
raw_messages = self._parse_all_messages(mapping, conv_id, conv_title, conv_metadata, active_path_ids)
|
|
321
|
+
|
|
322
|
+
# Build lookup structures
|
|
323
|
+
msg_by_id: Dict[str, Dict] = {m["provider_message_id"]: m for m in raw_messages}
|
|
324
|
+
children_by_parent: Dict[str, List[str]] = {}
|
|
325
|
+
for msg in raw_messages:
|
|
326
|
+
parent_id = msg.get("provider_parent_id")
|
|
327
|
+
if parent_id:
|
|
328
|
+
children_by_parent.setdefault(parent_id, []).append(msg["provider_message_id"])
|
|
329
|
+
|
|
330
|
+
# Second pass: Coalesce and build final messages
|
|
331
|
+
messages = self._coalesce_messages(
|
|
332
|
+
raw_messages,
|
|
333
|
+
msg_by_id,
|
|
334
|
+
children_by_parent,
|
|
335
|
+
active_path_ids,
|
|
336
|
+
conv_id,
|
|
337
|
+
conv_title,
|
|
338
|
+
conv_metadata,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return messages
|
|
342
|
+
|
|
343
|
+
def _build_active_path(self, current_node: Optional[str], mapping: Dict) -> Set[str]:
|
|
344
|
+
"""Walk from current_node to root to build active path set."""
|
|
345
|
+
active_path_ids: Set[str] = set()
|
|
346
|
+
node_id = current_node
|
|
347
|
+
hop_guard = 0
|
|
348
|
+
while node_id and node_id in mapping and hop_guard < 10_000:
|
|
349
|
+
active_path_ids.add(node_id)
|
|
350
|
+
node_id = mapping.get(node_id, {}).get("parent")
|
|
351
|
+
hop_guard += 1
|
|
352
|
+
return active_path_ids
|
|
353
|
+
|
|
354
|
+
def _parse_all_messages(
|
|
355
|
+
self,
|
|
356
|
+
mapping: Dict,
|
|
357
|
+
conv_id: str,
|
|
358
|
+
conv_title: str,
|
|
359
|
+
conv_metadata: Dict,
|
|
360
|
+
active_path_ids: Set[str],
|
|
361
|
+
) -> List[Dict]:
|
|
362
|
+
"""
|
|
363
|
+
First pass: Parse all messages from mapping into raw dict format.
|
|
364
|
+
This includes stubs, tool messages, thinking messages, etc.
|
|
365
|
+
|
|
366
|
+
Uses explicit field mappings where possible to ensure semantic correctness.
|
|
367
|
+
"""
|
|
368
|
+
messages = []
|
|
369
|
+
mapping_ids = set(mapping.keys())
|
|
370
|
+
referenced_parents: Set[str] = set()
|
|
371
|
+
parent_min_create: Dict[str, float] = {}
|
|
372
|
+
|
|
373
|
+
for msg_id, msg_data in mapping.items():
|
|
374
|
+
message = msg_data.get("message") or {}
|
|
375
|
+
is_stub = not bool(message)
|
|
376
|
+
|
|
377
|
+
author = message.get("author", {}) if not is_stub else {}
|
|
378
|
+
content = message.get("content", {}) if not is_stub else {}
|
|
379
|
+
|
|
380
|
+
# Normalize content type
|
|
381
|
+
content_type = None
|
|
382
|
+
if isinstance(content, dict):
|
|
383
|
+
content_type = content.get("content_type")
|
|
384
|
+
if not content_type:
|
|
385
|
+
meta_ct = message.get("metadata", {}).get("content_type")
|
|
386
|
+
if isinstance(meta_ct, str):
|
|
387
|
+
content_type = meta_ct
|
|
388
|
+
|
|
389
|
+
# Get timestamps using explicit mappings
|
|
390
|
+
# Note: We extract raw values here, mappings convert them
|
|
391
|
+
create_time = message.get("create_time") if not is_stub else None
|
|
392
|
+
update_time = message.get("update_time") if not is_stub else None
|
|
393
|
+
|
|
394
|
+
# Provider parent ID - explicit mapping from msg_data.parent
|
|
395
|
+
# Semantic: This is the conversation tree parent, NOT a "reply to" relationship
|
|
396
|
+
provider_parent_id = msg_data.get("parent") or message.get("metadata", {}).get("parent_id")
|
|
397
|
+
|
|
398
|
+
if provider_parent_id:
|
|
399
|
+
referenced_parents.add(provider_parent_id)
|
|
400
|
+
if provider_parent_id and isinstance(create_time, (int, float)):
|
|
401
|
+
prev = parent_min_create.get(provider_parent_id)
|
|
402
|
+
if prev is None or create_time < prev:
|
|
403
|
+
parent_min_create[provider_parent_id] = create_time
|
|
404
|
+
|
|
405
|
+
# Apply field mappings for timestamp conversion
|
|
406
|
+
# These mappings ensure correct semantic interpretation
|
|
407
|
+
mapped_fields = self.apply_field_mappings(message, self.MESSAGE_FIELD_MAPPINGS)
|
|
408
|
+
|
|
409
|
+
is_active_path = msg_id in active_path_ids
|
|
410
|
+
msg_metadata = message.get("metadata", {}) or {}
|
|
411
|
+
|
|
412
|
+
raw_msg = {
|
|
413
|
+
"provider_message_id": msg_id,
|
|
414
|
+
"provider_parent_id": provider_parent_id,
|
|
415
|
+
# Use mapped values (with semantic correctness)
|
|
416
|
+
"role": mapped_fields.get("role"),
|
|
417
|
+
"author_name": mapped_fields.get("author_name"),
|
|
418
|
+
"created_at": mapped_fields.get("created_at"),
|
|
419
|
+
"updated_at": mapped_fields.get("updated_at"),
|
|
420
|
+
"message_order": mapped_fields.get("message_order"),
|
|
421
|
+
"model": mapped_fields.get("model"),
|
|
422
|
+
"recipient": message.get("recipient"),
|
|
423
|
+
# Raw values for internal processing
|
|
424
|
+
"content": content,
|
|
425
|
+
"content_type": content_type,
|
|
426
|
+
"create_time": create_time, # Keep raw for hash computation
|
|
427
|
+
"update_time": update_time, # Keep raw for reference
|
|
428
|
+
"is_active_path": is_active_path,
|
|
429
|
+
"is_stub": is_stub,
|
|
430
|
+
"is_visually_hidden": msg_metadata.get("is_visually_hidden_from_conversation"),
|
|
431
|
+
"is_user_system_message": msg_metadata.get("is_user_system_message"),
|
|
432
|
+
"children": msg_data.get("children", []),
|
|
433
|
+
"msg_metadata": msg_metadata,
|
|
434
|
+
"author": author,
|
|
435
|
+
"raw_message": message,
|
|
436
|
+
"raw_msg_data": msg_data,
|
|
437
|
+
}
|
|
438
|
+
messages.append(raw_msg)
|
|
439
|
+
|
|
440
|
+
# Add stub nodes for missing parents
|
|
441
|
+
# These are synthetic records for orphaned parent references
|
|
442
|
+
missing_parents = referenced_parents - mapping_ids
|
|
443
|
+
for missing_id in missing_parents:
|
|
444
|
+
if not missing_id:
|
|
445
|
+
continue
|
|
446
|
+
stub_ct = parent_min_create.get(missing_id)
|
|
447
|
+
# Create a minimal message dict for field mapping
|
|
448
|
+
stub_message = {"create_time": stub_ct, "update_time": stub_ct}
|
|
449
|
+
stub_mapped = self.apply_field_mappings(stub_message, self.MESSAGE_FIELD_MAPPINGS)
|
|
450
|
+
|
|
451
|
+
messages.append({
|
|
452
|
+
"provider_message_id": missing_id,
|
|
453
|
+
"provider_parent_id": None,
|
|
454
|
+
"role": None,
|
|
455
|
+
"author_name": None,
|
|
456
|
+
"content": {},
|
|
457
|
+
"content_type": None,
|
|
458
|
+
"create_time": stub_ct,
|
|
459
|
+
"update_time": stub_ct,
|
|
460
|
+
"created_at": stub_mapped.get("created_at"),
|
|
461
|
+
"updated_at": stub_mapped.get("updated_at"),
|
|
462
|
+
"message_order": stub_mapped.get("message_order"),
|
|
463
|
+
"is_active_path": False,
|
|
464
|
+
"is_stub": True,
|
|
465
|
+
"is_visually_hidden": None,
|
|
466
|
+
"is_user_system_message": None,
|
|
467
|
+
"children": [],
|
|
468
|
+
"msg_metadata": {},
|
|
469
|
+
"author": {},
|
|
470
|
+
"raw_message": {},
|
|
471
|
+
"raw_msg_data": {},
|
|
472
|
+
"synthetic_stub": True,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
return messages
|
|
476
|
+
|
|
477
|
+
def _coalesce_messages(
|
|
478
|
+
self,
|
|
479
|
+
raw_messages: List[Dict],
|
|
480
|
+
msg_by_id: Dict[str, Dict],
|
|
481
|
+
children_by_parent: Dict[str, List[str]],
|
|
482
|
+
active_path_ids: Set[str],
|
|
483
|
+
conv_id: str,
|
|
484
|
+
conv_title: str,
|
|
485
|
+
conv_metadata: Dict,
|
|
486
|
+
) -> List[NormalizedMessage]:
|
|
487
|
+
"""
|
|
488
|
+
Second pass: Build normalized messages with coalesced content_blocks.
|
|
489
|
+
|
|
490
|
+
Strategy:
|
|
491
|
+
- Primary messages (user/assistant with displayable text) become rows
|
|
492
|
+
- Tool/thinking messages on the active path get coalesced into their parent
|
|
493
|
+
- Non-active-path branches remain as separate rows for history
|
|
494
|
+
"""
|
|
495
|
+
messages: List[NormalizedMessage] = []
|
|
496
|
+
coalesced_ids: Set[str] = set() # Track which messages were coalesced
|
|
497
|
+
|
|
498
|
+
# Sort by message_order for consistent processing
|
|
499
|
+
sorted_raw = sorted(raw_messages, key=lambda m: m.get("message_order") or 0)
|
|
500
|
+
|
|
501
|
+
for raw_msg in sorted_raw:
|
|
502
|
+
msg_id = raw_msg["provider_message_id"]
|
|
503
|
+
|
|
504
|
+
# Skip if already coalesced into another message
|
|
505
|
+
if msg_id in coalesced_ids:
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
# Determine if this is a primary message (should become a row)
|
|
509
|
+
is_primary = self._is_primary_message(raw_msg)
|
|
510
|
+
|
|
511
|
+
if is_primary:
|
|
512
|
+
# Collect children to coalesce (tool calls, thinking, results)
|
|
513
|
+
children_to_coalesce = self._collect_coalesce_candidates(
|
|
514
|
+
msg_id,
|
|
515
|
+
children_by_parent,
|
|
516
|
+
msg_by_id,
|
|
517
|
+
active_path_ids
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# Mark coalesced messages
|
|
521
|
+
for child_id in children_to_coalesce:
|
|
522
|
+
coalesced_ids.add(child_id)
|
|
523
|
+
|
|
524
|
+
# Build content blocks from primary + coalesced children
|
|
525
|
+
content_blocks = self._build_content_blocks(
|
|
526
|
+
raw_msg,
|
|
527
|
+
[msg_by_id[cid] for cid in children_to_coalesce if cid in msg_by_id],
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
# Create normalized message
|
|
531
|
+
normalized = self._create_normalized_message(
|
|
532
|
+
raw_msg,
|
|
533
|
+
content_blocks,
|
|
534
|
+
conv_id,
|
|
535
|
+
conv_title,
|
|
536
|
+
conv_metadata,
|
|
537
|
+
)
|
|
538
|
+
messages.append(normalized)
|
|
539
|
+
else:
|
|
540
|
+
# Non-primary message that wasn't coalesced - emit as separate row
|
|
541
|
+
# This includes stubs, non-active-path branches, etc.
|
|
542
|
+
content_blocks = self._build_content_blocks(raw_msg, [])
|
|
543
|
+
normalized = self._create_normalized_message(
|
|
544
|
+
raw_msg,
|
|
545
|
+
content_blocks,
|
|
546
|
+
conv_id,
|
|
547
|
+
conv_title,
|
|
548
|
+
conv_metadata,
|
|
549
|
+
)
|
|
550
|
+
messages.append(normalized)
|
|
551
|
+
|
|
552
|
+
return messages
|
|
553
|
+
|
|
554
|
+
def _is_primary_message(self, raw_msg: Dict) -> bool:
|
|
555
|
+
"""Determine if a message should be a primary row (vs coalesced).
|
|
556
|
+
|
|
557
|
+
Delegates to ``chatgpt_content.is_primary_message`` (stateless body).
|
|
558
|
+
"""
|
|
559
|
+
return is_primary_message(raw_msg)
|
|
560
|
+
|
|
561
|
+
def _collect_coalesce_candidates(
|
|
562
|
+
self,
|
|
563
|
+
parent_id: str,
|
|
564
|
+
children_by_parent: Dict[str, List[str]],
|
|
565
|
+
msg_by_id: Dict[str, Dict],
|
|
566
|
+
active_path_ids: Set[str],
|
|
567
|
+
) -> List[str]:
|
|
568
|
+
"""Collect child messages to coalesce into parent.
|
|
569
|
+
|
|
570
|
+
Delegates to ``chatgpt_content.collect_coalesce_candidates``.
|
|
571
|
+
"""
|
|
572
|
+
return collect_coalesce_candidates(
|
|
573
|
+
parent_id, children_by_parent, msg_by_id, active_path_ids
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def _build_content_blocks(
|
|
577
|
+
self,
|
|
578
|
+
primary_msg: Dict,
|
|
579
|
+
coalesced_msgs: List[Dict]
|
|
580
|
+
) -> List[ContentBlock]:
|
|
581
|
+
"""Build content_blocks from primary message and coalesced children.
|
|
582
|
+
|
|
583
|
+
Delegates to ``chatgpt_content.build_content_blocks``.
|
|
584
|
+
"""
|
|
585
|
+
return build_content_blocks(primary_msg, coalesced_msgs)
|
|
586
|
+
|
|
587
|
+
def _extract_content_blocks(
|
|
588
|
+
self,
|
|
589
|
+
raw_msg: Dict,
|
|
590
|
+
start_seq: int
|
|
591
|
+
) -> tuple[List[ContentBlock], int]:
|
|
592
|
+
"""Extract content blocks from a single raw message; returns (blocks, next_seq).
|
|
593
|
+
|
|
594
|
+
Delegates to ``chatgpt_content.extract_content_blocks``.
|
|
595
|
+
"""
|
|
596
|
+
return extract_content_blocks(raw_msg, start_seq)
|
|
597
|
+
|
|
598
|
+
def _extract_special_block(
|
|
599
|
+
self,
|
|
600
|
+
raw_msg: Dict,
|
|
601
|
+
content: Any,
|
|
602
|
+
content_type: str,
|
|
603
|
+
role: str,
|
|
604
|
+
msg_id: Optional[str],
|
|
605
|
+
seq: int,
|
|
606
|
+
) -> Optional[tuple[List[ContentBlock], int]]:
|
|
607
|
+
"""Handle the exclusive content cases that fully consume a message.
|
|
608
|
+
|
|
609
|
+
Delegates to ``chatgpt_content.extract_special_block``.
|
|
610
|
+
"""
|
|
611
|
+
return extract_special_block(
|
|
612
|
+
raw_msg, content, content_type, role, msg_id, seq
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def _append_metadata_tool_calls(
|
|
616
|
+
self,
|
|
617
|
+
blocks: List[ContentBlock],
|
|
618
|
+
msg_metadata: Dict,
|
|
619
|
+
msg_id: Optional[str],
|
|
620
|
+
seq: int,
|
|
621
|
+
) -> int:
|
|
622
|
+
"""Append tool_use blocks from metadata tool_calls (alternate export formats).
|
|
623
|
+
|
|
624
|
+
Delegates to ``chatgpt_content.append_metadata_tool_calls``.
|
|
625
|
+
"""
|
|
626
|
+
return append_metadata_tool_calls(blocks, msg_metadata, msg_id, seq)
|
|
627
|
+
|
|
628
|
+
def _append_text_content(
|
|
629
|
+
self, blocks: List[ContentBlock], content: Any, seq: int
|
|
630
|
+
) -> int:
|
|
631
|
+
"""Append a text block from regular content, if any.
|
|
632
|
+
|
|
633
|
+
Delegates to ``chatgpt_content.append_text_content``.
|
|
634
|
+
"""
|
|
635
|
+
return append_text_content(blocks, content, seq)
|
|
636
|
+
|
|
637
|
+
def _append_image_parts(
|
|
638
|
+
self, blocks: List[ContentBlock], content: Any, seq: int
|
|
639
|
+
) -> int:
|
|
640
|
+
"""Append image blocks from multimodal content parts.
|
|
641
|
+
|
|
642
|
+
Delegates to ``chatgpt_content.append_image_parts``.
|
|
643
|
+
"""
|
|
644
|
+
return append_image_parts(blocks, content, seq)
|
|
645
|
+
|
|
646
|
+
def _extract_text_from_content(self, content: Any) -> str:
|
|
647
|
+
"""Extract text content from ChatGPT content object.
|
|
648
|
+
|
|
649
|
+
Delegates to ``chatgpt_content.extract_text_from_content``.
|
|
650
|
+
"""
|
|
651
|
+
return extract_text_from_content(content)
|
|
652
|
+
|
|
653
|
+
def _create_normalized_message(
|
|
654
|
+
self,
|
|
655
|
+
raw_msg: Dict,
|
|
656
|
+
content_blocks: List[ContentBlock],
|
|
657
|
+
conv_id: str,
|
|
658
|
+
conv_title: str,
|
|
659
|
+
conv_metadata: Dict,
|
|
660
|
+
) -> NormalizedMessage:
|
|
661
|
+
"""Create a NormalizedMessage from raw message data."""
|
|
662
|
+
msg_id = raw_msg["provider_message_id"]
|
|
663
|
+
|
|
664
|
+
# Extract primary text from blocks
|
|
665
|
+
content_text = self.extract_text_from_blocks(content_blocks)
|
|
666
|
+
|
|
667
|
+
# Normalize role
|
|
668
|
+
role = self.normalize_role(raw_msg.get("role"))
|
|
669
|
+
|
|
670
|
+
# Build provider_data (exact original structure plus conversation context)
|
|
671
|
+
provider_data = {
|
|
672
|
+
"message": raw_msg.get("raw_message", {}),
|
|
673
|
+
"msg_data": raw_msg.get("raw_msg_data", {}),
|
|
674
|
+
"conversation_title": conv_title,
|
|
675
|
+
}
|
|
676
|
+
if raw_msg.get("model"):
|
|
677
|
+
provider_data["model"] = raw_msg["model"]
|
|
678
|
+
if raw_msg.get("synthetic_stub"):
|
|
679
|
+
provider_data["synthetic_stub"] = True
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
"source_provider": "chatgpt",
|
|
683
|
+
"provider_message_id": msg_id,
|
|
684
|
+
"provider_message_id_lower": msg_id.lower() if msg_id else None,
|
|
685
|
+
"provider_conversation_id": conv_id,
|
|
686
|
+
"content_hash": self.hash_message(
|
|
687
|
+
msg_id,
|
|
688
|
+
cast(str, raw_msg.get("role")),
|
|
689
|
+
raw_msg.get("content"),
|
|
690
|
+
raw_msg.get("create_time"),
|
|
691
|
+
),
|
|
692
|
+
"role": role,
|
|
693
|
+
"content_text": content_text,
|
|
694
|
+
"content_blocks": content_blocks,
|
|
695
|
+
"created_at": raw_msg.get("created_at"),
|
|
696
|
+
"updated_at": raw_msg.get("updated_at"),
|
|
697
|
+
"message_order": raw_msg.get("message_order"),
|
|
698
|
+
"provider_data": provider_data,
|
|
699
|
+
"provider_parent_id": raw_msg.get("provider_parent_id"),
|
|
700
|
+
"provider_parent_id_lower": raw_msg["provider_parent_id"].lower() if raw_msg.get("provider_parent_id") else None,
|
|
701
|
+
"is_active_path": raw_msg.get("is_active_path"),
|
|
702
|
+
"conversation_title": conv_title,
|
|
703
|
+
"conversation_metadata": conv_metadata,
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
def _timestamp_to_iso(self, ts: Optional[float]) -> Optional[str]:
|
|
707
|
+
"""Convert Unix timestamp to ISO format (aware UTC, never naive-local)."""
|
|
708
|
+
if ts is None:
|
|
709
|
+
return None
|
|
710
|
+
return parse_timestamp_iso(ts)
|
|
711
|
+
|
|
712
|
+
def _timestamp_to_order(self, ts: Optional[float]) -> Optional[int]:
|
|
713
|
+
"""Convert Unix timestamp to message order (microseconds)."""
|
|
714
|
+
if ts is None or not isinstance(ts, (int, float)):
|
|
715
|
+
return None
|
|
716
|
+
try:
|
|
717
|
+
return int(ts * 1_000_000)
|
|
718
|
+
except (ValueError, OverflowError):
|
|
719
|
+
return None
|
|
720
|
+
|
|
721
|
+
# parse_export_with_validation is inherited from ProviderParser (shared logic
|
|
722
|
+
# driving self._validators); no provider-specific override needed.
|