jarvis-ai-assistant 0.1.222__py3-none-any.whl → 0.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.
Files changed (162) hide show
  1. jarvis/__init__.py +1 -1
  2. jarvis/jarvis_agent/__init__.py +1143 -245
  3. jarvis/jarvis_agent/agent_manager.py +97 -0
  4. jarvis/jarvis_agent/builtin_input_handler.py +12 -10
  5. jarvis/jarvis_agent/config_editor.py +57 -0
  6. jarvis/jarvis_agent/edit_file_handler.py +392 -99
  7. jarvis/jarvis_agent/event_bus.py +48 -0
  8. jarvis/jarvis_agent/events.py +157 -0
  9. jarvis/jarvis_agent/file_context_handler.py +79 -0
  10. jarvis/jarvis_agent/file_methodology_manager.py +117 -0
  11. jarvis/jarvis_agent/jarvis.py +1117 -147
  12. jarvis/jarvis_agent/main.py +78 -34
  13. jarvis/jarvis_agent/memory_manager.py +195 -0
  14. jarvis/jarvis_agent/methodology_share_manager.py +174 -0
  15. jarvis/jarvis_agent/prompt_manager.py +82 -0
  16. jarvis/jarvis_agent/prompts.py +46 -9
  17. jarvis/jarvis_agent/protocols.py +4 -1
  18. jarvis/jarvis_agent/rewrite_file_handler.py +141 -0
  19. jarvis/jarvis_agent/run_loop.py +146 -0
  20. jarvis/jarvis_agent/session_manager.py +9 -9
  21. jarvis/jarvis_agent/share_manager.py +228 -0
  22. jarvis/jarvis_agent/shell_input_handler.py +23 -3
  23. jarvis/jarvis_agent/stdio_redirect.py +295 -0
  24. jarvis/jarvis_agent/task_analyzer.py +212 -0
  25. jarvis/jarvis_agent/task_manager.py +154 -0
  26. jarvis/jarvis_agent/task_planner.py +496 -0
  27. jarvis/jarvis_agent/tool_executor.py +8 -4
  28. jarvis/jarvis_agent/tool_share_manager.py +139 -0
  29. jarvis/jarvis_agent/user_interaction.py +42 -0
  30. jarvis/jarvis_agent/utils.py +54 -0
  31. jarvis/jarvis_agent/web_bridge.py +189 -0
  32. jarvis/jarvis_agent/web_output_sink.py +53 -0
  33. jarvis/jarvis_agent/web_server.py +751 -0
  34. jarvis/jarvis_c2rust/__init__.py +26 -0
  35. jarvis/jarvis_c2rust/cli.py +613 -0
  36. jarvis/jarvis_c2rust/collector.py +258 -0
  37. jarvis/jarvis_c2rust/library_replacer.py +1122 -0
  38. jarvis/jarvis_c2rust/llm_module_agent.py +1300 -0
  39. jarvis/jarvis_c2rust/optimizer.py +960 -0
  40. jarvis/jarvis_c2rust/scanner.py +1681 -0
  41. jarvis/jarvis_c2rust/transpiler.py +2325 -0
  42. jarvis/jarvis_code_agent/build_validation_config.py +133 -0
  43. jarvis/jarvis_code_agent/code_agent.py +1605 -178
  44. jarvis/jarvis_code_agent/code_analyzer/__init__.py +62 -0
  45. jarvis/jarvis_code_agent/code_analyzer/base_language.py +74 -0
  46. jarvis/jarvis_code_agent/code_analyzer/build_validator/__init__.py +44 -0
  47. jarvis/jarvis_code_agent/code_analyzer/build_validator/base.py +102 -0
  48. jarvis/jarvis_code_agent/code_analyzer/build_validator/cmake.py +59 -0
  49. jarvis/jarvis_code_agent/code_analyzer/build_validator/detector.py +125 -0
  50. jarvis/jarvis_code_agent/code_analyzer/build_validator/fallback.py +69 -0
  51. jarvis/jarvis_code_agent/code_analyzer/build_validator/go.py +38 -0
  52. jarvis/jarvis_code_agent/code_analyzer/build_validator/java_gradle.py +44 -0
  53. jarvis/jarvis_code_agent/code_analyzer/build_validator/java_maven.py +38 -0
  54. jarvis/jarvis_code_agent/code_analyzer/build_validator/makefile.py +50 -0
  55. jarvis/jarvis_code_agent/code_analyzer/build_validator/nodejs.py +93 -0
  56. jarvis/jarvis_code_agent/code_analyzer/build_validator/python.py +129 -0
  57. jarvis/jarvis_code_agent/code_analyzer/build_validator/rust.py +54 -0
  58. jarvis/jarvis_code_agent/code_analyzer/build_validator/validator.py +154 -0
  59. jarvis/jarvis_code_agent/code_analyzer/build_validator.py +43 -0
  60. jarvis/jarvis_code_agent/code_analyzer/context_manager.py +363 -0
  61. jarvis/jarvis_code_agent/code_analyzer/context_recommender.py +18 -0
  62. jarvis/jarvis_code_agent/code_analyzer/dependency_analyzer.py +132 -0
  63. jarvis/jarvis_code_agent/code_analyzer/file_ignore.py +330 -0
  64. jarvis/jarvis_code_agent/code_analyzer/impact_analyzer.py +781 -0
  65. jarvis/jarvis_code_agent/code_analyzer/language_registry.py +185 -0
  66. jarvis/jarvis_code_agent/code_analyzer/language_support.py +89 -0
  67. jarvis/jarvis_code_agent/code_analyzer/languages/__init__.py +31 -0
  68. jarvis/jarvis_code_agent/code_analyzer/languages/c_cpp_language.py +231 -0
  69. jarvis/jarvis_code_agent/code_analyzer/languages/go_language.py +183 -0
  70. jarvis/jarvis_code_agent/code_analyzer/languages/python_language.py +219 -0
  71. jarvis/jarvis_code_agent/code_analyzer/languages/rust_language.py +209 -0
  72. jarvis/jarvis_code_agent/code_analyzer/llm_context_recommender.py +451 -0
  73. jarvis/jarvis_code_agent/code_analyzer/symbol_extractor.py +77 -0
  74. jarvis/jarvis_code_agent/code_analyzer/tree_sitter_extractor.py +48 -0
  75. jarvis/jarvis_code_agent/lint.py +275 -13
  76. jarvis/jarvis_code_agent/utils.py +142 -0
  77. jarvis/jarvis_code_analysis/checklists/loader.py +20 -6
  78. jarvis/jarvis_code_analysis/code_review.py +583 -548
  79. jarvis/jarvis_data/config_schema.json +339 -28
  80. jarvis/jarvis_git_squash/main.py +22 -13
  81. jarvis/jarvis_git_utils/git_commiter.py +171 -55
  82. jarvis/jarvis_mcp/sse_mcp_client.py +22 -15
  83. jarvis/jarvis_mcp/stdio_mcp_client.py +4 -4
  84. jarvis/jarvis_mcp/streamable_mcp_client.py +36 -16
  85. jarvis/jarvis_memory_organizer/memory_organizer.py +753 -0
  86. jarvis/jarvis_methodology/main.py +48 -63
  87. jarvis/jarvis_multi_agent/__init__.py +302 -43
  88. jarvis/jarvis_multi_agent/main.py +70 -24
  89. jarvis/jarvis_platform/ai8.py +40 -23
  90. jarvis/jarvis_platform/base.py +210 -49
  91. jarvis/jarvis_platform/human.py +11 -1
  92. jarvis/jarvis_platform/kimi.py +82 -76
  93. jarvis/jarvis_platform/openai.py +73 -1
  94. jarvis/jarvis_platform/registry.py +8 -15
  95. jarvis/jarvis_platform/tongyi.py +115 -101
  96. jarvis/jarvis_platform/yuanbao.py +89 -63
  97. jarvis/jarvis_platform_manager/main.py +194 -132
  98. jarvis/jarvis_platform_manager/service.py +122 -86
  99. jarvis/jarvis_rag/cli.py +156 -53
  100. jarvis/jarvis_rag/embedding_manager.py +155 -12
  101. jarvis/jarvis_rag/llm_interface.py +10 -13
  102. jarvis/jarvis_rag/query_rewriter.py +63 -12
  103. jarvis/jarvis_rag/rag_pipeline.py +222 -40
  104. jarvis/jarvis_rag/reranker.py +26 -3
  105. jarvis/jarvis_rag/retriever.py +270 -14
  106. jarvis/jarvis_sec/__init__.py +3605 -0
  107. jarvis/jarvis_sec/checkers/__init__.py +32 -0
  108. jarvis/jarvis_sec/checkers/c_checker.py +2680 -0
  109. jarvis/jarvis_sec/checkers/rust_checker.py +1108 -0
  110. jarvis/jarvis_sec/cli.py +116 -0
  111. jarvis/jarvis_sec/report.py +257 -0
  112. jarvis/jarvis_sec/status.py +264 -0
  113. jarvis/jarvis_sec/types.py +20 -0
  114. jarvis/jarvis_sec/workflow.py +219 -0
  115. jarvis/jarvis_smart_shell/main.py +405 -137
  116. jarvis/jarvis_stats/__init__.py +13 -0
  117. jarvis/jarvis_stats/cli.py +387 -0
  118. jarvis/jarvis_stats/stats.py +711 -0
  119. jarvis/jarvis_stats/storage.py +612 -0
  120. jarvis/jarvis_stats/visualizer.py +282 -0
  121. jarvis/jarvis_tools/ask_user.py +1 -0
  122. jarvis/jarvis_tools/base.py +18 -2
  123. jarvis/jarvis_tools/clear_memory.py +239 -0
  124. jarvis/jarvis_tools/cli/main.py +220 -144
  125. jarvis/jarvis_tools/execute_script.py +52 -12
  126. jarvis/jarvis_tools/file_analyzer.py +17 -12
  127. jarvis/jarvis_tools/generate_new_tool.py +46 -24
  128. jarvis/jarvis_tools/read_code.py +277 -18
  129. jarvis/jarvis_tools/read_symbols.py +141 -0
  130. jarvis/jarvis_tools/read_webpage.py +86 -13
  131. jarvis/jarvis_tools/registry.py +294 -90
  132. jarvis/jarvis_tools/retrieve_memory.py +227 -0
  133. jarvis/jarvis_tools/save_memory.py +194 -0
  134. jarvis/jarvis_tools/search_web.py +62 -28
  135. jarvis/jarvis_tools/sub_agent.py +205 -0
  136. jarvis/jarvis_tools/sub_code_agent.py +217 -0
  137. jarvis/jarvis_tools/virtual_tty.py +330 -62
  138. jarvis/jarvis_utils/builtin_replace_map.py +4 -5
  139. jarvis/jarvis_utils/clipboard.py +90 -0
  140. jarvis/jarvis_utils/config.py +607 -50
  141. jarvis/jarvis_utils/embedding.py +3 -0
  142. jarvis/jarvis_utils/fzf.py +57 -0
  143. jarvis/jarvis_utils/git_utils.py +251 -29
  144. jarvis/jarvis_utils/globals.py +174 -17
  145. jarvis/jarvis_utils/http.py +58 -79
  146. jarvis/jarvis_utils/input.py +899 -153
  147. jarvis/jarvis_utils/methodology.py +210 -83
  148. jarvis/jarvis_utils/output.py +220 -137
  149. jarvis/jarvis_utils/utils.py +1906 -135
  150. jarvis_ai_assistant-0.7.0.dist-info/METADATA +465 -0
  151. jarvis_ai_assistant-0.7.0.dist-info/RECORD +192 -0
  152. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/entry_points.txt +8 -2
  153. jarvis/jarvis_git_details/main.py +0 -265
  154. jarvis/jarvis_platform/oyi.py +0 -357
  155. jarvis/jarvis_tools/edit_file.py +0 -255
  156. jarvis/jarvis_tools/rewrite_file.py +0 -195
  157. jarvis_ai_assistant-0.1.222.dist-info/METADATA +0 -767
  158. jarvis_ai_assistant-0.1.222.dist-info/RECORD +0 -110
  159. /jarvis/{jarvis_git_details → jarvis_memory_organizer}/__init__.py +0 -0
  160. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/WHEEL +0 -0
  161. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/licenses/LICENSE +0 -0
  162. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,612 @@
