@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.
- package/README.md +26 -125
- package/SKILL.md +27 -102
- package/package.json +12 -18
- package/references/env.yaml +1 -22
- package/scripts/start-octopus-agent.js +1 -125
- package/src/config.js +13 -0
- package/src/context.js +5 -0
- package/src/mcp-client.js +80 -0
- package/src/orchestrator.js +57 -0
- package/src/server-core.js +67 -0
- package/src/server.js +10 -0
- package/src/stdio.js +29 -0
- package/octopus_skill/__init__.py +0 -11
- package/pyproject.toml +0 -19
- package/scripts/refresh_dictionary.py +0 -67
- package/scripts/verify_binding.py +0 -47
- package/src/octopus_skill/__init__.py +0 -5
- package/src/octopus_skill/__main__.py +0 -4
- package/src/octopus_skill/_version.py +0 -3
- package/src/octopus_skill/agent.py +0 -4971
- package/src/octopus_skill/audit_logger.py +0 -29
- package/src/octopus_skill/config.py +0 -129
- package/src/octopus_skill/context.py +0 -17
- package/src/octopus_skill/device_info.py +0 -7
- package/src/octopus_skill/exporter.py +0 -66
- package/src/octopus_skill/llm_result_renderer.py +0 -101
- package/src/octopus_skill/llm_sql_generator.py +0 -197
- package/src/octopus_skill/main.py +0 -26
- package/src/octopus_skill/mcp_client.py +0 -114
- package/src/octopus_skill/octopus_run.py +0 -752
- package/src/octopus_skill/prompt_builder.py +0 -519
- package/src/octopus_skill/question_analyzer.py +0 -399
- package/src/octopus_skill/result_renderer.py +0 -367
- package/src/octopus_skill/schema_cache.py +0 -49
- package/src/octopus_skill/schema_context_retriever.py +0 -1053
- package/src/octopus_skill/sql_generator.py +0 -2788
|
@@ -1,1053 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
from dataclasses import dataclass, field
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@dataclass(frozen=True)
|
|
9
|
-
class SchemaContext:
|
|
10
|
-
tables: list[dict[str, Any]]
|
|
11
|
-
relations: list[dict[str, Any]]
|
|
12
|
-
business_terms: list[dict[str, Any]]
|
|
13
|
-
concepts: list[dict[str, Any]]
|
|
14
|
-
metrics: list[dict[str, Any]]
|
|
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)
|
|
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)
|
|
113
|
-
|
|
114
|
-
selected_table_names = {_table_name(table) for table in tables}
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
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.
|
|
421
|
-
|
|
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]
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
def _prepend_named_tables(
|
|
452
|
-
tables: Any,
|
|
453
|
-
ranked: list[dict[str, Any]],
|
|
454
|
-
names: tuple[str, ...],
|
|
455
|
-
max_tables: int,
|
|
456
|
-
) -> list[dict[str, Any]]:
|
|
457
|
-
by_name = {_table_name(table): table for table in tables if isinstance(table, dict)}
|
|
458
|
-
selected: list[dict[str, Any]] = []
|
|
459
|
-
seen: set[str] = set()
|
|
460
|
-
for name in names:
|
|
461
|
-
table = by_name.get(name)
|
|
462
|
-
if table is not None:
|
|
463
|
-
selected.append(table)
|
|
464
|
-
seen.add(name)
|
|
465
|
-
for table in ranked:
|
|
466
|
-
name = _table_name(table)
|
|
467
|
-
if name not in seen:
|
|
468
|
-
selected.append(table)
|
|
469
|
-
seen.add(name)
|
|
470
|
-
if len(selected) >= max_tables:
|
|
471
|
-
break
|
|
472
|
-
return selected[:max_tables]
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[dict[str, Any]]:
|
|
476
|
-
if not isinstance(tables, list):
|
|
477
|
-
return []
|
|
478
|
-
tokens = _question_tokens(question)
|
|
479
|
-
ranked: list[tuple[int, dict[str, Any]]] = []
|
|
480
|
-
for table in tables:
|
|
481
|
-
if not isinstance(table, dict):
|
|
482
|
-
continue
|
|
483
|
-
name = _table_name(table)
|
|
484
|
-
score = 5 if name in preferred_names else 0
|
|
485
|
-
text = " ".join(
|
|
486
|
-
[
|
|
487
|
-
name,
|
|
488
|
-
str(table.get("comment") or ""),
|
|
489
|
-
" ".join(_column_text(column) for column in table.get("columns", []) if isinstance(column, dict)),
|
|
490
|
-
]
|
|
491
|
-
).lower()
|
|
492
|
-
for token in tokens:
|
|
493
|
-
if token and token in text:
|
|
494
|
-
score += 1
|
|
495
|
-
if score > 0 or not preferred_names:
|
|
496
|
-
ranked.append((score, table))
|
|
497
|
-
ranked.sort(key=lambda item: item[0], reverse=True)
|
|
498
|
-
return [table for _, table in ranked]
|
|
499
|
-
|
|
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
|
-
|
|
774
|
-
def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list[dict[str, Any]]:
|
|
775
|
-
if not isinstance(items, list):
|
|
776
|
-
return []
|
|
777
|
-
tokens = _semantic_tokens(question)
|
|
778
|
-
matches: list[tuple[int, dict[str, Any]]] = []
|
|
779
|
-
for item in items:
|
|
780
|
-
if not isinstance(item, dict):
|
|
781
|
-
continue
|
|
782
|
-
text = " ".join(str(item.get(key) or "") for key in keys + ("sql",)).lower()
|
|
783
|
-
score = sum(1 for token in tokens if token and token in text)
|
|
784
|
-
if score > 0:
|
|
785
|
-
matches.append((score, item))
|
|
786
|
-
matches.sort(key=lambda match: match[0], reverse=True)
|
|
787
|
-
return [item for _, item in matches[:8]]
|
|
788
|
-
|
|
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
|
-
|
|
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, ...]:
|
|
874
|
-
names: set[str] = set()
|
|
875
|
-
ordered: list[str] = []
|
|
876
|
-
for item in items:
|
|
877
|
-
for key in ("tables", "targetTables", "requiredTables"):
|
|
878
|
-
value = item.get(key)
|
|
879
|
-
if isinstance(value, list):
|
|
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)
|
|
886
|
-
sql = item.get("sql")
|
|
887
|
-
if isinstance(sql, str):
|
|
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)
|
|
917
|
-
targets = item.get("targets")
|
|
918
|
-
if isinstance(targets, list):
|
|
919
|
-
for target in targets:
|
|
920
|
-
if isinstance(target, str) and "." in target:
|
|
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)
|
|
930
|
-
|
|
931
|
-
|
|
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]]:
|
|
937
|
-
if not isinstance(joins, list):
|
|
938
|
-
return []
|
|
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):
|
|
943
|
-
if not isinstance(join, dict):
|
|
944
|
-
continue
|
|
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]
|
|
958
|
-
|
|
959
|
-
|
|
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
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
def _table_name(table: dict[str, Any]) -> str:
|
|
994
|
-
return str(table.get("tableName") or table.get("name") or "")
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
def _column_text(column: dict[str, Any]) -> str:
|
|
998
|
-
return " ".join(
|
|
999
|
-
str(column.get(key) or "") for key in ("columnName", "name", "dataType", "type", "comment")
|
|
1000
|
-
)
|
|
1001
|
-
|
|
1002
|
-
|
|
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 ""))
|
|
1009
|
-
|
|
1010
|
-
|
|
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 ""))
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
|
1024
|
-
|
|
1025
|
-
|
|
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)
|
|
1028
|
-
|
|
1029
|
-
|
|
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)
|