@roll-agent/octopus-agent 0.0.10 → 0.0.11

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.
@@ -8,25 +8,62 @@ from typing import Any
8
8
  from .audit_logger import AuditLogger, sql_hash
9
9
  from .config import AppConfig
10
10
  from .context import UserContext, new_session_id, new_trace_id
11
+ from .entity_binding import bind_entities, missing_entities_in_sql
12
+ from .entity_dictionary import EntityDictionaryCache
13
+ from .entity_resolver import EntityResolver, load_geo_aliases
11
14
  from .llm_sql_generator import Sampler, SamplingSqlGenerator
12
15
  from .mcp_client import SpongeMcpClient
13
- from .query_sql_cache import QuerySqlCache
16
+ from .prompt_builder import _business_examples
14
17
  from .result_renderer import normalize_result_format, render_result
15
18
  from .schema_cache import SchemaCache
16
19
  from .schema_context_retriever import retrieve_schema_context
17
20
  from .sql_generator import (
18
21
  GeneratedSql,
19
- RuleBasedSqlGenerator,
20
22
  SqlGenerationError,
23
+ _extract_brand_name,
24
+ _extract_location_for_recruiting_job_question,
25
+ _extract_project_name,
26
+ _requested_job_status_values,
27
+ _enum_label_terms,
28
+ _is_job_count_intent_question,
29
+ _preferred_count_alias,
30
+ _status_enum,
31
+ _user_requests_recruiting_job_count,
21
32
  enforce_sql_rules,
33
+ _is_business_query,
22
34
  is_low_quality_sql_for_question,
23
35
  is_recruiting_match_effect_query,
36
+ low_quality_sql_issue_for_question,
37
+ normalize_job_count_sql,
38
+ normalize_text_filters_to_like,
24
39
  sensitive_terms_in_text,
25
40
  sql_satisfies_time_range,
26
41
  validate_sql_against_schema,
27
42
  )
28
43
 
29
44
 
45
+ # Resolver indexes are keyed by dictVersion and shared across agent instances in
46
+ # the same process. The MCP server recreates a SpongeAgent per tools/call, so an
47
+ # instance-level cache alone would rebuild the (large) index on every request.
48
+ _RESOLVER_CACHE: dict[str, EntityResolver] = {}
49
+
50
+ # Validate error codes treated as "caller has no data scope" -> a valid empty
51
+ # result, not a SQL failure.
52
+ #
53
+ # Per MCP source review (alignment checklist Q1), the *current* server emits NO
54
+ # error for empty scope: it injects an `IN (NULL)` scope filter and returns
55
+ # success with 0 rows, which the normal empty-result path already handles. These
56
+ # codes are therefore only a defensive net for OLDER ONLINE deployments still
57
+ # observed emitting MCP_SQL_MISSING_SCOPE in audit logs (plus the declared-but-
58
+ # currently-unthrown MCP_PERMISSION_EMPTY_SCOPE).
59
+ #
60
+ # This is intentionally NOT treated as the live MCP contract. Remove once every
61
+ # online MCP instance runs the no-error-code behaviour.
62
+ _LEGACY_EMPTY_SCOPE_CODES = frozenset(
63
+ {"MCP_PERMISSION_EMPTY_SCOPE", "MCP_SQL_MISSING_SCOPE"}
64
+ )
65
+
66
+
30
67
  @dataclass(frozen=True)
31
68
  class QueryResult:
32
69
  answer: str
@@ -34,6 +71,9 @@ class QueryResult:
34
71
  normalized_sql: str
35
72
  repaired_sql: str | None
36
73
  validation: dict[str, Any]
74
+ needs_clarification: bool = False
75
+ clarification_type: str | None = None
76
+ clarification_payload: dict[str, Any] | None = None
37
77
 
38
78
 
39
79
  @dataclass
@@ -42,9 +82,12 @@ class SpongeAgent:
42
82
  mcp_client: SpongeMcpClient
43
83
  schema_cache: SchemaCache
44
84
  audit_logger: AuditLogger
85
+ dictionary_cache: EntityDictionaryCache | None = None
45
86
  _last_validation_error: dict[str, Any] = field(default_factory=dict, init=False)
46
87
  _last_validation_result: dict[str, Any] = field(default_factory=dict, init=False)
47
88
  _last_validation_trace_id: str | None = field(default=None, init=False)
89
+ _resolver: EntityResolver | None = field(default=None, init=False)
90
+ _resolver_loaded: bool = field(default=False, init=False)
48
91
 
49
92
  @classmethod
50
93
  def from_config(cls, config: AppConfig) -> "SpongeAgent":
@@ -57,6 +100,11 @@ class SpongeAgent:
57
100
  ),
58
101
  schema_cache=SchemaCache(config.schema.cache_path),
59
102
  audit_logger=AuditLogger(config.audit.log_path, enabled=config.audit.enabled),
103
+ dictionary_cache=(
104
+ EntityDictionaryCache(config.dictionary.cache_path)
105
+ if config.dictionary is not None
106
+ else None
107
+ ),
60
108
  )
61
109
 
62
110
  def answer(
@@ -88,7 +136,7 @@ class SpongeAgent:
88
136
  result_format: str | None = None,
89
137
  fast_mode: bool = True,
90
138
  ) -> QueryResult:
91
- user_question = original_question or question
139
+ user_question = _isolate_current_question(original_question or question)
92
140
  if _is_mutation_request(user_question):
93
141
  return _query_only_result()
