@@ -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