@roll-agent/octopus-agent 0.1.3 → 0.2.0
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.
- package/README.md +26 -190
- package/SKILL.md +27 -103
- package/package.json +12 -18
- package/references/env.yaml +1 -22
- package/scripts/start-octopus-agent.js +1 -125
- package/src/config.js +13 -0
- package/src/context.js +5 -0
- package/src/mcp-client.js +80 -0
- package/src/orchestrator.js +57 -0
- package/src/server-core.js +67 -0
- package/src/server.js +10 -0
- package/src/stdio.js +29 -0
- package/octopus_skill/__init__.py +0 -11
- package/pyproject.toml +0 -19
- package/scripts/refresh_dictionary.py +0 -67
- package/scripts/verify_binding.py +0 -47
- package/src/octopus_skill/__init__.py +0 -5
- package/src/octopus_skill/__main__.py +0 -4
- package/src/octopus_skill/_version.py +0 -3
- package/src/octopus_skill/agent.py +0 -5739
- package/src/octopus_skill/audit_logger.py +0 -29
- package/src/octopus_skill/config.py +0 -129
- package/src/octopus_skill/context.py +0 -17
- package/src/octopus_skill/device_info.py +0 -7
- package/src/octopus_skill/exporter.py +0 -66
- package/src/octopus_skill/llm_result_renderer.py +0 -101
- package/src/octopus_skill/llm_sql_generator.py +0 -197
- package/src/octopus_skill/main.py +0 -26
- package/src/octopus_skill/mcp_client.py +0 -114
- package/src/octopus_skill/octopus_run.py +0 -755
- package/src/octopus_skill/prompt_builder.py +0 -522
- package/src/octopus_skill/question_analyzer.py +0 -401
- package/src/octopus_skill/result_renderer.py +0 -367
- package/src/octopus_skill/schema_cache.py +0 -49
- package/src/octopus_skill/schema_context_retriever.py +0 -1053
- package/src/octopus_skill/sql_generator.py +0 -2866
|
@@ -1,367 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import html
|
|
4
|
-
import json
|
|
5
|
-
import re
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
SUPPORTED_RESULT_FORMATS = {"text", "json", "yaml", "md", "markdown", "html"}
|
|
10
|
-
RESULT_ROW_DISPLAY_LIMIT = 500
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def normalize_result_format(question: str, result_format: str | None = None) -> str:
|
|
14
|
-
if result_format:
|
|
15
|
-
normalized = result_format.lower().strip()
|
|
16
|
-
if normalized not in SUPPORTED_RESULT_FORMATS:
|
|
17
|
-
raise ValueError(f"Unsupported resultFormat: {result_format}")
|
|
18
|
-
return "markdown" if normalized == "md" else normalized
|
|
19
|
-
lowered = question.lower()
|
|
20
|
-
if "json" in lowered:
|
|
21
|
-
return "json"
|
|
22
|
-
if "yaml" in lowered:
|
|
23
|
-
return "yaml"
|
|
24
|
-
if "html" in lowered:
|
|
25
|
-
return "html"
|
|
26
|
-
if "markdown" in lowered or " md" in lowered or "表格" in question:
|
|
27
|
-
return "markdown"
|
|
28
|
-
if _is_detail_table_question(question):
|
|
29
|
-
return "markdown"
|
|
30
|
-
if _is_match_effect_report_question(question):
|
|
31
|
-
return "markdown"
|
|
32
|
-
return "text"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def render_result(question: str, result: dict[str, Any], result_format: str | None = None) -> str:
|
|
36
|
-
output_format = normalize_result_format(question, result_format)
|
|
37
|
-
if not result.get("success", True):
|
|
38
|
-
return _format_failure(output_format)
|
|
39
|
-
row_count = int(result.get("rowCount", len(result.get("rows", []))))
|
|
40
|
-
rows = result.get("rows", [])
|
|
41
|
-
columns = result.get("columns", [])
|
|
42
|
-
if row_count == 0:
|
|
43
|
-
return _format_empty(output_format)
|
|
44
|
-
sample = rows[:RESULT_ROW_DISPLAY_LIMIT] if isinstance(rows, list) else []
|
|
45
|
-
if output_format == "json":
|
|
46
|
-
return _render_json(row_count, bool(result.get("limited", False)), sample)
|
|
47
|
-
if output_format == "yaml":
|
|
48
|
-
return _render_yaml(question, row_count, bool(result.get("limited", False)), sample)
|
|
49
|
-
if output_format == "markdown":
|
|
50
|
-
if _is_match_effect_report_question(question):
|
|
51
|
-
return _render_match_effect_report(question, row_count, bool(result.get("limited", False)), sample, columns)
|
|
52
|
-
return _render_markdown(question, row_count, bool(result.get("limited", False)), sample, columns)
|
|
53
|
-
if output_format == "html":
|
|
54
|
-
return _render_html(question, row_count, bool(result.get("limited", False)), sample, columns)
|
|
55
|
-
return _render_text(question, row_count, bool(result.get("limited", False)), sample, columns)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _format_failure(output_format: str) -> str:
|
|
59
|
-
if output_format == "json":
|
|
60
|
-
return json.dumps({"success": False, "message": "查询失败。"}, ensure_ascii=False, indent=2) + "\n"
|
|
61
|
-
if output_format == "yaml":
|
|
62
|
-
return "success: false\nmessage: 查询失败。\n"
|
|
63
|
-
if output_format == "markdown":
|
|
64
|
-
return "查询失败。"
|
|
65
|
-
if output_format == "html":
|
|
66
|
-
return "<p>查询失败。</p>"
|
|
67
|
-
return "查询失败。"
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _format_empty(output_format: str) -> str:
|
|
71
|
-
if output_format == "json":
|
|
72
|
-
return json.dumps(
|
|
73
|
-
{"success": True, "rowCount": 0, "rows": [], "message": "没有查询到符合条件的数据。"},
|
|
74
|
-
ensure_ascii=False,
|
|
75
|
-
indent=2,
|
|
76
|
-
) + "\n"
|
|
77
|
-
if output_format == "yaml":
|
|
78
|
-
return "success: true\nrowCount: 0\nrows: []\nmessage: 没有查询到符合条件的数据。\n"
|
|
79
|
-
if output_format == "markdown":
|
|
80
|
-
return "返回 0 行。\n\n没有查询到符合条件的数据。"
|
|
81
|
-
if output_format == "html":
|
|
82
|
-
return "<p>没有查询到符合条件的数据。</p>"
|
|
83
|
-
return "没有查询到符合条件的数据。"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _is_match_effect_report_question(question: str) -> bool:
|
|
87
|
-
return bool(
|
|
88
|
-
("匹配效果" in question or "分析报告" in question)
|
|
89
|
-
and "岗位" in question
|
|
90
|
-
and any(term in question for term in ("候选人", "报名", "入职", "上岗"))
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _is_detail_table_question(question: str) -> bool:
|
|
95
|
-
if any(term in question for term in ("明细", "列表", "清单")):
|
|
96
|
-
return True
|
|
97
|
-
if "对应" not in question:
|
|
98
|
-
return False
|
|
99
|
-
return any(term in question for term in ("项目", "品牌", "门店", "岗位", "城市", "日期", "时间"))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _render_match_effect_report(
|
|
103
|
-
question: str,
|
|
104
|
-
row_count: int,
|
|
105
|
-
limited: bool,
|
|
106
|
-
rows: list[Any],
|
|
107
|
-
columns: Any,
|
|
108
|
-
) -> str:
|
|
109
|
-
table_columns = _column_names(columns, rows)
|
|
110
|
-
display_count = len(rows)
|
|
111
|
-
period = _extract_report_period(question)
|
|
112
|
-
lines = [
|
|
113
|
-
f"# {period}岗位与候选人匹配效果分析报告",
|
|
114
|
-
"",
|
|
115
|
-
"## 完整分析报告",
|
|
116
|
-
"",
|
|
117
|
-
f"数据范围:{period};维度:品牌、项目、城市;口径:入职按上岗结果近似统计。",
|
|
118
|
-
_display_row_count_line(row_count, display_count),
|
|
119
|
-
f"limited: {str(limited).lower()}",
|
|
120
|
-
]
|
|
121
|
-
if table_columns:
|
|
122
|
-
display_columns = ["序号"] + table_columns
|
|
123
|
-
lines.extend(["", "|" + "|".join(_escape_markdown(column) for column in display_columns) + "|"])
|
|
124
|
-
lines.append("|" + "|".join("---" for _ in display_columns) + "|")
|
|
125
|
-
for index, row in enumerate(rows, start=1):
|
|
126
|
-
values = [str(index)] + [_cell_value(row, column) for column in table_columns]
|
|
127
|
-
lines.append("|" + "|".join(_escape_markdown(value) for value in values) + "|")
|
|
128
|
-
else:
|
|
129
|
-
lines.extend(["", "```json", json.dumps(rows, ensure_ascii=False, indent=2), "```"])
|
|
130
|
-
lines.extend(["", "## 总结关键发现", ""])
|
|
131
|
-
findings = _match_effect_findings(rows)
|
|
132
|
-
lines.extend(findings or ["- 暂未发现明显异常组合。"])
|
|
133
|
-
return "\n".join(lines)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def _extract_report_period(question: str) -> str:
|
|
137
|
-
relative_month = re.search(r"(今年|去年|前年|明年|后年)\s*(\d{1,2})\s*月", question)
|
|
138
|
-
if relative_month is not None:
|
|
139
|
-
return f"{relative_month.group(1)}{int(relative_month.group(2))}月"
|
|
140
|
-
explicit_month = re.search(r"(20\d{2})\s*年\s*(\d{1,2})\s*月", question)
|
|
141
|
-
if explicit_month is not None:
|
|
142
|
-
return f"{explicit_month.group(1)}年{int(explicit_month.group(2))}月"
|
|
143
|
-
return "本期"
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def _match_effect_findings(rows: list[Any]) -> list[str]:
|
|
147
|
-
if not rows:
|
|
148
|
-
return []
|
|
149
|
-
buckets = {
|
|
150
|
-
"报名多但入职少": [],
|
|
151
|
-
"在招多但报名少": [],
|
|
152
|
-
"候选人多但岗位少": [],
|
|
153
|
-
}
|
|
154
|
-
for row in rows:
|
|
155
|
-
if not isinstance(row, dict):
|
|
156
|
-
continue
|
|
157
|
-
issue = str(row.get("问题类型", ""))
|
|
158
|
-
label = _dimension_label(row)
|
|
159
|
-
for bucket in buckets:
|
|
160
|
-
if bucket in issue:
|
|
161
|
-
buckets[bucket].append(label)
|
|
162
|
-
findings: list[str] = []
|
|
163
|
-
for bucket, labels in buckets.items():
|
|
164
|
-
if labels:
|
|
165
|
-
findings.append(f"- {bucket}:{_join_limited(labels)}。")
|
|
166
|
-
return findings
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _dimension_label(row: dict[str, Any]) -> str:
|
|
170
|
-
parts = [
|
|
171
|
-
str(row.get("品牌名称") or "未知品牌"),
|
|
172
|
-
str(row.get("项目名称") or "未知项目"),
|
|
173
|
-
str(row.get("城市名称") or "未知城市"),
|
|
174
|
-
]
|
|
175
|
-
return " / ".join(parts)
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _join_limited(values: list[str], limit: int = 3) -> str:
|
|
179
|
-
visible = values[:limit]
|
|
180
|
-
suffix = f"等 {len(values)} 项" if len(values) > limit else ""
|
|
181
|
-
return "、".join(visible + ([suffix] if suffix else []))
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
def _render_json(row_count: int, limited: bool, rows: list[Any]) -> str:
|
|
185
|
-
return json.dumps(
|
|
186
|
-
{
|
|
187
|
-
"rowCount": row_count,
|
|
188
|
-
"limited": limited,
|
|
189
|
-
"rows": rows,
|
|
190
|
-
},
|
|
191
|
-
ensure_ascii=False,
|
|
192
|
-
indent=2,
|
|
193
|
-
) + "\n"
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def _render_yaml(question: str, row_count: int, limited: bool, rows: list[Any]) -> str:
|
|
197
|
-
lines = [
|
|
198
|
-
f"rowCount: {row_count}",
|
|
199
|
-
f"limited: {_yaml_bool(limited)}",
|
|
200
|
-
"rows:",
|
|
201
|
-
]
|
|
202
|
-
for row in rows:
|
|
203
|
-
lines.append(" -")
|
|
204
|
-
if isinstance(row, dict):
|
|
205
|
-
for key, value in row.items():
|
|
206
|
-
lines.append(f" {key}: {json.dumps(value, ensure_ascii=False)}")
|
|
207
|
-
else:
|
|
208
|
-
lines.append(f" value: {json.dumps(row, ensure_ascii=False)}")
|
|
209
|
-
return "\n".join(lines) + "\n"
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _render_markdown(
|
|
213
|
-
question: str,
|
|
214
|
-
row_count: int,
|
|
215
|
-
limited: bool,
|
|
216
|
-
rows: list[Any],
|
|
217
|
-
columns: Any,
|
|
218
|
-
) -> str:
|
|
219
|
-
table_columns = _column_names(columns, rows)
|
|
220
|
-
lines = [_display_row_count_line(row_count, len(rows)), f"limited: {str(limited).lower()}"]
|
|
221
|
-
if not table_columns:
|
|
222
|
-
lines.extend(["", "```json", json.dumps(rows, ensure_ascii=False, indent=2), "```"])
|
|
223
|
-
return "\n".join(lines)
|
|
224
|
-
display_columns = ["序号"] + table_columns
|
|
225
|
-
lines.extend(["", "|" + "|".join(_escape_markdown(column) for column in display_columns) + "|"])
|
|
226
|
-
lines.append("|" + "|".join("---" for _ in display_columns) + "|")
|
|
227
|
-
for index, row in enumerate(rows, start=1):
|
|
228
|
-
values = [str(index)] + [_cell_value(row, column) for column in table_columns]
|
|
229
|
-
lines.append("|" + "|".join(_escape_markdown(value) for value in values) + "|")
|
|
230
|
-
return "\n".join(lines)
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def _render_html(
|
|
234
|
-
question: str,
|
|
235
|
-
row_count: int,
|
|
236
|
-
limited: bool,
|
|
237
|
-
rows: list[Any],
|
|
238
|
-
columns: Any,
|
|
239
|
-
) -> str:
|
|
240
|
-
table_columns = _column_names(columns, rows)
|
|
241
|
-
parts = [
|
|
242
|
-
"<section>",
|
|
243
|
-
f"<p>{html.escape(_display_row_count_line(row_count, len(rows)))} limited: {str(limited).lower()}</p>",
|
|
244
|
-
]
|
|
245
|
-
if table_columns:
|
|
246
|
-
display_columns = ["序号"] + table_columns
|
|
247
|
-
parts.append("<table>")
|
|
248
|
-
parts.append("<thead><tr>" + "".join(f"<th>{html.escape(column)}</th>" for column in display_columns) + "</tr></thead>")
|
|
249
|
-
parts.append("<tbody>")
|
|
250
|
-
for index, row in enumerate(rows, start=1):
|
|
251
|
-
values = [str(index)] + [_cell_value(row, column) for column in table_columns]
|
|
252
|
-
parts.append("<tr>" + "".join(f"<td>{html.escape(value)}</td>" for value in values) + "</tr>")
|
|
253
|
-
parts.append("</tbody></table>")
|
|
254
|
-
else:
|
|
255
|
-
parts.append(f"<pre>{html.escape(json.dumps(rows, ensure_ascii=False, indent=2))}</pre>")
|
|
256
|
-
parts.append("</section>")
|
|
257
|
-
return "".join(parts)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def _display_row_count_line(row_count: int, display_count: int) -> str:
|
|
261
|
-
if row_count == display_count:
|
|
262
|
-
return f"返回 {row_count} 行。"
|
|
263
|
-
return f"当前展示 {display_count} 行。"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def _render_text(
|
|
267
|
-
question: str,
|
|
268
|
-
row_count: int,
|
|
269
|
-
limited: bool,
|
|
270
|
-
rows: list[Any],
|
|
271
|
-
columns: Any,
|
|
272
|
-
) -> str:
|
|
273
|
-
visible_columns = _text_columns(question, columns, rows)
|
|
274
|
-
if not visible_columns:
|
|
275
|
-
return f"查询到 {row_count} 条结果。"
|
|
276
|
-
|
|
277
|
-
noun = _result_noun(question)
|
|
278
|
-
lines = [f"查询到 {row_count} 个{noun}:", ""]
|
|
279
|
-
include_id = _question_requests_id(question)
|
|
280
|
-
for index, row in enumerate(rows, start=1):
|
|
281
|
-
text = _format_text_row(row, visible_columns, include_id)
|
|
282
|
-
if text:
|
|
283
|
-
lines.append(f"{index}. {text}")
|
|
284
|
-
if limited:
|
|
285
|
-
lines.append("")
|
|
286
|
-
lines.append("结果较多,已按当前限制返回部分数据。")
|
|
287
|
-
return "\n".join(lines)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _text_columns(question: str, columns: Any, rows: list[Any]) -> list[str]:
|
|
291
|
-
names = _column_names(columns, rows)
|
|
292
|
-
if not names:
|
|
293
|
-
return []
|
|
294
|
-
allow_id = _question_requests_id(question)
|
|
295
|
-
visible = [name for name in names if allow_id or not _is_id_column(name)]
|
|
296
|
-
name_columns = [name for name in visible if _is_name_column(name)]
|
|
297
|
-
if allow_id:
|
|
298
|
-
return visible
|
|
299
|
-
return name_columns or visible
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def _format_text_row(row: Any, columns: list[str], include_id: bool) -> str:
|
|
303
|
-
if not isinstance(row, dict):
|
|
304
|
-
return str(row)
|
|
305
|
-
name_column = next((column for column in columns if _is_name_column(column)), None)
|
|
306
|
-
id_column = next((column for column in columns if _is_id_column(column)), None)
|
|
307
|
-
name_value = _cell_value(row, name_column) if name_column else ""
|
|
308
|
-
id_value = _cell_value(row, id_column) if id_column else ""
|
|
309
|
-
if include_id and name_value and id_value:
|
|
310
|
-
return f"{name_value}(ID:{id_value})"
|
|
311
|
-
values = [_cell_value(row, column) for column in columns]
|
|
312
|
-
values = [value for value in values if value]
|
|
313
|
-
return ",".join(values)
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _question_requests_id(question: str) -> bool:
|
|
317
|
-
lowered = question.lower()
|
|
318
|
-
return "id" in lowered or "编号" in question or "主键" in question
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def _is_id_column(column: str) -> bool:
|
|
322
|
-
lowered = column.lower()
|
|
323
|
-
return lowered == "id" or lowered.endswith("_id") or lowered.endswith(".id")
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _is_name_column(column: str) -> bool:
|
|
327
|
-
lowered = column.lower()
|
|
328
|
-
return lowered == "name" or lowered.endswith("_name") or lowered.endswith(".name") or "名称" in column
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
def _result_noun(question: str) -> str:
|
|
332
|
-
if "品牌" in question:
|
|
333
|
-
return "品牌"
|
|
334
|
-
if "岗位" in question:
|
|
335
|
-
return "岗位"
|
|
336
|
-
if "门店" in question:
|
|
337
|
-
return "门店"
|
|
338
|
-
if "项目" in question:
|
|
339
|
-
return "项目"
|
|
340
|
-
return "结果"
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
def _column_names(columns: Any, rows: list[Any]) -> list[str]:
|
|
344
|
-
names: list[str] = []
|
|
345
|
-
if isinstance(columns, list):
|
|
346
|
-
for column in columns:
|
|
347
|
-
if isinstance(column, dict) and column.get("name"):
|
|
348
|
-
names.append(str(column["name"]))
|
|
349
|
-
elif isinstance(column, str):
|
|
350
|
-
names.append(column)
|
|
351
|
-
if not names and rows and isinstance(rows[0], dict):
|
|
352
|
-
names = [str(key) for key in rows[0].keys()]
|
|
353
|
-
return names
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _cell_value(row: Any, column: str) -> str:
|
|
357
|
-
if isinstance(row, dict):
|
|
358
|
-
return str(row.get(column, ""))
|
|
359
|
-
return str(row)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
def _escape_markdown(value: str) -> str:
|
|
363
|
-
return value.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
def _yaml_bool(value: bool) -> str:
|
|
367
|
-
return "true" if value else "false"
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import time
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class SchemaCacheError(RuntimeError):
|
|
11
|
-
"""Raised when the persisted schema cache cannot be used."""
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class SchemaCache:
|
|
16
|
-
path: Path
|
|
17
|
-
|
|
18
|
-
def load(self) -> dict[str, Any] | None:
|
|
19
|
-
if not self.path.exists():
|
|
20
|
-
return None
|
|
21
|
-
try:
|
|
22
|
-
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
23
|
-
except json.JSONDecodeError as exc:
|
|
24
|
-
raise SchemaCacheError(f"Invalid schema cache JSON: {self.path}") from exc
|
|
25
|
-
if not isinstance(data, dict):
|
|
26
|
-
raise SchemaCacheError(f"Schema cache must be a JSON object: {self.path}")
|
|
27
|
-
return data
|
|
28
|
-
|
|
29
|
-
def save(self, schema: dict[str, Any]) -> None:
|
|
30
|
-
if not isinstance(schema, dict):
|
|
31
|
-
raise TypeError("schema must be a dict")
|
|
32
|
-
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
-
self.path.write_text(
|
|
34
|
-
json.dumps(schema, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
35
|
-
encoding="utf-8",
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
def version(self) -> str | None:
|
|
39
|
-
schema = self.load()
|
|
40
|
-
if schema is None:
|
|
41
|
-
return None
|
|
42
|
-
version = schema.get("schemaVersion")
|
|
43
|
-
return str(version) if version is not None else None
|
|
44
|
-
|
|
45
|
-
def is_fresh(self, max_age_minutes: int) -> bool:
|
|
46
|
-
if max_age_minutes <= 0 or not self.path.exists():
|
|
47
|
-
return False
|
|
48
|
-
age_seconds = time.time() - self.path.stat().st_mtime
|
|
49
|
-
return age_seconds <= max_age_minutes * 60
|