@roll-agent/octopus-agent 0.0.4 → 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.4",
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.4"
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.4"
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,11 @@ 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)
98
103
  business_clarification = _business_metric_clarification(sql_question, schema)
99
104
  if business_clarification is not None:
100
105
  return _business_metric_clarification_result(business_clarification)
@@ -162,7 +167,12 @@ class SpongeAgent:
162
167
  else:
163
168
  schema_context = None
164
169
  generator = None
165
- 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
+ )
166
176
  if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
167
177
  if sampler is None or schema_context is None or generator is None:
168
178
  return _query_only_result()
@@ -179,7 +189,12 @@ class SpongeAgent:
179
189
  except SqlGenerationError:
180
190
  return _query_only_result()
181
191
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
182
- 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
+ )
183
198
  candidate_sql = repaired_sql
184
199
  if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
185
200
  return _query_only_result()
@@ -192,14 +207,19 @@ class SpongeAgent:
192
207
  original_sql=candidate_sql,
193
208
  validation_error={
194
209
  "code": "LOW_QUALITY_SQL",
195
- "message": "SQL 与问题意图不匹配,报名/工单/候选人明细不能退化成品牌列表,且必须使用正确工单表、状态和岗位关联。",
210
+ "message": "SQL 与问题意图不匹配,业务查询不能退化成品牌、项目、城市、门店、岗位等单表列表;明确名称时必须在完整业务 SQL 中 JOIN 对应表并使用 name LIKE 或 job_name LIKE。",
196
211
  },
197
212
  schema_context=schema_context,
198
213
  )
199
214
  except SqlGenerationError:
200
215
  return _query_only_result()
201
216
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
202
- 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
+ )
203
223
  candidate_sql = repaired_sql
204
224
  if is_low_quality_sql_for_question(sql_question, candidate_sql):
205
225
  return _query_only_result()
@@ -220,29 +240,69 @@ class SpongeAgent:
220
240
  except SqlGenerationError:
221
241
  return _query_only_result()
222
242
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
223
- 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
+ )
224
249
  candidate_sql = repaired_sql
225
250
  if validate_sql_against_schema(candidate_sql, schema):
226
251
  return _query_only_result()
227
252
  try:
228
253
  enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
229
- except SqlGenerationError:
230
- if sampler is None:
231
- return _query_only_result()
232
- if not fast_mode:
233
- return _query_only_result()
234
- try:
235
- generated = _generate_rule_based_sql(
236
- question=sql_question,
237
- 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,
238
273
  default_limit=self.config.sql.default_limit,
239
274
  max_limit=self.config.sql.max_limit,
240
275
  )
241
- repaired_sql = None
242
- candidate_sql = _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
243
- enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
244
- except SqlGenerationError:
245
- 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()
246
306
  normalized_sql = self._validate_sql(context, user_question, candidate_sql)
247
307
  if sampler is not None and normalized_sql is None and schema_context is not None and generator is not None:
248
308
  try:
@@ -263,17 +323,29 @@ class SpongeAgent:
263
323
  max_limit=self.config.sql.max_limit,
264
324
  )
265
325
  repaired_sql = None
266
- 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
+ )
267
332
  enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
268
333
  normalized_sql = self._validate_sql(context, user_question, candidate_sql, raise_on_error=True)
269
334
  except SqlGenerationError:
270
335
  return _query_only_result()
271
336
  else:
272
337
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
273
- 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
+ )
274
344
  try:
275
345
  enforce_sql_rules(repaired_sql, max_limit=self.config.sql.max_limit)
276
- 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)
277
349
  return _query_only_result()
278
350
  normalized_sql = self._validate_sql(context, user_question, repaired_sql, raise_on_error=True)
279
351
  if normalized_sql is None:
@@ -293,6 +365,13 @@ class SpongeAgent:
293
365
  result_format,
294
366
  _default_limit_notice(self.config.sql.default_limit),
295
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
+ )
296
375
  return QueryResult(
297
376
  answer=answer,
298
377
  generated_sql=generated.sql,
@@ -434,7 +513,17 @@ def _strip_result_format_instruction(question: str) -> str:
434
513
 
435
514
  def _query_only_result() -> QueryResult:
436
515
  return QueryResult(
437
- answer="目前只能查询",
516
+ answer="已按当前 schema 判断,目前不支持该查询",
517
+ generated_sql="",
518
+ normalized_sql="",
519
+ repaired_sql=None,
520
+ validation={},
521
+ )
522
+
523
+
524
+ def _sensitive_query_only_result(terms: list[str]) -> QueryResult:
525
+ return QueryResult(
526
+ answer=f"该查询包含敏感字段,{_sensitive_field_notice(terms)}已按当前 schema 判断,目前不支持该查询。",
438
527
  generated_sql="",
439
528
  normalized_sql="",
440
529
  repaired_sql=None,
@@ -452,6 +541,18 @@ def _business_metric_clarification_result(answer: str) -> QueryResult:
452
541
  )
453
542
 
454
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
+
455
556
  def _generate_with_sampler(
456
557
  *,
457
558
  generator: SamplingSqlGenerator,
@@ -478,7 +579,12 @@ def _generate_with_sampler(
478
579
  return GeneratedSql(sql="", tables=[], placeholders=[]), None
479
580
  return (
480
581
  GeneratedSql(sql="", tables=[], placeholders=[]),
481
- _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
+ ),
482
588
  )
483
589
 
484
590
 
@@ -564,6 +670,71 @@ def _business_metric_clarification(question: str, schema: dict[str, Any]) -> str
564
670
  return "\n".join(lines)
565
671
 
566
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
+
567
738
  def _ambiguous_business_metric_terms(question: str) -> list[str]:
568
739
  terms = (
569
740
  "漏斗",
@@ -608,19 +779,53 @@ def _schema_has_metric_definition(question: str, schema: dict[str, Any], metric_
608
779
  return any(term in metric_text for term in metric_terms for metric_text in metric_texts)
609
780
 
610
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
+
611
791
  def _apply_default_limit(sql: str, question: str, default_limit: int) -> str:
612
792
  if _question_requests_limit(question):
613
- return sql
614
- 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)
615
799
 
616
800
 
617
801
  def _question_requests_limit(question: str) -> bool:
618
- lowered = question.lower()
619
- if re.search(r"\b(?:limit|top)\s+\d+\b", lowered):
802
+ if _question_requests_all_rows(question):
620
803
  return True
621
- if re.search(r"(?:返回|展示|查询|列出|给我|取)\s*\d+\s*(?:条|行|个|项|家|名)", question):
622
- return True
623
- 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
+ )
624
829
 
625
830
 
626
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
  )