@roll-agent/octopus-agent 0.0.10 → 0.1.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.
@@ -1,61 +1,451 @@
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
 
8
8
  @dataclass(frozen=True)
9
9
  class SchemaContext:
10
10
  tables: list[dict[str, Any]]
11
- joins: list[dict[str, Any]]
12
- glossary: list[dict[str, Any]]
11
+ relations: list[dict[str, Any]]
12
+ business_terms: list[dict[str, Any]]
13
+ concepts: list[dict[str, Any]]
13
14
  metrics: list[dict[str, Any]]
14
15
  examples: list[dict[str, Any]]
16
+ default_filters: list[dict[str, Any]] = field(default_factory=list)
17
+ enums: list[dict[str, Any]] = field(default_factory=list)
18
+ agent_instructions: list[dict[str, Any]] = field(default_factory=list)
19
+ policy: list[dict[str, Any]] = field(default_factory=list)
20
+ glossary_terms: list[dict[str, Any]] = field(default_factory=list)
21
+ manual_joins: list[dict[str, Any]] = field(default_factory=list)
22
+ inferred_relations: list[dict[str, Any]] = field(default_factory=list)
23
+ example_join_patterns: list[dict[str, Any]] = field(default_factory=list)
24
+ example_guidance: list[dict[str, Any]] = field(default_factory=list)
25
+ structure_hints: list[dict[str, Any]] = field(default_factory=list)
15
26
 
27
+ @property
28
+ def joins(self) -> list[dict[str, Any]]:
29
+ return self.relations
30
+
31
+ @property
32
+ def glossary(self) -> list[dict[str, Any]]:
33
+ return self.glossary_terms + self.business_terms + self.concepts
34
+
35
+ @property
36
+ def table_defaults(self) -> list[dict[str, Any]]:
37
+ return self.default_filters
38
+
39
+
40
+ def retrieve_schema_context(
41
+ schema: dict[str, Any],
42
+ question: str,
43
+ *,
44
+ max_tables: int = 20,
45
+ required_tables: set[str] | None = None,
46
+ ) -> SchemaContext:
47
+ """Assemble the schema slice handed to the LLM.
48
+
49
+ Everything is driven by the MCP ``get_schema`` payload: tables are ranked by
50
+ overlap with the question and with the glossary/metrics/examples that match
51
+ the question, then the schema's own ``joins`` graph is walked to pull in any
52
+ bridge tables needed to connect them. The agent stores no per-business table
53
+ lists, keyword triggers or hand-coded joins.
54
+ """
55
+ all_tables = _schema_tables(schema)
56
+ manual_joins = _schema_manual_joins(schema)
57
+ inferred_relations = _schema_inferred_relations(schema)
58
+ all_relations = _schema_relations(schema)
59
+ all_default_filters = _schema_default_filters(schema)
60
+ all_enums = _schema_enums(schema)
61
+ agent_instructions = _schema_agent_instructions(schema)
62
+ policy = _schema_policy(schema)
63
+ glossary_terms = _matching_items(
64
+ _schema_glossary(schema),
65
+ question,
66
+ keys=("term", "name", "description", "comment", "mapsTo", "type", "table", "field"),
67
+ )
68
+ business_terms = _matching_items(
69
+ _schema_business_terms(schema),
70
+ question,
71
+ keys=("term", "description", "comment", "mapsTo", "type"),
72
+ )
73
+ concepts = _matching_items(
74
+ _schema_concepts(schema),
75
+ question,
76
+ keys=("name", "term", "comment", "description", "positiveExamples", "sqlPredicate"),
77
+ )
78
+ metrics = _matching_items(
79
+ _schema_metrics(schema),
80
+ question,
81
+ keys=("name", "metric", "comment", "description", "definition", "expression"),
82
+ )
83
+ examples = _matching_items(
84
+ _schema_examples(schema),
85
+ question,
86
+ keys=("question", "description", "intent", "questionExamples"),
87
+ )
88
+ example_join_patterns = _schema_example_join_patterns(examples)
89
+ example_guidance = _schema_example_guidance(examples)
90
+ structure_hints = _schema_structure_hints(schema, question)
91
+ table_names_from_context = _target_table_names(glossary_terms + business_terms + concepts + metrics + examples)
92
+ example_table_names = _ordered_target_table_names(examples)
93
+ glossary_metric_table_names = _target_table_names(glossary_terms + business_terms + concepts + metrics)
94
+ required_tables = required_tables or set()
95
+ preferred = table_names_from_context | required_tables
96
+
97
+ tables = _rank_tables(all_tables, question, preferred)[:max_tables]
98
+ if isinstance(all_tables, list):
99
+ # Pin the must-have tables to the front in a deterministic order:
100
+ # required (resolved-entity) tables first, then tables referenced by the
101
+ # best matched examples, then glossary / metrics. Example table order is
102
+ # preserved because examples often encode the intended join path; this
103
+ # keeps late-declared fact tables from being evicted by weaker matches.
104
+ ordered_names = _ordered_preferred_names(
105
+ all_tables,
106
+ required_tables,
107
+ example_table_names,
108
+ glossary_metric_table_names,
109
+ )
110
+ if ordered_names:
111
+ tables = _prepend_named_tables(all_tables, tables, ordered_names, max_tables)
112
+ tables = _expand_via_join_graph(all_tables, all_relations, tables, max_tables)
16
113
 
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:
40
- glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
41
- metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
42
- examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
43
- 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
114
  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)
