@roll-agent/octopus-agent 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +26 -190
  2. package/SKILL.md +27 -103
  3. package/package.json +12 -18
  4. package/references/env.yaml +1 -22
  5. package/scripts/start-octopus-agent.js +1 -125
  6. package/src/config.js +13 -0
  7. package/src/context.js +5 -0
  8. package/src/mcp-client.js +80 -0
  9. package/src/orchestrator.js +57 -0
  10. package/src/server-core.js +67 -0
  11. package/src/server.js +10 -0
  12. package/src/stdio.js +29 -0
  13. package/octopus_skill/__init__.py +0 -11
  14. package/pyproject.toml +0 -19
  15. package/scripts/refresh_dictionary.py +0 -67
  16. package/scripts/verify_binding.py +0 -47
  17. package/src/octopus_skill/__init__.py +0 -5
  18. package/src/octopus_skill/__main__.py +0 -4
  19. package/src/octopus_skill/_version.py +0 -3
  20. package/src/octopus_skill/agent.py +0 -5739
  21. package/src/octopus_skill/audit_logger.py +0 -29
  22. package/src/octopus_skill/config.py +0 -129
  23. package/src/octopus_skill/context.py +0 -17
  24. package/src/octopus_skill/device_info.py +0 -7
  25. package/src/octopus_skill/exporter.py +0 -66
  26. package/src/octopus_skill/llm_result_renderer.py +0 -101
  27. package/src/octopus_skill/llm_sql_generator.py +0 -197
  28. package/src/octopus_skill/main.py +0 -26
  29. package/src/octopus_skill/mcp_client.py +0 -114
  30. package/src/octopus_skill/octopus_run.py +0 -755
  31. package/src/octopus_skill/prompt_builder.py +0 -522
  32. package/src/octopus_skill/question_analyzer.py +0 -401
  33. package/src/octopus_skill/result_renderer.py +0 -367
  34. package/src/octopus_skill/schema_cache.py +0 -49
  35. package/src/octopus_skill/schema_context_retriever.py +0 -1053
  36. package/src/octopus_skill/sql_generator.py +0 -2866
@@ -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)