@nacre.work/api 0.0.0 → 0.2.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.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/dist/adapters.d.ts +421 -17
  4. package/dist/adapters.d.ts.map +1 -1
  5. package/dist/adapters.js +1092 -72
  6. package/dist/adapters.js.map +1 -1
  7. package/dist/audit-export.d.ts +45 -0
  8. package/dist/audit-export.d.ts.map +1 -0
  9. package/dist/audit-export.js +155 -0
  10. package/dist/audit-export.js.map +1 -0
  11. package/dist/auth.d.ts +17 -0
  12. package/dist/auth.d.ts.map +1 -1
  13. package/dist/auth.js +17 -7
  14. package/dist/auth.js.map +1 -1
  15. package/dist/idempotency.d.ts +62 -0
  16. package/dist/idempotency.d.ts.map +1 -0
  17. package/dist/idempotency.js +147 -0
  18. package/dist/idempotency.js.map +1 -0
  19. package/dist/index.d.ts +12 -3
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +10 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/init.d.ts +9 -0
  24. package/dist/init.d.ts.map +1 -1
  25. package/dist/init.js +50 -5
  26. package/dist/init.js.map +1 -1
  27. package/dist/limits.d.ts +80 -0
  28. package/dist/limits.d.ts.map +1 -0
  29. package/dist/limits.js +63 -0
  30. package/dist/limits.js.map +1 -0
  31. package/dist/login.d.ts +94 -0
  32. package/dist/login.d.ts.map +1 -0
  33. package/dist/login.js +283 -0
  34. package/dist/login.js.map +1 -0
  35. package/dist/main.js +171 -60
  36. package/dist/main.js.map +1 -1
  37. package/dist/pagination.d.ts +72 -0
  38. package/dist/pagination.d.ts.map +1 -0
  39. package/dist/pagination.js +110 -0
  40. package/dist/pagination.js.map +1 -0
  41. package/dist/rerank.d.ts +99 -0
  42. package/dist/rerank.d.ts.map +1 -0
  43. package/dist/rerank.js +138 -0
  44. package/dist/rerank.js.map +1 -0
  45. package/dist/server.d.ts +361 -6
  46. package/dist/server.d.ts.map +1 -1
  47. package/dist/server.js +1050 -31
  48. package/dist/server.js.map +1 -1
  49. package/dist/service-keys.d.ts +13 -5
  50. package/dist/service-keys.d.ts.map +1 -1
  51. package/dist/service-keys.js +54 -31
  52. package/dist/service-keys.js.map +1 -1
  53. package/dist/source.d.ts +53 -0
  54. package/dist/source.d.ts.map +1 -0
  55. package/dist/source.js +109 -0
  56. package/dist/source.js.map +1 -0
  57. package/package.json +28 -10
  58. package/dist/__tests__/admin-surface.test.d.ts +0 -2
  59. package/dist/__tests__/admin-surface.test.d.ts.map +0 -1
  60. package/dist/__tests__/admin-surface.test.js +0 -431
  61. package/dist/__tests__/admin-surface.test.js.map +0 -1
  62. package/dist/__tests__/init.test.d.ts +0 -2
  63. package/dist/__tests__/init.test.d.ts.map +0 -1
  64. package/dist/__tests__/init.test.js +0 -125
  65. package/dist/__tests__/init.test.js.map +0 -1
  66. package/dist/__tests__/service-keys.test.d.ts +0 -2
  67. package/dist/__tests__/service-keys.test.d.ts.map +0 -1
  68. package/dist/__tests__/service-keys.test.js +0 -204
  69. package/dist/__tests__/service-keys.test.js.map +0 -1