115
+ relations = _filter_joins(all_relations, selected_table_names, required_tables)
116
+ default_filters = _filter_table_defaults(all_default_filters, selected_table_names)
117
+ enums = _filter_enums(all_enums, selected_table_names)
118
+ return SchemaContext(
119
+ tables=tables,
120
+ relations=relations,
121
+ business_terms=business_terms,
122
+ concepts=concepts,
123
+ metrics=metrics,
124
+ examples=examples,
125
+ default_filters=default_filters,
126
+ enums=enums,
127
+ agent_instructions=agent_instructions,
128
+ policy=policy,
129
+ glossary_terms=glossary_terms,
130
+ manual_joins=_filter_joins(manual_joins, selected_table_names, required_tables),
131
+ inferred_relations=_filter_joins(inferred_relations, selected_table_names, required_tables),
132
+ example_join_patterns=example_join_patterns,
133
+ example_guidance=example_guidance,
134
+ structure_hints=structure_hints,
135
+ )
136
+
137
+
138
+ def narrow_schema_context_for_query_plan(
139
+ schema_context: SchemaContext,
140
+ query_plan: dict[str, Any],
141
+ *,
142
+ full_schema: dict[str, Any] | None = None,
143
+ max_tables: int = 12,
144
+ join_hops: int = 4,
145
+ ) -> SchemaContext:
146
+ """Shrink schemaContext for SQL sampling when a query plan is already confirmed.
147
+
148
+ Roll sampling models have limited context/output budgets; passing the full
149
+ ranked schema slice (~20 tables × all columns) often yields truncated or
150
+ invalid JSON. This keeps plan tables, join-path neighbours, plan fields,
151
+ and relation keys only.
152
+ """
153
+ required_table_names = _required_tables_from_query_plan(query_plan)
154
+ plan_table_names = {
155
+ str(item.get("name") or "").strip()
156
+ for item in query_plan.get("tables", [])
157
+ if isinstance(item, dict) and str(item.get("name") or "").strip()
158
+ }
159
+ plan_table_names |= required_table_names
160
+ if not plan_table_names:
161
+ return schema_context
162
+
163
+ all_tables = _schema_tables(full_schema) if full_schema else schema_context.tables
164
+ all_relations = _schema_relations(full_schema) if full_schema else schema_context.relations
165
+ tables_by_name = {_table_name(table): table for table in all_tables if isinstance(table, dict)}
166
+
167
+ selected_names = _expand_table_names_via_relations(
168
+ plan_table_names,
169
+ all_relations,
170
+ max_tables=max_tables,
171
+ join_hops=join_hops,
172
+ required_names=required_table_names,
173
+ )
174
+ keep_columns = _column_names_for_query_plan(query_plan, all_relations, selected_names)
175
+ ordered_tables = [
176
+ _trim_table_columns(tables_by_name[name], keep_columns.get(name, set()))
177
+ for name in sorted(selected_names)
178
+ if name in tables_by_name
179
+ ]
180
+
181
+ return SchemaContext(
182
+ tables=ordered_tables,
183
+ relations=_filter_joins(all_relations, selected_names, plan_table_names),
184
+ business_terms=schema_context.business_terms,
185
+ concepts=schema_context.concepts,
186
+ metrics=schema_context.metrics,
187
+ examples=[
188
+ example
189
+ for example in schema_context.examples
190
+ if _example_tables(example).issubset(selected_names) or not _example_tables(example)
191
+ ],
192
+ default_filters=_filter_table_defaults(schema_context.default_filters, selected_names),
193
+ enums=_filter_enums(schema_context.enums, selected_names),
194
+ agent_instructions=schema_context.agent_instructions,
195
+ policy=schema_context.policy,
196
+ glossary_terms=schema_context.glossary_terms,
197
+ manual_joins=_filter_joins(
198
+ _schema_manual_joins(full_schema) if full_schema else schema_context.manual_joins,
199
+ selected_names,
200
+ plan_table_names,
201
+ ),
202
+ inferred_relations=_filter_joins(
203
+ schema_context.inferred_relations,
204
+ selected_names,
205
+ plan_table_names,
206
+ ),
207
+ example_join_patterns=[
208
+ pattern
209
+ for pattern in schema_context.example_join_patterns
210
+ if _join_pattern_tables(pattern).issubset(selected_names) or not _join_pattern_tables(pattern)
211
+ ],
212
+ example_guidance=[
213
+ item
214
+ for item in schema_context.example_guidance
215
+ if _join_pattern_tables(item.get("joinPattern", {})).issubset(selected_names)
216
+ or not _join_pattern_tables(item.get("joinPattern", {}))
217
+ ],
218
+ structure_hints=schema_context.structure_hints,
219
+ )
220
+
221
+
222
+ def _required_tables_from_query_plan(query_plan: dict[str, Any]) -> set[str]:
223
+ """Tables referenced by filters/entities that must survive schema narrowing."""
224
+ required: set[str] = set()
225
+ maps_to_table = {
226
+ "brand": "brand",
227
+ "project": "sponge_project",
228
+ "city": "city",
229
+ "region": "region",
230
+ "store": "store",
231
+ }
232
+ job_tables = {"job", "job_basic_info", "job_address", "job_store", "job_hiring_requirement"}
233
+ for flt in query_plan.get("filters", []):
234
+ if not isinstance(flt, dict):
235
+ continue
236
+ field = str(flt.get("field") or "").strip()
237
+ if "." in field:
238
+ required.add(field.split(".", 1)[0].strip())
239
+ for entity in query_plan.get("entities", []):
240
+ if not isinstance(entity, dict):
241
+ continue
242
+ entity_type = str(entity.get("type") or "").strip()
243
+ if entity_type in maps_to_table:
244
+ required.add(maps_to_table[entity_type])
245
+ elif entity_type and entity_type not in {"status", "name"}:
246
+ required.add(entity_type)
247
+ for field in query_plan.get("fields", []):
248
+ if not isinstance(field, dict):
249
+ continue
250
+ table = str(field.get("table") or "").strip()
251
+ if table:
252
+ required.add(table)
253
+ if required.intersection({"city", "region"}) and required.intersection(job_tables):
254
+ required.update({"job_address", "city"})
255
+ if "region" in required and required.intersection(job_tables):
256
+ required.add("region")
257
+ if "store" in required and required.intersection(job_tables):
258
+ required.add("job_store")
259
+ required.discard("")
260
+ return required
261
+
262
+
263
+ def _expand_table_names_via_relations(
264
+ seed_names: set[str],
265
+ relations: list[dict[str, Any]],
266
+ *,
267
+ max_tables: int,
268
+ join_hops: int,
269
+ required_names: set[str] | None = None,
270
+ ) -> set[str]:
271
+ selected = {name for name in seed_names if name}
272
+ required = {name for name in (required_names or set()) if name}
273
+ for _ in range(join_hops):
274
+ if len(selected) >= max_tables:
275
+ break
276
+ expanded = set(selected)
277
+ for relation in relations:
278
+ if not isinstance(relation, dict):
279
+ continue
280
+ left_table, _ = _relation_left(relation)
281
+ right_table, _ = _relation_right(relation)
282
+ if left_table in selected:
283
+ expanded.add(right_table)
284
+ if right_table in selected:
285
+ expanded.add(left_table)
286
+ if expanded == selected:
287
+ break
288
+ selected = expanded
289
+ ordered = sorted(selected)
290
+ if len(ordered) <= max_tables:
291
+ return set(ordered)
292
+ optional = [name for name in ordered if name not in required]
293
+ required_list = [name for name in ordered if name in required]
294
+ slots_for_optional = max(0, max_tables - len(required_list))
295
+ return set(required_list + optional[:slots_for_optional])
296
+
297
+
298
+ def _column_names_for_query_plan(
299
+ query_plan: dict[str, Any],
300
+ relations: list[dict[str, Any]],
301
+ selected_names: set[str],
302
+ ) -> dict[str, set[str]]:
303
+ keep: dict[str, set[str]] = {name: {"id"} for name in selected_names}
304
+ for field in query_plan.get("fields", []):
305
+ if not isinstance(field, dict):
306
+ continue
307
+ table = str(field.get("table") or "").strip()
308
+ column = str(field.get("field") or "").strip()
309
+ if table and column:
310
+ keep.setdefault(table, set()).add(column)
311
+ for relation in relations:
312
+ if not isinstance(relation, dict):
313
+ continue
314
+ left_table, left_column = _relation_left(relation)
315
+ right_table, right_column = _relation_right(relation)
316
+ if left_table in selected_names and left_column:
317
+ keep.setdefault(left_table, set()).add(left_column)
318
+ if right_table in selected_names and right_column:
319
+ keep.setdefault(right_table, set()).add(right_column)
320
+ for entity in query_plan.get("entities", []):
321
+ if not isinstance(entity, dict):
322
+ continue
323
+ entity_type = str(entity.get("type") or "")
324
+ if entity_type == "supplier":
325
+ keep.setdefault("supplier", set()).update({"id", "name", "nick_name"})
326
+ if "failure_reasons" in selected_names:
327
+ keep.setdefault("failure_reasons", set()).update({"id", "info"})
328
+ for flt in query_plan.get("filters", []):
329
+ if not isinstance(flt, dict):
330
+ continue
331
+ field = str(flt.get("field") or "").strip()
332
+ if "." in field:
333
+ table, column = _split_field_ref(field)
334
+ if table in selected_names and column:
335
+ keep.setdefault(table, set()).add(column)
336
+ for ref in _field_refs(str(flt.get("value") or "")):
337
+ table, column = _split_field_ref(ref)
338
+ if table in selected_names:
339
+ keep.setdefault(table, set()).add(column)
340
+ return keep
341
+
342
+
343
+ def _trim_table_columns(table: dict[str, Any], keep_columns: set[str]) -> dict[str, Any]:
344
+ columns = table.get("columns")
345
+ if not isinstance(columns, list) or not keep_columns:
346
+ return table
347
+ trimmed = [
348
+ column
349
+ for column in columns
350
+ if isinstance(column, dict)
351
+ and str(column.get("columnName") or column.get("name") or "") in keep_columns
352
+ ]
353
+ if not trimmed:
354
+ return table
355
+ return {**table, "columns": trimmed}
356
+
357
+
358
+ def _example_tables(example: dict[str, Any]) -> set[str]:
359
+ names: set[str] = set()
360
+ for key in ("requiredTables", "tables"):
361
+ value = example.get(key)
362
+ if isinstance(value, list):
363
+ names.update(str(item) for item in value if str(item).strip())
364
+ return names
49
365
 
