@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.
@@ -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 .result_renderer import render_result
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 RuleBasedSqlGenerator, enforce_sql_rules
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
- user_id: int,
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
- user_id=user_id,
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
- user_id: int,
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(question)
96
+ sql_question = _strip_result_format_instruction(user_question)
84
97
  schema = self._ensure_schema()
85
- allowed_context = self._get_allowed_context(context, sql_question)
86
- scope_hints = allowed_context.get("scopeHints", {})
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 = retrieve_schema_context(schema, sql_question)
89
- generator = SamplingSqlGenerator(
90
- sampler=sampler,
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 = RuleBasedSqlGenerator(
102
- default_limit=self.config.sql.default_limit,
103
- max_limit=self.config.sql.max_limit,
104
- )
105
- generated = generator.generate(
106
- question=sql_question,
107
- schema=schema,
108
- scope_hints=scope_hints,
109
- )
110
- enforce_sql_rules(generated.sql, max_limit=self.config.sql.max_limit)
111
- normalized_sql = self._validate_sql(context, question, generated.sql)
112
- repaired_sql: str | None = None
113
- if sampler is not None and normalized_sql is None and schema_context is not None:
114
- repaired = generator.repair(
115
- question=sql_question,
116
- original_sql=generated.sql,
117
- validation_error=self._last_validation_error,
118
- schema_context=schema_context,
119
- scope_hints=scope_hints,
120
- )
121
- repaired_sql = repaired.sql
122
- enforce_sql_rules(repaired.sql, max_limit=self.config.sql.max_limit)
123
- normalized_sql = self._validate_sql(context, question, repaired.sql, raise_on_error=True)
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
- result = self._execute_sql(context, question, normalized_sql)
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=render_result(question, result, result_format),
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
- trace_id = new_trace_id()
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"[,,。;;]?\s*输出\s*(?:yaml|html|markdown|md)\s*(?:格式)?",
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=100,
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
-