@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.
@@ -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,236 @@ 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
+ 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 = retrieve_schema_context(schema, sql_question)
89
- generator = SamplingSqlGenerator(
90
- sampler=sampler,
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 = 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)
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
- result = self._execute_sql(context, question, normalized_sql)
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=render_result(question, result, result_format),
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
- trace_id = new_trace_id()
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"[,,。;;]?\s*输出\s*(?:yaml|html|markdown|md)\s*(?:格式)?",
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=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
-
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+
5
+
6
+ def get_device_info() -> str:
7
+ return platform.platform(aliased=True, terse=True)