@roll-agent/octopus-agent 0.0.9 → 0.0.11
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 +1 -0
- package/SKILL.md +25 -1
- package/package.json +1 -1
- package/pyproject.toml +4 -2
- package/references/env.yaml +17 -0
- package/scripts/refresh_dictionary.py +67 -0
- package/scripts/start-octopus-agent.js +11 -13
- package/scripts/verify_binding.py +47 -0
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +1099 -146
- package/src/octopus_skill/config.py +58 -10
- package/src/octopus_skill/entity_binding.py +523 -0
- package/src/octopus_skill/entity_dictionary.py +59 -0
- package/src/octopus_skill/entity_resolver.py +446 -0
- package/src/octopus_skill/llm_sql_generator.py +3 -0
- package/src/octopus_skill/mcp_client.py +16 -0
- package/src/octopus_skill/octopus_run.py +369 -17
- package/src/octopus_skill/prompt_builder.py +74 -31
- package/src/octopus_skill/schema_context_retriever.py +199 -150
- package/src/octopus_skill/sql_generator.py +432 -801
- package/src/octopus_skill/query_sql_cache.py +0 -72
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
|
|
@@ -12,50 +12,133 @@ class SchemaContext:
|
|
|
12
12
|
glossary: list[dict[str, Any]]
|
|
13
13
|
metrics: list[dict[str, Any]]
|
|
14
14
|
examples: list[dict[str, Any]]
|
|
15
|
+
table_defaults: list[dict[str, Any]] = field(default_factory=list)
|
|
16
|
+
enums: list[dict[str, Any]] = field(default_factory=list)
|
|
15
17
|
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
32
|
-
"job_basic_info",
|
|
33
|
-
"brand",
|
|
34
|
-
"supplier",
|
|
35
|
-
"sponge_project",
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int = 12) -> SchemaContext:
|
|
19
|
+
def retrieve_schema_context(
|
|
20
|
+
schema: dict[str, Any],
|
|
21
|
+
question: str,
|
|
22
|
+
*,
|
|
23
|
+
max_tables: int = 20,
|
|
24
|
+
required_tables: set[str] | None = None,
|
|
25
|
+
) -> SchemaContext:
|
|
26
|
+
"""Assemble the schema slice handed to the LLM.
|
|
27
|
+
|
|
28
|
+
Everything is driven by the MCP ``get_schema`` payload: tables are ranked by
|
|
29
|
+
overlap with the question and with the glossary/metrics/examples that match
|
|
30
|
+
the question, then the schema's own ``joins`` graph is walked to pull in any
|
|
31
|
+
bridge tables needed to connect them. The agent stores no per-business table
|
|
32
|
+
lists, keyword triggers or hand-coded joins.
|
|
33
|
+
"""
|
|
40
34
|
glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
|
|
41
35
|
metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
|
|
42
36
|
examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
|
|
43
37
|
table_names_from_context = _target_table_names(glossary + metrics + examples)
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
38
|
+
example_table_names = _ordered_target_table_names(examples)
|
|
39
|
+
glossary_metric_table_names = _target_table_names(glossary + metrics)
|
|
40
|
+
required_tables = required_tables or set()
|
|
41
|
+
preferred = table_names_from_context | required_tables
|
|
42
|
+
|
|
43
|
+
tables = _rank_tables(schema.get("tables", []), question, preferred)[:max_tables]
|
|
44
|
+
if isinstance(schema.get("tables"), list):
|
|
45
|
+
# Pin the must-have tables to the front in a deterministic order:
|
|
46
|
+
# required (resolved-entity) tables first, then tables referenced by the
|
|
47
|
+
# best matched examples, then glossary / metrics. Example table order is
|
|
48
|
+
# preserved because examples often encode the intended join path; this
|
|
49
|
+
# keeps late-declared fact tables from being evicted by weaker matches.
|
|
50
|
+
ordered_names = _ordered_preferred_names(
|
|
51
|
+
schema.get("tables", []),
|
|
52
|
+
required_tables,
|
|
53
|
+
example_table_names,
|
|
54
|
+
glossary_metric_table_names,
|
|
55
|
+
)
|
|
56
|
+
if ordered_names:
|
|
57
|
+
tables = _prepend_named_tables(schema.get("tables", []), tables, ordered_names, max_tables)
|
|
58
|
+
tables = _expand_via_join_graph(schema, tables, max_tables)
|
|
49
59
|
|
|
60
|
+
selected_table_names = {_table_name(table) for table in tables}
|
|
61
|
+
joins = _filter_joins(schema.get("joins", []), selected_table_names, required_tables)
|
|
62
|
+
table_defaults = _filter_table_defaults(schema.get("tableDefaults", []), selected_table_names)
|
|
63
|
+
enums = _filter_enums(schema.get("enums", []), selected_table_names)
|
|
64
|
+
return SchemaContext(
|
|
65
|
+
tables=tables,
|
|
66
|
+
joins=joins,
|
|
67
|
+
glossary=glossary,
|
|
68
|
+
metrics=metrics,
|
|
69
|
+
examples=examples,
|
|
70
|
+
table_defaults=table_defaults,
|
|
71
|
+
enums=enums,
|
|
72
|
+
)
|
|
50
73
|
|
|
51
|
-
def _select_tables(tables: Any, question: str, preferred_names: set[str], max_tables: int) -> list[dict[str, Any]]:
|
|
52
|
-
ranked = _rank_tables(tables, question, preferred_names)
|
|
53
|
-
if _needs_work_order_detail_tables(question) and isinstance(tables, list):
|
|
54
|
-
return _prepend_named_tables(tables, ranked, WORK_ORDER_DETAIL_TABLES, max_tables)
|
|
55
|
-
if not _needs_location_recruiting_tables(question) or not isinstance(tables, list):
|
|
56
|
-
return ranked[:max_tables]
|
|
57
74
|
|
|
58
|
-
|
|
75
|
+
def _ordered_preferred_names(
|
|
76
|
+
tables: Any,
|
|
77
|
+
required_tables: set[str],
|
|
78
|
+
example_tables: tuple[str, ...],
|
|
79
|
+
context_tables: set[str],
|
|
80
|
+
) -> tuple[str, ...]:
|
|
81
|
+
"""Deterministic priority order for must-have tables.
|
|
82
|
+
|
|
83
|
+
Required (resolved-entity) tables come first and follow schema declaration
|
|
84
|
+
order. Matched example tables come next in the example SQL/table order, then
|
|
85
|
+
glossary / metric tables follow schema order. Required tables always win the
|
|
86
|
+
max_tables budget, while example join paths stay intact.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(tables, list):
|
|
89
|
+
return ()
|
|
90
|
+
schema_order = [_table_name(table) for table in tables if isinstance(table, dict)]
|
|
91
|
+
schema_names = set(schema_order)
|
|
92
|
+
required_names = [name for name in schema_order if name in required_tables]
|
|
93
|
+
example_names = [
|
|
94
|
+
name
|
|
95
|
+
for name in example_tables
|
|
96
|
+
if name in schema_names and name not in required_tables
|
|
97
|
+
]
|
|
98
|
+
context_names = [
|
|
99
|
+
name
|
|
100
|
+
for name in schema_order
|
|
101
|
+
if name in context_tables and name not in required_tables and name not in example_names
|
|
102
|
+
]
|
|
103
|
+
return tuple(dict.fromkeys(required_names + example_names + context_names))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _expand_via_join_graph(
|
|
107
|
+
schema: dict[str, Any],
|
|
108
|
+
tables: list[dict[str, Any]],
|
|
109
|
+
max_tables: int,
|
|
110
|
+
) -> list[dict[str, Any]]:
|
|
111
|
+
"""Add bridge tables that connect two or more already-selected tables.
|
|
112
|
+
|
|
113
|
+
Relationship knowledge lives entirely in ``schema.joins``; this walks that
|
|
114
|
+
graph so indirect paths (e.g. brand -> brand_project -> sponge_project) are
|
|
115
|
+
complete without hard-coding any business-specific table set.
|
|
116
|
+
"""
|
|
117
|
+
all_tables = schema.get("tables", [])
|
|
118
|
+
joins = schema.get("joins", [])
|
|
119
|
+
if not isinstance(all_tables, list) or not isinstance(joins, list):
|
|
120
|
+
return tables[:max_tables]
|
|
121
|
+
by_name = {_table_name(table): table for table in all_tables if isinstance(table, dict)}
|
|
122
|
+
selected = {_table_name(table) for table in tables}
|
|
123
|
+
adjacency: dict[str, set[str]] = {}
|
|
124
|
+
for join in joins:
|
|
125
|
+
if not isinstance(join, dict):
|
|
126
|
+
continue
|
|
127
|
+
left = str(join.get("leftTable") or join.get("from") or "")
|
|
128
|
+
right = str(join.get("rightTable") or join.get("to") or "")
|
|
129
|
+
if left and right:
|
|
130
|
+
adjacency.setdefault(left, set()).add(right)
|
|
131
|
+
adjacency.setdefault(right, set()).add(left)
|
|
132
|
+
result = list(tables)
|
|
133
|
+
for candidate, neighbours in adjacency.items():
|
|
134
|
+
if len(result) >= max_tables:
|
|
135
|
+
break
|
|
136
|
+
if candidate in selected or candidate not in by_name:
|
|
137
|
+
continue
|
|
138
|
+
if len(neighbours & selected) >= 2:
|
|
139
|
+
result.append(by_name[candidate])
|
|
140
|
+
selected.add(candidate)
|
|
141
|
+
return result[:max_tables]
|
|
59
142
|
|
|
60
143
|
|
|
61
144
|
def _prepend_named_tables(
|
|
@@ -85,14 +168,13 @@ def _prepend_named_tables(
|
|
|
85
168
|
def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[dict[str, Any]]:
|
|
86
169
|
if not isinstance(tables, list):
|
|
87
170
|
return []
|
|
171
|
+
tokens = _question_tokens(question)
|
|
88
172
|
ranked: list[tuple[int, dict[str, Any]]] = []
|
|
89
173
|
for table in tables:
|
|
90
174
|
if not isinstance(table, dict):
|
|
91
175
|
continue
|
|
92
176
|
name = _table_name(table)
|
|
93
177
|
score = 5 if name in preferred_names else 0
|
|
94
|
-
if _needs_work_order_detail_tables(question) and name in WORK_ORDER_DETAIL_TABLES:
|
|
95
|
-
score += 10
|
|
96
178
|
text = " ".join(
|
|
97
179
|
[
|
|
98
180
|
name,
|
|
@@ -100,7 +182,7 @@ def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[
|
|
|
100
182
|
" ".join(_column_text(column) for column in table.get("columns", []) if isinstance(column, dict)),
|
|
101
183
|
]
|
|
102
184
|
).lower()
|
|
103
|
-
for token in
|
|
185
|
+
for token in tokens:
|
|
104
186
|
if token and token in text:
|
|
105
187
|
score += 1
|
|
106
188
|
if score > 0 or not preferred_names:
|
|
@@ -119,19 +201,6 @@ def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list
|
|
|
119
201
|
continue
|
|
120
202
|
text = " ".join(str(item.get(key) or "") for key in keys + ("sql",)).lower()
|
|
121
203
|
score = sum(1 for token in tokens if token and token in text)
|
|
122
|
-
if _needs_work_order_detail_tables(question):
|
|
123
|
-
for term in (
|
|
124
|
-
"mvp_applied_jobs_by_helped_account",
|
|
125
|
-
"work_order_status",
|
|
126
|
-
"current_helper_status_id",
|
|
127
|
-
"job_basic_info.job_id",
|
|
128
|
-
"报名",
|
|
129
|
-
"工单",
|
|
130
|
-
"候选人状态",
|
|
131
|
-
"面试成功",
|
|
132
|
-
):
|
|
133
|
-
if term.lower() in text:
|
|
134
|
-
score += 5
|
|
135
204
|
if score > 0:
|
|
136
205
|
matches.append((score, item))
|
|
137
206
|
matches.sort(key=lambda match: match[0], reverse=True)
|
|
@@ -139,71 +208,89 @@ def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list
|
|
|
139
208
|
|
|
140
209
|
|
|
141
210
|
def _target_table_names(items: list[dict[str, Any]]) -> set[str]:
|
|
211
|
+
return set(_ordered_target_table_names(items))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _ordered_target_table_names(items: list[dict[str, Any]]) -> tuple[str, ...]:
|
|
142
215
|
names: set[str] = set()
|
|
216
|
+
ordered: list[str] = []
|
|
143
217
|
for item in items:
|
|
144
218
|
for key in ("tables", "targetTables"):
|
|
145
219
|
value = item.get(key)
|
|
146
220
|
if isinstance(value, list):
|
|
147
|
-
|
|
221
|
+
for name in value:
|
|
222
|
+
_append_unique_table(ordered, names, str(name))
|
|
148
223
|
sql = item.get("sql")
|
|
149
224
|
if isinstance(sql, str):
|
|
150
|
-
|
|
225
|
+
for referenced_table in _referenced_tables(sql):
|
|
226
|
+
_append_unique_table(ordered, names, referenced_table)
|
|
151
227
|
targets = item.get("targets")
|
|
152
228
|
if isinstance(targets, list):
|
|
153
229
|
for target in targets:
|
|
154
230
|
if isinstance(target, str) and "." in target:
|
|
155
|
-
names
|
|
156
|
-
return
|
|
231
|
+
_append_unique_table(ordered, names, target.split(".", 1)[0])
|
|
232
|
+
return tuple(ordered)
|
|
157
233
|
|
|
158
234
|
|
|
159
|
-
def
|
|
235
|
+
def _append_unique_table(ordered: list[str], seen: set[str], name: str) -> None:
|
|
236
|
+
if not name or name in seen:
|
|
237
|
+
return
|
|
238
|
+
ordered.append(name)
|
|
239
|
+
seen.add(name)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _filter_joins(
|
|
243
|
+
joins: Any,
|
|
244
|
+
selected_table_names: set[str],
|
|
245
|
+
priority_table_names: set[str] | None = None,
|
|
246
|
+
) -> list[dict[str, Any]]:
|
|
160
247
|
if not isinstance(joins, list):
|
|
161
248
|
return []
|
|
162
|
-
|
|
163
|
-
|
|
249
|
+
priority_table_names = priority_table_names or set()
|
|
250
|
+
direct: list[tuple[int, int, dict[str, Any]]] = []
|
|
251
|
+
adjacent: list[tuple[int, int, dict[str, Any]]] = []
|
|
252
|
+
for index, join in enumerate(joins):
|
|
164
253
|
if not isinstance(join, dict):
|
|
165
254
|
continue
|
|
166
255
|
join_tables = {
|
|
167
256
|
str(join.get("leftTable") or join.get("from") or ""),
|
|
168
257
|
str(join.get("rightTable") or join.get("to") or ""),
|
|
169
258
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
259
|
+
matched = join_tables & selected_table_names
|
|
260
|
+
priority_score = len(join_tables & priority_table_names)
|
|
261
|
+
if join_tables <= selected_table_names:
|
|
262
|
+
direct.append((-priority_score, index, join))
|
|
263
|
+
elif matched:
|
|
264
|
+
adjacent.append((-priority_score, index, join))
|
|
265
|
+
direct.sort()
|
|
266
|
+
adjacent.sort()
|
|
267
|
+
return [join for _, _, join in direct + adjacent][:16]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _filter_table_defaults(table_defaults: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
|
|
271
|
+
"""Pass through default filters for selected tables (data from get_schema)."""
|
|
272
|
+
if not isinstance(table_defaults, list):
|
|
273
|
+
return []
|
|
274
|
+
result: list[dict[str, Any]] = []
|
|
275
|
+
for entry in table_defaults:
|
|
276
|
+
if isinstance(entry, dict) and str(entry.get("table") or "") in selected_table_names:
|
|
277
|
+
result.append(entry)
|
|
278
|
+
return result
|
|
173
279
|
|
|
174
280
|
|
|
175
|
-
def
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
for join in result
|
|
189
|
-
}
|
|
190
|
-
if (
|
|
191
|
-
{"resume", "supplier"} <= selected_table_names
|
|
192
|
-
and _table_has_column(schema, "resume", "supplier_id")
|
|
193
|
-
and _table_has_column(schema, "supplier", "id")
|
|
194
|
-
and ("supplier", "id", "resume", "supplier_id") not in existing
|
|
195
|
-
and ("resume", "supplier_id", "supplier", "id") not in existing
|
|
196
|
-
):
|
|
197
|
-
result.append(
|
|
198
|
-
{
|
|
199
|
-
"description": "简历和供应商关系;供应商名称可使用 supplier.nick_name",
|
|
200
|
-
"leftTable": "supplier",
|
|
201
|
-
"leftColumn": "id",
|
|
202
|
-
"rightTable": "resume",
|
|
203
|
-
"rightColumn": "supplier_id",
|
|
204
|
-
}
|
|
205
|
-
)
|
|
206
|
-
return result[:16]
|
|
281
|
+
def _filter_enums(enums: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
|
|
282
|
+
"""Pass through enum definitions whose field belongs to a selected table."""
|
|
283
|
+
if not isinstance(enums, list):
|
|
284
|
+
return []
|
|
285
|
+
result: list[dict[str, Any]] = []
|
|
286
|
+
for entry in enums:
|
|
287
|
+
if not isinstance(entry, dict):
|
|
288
|
+
continue
|
|
289
|
+
field = str(entry.get("field") or "")
|
|
290
|
+
table = field.split(".", 1)[0] if "." in field else ""
|
|
291
|
+
if table in selected_table_names:
|
|
292
|
+
result.append(entry)
|
|
293
|
+
return result
|
|
207
294
|
|
|
208
295
|
|
|
209
296
|
def _table_name(table: dict[str, Any]) -> str:
|
|
@@ -216,65 +303,27 @@ def _column_text(column: dict[str, Any]) -> str:
|
|
|
216
303
|
)
|
|
217
304
|
|
|
218
305
|
|
|
219
|
-
def _table_has_column(schema: dict[str, Any], table_name: str, column_name: str) -> bool:
|
|
220
|
-
tables = schema.get("tables", [])
|
|
221
|
-
if not isinstance(tables, list):
|
|
222
|
-
return False
|
|
223
|
-
for table in tables:
|
|
224
|
-
if not isinstance(table, dict) or _table_name(table) != table_name:
|
|
225
|
-
continue
|
|
226
|
-
columns = table.get("columns", [])
|
|
227
|
-
if not isinstance(columns, list):
|
|
228
|
-
return False
|
|
229
|
-
return any(
|
|
230
|
-
isinstance(column, dict)
|
|
231
|
-
and str(column.get("columnName") or column.get("name") or "") == column_name
|
|
232
|
-
for column in columns
|
|
233
|
-
)
|
|
234
|
-
return False
|
|
235
|
-
|
|
236
|
-
|
|
237
306
|
def _question_tokens(question: str) -> list[str]:
|
|
307
|
+
"""Generic tokenization with no business vocabulary.
|
|
308
|
+
|
|
309
|
+
Produces the lowered question, its ascii word splits, and CJK character
|
|
310
|
+
bigrams so Chinese questions can still overlap Chinese table comments /
|
|
311
|
+
glossary text without any hand-maintained keyword list.
|
|
312
|
+
"""
|
|
238
313
|
lowered = question.lower()
|
|
239
314
|
tokens = [lowered]
|
|
240
|
-
tokens.extend(part for part in
|
|
241
|
-
for
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
"招聘",
|
|
247
|
-
"在招",
|
|
248
|
-
"数量",
|
|
249
|
-
"列表",
|
|
250
|
-
"报名",
|
|
251
|
-
"报名情况",
|
|
252
|
-
"报名工单",
|
|
253
|
-
"工单",
|
|
254
|
-
"候选人",
|
|
255
|
-
"候选人状态",
|
|
256
|
-
"面试成功",
|
|
257
|
-
"进行中",
|
|
258
|
-
"姓名",
|
|
259
|
-
"人员",
|
|
260
|
-
"清单",
|
|
261
|
-
"供应商",
|
|
262
|
-
):
|
|
263
|
-
if word in question:
|
|
264
|
-
tokens.append(word)
|
|
315
|
+
tokens.extend(part for part in re.split(r"[\s_\-]+", lowered) if part)
|
|
316
|
+
for run in re.findall(r"[\u4e00-\u9fff]+", question):
|
|
317
|
+
if len(run) == 1:
|
|
318
|
+
tokens.append(run)
|
|
319
|
+
for index in range(len(run) - 1):
|
|
320
|
+
tokens.append(run[index : index + 2])
|
|
265
321
|
return list(dict.fromkeys(tokens))
|
|
266
322
|
|
|
267
323
|
|
|
268
|
-
def _referenced_tables(sql: str) ->
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _needs_work_order_detail_tables(question: str) -> bool:
|
|
277
|
-
return bool(
|
|
278
|
-
any(word in question for word in ("报名", "工单", "候选人"))
|
|
279
|
-
and any(word in question for word in ("姓名", "岗位", "项目", "候选人状态"))
|
|
280
|
-
)
|
|
324
|
+
def _referenced_tables(sql: str) -> tuple[str, ...]:
|
|
325
|
+
names: list[str] = []
|
|
326
|
+
seen: set[str] = set()
|
|
327
|
+
for match in re.finditer(r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b", sql, re.IGNORECASE):
|
|
328
|
+
_append_unique_table(names, seen, match.group(1))
|
|
329
|
+
return tuple(names)
|