@roll-agent/octopus-agent 0.0.2 → 0.0.5
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 +1 -1
- package/SKILL.md +4 -4
- package/package.json +2 -2
- package/pyproject.toml +2 -2
- package/src/octopus_sponge_skill/__init__.py +1 -1
- package/src/octopus_sponge_skill/_version.py +1 -1
- package/src/octopus_sponge_skill/agent.py +314 -31
- package/src/octopus_sponge_skill/config.py +8 -1
- package/src/octopus_sponge_skill/prompt_builder.py +10 -1
- package/src/octopus_sponge_skill/roll_server.py +3 -3
- package/src/octopus_sponge_skill/schema_context_retriever.py +62 -2
- package/src/octopus_sponge_skill/sql_generator.py +359 -29
package/README.md
CHANGED
package/SKILL.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: octopus-agent
|
|
3
|
-
description: Octopus
|
|
3
|
+
description: 丸子Agent(Octopus Agent),把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。
|
|
4
4
|
metadata:
|
|
5
5
|
roll-env-file: references/env.yaml
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
# Octopus
|
|
8
|
+
# 丸子Agent(Octopus Agent)
|
|
9
9
|
|
|
10
10
|
## 职责
|
|
11
11
|
|
|
12
|
-
本 Agent
|
|
12
|
+
本 Agent 只负责海绵数据查询:
|
|
13
13
|
|
|
14
14
|
- 读取和刷新 Schema Cache(表结构缓存)。
|
|
15
15
|
- 基于用户问题和 schema context,通过 MCP Sampling(采样)请求 roll-core 代调 LLM 生成只读 SQL。
|
|
@@ -22,7 +22,7 @@ metadata:
|
|
|
22
22
|
|-|-|-|
|
|
23
23
|
| `diagnostic_status` | 诊断状态 | 检查配置、缓存和日志路径 |
|
|
24
24
|
| `refresh_schema` | 刷新表结构 | 人工刷新本地 schema 缓存 |
|
|
25
|
-
| `query_sponge` |
|
|
25
|
+
| `query_sponge` | 查询海绵数据 | 自然语言查询入口 |
|
|
26
26
|
|
|
27
27
|
`refresh_schema` 必填 `traceId`;Agent 会自动读取本地缓存里的 `schemaVersion` 并传给 Sponge。
|
|
28
28
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -21,6 +21,7 @@ from .sql_generator import (
|
|
|
21
21
|
enforce_sql_rules,
|
|
22
22
|
is_low_quality_sql_for_question,
|
|
23
23
|
is_recruiting_match_effect_query,
|
|
24
|
+
sensitive_terms_in_text,
|
|
24
25
|
sql_satisfies_time_range,
|
|
25
26
|
validate_sql_against_schema,
|
|
26
27
|
)
|
|
@@ -94,7 +95,14 @@ class SpongeAgent:
|
|
|
94
95
|
session_id=session_id or new_session_id(),
|
|
95
96
|
)
|
|
96
97
|
sql_question = _strip_result_format_instruction(user_question)
|
|
98
|
+
sensitive_terms = sensitive_terms_in_text(sql_question)
|
|
97
99
|
schema = self._ensure_schema()
|
|
100
|
+
unsupported_field_message = _unsupported_requested_field_message(sql_question, schema)
|
|
101
|
+
if unsupported_field_message is not None:
|
|
102
|
+
return _business_metric_clarification_result(unsupported_field_message)
|
|
103
|
+
business_clarification = _business_metric_clarification(sql_question, schema)
|
|
104
|
+
if business_clarification is not None:
|
|
105
|
+
return _business_metric_clarification_result(business_clarification)
|
|
98
106
|
schema_version = str(schema.get("schemaVersion")) if schema.get("schemaVersion") is not None else None
|
|
99
107
|
repaired_sql: str | None = None
|
|
100
108
|
prefer_rule_based = is_recruiting_match_effect_query(sql_question)
|
|
@@ -159,7 +167,12 @@ class SpongeAgent:
|
|
|
159
167
|
else:
|
|
160
168
|
schema_context = None
|
|
161
169
|
generator = None
|
|
162
|
-
candidate_sql = repaired_sql or
|
|
170
|
+
candidate_sql = repaired_sql or _apply_limit_policy(
|
|
171
|
+
generated.sql,
|
|
172
|
+
sql_question,
|
|
173
|
+
default_limit=self.config.sql.default_limit,
|
|
174
|
+
max_limit=self.config.sql.max_limit,
|
|
175
|
+
)
|
|
163
176
|
if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
|
|
164
177
|
if sampler is None or schema_context is None or generator is None:
|
|
165
178
|
return _query_only_result()
|
|
@@ -176,7 +189,12 @@ class SpongeAgent:
|
|
|
176
189
|
except SqlGenerationError:
|
|
177
190
|
return _query_only_result()
|
|
178
191
|
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
179
|
-
repaired_sql =
|
|
192
|
+
repaired_sql = _apply_limit_policy(
|
|
193
|
+
repaired.sql,
|
|
194
|
+
sql_question,
|
|
195
|
+
default_limit=self.config.sql.default_limit,
|
|
196
|
+
max_limit=self.config.sql.max_limit,
|
|
197
|
+
)
|
|
180
198
|
candidate_sql = repaired_sql
|
|
181
199
|
if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
|
|
182
200
|
return _query_only_result()
|
|
@@ -189,14 +207,19 @@ class SpongeAgent:
|
|
|
189
207
|
original_sql=candidate_sql,
|
|
190
208
|
validation_error={
|
|
191
209
|
"code": "LOW_QUALITY_SQL",
|
|
192
|
-
"message": "SQL
|
|
210
|
+
"message": "SQL 与问题意图不匹配,业务查询不能退化成品牌、项目、城市、门店、岗位等单表列表;明确名称时必须在完整业务 SQL 中 JOIN 对应表并使用 name LIKE 或 job_name LIKE。",
|
|
193
211
|
},
|
|
194
212
|
schema_context=schema_context,
|
|
195
213
|
)
|
|
196
214
|
except SqlGenerationError:
|
|
197
215
|
return _query_only_result()
|
|
198
216
|
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
199
|
-
repaired_sql =
|
|
217
|
+
repaired_sql = _apply_limit_policy(
|
|
218
|
+
repaired.sql,
|
|
219
|
+
sql_question,
|
|
220
|
+
default_limit=self.config.sql.default_limit,
|
|
221
|
+
max_limit=self.config.sql.max_limit,
|
|
222
|
+
)
|
|
200
223
|
candidate_sql = repaired_sql
|
|
201
224
|
if is_low_quality_sql_for_question(sql_question, candidate_sql):
|
|
202
225
|
return _query_only_result()
|
|
@@ -217,29 +240,69 @@ class SpongeAgent:
|
|
|
217
240
|
except SqlGenerationError:
|
|
218
241
|
return _query_only_result()
|
|
219
242
|
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
220
|
-
repaired_sql =
|
|
243
|
+
repaired_sql = _apply_limit_policy(
|
|
244
|
+
repaired.sql,
|
|
245
|
+
sql_question,
|
|
246
|
+
default_limit=self.config.sql.default_limit,
|
|
247
|
+
max_limit=self.config.sql.max_limit,
|
|
248
|
+
)
|
|
221
249
|
candidate_sql = repaired_sql
|
|
222
250
|
if validate_sql_against_schema(candidate_sql, schema):
|
|
223
251
|
return _query_only_result()
|
|
224
252
|
try:
|
|
225
253
|
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
226
|
-
except SqlGenerationError:
|
|
227
|
-
if
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
254
|
+
except SqlGenerationError as exc:
|
|
255
|
+
if _is_sensitive_sql_error(exc) and sensitive_terms:
|
|
256
|
+
if sampler is None or schema_context is None or generator is None:
|
|
257
|
+
return _sensitive_query_only_result(sensitive_terms)
|
|
258
|
+
try:
|
|
259
|
+
repaired = generator.repair(
|
|
260
|
+
question=sql_question,
|
|
261
|
+
original_sql=candidate_sql,
|
|
262
|
+
validation_error={
|
|
263
|
+
"code": "SENSITIVE_FIELD",
|
|
264
|
+
"message": "用户要求的敏感字段必须从 SELECT 中排除;如还有其他非敏感字段,请继续生成去敏后的 SQL。",
|
|
265
|
+
},
|
|
266
|
+
schema_context=schema_context,
|
|
267
|
+
)
|
|
268
|
+
except SqlGenerationError:
|
|
269
|
+
return _sensitive_query_only_result(sensitive_terms)
|
|
270
|
+
repaired_sql = _apply_limit_policy(
|
|
271
|
+
repaired.sql,
|
|
272
|
+
sql_question,
|
|
235
273
|
default_limit=self.config.sql.default_limit,
|
|
236
274
|
max_limit=self.config.sql.max_limit,
|
|
237
275
|
)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
276
|
+
try:
|
|
277
|
+
enforce_sql_rules(repaired_sql, max_limit=self.config.sql.max_limit)
|
|
278
|
+
except SqlGenerationError as repaired_exc:
|
|
279
|
+
if _is_sensitive_sql_error(repaired_exc):
|
|
280
|
+
return _sensitive_query_only_result(sensitive_terms)
|
|
281
|
+
return _query_only_result()
|
|
282
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
283
|
+
candidate_sql = repaired_sql
|
|
284
|
+
else:
|
|
285
|
+
if sampler is None:
|
|
286
|
+
return _query_only_result()
|
|
287
|
+
if not fast_mode:
|
|
288
|
+
return _query_only_result()
|
|
289
|
+
try:
|
|
290
|
+
generated = _generate_rule_based_sql(
|
|
291
|
+
question=sql_question,
|
|
292
|
+
schema=schema,
|
|
293
|
+
default_limit=self.config.sql.default_limit,
|
|
294
|
+
max_limit=self.config.sql.max_limit,
|
|
295
|
+
)
|
|
296
|
+
repaired_sql = None
|
|
297
|
+
candidate_sql = _apply_limit_policy(
|
|
298
|
+
generated.sql,
|
|
299
|
+
sql_question,
|
|
300
|
+
default_limit=self.config.sql.default_limit,
|
|
301
|
+
max_limit=self.config.sql.max_limit,
|
|
302
|
+
)
|
|
303
|
+
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
304
|
+
except SqlGenerationError:
|
|
305
|
+
return _query_only_result()
|
|
243
306
|
normalized_sql = self._validate_sql(context, user_question, candidate_sql)
|
|
244
307
|
if sampler is not None and normalized_sql is None and schema_context is not None and generator is not None:
|
|
245
308
|
try:
|
|
@@ -260,17 +323,29 @@ class SpongeAgent:
|
|
|
260
323
|
max_limit=self.config.sql.max_limit,
|
|
261
324
|
)
|
|
262
325
|
repaired_sql = None
|
|
263
|
-
candidate_sql =
|
|
326
|
+
candidate_sql = _apply_limit_policy(
|
|
327
|
+
generated.sql,
|
|
328
|
+
sql_question,
|
|
329
|
+
default_limit=self.config.sql.default_limit,
|
|
330
|
+
max_limit=self.config.sql.max_limit,
|
|
331
|
+
)
|
|
264
332
|
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
265
333
|
normalized_sql = self._validate_sql(context, user_question, candidate_sql, raise_on_error=True)
|
|
266
334
|
except SqlGenerationError:
|
|
267
335
|
return _query_only_result()
|
|
268
336
|
else:
|
|
269
337
|
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
270
|
-
repaired_sql =
|
|
338
|
+
repaired_sql = _apply_limit_policy(
|
|
339
|
+
repaired.sql,
|
|
340
|
+
sql_question,
|
|
341
|
+
default_limit=self.config.sql.default_limit,
|
|
342
|
+
max_limit=self.config.sql.max_limit,
|
|
343
|
+
)
|
|
271
344
|
try:
|
|
272
345
|
enforce_sql_rules(repaired_sql, max_limit=self.config.sql.max_limit)
|
|
273
|
-
except SqlGenerationError:
|
|
346
|
+
except SqlGenerationError as exc:
|
|
347
|
+
if _is_sensitive_sql_error(exc) and sensitive_terms:
|
|
348
|
+
return _sensitive_query_only_result(sensitive_terms)
|
|
274
349
|
return _query_only_result()
|
|
275
350
|
normalized_sql = self._validate_sql(context, user_question, repaired_sql, raise_on_error=True)
|
|
276
351
|
if normalized_sql is None:
|
|
@@ -290,6 +365,13 @@ class SpongeAgent:
|
|
|
290
365
|
result_format,
|
|
291
366
|
_default_limit_notice(self.config.sql.default_limit),
|
|
292
367
|
)
|
|
368
|
+
if sensitive_terms:
|
|
369
|
+
answer = _append_default_limit_notice(
|
|
370
|
+
answer,
|
|
371
|
+
user_question,
|
|
372
|
+
result_format,
|
|
373
|
+
_sensitive_field_notice(sensitive_terms),
|
|
374
|
+
)
|
|
293
375
|
return QueryResult(
|
|
294
376
|
answer=answer,
|
|
295
377
|
generated_sql=generated.sql,
|
|
@@ -431,7 +513,7 @@ def _strip_result_format_instruction(question: str) -> str:
|
|
|
431
513
|
|
|
432
514
|
def _query_only_result() -> QueryResult:
|
|
433
515
|
return QueryResult(
|
|
434
|
-
answer="
|
|
516
|
+
answer="已按当前 schema 判断,目前不支持该查询",
|
|
435
517
|
generated_sql="",
|
|
436
518
|
normalized_sql="",
|
|
437
519
|
repaired_sql=None,
|
|
@@ -439,6 +521,38 @@ def _query_only_result() -> QueryResult:
|
|
|
439
521
|
)
|
|
440
522
|
|
|
441
523
|
|
|
524
|
+
def _sensitive_query_only_result(terms: list[str]) -> QueryResult:
|
|
525
|
+
return QueryResult(
|
|
526
|
+
answer=f"该查询包含敏感字段,{_sensitive_field_notice(terms)}已按当前 schema 判断,目前不支持该查询。",
|
|
527
|
+
generated_sql="",
|
|
528
|
+
normalized_sql="",
|
|
529
|
+
repaired_sql=None,
|
|
530
|
+
validation={},
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _business_metric_clarification_result(answer: str) -> QueryResult:
|
|
535
|
+
return QueryResult(
|
|
536
|
+
answer=answer,
|
|
537
|
+
generated_sql="",
|
|
538
|
+
normalized_sql="",
|
|
539
|
+
repaired_sql=None,
|
|
540
|
+
validation={},
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _sensitive_field_notice(terms: list[str]) -> str:
|
|
545
|
+
if not terms:
|
|
546
|
+
return "已排除敏感字段。"
|
|
547
|
+
shown_terms = "、".join(terms[:3])
|
|
548
|
+
suffix = "等敏感字段" if len(terms) > 1 or shown_terms not in {"敏感字段"} else "等敏感字段"
|
|
549
|
+
return f"已排除{shown_terms}{suffix}。"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _is_sensitive_sql_error(exc: SqlGenerationError) -> bool:
|
|
553
|
+
return "sensitive field" in str(exc).lower()
|
|
554
|
+
|
|
555
|
+
|
|
442
556
|
def _generate_with_sampler(
|
|
443
557
|
*,
|
|
444
558
|
generator: SamplingSqlGenerator,
|
|
@@ -465,7 +579,12 @@ def _generate_with_sampler(
|
|
|
465
579
|
return GeneratedSql(sql="", tables=[], placeholders=[]), None
|
|
466
580
|
return (
|
|
467
581
|
GeneratedSql(sql="", tables=[], placeholders=[]),
|
|
468
|
-
|
|
582
|
+
_apply_limit_policy(
|
|
583
|
+
repaired.sql,
|
|
584
|
+
question,
|
|
585
|
+
default_limit=generator.default_limit,
|
|
586
|
+
max_limit=generator.max_limit,
|
|
587
|
+
),
|
|
469
588
|
)
|
|
470
589
|
|
|
471
590
|
|
|
@@ -530,19 +649,183 @@ def _is_mutation_analytics_request(question: str) -> bool:
|
|
|
530
649
|
)
|
|
531
650
|
|
|
532
651
|
|
|
652
|
+
def _business_metric_clarification(question: str, schema: dict[str, Any]) -> str | None:
|
|
653
|
+
if is_recruiting_match_effect_query(question):
|
|
654
|
+
return None
|
|
655
|
+
metric_terms = _ambiguous_business_metric_terms(question)
|
|
656
|
+
if not metric_terms:
|
|
657
|
+
return None
|
|
658
|
+
if _schema_has_metric_definition(question, schema, metric_terms):
|
|
659
|
+
return None
|
|
660
|
+
|
|
661
|
+
lines = [
|
|
662
|
+
"这个查询涉及业务口径,我暂时不直接生成 SQL,避免结果口径不准。",
|
|
663
|
+
"",
|
|
664
|
+
"请先确认:",
|
|
665
|
+
f"1. 指标定义:“{'、'.join(metric_terms)}”具体包含哪些数据?",
|
|
666
|
+
"2. 时间口径:按哪个时间字段统计?",
|
|
667
|
+
"3. 状态口径:哪些状态算有效/成功/完成/异常?",
|
|
668
|
+
"4. 聚合维度:需要按什么维度展示?",
|
|
669
|
+
]
|
|
670
|
+
return "\n".join(lines)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _unsupported_requested_field_message(question: str, schema: dict[str, Any]) -> str | None:
|
|
674
|
+
if "供应商" not in question:
|
|
675
|
+
return None
|
|
676
|
+
if not re.search(r"(报名|工单|候选人|人员|清单|明细)", question):
|
|
677
|
+
return None
|
|
678
|
+
if not _schema_has_table(schema, "supplier") or not _schema_has_column(schema, "supplier", "nick_name"):
|
|
679
|
+
return "当前 schema 不支持“供应商”字段的查询,可联系管理员补充。"
|
|
680
|
+
if not _schema_supports_work_order_supplier_relation(schema):
|
|
681
|
+
return "当前 schema 缺少“供应商”的关联关系,可联系管理员补充。"
|
|
682
|
+
return None
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _schema_supports_work_order_supplier_relation(schema: dict[str, Any]) -> bool:
|
|
686
|
+
return (
|
|
687
|
+
_schema_has_join(schema, "mvp_applied_jobs_by_helped_account", "c_helped_account")
|
|
688
|
+
and _schema_has_join(schema, "c_helped_account", "resume")
|
|
689
|
+
and (
|
|
690
|
+
_schema_has_join(schema, "resume", "supplier")
|
|
691
|
+
or (_schema_has_column(schema, "resume", "supplier_id") and _schema_has_column(schema, "supplier", "id"))
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _schema_has_table(schema: dict[str, Any], table_name: str) -> bool:
|
|
697
|
+
tables = schema.get("tables", [])
|
|
698
|
+
return isinstance(tables, list) and any(
|
|
699
|
+
isinstance(table, dict) and str(table.get("tableName") or table.get("name") or "") == table_name
|
|
700
|
+
for table in tables
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _schema_has_column(schema: dict[str, Any], table_name: str, column_name: str) -> bool:
|
|
705
|
+
tables = schema.get("tables", [])
|
|
706
|
+
if not isinstance(tables, list):
|
|
707
|
+
return False
|
|
708
|
+
for table in tables:
|
|
709
|
+
if not isinstance(table, dict) or str(table.get("tableName") or table.get("name") or "") != table_name:
|
|
710
|
+
continue
|
|
711
|
+
columns = table.get("columns", [])
|
|
712
|
+
if not isinstance(columns, list):
|
|
713
|
+
return False
|
|
714
|
+
return any(
|
|
715
|
+
isinstance(column, dict)
|
|
716
|
+
and str(column.get("columnName") or column.get("name") or "") == column_name
|
|
717
|
+
for column in columns
|
|
718
|
+
)
|
|
719
|
+
return False
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _schema_has_join(schema: dict[str, Any], left_table: str, right_table: str) -> bool:
|
|
723
|
+
joins = schema.get("joins", [])
|
|
724
|
+
if not isinstance(joins, list):
|
|
725
|
+
return False
|
|
726
|
+
for join in joins:
|
|
727
|
+
if not isinstance(join, dict):
|
|
728
|
+
continue
|
|
729
|
+
join_tables = {
|
|
730
|
+
str(join.get("leftTable") or join.get("from") or ""),
|
|
731
|
+
str(join.get("rightTable") or join.get("to") or ""),
|
|
732
|
+
}
|
|
733
|
+
if {left_table, right_table} <= join_tables:
|
|
734
|
+
return True
|
|
735
|
+
return False
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _ambiguous_business_metric_terms(question: str) -> list[str]:
|
|
739
|
+
terms = (
|
|
740
|
+
"漏斗",
|
|
741
|
+
"转化率",
|
|
742
|
+
"转化",
|
|
743
|
+
"有效",
|
|
744
|
+
"活跃",
|
|
745
|
+
"成功率",
|
|
746
|
+
"留存",
|
|
747
|
+
"履约",
|
|
748
|
+
"异常",
|
|
749
|
+
"质量",
|
|
750
|
+
"效率",
|
|
751
|
+
"趋势",
|
|
752
|
+
"健康度",
|
|
753
|
+
"达成率",
|
|
754
|
+
"完成率",
|
|
755
|
+
)
|
|
756
|
+
return [term for term in terms if term in question]
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _schema_has_metric_definition(question: str, schema: dict[str, Any], metric_terms: list[str]) -> bool:
|
|
760
|
+
metric_texts: list[str] = []
|
|
761
|
+
for metric in schema.get("metrics") or []:
|
|
762
|
+
if not isinstance(metric, dict):
|
|
763
|
+
continue
|
|
764
|
+
parts: list[str] = []
|
|
765
|
+
for key in ("name", "term", "displayName", "definition", "description", "aggregation"):
|
|
766
|
+
value = metric.get(key)
|
|
767
|
+
if isinstance(value, str):
|
|
768
|
+
parts.append(value)
|
|
769
|
+
filters = metric.get("filters")
|
|
770
|
+
if isinstance(filters, list):
|
|
771
|
+
parts.extend(str(item) for item in filters)
|
|
772
|
+
metric_text = " ".join(parts)
|
|
773
|
+
if metric_text:
|
|
774
|
+
metric_texts.append(metric_text)
|
|
775
|
+
name = metric.get("name")
|
|
776
|
+
if isinstance(name, str) and name and name in question:
|
|
777
|
+
return True
|
|
778
|
+
|
|
779
|
+
return any(term in metric_text for term in metric_terms for metric_text in metric_texts)
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _apply_limit_policy(sql: str, question: str, *, default_limit: int, max_limit: int) -> str:
|
|
783
|
+
if _question_requests_all_rows(question):
|
|
784
|
+
return _replace_limit(sql, max_limit)
|
|
785
|
+
requested_limit = _requested_limit(question)
|
|
786
|
+
if requested_limit is not None:
|
|
787
|
+
return _replace_limit(sql, min(requested_limit, max_limit))
|
|
788
|
+
return _replace_limit(sql, default_limit)
|
|
789
|
+
|
|
790
|
+
|
|
533
791
|
def _apply_default_limit(sql: str, question: str, default_limit: int) -> str:
|
|
534
792
|
if _question_requests_limit(question):
|
|
535
|
-
return sql
|
|
536
|
-
return
|
|
793
|
+
return _replace_limit(sql, _requested_limit(question) or default_limit)
|
|
794
|
+
return _replace_limit(sql, default_limit)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _replace_limit(sql: str, limit: int) -> str:
|
|
798
|
+
return re.sub(r"\blimit\s+\d+\b", f"LIMIT {limit}", sql, count=1, flags=re.IGNORECASE)
|
|
537
799
|
|
|
538
800
|
|
|
539
801
|
def _question_requests_limit(question: str) -> bool:
|
|
540
|
-
|
|
541
|
-
if re.search(r"\b(?:limit|top)\s+\d+\b", lowered):
|
|
542
|
-
return True
|
|
543
|
-
if re.search(r"(?:返回|展示|查询|列出|给我|取)\s*\d+\s*(?:条|行|个|项|家|名)", question):
|
|
802
|
+
if _question_requests_all_rows(question):
|
|
544
803
|
return True
|
|
545
|
-
return
|
|
804
|
+
return _requested_limit(question) is not None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _requested_limit(question: str) -> int | None:
|
|
808
|
+
lowered = question.lower()
|
|
809
|
+
match = re.search(r"\b(?:limit|top)\s+(?P<limit>\d+)\b", lowered)
|
|
810
|
+
if match is None:
|
|
811
|
+
match = re.search(r"(?:返回|展示|查询|列出|给我|取)\s*(?P<limit>\d+)\s*(?:条|行|个|项|家|名)", question)
|
|
812
|
+
if match is None:
|
|
813
|
+
match = re.search(r"前\s*(?P<limit>\d+)\s*(?:条|行|个|项|家|名)?", question)
|
|
814
|
+
if match is None:
|
|
815
|
+
return None
|
|
816
|
+
try:
|
|
817
|
+
return int(match.group("limit"))
|
|
818
|
+
except (TypeError, ValueError):
|
|
819
|
+
return None
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _question_requests_all_rows(question: str) -> bool:
|
|
823
|
+
lowered = question.lower()
|
|
824
|
+
return bool(
|
|
825
|
+
re.search(r"(所有|全部|全量|完整)", question)
|
|
826
|
+
or re.search(r"(导出|生成|下载).{0,12}(excel|xlsx|表格|文件)", lowered)
|
|
827
|
+
or re.search(r"(excel|xlsx).{0,12}(导出|生成|下载)", lowered)
|
|
828
|
+
)
|
|
546
829
|
|
|
547
830
|
|
|
548
831
|
def _default_limit_notice(default_limit: int) -> str:
|
|
@@ -63,7 +63,7 @@ def load_config() -> AppConfig:
|
|
|
63
63
|
max_repair_attempts=2,
|
|
64
64
|
default_limit=50,
|
|
65
65
|
max_limit=500,
|
|
66
|
-
query_cache_path=
|
|
66
|
+
query_cache_path=_optional_path("OCTOPUS_QUERY_SQL_CACHE_PATH"),
|
|
67
67
|
query_cache_ttl_minutes=30,
|
|
68
68
|
),
|
|
69
69
|
audit=AuditConfig(
|
|
@@ -75,3 +75,10 @@ def load_config() -> AppConfig:
|
|
|
75
75
|
|
|
76
76
|
def load_config_from_env() -> AppConfig:
|
|
77
77
|
return load_config()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _optional_path(env_name: str) -> Path | None:
|
|
81
|
+
value = os.getenv(env_name)
|
|
82
|
+
if value is None or not value.strip():
|
|
83
|
+
return None
|
|
84
|
+
return Path(value).expanduser()
|
|
@@ -6,19 +6,28 @@ from typing import Any
|
|
|
6
6
|
from .schema_context_retriever import SchemaContext
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
NL2SQL_SYSTEM_PROMPT = """
|
|
9
|
+
NL2SQL_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的海绵数据 SQL 生成器。
|
|
10
10
|
你只能基于提供的 schema context 生成 MySQL SELECT 查询。
|
|
11
11
|
返回 JSON 中的 sql 字段必须直接以 SELECT 开头,不能返回非 SQL 文本。
|
|
12
12
|
只生成业务 SQL,不要生成权限占位符或权限条件。
|
|
13
13
|
不得使用 :scope.project_ids、:scope.brand_ids 或任何 :scope.* placeholder(占位符)。
|
|
14
14
|
如果用户没有明确要求返回条数,LIMIT 必须使用 defaultLimit。
|
|
15
|
+
如果用户要求所有数据、全部数据、全量数据、完整数据,或要求导出/生成 Excel/xlsx/表格文件,LIMIT 必须使用 maxLimit,不要使用 defaultLimit。
|
|
15
16
|
不得查询 auth 库、密码、令牌、密钥、凭证字段。
|
|
17
|
+
不得输出敏感字段,包括手机号、电话、联系方式、身份证、密码、token、secret、credential、private_key 等。
|
|
18
|
+
如果用户要求敏感字段,必须从 SELECT 中排除这些字段;还有其他非敏感字段时继续生成去敏后的 SQL。
|
|
16
19
|
不得生成 INSERT、UPDATE、DELETE、DDL、非 SELECT SQL 或多语句 SQL。
|
|
17
20
|
SELECT 中每个输出字段都必须使用 AS 中文含义,例如 b.name AS 品牌名称。
|
|
18
21
|
禁止使用英文别名,例如 brand_name、project_name。
|
|
19
22
|
用户点名要求输出的字段,必须优先使用 schema context 中含义匹配的真实字段或真实关联表生成。
|
|
20
23
|
不得为了凑齐字段写 NULL AS 字段名、CAST(NULL AS ...) AS 字段名 等空占位表达式。
|
|
21
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。
|
|
22
31
|
涉及业务指标或业务口径时,必须优先使用 schemaContext.metrics 中的 definition、aggregation 和 filters。
|
|
23
32
|
如果 schemaContext.metrics 没有给出对应业务口径,不得自行推断状态枚举、有效性过滤或跨轮次分割规则。
|
|
24
33
|
所有业务字段、状态枚举、状态映射、表关联方式必须来自 schemaContext.tables、schemaContext.joins、schemaContext.glossary、schemaContext.metrics 或 schemaContext.examples。
|
|
@@ -73,7 +73,7 @@ def tool_definitions() -> list[Json]:
|
|
|
73
73
|
return [
|
|
74
74
|
{
|
|
75
75
|
"name": "diagnostic_status",
|
|
76
|
-
"description": "
|
|
76
|
+
"description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。普通业务查询不要先调用本工具,直接调用 query_sponge。",
|
|
77
77
|
"inputSchema": {
|
|
78
78
|
"type": "object",
|
|
79
79
|
"properties": {},
|
|
@@ -82,7 +82,7 @@ def tool_definitions() -> list[Json]:
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"name": "refresh_schema",
|
|
85
|
-
"description": "调试/运维专用:手动刷新本地 Schema Cache(表结构缓存)。只有用户明确要求刷新 schema
|
|
85
|
+
"description": "调试/运维专用:手动刷新本地 Schema Cache(表结构缓存)。只有用户明确要求刷新 schema 时才调用;普通业务查询不要先调用本工具;query_sponge 内部会先检查 schemaVersion 并按需刷新 schema cache,query_sponge 失败后也不要自动调用本工具。",
|
|
86
86
|
"inputSchema": {
|
|
87
87
|
"type": "object",
|
|
88
88
|
"required": ["traceId"],
|
|
@@ -97,7 +97,7 @@ def tool_definitions() -> list[Json]:
|
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
"name": "query_sponge",
|
|
100
|
-
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server
|
|
100
|
+
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。上层模型必须直接调用本工具;不要先调用 diagnostic_status 或 refresh_schema;query_sponge 返回“目前不支持该查询”后也不要自行 retry fastMode=false 或再调用 refresh_schema,除非用户明确要求调试;不要自行生成 SQL;不要解释 schema;不要要求用户提供 SQL;不要改写用户问题。originalQuestion 必须传用户原始话术;如果 question 被上层改写,Agent 会忽略改写版并优先用 originalQuestion 生成 SQL、审计和缓存。对“匹配效果/分析报告/报名多但入职少/在招多但报名少/候选人多但岗位少”等报告类问题,必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个品牌/项目/城市子查询。工具返回后应把 answer 原文复制到最终答复正文完整展示,不要只放在折叠步骤、思考过程或工具调用详情中;如果 answer 已包含分析报告,必须先展示完整报告,再展示总结关键发现;不要自行汇总替代 answer,不要追加“是否继续查询/是否需要更多字段/我可以继续帮你”等引导。默认 fastMode=true、finalAnswerOnly=true,会优先使用规则模板,复杂查询会使用 schema context 和 LLM 生成。",
|
|
101
101
|
"inputSchema": {
|
|
102
102
|
"type": "object",
|
|
103
103
|
"required": ["question", "originalQuestion"],
|
|
@@ -26,13 +26,17 @@ LOCATION_RECRUITING_TABLES = (
|
|
|
26
26
|
|
|
27
27
|
WORK_ORDER_DETAIL_TABLES = (
|
|
28
28
|
"mvp_applied_jobs_by_helped_account",
|
|
29
|
-
"
|
|
29
|
+
"c_helped_account",
|
|
30
|
+
"resume",
|
|
31
|
+
"job",
|
|
30
32
|
"job_basic_info",
|
|
33
|
+
"brand",
|
|
34
|
+
"supplier",
|
|
31
35
|
"sponge_project",
|
|
32
36
|
)
|
|
33
37
|
|
|
34
38
|
|
|
35
|
-
def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int =
|
|
39
|
+
def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int = 12) -> SchemaContext:
|
|
36
40
|
glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
|
|
37
41
|
metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
|
|
38
42
|
examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
|
|
@@ -40,6 +44,7 @@ def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables
|
|
|
40
44
|
tables = _select_tables(schema.get("tables", []), question, table_names_from_context, max_tables)
|
|
41
45
|
selected_table_names = {_table_name(table) for table in tables}
|
|
42
46
|
joins = _filter_joins(schema.get("joins", []), selected_table_names)
|
|
47
|
+
joins = _append_derived_joins(schema, joins, selected_table_names)
|
|
43
48
|
return SchemaContext(tables=tables, joins=joins, glossary=glossary, metrics=metrics, examples=examples)
|
|
44
49
|
|
|
45
50
|
|
|
@@ -167,6 +172,40 @@ def _filter_joins(joins: Any, selected_table_names: set[str]) -> list[dict[str,
|
|
|
167
172
|
return result[:12]
|
|
168
173
|
|
|
169
174
|
|
|
175
|
+
def _append_derived_joins(
|
|
176
|
+
schema: dict[str, Any],
|
|
177
|
+
joins: list[dict[str, Any]],
|
|
178
|
+
selected_table_names: set[str],
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
result = list(joins)
|
|
181
|
+
existing = {
|
|
182
|
+
(
|
|
183
|
+
str(join.get("leftTable") or ""),
|
|
184
|
+
str(join.get("leftColumn") or ""),
|
|
185
|
+
str(join.get("rightTable") or ""),
|
|
186
|
+
str(join.get("rightColumn") or ""),
|
|
187
|
+
)
|
|
188
|
+
for join in result
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
{"resume", "supplier"} <= selected_table_names
|
|
192
|
+
and _table_has_column(schema, "resume", "supplier_id")
|
|
193
|
+
and _table_has_column(schema, "supplier", "id")
|
|
194
|
+
and ("supplier", "id", "resume", "supplier_id") not in existing
|
|
195
|
+
and ("resume", "supplier_id", "supplier", "id") not in existing
|
|
196
|
+
):
|
|
197
|
+
result.append(
|
|
198
|
+
{
|
|
199
|
+
"description": "简历和供应商关系;供应商名称可使用 supplier.nick_name",
|
|
200
|
+
"leftTable": "supplier",
|
|
201
|
+
"leftColumn": "id",
|
|
202
|
+
"rightTable": "resume",
|
|
203
|
+
"rightColumn": "supplier_id",
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return result[:16]
|
|
207
|
+
|
|
208
|
+
|
|
170
209
|
def _table_name(table: dict[str, Any]) -> str:
|
|
171
210
|
return str(table.get("tableName") or table.get("name") or "")
|
|
172
211
|
|
|
@@ -177,6 +216,24 @@ def _column_text(column: dict[str, Any]) -> str:
|
|
|
177
216
|
)
|
|
178
217
|
|
|
179
218
|
|
|
219
|
+
def _table_has_column(schema: dict[str, Any], table_name: str, column_name: str) -> bool:
|
|
220
|
+
tables = schema.get("tables", [])
|
|
221
|
+
if not isinstance(tables, list):
|
|
222
|
+
return False
|
|
223
|
+
for table in tables:
|
|
224
|
+
if not isinstance(table, dict) or _table_name(table) != table_name:
|
|
225
|
+
continue
|
|
226
|
+
columns = table.get("columns", [])
|
|
227
|
+
if not isinstance(columns, list):
|
|
228
|
+
return False
|
|
229
|
+
return any(
|
|
230
|
+
isinstance(column, dict)
|
|
231
|
+
and str(column.get("columnName") or column.get("name") or "") == column_name
|
|
232
|
+
for column in columns
|
|
233
|
+
)
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
180
237
|
def _question_tokens(question: str) -> list[str]:
|
|
181
238
|
lowered = question.lower()
|
|
182
239
|
tokens = [lowered]
|
|
@@ -199,6 +256,9 @@ def _question_tokens(question: str) -> list[str]:
|
|
|
199
256
|
"面试成功",
|
|
200
257
|
"进行中",
|
|
201
258
|
"姓名",
|
|
259
|
+
"人员",
|
|
260
|
+
"清单",
|
|
261
|
+
"供应商",
|
|
202
262
|
):
|
|
203
263
|
if word in question:
|
|
204
264
|
tokens.append(word)
|
|
@@ -65,15 +65,12 @@ class RuleBasedSqlGenerator:
|
|
|
65
65
|
if _is_multi_metric_query(question):
|
|
66
66
|
raise SqlGenerationError("Multi-metric query requires schema-driven metric generation")
|
|
67
67
|
|
|
68
|
-
if ("品牌" in question or "brand" in lowered) and "brand" in table_names:
|
|
68
|
+
if ("品牌" in question or "brand" in lowered) and "brand" in table_names and _is_entity_lookup_query(question, "品牌"):
|
|
69
69
|
brand_filter = _extract_brand_filter(question)
|
|
70
70
|
brand_where = "b.is_delete = 0"
|
|
71
71
|
if brand_filter is not None:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
brand_where = f"b.is_delete = 0 AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
|
|
75
|
-
else:
|
|
76
|
-
brand_where = f"b.is_delete = 0 AND b.name = '{_escape_sql_string(brand_name)}'"
|
|
72
|
+
_operator, brand_name = brand_filter
|
|
73
|
+
brand_where = f"b.is_delete = 0 AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
|
|
77
74
|
sql = (
|
|
78
75
|
"SELECT b.id AS 品牌ID, b.name AS 品牌名称 "
|
|
79
76
|
"FROM brand b "
|
|
@@ -82,11 +79,15 @@ class RuleBasedSqlGenerator:
|
|
|
82
79
|
)
|
|
83
80
|
return GeneratedSql(sql, ["brand"], [])
|
|
84
81
|
|
|
85
|
-
if ("项目" in question or "project" in lowered) and "sponge_project" in table_names:
|
|
82
|
+
if ("项目" in question or "project" in lowered) and "sponge_project" in table_names and _is_entity_lookup_query(question, "项目"):
|
|
83
|
+
project_name = _extract_project_name(question)
|
|
84
|
+
project_where = "p.is_delete = 0"
|
|
85
|
+
if project_name is not None:
|
|
86
|
+
project_where = f"p.is_delete = 0 AND p.name LIKE '%{_escape_sql_string(project_name)}%'"
|
|
86
87
|
sql = (
|
|
87
88
|
"SELECT p.id AS 项目ID, p.name AS 项目名称 "
|
|
88
89
|
"FROM sponge_project p "
|
|
89
|
-
"WHERE
|
|
90
|
+
f"WHERE {project_where} "
|
|
90
91
|
f"LIMIT {self.default_limit}"
|
|
91
92
|
)
|
|
92
93
|
return GeneratedSql(sql, ["sponge_project"], [])
|
|
@@ -101,7 +102,7 @@ class RuleBasedSqlGenerator:
|
|
|
101
102
|
if location and _has_recruiting_location_tables(table_names):
|
|
102
103
|
escaped_location = _escape_sql_string(location)
|
|
103
104
|
from_and_where = _append_job_time_range_condition(
|
|
104
|
-
_recruiting_location_from_and_where(escaped_location),
|
|
105
|
+
_append_job_name_condition(_recruiting_location_from_and_where(escaped_location), question),
|
|
105
106
|
question,
|
|
106
107
|
schema,
|
|
107
108
|
)
|
|
@@ -123,7 +124,11 @@ class RuleBasedSqlGenerator:
|
|
|
123
124
|
)
|
|
124
125
|
|
|
125
126
|
if _is_recruiting_job_question(question) and _has_recruiting_tables(table_names):
|
|
126
|
-
from_and_where = _append_job_time_range_condition(
|
|
127
|
+
from_and_where = _append_job_time_range_condition(
|
|
128
|
+
_append_job_name_condition(_recruiting_from_and_where(), question),
|
|
129
|
+
question,
|
|
130
|
+
schema,
|
|
131
|
+
)
|
|
127
132
|
if _question_requests_count(question):
|
|
128
133
|
sql = f"SELECT COUNT(DISTINCT jbi.id) AS 在招岗位数量 {from_and_where} LIMIT {self.default_limit}"
|
|
129
134
|
else:
|
|
@@ -150,8 +155,11 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
|
|
|
150
155
|
raise SqlGenerationError("SQL contains a forbidden keyword")
|
|
151
156
|
if re.search(r"\b(password|token|secret|credential|private_key)\b", normalized):
|
|
152
157
|
raise SqlGenerationError("SQL contains a sensitive field")
|
|
158
|
+
if _sql_selects_sensitive_field(sql):
|
|
159
|
+
raise SqlGenerationError("SQL contains a sensitive field")
|
|
153
160
|
if ":scope." in normalized:
|
|
154
161
|
raise SqlGenerationError("SQL must not contain scope placeholders")
|
|
162
|
+
_enforce_text_filters_use_like(sql)
|
|
155
163
|
_enforce_chinese_select_aliases(sql)
|
|
156
164
|
_enforce_no_null_placeholder_select_items(sql)
|
|
157
165
|
limit = re.search(r"\blimit\s+(\d+)\b", normalized)
|
|
@@ -161,11 +169,157 @@ def enforce_sql_rules(sql: str, *, max_limit: int) -> None:
|
|
|
161
169
|
raise SqlGenerationError(f"SQL LIMIT must not exceed {max_limit}")
|
|
162
170
|
|
|
163
171
|
|
|
172
|
+
SENSITIVE_FIELD_NAMES = {
|
|
173
|
+
"phone",
|
|
174
|
+
"mobile",
|
|
175
|
+
"tel",
|
|
176
|
+
"telephone",
|
|
177
|
+
"phone_number",
|
|
178
|
+
"mobile_number",
|
|
179
|
+
"mobile_phone",
|
|
180
|
+
"contact_phone",
|
|
181
|
+
"contact_mobile",
|
|
182
|
+
"contact_tel",
|
|
183
|
+
"id_card",
|
|
184
|
+
"idcard",
|
|
185
|
+
"identity_card",
|
|
186
|
+
"cert_no",
|
|
187
|
+
"certificate_no",
|
|
188
|
+
"password",
|
|
189
|
+
"token",
|
|
190
|
+
"secret",
|
|
191
|
+
"credential",
|
|
192
|
+
"private_key",
|
|
193
|
+
}
|
|
194
|
+
SENSITIVE_FIELD_SUFFIXES = ("_phone", "_mobile", "_tel")
|
|
195
|
+
SENSITIVE_FIELD_LABELS = (
|
|
196
|
+
"手机号",
|
|
197
|
+
"手机号码",
|
|
198
|
+
"电话",
|
|
199
|
+
"电话号码",
|
|
200
|
+
"联系方式",
|
|
201
|
+
"身份证",
|
|
202
|
+
"身份证号",
|
|
203
|
+
"身份证号码",
|
|
204
|
+
"密码",
|
|
205
|
+
"令牌",
|
|
206
|
+
"密钥",
|
|
207
|
+
"凭证",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def sensitive_terms_in_text(text: str) -> list[str]:
|
|
212
|
+
lowered = text.lower()
|
|
213
|
+
terms: list[str] = []
|
|
214
|
+
for label in SENSITIVE_FIELD_LABELS:
|
|
215
|
+
if label in text:
|
|
216
|
+
terms.append(label)
|
|
217
|
+
english_terms = {
|
|
218
|
+
"phone": "手机号",
|
|
219
|
+
"mobile": "手机号",
|
|
220
|
+
"tel": "电话",
|
|
221
|
+
"telephone": "电话",
|
|
222
|
+
"id_card": "身份证",
|
|
223
|
+
"idcard": "身份证",
|
|
224
|
+
"password": "密码",
|
|
225
|
+
"token": "令牌",
|
|
226
|
+
"secret": "密钥",
|
|
227
|
+
}
|
|
228
|
+
for term, label in english_terms.items():
|
|
229
|
+
if re.search(rf"\b{re.escape(term)}\b", lowered) and label not in terms:
|
|
230
|
+
terms.append(label)
|
|
231
|
+
return list(dict.fromkeys(terms))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _sql_selects_sensitive_field(sql: str) -> bool:
|
|
235
|
+
select_clause = _top_level_select_clause(sql)
|
|
236
|
+
if select_clause is None:
|
|
237
|
+
return False
|
|
238
|
+
return any(_select_item_contains_sensitive_field(item) for item in _split_select_items(select_clause))
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _top_level_select_clause(sql: str) -> str | None:
|
|
242
|
+
match = re.search(r"\bselect\b(?P<select>.*?)\bfrom\b", sql, flags=re.IGNORECASE | re.DOTALL)
|
|
243
|
+
if match is None:
|
|
244
|
+
return None
|
|
245
|
+
return match.group("select")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _split_select_items(select_clause: str) -> list[str]:
|
|
249
|
+
items: list[str] = []
|
|
250
|
+
start = 0
|
|
251
|
+
depth = 0
|
|
252
|
+
quote: str | None = None
|
|
253
|
+
for index, char in enumerate(select_clause):
|
|
254
|
+
if quote is not None:
|
|
255
|
+
if char == quote:
|
|
256
|
+
quote = None
|
|
257
|
+
continue
|
|
258
|
+
if char in {"'", '"', "`"}:
|
|
259
|
+
quote = char
|
|
260
|
+
elif char == "(":
|
|
261
|
+
depth += 1
|
|
262
|
+
elif char == ")" and depth > 0:
|
|
263
|
+
depth -= 1
|
|
264
|
+
elif char == "," and depth == 0:
|
|
265
|
+
items.append(select_clause[start:index].strip())
|
|
266
|
+
start = index + 1
|
|
267
|
+
tail = select_clause[start:].strip()
|
|
268
|
+
if tail:
|
|
269
|
+
items.append(tail)
|
|
270
|
+
return items
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _select_item_contains_sensitive_field(item: str) -> bool:
|
|
274
|
+
if any(label in item for label in SENSITIVE_FIELD_LABELS):
|
|
275
|
+
return True
|
|
276
|
+
identifiers = re.findall(r"\b[a-zA-Z_][a-zA-Z0-9_]*\b", item.lower())
|
|
277
|
+
for identifier in identifiers:
|
|
278
|
+
if identifier in {"select", "case", "when", "then", "else", "end", "as", "if", "null", "coalesce"}:
|
|
279
|
+
continue
|
|
280
|
+
if identifier in SENSITIVE_FIELD_NAMES or identifier.endswith(SENSITIVE_FIELD_SUFFIXES):
|
|
281
|
+
return True
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _enforce_text_filters_use_like(sql: str) -> None:
|
|
286
|
+
for match in re.finditer(
|
|
287
|
+
r"\b(?:where|and|or|having)\s*\(?\s*(?:[a-zA-Z_][a-zA-Z0-9_]*\.)?"
|
|
288
|
+
r"(?P<column>[a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*'(?P<value>[^']*)'",
|
|
289
|
+
sql,
|
|
290
|
+
flags=re.IGNORECASE,
|
|
291
|
+
):
|
|
292
|
+
column = match.group("column").lower()
|
|
293
|
+
value = match.group("value").strip()
|
|
294
|
+
if _is_name_filter_column(column) or (_is_status_text_filter_column(column) and not _is_numeric_literal(value)):
|
|
295
|
+
raise SqlGenerationError("SQL text filters must use LIKE")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _is_name_filter_column(column: str) -> bool:
|
|
299
|
+
return column == "name" or column == "job_name" or column.endswith("_name")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _is_status_text_filter_column(column: str) -> bool:
|
|
303
|
+
return column in {"status", "state"} or column.endswith("_status") or column.endswith("_state")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _is_numeric_literal(value: str) -> bool:
|
|
307
|
+
return bool(re.fullmatch(r"-?\d+(?:\.\d+)?", value))
|
|
308
|
+
|
|
309
|
+
|
|
164
310
|
def is_low_quality_sql_for_question(question: str, sql: str) -> bool:
|
|
165
311
|
normalized = re.sub(r"\s+", " ", sql.strip().lower())
|
|
312
|
+
if _is_business_query(question) and _is_single_entity_lookup_sql(sql):
|
|
313
|
+
return True
|
|
166
314
|
brand_filter = _extract_brand_filter(question)
|
|
167
315
|
if brand_filter is not None and _sql_references_brand_table(sql) and not _sql_contains_brand_name_filter(sql, brand_filter[1]):
|
|
168
316
|
return True
|
|
317
|
+
project_name = _extract_project_name(question)
|
|
318
|
+
if project_name is not None and _sql_references_table(sql, "sponge_project") and not _sql_contains_name_filter(sql, project_name):
|
|
319
|
+
return True
|
|
320
|
+
job_name = _extract_job_name_filter(question)
|
|
321
|
+
if job_name is not None and _sql_references_table(sql, "job_basic_info") and not _sql_contains_job_name_filter(sql, job_name):
|
|
322
|
+
return True
|
|
169
323
|
if _is_work_order_detail_query(question):
|
|
170
324
|
if "mvp_applied_jobs_by_helped_account" not in normalized:
|
|
171
325
|
return True
|
|
@@ -352,12 +506,27 @@ def _generate_recruiting_detail_sql(question: str, schema: dict[str, Any], defau
|
|
|
352
506
|
raise SqlGenerationError("job_basic_info is required for recruiting detail query")
|
|
353
507
|
|
|
354
508
|
select_items = _recruiting_detail_select_items(question, schema)
|
|
355
|
-
|
|
509
|
+
city_brand_filter = _extract_city_brand_filter(question)
|
|
510
|
+
location = _extract_location_for_recruiting_job_question(question)
|
|
511
|
+
brand_name = _extract_brand_name(question)
|
|
512
|
+
city_only_location = False
|
|
513
|
+
if city_brand_filter is not None:
|
|
514
|
+
location = city_brand_filter[0]
|
|
515
|
+
city_only_location = True
|
|
516
|
+
if brand_name is None:
|
|
517
|
+
brand_name = city_brand_filter[1]
|
|
518
|
+
location = location if location and _has_recruiting_location_tables(table_names) else None
|
|
519
|
+
brand_name = brand_name if brand_name and "brand" in table_names else None
|
|
520
|
+
from_and_where = _append_job_time_range_condition(
|
|
521
|
+
_recruiting_detail_from_and_where(table_names, location, brand_name, city_only_location),
|
|
522
|
+
question,
|
|
523
|
+
schema,
|
|
524
|
+
)
|
|
356
525
|
order_by = _job_order_by(question, schema)
|
|
357
526
|
sql = f"SELECT DISTINCT {', '.join(select_items)} {from_and_where} {order_by} LIMIT {default_limit}"
|
|
358
527
|
tables = [
|
|
359
528
|
table
|
|
360
|
-
for table in ("job_basic_info", "job_store", "store", "job_salary", "job_hiring_requirement")
|
|
529
|
+
for table in ("job_basic_info", "job_address", "job_store", "store", "province", "city", "region", "brand", "job_salary", "job_hiring_requirement")
|
|
361
530
|
if table in table_names
|
|
362
531
|
]
|
|
363
532
|
return GeneratedSql(sql, tables, [])
|
|
@@ -374,8 +543,8 @@ def _generate_work_order_detail_sql(question: str, schema: dict[str, Any], defau
|
|
|
374
543
|
if not sql:
|
|
375
544
|
raise SqlGenerationError("Work order detail schema example has no SQL")
|
|
376
545
|
sql = re.sub(
|
|
377
|
-
r"b\.name\s
|
|
378
|
-
f"b.name
|
|
546
|
+
r"b\.name\s*(?:=|LIKE)\s*'%?[^']*%?'",
|
|
547
|
+
f"b.name LIKE '%{_escape_sql_string(brand_name)}%'",
|
|
379
548
|
sql,
|
|
380
549
|
count=1,
|
|
381
550
|
flags=re.IGNORECASE,
|
|
@@ -682,17 +851,37 @@ def _match_effect_match_metric_sql(
|
|
|
682
851
|
)
|
|
683
852
|
|
|
684
853
|
|
|
685
|
-
def _recruiting_detail_from_and_where(
|
|
854
|
+
def _recruiting_detail_from_and_where(
|
|
855
|
+
table_names: set[str],
|
|
856
|
+
location: str | None = None,
|
|
857
|
+
brand_name: str | None = None,
|
|
858
|
+
city_only_location: bool = False,
|
|
859
|
+
) -> str:
|
|
686
860
|
parts = ["FROM job_basic_info jbi"]
|
|
861
|
+
if location is not None and "job_address" in table_names:
|
|
862
|
+
parts.append("LEFT JOIN job_address ja ON ja.job_id = jbi.job_id AND ja.delete_at IS NULL")
|
|
687
863
|
if "job_store" in table_names:
|
|
688
864
|
parts.append("LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0")
|
|
689
865
|
if {"job_store", "store"} <= table_names:
|
|
690
866
|
parts.append("LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL")
|
|
867
|
+
if brand_name is not None and "brand" in table_names:
|
|
868
|
+
parts.append("LEFT JOIN brand b ON b.id = jbi.brand_id AND b.is_delete = 0")
|
|
691
869
|
if "job_salary" in table_names:
|
|
692
870
|
parts.append("LEFT JOIN job_salary jsa ON jsa.job_basic_info_id = jbi.id AND jsa.is_deleted = 0")
|
|
693
871
|
if "job_hiring_requirement" in table_names:
|
|
694
872
|
parts.append("LEFT JOIN job_hiring_requirement jhr ON jhr.job_basic_info_id = jbi.id AND jhr.is_deleted = 0")
|
|
695
|
-
|
|
873
|
+
where = "WHERE jbi.is_deleted = 0 AND jbi.status = 1"
|
|
874
|
+
if location is not None:
|
|
875
|
+
escaped_location = _escape_sql_string(location)
|
|
876
|
+
location_condition = (
|
|
877
|
+
_recruiting_city_condition(escaped_location)
|
|
878
|
+
if city_only_location
|
|
879
|
+
else _recruiting_location_condition(escaped_location)
|
|
880
|
+
)
|
|
881
|
+
where = f"{where} AND {location_condition}"
|
|
882
|
+
if brand_name is not None:
|
|
883
|
+
where = f"{where} AND b.name LIKE '%{_escape_sql_string(brand_name)}%'"
|
|
884
|
+
parts.append(where)
|
|
696
885
|
return " ".join(parts)
|
|
697
886
|
|
|
698
887
|
|
|
@@ -836,6 +1025,17 @@ def _recruiting_location_condition(location: str) -> str:
|
|
|
836
1025
|
)
|
|
837
1026
|
|
|
838
1027
|
|
|
1028
|
+
def _recruiting_city_condition(location: str) -> str:
|
|
1029
|
+
return (
|
|
1030
|
+
"("
|
|
1031
|
+
"EXISTS ("
|
|
1032
|
+
"SELECT 1 FROM city c "
|
|
1033
|
+
f"WHERE ({_city_name_matches(location)}) "
|
|
1034
|
+
"AND (ja.city_id = c.id OR s.city_id = c.id OR FIND_IN_SET(c.id, jbi.city_ids) > 0)"
|
|
1035
|
+
"))"
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
|
|
839
1039
|
def _append_job_time_range_condition(from_and_where: str, question: str, schema: dict[str, Any]) -> str:
|
|
840
1040
|
time_range = _extract_time_range(question, schema)
|
|
841
1041
|
if time_range is None:
|
|
@@ -846,6 +1046,13 @@ def _append_job_time_range_condition(from_and_where: str, question: str, schema:
|
|
|
846
1046
|
)
|
|
847
1047
|
|
|
848
1048
|
|
|
1049
|
+
def _append_job_name_condition(from_and_where: str, question: str) -> str:
|
|
1050
|
+
job_name = _extract_job_name_filter(question)
|
|
1051
|
+
if job_name is None:
|
|
1052
|
+
return from_and_where
|
|
1053
|
+
return f"{from_and_where} AND jbi.job_name LIKE '%{_escape_sql_string(job_name)}%'"
|
|
1054
|
+
|
|
1055
|
+
|
|
849
1056
|
def _extract_time_range(question: str, schema: dict[str, Any] | None = None) -> TimeRange | None:
|
|
850
1057
|
field = _resolve_job_time_field(question, schema or {})
|
|
851
1058
|
if field is None:
|
|
@@ -1073,6 +1280,7 @@ def _extract_location_for_recruiting_job_question(question: str) -> str | None:
|
|
|
1073
1280
|
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有)?(?:多少|几|数量|总数).*?(?:在招|招聘)(?:岗位|职位)",
|
|
1074
1281
|
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:在招|招聘)(?:岗位|职位)",
|
|
1075
1282
|
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:有|有哪些|有什么).*?(?:在招|招聘)(?:岗位|职位)",
|
|
1283
|
+
r"(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:岗位|职位)",
|
|
1076
1284
|
r"(?:在|位于)(?P<location>[\u4e00-\u9fff]{2,12}?)(?:的)?(?:岗位|职位)",
|
|
1077
1285
|
]
|
|
1078
1286
|
for pattern in patterns:
|
|
@@ -1099,6 +1307,63 @@ def _is_work_order_detail_query(question: str) -> bool:
|
|
|
1099
1307
|
)
|
|
1100
1308
|
|
|
1101
1309
|
|
|
1310
|
+
def _is_entity_lookup_query(question: str, entity: str) -> bool:
|
|
1311
|
+
if _is_business_query(question):
|
|
1312
|
+
return False
|
|
1313
|
+
if entity == "品牌" and _extract_brand_filter(question) is not None:
|
|
1314
|
+
return True
|
|
1315
|
+
if entity == "项目" and _extract_project_name(question) is not None:
|
|
1316
|
+
return True
|
|
1317
|
+
if re.search(r"(列表|清单|信息|有哪些|哪些|我能看到|可见|能看到|查询.*(?:品牌|项目)|查看.*(?:品牌|项目))", question):
|
|
1318
|
+
return True
|
|
1319
|
+
return bool(re.fullmatch(rf"\s*(?:查询|查看|列出)?\s*{entity}\s*", question))
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _is_business_query(question: str) -> bool:
|
|
1323
|
+
return bool(
|
|
1324
|
+
re.search(
|
|
1325
|
+
r"(报名|工单|候选人|入职|上岗|在招|招聘|岗位|职位|门店|剩余|转化|漏斗|匹配|统计|多少|几|数量|总数|人数|数)",
|
|
1326
|
+
question,
|
|
1327
|
+
)
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
def _is_single_entity_lookup_sql(sql: str) -> bool:
|
|
1332
|
+
normalized = re.sub(r"\s+", " ", sql.strip().lower())
|
|
1333
|
+
table_aliases = _sql_table_aliases(normalized)
|
|
1334
|
+
referenced_tables = set(table_aliases.values())
|
|
1335
|
+
entity_tables = {"brand", "sponge_project", "city", "province", "region", "store"}
|
|
1336
|
+
if len(referenced_tables) != 1 or not referenced_tables <= entity_tables:
|
|
1337
|
+
return False
|
|
1338
|
+
if " limit " not in f" {normalized} ":
|
|
1339
|
+
return False
|
|
1340
|
+
return bool(re.search(r"\b(?:id|name|job_name)\b", normalized))
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
_ENTITY_NAME_CHARS = r"[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40}"
|
|
1344
|
+
_ENTITY_SEPARATOR = r"[\s::,,、;;]+"
|
|
1345
|
+
_DIRECT_MUNICIPALITIES = ("上海", "北京", "天津", "重庆")
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _extract_city_brand_filter(question: str) -> tuple[str, str] | None:
|
|
1349
|
+
if not _is_recruiting_job_question(question):
|
|
1350
|
+
return None
|
|
1351
|
+
text = re.sub(r"(根据|我要|海绵数据|帮我查询|帮我查|帮我|查询|查看|看看|当前|目前|现在|,|,|。|\s)", "", question)
|
|
1352
|
+
patterns = [
|
|
1353
|
+
r"(?P<city>[\u4e00-\u9fff]{2,8}?(?:省|市|区|县))(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{1,40}?)(?:当前|目前|现在)?(?:在招|招聘)(?:岗位|职位)",
|
|
1354
|
+
rf"(?P<city>{'|'.join(_DIRECT_MUNICIPALITIES)})(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{{1,40}}?)(?:当前|目前|现在)?(?:在招|招聘)(?:岗位|职位)",
|
|
1355
|
+
]
|
|
1356
|
+
for pattern in patterns:
|
|
1357
|
+
match = re.search(pattern, text)
|
|
1358
|
+
if match is None:
|
|
1359
|
+
continue
|
|
1360
|
+
location = _clean_location_candidate(match.group("city"), require_location_signal=True)
|
|
1361
|
+
brand = _clean_brand_candidate(match.group("brand"))
|
|
1362
|
+
if location is not None and brand is not None:
|
|
1363
|
+
return location, brand
|
|
1364
|
+
return None
|
|
1365
|
+
|
|
1366
|
+
|
|
1102
1367
|
def _extract_brand_filter(question: str) -> tuple[str, str] | None:
|
|
1103
1368
|
contains_patterns = [
|
|
1104
1369
|
r"品牌(?:名称)?(?:包含|含有|模糊匹配|like)(?P<brand>.+?)(?:,|,|。|;|;|的品牌|品牌信息|包括|返回|$)",
|
|
@@ -1113,8 +1378,9 @@ def _extract_brand_filter(question: str) -> tuple[str, str] | None:
|
|
|
1113
1378
|
return "contains", brand
|
|
1114
1379
|
|
|
1115
1380
|
exact_patterns = [
|
|
1116
|
-
|
|
1117
|
-
|
|
1381
|
+
rf"品牌(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<brand>{_ENTITY_NAME_CHARS})",
|
|
1382
|
+
rf"品牌{_ENTITY_SEPARATOR}(?P<brand>{_ENTITY_NAME_CHARS})",
|
|
1383
|
+
rf"(?P<brand>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?品牌(?:下|的)?",
|
|
1118
1384
|
]
|
|
1119
1385
|
for pattern in exact_patterns:
|
|
1120
1386
|
match = re.search(pattern, question)
|
|
@@ -1131,30 +1397,94 @@ def _extract_brand_name(question: str) -> str | None:
|
|
|
1131
1397
|
return brand_filter[1] if brand_filter is not None else None
|
|
1132
1398
|
|
|
1133
1399
|
|
|
1400
|
+
def _extract_project_name(question: str) -> str | None:
|
|
1401
|
+
patterns = [
|
|
1402
|
+
r"项目(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|项目信息|包括|返回|$)",
|
|
1403
|
+
rf"项目(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
|
|
1404
|
+
rf"项目{_ENTITY_SEPARATOR}(?P<name>{_ENTITY_NAME_CHARS})",
|
|
1405
|
+
rf"(?P<name>{_ENTITY_NAME_CHARS})(?:{_ENTITY_SEPARATOR})?项目(?:下|的)?",
|
|
1406
|
+
]
|
|
1407
|
+
for pattern in patterns:
|
|
1408
|
+
match = re.search(pattern, question, flags=re.IGNORECASE)
|
|
1409
|
+
if match is None:
|
|
1410
|
+
continue
|
|
1411
|
+
project = _clean_entity_candidate(match.group("name"), extra_forbidden=("品牌", "岗位", "职位", "报名"))
|
|
1412
|
+
if project is not None:
|
|
1413
|
+
return project
|
|
1414
|
+
return None
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _extract_job_name_filter(question: str) -> str | None:
|
|
1418
|
+
patterns = [
|
|
1419
|
+
r"(?:岗位|职位)(?:名称)?(?:包含|含有|模糊匹配|like)(?P<name>.+?)(?:,|,|。|;|;|岗位信息|职位信息|包括|返回|$)",
|
|
1420
|
+
rf"(?:岗位|职位)(?:名称)?(?:为|是|=|等于|:|:)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})",
|
|
1421
|
+
rf"(?:招聘|在招|招|急招)(?:{_ENTITY_SEPARATOR})?(?P<name>{_ENTITY_NAME_CHARS})(?:岗位|职位)",
|
|
1422
|
+
]
|
|
1423
|
+
for pattern in patterns:
|
|
1424
|
+
match = re.search(pattern, question, flags=re.IGNORECASE)
|
|
1425
|
+
if match is None:
|
|
1426
|
+
continue
|
|
1427
|
+
job_name = _clean_entity_candidate(match.group("name"), extra_forbidden=("品牌", "项目", "门店", "报名"))
|
|
1428
|
+
if job_name is not None:
|
|
1429
|
+
return job_name
|
|
1430
|
+
return None
|
|
1431
|
+
|
|
1432
|
+
|
|
1134
1433
|
def _clean_brand_candidate(value: str) -> str | None:
|
|
1434
|
+
return _clean_entity_candidate(
|
|
1435
|
+
value,
|
|
1436
|
+
extra_forbidden=("岗位", "候选人", "报名", "入职", "在招", "招聘", "数量", "多少"),
|
|
1437
|
+
suffix_pattern=r"(的品牌信息|品牌信息|的品牌|品牌|的|下)$",
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _clean_entity_candidate(
|
|
1442
|
+
value: str,
|
|
1443
|
+
*,
|
|
1444
|
+
extra_forbidden: tuple[str, ...] = (),
|
|
1445
|
+
suffix_pattern: str = r"(的|下)$",
|
|
1446
|
+
) -> str | None:
|
|
1135
1447
|
brand = re.sub(r"\s+", "", value)
|
|
1136
|
-
brand = brand.strip("\"'
|
|
1448
|
+
brand = brand.strip("\"'“”‘’%::,,、;;")
|
|
1137
1449
|
brand = re.sub(r"^(名称为|名称是|为|是|=|等于)", "", brand)
|
|
1138
|
-
brand = re.sub(
|
|
1139
|
-
brand = brand.strip("\"'
|
|
1140
|
-
|
|
1450
|
+
brand = re.sub(suffix_pattern, "", brand)
|
|
1451
|
+
brand = brand.strip("\"'“”‘’%::,,、;;")
|
|
1452
|
+
forbidden = ("查询", "查看", "给我", "帮我", "根据", "roll-core", "信息", "数据") + extra_forbidden
|
|
1453
|
+
if 2 <= len(brand) <= 40 and not any(term in brand for term in forbidden):
|
|
1141
1454
|
return brand
|
|
1142
1455
|
return None
|
|
1143
1456
|
|
|
1144
1457
|
|
|
1145
1458
|
def _sql_references_brand_table(sql: str) -> bool:
|
|
1146
|
-
return "brand"
|
|
1459
|
+
return _sql_references_table(sql, "brand")
|
|
1147
1460
|
|
|
1148
1461
|
|
|
1149
1462
|
def _sql_contains_brand_name_filter(sql: str, brand_name: str) -> bool:
|
|
1463
|
+
return _sql_contains_name_filter(sql, brand_name)
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _sql_references_table(sql: str, table_name: str) -> bool:
|
|
1467
|
+
return table_name in _sql_table_aliases(sql.lower()).values()
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def _sql_contains_name_filter(sql: str, name: str) -> bool:
|
|
1150
1471
|
normalized = sql.lower()
|
|
1151
|
-
escaped = re.escape(_escape_sql_string(
|
|
1472
|
+
escaped = re.escape(_escape_sql_string(name).lower())
|
|
1152
1473
|
return bool(
|
|
1153
1474
|
re.search(rf"\b[a-zA-Z_][a-zA-Z0-9_]*\.name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1154
1475
|
or re.search(rf"\bname\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1155
1476
|
)
|
|
1156
1477
|
|
|
1157
1478
|
|
|
1479
|
+
def _sql_contains_job_name_filter(sql: str, job_name: str) -> bool:
|
|
1480
|
+
normalized = sql.lower()
|
|
1481
|
+
escaped = re.escape(_escape_sql_string(job_name).lower())
|
|
1482
|
+
return bool(
|
|
1483
|
+
re.search(rf"\bjbi\.job_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1484
|
+
or re.search(rf"\bjob_name\s*(?:=|like)\s*['\"]%?{escaped}%?['\"]", normalized)
|
|
1485
|
+
)
|
|
1486
|
+
|
|
1487
|
+
|
|
1158
1488
|
def _is_monthly_recruiting_conversion_query(question: str) -> bool:
|
|
1159
1489
|
return bool(
|
|
1160
1490
|
re.search(r"(全年|年内|每月|月度)", question)
|
|
@@ -1203,11 +1533,11 @@ def _is_multi_metric_query(question: str) -> bool:
|
|
|
1203
1533
|
|
|
1204
1534
|
|
|
1205
1535
|
def _requires_recruiting_detail_joins(question: str) -> bool:
|
|
1206
|
-
if re.search(r"(
|
|
1536
|
+
if re.search(r"(明细|详情|信息|包含|需要|显示|带上|返回|字段)", question) is None:
|
|
1207
1537
|
return False
|
|
1208
1538
|
return bool(
|
|
1209
1539
|
re.search(
|
|
1210
|
-
r"(
|
|
1540
|
+
r"(门店|门店地址|详细地址|精确地址|地址|薪资|学历|经验|年龄|技能|用人要求|招聘人数|岗位类型|职能类别|工作年限|薪资范围|薪资类型|岗位描述)",
|
|
1211
1541
|
question,
|
|
1212
1542
|
)
|
|
1213
1543
|
)
|
|
@@ -1285,7 +1615,7 @@ def _escape_sql_string(value: str) -> str:
|
|
|
1285
1615
|
def _province_name_matches(location: str) -> str:
|
|
1286
1616
|
literal = f"'{location}'"
|
|
1287
1617
|
return (
|
|
1288
|
-
f"p.name
|
|
1618
|
+
f"p.name LIKE '%{location}%' OR "
|
|
1289
1619
|
"REPLACE(REPLACE(REPLACE(REPLACE(p.name, '特别行政区', ''), '自治区', ''), '省', ''), '市', '') = "
|
|
1290
1620
|
f"REPLACE(REPLACE(REPLACE(REPLACE({literal}, '特别行政区', ''), '自治区', ''), '省', ''), '市', '')"
|
|
1291
1621
|
)
|
|
@@ -1294,7 +1624,7 @@ def _province_name_matches(location: str) -> str:
|
|
|
1294
1624
|
def _city_name_matches(location: str) -> str:
|
|
1295
1625
|
literal = f"'{location}'"
|
|
1296
1626
|
return (
|
|
1297
|
-
f"c.name
|
|
1627
|
+
f"c.name LIKE '%{location}%' OR "
|
|
1298
1628
|
"REPLACE(REPLACE(REPLACE(c.name, '自治州', ''), '地区', ''), '市', '') = "
|
|
1299
1629
|
f"REPLACE(REPLACE(REPLACE({literal}, '自治州', ''), '地区', ''), '市', '')"
|
|
1300
1630
|
)
|
|
@@ -1303,7 +1633,7 @@ def _city_name_matches(location: str) -> str:
|
|
|
1303
1633
|
def _region_name_matches(location: str) -> str:
|
|
1304
1634
|
literal = f"'{location}'"
|
|
1305
1635
|
return (
|
|
1306
|
-
f"r.name
|
|
1636
|
+
f"r.name LIKE '%{location}%' OR "
|
|
1307
1637
|
"REPLACE(REPLACE(REPLACE(r.name, '自治县', ''), '区', ''), '县', '') = "
|
|
1308
1638
|
f"REPLACE(REPLACE(REPLACE({literal}, '自治县', ''), '区', ''), '县', '')"
|
|
1309
1639
|
)
|