@roll-agent/octopus-agent 0.0.1 → 0.0.4
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 +18 -8
- package/SKILL.md +20 -5
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/octopus_sponge_skill/_version.py +1 -1
- package/src/octopus_sponge_skill/agent.py +448 -82
- package/src/octopus_sponge_skill/config.py +5 -1
- package/src/octopus_sponge_skill/context.py +0 -3
- package/src/octopus_sponge_skill/device_info.py +7 -0
- package/src/octopus_sponge_skill/llm_sql_generator.py +23 -6
- package/src/octopus_sponge_skill/main.py +0 -4
- package/src/octopus_sponge_skill/mcp_client.py +7 -25
- package/src/octopus_sponge_skill/prompt_builder.py +42 -15
- package/src/octopus_sponge_skill/query_sql_cache.py +72 -0
- package/src/octopus_sponge_skill/result_renderer.py +118 -2
- package/src/octopus_sponge_skill/roll_server.py +40 -25
- package/src/octopus_sponge_skill/schema_cache.py +6 -0
- package/src/octopus_sponge_skill/schema_context_retriever.py +112 -7
- package/src/octopus_sponge_skill/sql_generator.py +1356 -16
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
3
4
|
import re
|
|
4
5
|
from dataclasses import dataclass, field
|
|
5
6
|
from typing import Any
|
|
@@ -9,10 +10,20 @@ from .config import AppConfig
|
|
|
9
10
|
from .context import UserContext, new_session_id, new_trace_id
|
|
10
11
|
from .llm_sql_generator import Sampler, SamplingSqlGenerator
|
|
11
12
|
from .mcp_client import SpongeMcpClient
|
|
12
|
-
from .
|
|
13
|
+
from .query_sql_cache import QuerySqlCache
|
|
14
|
+
from .result_renderer import normalize_result_format, render_result
|
|
13
15
|
from .schema_cache import SchemaCache
|
|
14
16
|
from .schema_context_retriever import retrieve_schema_context
|
|
15
|
-
from .sql_generator import
|
|
17
|
+
from .sql_generator import (
|
|
18
|
+
GeneratedSql,
|
|
19
|
+
RuleBasedSqlGenerator,
|
|
20
|
+
SqlGenerationError,
|
|
21
|
+
enforce_sql_rules,
|
|
22
|
+
is_low_quality_sql_for_question,
|
|
23
|
+
is_recruiting_match_effect_query,
|
|
24
|
+
sql_satisfies_time_range,
|
|
25
|
+
validate_sql_against_schema,
|
|
26
|
+
)
|
|
16
27
|
|
|
17
28
|
|
|
18
29
|
@dataclass(frozen=True)
|
|
@@ -32,6 +43,7 @@ class SpongeAgent:
|
|
|
32
43
|
audit_logger: AuditLogger
|
|
33
44
|
_last_validation_error: dict[str, Any] = field(default_factory=dict, init=False)
|
|
34
45
|
_last_validation_result: dict[str, Any] = field(default_factory=dict, init=False)
|
|
46
|
+
_last_validation_trace_id: str | None = field(default=None, init=False)
|
|
35
47
|
|
|
36
48
|
@classmethod
|
|
37
49
|
def from_config(cls, config: AppConfig) -> "SpongeAgent":
|
|
@@ -50,82 +62,239 @@ class SpongeAgent:
|
|
|
50
62
|
self,
|
|
51
63
|
*,
|
|
52
64
|
question: str,
|
|
53
|
-
|
|
65
|
+
original_question: str | None = None,
|
|
54
66
|
session_id: str | None = None,
|
|
55
|
-
device_id: str | None = None,
|
|
56
67
|
sampler: Sampler | None = None,
|
|
57
68
|
result_format: str | None = None,
|
|
69
|
+
fast_mode: bool = True,
|
|
58
70
|
) -> str:
|
|
59
71
|
return self.query(
|
|
60
72
|
question=question,
|
|
61
|
-
|
|
73
|
+
original_question=original_question,
|
|
62
74
|
session_id=session_id,
|
|
63
|
-
device_id=device_id,
|
|
64
75
|
sampler=sampler,
|
|
65
76
|
result_format=result_format,
|
|
77
|
+
fast_mode=fast_mode,
|
|
66
78
|
).answer
|
|
67
79
|
|
|
68
80
|
def query(
|
|
69
81
|
self,
|
|
70
82
|
*,
|
|
71
83
|
question: str,
|
|
72
|
-
|
|
84
|
+
original_question: str | None = None,
|
|
73
85
|
session_id: str | None = None,
|
|
74
|
-
device_id: str | None = None,
|
|
75
86
|
sampler: Sampler | None = None,
|
|
76
87
|
result_format: str | None = None,
|
|
88
|
+
fast_mode: bool = True,
|
|
77
89
|
) -> QueryResult:
|
|
90
|
+
user_question = original_question or question
|
|
91
|
+
if _is_mutation_request(user_question):
|
|
92
|
+
return _query_only_result()
|
|
78
93
|
context = UserContext(
|
|
79
|
-
user_id=user_id,
|
|
80
94
|
session_id=session_id or new_session_id(),
|
|
81
|
-
device_id=device_id,
|
|
82
95
|
)
|
|
83
|
-
sql_question = _strip_result_format_instruction(
|
|
96
|
+
sql_question = _strip_result_format_instruction(user_question)
|
|
84
97
|
schema = self._ensure_schema()
|
|
85
|
-
|
|
86
|
-
|
|
98
|
+
business_clarification = _business_metric_clarification(sql_question, schema)
|
|
99
|
+
if business_clarification is not None:
|
|
100
|
+
return _business_metric_clarification_result(business_clarification)
|
|
101
|
+
schema_version = str(schema.get("schemaVersion")) if schema.get("schemaVersion") is not None else None
|
|
102
|
+
repaired_sql: str | None = None
|
|
103
|
+
prefer_rule_based = is_recruiting_match_effect_query(sql_question)
|
|
104
|
+
sql_cache = _make_query_sql_cache(self.config)
|
|
105
|
+
cached_sql = sql_cache.get(question=sql_question, schema_version=schema_version) if sql_cache is not None else None
|
|
106
|
+
if cached_sql is not None and not sql_satisfies_time_range(cached_sql, sql_question, schema):
|
|
107
|
+
cached_sql = None
|
|
108
|
+
if cached_sql is not None and is_low_quality_sql_for_question(sql_question, cached_sql):
|
|
109
|
+
sql_cache.delete(question=sql_question, schema_version=schema_version)
|
|
110
|
+
cached_sql = None
|
|
111
|
+
if cached_sql is not None and validate_sql_against_schema(cached_sql, schema):
|
|
112
|
+
sql_cache.delete(question=sql_question, schema_version=schema_version)
|
|
113
|
+
cached_sql = None
|
|
114
|
+
if cached_sql is not None:
|
|
115
|
+
generated = GeneratedSql(sql=cached_sql, tables=[], placeholders=[])
|
|
87
116
|
if sampler is not None:
|
|
88
|
-
schema_context =
|
|
89
|
-
generator =
|
|
90
|
-
|
|
117
|
+
schema_context = None
|
|
118
|
+
generator: SamplingSqlGenerator | None = None
|
|
119
|
+
if cached_sql is None:
|
|
120
|
+
schema_context = retrieve_schema_context(schema, sql_question)
|
|
121
|
+
generator = SamplingSqlGenerator(
|
|
122
|
+
sampler=sampler,
|
|
123
|
+
default_limit=self.config.sql.default_limit,
|
|
124
|
+
max_limit=self.config.sql.max_limit,
|
|
125
|
+
)
|
|
126
|
+
if fast_mode or prefer_rule_based:
|
|
127
|
+
try:
|
|
128
|
+
generated = _generate_rule_based_sql(
|
|
129
|
+
question=sql_question,
|
|
130
|
+
schema=schema,
|
|
131
|
+
default_limit=self.config.sql.default_limit,
|
|
132
|
+
max_limit=self.config.sql.max_limit,
|
|
133
|
+
)
|
|
134
|
+
except SqlGenerationError:
|
|
135
|
+
generated, repaired_sql = _generate_with_sampler(
|
|
136
|
+
generator=generator,
|
|
137
|
+
question=sql_question,
|
|
138
|
+
schema_context=schema_context,
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
repaired_sql = None
|
|
142
|
+
else:
|
|
143
|
+
generated, repaired_sql = _generate_with_sampler(
|
|
144
|
+
generator=generator,
|
|
145
|
+
question=sql_question,
|
|
146
|
+
schema_context=schema_context,
|
|
147
|
+
)
|
|
148
|
+
if generated.sql == "" and repaired_sql is None:
|
|
149
|
+
return _query_only_result()
|
|
150
|
+
if fast_mode and generated.sql == "" and repaired_sql is None:
|
|
151
|
+
return _query_only_result()
|
|
152
|
+
if not fast_mode and generated.sql == "" and repaired_sql is None:
|
|
153
|
+
return _query_only_result()
|
|
154
|
+
elif cached_sql is None:
|
|
155
|
+
schema_context = None
|
|
156
|
+
generated = _generate_rule_based_sql(
|
|
157
|
+
question=sql_question,
|
|
158
|
+
schema=schema,
|
|
91
159
|
default_limit=self.config.sql.default_limit,
|
|
92
160
|
max_limit=self.config.sql.max_limit,
|
|
93
161
|
)
|
|
94
|
-
generated = generator.generate(
|
|
95
|
-
question=sql_question,
|
|
96
|
-
schema_context=schema_context,
|
|
97
|
-
scope_hints=scope_hints,
|
|
98
|
-
)
|
|
99
162
|
else:
|
|
100
163
|
schema_context = None
|
|
101
|
-
generator =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
generator = None
|
|
165
|
+
candidate_sql = repaired_sql or _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
|
|
166
|
+
if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
|
|
167
|
+
if sampler is None or schema_context is None or generator is None:
|
|
168
|
+
return _query_only_result()
|
|
169
|
+
try:
|
|
170
|
+
repaired = generator.repair(
|
|
171
|
+
question=sql_question,
|
|
172
|
+
original_sql=candidate_sql,
|
|
173
|
+
validation_error={
|
|
174
|
+
"code": "MISSING_TIME_RANGE_FILTER",
|
|
175
|
+
"message": "用户问题包含时间范围,SQL 必须在 WHERE 条件中直接包含对应时间字段过滤。",
|
|
176
|
+
},
|
|
177
|
+
schema_context=schema_context,
|
|
178
|
+
)
|
|
179
|
+
except SqlGenerationError:
|
|
180
|
+
return _query_only_result()
|
|
181
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
182
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
183
|
+
candidate_sql = repaired_sql
|
|
184
|
+
if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
|
|
185
|
+
return _query_only_result()
|
|
186
|
+
if is_low_quality_sql_for_question(sql_question, candidate_sql):
|
|
187
|
+
if sampler is None or schema_context is None or generator is None:
|
|
188
|
+
return _query_only_result()
|
|
189
|
+
try:
|
|
190
|
+
repaired = generator.repair(
|
|
191
|
+
question=sql_question,
|
|
192
|
+
original_sql=candidate_sql,
|
|
193
|
+
validation_error={
|
|
194
|
+
"code": "LOW_QUALITY_SQL",
|
|
195
|
+
"message": "SQL 与问题意图不匹配,报名/工单/候选人明细不能退化成品牌列表,且必须使用正确工单表、状态和岗位关联。",
|
|
196
|
+
},
|
|
197
|
+
schema_context=schema_context,
|
|
198
|
+
)
|
|
199
|
+
except SqlGenerationError:
|
|
200
|
+
return _query_only_result()
|
|
201
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
202
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
203
|
+
candidate_sql = repaired_sql
|
|
204
|
+
if is_low_quality_sql_for_question(sql_question, candidate_sql):
|
|
205
|
+
return _query_only_result()
|
|
206
|
+
schema_issues = validate_sql_against_schema(candidate_sql, schema)
|
|
207
|
+
if schema_issues:
|
|
208
|
+
if sampler is None or schema_context is None or generator is None:
|
|
209
|
+
return _query_only_result()
|
|
210
|
+
try:
|
|
211
|
+
repaired = generator.repair(
|
|
212
|
+
question=sql_question,
|
|
213
|
+
original_sql=candidate_sql,
|
|
214
|
+
validation_error={
|
|
215
|
+
"code": schema_issues[0].code,
|
|
216
|
+
"message": schema_issues[0].message,
|
|
217
|
+
},
|
|
218
|
+
schema_context=schema_context,
|
|
219
|
+
)
|
|
220
|
+
except SqlGenerationError:
|
|
221
|
+
return _query_only_result()
|
|
222
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
223
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
224
|
+
candidate_sql = repaired_sql
|
|
225
|
+
if validate_sql_against_schema(candidate_sql, schema):
|
|
226
|
+
return _query_only_result()
|
|
227
|
+
try:
|
|
228
|
+
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
229
|
+
except SqlGenerationError:
|
|
230
|
+
if sampler is None:
|
|
231
|
+
return _query_only_result()
|
|
232
|
+
if not fast_mode:
|
|
233
|
+
return _query_only_result()
|
|
234
|
+
try:
|
|
235
|
+
generated = _generate_rule_based_sql(
|
|
236
|
+
question=sql_question,
|
|
237
|
+
schema=schema,
|
|
238
|
+
default_limit=self.config.sql.default_limit,
|
|
239
|
+
max_limit=self.config.sql.max_limit,
|
|
240
|
+
)
|
|
241
|
+
repaired_sql = None
|
|
242
|
+
candidate_sql = _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
|
|
243
|
+
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
244
|
+
except SqlGenerationError:
|
|
245
|
+
return _query_only_result()
|
|
246
|
+
normalized_sql = self._validate_sql(context, user_question, candidate_sql)
|
|
247
|
+
if sampler is not None and normalized_sql is None and schema_context is not None and generator is not None:
|
|
248
|
+
try:
|
|
249
|
+
repaired = generator.repair(
|
|
250
|
+
question=sql_question,
|
|
251
|
+
original_sql=generated.sql,
|
|
252
|
+
validation_error=self._last_validation_error,
|
|
253
|
+
schema_context=schema_context,
|
|
254
|
+
)
|
|
255
|
+
except SqlGenerationError:
|
|
256
|
+
try:
|
|
257
|
+
if not fast_mode:
|
|
258
|
+
return _query_only_result()
|
|
259
|
+
generated = _generate_rule_based_sql(
|
|
260
|
+
question=sql_question,
|
|
261
|
+
schema=schema,
|
|
262
|
+
default_limit=self.config.sql.default_limit,
|
|
263
|
+
max_limit=self.config.sql.max_limit,
|
|
264
|
+
)
|
|
265
|
+
repaired_sql = None
|
|
266
|
+
candidate_sql = _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
|
|
267
|
+
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
268
|
+
normalized_sql = self._validate_sql(context, user_question, candidate_sql, raise_on_error=True)
|
|
269
|
+
except SqlGenerationError:
|
|
270
|
+
return _query_only_result()
|
|
271
|
+
else:
|
|
272
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
273
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
274
|
+
try:
|
|
275
|
+
enforce_sql_rules(repaired_sql, max_limit=self.config.sql.max_limit)
|
|
276
|
+
except SqlGenerationError:
|
|
277
|
+
return _query_only_result()
|
|
278
|
+
normalized_sql = self._validate_sql(context, user_question, repaired_sql, raise_on_error=True)
|
|
124
279
|
if normalized_sql is None:
|
|
125
280
|
raise RuntimeError("SQL validation failed")
|
|
126
|
-
|
|
281
|
+
if sql_cache is not None and cached_sql is None and not is_low_quality_sql_for_question(sql_question, normalized_sql):
|
|
282
|
+
sql_cache.set(question=sql_question, schema_version=schema_version, sql=normalized_sql)
|
|
283
|
+
result = self._execute_sql(context, user_question, normalized_sql)
|
|
284
|
+
answer = render_result(user_question, result, result_format)
|
|
285
|
+
if _should_append_default_limit_notice(
|
|
286
|
+
question=sql_question,
|
|
287
|
+
result=result,
|
|
288
|
+
default_limit=self.config.sql.default_limit,
|
|
289
|
+
):
|
|
290
|
+
answer = _append_default_limit_notice(
|
|
291
|
+
answer,
|
|
292
|
+
user_question,
|
|
293
|
+
result_format,
|
|
294
|
+
_default_limit_notice(self.config.sql.default_limit),
|
|
295
|
+
)
|
|
127
296
|
return QueryResult(
|
|
128
|
-
answer=
|
|
297
|
+
answer=answer,
|
|
129
298
|
generated_sql=generated.sql,
|
|
130
299
|
normalized_sql=normalized_sql,
|
|
131
300
|
repaired_sql=repaired_sql,
|
|
@@ -140,7 +309,6 @@ class SpongeAgent:
|
|
|
140
309
|
schema_response = self.mcp_client.get_schema(
|
|
141
310
|
trace_id=trace_id,
|
|
142
311
|
schema_version=cached_version,
|
|
143
|
-
refresh_reason="startup",
|
|
144
312
|
)
|
|
145
313
|
except Exception:
|
|
146
314
|
if cached_schema is not None:
|
|
@@ -166,13 +334,6 @@ class SpongeAgent:
|
|
|
166
334
|
)
|
|
167
335
|
return cached_schema
|
|
168
336
|
|
|
169
|
-
if schema_response.get("changed") is False and cached_schema is None:
|
|
170
|
-
schema_response = self.mcp_client.get_schema(
|
|
171
|
-
trace_id=new_trace_id("trace_schema"),
|
|
172
|
-
schema_version=None,
|
|
173
|
-
refresh_reason="startup",
|
|
174
|
-
)
|
|
175
|
-
|
|
176
337
|
if "tables" in schema_response:
|
|
177
338
|
self.schema_cache.save(schema_response)
|
|
178
339
|
self.audit_logger.log(
|
|
@@ -189,26 +350,6 @@ class SpongeAgent:
|
|
|
189
350
|
return cached_schema
|
|
190
351
|
raise RuntimeError("Sponge get_schema did not return tables and no local schema cache exists")
|
|
191
352
|
|
|
192
|
-
def _get_allowed_context(self, context: UserContext, question: str) -> dict[str, Any]:
|
|
193
|
-
trace_id = new_trace_id()
|
|
194
|
-
result = self.mcp_client.get_allowed_context(
|
|
195
|
-
user_id=context.user_id,
|
|
196
|
-
trace_id=trace_id,
|
|
197
|
-
session_id=context.session_id,
|
|
198
|
-
device_id=context.device_id,
|
|
199
|
-
question=question,
|
|
200
|
-
)
|
|
201
|
-
self.audit_logger.log(
|
|
202
|
-
{
|
|
203
|
-
"traceId": trace_id,
|
|
204
|
-
"sessionId": context.session_id,
|
|
205
|
-
"userId": context.user_id,
|
|
206
|
-
"question": question,
|
|
207
|
-
"tool": "get_allowed_context",
|
|
208
|
-
}
|
|
209
|
-
)
|
|
210
|
-
return result
|
|
211
|
-
|
|
212
353
|
def _validate_sql(
|
|
213
354
|
self,
|
|
214
355
|
context: UserContext,
|
|
@@ -218,10 +359,11 @@ class SpongeAgent:
|
|
|
218
359
|
raise_on_error: bool = False,
|
|
219
360
|
) -> str | None:
|
|
220
361
|
trace_id = new_trace_id()
|
|
362
|
+
self._last_validation_trace_id = None
|
|
221
363
|
result = self.mcp_client.validate_sql(
|
|
222
|
-
user_id=context.user_id,
|
|
223
364
|
trace_id=trace_id,
|
|
224
365
|
session_id=context.session_id,
|
|
366
|
+
question=question,
|
|
225
367
|
sql=sql,
|
|
226
368
|
)
|
|
227
369
|
self._last_validation_result = result
|
|
@@ -229,7 +371,6 @@ class SpongeAgent:
|
|
|
229
371
|
{
|
|
230
372
|
"traceId": trace_id,
|
|
231
373
|
"sessionId": context.session_id,
|
|
232
|
-
"userId": context.user_id,
|
|
233
374
|
"question": question,
|
|
234
375
|
"tool": "validate_sql",
|
|
235
376
|
"sqlHash": sql_hash(sql),
|
|
@@ -244,14 +385,17 @@ class SpongeAgent:
|
|
|
244
385
|
if raise_on_error:
|
|
245
386
|
raise RuntimeError(f"SQL validation failed: {code}")
|
|
246
387
|
return None
|
|
388
|
+
self._last_validation_trace_id = trace_id
|
|
247
389
|
return str(result.get("normalizedSql") or sql)
|
|
248
390
|
|
|
249
391
|
def _execute_sql(self, context: UserContext, question: str, sql: str) -> dict[str, Any]:
|
|
250
|
-
|
|
392
|
+
if self._last_validation_trace_id is None:
|
|
393
|
+
raise RuntimeError("SQL execution requires a prior successful validate_sql trace")
|
|
394
|
+
trace_id = self._last_validation_trace_id
|
|
251
395
|
result = self.mcp_client.execute_sql(
|
|
252
|
-
user_id=context.user_id,
|
|
253
396
|
trace_id=trace_id,
|
|
254
397
|
session_id=context.session_id,
|
|
398
|
+
question=question,
|
|
255
399
|
sql=sql,
|
|
256
400
|
timeout_ms=self.config.sponge_mcp.timeout_ms,
|
|
257
401
|
)
|
|
@@ -259,7 +403,6 @@ class SpongeAgent:
|
|
|
259
403
|
{
|
|
260
404
|
"traceId": trace_id,
|
|
261
405
|
"sessionId": context.session_id,
|
|
262
|
-
"userId": context.user_id,
|
|
263
406
|
"question": question,
|
|
264
407
|
"tool": "execute_sql",
|
|
265
408
|
"sqlHash": sql_hash(sql),
|
|
@@ -278,11 +421,234 @@ class SpongeAgent:
|
|
|
278
421
|
def _strip_result_format_instruction(question: str) -> str:
|
|
279
422
|
cleaned = question
|
|
280
423
|
patterns = [
|
|
281
|
-
r"
|
|
424
|
+
r"^\s*返回\s*(?:json|yaml|html|markdown|md)\s*格式\s*的?",
|
|
425
|
+
r"[,,。;;]?\s*输出\s*(?:json|yaml|html|markdown|md)\s*(?:格式)?",
|
|
282
426
|
r"[,,。;;]?\s*用\s*表格\s*展示",
|
|
283
|
-
r"[,,。;;]?\s*以\s*(?:yaml|html|markdown|md)\s*(?:格式)?\s*输出",
|
|
284
|
-
r"[,,。;;]?\s*return\s+(?:yaml|html|markdown|md)",
|
|
427
|
+
r"[,,。;;]?\s*以\s*(?:json|yaml|html|markdown|md)\s*(?:格式)?\s*输出",
|
|
428
|
+
r"[,,。;;]?\s*return\s+(?:json|yaml|html|markdown|md)",
|
|
285
429
|
]
|
|
286
430
|
for pattern in patterns:
|
|
287
431
|
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
|
|
288
432
|
return cleaned.strip() or question
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _query_only_result() -> QueryResult:
|
|
436
|
+
return QueryResult(
|
|
437
|
+
answer="目前只能查询",
|
|
438
|
+
generated_sql="",
|
|
439
|
+
normalized_sql="",
|
|
440
|
+
repaired_sql=None,
|
|
441
|
+
validation={},
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _business_metric_clarification_result(answer: str) -> QueryResult:
|
|
446
|
+
return QueryResult(
|
|
447
|
+
answer=answer,
|
|
448
|
+
generated_sql="",
|
|
449
|
+
normalized_sql="",
|
|
450
|
+
repaired_sql=None,
|
|
451
|
+
validation={},
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _generate_with_sampler(
|
|
456
|
+
*,
|
|
457
|
+
generator: SamplingSqlGenerator,
|
|
458
|
+
question: str,
|
|
459
|
+
schema_context: Any,
|
|
460
|
+
) -> tuple[GeneratedSql, str | None]:
|
|
461
|
+
try:
|
|
462
|
+
return (
|
|
463
|
+
generator.generate(
|
|
464
|
+
question=question,
|
|
465
|
+
schema_context=schema_context,
|
|
466
|
+
),
|
|
467
|
+
None,
|
|
468
|
+
)
|
|
469
|
+
except SqlGenerationError as sampling_exc:
|
|
470
|
+
try:
|
|
471
|
+
repaired = generator.repair(
|
|
472
|
+
question=question,
|
|
473
|
+
original_sql="",
|
|
474
|
+
validation_error={"code": type(sampling_exc).__name__, "message": str(sampling_exc)},
|
|
475
|
+
schema_context=schema_context,
|
|
476
|
+
)
|
|
477
|
+
except SqlGenerationError:
|
|
478
|
+
return GeneratedSql(sql="", tables=[], placeholders=[]), None
|
|
479
|
+
return (
|
|
480
|
+
GeneratedSql(sql="", tables=[], placeholders=[]),
|
|
481
|
+
_apply_default_limit(repaired.sql, question, generator.default_limit),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _generate_rule_based_sql(
|
|
486
|
+
*,
|
|
487
|
+
question: str,
|
|
488
|
+
schema: dict[str, Any],
|
|
489
|
+
default_limit: int,
|
|
490
|
+
max_limit: int,
|
|
491
|
+
) -> GeneratedSql:
|
|
492
|
+
generator = RuleBasedSqlGenerator(
|
|
493
|
+
default_limit=default_limit,
|
|
494
|
+
max_limit=max_limit,
|
|
495
|
+
)
|
|
496
|
+
return generator.generate(
|
|
497
|
+
question=question,
|
|
498
|
+
schema=schema,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _generate_sampling_fallback_sql(
|
|
503
|
+
*,
|
|
504
|
+
question: str,
|
|
505
|
+
schema: dict[str, Any],
|
|
506
|
+
default_limit: int,
|
|
507
|
+
max_limit: int,
|
|
508
|
+
) -> GeneratedSql:
|
|
509
|
+
if re.search(r"(岗位|职位|招聘|在招)", question) is None:
|
|
510
|
+
raise SqlGenerationError("No sampling fallback matched the question")
|
|
511
|
+
return _generate_rule_based_sql(
|
|
512
|
+
question=question,
|
|
513
|
+
schema=schema,
|
|
514
|
+
default_limit=default_limit,
|
|
515
|
+
max_limit=max_limit,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _make_query_sql_cache(config: AppConfig) -> QuerySqlCache | None:
|
|
520
|
+
if config.sql.query_cache_path is None:
|
|
521
|
+
return None
|
|
522
|
+
return QuerySqlCache(config.sql.query_cache_path, config.sql.query_cache_ttl_minutes)
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _is_mutation_request(question: str) -> bool:
|
|
526
|
+
mutation_match = re.search(
|
|
527
|
+
r"(新增|新建|创建|添加|插入|录入|修改|更新|编辑|变更|调整|删除|移除|作废|发布|下架|保存|提交|审批|导入|同步|insert|update|delete|create|drop|alter|truncate)",
|
|
528
|
+
question,
|
|
529
|
+
flags=re.IGNORECASE,
|
|
530
|
+
)
|
|
531
|
+
if mutation_match is None:
|
|
532
|
+
return False
|
|
533
|
+
if _is_mutation_analytics_request(question):
|
|
534
|
+
return False
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _is_mutation_analytics_request(question: str) -> bool:
|
|
539
|
+
return bool(
|
|
540
|
+
re.search(r"(统计|查询|查看|看看|多少|几次|次数|数量|count|列表|明细|记录|日志|历史|趋势|分组)", question, flags=re.IGNORECASE)
|
|
541
|
+
and re.search(r"(新增|新建|创建|添加|插入|录入|修改|更新|编辑|变更|调整|删除|移除|作废|发布|下架|保存|提交|审批|导入|同步)", question)
|
|
542
|
+
and re.search(r"(次数|数量|count|列表|明细|记录|日志|历史|趋势|分组|显示|展示|返回|包含|字段)", question, flags=re.IGNORECASE)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _business_metric_clarification(question: str, schema: dict[str, Any]) -> str | None:
|
|
547
|
+
if is_recruiting_match_effect_query(question):
|
|
548
|
+
return None
|
|
549
|
+
metric_terms = _ambiguous_business_metric_terms(question)
|
|
550
|
+
if not metric_terms:
|
|
551
|
+
return None
|
|
552
|
+
if _schema_has_metric_definition(question, schema, metric_terms):
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
lines = [
|
|
556
|
+
"这个查询涉及业务口径,我暂时不直接生成 SQL,避免结果口径不准。",
|
|
557
|
+
"",
|
|
558
|
+
"请先确认:",
|
|
559
|
+
f"1. 指标定义:“{'、'.join(metric_terms)}”具体包含哪些数据?",
|
|
560
|
+
"2. 时间口径:按哪个时间字段统计?",
|
|
561
|
+
"3. 状态口径:哪些状态算有效/成功/完成/异常?",
|
|
562
|
+
"4. 聚合维度:需要按什么维度展示?",
|
|
563
|
+
]
|
|
564
|
+
return "\n".join(lines)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _ambiguous_business_metric_terms(question: str) -> list[str]:
|
|
568
|
+
terms = (
|
|
569
|
+
"漏斗",
|
|
570
|
+
"转化率",
|
|
571
|
+
"转化",
|
|
572
|
+
"有效",
|
|
573
|
+
"活跃",
|
|
574
|
+
"成功率",
|
|
575
|
+
"留存",
|
|
576
|
+
"履约",
|
|
577
|
+
"异常",
|
|
578
|
+
"质量",
|
|
579
|
+
"效率",
|
|
580
|
+
"趋势",
|
|
581
|
+
"健康度",
|
|
582
|
+
"达成率",
|
|
583
|
+
"完成率",
|
|
584
|
+
)
|
|
585
|
+
return [term for term in terms if term in question]
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _schema_has_metric_definition(question: str, schema: dict[str, Any], metric_terms: list[str]) -> bool:
|
|
589
|
+
metric_texts: list[str] = []
|
|
590
|
+
for metric in schema.get("metrics") or []:
|
|
591
|
+
if not isinstance(metric, dict):
|
|
592
|
+
continue
|
|
593
|
+
parts: list[str] = []
|
|
594
|
+
for key in ("name", "term", "displayName", "definition", "description", "aggregation"):
|
|
595
|
+
value = metric.get(key)
|
|
596
|
+
if isinstance(value, str):
|
|
597
|
+
parts.append(value)
|
|
598
|
+
filters = metric.get("filters")
|
|
599
|
+
if isinstance(filters, list):
|
|
600
|
+
parts.extend(str(item) for item in filters)
|
|
601
|
+
metric_text = " ".join(parts)
|
|
602
|
+
if metric_text:
|
|
603
|
+
metric_texts.append(metric_text)
|
|
604
|
+
name = metric.get("name")
|
|
605
|
+
if isinstance(name, str) and name and name in question:
|
|
606
|
+
return True
|
|
607
|
+
|
|
608
|
+
return any(term in metric_text for term in metric_terms for metric_text in metric_texts)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _apply_default_limit(sql: str, question: str, default_limit: int) -> str:
|
|
612
|
+
if _question_requests_limit(question):
|
|
613
|
+
return sql
|
|
614
|
+
return re.sub(r"\blimit\s+\d+\b", f"LIMIT {default_limit}", sql, count=1, flags=re.IGNORECASE)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _question_requests_limit(question: str) -> bool:
|
|
618
|
+
lowered = question.lower()
|
|
619
|
+
if re.search(r"\b(?:limit|top)\s+\d+\b", lowered):
|
|
620
|
+
return True
|
|
621
|
+
if re.search(r"(?:返回|展示|查询|列出|给我|取)\s*\d+\s*(?:条|行|个|项|家|名)", question):
|
|
622
|
+
return True
|
|
623
|
+
return bool(re.search(r"前\s*\d+\s*(?:条|行|个|项|家|名)?", question))
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _default_limit_notice(default_limit: int) -> str:
|
|
627
|
+
return f"本次为默认查询 {default_limit} 条数据;如果需要查询更多数据,可以修改查询口径。"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _should_append_default_limit_notice(*, question: str, result: dict[str, Any], default_limit: int) -> bool:
|
|
631
|
+
if _question_requests_limit(question):
|
|
632
|
+
return False
|
|
633
|
+
try:
|
|
634
|
+
row_count = int(result.get("rowCount", len(result.get("rows", []))))
|
|
635
|
+
except (TypeError, ValueError):
|
|
636
|
+
return False
|
|
637
|
+
return row_count == default_limit
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _append_default_limit_notice(answer: str, question: str, result_format: str | None, notice: str) -> str:
|
|
641
|
+
output_format = normalize_result_format(question, result_format)
|
|
642
|
+
if output_format == "json":
|
|
643
|
+
try:
|
|
644
|
+
payload = json.loads(answer)
|
|
645
|
+
except json.JSONDecodeError:
|
|
646
|
+
return f"{answer}\n\n{notice}"
|
|
647
|
+
if isinstance(payload, dict):
|
|
648
|
+
payload["notice"] = notice
|
|
649
|
+
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
650
|
+
if output_format == "yaml":
|
|
651
|
+
return f"{answer.rstrip()}\nnotice: {notice}\n"
|
|
652
|
+
if output_format == "html":
|
|
653
|
+
return f"{answer}<p>{notice}</p>"
|
|
654
|
+
return f"{answer}\n\n{notice}"
|
|
@@ -29,6 +29,8 @@ class SqlConfig:
|
|
|
29
29
|
max_repair_attempts: int
|
|
30
30
|
default_limit: int
|
|
31
31
|
max_limit: int
|
|
32
|
+
query_cache_path: Path | None = None
|
|
33
|
+
query_cache_ttl_minutes: int = 30
|
|
32
34
|
|
|
33
35
|
|
|
34
36
|
@dataclass(frozen=True)
|
|
@@ -59,8 +61,10 @@ def load_config() -> AppConfig:
|
|
|
59
61
|
),
|
|
60
62
|
sql=SqlConfig(
|
|
61
63
|
max_repair_attempts=2,
|
|
62
|
-
default_limit=
|
|
64
|
+
default_limit=50,
|
|
63
65
|
max_limit=500,
|
|
66
|
+
query_cache_path=PACKAGE_ROOT / "data/query-sql-cache.json",
|
|
67
|
+
query_cache_ttl_minutes=30,
|
|
64
68
|
),
|
|
65
69
|
audit=AuditConfig(
|
|
66
70
|
enabled=True,
|
|
@@ -6,9 +6,7 @@ from uuid import uuid4
|
|
|
6
6
|
|
|
7
7
|
@dataclass(frozen=True)
|
|
8
8
|
class UserContext:
|
|
9
|
-
user_id: int
|
|
10
9
|
session_id: str
|
|
11
|
-
device_id: str | None = None
|
|
12
10
|
|
|
13
11
|
|
|
14
12
|
def new_trace_id(prefix: str = "trace") -> str:
|
|
@@ -17,4 +15,3 @@ def new_trace_id(prefix: str = "trace") -> str:
|
|
|
17
15
|
|
|
18
16
|
def new_session_id() -> str:
|
|
19
17
|
return f"session_{uuid4().hex}"
|
|
20
|
-
|