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.
- deeptutor/agents/research/capability.py +92 -0
- deeptutor/agents/research/data_structures.py +570 -0
- deeptutor/agents/research/mode_strategy.py +147 -0
- deeptutor/agents/research/pipeline.py +3281 -0
- deeptutor/agents/research/prompts/en/pipeline.yaml +328 -0
- deeptutor/agents/research/prompts/zh/pipeline.yaml +319 -0
- deeptutor/agents/research/request_config.py +249 -0
- deeptutor/agents/research/utils/__init__.py +22 -0
- deeptutor/agents/research/utils/citation_manager.py +907 -0
- deeptutor/agents/research/utils/json_utils.py +94 -0
- deeptutor/agents/research/utils/token_tracker.py +281 -0
- deeptutor/services/research/__init__.py +1 -0
- deeptutor/services/research/primitives.py +488 -0
- deeptutor/tools/knowledge_frontier.py +321 -0
- deeptutor/tools/paper_search_tool.py +207 -0
- deeptutor/tools/research_tools.py +396 -0
- deeptutor_research-1.7.0.dist-info/METADATA +7 -0
- deeptutor_research-1.7.0.dist-info/RECORD +20 -0
- deeptutor_research-1.7.0.dist-info/WHEEL +5 -0
- deeptutor_research-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|