gaard-api 0.2.16__tar.gz → 0.2.18__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.16/src/gaard_api.egg-info → gaard_api-0.2.18}/PKG-INFO +5 -5
- {gaard_api-0.2.16 → gaard_api-0.2.18}/pyproject.toml +5 -5
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/services.py +12 -3
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/analysis_findings.py +40 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/v1/analysis.py +70 -12
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/main.py +1 -1
- {gaard_api-0.2.16 → gaard_api-0.2.18/src/gaard_api.egg-info}/PKG-INFO +5 -5
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api.egg-info/requires.txt +4 -4
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_analysis_api.py +176 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/MANIFEST.in +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/README.md +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/setup.cfg +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/__init__.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/__init__.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/database.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/database_initial.sql +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/database_updates.sql +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/defaults.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/metadata.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/migration_runner.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/models.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/prompt_runtime.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin/security.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/identity.js +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/main.js +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/styles.css +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/index.html +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/admin-web/package.json +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/__init__.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/v1/__init__.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/v1/admin.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/v1/dashboards.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/v1/prompts.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/v1/query.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api/v1/schema.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/api_registry.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/auth_dependencies.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/auth_hooks.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/cli.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/cli_commands.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/conversations.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/core/__init__.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/core/error_handlers.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/core/schema_cache.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/core/settings.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/example_data/__init__.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/example_database.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/extension_services.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/extensions.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/license.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/package_updates.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/py.typed +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/query_hooks.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/server_cli.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/siem.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api/tls_http.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api.egg-info/SOURCES.txt +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api.egg-info/dependency_links.txt +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api.egg-info/entry_points.txt +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/src/gaard_api.egg-info/top_level.txt +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_admin_api.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_api_cli.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_api_registry.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_conversation_api.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_database_migrations.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_error_handlers.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_license_api.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_license_service.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_prompt_runtime.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_query_error_messages.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_server_cli.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_settings.py +0 -0
- {gaard_api-0.2.16 → gaard_api-0.2.18}/tests/test_tls_http.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gaard-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.18
|
|
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.
|
|
10
|
-
Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.
|
|
7
|
+
Requires-Dist: gaard-core==0.2.17
|
|
8
|
+
Requires-Dist: gaard-connectors==0.2.17
|
|
9
|
+
Requires-Dist: gaard-llm==0.2.17
|
|
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
|
|
13
13
|
Requires-Dist: pydantic>=2.7.0
|
|
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gaard-api"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.18"
|
|
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.
|
|
16
|
-
"gaard-plugin-api>=0.2.
|
|
13
|
+
"gaard-core==0.2.17",
|
|
14
|
+
"gaard-connectors==0.2.17",
|
|
15
|
+
"gaard-llm==0.2.17",
|
|
16
|
+
"gaard-plugin-api>=0.2.17,<0.3.0",
|
|
17
17
|
"fastapi>=0.111.0",
|
|
18
18
|
"uvicorn[standard]>=0.30.0",
|
|
19
19
|
"pydantic>=2.7.0",
|
|
@@ -1839,10 +1839,19 @@ def upsert_analysis_business_logic_suggestion(
|
|
|
1839
1839
|
session.flush()
|
|
1840
1840
|
return existing
|
|
1841
1841
|
|
|
1842
|
+
should_enable = auto_enable or existing.enabled
|
|
1842
1843
|
existing.source_audit_id = source_audit_id
|
|
1843
|
-
existing.status =
|
|
1844
|
-
|
|
1845
|
-
|
|
1844
|
+
existing.status = (
|
|
1845
|
+
BUSINESS_LOGIC_STATUS_ACTIVE
|
|
1846
|
+
if should_enable
|
|
1847
|
+
else BUSINESS_LOGIC_STATUS_PENDING
|
|
1848
|
+
)
|
|
1849
|
+
existing.safety = (
|
|
1850
|
+
BUSINESS_LOGIC_SAFETY_SAFE
|
|
1851
|
+
if should_enable
|
|
1852
|
+
else BUSINESS_LOGIC_SAFETY_REVIEW
|
|
1853
|
+
)
|
|
1854
|
+
existing.enabled = should_enable
|
|
1846
1855
|
existing.error_category = normalized_type
|
|
1847
1856
|
existing.title = normalized_title
|
|
1848
1857
|
existing.rule_text = normalized_rule
|
|
@@ -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,
|
|
@@ -81,6 +83,27 @@ EVIDENCE_REFERENCE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}:[^\s]{1,190}$
|
|
|
81
83
|
ACTIVE_WORKING_KNOWLEDGE_STATUSES = {"running", "waiting_for_user"}
|
|
82
84
|
|
|
83
85
|
|
|
86
|
+
def normalize_planner_text_list(value: Any) -> list[str]:
|
|
87
|
+
if value is None:
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
items = value if isinstance(value, list) else [value]
|
|
91
|
+
normalized: list[str] = []
|
|
92
|
+
seen: set[str] = set()
|
|
93
|
+
for item in items:
|
|
94
|
+
if item is None or isinstance(item, bool):
|
|
95
|
+
continue
|
|
96
|
+
if not isinstance(item, (str, int, float)):
|
|
97
|
+
continue
|
|
98
|
+
text = str(item).strip()
|
|
99
|
+
if not text or text in seen:
|
|
100
|
+
continue
|
|
101
|
+
seen.add(text)
|
|
102
|
+
normalized.append(text)
|
|
103
|
+
|
|
104
|
+
return normalized
|
|
105
|
+
|
|
106
|
+
|
|
84
107
|
class AnalysisAction(StrEnum):
|
|
85
108
|
ANSWER_FROM_CONTEXT = "answer_from_context"
|
|
86
109
|
ASK_USER = "ask_user"
|
|
@@ -101,6 +124,11 @@ class AnalysisBusinessLogicFinding(BaseModel):
|
|
|
101
124
|
terms: list[str] = Field(default_factory=list)
|
|
102
125
|
confidence: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
103
126
|
|
|
127
|
+
@field_validator("evidence_refs", "terms", mode="before")
|
|
128
|
+
@classmethod
|
|
129
|
+
def normalize_text_lists(cls, value: Any) -> list[str]:
|
|
130
|
+
return normalize_planner_text_list(value)
|
|
131
|
+
|
|
104
132
|
|
|
105
133
|
class AnalysisFindingEvidenceUpdate(BaseModel):
|
|
106
134
|
finding_id: str = Field(min_length=1, max_length=64)
|
|
@@ -109,6 +137,11 @@ class AnalysisFindingEvidenceUpdate(BaseModel):
|
|
|
109
137
|
summary: str = Field(min_length=1, max_length=4_000)
|
|
110
138
|
evidence_refs: list[str] = Field(default_factory=list, max_length=100)
|
|
111
139
|
|
|
140
|
+
@field_validator("evidence_refs", mode="before")
|
|
141
|
+
@classmethod
|
|
142
|
+
def normalize_evidence_refs(cls, value: Any) -> list[str]:
|
|
143
|
+
return normalize_planner_text_list(value)
|
|
144
|
+
|
|
112
145
|
|
|
113
146
|
class AnalysisFindingUsage(BaseModel):
|
|
114
147
|
finding_id: str = Field(min_length=1, max_length=64)
|
|
@@ -118,6 +151,11 @@ class AnalysisFindingUsage(BaseModel):
|
|
|
118
151
|
statement: str = Field(min_length=1, max_length=4_000)
|
|
119
152
|
evidence_refs: list[str] = Field(default_factory=list, max_length=100)
|
|
120
153
|
|
|
154
|
+
@field_validator("evidence_refs", mode="before")
|
|
155
|
+
@classmethod
|
|
156
|
+
def normalize_evidence_refs(cls, value: Any) -> list[str]:
|
|
157
|
+
return normalize_planner_text_list(value)
|
|
158
|
+
|
|
121
159
|
|
|
122
160
|
class AnalysisPlannerDecision(BaseModel):
|
|
123
161
|
action: AnalysisAction
|
|
@@ -1004,7 +1042,7 @@ Business logic learning:
|
|
|
1004
1042
|
- If the latest database observation reveals durable knowledge such as dictionary values, terminology meaning, table meaning, column meaning, or stable domain rules, include business_logic.create_suggestion=true.
|
|
1005
1043
|
- Distinct values, enumerations, statuses, categories, and table/column meaning discovered through ask_database should be saved as business logic findings unless they are clearly one-off metrics for the current answer.
|
|
1006
1044
|
- Do not create business logic for one-off answer values that are only useful for the current final answer.
|
|
1007
|
-
- statement must be a short, self-contained semantic claim. critique must state its main limitation or counterargument. Cite supporting query references in evidence_refs. Never include private chain-of-thought.
|
|
1045
|
+
- statement must be a short, self-contained semantic claim. critique must state its main limitation or counterargument. Cite supporting query references as JSON strings in evidence_refs; use the form "query:<audit_id>" for query audit references. Never include private chain-of-thought.
|
|
1008
1046
|
|
|
1009
1047
|
Investigation-scoped working knowledge:
|
|
1010
1048
|
- session_context.working_knowledge contains externally reviewed semantic evidence for this investigation only.
|
|
@@ -1185,22 +1223,42 @@ def save_business_logic_finding(
|
|
|
1185
1223
|
auto_enable=runtime_config.analysis_auto_enable_business_logic,
|
|
1186
1224
|
actor=f"analysis:{session_id}",
|
|
1187
1225
|
)
|
|
1188
|
-
|
|
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(
|
|
1189
1233
|
session,
|
|
1190
1234
|
investigation_id=session_id,
|
|
1191
1235
|
owner_user_id=record.user_id,
|
|
1192
|
-
connector_id=connector.id,
|
|
1193
1236
|
business_logic_suggestion_id=suggestion.id,
|
|
1194
|
-
statement=statement,
|
|
1195
|
-
finding_type=finding.knowledge_type,
|
|
1196
|
-
confidence=finding.confidence,
|
|
1197
|
-
critique=first_non_empty(
|
|
1198
|
-
finding.critique,
|
|
1199
|
-
"Confirmed only by evidence from this investigation and datasource.",
|
|
1200
|
-
),
|
|
1201
|
-
scope=finding_scope(finding, connector.connector_key, context),
|
|
1202
|
-
evidence_refs=finding_evidence_refs(finding, context),
|
|
1203
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
|
+
)
|
|
1204
1262
|
session.commit()
|
|
1205
1263
|
finding_payload = serialize_analysis_finding(finding_record)
|
|
1206
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.18",
|
|
44
44
|
description="Self-hosted AI SQL Gateway for governed natural-language access to relational data.",
|
|
45
45
|
lifespan=lifespan,
|
|
46
46
|
)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gaard-api
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.18
|
|
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.
|
|
10
|
-
Requires-Dist: gaard-plugin-api<0.3.0,>=0.2.
|
|
7
|
+
Requires-Dist: gaard-core==0.2.17
|
|
8
|
+
Requires-Dist: gaard-connectors==0.2.17
|
|
9
|
+
Requires-Dist: gaard-llm==0.2.17
|
|
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
|
|
13
13
|
Requires-Dist: pydantic>=2.7.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
gaard-core==0.2.
|
|
2
|
-
gaard-connectors==0.2.
|
|
3
|
-
gaard-llm==0.2.
|
|
4
|
-
gaard-plugin-api<0.3.0,>=0.2.
|
|
1
|
+
gaard-core==0.2.17
|
|
2
|
+
gaard-connectors==0.2.17
|
|
3
|
+
gaard-llm==0.2.17
|
|
4
|
+
gaard-plugin-api<0.3.0,>=0.2.17
|
|
5
5
|
fastapi>=0.111.0
|
|
6
6
|
uvicorn[standard]>=0.30.0
|
|
7
7
|
pydantic>=2.7.0
|
|
@@ -134,6 +134,42 @@ def create_active_default_datasource() -> DatasourceConnector:
|
|
|
134
134
|
return connector
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
def test_analysis_planner_normalizes_model_generated_text_lists() -> None:
|
|
138
|
+
decision = analysis_module.parse_analysis_decision(
|
|
139
|
+
json.dumps(
|
|
140
|
+
{
|
|
141
|
+
"action": "answer_from_context",
|
|
142
|
+
"business_logic": {
|
|
143
|
+
"evidence_refs": [156, " 157 ", 156, None, False, {"audit_id": 158}],
|
|
144
|
+
"terms": [" cardiology ", 23, "cardiology", None],
|
|
145
|
+
},
|
|
146
|
+
"finding_updates": [
|
|
147
|
+
{
|
|
148
|
+
"finding_id": "finding-1",
|
|
149
|
+
"effect": "strengthened",
|
|
150
|
+
"confidence": 0.9,
|
|
151
|
+
"summary": "Confirmed by another query.",
|
|
152
|
+
"evidence_refs": 158,
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
"finding_usages": [
|
|
156
|
+
{
|
|
157
|
+
"finding_id": "finding-1",
|
|
158
|
+
"usage": "used_for_query",
|
|
159
|
+
"statement": "Used to constrain the query.",
|
|
160
|
+
"evidence_refs": "query:159",
|
|
161
|
+
}
|
|
162
|
+
],
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert decision.business_logic.evidence_refs == ["156", "157"]
|
|
168
|
+
assert decision.business_logic.terms == ["cardiology", "23"]
|
|
169
|
+
assert decision.finding_updates[0].evidence_refs == ["158"]
|
|
170
|
+
assert decision.finding_usages[0].evidence_refs == ["query:159"]
|
|
171
|
+
|
|
172
|
+
|
|
137
173
|
def test_analysis_stream_runs_final_query_and_persists_session(
|
|
138
174
|
analysis_client: TestClient,
|
|
139
175
|
) -> None:
|
|
@@ -340,6 +376,146 @@ def test_analysis_database_step_can_record_business_logic_suggestion(
|
|
|
340
376
|
assert suggestions[0].error_category == "analysis.dictionary_value"
|
|
341
377
|
|
|
342
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
|
+
|
|
343
519
|
def test_investigation_finding_review_lifecycle_and_scoped_working_knowledge(
|
|
344
520
|
analysis_client: TestClient,
|
|
345
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|