@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,522 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import re
5
- from typing import Any, Literal
6
-
7
- from .schema_context_retriever import SchemaContext
8
- from .sql_generator import normalize_text_filters_to_like
9
-
10
-
11
- InstructionStage = Literal["before", "during", "after"]
12
-
13
- INSTRUCTION_STAGE_PREFIX: dict[InstructionStage, str] = {
14
- "before": "【生成前】",
15
- "during": "【生成中】",
16
- "after": "【执行后】",
17
- }
18
-
19
-
20
- NL2SQL_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的海绵数据 SQL 专家,一名专业 MySQL SQL 专家。
21
- 你必须以 SQL 专家的标准生成 SQL:输出前逐句自检,确保 SQL 整体可以直接执行,没有任何 SQL 语法错误,也没有 SQL 写法问题(如括号/引号不闭合、SELECT 与 GROUP BY 不一致、聚合函数误用、JOIN 缺少等值键、别名冲突、字段/表拼写错误等)。
22
- 业务规则的唯一来源是 payload 中的 mandatoryAgentInstructions 与 schemaContext.policy;必须逐条遵守,不得使用训练记忆中的业务口径替代 schema 要求。
23
- 生成 SQL 前必须严格按 schemaContext.readOrder 顺序阅读 schemaContext 各段内容,再组合 SELECT、JOIN、WHERE、LIMIT。
24
- 只输出 JSON,结构见 outputSchema。返回 JSON 中的 sql 字段必须直接以 SELECT 开头;不要为了状态枚举单独返回 clarification。用户明确提出状态筛选时把状态条件写入 WHERE;用户未提出状态筛选或 schemaContext 缺少枚举时不要追问,首次查询保持宽泛,并把相关状态字段及中文含义加入 SELECT 结果。"""
25
-
26
-
27
- def build_nl2sql_prompt(
28
- *,
29
- question: str,
30
- schema_context: SchemaContext,
31
- default_limit: int,
32
- max_limit: int,
33
- confirmed_query_plan: dict[str, Any] | None = None,
34
- ) -> str:
35
- payload = _base_sql_prompt_payload(
36
- question=question,
37
- schema_context=schema_context,
38
- default_limit=default_limit,
39
- max_limit=max_limit,
40
- confirmed_query_plan=confirmed_query_plan,
41
- )
42
- compact = confirmed_query_plan is not None
43
- return json.dumps(
44
- payload,
45
- ensure_ascii=False,
46
- indent=None if compact else 2,
47
- separators=(",", ":") if compact else (", ", ": "),
48
- )
49
-
50
-
51
- def build_sql_repair_prompt(
52
- *,
53
- question: str,
54
- original_sql: str,
55
- validation_error: dict[str, Any],
56
- schema_context: SchemaContext,
57
- default_limit: int,
58
- max_limit: int,
59
- confirmed_query_plan: dict[str, Any] | None = None,
60
- ) -> str:
61
- payload = _base_sql_prompt_payload(
62
- question=question,
63
- schema_context=schema_context,
64
- default_limit=default_limit,
65
- max_limit=max_limit,
66
- confirmed_query_plan=confirmed_query_plan,
67
- task="repair_sql",
68
- original_sql=original_sql,
69
- validation_error=validation_error,
70
- )
71
- compact = confirmed_query_plan is not None
72
- return json.dumps(
73
- payload,
74
- ensure_ascii=False,
75
- indent=None if compact else 2,
76
- separators=(",", ":") if compact else (", ", ": "),
77
- )
78
-
79
-
80
- def build_sql_expert_review_prompt(
81
- *,
82
- question: str,
83
- sql: str,
84
- schema_context: SchemaContext,
85
- default_limit: int,
86
- max_limit: int,
87
- confirmed_query_plan: dict[str, Any] | None = None,
88
- ) -> str:
89
- payload = _base_sql_prompt_payload(
90
- question=question,
91
- schema_context=schema_context,
92
- default_limit=default_limit,
93
- max_limit=max_limit,
94
- confirmed_query_plan=confirmed_query_plan,
95
- task="review_sql_executability",
96
- original_sql=sql,
97
- )
98
- payload["reviewInstruction"] = (
99
- "你是专业 MySQL SQL 专家,只检查 SQL 写法是否整体可执行,不改变业务口径。"
100
- "重点检查:聚合函数是否误写在 WHERE/JOIN ON;GROUP BY/HAVING 是否合法;"
101
- "子查询和派生表别名是否完整;外层引用的派生表字段是否存在;"
102
- "SELECT 中非聚合字段与 GROUP BY 是否兼容;MySQL 低版本不支持 WITH。"
103
- "如果 SQL 可执行,返回 executable=true 且 repairedSql 为空;"
104
- "如果不可执行,返回 executable=false、issues,并在不改变业务口径和 schema 字段的前提下给出 repairedSql。"
105
- )
106
- payload["reviewOutputSchema"] = {
107
- "executable": True,
108
- "issues": [{"code": "ISSUE_CODE", "message": "中文问题说明", "repairHint": "中文修复建议"}],
109
- "repairedSql": "SELECT ... LIMIT 50 或空字符串",
110
- }
111
- compact = confirmed_query_plan is not None
112
- return json.dumps(
113
- payload,
114
- ensure_ascii=False,
115
- indent=None if compact else 2,
116
- separators=(",", ":") if compact else (", ", ": "),
117
- )
118
-
119
-
120
- def _base_sql_prompt_payload(
121
- *,
122
- question: str,
123
- schema_context: SchemaContext,
124
- default_limit: int,
125
- max_limit: int,
126
- confirmed_query_plan: dict[str, Any] | None = None,
127
- task: str | None = None,
128
- original_sql: str | None = None,
129
- validation_error: dict[str, Any] | None = None,
130
- ) -> dict[str, Any]:
131
- compact = confirmed_query_plan is not None
132
- instruction_lines = _agent_instruction_lines(schema_context)
133
- payload: dict[str, Any] = {
134
- "mandatoryAgentInstructions": instruction_lines,
135
- "instruction": (
136
- "生成或修复 SQL 前必须先完整阅读 mandatoryAgentInstructions,"
137
- "再按 schemaContext.readOrder 阅读 schemaContext;"
138
- "WHERE/SELECT/JOIN/LIMIT/字段别名/澄清策略均以 mandatoryAgentInstructions 与 schemaContext 为准。"
139
- "涉及业务口径的查询必须优先使用 schemaContext.concepts、metrics、examples 或 exampleJoinPatterns;"
140
- "schemaContext 未提供对应口径时,不要凭训练记忆或字段名猜测 SQL。"
141
- "涉及时间范围时,如果命中的 schemaContext.concepts 或 metrics 提供 timeRangeField,"
142
- "必须使用该字段生成 WHERE 时间过滤,不要改用关联维表的同名时间字段。"
143
- "JOIN ... ON 只能写 schemaContext.relations / joins / exampleJoinPatterns 中提供的等值关联键;"
144
- "禁止凭字段名相似或训练记忆新增 schema 未定义 JOIN。"
145
- "涉及“最多/最高/最大/排名/排行”等聚合极值诉求时,必须优先按聚合指标倒序排序,"
146
- "再按时间等次要维度排序。"
147
- "你必须作为专业 MySQL SQL 专家自检 SQL 整体可执行性:聚合函数不能写在 WHERE/JOIN ON,"
148
- "聚合筛选必须放 HAVING 或先在子查询聚合后外层过滤;派生表字段和别名必须真实存在;"
149
- "MySQL 低版本不支持 WITH。"
150
- "不要把查询动作、状态变化、时间词或范围词当作名称筛选;"
151
- "只有用户明确说“名称包含/岗位名称是/叫做/简称为”时,才允许对 name/title/job_name 写 LIKE。"
152
- if not compact
153
- else
154
- "生成或修复 SQL 前必须先完整阅读 mandatoryAgentInstructions,"
155
- "再按 schemaContext.readOrder 阅读 schemaContext;"
156
- "confirmedQueryPlan 提供实体、筛选与输出字段,schemaContext 提供表结构、JOIN 与 policy。"
157
- "涉及业务口径的查询必须优先使用 schemaContext.concepts、metrics、examples 或 exampleJoinPatterns;"
158
- "schemaContext 未提供对应口径时,不要凭训练记忆或字段名猜测 SQL。"
159
- "涉及时间范围时,如果命中的 schemaContext.concepts 或 metrics 提供 timeRangeField,"
160
- "必须使用该字段生成 WHERE 时间过滤,不要改用关联维表的同名时间字段。"
161
- "JOIN ... ON 只能写 schemaContext.relations / joins / exampleJoinPatterns 中提供的等值关联键;"
162
- "禁止凭字段名相似或训练记忆新增 schema 未定义 JOIN。"
163
- "涉及“最多/最高/最大/排名/排行”等聚合极值诉求时,必须优先按聚合指标倒序排序,"
164
- "再按时间等次要维度排序。"
165
- "你必须作为专业 MySQL SQL 专家自检 SQL 整体可执行性:聚合函数不能写在 WHERE/JOIN ON,"
166
- "聚合筛选必须放 HAVING 或先在子查询聚合后外层过滤;派生表字段和别名必须真实存在;"
167
- "MySQL 低版本不支持 WITH。"
168
- "JOIN ... ON 只能写 schema relations / joins 中的等值关联键(如 a.id = b.x_id);"
169
- "confirmedQueryPlan.filters 里的 LIKE/IN 筛选必须写在 WHERE,禁止写在 JOIN ON;"
170
- "同一 logicalGroup 且 logicalOperator=OR 的筛选必须用 OR 连接,并用括号包裹;"
171
- "没有 logicalGroup 的筛选默认用 AND 连接;"
172
- "不同地点维度(city.name 与 region.name)若没有同一 logicalGroup,必须用 AND 连接,"
173
- "例如 c.name LIKE '%苏州%' AND r.name LIKE '%昆山%';禁止写成 (c.name LIKE '%苏州%' OR r.name LIKE '%昆山%');"
174
- "confirmedQueryPlan.fields 中 required=true 的字段必须出现在 SELECT 中;"
175
- "不要把查询动作、状态变化、时间词或范围词当作名称筛选。"
176
- ),
177
- "question": question,
178
- "limits": {"defaultLimit": default_limit, "maxLimit": max_limit},
179
- "schemaContext": _schema_context_payload(schema_context, compact=compact),
180
- "outputSchema": _sql_output_schema(default_limit, question),
181
- }
182
- if task:
183
- payload["task"] = task
184
- if task == "repair_sql":
185
- payload["originalSql"] = original_sql or ""
186
- payload["validationError"] = validation_error or {}
187
- elif task == "review_sql_executability":
188
- payload["sqlToReview"] = original_sql or ""
189
- if confirmed_query_plan:
190
- payload["confirmedQueryPlan"] = _compact_query_plan_for_sql(confirmed_query_plan)
191
- return payload
192
-
193
-
194
- def instruction_text(item: dict[str, Any]) -> str:
195
- return str(item.get("text") or item.get("instruction") or item.get("content") or "").strip()
196
-
197
-
198
- def filter_agent_instructions_by_stage(
199
- instructions: list[dict[str, Any]],
200
- stage: InstructionStage,
201
- ) -> list[str]:
202
- prefix = INSTRUCTION_STAGE_PREFIX[stage]
203
- texts: list[str] = []
204
- for item in instructions:
205
- if not isinstance(item, dict):
206
- continue
207
- text = instruction_text(item)
208
- if text.startswith(prefix):
209
- texts.append(text)
210
- return texts
211
-
212
-
213
- def format_numbered_instruction_lines(texts: list[str]) -> list[str]:
214
- return [f"{index}. {text}" for index, text in enumerate(texts, start=1)]
215
-
216
-
217
- def _agent_instruction_lines(
218
- schema_context: SchemaContext,
219
- *,
220
- stage: InstructionStage = "during",
221
- ) -> list[str]:
222
- texts = filter_agent_instructions_by_stage(schema_context.agent_instructions, stage)
223
- return format_numbered_instruction_lines(texts)
224
-
225
-
226
- def _filtered_agent_instruction_items(
227
- instructions: list[dict[str, Any]],
228
- *,
229
- stage: InstructionStage,
230
- ) -> list[dict[str, Any]]:
231
- prefix = INSTRUCTION_STAGE_PREFIX[stage]
232
- items: list[dict[str, Any]] = []
233
- for item in instructions:
234
- if not isinstance(item, dict):
235
- continue
236
- text = instruction_text(item)
237
- if text.startswith(prefix):
238
- items.append({"text": text})
239
- return items
240
-
241
-
242
- def _compact_query_plan_for_sql(query_plan: dict[str, Any]) -> dict[str, Any]:
243
- compact: dict[str, Any] = {}
244
- question = query_plan.get("question")
245
- if isinstance(question, str) and question.strip():
246
- compact["question"] = question.strip()
247
- entities = [
248
- {
249
- key: entity[key]
250
- for key in ("label", "type", "value", "source", "logicalGroup", "logicalOperator")
251
- if key in entity
252
- }
253
- for entity in query_plan.get("entities", [])
254
- if isinstance(entity, dict)
255
- ]
256
- if entities:
257
- compact["entities"] = entities
258
- plan_filters = query_plan.get("_sqlFilters")
259
- if not isinstance(plan_filters, list):
260
- plan_filters = query_plan.get("filters", [])
261
- filters = [
262
- {
263
- key: item[key]
264
- for key in ("field", "operator", "value", "description", "source", "logicalGroup", "logicalOperator")
265
- if key in item
266
- }
267
- for item in plan_filters
268
- if isinstance(item, dict)
269
- ]
270
- if filters:
271
- compact["filters"] = filters
272
- fields = [
273
- {key: item[key] for key in ("table", "field", "description", "source", "required") if key in item}
274
- for item in query_plan.get("fields", [])
275
- if isinstance(item, dict)
276
- ]
277
- if fields:
278
- compact["fields"] = fields
279
- tables = [
280
- {key: item[key] for key in ("name", "description") if key in item}
281
- for item in query_plan.get("tables", [])
282
- if isinstance(item, dict) and item.get("name")
283
- ]
284
- if tables:
285
- compact["tables"] = tables
286
- return compact
287
-
288
-
289
- def _schema_context_payload(schema_context: SchemaContext, *, compact: bool = False) -> dict[str, Any]:
290
- selected_tables = {
291
- str(table.get("tableName") or table.get("name") or "")
292
- for table in schema_context.tables
293
- if isinstance(table, dict)
294
- }
295
- selected_tables.discard("")
296
- relations = schema_context.relations
297
- policy = schema_context.policy
298
- if compact:
299
- relations = _slim_relations(relations, selected_tables)
300
- policy = _slim_policy(policy, selected_tables)
301
- payload: dict[str, Any] = {
302
- "readOrder": _schema_read_order(compact=compact),
303
- "policy": policy,
304
- "schema": {"tables": schema_context.tables},
305
- "joins": schema_context.manual_joins,
306
- "relations": relations,
307
- "defaultFilters": schema_context.default_filters,
308
- "enums": schema_context.enums,
309
- "businessTerms": schema_context.business_terms,
310
- "concepts": schema_context.concepts,
311
- "metrics": schema_context.metrics,
312
- "exampleJoinPatterns": schema_context.example_join_patterns,
313
- "exampleGuidance": schema_context.example_guidance,
314
- "structureHints": schema_context.structure_hints,
315
- "examples": _business_examples(schema_context.examples),
316
- }
317
- if not compact:
318
- payload["agentInstructions"] = _filtered_agent_instruction_items(
319
- schema_context.agent_instructions,
320
- stage="during",
321
- )
322
- payload["glossary"] = schema_context.glossary_terms
323
- payload["tables"] = schema_context.tables
324
- if compact:
325
- if not payload["businessTerms"]:
326
- payload.pop("businessTerms", None)
327
- if not payload["metrics"]:
328
- payload.pop("metrics", None)
329
- if not payload["exampleJoinPatterns"]:
330
- payload.pop("exampleJoinPatterns", None)
331
- if not payload.get("exampleGuidance"):
332
- payload.pop("exampleGuidance", None)
333
- if not payload.get("structureHints"):
334
- payload.pop("structureHints", None)
335
- if not payload["examples"]:
336
- payload.pop("examples", None)
337
- if not payload["enums"]:
338
- payload.pop("enums", None)
339
- if not payload["defaultFilters"]:
340
- payload.pop("defaultFilters", None)
341
- if not payload["joins"]:
342
- payload.pop("joins", None)
343
- else:
344
- if not payload.get("glossary"):
345
- payload.pop("glossary", None)
346
- if not payload["businessTerms"]:
347
- payload.pop("businessTerms", None)
348
- if not payload["metrics"]:
349
- payload.pop("metrics", None)
350
- if not payload["exampleJoinPatterns"]:
351
- payload.pop("exampleJoinPatterns", None)
352
- if not payload.get("exampleGuidance"):
353
- payload.pop("exampleGuidance", None)
354
- if not payload.get("structureHints"):
355
- payload.pop("structureHints", None)
356
- if not payload["examples"]:
357
- payload.pop("examples", None)
358
- if not payload["enums"]:
359
- payload.pop("enums", None)
360
- if not payload["defaultFilters"]:
361
- payload.pop("defaultFilters", None)
362
- return payload
363
-
364
-
365
- def _slim_relations(
366
- relations: list[dict[str, Any]],
367
- selected_tables: set[str],
368
- ) -> list[dict[str, Any]]:
369
- slim: list[dict[str, Any]] = []
370
- for relation in relations:
371
- if not isinstance(relation, dict):
372
- continue
373
- left_table, right_table = _relation_endpoint_tables(relation)
374
- if left_table not in selected_tables or right_table not in selected_tables:
375
- continue
376
- from_ref, to_ref = _relation_from_to_refs(relation)
377
- if not from_ref or not to_ref:
378
- continue
379
- item: dict[str, Any] = {"from": from_ref, "to": to_ref}
380
- join_type = relation.get("joinType")
381
- if isinstance(join_type, str) and join_type.strip():
382
- item["joinType"] = join_type.strip()
383
- required_filters = relation.get("requiredFilters")
384
- if isinstance(required_filters, list) and required_filters:
385
- item["requiredFilters"] = [str(value) for value in required_filters if str(value).strip()]
386
- slim.append(item)
387
- return slim
388
-
389
-
390
- def _slim_policy(
391
- policy_items: list[dict[str, Any]],
392
- selected_tables: set[str],
393
- ) -> list[dict[str, Any]]:
394
- if not policy_items:
395
- return []
396
- merged: dict[str, Any] = {}
397
- for item in policy_items:
398
- if not isinstance(item, dict):
399
- continue
400
- for key, value in item.items():
401
- if key in {"requiredScopes", "scopePlaceholders", "source"}:
402
- continue
403
- if key == "fieldBlacklist" and isinstance(value, list):
404
- filtered = [
405
- {
406
- "table": entry.get("table"),
407
- "columns": entry.get("columns"),
408
- }
409
- for entry in value
410
- if isinstance(entry, dict)
411
- and str(entry.get("table") or "") in selected_tables
412
- and isinstance(entry.get("columns"), list)
413
- ]
414
- if filtered:
415
- merged["fieldBlacklist"] = filtered
416
- elif key == "sqlRules" and value:
417
- merged["sqlRules"] = value
418
- elif key not in merged and value:
419
- merged[key] = value
420
- return [merged] if merged else []
421
-
422
-
423
- def _relation_endpoint_tables(relation: dict[str, Any]) -> tuple[str, str]:
424
- left_table = str(relation.get("leftTable") or "")
425
- right_table = str(relation.get("rightTable") or "")
426
- if left_table and right_table:
427
- return left_table, right_table
428
- from_ref = str(relation.get("from") or "")
429
- to_ref = str(relation.get("to") or "")
430
- left = from_ref.split(".", 1)[0] if "." in from_ref else from_ref
431
- right = to_ref.split(".", 1)[0] if "." in to_ref else to_ref
432
- return left, right
433
-
434
-
435
- def _relation_from_to_refs(relation: dict[str, Any]) -> tuple[str | None, str | None]:
436
- left_table = str(relation.get("leftTable") or "")
437
- right_table = str(relation.get("rightTable") or "")
438
- if left_table and right_table:
439
- left_column = str(relation.get("leftColumn") or relation.get("fromColumn") or "")
440
- right_column = str(relation.get("rightColumn") or relation.get("toColumn") or "")
441
- from_ref = f"{left_table}.{left_column}".rstrip(".") if left_column else left_table
442
- to_ref = f"{right_table}.{right_column}".rstrip(".") if right_column else right_table
443
- return from_ref, to_ref
444
- from_ref = str(relation.get("from") or "").strip()
445
- to_ref = str(relation.get("to") or "").strip()
446
- return (from_ref or None), (to_ref or None)
447
-
448
-
449
- def _sql_output_schema(default_limit: int, question: str) -> dict[str, Any]:
450
- return {
451
- "sql": f"SELECT column AS 中文含义 ... LIMIT {default_limit}",
452
- "tables": ["table_name"],
453
- "placeholders": [],
454
- "usedColumns": ["table.column"],
455
- "usedJoins": ["left_table.left_column = right_table.right_column"],
456
- "metricRefs": ["schemaContext.metrics.name"],
457
- "clarification": None,
458
- "statusEnumPolicy": {
459
- "explicitStatusFilter": "用户明确提出状态筛选时,把状态枚举条件写入 WHERE,并在 SELECT 中用 CASE 或枚举关联展示中文含义。",
460
- "missingOrUnspecifiedStatus": "用户未提出状态筛选或 schemaContext 缺少枚举时,不要追问;首次查询保持宽泛,并把相关状态字段及中文含义加入 SELECT。",
461
- },
462
- }
463
-
464
-
465
- def _schema_read_order(*, compact: bool = False) -> list[str]:
466
- if compact:
467
- return ["policy", "schema", "concepts", "relations", "enums", "policy"]
468
- return [
469
- "agentInstructions",
470
- "policy",
471
- "glossary",
472
- "schema",
473
- "concepts/metrics/businessTerms",
474
- "defaultFilters",
475
- "joins",
476
- "relations",
477
- "exampleJoinPatterns",
478
- "enums",
479
- "policy",
480
- ]
481
-
482
-
483
- def _business_examples(examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
484
- """Prepare examples for the LLM prompt.
485
-
486
- The MCP examples carry data-scope predicates (``... IN (:scope.project_ids)``)
487
- that the LLM must never emit. Rather than dropping every example that has
488
- them (which removed almost all join guidance), strip the scope predicates
489
- and keep the rest of the pattern. If an example still contains ``:scope.``
490
- after stripping, drop it to stay safe.
491
- """
492
- result: list[dict[str, Any]] = []
493
- for example in examples:
494
- sql = str(example.get("sql") or "")
495
- original_sql = sql
496
- if ":scope." in sql:
497
- sql = _strip_scope_predicates(sql)
498
- if ":scope." in sql:
499
- continue
500
- sql = _normalize_example_text_filters(sql)
501
- if sql != original_sql:
502
- example = {**example, "sql": sql}
503
- result.append(example)
504
- return result
505
-
506
-
507
- def _strip_scope_predicates(sql: str) -> str:
508
- # Remove "AND <column> IN (:scope.<name>)" predicates, then tidy a WHERE
509
- # clause that may be left starting with a stray AND.
510
- cleaned = re.sub(
511
- r"\s+AND\s+[A-Za-z_][\w.]*\s+IN\s*\(\s*:scope\.\w+\s*\)",
512
- "",
513
- sql,
514
- flags=re.IGNORECASE,
515
- )
516
- cleaned = re.sub(r"\bWHERE\s+AND\s+", "WHERE ", cleaned, flags=re.IGNORECASE)
517
- return cleaned
518
-
519
-
520
- def _normalize_example_text_filters(sql: str) -> str:
521
- """Align business examples with SQL guardrails before prompting."""
522
- return normalize_text_filters_to_like(sql)