@camstack/addon-post-analysis 1.2.641 → 1.2.643

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 (14) hide show
  1. package/dist/{clip-model-registry-Cw7Iy7Qi.mjs → clip-model-registry-DdgT3gdf.mjs} +301 -25
  2. package/dist/{clip-model-registry-Qr0XipkX.js → clip-model-registry-lKeBMKlT.js} +301 -25
  3. package/dist/embedding-encoder/index.js +1 -1
  4. package/dist/embedding-encoder/index.mjs +1 -1
  5. package/dist/pipeline-analytics/_stub.js +2 -2
  6. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Cct0TMzC.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-B6X8TAQP.mjs} +2 -2
  7. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-BIjXlrFH.mjs +26 -0
  8. package/dist/pipeline-analytics/{_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-CuNXxDlT.mjs → _virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js--XkysBjS.mjs} +1 -1
  9. package/dist/pipeline-analytics/{hostInit-B9c2FowI.mjs → hostInit-CDPfOUWf.mjs} +2 -2
  10. package/dist/pipeline-analytics/index.js +411 -15
  11. package/dist/pipeline-analytics/index.mjs +411 -15
  12. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  13. package/package.json +1 -1
  14. package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-_TUthUZh.mjs +0 -26
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-Qr0XipkX.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-lKeBMKlT.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -757,6 +757,8 @@ var DEFAULT_ACTION_TTL_MS = 15 * 6e4;
757
757
  * notification that is not fresh.
758
758
  */
759
759
  var SNOOZE_ACTION_TTL_MS = 60 * 6e4;
760
+ /** How long a Correct / Wrong button stays live: one day. */
761
+ var VERDICT_ACTION_TTL_MS = 1440 * 6e4;
760
762
  /**
761
763
  * How long a DISARM button stays live: five minutes.
762
764
  *
@@ -784,6 +786,7 @@ function actionTtlMs(subject) {
784
786
  case "sequence": return DEFAULT_ACTION_TTL_MS;
785
787
  case "addon-update": return DEFAULT_ACTION_TTL_MS;
786
788
  case "server-update": return DEFAULT_ACTION_TTL_MS;
789
+ case "detection-verdict": return VERDICT_ACTION_TTL_MS;
787
790
  }
788
791
  }
789
792
  var NcActionTokenRegistry = class {
@@ -827,6 +830,30 @@ var NcActionTokenRegistry = class {
827
830
  grant: found.grant
828
831
  };
829
832
  }
833
+ /**
834
+ * Read the grant without spending it. ONLY the confirm page of a link-only
835
+ * channel may call this: it exists so a link preview that opens the URL
836
+ * cannot spend the token, and it never runs anything.
837
+ */
838
+ peek(id) {
839
+ const found = this.grants.get(id);
840
+ if (found === void 0) return {
841
+ ok: false,
842
+ reason: "unknown"
843
+ };
844
+ if (found.used) return {
845
+ ok: false,
846
+ reason: "already-used"
847
+ };
848
+ if (found.grant.expiresAt <= this.now()) return {
849
+ ok: false,
850
+ reason: "expired"
851
+ };
852
+ return {
853
+ ok: true,
854
+ grant: found.grant
855
+ };
856
+ }
830
857
  /** Drop records that can no longer be claimed. Memory hygiene only — an
831
858
  * expired record is already refused by {@link claim}, so a sweep that never
832
859
  * ran would cost bytes, never safety. */
@@ -868,6 +895,7 @@ function describeGrant(grant) {
868
895
  return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
869
896
  case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
870
897
  case "server-update": return `Server update on ${grant.nodeId}`;
898
+ case "detection-verdict": return grant.verdict === "correct" ? "Marked correct — kept for retraining" : grant.suggestedClass === void 0 ? "Marked wrong — kept for retraining" : `Marked wrong, it was a ${grant.suggestedClass} — kept for retraining`;
871
899
  }
872
900
  }
873
901
  /** The audit line's `meta`, per family. A snooze that logged a `sequence` it
@@ -921,6 +949,15 @@ function grantMeta(grant) {
921
949
  ...common,
922
950
  nodeId: grant.nodeId
923
951
  };
952
+ case "detection-verdict": return {
953
+ ...common,
954
+ ruleId: grant.ruleId,
955
+ rule: grant.ruleName,
956
+ userId: grant.userId,
957
+ trackId: grant.trackId,
958
+ verdict: grant.verdict,
959
+ suggestedClass: grant.suggestedClass ?? null
960
+ };
924
961
  }
925
962
  }
926
963
  /** What the human standing in front of the page is told. */
@@ -929,6 +966,11 @@ var FAILURE_COPY = {
929
966
  expired: "This button has expired. Open CamStack to do it there.",
930
967
  "already-used": "Already done — this button had been used."
931
968
  };
