@remit/backend 0.0.117 → 0.0.118

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.117",
3
+ "version": "0.0.118",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -177,6 +177,7 @@ export const OrganizeOperations: Record<
177
177
  messageIds: result.messageIds,
178
178
  };
179
179
  if (result.semanticUnavailable) response.semanticUnavailable = true;
180
+ if (result.semanticIndexEmpty) response.semanticIndexEmpty = true;
180
181
  return response;
181
182
  },
182
183
  };
@@ -512,6 +512,105 @@ describe("matchOrganize honors the persisted FilterAnchor (reader #350)", () =>
512
512
  });
513
513
  });
514
514
 
515
+ /**
516
+ * A zero from the widen has two meanings and the preview could not tell them
517
+ * apart (issue #452): no mail resembles the anchor, or the index holds nothing
518
+ * to compare it against. Only the second is a reason to keep the rule.
519
+ */
520
+ describe("matchOrganize reporting an empty semantic index", () => {
521
+ it("flags a widen answered by an index with no vectors at all", async () => {
522
+ const { messageIds, semanticIndexEmpty, semanticUnavailable } =
523
+ await matchAccepted(
524
+ matchDeps(createMemoryVectorStore()),
525
+ ACCOUNT_CONFIG_ID,
526
+ predicate(),
527
+ );
528
+
529
+ assert.deepEqual(messageIds, []);
530
+ assert.equal(semanticIndexEmpty, true);
531
+ assert.equal(
532
+ semanticUnavailable,
533
+ false,
534
+ "an empty index is not a missing vector pipeline",
535
+ );
536
+ });
537
+
538
+ it("flags a widen whose anchor message has no chunk vectors to pool", async () => {
539
+ const store = createMemoryVectorStore();
540
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
541
+ const deps: OrganizeMatchDeps = {
542
+ ...matchDeps(store),
543
+ semantic: () => ({
544
+ buildAnchor: async () => null,
545
+ vectorStore: store,
546
+ embed: async () => ANCHOR_VECTOR,
547
+ embeddingId: CURRENT_EMBEDDING_ID,
548
+ }),
549
+ };
550
+
551
+ const { messageIds, semanticIndexEmpty } = await matchAccepted(
552
+ deps,
553
+ ACCOUNT_CONFIG_ID,
554
+ predicate(),
555
+ );
556
+
557
+ assert.deepEqual(messageIds, []);
558
+ assert.equal(semanticIndexEmpty, true);
559
+ });
560
+
561
+ it("does not flag a populated index that simply holds nothing similar enough", async () => {
562
+ const store = createMemoryVectorStore();
563
+ await store.upsert([bodyChunk("msg-1", ORTHOGONAL_VECTOR)]);
564
+
565
+ const { messageIds, semanticIndexEmpty } = await matchAccepted(
566
+ matchDeps(store),
567
+ ACCOUNT_CONFIG_ID,
568
+ predicate(),
569
+ );
570
+
571
+ assert.deepEqual(
572
+ messageIds,
573
+ [],
574
+ "the one indexed message is below threshold",
575
+ );
576
+ assert.equal(
577
+ semanticIndexEmpty,
578
+ false,
579
+ "the index answered; the rule is what matched nothing",
580
+ );
581
+ });
582
+
583
+ it("does not flag a widen that matched", async () => {
584
+ const store = createMemoryVectorStore();
585
+ await store.upsert([bodyChunk("msg-1", ANCHOR_VECTOR)]);
586
+
587
+ const { messageIds, semanticIndexEmpty } = await matchAccepted(
588
+ matchDeps(store),
589
+ ACCOUNT_CONFIG_ID,
590
+ predicate(),
591
+ );
592
+
593
+ assert.deepEqual(messageIds, ["msg-1"]);
594
+ assert.equal(semanticIndexEmpty, false);
595
+ });
596
+
597
+ it("does not flag a literal-only predicate, which never consults the index", async () => {
598
+ const { semanticIndexEmpty } = await matchAccepted(
599
+ matchDeps(createMemoryVectorStore(), [
600
+ candidate("msg-1", { subject: "Weekly newsletter" }),
601
+ ]),
602
+ ACCOUNT_CONFIG_ID,
603
+ {
604
+ ...predicate(),
605
+ anchorMessageId: "None",
606
+ literalClauses: [{ field: "Subject", value: "reservation" }],
607
+ },
608
+ );
609
+
610
+ assert.equal(semanticIndexEmpty, false);
611
+ });
612
+ });
613
+
515
614
  describe("matchOrganize on a deployment without the vector pipeline", () => {
516
615
  const ORIGINAL = process.env.DATA_BACKEND;
517
616
  beforeEach(() => {
@@ -610,14 +709,16 @@ describe("matchOrganize on a deployment without the vector pipeline", () => {
610
709
  it("degrades an anchor-only widen to an empty flagged result instead of crashing", async () => {
611
710
  const deps = vectorlessDeps([candidate("msg-1"), candidate("msg-2")]);
612
711
 
613
- const { messageIds, semanticUnavailable } = await matchAccepted(
614
- deps,
615
- ACCOUNT_CONFIG_ID,
616
- predicate(),
617
- );
712
+ const { messageIds, semanticUnavailable, semanticIndexEmpty } =
713
+ await matchAccepted(deps, ACCOUNT_CONFIG_ID, predicate());
618
714
 
619
715
  assert.deepEqual(messageIds, []);
620
716
  assert.equal(semanticUnavailable, true);
717
+ assert.equal(
718
+ semanticIndexEmpty,
719
+ false,
720
+ "a deployment with no vector pipeline has no index to call empty",
721
+ );
621
722
  });
622
723
 
623
724
  it("keeps widening from a drifted persisted anchor when no embedding model is available", async () => {
@@ -183,11 +183,21 @@ export interface OrganizeRejection {
183
183
  message: string;
184
184
  }
185
185
 
186
- /** The matched ids plus whether the semantic widen was skipped as unavailable. */
186
+ /** The matched ids plus what the semantic side had to say about the zero it may have returned. */
187
187
  export interface OrganizeMatched {
188
188
  rejected: null;
189
189
  messageIds: string[];
190
190
  semanticUnavailable: boolean;
191
+ /**
192
+ * The widen ran and the index had nothing to answer with (issue #452): the
193
+ * account holds no vectors at all, or none yet for the anchor message. It is
194
+ * the difference between "this rule matches nothing" and "nothing is indexed
195
+ * yet", which a bare zero collapses into one wrong sentence. Distinct from
196
+ * {@link OrganizeMatched.semanticUnavailable}, which is the deployment
197
+ * shipping no vector pipeline at all; an empty index is not an error, so it
198
+ * cannot be inferred from a throw.
199
+ */
200
+ semanticIndexEmpty: boolean;
191
201
  }
192
202
 
193
203
  /** The predicate was refused: nothing matched, and nothing may be applied. */
@@ -307,16 +317,21 @@ const anchorForWiden = async (
307
317
  * Fan out with a k-NN query gated on the cosine threshold, then refine by
308
318
  * literal clauses reconstructed from the same chunk vectors. Every read here
309
319
  * goes through the vector store; a deployment without the vector pipeline
310
- * fails on the first call, which {@link matchOrganize} catches. Returns null
311
- * when neither a persisted anchor nor the message's own chunk vectors exist
312
- * to pool.
320
+ * fails on the first call, which {@link matchOrganize} catches.
321
+ *
322
+ * `indexEmpty` separates the two ways this arm returns nothing (issue #452):
323
+ * neither a persisted anchor nor the anchor message's own chunk vectors exist
324
+ * to pool from, or the k-NN read came back with no rows at all for the account.
325
+ * Both mean the index has nothing to answer with — it is empty or still
326
+ * building — rather than that no mail resembles the anchor. A thresholded-away
327
+ * result set is a real answer and is not flagged.
313
328
  */
314
329
  const matchSemantic = async (
315
330
  deps: OrganizeMatchDeps,
316
331
  accountConfigId: string,
317
332
  predicate: OrganizePredicate,
318
333
  limit: number,
319
- ): Promise<string[] | null> => {
334
+ ): Promise<{ messageIds: string[]; indexEmpty: boolean }> => {
320
335
  const semantic = deps.semantic();
321
336
  const persisted = await findPersistedAnchor(
322
337
  deps.filterAnchors,
@@ -326,7 +341,7 @@ const matchSemantic = async (
326
341
  const anchor: AnchorPayload | null = persisted
327
342
  ? await anchorForWiden(deps, semantic, persisted)
328
343
  : await semantic.buildAnchor(accountConfigId, predicate.anchorMessageId);
329
- if (!anchor) return null;
344
+ if (!anchor) return { messageIds: [], indexEmpty: true };
330
345
  const threshold =
331
346
  predicate.similarityThreshold ?? DEFAULT_SEMANTIC_MATCH_THRESHOLD;
332
347
  const matches = await semantic.vectorStore.query({
@@ -334,6 +349,7 @@ const matchSemantic = async (
334
349
  topK: limit * VECTOR_CHUNK_FACTOR,
335
350
  filter: { accountConfigId },
336
351
  });
352
+ if (matches.length === 0) return { messageIds: [], indexEmpty: true };
337
353
  const bestScore = new Map<string, number>();
338
354
  for (const match of matches) {
339
355
  const messageId = match.metadata.messageId;
@@ -347,7 +363,8 @@ const matchSemantic = async (
347
363
  .map(([messageId]) => messageId);
348
364
 
349
365
  const clauses = predicate.literalClauses;
350
- if (clauses.length === 0) return base.slice(0, limit);
366
+ if (clauses.length === 0)
367
+ return { messageIds: base.slice(0, limit), indexEmpty: false };
351
368
 
352
369
  const matched: string[] = [];
353
370
  for (const messageId of base) {
@@ -359,7 +376,7 @@ const matchSemantic = async (
359
376
  matched.push(messageId);
360
377
  }
361
378
  }
362
- return matched;
379
+ return { messageIds: matched, indexEmpty: false };
363
380
  };
364
381
 
365
382
  /**
@@ -475,7 +492,12 @@ export const matchOrganize = async (
475
492
  const anchored = hasAnchor(predicate);
476
493
  const clauses = predicate.literalClauses;
477
494
  if (!anchored && clauses.length === 0) {
478
- return { rejected: null, messageIds: [], semanticUnavailable: false };
495
+ return {
496
+ rejected: null,
497
+ messageIds: [],
498
+ semanticUnavailable: false,
499
+ semanticIndexEmpty: false,
500
+ };
479
501
  }
480
502
 
481
503
  if (!anchored) {
@@ -487,11 +509,16 @@ export const matchOrganize = async (
487
509
  predicate,
488
510
  limit,
489
511
  );
490
- return { rejected: null, messageIds, semanticUnavailable: false };
512
+ return {
513
+ rejected: null,
514
+ messageIds,
515
+ semanticUnavailable: false,
516
+ semanticIndexEmpty: false,
517
+ };
491
518
  }
492
519
 
493
520
  try {
494
- const semanticIds = await matchSemantic(
521
+ const semantic = await matchSemantic(
495
522
  deps,
496
523
  accountConfigId,
497
524
  predicate,
@@ -499,8 +526,9 @@ export const matchOrganize = async (
499
526
  );
500
527
  return {
501
528
  rejected: null,
502
- messageIds: semanticIds ?? [],
529
+ messageIds: semantic.messageIds,
503
530
  semanticUnavailable: false,
531
+ semanticIndexEmpty: semantic.indexEmpty,
504
532
  };
505
533
  } catch (error) {
506
534
  if (!noteSemanticCapabilityAbsence(error)) throw error;
@@ -508,7 +536,12 @@ export const matchOrganize = async (
508
536
  // clauses over the corpus — nothing when the predicate is anchor-only —
509
537
  // and flag the absence so the client can say so.
510
538
  if (clauses.length === 0) {
511
- return { rejected: null, messageIds: [], semanticUnavailable: true };
539
+ return {
540
+ rejected: null,
541
+ messageIds: [],
542
+ semanticUnavailable: true,
543
+ semanticIndexEmpty: false,
544
+ };
512
545
  }
513
546
  const rejection = bodyContentRejection(clauses);
514
547
  if (rejection) return { rejected: rejection };
@@ -518,7 +551,12 @@ export const matchOrganize = async (
518
551
  predicate,
519
552
  limit,
520
553
  );
521
- return { rejected: null, messageIds, semanticUnavailable: true };
554
+ return {
555
+ rejected: null,
556
+ messageIds,
557
+ semanticUnavailable: true,
558
+ semanticIndexEmpty: false,
559
+ };
522
560
  }
523
561
  };
524
562