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.
Files changed (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,365 @@
1
+ """Pure content-block builders for the Cursor parser.
2
+
3
+ These are the stateless helper bodies extracted verbatim from
4
+ ``CursorParser`` — every Cursor content shape (pre-parsed ``content_blocks``,
5
+ the legacy ``thinking``/``tool_calls``/``tool_results`` fields, and the v2
6
+ export's ``code_blocks``/``tool_call``/``thinking``) plus the fallback and
7
+ tool-input normalization. ``CursorParser`` delegates to these so the class keeps
8
+ its method surface while the heavy bodies live here.
9
+
10
+ Block construction uses the ``ProviderParser`` static factory methods
11
+ (``create_text_block`` etc.), so the emitted dict/list shapes and the running
12
+ ``seq`` numbering are byte-identical to the original in-class form.
13
+ """
14
+
15
+ import json
16
+ from typing import Any, Dict, List, cast
17
+
18
+ from ..tool_names import FILE_TOOLS, PROVIDER_PATH_FIELDS, normalize_tool_name
19
+ from .base import ContentBlock, ProviderParser
20
+
21
+
22
+ def build_content_blocks(
23
+ msg: Dict[str, Any],
24
+ raw_blocks: List[Dict],
25
+ fallback_content: Any,
26
+ ) -> List[ContentBlock]:
27
+ """Build normalized content blocks from Cursor message data.
28
+
29
+ Each Cursor content shape (pre-parsed raw_blocks, legacy
30
+ thinking/tool_calls/tool_results, the v2 export's code_blocks/tool_call/
31
+ thinking) is appended in turn by a dedicated helper, all threading the
32
+ same running ``seq`` counter so block ordering and seq numbering are
33
+ unchanged from the single-function form.
34
+ """
35
+ blocks: List[ContentBlock] = []
36
+ seq = 0
37
+
38
+ seq = append_raw_blocks(blocks, raw_blocks, seq)
39
+ seq = append_legacy_thinking(blocks, msg, seq)
40
+ seq = append_legacy_tool_calls(blocks, msg, seq)
41
+ seq = append_legacy_tool_results(blocks, msg, seq)
42
+ seq = append_v2_code_blocks(blocks, msg, seq)
43
+ seq = append_v2_tool_call(blocks, msg, seq)
44
+ seq = append_v2_thinking(blocks, msg, seq)
45
+
46
+ # If no blocks yet, create from fallback content
47
+ if not blocks and fallback_content:
48
+ append_fallback(blocks, fallback_content, seq)
49
+
50
+ return blocks
51
+
52
+
53
+ def append_raw_blocks(
54
+ blocks: List[ContentBlock], raw_blocks: List[Dict], seq: int
55
+ ) -> int:
56
+ """Append blocks from Cursor's pre-parsed ``content_blocks`` array.
57
+
58
+ Each ``raw_block`` is dispatched by its ``type`` to a dedicated appender
59
+ that returns the number of blocks it added; ``seq`` advances by that
60
+ count, preserving the original ordering and seq numbering.
61
+ """
62
+ appenders = {
63
+ "text": append_raw_text,
64
+ "tool_use": append_raw_tool_use,
65
+ "tool_result": append_raw_tool_result,
66
+ "thinking": append_raw_thinking,
67
+ "code": append_raw_code,
68
+ }
69
+ for raw_block in raw_blocks or []:
70
+ block_type = raw_block.get("type", "text")
71
+ appender = appenders.get(block_type, append_raw_unknown)
72
+ seq += appender(blocks, raw_block, seq)
73
+ return seq
74
+
75
+
76
+ def append_raw_text(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
77
+ text = raw_block.get("text", "")
78
+ if text:
79
+ blocks.append(ProviderParser.create_text_block(text, seq))
80
+ return 1
81
+ return 0
82
+
83
+
84
+ def append_raw_tool_use(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
85
+ original_name = raw_block.get("name", "unknown")
86
+ canonical_name = normalize_tool_name(original_name)
87
+ input_data = raw_block.get("input", {})
88
+ block = ProviderParser.create_tool_use_block(canonical_name, input_data, seq)
89
+ if canonical_name != original_name:
90
+ block["provider_tool_name"] = original_name
91
+ blocks.append(block)
92
+ return 1
93
+
94
+
95
+ def append_raw_tool_result(
96
+ blocks: List[ContentBlock], raw_block: Dict, seq: int
97
+ ) -> int:
98
+ name = raw_block.get("name", "unknown")
99
+ content = raw_block.get("content")
100
+ is_error = raw_block.get("is_error", False)
101
+ blocks.append(ProviderParser.create_tool_result_block(name, content, seq, is_error))
102
+ return 1
103
+
104
+
105
+ def append_raw_thinking(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
106
+ text = raw_block.get("text", "")
107
+ if text:
108
+ blocks.append(ProviderParser.create_thinking_block(text, seq))
109
+ return 1
110
+ return 0
111
+
112
+
113
+ def append_raw_code(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
114
+ code = raw_block.get("code", "")
115
+ language = raw_block.get("language")
116
+ blocks.append({
117
+ "type": "code",
118
+ "code": code,
119
+ "language": language,
120
+ "seq": seq,
121
+ })
122
+ return 1
123
+
124
+
125
+ def append_raw_unknown(blocks: List[ContentBlock], raw_block: Dict, seq: int) -> int:
126
+ """Unknown type - try to extract text."""
127
+ text = raw_block.get("text") or raw_block.get("content", "")
128
+ if text and isinstance(text, str):
129
+ blocks.append(ProviderParser.create_text_block(text, seq))
130
+ return 1
131
+ return 0
132
+
133
+
134
+ def append_legacy_thinking(
135
+ blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
136
+ ) -> int:
137
+ """Append thinking blocks from the legacy ``thinking``/``reasoning`` fields."""
138
+ thinking = msg.get("thinking") or msg.get("reasoning")
139
+ if thinking:
140
+ if isinstance(thinking, str):
141
+ blocks.append(ProviderParser.create_thinking_block(thinking, seq))
142
+ seq += 1
143
+ elif isinstance(thinking, list):
144
+ for t in thinking:
145
+ if isinstance(t, str):
146
+ blocks.append(ProviderParser.create_thinking_block(t, seq))
147
+ seq += 1
148
+ elif isinstance(t, dict):
149
+ text = t.get("text", "")
150
+ if text:
151
+ blocks.append(ProviderParser.create_thinking_block(text, seq))
152
+ seq += 1
153
+ return seq
154
+
155
+
156
+ def append_legacy_tool_calls(
157
+ blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
158
+ ) -> int:
159
+ """Append tool_use blocks from the legacy ``tool_calls``/``toolCalls`` array."""
160
+ tool_calls = msg.get("tool_calls") or msg.get("toolCalls", [])
161
+ if tool_calls:
162
+ for tc in tool_calls:
163
+ if isinstance(tc, dict):
164
+ original_name = tc.get("name") or tc.get("function", {}).get("name", "unknown")
165
+ canonical_name = normalize_tool_name(original_name)
166
+ input_data = tc.get("arguments") or tc.get("input") or tc.get("function", {}).get("arguments", {})
167
+ # Parse JSON string arguments if needed
168
+ if isinstance(input_data, str):
169
+ try:
170
+ input_data = json.loads(input_data)
171
+ except (json.JSONDecodeError, TypeError):
172
+ pass
173
+ input_data = normalize_tool_input(original_name, canonical_name, input_data, msg)
174
+ block = ProviderParser.create_tool_use_block(canonical_name, input_data, seq)
175
+ if canonical_name != original_name:
176
+ block["provider_tool_name"] = original_name
177
+ blocks.append(block)
178
+ seq += 1
179
+ return seq
180
+
181
+
182
+ def append_legacy_tool_results(
183
+ blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
184
+ ) -> int:
185
+ """Append tool_result blocks from the legacy ``tool_results``/``toolResults`` array."""
186
+ tool_results = msg.get("tool_results") or msg.get("toolResults", [])
187
+ if tool_results:
188
+ for tr in tool_results:
189
+ if isinstance(tr, dict):
190
+ name = tr.get("name", "unknown")
191
+ content = tr.get("content") or tr.get("result")
192
+ is_error = tr.get("is_error", False) or tr.get("isError", False)
193
+ blocks.append(ProviderParser.create_tool_result_block(name, content, seq, is_error))
194
+ seq += 1
195
+ return seq
196
+
197
+
198
+ def append_v2_code_blocks(
199
+ blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
200
+ ) -> int:
201
+ """Append code blocks from the v2 export ``code_blocks`` array (cursor_export.py output)."""
202
+ code_blocks = msg.get("code_blocks", [])
203
+ if code_blocks:
204
+ for cb in code_blocks:
205
+ if isinstance(cb, dict):
206
+ # v2 format: {uri, version, codeBlockIdx, content, languageId}
207
+ code = cb.get("content", "")
208
+ language = cb.get("languageId") or cb.get("language")
209
+ uri = cb.get("uri", {})
210
+ file_path = uri.get("path") if isinstance(uri, dict) else None
211
+ blocks.append(cast(ContentBlock, {
212
+ "type": "code",
213
+ "code": code,
214
+ "language": language,
215
+ "file_path": file_path,
216
+ "seq": seq,
217
+ }))
218
+ seq += 1
219
+ return seq
220
+
221
+
222
+ def append_v2_tool_call(
223
+ blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
224
+ ) -> int:
225
+ """Append tool_use (+ optional tool_result) from the v2 ``tool_call`` object.
226
+
227
+ This contains the structured tool call data from toolFormerData.
228
+ """
229
+ tool_call = msg.get("tool_call")
230
+ if tool_call and isinstance(tool_call, dict):
231
+ original_name = tool_call.get("name", "unknown")
232
+ canonical_name = normalize_tool_name(original_name)
233
+ args = tool_call.get("args")
234
+ result = tool_call.get("result")
235
+ status = tool_call.get("status")
236
+
237
+ # Parse JSON string args if needed
238
+ if isinstance(args, str):
239
+ try:
240
+ args = json.loads(args)
241
+ except (json.JSONDecodeError, TypeError):
242
+ pass
243
+
244
+ args = normalize_tool_input(original_name, canonical_name, args, msg)
245
+
246
+ # Add tool use block
247
+ tool_block: dict = {
248
+ "type": "tool_use",
249
+ "name": canonical_name,
250
+ "input": args,
251
+ "tool_call_id": tool_call.get("call_id"),
252
+ "seq": seq,
253
+ }
254
+ if canonical_name != original_name:
255
+ tool_block["provider_tool_name"] = original_name
256
+ blocks.append(cast(ContentBlock, tool_block))
257
+ seq += 1
258
+
259
+ # Add tool result if present
260
+ if result:
261
+ result_content = result
262
+ if isinstance(result, str):
263
+ try:
264
+ result_content = json.loads(result)
265
+ except (json.JSONDecodeError, TypeError):
266
+ pass
267
+
268
+ blocks.append(cast(ContentBlock, {
269
+ "type": "tool_result",
270
+ "name": canonical_name,
271
+ "content": result_content,
272
+ "status": status,
273
+ "user_decision": tool_call.get("user_decision"),
274
+ "seq": seq,
275
+ }))
276
+ if canonical_name != original_name:
277
+ cast(Dict[str, Any], blocks[-1])["provider_tool_name"] = original_name
278
+ seq += 1
279
+ return seq
280
+
281
+
282
+ def append_v2_thinking(
283
+ blocks: List[ContentBlock], msg: Dict[str, Any], seq: int
284
+ ) -> int:
285
+ """Append the v2-export thinking block (only when ``thinking_duration_ms`` is present).
286
+
287
+ This is the actual thinking text from thinking.text. Only used when
288
+ thinking_duration_ms is present (the v2 indicator); otherwise the legacy
289
+ handler (append_legacy_thinking) already covered it. Note both fire for
290
+ a plain-string v2 thinking, by design preserved here.
291
+ """
292
+ thinking_text = msg.get("thinking")
293
+ thinking_duration = msg.get("thinking_duration_ms")
294
+ if thinking_text and isinstance(thinking_text, str) and thinking_duration is not None:
295
+ blocks.append(cast(ContentBlock, {
296
+ "type": "thinking",
297
+ "text": thinking_text,
298
+ "duration_ms": thinking_duration,
299
+ "seq": seq,
300
+ }))
301
+ seq += 1
302
+ return seq
303
+
304
+
305
+ def append_fallback(
306
+ blocks: List[ContentBlock], fallback_content: Any, seq: int
307
+ ) -> int:
308
+ """Append text blocks from fallback content when no other blocks were built."""
309
+ if isinstance(fallback_content, str):
310
+ blocks.append(ProviderParser.create_text_block(fallback_content, seq))
311
+ elif isinstance(fallback_content, list):
312
+ for item in fallback_content:
313
+ if isinstance(item, str):
314
+ blocks.append(ProviderParser.create_text_block(item, seq))
315
+ seq += 1
316
+ elif isinstance(item, dict):
317
+ text = item.get("text", "")
318
+ if text:
319
+ blocks.append(ProviderParser.create_text_block(text, seq))
320
+ seq += 1
321
+ return seq
322
+
323
+
324
+ def normalize_tool_input(
325
+ original_name: str,
326
+ canonical_name: str,
327
+ input_data: Any,
328
+ msg: Dict[str, Any],
329
+ ) -> Any:
330
+ """Normalize tool input to Thread's canonical format.
331
+
332
+ - Recovers file_path from code_blocks when input is null/missing
333
+ - Normalizes provider-specific path fields (e.g. input.path → input.file_path)
334
+ """
335
+ if canonical_name not in FILE_TOOLS:
336
+ return input_data
337
+
338
+ # Ensure input is a dict
339
+ if input_data is None:
340
+ input_data = {}
341
+ if not isinstance(input_data, dict):
342
+ return input_data
343
+
344
+ # Already has file_path — nothing to do
345
+ if input_data.get("file_path"):
346
+ return input_data
347
+
348
+ # Try provider-specific path field (e.g. read_file_v2 uses "path")
349
+ path_field = PROVIDER_PATH_FIELDS.get(original_name)
350
+ if path_field and input_data.get(path_field):
351
+ input_data["file_path"] = input_data[path_field]
352
+ return input_data
353
+
354
+ # Recover from code_blocks on the same message (Cursor v2 format)
355
+ # code_blocks contain the files the tool operated on
356
+ code_blocks = msg.get("code_blocks", [])
357
+ if code_blocks:
358
+ for cb in code_blocks:
359
+ if isinstance(cb, dict):
360
+ uri = cb.get("uri", {})
361
+ if isinstance(uri, dict) and uri.get("path"):
362
+ input_data["file_path"] = uri["path"]
363
+ return input_data
364
+
365
+ return input_data
@@ -0,0 +1,29 @@
1
+ """
2
+ Pipeline infrastructure for parser composition.
3
+
4
+ The pipeline pattern separates parsing into distinct phases:
5
+ 1. Parse: Raw provider data -> RawMessage (intermediate representation)
6
+ 2. Transform: RawMessage[] -> RawMessage[] (coalescing, merging, etc.)
7
+ 3. Normalize: RawMessage -> NormalizedMessage (canonical format)
8
+ 4. Validate: NormalizedMessage[] -> validation results
9
+
10
+ Each phase has a Protocol definition for pluggability.
11
+ """
12
+
13
+ from .base import ParserPipeline
14
+ from .interfaces import (
15
+ Normalizer,
16
+ Parser,
17
+ RawMessage,
18
+ Transformer,
19
+ Validator,
20
+ )
21
+
22
+ __all__ = [
23
+ "RawMessage",
24
+ "Parser",
25
+ "Transformer",
26
+ "Normalizer",
27
+ "Validator",
28
+ "ParserPipeline",
29
+ ]
@@ -0,0 +1,251 @@
1
+ """
2
+ ParserPipeline implementation.
3
+
4
+ Composes Parser -> Transformer[] -> Normalizer -> Validator[] into
5
+ a single processing pipeline.
6
+ """
7
+
8
+ from typing import Any, Dict, Generic, List, Optional, TypeVar
9
+
10
+ from ..base import ImportResult, NormalizedMessage
11
+ from ..config import ProviderConfig
12
+ from .interfaces import (
13
+ Normalizer,
14
+ Parser,
15
+ Transformer,
16
+ ValidationContext,
17
+ Validator,
18
+ )
19
+
20
+ T_Input = TypeVar("T_Input")
21
+
22
+
23
+ class ParserPipeline(Generic[T_Input]):
24
+ """Composes parsing phases into a complete pipeline.
25
+
26
+ The pipeline processes data through:
27
+ 1. Parse: Raw data -> RawMessage[]
28
+ 2. Transform: RawMessage[] -> RawMessage[] (optional, multiple)
29
+ 3. Normalize: RawMessage -> NormalizedMessage (for each message)
30
+ 4. Validate: NormalizedMessage[] -> validation results
31
+
32
+ Example usage:
33
+ pipeline = ParserPipeline(
34
+ parser=ChatGPTRawParser(),
35
+ transformers=[
36
+ ActivePathTransformer(),
37
+ ToolCoalescingTransformer(),
38
+ ],
39
+ normalizer=ChatGPTNormalizer(),
40
+ validators=[
41
+ ThinkingBlockValidator(CHATGPT_CONFIG),
42
+ ReferentialIntegrityValidator(),
43
+ ],
44
+ config=CHATGPT_CONFIG,
45
+ )
46
+ result = pipeline.process(data)
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ parser: Parser[T_Input],
52
+ normalizer: Normalizer,
53
+ config: ProviderConfig,
54
+ transformers: Optional[List[Transformer]] = None,
55
+ validators: Optional[List[Validator]] = None,
56
+ ):
57
+ """Initialize the pipeline.
58
+
59
+ Args:
60
+ parser: Parser for converting raw data to RawMessages
61
+ normalizer: Normalizer for converting to NormalizedMessages
62
+ config: Provider-specific configuration
63
+ transformers: Optional list of transformers (applied in order)
64
+ validators: Optional list of validators (all applied)
65
+ """
66
+ self.parser = parser
67
+ self.transformers = transformers or []
68
+ self.normalizer = normalizer
69
+ self.validators = validators or []
70
+ self.config = config
71
+
72
+ def process(self, data: T_Input) -> ImportResult:
73
+ """Process data through the complete pipeline.
74
+
75
+ Args:
76
+ data: Raw provider export data
77
+
78
+ Returns:
79
+ ImportResult with messages, errors, warnings, and coverage stats
80
+ """
81
+ # Phase 1: Parse
82
+ raw_messages = self.parser.parse(data)
83
+
84
+ # Phase 2: Transform
85
+ for transformer in self.transformers:
86
+ raw_messages = transformer.transform(raw_messages)
87
+
88
+ # Phase 3: Normalize
89
+ normalized_messages: List[NormalizedMessage] = []
90
+ for raw in raw_messages:
91
+ normalized = self.normalizer.normalize(raw)
92
+ normalized_messages.append(normalized)
93
+
94
+ # Phase 4: Validate
95
+ all_errors, all_warnings = self._validate_all(normalized_messages)
96
+
97
+ # Calculate field coverage
98
+ field_coverage = self._calculate_field_coverage(normalized_messages)
99
+
100
+ # Count unique conversations
101
+ conversations = set(
102
+ msg.get("provider_conversation_id", "unknown")
103
+ for msg in normalized_messages
104
+ )
105
+
106
+ return ImportResult(
107
+ messages=normalized_messages,
108
+ validation_errors=all_errors,
109
+ validation_warnings=all_warnings,
110
+ field_coverage=field_coverage,
111
+ conversations_processed=len(conversations),
112
+ messages_processed=len(normalized_messages),
113
+ )
114
+
115
+ def _validate_all(
116
+ self, messages: List[NormalizedMessage]
117
+ ) -> tuple[List[str], List[str]]:
118
+ """Run all validators on messages.
119
+
120
+ Groups messages by conversation and validates each group.
121
+
122
+ Returns:
123
+ Tuple of (errors, warnings)
124
+ """
125
+ # Group by conversation
126
+ conversations: Dict[str, List[NormalizedMessage]] = {}
127
+ for msg in messages:
128
+ conv_id = msg.get("provider_conversation_id", "unknown")
129
+ if conv_id not in conversations:
130
+ conversations[conv_id] = []
131
+ conversations[conv_id].append(msg)
132
+
133
+ all_errors: List[str] = []
134
+ all_warnings: List[str] = []
135
+
136
+ # Validate each conversation
137
+ for conv_id, conv_messages in conversations.items():
138
+ context = ValidationContext(
139
+ conversation_id=conv_id,
140
+ source_provider=self.config.provider_name,
141
+ )
142
+
143
+ # Sort by message_order for proper validation
144
+ sorted_msgs = sorted(
145
+ conv_messages, key=lambda m: m.get("message_order") or 0
146
+ )
147
+
148
+ # Run each validator
149
+ for validator in self.validators:
150
+ validator.validate(sorted_msgs, context)
151
+
152
+ all_errors.extend(context.errors)
153
+ all_warnings.extend(context.warnings)
154
+
155
+ return all_errors, all_warnings
156
+
157
+ def _calculate_field_coverage(
158
+ self, messages: List[NormalizedMessage]
159
+ ) -> Dict[str, float]:
160
+ """Calculate percentage of messages with each field populated."""
161
+ if not messages:
162
+ return {}
163
+
164
+ tracked_fields = [
165
+ "created_at",
166
+ "updated_at",
167
+ "content_text",
168
+ "content_blocks",
169
+ "provider_parent_id",
170
+ "message_order",
171
+ ]
172
+
173
+ coverage: Dict[str, int] = {f: 0 for f in tracked_fields}
174
+ total = len(messages)
175
+
176
+ for msg in messages:
177
+ for field_name in tracked_fields:
178
+ value = msg.get(field_name) # type: ignore
179
+ if value is not None and value != "" and value != []:
180
+ coverage[field_name] += 1
181
+
182
+ return {f: count / total for f, count in coverage.items()}
183
+
184
+
185
+ class BaseNormalizer:
186
+ """Base class for normalizers with common utilities.
187
+
188
+ Provides helper methods for creating content blocks, hashing,
189
+ and other common normalization tasks.
190
+ """
191
+
192
+ def __init__(self, config: ProviderConfig):
193
+ """Initialize with provider config.
194
+
195
+ Args:
196
+ config: Provider-specific configuration
197
+ """
198
+ self.config = config
199
+
200
+ def normalize_role(self, role: Optional[str]) -> str:
201
+ """Normalize role to standard values."""
202
+ if not role:
203
+ return "unknown"
204
+ role_lower = role.lower()
205
+ if role_lower in ("user", "human"):
206
+ return "user"
207
+ elif role_lower in ("assistant", "ai", "bot"):
208
+ return "assistant"
209
+ elif role_lower in ("system", "tool"):
210
+ return role_lower
211
+ return role_lower
212
+
213
+ def hash_message(
214
+ self, msg_id: str, role: Optional[str], content: Any, create_time: Any
215
+ ) -> str:
216
+ """Create deterministic hash for deduplication."""
217
+ import hashlib
218
+ import json
219
+ import math
220
+
221
+ # Normalize create_time
222
+ normalized_create_time = create_time
223
+ if isinstance(create_time, float):
224
+ if math.isfinite(create_time) and create_time.is_integer():
225
+ normalized_create_time = int(create_time)
226
+
227
+ hash_input = json.dumps(
228
+ {
229
+ "id": msg_id,
230
+ "role": role,
231
+ "content": content,
232
+ "create_time": normalized_create_time,
233
+ },
234
+ sort_keys=True,
235
+ )
236
+ return hashlib.sha256(hash_input.encode()).hexdigest()
237
+
238
+ def extract_text_from_blocks(self, blocks: List[Any]) -> str:
239
+ """Extract primary display text from content blocks."""
240
+ text_parts = []
241
+ for block in blocks:
242
+ block_type = block.get("type", "")
243
+ if block_type == "text":
244
+ text = block.get("text", "")
245
+ if text:
246
+ text_parts.append(text)
247
+ elif block_type == "system_context":
248
+ text = block.get("text", "")
249
+ if text:
250
+ text_parts.append(text)
251
+ return "\n\n".join(text_parts).strip()