rhombus-node-mcp 0.1.54 → 0.1.55
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.
|
@@ -1,134 +1,139 @@
|
|
|
1
1
|
import { buildMediaHints } from "./badge-correlation.js";
|
|
2
|
-
import {
|
|
2
|
+
import { searchElementsEvents } from "./elements-tool-api.js";
|
|
3
|
+
import { getCameraList } from "./get-entity-tool-api.js";
|
|
4
|
+
import { searchNetboxEvents } from "./netbox-tool-api.js";
|
|
5
|
+
import { searchOnGuardEvents } from "./onguard-tool-api.js";
|
|
6
|
+
import { listReidentificationEmbeddings, searchReidentificationMatchesByEmbedding } from "./reid-tool-api.js";
|
|
3
7
|
import { formatTimestamp } from "../util.js";
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
function matchRegisteredPeople(query, people) {
|
|
10
|
-
const input = query.toLowerCase().trim();
|
|
11
|
-
if (!input)
|
|
12
|
-
return [];
|
|
13
|
-
let bestScore = 0;
|
|
14
|
-
const scored = [];
|
|
15
|
-
for (const person of people) {
|
|
16
|
-
if (!person.name || !person.uuid)
|
|
17
|
-
continue;
|
|
18
|
-
const fullName = person.name.toLowerCase().trim();
|
|
19
|
-
const parts = fullName.split(/\s+/);
|
|
20
|
-
let score = 0;
|
|
21
|
-
if (fullName === input)
|
|
22
|
-
score = 4;
|
|
23
|
-
else if (parts[0] === input)
|
|
24
|
-
score = 3;
|
|
25
|
-
else if (parts.length > 1 && parts[parts.length - 1] === input)
|
|
26
|
-
score = 2;
|
|
27
|
-
else if (input.length >= 3 && fullName.includes(input))
|
|
28
|
-
score = 1;
|
|
29
|
-
if (score > 0) {
|
|
30
|
-
scored.push({ name: person.name, uuid: person.uuid, score });
|
|
31
|
-
if (score > bestScore)
|
|
32
|
-
bestScore = score;
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
// Dedupe by uuid among the best-scoring tier.
|
|
36
|
-
const seen = new Set();
|
|
37
|
-
const top = [];
|
|
38
|
-
for (const s of scored) {
|
|
39
|
-
if (s.score !== bestScore || seen.has(s.uuid))
|
|
40
|
-
continue;
|
|
41
|
-
seen.add(s.uuid);
|
|
42
|
-
top.push({ name: s.name, personUuid: s.uuid });
|
|
43
|
-
}
|
|
44
|
-
return top;
|
|
8
|
+
const DEFAULT_BADGE_MATCH_WINDOW_S = 30;
|
|
9
|
+
const DEFAULT_TRACK_FORWARD_MS = 6 * 60 * 60 * 1000; // track 6h forward from the badge tap if no endTime
|
|
10
|
+
/** RUUID without any `.vN` facet suffix, so a badge event's camera matches the camera-state list. */
|
|
11
|
+
function stripFacet(uuid) {
|
|
12
|
+
return (uuid ?? "").split(".")[0];
|
|
45
13
|
}
|
|
46
14
|
/**
|
|
47
|
-
* Reconstructs
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* -
|
|
51
|
-
*
|
|
52
|
-
*
|
|
15
|
+
* Reconstructs where a named person went, grounded in access control + person re-identification:
|
|
16
|
+
* 1. find the person's badge tap(s) (OnGuard / Elements / NetBox) → a camera + time we KNOW is them,
|
|
17
|
+
* 2. pull the re-id embedding recorded on that camera nearest the badge time (the person at the door),
|
|
18
|
+
* 3. re-id-search that embedding across cameras over the window → their cross-camera movement track.
|
|
19
|
+
*
|
|
20
|
+
* Identity comes from the badge (not face recognition); the track is appearance/re-id based.
|
|
53
21
|
*/
|
|
54
22
|
export async function getPersonTrack(args, timeZone, requestModifiers, sessionId) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
23
|
+
// 1. Anchor on access-control events. Check all three badge integrations; an org may use any.
|
|
24
|
+
const badgeArgs = {
|
|
25
|
+
cardholderQuery: args.personQuery,
|
|
26
|
+
locationUuids: args.locationUuids,
|
|
27
|
+
afterMs: args.afterMs,
|
|
28
|
+
beforeMs: args.beforeMs,
|
|
29
|
+
limit: args.limit ?? 200,
|
|
30
|
+
};
|
|
31
|
+
const empty = { events: [] };
|
|
32
|
+
const [og, el, nb] = await Promise.all([
|
|
33
|
+
searchOnGuardEvents(badgeArgs, timeZone, requestModifiers, sessionId).catch(() => empty),
|
|
34
|
+
searchElementsEvents(badgeArgs, timeZone, requestModifiers, sessionId).catch(() => empty),
|
|
35
|
+
searchNetboxEvents(badgeArgs, timeZone, requestModifiers, sessionId).catch(() => empty),
|
|
36
|
+
]);
|
|
37
|
+
const badgeEvents = [
|
|
38
|
+
...og.events.map((e) => ({ ...e, integration: "OnGuard" })),
|
|
39
|
+
...el.events.map((e) => ({ ...e, integration: "Elements" })),
|
|
40
|
+
...nb.events.map((e) => ({ ...e, integration: "NetBox" })),
|
|
41
|
+
]
|
|
42
|
+
.filter((e) => e.deviceUuid && e.timestampMs != null)
|
|
43
|
+
.sort((a, b) => (a.timestampMs ?? 0) - (b.timestampMs ?? 0));
|
|
44
|
+
if (badgeEvents.length === 0) {
|
|
45
|
+
return {
|
|
46
|
+
sightings: [],
|
|
47
|
+
path: [],
|
|
48
|
+
count: 0,
|
|
49
|
+
note: `No access-control (badge) events found for "${args.personQuery}" in the window — can't anchor a re-id track. Try a wider time range, or confirm the name as it appears on the badge.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Earliest badge tap in the window — track forward from there.
|
|
53
|
+
const anchor = badgeEvents[0];
|
|
54
|
+
const resolvedPerson = anchor.cardholderName ? { name: anchor.cardholderName } : undefined;
|
|
55
|
+
const anchorOut = {
|
|
56
|
+
deviceUuid: anchor.deviceUuid,
|
|
57
|
+
timestampMs: anchor.timestampMs,
|
|
58
|
+
datetime: anchor.datetime,
|
|
59
|
+
integration: anchor.integration,
|
|
60
|
+
area: anchor.areaEntering ?? anchor.areaExiting,
|
|
61
|
+
};
|
|
62
|
+
// 2. Resolve the badge camera's location (re-id list is scoped by location).
|
|
63
|
+
let anchorLocationUuid;
|
|
64
|
+
try {
|
|
65
|
+
const { cameras } = await getCameraList(requestModifiers, sessionId);
|
|
66
|
+
const dev = stripFacet(anchor.deviceUuid);
|
|
67
|
+
anchorLocationUuid = cameras.find((c) => stripFacet(c.uuid) === dev)?.locationUuid;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// best-effort; the re-id list can still run device-scoped without a location
|
|
71
|
+
}
|
|
72
|
+
// 3. Ground the re-id embedding: the person detected on that camera nearest the badge tap.
|
|
73
|
+
const winMs = (args.badgeMatchWindowSeconds ?? DEFAULT_BADGE_MATCH_WINDOW_S) * 1000;
|
|
74
|
+
const anchorMs = anchor.timestampMs;
|
|
75
|
+
const embeddings = await listReidentificationEmbeddings({
|
|
76
|
+
deviceUuids: [anchor.deviceUuid],
|
|
77
|
+
locationUuid: anchorLocationUuid,
|
|
78
|
+
startTimestampMs: anchorMs - winMs,
|
|
79
|
+
endTimestampMs: anchorMs + winMs,
|
|
80
|
+
limit: 100,
|
|
81
|
+
}, requestModifiers, sessionId);
|
|
82
|
+
if (embeddings.length === 0) {
|
|
83
|
+
return {
|
|
84
|
+
resolvedPerson,
|
|
85
|
+
anchor: anchorOut,
|
|
86
|
+
sightings: [],
|
|
87
|
+
path: [],
|
|
88
|
+
count: 0,
|
|
89
|
+
note: `Found ${anchor.cardholderName ?? "the person"}'s badge tap at ${anchor.datetime}, but no person re-identification embedding was recorded on that camera within ±${args.badgeMatchWindowSeconds ?? DEFAULT_BADGE_MATCH_WINDOW_S}s — can't build a re-id track. (Re-id needs human-detection coverage on the door camera.)`,
|
|
90
|
+
};
|
|
67
91
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return { sightings: [], path: [], count: 0 };
|
|
79
|
-
}
|
|
80
|
-
resolvedPerson = matches[0];
|
|
81
|
-
personUuid = matches[0].personUuid;
|
|
82
|
-
}
|
|
83
|
-
else if (personUuid) {
|
|
84
|
-
resolvedPerson = { personUuid };
|
|
85
|
-
}
|
|
86
|
-
if (!personUuid) {
|
|
87
|
-
return { sightings: [], path: [], count: 0 };
|
|
88
|
-
}
|
|
89
|
-
const faceEventArgs = {
|
|
90
|
-
pageRequest: { maxPageSize: args.limit ?? 200, lastEvaluatedKey: null },
|
|
91
|
-
searchFilter: {
|
|
92
|
-
faceNameContains: null,
|
|
93
|
-
faceNames: [],
|
|
94
|
-
hasEmbedding: null,
|
|
95
|
-
hasName: null,
|
|
96
|
-
labels: [],
|
|
97
|
-
locationUuids: args.locationUuids ?? [],
|
|
98
|
-
personUuids: [personUuid],
|
|
99
|
-
timestampFilter: args.afterMs != null || args.beforeMs != null
|
|
100
|
-
? {
|
|
101
|
-
rangeStart: args.afterMs != null ? new Date(args.afterMs).toISOString() : null,
|
|
102
|
-
rangeEnd: args.beforeMs != null ? new Date(args.beforeMs).toISOString() : null,
|
|
103
|
-
}
|
|
104
|
-
: null,
|
|
105
|
-
},
|
|
92
|
+
// The detection closest in time to the badge tap is the best proxy for the badge holder.
|
|
93
|
+
const seed = embeddings.reduce((best, e) => Math.abs((e.timestamp ?? 0) - anchorMs) < Math.abs((best.timestamp ?? 0) - anchorMs) ? e : best);
|
|
94
|
+
if (!seed.embedding || seed.embedding.length === 0) {
|
|
95
|
+
return {
|
|
96
|
+
resolvedPerson,
|
|
97
|
+
anchor: anchorOut,
|
|
98
|
+
sightings: [],
|
|
99
|
+
path: [],
|
|
100
|
+
count: 0,
|
|
101
|
+
note: "The re-id detection at the door had no embedding vector; can't search for matches.",
|
|
106
102
|
};
|
|
107
|
-
const { faceEvents } = await getFaceEvents(faceEventArgs, timeZone, requestModifiers, sessionId);
|
|
108
|
-
raw = faceEvents.map((e) => ({
|
|
109
|
-
deviceUuid: e.deviceUuid,
|
|
110
|
-
timestampMs: e.eventTimestampMs,
|
|
111
|
-
datetime: e.eventTimestamp,
|
|
112
|
-
locationUuid: e.locationUuid,
|
|
113
|
-
faceName: e.faceName,
|
|
114
|
-
thumbnailS3Key: e.thumbnailS3Key,
|
|
115
|
-
}));
|
|
116
|
-
if (!resolvedPerson?.name) {
|
|
117
|
-
const named = raw.find((r) => r.faceName);
|
|
118
|
-
if (named?.faceName)
|
|
119
|
-
resolvedPerson = { name: named.faceName, personUuid };
|
|
120
|
-
}
|
|
121
103
|
}
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
104
|
+
// 4. Re-id search that appearance across cameras over the window.
|
|
105
|
+
const searchStart = args.afterMs ?? anchorMs;
|
|
106
|
+
const searchEnd = args.beforeMs ?? anchorMs + DEFAULT_TRACK_FORWARD_MS;
|
|
107
|
+
const matches = await searchReidentificationMatchesByEmbedding({
|
|
108
|
+
searchEmbedding: seed.embedding,
|
|
109
|
+
locationUuid: args.locationUuids?.[0],
|
|
110
|
+
startTimestampMs: searchStart,
|
|
111
|
+
endTimestampMs: searchEnd,
|
|
112
|
+
limit: args.limit ?? 200,
|
|
113
|
+
}, requestModifiers, sessionId);
|
|
114
|
+
// 5. Build the chronological track with media hints.
|
|
115
|
+
const ordered = matches
|
|
116
|
+
.filter((m) => m.timestamp != null)
|
|
117
|
+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
|
|
118
|
+
const sightings = ordered.map((m, i) => {
|
|
119
|
+
const next = ordered[i + 1];
|
|
120
|
+
const gapToNextSeconds = next?.timestamp != null && m.timestamp != null
|
|
121
|
+
? Math.round((next.timestamp - m.timestamp) / 1000)
|
|
122
|
+
: undefined;
|
|
123
|
+
const { clipHint, stillHint } = buildMediaHints({ deviceUuid: m.deviceUuid, timestampMs: m.timestamp }, args.clipPaddingSeconds);
|
|
124
|
+
return {
|
|
125
|
+
timestampMs: m.timestamp,
|
|
126
|
+
datetime: m.timestamp != null ? formatTimestamp(m.timestamp, timeZone) : undefined,
|
|
127
|
+
deviceUuid: m.deviceUuid,
|
|
128
|
+
locationUuid: m.locationUuid,
|
|
129
|
+
distance: m.distance,
|
|
130
|
+
stableTrackId: m.stableTrackId,
|
|
131
|
+
thumbnailUri: m.thumbnailUri,
|
|
132
|
+
clipHint,
|
|
133
|
+
stillHint,
|
|
134
|
+
gapToNextSeconds,
|
|
135
|
+
};
|
|
136
|
+
});
|
|
132
137
|
const path = [];
|
|
133
138
|
for (const s of sightings) {
|
|
134
139
|
if (s.deviceUuid && s.deviceUuid !== path[path.length - 1])
|
|
@@ -136,30 +141,10 @@ export async function getPersonTrack(args, timeZone, requestModifiers, sessionId
|
|
|
136
141
|
}
|
|
137
142
|
return {
|
|
138
143
|
resolvedPerson,
|
|
144
|
+
anchor: anchorOut,
|
|
139
145
|
sightings,
|
|
140
146
|
path,
|
|
141
147
|
lastKnownSighting: sightings[sightings.length - 1],
|
|
142
148
|
count: sightings.length,
|
|
143
149
|
};
|
|
144
150
|
}
|
|
145
|
-
function buildSightings(ordered, timeZone, clipPaddingSeconds) {
|
|
146
|
-
return ordered.map((s, i) => {
|
|
147
|
-
const next = ordered[i + 1];
|
|
148
|
-
const gapToNextSeconds = next?.timestampMs != null && s.timestampMs != null
|
|
149
|
-
? Math.round((next.timestampMs - s.timestampMs) / 1000)
|
|
150
|
-
: undefined;
|
|
151
|
-
const { clipHint, stillHint } = buildMediaHints({ deviceUuid: s.deviceUuid, timestampMs: s.timestampMs }, clipPaddingSeconds);
|
|
152
|
-
return {
|
|
153
|
-
timestampMs: s.timestampMs,
|
|
154
|
-
datetime: s.datetime ?? (s.timestampMs != null ? formatTimestamp(s.timestampMs, timeZone) : undefined),
|
|
155
|
-
deviceUuid: s.deviceUuid,
|
|
156
|
-
locationUuid: s.locationUuid,
|
|
157
|
-
faceName: s.faceName,
|
|
158
|
-
similarity: s.similarity,
|
|
159
|
-
thumbnailS3Key: s.thumbnailS3Key,
|
|
160
|
-
clipHint,
|
|
161
|
-
stillHint,
|
|
162
|
-
gapToNextSeconds,
|
|
163
|
-
};
|
|
164
|
-
});
|
|
165
|
-
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { postApi } from "../network/network.js";
|
|
2
|
+
function mapEmbedding(e) {
|
|
3
|
+
return {
|
|
4
|
+
deviceUuid: e.deviceUuid ?? undefined,
|
|
5
|
+
locationUuid: e.locationUuid ?? undefined,
|
|
6
|
+
timestamp: e.timestamp ?? undefined,
|
|
7
|
+
embedding: (e.embedding ?? []).filter((n) => n != null),
|
|
8
|
+
embeddingId: e.embeddingId ?? undefined,
|
|
9
|
+
stableTrackId: e.stableTrackId ?? undefined,
|
|
10
|
+
thumbnailUri: e.thumbnailUri ?? undefined,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Lists the person re-identification embeddings recorded on the given camera(s) in a time window —
|
|
15
|
+
* i.e. the people the camera's human-detection AI saw. Used to grab the embedding of the person standing
|
|
16
|
+
* at a door at a known moment (e.g. a badge tap), so it can be tracked across other cameras.
|
|
17
|
+
*/
|
|
18
|
+
export async function listReidentificationEmbeddings(args, requestModifiers, sessionId) {
|
|
19
|
+
const body = {
|
|
20
|
+
deviceUuids: args.deviceUuids,
|
|
21
|
+
locationUuid: args.locationUuid,
|
|
22
|
+
startTimestampMs: args.startTimestampMs,
|
|
23
|
+
endTimestampMs: args.endTimestampMs,
|
|
24
|
+
limit: args.limit ?? 100,
|
|
25
|
+
};
|
|
26
|
+
const res = await postApi({
|
|
27
|
+
route: "/search/listReidentificationEmbeddings",
|
|
28
|
+
body,
|
|
29
|
+
modifiers: requestModifiers,
|
|
30
|
+
sessionId,
|
|
31
|
+
});
|
|
32
|
+
if (res.error)
|
|
33
|
+
throw new Error(res.errorMsg ?? "listReidentificationEmbeddings failed");
|
|
34
|
+
return (res.embeddings ?? []).map(mapEmbedding);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Searches person re-identification matches for a given appearance embedding across cameras and time —
|
|
38
|
+
* "find this same person elsewhere". Returns sightings ordered by similarity (distance asc; lower is a
|
|
39
|
+
* closer match).
|
|
40
|
+
*/
|
|
41
|
+
export async function searchReidentificationMatchesByEmbedding(args, requestModifiers, sessionId) {
|
|
42
|
+
const body = {
|
|
43
|
+
searchEmbedding: args.searchEmbedding,
|
|
44
|
+
deviceUuids: args.deviceUuids,
|
|
45
|
+
locationUuid: args.locationUuid,
|
|
46
|
+
startTimestampMs: args.startTimestampMs,
|
|
47
|
+
endTimestampMs: args.endTimestampMs,
|
|
48
|
+
limit: args.limit ?? 100,
|
|
49
|
+
};
|
|
50
|
+
const res = await postApi({
|
|
51
|
+
route: "/search/searchReidentificationMatchesByEmbedding",
|
|
52
|
+
body,
|
|
53
|
+
modifiers: requestModifiers,
|
|
54
|
+
sessionId,
|
|
55
|
+
});
|
|
56
|
+
if (res.error)
|
|
57
|
+
throw new Error(res.errorMsg ?? "searchReidentificationMatchesByEmbedding failed");
|
|
58
|
+
return (res.matches ?? []).map((m) => ({
|
|
59
|
+
...mapEmbedding(m.embedding ?? {}),
|
|
60
|
+
distance: m.distance ?? undefined,
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
@@ -3,28 +3,25 @@ import { OUTPUT_SCHEMA, TOOL_ARGS } from "../types/person-tracking-tool-types.js
|
|
|
3
3
|
import { createToolStructuredContent, extractFromToolExtra } from "../util.js";
|
|
4
4
|
const TOOL_NAME = "person-tracking-tool";
|
|
5
5
|
const TOOL_DESCRIPTION = `
|
|
6
|
-
|
|
7
|
-
"
|
|
8
|
-
|
|
6
|
+
Reconstructs where a named person went across cameras, e.g. "show me where Brandon Salzberg went" or
|
|
7
|
+
"track Eve through the building yesterday". Identity is grounded in ACCESS CONTROL and the track uses
|
|
8
|
+
person RE-IDENTIFICATION (appearance), NOT face recognition:
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
directory. If "ambiguousPeople" is returned the name matched more than one person — ask the user which
|
|
16
|
-
one before trusting the track.
|
|
10
|
+
1. Finds the person's badge tap(s) (OnGuard / Elements / NetBox) in the window — a camera + time we KNOW
|
|
11
|
+
is them. (Pass the name as it appears on the badge.)
|
|
12
|
+
2. Pulls the person re-id embedding recorded on that door camera nearest the badge tap (the person at the
|
|
13
|
+
door).
|
|
14
|
+
3. Re-id-searches that appearance across all cameras over the window to reconstruct their movement.
|
|
17
15
|
|
|
18
|
-
Returns
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
24
|
-
person's last-known location).
|
|
16
|
+
Returns:
|
|
17
|
+
- resolvedPerson and "anchor" (the badge tap that grounded the track: door camera, time, integration).
|
|
18
|
+
- sightings: chronological re-id hits, each with deviceUuid (camera), timestampMs/datetime, distance
|
|
19
|
+
(LOWER = closer appearance match), a thumbnail, clipHint/stillHint, and gapToNextSeconds.
|
|
20
|
+
- path (camera sequence) and lastKnownSighting (last-known location).
|
|
21
|
+
- note: set when no badge tap was found, or no re-id embedding existed on the door camera.
|
|
25
22
|
|
|
26
23
|
Resolve relative times like "yesterday" to ISO 8601 first (use the timestamp tool), then pass
|
|
27
|
-
startTime/endTime.
|
|
24
|
+
startTime/endTime. Re-id depends on human-detection coverage, so treat the track as investigative, not proof.
|
|
28
25
|
|
|
29
26
|
IMPORTANT — to show the movement visually: for each sighting (or the key transitions), call the camera-tool
|
|
30
27
|
(requestType "image", cameraUuid = sighting.deviceUuid, timestamp = sighting.timestampMs) for a still, and/or
|
|
@@ -35,12 +32,11 @@ const TOOL_HANDLER = async (args, _extra) => {
|
|
|
35
32
|
const { requestModifiers, sessionId } = extractFromToolExtra(_extra);
|
|
36
33
|
try {
|
|
37
34
|
const result = await getPersonTrack({
|
|
38
|
-
personQuery: args.personQuery
|
|
39
|
-
personUuid: args.personUuid ?? undefined,
|
|
40
|
-
faceEventUuid: args.faceEventUuid ?? undefined,
|
|
41
|
-
locationUuids: args.locationUuids ?? undefined,
|
|
35
|
+
personQuery: args.personQuery,
|
|
42
36
|
afterMs: args.startTime ? new Date(args.startTime).getTime() : undefined,
|
|
43
37
|
beforeMs: args.endTime ? new Date(args.endTime).getTime() : undefined,
|
|
38
|
+
locationUuids: args.locationUuids ?? undefined,
|
|
39
|
+
badgeMatchWindowSeconds: args.badgeMatchWindowSeconds ?? undefined,
|
|
44
40
|
clipPaddingSeconds: args.clipPaddingSeconds ?? undefined,
|
|
45
41
|
limit: args.limit ?? undefined,
|
|
46
42
|
}, args.timeZone ?? "UTC", requestModifiers, sessionId);
|
|
@@ -4,32 +4,25 @@ import { ISOTimestampFormatDescription } from "../utils/timestampInput.js";
|
|
|
4
4
|
export const TOOL_ARGS = {
|
|
5
5
|
personQuery: z
|
|
6
6
|
.string()
|
|
7
|
-
.
|
|
8
|
-
.describe('The recognized person to track, full-text name match against registered faces, e.g. "Eve" or "Eve Adams". ' +
|
|
9
|
-
"Provide this OR personUuid OR faceEventUuid."),
|
|
10
|
-
personUuid: z
|
|
11
|
-
.string()
|
|
12
|
-
.nullable()
|
|
13
|
-
.describe("The exact person UUID to track (from faces-tool get-registered-faces). Takes precedence over personQuery."),
|
|
14
|
-
faceEventUuid: z
|
|
15
|
-
.string()
|
|
16
|
-
.nullable()
|
|
17
|
-
.describe("Track by appearance from a specific face sighting (a faceEvent UUID), even when the person isn't a registered/named face. " +
|
|
18
|
-
"Use this for 'track THIS person' from a sighting. Takes precedence over personQuery/personUuid."),
|
|
19
|
-
locationUuids: z
|
|
20
|
-
.array(createUuidSchema())
|
|
21
|
-
.nullable()
|
|
22
|
-
.describe("Optional: restrict to these Rhombus location UUIDs. Use the location-tool to resolve names."),
|
|
7
|
+
.describe('The person to track, full-text name match against their access-control (badge) records, e.g. "Brandon" or "Brandon Salzberg".'),
|
|
23
8
|
startTime: z
|
|
24
9
|
.string()
|
|
25
10
|
.datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
|
|
26
11
|
.nullable()
|
|
27
|
-
.describe("Start of the window (inclusive). " + ISOTimestampFormatDescription),
|
|
12
|
+
.describe("Start of the window to search badge taps and track over (inclusive). " + ISOTimestampFormatDescription),
|
|
28
13
|
endTime: z
|
|
29
14
|
.string()
|
|
30
15
|
.datetime({ message: "Invalid datetime string. Expected ISO 8601 format.", offset: true })
|
|
31
16
|
.nullable()
|
|
32
17
|
.describe("End of the window (inclusive). " + ISOTimestampFormatDescription),
|
|
18
|
+
locationUuids: z
|
|
19
|
+
.array(createUuidSchema())
|
|
20
|
+
.nullable()
|
|
21
|
+
.describe("Optional: restrict badge search and the re-id track to these Rhombus location UUIDs."),
|
|
22
|
+
badgeMatchWindowSeconds: z
|
|
23
|
+
.number()
|
|
24
|
+
.nullable()
|
|
25
|
+
.describe("± seconds around the badge tap to look on the door camera for the person's re-id embedding (default 30)."),
|
|
33
26
|
clipPaddingSeconds: z
|
|
34
27
|
.number()
|
|
35
28
|
.nullable()
|
|
@@ -42,36 +35,22 @@ export const TOOL_ARGS = {
|
|
|
42
35
|
};
|
|
43
36
|
const TOOL_ARGS_SCHEMA = z.object(TOOL_ARGS);
|
|
44
37
|
const ClipHintSchema = z
|
|
45
|
-
.object({
|
|
46
|
-
deviceUuid: z.string(),
|
|
47
|
-
startTimeMs: z.number(),
|
|
48
|
-
endTimeMs: z.number(),
|
|
49
|
-
})
|
|
38
|
+
.object({ deviceUuid: z.string(), startTimeMs: z.number(), endTimeMs: z.number() })
|
|
50
39
|
.describe("Pass to clips-tool createClip to get video of this sighting.");
|
|
51
40
|
const StillHintSchema = z
|
|
52
|
-
.object({
|
|
53
|
-
deviceUuid: z.string(),
|
|
54
|
-
timestampMs: z.number(),
|
|
55
|
-
})
|
|
41
|
+
.object({ deviceUuid: z.string(), timestampMs: z.number() })
|
|
56
42
|
.describe("Pass to camera-tool (requestType image) to get a still of this sighting.");
|
|
57
|
-
const PersonRefSchema = z.object({
|
|
58
|
-
name: z.string().optional(),
|
|
59
|
-
personUuid: z.string().optional(),
|
|
60
|
-
});
|
|
61
43
|
export const SightingSchema = z.object({
|
|
62
44
|
timestampMs: z.number().optional(),
|
|
63
45
|
datetime: z.string().optional().describe("Human-readable sighting time in the requested timezone."),
|
|
64
|
-
deviceUuid: z
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.describe("The camera that saw the person. Pass to camera-tool (image) or clips-tool (createClip)."),
|
|
68
|
-
locationUuid: z.string().optional().describe("The location where the sighting occurred."),
|
|
69
|
-
faceName: z.string().optional().describe("The recognized name on this sighting, if any."),
|
|
70
|
-
similarity: z
|
|
46
|
+
deviceUuid: z.string().optional().describe("The camera that re-identified the person."),
|
|
47
|
+
locationUuid: z.string().optional(),
|
|
48
|
+
distance: z
|
|
71
49
|
.number()
|
|
72
50
|
.optional()
|
|
73
|
-
.describe("
|
|
74
|
-
|
|
51
|
+
.describe("Re-id match distance to the door embedding — LOWER means a closer appearance match."),
|
|
52
|
+
stableTrackId: z.number().optional().describe("Per-camera track-consolidation id for this detection."),
|
|
53
|
+
thumbnailUri: z.string().optional().describe("Thumbnail of the detected person."),
|
|
75
54
|
clipHint: ClipHintSchema.optional(),
|
|
76
55
|
stillHint: StillHintSchema.optional(),
|
|
77
56
|
gapToNextSeconds: z
|
|
@@ -79,21 +58,31 @@ export const SightingSchema = z.object({
|
|
|
79
58
|
.optional()
|
|
80
59
|
.describe("Seconds until the next sighting — large gaps mean the person was unobserved between cameras."),
|
|
81
60
|
});
|
|
61
|
+
const AnchorSchema = z
|
|
62
|
+
.object({
|
|
63
|
+
deviceUuid: z.string().optional().describe("The door camera where the badge tap happened."),
|
|
64
|
+
timestampMs: z.number().optional(),
|
|
65
|
+
datetime: z.string().optional(),
|
|
66
|
+
integration: z.string().optional().describe("Which badge system the tap came from (OnGuard / Elements / NetBox)."),
|
|
67
|
+
area: z.string().optional(),
|
|
68
|
+
})
|
|
69
|
+
.describe("The access-control badge tap used to ground the re-id track (the known identity moment).");
|
|
82
70
|
export const OUTPUT_SCHEMA = z.object({
|
|
83
|
-
resolvedPerson:
|
|
84
|
-
|
|
85
|
-
.array(PersonRefSchema)
|
|
71
|
+
resolvedPerson: z
|
|
72
|
+
.object({ name: z.string().optional() })
|
|
86
73
|
.optional()
|
|
87
|
-
.describe("
|
|
74
|
+
.describe("The person resolved from the badge record."),
|
|
75
|
+
anchor: AnchorSchema.optional(),
|
|
88
76
|
sightings: z
|
|
89
77
|
.array(SightingSchema)
|
|
90
78
|
.optional()
|
|
91
|
-
.describe("
|
|
79
|
+
.describe("Re-id sightings of the person across cameras, in chronological order (oldest first)."),
|
|
92
80
|
path: z
|
|
93
81
|
.array(z.string())
|
|
94
82
|
.optional()
|
|
95
|
-
.describe("Camera UUIDs the person
|
|
96
|
-
lastKnownSighting: SightingSchema.optional().describe("The most recent sighting —
|
|
97
|
-
count: z.number().optional().describe("Number of sightings returned."),
|
|
83
|
+
.describe("Camera UUIDs the person was re-identified at, in order (consecutive repeats collapsed)."),
|
|
84
|
+
lastKnownSighting: SightingSchema.optional().describe("The most recent re-id sighting — last-known location."),
|
|
85
|
+
count: z.number().optional().describe("Number of re-id sightings returned."),
|
|
86
|
+
note: z.string().optional().describe("Set when the track couldn't be built (no badge tap, or no re-id at the door)."),
|
|
98
87
|
error: z.string().optional(),
|
|
99
88
|
});
|