@konneal/engine 0.2.44 → 0.2.46

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-HDUSO5S5.js";
3
+ } from "./chunk-SE67PTJ6.js";
4
4
  import "./chunk-N2UOENDY.js";
5
5
  import "./chunk-R2V3X6SQ.js";
6
6
  import "./chunk-A3QHHUN5.js";
package/dist/ask.d.ts CHANGED
@@ -2,4 +2,7 @@ import type { Env } from "./env";
2
2
  export type { Env };
3
3
  import { type ApiKey } from "./lib/http";
4
4
  declare function handleAsk(env: Env, ctx: ExecutionContext, req: Request, tier: "anon" | "key" | "member", key: ApiKey | null): Promise<Response>;
5
+ /** RAGAS-style metric battery (G13): judge an (question, answer, passages)
6
+ * triple — faithfulness, answer relevancy, context precision. Driven by
7
+ * tests/eval-suite.mjs; prompts are data; refuses nothing, judges only. */
5
8
  export { handleAsk };
@@ -15,6 +15,10 @@ export declare function isRegisterShaped(query: string): boolean;
15
15
  * 2+ characters that are not register question words, plus bare model
16
16
  * numbers ("190", "HM14H1"). Upper-cased tokens match case-insensitively
17
17
  * in SQL LIKE. */
18
+ /** The family the question names ("R 60", "R60", "D 31") — the register
19
+ * search filters to it, so a question about R 60 never presents R 76
20
+ * rows. */
21
+ export declare function queryFamily(query: string): string | null;
18
22
  export declare function registerTokens(query: string): string[];
