@roll-agent/octopus-agent 0.0.11 → 0.1.1

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.
@@ -8,12 +8,33 @@ from typing import Any
8
8
  @dataclass(frozen=True)
9
9
  class SchemaContext:
10
10
  tables: list[dict[str, Any]]
11
- joins: list[dict[str, Any]]
12
- glossary: list[dict[str, Any]]
11
+ relations: list[dict[str, Any]]
12
+ business_terms: list[dict[str, Any]]
13
+ concepts: list[dict[str, Any]]
13
14
  metrics: list[dict[str, Any]]
14
15
  examples: list[dict[str, Any]]
15
- table_defaults: list[dict[str, Any]] = field(default_factory=list)
16
+ default_filters: list[dict[str, Any]] = field(default_factory=list)
16
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
17
38
 
18
39
 
19
40
  def retrieve_schema_context(
@@ -31,47 +52,334 @@ def retrieve_schema_context(
31
52
  bridge tables needed to connect them. The agent stores no per-business table
32
53
  lists, keyword triggers or hand-coded joins.
33
54
  """
34
- glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
35
- metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
36
- examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
37
- table_names_from_context = _target_table_names(glossary + metrics + examples)
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)
38
92
  example_table_names = _ordered_target_table_names(examples)
39
- glossary_metric_table_names = _target_table_names(glossary + metrics)
93
+ glossary_metric_table_names = _target_table_names(glossary_terms + business_terms + concepts + metrics)
40
94
  required_tables = required_tables or set()
41
95
  preferred = table_names_from_context | required_tables
42
96
 
43
- tables = _rank_tables(schema.get("tables", []), question, preferred)[:max_tables]
44
- if isinstance(schema.get("tables"), list):
97
+ tables = _rank_tables(all_tables, question, preferred)[:max_tables]
98
+ if isinstance(all_tables, list):
45
99
  # Pin the must-have tables to the front in a deterministic order:
46
100
  # required (resolved-entity) tables first, then tables referenced by the
47
101
  # best matched examples, then glossary / metrics. Example table order is
48
102
  # preserved because examples often encode the intended join path; this
49
103
  # keeps late-declared fact tables from being evicted by weaker matches.
50
104
  ordered_names = _ordered_preferred_names(
51
- schema.get("tables", []),
105
+ all_tables,
52
106
  required_tables,
53
107
  example_table_names,
54
108
  glossary_metric_table_names,
55
109
  )
56
110
  if ordered_names:
57
- tables = _prepend_named_tables(schema.get("tables", []), tables, ordered_names, max_tables)
58
- tables = _expand_via_join_graph(schema, tables, max_tables)
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)
59
113
 
60
114
  selected_table_names = {_table_name(table) for table in tables}
61
- joins = _filter_joins(schema.get("joins", []), selected_table_names, required_tables)
62
- table_defaults = _filter_table_defaults(schema.get("tableDefaults", []), selected_table_names)
63
- enums = _filter_enums(schema.get("enums", []), selected_table_names)
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)
64
118
  return SchemaContext(
65
119
  tables=tables,
66
- joins=joins,
67
- glossary=glossary,
120
+ relations=relations,
121
+ business_terms=business_terms,
122
+ concepts=concepts,
68
123
  metrics=metrics,
69
124
  examples=examples,
70
- table_defaults=table_defaults,
125
+ default_filters=default_filters,
71
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,
72
219
  )
73
220
 
74
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
+
75
383
  def _ordered_preferred_names(
76
384
  tables: Any,
77
385
  required_tables: set[str],
@@ -104,28 +412,27 @@ def _ordered_preferred_names(
104
412
 
105
413
 
106
414
  def _expand_via_join_graph(
107
- schema: dict[str, Any],
415
+ all_tables: list[dict[str, Any]],
416
+ relations: list[dict[str, Any]],
108
417
  tables: list[dict[str, Any]],
109
418
  max_tables: int,
110
419
  ) -> list[dict[str, Any]]:
111
420
  """Add bridge tables that connect two or more already-selected tables.
112
421
 
113
- Relationship knowledge lives entirely in ``schema.joins``; this walks that
422
+ Relationship knowledge lives entirely in ``structure.relations``; this walks that
114
423
  graph so indirect paths (e.g. brand -> brand_project -> sponge_project) are
115
424
  complete without hard-coding any business-specific table set.
116
425
  """
117
- all_tables = schema.get("tables", [])
118
- joins = schema.get("joins", [])
119
- if not isinstance(all_tables, list) or not isinstance(joins, list):
426
+ if not isinstance(all_tables, list) or not isinstance(relations, list):
120
427
  return tables[:max_tables]
121
428
  by_name = {_table_name(table): table for table in all_tables if isinstance(table, dict)}
122
429
  selected = {_table_name(table) for table in tables}
123
430
  adjacency: dict[str, set[str]] = {}
124
- for join in joins:
125
- if not isinstance(join, dict):
431
+ for relation in relations:
432
+ if not isinstance(relation, dict):
126
433
  continue
127
- left = str(join.get("leftTable") or join.get("from") or "")
128
- right = str(join.get("rightTable") or join.get("to") or "")
434
+ left, _ = _relation_left(relation)
435
+ right, _ = _relation_right(relation)
129
436
  if left and right:
130
437
  adjacency.setdefault(left, set()).add(right)
131
438
  adjacency.setdefault(right, set()).add(left)
@@ -191,10 +498,283 @@ def _rank_tables(tables: Any, question: str, preferred_names: set[str]) -> list[
191
498
  return [table for _, table in ranked]
192
499
 
193
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
+
194
774
  def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list[dict[str, Any]]:
195
775
  if not isinstance(items, list):
196
776
  return []
197
- tokens = _question_tokens(question)
777
+ tokens = _semantic_tokens(question)
198
778
  matches: list[tuple[int, dict[str, Any]]] = []
199
779
  for item in items:
200
780
  if not isinstance(item, dict):
@@ -207,6 +787,85 @@ def _matching_items(items: Any, question: str, *, keys: tuple[str, ...]) -> list
207
787
  return [item for _, item in matches[:8]]
208
788
 
209
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
+
210
869
  def _target_table_names(items: list[dict[str, Any]]) -> set[str]:
211
870
  return set(_ordered_target_table_names(items))
212
871
 
@@ -215,15 +874,46 @@ def _ordered_target_table_names(items: list[dict[str, Any]]) -> tuple[str, ...]:
215
874
  names: set[str] = set()
216
875
  ordered: list[str] = []
217
876
  for item in items:
218
- for key in ("tables", "targetTables"):
877
+ for key in ("tables", "targetTables", "requiredTables"):
219
878
  value = item.get(key)
220
879
  if isinstance(value, list):
221
880
  for name in value:
222
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)
223
886
  sql = item.get("sql")
224
887
  if isinstance(sql, str):
225
888
  for referenced_table in _referenced_tables(sql):
226
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)
227
917
  targets = item.get("targets")
228
918
  if isinstance(targets, list):
229
919
  for target in targets:
@@ -252,10 +942,10 @@ def _filter_joins(
252
942
  for index, join in enumerate(joins):
253
943
  if not isinstance(join, dict):
254
944
  continue
255
- join_tables = {
256
- str(join.get("leftTable") or join.get("from") or ""),
257
- str(join.get("rightTable") or join.get("to") or ""),
258
- }
945
+ left_table, _ = _relation_left(join)
946
+ right_table, _ = _relation_right(join)
947
+ join_tables = {left_table, right_table}
948
+ join_tables.discard("")
259
949
  matched = join_tables & selected_table_names
260
950
  priority_score = len(join_tables & priority_table_names)
261
951
  if join_tables <= selected_table_names:
@@ -273,7 +963,14 @@ def _filter_table_defaults(table_defaults: Any, selected_table_names: set[str])
273
963
  return []
274
964
  result: list[dict[str, Any]] = []
275
965
  for entry in table_defaults:
276
- if isinstance(entry, dict) and str(entry.get("table") or "") in selected_table_names:
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:
277
974
  result.append(entry)
278
975
  return result
279
976
 
@@ -303,6 +1000,33 @@ def _column_text(column: dict[str, Any]) -> str:
303
1000
  )
304
1001
 
305
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
+
306
1030
  def _question_tokens(question: str) -> list[str]:
307
1031
  """Generic tokenization with no business vocabulary.
308
1032