@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 +1 -1
- package/src/handlers/organize.ts +1 -0
- package/src/service/organize.test.ts +106 -5
- package/src/service/organize.ts +52 -14
package/package.json
CHANGED
package/src/handlers/organize.ts
CHANGED
|
@@ -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 } =
|
|
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 () => {
|
package/src/service/organize.ts
CHANGED
|
@@ -183,11 +183,21 @@ export interface OrganizeRejection {
|
|
|
183
183
|
message: string;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
/** The matched ids plus
|
|
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.
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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[]
|
|
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
|
|
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)
|
|
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 {
|
|
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 {
|
|
512
|
+
return {
|
|
513
|
+
rejected: null,
|
|
514
|
+
messageIds,
|
|
515
|
+
semanticUnavailable: false,
|
|
516
|
+
semanticIndexEmpty: false,
|
|
517
|
+
};
|
|
491
518
|
}
|
|
492
519
|
|
|
493
520
|
try {
|
|
494
|
-
const
|
|
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:
|
|
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 {
|
|
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 {
|
|
554
|
+
return {
|
|
555
|
+
rejected: null,
|
|
556
|
+
messageIds,
|
|
557
|
+
semanticUnavailable: true,
|
|
558
|
+
semanticIndexEmpty: false,
|
|
559
|
+
};
|
|
522
560
|
}
|
|
523
561
|
};
|
|
524
562
|
|