@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.
- package/README.md +1 -0
- package/SKILL.md +25 -1
- package/package.json +1 -1
- package/pyproject.toml +4 -2
- package/references/env.yaml +17 -0
- package/scripts/refresh_dictionary.py +67 -0
- package/scripts/verify_binding.py +47 -0
- package/src/octopus_skill/_version.py +1 -1
- package/src/octopus_skill/agent.py +1099 -146
- package/src/octopus_skill/config.py +58 -10
- package/src/octopus_skill/entity_binding.py +523 -0
- package/src/octopus_skill/entity_dictionary.py +59 -0
- package/src/octopus_skill/entity_resolver.py +446 -0
- package/src/octopus_skill/llm_sql_generator.py +3 -0
- package/src/octopus_skill/mcp_client.py +16 -0
- package/src/octopus_skill/octopus_run.py +369 -17
- package/src/octopus_skill/prompt_builder.py +74 -31
- package/src/octopus_skill/schema_context_retriever.py +199 -150
- package/src/octopus_skill/sql_generator.py +432 -801
- package/src/octopus_skill/query_sql_cache.py +0 -72
|
@@ -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 .
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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":
|
|
210
|
-
"message":
|
|
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
|
|
283
|
+
return _low_quality_sql_result(low_quality_issue.message, candidate_sql)
|
|
216
284
|
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
217
|
-
repaired_sql =
|
|
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
|
-
|
|
225
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
364
|
+
repaired = generator.repair(
|
|
291
365
|
question=sql_question,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
1199
|
+
def _generate_from_schema_example(
|
|
592
1200
|
*,
|
|
593
1201
|
question: str,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
return
|
|
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
|
-
|
|
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":
|