@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.
- package/README.md +7 -4
- package/SKILL.md +4 -4
- package/bin/check-python.js +117 -0
- package/bin/start-roll-server.js +109 -0
- package/package.json +8 -5
- package/pyproject.toml +2 -2
- package/src/octopus_sponge_skill/__init__.py +1 -1
- package/src/octopus_sponge_skill/_version.py +1 -1
- package/src/octopus_sponge_skill/agent.py +236 -31
- package/src/octopus_sponge_skill/config.py +8 -1
- package/src/octopus_sponge_skill/prompt_builder.py +10 -1
- package/src/octopus_sponge_skill/roll_server.py +3 -3
- package/src/octopus_sponge_skill/schema_context_retriever.py +62 -2
- package/src/octopus_sponge_skill/sql_generator.py +359 -29
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
619
|
-
if re.search(r"\b(?:limit|top)\s+\d+\b", lowered):
|
|
802
|
+
if _question_requests_all_rows(question):
|
|
620
803
|
return True
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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=
|
|
66
|
+
query_cache_path=_optional_path("OCTOPUS_QUERY_SQL_CACHE_PATH"),
|
|
67
67
|
query_cache_ttl_minutes=30,
|
|
68
68
|
),
|
|
69
69
|
audit=AuditConfig(
|
|
@@ -75,3 +75,10 @@ def load_config() -> AppConfig:
|
|
|
75
75
|
|
|
76
76
|
def load_config_from_env() -> AppConfig:
|
|
77
77
|
return load_config()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _optional_path(env_name: str) -> Path | None:
|
|
81
|
+
value = os.getenv(env_name)
|
|
82
|
+
if value is None or not value.strip():
|
|
83
|
+
return None
|
|
84
|
+
return Path(value).expanduser()
|
|
@@ -6,19 +6,28 @@ from typing import Any
|
|
|
6
6
|
from .schema_context_retriever import SchemaContext
|
|
7
7
|
|
|
8
8
|
|
|
9
|
-
NL2SQL_SYSTEM_PROMPT = """
|
|
9
|
+
NL2SQL_SYSTEM_PROMPT = """你是丸子Agent(Octopus Agent)的海绵数据 SQL 生成器。
|
|
10
10
|
你只能基于提供的 schema context 生成 MySQL SELECT 查询。
|
|
11
11
|
返回 JSON 中的 sql 字段必须直接以 SELECT 开头,不能返回非 SQL 文本。
|
|
12
12
|
只生成业务 SQL,不要生成权限占位符或权限条件。
|
|
13
13
|
不得使用 :scope.project_ids、:scope.brand_ids 或任何 :scope.* placeholder(占位符)。
|
|
14
14
|
如果用户没有明确要求返回条数,LIMIT 必须使用 defaultLimit。
|
|
15
|
+
如果用户要求所有数据、全部数据、全量数据、完整数据,或要求导出/生成 Excel/xlsx/表格文件,LIMIT 必须使用 maxLimit,不要使用 defaultLimit。
|
|
15
16
|
不得查询 auth 库、密码、令牌、密钥、凭证字段。
|
|
17
|
+
不得输出敏感字段,包括手机号、电话、联系方式、身份证、密码、token、secret、credential、private_key 等。
|
|
18
|
+
如果用户要求敏感字段,必须从 SELECT 中排除这些字段;还有其他非敏感字段时继续生成去敏后的 SQL。
|
|
16
19
|
不得生成 INSERT、UPDATE、DELETE、DDL、非 SELECT SQL 或多语句 SQL。
|
|
17
20
|
SELECT 中每个输出字段都必须使用 AS 中文含义,例如 b.name AS 品牌名称。
|
|
18
21
|
禁止使用英文别名,例如 brand_name、project_name。
|
|
19
22
|
用户点名要求输出的字段,必须优先使用 schema context 中含义匹配的真实字段或真实关联表生成。
|
|
20
23
|
不得为了凑齐字段写 NULL AS 字段名、CAST(NULL AS ...) AS 字段名 等空占位表达式。
|
|
21
24
|
只有 schema context 中完全没有相关真实字段或关联表时,才允许不返回该字段;不要用空占位字段替代。
|
|
25
|
+
用户明确给出品牌、项目、岗位、职位、门店等实体名称时,必须在对应名称字段上使用 LIKE 过滤,不要先查询全量或默认 LIMIT 列表再比对。
|
|
26
|
+
名称类字段和状态文本类字段的筛选条件必须使用 LIKE '%值%',例如 name、job_name、status_text、status_name。
|
|
27
|
+
数字 ID、数字状态枚举、删除标记、时间范围和 JOIN ON 表关联条件不要改成 LIKE;例如 status = 1、is_delete = 0、a.id = b.id、create_at >= '2026-05-01' 保持原比较符。
|
|
28
|
+
只有“名称包含/名称为/是/等于/招聘xx岗位/在招xx职位”等明确关系才能判定为岗位或职位名称;不要仅因逗号、空格、冒号相邻就推断岗位名称。
|
|
29
|
+
业务查询必须一次生成完整 SQL;不要先查 brand、sponge_project、city、store、job_basic_info 等单表列表来找 ID,也不要要求外部先提供 ID。
|
|
30
|
+
业务查询中需要按品牌、项目、城市、门店、岗位名称过滤时,必须 JOIN 对应表并直接使用 name 或 job_name LIKE。
|
|
22
31
|
涉及业务指标或业务口径时,必须优先使用 schemaContext.metrics 中的 definition、aggregation 和 filters。
|
|
23
32
|
如果 schemaContext.metrics 没有给出对应业务口径,不得自行推断状态枚举、有效性过滤或跨轮次分割规则。
|
|
24
33
|
所有业务字段、状态枚举、状态映射、表关联方式必须来自 schemaContext.tables、schemaContext.joins、schemaContext.glossary、schemaContext.metrics 或 schemaContext.examples。
|
|
@@ -73,7 +73,7 @@ def tool_definitions() -> list[Json]:
|
|
|
73
73
|
return [
|
|
74
74
|
{
|
|
75
75
|
"name": "diagnostic_status",
|
|
76
|
-
"description": "
|
|
76
|
+
"description": "调试专用:检查丸子Agent(Octopus Agent)配置、缓存和本地持久化状态。普通业务查询不要先调用本工具,直接调用 query_sponge。",
|
|
77
77
|
"inputSchema": {
|
|
78
78
|
"type": "object",
|
|
79
79
|
"properties": {},
|
|
@@ -82,7 +82,7 @@ def tool_definitions() -> list[Json]:
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"name": "refresh_schema",
|
|
85
|
-
"description": "调试/运维专用:手动刷新本地 Schema Cache(表结构缓存)。只有用户明确要求刷新 schema
|
|
85
|
+
"description": "调试/运维专用:手动刷新本地 Schema Cache(表结构缓存)。只有用户明确要求刷新 schema 时才调用;普通业务查询不要先调用本工具;query_sponge 内部会先检查 schemaVersion 并按需刷新 schema cache,query_sponge 失败后也不要自动调用本工具。",
|
|
86
86
|
"inputSchema": {
|
|
87
87
|
"type": "object",
|
|
88
88
|
"required": ["traceId"],
|
|
@@ -97,7 +97,7 @@ def tool_definitions() -> list[Json]:
|
|
|
97
97
|
},
|
|
98
98
|
{
|
|
99
99
|
"name": "query_sponge",
|
|
100
|
-
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server
|
|
100
|
+
"description": "业务查询唯一入口:把自然语言问题转换为只读 SQL,并通过 Sponge MCP Server 校验和执行。本工具内部第一步会检查本地 schemaVersion,并调用 Sponge get_schema 按需刷新 schema cache;普通业务查询不得额外调用 refresh_schema。上层模型必须直接调用本工具;不要先调用 diagnostic_status 或 refresh_schema;query_sponge 返回“目前不支持该查询”后也不要自行 retry fastMode=false 或再调用 refresh_schema,除非用户明确要求调试;不要自行生成 SQL;不要解释 schema;不要要求用户提供 SQL;不要改写用户问题。originalQuestion 必须传用户原始话术;如果 question 被上层改写,Agent 会忽略改写版并优先用 originalQuestion 生成 SQL、审计和缓存。对“匹配效果/分析报告/报名多但入职少/在招多但报名少/候选人多但岗位少”等报告类问题,必须只调用一次 query_sponge 处理完整原始问题,不要拆成多个品牌/项目/城市子查询。工具返回后应把 answer 原文复制到最终答复正文完整展示,不要只放在折叠步骤、思考过程或工具调用详情中;如果 answer 已包含分析报告,必须先展示完整报告,再展示总结关键发现;不要自行汇总替代 answer,不要追加“是否继续查询/是否需要更多字段/我可以继续帮你”等引导。默认 fastMode=true、finalAnswerOnly=true,会优先使用规则模板,复杂查询会使用 schema context 和 LLM 生成。",
|
|
101
101
|
"inputSchema": {
|
|
102
102
|
"type": "object",
|
|
103
103
|
"required": ["question", "originalQuestion"],
|
|
@@ -26,13 +26,17 @@ LOCATION_RECRUITING_TABLES = (
|
|
|
26
26
|
|
|
27
27
|
WORK_ORDER_DETAIL_TABLES = (
|
|
28
28
|
"mvp_applied_jobs_by_helped_account",
|
|
29
|
-
"
|
|
29
|
+
"c_helped_account",
|
|
30
|
+
"resume",
|
|
31
|
+
"job",
|
|
30
32
|
"job_basic_info",
|
|
33
|
+
"brand",
|
|
34
|
+
"supplier",
|
|
31
35
|
"sponge_project",
|
|
32
36
|
)
|
|
33
37
|
|
|
34
38
|
|
|
35
|
-
def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int =
|
|
39
|
+
def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables: int = 12) -> SchemaContext:
|
|
36
40
|
glossary = _matching_items(schema.get("glossary", []), question, keys=("term", "description"))
|
|
37
41
|
metrics = _matching_items(schema.get("metrics", []), question, keys=("name", "metric", "description", "definition"))
|
|
38
42
|
examples = _matching_items(schema.get("examples", []), question, keys=("question", "description"))
|
|
@@ -40,6 +44,7 @@ def retrieve_schema_context(schema: dict[str, Any], question: str, *, max_tables
|
|
|
40
44
|
tables = _select_tables(schema.get("tables", []), question, table_names_from_context, max_tables)
|
|
41
45
|
selected_table_names = {_table_name(table) for table in tables}
|
|
42
46
|
joins = _filter_joins(schema.get("joins", []), selected_table_names)
|
|
47
|
+
joins = _append_derived_joins(schema, joins, selected_table_names)
|
|
43
48
|
return SchemaContext(tables=tables, joins=joins, glossary=glossary, metrics=metrics, examples=examples)
|
|
44
49
|
|
|
45
50
|
|
|
@@ -167,6 +172,40 @@ def _filter_joins(joins: Any, selected_table_names: set[str]) -> list[dict[str,
|
|
|
167
172
|
return result[:12]
|
|
168
173
|
|
|
169
174
|
|
|
175
|
+
def _append_derived_joins(
|
|
176
|
+
schema: dict[str, Any],
|
|
177
|
+
joins: list[dict[str, Any]],
|
|
178
|
+
selected_table_names: set[str],
|
|
179
|
+
) -> list[dict[str, Any]]:
|
|
180
|
+
result = list(joins)
|
|
181
|
+
existing = {
|
|
182
|
+
(
|
|
183
|
+
str(join.get("leftTable") or ""),
|
|
184
|
+
str(join.get("leftColumn") or ""),
|
|
185
|
+
str(join.get("rightTable") or ""),
|
|
186
|
+
str(join.get("rightColumn") or ""),
|
|
187
|
+
)
|
|
188
|
+
for join in result
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
{"resume", "supplier"} <= selected_table_names
|
|
192
|
+
and _table_has_column(schema, "resume", "supplier_id")
|
|
193
|
+
and _table_has_column(schema, "supplier", "id")
|
|
194
|
+
and ("supplier", "id", "resume", "supplier_id") not in existing
|
|
195
|
+
and ("resume", "supplier_id", "supplier", "id") not in existing
|
|
196
|
+
):
|
|
197
|
+
result.append(
|
|
198
|
+
{
|
|
199
|
+
"description": "简历和供应商关系;供应商名称可使用 supplier.nick_name",
|
|
200
|
+
"leftTable": "supplier",
|
|
201
|
+
"leftColumn": "id",
|
|
202
|
+
"rightTable": "resume",
|
|
203
|
+
"rightColumn": "supplier_id",
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
return result[:16]
|
|
207
|
+
|
|
208
|
+
|
|
170
209
|
def _table_name(table: dict[str, Any]) -> str:
|
|
171
210
|
return str(table.get("tableName") or table.get("name") or "")
|
|
172
211
|
|
|
@@ -177,6 +216,24 @@ def _column_text(column: dict[str, Any]) -> str:
|
|
|
177
216
|
)
|
|
178
217
|
|
|
179
218
|
|
|
219
|
+
def _table_has_column(schema: dict[str, Any], table_name: str, column_name: str) -> bool:
|
|
220
|
+
tables = schema.get("tables", [])
|
|
221
|
+
if not isinstance(tables, list):
|
|
222
|
+
return False
|
|
223
|
+
for table in tables:
|
|
224
|
+
if not isinstance(table, dict) or _table_name(table) != table_name:
|
|
225
|
+
continue
|
|
226
|
+
columns = table.get("columns", [])
|
|
227
|
+
if not isinstance(columns, list):
|
|
228
|
+
return False
|
|
229
|
+
return any(
|
|
230
|
+
isinstance(column, dict)
|
|
231
|
+
and str(column.get("columnName") or column.get("name") or "") == column_name
|
|
232
|
+
for column in columns
|
|
233
|
+
)
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
|
|
180
237
|
def _question_tokens(question: str) -> list[str]:
|
|
181
238
|
lowered = question.lower()
|
|
182
239
|
tokens = [lowered]
|
|
@@ -199,6 +256,9 @@ def _question_tokens(question: str) -> list[str]:
|
|
|
199
256
|
"面试成功",
|
|
200
257
|
"进行中",
|
|
201
258
|
"姓名",
|
|
259
|
+
"人员",
|
|
260
|
+
"清单",
|
|
261
|
+
"供应商",
|
|
202
262
|
):
|
|
203
263
|
if word in question:
|
|
204
264
|
tokens.append(word)
|