gaard-api 0.2.17__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.
Files changed (80) hide show
  1. {gaard_api-0.2.17/src/gaard_api.egg-info → gaard_api-0.2.18}/PKG-INFO +1 -1
  2. {gaard_api-0.2.17 → gaard_api-0.2.18}/pyproject.toml +1 -1
  3. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/services.py +12 -3
  4. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/analysis_findings.py +40 -0
  5. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/v1/analysis.py +33 -11
  6. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/main.py +1 -1
  7. {gaard_api-0.2.17 → gaard_api-0.2.18/src/gaard_api.egg-info}/PKG-INFO +1 -1
  8. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_analysis_api.py +140 -0
  9. {gaard_api-0.2.17 → gaard_api-0.2.18}/MANIFEST.in +0 -0
  10. {gaard_api-0.2.17 → gaard_api-0.2.18}/README.md +0 -0
  11. {gaard_api-0.2.17 → gaard_api-0.2.18}/setup.cfg +0 -0
  12. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/__init__.py +0 -0
  13. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/__init__.py +0 -0
  14. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/database.py +0 -0
  15. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/database_initial.sql +0 -0
  16. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/database_legacy_updates.sql +0 -0
  17. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/database_updates.sql +0 -0
  18. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/defaults.py +0 -0
  19. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/metadata.py +0 -0
  20. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/migration_runner.py +0 -0
  21. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/models.py +0 -0
  22. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/prompt_runtime.py +0 -0
  23. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin/security.py +0 -0
  24. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/getgaard.svg +0 -0
  25. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/identity.js +0 -0
  26. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/main.js +0 -0
  27. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/styles.css +0 -0
  28. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack-all.js +0 -0
  29. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/assets/vendor/gridstack/gridstack.min.css +0 -0
  30. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/index.html +0 -0
  31. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/admin-web/package.json +0 -0
  32. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/__init__.py +0 -0
  33. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/v1/__init__.py +0 -0
  34. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/v1/admin.py +0 -0
  35. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/v1/dashboards.py +0 -0
  36. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/v1/prompts.py +0 -0
  37. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/v1/query.py +0 -0
  38. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api/v1/schema.py +0 -0
  39. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/api_registry.py +0 -0
  40. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/auth_dependencies.py +0 -0
  41. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/auth_hooks.py +0 -0
  42. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/cli.py +0 -0
  43. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/cli_commands.py +0 -0
  44. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/conversations.py +0 -0
  45. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/core/__init__.py +0 -0
  46. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/core/error_handlers.py +0 -0
  47. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/core/schema_cache.py +0 -0
  48. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/core/settings.py +0 -0
  49. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/example_data/__init__.py +0 -0
  50. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  51. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  52. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  53. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/example_database.py +0 -0
  54. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/extension_services.py +0 -0
  55. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/extensions.py +0 -0
  56. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/license.py +0 -0
  57. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/package_updates.py +0 -0
  58. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/py.typed +0 -0
  59. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/query_hooks.py +0 -0
  60. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/server_cli.py +0 -0
  61. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/siem.py +0 -0
  62. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api/tls_http.py +0 -0
  63. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api.egg-info/SOURCES.txt +0 -0
  64. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  65. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api.egg-info/entry_points.txt +0 -0
  66. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api.egg-info/requires.txt +0 -0
  67. {gaard_api-0.2.17 → gaard_api-0.2.18}/src/gaard_api.egg-info/top_level.txt +0 -0
  68. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_admin_api.py +0 -0
  69. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_api_cli.py +0 -0
  70. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_api_registry.py +0 -0
  71. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_conversation_api.py +0 -0
  72. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_database_migrations.py +0 -0
  73. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_error_handlers.py +0 -0
  74. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_license_api.py +0 -0
  75. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_license_service.py +0 -0
  76. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_prompt_runtime.py +0 -0
  77. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_query_error_messages.py +0 -0
  78. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_server_cli.py +0 -0
  79. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_settings.py +0 -0
  80. {gaard_api-0.2.17 → gaard_api-0.2.18}/tests/test_tls_http.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.17
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.2.17"
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"
@@ -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 = 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
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,
@@ -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.18",
44
44
  description="Self-hosted AI SQL Gateway for governed natural-language access to relational data.",
45
45
  lifespan=lifespan,
46
46
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.2.17
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
@@ -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