969
+ /** The question a link-only channel lands on, answered by a POST form. */
970
+ function confirmPage(url, grant) {
971
+ const action = `${url.pathname}?exp=${encodeURIComponent(url.searchParams.get("exp") ?? "")}&amp;sig=${encodeURIComponent(url.searchParams.get("sig") ?? "")}&amp;confirm=1`;
972
+ return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Confirm</title><style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee}main{max-width:24rem;padding:2rem;text-align:center}h1{font-size:1.25rem;margin:0 0 1rem}button{font:inherit;padding:.6rem 1.4rem;border-radius:.5rem;border:0;background:#3b82f6;color:#fff}</style><main><h1>Confirm</h1><p>${grant.verdict === "correct" ? "Mark this detection as correct?" : grant.suggestedClass === void 0 ? "Mark this detection as wrong?" : `Mark this detection as wrong, it was a ${grant.suggestedClass}?`}</p><form method="post" action="${action}"><button type="submit">Confirm</button></form></main>`;
973
+ }
932
974
  function page(title, detail) {
933
975
  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee}main{max-width:24rem;padding:2rem;text-align:center}h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style><main><h1>${title}</h1><p>${detail}</p></main>`;
934
976
  }
@@ -942,7 +984,8 @@ var NcActionPlane = class {
942
984
  handler = async (req, res) => {
943
985
  const url = new URL(req.url ?? "/", "http://placeholder");
944
986
  const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
945
- const background = (req.method ?? "GET").toUpperCase() === "POST";
987
+ const posted = (req.method ?? "GET").toUpperCase() === "POST";
988
+ const background = posted && url.searchParams.get("confirm") !== "1";
946
989
  if (!verifyActionSignature({
947
990
  secret: this.deps.secret,
948
991
  id,
@@ -954,6 +997,13 @@ var NcActionPlane = class {
954
997
  this.reply(res, background, 404, "Not found", "This link is not valid.");
955
998
  return;
956
999
  }
1000
+ if (!posted) {
1001
+ const peeked = this.deps.registry.peek(id);
1002
+ if (peeked.ok && peeked.grant.kind === "detection-verdict" && peeked.grant.viaLink === true) {
1003
+ this.sendBody(res, 200, "text/html; charset=utf-8", confirmPage(url, peeked.grant));
1004
+ return;
1005
+ }
1006
+ }
957
1007
  const claim = this.deps.registry.claim(id);
958
1008
  if (!claim.ok) {
959
1009
  this.deps.logger.info("nc action callback not claimable", { meta: {
@@ -1099,6 +1149,7 @@ var ncDurableRecordKinds = require_clip_model_registry.defineRecordKinds({
1099
1149
  "state-summary-stamps": ledger("digest delivery stamps per rule kind"),
1100
1150
  meta: ledger("outbox boot-reconcile watermark and other single-key meta rows", "settings", { none: "one fixed key (watermark), overwritten in place, never deleted" }),
1101
1151
  snoozes: ledger("operator opens or closes a snooze window (write-through)"),
1152
+ "detection-verdicts": ledger("operator taps Correct or Wrong on a detection notification; one row per track"),
1102
1153
  "snooze-suppressed": {
1103
1154
  ...QUERIED,
1104
1155
  class: "ledger",
@@ -9234,6 +9285,9 @@ var en_default = {
9234
9285
  "action.addon-update.uptodate": "Already up to date on {{nodeId}} — nothing to do",
9235
9286
  "action.addon-update.unknown": "Could not read what needs updating on {{nodeId}} — nothing was changed. Open CamStack.",
9236
9287
  "button.server-update.apply": "Update server on {{nodeId}}",
9288
+ "button.verdict.correct": "Correct",
9289
+ "button.verdict.wrong": "Wrong",
9290
+ "button.verdict.wrongAs": "Wrong: it is {{class}}",
9237
9291
  "action.server-update.title": "Server update",
9238
9292
  "action.server-update.started": "Updating server on {{nodeId}}…",
9239
9293
  "action.server-update.restarting": "Server update staged on {{nodeId}} — the node is restarting. You will hear when it comes back.",
@@ -9635,6 +9689,9 @@ var it_default = {
9635
9689
  "action.addon-update.uptodate": "Già aggiornato su {{nodeId}} — niente da fare",
9636
9690
  "action.addon-update.unknown": "Non è stato possibile leggere cosa aggiornare su {{nodeId}} — non è stato cambiato nulla. Apri CamStack.",
9637
9691
  "button.server-update.apply": "Aggiorna server su {{nodeId}}",
9692
+ "button.verdict.correct": "Corretto",
9693
+ "button.verdict.wrong": "Sbagliato",
9694
+ "button.verdict.wrongAs": "Sbagliato: è {{class}}",
9638
9695
  "action.server-update.title": "Aggiornamento server",
9639
9696
  "action.server-update.started": "Aggiornamento server su {{nodeId}}…",
9640
9697
  "action.server-update.restarting": "Aggiornamento server predisposto su {{nodeId}} — il nodo si riavvia. Ti avviseremo quando torna.",
@@ -53130,6 +53187,15 @@ var SESSION_STILLNESS_MIN_POINTS = 4;
53130
53187
  * long (object removed while unobserved, or a long occlusion) → retire it. */
53131
53188
  var ENTRY_TTL_MS = 5 * 6e4;
53132
53189
  /**
53190
+ * The most one gap between two consecutive processed frames counts toward an
53191
+ * entry's WATCHED unconfirmed time. Frames inside a motion session arrive every
53192
+ * 0.2-2 s; the silence between on-motion sessions is tens of seconds to hours.
53193
+ * 10 s sits above any real in-session cadence (a slower frame still counts
53194
+ * nearly in full) and far below a session gap, so a gap contributes at most
53195
+ * one bound, never its length. Under-counting only delays a retirement.
53196
+ */
53197
+ var WATCHED_GAP_CAP_MS = 1e4;
53198
+ /**
53133
53199
  * How far apart the two reads of a departure must be before the second one
53134
53200
  * counts as CONFIRMING the first.
53135
53201
  *
@@ -59797,6 +59863,25 @@ var TrackAlreadyTrainedError = class extends Error {
59797
59863
  }
59798
59864
  };
59799
59865
  /**
59866
+ * Classify a failed `setTrackFlags` as one of the lifecycle's DELIBERATE
59867
+ * refusals, or `null` when it is a failure of the call itself.
59868
+ *
59869
+ * Needed because the caller that matters (a notification button) reaches the
59870
+ * method over `ctx.api`, where the error class does not survive the hop and
59871
+ * only the message does. The class check comes first; the message check is the
59872
+ * transported form, and `track-flags.spec.ts` pins it against the real
59873
+ * messages so a reworded refusal cannot silently turn into a generic failure.
59874
+ */
59875
+ function retrainRefusalReason(err) {
59876
+ if (err instanceof TrackAlreadyTrainedError) return "trained";
59877
+ if (err instanceof StagingBudgetExceededError) return "budget";
59878
+ const message = err instanceof Error ? err.message : "";
59879
+ if (message.includes("is already trained")) return "trained";
59880
+ if (message.includes("in retrain staging")) return "budget";
59881
+ if (message.includes("unknown track")) return "unknown-track";
59882
+ return null;
59883
+ }
59884
+ /**
59800
59885
  * The `debugNote` half of the patch — `{}` when nothing should be written.
59801
59886
  *
59802
59887
  * The note is not an independent column: it is the operator's sentence about
@@ -62360,6 +62445,19 @@ var StationaryObjectRegistry = class {
62360
62445
  * so quiet minutes must not age the entries (see {@link sweep}). */
62361
62446
  lastFrameAtByDevice = /* @__PURE__ */ new Map();
62362
62447
  /**
62448
+ * WATCHED time each entry has gone unconfirmed, in ms. Entry expiry is judged
62449
+ * on this, never on the span between two frame timestamps: a session-dispatch
62450
+ * camera's silence between motion sessions would otherwise be counted in full
62451
+ * the moment the next session's first frame arrives (measured: a car parked
62452
+ * 3.3 h was retired after seconds of watching, 316 s after its last
62453
+ * confirmation). Each frame adds its delta from the previous one, capped at
62454
+ * {@link WATCHED_GAP_CAP_MS}; a confirmation resets it. `confirmedAt` is the
62455
+ * `lastConfirmedAt` the counter was last reset for, so a changed value (any of
62456
+ * the places that confirm an entry) restarts the count without a hook at each.
62457
+ * An entry that was just hydrated has no record here and starts at zero.
62458
+ */
62459
+ unconfirmedWatched = /* @__PURE__ */ new Map();
62460
+ /**
62363
62461
  * Entries carrying ONE unconfirmed departure read, per device (D49, D174,
62364
62462
  * D279).
62365
62463
  *
@@ -62473,7 +62571,27 @@ var StationaryObjectRegistry = class {
62473
62571
  /** Record that a frame was processed for a device — advances the OBSERVED
62474
62572
  * clock that drives entry expiry in {@link sweep}. */
62475
62573
  noteFrame(deviceId, timestamp) {
62476
- if (timestamp > (this.lastFrameAtByDevice.get(deviceId) ?? 0)) this.lastFrameAtByDevice.set(deviceId, timestamp);
62574
+ const known = this.lastFrameAtByDevice.get(deviceId);
62575
+ if (timestamp <= (known ?? 0)) return;
62576
+ this.lastFrameAtByDevice.set(deviceId, timestamp);
62577
+ const ids = this.idsByDevice.get(deviceId);
62578
+ if (!ids) return;
62579
+ const delta = known === void 0 ? 0 : Math.min(timestamp - known, WATCHED_GAP_CAP_MS);
62580
+ for (const id of ids) {
62581
+ const e = this.ledger.get(id);
62582
+ if (!e) continue;
62583
+ const rec = this.unconfirmedWatched.get(id);
62584
+ if (!rec || rec.confirmedAt !== e.lastConfirmedAt) this.unconfirmedWatched.set(id, {
62585
+ confirmedAt: e.lastConfirmedAt,
62586
+ ms: 0
62587
+ });
62588
+ else rec.ms += delta;
62589
+ }
62590
+ }
62591
+ /** Watched ms an entry has gone unconfirmed (0 when not yet tracked). */
62592
+ watchedUnconfirmedMs(e) {
62593
+ const rec = this.unconfirmedWatched.get(e.id);
62594
+ return rec && rec.confirmedAt === e.lastConfirmedAt ? rec.ms : 0;
62477
62595
  }
62478
62596
  /**
62479
62597
  * Per-frame gate: partition this frame's detections against the device's
@@ -62718,12 +62836,14 @@ var StationaryObjectRegistry = class {
62718
62836
  * logged by `MacMiniAgent.localdomain` and by `agent-a`, the same
62719
62837
  * rows, seven seconds apart.
62720
62838
  *
62721
- * Expiry is measured against the device's latest processed-frame timestamp
62722
- * ({@link noteFrame}), NOT the wall clock: a session-dispatch camera emits
62723
- * no frames between motion sessions, and that silence says nothing about
62724
- * the object. Only when the camera has actually been WATCHING for `ttl`
62839
+ * Expiry is measured in WATCHED time ({@link noteFrame}): the sum of the
62840
+ * gaps between consecutive processed frames, each capped at
62841
+ * {@link WATCHED_GAP_CAP_MS}, since the entry's last confirmation. Neither the
62842
+ * wall clock nor the span between frame timestamps: a session-dispatch
62843
+ * camera emits no frames between motion sessions, and a silent gap must not
62844
+ * age the object. Only when the camera has actually been WATCHING for `ttl`
62725
62845
  * beyond the last confirmation (frames flowed, object never matched) is the
62726
- * object considered removed. A device with no recorded frame yet never
62846
+ * object considered removed. A freshly hydrated entry starts at zero. A device with no recorded frame yet never
62727
62847
  * expires its entries. `now` only stamps the departed telemetry.
62728
62848
  */
62729
62849
  async sweep(now) {
@@ -62742,9 +62862,10 @@ var StationaryObjectRegistry = class {
62742
62862
  ids.delete(id);
62743
62863
  continue;
62744
62864
  }
62745
- const overdue = observedAt - e.lastConfirmedAt > ttl;
62865
+ const watchedMs = this.watchedUnconfirmedMs(e);
62866
+ const overdue = watchedMs > ttl;
62746
62867
  const proven = this.detectorProvedItself(e);
62747
- const unprovenBackstop = observedAt - e.lastConfirmedAt > ttl * 12;
62868
+ const unprovenBackstop = watchedMs > ttl * 12;
62748
62869
  if (overdue && !proven && !unprovenBackstop) {
62749
62870
  this.bumpStrikeCounters(deviceId, { ttlHeldUnproven: 1 });
62750
62871
  continue;
@@ -62760,7 +62881,8 @@ var StationaryObjectRegistry = class {
62760
62881
  className: e.className,
62761
62882
  reason: proven ? "ttl" : "ttl-unproven",
62762
62883
  ttlMs: ttl,
62763
- unconfirmedForMs: observedAt - e.lastConfirmedAt,
62884
+ unconfirmedForMs: watchedMs,
62885
+ wallGapMs: observedAt - e.lastConfirmedAt,
62764
62886
  parkedForMs: observedAt - e.becameStationaryAt
62765
62887
  }
62766
62888
  });
@@ -62844,6 +62966,7 @@ var StationaryObjectRegistry = class {
62844
62966
  for (const id of ids) {
62845
62967
  this.dirty.delete(id);
62846
62968
  this.weakRuns.delete(id);
62969
+ this.unconfirmedWatched.delete(id);
62847
62970
  this.ledger.evict(id);
62848
62971
  }
62849
62972
  this.idsByDevice.delete(deviceId);
@@ -63031,6 +63154,7 @@ var StationaryObjectRegistry = class {
63031
63154
  return at !== void 0 && at > entry.lastConfirmedAt;
63032
63155
  }
63033
63156
  dropEntry(deviceId, id) {
63157
+ this.unconfirmedWatched.delete(id);
63034
63158
  const dropped = this.ledger.get(id);
63035
63159
  this.idsByDevice.get(deviceId)?.delete(id);
63036
63160
  this.dirty.delete(id);
@@ -81938,6 +82062,10 @@ var NC_DEVICE_ROW_KINDS = [
81938
82062
  {
81939
82063
  key: "nc-artifacts",
81940
82064
  kind: ncDurableRecordKinds.artifacts
82065
+ },
82066
+ {
82067
+ key: "nc-detection-verdicts",
82068
+ kind: ncDurableRecordKinds["detection-verdicts"]
81941
82069
  }
81942
82070
  ];
81943
82071
  /**
@@ -82357,6 +82485,82 @@ var NcSequenceStore = class NcSequenceStore {
82357
82485
  }
82358
82486
  };
82359
82487
  //#endregion
82488
+ //#region src/notification-center/detection-verdict-ledger.ts
82489
+ /**
82490
+ * @durable class=ledger owner=notification-center
82491
+ * write="the operator taps Correct / Wrong on a detection notification; one row per track, the latest answer replaces the earlier one"
82492
+ * retention="none — bounded by the tracks a rule asked about; deleted with the camera by the device-removal hook"
82493
+ */
82494
+ var NC_DETECTION_VERDICTS_COLLECTION = "notification-center:detection-verdicts";
82495
+ var NC_DETECTION_VERDICTS_COLUMNS = [
82496
+ {
82497
+ name: "id",
82498
+ type: "TEXT",
82499
+ primaryKey: true,
82500
+ notNull: true
82501
+ },
82502
+ {
82503
+ name: "ruleId",
82504
+ type: "TEXT",
82505
+ notNull: true
82506
+ },
82507
+ {
82508
+ name: "at",
82509
+ type: "INTEGER",
82510
+ notNull: true
82511
+ },
82512
+ (
82513
+ /** The FULL row (Zod-validated on read). */
82514
+ {
82515
+ name: "row",
82516
+ type: "JSON",
82517
+ notNull: true
82518
+ })
82519
+ ];
82520
+ var NcDetectionVerdictRecordSchema = require_clip_model_registry.object({
82521
+ trackId: require_clip_model_registry.string().min(1),
82522
+ deviceId: require_clip_model_registry.number().int(),
82523
+ verdict: require_clip_model_registry._enum(["correct", "wrong"]),
82524
+ /** The class the operator said it really was. Only with `wrong`. */
82525
+ suggestedClass: require_clip_model_registry.string().min(1).optional(),
82526
+ /** Whose notification it was (the token's frozen owner), NOT who tapped. */
82527
+ userId: require_clip_model_registry.string().min(1),
82528
+ ruleId: require_clip_model_registry.string().min(1),
82529
+ ruleName: require_clip_model_registry.string(),
82530
+ at: require_clip_model_registry.number()
82531
+ });
82532
+ var NC_DETECTION_VERDICTS_SPEC = {
82533
+ collection: NC_DETECTION_VERDICTS_COLLECTION,
82534
+ columns: [...NC_DETECTION_VERDICTS_COLUMNS],
82535
+ writeMode: "write-through",
82536
+ keyOf: (row) => row.trackId,
82537
+ toValue: (row) => ({
82538
+ ruleId: row.ruleId,
82539
+ at: row.at,
82540
+ row
82541
+ }),
82542
+ fromRecord: (_key, data) => {
82543
+ const parsed = NcDetectionVerdictRecordSchema.safeParse(data["row"]);
82544
+ return parsed.success ? parsed.data : null;
82545
+ },
82546
+ deviceIdOf: (row) => row.deviceId,
82547
+ loadLimit: 1e4
82548
+ };
82549
+ var NcDetectionVerdictLedger = class {
82550
+ ledger;
82551
+ constructor(deps) {
82552
+ this.ledger = new DurableLedger({
82553
+ spec: NC_DETECTION_VERDICTS_SPEC,
82554
+ kind: deps.records.kind(ncDurableRecordKinds["detection-verdicts"]),
82555
+ logger: deps.logger
82556
+ });
82557
+ }
82558
+ /** Write-through; throws on a failed write so the tap reports it. */
82559
+ async record(row) {
82560
+ await this.ledger.put(NcDetectionVerdictRecordSchema.parse(row));
82561
+ }
82562
+ };
82563
+ //#endregion
82360
82564
  //#region src/notification-center/device-prune.ts
82361
82565
  function judgeList(list, isDead) {
82362
82566
  const dead = list.filter(isDead);
@@ -83311,6 +83515,64 @@ var NcDeviceStateCache = class {
83311
83515
  }
83312
83516
  };
83313
83517
  //#endregion
83518
+ //#region src/notification-center/detection-verdict-buttons.ts
83519
+ var VERDICT_CORRECT_ID = "detection-verdict-correct";
83520
+ var VERDICT_WRONG_ID = "detection-verdict-wrong";
83521
+ /** `detection-verdict-wrong:<class>` — Wrong, and what it really was. */
83522
+ var VERDICT_WRONG_AS_PREFIX = "detection-verdict-wrong:";
83523
+ /** The macro classes offered as "it was really…". Short on purpose. */
83524
+ var VERDICT_CLASS_CHOICES = [
83525
+ "person",
83526
+ "vehicle",
83527
+ "animal"
83528
+ ];
83529
+ /** At most this many class buttons, and only when the channel has room. */
83530
+ var VERDICT_MAX_CLASS_CHOICES = 2;
83531
+ function verdictButtonRefusal(subject) {
83532
+ if (!subject.askVerdict) return "rule-off";
83533
+ if (subject.trackId === void 0) return "no-track";
83534
+ if (subject.ownerUserId === void 0) return "unowned-target";
83535
+ return null;
83536
+ }
83537
+ /** The classes worth offering for a subject of `className`. */
83538
+ function verdictClassChoices(className) {
83539
+ return VERDICT_CLASS_CHOICES.filter((c) => c !== className).slice(0, VERDICT_MAX_CLASS_CHOICES);
83540
+ }
83541
+ /**
83542
+ * Which buttons a channel with `maxActions` slots gets. `maxActions` absent
83543
+ * (the kind catalog did not answer) is treated as the common floor of three:
83544
+ * the two plain choices, never the class ones. The class choices appear only
83545
+ * when the channel declared room for a fourth button.
83546
+ */
83547
+ function verdictButtonSpecs(input) {
83548
+ const base = [{
83549
+ actionId: VERDICT_CORRECT_ID,
83550
+ verdict: "correct"
83551
+ }, {
83552
+ actionId: VERDICT_WRONG_ID,
83553
+ verdict: "wrong"
83554
+ }];
83555
+ if (input.maxActions === void 0 || input.maxActions <= 3) return base;
83556
+ const room = input.maxActions - base.length;
83557
+ const extra = verdictClassChoices(input.className).slice(0, room).map((suggestedClass) => ({
83558
+ actionId: `${VERDICT_WRONG_AS_PREFIX}${suggestedClass}`,
83559
+ verdict: "wrong",
83560
+ suggestedClass
83561
+ }));
83562
+ return [...base, ...extra];
83563
+ }
83564
+ /** Is this one of our buttons (so a link-only channel can lift it into the body)? */
83565
+ function isVerdictAction(action) {
83566
+ return action.id === VERDICT_CORRECT_ID || action.id.startsWith(VERDICT_WRONG_ID);
83567
+ }
83568
+ /**
83569
+ * The body lines that carry the verdict choices on a channel with no buttons.
83570
+ * A bare URL per line: every chat and mail client linkifies it.
83571
+ */
83572
+ function verdictLinkLines(actions) {
83573
+ return actions.filter(isVerdictAction).map((a) => `${a.label}: ${a.url ?? ""}`).join("\n");
83574
+ }
83575
+ //#endregion
83314
83576
  //#region src/notification-center/state-summary/state-model.ts
83315
83577
  /**
83316
83578
  * The STATE SUMMARY's vocabulary (D647): its sections, and what one reported
@@ -87019,6 +87281,8 @@ var NcDispatcher = class {
87019
87281
  targetId: target.id,
87020
87282
  ...target.ownerUserId !== void 0 ? { ownerUserId: target.ownerUserId } : {},
87021
87283
  ...entry.payload.subject.className !== void 0 ? { className: entry.payload.subject.className } : {},
87284
+ ...entry.payload.subject.trackId !== void 0 ? { trackId: entry.payload.subject.trackId } : {},
87285
+ ...target.maxActions !== void 0 ? { maxActions: target.maxActions } : {},
87022
87286
  ...entry.payload.subject.systemEvent !== void 0 ? { systemEvent: entry.payload.subject.systemEvent } : {}
87023
87287
  });
87024
87288
  } catch (err) {
@@ -87114,9 +87378,13 @@ var NcDispatcher = class {
87114
87378
  ...summarySectionOf(systemEvent.data)
87115
87379
  } } : {}
87116
87380
  }) : null;
87117
- const actions = await this.mintActions(entry, target);
87381
+ const minted = await this.mintActions(entry, target);
87382
+ const linkOnly = target.maxActions === 0;
87383
+ const verdictLinks = linkOnly ? verdictLinkLines(minted) : "";
87384
+ const actions = linkOnly ? minted.filter((a) => !isVerdictAction(a)) : minted;
87385
+ const withLinks = (text) => verdictLinks === "" ? text : `${text}\n\n${verdictLinks}`;
87118
87386
  const notification = {
87119
- body,
87387
+ body: withLinks(body),
87120
87388
  title,
87121
87389
  priority: clampPriority(paramNumber(entry.payload.params, "priority") ?? entry.payload.priority),
87122
87390
  ...params,
@@ -87138,7 +87406,7 @@ var NcDispatcher = class {
87138
87406
  grouped: resolved.grouped,
87139
87407
  withAiText: (aiText) => ({
87140
87408
  ...notification,
87141
- body: renderBody(aiText)
87409
+ body: withLinks(renderBody(aiText))
87142
87410
  })
87143
87411
  };
87144
87412
  }
@@ -99751,6 +100019,55 @@ async function mintButtons(host, input) {
99751
100019
  const announcement = stored === null && input.ruleId === NC_ALARM_ANNOUNCE_RULE_ID;
99752
100020
  if (stored === null && !announcement) return [];
99753
100021
  const drafts = [];
100022
+ const verdictRefusal = verdictButtonRefusal({
100023
+ askVerdict: stored?.askVerdict === true,
100024
+ ...input.trackId !== void 0 ? { trackId: input.trackId } : {},
100025
+ ...input.ownerUserId !== void 0 ? { ownerUserId: input.ownerUserId } : {}
100026
+ });
100027
+ if (stored?.askVerdict === true && verdictRefusal !== null) host.logger.info("detection notification carries no verdict buttons", {
100028
+ tags: { deviceId: input.deviceId },
100029
+ meta: {
100030
+ ruleId: input.ruleId,
100031
+ reason: verdictRefusal,
100032
+ targetId: input.targetId
100033
+ }
100034
+ });
100035
+ const verdictTrackId = input.trackId;
100036
+ const verdictOwner = input.ownerUserId;
100037
+ const verdictButtons = verdictRefusal !== null || verdictTrackId === void 0 || verdictOwner === void 0 ? [] : verdictButtonSpecs({
100038
+ className: input.className,
100039
+ maxActions: input.maxActions
100040
+ }).map((spec) => {
100041
+ const id = (0, node_crypto.randomUUID)();
100042
+ drafts.push({
100043
+ id,
100044
+ ttlMs: actionTtlMs({ kind: "detection-verdict" }),
100045
+ grant: (expiresAt) => ({
100046
+ kind: "detection-verdict",
100047
+ id,
100048
+ verdict: spec.verdict,
100049
+ trackId: verdictTrackId,
100050
+ ruleId: input.ruleId,
100051
+ ruleName: input.ruleName,
100052
+ userId: verdictOwner,
100053
+ ...spec.suggestedClass !== void 0 ? { suggestedClass: spec.suggestedClass } : {},
100054
+ ...input.maxActions === 0 ? { viaLink: true } : {},
100055
+ deviceId: input.deviceId,
100056
+ actionId: spec.actionId,
100057
+ targetId: input.targetId,
100058
+ expiresAt
100059
+ })
100060
+ });
100061
+ return {
100062
+ id: spec.actionId,
100063
+ label: spec.suggestedClass === void 0 ? host.texts.text({ key: spec.verdict === "correct" ? "button.verdict.correct" : "button.verdict.wrong" }) : host.texts.text({
100064
+ key: "button.verdict.wrongAs",
100065
+ vars: { class: spec.suggestedClass }
100066
+ }),
100067
+ url: id,
100068
+ mode: "background"
100069
+ };
100070
+ });
99754
100071
  const sequenceButtons = host.actionRunner === null || stored === null ? [] : buildActionButtons({
99755
100072
  actions: stored.actions,
99756
100073
  knownSequenceIds: host.sequences.knownIds(),
@@ -99963,6 +100280,7 @@ async function mintButtons(host, input) {
99963
100280
  }
99964
100281
  });
99965
100282
  const out = [
100283
+ ...verdictButtons,
99966
100284
  ...sequenceButtons,
99967
100285
  ...snoozeButtons,
99968
100286
  ...alarmButtons,
@@ -100186,7 +100504,52 @@ async function runGrantedAction(host, grant) {
100186
100504
  case "sequence": return host.runGrantedSequence(grant);
100187
100505
  case "addon-update": return host.applyGrantedAddonUpdate(grant);
100188
100506
  case "server-update": return host.applyGrantedServerUpdate(grant);
100507
+ case "detection-verdict": return host.applyGrantedDetectionVerdict(grant);
100508
+ }
100509
+ }
100510
+ /** What a person is told when the retrain lifecycle refuses the mark. */
100511
+ var VERDICT_REFUSAL_COPY = {
100512
+ trained: "This track was already used for training — nothing was changed",
100513
+ budget: "This camera already holds as many tracks for retraining as it may — nothing was changed",
100514
+ "unknown-track": "This track is no longer available — nothing was changed"
100515
+ };
100516
+ /**
100517
+ * Mark the track one notification reported for retraining, and record who was
100518
+ * asked and when.
100519
+ *
100520
+ * Order matters. The MARK goes first and is the authority: a refusal ("already
100521
+ * trained", "budget full") means the operator's answer changed nothing, so no
100522
+ * ledger row is written for an answer that was not applied. The token is
100523
+ * already spent (D47) — the refusal is shown as the reason, not a retry.
100524
+ */
100525
+ async function applyGrantedDetectionVerdict(host, grant) {
100526
+ const mark = host.deps.markTrackForRetrain;
100527
+ if (mark === void 0) throw new Error("no retrain mark is wired on this node");
100528
+ const outcome = await mark({
100529
+ deviceId: grant.deviceId,
100530
+ trackId: grant.trackId
100531
+ });
100532
+ if (!outcome.ok) {
100533
+ host.logger.info("detection verdict changed nothing — the retrain flow refused the mark", {
100534
+ tags: { deviceId: grant.deviceId },
100535
+ meta: {
100536
+ trackId: grant.trackId,
100537
+ reason: outcome.reason,
100538
+ verdict: grant.verdict
100539
+ }
100540
+ });
100541
+ throw new NcActionRefusedError(VERDICT_REFUSAL_COPY[outcome.reason]);
100189
100542
  }
100543
+ await host.verdicts.record({
100544
+ trackId: grant.trackId,
100545
+ deviceId: grant.deviceId,
100546
+ verdict: grant.verdict,
100547
+ ...grant.suggestedClass !== void 0 ? { suggestedClass: grant.suggestedClass } : {},
100548
+ userId: grant.userId,
100549
+ ruleId: grant.ruleId,
100550
+ ruleName: grant.ruleName,
100551
+ at: host.now()
100552
+ });
100190
100553
  }
100191
100554
  /**
100192
100555
  * Apply the addon updates one notification was about.
@@ -105364,6 +105727,7 @@ var NotificationCenter = class {
105364
105727
  * evaluating one.
105365
105728
  */
105366
105729
  pruneLedger;
105730
+ verdicts;
105367
105731
  /** D905: which rules cannot work, announced once per distinct reason. */
105368
105732
  ruleHealth = new NcRuleHealthMonitor();
105369
105733
  /** The prune itself — see `device-prune-runner.ts`. */
@@ -105592,6 +105956,11 @@ var NotificationCenter = class {
105592
105956
  applyGrantedArmAnyway: (panel, grant) => self.applyGrantedArmAnyway(panel, grant),
105593
105957
  applyGrantedBypassInput: (panel, grant) => self.applyGrantedBypassInput(panel, grant),
105594
105958
  applyGrantedServerUpdate: (grant) => self.applyGrantedServerUpdate(grant),
105959
+ applyGrantedDetectionVerdict: (grant) => self.applyGrantedDetectionVerdict(grant),
105960
+ now: self.now,
105961
+ get verdicts() {
105962
+ return self.verdicts;
105963
+ },
105595
105964
  applyGrantedSnooze: (grant) => self.applyGrantedSnooze(grant),
105596
105965
  get deps() {
105597
105966
  return self.deps;
@@ -105642,6 +106011,9 @@ var NotificationCenter = class {
105642
106011
  async applyGrantedAddonUpdate(grant) {
105643
106012
  return applyGrantedAddonUpdate(this.grantedActionsHost(), grant);
105644
106013
  }
106014
+ async applyGrantedDetectionVerdict(grant) {
106015
+ return applyGrantedDetectionVerdict(this.grantedActionsHost(), grant);
106016
+ }
105645
106017
  async applyGrantedServerUpdate(grant) {
105646
106018
  return applyGrantedServerUpdate(this.grantedActionsHost(), grant);
105647
106019
  }
@@ -105881,6 +106253,10 @@ var NotificationCenter = class {
105881
106253
  }
105882
106254
  this.summaryRules = NcSummaryStore.of(deps.records, this.logger.child("summary"), deps.now);
105883
106255
  this.calendarEventsRules = NcCalendarEventsStore.of(deps.records, this.logger.child("calendar-events"), deps.now);
106256
+ this.verdicts = new NcDetectionVerdictLedger({
106257
+ ...durableStore,
106258
+ logger: this.logger.child("detection-verdicts")
106259
+ });
105884
106260
  this.pruneLedger = new NcPruneLedger({
105885
106261
  ...durableStore,
105886
106262
  logger: this.logger.child("device-prune")
@@ -108488,6 +108864,23 @@ var BuildNotificationCenter = class extends BuildNotificationCenterBase {
108488
108864
  };
108489
108865
  }
108490
108866
  },
108867
+ markTrackForRetrain: async ({ deviceId, trackId }) => {
108868
+ try {
108869
+ await this.ctx.api.pipelineAnalytics.setTrackFlags.mutate({
108870
+ deviceId,
108871
+ trackId,
108872
+ flags: { markForTrain: true }
108873
+ });
108874
+ return { ok: true };
108875
+ } catch (err) {
108876
+ const reason = retrainRefusalReason(err);
108877
+ if (reason === null) throw err;
108878
+ return {
108879
+ ok: false,
108880
+ reason
108881
+ };
108882
+ }
108883
+ },
108491
108884
  readDeviceStates: async (ids) => {
108492
108885
  const out = /* @__PURE__ */ new Map();
108493
108886
  for (const id of ids) try {
@@ -108559,9 +108952,11 @@ var BuildNotificationCenter = class extends BuildNotificationCenterBase {
108559
108952
  const targets = await api.notificationOutput.listTargets.query({});
108560
108953
  const kinds = await api.notificationOutput.listTargetKinds.query({}).catch(() => []);
108561
108954
  const bodyMaxLenByKind = new Map(kinds.map((k) => [k.kind, k.caps.bodyMaxLen]));
108955
+ const maxActionsByKind = new Map(kinds.map((k) => [k.kind, k.caps.actions]));
108562
108956
  return targets.map((t) => {
108563
108957
  const owner = t.config["ownerUserId"];
108564
108958
  const bodyMaxLen = bodyMaxLenByKind.get(t.kind);
108959
+ const maxActions = maxActionsByKind.get(t.kind);
108565
108960
  return {
108566
108961
  id: t.id,
108567
108962
  addonId: t.addonId,
@@ -108569,7 +108964,8 @@ var BuildNotificationCenter = class extends BuildNotificationCenterBase {
108569
108964
  kind: t.kind,
108570
108965
  enabled: t.enabled,
108571
108966
  ...typeof owner === "string" && owner.length > 0 ? { ownerUserId: owner } : {},
108572
- ...typeof bodyMaxLen === "number" ? { bodyMaxLen } : {}
108967
+ ...typeof bodyMaxLen === "number" ? { bodyMaxLen } : {},
108968
+ ...typeof maxActions === "number" ? { maxActions } : {}
108573
108969
  };
108574
108970
  });
108575
108971
  },