@roll-agent/octopus-agent 0.0.4 → 0.0.6

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.
@@ -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)