19
23
  /** The register query for a token set. Numeric tokens (a model number
20
24
  * like "190") must match as a STANDALONE value — `LIKE '%190%'` also
@@ -1743,14 +1743,15 @@ function cacheKeyMaterial(query, lang, salt) {
1743
1743
  function exactCacheKey(indexVersion, gen, ns, queryHash) {
1744
1744
  return `a:${indexVersion}:g${gen}:${ns}:${queryHash}`;
1745
1745
  }
1746
- function semanticCacheKey(indexVersion, gen, signature) {
1747
- return `sc:${indexVersion}:g${gen}:${signature}`;
1748
- }
1749
1746
 
1750
1747
  // workers/worker_public/src/certificates.ts
1751
1748
  function isRegisterShaped(query) {
1752
1749
  return /\bcertif(ied|icates?|ication)s?\b/i.test(query) && /\b(is|are|still|currently|valid|status|suspended|revoked|was|were)\b/i.test(query);
1753
1750
  }
1751
+ function queryFamily(query) {
1752
+ const m = /\b([RDMB])\s?-?(\d{2,3})\b/i.exec(query);
1753
+ return m ? `${m[1].toUpperCase()}${m[2].padStart(2, "0")}` : null;
1754
+ }
1754
1755
  function registerTokens(query) {
1755
1756
  const stop = /* @__PURE__ */ new Set([
1756
1757
  "the",
@@ -1833,7 +1834,13 @@ async function searchRegister(db, query) {
1833
1834
  if (!isRegisterShaped(query)) return null;
1834
1835
  const tokens2 = registerTokens(query);
1835
1836
  if (!tokens2.length) return null;
1836
- const { sql, params } = buildRegisterQuery(tokens2);
1837
+ const built = buildRegisterQuery(tokens2);
1838
+ let { sql, params } = built;
1839
+ const family = queryFamily(query);
1840
+ if (family) {
1841
+ sql = sql.replace(" WHERE ", " WHERE family = ?0 AND ");
1842
+ params.unshift(family);
1843
+ }
1837
1844
  try {
1838
1845
  const res = await db.prepare(sql).bind(...params).all();
1839
1846
  return { rows: res.results ?? [], tokens: tokens2 };
@@ -2083,22 +2090,6 @@ async function handleAsk(env, ctx, req, tier, key) {
2083
2090
  }
2084
2091
  }
2085
2092
  let understanding = null;
2086
- const nodeScoped = !!modelNodeRefIn(q.query) || !!modelNodeRefIn(declaredCtx?.label);
2087
- if (!cached && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !q.lang && !userImage && !fresh) {
2088
- const wv0 = await warmEmbed ?? null;
2089
- if (wv0) {
2090
- const sc0 = await semanticCacheGet(env, gen, wv0, salt);
2091
- if (sc0) {
2092
- console.log("semantic cache hit (pre-understanding)");
2093
- telemetry(env, ctx, tier, "ask", null, true, sc0.answer.length, sc0.query_hash, q.lang, "semantic", telemetryMeta());
2094
- const cctx0 = sc0.context_applied ?? NO_CONTEXT;
2095
- if (wantsStream) {
2096
- return sseResponse([{ type: "citations", citations: sc0.citations ?? [], context_applied: cctx0 }, { type: "token", v: sc0.answer }, { type: "done", model: sc0.model, query_hash: sc0.query_hash, similar: true, served_from: "similar", context_applied: cctx0 }], corsHeaders(req));
2097
- }
2098
- return json({ ...sc0, similar: true, context_applied: cctx0, quota });
2099
- }
2100
- }
2101
- }
2102
2093
  let optimisticVec = null;
2103
2094
  let optimisticHits = [];
2104
2095
  const t0 = Date.now();
@@ -2183,27 +2174,8 @@ async function handleAsk(env, ctx, req, tier, key) {
2183
2174
  const graphDocNumbers = await graphExpand(env, understanding);
2184
2175
  const eNote = await editionNote(env, understanding);
2185
2176
  const register = await searchRegister(env.DB, q.query);
2177
+ console.log("register-search:", JSON.stringify({ shaped: !!register, tokens: register?.tokens ?? null, rows: register?.rows?.length ?? null, first: register?.rows?.[0]?.num ?? null }));
2186
2178
  const regNote = register ? registerNote(register.rows) : void 0;
2187
- if (understanding?.intent !== "conversational" && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !userImage && !fresh) {
2188
- const warmVec = await warmEmbed ?? null;
2189
- if (warmVec) {
2190
- const sc = await semanticCacheGet(env, gen, warmVec, salt);
2191
- if (sc) {
2192
- console.log("semantic cache hit");
2193
- telemetry(env, ctx, tier, "ask", null, true, sc.answer.length, sc.query_hash, q.lang, "semantic", telemetryMeta());
2194
- const cctx = sc.context_applied ?? NO_CONTEXT;
2195
- if (wantsStream) {
2196
- return sseResponse([
2197
- ...readAs() ? [{ type: "read", read: readAs() }] : [],
2198
- { type: "citations", citations: sc.citations ?? [], context_applied: cctx },
2199
- { type: "token", v: sc.answer },
2200
- { type: "done", model: sc.model, query_hash: sc.query_hash, similar: true, served_from: "similar", context_applied: cctx, read: readAs() }
2201
- ], corsHeaders(req));
2202
- }
2203
- return json({ ...sc, similar: true, context_applied: cctx, read: readAs(), quota });
2204
- }
2205
- }
2206
- }
2207
2179
  if (understanding?.intent === "conversational") {
2208
2180
  const queryHash2 = await sha256Hex(q.query);
2209
2181
  const messages2 = [
@@ -2617,8 +2589,6 @@ Answer account questions from these records ONLY: name the record when you use i
2617
2589
  console.log("anchors:", streamed.violations.length, "of", streamed.total, "unverified \u2014 not caching");
2618
2590
  }
2619
2591
  if (streamed.violations.length === 0 && canonical.length > 0 && !contextual && !declaredCtx && !canonical.includes(refusalAnswer())) {
2620
- const wv = await warmEmbed ?? null;
2621
- if (wv) semanticCachePut(env, ctx, gen, wv, salt, { answer: canonical, citations: cites, model, query_hash: queryHash });
2622
2592
  ctx.waitUntil(
2623
2593
  env.CACHE.put(exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt))), JSON.stringify({ answer: canonical, citations: cites, model, query_hash: queryHash }), { expirationTtl: LIMITS.cacheTtlSec })
2624
2594
  );
@@ -2720,10 +2690,6 @@ Answer account questions from these records ONLY: name the record when you use i
2720
2690
  const jsonQuality = answerQuality(finalCites.map((c) => c.quality));
2721
2691
  const out = { answer, citations: finalCites, ...jsonQuality ? { source_quality: jsonQuality, confidence_note: qualityNote(jsonQuality), ...jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {} } : {}, model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...verdictBlock ? [verdictBlock] : [], ...conditionBlock ? [conditionBlock] : [], ...aggregationBlock ? [aggregationBlock] : [], ...completionBlocks], context_applied: ctxApplied, ...liveRecords ? { records: liveRecords } : {} };
2722
2692
  const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
2723
- if (cacheable) {
2724
- const warmVec = await warmEmbed ?? null;
2725
- if (warmVec) semanticCachePut(env, ctx, gen, warmVec, salt, out);
2726
- }
2727
2693
  if (cacheable) {
2728
2694
  const ck = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt)));
