@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 CHANGED
@@ -1,4 +1,4 @@
1
- # Octopus Sponge NL2SQL Agent
1
+ # 丸子Agent(Octopus Agent
2
2
 
3
3
  本仓库实现 Octopus 对接 Sponge MCP Server 的第一版 MVP。
4
4
 
package/SKILL.md CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  name: octopus-agent
3
- description: Octopus Sponge NL2SQL Agent,把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。
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 Sponge Agent
8
+ # 丸子Agent(Octopus Agent
9
9
 
10
10
  ## 职责
11
11
 
12
- 本 Agent 只负责 Sponge 数据查询:
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` | 查询 Sponge 数据 | 自然语言查询入口 |
25
+ | `query_sponge` | 查询海绵数据 | 自然语言查询入口 |
26
26
 
27
27
  `refresh_schema` 必填 `traceId`;Agent 会自动读取本地缓存里的 `schemaVersion` 并传给 Sponge。
28
28
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@roll-agent/octopus-agent",
3
- "version": "0.0.2",
4
- "description": "Octopus Sponge NL2SQL Agent",
3
+ "version": "0.0.5",
4
+ "description": "丸子Agent(Octopus Agent",
5
5
  "license": "UNLICENSED",
6
6
  "keywords": [
7
7
  "roll-agent",
package/pyproject.toml CHANGED
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "octopus-skill"
3
- version = "0.0.2"
4
- description = "Octopus Sponge NL2SQL Agent"
3
+ version = "0.0.5"
4
+ description = "丸子Agent(Octopus Agent"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
7
7
  dependencies = []
@@ -1,4 +1,4 @@
1
- """Octopus Sponge NL2SQL Agent."""
1
+ """丸子Agent(Octopus Agent)."""
2
2
 
3
3
  from ._version import __version__
4
4
 
@@ -1,3 +1,3 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.0.2"
3
+ __version__ = "0.0.5"
@@ -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 _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
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 = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
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 = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
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 = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
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 sampler is None:
228
- return _query_only_result()
229
- if not fast_mode:
230
- return _query_only_result()
231
- try:
232
- generated = _generate_rule_based_sql(
233
- question=sql_question,
234
- schema=schema,
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
- repaired_sql = None
239
- candidate_sql = _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
240
- enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
241
- except SqlGenerationError:
242
- return _query_only_result()
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 = _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
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 = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
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
- _apply_default_limit(repaired.sql, question, generator.default_limit),
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 re.sub(r"\blimit\s+\d+\b", f"LIMIT {default_limit}", sql, count=1, flags=re.IGNORECASE)
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
- lowered = question.lower()
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 bool(re.search(r"前\s*\d+\s*(?:条|行|个|项|家|名)?", question))
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=PACKAGE_ROOT / "data/query-sql-cache.json",
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 = """你是 Octopus Sponge 数据查询 SQL 生成器。
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": "调试专用:检查 Octopus Sponge Agent 配置、缓存和本地持久化状态。普通业务查询不要先调用本工具,直接调用 query_sponge。",
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 校验和执行。上层模型必须直接调用本工具;不要先调用 diagnostic_status 或 refresh_schema;不要自行生成 SQL;不要解释 schema;不要要求用户提供 SQL;不要改写用户问题。originalQuestion 必须传用户原始话术;如果 question 被上层改写,Agent 会忽略改写版并优先用 originalQuestion 生成 SQL、审计和缓存。对“匹配效果/分析报告/报名多但入职少/在招多但报名少/候选人多但岗位少”等报告类问题,必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个品牌/项目/城市子查询。工具返回后应把 answer 原文复制到最终答复正文完整展示,不要只放在折叠步骤、思考过程或工具调用详情中;如果 answer 已包含分析报告,必须先展示完整报告,再展示总结关键发现;不要自行汇总替代 answer,不要追加“是否继续查询/是否需要更多字段/我可以继续帮你”等引导。默认 fastMode=true、finalAnswerOnly=true,会优先使用缓存和规则模板。",
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
- "brand",
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 = 8) -> SchemaContext:
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
- operator, brand_name = brand_filter
73
- if operator == "contains":
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 p.is_delete = 0 "
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(_recruiting_from_and_where(), question, schema)
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
- from_and_where = _append_job_time_range_condition(_recruiting_detail_from_and_where(table_names), question, schema)
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*=\s*'[^']*'",
378
- f"b.name = '{_escape_sql_string(brand_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(table_names: set[str]) -> str:
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
- parts.append("WHERE jbi.is_deleted = 0 AND jbi.status = 1")
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
- r"品牌(?:名称)?(?:为|是|=|等于)(?P<brand>.+?)(?:,|,|。|;|;|候选人|工单|报名|提供|输出|通过|包括|返回|$)",
1117
- r"(?P<brand>[\u4e00-\u9fffA-Za-z0-9()()·_\-]{2,40})品牌(?:下|的)?",
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(r"(的品牌信息|品牌信息|的品牌|品牌|的|下)$", "", brand)
1139
- brand = brand.strip("\"'“”‘’%")
1140
- if 2 <= len(brand) <= 40 and re.search(r"(查询|查看|给我|帮我|根据|roll-core|岗位|候选人|报名|入职|在招|招聘|数量|多少)", brand) is None:
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" in _sql_table_aliases(sql.lower()).values()
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(brand_name).lower())
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"(明细|详情|包含|需要|显示|带上|返回)", question) is None:
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 = {literal} OR "
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 = {literal} OR "
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 = {literal} OR "
1636
+ f"r.name LIKE '%{location}%' OR "
1307
1637
  "REPLACE(REPLACE(REPLACE(r.name, '自治县', ''), '区', ''), '县', '') = "
1308
1638
  f"REPLACE(REPLACE(REPLACE({literal}, '自治县', ''), '区', ''), '县', '')"
1309
1639
  )