sediment-api 0.1.0__py3-none-any.whl
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.
- sediment_api/__init__.py +6 -0
- sediment_api/config.py +230 -0
- sediment_api/database.py +43 -0
- sediment_api/deps.py +191 -0
- sediment_api/main.py +212 -0
- sediment_api/mirror_gc.py +151 -0
- sediment_api/reports/__init__.py +9 -0
- sediment_api/reports/abandonment_report.py +123 -0
- sediment_api/reports/attribution_share_report.py +298 -0
- sediment_api/reports/dataset_diagnostics.py +311 -0
- sediment_api/reports/label_confidence_inspection.py +438 -0
- sediment_api/reports/lifecycle_report.py +73 -0
- sediment_api/reports/merge_retention_report.py +165 -0
- sediment_api/reports/model_report.py +1534 -0
- sediment_api/reports/precision_report.py +182 -0
- sediment_api/reports/recovery_yield_report.py +132 -0
- sediment_api/routers/__init__.py +3 -0
- sediment_api/routers/ci_vendor.py +149 -0
- sediment_api/routers/forge.py +452 -0
- sediment_api/routers/gateway.py +137 -0
- sediment_api/routers/otlp.py +120 -0
- sediment_api/routers/query.py +1010 -0
- sediment_api/routers/reports.py +116 -0
- sediment_api/routers/v1.py +170 -0
- sediment_api/services/__init__.py +2 -0
- sediment_api/services/operational_reports.py +391 -0
- sediment_api/worker.py +242 -0
- sediment_api/workers.py +382 -0
- sediment_api-0.1.0.dist-info/METADATA +20 -0
- sediment_api-0.1.0.dist-info/RECORD +32 -0
- sediment_api-0.1.0.dist-info/WHEEL +4 -0
- sediment_api-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Read-only CI and Session investigations over captured Facts.
|
|
3
|
+
|
|
4
|
+
Stored observations establish Session-to-commit identity. Optional mirror-based
|
|
5
|
+
call/file Attribution remains explicitly inferred and is recomputed per request.
|
|
6
|
+
No investigation result is persisted (ADR 0001).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from sediment_derive.session_commit import bind_session_commit_keys_result
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import binascii
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from dataclasses import asdict, dataclass, field, is_dataclass
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from typing import Any, Literal
|
|
22
|
+
from urllib.parse import urlencode
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
25
|
+
from fastapi.responses import Response
|
|
26
|
+
from pydantic import TypeAdapter
|
|
27
|
+
from sediment_core import (
|
|
28
|
+
AgentHarness,
|
|
29
|
+
CIProvider,
|
|
30
|
+
CIResult,
|
|
31
|
+
FactStore,
|
|
32
|
+
ForgeProvider,
|
|
33
|
+
ForgeHost,
|
|
34
|
+
ProviderRepositoryId,
|
|
35
|
+
RequiredRepoSlug,
|
|
36
|
+
RepositoryReadAmbiguous,
|
|
37
|
+
OperationalReportLimitExceeded,
|
|
38
|
+
GatewayProvider,
|
|
39
|
+
InteractionMode,
|
|
40
|
+
NonEmptyId,
|
|
41
|
+
WorkflowName,
|
|
42
|
+
normalize_commit_sha,
|
|
43
|
+
)
|
|
44
|
+
from sediment_core.models import AwareDatetime
|
|
45
|
+
from sediment_core.store import (
|
|
46
|
+
CIOutcomeProjection,
|
|
47
|
+
PushProjection,
|
|
48
|
+
SessionDossierProjection,
|
|
49
|
+
SessionTimelineProjection,
|
|
50
|
+
)
|
|
51
|
+
from sediment_derive import (
|
|
52
|
+
AttributionSource,
|
|
53
|
+
MirrorManager,
|
|
54
|
+
InferenceCall,
|
|
55
|
+
derive_attributions,
|
|
56
|
+
inference_fact_id,
|
|
57
|
+
inference_gateway_provider,
|
|
58
|
+
inference_model,
|
|
59
|
+
inference_user_id,
|
|
60
|
+
join_decisions_by_call_id,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
from sediment_derive.repository_context import read_repository_context
|
|
64
|
+
from sediment_derive.repository_identity import (
|
|
65
|
+
RepositoryIdentity,
|
|
66
|
+
RepositoryIdentitySkipReason,
|
|
67
|
+
IdentifiedRepositoryKey,
|
|
68
|
+
CommitKey,
|
|
69
|
+
RepositoryKey,
|
|
70
|
+
repository_identity_of,
|
|
71
|
+
repository_read_key,
|
|
72
|
+
repository_sort_key,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
from ..config import settings
|
|
76
|
+
from ..deps import get_store, verify_operator_token
|
|
77
|
+
|
|
78
|
+
_SESSION_DOSSIER_LIMIT = 500
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
router = APIRouter(tags=["query"])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_QUERY_REPRESENTATION_RESPONSES = {
|
|
85
|
+
503: {
|
|
86
|
+
"description": "Database unavailable, or work capacity or execution budget exceeded."
|
|
87
|
+
},
|
|
88
|
+
409: {
|
|
89
|
+
"description": "Repository selection is ambiguous, complete evidence exceeds its bound, or query content contains a non-finite number. The response carries a closed detail.reason."
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _validate_query_numbers(value: Any) -> None:
|
|
95
|
+
"""Decline only non-finite numbers, including nested content and object keys."""
|
|
96
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
97
|
+
raise HTTPException(status_code=409, detail={"reason": "non_finite_number"})
|
|
98
|
+
if isinstance(value, dict):
|
|
99
|
+
for key, item in value.items():
|
|
100
|
+
_validate_query_numbers(key)
|
|
101
|
+
_validate_query_numbers(item)
|
|
102
|
+
elif isinstance(value, (list, tuple)):
|
|
103
|
+
for item in value:
|
|
104
|
+
_validate_query_numbers(item)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _query_response(
|
|
108
|
+
value: Any, contract: Any, *, exclude_none: bool = False
|
|
109
|
+
) -> Response:
|
|
110
|
+
"""Validate the declared response, then preserve content through ASCII JSON.
|
|
111
|
+
|
|
112
|
+
Pydantic's JSON serializer requires UTF-8 for every string. Python-mode
|
|
113
|
+
serialization keeps canonical descriptive surrogates for JSON escaping.
|
|
114
|
+
"""
|
|
115
|
+
adapter = TypeAdapter(contract)
|
|
116
|
+
validated = adapter.validate_python(asdict(value) if is_dataclass(value) else value)
|
|
117
|
+
payload = adapter.dump_python(validated, mode="python", exclude_none=exclude_none)
|
|
118
|
+
_validate_query_numbers(payload)
|
|
119
|
+
|
|
120
|
+
def encode_timestamp(item: Any) -> str:
|
|
121
|
+
if isinstance(item, datetime):
|
|
122
|
+
return TypeAdapter(datetime).dump_python(item, mode="json")
|
|
123
|
+
raise TypeError(f"unsupported query response value: {type(item).__name__}")
|
|
124
|
+
|
|
125
|
+
return Response(
|
|
126
|
+
content=json.dumps(
|
|
127
|
+
payload,
|
|
128
|
+
ensure_ascii=True,
|
|
129
|
+
allow_nan=False,
|
|
130
|
+
separators=(",", ":"),
|
|
131
|
+
default=encode_timestamp,
|
|
132
|
+
),
|
|
133
|
+
media_type="application/json",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class CIOutcomeSummary:
|
|
139
|
+
outcome_id: str
|
|
140
|
+
provider: CIProvider
|
|
141
|
+
run_id: str
|
|
142
|
+
run_attempt: int | None
|
|
143
|
+
repo: str
|
|
144
|
+
commit_sha: str
|
|
145
|
+
branch: str
|
|
146
|
+
result: CIResult
|
|
147
|
+
workflow_name: str
|
|
148
|
+
workflow_id: str | None
|
|
149
|
+
run_url: str | None
|
|
150
|
+
provider_result: str | None
|
|
151
|
+
error_type: str | None
|
|
152
|
+
reason: str | None
|
|
153
|
+
pr_number: int | None
|
|
154
|
+
captured_at: datetime
|
|
155
|
+
commit_query: str
|
|
156
|
+
repository_identity: RepositoryIdentity | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class CIOutcomeFound:
|
|
161
|
+
found: Literal[True]
|
|
162
|
+
outcome: CIOutcomeSummary
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class CIOutcomeNotFound:
|
|
167
|
+
found: Literal[False]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True)
|
|
171
|
+
class CIOutcomePage:
|
|
172
|
+
result: CIResult
|
|
173
|
+
outcomes: list[CIOutcomeSummary]
|
|
174
|
+
next_cursor: str | None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class SessionFactCoverageResponse:
|
|
179
|
+
total: int
|
|
180
|
+
visible: int
|
|
181
|
+
quarantined: int
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True)
|
|
185
|
+
class SessionCoverageResponse:
|
|
186
|
+
inference_calls: SessionFactCoverageResponse
|
|
187
|
+
developer_decisions: SessionFactCoverageResponse
|
|
188
|
+
edit_observations: SessionFactCoverageResponse
|
|
189
|
+
rejected_edits: SessionFactCoverageResponse
|
|
190
|
+
retry_linkages: SessionFactCoverageResponse
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
SessionEventType = Literal[
|
|
194
|
+
"inference_call",
|
|
195
|
+
"developer_decision",
|
|
196
|
+
"edit_observation",
|
|
197
|
+
"rejected_edit",
|
|
198
|
+
"retry_linkage",
|
|
199
|
+
]
|
|
200
|
+
SessionGap = Literal[
|
|
201
|
+
"no_inference_calls_observed",
|
|
202
|
+
"no_developer_decisions_observed",
|
|
203
|
+
"no_edit_observations_observed",
|
|
204
|
+
"no_rejected_edits_observed",
|
|
205
|
+
"no_retry_linkages_observed",
|
|
206
|
+
"session_commit_unobserved",
|
|
207
|
+
"no_exact_push_receipts_observed",
|
|
208
|
+
"no_ci_outcomes_observed",
|
|
209
|
+
]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class SessionTimelineEvent:
|
|
214
|
+
event_type: SessionEventType
|
|
215
|
+
fact_id: str
|
|
216
|
+
occurred_at: datetime
|
|
217
|
+
gateway_provider: GatewayProvider | None = None
|
|
218
|
+
model_provider: str | None = None
|
|
219
|
+
model: str | None = None
|
|
220
|
+
input_tokens: int | None = None
|
|
221
|
+
output_tokens: int | None = None
|
|
222
|
+
duration_ms: int | None = None
|
|
223
|
+
model_call_id: str | None = None
|
|
224
|
+
agent_harness: AgentHarness | None = None
|
|
225
|
+
accepted: bool | None = None
|
|
226
|
+
explicit: bool | None = None
|
|
227
|
+
interaction_mode: InteractionMode | None = None
|
|
228
|
+
has_file_path: bool | None = None
|
|
229
|
+
call_id: str | None = None
|
|
230
|
+
commit_sha: str | None = None
|
|
231
|
+
external_lines_added_present: bool | None = None
|
|
232
|
+
external_lines_removed_present: bool | None = None
|
|
233
|
+
external_lines_added: int | None = None
|
|
234
|
+
external_lines_removed: int | None = None
|
|
235
|
+
tool_name: str | None = None
|
|
236
|
+
rejected_call_id: str | None = None
|
|
237
|
+
accepted_call_id: str | None = None
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class AttributedCommitSummary:
|
|
242
|
+
"""Observation-backed Session edge with compatibility Attribution fields.
|
|
243
|
+
|
|
244
|
+
``attribution_sources`` is empty. Similarity extrema and ``attributed_files``
|
|
245
|
+
are unavailable (None), so the Session dossier omits them from JSON.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
repo: str
|
|
249
|
+
commit_sha: str
|
|
250
|
+
attribution_sources: list[AttributionSource]
|
|
251
|
+
minimum_similarity: float | None
|
|
252
|
+
maximum_similarity: float | None
|
|
253
|
+
attributed_files: int | None
|
|
254
|
+
session_commit_observation_ids: tuple[NonEmptyId, ...] = ()
|
|
255
|
+
repository_identity: RepositoryIdentity | None = None
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@dataclass(frozen=True)
|
|
259
|
+
class PushSummary:
|
|
260
|
+
push_id: str
|
|
261
|
+
provider: ForgeProvider
|
|
262
|
+
repo: str
|
|
263
|
+
ref: str
|
|
264
|
+
before_sha: str
|
|
265
|
+
after_sha: str
|
|
266
|
+
forced: bool
|
|
267
|
+
captured_at: datetime
|
|
268
|
+
match_kind: Literal["after_sha"] = "after_sha"
|
|
269
|
+
repository_identity: RepositoryIdentity | None = None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class SessionDossierFound:
|
|
274
|
+
found: Literal[True]
|
|
275
|
+
session_id: str
|
|
276
|
+
first_observed_at: datetime
|
|
277
|
+
last_observed_at: datetime
|
|
278
|
+
coverage: SessionCoverageResponse
|
|
279
|
+
omitted_events: int
|
|
280
|
+
gaps: list[SessionGap]
|
|
281
|
+
timeline: list[SessionTimelineEvent]
|
|
282
|
+
attributed_commits: list[AttributedCommitSummary]
|
|
283
|
+
pushes: list[PushSummary]
|
|
284
|
+
ci_outcomes: list[SessionCIOutcomeSummary]
|
|
285
|
+
session_commit_unobserved: int = 0
|
|
286
|
+
repository_skipped: dict[RepositoryIdentitySkipReason, int] = field(
|
|
287
|
+
default_factory=dict
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class SessionDossierNotFound:
|
|
293
|
+
found: Literal[False]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True)
|
|
297
|
+
class SessionCIOutcomeSummary:
|
|
298
|
+
"""CI navigation metadata without free-form provider content."""
|
|
299
|
+
|
|
300
|
+
outcome_id: str
|
|
301
|
+
provider: CIProvider
|
|
302
|
+
run_id: str
|
|
303
|
+
run_attempt: int | None
|
|
304
|
+
repo: str
|
|
305
|
+
commit_sha: str
|
|
306
|
+
branch: str
|
|
307
|
+
result: CIResult
|
|
308
|
+
workflow_name: str
|
|
309
|
+
workflow_id: str | None
|
|
310
|
+
run_url: str | None
|
|
311
|
+
provider_result: str | None
|
|
312
|
+
error_type: str | None
|
|
313
|
+
pr_number: int | None
|
|
314
|
+
captured_at: datetime
|
|
315
|
+
commit_query: str
|
|
316
|
+
repository_identity: RepositoryIdentity | None = None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _commit_query_link(outcome: CIOutcomeProjection, as_of: datetime | None) -> str:
|
|
320
|
+
identity = repository_identity_of(outcome)
|
|
321
|
+
params = {}
|
|
322
|
+
if identity is not None:
|
|
323
|
+
params.update(
|
|
324
|
+
repository_provider=identity.provider.value,
|
|
325
|
+
repository_host=identity.host,
|
|
326
|
+
repository_id=identity.repository_id,
|
|
327
|
+
)
|
|
328
|
+
if as_of is not None:
|
|
329
|
+
params["as_of"] = as_of.astimezone(UTC).isoformat()
|
|
330
|
+
path = f"/query/commit/{outcome.commit_sha}"
|
|
331
|
+
return f"{path}?{urlencode(params)}" if params else path
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _selector_identity(provider, host, repository_id) -> RepositoryIdentity | None:
|
|
335
|
+
supplied = (provider is not None, host is not None, repository_id is not None)
|
|
336
|
+
if any(supplied) and not all(supplied):
|
|
337
|
+
raise HTTPException(
|
|
338
|
+
status_code=422, detail="repository identity requires all three components"
|
|
339
|
+
)
|
|
340
|
+
return RepositoryIdentity(provider, host, repository_id) if all(supplied) else None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _query_repository(context, repo, identity) -> RepositoryKey | None:
|
|
344
|
+
try:
|
|
345
|
+
return context.select_repository(
|
|
346
|
+
settings.org_id, repo=repo, repository_identity=identity
|
|
347
|
+
)
|
|
348
|
+
except RepositoryReadAmbiguous:
|
|
349
|
+
raise HTTPException(
|
|
350
|
+
status_code=409, detail={"reason": "repository_selector_ambiguous"}
|
|
351
|
+
) from None
|
|
352
|
+
except ValueError:
|
|
353
|
+
raise HTTPException(
|
|
354
|
+
status_code=422,
|
|
355
|
+
detail="repository selector conflicts with declared evidence",
|
|
356
|
+
) from None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _ci_summary(
|
|
360
|
+
outcome: CIOutcomeProjection, *, as_of: datetime | None = None
|
|
361
|
+
) -> CIOutcomeSummary:
|
|
362
|
+
return CIOutcomeSummary(
|
|
363
|
+
outcome_id=outcome.outcome_id,
|
|
364
|
+
provider=outcome.provider,
|
|
365
|
+
run_id=outcome.run_id,
|
|
366
|
+
run_attempt=outcome.run_attempt,
|
|
367
|
+
repo=outcome.repo,
|
|
368
|
+
commit_sha=outcome.commit_sha,
|
|
369
|
+
branch=outcome.branch,
|
|
370
|
+
result=outcome.result,
|
|
371
|
+
workflow_name=outcome.workflow_name,
|
|
372
|
+
workflow_id=outcome.workflow_id,
|
|
373
|
+
run_url=outcome.run_url,
|
|
374
|
+
provider_result=outcome.provider_result,
|
|
375
|
+
error_type=outcome.error_type,
|
|
376
|
+
reason=outcome.reason,
|
|
377
|
+
pr_number=outcome.pr_number,
|
|
378
|
+
captured_at=outcome.captured_at,
|
|
379
|
+
commit_query=_commit_query_link(outcome, as_of),
|
|
380
|
+
repository_identity=repository_identity_of(outcome),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _session_ci_summary(
|
|
385
|
+
outcome: CIOutcomeProjection, *, as_of: datetime | None = None
|
|
386
|
+
) -> SessionCIOutcomeSummary:
|
|
387
|
+
return SessionCIOutcomeSummary(
|
|
388
|
+
outcome_id=outcome.outcome_id,
|
|
389
|
+
provider=outcome.provider,
|
|
390
|
+
run_id=outcome.run_id,
|
|
391
|
+
run_attempt=outcome.run_attempt,
|
|
392
|
+
repo=outcome.repo,
|
|
393
|
+
commit_sha=outcome.commit_sha,
|
|
394
|
+
branch=outcome.branch,
|
|
395
|
+
result=outcome.result,
|
|
396
|
+
workflow_name=outcome.workflow_name,
|
|
397
|
+
workflow_id=outcome.workflow_id,
|
|
398
|
+
run_url=outcome.run_url,
|
|
399
|
+
provider_result=outcome.provider_result,
|
|
400
|
+
error_type=outcome.error_type,
|
|
401
|
+
pr_number=outcome.pr_number,
|
|
402
|
+
captured_at=outcome.captured_at,
|
|
403
|
+
commit_query=_commit_query_link(outcome, as_of),
|
|
404
|
+
repository_identity=repository_identity_of(outcome),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _encode_ci_cursor(captured_at: datetime, outcome_id: str, filter_key: str) -> str:
|
|
409
|
+
payload = json.dumps(
|
|
410
|
+
[captured_at.isoformat(), outcome_id, filter_key], separators=(",", ":")
|
|
411
|
+
).encode()
|
|
412
|
+
return base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _decode_ci_cursor(cursor: str, filter_key: str) -> tuple[datetime, str]:
|
|
416
|
+
try:
|
|
417
|
+
padding = "=" * (-len(cursor) % 4)
|
|
418
|
+
captured_at, outcome_id, cursor_filter_key = json.loads(
|
|
419
|
+
base64.urlsafe_b64decode(cursor + padding).decode()
|
|
420
|
+
)
|
|
421
|
+
parsed = datetime.fromisoformat(captured_at)
|
|
422
|
+
if parsed.tzinfo is None:
|
|
423
|
+
raise ValueError
|
|
424
|
+
outcome_id = TypeAdapter(NonEmptyId).validate_python(outcome_id)
|
|
425
|
+
if cursor_filter_key != filter_key:
|
|
426
|
+
raise HTTPException(
|
|
427
|
+
status_code=422,
|
|
428
|
+
detail="CI outcome cursor doesn't match filters",
|
|
429
|
+
)
|
|
430
|
+
return parsed, outcome_id
|
|
431
|
+
except HTTPException:
|
|
432
|
+
raise
|
|
433
|
+
except (
|
|
434
|
+
ValueError,
|
|
435
|
+
TypeError,
|
|
436
|
+
binascii.Error,
|
|
437
|
+
json.JSONDecodeError,
|
|
438
|
+
UnicodeDecodeError,
|
|
439
|
+
):
|
|
440
|
+
raise HTTPException(
|
|
441
|
+
status_code=422, detail="invalid CI outcome cursor"
|
|
442
|
+
) from None
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _ci_filter_key(
|
|
446
|
+
*,
|
|
447
|
+
repository_key: RepositoryKey,
|
|
448
|
+
org_id: str,
|
|
449
|
+
as_of: datetime,
|
|
450
|
+
quarantine_revision: int,
|
|
451
|
+
captured_after: datetime,
|
|
452
|
+
captured_before: datetime,
|
|
453
|
+
result: CIResult,
|
|
454
|
+
workflow_name: str | None,
|
|
455
|
+
pr_number: int | None,
|
|
456
|
+
) -> str:
|
|
457
|
+
return json.dumps(
|
|
458
|
+
[
|
|
459
|
+
org_id,
|
|
460
|
+
repository_sort_key(repository_key),
|
|
461
|
+
as_of.astimezone(UTC).isoformat(),
|
|
462
|
+
quarantine_revision,
|
|
463
|
+
captured_after.astimezone(UTC).isoformat(),
|
|
464
|
+
captured_before.astimezone(UTC).isoformat(),
|
|
465
|
+
result.value,
|
|
466
|
+
workflow_name,
|
|
467
|
+
pr_number,
|
|
468
|
+
],
|
|
469
|
+
separators=(",", ":"),
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _timeline_event(row: SessionTimelineProjection) -> SessionTimelineEvent:
|
|
474
|
+
return SessionTimelineEvent(**row.__dict__)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _fact_coverage(
|
|
478
|
+
dossier: SessionDossierProjection, fact_type: str
|
|
479
|
+
) -> SessionFactCoverageResponse:
|
|
480
|
+
coverage = dossier.coverage[fact_type]
|
|
481
|
+
return SessionFactCoverageResponse(
|
|
482
|
+
total=coverage.total,
|
|
483
|
+
visible=coverage.visible,
|
|
484
|
+
quarantined=coverage.quarantined,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _push_summary(push: PushProjection) -> PushSummary:
|
|
489
|
+
return PushSummary(
|
|
490
|
+
push_id=push.push_id,
|
|
491
|
+
provider=ForgeProvider(push.provider),
|
|
492
|
+
repo=push.repo,
|
|
493
|
+
ref=push.ref,
|
|
494
|
+
before_sha=push.before_sha,
|
|
495
|
+
after_sha=push.after_sha,
|
|
496
|
+
forced=push.forced,
|
|
497
|
+
captured_at=push.captured_at,
|
|
498
|
+
repository_identity=repository_identity_of(push),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@router.get(
|
|
503
|
+
"/ci/outcome",
|
|
504
|
+
response_model=CIOutcomeFound | CIOutcomeNotFound,
|
|
505
|
+
responses=_QUERY_REPRESENTATION_RESPONSES,
|
|
506
|
+
)
|
|
507
|
+
def query_ci_outcome(
|
|
508
|
+
provider: CIProvider,
|
|
509
|
+
run_id: NonEmptyId,
|
|
510
|
+
run_attempt: int | None = Query(default=None, ge=1),
|
|
511
|
+
repo: RequiredRepoSlug | None = None,
|
|
512
|
+
repository_provider: ForgeProvider | None = None,
|
|
513
|
+
repository_host: ForgeHost | None = None,
|
|
514
|
+
repository_id: ProviderRepositoryId | None = None,
|
|
515
|
+
as_of: AwareDatetime | None = None,
|
|
516
|
+
_: None = Depends(verify_operator_token),
|
|
517
|
+
store: FactStore = Depends(get_store),
|
|
518
|
+
) -> Response:
|
|
519
|
+
"""Return one exact CI run receipt; ambiguous forge namespaces require a selector."""
|
|
520
|
+
identity = _selector_identity(repository_provider, repository_host, repository_id)
|
|
521
|
+
try:
|
|
522
|
+
with store.read_snapshot() as snapshot:
|
|
523
|
+
context = read_repository_context(snapshot, settings.org_id, as_of=as_of)
|
|
524
|
+
key = (
|
|
525
|
+
_query_repository(context, repo, identity)
|
|
526
|
+
if repo is not None or identity is not None
|
|
527
|
+
else None
|
|
528
|
+
)
|
|
529
|
+
if key is None and (repo is not None or identity is not None):
|
|
530
|
+
outcome = None
|
|
531
|
+
else:
|
|
532
|
+
outcome = snapshot.read_ci_outcome_by_run(
|
|
533
|
+
settings.org_id,
|
|
534
|
+
provider,
|
|
535
|
+
run_id,
|
|
536
|
+
run_attempt=run_attempt,
|
|
537
|
+
repository_key=repository_read_key(key)
|
|
538
|
+
if key is not None
|
|
539
|
+
else None,
|
|
540
|
+
captured_through=context.as_of,
|
|
541
|
+
)
|
|
542
|
+
except RepositoryReadAmbiguous:
|
|
543
|
+
raise HTTPException(
|
|
544
|
+
status_code=409, detail={"reason": "repository_selector_ambiguous"}
|
|
545
|
+
) from None
|
|
546
|
+
except OperationalReportLimitExceeded:
|
|
547
|
+
raise HTTPException(
|
|
548
|
+
status_code=409, detail={"reason": "repository_evidence_limit"}
|
|
549
|
+
) from None
|
|
550
|
+
if outcome is None:
|
|
551
|
+
return _query_response(
|
|
552
|
+
CIOutcomeNotFound(found=False), CIOutcomeFound | CIOutcomeNotFound
|
|
553
|
+
)
|
|
554
|
+
return _query_response(
|
|
555
|
+
CIOutcomeFound(found=True, outcome=_ci_summary(outcome, as_of=context.as_of)),
|
|
556
|
+
CIOutcomeFound | CIOutcomeNotFound,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@router.get(
|
|
561
|
+
"/ci/failures",
|
|
562
|
+
response_model=CIOutcomePage,
|
|
563
|
+
responses=_QUERY_REPRESENTATION_RESPONSES,
|
|
564
|
+
)
|
|
565
|
+
def query_ci_failures(
|
|
566
|
+
captured_after: AwareDatetime,
|
|
567
|
+
captured_before: AwareDatetime,
|
|
568
|
+
repo: RequiredRepoSlug | None = None,
|
|
569
|
+
repository_provider: ForgeProvider | None = None,
|
|
570
|
+
repository_host: ForgeHost | None = None,
|
|
571
|
+
repository_id: ProviderRepositoryId | None = None,
|
|
572
|
+
as_of: AwareDatetime | None = None,
|
|
573
|
+
result: CIResult = CIResult.FAILED,
|
|
574
|
+
workflow_name: WorkflowName | None = None,
|
|
575
|
+
pr_number: int | None = Query(default=None, ge=1),
|
|
576
|
+
limit: int = Query(default=50, ge=1, le=100),
|
|
577
|
+
cursor: str | None = None,
|
|
578
|
+
_: None = Depends(verify_operator_token),
|
|
579
|
+
store: FactStore = Depends(get_store),
|
|
580
|
+
) -> Response:
|
|
581
|
+
"""Page exact CI receipts under one repository identity and historical boundary.
|
|
582
|
+
|
|
583
|
+
The default identity boundary is ``captured_before``. Capture bounds remain
|
|
584
|
+
half-open; an explicit ``as_of`` adds an inclusive evidence ceiling. Cursors
|
|
585
|
+
bind organization, qualified repository, boundary, filters, and quarantine
|
|
586
|
+
revision. They don't preserve a database transaction between requests.
|
|
587
|
+
"""
|
|
588
|
+
if captured_after >= captured_before:
|
|
589
|
+
raise HTTPException(
|
|
590
|
+
status_code=422, detail="captured_after must precede captured_before"
|
|
591
|
+
)
|
|
592
|
+
identity = _selector_identity(repository_provider, repository_host, repository_id)
|
|
593
|
+
if repo is None and identity is None:
|
|
594
|
+
raise HTTPException(
|
|
595
|
+
status_code=422, detail="CI summaries require a repository selector"
|
|
596
|
+
)
|
|
597
|
+
boundary = as_of if as_of is not None else captured_before
|
|
598
|
+
try:
|
|
599
|
+
with store.read_snapshot() as snapshot:
|
|
600
|
+
context = read_repository_context(snapshot, settings.org_id, as_of=boundary)
|
|
601
|
+
key = _query_repository(context, repo, identity)
|
|
602
|
+
if key is None:
|
|
603
|
+
if cursor is not None:
|
|
604
|
+
raise HTTPException(
|
|
605
|
+
status_code=422,
|
|
606
|
+
detail="CI outcome cursor doesn't match filters",
|
|
607
|
+
)
|
|
608
|
+
return _query_response(
|
|
609
|
+
CIOutcomePage(result=result, outcomes=[], next_cursor=None),
|
|
610
|
+
CIOutcomePage,
|
|
611
|
+
)
|
|
612
|
+
filter_key = _ci_filter_key(
|
|
613
|
+
repository_key=key,
|
|
614
|
+
org_id=settings.org_id,
|
|
615
|
+
as_of=context.as_of,
|
|
616
|
+
quarantine_revision=snapshot.quarantine_revision(settings.org_id),
|
|
617
|
+
captured_after=captured_after,
|
|
618
|
+
captured_before=captured_before,
|
|
619
|
+
result=result,
|
|
620
|
+
workflow_name=workflow_name,
|
|
621
|
+
pr_number=pr_number,
|
|
622
|
+
)
|
|
623
|
+
before = (
|
|
624
|
+
_decode_ci_cursor(cursor, filter_key) if cursor is not None else None
|
|
625
|
+
)
|
|
626
|
+
outcomes = snapshot.read_ci_outcome_summaries(
|
|
627
|
+
settings.org_id,
|
|
628
|
+
repository_key=repository_read_key(key),
|
|
629
|
+
result=result,
|
|
630
|
+
captured_between=(captured_after, captured_before),
|
|
631
|
+
captured_through=context.as_of,
|
|
632
|
+
before=before,
|
|
633
|
+
workflow_name=workflow_name,
|
|
634
|
+
pr_number=pr_number,
|
|
635
|
+
limit=limit + 1,
|
|
636
|
+
)
|
|
637
|
+
except OperationalReportLimitExceeded:
|
|
638
|
+
raise HTTPException(
|
|
639
|
+
status_code=409, detail={"reason": "repository_evidence_limit"}
|
|
640
|
+
) from None
|
|
641
|
+
page = outcomes[:limit]
|
|
642
|
+
next_cursor = None
|
|
643
|
+
if len(outcomes) > limit:
|
|
644
|
+
last = page[-1]
|
|
645
|
+
next_cursor = _encode_ci_cursor(last.captured_at, last.outcome_id, filter_key)
|
|
646
|
+
return _query_response(
|
|
647
|
+
CIOutcomePage(
|
|
648
|
+
result=result,
|
|
649
|
+
outcomes=[_ci_summary(outcome, as_of=context.as_of) for outcome in page],
|
|
650
|
+
next_cursor=next_cursor,
|
|
651
|
+
),
|
|
652
|
+
CIOutcomePage,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
@router.get(
|
|
657
|
+
"/session/{session_id}",
|
|
658
|
+
response_model=SessionDossierFound | SessionDossierNotFound,
|
|
659
|
+
response_model_exclude_none=True,
|
|
660
|
+
responses={
|
|
661
|
+
503: {
|
|
662
|
+
"description": "Database unavailable, or work capacity or execution budget exceeded."
|
|
663
|
+
},
|
|
664
|
+
409: {
|
|
665
|
+
"description": "Session evidence exceeds the bounded read, or query content contains a non-finite number (detail.reason: non_finite_number)."
|
|
666
|
+
},
|
|
667
|
+
},
|
|
668
|
+
)
|
|
669
|
+
async def query_session_dossier(
|
|
670
|
+
session_id: NonEmptyId,
|
|
671
|
+
request: Request,
|
|
672
|
+
_: None = Depends(verify_operator_token),
|
|
673
|
+
) -> Response:
|
|
674
|
+
"""Return one bounded, metadata-only Session evidence dossier.
|
|
675
|
+
|
|
676
|
+
Observation-backed commit edges keep ``attribution_sources`` empty. Similarity
|
|
677
|
+
extrema and ``attributed_files`` are unavailable and omitted from JSON.
|
|
678
|
+
"""
|
|
679
|
+
return await request.app.state.workers.run("session", {"session_id": session_id})
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _run_session_query(
|
|
683
|
+
session_id: str, store: FactStore
|
|
684
|
+
) -> SessionDossierFound | SessionDossierNotFound:
|
|
685
|
+
with store.read_snapshot() as snapshot:
|
|
686
|
+
dossier = snapshot.read_session_dossier(
|
|
687
|
+
settings.org_id, session_id, limit=_SESSION_DOSSIER_LIMIT
|
|
688
|
+
)
|
|
689
|
+
if dossier is None:
|
|
690
|
+
return SessionDossierNotFound(found=False)
|
|
691
|
+
|
|
692
|
+
# One request boundary and complete context precede the Session filter.
|
|
693
|
+
boundary = datetime.now(UTC)
|
|
694
|
+
context = read_repository_context(snapshot, settings.org_id, as_of=boundary)
|
|
695
|
+
observations = snapshot.read_session_commit_observations(
|
|
696
|
+
settings.org_id,
|
|
697
|
+
session_ids={session_id},
|
|
698
|
+
as_of=boundary,
|
|
699
|
+
limit=_SESSION_DOSSIER_LIMIT,
|
|
700
|
+
)
|
|
701
|
+
binding_result = bind_session_commit_keys_result(
|
|
702
|
+
observations,
|
|
703
|
+
settings.org_id,
|
|
704
|
+
as_of=boundary,
|
|
705
|
+
repository_context=context,
|
|
706
|
+
)
|
|
707
|
+
bindings = binding_result.bindings
|
|
708
|
+
attributed_commits = [
|
|
709
|
+
AttributedCommitSummary(
|
|
710
|
+
repo=context.repo_for(commit.repository),
|
|
711
|
+
commit_sha=commit.commit_sha,
|
|
712
|
+
repository_identity=commit.repository.identity
|
|
713
|
+
if isinstance(commit.repository, IdentifiedRepositoryKey)
|
|
714
|
+
else None,
|
|
715
|
+
attribution_sources=[],
|
|
716
|
+
minimum_similarity=None,
|
|
717
|
+
maximum_similarity=None,
|
|
718
|
+
attributed_files=None,
|
|
719
|
+
session_commit_observation_ids=tuple(
|
|
720
|
+
sorted({item.observation_id for item in facts})
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
for (commit, _session), facts in bindings.items()
|
|
724
|
+
]
|
|
725
|
+
repository_commits = {
|
|
726
|
+
(repository_read_key(commit.repository), commit.commit_sha)
|
|
727
|
+
for commit, _session in bindings
|
|
728
|
+
}
|
|
729
|
+
pushes, ci_outcomes = snapshot.read_delivery_summaries(
|
|
730
|
+
settings.org_id,
|
|
731
|
+
repository_commits=repository_commits,
|
|
732
|
+
captured_through=boundary,
|
|
733
|
+
limit=_SESSION_DOSSIER_LIMIT,
|
|
734
|
+
)
|
|
735
|
+
gap_pairs: tuple[tuple[str, SessionGap], ...] = (
|
|
736
|
+
("inference_calls", "no_inference_calls_observed"),
|
|
737
|
+
("developer_decisions", "no_developer_decisions_observed"),
|
|
738
|
+
("edit_observations", "no_edit_observations_observed"),
|
|
739
|
+
("rejected_edits", "no_rejected_edits_observed"),
|
|
740
|
+
("retry_linkages", "no_retry_linkages_observed"),
|
|
741
|
+
)
|
|
742
|
+
gaps: list[SessionGap] = [
|
|
743
|
+
gap for fact_type, gap in gap_pairs if dossier.coverage[fact_type].visible == 0
|
|
744
|
+
]
|
|
745
|
+
if not attributed_commits:
|
|
746
|
+
gaps.append("session_commit_unobserved")
|
|
747
|
+
if attributed_commits and not pushes:
|
|
748
|
+
gaps.append("no_exact_push_receipts_observed")
|
|
749
|
+
if attributed_commits and not ci_outcomes:
|
|
750
|
+
gaps.append("no_ci_outcomes_observed")
|
|
751
|
+
return SessionDossierFound(
|
|
752
|
+
found=True,
|
|
753
|
+
session_id=dossier.session_id,
|
|
754
|
+
first_observed_at=dossier.first_observed_at,
|
|
755
|
+
last_observed_at=dossier.last_observed_at,
|
|
756
|
+
coverage=SessionCoverageResponse(
|
|
757
|
+
inference_calls=_fact_coverage(dossier, "inference_calls"),
|
|
758
|
+
developer_decisions=_fact_coverage(dossier, "developer_decisions"),
|
|
759
|
+
edit_observations=_fact_coverage(dossier, "edit_observations"),
|
|
760
|
+
rejected_edits=_fact_coverage(dossier, "rejected_edits"),
|
|
761
|
+
retry_linkages=_fact_coverage(dossier, "retry_linkages"),
|
|
762
|
+
),
|
|
763
|
+
omitted_events=sum(
|
|
764
|
+
coverage.quarantined for coverage in dossier.coverage.values()
|
|
765
|
+
),
|
|
766
|
+
gaps=gaps,
|
|
767
|
+
timeline=[_timeline_event(row) for row in dossier.timeline],
|
|
768
|
+
attributed_commits=attributed_commits,
|
|
769
|
+
session_commit_unobserved=int(not attributed_commits),
|
|
770
|
+
repository_skipped=dict(binding_result.skipped),
|
|
771
|
+
pushes=[_push_summary(push) for push in pushes],
|
|
772
|
+
ci_outcomes=[
|
|
773
|
+
_session_ci_summary(outcome, as_of=boundary) for outcome in ci_outcomes
|
|
774
|
+
],
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _valid_sha(sha: str) -> str:
|
|
779
|
+
"""The shared CommitSha definition as a clean 422: full-length
|
|
780
|
+
40/64 hex, lowercased — so an uppercase or SHA-256 query matches the
|
|
781
|
+
normalized facts instead of missing or 422ing."""
|
|
782
|
+
try:
|
|
783
|
+
return normalize_commit_sha(sha)
|
|
784
|
+
except ValueError as exc:
|
|
785
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
@router.get(
|
|
789
|
+
"/commit/{sha}",
|
|
790
|
+
response_model=dict[str, Any],
|
|
791
|
+
responses=_QUERY_REPRESENTATION_RESPONSES,
|
|
792
|
+
)
|
|
793
|
+
async def query_commit(
|
|
794
|
+
sha: str,
|
|
795
|
+
request: Request,
|
|
796
|
+
repo: RequiredRepoSlug | None = None,
|
|
797
|
+
repository_provider: ForgeProvider | None = None,
|
|
798
|
+
repository_host: ForgeHost | None = None,
|
|
799
|
+
repository_id: ProviderRepositoryId | None = None,
|
|
800
|
+
as_of: AwareDatetime | None = None,
|
|
801
|
+
_: None = Depends(verify_operator_token),
|
|
802
|
+
) -> Response:
|
|
803
|
+
"""Investigate exact commit evidence in separate repository lifetimes.
|
|
804
|
+
|
|
805
|
+
Captured observations establish Session associations. Call-to-file Attribution
|
|
806
|
+
remains inferred. Optional repository selectors and an inclusive ``as_of``
|
|
807
|
+
bound all supporting evidence; otherwise one request instant supplies the bound.
|
|
808
|
+
A bounded child process owns the Derivation and its database connection.
|
|
809
|
+
Capacity and execution deadlines return 503 after process cleanup.
|
|
810
|
+
"""
|
|
811
|
+
commit_sha = _valid_sha(sha)
|
|
812
|
+
identity = _selector_identity(repository_provider, repository_host, repository_id)
|
|
813
|
+
return await request.app.state.workers.run(
|
|
814
|
+
"commit",
|
|
815
|
+
{
|
|
816
|
+
"sha": commit_sha,
|
|
817
|
+
"repo": repo,
|
|
818
|
+
"repository_identity": asdict(identity) if identity is not None else None,
|
|
819
|
+
"as_of": as_of.isoformat() if as_of is not None else None,
|
|
820
|
+
},
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _run_query(
|
|
825
|
+
commit_sha: str,
|
|
826
|
+
store: FactStore,
|
|
827
|
+
*,
|
|
828
|
+
repo: RequiredRepoSlug | None = None,
|
|
829
|
+
repository_identity: RepositoryIdentity | None = None,
|
|
830
|
+
as_of: datetime | None = None,
|
|
831
|
+
) -> dict[str, Any]:
|
|
832
|
+
"""Read exact CI/Session evidence beside labeled inferred call diagnostics."""
|
|
833
|
+
boundary = as_of if as_of is not None else datetime.now(UTC)
|
|
834
|
+
with store.read_snapshot() as snapshot:
|
|
835
|
+
context = read_repository_context(snapshot, settings.org_id, as_of=boundary)
|
|
836
|
+
selected = repo is not None or repository_identity is not None
|
|
837
|
+
selected_key = (
|
|
838
|
+
_query_repository(context, repo, repository_identity) if selected else None
|
|
839
|
+
)
|
|
840
|
+
if selected and selected_key is None:
|
|
841
|
+
return {"commit_sha": commit_sha, "attributed": False}
|
|
842
|
+
observations = [
|
|
843
|
+
item
|
|
844
|
+
for item in snapshot.read_session_commit_observations(
|
|
845
|
+
settings.org_id, as_of=boundary
|
|
846
|
+
)
|
|
847
|
+
if item.commit_sha == commit_sha
|
|
848
|
+
]
|
|
849
|
+
binding_result = bind_session_commit_keys_result(
|
|
850
|
+
observations,
|
|
851
|
+
settings.org_id,
|
|
852
|
+
as_of=boundary,
|
|
853
|
+
repository_context=context,
|
|
854
|
+
)
|
|
855
|
+
bindings = {
|
|
856
|
+
key: facts
|
|
857
|
+
for key, facts in binding_result.bindings.items()
|
|
858
|
+
if not selected or key[0].repository == selected_key
|
|
859
|
+
}
|
|
860
|
+
skipped = Counter(binding_result.skipped)
|
|
861
|
+
matching = {}
|
|
862
|
+
if settings.mirror_path:
|
|
863
|
+
for item in derive_attributions(
|
|
864
|
+
snapshot,
|
|
865
|
+
MirrorManager(settings.mirror_path),
|
|
866
|
+
settings.org_id,
|
|
867
|
+
repository_context=context,
|
|
868
|
+
as_of=boundary,
|
|
869
|
+
):
|
|
870
|
+
if item.commit_sha != commit_sha:
|
|
871
|
+
continue
|
|
872
|
+
resolution = context.resolve_reference(
|
|
873
|
+
item.org_id,
|
|
874
|
+
item.repo,
|
|
875
|
+
repository_identity=item.repository_identity,
|
|
876
|
+
)
|
|
877
|
+
if resolution.key is None:
|
|
878
|
+
skipped[resolution.reason] += 1
|
|
879
|
+
continue
|
|
880
|
+
if not selected or resolution.key == selected_key:
|
|
881
|
+
matching.setdefault(resolution.key, []).append(item)
|
|
882
|
+
commit_ci = {}
|
|
883
|
+
unresolved_ci = []
|
|
884
|
+
for item in snapshot.read_ci_outcomes(
|
|
885
|
+
settings.org_id, captured_through=boundary
|
|
886
|
+
):
|
|
887
|
+
if item.commit_sha != commit_sha:
|
|
888
|
+
continue
|
|
889
|
+
resolution = context.resolve_fact(item)
|
|
890
|
+
if resolution.key is None:
|
|
891
|
+
skipped[resolution.reason] += 1
|
|
892
|
+
if not selected:
|
|
893
|
+
unresolved_ci.append(item.model_dump(mode="python"))
|
|
894
|
+
continue
|
|
895
|
+
if not selected or resolution.key == selected_key:
|
|
896
|
+
commit_ci.setdefault(resolution.key, []).append(item)
|
|
897
|
+
if not bindings and not matching and not commit_ci and not skipped:
|
|
898
|
+
return {"commit_sha": commit_sha, "attributed": False}
|
|
899
|
+
stored = [
|
|
900
|
+
item
|
|
901
|
+
for item in snapshot.read_inference_calls(settings.org_id)
|
|
902
|
+
if item.observed_at.astimezone(UTC) <= boundary.astimezone(UTC)
|
|
903
|
+
]
|
|
904
|
+
by_inference_call_id = {inference_fact_id(item): item for item in stored}
|
|
905
|
+
joined = join_decisions_by_call_id(
|
|
906
|
+
stored,
|
|
907
|
+
snapshot.read_decisions(settings.org_id, captured_through=boundary),
|
|
908
|
+
)
|
|
909
|
+
repository_keys = (
|
|
910
|
+
set(matching)
|
|
911
|
+
| set(commit_ci)
|
|
912
|
+
| {commit.repository for commit, _session in bindings}
|
|
913
|
+
)
|
|
914
|
+
repos = []
|
|
915
|
+
for repository_key in sorted(repository_keys, key=repository_sort_key):
|
|
916
|
+
best = {}
|
|
917
|
+
for item in matching.get(repository_key, ()):
|
|
918
|
+
score = (item.similarity_score, item.file_path)
|
|
919
|
+
held = best.get(item.inference_call_id)
|
|
920
|
+
if held is None or score > held[0]:
|
|
921
|
+
best[item.inference_call_id] = (
|
|
922
|
+
score,
|
|
923
|
+
item.session_id,
|
|
924
|
+
item.attribution_source.value,
|
|
925
|
+
)
|
|
926
|
+
calls = _build_inference_calls(
|
|
927
|
+
by_inference_call_id,
|
|
928
|
+
[(cid, sid, source) for cid, (_, sid, source) in sorted(best.items())],
|
|
929
|
+
)
|
|
930
|
+
for call in calls:
|
|
931
|
+
call["relationship"] = "inferred_call_to_file"
|
|
932
|
+
observed_sessions = [
|
|
933
|
+
{
|
|
934
|
+
"session_id": session_id,
|
|
935
|
+
"session_commit_observation_ids": sorted(
|
|
936
|
+
{item.observation_id for item in facts}
|
|
937
|
+
),
|
|
938
|
+
}
|
|
939
|
+
for (commit, session_id), facts in bindings.items()
|
|
940
|
+
if commit.repository == repository_key
|
|
941
|
+
]
|
|
942
|
+
unobserved = {
|
|
943
|
+
(CommitKey(repository_key, item.commit_sha), item.session_id)
|
|
944
|
+
for item in matching.get(repository_key, ())
|
|
945
|
+
if (CommitKey(repository_key, item.commit_sha), item.session_id)
|
|
946
|
+
not in bindings
|
|
947
|
+
}
|
|
948
|
+
repos.append(
|
|
949
|
+
{
|
|
950
|
+
"repo": context.repo_for(repository_key),
|
|
951
|
+
"repository_identity": asdict(repository_key.identity)
|
|
952
|
+
if isinstance(repository_key, IdentifiedRepositoryKey)
|
|
953
|
+
else None,
|
|
954
|
+
"observed_repo_slugs": list(
|
|
955
|
+
context.observed_repo_slugs(repository_key)
|
|
956
|
+
),
|
|
957
|
+
"inference_calls": calls,
|
|
958
|
+
"decisions": sum(
|
|
959
|
+
len(ds) for cid, ds in joined.items() if cid in best
|
|
960
|
+
),
|
|
961
|
+
"observed_sessions": observed_sessions,
|
|
962
|
+
"session_commit_unobserved": len(unobserved),
|
|
963
|
+
"ci_outcomes": [
|
|
964
|
+
item.model_dump(mode="python")
|
|
965
|
+
for item in commit_ci.get(repository_key, ())
|
|
966
|
+
],
|
|
967
|
+
}
|
|
968
|
+
)
|
|
969
|
+
result = {
|
|
970
|
+
"commit_sha": commit_sha,
|
|
971
|
+
"attributed": bool(bindings),
|
|
972
|
+
"repos": repos,
|
|
973
|
+
}
|
|
974
|
+
if skipped:
|
|
975
|
+
result["repository_skipped"] = dict(sorted(skipped.items()))
|
|
976
|
+
if unresolved_ci:
|
|
977
|
+
result["unresolved_ci_outcomes"] = unresolved_ci
|
|
978
|
+
return result
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _build_inference_calls(
|
|
982
|
+
by_inference_call_id: dict[str, InferenceCall],
|
|
983
|
+
entries: Sequence[tuple[str, str, str]],
|
|
984
|
+
) -> list[dict[str, Any]]:
|
|
985
|
+
"""Resolve inference-call, session, and attribution-source triples into the
|
|
986
|
+
endpoint's inference-call shape, reading model/provider/user_id from stored
|
|
987
|
+
facts. ``session_id`` and ``attribution_source`` come from the attribution, not
|
|
988
|
+
the fact row. The session_id on the fact is
|
|
989
|
+
the session that produced the inference call (ADR 0002), and attribution
|
|
990
|
+
names the attribution source (`git_notes` or `jaccard`)."""
|
|
991
|
+
result: list[dict[str, Any]] = []
|
|
992
|
+
for inference_call_id, session_id, attribution_source in entries:
|
|
993
|
+
call = by_inference_call_id.get(inference_call_id)
|
|
994
|
+
# An inference call may have been quarantined between attribution and
|
|
995
|
+
# read: skip, don't 500. The session_id from the attribution is
|
|
996
|
+
# still trustable (it was read non-quarantined during derivation).
|
|
997
|
+
if call is None:
|
|
998
|
+
continue
|
|
999
|
+
result.append(
|
|
1000
|
+
{
|
|
1001
|
+
"inference_call_id": inference_call_id,
|
|
1002
|
+
"session_id": session_id,
|
|
1003
|
+
"gateway_provider": inference_gateway_provider(call).value,
|
|
1004
|
+
"model_provider": call.model_provider,
|
|
1005
|
+
"model": inference_model(call),
|
|
1006
|
+
"user_id": inference_user_id(call),
|
|
1007
|
+
"attribution_source": attribution_source,
|
|
1008
|
+
}
|
|
1009
|
+
)
|
|
1010
|
+
return result
|