2729
2695
  ctx.waitUntil(env.CACHE.put(ck, JSON.stringify(out), { expirationTtl: LIMITS.cacheTtlSec }));
@@ -2751,36 +2717,6 @@ function sseResponse(events, cors) {
2751
2717
  }
2752
2718
  });
2753
2719
  }
2754
- function cosine(a, b) {
2755
- let dot = 0;
2756
- let na = 0;
2757
- let nb = 0;
2758
- for (let i = 0; i < a.length; i++) {
2759
- dot += a[i] * b[i];
2760
- na += a[i] * a[i];
2761
- nb += b[i] * b[i];
2762
- }
2763
- return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
2764
- }
2765
- function scSignature(v, salt) {
2766
- return v.slice(0, 16).map((x) => x.toFixed(2)).join(",") + (salt ? `|s:${salt.length}:${salt.slice(0, 64)}` : "");
2767
- }
2768
- async function semanticCacheGet(env, gen, vec, salt) {
2769
- try {
2770
- const raw = await env.CACHE.get(semanticCacheKey(env.INDEX_VERSION, gen, scSignature(vec, salt)), "json");
2771
- if (!raw?.v || !Array.isArray(raw.v) || raw.v.length !== vec.length) return null;
2772
- if (cosine(raw.v, vec) < 0.97) return null;
2773
- return raw;
2774
- } catch {
2775
- return null;
2776
- }
2777
- }
2778
- function semanticCachePut(env, ctx, gen, vec, salt, payload) {
2779
- const v = vec.map((x) => Number(x.toFixed(3)));
2780
- ctx.waitUntil(
2781
- env.CACHE.put(semanticCacheKey(env.INDEX_VERSION, gen, scSignature(vec, salt)), JSON.stringify({ v, ...payload }), { expirationTtl: LIMITS.cacheTtlSec })
2782
- );
2783
- }
2784
2720
 
2785
2721
  export {
2786
2722
  handleMemories,
@@ -9,7 +9,7 @@ import {
9
9
  modelGroundingBlock,
10
10
  scoreJudge,
11
11
  standardForDocNumber
12
- } from "../../chunk-HDUSO5S5.js";
12
+ } from "../../chunk-SE67PTJ6.js";
13
13
  import {
14
14
  buildMessages,
15
15
  citations,
@@ -999,7 +999,7 @@ async function handleMcp(env, ctx, req, tier, key) {
999
999
  // stream:false forces the JSON lane (anon defaults to SSE)
1000
1000
  body: JSON.stringify({ ...args, stream: false })
1001
1001
  });
1002
- const res = name === "ask" ? await (await import("../../ask-JOX2D7KH.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-QZDQVB57.js")).handleSearch(env, ctx, inner, tier, key);
1002
+ const res = name === "ask" ? await (await import("../../ask-6QPD3QA5.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-QZDQVB57.js")).handleSearch(env, ctx, inner, tier, key);
1003
1003
  return res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1004
1004
  });
1005
1005
  if (out.ok && "accepted" in out) return new Response(null, { status: 202 });
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.44",
30
+ "version": "0.2.46",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -20,7 +20,7 @@ import { contractV2, tableRetyped } from "./refs";
20
20
  import { completeTables, completeFigures } from "./completion";
21
21
  import { NO_CONTEXT, appliedContext, contextNote, namedDocumentIn, parseContext, resolveDocScope, syntheticUnderstanding } from "./context";
22
22
  import { liveDataConfig, liveTokenFor, opCfg, opTokenMember, resolveLiveAccount, type LiveRecord } from "./livedata";
