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,698 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base class for provider-specific chat export parsers.
|
|
3
|
+
|
|
4
|
+
Defines the unified ContentBlock schema used across all providers:
|
|
5
|
+
- ChatGPT: Tool calls, thinking, and results are coalesced into parent message blocks
|
|
6
|
+
- Claude: Content blocks are normalized to the common schema
|
|
7
|
+
|
|
8
|
+
Also provides explicit field mapping infrastructure for semantic correctness:
|
|
9
|
+
- FieldMapping: Declares how provider fields map to our model
|
|
10
|
+
- ValidationContext: Tracks validation state across conversation import
|
|
11
|
+
- ImportResult: Rich result with messages, errors, warnings, and coverage stats
|
|
12
|
+
|
|
13
|
+
Validation is now handled by pluggable validators in validators/.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any, Callable, Dict, List, Literal, Optional, TypedDict, Union
|
|
23
|
+
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Content Block Schema Version
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# See apps/chat_import/migrations/content_blocks.py for migration logic
|
|
28
|
+
CURRENT_CONTENT_BLOCKS_VERSION = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# =============================================================================
|
|
32
|
+
# Content Block Types - Unified schema for all providers
|
|
33
|
+
# =============================================================================
|
|
34
|
+
|
|
35
|
+
class TextBlock(TypedDict, total=False):
|
|
36
|
+
"""Plain text content block."""
|
|
37
|
+
type: Literal["text"]
|
|
38
|
+
text: str
|
|
39
|
+
seq: int
|
|
40
|
+
# Optional timing (Claude)
|
|
41
|
+
start_timestamp: Optional[str]
|
|
42
|
+
stop_timestamp: Optional[str]
|
|
43
|
+
# Optional provider-specific ID (for annotation targeting)
|
|
44
|
+
provider_block_id: Optional[str]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ThinkingBlock(TypedDict, total=False):
|
|
48
|
+
"""Model thinking/reasoning block."""
|
|
49
|
+
type: Literal["thinking"]
|
|
50
|
+
text: str
|
|
51
|
+
seq: int
|
|
52
|
+
# ChatGPT content_type variants: thoughts, analysis, reasoning_recap
|
|
53
|
+
thinking_type: Optional[str]
|
|
54
|
+
provider_block_id: Optional[str]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ToolUseBlock(TypedDict, total=False):
|
|
58
|
+
"""Tool/function call block."""
|
|
59
|
+
type: Literal["tool_use"]
|
|
60
|
+
name: str # Canonical tool name (Edit, Write, Read, Bash, etc.)
|
|
61
|
+
provider_tool_name: Optional[str] # Original provider name (edit_file_v2, etc.)
|
|
62
|
+
tool_call_id: Optional[str] # Unique ID for this tool call (links to tool_result)
|
|
63
|
+
input: Any # Tool arguments
|
|
64
|
+
seq: int
|
|
65
|
+
# Optional display content (Claude)
|
|
66
|
+
message: Optional[str]
|
|
67
|
+
display_content: Optional[Any]
|
|
68
|
+
start_timestamp: Optional[str]
|
|
69
|
+
stop_timestamp: Optional[str]
|
|
70
|
+
# If this came from a separate ChatGPT message, store its ID
|
|
71
|
+
provider_message_id: Optional[str]
|
|
72
|
+
provider_block_id: Optional[str]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ToolResultBlock(TypedDict, total=False):
|
|
76
|
+
"""Tool/function result block."""
|
|
77
|
+
type: Literal["tool_result"]
|
|
78
|
+
tool_use_id: Optional[str] # Links to corresponding tool_use block's tool_call_id
|
|
79
|
+
name: str
|
|
80
|
+
content: Any # Result content
|
|
81
|
+
is_error: bool
|
|
82
|
+
seq: int
|
|
83
|
+
display_content: Optional[Any]
|
|
84
|
+
# If this came from a separate ChatGPT message, store its ID
|
|
85
|
+
provider_message_id: Optional[str]
|
|
86
|
+
provider_block_id: Optional[str]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ImageBlock(TypedDict, total=False):
|
|
90
|
+
"""Image content block."""
|
|
91
|
+
type: Literal["image"]
|
|
92
|
+
url: Optional[str]
|
|
93
|
+
asset_pointer: Optional[str] # ChatGPT asset reference
|
|
94
|
+
mime_type: Optional[str]
|
|
95
|
+
seq: int
|
|
96
|
+
provider_block_id: Optional[str]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class FileBlock(TypedDict, total=False):
|
|
100
|
+
"""File attachment block."""
|
|
101
|
+
type: Literal["file"]
|
|
102
|
+
name: str
|
|
103
|
+
url: Optional[str]
|
|
104
|
+
mime_type: Optional[str]
|
|
105
|
+
size: Optional[int]
|
|
106
|
+
seq: int
|
|
107
|
+
provider_block_id: Optional[str]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class CodeBlock(TypedDict, total=False):
|
|
111
|
+
"""Code execution block."""
|
|
112
|
+
type: Literal["code"]
|
|
113
|
+
language: Optional[str]
|
|
114
|
+
code: str
|
|
115
|
+
seq: int
|
|
116
|
+
provider_block_id: Optional[str]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SystemContextBlock(TypedDict, total=False):
|
|
120
|
+
"""System/user context block (custom instructions, etc.)."""
|
|
121
|
+
type: Literal["system_context"]
|
|
122
|
+
text: str
|
|
123
|
+
context_type: Optional[str] # user_editable_context, model_editable_context, etc.
|
|
124
|
+
seq: int
|
|
125
|
+
provider_block_id: Optional[str]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ParseErrorBlock(TypedDict, total=False):
|
|
129
|
+
"""Preserved malformed data with error context.
|
|
130
|
+
|
|
131
|
+
Instead of silently dropping data that fails to parse, we store it here.
|
|
132
|
+
This ensures nothing is lost and makes parse failures visible.
|
|
133
|
+
"""
|
|
134
|
+
type: Literal["parse_error"]
|
|
135
|
+
raw_text: str # The original text that failed to parse
|
|
136
|
+
error: str # The error message
|
|
137
|
+
line_number: Optional[int] # Line number in source file, if applicable
|
|
138
|
+
seq: int
|
|
139
|
+
provider_block_id: Optional[str]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class FileSnapshotBlock(TypedDict, total=False):
|
|
143
|
+
"""File state snapshot (Claude Code file-history-snapshot).
|
|
144
|
+
|
|
145
|
+
Captures the state of files at a point in the conversation.
|
|
146
|
+
Used by Claude Code to track file changes during a session.
|
|
147
|
+
"""
|
|
148
|
+
type: Literal["file_snapshot"]
|
|
149
|
+
files: List[Dict[str, Any]] # List of file states
|
|
150
|
+
timestamp: Optional[str]
|
|
151
|
+
seq: int
|
|
152
|
+
provider_block_id: Optional[str]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ContextSummaryBlock(TypedDict, total=False):
|
|
156
|
+
"""Context continuation summary (Claude Code summary type).
|
|
157
|
+
|
|
158
|
+
When a conversation runs out of context, Claude creates a summary
|
|
159
|
+
of prior messages. This block preserves that summary and tracks
|
|
160
|
+
which messages it replaced.
|
|
161
|
+
"""
|
|
162
|
+
type: Literal["context_summary"]
|
|
163
|
+
text: str # The summary text
|
|
164
|
+
summarizes: Optional[List[str]] # Message IDs that were summarized
|
|
165
|
+
seq: int
|
|
166
|
+
provider_block_id: Optional[str]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class SystemMetadataBlock(TypedDict, total=False):
|
|
170
|
+
"""Internal system metadata (token_budget, etc.).
|
|
171
|
+
|
|
172
|
+
Preserves provider-specific internal data that doesn't fit
|
|
173
|
+
other block types but shouldn't be dropped.
|
|
174
|
+
"""
|
|
175
|
+
type: Literal["system_metadata"]
|
|
176
|
+
metadata_type: str # e.g., "token_budget"
|
|
177
|
+
data: Dict[str, Any] # The actual metadata
|
|
178
|
+
seq: int
|
|
179
|
+
provider_block_id: Optional[str]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# Union of all block types
|
|
183
|
+
ContentBlock = Union[
|
|
184
|
+
TextBlock,
|
|
185
|
+
ThinkingBlock,
|
|
186
|
+
ToolUseBlock,
|
|
187
|
+
ToolResultBlock,
|
|
188
|
+
ImageBlock,
|
|
189
|
+
FileBlock,
|
|
190
|
+
CodeBlock,
|
|
191
|
+
SystemContextBlock,
|
|
192
|
+
ParseErrorBlock,
|
|
193
|
+
FileSnapshotBlock,
|
|
194
|
+
ContextSummaryBlock,
|
|
195
|
+
SystemMetadataBlock,
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# =============================================================================
|
|
200
|
+
# Field Mapping Infrastructure
|
|
201
|
+
# =============================================================================
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ValidationSeverity(str, Enum):
|
|
205
|
+
"""Severity level for validation issues."""
|
|
206
|
+
|
|
207
|
+
error = "error" # Fatal - import should abort
|
|
208
|
+
warning = "warning" # Non-fatal - import continues with warning
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class FieldMapping:
|
|
213
|
+
"""
|
|
214
|
+
Explicit mapping from provider field(s) to our model field.
|
|
215
|
+
|
|
216
|
+
This ensures we're intentional about semantic meaning, not just
|
|
217
|
+
matching field names. Each mapping documents what the field MEANS
|
|
218
|
+
in our model, not just where it comes from.
|
|
219
|
+
|
|
220
|
+
Examples:
|
|
221
|
+
# Simple field rename
|
|
222
|
+
FieldMapping(
|
|
223
|
+
model_field="created_at",
|
|
224
|
+
extractor=lambda msg: msg.get("create_time"),
|
|
225
|
+
semantic_doc="When the message was originally sent (immutable)"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
# Complex extraction from nested structure
|
|
229
|
+
FieldMapping(
|
|
230
|
+
model_field="content_text",
|
|
231
|
+
extractor=extract_content_text, # dedicated function
|
|
232
|
+
semantic_doc="Primary display text for the message"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Computed from multiple fields
|
|
236
|
+
FieldMapping(
|
|
237
|
+
model_field="message_order",
|
|
238
|
+
extractor=lambda msg: int(msg.get("timestamp", 0) * 1_000_000),
|
|
239
|
+
semantic_doc="Microsecond-precision ordering within conversation"
|
|
240
|
+
)
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
model_field: str
|
|
244
|
+
extractor: Callable[[Dict[str, Any]], Any]
|
|
245
|
+
required: bool = True
|
|
246
|
+
default: Any = None
|
|
247
|
+
semantic_doc: str = ""
|
|
248
|
+
|
|
249
|
+
def extract(self, raw_data: Dict[str, Any]) -> Any:
|
|
250
|
+
"""Extract the field value from raw provider data."""
|
|
251
|
+
try:
|
|
252
|
+
value = self.extractor(raw_data)
|
|
253
|
+
if value is None and self.default is not None:
|
|
254
|
+
return self.default
|
|
255
|
+
return value
|
|
256
|
+
except Exception:
|
|
257
|
+
if self.required:
|
|
258
|
+
raise
|
|
259
|
+
return self.default
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass
|
|
263
|
+
class SemanticCheck:
|
|
264
|
+
"""
|
|
265
|
+
Defines a semantic validation rule that spans messages.
|
|
266
|
+
|
|
267
|
+
Unlike FieldMapping which extracts values, SemanticCheck validates
|
|
268
|
+
higher-level requirements like "first user message must have context".
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
name: str
|
|
272
|
+
description: str
|
|
273
|
+
applies_to: str # "first_user_message", "assistant_messages", "all_messages", etc.
|
|
274
|
+
severity: ValidationSeverity
|
|
275
|
+
check_fn: Optional[Callable[["NormalizedMessage", "ValidationContext"], bool]] = None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@dataclass
|
|
279
|
+
class ValidationContext:
|
|
280
|
+
"""
|
|
281
|
+
Tracks validation state across a conversation import.
|
|
282
|
+
|
|
283
|
+
This context is passed through as messages are processed, allowing
|
|
284
|
+
validation rules to reason about the conversation as a whole.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
conversation_id: str
|
|
288
|
+
source_provider: str
|
|
289
|
+
first_user_msg_seen: bool = False
|
|
290
|
+
first_user_msg_has_context: bool = False
|
|
291
|
+
message_count: int = 0
|
|
292
|
+
assistant_msg_count: int = 0
|
|
293
|
+
assistant_msgs_with_thinking: int = 0
|
|
294
|
+
assistant_msgs_with_tools: int = 0
|
|
295
|
+
# Model-specific tracking for fine-grained validation
|
|
296
|
+
thinking_required_msgs: int = 0 # Messages where thinking is required
|
|
297
|
+
thinking_required_msgs_with_thinking: int = 0
|
|
298
|
+
warnings: List[str] = field(default_factory=list)
|
|
299
|
+
errors: List[str] = field(default_factory=list)
|
|
300
|
+
|
|
301
|
+
def add_error(self, msg: str) -> None:
|
|
302
|
+
"""Add a fatal validation error."""
|
|
303
|
+
self.errors.append(msg)
|
|
304
|
+
|
|
305
|
+
def add_warning(self, msg: str) -> None:
|
|
306
|
+
"""Add a non-fatal validation warning."""
|
|
307
|
+
self.warnings.append(msg)
|
|
308
|
+
|
|
309
|
+
def add_issue(self, msg: str, severity: ValidationSeverity) -> None:
|
|
310
|
+
"""Add an issue with specified severity."""
|
|
311
|
+
if severity == ValidationSeverity.error:
|
|
312
|
+
self.add_error(msg)
|
|
313
|
+
else:
|
|
314
|
+
self.add_warning(msg)
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def has_errors(self) -> bool:
|
|
318
|
+
"""Check if any fatal errors occurred."""
|
|
319
|
+
return len(self.errors) > 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass
|
|
323
|
+
class ImportResult:
|
|
324
|
+
"""
|
|
325
|
+
Rich result from parsing a provider export.
|
|
326
|
+
|
|
327
|
+
Contains the parsed messages plus validation diagnostics and coverage stats.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
messages: List["NormalizedMessage"]
|
|
331
|
+
validation_errors: List[str] = field(default_factory=list)
|
|
332
|
+
validation_warnings: List[str] = field(default_factory=list)
|
|
333
|
+
field_coverage: Dict[str, float] = field(default_factory=dict)
|
|
334
|
+
conversations_processed: int = 0
|
|
335
|
+
messages_processed: int = 0
|
|
336
|
+
|
|
337
|
+
@property
|
|
338
|
+
def success(self) -> bool:
|
|
339
|
+
"""Check if import succeeded (no fatal errors)."""
|
|
340
|
+
return len(self.validation_errors) == 0
|
|
341
|
+
|
|
342
|
+
def merge(self, other: "ImportResult") -> "ImportResult":
|
|
343
|
+
"""Merge another ImportResult into this one."""
|
|
344
|
+
self.messages.extend(other.messages)
|
|
345
|
+
self.validation_errors.extend(other.validation_errors)
|
|
346
|
+
self.validation_warnings.extend(other.validation_warnings)
|
|
347
|
+
self.conversations_processed += other.conversations_processed
|
|
348
|
+
self.messages_processed += other.messages_processed
|
|
349
|
+
# Merge field coverage (average)
|
|
350
|
+
for field_name, coverage in other.field_coverage.items():
|
|
351
|
+
if field_name in self.field_coverage:
|
|
352
|
+
# Weighted average based on message count
|
|
353
|
+
total = self.messages_processed + other.messages_processed
|
|
354
|
+
if total > 0:
|
|
355
|
+
self.field_coverage[field_name] = (
|
|
356
|
+
self.field_coverage[field_name] * self.messages_processed
|
|
357
|
+
+ coverage * other.messages_processed
|
|
358
|
+
) / total
|
|
359
|
+
else:
|
|
360
|
+
self.field_coverage[field_name] = coverage
|
|
361
|
+
return self
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# =============================================================================
|
|
365
|
+
# Normalized Message Schema
|
|
366
|
+
# =============================================================================
|
|
367
|
+
|
|
368
|
+
class NormalizedMessage(TypedDict, total=False):
|
|
369
|
+
"""
|
|
370
|
+
Unified message format output by all parsers.
|
|
371
|
+
|
|
372
|
+
This is the canonical format stored in the database.
|
|
373
|
+
"""
|
|
374
|
+
# Core identifiers
|
|
375
|
+
source_provider: str
|
|
376
|
+
provider_message_id: str
|
|
377
|
+
provider_conversation_id: str
|
|
378
|
+
content_hash: str
|
|
379
|
+
|
|
380
|
+
# Normalized fields
|
|
381
|
+
role: str # 'user', 'assistant', 'system'
|
|
382
|
+
content_text: str # Primary display text (extracted from blocks)
|
|
383
|
+
content_blocks: List[ContentBlock] # Structured content
|
|
384
|
+
is_visually_hidden: bool # Display hint — system/snapshot/meta rows hidden by default
|
|
385
|
+
|
|
386
|
+
# Timestamps
|
|
387
|
+
created_at: Optional[str] # ISO format
|
|
388
|
+
updated_at: Optional[str] # ISO format
|
|
389
|
+
message_order: Optional[int] # Ordering within conversation
|
|
390
|
+
|
|
391
|
+
# Provider data preservation (exact original structure)
|
|
392
|
+
provider_data: Dict[str, Any]
|
|
393
|
+
|
|
394
|
+
# ChatGPT-specific (for branching)
|
|
395
|
+
provider_parent_id: Optional[str]
|
|
396
|
+
provider_parent_id_lower: Optional[str]
|
|
397
|
+
provider_message_id_lower: Optional[str]
|
|
398
|
+
is_active_path: Optional[bool]
|
|
399
|
+
|
|
400
|
+
# Conversation metadata
|
|
401
|
+
conversation_title: Optional[str]
|
|
402
|
+
conversation_metadata: Optional[Dict[str, Any]]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class ProviderParser(ABC):
|
|
406
|
+
"""
|
|
407
|
+
Base class for provider-specific parsers.
|
|
408
|
+
|
|
409
|
+
Subclasses should define:
|
|
410
|
+
- PROVIDER_NAME: str - Identifier for this provider
|
|
411
|
+
- FIELD_MAPPINGS: List[FieldMapping] - Explicit field mappings (optional, for new-style parsers)
|
|
412
|
+
- SEMANTIC_CHECKS: List[SemanticCheck] - Semantic validation rules (optional)
|
|
413
|
+
|
|
414
|
+
And implement:
|
|
415
|
+
- parse_export() - Parse provider data into normalized messages
|
|
416
|
+
- parse_export_with_validation() - Parse with full validation (optional override)
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
# Override in subclasses
|
|
420
|
+
PROVIDER_NAME: str = "unknown"
|
|
421
|
+
FIELD_MAPPINGS: List[FieldMapping] = []
|
|
422
|
+
SEMANTIC_CHECKS: List[SemanticCheck] = []
|
|
423
|
+
|
|
424
|
+
def __init__(self, strict: bool = False):
|
|
425
|
+
"""
|
|
426
|
+
Initialize the parser.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
strict: If True, validation warnings become errors
|
|
430
|
+
"""
|
|
431
|
+
self.strict = strict
|
|
432
|
+
# Concrete subclasses populate this with provider-specific validators;
|
|
433
|
+
# parse_export_with_validation drives them. Local import avoids the
|
|
434
|
+
# parsers.base <-> parsers.validators load-time cycle.
|
|
435
|
+
from .validators import BaseValidator
|
|
436
|
+
|
|
437
|
+
self._validators: List["BaseValidator"] = []
|
|
438
|
+
|
|
439
|
+
@abstractmethod
|
|
440
|
+
def parse_export(self, data: Any) -> List[NormalizedMessage]:
|
|
441
|
+
"""
|
|
442
|
+
Parse provider export data into normalized message format.
|
|
443
|
+
|
|
444
|
+
Returns list of NormalizedMessage dicts with:
|
|
445
|
+
- source_provider: str - Provider identifier (chatgpt, claude, cursor)
|
|
446
|
+
- provider_message_id: str - Original message ID from provider
|
|
447
|
+
- provider_conversation_id: str - Original conversation ID
|
|
448
|
+
- content_hash: str - Deterministic hash for deduplication
|
|
449
|
+
- role: str - Normalized role (user, assistant, system)
|
|
450
|
+
- content_text: str - Primary display text
|
|
451
|
+
- content_blocks: List[ContentBlock] - Structured content blocks
|
|
452
|
+
- provider_data: dict - Exact original data for reconstruction
|
|
453
|
+
- created_at/updated_at: str (ISO format)
|
|
454
|
+
- message_order: int - Ordering within conversation
|
|
455
|
+
"""
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
def parse_export_with_validation(self, data: Any) -> ImportResult:
|
|
459
|
+
"""Parse with validation using pluggable validators from validators/.
|
|
460
|
+
|
|
461
|
+
Subclasses initialize ``self._validators`` in ``__init__``; this shared
|
|
462
|
+
implementation drives them. Override only for genuinely provider-specific
|
|
463
|
+
validation flow.
|
|
464
|
+
"""
|
|
465
|
+
# Local import avoids the parsers.base <-> parsers.validators import cycle
|
|
466
|
+
# (validators import NormalizedMessage from this module at module load).
|
|
467
|
+
from .validators import ValidationContext
|
|
468
|
+
|
|
469
|
+
messages = self.parse_export(data)
|
|
470
|
+
|
|
471
|
+
# Group messages by conversation
|
|
472
|
+
conversations: Dict[str, List[NormalizedMessage]] = {}
|
|
473
|
+
for msg in messages:
|
|
474
|
+
conv_id = msg.get("provider_conversation_id", "unknown")
|
|
475
|
+
if conv_id not in conversations:
|
|
476
|
+
conversations[conv_id] = []
|
|
477
|
+
conversations[conv_id].append(msg)
|
|
478
|
+
|
|
479
|
+
# Validate each conversation using new validators
|
|
480
|
+
all_errors: List[str] = []
|
|
481
|
+
all_warnings: List[str] = []
|
|
482
|
+
|
|
483
|
+
for conv_id, conv_messages in conversations.items():
|
|
484
|
+
context = ValidationContext(
|
|
485
|
+
conversation_id=conv_id,
|
|
486
|
+
source_provider=self.PROVIDER_NAME,
|
|
487
|
+
strict=self.strict,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# Sort by message_order
|
|
491
|
+
sorted_msgs = sorted(
|
|
492
|
+
conv_messages, key=lambda m: m.get("message_order") or 0
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Run each validator
|
|
496
|
+
for validator in self._validators:
|
|
497
|
+
validator.validate(sorted_msgs, context)
|
|
498
|
+
|
|
499
|
+
all_errors.extend(context.errors)
|
|
500
|
+
all_warnings.extend(context.warnings)
|
|
501
|
+
|
|
502
|
+
# Calculate field coverage
|
|
503
|
+
field_coverage = self._calculate_field_coverage(messages)
|
|
504
|
+
|
|
505
|
+
return ImportResult(
|
|
506
|
+
messages=messages,
|
|
507
|
+
validation_errors=all_errors,
|
|
508
|
+
validation_warnings=all_warnings,
|
|
509
|
+
field_coverage=field_coverage,
|
|
510
|
+
conversations_processed=len(conversations),
|
|
511
|
+
messages_processed=len(messages),
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
def _calculate_field_coverage(
|
|
515
|
+
self, messages: List[NormalizedMessage]
|
|
516
|
+
) -> Dict[str, float]:
|
|
517
|
+
"""Calculate the percentage of messages with each field populated."""
|
|
518
|
+
if not messages:
|
|
519
|
+
return {}
|
|
520
|
+
|
|
521
|
+
# Fields to track
|
|
522
|
+
tracked_fields = [
|
|
523
|
+
"created_at",
|
|
524
|
+
"updated_at",
|
|
525
|
+
"content_text",
|
|
526
|
+
"content_blocks",
|
|
527
|
+
"provider_parent_id",
|
|
528
|
+
"message_order",
|
|
529
|
+
]
|
|
530
|
+
|
|
531
|
+
coverage: Dict[str, int] = {f: 0 for f in tracked_fields}
|
|
532
|
+
total = len(messages)
|
|
533
|
+
|
|
534
|
+
for msg in messages:
|
|
535
|
+
for field_name in tracked_fields:
|
|
536
|
+
value = msg.get(field_name) # type: ignore
|
|
537
|
+
if value is not None and value != "" and value != []:
|
|
538
|
+
coverage[field_name] += 1
|
|
539
|
+
|
|
540
|
+
return {f: count / total for f, count in coverage.items()}
|
|
541
|
+
|
|
542
|
+
def apply_field_mappings(
|
|
543
|
+
self, raw_data: Dict[str, Any], mappings: Optional[List[FieldMapping]] = None
|
|
544
|
+
) -> Dict[str, Any]:
|
|
545
|
+
"""
|
|
546
|
+
Apply field mappings to extract values from raw provider data.
|
|
547
|
+
|
|
548
|
+
Args:
|
|
549
|
+
raw_data: Raw provider data dictionary
|
|
550
|
+
mappings: List of FieldMapping to apply (uses self.FIELD_MAPPINGS if None)
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
Dictionary with model field names as keys and extracted values
|
|
554
|
+
"""
|
|
555
|
+
if mappings is None:
|
|
556
|
+
mappings = self.FIELD_MAPPINGS
|
|
557
|
+
|
|
558
|
+
result: Dict[str, Any] = {}
|
|
559
|
+
for mapping in mappings:
|
|
560
|
+
try:
|
|
561
|
+
value = mapping.extract(raw_data)
|
|
562
|
+
result[mapping.model_field] = value
|
|
563
|
+
except Exception as e:
|
|
564
|
+
if mapping.required:
|
|
565
|
+
raise ValueError(
|
|
566
|
+
f"Failed to extract required field '{mapping.model_field}': {e}"
|
|
567
|
+
)
|
|
568
|
+
result[mapping.model_field] = mapping.default
|
|
569
|
+
|
|
570
|
+
return result
|
|
571
|
+
|
|
572
|
+
@staticmethod
|
|
573
|
+
def hash_message(msg_id: str, role: str, content: Any, create_time: Any) -> str:
|
|
574
|
+
"""Create deterministic hash for deduplication."""
|
|
575
|
+
# Normalize create_time so equivalent numeric values hash the same.
|
|
576
|
+
# Chat export files sometimes represent timestamps as ints or floats
|
|
577
|
+
# (e.g. 1700000000 vs 1700000000.0).
|
|
578
|
+
normalized_create_time = create_time
|
|
579
|
+
if isinstance(create_time, float):
|
|
580
|
+
if math.isfinite(create_time) and create_time.is_integer():
|
|
581
|
+
normalized_create_time = int(create_time)
|
|
582
|
+
hash_input = json.dumps(
|
|
583
|
+
{
|
|
584
|
+
"id": msg_id,
|
|
585
|
+
"role": role,
|
|
586
|
+
"content": content,
|
|
587
|
+
"create_time": normalized_create_time,
|
|
588
|
+
},
|
|
589
|
+
sort_keys=True,
|
|
590
|
+
)
|
|
591
|
+
return hashlib.sha256(hash_input.encode()).hexdigest()
|
|
592
|
+
|
|
593
|
+
@staticmethod
|
|
594
|
+
def extract_text_from_blocks(blocks: List[ContentBlock]) -> str:
|
|
595
|
+
"""Extract primary display text from content blocks."""
|
|
596
|
+
text_parts = []
|
|
597
|
+
for block in blocks:
|
|
598
|
+
block_type = block.get("type", "")
|
|
599
|
+
if block_type == "text":
|
|
600
|
+
text = block.get("text", "")
|
|
601
|
+
if text:
|
|
602
|
+
text_parts.append(text)
|
|
603
|
+
elif block_type == "system_context":
|
|
604
|
+
# Include system context in display text
|
|
605
|
+
text = block.get("text", "")
|
|
606
|
+
if text:
|
|
607
|
+
text_parts.append(text)
|
|
608
|
+
return "\n\n".join(text_parts).strip()
|
|
609
|
+
|
|
610
|
+
@staticmethod
|
|
611
|
+
def normalize_role(role: Optional[str], sender: Optional[str] = None) -> str:
|
|
612
|
+
"""Normalize role/sender to standard values."""
|
|
613
|
+
if role:
|
|
614
|
+
role_lower = role.lower()
|
|
615
|
+
if role_lower in ("user", "human"):
|
|
616
|
+
return "user"
|
|
617
|
+
elif role_lower == "assistant":
|
|
618
|
+
return "assistant"
|
|
619
|
+
elif role_lower in ("system", "tool"):
|
|
620
|
+
return role_lower
|
|
621
|
+
if sender:
|
|
622
|
+
sender_lower = sender.lower()
|
|
623
|
+
if sender_lower == "human":
|
|
624
|
+
return "user"
|
|
625
|
+
elif sender_lower == "assistant":
|
|
626
|
+
return "assistant"
|
|
627
|
+
return role or "unknown"
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def create_text_block(text: str, seq: int, **kwargs) -> TextBlock:
|
|
631
|
+
"""Create a text content block."""
|
|
632
|
+
block: TextBlock = {"type": "text", "text": text, "seq": seq}
|
|
633
|
+
for key, value in kwargs.items():
|
|
634
|
+
if value is not None:
|
|
635
|
+
block[key] = value # type: ignore
|
|
636
|
+
return block
|
|
637
|
+
|
|
638
|
+
@staticmethod
|
|
639
|
+
def create_tool_use_block(
|
|
640
|
+
name: str,
|
|
641
|
+
input_data: Any,
|
|
642
|
+
seq: int,
|
|
643
|
+
provider_message_id: Optional[str] = None,
|
|
644
|
+
**kwargs
|
|
645
|
+
) -> ToolUseBlock:
|
|
646
|
+
"""Create a tool use content block."""
|
|
647
|
+
block: ToolUseBlock = {
|
|
648
|
+
"type": "tool_use",
|
|
649
|
+
"name": name,
|
|
650
|
+
"input": input_data,
|
|
651
|
+
"seq": seq,
|
|
652
|
+
}
|
|
653
|
+
if provider_message_id:
|
|
654
|
+
block["provider_message_id"] = provider_message_id
|
|
655
|
+
for key, value in kwargs.items():
|
|
656
|
+
if value is not None:
|
|
657
|
+
block[key] = value # type: ignore
|
|
658
|
+
return block
|
|
659
|
+
|
|
660
|
+
@staticmethod
|
|
661
|
+
def create_tool_result_block(
|
|
662
|
+
name: str,
|
|
663
|
+
content: Any,
|
|
664
|
+
seq: int,
|
|
665
|
+
is_error: bool = False,
|
|
666
|
+
provider_message_id: Optional[str] = None,
|
|
667
|
+
**kwargs
|
|
668
|
+
) -> ToolResultBlock:
|
|
669
|
+
"""Create a tool result content block."""
|
|
670
|
+
block: ToolResultBlock = {
|
|
671
|
+
"type": "tool_result",
|
|
672
|
+
"name": name,
|
|
673
|
+
"content": content,
|
|
674
|
+
"is_error": is_error,
|
|
675
|
+
"seq": seq,
|
|
676
|
+
}
|
|
677
|
+
if provider_message_id:
|
|
678
|
+
block["provider_message_id"] = provider_message_id
|
|
679
|
+
for key, value in kwargs.items():
|
|
680
|
+
if value is not None:
|
|
681
|
+
block[key] = value # type: ignore
|
|
682
|
+
return block
|
|
683
|
+
|
|
684
|
+
@staticmethod
|
|
685
|
+
def create_thinking_block(
|
|
686
|
+
text: str,
|
|
687
|
+
seq: int,
|
|
688
|
+
thinking_type: Optional[str] = None,
|
|
689
|
+
**kwargs
|
|
690
|
+
) -> ThinkingBlock:
|
|
691
|
+
"""Create a thinking/reasoning content block."""
|
|
692
|
+
block: ThinkingBlock = {"type": "thinking", "text": text, "seq": seq}
|
|
693
|
+
if thinking_type:
|
|
694
|
+
block["thinking_type"] = thinking_type
|
|
695
|
+
for key, value in kwargs.items():
|
|
696
|
+
if value is not None:
|
|
697
|
+
block[key] = value # type: ignore
|
|
698
|
+
return block
|