deeptutor-research 1.7.0__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.
@@ -0,0 +1,92 @@
1
+ """Deep Research capability — agentic-engine-based deep research.
2
+
3
+ Thin shim that delegates to :class:`ResearchPipeline`. All orchestration
4
+ — rephrase (mini agentic loop with ``ask_user``), decompose, per-block
5
+ research loops with ``THINK`` / ``TOOL`` / ``APPEND`` / ``FINISH``,
6
+ queue scheduler, and iterative reporting — lives in the pipeline
7
+ module. The capability only handles:
8
+
9
+ * request-config validation,
10
+ * the outline-preview two-stage flow (first call returns sub-topics
11
+ the user edits / confirms; second call drives Phase 3+4 with the
12
+ confirmed outline).
13
+
14
+ Tool composition is delegated to the shared
15
+ :mod:`deeptutor.agents._shared.tool_composition` policy — same as chat,
16
+ so the user's composer toggles + the attached KB drive what the per-block
17
+ research loop actually has access to. There is no separate "sources"
18
+ knob.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import Any
24
+
25
+ from deeptutor.agents.research.pipeline import ResearchPipeline, SubTopicItem
26
+ from deeptutor.agents.research.request_config import (
27
+ build_research_runtime_config,
28
+ validate_research_request_config,
29
+ )
30
+ from deeptutor.core.capability_protocol import TurnCapability
31
+ from deeptutor.core.context import UnifiedContext
32
+ from deeptutor.runtime.bootstrap.builtin_capabilities import builtin_capability_manifest
33
+ from deeptutor.runtime.stream_bus import StreamBus
34
+ from deeptutor.services.config import load_config_with_main
35
+
36
+
37
+ class DeepResearchCapability(TurnCapability):
38
+ manifest = builtin_capability_manifest("deep_research")
39
+
40
+ async def run(self, context: UnifiedContext, stream: StreamBus) -> None:
41
+ kb_name = context.knowledge_bases[0] if context.knowledge_bases else None
42
+ request_config = validate_research_request_config(context.config_overrides)
43
+
44
+ enabled_tools = list(context.enabled_tools or [])
45
+ runtime_config = build_research_runtime_config(
46
+ base_config=load_config_with_main("main.yaml"),
47
+ request_config=request_config,
48
+ kb_name=kb_name,
49
+ )
50
+
51
+ # Outline-preview two-stage flow: first call lacks a confirmed
52
+ # outline → pipeline returns ``outline_preview`` and exits; the
53
+ # frontend surfaces the outline editor and (after the user
54
+ # confirms) sends a second call with ``confirmed_outline`` set.
55
+ confirmed_outline_items: list[SubTopicItem] | None = None
56
+ if request_config.confirmed_outline is not None:
57
+ confirmed_outline_items = [
58
+ SubTopicItem(title=item.title, overview=item.overview or "")
59
+ for item in request_config.confirmed_outline
60
+ ]
61
+
62
+ pipeline = ResearchPipeline(
63
+ language=context.language,
64
+ runtime_config=runtime_config,
65
+ kb_name=kb_name,
66
+ enabled_tools=enabled_tools,
67
+ )
68
+ result = await pipeline.run(
69
+ context=context,
70
+ topic=context.user_message,
71
+ confirmed_outline=confirmed_outline_items,
72
+ attachments=context.attachments,
73
+ stream=stream,
74
+ )
75
+
76
+ # Outline-preview payloads carry the sub-topics + the original
77
+ # request config so the second call has everything it needs to
78
+ # confirm and resume. Fields live at top level so
79
+ # ``event.metadata.outline_preview`` resolves on the FE.
80
+ if result.get("outline_preview"):
81
+ research_config: dict[str, Any] = {
82
+ "mode": request_config.mode,
83
+ "depth": request_config.depth,
84
+ }
85
+ if request_config.manual_subtopics is not None:
86
+ research_config["manual_subtopics"] = request_config.manual_subtopics
87
+ if request_config.manual_max_iterations is not None:
88
+ research_config["manual_max_iterations"] = request_config.manual_max_iterations
89
+ await stream.result(
90
+ {**result, "research_config": research_config},
91
+ source=self.name,
92
+ )
@@ -0,0 +1,570 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ DR-in-KG 2.0 Core Data Structures
4
+ Includes: TopicBlock, ToolTrace, DynamicTopicQueue
5
+ """
6
+
7
+ from dataclasses import asdict, dataclass, field
8
+ from datetime import datetime
9
+ import difflib
10
+ from enum import Enum
11
+ import json
12
+ from pathlib import Path
13
+ import re
14
+ from typing import Any
15
+
16
+ from deeptutor.utils.json_parser import parse_json_response
17
+
18
+ # Default fuzzy-match threshold used by :meth:`DynamicTopicQueue.find_similar`.
19
+ # 0.85 reliably catches near-duplicate titles (case / punctuation / one-word
20
+ # reorderings) while letting genuinely distinct sub-topics through.
21
+ DEFAULT_TOPIC_SIMILARITY_THRESHOLD = 0.85
22
+ _TOPIC_TOKEN_RE = re.compile(r"[^\W_]+", re.UNICODE)
23
+ _TOPIC_STOPWORDS = {
24
+ "a",
25
+ "an",
26
+ "and",
27
+ "as",
28
+ "at",
29
+ "by",
30
+ "for",
31
+ "from",
32
+ "in",
33
+ "into",
34
+ "of",
35
+ "on",
36
+ "or",
37
+ "the",
38
+ "to",
39
+ "vs",
40
+ "with",
41
+ }
42
+
43
+
44
+ class TopicStatus(Enum):
45
+ """Topic block status enumeration"""
46
+
47
+ PENDING = "pending" # Pending research
48
+ RESEARCHING = "researching" # Researching
49
+ COMPLETED = "completed" # Completed
50
+ FAILED = "failed" # Failed
51
+
52
+
53
+ class ToolType(Enum):
54
+ """Tool type enumeration"""
55
+
56
+ RAG = "rag"
57
+ PAPER_SEARCH = "paper_search"
58
+ EXEC = "exec"
59
+ WEB_SEARCH = "web_search"
60
+
61
+
62
+ # Default max size for raw_answer (50KB)
63
+ DEFAULT_RAW_ANSWER_MAX_SIZE = 50 * 1024
64
+
65
+
66
+ @dataclass
67
+ class ToolTrace:
68
+ """
69
+ Tool trace - Records complete loop of a single tool call
70
+ """
71
+
72
+ tool_id: str # Unique identifier (e.g., "tool_1", "tool_2")
73
+ citation_id: str # Citation ID (for report citations and anchors, e.g., CIT-1-01)
74
+ tool_type: str # Tool type (rag, web_search, etc.)
75
+ query: str # Query statement issued
76
+ raw_answer: str # Raw detailed result returned by tool (may be truncated)
77
+ summary: str # Core summary generated by Note Agent
78
+ timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
79
+ raw_answer_truncated: bool = field(default=False) # Whether raw_answer was truncated
80
+ raw_answer_original_size: int = field(default=0) # Original size before truncation
81
+
82
+ def __post_init__(self):
83
+ """Post-initialization to handle raw_answer size limit"""
84
+ if self.raw_answer_original_size == 0:
85
+ self.raw_answer_original_size = len(self.raw_answer)
86
+
87
+ # Truncate if needed
88
+ if len(self.raw_answer) > DEFAULT_RAW_ANSWER_MAX_SIZE:
89
+ self.raw_answer = self._truncate_raw_answer(
90
+ self.raw_answer, DEFAULT_RAW_ANSWER_MAX_SIZE
91
+ )
92
+ self.raw_answer_truncated = True
93
+
94
+ @staticmethod
95
+ def _truncate_raw_answer(raw_answer: str, max_size: int) -> str:
96
+ """
97
+ Truncate raw_answer while trying to preserve valid JSON structure
98
+
99
+ Args:
100
+ raw_answer: Original raw answer string
101
+ max_size: Maximum size in bytes
102
+
103
+ Returns:
104
+ Truncated string
105
+ """
106
+ if len(raw_answer) <= max_size:
107
+ return raw_answer
108
+
109
+ # Try to parse as JSON and truncate intelligently
110
+ data = parse_json_response(raw_answer, fallback=None)
111
+ if isinstance(data, dict):
112
+ content_fields = ["answer", "content", "text", "chunks", "documents"]
113
+ for field_name in content_fields:
114
+ if field_name in data:
115
+ if isinstance(data[field_name], str) and len(data[field_name]) > max_size // 2:
116
+ data[field_name] = data[field_name][: max_size // 2] + "... [truncated]"
117
+ elif isinstance(data[field_name], list):
118
+ data[field_name] = data[field_name][:3]
119
+ if data[field_name]:
120
+ data[field_name].append({"note": "... additional items truncated"})
121
+
122
+ try:
123
+ truncated = json.dumps(data, ensure_ascii=False)
124
+ if len(truncated) <= max_size:
125
+ return truncated
126
+ except (TypeError, ValueError):
127
+ pass
128
+
129
+ # Fallback: simple truncation with marker
130
+ truncation_marker = "\n... [content truncated, original size: {} bytes]".format(
131
+ len(raw_answer)
132
+ )
133
+ return raw_answer[: max_size - len(truncation_marker)] + truncation_marker
134
+
135
+ def to_dict(self) -> dict[str, Any]:
136
+ """Convert to dictionary"""
137
+ return asdict(self)
138
+
139
+ @classmethod
140
+ def from_dict(cls, data: dict[str, Any]) -> "ToolTrace":
141
+ """Create from dictionary"""
142
+ return cls(**data)
143
+
144
+ @classmethod
145
+ def create_with_size_limit(
146
+ cls,
147
+ tool_id: str,
148
+ citation_id: str,
149
+ tool_type: str,
150
+ query: str,
151
+ raw_answer: str,
152
+ summary: str,
153
+ max_size: int = DEFAULT_RAW_ANSWER_MAX_SIZE,
154
+ ) -> "ToolTrace":
155
+ """
156
+ Create a ToolTrace with explicit size limit
157
+
158
+ Args:
159
+ tool_id: Tool ID
160
+ citation_id: Citation ID
161
+ tool_type: Tool type
162
+ query: Query string
163
+ raw_answer: Raw answer (will be truncated if needed)
164
+ summary: Summary
165
+ max_size: Maximum size for raw_answer
166
+
167
+ Returns:
168
+ ToolTrace instance
169
+ """
170
+ original_size = len(raw_answer)
171
+ truncated = len(raw_answer) > max_size
172
+
173
+ if truncated:
174
+ raw_answer = cls._truncate_raw_answer(raw_answer, max_size)
175
+
176
+ return cls(
177
+ tool_id=tool_id,
178
+ citation_id=citation_id,
179
+ tool_type=tool_type,
180
+ query=query,
181
+ raw_answer=raw_answer,
182
+ summary=summary,
183
+ raw_answer_truncated=truncated,
184
+ raw_answer_original_size=original_size,
185
+ )
186
+
187
+
188
+ @dataclass
189
+ class TopicBlock:
190
+ """
191
+ Topic block - Minimum scheduling unit in queue
192
+ """
193
+
194
+ block_id: str # Unique identifier (e.g., "block_1", "block_2")
195
+ sub_topic: str # Sub-topic name
196
+ overview: str # Topic overview/background
197
+ status: TopicStatus = TopicStatus.PENDING # Topic status
198
+ tool_traces: list[ToolTrace] = field(default_factory=list) # Tool call trace list
199
+ iteration_count: int = 0 # Current iteration count
200
+ created_at: str = field(default_factory=lambda: datetime.now().isoformat())
201
+ updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
202
+ metadata: dict[str, Any] = field(default_factory=dict) # Additional metadata
203
+
204
+ def add_tool_trace(self, trace: ToolTrace) -> None:
205
+ """Add tool trace"""
206
+ self.tool_traces.append(trace)
207
+ self.updated_at = datetime.now().isoformat()
208
+
209
+ def get_latest_trace(self) -> ToolTrace | None:
210
+ """Get latest tool trace"""
211
+ return self.tool_traces[-1] if self.tool_traces else None
212
+
213
+ def get_all_summaries(self) -> str:
214
+ """Get concatenated summaries of all tool traces"""
215
+ if not self.tool_traces:
216
+ return ""
217
+ return "\n".join([f"[{trace.tool_type}] {trace.summary}" for trace in self.tool_traces])
218
+
219
+ def to_dict(self) -> dict[str, Any]:
220
+ """Convert to dictionary"""
221
+ data = asdict(self)
222
+ data["status"] = self.status.value
223
+ data["tool_traces"] = [trace.to_dict() for trace in self.tool_traces]
224
+ return data
225
+
226
+ @classmethod
227
+ def from_dict(cls, data: dict[str, Any]) -> "TopicBlock":
228
+ """Create from dictionary"""
229
+ data_copy = data.copy()
230
+ if isinstance(data_copy.get("status"), str):
231
+ data_copy["status"] = TopicStatus(data_copy["status"])
232
+ if "tool_traces" in data_copy:
233
+ data_copy["tool_traces"] = [
234
+ ToolTrace.from_dict(t) if isinstance(t, dict) else t
235
+ for t in data_copy["tool_traces"]
236
+ ]
237
+ return cls(**data_copy)
238
+
239
+
240
+ class DynamicTopicQueue:
241
+ """
242
+ Dynamic topic queue - Core memory and scheduling center of the system
243
+ """
244
+
245
+ def __init__(
246
+ self, research_id: str, max_length: int | None = None, state_file: str | None = None
247
+ ):
248
+ """
249
+ Initialize queue
250
+
251
+ Args:
252
+ research_id: Research task ID
253
+ max_length: Maximum queue length (None means unlimited)
254
+ state_file: Auto-persistence file path
255
+ """
256
+ self.research_id = research_id
257
+ self.blocks: list[TopicBlock] = []
258
+ self.block_counter = 0
259
+ self.created_at = datetime.now().isoformat()
260
+ self.max_length = max_length if isinstance(max_length, int) and max_length > 0 else None
261
+ self.state_file = state_file
262
+
263
+ def set_state_file(self, filepath: str | None) -> None:
264
+ """Set queue auto-persistence file"""
265
+ self.state_file = filepath
266
+ self._auto_save()
267
+
268
+ @staticmethod
269
+ def _normalize_topic(text: str) -> str:
270
+ return re.sub(r"\s+", " ", (text or "").strip().lower())
271
+
272
+ @classmethod
273
+ def _topic_tokens(cls, text: str) -> set[str]:
274
+ tokens: set[str] = set()
275
+ for raw in _TOPIC_TOKEN_RE.findall(cls._normalize_topic(text)):
276
+ token = raw.strip()
277
+ if not token or token in _TOPIC_STOPWORDS:
278
+ continue
279
+ # Tiny English stemmer: enough to align "basics" and "basic"
280
+ # without adding a heavyweight NLP dependency.
281
+ if len(token) > 4 and token.endswith("ies"):
282
+ token = token[:-3] + "y"
283
+ elif len(token) > 3 and token.endswith("s"):
284
+ token = token[:-1]
285
+ tokens.add(token)
286
+ return tokens
287
+
288
+ @classmethod
289
+ def _topic_similarity(cls, left: str, right: str) -> float:
290
+ left_norm = cls._normalize_topic(left)
291
+ right_norm = cls._normalize_topic(right)
292
+ if not left_norm or not right_norm:
293
+ return 0.0
294
+ if left_norm == right_norm:
295
+ return 1.0
296
+
297
+ sequence_score = difflib.SequenceMatcher(None, left_norm, right_norm).ratio()
298
+ left_tokens = cls._topic_tokens(left_norm)
299
+ right_tokens = cls._topic_tokens(right_norm)
300
+ if not left_tokens or not right_tokens:
301
+ return sequence_score
302
+
303
+ overlap = left_tokens & right_tokens
304
+ jaccard = len(overlap) / max(1, len(left_tokens | right_tokens))
305
+ containment = len(overlap) / max(1, min(len(left_tokens), len(right_tokens)))
306
+ token_score = jaccard
307
+ if len(left_tokens) >= 2 and len(right_tokens) >= 2 and jaccard >= 0.5:
308
+ token_score = max(token_score, containment * 0.95)
309
+ return max(sequence_score, token_score)
310
+
311
+ def add_block(self, sub_topic: str, overview: str) -> TopicBlock:
312
+ """
313
+ Add new topic block to the end of queue
314
+
315
+ Args:
316
+ sub_topic: Sub-topic name
317
+ overview: Topic overview
318
+
319
+ Returns:
320
+ Created TopicBlock
321
+ """
322
+ if self.max_length and len(self.blocks) >= self.max_length:
323
+ raise RuntimeError(
324
+ f"Queue has reached maximum capacity ({self.max_length}), cannot add new topic."
325
+ )
326
+ self.block_counter += 1
327
+ block_id = f"block_{self.block_counter}"
328
+ block = TopicBlock(block_id=block_id, sub_topic=sub_topic, overview=overview)
329
+ self.blocks.append(block)
330
+ self._auto_save()
331
+ return block
332
+
333
+ def has_topic(self, sub_topic: str) -> bool:
334
+ """Check if topic already exists (case-insensitive, ignoring leading/trailing spaces)"""
335
+ target = self._normalize_topic(sub_topic)
336
+ if not target:
337
+ return False
338
+ return any(self._normalize_topic(b.sub_topic) == target for b in self.blocks)
339
+
340
+ def is_full(self) -> bool:
341
+ """Return ``True`` when the queue has reached its configured cap."""
342
+ return self.max_length is not None and len(self.blocks) >= self.max_length
343
+
344
+ def find_similar(
345
+ self,
346
+ sub_topic: str,
347
+ *,
348
+ threshold: float = DEFAULT_TOPIC_SIMILARITY_THRESHOLD,
349
+ ) -> TopicBlock | None:
350
+ """Return an existing block whose title is fuzzily similar to
351
+ ``sub_topic``, or ``None`` when no match exceeds ``threshold``.
352
+
353
+ Used to dedup ``APPEND`` requests so the LLM can't reliably keep
354
+ proposing the same topic in slightly different words. Exact
355
+ normalised matches always win; otherwise the highest-scoring
356
+ block above ``threshold`` is returned.
357
+ """
358
+ target = self._normalize_topic(sub_topic)
359
+ if not target:
360
+ return None
361
+
362
+ best: tuple[float, TopicBlock] | None = None
363
+ for block in self.blocks:
364
+ candidate = self._normalize_topic(block.sub_topic)
365
+ if not candidate:
366
+ continue
367
+ if candidate == target:
368
+ return block
369
+ score = self._topic_similarity(target, candidate)
370
+ if score >= threshold and (best is None or score > best[0]):
371
+ best = (score, block)
372
+ return best[1] if best else None
373
+
374
+ def append_child(
375
+ self,
376
+ *,
377
+ parent: TopicBlock | None,
378
+ sub_topic: str,
379
+ overview: str = "",
380
+ ) -> TopicBlock | None:
381
+ """Append a new block to the queue tail, optionally tagging the
382
+ parent block's id in metadata so reporting can reconstruct the
383
+ topic tree.
384
+
385
+ Returns the new block on success, or ``None`` when the queue is
386
+ already full. Duplicate detection is the caller's responsibility
387
+ (use :meth:`find_similar` first when needed).
388
+ """
389
+ if self.is_full():
390
+ return None
391
+ self.block_counter += 1
392
+ block_id = f"block_{self.block_counter}"
393
+ metadata: dict[str, Any] = {}
394
+ if parent is not None:
395
+ metadata["parent_block_id"] = parent.block_id
396
+ block = TopicBlock(
397
+ block_id=block_id,
398
+ sub_topic=sub_topic,
399
+ overview=overview,
400
+ metadata=metadata,
401
+ )
402
+ self.blocks.append(block)
403
+ self._auto_save()
404
+ return block
405
+
406
+ def list_topics(self) -> list[str]:
407
+ """List all current topic titles"""
408
+ return [b.sub_topic for b in self.blocks]
409
+
410
+ def get_pending_block(self) -> TopicBlock | None:
411
+ """
412
+ Get first pending topic block
413
+
414
+ Returns:
415
+ First TopicBlock with PENDING status, or None if not found
416
+ """
417
+ for block in self.blocks:
418
+ if block.status == TopicStatus.PENDING:
419
+ return block
420
+ return None
421
+
422
+ def get_block_by_id(self, block_id: str) -> TopicBlock | None:
423
+ """
424
+ Get topic block by ID
425
+
426
+ Args:
427
+ block_id: Topic block ID
428
+
429
+ Returns:
430
+ Corresponding TopicBlock, or None if not found
431
+ """
432
+ for block in self.blocks:
433
+ if block.block_id == block_id:
434
+ return block
435
+ return None
436
+
437
+ def mark_researching(self, block_id: str) -> bool:
438
+ """
439
+ Mark topic block as researching
440
+
441
+ Args:
442
+ block_id: Topic block ID
443
+
444
+ Returns:
445
+ Whether marking was successful
446
+ """
447
+ block = self.get_block_by_id(block_id)
448
+ if block:
449
+ block.status = TopicStatus.RESEARCHING
450
+ block.updated_at = datetime.now().isoformat()
451
+ self._auto_save()
452
+ return True
453
+ return False
454
+
455
+ def mark_completed(self, block_id: str) -> bool:
456
+ """
457
+ Mark topic block as completed
458
+
459
+ Args:
460
+ block_id: Topic block ID
461
+
462
+ Returns:
463
+ Whether marking was successful
464
+ """
465
+ block = self.get_block_by_id(block_id)
466
+ if block:
467
+ block.status = TopicStatus.COMPLETED
468
+ block.updated_at = datetime.now().isoformat()
469
+ self._auto_save()
470
+ return True
471
+ return False
472
+
473
+ def mark_failed(self, block_id: str) -> bool:
474
+ """
475
+ Mark topic block as failed
476
+
477
+ Args:
478
+ block_id: Topic block ID
479
+
480
+ Returns:
481
+ Whether marking was successful
482
+ """
483
+ block = self.get_block_by_id(block_id)
484
+ if block:
485
+ block.status = TopicStatus.FAILED
486
+ block.updated_at = datetime.now().isoformat()
487
+ self._auto_save()
488
+ return True
489
+ return False
490
+
491
+ def get_all_completed_blocks(self) -> list[TopicBlock]:
492
+ """Get all completed topic blocks"""
493
+ return [b for b in self.blocks if b.status == TopicStatus.COMPLETED]
494
+
495
+ def get_all_pending_blocks(self) -> list[TopicBlock]:
496
+ """Get all pending topic blocks"""
497
+ return [b for b in self.blocks if b.status == TopicStatus.PENDING]
498
+
499
+ def is_all_completed(self) -> bool:
500
+ """Check if all topic blocks are completed"""
501
+ if not self.blocks:
502
+ return False
503
+ return all(b.status == TopicStatus.COMPLETED for b in self.blocks)
504
+
505
+ def get_statistics(self) -> dict[str, Any]:
506
+ """Get queue statistics"""
507
+ return {
508
+ "total_blocks": len(self.blocks),
509
+ "pending": len(self.get_all_pending_blocks()),
510
+ "researching": len([b for b in self.blocks if b.status == TopicStatus.RESEARCHING]),
511
+ "completed": len(self.get_all_completed_blocks()),
512
+ "failed": len([b for b in self.blocks if b.status == TopicStatus.FAILED]),
513
+ "total_tool_calls": sum(len(b.tool_traces) for b in self.blocks),
514
+ }
515
+
516
+ def to_dict(self) -> dict[str, Any]:
517
+ """Convert to dictionary"""
518
+ return {
519
+ "research_id": self.research_id,
520
+ "created_at": self.created_at,
521
+ "blocks": [b.to_dict() for b in self.blocks],
522
+ "statistics": self.get_statistics(),
523
+ }
524
+
525
+ @classmethod
526
+ def from_dict(cls, data: dict[str, Any]) -> "DynamicTopicQueue":
527
+ """Create from dictionary"""
528
+ queue = cls(data["research_id"])
529
+ queue.created_at = data.get("created_at", queue.created_at)
530
+ for block_data in data.get("blocks", []):
531
+ block = TopicBlock.from_dict(block_data)
532
+ queue.blocks.append(block)
533
+ # Update counter
534
+ if block.block_id.startswith("block_"):
535
+ try:
536
+ block_num = int(block.block_id.split("_")[1])
537
+ queue.block_counter = max(queue.block_counter, block_num)
538
+ except (ValueError, IndexError):
539
+ pass
540
+ return queue
541
+
542
+ def save_to_json(self, filepath: str) -> None:
543
+ """Save queue to JSON file"""
544
+ Path(filepath).parent.mkdir(parents=True, exist_ok=True)
545
+ with open(filepath, "w", encoding="utf-8") as f:
546
+ json.dump(self.to_dict(), f, ensure_ascii=False, indent=2)
547
+
548
+ def _auto_save(self) -> None:
549
+ """Auto-save if state_file is set"""
550
+ if self.state_file:
551
+ try:
552
+ self.save_to_json(self.state_file)
553
+ except Exception as exc:
554
+ print(f"⚠️ Failed to save queue progress: {exc}")
555
+
556
+ @classmethod
557
+ def load_from_json(cls, filepath: str) -> "DynamicTopicQueue":
558
+ """Load queue from JSON file"""
559
+ with open(filepath, encoding="utf-8") as f:
560
+ data = json.load(f)
561
+ return cls.from_dict(data)
562
+
563
+
564
+ __all__ = [
565
+ "DynamicTopicQueue",
566
+ "ToolTrace",
567
+ "ToolType",
568
+ "TopicBlock",
569
+ "TopicStatus",
570
+ ]