23
- import { bindModelNode, licenseBoundaryNote, licenseBoundaryRefusal, licensedEntryForPackage, modelCitation, modelEcho, modelGroundingBlock, modelNodeRefIn, standardForDocNumber } from "./modelplane";
23
+ import { bindModelNode, licenseBoundaryNote, licenseBoundaryRefusal, licensedEntryForPackage, modelCitation, modelEcho, modelGroundingBlock, standardForDocNumber } from "./modelplane";
24
24
  import { evaluate as machineEvaluate, verdictNote } from "./verdict";
25
25
  import { evaluateConditionSets, quantitiesIn, type ConditionVerdict } from "./conditions";
26
26
  import { evaluateAggregation, type AggregationVerdict } from "./aggregation";
@@ -31,7 +31,7 @@ import { detectApiCallIntent, prepareApiCall } from "./apicalls";
31
31
  import { memoryNote } from "./memories";
32
32
  import { entitlementScope, resolveRequestScope, requestSalt, standardKeysFrom } from "./requestScope";
33
33
  import { rawSessionToken, type SessionClaims } from "./session";
34
- import { cacheKeyMaterial, corpusGen, exactCacheKey, freshRequested, semanticCacheKey } from "./answercache";
34
+ import { cacheKeyMaterial, corpusGen, exactCacheKey, freshRequested } from "./answercache";
35
35
  import type { Env } from "./env";
36
36
  export type { Env };
37
37
  import { json, err, corsHeaders, readJson, validateQuery, type ApiKey } from "./lib/http";
@@ -435,26 +435,6 @@ async function handleAsk(
435
435
  // fresh already bypassed the exact cache above and bypasses this one.
436
436
  let understanding: any = null;
437
437
  // a query naming a model node (/req/…, /term/…) is node-SCOPED: its
438
- // embedding sits near every other node-scoped ask about the same
439
- // standard, and the single-entry semantic bucket then serves one
440
- // node's answer for another (observed run-to-run across the golden
441
- // model legs). Node-scoped queries use the exact cache only.
442
- const nodeScoped = !!modelNodeRefIn(q.query) || !!modelNodeRefIn(declaredCtx?.label);
443
- if (!cached && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !q.lang && !userImage && !fresh) {
444
- const wv0 = (await warmEmbed) ?? null;
445
- if (wv0) {
446
- const sc0 = await semanticCacheGet(env, gen, wv0, salt);
447
- if (sc0) {
448
- console.log("semantic cache hit (pre-understanding)");
449
- telemetry(env, ctx, tier, "ask", null, true, sc0.answer.length, sc0.query_hash, q.lang, "semantic", telemetryMeta());
450
- const cctx0 = sc0.context_applied ?? NO_CONTEXT;
451
- if (wantsStream) {
452
- return sseResponse([{ type: "citations", citations: sc0.citations ?? [], context_applied: cctx0 }, { type: "token", v: sc0.answer }, { type: "done", model: sc0.model, query_hash: sc0.query_hash, similar: true, served_from: "similar", context_applied: cctx0 }], corsHeaders(req));
453
- }
454
- return json({ ...sc0, similar: true, context_applied: cctx0, quota, });
455
- }
456
- }
457
- }
458
438
  // ── Option C: optimistic parallel retrieval ──
459
439
  // The dense lane (folded-query embed + unfiltered Vectorize query) runs
460
440
  // CONCURRENTLY with understanding instead of after it — the serial
@@ -583,34 +563,9 @@ async function handleAsk(
583
563
  // join the prompt as an authoritative note — exact matches, never a
584
564
  // similarity guess about certification standing
585
565
  const register = await searchRegister(env.DB, q.query);
566
+ console.log("register-search:", JSON.stringify({ shaped: !!register, tokens: register?.tokens ?? null, rows: register?.rows?.length ?? null, first: register?.rows?.[0]?.num ?? null }));
586
567
  const regNote = register ? registerNote(register.rows) : undefined;
587
568
 
588
- // semantic cache: near-duplicate of a recently answered question —
589
- // serves the stored answer with a `similar: true` marker (checked only
590
- // for standalone knowledge questions; contextual turns, declared-context
591
- // asks and image asks always run live; fresh regenerates, bypassing
592
- // this cache too)
593
- if (understanding?.intent !== "conversational" && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !userImage && !fresh) {
594
- const warmVec = (await warmEmbed) ?? null;
595
- if (warmVec) {
596
- const sc = await semanticCacheGet(env, gen, warmVec, salt);
597
- if (sc) {
598
- console.log("semantic cache hit");
599
- telemetry(env, ctx, tier, "ask", null, true, sc.answer.length, sc.query_hash, q.lang, "semantic", telemetryMeta());
600
- const cctx = sc.context_applied ?? NO_CONTEXT;
601
- if (wantsStream) {
602
- return sseResponse([
603
- ...(readAs() ? [{ type: "read", read: readAs() }] : []),
604
- { type: "citations", citations: sc.citations ?? [], context_applied: cctx },
605
- { type: "token", v: sc.answer },
606
- { type: "done", model: sc.model, query_hash: sc.query_hash, similar: true, served_from: "similar", context_applied: cctx, read: readAs() },
607
- ], corsHeaders(req));
608
- }
609
- return json({ ...sc, similar: true, context_applied: cctx, read: readAs(), quota, });
610
- }
611
- }
612
- }
613
-
614
569
  // Conversational route, decided by query UNDERSTANDING (any language, any
615
570
  // phrasing) — not string matching. No retrieval: nothing in the corpus
616
571
  // answers "who are you". The model speaks for itself from the service
@@ -1192,8 +1147,6 @@ async function handleAsk(
1192
1147
  console.log("anchors:", streamed.violations.length, "of", streamed.total, "unverified — not caching");
1193
1148
  }
1194
1149
  if (streamed.violations.length === 0 && canonical.length > 0 && !contextual && !declaredCtx && !canonical.includes(refusalAnswer())) {
1195
- const wv = (await warmEmbed) ?? null;
1196
- if (wv) semanticCachePut(env, ctx, gen, wv, salt, { answer: canonical, citations: cites, model, query_hash: queryHash });
1197
1150
  ctx.waitUntil(
1198
1151
  env.CACHE.put(exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt))), JSON.stringify({ answer: canonical, citations: cites, model, query_hash: queryHash }), { expirationTtl: LIMITS.cacheTtlSec }),
1199
1152
  );
