Skip to content

Commit 97469ae

Browse files
authored
fix: add correct amb filter types (#134)
1 parent f9fb122 commit 97469ae

File tree

13 files changed

+554
-78
lines changed

13 files changed

+554
-78
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ All adapters implement `SourceAdapter` from `oer-adapter-core` and normalize res
6464
| `NODE_ENV` | `development` | `development`, `production`, `test` |
6565
| `ENABLED_ADAPTERS` | `''` | Comma-separated adapter IDs |
6666
| `ADAPTER_TIMEOUT_MS` | `3000` | Per-adapter request timeout |
67-
| `NOSTR_AMB_RELAY_URL` | `''` | WebSocket URL for AMB relay |
67+
| `NOSTR_AMB_RELAY_URL` | `''` | Comma-separated WebSocket URL(s) for AMB relay(s) |
6868
| `RPI_VIRTUELL_API_URL` | `''` | Optional override for RPI-Virtuell |
6969
| `IMGPROXY_BASE_URL` | `''` | Enables imgproxy when set |
7070
| `IMGPROXY_KEY` | `''` | Hex key for signed URLs |

docs/client-packages.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ interface SourceConfig {
460460
461461
| Source ID | Description | `baseUrl` |
462462
|-----------|-------------|-----------|
463-
| `nostr-amb-relay` | Nostr AMB Relay | Required (e.g., `'wss://amb-relay.edufeed.org'`) |
463+
| `nostr-amb-relay` | Nostr AMB Relay | Required. WebSocket URL(s) — supports comma-separated values for multiple relays (e.g., `'wss://relay1.example.com, wss://relay2.example.com'`) |
464464
| `openverse` | Openverse | Not needed |
465465
| `arasaac` | ARASAAC | Not needed |
466466
| `wikimedia` | Wikimedia Commons | Not needed |
@@ -515,6 +515,34 @@ searchElement.sources = [
515515
];
516516
```
517517
518+
#### Nostr AMB Relay Adapter
519+
520+
The `nostr-amb-relay` adapter connects to one or more [AMB relay](https://github.com/edufeed-org/amb-relay) instances via WebSocket to search educational metadata using the Nostr protocol (kind 30142 events).
521+
522+
**Key features:**
523+
- Fan-out queries to multiple relays concurrently with result merging and deduplication
524+
- Supports all resource types, license filtering, and educational level filtering
525+
- Uses `learningResourceType` (HCRT vocabulary) for type filtering
526+
527+
**Configuration:** Set `baseUrl` in the `SourceConfig` to one or more WebSocket URLs. For multiple relays, separate URLs with commas:
528+
529+
```javascript
530+
// Single relay
531+
{ id: 'nostr-amb-relay', label: 'AMB Relay', baseUrl: 'wss://amb-relay.edufeed.org' }
532+
533+
// Multiple relays (fan-out: queries all relays, merges and deduplicates results)
534+
{ id: 'nostr-amb-relay', label: 'AMB Relay', baseUrl: 'wss://relay1.example.com, wss://relay2.example.com' }
535+
```
536+
537+
In direct client mode, you must register the adapter before the first search:
538+
539+
```javascript
540+
import { registerNostrAmbRelayAdapter } from '@edufeed-org/oer-finder-plugin/adapter/nostr-amb-relay';
541+
registerNostrAmbRelayAdapter();
542+
```
543+
544+
In server-proxy mode, relay URLs are configured server-side via the `NOSTR_AMB_RELAY_URL` environment variable (also comma-separated for multiple relays) — no adapter registration is needed on the client.
545+
518546
### Advanced Features
519547
520548
#### Customizing Page Size

nak-data/publish-demo-events.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ for i in $(seq 1 2); do
6666
-t "d=https://example.edu/images/photosynthesis${i}.png" \
6767
-t "e=${KIND_1063_EVENT_ID};${RELAY_URL};" \
6868
-t "type=LearningResource" \
69-
-t "type=Image" \
69+
-t "type=ImageObject" \
7070
-t "name=Photosynthesis Process Diagram ${i}" \
7171
-t "description=Detailed diagram illustrating the photosynthesis process in plant cells" \
7272
-t "dateCreated=2025-01-15" \

packages/oer-adapter-nostr-amb-relay/src/nostr-amb-relay.adapter.ts

Lines changed: 158 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,62 @@ import { mapNostrAmbEventToExternalOerItem } from './mappers/nostr-amb-to-extern
2323
/** Default timeout for relay requests */
2424
const DEFAULT_TIMEOUT_MS = 10000;
2525

26+
/** Maximum number of relay URLs allowed to prevent resource exhaustion */
27+
const MAX_RELAY_COUNT = 10;
28+
29+
/**
30+
* Maps UI resource types to HCRT (Hochschulcurriculare Ressourcentypen) vocabulary entries.
31+
*
32+
* The relay groups filter tokens by base field name (before the first dot) and
33+
* OR's values within the same group. Using `learningResourceType.id` and
34+
* `learningResourceType.prefLabel.en` ensures they share the `learningResourceType`
35+
* group and are OR'd together, matching resources tagged with either the HCRT URI
36+
* or the English label.
37+
*
38+
* @see https://w3id.org/kim/hcrt/scheme
39+
*/
40+
interface TypeFilterTokens {
41+
readonly hcrtId: string;
42+
readonly hcrtPrefLabelEn: string;
43+
}
44+
45+
const TYPE_FILTER_CONFIG: Readonly<Record<string, TypeFilterTokens>> = {
46+
image: {
47+
hcrtId: 'https://w3id.org/kim/hcrt/image',
48+
hcrtPrefLabelEn: 'Image',
49+
},
50+
video: {
51+
hcrtId: 'https://w3id.org/kim/hcrt/video',
52+
hcrtPrefLabelEn: 'Video',
53+
},
54+
audio: {
55+
hcrtId: 'https://w3id.org/kim/hcrt/audio',
56+
hcrtPrefLabelEn: 'Audio',
57+
},
58+
text: {
59+
hcrtId: 'https://w3id.org/kim/hcrt/text',
60+
hcrtPrefLabelEn: 'Text',
61+
},
62+
'application/pdf': {
63+
hcrtId: 'https://w3id.org/kim/hcrt/text',
64+
hcrtPrefLabelEn: 'Text',
65+
},
66+
};
67+
68+
interface RelayQueryResults {
69+
readonly events: readonly Event[];
70+
readonly errors: readonly Error[];
71+
}
72+
2673
/**
2774
* Nostr AMB Relay adapter for searching educational metadata.
2875
*
2976
* The amb-relay is a specialized search relay for educational metadata
3077
* built on the AMB (Allgemeines Metadatenprofil für Bildungsressourcen) standard.
3178
* It combines Typesense full-text search with the Nostr protocol.
3279
*
80+
* Supports multiple relay URLs for fan-out queries with result merging and deduplication.
81+
*
3382
* @see https://github.com/edufeed-org/amb-relay
3483
*/
3584
export class NostrAmbRelayAdapter implements SourceAdapter {
@@ -41,26 +90,36 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
4190
supportsEducationalLevelFilter: true,
4291
};
4392

44-
private readonly relayUrl: string;
93+
private readonly relayUrls: readonly string[];
4594
private readonly timeoutMs: number;
4695

4796
constructor(config: NostrAmbRelayConfig) {
48-
this.relayUrl = config.relayUrl;
97+
if (config.relayUrls.length === 0) {
98+
throw new Error('At least one relay URL must be provided');
99+
}
100+
if (config.relayUrls.length > MAX_RELAY_COUNT) {
101+
throw new Error(
102+
`Too many relay URLs (${config.relayUrls.length}). Maximum is ${MAX_RELAY_COUNT}.`,
103+
);
104+
}
105+
const validatedUrls = config.relayUrls.map((url) => {
106+
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
107+
throw new Error(
108+
`Invalid relay URL scheme: ${url}. Must use ws:// or wss://`,
109+
);
110+
}
111+
return url;
112+
});
113+
this.relayUrls = validatedUrls;
49114
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
50115
}
51116

