@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.
@@ -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
- LOCATION_RECRUITING_TABLES = (
18
- "province",
19
- "city",
20
- "region",
21
- "job_basic_info",
22
- "job_address",
23
- "job_store",
24
- "store",
25
- )
26
-
27
- WORK_ORDER_DETAIL_TABLES = (
28
- "mvp_applied_jobs_by_helped_account",
29
- "c_helped_account",
30
- "resume",
31
- "job",
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
- tables = _select_tables(schema.get("tables", []), question, table_names_from_context, max_tables)
45
- selected_table_names = {_table_name(table) for table in tables}
46
- joins = _filter_joins(schema.get("joins", []), selected_table_names)
47
- joins = _append_derived_joins(schema, joins, selected_table_names)
48
- return SchemaContext(tables=tables, joins=joins, glossary=glossary, metrics=metrics, examples=examples)
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
- return _prepend_named_tables(tables, ranked, LOCATION_RECRUITING_TABLES, max_tables)
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 _question_tokens(question):
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
- names.update(str(name) for name in value)
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
- names.update(referenced_table for referenced_table in _referenced_tables(sql))
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.add(target.split(".", 1)[0])
156
- return names
231
+ _append_unique_table(ordered, names, target.split(".", 1)[0])
232
+ return tuple(ordered)
157
233
 
158
234
 
159
- def _filter_joins(joins: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
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
- result: list[dict[str, Any]] = []
163
- for join in joins:
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
- if join_tables & selected_table_names:
171
- result.append(join)
172
- return result[:12]
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 _append_derived_joins(
176
- schema: dict[str, Any],
177
- joins: list[dict[str, Any]],
178
- selected_table_names: set[str],
179
- ) -> list[dict[str, Any]]:
180
- result = list(joins)
181
- existing = {
182
- (
183
- str(join.get("leftTable") or ""),
184
- str(join.get("leftColumn") or ""),
185
- str(join.get("rightTable") or ""),
186
- str(join.get("rightColumn") or ""),
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 lowered.replace("_", " ").replace("-", " ").split() if part)
241
- for word in (
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) -> set[str]:
269
- return set(match.group(1) for match in re.finditer(r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b", sql, re.IGNORECASE))
270
-
271
-
272
- def _needs_location_recruiting_tables(question: str) -> bool:
273
- return any(word in question for word in ("岗位", "门店", "招聘", "在招"))
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)