@@ -1358,10 +1311,6 @@ async function handleAsk(
1358
1311
  const jsonQuality = answerQuality(finalCites.map((c: any) => c.quality));
1359
1312
  const out = { answer, citations: finalCites, ...(jsonQuality ? { source_quality: jsonQuality, confidence_note: qualityNote(jsonQuality), ...(jsonQuality === "ocr" ? { experimental_sources: experimentalSourceLabels(finalCites) } : {}) } : {}), model: MODELS.member, query_hash: queryHash, follow_ups: understanding?.follow_ups ?? [], blocks: [...c2ns.blocks, ...(verdictBlock ? [verdictBlock] : []), ...(conditionBlock ? [conditionBlock] : []), ...(aggregationBlock ? [aggregationBlock] : []), ...completionBlocks], context_applied: ctxApplied, ...(liveRecords ? { records: liveRecords } : {}) };
1360
1313
  const cacheable = !contextual && !declaredCtx && !answer.includes(refusalAnswer()) && finalAnchors.violations.length === 0;
1361
- if (cacheable) {
1362
- const warmVec = (await warmEmbed) ?? null;
1363
- if (warmVec) semanticCachePut(env, ctx, gen, warmVec, salt, out);
1364
- }
1365
1314
  if (cacheable) {
1366
1315
  const ck = exactCacheKey(env.INDEX_VERSION, gen, ns, await sha256Hex(cacheKeyMaterial(q.query, q.lang, salt)));
1367
1316
  ctx.waitUntil(env.CACHE.put(ck, JSON.stringify(out), { expirationTtl: LIMITS.cacheTtlSec }));
@@ -1403,44 +1352,6 @@ function sseResponse(events: unknown[], cors: Record<string, string>): Response
1403
1352
 
1404
1353
 
1405
1354
  // ── Semantic answer cache (G6) ──
1406
- // Near-duplicate queries re-pay the whole pipeline. Bucket KV by a
1407
- // leading-dimension signature of the query embedding; confirm with full
1408
- // cosine >= 0.97 before serving. Same INDEX_VERSION + corpus-generation
1409
- // namespace as the answer cache (deploys and corpus surgery invalidate
1410
- // both). Single entry per bucket (v1): collisions overwrite, never mix.
1411
- function cosine(a: number[], b: number[]): number {
1412
- let dot = 0;
1413
- let na = 0;
1414
- let nb = 0;
1415
- for (let i = 0; i < a.length; i++) {
1416
- dot += a[i] * b[i];
1417
- na += a[i] * a[i];
1418
- nb += b[i] * b[i];
1419
- }
1420
- return dot / (Math.sqrt(na) * Math.sqrt(nb) || 1);
1421
- }
1422
-
1423
- function scSignature(v: number[], salt?: string | null): string {
1424
- return v.slice(0, 16).map((x) => x.toFixed(2)).join(",") + (salt ? `|s:${salt.length}:${salt.slice(0, 64)}` : "");
1425
- }
1426
-
1427
- async function semanticCacheGet(env: Env, gen: string, vec: number[], salt?: string | null): Promise<{ answer: string; citations: unknown[]; model: string; query_hash: string; context_applied?: unknown } | null> {
1428
- try {
1429
- const raw = await env.CACHE.get(semanticCacheKey(env.INDEX_VERSION, gen, scSignature(vec, salt)), "json") as any;
1430
- if (!raw?.v || !Array.isArray(raw.v) || raw.v.length !== vec.length) return null;
1431
- if (cosine(raw.v, vec) < 0.97) return null;
1432
- return raw;
1433
- } catch {
1434
- return null;
1435
- }
1436
- }
1437
-
1438
- function semanticCachePut(env: Env, ctx: ExecutionContext, gen: string, vec: number[], salt: string | null | undefined, payload: { answer: string; citations: unknown[]; model: string; query_hash: string }): void {
1439
- const v = vec.map((x) => Number(x.toFixed(3)));
1440
- ctx.waitUntil(
1441
- env.CACHE.put(semanticCacheKey(env.INDEX_VERSION, gen, scSignature(vec, salt)), JSON.stringify({ v, ...payload }), { expirationTtl: LIMITS.cacheTtlSec }),
1442
- );
1443
- }
1444
1355
 
1445
1356
  // ── the route table (TODO.impl/03) ───────────────────────────────────────
1446
1357
 
@@ -29,6 +29,14 @@ export function isRegisterShaped(query: string): boolean {
29
29
  * 2+ characters that are not register question words, plus bare model
30
30
  * numbers ("190", "HM14H1"). Upper-cased tokens match case-insensitively
31
31
  * in SQL LIKE. */
32
+ /** The family the question names ("R 60", "R60", "D 31") — the register
33
+ * search filters to it, so a question about R 60 never presents R 76
34
+ * rows. */
35
+ export function queryFamily(query: string): string | null {
36
+ const m = /\b([RDMB])\s?-?(\d{2,3})\b/i.exec(query);
37
+ return m ? `${m[1].toUpperCase()}${m[2].padStart(2, "0")}` : null;
38
+ }
39
+
32
40
  export function registerTokens(query: string): string[] {
33
41
  const stop = new Set([
34
42
  "the", "a", "an", "is", "are", "was", "were", "still", "currently", "in", "on", "for", "of", "and", "or",
@@ -84,7 +92,13 @@ export async function searchRegister(db: any, query: string): Promise<{ rows: Re
84
92
  if (!isRegisterShaped(query)) return null;
85
93
  const tokens = registerTokens(query);
86
94
  if (!tokens.length) return null;
87
- const { sql, params } = buildRegisterQuery(tokens);
95
+ const built = buildRegisterQuery(tokens);
96
+ let { sql, params } = built;
97
+ const family = queryFamily(query);
98
+ if (family) {
99
+ sql = sql.replace(" WHERE ", " WHERE family = ?0 AND ");
100
+ params.unshift(family);
101
+ }
88
102
  try {
89
103
  const res = await db.prepare(sql).bind(...params).all();
90
104
  return { rows: (res.results ?? []) as RegisterRow[], tokens };