Skip to content

Commit 904e94a

Browse files
authored
Merge pull request #23 from baasith6/Training-Dataset-creation
Training dataset creation
2 parents cdc8d02 + c4de1a0 commit 904e94a

26 files changed

Lines changed: 1482 additions & 487 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,6 @@ installer-site/*.exe
3737
# IDE
3838
.vs/
3939
.idea/
40+
41+
# Claude Code local settings (contains API keys)
42+
.claude/

backend/Contracts/Dtos.cs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,72 @@ public record AiEventDto(
168168
public record AiEventsBatchRequest(Guid ClipId, string ModelVersion, List<AiEventDto> Events);
169169

170170
// ---- Alerts / reviews ----
171-
public record ReviewRequest(string Action, string? ReasonCode, string? Notes);
171+
public record ReviewRequest(
172+
string Action,
173+
string? ReasonCode,
174+
string? Notes,
175+
List<string>? ConfirmedPatterns = null);
172176

173177
public record BulkDeleteRequest(Guid? StoreId, List<Guid>? Ids, bool DeleteAllInStore = false);
174178

175179
public record BulkDeleteResponse(int Deleted, int Skipped, List<string> Errors);
180+
181+
// ---- Training dataset ----
182+
public record TrainingPatternLabel(
183+
string Pattern,
184+
bool AiDetected,
185+
bool HumanConfirmed,
186+
string LabelStatus);
187+
188+
public record TrainingSampleListItem(
189+
Guid Id,
190+
Guid AlertId,
191+
Guid ClipId,
192+
string AlertType,
193+
List<string> AiDetectedPatterns,
194+
List<string> HumanConfirmedPatterns,
195+
int PositiveCount,
196+
int HardNegativeCount,
197+
string ReviewOutcome,
198+
string DatasetStatus,
199+
bool IncludeInTraining,
200+
string ReviewerEmail,
201+
string ModelVersion,
202+
string StoreName,
203+
string CameraName,
204+
DateTimeOffset CreatedAt,
205+
DateTimeOffset UpdatedAt);
206+
207+
public record TrainingSampleDetail(
208+
Guid Id,
209+
Guid AlertId,
210+
Guid ClipId,
211+
string AlertType,
212+
List<TrainingPatternLabel> Labels,
213+
string ReviewOutcome,
214+
string DatasetStatus,
215+
bool IncludeInTraining,
216+
string ReviewerEmail,
217+
string ModelVersion,
218+
string RuleVersion,
219+
string StoreName,
220+
string CameraName,
221+
string? ClipUrl,
222+
string EditHistoryJson,
223+
DateTimeOffset CreatedAt,
224+
DateTimeOffset UpdatedAt);
225+
226+
public record TrainingStatsResponse(
227+
int Total,
228+
int Ready,
229+
int Pending,
230+
int Excluded,
231+
int ClipIssues,
232+
Dictionary<string, int> PositiveByPattern,
233+
Dictionary<string, int> HardNegativeByPattern,
234+
Dictionary<string, int> ByModelVersion,
235+
Dictionary<string, int> ByStore);
236+
237+
public record UpdateLabelsRequest(List<string> ConfirmedPatterns);
238+
239+
public record IncludeRequest(bool Include);

backend/Controllers/AlertsController.cs

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ join s in _db.Stores on a.StoreId equals s.Id
5252
return Ok(visible);
5353
}
5454

55+
// GET /api/alerts/patterns — the supported suspicious-activity patterns.
56+
// Single source of truth: the AiEventType enum.
57+
[HttpGet("patterns")]
58+
public IActionResult Patterns() => Ok(Enum.GetNames<AiEventType>());
59+
5560
// GET /api/alerts/{id} — returns alert with a fresh 24-hour presigned clip URL.
5661
[HttpGet("{id:guid}")]
5762
public async Task<IActionResult> Get(Guid id)
@@ -82,6 +87,22 @@ public async Task<IActionResult> Get(Guid id)
8287
freshUrl = alert.ClipUrl; // already a URL (legacy alerts or dev mode)
8388
}
8489

90+
// AI detections for this clip so the review UI can pre-select patterns.
91+
var aiEventRows = await _db.AiEvents
92+
.Where(e => e.ClipId == alert.ClipId)
93+
.OrderBy(e => e.StartTs)
94+
.Select(e => new { e.EventType, e.Confidence, e.StartTs, e.EndTs })
95+
.ToListAsync();
96+
var aiEvents = aiEventRows
97+
.Select(e => new
98+
{
99+
EventType = e.EventType.ToString(),
100+
e.Confidence,
101+
e.StartTs,
102+
e.EndTs,
103+
})
104+
.ToList();
105+
85106
// Return the alert with the fresh URL.
86107
return Ok(new
87108
{
@@ -99,6 +120,7 @@ public async Task<IActionResult> Get(Guid id)
99120
alert.Status,
100121
alert.CreatedAt,
101122
alert.Reviews,
123+
AiEvents = aiEvents,
102124
ClipUrl = freshUrl, // fresh presigned URL, valid 24h
103125
});
104126
}
@@ -178,13 +200,36 @@ public async Task<IActionResult> Review(Guid id, ReviewRequest req)
178200
if ((action is ReviewAction.Dismiss or ReviewAction.FalsePositive) && string.IsNullOrWhiteSpace(req.ReasonCode))
179201
return BadRequest(new { error = "Reason code required for dismiss / false positive" });
180202

203+
// Validate confirmed patterns against the AiEventType enum (single source of truth).
204+
// Null = old client without pattern selection: keep saving the review, no dataset entry.
205+
List<string>? confirmed = null;
206+
if (req.ConfirmedPatterns is not null)
207+
{
208+
confirmed = new List<string>();
209+
foreach (var p in req.ConfirmedPatterns)
210+
{
211+
if (!Enum.TryParse<AiEventType>(p, true, out var pattern))
212+
return BadRequest(new { error = $"Unknown pattern '{p}'" });
213+
var name = pattern.ToString();
214+
if (confirmed.Contains(name))
215+
return BadRequest(new { error = $"Duplicate pattern '{p}'" });
216+
confirmed.Add(name);
217+
}
218+
219+
if (action is ReviewAction.Confirm && confirmed.Count == 0)
220+
return BadRequest(new { error = "Select at least one pattern to confirm the incident" });
221+
if (action is ReviewAction.FalsePositive && confirmed.Count > 0)
222+
return BadRequest(new { error = "False positive reviews must not have confirmed patterns" });
223+
}
224+
181225
var review = new AlertReview
182226
{
183227
AlertId = alert.Id,
184228
ReviewerId = TenantAccess.CurrentUserId(User),
185229
Action = action,
186230
ReasonCode = req.ReasonCode,
187-
Notes = req.Notes
231+
Notes = req.Notes,
232+
ConfirmedPatternsJson = confirmed is null ? null : JsonSerializer.Serialize(confirmed)
188233
};
189234
_db.AlertReviews.Add(review);
190235

@@ -197,10 +242,133 @@ public async Task<IActionResult> Review(Guid id, ReviewRequest req)
197242
_ => alert.Status
198243
};
199244

245+
await UpsertTrainingSampleAsync(alert, action, confirmed, review.ReviewerId);
246+
200247
await _db.SaveChangesAsync();
201248
return Ok(alert);
202249
}
203250

251+
/// <summary>
252+
/// Dataset bookkeeping for the human-in-the-loop training pipeline.
253+
/// Confirm/FalsePositive with pattern data upserts the sample (one per alert) and
254+
/// copies the clip into training-dataset storage. Dismiss excludes an existing sample;
255+
/// NeedsFollowUp marks it pending. Never fails the review itself.
256+
/// </summary>
257+
private async Task UpsertTrainingSampleAsync(
258+
Alert alert, ReviewAction action, List<string>? confirmed, Guid reviewerId)
259+
{
260+
var existing = await _db.TrainingSamples
261+
.Include(t => t.Patterns)
262+
.FirstOrDefaultAsync(t => t.AlertId == alert.Id);
263+
264+
if (action is ReviewAction.Dismiss)
265+
{
266+
// Dismiss carries no ground truth — never auto-create a sample.
267+
if (existing is not null)
268+
{
269+
existing.DatasetStatus = DatasetStatus.Excluded;
270+
existing.IncludeInTraining = false;
271+
existing.ReviewOutcome = action;
272+
existing.UpdatedAt = DateTimeOffset.UtcNow;
273+
}
274+
return;
275+
}
276+
277+
if (action is ReviewAction.NeedsFollowUp)
278+
{
279+
// Undetermined — keep labels but hold the sample out of training.
280+
if (existing is not null)
281+
{
282+
existing.DatasetStatus = DatasetStatus.PendingReview;
283+
existing.ReviewOutcome = action;
284+
existing.UpdatedAt = DateTimeOffset.UtcNow;
285+
}
286+
return;
287+
}
288+
289+
// Confirm / FalsePositive: only clients that sent pattern data create samples.
290+
if (confirmed is null) return;
291+
292+
var detected = (await _db.AiEvents
293+
.Where(e => e.ClipId == alert.ClipId)
294+
.Select(e => e.EventType)
295+
.Distinct()
296+
.ToListAsync())
297+
.Select(e => e.ToString())
298+
.ToList();
299+
if (!string.IsNullOrEmpty(alert.AlertType)
300+
&& Enum.TryParse<AiEventType>(alert.AlertType, true, out _)
301+
&& !detected.Contains(alert.AlertType))
302+
{
303+
detected.Add(alert.AlertType);
304+
}
305+
306+
var sample = existing;
307+
if (sample is null)
308+
{
309+
sample = new TrainingSample { AlertId = alert.Id };
310+
_db.TrainingSamples.Add(sample);
311+
}
312+
313+
sample.ClipId = alert.ClipId;
314+
sample.StoreId = alert.StoreId;
315+
sample.CameraId = alert.CameraId;
316+
sample.AlertType = alert.AlertType;
317+
sample.ReviewOutcome = action;
318+
sample.ReviewerId = reviewerId;
319+
sample.ModelVersion = alert.ModelVersion;
320+
sample.RuleVersion = alert.RuleVersion;
321+
sample.IncludeInTraining = true;
322+
sample.UpdatedAt = DateTimeOffset.UtcNow;
323+
324+
// Recalculate per-pattern labels: confirmed → Positive, AI-only → HardNegative.
325+
_db.TrainingSamplePatterns.RemoveRange(sample.Patterns);
326+
sample.Patterns.Clear();
327+
foreach (var name in confirmed.Union(detected))
328+
{
329+
var isConfirmed = confirmed.Contains(name);
330+
sample.Patterns.Add(new TrainingSamplePattern
331+
{
332+
TrainingSampleId = sample.Id,
333+
Pattern = Enum.Parse<AiEventType>(name),
334+
AiDetected = detected.Contains(name),
335+
HumanConfirmed = isConfirmed,
336+
LabelStatus = isConfirmed ? PatternLabelStatus.Positive : PatternLabelStatus.HardNegative
337+
});
338+
}
339+
340+
// Copy the clip into dedicated dataset storage so it survives alert retention.
341+
sample.SourceClipObjectKey =
342+
(!string.IsNullOrEmpty(alert.ClipUrl) && !alert.ClipUrl.StartsWith("http"))
343+
? alert.ClipUrl : string.Empty;
344+
var destKey = $"training-dataset/{alert.StoreId}/{sample.Id}/clip.mp4";
345+
346+
if (!string.IsNullOrEmpty(sample.DatasetClipObjectKey)
347+
&& await _s3.ExistsAsync(sample.DatasetClipObjectKey))
348+
{
349+
sample.DatasetStatus = DatasetStatus.Ready; // already copied (re-review)
350+
}
351+
else if (string.IsNullOrEmpty(sample.SourceClipObjectKey)
352+
|| !await _s3.ExistsAsync(sample.SourceClipObjectKey))
353+
{
354+
sample.DatasetStatus = DatasetStatus.ClipUnavailable;
355+
}
356+
else
357+
{
358+
try
359+
{
360+
await _s3.CopyAsync(sample.SourceClipObjectKey, destKey);
361+
sample.DatasetClipObjectKey = destKey;
362+
sample.DatasetStatus = DatasetStatus.Ready;
363+
}
364+
catch
365+
{
366+
// Copy failure must not lose the review; retried on next re-review.
367+
sample.DatasetStatus = DatasetStatus.CopyFailed;
368+
}
369+
}
370+
}
371+
204372
[Authorize(Roles = "Admin,Manager")]
205373
[HttpPost("bulk-delete")]
206374
public async Task<ActionResult<BulkDeleteResponse>> BulkDelete(BulkDeleteRequest req)

0 commit comments

Comments
 (0)