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