50
366
 
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]
367
+ def _join_pattern_tables(pattern: dict[str, Any]) -> set[str]:
368
+ names: set[str] = set()
369
+ from_clause = str(pattern.get("from") or "")
370
+ match = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*)\b", from_clause)
371
+ if match:
372
+ names.add(match.group(1))
373
+ for join in pattern.get("joins", []) if isinstance(pattern.get("joins"), list) else []:
374
+ if not isinstance(join, dict):
375
+ continue
376
+ table_clause = str(join.get("table") or "")
377
+ match = re.search(r"\b([A-Za-z_][A-Za-z0-9_]*)\b", table_clause)
378
+ if match:
379
+ names.add(match.group(1))
380
+ return names
381
+
382
+
383
+ def _ordered_preferred_names(
384
+ tables: Any,
385
+ required_tables: set[str],
386
+ example_tables: tuple[str, ...],
387
+ context_tables: set[str],
388
+ ) -> tuple[str, ...]:
389
+ """Deterministic priority order for must-have tables.
390
+
391
+ Required (resolved-entity) tables come first and follow schema declaration
392
+ order. Matched example tables come next in the example SQL/table order, then
393
+ glossary / metric tables follow schema order. Required tables always win the
394
+ max_tables budget, while example join paths stay intact.
395
+ """
396
+ if not isinstance(tables, list):
397
+ return ()
398
+ schema_order = [_table_name(table) for table in tables if isinstance(table, dict)]
399
+ schema_names = set(schema_order)
400
+ required_names = [name for name in schema_order if name in required_tables]
401
+ example_names = [
402
+ name
403
+ for name in example_tables
404
+ if name in schema_names and name not in required_tables
405
+ ]
406
+ context_names = [
407
+ name
408
+ for name in schema_order
409
+ if name in context_tables and name not in required_tables and name not in example_names
410
+ ]
411
+ return tuple(dict.fromkeys(required_names + example_names + context_names))
412
+
413
+
414
+ def _expand_via_join_graph(
415
+ all_tables: list[dict[str, Any]],
416
+ relations: list[dict[str, Any]],
417
+ tables: list[dict[str, Any]],
418
+ max_tables: int,
419
+ ) -> list[dict[str, Any]]:
420
+ """Add bridge tables that connect two or more already-selected tables.
57
421
 
58
- return _prepend_named_tables(tables, ranked, LOCATION_RECRUITING_TABLES, max_tables)
422
+ Relationship knowledge lives entirely in ``structure.relations``; this walks that
423
+ graph so indirect paths (e.g. brand -> brand_project -> sponge_project) are
424
+ complete without hard-coding any business-specific table set.
425
+ """
426
+ if not isinstance(all_tables, list) or not isinstance(relations, list):
427
+ return tables[:max_tables]
428
+ by_name = {_table_name(table): table for table in all_tables if isinstance(table, dict)}
429
+ selected = {_table_name(table) for table in tables}
430
+ adjacency: dict[str, set[str]] = {}
431
+ for relation in relations:
432
+ if not isinstance(relation, dict):
433
+ continue
434
+ left, _ = _relation_left(relation)
435
+ right, _ = _relation_right(relation)
436
+ if left and right:
437
+ adjacency.setdefault(left, set()).add(right)
438
+ adjacency.setdefault(right, set()).add(left)
439
+ result = list(tables)
440
+ for candidate, neighbours in adjacency.items():
441
+ if len(result) >= max_tables:
442
+ break
443
+ if candidate in selected or candidate not in by_name:
444
+ continue
445
+ if len(neighbours & selected) >= 2:
446
+ result.append(by_name[candidate])
447
+ selected.add(candidate)
448
+ return result[:max_tables]
59
449
 
