@siduri-x/observation 1.0.0
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.
- package/dist/index.d.ts +43 -0
- package/dist/index.js +117 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +48 -0
- package/organ-manifest.json +18 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { VisionOrgan } from '@siduri-x/core';
|
|
2
|
+
export interface ObservationReading {
|
|
3
|
+
entity: string;
|
|
4
|
+
value: string;
|
|
5
|
+
confidence: number;
|
|
6
|
+
sourceCrop?: string;
|
|
7
|
+
ocrText?: string;
|
|
8
|
+
competingInterpretations?: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface Observation {
|
|
11
|
+
observationId: string;
|
|
12
|
+
evidenceId: string;
|
|
13
|
+
sourceName: string;
|
|
14
|
+
providerId: string;
|
|
15
|
+
readings: ObservationReading[];
|
|
16
|
+
confidence: number;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
expiresAt: string;
|
|
19
|
+
frameDigest: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ObservationResult {
|
|
22
|
+
observation?: Observation;
|
|
23
|
+
duplicate: boolean;
|
|
24
|
+
reason?: 'empty_frame' | 'duplicate_frame' | 'invalid_reading' | 'provider_failure';
|
|
25
|
+
}
|
|
26
|
+
export interface ObservationOrgan {
|
|
27
|
+
ingest(frame: Uint8Array, sourceName: string, providerId?: string): Promise<ObservationResult>;
|
|
28
|
+
current(now?: Date): Observation[];
|
|
29
|
+
clearExpired(now?: Date): number;
|
|
30
|
+
}
|
|
31
|
+
export declare function digestFrame(frame: Uint8Array): string;
|
|
32
|
+
export declare class FixtureObservationOrgan implements ObservationOrgan {
|
|
33
|
+
private readonly vision;
|
|
34
|
+
private readonly ttlMs;
|
|
35
|
+
private readonly maxFrames;
|
|
36
|
+
private readonly maxFrameBytes;
|
|
37
|
+
private readonly observations;
|
|
38
|
+
private readonly digests;
|
|
39
|
+
constructor(vision: VisionOrgan, ttlMs?: number, maxFrames?: number, maxFrameBytes?: number);
|
|
40
|
+
ingest(frame: Uint8Array, sourceName: string, providerId?: string): Promise<ObservationResult>;
|
|
41
|
+
current(now?: Date): Observation[];
|
|
42
|
+
clearExpired(now?: Date): number;
|
|
43
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FixtureObservationOrgan = void 0;
|
|
4
|
+
exports.digestFrame = digestFrame;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
function digestFrame(frame) {
|
|
7
|
+
// Cryptographically robust SHA-256 content identity digest
|
|
8
|
+
return (0, node_crypto_1.createHash)('sha256').update(frame).digest('hex');
|
|
9
|
+
}
|
|
10
|
+
function frameDataUrl(frame) {
|
|
11
|
+
return `data:image/png;base64,${Buffer.from(frame).toString('base64')}`;
|
|
12
|
+
}
|
|
13
|
+
function id(prefix) {
|
|
14
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
15
|
+
}
|
|
16
|
+
function clampConfidence(value) {
|
|
17
|
+
return typeof value === 'number' && Number.isFinite(value)
|
|
18
|
+
? Math.max(0, Math.min(1, value))
|
|
19
|
+
: 0;
|
|
20
|
+
}
|
|
21
|
+
function parseReadings(value) {
|
|
22
|
+
let parsed;
|
|
23
|
+
try {
|
|
24
|
+
parsed = JSON.parse(value);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const raw = Array.isArray(parsed) ? parsed : (parsed && typeof parsed === 'object' && Array.isArray(parsed.readings) ? parsed.readings : undefined);
|
|
30
|
+
if (!raw)
|
|
31
|
+
return undefined;
|
|
32
|
+
const readings = raw.filter((item) => item && typeof item.entity === 'string' && typeof item.value === 'string')
|
|
33
|
+
.map((item) => ({
|
|
34
|
+
entity: item.entity.slice(0, 96),
|
|
35
|
+
value: item.value.slice(0, 512),
|
|
36
|
+
confidence: clampConfidence(item.confidence),
|
|
37
|
+
sourceCrop: typeof item.source_crop === 'string' ? item.source_crop : undefined,
|
|
38
|
+
ocrText: typeof item.ocr_text === 'string' ? item.ocr_text.slice(0, 512) : undefined,
|
|
39
|
+
competingInterpretations: Array.isArray(item.competing_interpretations)
|
|
40
|
+
? item.competing_interpretations.filter((v) => typeof v === 'string').slice(0, 4)
|
|
41
|
+
: undefined,
|
|
42
|
+
}));
|
|
43
|
+
return readings.length === raw.length ? readings : undefined;
|
|
44
|
+
}
|
|
45
|
+
class FixtureObservationOrgan {
|
|
46
|
+
vision;
|
|
47
|
+
ttlMs;
|
|
48
|
+
maxFrames;
|
|
49
|
+
maxFrameBytes;
|
|
50
|
+
observations = [];
|
|
51
|
+
digests = new Set();
|
|
52
|
+
constructor(vision, ttlMs = 30_000, maxFrames = 8, maxFrameBytes = 10 * 1024 * 1024) {
|
|
53
|
+
this.vision = vision;
|
|
54
|
+
this.ttlMs = ttlMs;
|
|
55
|
+
this.maxFrames = maxFrames;
|
|
56
|
+
this.maxFrameBytes = maxFrameBytes;
|
|
57
|
+
if (ttlMs <= 0 || maxFrames <= 0)
|
|
58
|
+
throw new Error('observation limits must be positive');
|
|
59
|
+
}
|
|
60
|
+
async ingest(frame, sourceName, providerId = 'vision') {
|
|
61
|
+
if (!frame.length)
|
|
62
|
+
return { duplicate: false, reason: 'empty_frame' };
|
|
63
|
+
if (frame.byteLength > this.maxFrameBytes) {
|
|
64
|
+
throw new Error(`Observation frame exceeds maximum allowed size of ${this.maxFrameBytes} bytes (received ${frame.byteLength})`);
|
|
65
|
+
}
|
|
66
|
+
this.clearExpired();
|
|
67
|
+
const frameDigest = digestFrame(frame);
|
|
68
|
+
if (this.digests.has(frameDigest))
|
|
69
|
+
return { duplicate: true, reason: 'duplicate_frame' };
|
|
70
|
+
let readings;
|
|
71
|
+
try {
|
|
72
|
+
// The frame is converted only for the provider call and is never stored.
|
|
73
|
+
const imageUrl = frameDataUrl(frame);
|
|
74
|
+
readings = parseReadings(await this.vision.analyze(imageUrl, 'Return only visible readings as JSON with entity, value, and confidence.'));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return { duplicate: false, reason: 'provider_failure' };
|
|
78
|
+
}
|
|
79
|
+
if (!readings)
|
|
80
|
+
return { duplicate: false, reason: 'invalid_reading' };
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
const observation = {
|
|
83
|
+
observationId: id('obs'),
|
|
84
|
+
evidenceId: id('evidence'),
|
|
85
|
+
sourceName: sourceName.slice(0, 96),
|
|
86
|
+
providerId: providerId.slice(0, 96),
|
|
87
|
+
readings,
|
|
88
|
+
confidence: readings.length ? readings.reduce((sum, item) => sum + item.confidence, 0) / readings.length : 0,
|
|
89
|
+
createdAt: new Date(now).toISOString(),
|
|
90
|
+
expiresAt: new Date(now + this.ttlMs).toISOString(),
|
|
91
|
+
frameDigest,
|
|
92
|
+
};
|
|
93
|
+
this.observations.push(observation);
|
|
94
|
+
this.digests.add(frameDigest);
|
|
95
|
+
while (this.observations.length > this.maxFrames) {
|
|
96
|
+
const removed = this.observations.shift();
|
|
97
|
+
if (removed)
|
|
98
|
+
this.digests.delete(removed.frameDigest);
|
|
99
|
+
}
|
|
100
|
+
return { observation, duplicate: false };
|
|
101
|
+
}
|
|
102
|
+
current(now = new Date()) {
|
|
103
|
+
this.clearExpired(now);
|
|
104
|
+
return this.observations.map((item) => ({ ...item, readings: item.readings.map((reading) => ({ ...reading })) }));
|
|
105
|
+
}
|
|
106
|
+
clearExpired(now = new Date()) {
|
|
107
|
+
const before = this.observations.length;
|
|
108
|
+
const current = now.getTime();
|
|
109
|
+
const retained = this.observations.filter((item) => new Date(item.expiresAt).getTime() > current);
|
|
110
|
+
this.observations.splice(0, this.observations.length, ...retained);
|
|
111
|
+
this.digests.clear();
|
|
112
|
+
for (const item of retained)
|
|
113
|
+
this.digests.add(item.frameDigest);
|
|
114
|
+
return before - retained.length;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
exports.FixtureObservationOrgan = FixtureObservationOrgan;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_1 = require("./index");
|
|
4
|
+
describe('FixtureObservationOrgan', () => {
|
|
5
|
+
test('creates bounded evidence without retaining the raw frame', async () => {
|
|
6
|
+
const vision = { analyze: jest.fn().mockResolvedValue(JSON.stringify({ readings: [
|
|
7
|
+
{ entity: 'scene', value: 'combat', confidence: 0.9 },
|
|
8
|
+
] })) };
|
|
9
|
+
const organ = new index_1.FixtureObservationOrgan(vision, 1000, 2);
|
|
10
|
+
const frame = new Uint8Array([1, 2, 3]);
|
|
11
|
+
const result = await organ.ingest(frame, 'fixture-genshin', 'fixture-vision');
|
|
12
|
+
expect(result.observation).toMatchObject({ sourceName: 'fixture-genshin', providerId: 'fixture-vision', confidence: 0.9 });
|
|
13
|
+
expect(result.observation?.evidenceId).toMatch(/^evidence_/);
|
|
14
|
+
expect(JSON.stringify(result.observation)).not.toContain('1,2,3');
|
|
15
|
+
expect(vision.analyze).toHaveBeenCalledWith(expect.stringContaining('data:image/png;base64'), expect.any(String));
|
|
16
|
+
});
|
|
17
|
+
test('suppresses duplicate frames and expires observations', async () => {
|
|
18
|
+
const vision = { analyze: jest.fn().mockResolvedValue('[{"entity":"scene","value":"idle","confidence":1}]') };
|
|
19
|
+
const organ = new index_1.FixtureObservationOrgan(vision, 1000, 2);
|
|
20
|
+
const frame = new Uint8Array([7, 8, 9]);
|
|
21
|
+
const first = await organ.ingest(frame, 'fixture');
|
|
22
|
+
const duplicate = await organ.ingest(frame, 'fixture');
|
|
23
|
+
expect(first.observation).toBeDefined();
|
|
24
|
+
expect(duplicate).toMatchObject({ duplicate: true, reason: 'duplicate_frame' });
|
|
25
|
+
expect(organ.clearExpired(new Date(Date.now() + 2000))).toBe(1);
|
|
26
|
+
expect(organ.current(new Date(Date.now() + 2000))).toEqual([]);
|
|
27
|
+
});
|
|
28
|
+
test('generates deterministic and distinct SHA-256 digests for distinct frames', () => {
|
|
29
|
+
const frame1 = new Uint8Array([1, 2, 3, 4]);
|
|
30
|
+
const frame2 = new Uint8Array([1, 2, 3, 5]);
|
|
31
|
+
const digest1a = (0, index_1.digestFrame)(frame1);
|
|
32
|
+
const digest1b = (0, index_1.digestFrame)(frame1);
|
|
33
|
+
const digest2 = (0, index_1.digestFrame)(frame2);
|
|
34
|
+
expect(digest1a).toBe(digest1b);
|
|
35
|
+
expect(digest1a).not.toBe(digest2);
|
|
36
|
+
expect(digest1a).toMatch(/^[a-f0-9]{64}$/);
|
|
37
|
+
});
|
|
38
|
+
test('rejects oversized observation frames exceeding configured maximum bytes', async () => {
|
|
39
|
+
const vision = { analyze: jest.fn().mockResolvedValue('[]') };
|
|
40
|
+
const organ = new index_1.FixtureObservationOrgan(vision, 1000, 2, 100); // 100 bytes max
|
|
41
|
+
const oversizedFrame = new Uint8Array(200);
|
|
42
|
+
await expect(organ.ingest(oversizedFrame, 'test')).rejects.toThrow(/exceeds maximum allowed size/);
|
|
43
|
+
});
|
|
44
|
+
test('rejects malformed provider readings', async () => {
|
|
45
|
+
const organ = new index_1.FixtureObservationOrgan({ analyze: jest.fn().mockResolvedValue('not json') });
|
|
46
|
+
await expect(organ.ingest(new Uint8Array([1]), 'fixture')).resolves.toMatchObject({ reason: 'invalid_reading' });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siduri-x/observation",
|
|
3
|
+
"organType": "observation",
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"displayName": "Observation (Grounded Observation Ingestion)",
|
|
6
|
+
"description": "Cryptographically hashed video/image observation ingest and duplicate detection",
|
|
7
|
+
"entrypoint": "./dist/index.js",
|
|
8
|
+
"factory": "FixtureObservationOrgan",
|
|
9
|
+
"configKey": "observation",
|
|
10
|
+
"configSchema": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {}
|
|
13
|
+
},
|
|
14
|
+
"environment": [],
|
|
15
|
+
"services": [],
|
|
16
|
+
"database": null,
|
|
17
|
+
"healthCheck": null
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@siduri-x/observation",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"test": "jest --config jest.config.json"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@siduri-x/core": "workspace:*"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/jest": "^29.5.14",
|
|
15
|
+
"jest": "^29.7.0",
|
|
16
|
+
"ts-jest": "^29.2.5",
|
|
17
|
+
"typescript": "^5.3.3"
|
|
18
|
+
},
|
|
19
|
+
"description": "Observation organ for evidence extraction, frame deduplication, and OCR readings",
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/vxnuslabs/siduri-y",
|
|
24
|
+
"directory": "packages/organs/observation"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"organ-manifest.json",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
41
|
+
"import": "./dist/index.js",
|
|
42
|
+
"default": "./dist/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./organ-manifest.json": "./organ-manifest.json"
|
|
45
|
+
}
|
|
46
|
+
}
|