gaard-api 0.2.17__tar.gz → 0.2.19__tar.gz

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.
Files changed (80) hide show
  1. {gaard_api-0.2.17/src/gaard_api.egg-info → gaard_api-0.2.19}/PKG-INFO +4 -4
  2. {gaard_api-0.2.17 → gaard_api-0.2.19}/pyproject.toml +4 -4
  3. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database.py +13 -0
  4. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/defaults.py +2 -2
  5. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/prompt_runtime.py +3 -0
  6. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/services.py +28 -4
  7. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/analysis_findings.py +40 -0
  8. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/analysis.py +33 -11
  9. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/main.py +1 -1
  10. {gaard_api-0.2.17 → gaard_api-0.2.19/src/gaard_api.egg-info}/PKG-INFO +4 -4
  11. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/requires.txt +3 -3
  12. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_admin_api.py +157 -1
  13. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_analysis_api.py +140 -0
  14. {gaard_api-0.2.17 → gaard_api-0.2.19}/MANIFEST.in +0 -0
  15. {gaard_api-0.2.17 → gaard_api-0.2.19}/README.md +0 -0
  16. {gaard_api-0.2.17 → gaard_api-0.2.19}/setup.cfg +0 -0
  17. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/__init__.py +0 -0
  18. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/__init__.py +0 -0
  19. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database_initial.sql +0 -0
  20. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
  21. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database_updates.sql +0 -0
  22. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/metadata.py +0 -0
  23. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/migration_runner.py +0 -0
  24. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/models.py +0 -0
  25. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/security.py +0 -0
  26. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  27. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/identity.js +0 -0
  28. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/main.js +0 -0
  29. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/styles.css +0 -0
  30. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  31. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  32. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/index.html +0 -0
  33. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/package.json +0 -0
  34. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/__init__.py +0 -0
  35. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/__init__.py +0 -0
  36. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/admin.py +0 -0
  37. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/dashboards.py +0 -0
  38. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/prompts.py +0 -0
  39. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/query.py +0 -0
  40. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/schema.py +0 -0
  41. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api_registry.py +0 -0
  42. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/auth_dependencies.py +0 -0
  43. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/auth_hooks.py +0 -0
  44. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/cli.py +0 -0
  45. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/cli_commands.py +0 -0
  46. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/conversations.py +0 -0
  47. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/__init__.py +0 -0
  48. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/error_handlers.py +0 -0
  49. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/schema_cache.py +0 -0
  50. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/settings.py +0 -0
  51. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/__init__.py +0 -0
  52. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  53. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  54. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  55. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_database.py +0 -0
  56. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/extension_services.py +0 -0
  57. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/extensions.py +0 -0
  58. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/license.py +0 -0
  59. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/package_updates.py +0 -0
  60. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/py.typed +0 -0
  61. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/query_hooks.py +0 -0
  62. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/server_cli.py +0 -0
  63. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/siem.py +0 -0
  64. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/tls_http.py +0 -0
  65. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  66. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  67. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/entry_points.txt +0 -0
  68. {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/top_level.txt +0 -0
  69. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_api_cli.py +0 -0
  70. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_api_registry.py +0 -0
  71. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_conversation_api.py +0 -0
  72. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_database_migrations.py +0 -0
  73. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_error_handlers.py +0 -0
  74. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_license_api.py +0 -0
  75. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_license_service.py +0 -0
  76. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_prompt_runtime.py +0 -0
  77. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_query_error_messages.py +0 -0
  78. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_server_cli.py +0 -0
  79. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_settings.py +0 -0
  80. {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_tls_http.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.17
3
+ Version: 0.2.19
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.17
8
- Requires-Dist: gaard-connectors==0.2.17
9
- Requires-Dist: gaard-llm==0.2.17
7
+ Requires-Dist: gaard-core==0.2.19
8
+ Requires-Dist: gaard-connectors==0.2.19
9
+ Requires-Dist: gaard-llm==0.2.19
10
10
  Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.17
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.17"
7
+ version = "0.2.19"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
- "gaard-core==0.2.17",
14
- "gaard-connectors==0.2.17",
15
- "gaard-llm==0.2.17",
13
+ "gaard-core==0.2.19",
14
+ "gaard-connectors==0.2.19",
15
+ "gaard-llm==0.2.19",
16
16
  "gaard-plugin-api>=0.2.17,<0.3.0",
17
17
  "fastapi>=0.111.0",
18
18
  "uvicorn[standard]>=0.30.0",
@@ -252,6 +252,19 @@ def seed_prompts(session: Session) -> None:
252
252
  continue
253
253
 
254
254
  if existing.updated_by != "system":
255
+ if existing.prompt_key == "sql_generation":
256
+ # Replace only the shipped LIMIT rules, preserving administrator customizations.
257
+ updated_prompt = existing.system_prompt.replace(
258
+ "Add LIMIT {max_rows} when the query may return many rows.",
259
+ "{row_limit_instruction}",
260
+ ).replace(
261
+ "Do not add LIMIT to pure aggregate queries that return a single row, "
262
+ "unless it is already useful for the dialect or safety.",
263
+ "Do not add a row limit to pure aggregate queries that return a single row.",
264
+ )
265
+ if updated_prompt != existing.system_prompt:
266
+ existing.system_prompt = updated_prompt
267
+ existing.version += 1
255
268
  continue
256
269
 
257
270
  changed = any(
@@ -28,8 +28,8 @@ Query construction rules:
28
28
  3. If the user asks for both a total and a breakdown, prefer one SELECT statement that returns grouped rows or conditional aggregate columns.
29
29
  4. Do not solve one user question by generating multiple separate SELECT statements.
30
30
  5. Prefer explicit column names over SELECT *.
31
- 6. Add LIMIT {max_rows} when the query may return many rows.
32
- 7. Do not add LIMIT to pure aggregate queries that return a single row, unless it is already useful for the dialect or safety.
31
+ 6. {row_limit_instruction}
32
+ 7. Do not add a row limit to pure aggregate queries that return a single row.
33
33
  8. Use clear aliases for computed expressions.
34
34
  9. If the question is ambiguous, choose the most likely interpretation based on the schema, column names, descriptions and data rules.
35
35
 
@@ -4,6 +4,7 @@ from gaard_core.errors import ConfigurationError
4
4
  from gaard_core.json_utils import json_dumps
5
5
  from gaard_core.prompt_compiler.models import CompiledPrompt, SqlGenerationPromptRequest
6
6
  from gaard_core.prompt_compiler.schema_formatter import SchemaPromptFormatter
7
+ from gaard_core.prompt_compiler.sql_generation_prompt import sql_row_limit_instruction
7
8
  from gaard_core.query_pipeline.models import (
8
9
  ConversationContextDecision,
9
10
  QueryRequest,
@@ -29,12 +30,14 @@ class MetadataSqlGenerationPromptCompiler:
29
30
  system_prompt = self.prompt_template.system_prompt.format(
30
31
  dialect=request.dialect,
31
32
  max_rows=request.max_rows,
33
+ row_limit_instruction=sql_row_limit_instruction(request.dialect, request.max_rows),
32
34
  )
33
35
  user_prompt = self.prompt_template.user_prompt_template.format(
34
36
  schema=formatted_schema,
35
37
  question=request.question,
36
38
  dialect=request.dialect,
37
39
  max_rows=request.max_rows,
40
+ row_limit_instruction=sql_row_limit_instruction(request.dialect, request.max_rows),
38
41
  )
39
42
 
40
43
  return CompiledPrompt(
@@ -1747,6 +1747,14 @@ def upsert_llm_business_logic_suggestion(
1747
1747
  )
1748
1748
  )
1749
1749
 
1750
+ if existing is None:
1751
+ existing = session.scalar(
1752
+ select(BusinessLogicSuggestion).where(
1753
+ BusinessLogicSuggestion.connector_id == connector.id,
1754
+ BusinessLogicSuggestion.rule_text == lesson.rule_text,
1755
+ )
1756
+ )
1757
+
1750
1758
  if existing is None:
1751
1759
  existing = BusinessLogicSuggestion(
1752
1760
  connector_id=connector.id,
@@ -1769,6 +1777,10 @@ def upsert_llm_business_logic_suggestion(
1769
1777
  return existing
1770
1778
 
1771
1779
  existing.source_audit_id = audit_log.id
1780
+ # Re-observing an error must not revoke approval or overwrite reviewed content.
1781
+ if existing.enabled:
1782
+ return existing
1783
+
1772
1784
  existing.status = BUSINESS_LOGIC_STATUS_PENDING
1773
1785
  existing.safety = BUSINESS_LOGIC_SAFETY_REVIEW
1774
1786
  existing.enabled = False
@@ -1839,10 +1851,19 @@ def upsert_analysis_business_logic_suggestion(
1839
1851
  session.flush()
1840
1852
  return existing
1841
1853
 
1854
+ should_enable = auto_enable or existing.enabled
1842
1855
  existing.source_audit_id = source_audit_id
1843
- existing.status = BUSINESS_LOGIC_STATUS_ACTIVE if auto_enable else BUSINESS_LOGIC_STATUS_PENDING
1844
- existing.safety = BUSINESS_LOGIC_SAFETY_SAFE if auto_enable else BUSINESS_LOGIC_SAFETY_REVIEW
1845
- existing.enabled = auto_enable
1856
+ existing.status = (
1857
+ BUSINESS_LOGIC_STATUS_ACTIVE
1858
+ if should_enable
1859
+ else BUSINESS_LOGIC_STATUS_PENDING
1860
+ )
1861
+ existing.safety = (
1862
+ BUSINESS_LOGIC_SAFETY_SAFE
1863
+ if should_enable
1864
+ else BUSINESS_LOGIC_SAFETY_REVIEW
1865
+ )
1866
+ existing.enabled = should_enable
1846
1867
  existing.error_category = normalized_type
1847
1868
  existing.title = normalized_title
1848
1869
  existing.rule_text = normalized_rule
@@ -1863,9 +1884,12 @@ def record_business_logic_learning_suggestion(
1863
1884
  metadata["failed_identifier"] = lesson.failed_identifier
1864
1885
  metadata["repaired_identifier"] = lesson.repaired_identifier
1865
1886
  metadata["business_logic_learning"] = {
1866
- "status": "pending_approval",
1887
+ "status": BUSINESS_LOGIC_STATUS_ACTIVE if suggestion.enabled else "pending_approval",
1867
1888
  "suggestion_id": suggestion.id,
1868
1889
  "message": (
1890
+ "This error matches an already approved business logic rule. "
1891
+ "The rule remains active."
1892
+ if suggestion.enabled else
1869
1893
  "Nauczyłem się propozycji rozwiązania tego błędu, ale musisz ją "
1870
1894
  "zatwierdzić w Sugestiach logiki biznesowej."
1871
1895
  ),
@@ -186,6 +186,46 @@ def create_analysis_finding(
186
186
  return finding
187
187
 
188
188
 
189
+ def get_analysis_finding_for_business_logic_suggestion(
190
+ session: Session,
191
+ *,
192
+ investigation_id: str,
193
+ owner_user_id: str,
194
+ business_logic_suggestion_id: int,
195
+ ) -> AnalysisFinding | None:
196
+ return session.scalar(
197
+ select(AnalysisFinding)
198
+ .where(
199
+ AnalysisFinding.investigation_id == investigation_id,
200
+ AnalysisFinding.owner_user_id == owner_user_id,
201
+ AnalysisFinding.business_logic_suggestion_id == business_logic_suggestion_id,
202
+ )
203
+ .order_by(AnalysisFinding.id.asc())
204
+ )
205
+
206
+
207
+ def refresh_analysis_finding_observation(
208
+ finding: AnalysisFinding,
209
+ *,
210
+ statement: str,
211
+ finding_type: str,
212
+ confidence: float,
213
+ critique: str,
214
+ scope: dict[str, Any],
215
+ evidence_refs: list[str],
216
+ ) -> AnalysisFinding:
217
+ finding.statement = remove_thinking_blocks(statement).strip()[:4_000]
218
+ finding.finding_type = (finding_type.strip() or "finding")[:100]
219
+ finding.confidence = max(0.0, min(1.0, float(confidence)))
220
+ finding.critique = remove_thinking_blocks(critique).strip()[:4_000]
221
+ finding.scope_json = json_dumps(scope)
222
+ finding.evidence_refs_json = json_dumps(
223
+ unique_texts(text_list(finding.evidence_refs_json) + evidence_refs)
224
+ )
225
+ finding.contract_version = FINDING_CONTRACT_VERSION
226
+ return finding
227
+
228
+
189
229
  def get_owned_analysis_finding(
190
230
  session: Session,
191
231
  *,
@@ -45,10 +45,12 @@ from gaard_api.analysis_findings import (
45
45
  create_analysis_finding,
46
46
  create_radar_finding_decision,
47
47
  format_working_knowledge,
48
+ get_analysis_finding_for_business_logic_suggestion,
48
49
  get_owned_analysis_finding,
49
50
  list_active_analysis_findings,
50
51
  list_owned_analysis_findings,
51
52
  record_finding_usage,
53
+ refresh_analysis_finding_observation,
52
54
  serialize_analysis_finding,
53
55
  serialize_finding_decision,
54
56
  serialize_working_knowledge_item,
@@ -1221,22 +1223,42 @@ def save_business_logic_finding(
1221
1223
  auto_enable=runtime_config.analysis_auto_enable_business_logic,
1222
1224
  actor=f"analysis:{session_id}",
1223
1225
  )
1224
- finding_record = create_analysis_finding(
1226
+ critique = first_non_empty(
1227
+ finding.critique,
1228
+ "Confirmed only by evidence from this investigation and datasource.",
1229
+ )
1230
+ scope = finding_scope(finding, connector.connector_key, context)
1231
+ evidence_refs = finding_evidence_refs(finding, context)
1232
+ finding_record = get_analysis_finding_for_business_logic_suggestion(
1225
1233
  session,
1226
1234
  investigation_id=session_id,
1227
1235
  owner_user_id=record.user_id,
1228
- connector_id=connector.id,
1229
1236
  business_logic_suggestion_id=suggestion.id,
1230
- statement=statement,
1231
- finding_type=finding.knowledge_type,
1232
- confidence=finding.confidence,
1233
- critique=first_non_empty(
1234
- finding.critique,
1235
- "Confirmed only by evidence from this investigation and datasource.",
1236
- ),
1237
- scope=finding_scope(finding, connector.connector_key, context),
1238
- evidence_refs=finding_evidence_refs(finding, context),
1239
1237
  )
1238
+ if finding_record is None:
1239
+ finding_record = create_analysis_finding(
1240
+ session,
1241
+ investigation_id=session_id,
1242
+ owner_user_id=record.user_id,
1243
+ connector_id=connector.id,
1244
+ business_logic_suggestion_id=suggestion.id,
1245
+ statement=statement,
1246
+ finding_type=finding.knowledge_type,
1247
+ confidence=finding.confidence,
1248
+ critique=critique,
1249
+ scope=scope,
1250
+ evidence_refs=evidence_refs,
1251
+ )
1252
+ else:
1253
+ refresh_analysis_finding_observation(
1254
+ finding_record,
1255
+ statement=statement,
1256
+ finding_type=finding.knowledge_type,
1257
+ confidence=finding.confidence,
1258
+ critique=critique,
1259
+ scope=scope,
1260
+ evidence_refs=evidence_refs,
1261
+ )
1240
1262
  session.commit()
1241
1263
  finding_payload = serialize_analysis_finding(finding_record)
1242
1264
  return {
@@ -40,7 +40,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
40
40
 
41
41
  app = FastAPI(
42
42
  title="GAARD API",
43
- version="0.2.17",
43
+ version="0.2.19",
44
44
  description="Self-hosted AI SQL Gateway for governed natural-language access to relational data.",
45
45
  lifespan=lifespan,
46
46
  )
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.17
3
+ Version: 0.2.19
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
- Requires-Dist: gaard-core==0.2.17
8
- Requires-Dist: gaard-connectors==0.2.17
9
- Requires-Dist: gaard-llm==0.2.17
7
+ Requires-Dist: gaard-core==0.2.19
8
+ Requires-Dist: gaard-connectors==0.2.19
9
+ Requires-Dist: gaard-llm==0.2.19
10
10
  Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.17
11
11
  Requires-Dist: fastapi>=0.111.0
12
12
  Requires-Dist: uvicorn[standard]>=0.30.0
@@ -1,6 +1,6 @@
1
- gaard-core==0.2.17
2
- gaard-connectors==0.2.17
3
- gaard-llm==0.2.17
1
+ gaard-core==0.2.19
2
+ gaard-connectors==0.2.19
3
+ gaard-llm==0.2.19
4
4
  gaard-plugin-api<0.3.0,>=0.2.17
5
5
  fastapi>=0.111.0
6
6
  uvicorn[standard]>=0.30.0
@@ -11,7 +11,7 @@ import pytest
11
11
  from fastapi.testclient import TestClient
12
12
  from gaard_connectors import ConnectorNotFoundError, create_builtin_connector_registry
13
13
  from gaard_connectors.odbc.connection_string import parse_odbc_connection_string
14
- from gaard_core.errors import LlmProviderError, QueryPipelineStepError
14
+ from gaard_core.errors import LlmProviderError, QueryExecutionError, QueryPipelineStepError
15
15
  from gaard_core.query_pipeline.mock_sql_generator import MockSqlGenerator
16
16
  from gaard_core.query_pipeline.models import (
17
17
  GeneratedSql,
@@ -19,6 +19,7 @@ from gaard_core.query_pipeline.models import (
19
19
  QueryIntentClassification,
20
20
  QueryIntentDecision,
21
21
  QueryRequest,
22
+ QueryResult,
22
23
  )
23
24
  from gaard_llm.providers.models import ChatCompletionRequest, ChatCompletionResponse
24
25
  from openpyxl import Workbook
@@ -1518,6 +1519,49 @@ def test_system_seeded_mock_runtime_modes_are_migrated_to_current_defaults(
1518
1519
  reset_metadata_store_for_tests()
1519
1520
 
1520
1521
 
1522
+ @pytest.mark.parametrize("updated_by", ["system", "admin"])
1523
+ def test_legacy_limit_prompt_is_upgraded_without_losing_customizations(
1524
+ admin_client: TestClient,
1525
+ updated_by: str,
1526
+ ) -> None:
1527
+ from gaard_core.prompt_compiler.models import SqlGenerationPromptRequest
1528
+
1529
+ from gaard_api.admin.prompt_runtime import MetadataSqlGenerationPromptCompiler
1530
+
1531
+ with create_session() as session:
1532
+ prompt = session.scalar(
1533
+ select(PromptTemplate).where(PromptTemplate.prompt_key == "sql_generation")
1534
+ )
1535
+ assert prompt is not None
1536
+ prompt.system_prompt = (
1537
+ "You must generate SQL for the {dialect} dialect.\n"
1538
+ "6. Add LIMIT {max_rows} when the query may return many rows.\n"
1539
+ "7. Do not add LIMIT to pure aggregate queries that return a single row, "
1540
+ "unless it is already useful for the dialect or safety.\n"
1541
+ "Custom rule: use the reporting schema."
1542
+ )
1543
+ prompt.updated_by = updated_by
1544
+ prompt.version = 7
1545
+ session.commit()
1546
+ seed_prompts(session)
1547
+ session.commit()
1548
+ assert prompt.version == 8
1549
+ if updated_by == "admin":
1550
+ assert "Custom rule: use the reporting schema." in prompt.system_prompt
1551
+ assert prompt.updated_by == "admin"
1552
+ compiled = MetadataSqlGenerationPromptCompiler(prompt).compile(
1553
+ SqlGenerationPromptRequest(
1554
+ question="What is in abc?", formatted_schema="Table: abc",
1555
+ dialect="tsql", max_rows=100,
1556
+ )
1557
+ )
1558
+ assert "TOP (100)" in compiled.system_prompt
1559
+ assert "LIMIT 100" not in compiled.system_prompt
1560
+ seed_prompts(session)
1561
+ session.commit()
1562
+ assert prompt.version == 8
1563
+
1564
+
1521
1565
  def test_investigation_prompts_are_not_seeded(
1522
1566
  tmp_path: Path,
1523
1567
  monkeypatch: pytest.MonkeyPatch,
@@ -5481,6 +5525,21 @@ def test_sql_error_creates_datasource_scoped_business_logic_suggestion(
5481
5525
  assert edited["enabled"] is True
5482
5526
  assert "customer project orders" in get_active_business_logic_prompt_safe(connector_id)
5483
5527
 
5528
+ repeated_response = admin_client.post(
5529
+ "/api/v1/query", headers=headers,
5530
+ json={"question": "Ktory pracownik zrealizowal najwiecej projektow", "user_id": "alice"},
5531
+ )
5532
+ assert repeated_response.status_code == 400
5533
+ repeated_items = admin_client.get(
5534
+ "/api/v1/admin/business-logic-suggestions", headers=headers,
5535
+ ).json()["items"]
5536
+ assert len(repeated_items) == 1
5537
+ assert repeated_items[0]["id"] == edited["id"]
5538
+ assert repeated_items[0]["enabled"] is True
5539
+ assert repeated_items[0]["status"] == "active"
5540
+ assert repeated_items[0]["title"] == edited["title"]
5541
+ assert repeated_items[0]["rule_text"] == edited["rule_text"]
5542
+
5484
5543
  empty_edit_response = admin_client.put(
5485
5544
  f"/api/v1/admin/business-logic-suggestions/{suggestions[0]['id']}",
5486
5545
  headers=headers,
@@ -5498,6 +5557,103 @@ def test_sql_error_creates_datasource_scoped_business_logic_suggestion(
5498
5557
  assert delete_response.json()["status"] == "deleted"
5499
5558
 
5500
5559
 
5560
+ @pytest.mark.parametrize("with_identifiers", [True, False])
5561
+ def test_mssql_sql_error_learning_preserves_manual_approval(
5562
+ admin_client: TestClient,
5563
+ monkeypatch: pytest.MonkeyPatch,
5564
+ with_identifiers: bool,
5565
+ ) -> None:
5566
+ from gaard_api.api.v1 import query as query_api
5567
+
5568
+ headers = auth_headers(admin_client)
5569
+ rule = "Use SELECT TOP (100) for row limits on this datasource; never use LIMIT."
5570
+ stub_business_logic_learning_llm(monkeypatch, {
5571
+ "create_suggestion": True, "error_category": "sql.dialect",
5572
+ "title": "Use TOP instead of LIMIT", "rule_text": rule,
5573
+ "failed_identifier": "LIMIT" if with_identifiers else "",
5574
+ "repaired_identifier": "TOP" if with_identifiers else "",
5575
+ "confidence": 0.95,
5576
+ })
5577
+ with create_session() as session:
5578
+ for connector in session.scalars(select(DatasourceConnector)):
5579
+ connector.active = False
5580
+ connector = DatasourceConnector(
5581
+ connector_key="mssql_abc", name="MSSQL ABC", database_type="mssql",
5582
+ database_url="mssql+pyodbc://example.test/abc", sql_dialect="tsql", active=True,
5583
+ )
5584
+ session.add(connector)
5585
+ session.flush()
5586
+ session.add(DatasourceSchemaCache(
5587
+ connector_id=connector.id, schema_json='{"tables":[]}',
5588
+ table_settings_json="{}", formatted_schema="Table: abc\nColumns:\n- id: INTEGER",
5589
+ ))
5590
+ set_setting(session, "gaard_sql_generation_mode", "llm", "test")
5591
+ set_setting(session, "gaard_intent_classification_mode", "mock", "test")
5592
+ session.commit()
5593
+
5594
+ requests: list[ChatCompletionRequest] = []
5595
+
5596
+ class SqlClient:
5597
+ def create_chat_completion(self, request: ChatCompletionRequest) -> ChatCompletionResponse:
5598
+ requests.append(request)
5599
+ sql = (
5600
+ "SELECT * FROM abc LIMIT 100" if len(requests) <= 2
5601
+ else "SELECT TOP (100) * FROM abc"
5602
+ )
5603
+ return ChatCompletionResponse(content=sql)
5604
+
5605
+ class MssqlExecutor:
5606
+ def execute(self, sql: str) -> QueryResult:
5607
+ if "LIMIT" in sql:
5608
+ raise QueryExecutionError(
5609
+ "Incorrect syntax near LIMIT.", sql=sql,
5610
+ error_detail="[SQL Server] Incorrect syntax near 'LIMIT'.",
5611
+ )
5612
+ return QueryResult(columns=["id"], rows=[{"id": 1}])
5613
+
5614
+ monkeypatch.setattr(query_api, "create_llm_client", lambda config: SqlClient())
5615
+ monkeypatch.setattr(
5616
+ query_api, "create_datasource_executor", lambda *args, **kwargs: MssqlExecutor(),
5617
+ )
5618
+ payload = {"question": "What is in table abc?", "interpret": False}
5619
+ assert admin_client.post("/api/v1/query", headers=headers, json=payload).status_code == 400
5620
+ first = admin_client.get(
5621
+ "/api/v1/admin/business-logic-suggestions", headers=headers,
5622
+ ).json()["items"]
5623
+ assert len(first) == 1 and first[0]["status"] == "pending"
5624
+ suggestion_id = first[0]["id"]
5625
+ approved = admin_client.put(
5626
+ f"/api/v1/admin/business-logic-suggestions/{suggestion_id}",
5627
+ headers=headers, json={"enabled": True},
5628
+ )
5629
+ assert approved.status_code == 200
5630
+ assert approved.json()["item"]["status"] == "active"
5631
+
5632
+ # Force the model to repeat its error, even with the approved rule in its prompt.
5633
+ assert admin_client.post("/api/v1/query", headers=headers, json=payload).status_code == 400
5634
+ repeated = admin_client.get(
5635
+ "/api/v1/admin/business-logic-suggestions", headers=headers,
5636
+ ).json()["items"]
5637
+ assert len(repeated) == 1
5638
+ assert repeated[0]["id"] == suggestion_id
5639
+ assert repeated[0]["enabled"] is True
5640
+ assert repeated[0]["status"] == "active"
5641
+ assert repeated[0]["updated_by"] == "admin"
5642
+ assert rule in requests[1].messages[1].content
5643
+ assert "tsql" in requests[1].messages[0].content
5644
+ assert "TOP (100)" in requests[1].messages[0].content
5645
+ assert "Add LIMIT" not in requests[1].messages[0].content
5646
+ audit = admin_client.get(
5647
+ "/api/v1/admin/audit/data-queries?audit_type=sql_error", headers=headers,
5648
+ ).json()["items"][0]
5649
+ assert audit["metadata"]["business_logic_learning"]["status"] == "active"
5650
+
5651
+ success = admin_client.post("/api/v1/query", headers=headers, json=payload)
5652
+ assert success.status_code == 200
5653
+ assert success.json()["sql"] == "SELECT TOP (100) * FROM abc"
5654
+ assert rule in requests[2].messages[1].content
5655
+
5656
+
5501
5657
  def test_missing_column_sql_error_creates_business_logic_suggestion(
5502
5658
  admin_client: TestClient,
5503
5659
  tmp_path: Path,
@@ -376,6 +376,146 @@ def test_analysis_database_step_can_record_business_logic_suggestion(
376
376
  assert suggestions[0].error_category == "analysis.dictionary_value"
377
377
 
378
378
 
379
+ def test_repeated_analysis_finding_preserves_review_decisions(
380
+ analysis_client: TestClient,
381
+ monkeypatch: pytest.MonkeyPatch,
382
+ ) -> None:
383
+ headers = auth_headers(analysis_client)
384
+ connector = create_active_default_datasource()
385
+
386
+ def repeated_finding() -> analysis_module.AnalysisBusinessLogicFinding:
387
+ return analysis_module.AnalysisBusinessLogicFinding(
388
+ create_suggestion=True,
389
+ knowledge_type="semantic_mapping",
390
+ title="Cardiology dictionary mapping",
391
+ rule_text="The dictionary value for cardiology is cardiology.",
392
+ statement="The dictionary value for cardiology is cardiology.",
393
+ confidence=0.94,
394
+ critique="The mapping is confirmed only in the current datasource.",
395
+ scope={"entity": "specialization", "field": "specialization_name"},
396
+ evidence_refs=["query:dictionary-check"],
397
+ )
398
+
399
+ class RepeatedFindingPlanner:
400
+ def __init__(self) -> None:
401
+ self.calls = 0
402
+ self.working_knowledge_seen: list[list[dict[str, Any]]] = []
403
+
404
+ def decide(
405
+ self, request: Any, datasource_context: Any, context: dict[str, Any]
406
+ ) -> analysis_module.AnalysisPlannerDecision:
407
+ self.calls += 1
408
+ self.working_knowledge_seen.append(list(context.get("working_knowledge") or []))
409
+ return analysis_module.AnalysisPlannerDecision(
410
+ action=analysis_module.AnalysisAction.ASK_USER,
411
+ visible_question=f"Review mapping attempt {self.calls}.",
412
+ visible_reasoning="The same semantic mapping was observed again.",
413
+ user_question="Continue after review.",
414
+ business_logic=repeated_finding(),
415
+ )
416
+
417
+ planner = RepeatedFindingPlanner()
418
+ monkeypatch.setattr(analysis_module, "create_analysis_planner", lambda: planner)
419
+
420
+ response = analysis_client.post(
421
+ "/api/v1/analysis/stream",
422
+ headers=headers,
423
+ json={"question": "Find the cardiology dictionary value.", "user_id": "spoofed"},
424
+ )
425
+ assert response.status_code == 200
426
+ events = parse_ndjson(response.text)
427
+ session_id = events[0]["session_id"]
428
+ first_suggestion = next(
429
+ item["business_logic_suggestion"]
430
+ for item in events
431
+ if item["event"] == "business_logic_suggestion"
432
+ )
433
+ finding_id = first_suggestion["finding_id"]
434
+
435
+ radar_decision_payload = {
436
+ "finding_id": finding_id,
437
+ "decision": "accept_for_investigation",
438
+ "confidence": 0.93,
439
+ "verdict": "The observed values explain the terminology lookup.",
440
+ "scope": {
441
+ "investigation_id": session_id,
442
+ "radar_run_id": "radar-repeat-1",
443
+ },
444
+ "evidence_refs": ["query:dictionary-check"],
445
+ }
446
+ accepted_response = analysis_client.post(
447
+ f"/api/v1/analysis/{session_id}/finding-decisions",
448
+ headers=headers,
449
+ json=radar_decision_payload,
450
+ )
451
+ assert accepted_response.status_code == 200
452
+ assert accepted_response.json()["accepted"] is True
453
+
454
+ repeat_response = analysis_client.post(
455
+ f"/api/v1/analysis/{session_id}/messages/stream",
456
+ headers=headers,
457
+ json={"message": "Continue."},
458
+ )
459
+ assert repeat_response.status_code == 200
460
+ repeat_events = parse_ndjson(repeat_response.text)
461
+ repeated_suggestion = next(
462
+ item["business_logic_suggestion"]
463
+ for item in repeat_events
464
+ if item["event"] == "business_logic_suggestion"
465
+ )
466
+ assert repeated_suggestion["finding_id"] == finding_id
467
+ assert repeated_suggestion["finding"]["status"] == "accepted_for_investigation"
468
+ assert planner.working_knowledge_seen[-1][0]["finding_id"] == finding_id
469
+
470
+ persistent_response = analysis_client.put(
471
+ f"/api/v1/analysis/{session_id}/findings/{finding_id}/decision",
472
+ headers=headers,
473
+ json={
474
+ "finding_id": finding_id,
475
+ "decision": "accept_as_persistent_business_logic",
476
+ "confidence": 0.93,
477
+ "verdict": "An administrator approved this as durable logic.",
478
+ "scope": {"investigation_id": session_id},
479
+ "evidence_refs": ["query:dictionary-check"],
480
+ },
481
+ )
482
+ assert persistent_response.status_code == 200
483
+
484
+ repeat_after_persistent_response = analysis_client.post(
485
+ f"/api/v1/analysis/{session_id}/messages/stream",
486
+ headers=headers,
487
+ json={"message": "Continue again."},
488
+ )
489
+ assert repeat_after_persistent_response.status_code == 200
490
+ repeat_after_persistent_events = parse_ndjson(repeat_after_persistent_response.text)
491
+ repeated_persistent_suggestion = next(
492
+ item["business_logic_suggestion"]
493
+ for item in repeat_after_persistent_events
494
+ if item["event"] == "business_logic_suggestion"
495
+ )
496
+ assert repeated_persistent_suggestion["finding_id"] == finding_id
497
+ assert repeated_persistent_suggestion["finding"]["status"] == (
498
+ "accepted_as_persistent_business_logic"
499
+ )
500
+
501
+ with create_session() as session:
502
+ findings = list(
503
+ session.scalars(
504
+ select(AnalysisFinding).where(
505
+ AnalysisFinding.investigation_id == session_id
506
+ )
507
+ )
508
+ )
509
+ assert len(findings) == 1
510
+ assert findings[0].finding_id == finding_id
511
+ assert findings[0].decision == "accept_as_persistent_business_logic"
512
+ assert findings[0].status == "accepted_as_persistent_business_logic"
513
+ suggestions = list_business_logic_suggestions(session, connector.id)
514
+ assert len(suggestions) == 1
515
+ assert suggestions[0].enabled is True
516
+ assert suggestions[0].status == "active"
517
+
518
+
379
519
  def test_investigation_finding_review_lifecycle_and_scoped_working_knowledge(
380
520
  analysis_client: TestClient,
381
521
  monkeypatch: pytest.MonkeyPatch,
File without changes
File without changes
File without changes