@roll-agent/octopus-agent 0.0.1 → 0.0.2
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 +370 -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,236 @@ 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
|
+
schema_version = str(schema.get("schemaVersion")) if schema.get("schemaVersion") is not None else None
|
|
99
|
+
repaired_sql: str | None = None
|
|
100
|
+
prefer_rule_based = is_recruiting_match_effect_query(sql_question)
|
|
101
|
+
sql_cache = _make_query_sql_cache(self.config)
|
|
102
|
+
cached_sql = sql_cache.get(question=sql_question, schema_version=schema_version) if sql_cache is not None else None
|
|
103
|
+
if cached_sql is not None and not sql_satisfies_time_range(cached_sql, sql_question, schema):
|
|
104
|
+
cached_sql = None
|
|
105
|
+
if cached_sql is not None and is_low_quality_sql_for_question(sql_question, cached_sql):
|
|
106
|
+
sql_cache.delete(question=sql_question, schema_version=schema_version)
|
|
107
|
+
cached_sql = None
|
|
108
|
+
if cached_sql is not None and validate_sql_against_schema(cached_sql, schema):
|
|
109
|
+
sql_cache.delete(question=sql_question, schema_version=schema_version)
|
|
110
|
+
cached_sql = None
|
|
111
|
+
if cached_sql is not None:
|
|
112
|
+
generated = GeneratedSql(sql=cached_sql, tables=[], placeholders=[])
|
|
87
113
|
if sampler is not None:
|
|
88
|
-
schema_context =
|
|
89
|
-
generator =
|
|
90
|
-
|
|
114
|
+
schema_context = None
|
|
115
|
+
generator: SamplingSqlGenerator | None = None
|
|
116
|
+
if cached_sql is None:
|
|
117
|
+
schema_context = retrieve_schema_context(schema, sql_question)
|
|
118
|
+
generator = SamplingSqlGenerator(
|
|
119
|
+
sampler=sampler,
|
|
120
|
+
default_limit=self.config.sql.default_limit,
|
|
121
|
+
max_limit=self.config.sql.max_limit,
|
|
122
|
+
)
|
|
123
|
+
if fast_mode or prefer_rule_based:
|
|
124
|
+
try:
|
|
125
|
+
generated = _generate_rule_based_sql(
|
|
126
|
+
question=sql_question,
|
|
127
|
+
schema=schema,
|
|
128
|
+
default_limit=self.config.sql.default_limit,
|
|
129
|
+
max_limit=self.config.sql.max_limit,
|
|
130
|
+
)
|
|
131
|
+
except SqlGenerationError:
|
|
132
|
+
generated, repaired_sql = _generate_with_sampler(
|
|
133
|
+
generator=generator,
|
|
134
|
+
question=sql_question,
|
|
135
|
+
schema_context=schema_context,
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
repaired_sql = None
|
|
139
|
+
else:
|
|
140
|
+
generated, repaired_sql = _generate_with_sampler(
|
|
141
|
+
generator=generator,
|
|
142
|
+
question=sql_question,
|
|
143
|
+
schema_context=schema_context,
|
|
144
|
+
)
|
|
145
|
+
if generated.sql == "" and repaired_sql is None:
|
|
146
|
+
return _query_only_result()
|
|
147
|
+
if fast_mode and generated.sql == "" and repaired_sql is None:
|
|
148
|
+
return _query_only_result()
|
|
149
|
+
if not fast_mode and generated.sql == "" and repaired_sql is None:
|
|
150
|
+
return _query_only_result()
|
|
151
|
+
elif cached_sql is None:
|
|
152
|
+
schema_context = None
|
|
153
|
+
generated = _generate_rule_based_sql(
|
|
154
|
+
question=sql_question,
|
|
155
|
+
schema=schema,
|
|
91
156
|
default_limit=self.config.sql.default_limit,
|
|
92
157
|
max_limit=self.config.sql.max_limit,
|
|
93
158
|
)
|
|
94
|
-
generated = generator.generate(
|
|
95
|
-
question=sql_question,
|
|
96
|
-
schema_context=schema_context,
|
|
97
|
-
scope_hints=scope_hints,
|
|
98
|
-
)
|
|
99
159
|
else:
|
|
100
160
|
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
|
-
|
|
161
|
+
generator = None
|
|
162
|
+
candidate_sql = repaired_sql or _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
|
|
163
|
+
if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
|
|
164
|
+
if sampler is None or schema_context is None or generator is None:
|
|
165
|
+
return _query_only_result()
|
|
166
|
+
try:
|
|
167
|
+
repaired = generator.repair(
|
|
168
|
+
question=sql_question,
|
|
169
|
+
original_sql=candidate_sql,
|
|
170
|
+
validation_error={
|
|
171
|
+
"code": "MISSING_TIME_RANGE_FILTER",
|
|
172
|
+
"message": "用户问题包含时间范围,SQL 必须在 WHERE 条件中直接包含对应时间字段过滤。",
|
|
173
|
+
},
|
|
174
|
+
schema_context=schema_context,
|
|
175
|
+
)
|
|
176
|
+
except SqlGenerationError:
|
|
177
|
+
return _query_only_result()
|
|
178
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
179
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
180
|
+
candidate_sql = repaired_sql
|
|
181
|
+
if not sql_satisfies_time_range(candidate_sql, sql_question, schema):
|
|
182
|
+
return _query_only_result()
|
|
183
|
+
if is_low_quality_sql_for_question(sql_question, candidate_sql):
|
|
184
|
+
if sampler is None or schema_context is None or generator is None:
|
|
185
|
+
return _query_only_result()
|
|
186
|
+
try:
|
|
187
|
+
repaired = generator.repair(
|
|
188
|
+
question=sql_question,
|
|
189
|
+
original_sql=candidate_sql,
|
|
190
|
+
validation_error={
|
|
191
|
+
"code": "LOW_QUALITY_SQL",
|
|
192
|
+
"message": "SQL 与问题意图不匹配,报名/工单/候选人明细不能退化成品牌列表,且必须使用正确工单表、状态和岗位关联。",
|
|
193
|
+
},
|
|
194
|
+
schema_context=schema_context,
|
|
195
|
+
)
|
|
196
|
+
except SqlGenerationError:
|
|
197
|
+
return _query_only_result()
|
|
198
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
199
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
200
|
+
candidate_sql = repaired_sql
|
|
201
|
+
if is_low_quality_sql_for_question(sql_question, candidate_sql):
|
|
202
|
+
return _query_only_result()
|
|
203
|
+
schema_issues = validate_sql_against_schema(candidate_sql, schema)
|
|
204
|
+
if schema_issues:
|
|
205
|
+
if sampler is None or schema_context is None or generator is None:
|
|
206
|
+
return _query_only_result()
|
|
207
|
+
try:
|
|
208
|
+
repaired = generator.repair(
|
|
209
|
+
question=sql_question,
|
|
210
|
+
original_sql=candidate_sql,
|
|
211
|
+
validation_error={
|
|
212
|
+
"code": schema_issues[0].code,
|
|
213
|
+
"message": schema_issues[0].message,
|
|
214
|
+
},
|
|
215
|
+
schema_context=schema_context,
|
|
216
|
+
)
|
|
217
|
+
except SqlGenerationError:
|
|
218
|
+
return _query_only_result()
|
|
219
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
220
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
221
|
+
candidate_sql = repaired_sql
|
|
222
|
+
if validate_sql_against_schema(candidate_sql, schema):
|
|
223
|
+
return _query_only_result()
|
|
224
|
+
try:
|
|
225
|
+
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
226
|
+
except SqlGenerationError:
|
|
227
|
+
if sampler is None:
|
|
228
|
+
return _query_only_result()
|
|
229
|
+
if not fast_mode:
|
|
230
|
+
return _query_only_result()
|
|
231
|
+
try:
|
|
232
|
+
generated = _generate_rule_based_sql(
|
|
233
|
+
question=sql_question,
|
|
234
|
+
schema=schema,
|
|
235
|
+
default_limit=self.config.sql.default_limit,
|
|
236
|
+
max_limit=self.config.sql.max_limit,
|
|
237
|
+
)
|
|
238
|
+
repaired_sql = None
|
|
239
|
+
candidate_sql = _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
|
|
240
|
+
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
241
|
+
except SqlGenerationError:
|
|
242
|
+
return _query_only_result()
|
|
243
|
+
normalized_sql = self._validate_sql(context, user_question, candidate_sql)
|
|
244
|
+
if sampler is not None and normalized_sql is None and schema_context is not None and generator is not None:
|
|
245
|
+
try:
|
|
246
|
+
repaired = generator.repair(
|
|
247
|
+
question=sql_question,
|
|
248
|
+
original_sql=generated.sql,
|
|
249
|
+
validation_error=self._last_validation_error,
|
|
250
|
+
schema_context=schema_context,
|
|
251
|
+
)
|
|
252
|
+
except SqlGenerationError:
|
|
253
|
+
try:
|
|
254
|
+
if not fast_mode:
|
|
255
|
+
return _query_only_result()
|
|
256
|
+
generated = _generate_rule_based_sql(
|
|
257
|
+
question=sql_question,
|
|
258
|
+
schema=schema,
|
|
259
|
+
default_limit=self.config.sql.default_limit,
|
|
260
|
+
max_limit=self.config.sql.max_limit,
|
|
261
|
+
)
|
|
262
|
+
repaired_sql = None
|
|
263
|
+
candidate_sql = _apply_default_limit(generated.sql, sql_question, self.config.sql.default_limit)
|
|
264
|
+
enforce_sql_rules(candidate_sql, max_limit=self.config.sql.max_limit)
|
|
265
|
+
normalized_sql = self._validate_sql(context, user_question, candidate_sql, raise_on_error=True)
|
|
266
|
+
except SqlGenerationError:
|
|
267
|
+
return _query_only_result()
|
|
268
|
+
else:
|
|
269
|
+
generated = GeneratedSql(sql="", tables=[], placeholders=[])
|
|
270
|
+
repaired_sql = _apply_default_limit(repaired.sql, sql_question, self.config.sql.default_limit)
|
|
271
|
+
try:
|
|
272
|
+
enforce_sql_rules(repaired_sql, max_limit=self.config.sql.max_limit)
|
|
273
|
+
except SqlGenerationError:
|
|
274
|
+
return _query_only_result()
|
|
275
|
+
normalized_sql = self._validate_sql(context, user_question, repaired_sql, raise_on_error=True)
|
|
124
276
|
if normalized_sql is None:
|
|
125
277
|
raise RuntimeError("SQL validation failed")
|
|
126
|
-
|
|
278
|
+
if sql_cache is not None and cached_sql is None and not is_low_quality_sql_for_question(sql_question, normalized_sql):
|
|
279
|
+
sql_cache.set(question=sql_question, schema_version=schema_version, sql=normalized_sql)
|
|
280
|
+
result = self._execute_sql(context, user_question, normalized_sql)
|
|
281
|
+
answer = render_result(user_question, result, result_format)
|
|
282
|
+
if _should_append_default_limit_notice(
|
|
283
|
+
question=sql_question,
|
|
284
|
+
result=result,
|
|
285
|
+
default_limit=self.config.sql.default_limit,
|
|
286
|
+
):
|
|
287
|
+
answer = _append_default_limit_notice(
|
|
288
|
+
answer,
|
|
289
|
+
user_question,
|
|
290
|
+
result_format,
|
|
291
|
+
_default_limit_notice(self.config.sql.default_limit),
|
|
292
|
+
)
|
|
127
293
|
return QueryResult(
|
|
128
|
-
answer=
|
|
294
|
+
answer=answer,
|
|
129
295
|
generated_sql=generated.sql,
|
|
130
296
|
normalized_sql=normalized_sql,
|
|
131
297
|
repaired_sql=repaired_sql,
|
|
@@ -140,7 +306,6 @@ class SpongeAgent:
|
|
|
140
306
|
schema_response = self.mcp_client.get_schema(
|
|
141
307
|
trace_id=trace_id,
|
|
142
308
|
schema_version=cached_version,
|
|
143
|
-
refresh_reason="startup",
|
|
144
309
|
)
|
|
145
310
|
except Exception:
|
|
146
311
|
if cached_schema is not None:
|
|
@@ -166,13 +331,6 @@ class SpongeAgent:
|
|
|
166
331
|
)
|
|
167
332
|
return cached_schema
|
|
168
333
|
|
|
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
334
|
if "tables" in schema_response:
|
|
177
335
|
self.schema_cache.save(schema_response)
|
|
178
336
|
self.audit_logger.log(
|
|
@@ -189,26 +347,6 @@ class SpongeAgent:
|
|
|
189
347
|
return cached_schema
|
|
190
348
|
raise RuntimeError("Sponge get_schema did not return tables and no local schema cache exists")
|
|
191
349
|
|
|
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
350
|
def _validate_sql(
|
|
213
351
|
self,
|
|
214
352
|
context: UserContext,
|
|
@@ -218,10 +356,11 @@ class SpongeAgent:
|
|
|
218
356
|
raise_on_error: bool = False,
|
|
219
357
|
) -> str | None:
|
|
220
358
|
trace_id = new_trace_id()
|
|
359
|
+
self._last_validation_trace_id = None
|
|
221
360
|
result = self.mcp_client.validate_sql(
|
|
222
|
-
user_id=context.user_id,
|
|
223
361
|
trace_id=trace_id,
|
|
224
362
|
session_id=context.session_id,
|
|
363
|
+
question=question,
|
|
225
364
|
sql=sql,
|
|
226
365
|
)
|
|
227
366
|
self._last_validation_result = result
|
|
@@ -229,7 +368,6 @@ class SpongeAgent:
|
|
|
229
368
|
{
|
|
230
369
|
"traceId": trace_id,
|
|
231
370
|
"sessionId": context.session_id,
|
|
232
|
-
"userId": context.user_id,
|
|
233
371
|
"question": question,
|
|
234
372
|
"tool": "validate_sql",
|
|
235
373
|
"sqlHash": sql_hash(sql),
|
|
@@ -244,14 +382,17 @@ class SpongeAgent:
|
|
|
244
382
|
if raise_on_error:
|
|
245
383
|
raise RuntimeError(f"SQL validation failed: {code}")
|
|
246
384
|
return None
|
|
385
|
+
self._last_validation_trace_id = trace_id
|
|
247
386
|
return str(result.get("normalizedSql") or sql)
|
|
248
387
|
|
|
249
388
|
def _execute_sql(self, context: UserContext, question: str, sql: str) -> dict[str, Any]:
|
|
250
|
-
|
|
389
|
+
if self._last_validation_trace_id is None:
|
|
390
|
+
raise RuntimeError("SQL execution requires a prior successful validate_sql trace")
|
|
391
|
+
trace_id = self._last_validation_trace_id
|
|
251
392
|
result = self.mcp_client.execute_sql(
|
|
252
|
-
user_id=context.user_id,
|
|
253
393
|
trace_id=trace_id,
|
|
254
394
|
session_id=context.session_id,
|
|
395
|
+
question=question,
|
|
255
396
|
sql=sql,
|
|
256
397
|
timeout_ms=self.config.sponge_mcp.timeout_ms,
|
|
257
398
|
)
|
|
@@ -259,7 +400,6 @@ class SpongeAgent:
|
|
|
259
400
|
{
|
|
260
401
|
"traceId": trace_id,
|
|
261
402
|
"sessionId": context.session_id,
|
|
262
|
-
"userId": context.user_id,
|
|
263
403
|
"question": question,
|
|
264
404
|
"tool": "execute_sql",
|
|
265
405
|
"sqlHash": sql_hash(sql),
|
|
@@ -278,11 +418,159 @@ class SpongeAgent:
|
|
|
278
418
|
def _strip_result_format_instruction(question: str) -> str:
|
|
279
419
|
cleaned = question
|
|
280
420
|
patterns = [
|
|
281
|
-
r"
|
|
421
|
+
r"^\s*返回\s*(?:json|yaml|html|markdown|md)\s*格式\s*的?",
|
|
422
|
+
r"[,,。;;]?\s*输出\s*(?:json|yaml|html|markdown|md)\s*(?:格式)?",
|
|
282
423
|
r"[,,。;;]?\s*用\s*表格\s*展示",
|
|
283
|
-
r"[,,。;;]?\s*以\s*(?:yaml|html|markdown|md)\s*(?:格式)?\s*输出",
|
|
284
|
-
r"[,,。;;]?\s*return\s+(?:yaml|html|markdown|md)",
|
|
424
|
+
r"[,,。;;]?\s*以\s*(?:json|yaml|html|markdown|md)\s*(?:格式)?\s*输出",
|
|
425
|
+
r"[,,。;;]?\s*return\s+(?:json|yaml|html|markdown|md)",
|
|
285
426
|
]
|
|
286
427
|
for pattern in patterns:
|
|
287
428
|
cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE)
|
|
288
429
|
return cleaned.strip() or question
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _query_only_result() -> QueryResult:
|
|
433
|
+
return QueryResult(
|
|
434
|
+
answer="目前只能查询",
|
|
435
|
+
generated_sql="",
|
|
436
|
+
normalized_sql="",
|
|
437
|
+
repaired_sql=None,
|
|
438
|
+
validation={},
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _generate_with_sampler(
|
|
443
|
+
*,
|
|
444
|
+
generator: SamplingSqlGenerator,
|
|
445
|
+
question: str,
|
|
446
|
+
schema_context: Any,
|
|
447
|
+
) -> tuple[GeneratedSql, str | None]:
|
|
448
|
+
try:
|
|
449
|
+
return (
|
|
450
|
+
generator.generate(
|
|
451
|
+
question=question,
|
|
452
|
+
schema_context=schema_context,
|
|
453
|
+
),
|
|
454
|
+
None,
|
|
455
|
+
)
|
|
456
|
+
except SqlGenerationError as sampling_exc:
|
|
457
|
+
try:
|
|
458
|
+
repaired = generator.repair(
|
|
459
|
+
question=question,
|
|
460
|
+
original_sql="",
|
|
461
|
+
validation_error={"code": type(sampling_exc).__name__, "message": str(sampling_exc)},
|
|
462
|
+
schema_context=schema_context,
|
|
463
|
+
)
|
|
464
|
+
except SqlGenerationError:
|
|
465
|
+
return GeneratedSql(sql="", tables=[], placeholders=[]), None
|
|
466
|
+
return (
|
|
467
|
+
GeneratedSql(sql="", tables=[], placeholders=[]),
|
|
468
|
+
_apply_default_limit(repaired.sql, question, generator.default_limit),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _generate_rule_based_sql(
|
|
473
|
+
*,
|
|
474
|
+
question: str,
|
|
475
|
+
schema: dict[str, Any],
|
|
476
|
+
default_limit: int,
|
|
477
|
+
max_limit: int,
|
|
478
|
+
) -> GeneratedSql:
|
|
479
|
+
generator = RuleBasedSqlGenerator(
|
|
480
|
+
default_limit=default_limit,
|
|
481
|
+
max_limit=max_limit,
|
|
482
|
+
)
|
|
483
|
+
return generator.generate(
|
|
484
|
+
question=question,
|
|
485
|
+
schema=schema,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _generate_sampling_fallback_sql(
|
|
490
|
+
*,
|
|
491
|
+
question: str,
|
|
492
|
+
schema: dict[str, Any],
|
|
493
|
+
default_limit: int,
|
|
494
|
+
max_limit: int,
|
|
495
|
+
) -> GeneratedSql:
|
|
496
|
+
if re.search(r"(岗位|职位|招聘|在招)", question) is None:
|
|
497
|
+
raise SqlGenerationError("No sampling fallback matched the question")
|
|
498
|
+
return _generate_rule_based_sql(
|
|
499
|
+
question=question,
|
|
500
|
+
schema=schema,
|
|
501
|
+
default_limit=default_limit,
|
|
502
|
+
max_limit=max_limit,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _make_query_sql_cache(config: AppConfig) -> QuerySqlCache | None:
|
|
507
|
+
if config.sql.query_cache_path is None:
|
|
508
|
+
return None
|
|
509
|
+
return QuerySqlCache(config.sql.query_cache_path, config.sql.query_cache_ttl_minutes)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _is_mutation_request(question: str) -> bool:
|
|
513
|
+
mutation_match = re.search(
|
|
514
|
+
r"(新增|新建|创建|添加|插入|录入|修改|更新|编辑|变更|调整|删除|移除|作废|发布|下架|保存|提交|审批|导入|同步|insert|update|delete|create|drop|alter|truncate)",
|
|
515
|
+
question,
|
|
516
|
+
flags=re.IGNORECASE,
|
|
517
|
+
)
|
|
518
|
+
if mutation_match is None:
|
|
519
|
+
return False
|
|
520
|
+
if _is_mutation_analytics_request(question):
|
|
521
|
+
return False
|
|
522
|
+
return True
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _is_mutation_analytics_request(question: str) -> bool:
|
|
526
|
+
return bool(
|
|
527
|
+
re.search(r"(统计|查询|查看|看看|多少|几次|次数|数量|count|列表|明细|记录|日志|历史|趋势|分组)", question, flags=re.IGNORECASE)
|
|
528
|
+
and re.search(r"(新增|新建|创建|添加|插入|录入|修改|更新|编辑|变更|调整|删除|移除|作废|发布|下架|保存|提交|审批|导入|同步)", question)
|
|
529
|
+
and re.search(r"(次数|数量|count|列表|明细|记录|日志|历史|趋势|分组|显示|展示|返回|包含|字段)", question, flags=re.IGNORECASE)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _apply_default_limit(sql: str, question: str, default_limit: int) -> str:
|
|
534
|
+
if _question_requests_limit(question):
|
|
535
|
+
return sql
|
|
536
|
+
return re.sub(r"\blimit\s+\d+\b", f"LIMIT {default_limit}", sql, count=1, flags=re.IGNORECASE)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _question_requests_limit(question: str) -> bool:
|
|
540
|
+
lowered = question.lower()
|
|
541
|
+
if re.search(r"\b(?:limit|top)\s+\d+\b", lowered):
|
|
542
|
+
return True
|
|
543
|
+
if re.search(r"(?:返回|展示|查询|列出|给我|取)\s*\d+\s*(?:条|行|个|项|家|名)", question):
|
|
544
|
+
return True
|
|
545
|
+
return bool(re.search(r"前\s*\d+\s*(?:条|行|个|项|家|名)?", question))
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _default_limit_notice(default_limit: int) -> str:
|
|
549
|
+
return f"本次为默认查询 {default_limit} 条数据;如果需要查询更多数据,可以修改查询口径。"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _should_append_default_limit_notice(*, question: str, result: dict[str, Any], default_limit: int) -> bool:
|
|
553
|
+
if _question_requests_limit(question):
|
|
554
|
+
return False
|
|
555
|
+
try:
|
|
556
|
+
row_count = int(result.get("rowCount", len(result.get("rows", []))))
|
|
557
|
+
except (TypeError, ValueError):
|
|
558
|
+
return False
|
|
559
|
+
return row_count == default_limit
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _append_default_limit_notice(answer: str, question: str, result_format: str | None, notice: str) -> str:
|
|
563
|
+
output_format = normalize_result_format(question, result_format)
|
|
564
|
+
if output_format == "json":
|
|
565
|
+
try:
|
|
566
|
+
payload = json.loads(answer)
|
|
567
|
+
except json.JSONDecodeError:
|
|
568
|
+
return f"{answer}\n\n{notice}"
|
|
569
|
+
if isinstance(payload, dict):
|
|
570
|
+
payload["notice"] = notice
|
|
571
|
+
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
|
|
572
|
+
if output_format == "yaml":
|
|
573
|
+
return f"{answer.rstrip()}\nnotice: {notice}\n"
|
|
574
|
+
if output_format == "html":
|
|
575
|
+
return f"{answer}<p>{notice}</p>"
|
|
576
|
+
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
|
-
|