@roll-agent/octopus-agent 0.1.2 → 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.
Files changed (36) hide show
  1. package/README.md +26 -125
  2. package/SKILL.md +27 -102
  3. package/package.json +12 -18
  4. package/references/env.yaml +1 -22
  5. package/scripts/start-octopus-agent.js +1 -125
  6. package/src/config.js +13 -0
  7. package/src/context.js +5 -0
  8. package/src/mcp-client.js +80 -0
  9. package/src/orchestrator.js +57 -0
  10. package/src/server-core.js +67 -0
  11. package/src/server.js +10 -0
  12. package/src/stdio.js +29 -0
  13. package/octopus_skill/__init__.py +0 -11
  14. package/pyproject.toml +0 -19
  15. package/scripts/refresh_dictionary.py +0 -67
  16. package/scripts/verify_binding.py +0 -47
  17. package/src/octopus_skill/__init__.py +0 -5
  18. package/src/octopus_skill/__main__.py +0 -4
  19. package/src/octopus_skill/_version.py +0 -3
  20. package/src/octopus_skill/agent.py +0 -4971
  21. package/src/octopus_skill/audit_logger.py +0 -29
  22. package/src/octopus_skill/config.py +0 -129
  23. package/src/octopus_skill/context.py +0 -17
  24. package/src/octopus_skill/device_info.py +0 -7
  25. package/src/octopus_skill/exporter.py +0 -66
  26. package/src/octopus_skill/llm_result_renderer.py +0 -101
  27. package/src/octopus_skill/llm_sql_generator.py +0 -197
  28. package/src/octopus_skill/main.py +0 -26
  29. package/src/octopus_skill/mcp_client.py +0 -114
  30. package/src/octopus_skill/octopus_run.py +0 -752
  31. package/src/octopus_skill/prompt_builder.py +0 -519
  32. package/src/octopus_skill/question_analyzer.py +0 -399
  33. package/src/octopus_skill/result_renderer.py +0 -367
  34. package/src/octopus_skill/schema_cache.py +0 -49
  35. package/src/octopus_skill/schema_context_retriever.py +0 -1053
  36. package/src/octopus_skill/sql_generator.py +0 -2788
@@ -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