94
142
  context = UserContext(
@@ -103,76 +151,95 @@ class SpongeAgent:
103
151
  business_clarification = _business_metric_clarification(sql_question, schema)
104
152
  if business_clarification is not None:
105
153
  return _business_metric_clarification_result(business_clarification)
106
- schema_version = str(schema.get("schemaVersion")) if schema.get("schemaVersion") is not None else None
154
+ self._log_entity_bindings_shadow(context, user_question, sql_question)
155
+ bindings = self._compute_entity_bindings(sql_question)
156
+ entity_clarification = _entity_ambiguity_clarification(bindings)
157
+ if entity_clarification is not None:
158
+ return _entity_clarification_result(
159
+ entity_clarification,
160
+ _entity_ambiguity_payload(bindings),
161
+ )
162
+ resolved_entities = self._resolved_entities_for_prompt(bindings)
107
163
  repaired_sql: str | None = None
108
- prefer_rule_based = is_recruiting_match_effect_query(sql_question)
109
- sql_cache = _make_query_sql_cache(self.config)
110
- cached_sql = sql_cache.get(question=sql_question, schema_version=schema_version) if sql_cache is not None else None
111
- if cached_sql is not None and not sql_satisfies_time_range(cached_sql, sql_question, schema):
112
- cached_sql = None
113
- if cached_sql is not None and is_low_quality_sql_for_question(sql_question, cached_sql):
114
- sql_cache.delete(question=sql_question, schema_version=schema_version)
115
- cached_sql = None
116
- if cached_sql is not None and validate_sql_against_schema(cached_sql, schema):
117
- sql_cache.delete(question=sql_question, schema_version=schema_version)
118
- cached_sql = None
119
- if cached_sql is not None:
120
- generated = GeneratedSql(sql=cached_sql, tables=[], placeholders=[])
121
- if sampler is not None:
122
- schema_context = None
123
- generator: SamplingSqlGenerator | None = None
124
- if cached_sql is None:
125
- schema_context = retrieve_schema_context(schema, sql_question)
126
- generator = SamplingSqlGenerator(
127
- sampler=sampler,
164
+ schema_context = retrieve_schema_context(
165
+ schema,
166
+ sql_question,
167
+ required_tables=_resolved_entity_tables(resolved_entities),
168
+ )
169
+ generator: SamplingSqlGenerator | None = None
170
+ if sampler is None:
171
+ generated = _generate_from_schema_example(
172
+ question=sql_question,
173
+ schema_context=schema_context,
174
+ )
175
+ self.audit_logger.log(
176
+ {
177
+ "sessionId": context.session_id,
178
+ "question": sql_question,
179
+ "tool": "schema_example_fallback",
180
+ "matched": generated is not None,
181
+ "exampleCount": len(schema_context.examples),
182
+ "sqlHash": sql_hash(generated.sql) if generated is not None else None,
183
+ }
184
+ )
185
+ if generated is None:
186
+ generated = _generate_from_resolved_entities(
187
+ question=sql_question,
188
+ resolved_entities=resolved_entities,
189
+ schema=schema,
128
190
  default_limit=self.config.sql.default_limit,
129
- max_limit=self.config.sql.max_limit,
130
191
  )
131
- if fast_mode or prefer_rule_based:
132
- try:
133
- generated = _generate_rule_based_sql(
134
- question=sql_question,
135
- schema=schema,
136
- default_limit=self.config.sql.default_limit,
137
- max_limit=self.config.sql.max_limit,
138
- )
139
- except SqlGenerationError:
140
- generated, repaired_sql = _generate_with_sampler(
141
- generator=generator,
142
- question=sql_question,
143
- schema_context=schema_context,
144
- )
145
- else:
146
- repaired_sql = None
147
- else:
148
- generated, repaired_sql = _generate_with_sampler(
149
- generator=generator,
192
+ if generated is None:
193
+ return _query_only_result()
194
+ else:
195
+ generator = SamplingSqlGenerator(
196
+ sampler=sampler,
197
+ default_limit=self.config.sql.default_limit,
198
+ max_limit=self.config.sql.max_limit,
199
+ resolved_entities=resolved_entities,
200
+ )
201
+ generated, repaired_sql, generation_repair_attempted = _generate_with_sampler(
202
+ generator=generator,
203
+ question=sql_question,
204
+ schema_context=schema_context,
205
+ )
206
+ if generated.sql == "" and repaired_sql is None:
207
+ if generation_repair_attempted:
208
+ return _query_only_result()
209
+ try:
210
+ repaired = generator.repair(
150
211
  question=sql_question,
212
+ original_sql="",
213
+ validation_error={
214
+ "code": "EMPTY_SQL",
215
+ "message": "schemaContext 已提供相关表、JOIN、枚举或示例时,不要直接返回空 SQL;请基于当前 schemaContext 生成完整 SELECT SQL。",
216
+ },
151
217
  schema_context=schema_context,
152
218
  )
153
- if generated.sql == "" and repaired_sql is None:
154
- return _query_only_result()
155
- if fast_mode and generated.sql == "" and repaired_sql is None:
156
- return _query_only_result()
157
- if not fast_mode and generated.sql == "" and repaired_sql is None:
219
+ except SqlGenerationError:
158
220
  return _query_only_result()
159
- elif cached_sql is None:
160
- schema_context = None
161
- generated = _generate_rule_based_sql(
162
- question=sql_question,
163
- schema=schema,
164
- default_limit=self.config.sql.default_limit,
165
- max_limit=self.config.sql.max_limit,
166
- )
167
- else:
168
- schema_context = None
169
- generator = None
170
- candidate_sql = repaired_sql or _apply_limit_policy(
221
+ repaired_sql = _finalize_candidate_sql(
222
+ repaired.sql,
223
+ sql_question,
224
+ default_limit=self.config.sql.default_limit,
225
+ max_limit=self.config.sql.max_limit,
226
+ )
227
+ candidate_sql = repaired_sql or _finalize_candidate_sql(
171
228
  generated.sql,
172
229
  sql_question,
173
230
  default_limit=self.config.sql.default_limit,
174
231
  max_limit=self.config.sql.max_limit,
232
+ schema=schema,
233
+ )
234
+ candidate_sql, still_missing_entities = self._enforce_resolved_entities(
235
+ candidate_sql,
236
+ sql_question,
237
+ resolved_entities,
238
+ generator,
239
+ schema_context,
175
240
  )
241
+ if still_missing_entities:
242
+ return _missing_resolved_entities_result(still_missing_entities)
176
243
  if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
177
244
  if sampler is None or schema_context is None or generator is None:
178
245
  return _query_only_result()
@@ -189,7 +256,7 @@ class SpongeAgent:
189
256
  except SqlGenerationError:
190
257
  return _query_only_result()
191
258
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
192
- repaired_sql = _apply_limit_policy(
259
+ repaired_sql = _finalize_candidate_sql(
193
260
  repaired.sql,
194
261
  sql_question,
195
262
  default_limit=self.config.sql.default_limit,
@@ -198,31 +265,34 @@ class SpongeAgent:
198
265
  candidate_sql = repaired_sql
199
266
  if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
200
267
  return _query_only_result()
201
- if is_low_quality_sql_for_question(sql_question, candidate_sql):
268
+ low_quality_issue = low_quality_sql_issue_for_question(sql_question, candidate_sql, schema=schema)
269
+ if low_quality_issue is not None:
202
270
  if sampler is None or schema_context is None or generator is None:
203
- return _query_only_result()
271
+ return _low_quality_sql_result(low_quality_issue.message, candidate_sql)
204
272
  try:
205
273
  repaired = generator.repair(
206
274
  question=sql_question,
207
275
  original_sql=candidate_sql,
208
276
  validation_error={
209
- "code": "LOW_QUALITY_SQL",
210
- "message": "SQL 与问题意图不匹配,业务查询不能退化成品牌、项目、城市、门店、岗位等单表列表;明确名称时必须在完整业务 SQL 中 JOIN 对应表并使用 name LIKE 或 job_name LIKE。",
277
+ "code": low_quality_issue.code,
278
+ "message": low_quality_issue.message,
211
279
  },
212
280
  schema_context=schema_context,
213
281
  )
214
282
  except SqlGenerationError:
215
- return _query_only_result()
283
+ return _low_quality_sql_result(low_quality_issue.message, candidate_sql)
216
284
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
217
- repaired_sql = _apply_limit_policy(
285
+ repaired_sql = _finalize_candidate_sql(
218
286
  repaired.sql,
219
287
  sql_question,
220
288
  default_limit=self.config.sql.default_limit,
221
289
  max_limit=self.config.sql.max_limit,
290
+ schema=schema,
222
291
  )
223
292
  candidate_sql = repaired_sql
224
- if is_low_quality_sql_for_question(sql_question, candidate_sql):
225
- return _query_only_result()
293
+ low_quality_issue = low_quality_sql_issue_for_question(sql_question, candidate_sql, schema=schema)
294
+ if low_quality_issue is not None:
295
+ return _low_quality_sql_result(low_quality_issue.message, candidate_sql)
226
296
  schema_issues = validate_sql_against_schema(candidate_sql, schema)
227
297
  if schema_issues:
228
298
  if sampler is None or schema_context is None or generator is None:
@@ -240,7 +310,7 @@ class SpongeAgent:
240
310
  except SqlGenerationError:
241
311
  return _query_only_result()
242
312
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
243
- repaired_sql = _apply_limit_policy(
313
+ repaired_sql = _finalize_candidate_sql(
244
314
  repaired.sql,
245
315
  sql_question,
246
316
  default_limit=self.config.sql.default_limit,
@@ -249,6 +319,12 @@ class SpongeAgent:
249
319
  candidate_sql = repaired_sql
250
320
  if validate_sql_against_schema(candidate_sql, schema):
251
321
  return _query_only_result()
322
+ candidate_sql = _finalize_candidate_sql(
323
+ candidate_sql,
324
+ sql_question,
325
+ default_limit=self.config.sql.default_limit,
326
+ max_limit=self.config.sql.max_limit,
327
+ )
252
328
  try:
253
329
  enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
254
330
  except SqlGenerationError as exc:
@@ -267,7 +343,7 @@ class SpongeAgent:
267
343
  )
268
344
  except SqlGenerationError:
269
345
  return _sensitive_query_only_result(sensitive_terms)
270
- repaired_sql = _apply_limit_policy(
346
+ repaired_sql = _finalize_candidate_sql(
271
347
  repaired.sql,
272
348
  sql_question,
273
349
  default_limit=self.config.sql.default_limit,
@@ -282,28 +358,46 @@ class SpongeAgent:
282
358
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
283
359
  candidate_sql = repaired_sql
284
360
  else:
285
- if sampler is None:
286
- return _query_only_result()
287
- if not fast_mode:
361
+ if generator is None:
288
362
  return _query_only_result()
289
363
  try:
290
- generated = _generate_rule_based_sql(
364
+ repaired = generator.repair(
291
365
  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,
366
+ original_sql=candidate_sql,
367
+ validation_error={
368
+ "code": "SQL_RULE_VIOLATION",
369
+ "message": str(exc),
370
+ },
371
+ schema_context=schema_context,
302
372
  )
303
- enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
304
373
  except SqlGenerationError:
305
374
  return _query_only_result()
375
+ repaired_sql = _finalize_candidate_sql(
376
+ repaired.sql,
377
+ sql_question,
378
+ default_limit=self.config.sql.default_limit,
379
+ max_limit=self.config.sql.max_limit,
380
+ )
381
+ try:
382
+ enforce_sql_rules(repaired_sql, max_limit=self.config.sql.max_limit)
383
+ except SqlGenerationError as repaired_exc:
384
+ if _is_sensitive_sql_error(repaired_exc) and sensitive_terms:
385
+ return _sensitive_query_only_result(sensitive_terms)
386
+ return _query_only_result()
387
+ candidate_sql = repaired_sql
306
388
  normalized_sql = self._validate_sql(context, user_question, candidate_sql)
389
+ if normalized_sql is None and _is_auth_validation_error(self._validation_error_code()):
390
+ return _auth_error_result(
391
+ self._last_validation_result,
392
+ generated_sql=generated.sql,
393
+ repaired_sql=repaired_sql,
394
+ )
395
+ if normalized_sql is None and self._validation_error_code() in _LEGACY_EMPTY_SCOPE_CODES:
396
+ # Legacy online-version compatibility only (see _LEGACY_EMPTY_SCOPE_CODES):
397
+ # the current MCP contract returns an empty success result for no-scope
398
+ # users, which the normal flow already renders. Older deployments still
399
+ # emit these codes, so treat them as an empty result instead of raising.
400
+ return _empty_scope_result(user_question, result_format)
307
401
  if sampler is not None and normalized_sql is None and schema_context is not None and generator is not None:
308
402
  try:
309
403
  repaired = generator.repair(
@@ -313,29 +407,10 @@ class SpongeAgent:
313
407
  schema_context=schema_context,
314
408
  )
315
409
  except SqlGenerationError:
316
- try:
317
- if not fast_mode:
318
- return _query_only_result()
319
- generated = _generate_rule_based_sql(
320
- question=sql_question,
321
- schema=schema,
322
- default_limit=self.config.sql.default_limit,
323
- max_limit=self.config.sql.max_limit,
324
- )
325
- repaired_sql = None
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
- )
332
- enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
333
- normalized_sql = self._validate_sql(context, user_question, candidate_sql, raise_on_error=True)
334
- except SqlGenerationError:
335
- return _query_only_result()
410
+ return _query_only_result()
336
411
  else:
337
412
  generated = GeneratedSql(sql="", tables=[], placeholders=[])
338
- repaired_sql = _apply_limit_policy(
413
+ repaired_sql = _finalize_candidate_sql(
339
414
  repaired.sql,
340
415
  sql_question,
341
416
  default_limit=self.config.sql.default_limit,
@@ -347,13 +422,23 @@ class SpongeAgent:
347
422
  if _is_sensitive_sql_error(exc) and sensitive_terms:
348
423
  return _sensitive_query_only_result(sensitive_terms)
349
424
  return _query_only_result()
350
- normalized_sql = self._validate_sql(context, user_question, repaired_sql, raise_on_error=True)
425
+ normalized_sql = self._validate_sql(context, user_question, repaired_sql)
426
+ if normalized_sql is None:
427
+ if _is_auth_validation_error(self._validation_error_code()):
428
+ return _auth_error_result(
429
+ self._last_validation_result,
430
+ generated_sql=generated.sql,
431
+ repaired_sql=repaired_sql,
432
+ )
433
+ return _query_only_result()
351
434
  if normalized_sql is None:
352
435
  raise RuntimeError("SQL validation failed")
353
- if sql_cache is not None and cached_sql is None and not is_low_quality_sql_for_question(sql_question, normalized_sql):
354
- sql_cache.set(question=sql_question, schema_version=schema_version, sql=normalized_sql)
355
436
  result = self._execute_sql(context, user_question, normalized_sql)
437
+ result = self._ensure_limited_flag(result)
356
438
  answer = render_result(user_question, result, result_format)
439
+ entity_notice = _resolved_entity_notice(resolved_entities)
440
+ if entity_notice:
441
+ answer = _prepend_entity_notice(answer, user_question, result_format, entity_notice)
357
442
  if _should_append_default_limit_notice(
358
443
  question=sql_question,
359
444
  result=result,
@@ -429,6 +514,255 @@ class SpongeAgent:
429
514
  return cached_schema
430
515
  raise RuntimeError("Sponge get_schema did not return tables and no local schema cache exists")
431
516
 
517
+ def resolver(self) -> EntityResolver | None:
518
+ """Lazily build the entity resolver from the dictionary snapshot.
519
+
520
+ The dictionary is an optional, additive capability: if the MCP server
521
+ does not expose ``get_entity_dictionary`` and no cache exists, this
522
+ returns ``None`` and callers fall back to existing behaviour.
523
+ """
524
+ if self._resolver_loaded:
525
+ return self._resolver
526
+ self._resolver_loaded = True
527
+ dictionary = self._ensure_dictionary()
528
+ if dictionary is None or self.config.dictionary is None:
529
+ self._resolver = None
530
+ return None
531
+ version = str(dictionary.get("dictVersion") or "")
532
+ cached = _RESOLVER_CACHE.get(version) if version else None
533
+ if cached is not None:
534
+ self._resolver = cached
535
+ return cached
536
+ geo_aliases = load_geo_aliases(self.config.dictionary.geo_alias_path)
537
+ resolver = EntityResolver.from_payload(dictionary, geo_aliases=geo_aliases)
538
+ if version:
539
+ _RESOLVER_CACHE[version] = resolver
540
+ self._resolver = resolver
541
+ return resolver
542
+
543
+ def _ensure_dictionary(self) -> dict[str, Any] | None:
544
+ if self.dictionary_cache is None or self.config.dictionary is None:
545
+ return None
546
+ cached = self.dictionary_cache.load()
547
+ if cached is not None and self.dictionary_cache.is_fresh(
548
+ self.config.dictionary.refresh_interval_minutes
549
+ ):
550
+ return cached
551
+ cached_version = (
552
+ str(cached.get("dictVersion")) if cached and cached.get("dictVersion") else None
553
+ )
554
+ trace_id = new_trace_id("trace_dict")
555
+ try:
556
+ response = self.mcp_client.get_entity_dictionary(
557
+ trace_id=trace_id,
558
+ dict_version=cached_version,
559
+ entity_types=self.config.dictionary.entity_types,
560
+ )
561
+ except Exception:
562
+ if cached is not None:
563
+ self.audit_logger.log(
564
+ {
565
+ "traceId": trace_id,
566
+ "tool": "get_entity_dictionary",
567
+ "dictVersion": cached_version,
568
+ "fallback": "cached_dictionary",
569
+ }
570
+ )
571
+ return cached
572
+ return None
573
+
574
+ if response.get("changed") is False and cached is not None:
575
+ self.audit_logger.log(
576
+ {
577
+ "traceId": trace_id,
578
+ "tool": "get_entity_dictionary",
579
+ "dictVersion": cached_version,
580
+ "changed": False,
581
+ }
582
+ )
583
+ return cached
584
+
585
+ if "entities" in response:
586
+ self.dictionary_cache.save(response)
587
+ self.audit_logger.log(
588
+ {
589
+ "traceId": trace_id,
590
+ "tool": "get_entity_dictionary",
591
+ "dictVersion": response.get("dictVersion"),
592
+ "changed": response.get("changed"),
593
+ }
594
+ )
595
+ return response
596
+
597
+ return cached
598
+
599
+ def _compute_entity_bindings(self, sql_question: str) -> Any | None:
600
+ if self.config.binding is None:
601
+ return None
602
+ try:
603
+ resolver = self.resolver()
604
+ if resolver is None:
605
+ return None
606
+ return bind_entities(
607
+ sql_question,
608
+ resolver,
609
+ fuzzy_candidates=_regex_entity_spans(sql_question),
610
+ min_fuzzy_score=self.config.binding.min_fuzzy_score,
611
+ )
612
+ except Exception: # pragma: no cover - binding must never break a query
613
+ return None
614
+
615
+ def _live_enabled_entity_types(self) -> tuple[str, ...]:
616
+ """Entity types whose bindings may drive SQL right now.
617
+
618
+ Empty while in shadow mode, so bindings are computed and logged but
619
+ never change SQL generation.
620
+ """
621
+ if self.config.binding is None or self.config.binding.shadow_mode:
622
+ return ()
623
+ return tuple(self.config.binding.enabled_entity_types)
624
+
625
+ def _resolved_entities_for_prompt(self, bindings: Any | None) -> dict[str, Any] | None:
626
+ """Serialize enabled bindings for injection into the LLM prompt.
627
+
628
+ The LLM uses these corrected entities (canonical name + id + the main
629
+ data table/column) together with schemaContext.joins to build correct
630
+ SQL. Returns ``None`` in shadow mode or when nothing is enabled/bound,
631
+ so the prompt is unchanged. Table relationships stay owned by the MCP
632
+ schema; this only supplies the normalized entity identity.
633
+ """
634
+ enabled = self._live_enabled_entity_types()
635
+ if bindings is None or not enabled:
636
+ return None
637
+ payload: dict[str, Any] = {}
638
+ if "brand" in enabled and bindings.brands:
639
+ payload["brands"] = [_resolved_entity_payload(entity) for entity in bindings.brands]
640
+ if "project" in enabled and bindings.projects:
641
+ payload["projects"] = [_resolved_entity_payload(entity) for entity in bindings.projects]
642
+ if "location" in enabled and bindings.location:
643
+ payload["location"] = {
644
+ level: _resolved_entity_payload(entity)
645
+ for level, entity in bindings.location.items()
646
+ }
647
+ return payload or None
648
+
649
+ def _enforce_resolved_entities(
650
+ self,
651
+ candidate_sql: str,
652
+ question: str,
653
+ resolved_entities: dict[str, Any] | None,
654
+ generator: SamplingSqlGenerator | None,
655
+ schema_context: Any | None,
656
+ ) -> tuple[str, list[str]]:
657
+ """Deterministic safety net for model compliance.
658
+
659
+ If the generated SQL has no trace of a recognized entity (id/name/core
660
+ completely absent), repair once with an explicit error. When repair
661
+ still misses entities, return the missing list so the caller can refuse
662
+ execute. The presence check is active for every generation path; the
663
+ LLM repair step is only used when a sampler is available.
664
+ """
665
+ if not resolved_entities:
666
+ return candidate_sql, []
667
+ missing = missing_entities_in_sql(candidate_sql, resolved_entities)
668
+ self.audit_logger.log(
669
+ {
670
+ "tool": "entity_enforcement",
671
+ "question": question,
672
+ "missing": missing,
673
+ "sql": candidate_sql,
674
+ }
675
+ )
676
+ if not missing:
677
+ return candidate_sql, []
678
+ if generator is None or schema_context is None:
679
+ return candidate_sql, missing
680
+ try:
681
+ repaired = generator.repair(
682
+ question=question,
683
+ original_sql=candidate_sql,
684
+ validation_error={
685
+ "code": "MISSING_RESOLVED_ENTITY",
686
+ "message": (
687
+ "SQL 未对以下已识别实体过滤:" + ";".join(missing) + "。"
688
+ "必须按 resolvedEntities 用最细粒度补齐这些过滤条件,"
689
+ "地点有 region 时按 region 过滤。"
690
+ ),
691
+ },
692
+ schema_context=schema_context,
693
+ )
694
+ except SqlGenerationError:
695
+ self.audit_logger.log(
696
+ {
697
+ "tool": "entity_enforcement_repair",
698
+ "question": question,
699
+ "missingBefore": missing,
700
+ "stillMissing": missing,
701
+ "repairFailed": True,
702
+ }
703
+ )
704
+ return candidate_sql, missing
705
+ repaired_sql = _finalize_candidate_sql(
706
+ repaired.sql,
707
+ question,
708
+ default_limit=self.config.sql.default_limit,
709
+ max_limit=self.config.sql.max_limit,
710
+ )
711
+ still_missing = (
712
+ missing_entities_in_sql(repaired_sql, resolved_entities) if repaired_sql else missing
713
+ )
714
+ self.audit_logger.log(
715
+ {
716
+ "tool": "entity_enforcement_repair",
717
+ "question": question,
718
+ "missingBefore": missing,
719
+ "sqlAfter": repaired_sql or candidate_sql,
720
+ "stillMissing": still_missing,
721
+ }
722
+ )
723
+ if still_missing:
724
+ return repaired_sql or candidate_sql, still_missing
725
+ return repaired_sql or candidate_sql, []
726
+
727
+ def _log_entity_bindings_shadow(
728
+ self,
729
+ context: UserContext,
730
+ question: str,
731
+ sql_question: str,
732
+ ) -> None:
733
+ """Compute entity bindings and record them for comparison.
734
+
735
+ This logging path never changes SQL generation. It exists so the
736
+ binding pipeline can be validated against real traffic. Any failure
737
+ here must not break the actual query.
738
+ """
739
+ if self.config.binding is None:
740
+ return
741
+ try:
742
+ bindings = self._compute_entity_bindings(sql_question)
743
+ if bindings is None or bindings.is_empty():
744
+ return
745
+ self.audit_logger.log(
746
+ {
747
+ "sessionId": context.session_id,
748
+ "question": question,
749
+ "tool": "entity_binding_shadow",
750
+ "shadowMode": self.config.binding.shadow_mode,
751
+ "enabledEntityTypes": list(self.config.binding.enabled_entity_types),
752
+ "appliedEntityTypes": list(self._live_enabled_entity_types()),
753
+ "bindings": bindings.to_log_dict(),
754
+ "regexSpans": _regex_entity_spans(sql_question),
755
+ }
756
+ )
757
+ except Exception as exc: # pragma: no cover - shadow logging must never break a query
758
+ self.audit_logger.log(
759
+ {
760
+ "sessionId": context.session_id,
761
+ "tool": "entity_binding_shadow",
762
+ "error": f"{type(exc).__name__}: {exc}",
763
+ }
764
+ )
765
+
432
766
  def _validate_sql(
433
767
  self,
434
768
  context: UserContext,
@@ -467,6 +801,38 @@ class SpongeAgent:
467
801
  self._last_validation_trace_id = trace_id
468
802
  return str(result.get("normalizedSql") or sql)
469
803
 
804
+ def _validation_error_code(self) -> str | None:
805
+ error = self._last_validation_error
806
+ if isinstance(error, dict):
807
+ code = error.get("code")
808
+ return str(code) if code else None
809
+ return None
810
+
811
+ def _ensure_limited_flag(self, result: dict[str, Any]) -> dict[str, Any]:
812
+ """Derive `limited` from the validate-stage LIMIT when execute omits it.
813
+
814
+ MCP execute_sql does not return a `limited` flag (alignment checklist
815
+ supplementary #1); the agreed approach is to infer it client-side from
816
+ the LIMIT that validate_sql parsed. The ceiling is never hardcoded here:
817
+ the value always comes from MCP's validate response.
818
+ """
819
+ if "limited" in result:
820
+ return result
821
+ limit: int | None = None
822
+ if isinstance(self._last_validation_result, dict):
823
+ raw_limit = self._last_validation_result.get("limit")
824
+ if isinstance(raw_limit, int) and not isinstance(raw_limit, bool):
825
+ limit = raw_limit
826
+ if limit is None:
827
+ return result
828
+ row_count = result.get("rowCount")
829
+ if row_count is None:
830
+ rows = result.get("rows")
831
+ row_count = len(rows) if isinstance(rows, list) else None
832
+ if not isinstance(row_count, int):
833
+ return result
834
+ return {**result, "limited": row_count >= limit}
835
+
470
836
  def _execute_sql(self, context: UserContext, question: str, sql: str) -> dict[str, Any]:
471
837
  if self._last_validation_trace_id is None:
472
838
  raise RuntimeError("SQL execution requires a prior successful validate_sql trace")
@@ -497,6 +863,93 @@ class SpongeAgent:
497
863
  return result
498
864
 
499
865
 
866
+ def _resolved_entity_tables(resolved_entities: Any | None) -> set[str]:
867
+ """Tables referenced by injected resolvedEntities, so the retriever pulls
868
+ in their MCP-maintained joins (e.g. brand -> brand_project -> project)."""
869
+ if not resolved_entities:
870
+ return set()
871
+ tables: set[str] = set()
872
+ for entity in resolved_entities.get("brands", []) or []:
873
+ if entity.get("table"):
874
+ tables.add(str(entity["table"]))
875
+ for entity in resolved_entities.get("projects", []) or []:
876
+ if entity.get("table"):
877
+ tables.add(str(entity["table"]))
878
+ location = resolved_entities.get("location") or {}
879
+ for entity in location.values():
880
+ if isinstance(entity, dict) and entity.get("table"):
881
+ tables.add(str(entity["table"]))
882
+ return tables
883
+
884
+
885
+ def _resolved_entity_payload(entity: Any) -> dict[str, Any]:
886
+ """Compact entity descriptor for the LLM prompt (name + id + location)."""
887
+ return {
888
+ "name": entity.canonical,
889
+ "table": entity.table,
890
+ "column": entity.column,
891
+ "id": entity.id,
892
+ }
893
+
894
+
895
+ def _regex_entity_spans(question: str) -> dict[str, list[str]]:
896
+ """Candidate entity spans from the existing regex extractors.
897
+
898
+ Fed to the binding layer as fuzzy candidates so typo/colloquial spans the
899
+ dictionary substring scan misses (e.g. "刘姐") can still resolve.
900
+ """
901
+ spans: dict[str, list[str]] = {}
902
+ brand = _extract_brand_name(question)
903
+ if brand:
904
+ spans["brand"] = [brand]
905
+ project = _extract_project_name(question)
906
+ if project:
907
+ spans["project"] = [project]
908
+ location = _extract_location_for_recruiting_job_question(question)
909
+ if location:
910
+ spans["location"] = [location]
911
+ return spans
912
+
913
+
914
+ _CLARIFICATION_RESOLVED_SUFFIX = re.compile(
915
+ r",按(?:品牌|项目)「.+?」(?:\(id:\s*[^)]+\))?查询\s*$"
916
+ )
917
+ _NEW_QUESTION_STARTERS = re.compile(
918
+ r"(?=你得查|你得|请查|请帮|另外|接下来|然后|再查|继续查|现在查|帮我查|还要查|再帮我)"
919
+ )
920
+
921
+
922
+ def _split_question_clauses(question: str) -> list[str]:
923
+ parts: list[str] = []
924
+ for chunk in re.split(r"[。;;\n]+", question):
925
+ chunk = chunk.strip()
926
+ if not chunk:
927
+ continue
928
+ for part in _NEW_QUESTION_STARTERS.split(chunk):
929
+ cleaned = part.strip(",, ")
930
+ if cleaned:
931
+ parts.append(cleaned)
932
+ return parts
933
+
934
+
935
+ def _isolate_current_question(question: str) -> str:
936
+ """When an orchestrator concatenates prior turns, keep the latest business clause."""
937
+ normalized = question.strip()
938
+ if not normalized:
939
+ return question
940
+ if _CLARIFICATION_RESOLVED_SUFFIX.search(normalized):
941
+ return question
942
+ parts = _split_question_clauses(normalized)
943
+ if len(parts) <= 1:
944
+ return question
945
+ business_parts = [part for part in parts if _is_business_query(part)]
946
+ if not business_parts:
947
+ return question
948
+ if len(business_parts) == 1:
949
+ return business_parts[0]
950
+ return business_parts[-1]
951
+
952
+
500
953
  def _strip_result_format_instruction(question: str) -> str:
501
954
  cleaned = question
502
955
  patterns = [
@@ -521,6 +974,30 @@ def _query_only_result() -> QueryResult:
521
974
  )
522
975
 
523
976
 
977
+ def _missing_resolved_entities_result(missing: list[str]) -> QueryResult:
978
+ detail = ";".join(missing)
979
+ return QueryResult(
980
+ answer=(
981
+ f"已识别到{detail},但本次生成的 SQL 未能正确加入对应筛选条件,无法执行查询。"
982
+ "请换一种问法重试。"
983
+ ),
984
+ generated_sql="",
985
+ normalized_sql="",
986
+ repaired_sql=None,
987
+ validation={},
988
+ )
989
+
990
+
991
+ def _low_quality_sql_result(message: str, generated_sql: str) -> QueryResult:
992
+ return QueryResult(
993
+ answer=message,
994
+ generated_sql=generated_sql,
995
+ normalized_sql="",
996
+ repaired_sql=None,
997
+ validation={},
998
+ )
999
+
1000
+
524
1001
  def _sensitive_query_only_result(terms: list[str]) -> QueryResult:
525
1002
  return QueryResult(
526
1003
  answer=f"该查询包含敏感字段,{_sensitive_field_notice(terms)}已按当前 schema 判断,目前不支持该查询。",
@@ -531,6 +1008,28 @@ def _sensitive_query_only_result(terms: list[str]) -> QueryResult:
531
1008
  )
532
1009
 
533
1010
 
1011
+ def _auth_error_result(
1012
+ validation: dict[str, Any] | None,
1013
+ *,
1014
+ generated_sql: str = "",
1015
+ repaired_sql: str | None = None,
1016
+ ) -> QueryResult:
1017
+ error = validation.get("error") if isinstance(validation, dict) else None
1018
+ code = error.get("code") if isinstance(error, dict) else None
1019
+ suffix = f"(错误码:{code})" if code else ""
1020
+ return QueryResult(
1021
+ answer=f"当前 MCP 用户认证失败,无法校验和执行查询。请检查 MCP 访问 token 或重新登录后再试。{suffix}",
1022
+ generated_sql=generated_sql,
1023
+ normalized_sql="",
1024
+ repaired_sql=repaired_sql,
1025
+ validation=validation or {},
1026
+ )
1027
+
1028
+
1029
+ def _is_auth_validation_error(code: str | None) -> bool:
1030
+ return code in {"MCP_AUTH_INVALID_TOKEN", "MCP_AUTH_REQUIRED", "MCP_AUTH_FORBIDDEN"}
1031
+
1032
+
534
1033
  def _business_metric_clarification_result(answer: str) -> QueryResult:
535
1034
  return QueryResult(
536
1035
  answer=answer,
@@ -541,6 +1040,113 @@ def _business_metric_clarification_result(answer: str) -> QueryResult:
541
1040
  )
542
1041
 
543
1042
 
1043
+ def _entity_clarification_result(answer: str, payload: dict[str, Any] | None) -> QueryResult:
1044
+ return QueryResult(
1045
+ answer=answer,
1046
+ generated_sql="",
1047
+ normalized_sql="",
1048
+ repaired_sql=None,
1049
+ validation={},
1050
+ needs_clarification=True,
1051
+ clarification_type="entity_resolution",
1052
+ clarification_payload=payload,
1053
+ )
1054
+
1055
+
1056
+ def _entity_ambiguity_clarification(bindings: Any | None) -> str | None:
1057
+ ambiguity = getattr(bindings, "ambiguity", None)
1058
+ if ambiguity is None:
1059
+ return None
1060
+
1061
+ brands = list(getattr(ambiguity, "brands", []))
1062
+ projects = list(getattr(ambiguity, "projects", []))
1063
+ if brands and projects:
1064
+ lines = [
1065
+ f"“{ambiguity.term}”同时匹配到品牌和项目,请确认按哪类查询?",
1066
+ "",
1067
+ "品牌:",
1068
+ ]
1069
+ lines.extend(_format_entity_options(brands))
1070
+ lines.extend(["", "项目:"])
1071
+ elif projects:
1072
+ lines = [
1073
+ f"“{ambiguity.term}”未完全匹配到唯一项目,请确认要查询哪个项目?",
1074
+ "",
1075
+ "项目:",
1076
+ ]
1077
+ elif brands:
1078
+ lines = [
1079
+ f"“{ambiguity.term}”未完全匹配到唯一品牌,请确认要查询哪个品牌?",
1080
+ "",
1081
+ "品牌:",
1082
+ ]
1083
+ lines.extend(_format_entity_options(brands))
1084
+ return "\n".join(lines)
1085
+ else:
1086
+ return f"“{ambiguity.term}”未匹配到明确实体,请提供完整的品牌或项目名称。"
1087
+
1088
+ lines.extend(_format_entity_options(projects[:10]))
1089
+ if len(projects) > 10:
1090
+ lines.extend(
1091
+ [
1092
+ "",
1093
+ "项目命中较多,当前只展示前 10 个;如果需要查询明确数据,请提供完整项目名称。",
1094
+ ]
1095
+ )
1096
+ return "\n".join(lines)
1097
+
1098
+
1099
+ def _entity_ambiguity_payload(bindings: Any | None) -> dict[str, Any] | None:
1100
+ ambiguity = getattr(bindings, "ambiguity", None)
1101
+ if ambiguity is None:
1102
+ return None
1103
+ brands = list(getattr(ambiguity, "brands", []))
1104
+ projects = list(getattr(ambiguity, "projects", []))
1105
+ return {
1106
+ "term": getattr(ambiguity, "term", ""),
1107
+ "brands": [_entity_candidate_payload(entity) for entity in brands],
1108
+ "projects": [_entity_candidate_payload(entity) for entity in projects[:10]],
1109
+ "projectTotal": len(projects),
1110
+ "projectDisplayLimit": 10,
1111
+ "projectTruncated": len(projects) > 10,
1112
+ "instruction": "必须先把 answer 原文展示给用户并等待确认;不得自行选择候选项、不得改写问题、不得再次调用 query_sponge。",
1113
+ }
1114
+
1115
+
1116
+ def _entity_candidate_payload(entity: Any) -> dict[str, Any]:
1117
+ return {
1118
+ "type": getattr(entity, "entity_type", ""),
1119
+ "id": getattr(entity, "id", None),
1120
+ "name": getattr(entity, "canonical", ""),
1121
+ "matchedVia": getattr(entity, "matched_via", ""),
1122
+ "score": getattr(entity, "score", None),
1123
+ }
1124
+
1125
+
1126
+ def _format_entity_options(entities: list[Any]) -> list[str]:
1127
+ if not entities:
1128
+ return ["无"]
1129
+ return [
1130
+ f"{index}. {getattr(entity, 'canonical', '')}(id: {getattr(entity, 'id', '')})"
1131
+ for index, entity in enumerate(entities, start=1)
1132
+ ]
1133
+
1134
+
1135
+ def _empty_scope_result(question: str, result_format: str | None) -> QueryResult:
1136
+ # Reuses the standard empty-result rendering so the requested output format is
1137
+ # respected. Only used as legacy compatibility for older online MCP versions
1138
+ # (see _LEGACY_EMPTY_SCOPE_CODES); the current contract returns this shape
1139
+ # directly as a normal success result.
1140
+ answer = render_result(question, {"success": True, "rowCount": 0, "rows": []}, result_format)
1141
+ return QueryResult(
1142
+ answer=answer,
1143
+ generated_sql="",
1144
+ normalized_sql="",
1145
+ repaired_sql=None,
1146
+ validation={},
1147
+ )
1148
+
1149
+
544
1150
  def _sensitive_field_notice(terms: list[str]) -> str:
545
1151
  if not terms:
546
1152
  return "已排除敏感字段。"
@@ -558,7 +1164,7 @@ def _generate_with_sampler(
558
1164
  generator: SamplingSqlGenerator,
559
1165
  question: str,
560
1166
  schema_context: Any,
561
- ) -> tuple[GeneratedSql, str | None]:
1167
+ ) -> tuple[GeneratedSql, str | None, bool]:
562
1168
  try:
563
1169
  return (
564
1170
  generator.generate(
@@ -566,6 +1172,7 @@ def _generate_with_sampler(
566
1172
  schema_context=schema_context,
567
1173
  ),
568
1174
  None,
1175
+ False,
569
1176
  )
570
1177
  except SqlGenerationError as sampling_exc:
571
1178
  try:
@@ -576,56 +1183,77 @@ def _generate_with_sampler(
576
1183
  schema_context=schema_context,
577
1184
  )
578
1185
  except SqlGenerationError:
579
- return GeneratedSql(sql="", tables=[], placeholders=[]), None
1186
+ return GeneratedSql(sql="", tables=[], placeholders=[]), None, True
580
1187
  return (
581
1188
  GeneratedSql(sql="", tables=[], placeholders=[]),
582
- _apply_limit_policy(
1189
+ _finalize_candidate_sql(
583
1190
  repaired.sql,
584
1191
  question,
585
1192
  default_limit=generator.default_limit,
586
1193
  max_limit=generator.max_limit,
587
1194
  ),
1195
+ True,
588
1196
  )
589
1197
 
590
1198
 
591
- def _generate_rule_based_sql(
1199
+ def _generate_from_schema_example(
592
1200
  *,
593
1201
  question: str,
594
- schema: dict[str, Any],
595
- default_limit: int,
596
- max_limit: int,
597
- ) -> GeneratedSql:
598
- generator = RuleBasedSqlGenerator(
599
- default_limit=default_limit,
600
- max_limit=max_limit,
601
- )
602
- return generator.generate(
603
- question=question,
604
- schema=schema,
1202
+ schema_context: Any,
1203
+ ) -> GeneratedSql | None:
1204
+ examples = _business_examples(getattr(schema_context, "examples", []) or [])
1205
+ best: tuple[int, dict[str, Any]] | None = None
1206
+ for example in examples:
1207
+ if not isinstance(example, dict) or not isinstance(example.get("sql"), str):
1208
+ continue
1209
+ if not _is_high_match_example(question, str(example.get("question") or "")):
1210
+ continue
1211
+ score = _example_match_score(question, str(example.get("question") or ""))
1212
+ if best is None or score > best[0]:
1213
+ best = (score, example)
1214
+ if best is None:
1215
+ return None
1216
+ example = best[1]
1217
+ tables = example.get("tables") if isinstance(example.get("tables"), list) else []
1218
+ return GeneratedSql(
1219
+ sql=str(example["sql"]).strip(),
1220
+ tables=[str(table) for table in tables],
1221
+ placeholders=[],
605
1222
  )
606
1223
 
607
1224
 
608
- def _generate_sampling_fallback_sql(
609
- *,
610
- question: str,
611
- schema: dict[str, Any],
612
- default_limit: int,
613
- max_limit: int,
614
- ) -> GeneratedSql:
615
- if re.search(r"(岗位|职位|招聘|在招)", question) is None:
616
- raise SqlGenerationError("No sampling fallback matched the question")
617
- return _generate_rule_based_sql(
618
- question=question,
619
- schema=schema,
620
- default_limit=default_limit,
621
- max_limit=max_limit,
622
- )
1225
+ def _is_high_match_example(question: str, example_question: str) -> bool:
1226
+ if not question or not example_question:
1227
+ return False
1228
+ question_compact = _compact_match_text(question)
1229
+ example_compact = _compact_match_text(example_question)
1230
+ if len(example_compact) >= 12 and (example_compact in question_compact or question_compact in example_compact):
1231
+ return True
1232
+ question_tokens = set(_match_tokens(question))
1233
+ example_tokens = set(_match_tokens(example_question))
1234
+ if not question_tokens or not example_tokens:
1235
+ return False
1236
+ overlap = len(question_tokens & example_tokens)
1237
+ return overlap >= 8 and overlap / min(len(question_tokens), len(example_tokens)) >= 0.65
623
1238
 
624
1239
 
625
- def _make_query_sql_cache(config: AppConfig) -> QuerySqlCache | None:
626
- if config.sql.query_cache_path is None:
627
- return None
628
- return QuerySqlCache(config.sql.query_cache_path, config.sql.query_cache_ttl_minutes)
1240
+ def _example_match_score(question: str, example_question: str) -> int:
1241
+ question_tokens = set(_match_tokens(question))
1242
+ example_tokens = set(_match_tokens(example_question))
1243
+ return len(question_tokens & example_tokens)
1244
+
1245
+
1246
+ def _compact_match_text(text: str) -> str:
1247
+ return "".join(re.findall(r"[A-Za-z0-9\u4e00-\u9fff]+", text.lower()))
1248
+
1249
+
1250
+ def _match_tokens(text: str) -> list[str]:
1251
+ compact = _compact_match_text(text)
1252
+ tokens = re.findall(r"[a-z0-9]+", compact)
1253
+ cjk = "".join(re.findall(r"[\u4e00-\u9fff]+", compact))
1254
+ for index in range(max(0, len(cjk) - 1)):
1255
+ tokens.append(cjk[index : index + 2])
1256
+ return list(dict.fromkeys(tokens))
629
1257
 
630
1258
 
631
1259
  def _is_mutation_request(question: str) -> bool:
@@ -636,11 +1264,30 @@ def _is_mutation_request(question: str) -> bool:
636
1264
  )
637
1265
  if mutation_match is None:
638
1266
  return False
1267
+ if _is_status_enum_query(question):
1268
+ return False
639
1269
  if _is_mutation_analytics_request(question):
640
1270
  return False
641
1271
  return True
642
1272
 
643
1273
 
1274
+ def _is_status_enum_query(question: str) -> bool:
1275
+ """Allow read-only queries that mention publish/offline status labels."""
1276
+ has_read_intent = re.search(
1277
+ r"(查询|查看|看看|搜索|统计|多少|哪些|有哪些|列表|明细|展示|返回|状态|数量|数|总数)",
1278
+ question,
1279
+ flags=re.IGNORECASE,
1280
+ )
1281
+ has_status_label = re.search(r"(已发布|未发布|已下架|已上架|在招)", question)
1282
+ if has_read_intent and has_status_label and re.search(r"(岗位|职位)", question):
1283
+ return True
1284
+ return bool(
1285
+ has_read_intent
1286
+ and re.search(r"(岗位状态|状态|包括|包含|为|是|=|:|:)", question)
1287
+ and has_status_label
1288
+ )
1289
+
1290
+
644
1291
  def _is_mutation_analytics_request(question: str) -> bool:
645
1292
  return bool(
646
1293
  re.search(r"(统计|查询|查看|看看|多少|几次|次数|数量|count|列表|明细|记录|日志|历史|趋势|分组)", question, flags=re.IGNORECASE)
@@ -779,6 +1426,235 @@ def _schema_has_metric_definition(question: str, schema: dict[str, Any], metric_
779
1426
  return any(term in metric_text for term in metric_terms for metric_text in metric_texts)
780
1427
 
781
1428
 
1429
+ def _finalize_candidate_sql(
1430
+ sql: str,
1431
+ question: str,
1432
+ *,
1433
+ default_limit: int,
1434
+ max_limit: int,
1435
+ schema: dict[str, Any] | None = None,
1436
+ ) -> str:
1437
+ normalized = normalize_text_filters_to_like(sql)
1438
+ normalized = normalize_job_count_sql(normalized, question, schema=schema)
1439
+ return _apply_limit_policy(normalized, question, default_limit=default_limit, max_limit=max_limit)
1440
+
1441
+
1442
+ def _is_job_count_question(question: str) -> bool:
1443
+ return _is_job_count_intent_question(question)
1444
+
1445
+
1446
+ def _is_job_list_question(question: str) -> bool:
1447
+ if _is_job_count_question(question):
1448
+ return False
1449
+ return bool(
1450
+ re.search(r"(岗位|职位)", question)
1451
+ and re.search(r"(哪些|有什么|列表|明细|清单|查询|查看|看看|列出|返回|展示)", question)
1452
+ )
1453
+
1454
+
1455
+ def _generate_from_resolved_entities(
1456
+ *,
1457
+ question: str,
1458
+ resolved_entities: dict[str, Any] | None,
1459
+ schema: dict[str, Any],
1460
+ default_limit: int,
1461
+ ) -> GeneratedSql | None:
1462
+ if not resolved_entities:
1463
+ return None
1464
+
1465
+ location = resolved_entities.get("location") or {}
1466
+ location_filter = _resolved_location_job_filter(location)
1467
+ if location_filter is None:
1468
+ return None
1469
+
1470
+ projects = resolved_entities.get("projects") or []
1471
+ brands = resolved_entities.get("brands") or []
1472
+ entity_filter: str | None = None
1473
+ extra_tables: list[str] = []
1474
+ if projects:
1475
+ project_id = projects[0].get("id")
1476
+ if project_id is None:
1477
+ return None
1478
+ entity_filter = f"sp.id = {project_id}"
1479
+ extra_tables = ["sponge_project"]
1480
+ elif brands:
1481
+ brand_id = brands[0].get("id")
1482
+ if brand_id is None:
1483
+ return None
1484
+ entity_filter = f"jbi.brand_id = {brand_id}"
1485
+ else:
1486
+ return None
1487
+
1488
+ if _is_job_list_question(question):
1489
+ return _generate_job_list_from_resolved_entities(
1490
+ question=question,
1491
+ schema=schema,
1492
+ default_limit=default_limit,
1493
+ entity_filter=entity_filter,
1494
+ location_filter=location_filter,
1495
+ projects=projects,
1496
+ location=location,
1497
+ )
1498
+
1499
+ if not _is_job_count_question(question):
1500
+ return None
1501
+
1502
+ status_values = _requested_job_status_values(question, schema)
1503
+ status_clause = ""
1504
+ if status_values:
1505
+ status_clause = f" AND jbi.status IN ({', '.join(sorted(status_values, key=int))})"
1506
+ elif _user_requests_recruiting_job_count(question, schema):
1507
+ status_enum = _status_enum(schema, "job_basic_info.status")
1508
+ recruiting_values: set[str] = set()
1509
+ if status_enum:
1510
+ for item in status_enum:
1511
+ value = item.get("value")
1512
+ labels = _enum_label_terms(str(item.get("label") or ""))
1513
+ if value is None:
1514
+ continue
1515
+ if any(label in {"在招", "已发布", "已上架"} or "在招" in label for label in labels):
1516
+ recruiting_values.add(str(value))
1517
+ if recruiting_values:
1518
+ status_clause = f" AND jbi.status IN ({', '.join(sorted(recruiting_values, key=int))})"
1519
+
1520
+ count_alias = _preferred_count_alias(question) or "岗位总数"
1521
+ project_join = (
1522
+ "INNER JOIN sponge_project sp ON sp.id = jbi.organization_id AND sp.is_delete = 0 "
1523
+ if projects
1524
+ else ""
1525
+ )
1526
+ sql = (
1527
+ f"SELECT COUNT(DISTINCT jbi.id) AS {count_alias} "
1528
+ "FROM job_basic_info jbi "
1529
+ f"{project_join}"
1530
+ "LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
1531
+ "LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL "
1532
+ "LEFT JOIN job_address ja ON ja.job_id = jbi.job_id AND ja.delete_at IS NULL "
1533
+ "WHERE jbi.is_deleted = 0 "
1534
+ f"AND {entity_filter} "
1535
+ f"AND ({location_filter})"
1536
+ f"{status_clause} "
1537
+ f"LIMIT {default_limit}"
1538
+ )
1539
+ tables = ["job_basic_info", "job_store", "store", "job_address", *extra_tables]
1540
+ return GeneratedSql(
1541
+ sql=sql,
1542
+ tables=tables,
1543
+ placeholders=[],
1544
+ )
1545
+
1546
+
1547
+ def _generate_job_list_from_resolved_entities(
1548
+ *,
1549
+ question: str,
1550
+ schema: dict[str, Any],
1551
+ default_limit: int,
1552
+ entity_filter: str,
1553
+ location_filter: str,
1554
+ projects: list[Any],
1555
+ location: dict[str, Any],
1556
+ ) -> GeneratedSql | None:
1557
+ required_columns = (
1558
+ ("job_basic_info", "id"),
1559
+ ("job_basic_info", "job_name"),
1560
+ ("job_basic_info", "status"),
1561
+ ("job_basic_info", "is_deleted"),
1562
+ ("job_basic_info", "organization_id"),
1563
+ ("job_basic_info", "job_id"),
1564
+ ("sponge_project", "id"),
1565
+ ("sponge_project", "name"),
1566
+ ("sponge_project", "is_delete"),
1567
+ ("job_store", "job_basic_info_id"),
1568
+ ("job_store", "store_id"),
1569
+ ("job_store", "is_deleted"),
1570
+ ("store", "id"),
1571
+ ("store", "city_id"),
1572
+ ("store", "region_id"),
1573
+ ("store", "delete_at"),
1574
+ ("job_address", "job_id"),
1575
+ ("job_address", "city_id"),
1576
+ ("job_address", "region_id"),
1577
+ ("job_address", "delete_at"),
1578
+ )
1579
+ if any(not _schema_has_column(schema, table, column) for table, column in required_columns):
1580
+ return None
1581
+
1582
+ status_values = _requested_job_status_values(question, schema)
1583
+ status_clause = ""
1584
+ if status_values:
1585
+ status_clause = f" AND jbi.status IN ({', '.join(sorted(status_values, key=int))})"
1586
+ status_expr = _job_status_select_expression(schema)
1587
+ city_name = _sql_string_literal(_resolved_location_display_name(location))
1588
+ project_join = (
1589
+ "INNER JOIN sponge_project sp ON sp.id = jbi.organization_id AND sp.is_delete = 0 "
1590
+ if projects
1591
+ else "LEFT JOIN sponge_project sp ON sp.id = jbi.organization_id AND sp.is_delete = 0 "
1592
+ )
1593
+ sql = (
1594
+ "SELECT DISTINCT "
1595
+ "jbi.id AS 岗位ID, "
1596
+ "jbi.job_name AS 岗位名称, "
1597
+ "sp.name AS 项目名称, "
1598
+ f"{city_name} AS 城市, "
1599
+ f"{status_expr} AS 岗位状态 "
1600
+ "FROM job_basic_info jbi "
1601
+ f"{project_join}"
1602
+ "LEFT JOIN job_store js ON js.job_basic_info_id = jbi.id AND js.is_deleted = 0 "
1603
+ "LEFT JOIN store s ON s.id = js.store_id AND s.delete_at IS NULL "
1604
+ "LEFT JOIN job_address ja ON ja.job_id = jbi.job_id AND ja.delete_at IS NULL "
1605
+ "WHERE jbi.is_deleted = 0 "
1606
+ f"AND {entity_filter} "
1607
+ f"AND ({location_filter})"
1608
+ f"{status_clause} "
1609
+ "ORDER BY jbi.id DESC "
1610
+ f"LIMIT {default_limit}"
1611
+ )
1612
+ return GeneratedSql(
1613
+ sql=sql,
1614
+ tables=["job_basic_info", "sponge_project", "job_store", "store", "job_address"],
1615
+ placeholders=[],
1616
+ )
1617
+
1618
+
1619
+ def _job_status_select_expression(schema: dict[str, Any]) -> str:
1620
+ status_enum = _status_enum(schema, "job_basic_info.status")
1621
+ if not status_enum:
1622
+ return "jbi.status"
1623
+ clauses: list[str] = []
1624
+ for item in status_enum:
1625
+ value = item.get("value")
1626
+ label = str(item.get("label") or "").strip()
1627
+ if value is None or not label:
1628
+ continue
1629
+ clauses.append(f"WHEN {value} THEN {_sql_string_literal(label)}")
1630
+ if not clauses:
1631
+ return "jbi.status"
1632
+ return "CASE jbi.status " + " ".join(clauses) + " ELSE jbi.status END"
1633
+
1634
+
1635
+ def _resolved_location_display_name(location: dict[str, Any]) -> str:
1636
+ entity = location.get("region") or location.get("city") or location.get("province") or {}
1637
+ if isinstance(entity, dict):
1638
+ return str(entity.get("name") or "")
1639
+ return ""
1640
+
1641
+
1642
+ def _sql_string_literal(value: str) -> str:
1643
+ return "'" + value.replace("'", "''") + "'"
1644
+
1645
+
1646
+ def _resolved_location_job_filter(location: dict[str, Any]) -> str | None:
1647
+ region = location.get("region")
1648
+ if isinstance(region, dict) and region.get("id") is not None:
1649
+ region_id = region["id"]
1650
+ return f"s.region_id = {region_id}"
1651
+ city = location.get("city")
1652
+ if isinstance(city, dict) and city.get("id") is not None:
1653
+ city_id = city["id"]
1654
+ return f"s.city_id = {city_id}"
1655
+ return None
1656
+
1657
+
782
1658
  def _apply_limit_policy(sql: str, question: str, *, default_limit: int, max_limit: int) -> str:
783
1659
  if _question_requests_all_rows(question):
784
1660
  return _replace_limit(sql, max_limit)
@@ -795,7 +1671,10 @@ def _apply_default_limit(sql: str, question: str, default_limit: int) -> str:
795
1671
 
796
1672
 
797
1673
  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)
1674
+ stripped = sql.strip().rstrip(";")
1675
+ if re.search(r"\blimit\s+\d+\b", stripped, flags=re.IGNORECASE):
1676
+ return re.sub(r"\blimit\s+\d+\b", f"LIMIT {limit}", stripped, count=1, flags=re.IGNORECASE)
1677
+ return f"{stripped} LIMIT {limit}"
799
1678
 
800
1679
 
801
1680
  def _question_requests_limit(question: str) -> bool:
@@ -842,6 +1721,80 @@ def _should_append_default_limit_notice(*, question: str, result: dict[str, Any]
842
1721
  return row_count == default_limit
843
1722
 
844
1723
 
1724
+ def _resolved_entity_notice(resolved_entities: Any | None) -> str | None:
1725
+ """Human-readable note of which entities were recognized and applied.
1726
+
1727
+ Only present when binding is live and matched (resolved_entities non-empty),
1728
+ so the user sees how their colloquial wording was normalized (e.g. the
1729
+ canonical brand name actually used to filter).
1730
+ """
1731
+ if not resolved_entities:
1732
+ return None
1733
+ parts: list[str] = []
1734
+ brands = resolved_entities.get("brands") or []
1735
+ if brands:
1736
+ parts.append("品牌「" + "、".join(str(item["name"]) for item in brands) + "」")
1737
+ projects = resolved_entities.get("projects") or []
1738
+ if projects:
1739
+ parts.append("项目「" + "、".join(str(item["name"]) for item in projects) + "」")
1740
+ location = resolved_entities.get("location") or {}
1741
+ if location:
1742
+ location_names: list[str] = []
1743
+ for level in ("province", "city", "region"):
1744
+ entity = location.get(level)
1745
+ if not entity:
1746
+ continue
1747
+ name = str(entity["name"])
1748
+ # 直辖市的省名与市名相同,去掉相邻重复,避免「上海市 / 上海市」
1749
+ if location_names and location_names[-1] == name:
1750
+ continue
1751
+ location_names.append(name)
1752
+ if location_names:
1753
+ parts.append("地点「" + " / ".join(location_names) + "」")
1754
+ if not parts:
1755
+ return None
1756
+ return "已识别并用于筛选:" + ";".join(parts) + "。"
1757
+
1758
+
1759
+ def _append_entity_notice(answer: str, question: str, result_format: str | None, notice: str) -> str:
1760
+ output_format = normalize_result_format(question, result_format)
1761
+ if output_format == "json":
1762
+ try:
1763
+ payload = json.loads(answer)
1764
+ except json.JSONDecodeError:
1765
+ return f"{answer}\n\n{notice}"
1766
+ if isinstance(payload, dict):
1767
+ payload["recognizedEntities"] = notice
1768
+ return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
1769
+ if output_format == "yaml":
1770
+ return f"{answer.rstrip()}\nrecognizedEntities: {notice}\n"
1771
+ if output_format == "html":
1772
+ return f"{answer}<p>{notice}</p>"
1773
+ return f"{answer}\n\n{notice}"
1774
+
1775
+
1776
+ def _prepend_entity_notice(answer: str, question: str, result_format: str | None, notice: str) -> str:
1777
+ """Lead the answer with the recognized-entity note.
1778
+
1779
+ Placed first (not appended) so the upper model is more likely to surface it
1780
+ when it reformats the tool output.
1781
+ """
1782
+ output_format = normalize_result_format(question, result_format)
1783
+ if output_format == "json":
1784
+ try:
1785
+ payload = json.loads(answer)
1786
+ except json.JSONDecodeError:
1787
+ return f"{notice}\n\n{answer}"
1788
+ if isinstance(payload, dict):
1789
+ payload["recognizedEntities"] = notice
1790
+ return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
1791
+ if output_format == "yaml":
1792
+ return f"recognizedEntities: {notice}\n{answer}"
1793
+ if output_format == "html":
1794
+ return f"<p>{notice}</p>{answer}"
1795
+ return f"{notice}\n\n{answer}"
1796
+
1797
+
845
1798
  def _append_default_limit_notice(answer: str, question: str, result_format: str | None, notice: str) -> str:
846
1799
  output_format = normalize_result_format(question, result_format)
847
1800
  if output_format == "json":