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.
- {gaard_api-0.2.17/src/gaard_api.egg-info → gaard_api-0.2.19}/PKG-INFO +4 -4
- {gaard_api-0.2.17 → gaard_api-0.2.19}/pyproject.toml +4 -4
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database.py +13 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/defaults.py +2 -2
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/prompt_runtime.py +3 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/services.py +28 -4
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/analysis_findings.py +40 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/analysis.py +33 -11
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/main.py +1 -1
- {gaard_api-0.2.17 → gaard_api-0.2.19/src/gaard_api.egg-info}/PKG-INFO +4 -4
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/requires.txt +3 -3
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_admin_api.py +157 -1
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_analysis_api.py +140 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/MANIFEST.in +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/README.md +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/setup.cfg +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database_initial.sql +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/database_updates.sql +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/metadata.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/migration_runner.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/models.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/identity.js +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/main.js +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/styles.css +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/admin.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/dashboards.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/prompts.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/query.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api/v1/schema.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/api_registry.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/auth_dependencies.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/auth_hooks.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/conversations.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/core/settings.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/example_database.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/extensions.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/license.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/package_updates.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/py.typed +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/query_hooks.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/siem.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api/tls_http.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/SOURCES.txt +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_api_cli.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_api_registry.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_conversation_api.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_database_migrations.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_error_handlers.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_license_api.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_license_service.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_prompt_runtime.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_server_cli.py +0 -0
- {gaard_api-0.2.17 → gaard_api-0.2.19}/tests/test_settings.py +0 -0
- {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.
|
|
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.
|
|
8
|
-
Requires-Dist: gaard-connectors==0.2.
|
|
9
|
-
Requires-Dist: gaard-llm==0.2.
|
|
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.
|
|
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.
|
|
14
|
-
"gaard-connectors==0.2.
|
|
15
|
-
"gaard-llm==0.2.
|
|
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.
|
|
32
|
-
7. Do not add
|
|
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 =
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
8
|
-
Requires-Dist: gaard-connectors==0.2.
|
|
9
|
-
Requires-Dist: gaard-llm==0.2.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|