@@ -0,0 +1,72 @@
1
+ import { type Problem } from './errors.js';
2
+ /**
3
+ * Cursor pagination, and never offset.
4
+ *
5
+ * `docs/api.md` forbids offset on two grounds and both are real. It breaks
6
+ * under concurrent inserts — a row added while a client pages through shifts
7
+ * everything after it, so the client sees an item twice or not at all. And it
8
+ * invites enumeration: `?offset=10000` is a question about how much exists,
9
+ * which on a permission-scoped collection is a question the caller has no right
10
+ * to ask.
11
+ *
12
+ * The cursor is a position, not an index. It carries the sort key of the last
13
+ * item returned, so the next page is "everything after this one" — which is
14
+ * stable no matter what was inserted in between, and says nothing about size.
15
+ *
16
+ * ## Opaque, and only just
17
+ *
18
+ * It is base64url of `created_at|id`, which anyone can decode, and that is
19
+ * fine: both values are already in the response they came from. What matters is
20
+ * that it is *treated* as opaque — a client that parses it and constructs its
21
+ * own has built a dependency on a format that is allowed to change, and the
22
+ * signature check below is what tells them so before it becomes their problem.
23
+ *
24
+ * It is not signed. A forged cursor selects a different page of the same
25
+ * caller's own collection, which they could reach by paging anyway; signing it
26
+ * would defend nothing and imply a guarantee that does not exist.
27
+ */
28
+ export declare const MAX_LIMIT = 200;
29
+ export declare const DEFAULT_LIMIT = 50;
30
+ export interface Position {
31
+ /** The sort key of the last item on the previous page. */
32
+ readonly createdAt: string;
33
+ /** Tie-breaker, because timestamps collide under a bulk insert. */
34
+ readonly id: string;
35
+ }
36
+ export interface Page {
37
+ readonly limit: number;
38
+ readonly after: Position | undefined;
39
+ }
40
+ export declare function encodeCursor(position: Position): string;
41
+ /**
42
+ * Read `?limit=` and `?cursor=`, or explain what is wrong with them.
43
+ *
44
+ * `?offset=` is refused rather than ignored. Ignoring it gives a client the
45
+ * first page over and over while they believe they are paging, which is the
46
+ * failure that takes an afternoon to see.
47
+ */
48
+ export declare function readPage(params: URLSearchParams, instance: string, requestId: string): Page | Problem;
49
+ /**
50
+ * What a paginated port returns.
51
+ *
52
+ * `items` and the cursor are separate because they are not always derived from
53
+ * the same row. Where a collection is filtered in application code after the
54
+ * database has already applied the cursor — grants are, because who may see one
55
+ * is decided by walking the scope tree — the cursor has to come from the last
56
+ * row **fetched** rather than the last one returned. Taking it from the last
57
+ * returned row would skip everything the filter removed after it.
58
+ *
59
+ * The consequence, which callers have to know: **a page may hold fewer than
60
+ * `limit` items, and an empty page does not mean the end.** `nextCursor` is the
61
+ * only signal that more exist.
62
+ */
63
+ export interface PageResult<T> {
64
+ readonly items: readonly T[];
65
+ readonly nextCursor: string | null;
66
+ }
67
+ /**
68
+ * The common case: rows come back already filtered, so the cursor is the last
69
+ * one returned and a short page means the collection is exhausted.
70
+ */
71
+ export declare function pageOf<T>(rows: readonly T[], page: Page | undefined, positionOf: (row: T) => Position): PageResult<T>;
72
+ //# sourceMappingURL=pagination.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pagination.d.ts","sourceRoot":"","sources":["../src/pagination.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,OAAO,EAAE,MAAM,aAAa,CAAA;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,eAAO,MAAM,SAAS,MAAM,CAAA;AAC5B,eAAO,MAAM,aAAa,KAAK,CAAA;AAE/B,MAAM,WAAW,QAAQ;IACvB,0DAA0D;IAC1D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,mEAAmE;IACnE,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,SAAS,CAAA;CACrC;AAED,wBAAgB,YAAY,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAEvD;AAoCD;;;;;;GAMG;AACH,wBAAgB,QAAQ,CACtB,MAAM,EAAE,eAAe,EACvB,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,IAAI,GAAG,OAAO,CAoChB;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,EAAE,CAAA;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;CACnC;AAED;;;GAGG;AACH,wBAAgB,MAAM,CAAC,CAAC,EACtB,IAAI,EAAE,SAAS,CAAC,EAAE,EAClB,IAAI,EAAE,IAAI,GAAG,SAAS,EACtB,UAAU,EAAE,CAAC,GAAG,EAAE,CAAC,KAAK,QAAQ,GAC/B,UAAU,CAAC,CAAC,CAAC,CAIf"}
@@ -0,0 +1,110 @@
1
+ import { badRequest } from './errors.js';
2
+ /**
3
+ * Cursor pagination, and never offset.
4
+ *
5
+ * `docs/api.md` forbids offset on two grounds and both are real. It breaks
6
+ * under concurrent inserts — a row added while a client pages through shifts
7
+ * everything after it, so the client sees an item twice or not at all. And it
8
+ * invites enumeration: `?offset=10000` is a question about how much exists,
9
+ * which on a permission-scoped collection is a question the caller has no right
10
+ * to ask.
11
+ *
12
+ * The cursor is a position, not an index. It carries the sort key of the last
13
+ * item returned, so the next page is "everything after this one" — which is
14
+ * stable no matter what was inserted in between, and says nothing about size.
15
+ *
16
+ * ## Opaque, and only just
17
+ *
18
+ * It is base64url of `created_at|id`, which anyone can decode, and that is
19
+ * fine: both values are already in the response they came from. What matters is
20
+ * that it is *treated* as opaque — a client that parses it and constructs its
21
+ * own has built a dependency on a format that is allowed to change, and the
22
+ * signature check below is what tells them so before it becomes their problem.
23
+ *
24
+ * It is not signed. A forged cursor selects a different page of the same
25
+ * caller's own collection, which they could reach by paging anyway; signing it
26
+ * would defend nothing and imply a guarantee that does not exist.
27
+ */
28
+ export const MAX_LIMIT = 200;
29
+ export const DEFAULT_LIMIT = 50;
30
+ export function encodeCursor(position) {
31
+ return Buffer.from(`${position.createdAt}|${position.id}`, 'utf8').toString('base64url');
32
+ }
33
+ function decodeCursor(cursor) {
34
+ let decoded;
35
+ try {
36
+ decoded = Buffer.from(cursor, 'base64url').toString('utf8');
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ const at = decoded.lastIndexOf('|');
42
+ if (at === -1)
43
+ return undefined;
44
+ const createdAt = decoded.slice(0, at);
45
+ const id = decoded.slice(at + 1);
46
+ // A cursor that does not carry a timestamp and an identifier is not one this
47
+ // API issued, whatever it decodes to.
48
+ //
49
+ // A uuid **or** a run of digits: every paged collection here is keyed by uuid
50
+ // except `audit_events`, whose primary key is a bigserial — an audit log is
51
+ // append-only and strictly ordered, and a sequence says so in a way a random
52
+ // uuid does not.
53
+ //
54
+ // Widening the format does not weaken anything, and it is worth saying why
55
+ // rather than leaving the next reader to work it out. The cursor is not a
56
+ // security boundary: it is not signed, it is decodable by anyone, and a
57
+ // forged one selects a different page of the caller's *own* collection —
58
+ // which they could reach by paging. What this check is for is telling a
59
+ // client that built its own cursor that it has taken a dependency on a format
60
+ // allowed to change, before that becomes their problem.
61
+ if (Number.isNaN(Date.parse(createdAt)))
62
+ return undefined;
63
+ if (!/^[0-9a-f-]{36}$/i.test(id) && !/^\d{1,19}$/.test(id))
64
+ return undefined;
65
+ return { createdAt, id };
66
+ }
67
+ /**
68
+ * Read `?limit=` and `?cursor=`, or explain what is wrong with them.
69
+ *
70
+ * `?offset=` is refused rather than ignored. Ignoring it gives a client the
71
+ * first page over and over while they believe they are paging, which is the
72
+ * failure that takes an afternoon to see.
73
+ */
74
+ export function readPage(params, instance, requestId) {
75
+ if (params.has('offset')) {
76
+ return badRequest(instance, requestId, "'offset' is not supported. Use '?cursor=' — offset paging skips and repeats rows " +
77
+ 'when the collection changes underneath it.');
78
+ }
79
+ let limit = DEFAULT_LIMIT;
80
+ const raw = params.get('limit');
81
+ if (raw !== null) {
82
+ const value = Number(raw);
83
+ if (!Number.isInteger(value) || value < 1 || value > MAX_LIMIT) {
84
+ return badRequest(instance, requestId, `'limit' must be an integer between 1 and ${MAX_LIMIT}.`);
85
+ }
86
+ limit = value;
87
+ }
88
+ let after;
89
+ const cursor = params.get('cursor');
90
+ if (cursor !== null && cursor !== '') {
91
+ const position = decodeCursor(cursor);
92
+ if (position === undefined) {
93
+ return badRequest(instance, requestId, "'cursor' is not one this API issued. Cursors are opaque — pass back the " +
94
+ '`next_cursor` from the previous page rather than constructing one.');
95
+ }
96
+ after = position;
97
+ }
98
+ return { limit, after };
99
+ }
100
+ /**
101
+ * The common case: rows come back already filtered, so the cursor is the last
102
+ * one returned and a short page means the collection is exhausted.
103
+ */
104
+ export function pageOf(rows, page, positionOf) {
105
+ if (page === undefined || rows.length < page.limit)
106
+ return { items: rows, nextCursor: null };
107
+ const last = rows[rows.length - 1];
108
+ return { items: rows, nextCursor: last === undefined ? null : encodeCursor(positionOf(last)) };
109
+ }
110
+ //# sourceMappingURL=pagination.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pagination.js","sourceRoot":"","sources":["../src/pagination.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Cross-encoder reranking.
3
+ *
4
+ * `docs/architecture.md` has specified this since the beginning — "a
5
+ * cross-encoder rerank of the top 50 down to top-k. Reranking buys more quality
6
+ * than any amount of chunking tuning and is on by default" — and the search
7
+ * path has never called one.
8
+ *
9
+ * ## Why this is not the post-filter invariant I2 forbids
10
+ *
11
+ * It looks like it. I2 says `top_k` is passed through uncorrected and that
12
+ * asking for more and trimming is forbidden; this asks for fifty and returns
13
+ * ten. The difference is what the trim decides.
14
+ *
15
+ * The forbidden pattern trims on **permission**: fetch k results, drop the ones
16
+ * the caller may not see, return what is left. That returns fewer than k
17
+ * permitted results — the caller's last page silently shrinks, and how much it
18
+ * shrinks by is a measurement of what exists but is invisible to them. It also
19
+ * costs more, because the index ranked documents it then threw away.
20
+ *
21
+ * This trims on **relevance**, over a set that is already entirely permitted.
22
+ * The ACL filter runs inside the index traversal exactly as before, so all
23
+ * fifty candidates are ones this caller may read; reordering them and keeping
24
+ * ten changes which permitted results are returned, never how many. The test
25
+ * that separates the two: **can the number of results ever be smaller than the
26
+ * number of permitted matches, up to k?** For a post-filter, yes. Here, no.
27
+ *
28
+ * That is the whole justification, and it is worth stating in the code rather
29
+ * than in a commit message, because the next person to read the search path
30
+ * will see an over-fetch and reach for the rule.
31
+ *
32
+ * ## Failing closed, which is the other way round from the rate limiter
33
+ *
34
+ * If the reranker is unreachable the search still answers, in fusion order,
35
+ * with a metric recording that it degraded. This is not a contradiction of
36
+ * invariant 3: reranking decides *ordering*, and every candidate in the list
37
+ * was already permitted before the reranker saw it. Refusing the search would
38
+ * make an optional quality component into a hard dependency of the product's
39
+ * central operation.
40
+ *
41
+ * What it must never do is fail *quietly* — an operator who turned reranking on
42
+ * is entitled to know it is not running, so degradation is a counter and a log
43
+ * line rather than a silent fallback.
44
+ */
45
+ export interface Reranker {
46
+ /**
47
+ * Score each text against the query. One score per input, **in input order**.
48
+ *
49
+ * Returning scores rather than a sorted list is deliberate: the caller holds
50
+ * the hits, and a reordered list of texts cannot be matched back to them
51
+ * without an index that a wrong implementation would get to choose.
52
+ */
53
+ rank(query: string, texts: readonly string[]): Promise<readonly number[]>;
54
+ }
55
+ /**
56
+ * Text Embeddings Inference's `/rerank`, which is what the `full` and
57
+ * `airgapped` Compose profiles run.
58
+ *
59
+ * The response is `[{index, score}, …]` sorted by score, so the index is the
60
+ * only thing tying a score back to its input. Trusting the order instead would
61
+ * attach the wrong score to the wrong chunk and be invisible in every test that
62
+ * does not check ranking against known-relevant text.
63
+ */
64
+ export declare class HttpReranker implements Reranker {
65
+ private readonly endpoint;
66
+ private readonly timeoutMs;
67
+ constructor(endpoint: string, timeoutMs?: number);
68
+ rank(query: string, texts: readonly string[]): Promise<readonly number[]>;
69
+ }
70
+ /**
71
+ * The reranker a configuration asks for, or none.
72
+ *
73
+ * One factory rather than the same three lines in the API process and in the
74
+ * MCP one. They share a search service and must not disagree about whether
75
+ * results are reranked — two surfaces over one index answering in different
76
+ * orders is the kind of difference nobody reports as a bug and everybody
77
+ * notices.
78
+ *
79
+ * `loadConfig` already refuses the contradictory case (enabled with no
80
+ * endpoint) at startup, so there is nothing to validate here.
81
+ */
82
+ export declare function rerankerFor(config: {
83
+ readonly rerankerEnabled: boolean;
84
+ readonly rerankerEndpoint: string | undefined;
85
+ }): Reranker | undefined;
86
+ /**
87
+ * Reorder hits by a reranker's scores and keep the best `topK`.
88
+ *
89
+ * Separate from the transport so the ordering rule can be tested without a
90
+ * model server — the rule being the part that is easy to get subtly wrong.
91
+ *
92
+ * The sort is stable in the sense that matters: ties keep the fusion order they
93
+ * arrived in, because `Array.prototype.sort` is stable and the comparator
94
+ * answers 0 for equal scores. A cross-encoder returning identical scores for
95
+ * two chunks is saying it cannot separate them, and falling back to the index's
96
+ * own ranking is a better answer than an arbitrary one.
97
+ */
98
+ export declare function applyRanking<T>(items: readonly T[], scores: readonly number[], topK: number): T[];
99
+ //# sourceMappingURL=rerank.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rerank.d.ts","sourceRoot":"","sources":["../src/rerank.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAEH,MAAM,WAAW,QAAQ;IACvB;;;;;;OAMG;IACH,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAA;CAC1E;AAED;;;;;;;;GAQG;AACH,qBAAa,YAAa,YAAW,QAAQ;IAEzC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,SAAS;gBADT,QAAQ,EAAE,MAAM,EAChB,SAAS,SAAO;IAG7B,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC;CA0ChF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE;IAClC,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAA;IACjC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,SAAS,CAAA;CAC9C,GAAG,QAAQ,GAAG,SAAS,CAGvB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,EAAE,CASjG"}
package/dist/rerank.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Cross-encoder reranking.
3
+ *
4
+ * `docs/architecture.md` has specified this since the beginning — "a
5
+ * cross-encoder rerank of the top 50 down to top-k. Reranking buys more quality
6
+ * than any amount of chunking tuning and is on by default" — and the search
7
+ * path has never called one.
8
+ *
9
+ * ## Why this is not the post-filter invariant I2 forbids
10
+ *
11
+ * It looks like it. I2 says `top_k` is passed through uncorrected and that
12
+ * asking for more and trimming is forbidden; this asks for fifty and returns
13
+ * ten. The difference is what the trim decides.
14
+ *
15
+ * The forbidden pattern trims on **permission**: fetch k results, drop the ones
16
+ * the caller may not see, return what is left. That returns fewer than k
17
+ * permitted results — the caller's last page silently shrinks, and how much it
18
+ * shrinks by is a measurement of what exists but is invisible to them. It also
19
+ * costs more, because the index ranked documents it then threw away.
20
+ *
21
+ * This trims on **relevance**, over a set that is already entirely permitted.
22
+ * The ACL filter runs inside the index traversal exactly as before, so all
23
+ * fifty candidates are ones this caller may read; reordering them and keeping
24
+ * ten changes which permitted results are returned, never how many. The test
25
+ * that separates the two: **can the number of results ever be smaller than the
26
+ * number of permitted matches, up to k?** For a post-filter, yes. Here, no.
27
+ *
28
+ * That is the whole justification, and it is worth stating in the code rather
29
+ * than in a commit message, because the next person to read the search path
30
+ * will see an over-fetch and reach for the rule.
31
+ *
32
+ * ## Failing closed, which is the other way round from the rate limiter
33
+ *
34
+ * If the reranker is unreachable the search still answers, in fusion order,
35
+ * with a metric recording that it degraded. This is not a contradiction of
36
+ * invariant 3: reranking decides *ordering*, and every candidate in the list
37
+ * was already permitted before the reranker saw it. Refusing the search would
38
+ * make an optional quality component into a hard dependency of the product's
39
+ * central operation.
40
+ *
41
+ * What it must never do is fail *quietly* — an operator who turned reranking on
42
+ * is entitled to know it is not running, so degradation is a counter and a log
43
+ * line rather than a silent fallback.
44
+ */
45
+ /**
46
+ * Text Embeddings Inference's `/rerank`, which is what the `full` and
47
+ * `airgapped` Compose profiles run.
48
+ *
49
+ * The response is `[{index, score}, …]` sorted by score, so the index is the
50
+ * only thing tying a score back to its input. Trusting the order instead would
51
+ * attach the wrong score to the wrong chunk and be invisible in every test that
52
+ * does not check ranking against known-relevant text.
53
+ */
54
+ export class HttpReranker {
55
+ endpoint;
56
+ timeoutMs;
57
+ constructor(endpoint, timeoutMs = 3000) {
58
+ this.endpoint = endpoint;
59
+ this.timeoutMs = timeoutMs;
60
+ }
61
+ async rank(query, texts) {
62
+ if (texts.length === 0)
63
+ return [];
64
+ // A search that would otherwise have answered must not hang because a model
65
+ // server is wedged; the caller treats a timeout as a degradation.
66
+ const abort = AbortSignal.timeout(this.timeoutMs);
67
+ const response = await fetch(new URL('/rerank', this.endpoint), {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify({ query, texts: [...texts], raw_scores: false }),
71
+ signal: abort,
72
+ });
73
+ if (!response.ok)
74
+ throw new Error(`the reranker answered ${response.status}`);
75
+ const body = (await response.json());
76
+ if (!Array.isArray(body))
77
+ throw new Error('the reranker did not answer with a list');
78
+ const scores = new Array(texts.length).fill(Number.NEGATIVE_INFINITY);
79
+ let seen = 0;
80
+ for (const entry of body) {
81
+ const { index, score } = entry;
82
+ if (typeof index !== 'number' || typeof score !== 'number') {
83
+ throw new Error('a reranker result carried no index and score');
84
+ }
85
+ if (index < 0 || index >= texts.length) {
86
+ throw new Error(`the reranker scored index ${index} for ${texts.length} inputs`);
87
+ }
88
+ scores[index] = score;
89
+ seen++;
90
+ }
91
+ if (seen !== texts.length) {
92
+ // A short answer would leave some chunks at -Infinity and sink them to the
93
+ // bottom, which is a silent quality loss rather than a visible failure.
94
+ throw new Error(`the reranker scored ${seen} of ${texts.length} inputs`);
95
+ }
96
+ return scores;
97
+ }
98
+ }
99
+ /**
100
+ * The reranker a configuration asks for, or none.
101
+ *
102
+ * One factory rather than the same three lines in the API process and in the
103
+ * MCP one. They share a search service and must not disagree about whether
104
+ * results are reranked — two surfaces over one index answering in different
105
+ * orders is the kind of difference nobody reports as a bug and everybody
106
+ * notices.
107
+ *
108
+ * `loadConfig` already refuses the contradictory case (enabled with no
109
+ * endpoint) at startup, so there is nothing to validate here.
110
+ */
111
+ export function rerankerFor(config) {
112
+ if (!config.rerankerEnabled || config.rerankerEndpoint === undefined)
113
+ return undefined;
114
+ return new HttpReranker(config.rerankerEndpoint);
115
+ }
116
+ /**
117
+ * Reorder hits by a reranker's scores and keep the best `topK`.
118
+ *
119
+ * Separate from the transport so the ordering rule can be tested without a
120
+ * model server — the rule being the part that is easy to get subtly wrong.
121
+ *
122
+ * The sort is stable in the sense that matters: ties keep the fusion order they
123
+ * arrived in, because `Array.prototype.sort` is stable and the comparator
124
+ * answers 0 for equal scores. A cross-encoder returning identical scores for
125
+ * two chunks is saying it cannot separate them, and falling back to the index's
126
+ * own ranking is a better answer than an arbitrary one.
127
+ */
128
+ export function applyRanking(items, scores, topK) {
129
+ if (scores.length !== items.length) {
130
+ throw new Error(`the reranker returned ${scores.length} scores for ${items.length} items`);
131
+ }
132
+ return items
133
+ .map((item, i) => ({ item, score: scores[i] }))
134
+ .sort((a, b) => b.score - a.score)
135
+ .slice(0, topK)
136
+ .map((s) => s.item);
137
+ }
138
+ //# sourceMappingURL=rerank.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rerank.js","sourceRoot":"","sources":["../src/rerank.ts"],"names":[],"mappings":"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"}