1
+ """
2
+ 统计数据存储模块
3
+
4
+ 负责统计数据的持久化存储和读取
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+ from typing import Dict, List, Optional, Any, Set
12
+ from collections import defaultdict
13
+ import sys
14
+ import time
15
+ import uuid
16
+
17
+
18
+ class StatsStorage:
19
+ """统计数据存储类"""
20
+
21
+ def __init__(self, storage_dir: Optional[str] = None):
22
+ """
23
+ 初始化存储
24
+
25
+ Args:
26
+ storage_dir: 存储目录路径,默认为 ~/.jarvis/stats
27
+ """
28
+ if storage_dir is None:
29
+ storage_dir = os.path.expanduser("~/.jarvis/stats")
30
+
31
+ self.storage_dir = Path(storage_dir)
32
+ self.storage_dir.mkdir(parents=True, exist_ok=True)
33
+
34
+ # 数据目录路径
35
+ self.data_dir = self.storage_dir / "data"
36
+ self.data_dir.mkdir(exist_ok=True)
37
+
38
+ # 统计总量缓存目录(每个指标一个文件,内容为统计总量)
39
+ self.totals_dir = self.storage_dir / "totals"
40
+ self.totals_dir.mkdir(exist_ok=True)
41
+
42
+ # 元数据文件路径
43
+ self.meta_file = self.storage_dir / "stats_meta.json"
44
+
45
+ # 初始化元数据
46
+ self._init_metadata()
47
+
48
+ def _init_metadata(self):
49
+ """初始化元数据"""
50
+ if not self.meta_file.exists():
51
+ meta = {
52
+ "version": "1.0.0",
53
+ "created_at": datetime.now().isoformat(),
54
+ "metrics": {}, # 存储各个指标的元信息
55
+ }
56
+ self._save_json(self.meta_file, meta)
57
+
58
+ def _get_data_file(self, date_str: str) -> Path:
59
+ """获取指定日期的数据文件路径"""
60
+ return self.data_dir / f"stats_{date_str}.json"
61
+
62
+ def _load_json(self, filepath: Path) -> Dict:
63
+ """加载JSON文件"""
64
+ if not filepath.exists():
65
+ return {}
66
+
67
+ # 重试机制处理并发访问
68
+ max_retries = 3
69
+ for attempt in range(max_retries):
70
+ try:
71
+ with open(filepath, "r", encoding="utf-8") as f:
72
+ data = json.load(f)
73
+ return data
74
+ except (json.JSONDecodeError, IOError):
75
+ if attempt < max_retries - 1:
76
+ time.sleep(0.1 * (attempt + 1)) # 递增延迟
77
+ continue
78
+ return {}
79
+ return {}
80
+
81
+ def _save_json(self, filepath: Path, data: Dict):
82
+ """保存JSON文件"""
83
+ # 使用临时文件+重命名的原子操作来避免并发写入问题
84
+ # 使用唯一的临时文件名避免并发冲突
85
+ temp_suffix = f".tmp.{uuid.uuid4().hex[:8]}"
86
+ temp_filepath = filepath.with_suffix(temp_suffix)
87
+ max_retries = 3
88
+
89
+ for attempt in range(max_retries):
90
+ try:
91
+ # 先写入临时文件
92
+ with open(temp_filepath, "w", encoding="utf-8") as f:
93
+ json.dump(data, f, ensure_ascii=False, indent=2)
94
+
95
+ # Windows上需要先删除目标文件(如果存在)
96
+ if sys.platform == "win32" and filepath.exists():
97
+ filepath.unlink()
98
+
99
+ # 原子性重命名
100
+ temp_filepath.rename(filepath)
101
+ return
102
+
103
+ except Exception as e:
104
+ if attempt < max_retries - 1:
105
+ time.sleep(0.1 * (attempt + 1)) # 递增延迟
106
+ continue
107
+ # 清理临时文件
108
+ if temp_filepath.exists():
109
+ try:
110
+ temp_filepath.unlink()
111
+ except OSError:
112
+ pass
113
+ raise RuntimeError(f"保存数据失败: {e}") from e
114
+
115
+ def _save_text_atomic(self, filepath: Path, text: str):
116
+ """原子性地保存纯文本内容"""
117
+ temp_suffix = f".tmp.{uuid.uuid4().hex[:8]}"
118
+ temp_filepath = filepath.with_suffix(temp_suffix)
119
+ max_retries = 3
120
+ for attempt in range(max_retries):
121
+ try:
122
+ with open(temp_filepath, "w", encoding="utf-8") as f:
123
+ f.write(text)
124
+
125
+ if sys.platform == "win32" and filepath.exists():
126
+ filepath.unlink()
127
+ temp_filepath.rename(filepath)
128
+ return
129
+ except Exception:
130
+ if attempt < max_retries - 1:
131
+ time.sleep(0.1 * (attempt + 1))
132
+ continue
133
+ if temp_filepath.exists():
134
+ try:
135
+ temp_filepath.unlink()
136
+ except OSError:
137
+ pass
138
+ raise
139
+
140
+ def add_metric(
141
+ self,
142
+ metric_name: str,
143
+ value: float,
144
+ unit: Optional[str] = None,
145
+ timestamp: Optional[datetime] = None,
146
+ tags: Optional[Dict[str, str]] = None,
147
+ ):
148
+ """
149
+ 添加统计数据
150
+
151
+ Args:
152
+ metric_name: 指标名称
153
+ value: 指标值
154
+ unit: 单位
155
+ timestamp: 时间戳,默认为当前时间
156
+ tags: 标签字典,用于数据分类
157
+ """
158
+ if timestamp is None:
159
+ timestamp = datetime.now()
160
+
161
+ # 更新元数据
162
+ meta = self._load_json(self.meta_file)
163
+ if metric_name not in meta["metrics"]:
164
+ meta["metrics"][metric_name] = {
165
+ "unit": unit,
166
+ "created_at": timestamp.isoformat(),
167
+ "last_updated": timestamp.isoformat(),
168
+ }
169
+ else:
170
+ meta["metrics"][metric_name]["last_updated"] = timestamp.isoformat()
171
+ if unit and not meta["metrics"][metric_name].get("unit"):
172
+ meta["metrics"][metric_name]["unit"] = unit
173
+
174
+ # 记录分组信息(如果提供)
175
+ if tags and isinstance(tags, dict):
176
+ group = tags.get("group")
177
+ if group:
178
+ meta["metrics"][metric_name]["group"] = group
179
+
180
+ self._save_json(self.meta_file, meta)
181
+
182
+ # 获取日期对应的数据文件
183
+ date_key = timestamp.strftime("%Y-%m-%d")
184
+ hour_key = timestamp.strftime("%H")
185
+ date_file = self._get_data_file(date_key)
186
+
187
+ # 加载日期文件的数据
188
+ data = self._load_json(date_file)
189
+
190
+ # 组织数据结构:metric_name -> hour -> records
191
+ if metric_name not in data:
192
+ data[metric_name] = {}
193
+
194
+ if hour_key not in data[metric_name]:
195
+ data[metric_name][hour_key] = []
196
+
197
+ # 添加数据记录
198
+ record = {
199
+ "timestamp": timestamp.isoformat(),
200
+ "value": value,
201
+ "tags": tags or {},
202
+ }
203
+ data[metric_name][hour_key].append(record)
204
+
205
+ # 保存数据到日期文件
206
+ self._save_json(date_file, data)
207
+
208
+ # 更新总量缓存文件(每个指标一个文件,内容为累计统计值)
209
+ try:
210
+ total_file = self._get_total_file(metric_name)
211
+ if total_file.exists():
212
+ # 正常累加
213
+ try:
214
+ with open(total_file, "r", encoding="utf-8") as tf:
215
+ current_total = float(tf.read().strip() or "0")
216
+ except Exception:
217
+ current_total = 0.0
218
+ new_total = current_total + float(value)
219
+ self._save_text_atomic(total_file, str(new_total))
220
+ else:
221
+ # 首次生成:扫描历史数据(包含刚写入的这条记录)并写入
222
+ # 注意:get_metric_total 内部会完成扫描并写入 totals 文件,这里无需再额外写入或累加
223
+ _ = self.get_metric_total(metric_name)
224
+ except Exception:
225
+ # 静默失败,不影响主流程
226
+ pass
227
+
228
+ def get_metrics(
229
+ self,
230
+ metric_name: str,
231
+ start_time: Optional[datetime] = None,
232
+ end_time: Optional[datetime] = None,
233
+ tags: Optional[Dict[str, str]] = None,
234
+ ) -> List[Dict]:
235
+ """
236
+ 获取指定时间范围的统计数据
237
+
238
+ Args:
239
+ metric_name: 指标名称
240
+ start_time: 开始时间
241
+ end_time: 结束时间
242
+ tags: 过滤标签
243
+
244
+ Returns:
245
+ 数据记录列表
246
+ """
247
+ # 默认时间范围
248
+ if end_time is None:
249
+ end_time = datetime.now()
250
+ if start_time is None:
251
+ start_time = end_time - timedelta(days=7) # 默认最近7天
252
+
253
+ results = []
254
+
255
+ # 遍历日期
256
+ current_date = start_time.date()
257
+ end_date = end_time.date()
258
+
259
+ while current_date <= end_date:
260
+ date_key = current_date.strftime("%Y-%m-%d")
261
+ date_file = self._get_data_file(date_key)
262
+
263
+ # 如果日期文件不存在,跳过
264
+ if not date_file.exists():
265
+ current_date += timedelta(days=1)
266
+ continue
267
+
268
+ # 加载日期文件的数据
269
+ data = self._load_json(date_file)
270
+
271
+ if metric_name in data:
272
+ for hour_key, records in data[metric_name].items():
273
+ for record in records:
274
+ record_time = datetime.fromisoformat(record["timestamp"])
275
+
276
+ # 检查时间范围
277
+ if start_time <= record_time <= end_time:
278
+ # 检查标签过滤
279
+ if tags:
280
+ record_tags = record.get("tags", {})
281
+ if all(
282
+ record_tags.get(k) == v for k, v in tags.items()
283
+ ):
284
+ results.append(record)
285
+ else:
286
+ results.append(record)
287
+
288
+ current_date += timedelta(days=1)
289
+
290
+ # 按时间排序
291
+ results.sort(key=lambda x: x["timestamp"])
292
+
293
+ return results
294
+
295
+ def _get_total_file(self, metric_name: str) -> Path:
296
+ """获取某个指标的总量文件路径"""
297
+ return self.totals_dir / metric_name
298
+
299
+ def get_metric_total(self, metric_name: str) -> float:
300
+ """
301
+ 获取某个指标的累计总量。
302
+ - 如果总量缓存文件存在,直接读取
303
+ - 如果不存在,则扫描历史数据计算一次并写入缓存
304
+ """
305
+ total_file = self._get_total_file(metric_name)
306
+ # 优先读取缓存
307
+ if total_file.exists():
308
+ try:
309
+ with open(total_file, "r", encoding="utf-8") as f:
310
+ return float((f.read() or "0").strip() or "0")
311
+ except Exception:
312
+ # 读取失败则重建
313
+ pass
314
+
315
+ # 扫描历史数据进行一次性计算,并尽可能推断分组信息
316
+ total = 0.0
317
+ group_counts: Dict[str, int] = {}
318
+ try:
319
+ for data_file in self.data_dir.glob("stats_*.json"):
320
+ data = self._load_json(data_file)
321
+ metric_data = data.get(metric_name) or {}
322
+ # metric_data: {hour_key: [records]}
323
+ for hour_records in metric_data.values():
324
+ for record in hour_records:
325
+ # 累加数值
326
+ try:
327
+ total += float(record.get("value", 0))
328
+ except Exception:
329
+ pass
330
+ # 统计历史记录中的分组标签
331
+ try:
332
+ tags = record.get("tags", {})
333
+ grp = tags.get("group")
334
+ if grp:
335
+ group_counts[grp] = group_counts.get(grp, 0) + 1
336
+ except Exception:
337
+ pass
338
+
339
+ # 写入缓存
340
+ self._save_text_atomic(total_file, str(total))
341
+
342
+ # 如果元数据中没有该指标或缺少分组,则根据历史数据推断一次
343
+ try:
344
+ meta = self._load_json(self.meta_file)
345
+ if "metrics" not in meta or not isinstance(meta.get("metrics"), dict):
346
+ meta["metrics"] = {}
347
+ info = meta["metrics"].get(metric_name)
348
+ now_iso = datetime.now().isoformat()
349
+ if info is None:
350
+ info = {
351
+ "unit": None,
352
+ "created_at": now_iso,
353
+ "last_updated": now_iso,
354
+ }
355
+ meta["metrics"][metric_name] = info
356
+ if not info.get("group"):
357
+ inferred_group = None
358
+ if group_counts:
359
+ inferred_group = max(
360
+ group_counts.items(), key=lambda kv: kv[1]
361
+ )[0]
362
+ # 名称启发式作为补充
363
+ if not inferred_group:
364
+ if (
365
+ metric_name.startswith("code_lines_")
366
+ or "commit" in metric_name
367
+ ):
368
+ inferred_group = "code_agent"
369
+ if inferred_group:
370
+ info["group"] = inferred_group
371
+ # 保存元数据
372
+ self._save_json(self.meta_file, meta)
373
+ except Exception:
374
+ # 分组推断失败不影响总量结果
375
+ pass
376
+
377
+ except Exception:
378
+ # 失败则返回0
379
+ return 0.0
380
+ return total
381
+
382
+ def resolve_metric_group(self, metric_name: str) -> Optional[str]:
383
+ """
384
+ 解析并确保写回某个指标的分组信息:
385
+ - 若元数据已存在group则直接返回
386
+ - 否则扫描历史记录中的tags['group']做多数投票推断
387
+ - 若仍无法得到,则用名称启发式(code_lines_*或包含commit -> code_agent)
388
+ - 推断出group后会写回到元数据,返回推断值;否则返回None
389
+ """
390
+ try:
391
+ # 优先从元数据读取
392
+ meta = self._load_json(self.meta_file)
393
+ metrics_meta = (
394
+ meta.get("metrics", {}) if isinstance(meta.get("metrics"), dict) else {}
395
+ )
396
+ info = metrics_meta.get(metric_name)
397
+ if info and isinstance(info, dict):
398
+ grp = info.get("group")
399
+ if grp:
400
+ return grp
401
+
402
+ # 扫描历史记录以推断
403
+ group_counts: Dict[str, int] = {}
404
+ for data_file in self.data_dir.glob("stats_*.json"):
405
+ data = self._load_json(data_file)
406
+ metric_data = data.get(metric_name) or {}
407
+ for hour_records in metric_data.values():
408
+ for record in hour_records:
409
+ try:
410
+ tags = record.get("tags", {})
411
+ grp = tags.get("group")
412
+ if grp:
413
+ group_counts[grp] = group_counts.get(grp, 0) + 1
414
+ except Exception:
415
+ continue
416
+
417
+ inferred_group: Optional[str] = None
418
+ if group_counts:
419
+ inferred_group = max(group_counts.items(), key=lambda kv: kv[1])[0]
420
+
421
+ # 名称启发式补充
422
+ if not inferred_group:
423
+ name = metric_name or ""
424
+ if name.startswith("code_lines_") or ("commit" in name):
425
+ inferred_group = "code_agent"
426
+
427
+ # 如果推断出了分组,写回元数据
428
+ if inferred_group:
429
+ if not isinstance(metrics_meta, dict):
430
+ meta["metrics"] = {}
431
+ metrics_meta = meta["metrics"]
432
+ if info is None:
433
+ now_iso = datetime.now().isoformat()
434
+ info = {
435
+ "unit": None,
436
+ "created_at": now_iso,
437
+ "last_updated": now_iso,
438
+ }
439
+ metrics_meta[metric_name] = info
440
+ info["group"] = inferred_group
441
+ self._save_json(self.meta_file, meta)
442
+ return inferred_group
443
+
444
+ return None
445
+ except Exception:
446
+ return None
447
+
448
+ def get_metric_info(self, metric_name: str) -> Optional[Dict]:
449
+ """获取指标元信息"""
450
+ meta = self._load_json(self.meta_file)
451
+ return meta.get("metrics", {}).get(metric_name)
452
+
453
+ def list_metrics(self) -> List[str]:
454
+ """列出所有指标"""
455
+ # 从元数据文件获取指标
456
+ meta = self._load_json(self.meta_file)
457
+ metrics_from_meta = set(meta.get("metrics", {}).keys())
458
+
459
+ # 扫描所有数据文件获取实际存在的指标
460
+ metrics_from_data: Set[str] = set()
461
+ for data_file in self.data_dir.glob("stats_*.json"):
462
+ try:
463
+ data = self._load_json(data_file)
464
+ metrics_from_data.update(data.keys())
465
+ except (json.JSONDecodeError, OSError):
466
+ # 忽略无法读取的文件
467
+ continue
468
+
469
+ # 扫描总量缓存目录中已有的指标文件
470
+ metrics_from_totals: Set[str] = set()
471
+ try:
472
+ for f in self.totals_dir.glob("*"):
473
+ if f.is_file():
474
+ metrics_from_totals.add(f.name)
475
+ except Exception:
476
+ pass
477
+
478
+ # 合并三个来源的指标并返回排序后的列表
479
+ all_metrics = metrics_from_meta.union(metrics_from_data).union(
480
+ metrics_from_totals
481
+ )
482
+ return sorted(list(all_metrics))
483
+
484
+ def aggregate_metrics(
485
+ self,
486
+ metric_name: str,
487
+ start_time: Optional[datetime] = None,
488
+ end_time: Optional[datetime] = None,
489
+ aggregation: str = "hourly",
490
+ tags: Optional[Dict[str, str]] = None,
491
+ ) -> Dict[str, Dict[str, Any]]:
492
+ """
493
+ 聚合统计数据
494
+
495
+ Args:
496
+ metric_name: 指标名称
497
+ start_time: 开始时间
498
+ end_time: 结束时间
499
+ aggregation: 聚合方式 (hourly, daily)
500
+ tags: 过滤标签
501
+
502
+ Returns:
503
+ 聚合后的数据字典
504
+ """
505
+ records = self.get_metrics(metric_name, start_time, end_time, tags)
506
+
507
+ if not records:
508
+ return {}
509
+
510
+ # 聚合数据
511
+ aggregated: Dict[str, Dict[str, Any]] = defaultdict(
512
+ lambda: {
513
+ "count": 0,
514
+ "sum": 0,
515
+ "min": float("inf"),
516
+ "max": float("-inf"),
517
+ "values": [],
518
+ }
519
+ )
520
+
521
+ for record in records:
522
+ timestamp = datetime.fromisoformat(record["timestamp"])
523
+ value = record["value"]
524
+
525
+ if aggregation == "hourly":
526
+ key = timestamp.strftime("%Y-%m-%d %H:00")
527
+ elif aggregation == "daily":
528
+ key = timestamp.strftime("%Y-%m-%d")
529
+ else:
530
+ key = timestamp.strftime("%Y-%m-%d %H:00")
531
+
532
+ aggregated[key]["count"] += 1
533
+ aggregated[key]["sum"] += value
534
+ aggregated[key]["min"] = min(aggregated[key]["min"], value)
535
+ aggregated[key]["max"] = max(aggregated[key]["max"], value)
536
+ aggregated[key]["values"].append(value)
537
+
538
+ # 计算平均值
539
+ result = {}
540
+ for key, stats in aggregated.items():
541
+ result[key] = {
542
+ "count": stats["count"],
543
+ "sum": stats["sum"],
544
+ "min": stats["min"],
545
+ "max": stats["max"],
546
+ "avg": stats["sum"] / stats["count"] if stats["count"] > 0 else 0,
547
+ }
548
+
549
+ return result
550
+
551
+ def delete_metric(self, metric_name: str) -> bool:
552
+ """
553
+ 删除指定的指标及其所有数据
554
+
555
+ Args:
556
+ metric_name: 要删除的指标名称
557
+
558
+ Returns:
559
+ True 如果成功删除,False 如果指标不存在
560
+ """
561
+ # 检查指标是否存在
562
+ meta = self._load_json(self.meta_file)
563
+ if metric_name not in meta.get("metrics", {}):
564
+ return False
565
+
566
+ # 从元数据中删除指标
567
+ del meta["metrics"][metric_name]
568
+ self._save_json(self.meta_file, meta)
569
+
570
+ # 遍历所有数据文件,删除该指标的数据
571
+ for data_file in self.data_dir.glob("stats_*.json"):
572
+ try:
573
+ data = self._load_json(data_file)
574
+ if metric_name in data:
575
+ del data[metric_name]
576
+ # 如果文件中还有其他数据,保存更新后的文件
577
+ if data:
578
+ self._save_json(data_file, data)
579
+ # 如果文件变空了,删除文件
580
+ else:
581
+ data_file.unlink()
582
+ except Exception:
583
+ # 忽略单个文件的错误,继续处理其他文件
584
+ pass
585
+
586
+ # 删除总量缓存文件
587
+ try:
588
+ total_file = self._get_total_file(metric_name)
589
+ if total_file.exists():
590
+ total_file.unlink()
591
+ except Exception:
592
+ pass
593
+
594
+ return True
595
+
596
+ def delete_old_data(self, days_to_keep: int = 30):
597
+ """删除旧数据"""
598
+ cutoff_date = (datetime.now() - timedelta(days=days_to_keep)).date()
599
+
600
+ # 遍历数据目录中的所有文件
601
+ for data_file in self.data_dir.glob("stats_*.json"):
602
+ try:
603
+ # 从文件名中提取日期
604
+ date_str = data_file.stem.replace("stats_", "")
605
+ file_date = datetime.strptime(date_str, "%Y-%m-%d").date()
606
+
607
+ # 如果文件日期早于截止日期,删除文件
608
+ if file_date < cutoff_date:
609
+ data_file.unlink()
610
+ except (ValueError, OSError):
611
+ # 忽略无法解析或删除的文件
612
+ continue