52117
/**
53118
* Search for educational resources matching the query.
54119
*
55-
* The amb-relay supports full-text search through the Nostr protocol
56-
* using the 'search' filter parameter.
57-
*
58-
* Supported filters:
59-
* - search: Full-text search across all AMB metadata fields
60-
* - limit: Number of results to return (maps to pageSize)
61-
*
62-
* Field-specific queries can be performed using the format:
63-
* "field.subfield:value" (e.g., "publisher.name:example")
120+
* Fans out to all configured relays concurrently. Results are merged and
121+
* deduplicated by event ID. Partial results are returned if some relays fail;
122+
* an error is thrown only when all relays fail.
64123
*/
65124
async search(
66125
query: AdapterSearchQuery,
@@ -70,28 +129,46 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
70129
return EMPTY_RESULT;
71130
}
72131

73-
let relay: Relay | null = null;
132+
const filter = this.buildFilter(query);
133+
const { events, errors } = await this.queryAllRelays(
134+
filter,
135+
options?.signal,
136+
);
74137

75-
try {
76-
relay = await Relay.connect(this.relayUrl);
138+
if (events.length === 0 && errors.length > 0) {
139+
throw this.wrapError(errors[0]);
140+
}
77141

78-
const filter = this.buildFilter(query);
79-
const events = await this.subscribeToEvents(
80-
relay,
81-
filter,
82-
options?.signal,
83-
);
84-
const items = this.mapEventsToItems(events);
85-
const paginatedItems = paginateItems(items, query.page, query.pageSize);
142+
const deduplicatedEvents = deduplicateEvents(events);
143+
const items = this.mapEventsToItems(deduplicatedEvents);
144+
const paginatedItems = paginateItems(items, query.page, query.pageSize);
86145

87-
return {
88-
items: paginatedItems,
89-
total: events.length,
90-
};
91-
} catch (error) {
92-
throw this.wrapError(error);
146+
return {
147+
items: paginatedItems,
148+
total: deduplicatedEvents.length,
149+
};
150+
}
151+
152+
private async queryAllRelays(
153+
filter: Filter,
154+
signal?: AbortSignal,
155+
): Promise<RelayQueryResults> {
156+
const settled = await Promise.allSettled(
157+
this.relayUrls.map((url) => this.queryRelay(url, filter, signal)),
158+
);
159+
return collectResults(settled);
160+
}
161+
162+
private async queryRelay(
163+
url: string,
164+
filter: Filter,
165+
signal?: AbortSignal,
166+
): Promise<Event[]> {
167+
const relay = await Relay.connect(url);
168+
try {
169+
return await this.subscribeToEvents(relay, filter, signal);
93170
} finally {
94-
relay?.close();
171+
relay.close();
95172
}
96173
}
97174

