@roll-agent/octopus-agent 0.0.10 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -1
- package/SKILL.md +77 -12
- package/package.json +1 -1
- package/pyproject.toml +4 -2
- package/references/env.yaml +21 -0
- package/scripts/refresh_dictionary.py +67 -0
- package/scripts/verify_binding.py +47 -0
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +4045 -415
- package/src/octopus_skill/config.py +55 -10
- package/src/octopus_skill/exporter.py +66 -0
- package/src/octopus_skill/llm_result_renderer.py +101 -0
- package/src/octopus_skill/llm_sql_generator.py +17 -3
- package/src/octopus_skill/mcp_client.py +16 -0
- package/src/octopus_skill/octopus_run.py +370 -32
- package/src/octopus_skill/prompt_builder.py +427 -78
- package/src/octopus_skill/question_analyzer.py +388 -0
- package/src/octopus_skill/result_renderer.py +38 -14
- package/src/octopus_skill/schema_context_retriever.py +932 -159
- package/src/octopus_skill/sql_generator.py +2093 -1231
- package/src/octopus_skill/query_sql_cache.py +0 -72
|
@@ -1,49 +1,26 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Literal
|
|
5
6
|
|
|
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
|
+
}
|
|
7
18
|
|
|
8
19
|
|
|
9
20
|
NL2SQL_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的海绵数据 SQL 生成器。
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
不得使用 :scope.project_ids、:scope.brand_ids 或任何 :scope.* placeholder(占位符)。
|
|
14
|
-
如果用户没有明确要求返回条数,LIMIT 必须使用 defaultLimit。
|
|
15
|
-
如果用户要求所有数据、全部数据、全量数据、完整数据,或要求导出/生成 Excel/xlsx/表格文件,LIMIT 必须使用 maxLimit,不要使用 defaultLimit。
|
|
16
|
-
不得查询 auth 库、密码、令牌、密钥、凭证字段。
|
|
17
|
-
不得输出敏感字段,包括手机号、电话、联系方式、身份证、密码、token、secret、credential、private_key 等。
|
|
18
|
-
如果用户要求敏感字段,必须从 SELECT 中排除这些字段;还有其他非敏感字段时继续生成去敏后的 SQL。
|
|
19
|
-
不得生成 INSERT、UPDATE、DELETE、DDL、非 SELECT SQL 或多语句 SQL。
|
|
20
|
-
SELECT 中每个输出字段都必须使用 AS 中文含义,例如 b.name AS 品牌名称。
|
|
21
|
-
禁止使用英文别名,例如 brand_name、project_name。
|
|
22
|
-
用户点名要求输出的字段,必须优先使用 schema context 中含义匹配的真实字段或真实关联表生成。
|
|
23
|
-
不得为了凑齐字段写 NULL AS 字段名、CAST(NULL AS ...) AS 字段名 等空占位表达式。
|
|
24
|
-
只有 schema context 中完全没有相关真实字段或关联表时,才允许不返回该字段;不要用空占位字段替代。
|
|
25
|
-
用户明确给出品牌、项目、岗位、职位、门店等实体名称时,必须在对应名称字段上使用 LIKE 过滤,不要先查询全量或默认 LIMIT 列表再比对。
|
|
26
|
-
名称类字段和状态文本类字段的筛选条件必须使用 LIKE '%值%',例如 name、job_name、status_text、status_name。
|
|
27
|
-
数字 ID、数字状态枚举、删除标记、时间范围和 JOIN ON 表关联条件不要改成 LIKE;例如 status = 1、is_delete = 0、a.id = b.id、create_at >= '2026-05-01' 保持原比较符。
|
|
28
|
-
只有“名称包含/名称为/是/等于/招聘xx岗位/在招xx职位”等明确关系才能判定为岗位或职位名称;不要仅因逗号、空格、冒号相邻就推断岗位名称。
|
|
29
|
-
业务查询必须一次生成完整 SQL;不要先查 brand、sponge_project、city、store、job_basic_info 等单表列表来找 ID,也不要要求外部先提供 ID。
|
|
30
|
-
业务查询中需要按品牌、项目、城市、门店、岗位名称过滤时,必须 JOIN 对应表并直接使用 name 或 job_name LIKE。
|
|
31
|
-
涉及业务指标或业务口径时,必须优先使用 schemaContext.metrics 中的 definition、aggregation 和 filters。
|
|
32
|
-
如果 schemaContext.metrics 没有给出对应业务口径,不得自行推断状态枚举、有效性过滤或跨轮次分割规则。
|
|
33
|
-
所有业务字段、状态枚举、状态映射、表关联方式必须来自 schemaContext.tables、schemaContext.joins、schemaContext.glossary、schemaContext.metrics 或 schemaContext.examples。
|
|
34
|
-
不得自行猜测状态值;例如“面试成功”“进行中”“已上岗”等状态必须使用 schemaContext 中给出的字段和枚举。
|
|
35
|
-
所有 JOIN 必须优先使用 schemaContext.joins;如果相关 JOIN 只出现在 schemaContext.metrics.filters 或 schemaContext.examples 中,也必须严格照用。
|
|
36
|
-
如果 schemaContext 没有明确给出表、字段、JOIN 或指标口径,不要猜测;返回 {"sql":"","tables":[],"placeholders":[],"usedColumns":[],"usedJoins":[],"metricRefs":[]}。
|
|
37
|
-
报名/工单/候选人状态/姓名/岗位/项目明细查询,必须优先使用 schemaContext 中的报名工单表和示例,不得退化为只查询 brand 品牌列表。
|
|
38
|
-
报名工单与岗位的关联方式必须来自 schemaContext;如果 schemaContext 指明 job_id 关联 job_basic_info.job_id,不得改用 job_basic_info.id。
|
|
39
|
-
地点条件必须先按业务字典判断层级:province.name 省份优先,其次 city.name 城市,再其次 region.name 区域。
|
|
40
|
-
用户提到南京、上海、江苏等地点时,不要先猜测为道路、商圈、门店或地址关键词;只有省/城市/区域都无法匹配时,才按地址关键词模糊查询。
|
|
41
|
-
岗位或门店地点查询优先使用 job_address.city_id、job_address.region_id、store.city_id、store.region_id 等结构化字段。
|
|
42
|
-
用户问题包含时间范围时,SQL 的 WHERE 条件必须直接包含对应时间字段过滤,不得先查询全量数据再补充时间范围。
|
|
43
|
-
时间字段按语义选择:创建/新建/新增使用 create_at,发布/上架使用 publish_at,下架使用 off_at,更新/修改使用 update_at。
|
|
44
|
-
必须包含 LIMIT,且 LIMIT 不超过 500。
|
|
45
|
-
不要使用 WITH;低版本 MySQL 不支持公共表表达式,请使用 JOIN 或派生表。
|
|
46
|
-
只输出 JSON,不输出解释文本、拒绝文本或 Markdown。"""
|
|
21
|
+
业务规则的唯一来源是 payload 中的 mandatoryAgentInstructions 与 schemaContext.policy;必须逐条遵守,不得使用训练记忆中的业务口径替代 schema 要求。
|
|
22
|
+
生成 SQL 前必须严格按 schemaContext.readOrder 顺序阅读 schemaContext 各段内容,再组合 SELECT、JOIN、WHERE、LIMIT。
|
|
23
|
+
只输出 JSON,结构见 outputSchema。返回 JSON 中的 sql 字段必须直接以 SELECT 开头;不要为了状态枚举单独返回 clarification。用户明确提出状态筛选时把状态条件写入 WHERE;用户未提出状态筛选或 schemaContext 缺少枚举时不要追问,首次查询保持宽泛,并把相关状态字段及中文含义加入 SELECT 结果。"""
|
|
47
24
|
|
|
48
25
|
|
|
49
26
|
def build_nl2sql_prompt(
|
|
@@ -52,27 +29,22 @@ def build_nl2sql_prompt(
|
|
|
52
29
|
schema_context: SchemaContext,
|
|
53
30
|
default_limit: int,
|
|
54
31
|
max_limit: int,
|
|
32
|
+
confirmed_query_plan: dict[str, Any] | None = None,
|
|
55
33
|
) -> str:
|
|
56
|
-
payload =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"usedColumns": ["table.column"],
|
|
71
|
-
"usedJoins": ["left_table.left_column = right_table.right_column"],
|
|
72
|
-
"metricRefs": ["schemaContext.metrics.name"],
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
return json.dumps(payload, ensure_ascii=False, indent=2)
|
|
34
|
+
payload = _base_sql_prompt_payload(
|
|
35
|
+
question=question,
|
|
36
|
+
schema_context=schema_context,
|
|
37
|
+
default_limit=default_limit,
|
|
38
|
+
max_limit=max_limit,
|
|
39
|
+
confirmed_query_plan=confirmed_query_plan,
|
|
40
|
+
)
|
|
41
|
+
compact = confirmed_query_plan is not None
|
|
42
|
+
return json.dumps(
|
|
43
|
+
payload,
|
|
44
|
+
ensure_ascii=False,
|
|
45
|
+
indent=None if compact else 2,
|
|
46
|
+
separators=(",", ":") if compact else (", ", ": "),
|
|
47
|
+
)
|
|
76
48
|
|
|
77
49
|
|
|
78
50
|
def build_sql_repair_prompt(
|
|
@@ -83,31 +55,408 @@ def build_sql_repair_prompt(
|
|
|
83
55
|
schema_context: SchemaContext,
|
|
84
56
|
default_limit: int,
|
|
85
57
|
max_limit: int,
|
|
58
|
+
confirmed_query_plan: dict[str, Any] | None = None,
|
|
86
59
|
) -> str:
|
|
87
|
-
payload =
|
|
88
|
-
|
|
60
|
+
payload = _base_sql_prompt_payload(
|
|
61
|
+
question=question,
|
|
62
|
+
schema_context=schema_context,
|
|
63
|
+
default_limit=default_limit,
|
|
64
|
+
max_limit=max_limit,
|
|
65
|
+
confirmed_query_plan=confirmed_query_plan,
|
|
66
|
+
task="repair_sql",
|
|
67
|
+
original_sql=original_sql,
|
|
68
|
+
validation_error=validation_error,
|
|
69
|
+
)
|
|
70
|
+
compact = confirmed_query_plan is not None
|
|
71
|
+
return json.dumps(
|
|
72
|
+
payload,
|
|
73
|
+
ensure_ascii=False,
|
|
74
|
+
indent=None if compact else 2,
|
|
75
|
+
separators=(",", ":") if compact else (", ", ": "),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _base_sql_prompt_payload(
|
|
80
|
+
*,
|
|
81
|
+
question: str,
|
|
82
|
+
schema_context: SchemaContext,
|
|
83
|
+
default_limit: int,
|
|
84
|
+
max_limit: int,
|
|
85
|
+
confirmed_query_plan: dict[str, Any] | None = None,
|
|
86
|
+
task: str | None = None,
|
|
87
|
+
original_sql: str | None = None,
|
|
88
|
+
validation_error: dict[str, Any] | None = None,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
compact = confirmed_query_plan is not None
|
|
91
|
+
instruction_lines = _agent_instruction_lines(schema_context)
|
|
92
|
+
payload: dict[str, Any] = {
|
|
93
|
+
"mandatoryAgentInstructions": instruction_lines,
|
|
94
|
+
"instruction": (
|
|
95
|
+
"生成或修复 SQL 前必须先完整阅读 mandatoryAgentInstructions,"
|
|
96
|
+
"再按 schemaContext.readOrder 阅读 schemaContext;"
|
|
97
|
+
"WHERE/SELECT/JOIN/LIMIT/字段别名/澄清策略均以 mandatoryAgentInstructions 与 schemaContext 为准。"
|
|
98
|
+
"涉及业务口径的查询必须优先使用 schemaContext.concepts、metrics、examples 或 exampleJoinPatterns;"
|
|
99
|
+
"schemaContext 未提供对应口径时,不要凭训练记忆或字段名猜测 SQL。"
|
|
100
|
+
"涉及时间范围时,如果命中的 schemaContext.concepts 或 metrics 提供 timeRangeField,"
|
|
101
|
+
"必须使用该字段生成 WHERE 时间过滤,不要改用关联维表的同名时间字段。"
|
|
102
|
+
"涉及“最多/最高/最大/排名/排行”等聚合极值诉求时,必须优先按聚合指标倒序排序,"
|
|
103
|
+
"再按时间等次要维度排序。"
|
|
104
|
+
"不要把查询动作、状态变化、时间词或范围词当作名称筛选;"
|
|
105
|
+
"只有用户明确说“名称包含/岗位名称是/叫做/简称为”时,才允许对 name/title/job_name 写 LIKE。"
|
|
106
|
+
if not compact
|
|
107
|
+
else
|
|
108
|
+
"生成或修复 SQL 前必须先完整阅读 mandatoryAgentInstructions,"
|
|
109
|
+
"再按 schemaContext.readOrder 阅读 schemaContext;"
|
|
110
|
+
"confirmedQueryPlan 提供实体、筛选与输出字段,schemaContext 提供表结构、JOIN 与 policy。"
|
|
111
|
+
"涉及业务口径的查询必须优先使用 schemaContext.concepts、metrics、examples 或 exampleJoinPatterns;"
|
|
112
|
+
"schemaContext 未提供对应口径时,不要凭训练记忆或字段名猜测 SQL。"
|
|
113
|
+
"涉及时间范围时,如果命中的 schemaContext.concepts 或 metrics 提供 timeRangeField,"
|
|
114
|
+
"必须使用该字段生成 WHERE 时间过滤,不要改用关联维表的同名时间字段。"
|
|
115
|
+
"涉及“最多/最高/最大/排名/排行”等聚合极值诉求时,必须优先按聚合指标倒序排序,"
|
|
116
|
+
"再按时间等次要维度排序。"
|
|
117
|
+
"JOIN ... ON 只能写 schema relations / joins 中的等值关联键(如 a.id = b.x_id);"
|
|
118
|
+
"confirmedQueryPlan.filters 里的 LIKE/IN 筛选必须写在 WHERE,禁止写在 JOIN ON;"
|
|
119
|
+
"同一 logicalGroup 且 logicalOperator=OR 的筛选必须用 OR 连接,并用括号包裹;"
|
|
120
|
+
"confirmedQueryPlan.fields 中 required=true 的字段必须出现在 SELECT 中;"
|
|
121
|
+
"不要把查询动作、状态变化、时间词或范围词当作名称筛选。"
|
|
122
|
+
),
|
|
89
123
|
"question": question,
|
|
90
|
-
"originalSql": original_sql,
|
|
91
|
-
"validationError": validation_error,
|
|
92
124
|
"limits": {"defaultLimit": default_limit, "maxLimit": max_limit},
|
|
93
|
-
"schemaContext":
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
125
|
+
"schemaContext": _schema_context_payload(schema_context, compact=compact),
|
|
126
|
+
"outputSchema": _sql_output_schema(default_limit, question),
|
|
127
|
+
}
|
|
128
|
+
if task == "repair_sql":
|
|
129
|
+
payload["task"] = task
|
|
130
|
+
payload["originalSql"] = original_sql or ""
|
|
131
|
+
payload["validationError"] = validation_error or {}
|
|
132
|
+
if confirmed_query_plan:
|
|
133
|
+
payload["confirmedQueryPlan"] = _compact_query_plan_for_sql(confirmed_query_plan)
|
|
134
|
+
return payload
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def instruction_text(item: dict[str, Any]) -> str:
|
|
138
|
+
return str(item.get("text") or item.get("instruction") or item.get("content") or "").strip()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def filter_agent_instructions_by_stage(
|
|
142
|
+
instructions: list[dict[str, Any]],
|
|
143
|
+
stage: InstructionStage,
|
|
144
|
+
) -> list[str]:
|
|
145
|
+
prefix = INSTRUCTION_STAGE_PREFIX[stage]
|
|
146
|
+
texts: list[str] = []
|
|
147
|
+
for item in instructions:
|
|
148
|
+
if not isinstance(item, dict):
|
|
149
|
+
continue
|
|
150
|
+
text = instruction_text(item)
|
|
151
|
+
if text.startswith(prefix):
|
|
152
|
+
texts.append(text)
|
|
153
|
+
return texts
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def format_numbered_instruction_lines(texts: list[str]) -> list[str]:
|
|
157
|
+
return [f"{index}. {text}" for index, text in enumerate(texts, start=1)]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _agent_instruction_lines(
|
|
161
|
+
schema_context: SchemaContext,
|
|
162
|
+
*,
|
|
163
|
+
stage: InstructionStage = "during",
|
|
164
|
+
) -> list[str]:
|
|
165
|
+
texts = filter_agent_instructions_by_stage(schema_context.agent_instructions, stage)
|
|
166
|
+
return format_numbered_instruction_lines(texts)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _filtered_agent_instruction_items(
|
|
170
|
+
instructions: list[dict[str, Any]],
|
|
171
|
+
*,
|
|
172
|
+
stage: InstructionStage,
|
|
173
|
+
) -> list[dict[str, Any]]:
|
|
174
|
+
prefix = INSTRUCTION_STAGE_PREFIX[stage]
|
|
175
|
+
items: list[dict[str, Any]] = []
|
|
176
|
+
for item in instructions:
|
|
177
|
+
if not isinstance(item, dict):
|
|
178
|
+
continue
|
|
179
|
+
text = instruction_text(item)
|
|
180
|
+
if text.startswith(prefix):
|
|
181
|
+
items.append({"text": text})
|
|
182
|
+
return items
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _compact_query_plan_for_sql(query_plan: dict[str, Any]) -> dict[str, Any]:
|
|
186
|
+
compact: dict[str, Any] = {}
|
|
187
|
+
question = query_plan.get("question")
|
|
188
|
+
if isinstance(question, str) and question.strip():
|
|
189
|
+
compact["question"] = question.strip()
|
|
190
|
+
entities = [
|
|
191
|
+
{
|
|
192
|
+
key: entity[key]
|
|
193
|
+
for key in ("label", "type", "value", "source", "logicalGroup", "logicalOperator")
|
|
194
|
+
if key in entity
|
|
195
|
+
}
|
|
196
|
+
for entity in query_plan.get("entities", [])
|
|
197
|
+
if isinstance(entity, dict)
|
|
198
|
+
]
|
|
199
|
+
if entities:
|
|
200
|
+
compact["entities"] = entities
|
|
201
|
+
filters = [
|
|
202
|
+
{
|
|
203
|
+
key: item[key]
|
|
204
|
+
for key in ("field", "operator", "value", "description", "source", "logicalGroup", "logicalOperator")
|
|
205
|
+
if key in item
|
|
206
|
+
}
|
|
207
|
+
for item in query_plan.get("filters", [])
|
|
208
|
+
if isinstance(item, dict)
|
|
209
|
+
]
|
|
210
|
+
if filters:
|
|
211
|
+
compact["filters"] = filters
|
|
212
|
+
fields = [
|
|
213
|
+
{key: item[key] for key in ("table", "field", "description", "source", "required") if key in item}
|
|
214
|
+
for item in query_plan.get("fields", [])
|
|
215
|
+
if isinstance(item, dict)
|
|
216
|
+
]
|
|
217
|
+
if fields:
|
|
218
|
+
compact["fields"] = fields
|
|
219
|
+
tables = [
|
|
220
|
+
{key: item[key] for key in ("name", "description") if key in item}
|
|
221
|
+
for item in query_plan.get("tables", [])
|
|
222
|
+
if isinstance(item, dict) and item.get("name")
|
|
223
|
+
]
|
|
224
|
+
if tables:
|
|
225
|
+
compact["tables"] = tables
|
|
226
|
+
return compact
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _schema_context_payload(schema_context: SchemaContext, *, compact: bool = False) -> dict[str, Any]:
|
|
230
|
+
selected_tables = {
|
|
231
|
+
str(table.get("tableName") or table.get("name") or "")
|
|
232
|
+
for table in schema_context.tables
|
|
233
|
+
if isinstance(table, dict)
|
|
234
|
+
}
|
|
235
|
+
selected_tables.discard("")
|
|
236
|
+
relations = schema_context.relations
|
|
237
|
+
policy = schema_context.policy
|
|
238
|
+
if compact:
|
|
239
|
+
relations = _slim_relations(relations, selected_tables)
|
|
240
|
+
policy = _slim_policy(policy, selected_tables)
|
|
241
|
+
payload: dict[str, Any] = {
|
|
242
|
+
"readOrder": _schema_read_order(compact=compact),
|
|
243
|
+
"policy": policy,
|
|
244
|
+
"schema": {"tables": schema_context.tables},
|
|
245
|
+
"joins": schema_context.manual_joins,
|
|
246
|
+
"relations": relations,
|
|
247
|
+
"defaultFilters": schema_context.default_filters,
|
|
248
|
+
"enums": schema_context.enums,
|
|
249
|
+
"businessTerms": schema_context.business_terms,
|
|
250
|
+
"concepts": schema_context.concepts,
|
|
251
|
+
"metrics": schema_context.metrics,
|
|
252
|
+
"exampleJoinPatterns": schema_context.example_join_patterns,
|
|
253
|
+
"exampleGuidance": schema_context.example_guidance,
|
|
254
|
+
"structureHints": schema_context.structure_hints,
|
|
255
|
+
"examples": _business_examples(schema_context.examples),
|
|
256
|
+
}
|
|
257
|
+
if not compact:
|
|
258
|
+
payload["agentInstructions"] = _filtered_agent_instruction_items(
|
|
259
|
+
schema_context.agent_instructions,
|
|
260
|
+
stage="during",
|
|
261
|
+
)
|
|
262
|
+
payload["glossary"] = schema_context.glossary_terms
|
|
263
|
+
payload["tables"] = schema_context.tables
|
|
264
|
+
if compact:
|
|
265
|
+
if not payload["businessTerms"]:
|
|
266
|
+
payload.pop("businessTerms", None)
|
|
267
|
+
if not payload["metrics"]:
|
|
268
|
+
payload.pop("metrics", None)
|
|
269
|
+
if not payload["exampleJoinPatterns"]:
|
|
270
|
+
payload.pop("exampleJoinPatterns", None)
|
|
271
|
+
if not payload.get("exampleGuidance"):
|
|
272
|
+
payload.pop("exampleGuidance", None)
|
|
273
|
+
if not payload.get("structureHints"):
|
|
274
|
+
payload.pop("structureHints", None)
|
|
275
|
+
if not payload["examples"]:
|
|
276
|
+
payload.pop("examples", None)
|
|
277
|
+
if not payload["enums"]:
|
|
278
|
+
payload.pop("enums", None)
|
|
279
|
+
if not payload["defaultFilters"]:
|
|
280
|
+
payload.pop("defaultFilters", None)
|
|
281
|
+
if not payload["joins"]:
|
|
282
|
+
payload.pop("joins", None)
|
|
283
|
+
else:
|
|
284
|
+
if not payload.get("glossary"):
|
|
285
|
+
payload.pop("glossary", None)
|
|
286
|
+
if not payload["businessTerms"]:
|
|
287
|
+
payload.pop("businessTerms", None)
|
|
288
|
+
if not payload["metrics"]:
|
|
289
|
+
payload.pop("metrics", None)
|
|
290
|
+
if not payload["exampleJoinPatterns"]:
|
|
291
|
+
payload.pop("exampleJoinPatterns", None)
|
|
292
|
+
if not payload.get("exampleGuidance"):
|
|
293
|
+
payload.pop("exampleGuidance", None)
|
|
294
|
+
if not payload.get("structureHints"):
|
|
295
|
+
payload.pop("structureHints", None)
|
|
296
|
+
if not payload["examples"]:
|
|
297
|
+
payload.pop("examples", None)
|
|
298
|
+
if not payload["enums"]:
|
|
299
|
+
payload.pop("enums", None)
|
|
300
|
+
if not payload["defaultFilters"]:
|
|
301
|
+
payload.pop("defaultFilters", None)
|
|
302
|
+
return payload
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _slim_relations(
|
|
306
|
+
relations: list[dict[str, Any]],
|
|
307
|
+
selected_tables: set[str],
|
|
308
|
+
) -> list[dict[str, Any]]:
|
|
309
|
+
slim: list[dict[str, Any]] = []
|
|
310
|
+
for relation in relations:
|
|
311
|
+
if not isinstance(relation, dict):
|
|
312
|
+
continue
|
|
313
|
+
left_table, right_table = _relation_endpoint_tables(relation)
|
|
314
|
+
if left_table not in selected_tables or right_table not in selected_tables:
|
|
315
|
+
continue
|
|
316
|
+
from_ref, to_ref = _relation_from_to_refs(relation)
|
|
317
|
+
if not from_ref or not to_ref:
|
|
318
|
+
continue
|
|
319
|
+
item: dict[str, Any] = {"from": from_ref, "to": to_ref}
|
|
320
|
+
join_type = relation.get("joinType")
|
|
321
|
+
if isinstance(join_type, str) and join_type.strip():
|
|
322
|
+
item["joinType"] = join_type.strip()
|
|
323
|
+
required_filters = relation.get("requiredFilters")
|
|
324
|
+
if isinstance(required_filters, list) and required_filters:
|
|
325
|
+
item["requiredFilters"] = [str(value) for value in required_filters if str(value).strip()]
|
|
326
|
+
slim.append(item)
|
|
327
|
+
return slim
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _slim_policy(
|
|
331
|
+
policy_items: list[dict[str, Any]],
|
|
332
|
+
selected_tables: set[str],
|
|
333
|
+
) -> list[dict[str, Any]]:
|
|
334
|
+
if not policy_items:
|
|
335
|
+
return []
|
|
336
|
+
merged: dict[str, Any] = {}
|
|
337
|
+
for item in policy_items:
|
|
338
|
+
if not isinstance(item, dict):
|
|
339
|
+
continue
|
|
340
|
+
for key, value in item.items():
|
|
341
|
+
if key in {"requiredScopes", "scopePlaceholders", "source"}:
|
|
342
|
+
continue
|
|
343
|
+
if key == "fieldBlacklist" and isinstance(value, list):
|
|
344
|
+
filtered = [
|
|
345
|
+
{
|
|
346
|
+
"table": entry.get("table"),
|
|
347
|
+
"columns": entry.get("columns"),
|
|
348
|
+
}
|
|
349
|
+
for entry in value
|
|
350
|
+
if isinstance(entry, dict)
|
|
351
|
+
and str(entry.get("table") or "") in selected_tables
|
|
352
|
+
and isinstance(entry.get("columns"), list)
|
|
353
|
+
]
|
|
354
|
+
if filtered:
|
|
355
|
+
merged["fieldBlacklist"] = filtered
|
|
356
|
+
elif key == "sqlRules" and value:
|
|
357
|
+
merged["sqlRules"] = value
|
|
358
|
+
elif key not in merged and value:
|
|
359
|
+
merged[key] = value
|
|
360
|
+
return [merged] if merged else []
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _relation_endpoint_tables(relation: dict[str, Any]) -> tuple[str, str]:
|
|
364
|
+
left_table = str(relation.get("leftTable") or "")
|
|
365
|
+
right_table = str(relation.get("rightTable") or "")
|
|
366
|
+
if left_table and right_table:
|
|
367
|
+
return left_table, right_table
|
|
368
|
+
from_ref = str(relation.get("from") or "")
|
|
369
|
+
to_ref = str(relation.get("to") or "")
|
|
370
|
+
left = from_ref.split(".", 1)[0] if "." in from_ref else from_ref
|
|
371
|
+
right = to_ref.split(".", 1)[0] if "." in to_ref else to_ref
|
|
372
|
+
return left, right
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _relation_from_to_refs(relation: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
376
|
+
left_table = str(relation.get("leftTable") or "")
|
|
377
|
+
right_table = str(relation.get("rightTable") or "")
|
|
378
|
+
if left_table and right_table:
|
|
379
|
+
left_column = str(relation.get("leftColumn") or relation.get("fromColumn") or "")
|
|
380
|
+
right_column = str(relation.get("rightColumn") or relation.get("toColumn") or "")
|
|
381
|
+
from_ref = f"{left_table}.{left_column}".rstrip(".") if left_column else left_table
|
|
382
|
+
to_ref = f"{right_table}.{right_column}".rstrip(".") if right_column else right_table
|
|
383
|
+
return from_ref, to_ref
|
|
384
|
+
from_ref = str(relation.get("from") or "").strip()
|
|
385
|
+
to_ref = str(relation.get("to") or "").strip()
|
|
386
|
+
return (from_ref or None), (to_ref or None)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _sql_output_schema(default_limit: int, question: str) -> dict[str, Any]:
|
|
390
|
+
return {
|
|
391
|
+
"sql": f"SELECT column AS 中文含义 ... LIMIT {default_limit}",
|
|
392
|
+
"tables": ["table_name"],
|
|
393
|
+
"placeholders": [],
|
|
394
|
+
"usedColumns": ["table.column"],
|
|
395
|
+
"usedJoins": ["left_table.left_column = right_table.right_column"],
|
|
396
|
+
"metricRefs": ["schemaContext.metrics.name"],
|
|
397
|
+
"clarification": None,
|
|
398
|
+
"statusEnumPolicy": {
|
|
399
|
+
"explicitStatusFilter": "用户明确提出状态筛选时,把状态枚举条件写入 WHERE,并在 SELECT 中用 CASE 或枚举关联展示中文含义。",
|
|
400
|
+
"missingOrUnspecifiedStatus": "用户未提出状态筛选或 schemaContext 缺少枚举时,不要追问;首次查询保持宽泛,并把相关状态字段及中文含义加入 SELECT。",
|
|
107
401
|
},
|
|
108
402
|
}
|
|
109
|
-
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _schema_read_order(*, compact: bool = False) -> list[str]:
|
|
406
|
+
if compact:
|
|
407
|
+
return ["policy", "schema", "concepts", "relations", "enums", "policy"]
|
|
408
|
+
return [
|
|
409
|
+
"agentInstructions",
|
|
410
|
+
"policy",
|
|
411
|
+
"glossary",
|
|
412
|
+
"schema",
|
|
413
|
+
"concepts/metrics/businessTerms",
|
|
414
|
+
"defaultFilters",
|
|
415
|
+
"joins",
|
|
416
|
+
"relations",
|
|
417
|
+
"exampleJoinPatterns",
|
|
418
|
+
"enums",
|
|
419
|
+
"policy",
|
|
420
|
+
]
|
|
110
421
|
|
|
111
422
|
|
|
112
423
|
def _business_examples(examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
113
|
-
|
|
424
|
+
"""Prepare examples for the LLM prompt.
|
|
425
|
+
|
|
426
|
+
The MCP examples carry data-scope predicates (``... IN (:scope.project_ids)``)
|
|
427
|
+
that the LLM must never emit. Rather than dropping every example that has
|
|
428
|
+
them (which removed almost all join guidance), strip the scope predicates
|
|
429
|
+
and keep the rest of the pattern. If an example still contains ``:scope.``
|
|
430
|
+
after stripping, drop it to stay safe.
|
|
431
|
+
"""
|
|
432
|
+
result: list[dict[str, Any]] = []
|
|
433
|
+
for example in examples:
|
|
434
|
+
sql = str(example.get("sql") or "")
|
|
435
|
+
original_sql = sql
|
|
436
|
+
if ":scope." in sql:
|
|
437
|
+
sql = _strip_scope_predicates(sql)
|
|
438
|
+
if ":scope." in sql:
|
|
439
|
+
continue
|
|
440
|
+
sql = _normalize_example_text_filters(sql)
|
|
441
|
+
if sql != original_sql:
|
|
442
|
+
example = {**example, "sql": sql}
|
|
443
|
+
result.append(example)
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _strip_scope_predicates(sql: str) -> str:
|
|
448
|
+
# Remove "AND <column> IN (:scope.<name>)" predicates, then tidy a WHERE
|
|
449
|
+
# clause that may be left starting with a stray AND.
|
|
450
|
+
cleaned = re.sub(
|
|
451
|
+
r"\s+AND\s+[A-Za-z_][\w.]*\s+IN\s*\(\s*:scope\.\w+\s*\)",
|
|
452
|
+
"",
|
|
453
|
+
sql,
|
|
454
|
+
flags=re.IGNORECASE,
|
|
455
|
+
)
|
|
456
|
+
cleaned = re.sub(r"\bWHERE\s+AND\s+", "WHERE ", cleaned, flags=re.IGNORECASE)
|
|
457
|
+
return cleaned
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _normalize_example_text_filters(sql: str) -> str:
|
|
461
|
+
"""Align business examples with SQL guardrails before prompting."""
|
|
462
|
+
return normalize_text_filters_to_like(sql)
|