gaard-api 0.2.18__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.18/src/gaard_api.egg-info → gaard_api-0.2.19}/PKG-INFO +4 -4
  2. {gaard_api-0.2.18 → gaard_api-0.2.19}/pyproject.toml +4 -4
  3. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/database.py +13 -0
  4. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/defaults.py +2 -2
  5. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/prompt_runtime.py +3 -0
  6. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/services.py +16 -1
  7. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/main.py +1 -1
  8. {gaard_api-0.2.18 → gaard_api-0.2.19/src/gaard_api.egg-info}/PKG-INFO +4 -4
  9. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api.egg-info/requires.txt +3 -3
  10. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_admin_api.py +157 -1
  11. {gaard_api-0.2.18 → gaard_api-0.2.19}/MANIFEST.in +0 -0
  12. {gaard_api-0.2.18 → gaard_api-0.2.19}/README.md +0 -0
  13. {gaard_api-0.2.18 → gaard_api-0.2.19}/setup.cfg +0 -0
  14. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/__init__.py +0 -0
  15. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/__init__.py +0 -0
  16. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/database_initial.sql +0 -0
  17. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
  18. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/database_updates.sql +0 -0
  19. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/metadata.py +0 -0
  20. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/migration_runner.py +0 -0
  21. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/models.py +0 -0
  22. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin/security.py +0 -0
  23. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  24. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/identity.js +0 -0
  25. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/main.js +0 -0
  26. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/styles.css +0 -0
  27. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  28. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  29. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/index.html +0 -0
  30. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/admin-web/package.json +0 -0
  31. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/analysis_findings.py +0 -0
  32. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/__init__.py +0 -0
  33. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/v1/__init__.py +0 -0
  34. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/v1/admin.py +0 -0
  35. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/v1/analysis.py +0 -0
  36. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/v1/dashboards.py +0 -0
  37. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/v1/prompts.py +0 -0
  38. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/v1/query.py +0 -0
  39. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api/v1/schema.py +0 -0
  40. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/api_registry.py +0 -0
  41. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/auth_dependencies.py +0 -0
  42. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/auth_hooks.py +0 -0
  43. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/cli.py +0 -0
  44. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/cli_commands.py +0 -0
  45. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/conversations.py +0 -0
  46. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/core/__init__.py +0 -0
  47. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/core/error_handlers.py +0 -0
  48. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/core/schema_cache.py +0 -0
  49. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/core/settings.py +0 -0
  50. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/example_data/__init__.py +0 -0
  51. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  52. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  53. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  54. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/example_database.py +0 -0
  55. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/extension_services.py +0 -0
  56. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/extensions.py +0 -0
  57. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/license.py +0 -0
  58. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/package_updates.py +0 -0
  59. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/py.typed +0 -0
  60. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/query_hooks.py +0 -0
  61. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/server_cli.py +0 -0
  62. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/siem.py +0 -0
  63. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api/tls_http.py +0 -0
  64. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  65. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  66. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api.egg-info/entry_points.txt +0 -0
  67. {gaard_api-0.2.18 → gaard_api-0.2.19}/src/gaard_api.egg-info/top_level.txt +0 -0
  68. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_analysis_api.py +0 -0
  69. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_api_cli.py +0 -0
  70. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_api_registry.py +0 -0
  71. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_conversation_api.py +0 -0
  72. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_database_migrations.py +0 -0
  73. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_error_handlers.py +0 -0
  74. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_license_api.py +0 -0
  75. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_license_service.py +0 -0
  76. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_prompt_runtime.py +0 -0
  77. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_query_error_messages.py +0 -0
  78. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_server_cli.py +0 -0
  79. {gaard_api-0.2.18 → gaard_api-0.2.19}/tests/test_settings.py +0 -0
  80. {gaard_api-0.2.18 → 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.18
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.18"
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
@@ -1872,9 +1884,12 @@ def record_business_logic_learning_suggestion(
1872
1884
  metadata["failed_identifier"] = lesson.failed_identifier
1873
1885
  metadata["repaired_identifier"] = lesson.repaired_identifier
1874
1886
  metadata["business_logic_learning"] = {
1875
- "status": "pending_approval",
1887
+ "status": BUSINESS_LOGIC_STATUS_ACTIVE if suggestion.enabled else "pending_approval",
1876
1888
  "suggestion_id": suggestion.id,
1877
1889
  "message": (
1890
+ "This error matches an already approved business logic rule. "
1891
+ "The rule remains active."
1892
+ if suggestion.enabled else
1878
1893
  "Nauczyłem się propozycji rozwiązania tego błędu, ale musisz ją "
1879
1894
  "zatwierdzić w Sugestiach logiki biznesowej."
1880
1895
  ),
@@ -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.18",
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.18
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,
File without changes
File without changes
File without changes