@@ -102,9 +179,7 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
102179
* and converts them to Typesense filter_by expressions. This allows
103180
* filtering by language, license, and type without protocol-level changes.
104181
*/
105-
private buildFilter(
106-
query: AdapterSearchQuery,
107-
): Filter & { search?: string } {
182+
private buildFilter(query: AdapterSearchQuery): Filter & { search?: string } {
108183
const searchParts: string[] = [query.keywords?.trim() ?? ''];
109184

110185
if (query.language) {
@@ -116,7 +191,13 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
116191
}
117192

118193
if (query.type) {
119-
searchParts.push(`type:${query.type}`);
194+
const config = TYPE_FILTER_CONFIG[query.type];
195+
if (config) {
196+
searchParts.push(`learningResourceType.id:${config.hcrtId}`);
197+
searchParts.push(
198+
`learningResourceType.prefLabel.en:${config.hcrtPrefLabelEn}`,
199+
);
200+
}
120201
}
121202

122203
if (query.educationalLevel) {
@@ -138,22 +219,33 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
138219
const events: Event[] = [];
139220

140221
await new Promise<void>((resolve, reject) => {
222+
let sub: { close: () => void } | undefined;
223+
224+
const cleanup = () => {
225+
sub?.close();
226+
};
227+
141228
const timeoutId = setTimeout(() => {
229+
cleanup();
142230
reject(new Error(`Relay request timed out after ${this.timeoutMs}ms`));
143231
}, this.timeoutMs);
144232

145-
signal?.addEventListener('abort', () => {
233+
const onAbort = () => {
146234
clearTimeout(timeoutId);
235+
cleanup();
147236
reject(new Error('Request aborted'));
148-
});
237+
};
238+
239+
signal?.addEventListener('abort', onAbort, { once: true });
149240

150-
const sub = relay.subscribe([filter], {
241+
sub = relay.subscribe([filter], {
151242
onevent: (event: Event) => {
152243
events.push(event);
153244
},
154245
oneose: () => {
155246
clearTimeout(timeoutId);
156-
sub.close();
247+
signal?.removeEventListener('abort', onAbort);
248+
sub?.close();
157249
resolve();
158250
},
159251
});
@@ -183,6 +275,35 @@ export class NostrAmbRelayAdapter implements SourceAdapter {
183275
}
184276
}
185277

278+
function deduplicateEvents(events: readonly Event[]): Event[] {
279+
return Array.from(
280+
events
281+
.reduce(
282+
(seen, event) => (seen.has(event.id) ? seen : seen.set(event.id, event)),
283+
new Map<string, Event>(),
284+
)
285+
.values(),
286+
);
287+
}
288+
289+
function collectResults(
290+
settled: readonly PromiseSettledResult<Event[]>[],
291+
): RelayQueryResults {
292+
const fulfilled = settled.filter(
293+
(r): r is PromiseFulfilledResult<Event[]> => r.status === 'fulfilled',
294+
);
295+
const rejected = settled.filter(
296+
(r): r is PromiseRejectedResult => r.status === 'rejected',
297+
);
298+
299+
return {
300+
events: fulfilled.flatMap((r) => r.value),
301+
errors: rejected.map((r) =>
302+
r.reason instanceof Error ? r.reason : new Error(String(r.reason)),
303+
),
304+
};
305+
}
306+
186307
/**
187308
* Factory function to create a NostrAmbRelayAdapter.
188309
*

packages/oer-adapter-nostr-amb-relay/src/nostr-amb-relay.types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,8 @@ export function parseNostrAmbEvent(data: unknown): NostrAmbEvent {
5252
* Configuration options for the Nostr AMB Relay adapter
5353
*/
5454
export interface NostrAmbRelayConfig {
55-
/** WebSocket URL of the amb-relay (e.g., 'wss://relay.example.com') */
56-
relayUrl: string;
55+
/** WebSocket URLs of the amb-relay(s) — supports multiple relays for fan-out queries */
56+
relayUrls: readonly string[];
5757
/** Timeout for relay requests in milliseconds (default: 10000) */
5858
timeoutMs?: number;
5959
}

packages/oer-adapter-nostr-amb-relay/src/utils/tag-parser.util.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,7 @@
99
*/
1010

1111
/** Checks whether a value already exists at a nested path. */
12-
function hasNestedValue(
13-
obj: Record<string, unknown>,
14-
path: string[],
15-
): boolean {
12+
function hasNestedValue(obj: Record<string, unknown>, path: string[]): boolean {
1613
let current: unknown = obj;
1714
for (const segment of path) {
1815
if (typeof current !== 'object' || current === null) return false;

0 commit comments

Comments
 (0)