60
450
 
61
451
  def _prepend_named_tables(
@@ -85,14 +475,13 @@ def _prepend_named_tables(
85
475
  def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[dict[str, Any]]:
86
476
  if not isinstance(tables, list):
87
477
  return []
478
+ tokens = _question_tokens(question)
88
479
  ranked: list[tuple[int, dict[str, Any]]] = []
89
480
  for table in tables:
90
481
  if not isinstance(table, dict):
91
482
  continue
92
483
  name = _table_name(table)
93
484
  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
485
  text = " ".join(
97
486
  [
98
487
  name,
@@ -100,7 +489,7 @@ def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[
100
489
  " ".join(_column_text(column) for column in table.get("columns", []) if isinstance(column, dict)),
101
490
  ]
102
491
  ).lower()
103
- for token in _question_tokens(question):
492
+ for token in tokens:
104
493
  if token and token in text:
105
494
  score += 1
106
495
  if score > 0 or not preferred_names:
@@ -109,101 +498,496 @@ def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[
109
498
  return [table for _, table in ranked]
110
499
 
111
500
 
501
+ def _schema_tables(schema: dict[str, Any]) -> list[dict[str, Any]]:
502
+ tables = schema.get("tables")
503
+ if isinstance(tables, list):
504
+ return [table for table in tables if isinstance(table, dict)]
505
+ nested_schema = schema.get("schema")
506
+ if isinstance(nested_schema, dict) and isinstance(nested_schema.get("tables"), list):
507
+ return [table for table in nested_schema["tables"] if isinstance(table, dict)]
508
+ return []
509
+
510
+
511
+ def _schema_structure(schema: dict[str, Any]) -> dict[str, Any]:
512
+ structure = schema.get("structure")
513
+ return structure if isinstance(structure, dict) else {}
514
+
515
+
516
+ def _schema_relations(schema: dict[str, Any]) -> list[dict[str, Any]]:
517
+ return _schema_manual_joins(schema) + _schema_inferred_relations(schema)
518
+
519
+
520
+ def _schema_manual_joins(schema: dict[str, Any]) -> list[dict[str, Any]]:
521
+ legacy_joins = schema.get("joins")
522
+ if isinstance(legacy_joins, list):
523
+ return [_mark_relation_source(join, "joins") for join in legacy_joins if isinstance(join, dict)]
524
+ return []
525
+
526
+
527
+ def _schema_inferred_relations(schema: dict[str, Any]) -> list[dict[str, Any]]:
528
+ structure_relations = _schema_structure(schema).get("relations")
529
+ if isinstance(structure_relations, list):
530
+ return [_mark_relation_source(relation, "structure.relations") for relation in structure_relations if isinstance(relation, dict)]
531
+ return []
532
+
533
+
534
+ def _mark_relation_source(relation: dict[str, Any], source: str) -> dict[str, Any]:
535
+ return relation if relation.get("source") else {**relation, "source": source}
536
+
537
+
538
+ def _schema_default_filters(schema: dict[str, Any]) -> list[dict[str, Any]]:
539
+ result: list[dict[str, Any]] = []
540
+ default_filters = _schema_structure(schema).get("defaultFilters")
541
+ if isinstance(default_filters, list):
542
+ result.extend(item for item in default_filters if isinstance(item, dict))
543
+ legacy_defaults = schema.get("tableDefaults")
544
+ if isinstance(legacy_defaults, list):
545
+ result.extend(_flatten_table_default(item) for item in legacy_defaults if isinstance(item, dict))
546
+ return result
547
+
548
+
549
+ def _flatten_table_default(item: dict[str, Any]) -> dict[str, Any]:
550
+ if any(key in item for key in ("predicate", "condition", "where", "expression", "filter")):
551
+ return item
552
+ filters = item.get("defaultFilters")
553
+ if isinstance(filters, list) and filters:
554
+ return {**item, "condition": " AND ".join(str(value) for value in filters if value)}
555
+ return item
556
+
557
+
558
+ def _schema_business_terms(schema: dict[str, Any]) -> list[dict[str, Any]]:
559
+ business_terms = _schema_structure(schema).get("businessTerms")
560
+ if isinstance(business_terms, list):
561
+ return [item for item in business_terms if isinstance(item, dict)]
562
+ legacy_glossary = schema.get("glossary")
563
+ if isinstance(legacy_glossary, list):
564
+ return [item for item in legacy_glossary if isinstance(item, dict)]
565
+ return []
566
+
567
+
568
+ def _schema_agent_instructions(schema: dict[str, Any]) -> list[dict[str, Any]]:
569
+ return _normalize_instruction_items(_schema_list(schema, "agentInstructions"))
570
+
571
+
572
+ def _normalize_instruction_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
573
+ """Flatten schema instruction payloads into numbered ``{"text": ...}`` entries for LLM prompts."""
574
+ normalized: list[dict[str, Any]] = []
575
+ for item in items:
576
+ if not isinstance(item, dict):
577
+ continue
578
+ for key in ("text", "instruction", "content"):
579
+ value = item.get(key)
580
+ if isinstance(value, str) and value.strip():
581
+ normalized.append({"text": value.strip()})
582
+ break
583
+ else:
584
+ nested = item.get("instructions")
585
+ if isinstance(nested, list):
586
+ for line in nested:
587
+ if isinstance(line, str) and line.strip():
588
+ normalized.append({"text": line.strip()})
589
+ elif isinstance(nested, str) and nested.strip():
590
+ normalized.append({"text": nested.strip()})
591
+ elif item.get("text") is None and item.get("instructions") is None:
592
+ normalized.append(item)
593
+ return normalized
594
+
595
+
596
+ def _schema_policy(schema: dict[str, Any]) -> list[dict[str, Any]]:
597
+ return _schema_list(schema, "policy")
598
+
599
+
600
+ def _schema_glossary(schema: dict[str, Any]) -> list[dict[str, Any]]:
601
+ return _schema_list(schema, "glossary")
602
+
603
+
604
+ def _schema_list(schema: dict[str, Any], key: str) -> list[dict[str, Any]]:
605
+ value = schema.get(key)
606
+ if isinstance(value, list):
607
+ return [item for item in value if isinstance(item, dict)]
608
+ if isinstance(value, dict):
609
+ return [value]
610
+ if isinstance(value, str) and value.strip():
611
+ return [{"text": value.strip()}]
612
+ structure_value = _schema_structure(schema).get(key)
613
+ if isinstance(structure_value, list):
614
+ return [item for item in structure_value if isinstance(item, dict)]
615
+ if isinstance(structure_value, dict):
616
+ return [structure_value]
617
+ if isinstance(structure_value, str) and structure_value.strip():
618
+ return [{"text": structure_value.strip()}]
619
+ return []
620
+
621
+
622
+ def _schema_concepts(schema: dict[str, Any]) -> list[dict[str, Any]]:
623
+ concepts = _schema_structure(schema).get("concepts")
624
+ if isinstance(concepts, list):
625
+ return [item for item in concepts if isinstance(item, dict)]
626
+ return []
627
+
628
+
629
+ def _schema_metrics(schema: dict[str, Any]) -> list[dict[str, Any]]:
630
+ metrics = _schema_structure(schema).get("metrics")
631
+ if isinstance(metrics, list):
632
+ return [item for item in metrics if isinstance(item, dict)]
633
+ legacy_metrics = schema.get("metrics")
634
+ if isinstance(legacy_metrics, list):
635
+ return [item for item in legacy_metrics if isinstance(item, dict)]
636
+ return []
637
+
638
+
639
+ def _schema_examples(schema: dict[str, Any]) -> list[dict[str, Any]]:
640
+ example = schema.get("example")
641
+ if isinstance(example, dict) and isinstance(example.get("examples"), list):
642
+ return [item for item in example["examples"] if isinstance(item, dict)]
643
+ legacy_examples = schema.get("examples")
644
+ if isinstance(legacy_examples, list):
645
+ return [item for item in legacy_examples if isinstance(item, dict)]
646
+ return []
647
+
648
+
649
+ def _schema_example_join_patterns(examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
650
+ result: list[dict[str, Any]] = []
651
+ for example in examples:
652
+ join_pattern = example.get("joinPattern")
653
+ if isinstance(join_pattern, dict):
654
+ result.append(join_pattern)
655
+ return result
656
+
657
+
658
+ def _schema_example_guidance(examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
659
+ result: list[dict[str, Any]] = []
660
+ for example in examples:
661
+ if not isinstance(example, dict):
662
+ continue
663
+ join_pattern = example.get("joinPattern")
664
+ if not isinstance(join_pattern, dict):
665
+ continue
666
+ item: dict[str, Any] = {"joinPattern": join_pattern}
667
+ for key in ("id", "intent", "notes", "questionExamples", "requiredTables"):
668
+ value = example.get(key)
669
+ if value:
670
+ item[key] = value
671
+ result.append(item)
672
+ return result
673
+
674
+
675
+ def _schema_structure_hints(schema: dict[str, Any], question: str) -> list[dict[str, Any]]:
676
+ hints: list[dict[str, Any]] = []
677
+ if not any(token in question for token in ("原因", "失败原因", "工单原因", "失败阶段")):
678
+ return hints
679
+ structure = schema.get("structure")
680
+ if not isinstance(structure, dict):
681
+ return hints
682
+ unpivot = structure.get("workOrderFailureReasonUnpivot")
683
+ if isinstance(unpivot, dict):
684
+ hints.append({"type": "workOrderFailureReasonUnpivot", **unpivot})
685
+ mappings = structure.get("workOrderFailureReasonMappings")
686
+ if isinstance(mappings, list) and mappings:
687
+ hints.append({"type": "workOrderFailureReasonMappings", "mappings": mappings})
688
+ return hints
689
+
690
+
691
+ def _schema_enums(schema: dict[str, Any]) -> list[dict[str, Any]]:
692
+ """Merge enum metadata from top-level enums, column.enum, and concept enumRefs."""
693
+ by_field: dict[str, dict[Any, str]] = {}
694
+
695
+ def add_value(field: str, value: Any, label: str, *, overwrite: bool = True) -> None:
696
+ if not field or value is None:
697
+ return
698
+ text = str(label or "").strip()
699
+ if not text:
700
+ return
701
+ bucket = by_field.setdefault(field, {})
702
+ if overwrite or value not in bucket:
703
+ bucket[value] = text
704
+
705
+ def add_enum_values(field: str, values: Any, *, overwrite: bool = True) -> None:
706
+ if not field or not isinstance(values, list):
707
+ return
708
+ for item in values:
709
+ if not isinstance(item, dict):
710
+ continue
711
+ value = item.get("value")
712
+ if value is None and "code" in item:
713
+ value = item.get("code")
714
+ add_value(field, value, str(item.get("label") or item.get("meaning") or ""), overwrite=overwrite)
715
+
716
+ top_level = schema.get("enums")
717
+ if isinstance(top_level, list):
718
+ for entry in top_level:
719
+ if not isinstance(entry, dict):
720
+ continue
721
+ field = str(entry.get("field") or "")
722
+ add_enum_values(field, entry.get("values"), overwrite=True)
723
+
724
+ for table in _schema_tables(schema):
725
+ table_name = _table_name(table)
726
+ if not table_name:
727
+ continue
728
+ columns = table.get("columns")
729
+ if not isinstance(columns, list):
730
+ continue
731
+ for column in columns:
732
+ if not isinstance(column, dict):
733
+ continue
734
+ column_name = str(column.get("columnName") or column.get("name") or "")
735
+ enum = column.get("enum")
736
+ if not column_name or not isinstance(enum, dict):
737
+ continue
738
+ add_enum_values(f"{table_name}.{column_name}", enum.get("values"), overwrite=True)
739
+
740
+ for concept in _schema_concepts(schema):
741
+ enum_refs = concept.get("enumRefs")
742
+ if not isinstance(enum_refs, list):
743
+ continue
744
+ label_parts = [
745
+ str(concept.get("comment") or ""),
746
+ str(concept.get("name") or ""),
747
+ str(concept.get("description") or ""),
748
+ ]
749
+ for enum_ref in enum_refs:
750
+ if not isinstance(enum_ref, dict):
751
+ continue
752
+ field = str(enum_ref.get("field") or "")
753
+ if not field:
754
+ continue
755
+ meaning = str(enum_ref.get("meaning") or "")
756
+ label = " / ".join(part for part in label_parts + [meaning] if part)
757
+ if "value" in enum_ref:
758
+ add_value(field, enum_ref["value"], label, overwrite=False)
759
+ continue
760
+ values = enum_ref.get("values")
761
+ if isinstance(values, list):
762
+ for value in values:
763
+ add_value(field, value, meaning or label, overwrite=False)
764
+
765
+ return [
766
+ {
767
+ "field": field,
768
+ "values": [{"value": value, "label": label} for value, label in sorted(values.items(), key=lambda item: str(item[0]))],
769
+ }
770
+ for field, values in sorted(by_field.items())
771
+ ]
772
+
773
+
112
774
  def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list[dict[str, Any]]:
113
775
  if not isinstance(items, list):
114
776
  return []
115
- tokens = _question_tokens(question)
777
+ tokens = _semantic_tokens(question)
116
778
  matches: list[tuple[int, dict[str, Any]]] = []
117
779
  for item in items:
118
780
  if not isinstance(item, dict):
119
781
  continue
120
782
  text = " ".join(str(item.get(key) or "") for key in keys + ("sql",)).lower()
121
783
  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
784
  if score > 0:
136
785
  matches.append((score, item))
137
786
  matches.sort(key=lambda match: match[0], reverse=True)
138
787
  return [item for _, item in matches[:8]]
139
788
 
140
789
 
790
+ def concept_question_match_score(concept: dict[str, Any], question: str) -> int:
791
+ """Score how well a schema concept matches the user question."""
792
+ if not isinstance(concept, dict):
793
+ return 0
794
+ question_text = _normalize_fullwidth_digits(str(question or ""))
795
+ score = 0
796
+ for example in concept.get("positiveExamples") or []:
797
+ example_text = str(example or "").strip()
798
+ if example_text and example_text in question_text:
799
+ score += 10
800
+ for example in concept.get("negativeExamples") or []:
801
+ example_text = str(example or "").strip()
802
+ if example_text and example_text in question_text:
803
+ score -= 100
804
+ tokens = _semantic_tokens(question_text)
805
+ blob = " ".join(
806
+ str(concept.get(key) or "")
807
+ for key in ("comment", "description", "name", "intent")
808
+ ).lower()
809
+ score += sum(1 for token in tokens if token and token in blob)
810
+ return score
811
+
812
+
813
+ def match_concepts_for_question(concepts: Any, question: str) -> list[dict[str, Any]]:
814
+ """Pick schema concepts for query-plan filters, honoring conflictsWith."""
815
+ if not isinstance(concepts, list):
816
+ return []
817
+ scored: list[tuple[int, dict[str, Any]]] = []
818
+ for concept in concepts:
819
+ if not isinstance(concept, dict):
820
+ continue
821
+ match_score = concept_question_match_score(concept, question)
822
+ if match_score > 0:
823
+ scored.append((match_score, concept))
824
+ scored.sort(key=lambda item: item[0], reverse=True)
825
+ selected: list[dict[str, Any]] = []
826
+ excluded_names: set[str] = set()
827
+ for _, concept in scored:
828
+ name = str(concept.get("name") or "").strip()
829
+ if not name or name in excluded_names:
830
+ continue
831
+ if any(name in (item.get("conflictsWith") or []) for item in selected):
832
+ continue
833
+ selected.append(concept)
834
+ for conflict in concept.get("conflictsWith") or []:
835
+ conflict_name = str(conflict or "").strip()
836
+ if conflict_name:
837
+ excluded_names.add(conflict_name)
838
+ return selected
839
+
840
+
841
+ def _normalize_fullwidth_digits(text: str) -> str:
842
+ return text.translate(str.maketrans("0123456789", "0123456789"))
843
+
844
+
845
+ def _semantic_tokens(question: str) -> list[str]:
846
+ ignored = {
847
+ "查询",
848
+ "查看",
849
+ "看看",
850
+ "搜索",
851
+ "统计",
852
+ "多少",
853
+ "几个",
854
+ "几条",
855
+ "哪些",
856
+ "列表",
857
+ "明细",
858
+ "数据",
859
+ "目前",
860
+ "当前",
861
+ "现在",
862
+ "涉及",
863
+ "关联",
864
+ "关系",
865
+ }
866
+ return [token for token in _question_tokens(question) if token not in ignored]
867
+
868
+
141
869
  def _target_table_names(items: list[dict[str, Any]]) -> set[str]:
870
+ return set(_ordered_target_table_names(items))
871
+
872
+
873
+ def _ordered_target_table_names(items: list[dict[str, Any]]) -> tuple[str, ...]:
142
874
  names: set[str] = set()
875
+ ordered: list[str] = []
143
876
  for item in items:
144
- for key in ("tables", "targetTables"):
877
+ for key in ("tables", "targetTables", "requiredTables"):
145
878
  value = item.get(key)
146
879
  if isinstance(value, list):
147
- names.update(str(name) for name in value)
880
+ for name in value:
881
+ _append_unique_table(ordered, names, str(name))
882
+ for key in ("mapsTo", "baseTable"):
883
+ value = item.get(key)
884
+ if isinstance(value, str):
885
+ _append_unique_table(ordered, names, value)
148
886
  sql = item.get("sql")
149
887
  if isinstance(sql, str):
150
- names.update(referenced_table for referenced_table in _referenced_tables(sql))
888
+ for referenced_table in _referenced_tables(sql):
889
+ _append_unique_table(ordered, names, referenced_table)
890
+ for field_key in ("expression", "predicate"):
891
+ value = item.get(field_key)
892
+ if isinstance(value, str):
893
+ for field_ref in _field_refs(value):
894
+ _append_unique_table(ordered, names, field_ref.split(".", 1)[0])
895
+ predicates = item.get("sqlPredicate")
896
+ if isinstance(predicates, list):
897
+ for predicate in predicates:
898
+ for field_ref in _field_refs(str(predicate)):
899
+ _append_unique_table(ordered, names, field_ref.split(".", 1)[0])
900
+ enum_refs = item.get("enumRefs")
901
+ if isinstance(enum_refs, list):
902
+ for enum_ref in enum_refs:
903
+ if isinstance(enum_ref, dict) and isinstance(enum_ref.get("field"), str):
904
+ _append_unique_table(ordered, names, enum_ref["field"].split(".", 1)[0])
905
+ join_pattern = item.get("joinPattern")
906
+ if isinstance(join_pattern, dict):
907
+ from_clause = join_pattern.get("from")
908
+ if isinstance(from_clause, str):
909
+ for referenced_table in _referenced_tables("FROM " + from_clause):
910
+ _append_unique_table(ordered, names, referenced_table)
911
+ joins = join_pattern.get("joins")
912
+ if isinstance(joins, list):
913
+ for join in joins:
914
+ if isinstance(join, dict) and isinstance(join.get("table"), str):
915
+ for referenced_table in _referenced_tables("JOIN " + join["table"]):
916
+ _append_unique_table(ordered, names, referenced_table)
151
917
  targets = item.get("targets")
152
918
  if isinstance(targets, list):
153
919
  for target in targets:
154
920
  if isinstance(target, str) and "." in target:
155
- names.add(target.split(".", 1)[0])
156
- return names
921
+ _append_unique_table(ordered, names, target.split(".", 1)[0])
922
+ return tuple(ordered)
923
+
924
+
925
+ def _append_unique_table(ordered: list[str], seen: set[str], name: str) -> None:
926
+ if not name or name in seen:
927
+ return
928
+ ordered.append(name)
929
+ seen.add(name)
157
930
 
158
931
 
159
- def _filter_joins(joins: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
932
+ def _filter_joins(
933
+ joins: Any,
934
+ selected_table_names: set[str],
935
+ priority_table_names: set[str] | None = None,
936
+ ) -> list[dict[str, Any]]:
160
937
  if not isinstance(joins, list):
161
938
  return []
162
- result: list[dict[str, Any]] = []
163
- for join in joins:
939
+ priority_table_names = priority_table_names or set()
940
+ direct: list[tuple[int, int, dict[str, Any]]] = []
941
+ adjacent: list[tuple[int, int, dict[str, Any]]] = []
942
+ for index, join in enumerate(joins):
164
943
  if not isinstance(join, dict):
165
944
  continue
166
- join_tables = {
167
- str(join.get("leftTable") or join.get("from") or ""),
168
- str(join.get("rightTable") or join.get("to") or ""),
169
- }
170
- if join_tables & selected_table_names:
171
- result.append(join)
172
- return result[:12]
945
+ left_table, _ = _relation_left(join)
946
+ right_table, _ = _relation_right(join)
947
+ join_tables = {left_table, right_table}
948
+ join_tables.discard("")
949
+ matched = join_tables & selected_table_names
950
+ priority_score = len(join_tables & priority_table_names)
951
+ if join_tables <= selected_table_names:
952
+ direct.append((-priority_score, index, join))
953
+ elif matched:
954
+ adjacent.append((-priority_score, index, join))
955
+ direct.sort()
956
+ adjacent.sort()
957
+ return [join for _, _, join in direct + adjacent][:16]
173
958
 
174
959
 
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]
960
+ def _filter_table_defaults(table_defaults: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
961
+ """Pass through default filters for selected tables (data from get_schema)."""
962
+ if not isinstance(table_defaults, list):
963
+ return []
964
+ result: list[dict[str, Any]] = []
965
+ for entry in table_defaults:
966
+ if not isinstance(entry, dict):
967
+ continue
968
+ table = str(entry.get("table") or "")
969
+ if not table:
970
+ predicate = str(entry.get("predicate") or entry.get("condition") or "")
971
+ first_ref = _field_refs(predicate)
972
+ table = first_ref[0].split(".", 1)[0] if first_ref else ""
973
+ if table in selected_table_names:
974
+ result.append(entry)
975
+ return result
976
+
977
+
978
+ def _filter_enums(enums: Any, selected_table_names: set[str]) -> list[dict[str, Any]]:
979
+ """Pass through enum definitions whose field belongs to a selected table."""
980
+ if not isinstance(enums, list):
981
+ return []
982
+ result: list[dict[str, Any]] = []
983
+ for entry in enums:
984
+ if not isinstance(entry, dict):
985
+ continue
986
+ field = str(entry.get("field") or "")
987
+ table = field.split(".", 1)[0] if "." in field else ""
988
+ if table in selected_table_names:
989
+ result.append(entry)
990
+ return result
207
991
 
208
992
 
209
993
  def _table_name(table: dict[str, Any]) -> str:
@@ -216,65 +1000,54 @@ def _column_text(column: dict[str, Any]) -> str:
216
1000
  )
217
1001
 
218
1002
 
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
1003
+ def _relation_left(relation: dict[str, Any]) -> tuple[str, str]:
1004
+ table = str(relation.get("leftTable") or "")
1005
+ column = str(relation.get("leftColumn") or relation.get("fromColumn") or "")
1006
+ if table:
1007
+ return table, column
1008
+ return _split_field_ref(str(relation.get("from") or ""))
235
1009
 
236
1010
 
237
- def _question_tokens(question: str) -> list[str]:
238
- lowered = question.lower()
239
- 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)
265
- return list(dict.fromkeys(tokens))
1011
+ def _relation_right(relation: dict[str, Any]) -> tuple[str, str]:
1012
+ table = str(relation.get("rightTable") or "")
1013
+ column = str(relation.get("rightColumn") or relation.get("toColumn") or "")
1014
+ if table:
1015
+ return table, column
1016
+ return _split_field_ref(str(relation.get("to") or ""))
266
1017
 
267
1018
 
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))
1019
+ def _split_field_ref(value: str) -> tuple[str, str]:
1020
+ if "." not in value:
1021
+ return value, ""
1022
+ table, column = value.split(".", 1)
1023
+ return table, column
270
1024
 
271
1025
 
272
- def _needs_location_recruiting_tables(question: str) -> bool:
273
- return any(word in question for word in ("岗位", "门店", "招聘", "在招"))
1026
+ def _field_refs(text: str) -> list[str]:
1027
+ return re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*\b", text)
274
1028
 
275
1029
 
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
- )
1030
+ def _question_tokens(question: str) -> list[str]:
1031
+ """Generic tokenization with no business vocabulary.
1032
+
1033
+ Produces the lowered question, its ascii word splits, and CJK character
1034
+ bigrams so Chinese questions can still overlap Chinese table comments /
1035
+ glossary text without any hand-maintained keyword list.
1036
+ """
1037
+ lowered = question.lower()
1038
+ tokens = [lowered]
1039
+ tokens.extend(part for part in re.split(r"[\s_\-]+", lowered) if part)
1040
+ for run in re.findall(r"[\u4e00-\u9fff]+", question):
1041
+ if len(run) == 1:
1042
+ tokens.append(run)
1043
+ for index in range(len(run) - 1):
1044
+ tokens.append(run[index : index + 2])
1045
+ return list(dict.fromkeys(tokens))
1046
+
1047
+
1048
+ def _referenced_tables(sql: str) -> tuple[str, ...]:
1049
+ names: list[str] = []
1050
+ seen: set[str] = set()
1051
+ for match in re.finditer(r"\b(?:from|join)\s+([a-zA-Z_][a-zA-Z0-9_]*)\b", sql, re.IGNORECASE):
1052
+ _append_unique_table(names, seen, match.group(1))
1053
+ return tuple(names)