@askdb/rag 0.2.0-beta.3 → 0.2.0-beta.31

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 (72) hide show
  1. package/README.md +55 -15
  2. package/dist/chunker/chunker.d.ts +5 -1
  3. package/dist/chunker/chunker.d.ts.map +1 -1
  4. package/dist/chunker/chunker.js +225 -103
  5. package/dist/chunker/chunker.js.map +1 -1
  6. package/dist/chunker/ids.d.ts +15 -0
  7. package/dist/chunker/ids.d.ts.map +1 -0
  8. package/dist/chunker/ids.js +22 -0
  9. package/dist/chunker/ids.js.map +1 -0
  10. package/dist/chunker/index.d.ts +1 -0
  11. package/dist/chunker/index.d.ts.map +1 -1
  12. package/dist/chunker/index.js +1 -0
  13. package/dist/chunker/index.js.map +1 -1
  14. package/dist/chunker/sources.d.ts +2 -1
  15. package/dist/chunker/sources.d.ts.map +1 -1
  16. package/dist/chunker/sources.js +17 -3
  17. package/dist/chunker/sources.js.map +1 -1
  18. package/dist/embedders/ai-sdk.d.ts +22 -1
  19. package/dist/embedders/ai-sdk.d.ts.map +1 -1
  20. package/dist/embedders/ai-sdk.js +10 -0
  21. package/dist/embedders/ai-sdk.js.map +1 -1
  22. package/dist/embedders/dimensions.d.ts +9 -0
  23. package/dist/embedders/dimensions.d.ts.map +1 -0
  24. package/dist/embedders/dimensions.js +18 -0
  25. package/dist/embedders/dimensions.js.map +1 -0
  26. package/dist/index.d.ts +7 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +12 -4
  29. package/dist/index.js.map +1 -1
  30. package/dist/indexer/hash.d.ts +7 -0
  31. package/dist/indexer/hash.d.ts.map +1 -1
  32. package/dist/indexer/hash.js +13 -0
  33. package/dist/indexer/hash.js.map +1 -1
  34. package/dist/indexer/index.d.ts +10 -4
  35. package/dist/indexer/index.d.ts.map +1 -1
  36. package/dist/indexer/index.js +167 -30
  37. package/dist/indexer/index.js.map +1 -1
  38. package/dist/indexer/lock-file.d.ts +85 -4
  39. package/dist/indexer/lock-file.d.ts.map +1 -1
  40. package/dist/indexer/lock-file.js +108 -9
  41. package/dist/indexer/lock-file.js.map +1 -1
  42. package/dist/log-events.d.ts +11 -0
  43. package/dist/log-events.d.ts.map +1 -1
  44. package/dist/log-events.js +11 -0
  45. package/dist/log-events.js.map +1 -1
  46. package/dist/stores/file.d.ts +7 -1
  47. package/dist/stores/file.d.ts.map +1 -1
  48. package/dist/stores/file.js +52 -10
  49. package/dist/stores/file.js.map +1 -1
  50. package/dist/stores/memory.d.ts.map +1 -1
  51. package/dist/stores/memory.js +31 -3
  52. package/dist/stores/memory.js.map +1 -1
  53. package/dist/stores/pgvector.d.ts +38 -3
  54. package/dist/stores/pgvector.d.ts.map +1 -1
  55. package/dist/stores/pgvector.js +129 -16
  56. package/dist/stores/pgvector.js.map +1 -1
  57. package/dist/types.d.ts +70 -10
  58. package/dist/types.d.ts.map +1 -1
  59. package/package.json +23 -26
  60. package/bin/askdb-rag.js +0 -2
  61. package/dist/bin.d.ts +0 -3
  62. package/dist/bin.d.ts.map +0 -1
  63. package/dist/bin.js +0 -7
  64. package/dist/bin.js.map +0 -1
  65. package/dist/cli.d.ts +0 -11
  66. package/dist/cli.d.ts.map +0 -1
  67. package/dist/cli.js +0 -332
  68. package/dist/cli.js.map +0 -1
  69. package/dist/embedders/openai.d.ts +0 -21
  70. package/dist/embedders/openai.d.ts.map +0 -1
  71. package/dist/embedders/openai.js +0 -24
  72. package/dist/embedders/openai.js.map +0 -1
package/README.md CHANGED
@@ -7,21 +7,25 @@ Deterministic retrieval over AskDB Schema v2 artifacts. `@askdb/rag` chunks the
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- pnpm add @askdb/rag @askdb/core
10
+ pnpm add @askdb/rag @askdb/core ai
11
11
  # only if you use the pgvector adapter:
12
12
  pnpm add pg
13
- # only if you use the OpenAI embedder helper or CLI `--embedder openai`:
14
- pnpm add ai @ai-sdk/openai
13
+ # only if you build OpenAI embedding models with the AI SDK yourself:
14
+ pnpm add @ai-sdk/openai
15
15
  ```
16
16
 
17
- `pg`, `ai`, and `@ai-sdk/openai` are optional peer dependencies. The chunker, in-memory store, and file store do not require them.
17
+ `ai` is a required peer dependency of `@askdb/core`. For `@askdb/rag` itself, `pg`, `ai`, and `@ai-sdk/openai` are optional peer dependencies — the chunker, in-memory store, and file store do not require them.
18
18
 
19
19
  ## Quickstart
20
20
 
21
21
  ```ts
22
22
  import { ask, loadSchema } from "@askdb/core";
23
- import { buildSchemaIndex, loadChunkerSourcesFromDir } from "@askdb/rag";
24
- import { createMemoryStore } from "@askdb/rag/stores/memory";
23
+ import {
24
+ buildSchemaIndex,
25
+ loadChunkerSourcesFromDir,
26
+ createMemoryStore,
27
+ createAiSdkEmbedder,
28
+ } from "@askdb/rag";
25
29
 
26
30
  const schemaDir = "./fixtures/schemas/orders-users.schema";
27
31
  const schema = loadSchema(schemaDir);
@@ -48,24 +52,60 @@ const { sql } = await ask({
48
52
 
49
53
  ## CLI
50
54
 
55
+ `@askdb/rag` is a library. The command line for it is `askdb rag` in the [`askdb`](https://www.npmjs.com/package/askdb) package, which reads `rag.*` and `ai.embedding` from `askdb.config.*`:
56
+
51
57
  ```bash
52
- askdb-rag index fixtures/schemas/orders-users.schema --store file
53
- askdb-rag query fixtures/schemas/orders-users.schema \
58
+ npx askdb rag index fixtures/schemas/orders-users.schema --store file
59
+ npx askdb rag query fixtures/schemas/orders-users.schema \
54
60
  --question "How much revenue did we make last month?"
55
61
  ```
56
62
 
57
- The default CLI embedder is a deterministic mock for smoke tests. Use `--embedder openai` with `OPENAI_API_KEY` for real embeddings.
63
+ See the [CLI reference](https://askdb.tools/reference/cli/#askdb-rag) for every flag. This package no longer ships an `askdb-rag` binary.
58
64
 
59
65
  ## Public Surface
60
66
 
67
+ All exports are available from the root `@askdb/rag` import. Sub-path imports are also supported and point to the same modules — use whichever style you prefer.
68
+
69
+ ### Core
70
+
61
71
  - `chunkSchema`, `chunkSchemaDir`, `chunkSchemaBundle` — deterministic Schema v2 chunking.
62
72
  - `buildSchemaIndex` — chunk, embed, upsert, write `schema.lock.json`, and return a retriever.
63
73
  - `createRetriever` — bind an existing store and embedder without indexing.
64
- - `@askdb/rag/stores/memory` — in-memory cosine store.
65
- - `@askdb/rag/stores/file` — binary embedding file plus JSON metadata.
66
- - `@askdb/rag/stores/pgvector` — pgvector adapter with documented setup SQL.
67
- - `@askdb/rag/embedders/ai-sdk` — generic AI SDK `EmbeddingModel<string>` adapter.
68
- - `@askdb/rag/embedders/openai` — optional OpenAI convenience helper using `text-embedding-3-small` by default.
74
+
75
+ ### Stores
76
+
77
+ | Root import | Sub-path import | Description |
78
+ |---|---|---|
79
+ | `createMemoryStore` | `@askdb/rag/stores/memory` | In-memory cosine store. Zero deps. |
80
+ | `createFileStore` | `@askdb/rag/stores/file` | Binary embedding file + JSON metadata. |
81
+ | `createPgvectorStore` | `@askdb/rag/stores/pgvector` | pgvector adapter with documented setup SQL. Requires `pg`. Needs `dimensions` only to create its table; `ensureSchema()` refuses an existing table of another width with a `PgvectorDimensionMismatchError`. |
82
+
83
+ ### Embedders
84
+
85
+ | Root import | Sub-path import | Description |
86
+ |---|---|---|
87
+ | `createAiSdkEmbedder` | `@askdb/rag/embedders/ai-sdk` | Generic AI SDK `EmbeddingModel` adapter. Requires `ai`. |
88
+ | `detectEmbeddingDimensions` | — | Learns an embedder's vector width by embedding one short text. Use it for a new pgvector table instead of hard-coding a width. |
89
+ | `aiSdkEmbedderId` | `@askdb/rag/embedders/ai-sdk` | The `embedderId` Studio and `askdb rag` record for an `ai.embedding` model (`ai-sdk:<provider>:<model>:<dimensions or default>`), so an index built by one is accepted by the other. |
90
+
91
+ ### Import examples
92
+
93
+ ```ts
94
+ // Everything from the root — simplest DX
95
+ import {
96
+ buildSchemaIndex,
97
+ createMemoryStore,
98
+ createAiSdkEmbedder,
99
+ createPgvectorStore,
100
+ } from "@askdb/rag";
101
+
102
+ // Sub-path imports — same modules, explicit namespacing
103
+ import { createMemoryStore } from "@askdb/rag/stores/memory";
104
+ import { createFileStore } from "@askdb/rag/stores/file";
105
+ import { createPgvectorStore } from "@askdb/rag/stores/pgvector";
106
+ import { createAiSdkEmbedder } from "@askdb/rag/embedders/ai-sdk";
107
+ // Bring your own embedding model (e.g. from @ai-sdk/openai, or @askdb/ai's createEmbeddingModelFromSection) + createAiSdkEmbedder
108
+ ```
69
109
 
70
110
  ## Sensitive Fields
71
111
 
@@ -73,4 +113,4 @@ Sensitive describable-layer content and sensitive identifiers are excluded from
73
113
 
74
114
  ## License
75
115
 
76
- Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
116
+ Apache-2.0 © [Yahya Gilany](https://yahyagilany.io). See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
@@ -11,7 +11,11 @@ import type { ChunkerSources } from "./sources.js";
11
11
  export type ChunkStats = {
12
12
  totalChunks: number;
13
13
  byType: Record<Chunk["type"], number>;
14
- /** Describable-layer chunks dropped because the source content references a sensitive column/table. */
14
+ /**
15
+ * Chunks dropped, plus chunks emitted with part of their describable text
16
+ * (a description, alias, primary entity, enum values, or note) dropped,
17
+ * because the content belongs to or mentions a sensitive column/table.
18
+ */
15
19
  sensitiveExcluded: number;
16
20
  /** Describable-layer chunks emitted **only because** `includeSensitiveDescribable: true` was set. */
17
21
  sensitiveIncluded: number;
@@ -1 +1 @@
1
- {"version":3,"file":"chunker.d.ts","sourceRoot":"","sources":["../../src/chunker/chunker.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAEL,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC;IACtC,uGAAuG;IACvG,iBAAiB,EAAE,MAAM,CAAC;IAC1B,qGAAqG;IACrG,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,qCAAqC;AACrC,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,KAAK,EAAE,UAAU,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,YAAiB,GACzB,WAAW,CAmMb"}
1
+ {"version":3,"file":"chunker.d.ts","sourceRoot":"","sources":["../../src/chunker/chunker.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAEL,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAGnD;;;;;;GAMG;AACH,MAAM,MAAM,UAAU,GAAG;IACvB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC;IACtC;;;;OAIG;IACH,iBAAiB,EAAE,MAAM,CAAC;IAC1B,qGAAqG;IACrG,iBAAiB,EAAE,MAAM,CAAC;CAC3B,CAAC;AAEF,qCAAqC;AACrC,MAAM,MAAM,WAAW,GAAG;IACxB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,KAAK,EAAE,UAAU,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,cAAc,EACvB,OAAO,GAAE,YAAiB,GACzB,WAAW,CAqQb"}
@@ -1,4 +1,6 @@
1
+ import { createMentionMatcher, sensitiveMentionNames } from "@askdb/core";
1
2
  import { DEFAULT_CHUNK_MAX_CHARS, } from "./options.js";
3
+ import { chunkId } from "./ids.js";
2
4
  /**
3
5
  * Deterministic chunker for a Schema v2 artifact.
4
6
  *
@@ -24,24 +26,40 @@ export function chunkSchema(sources, options = {}) {
24
26
  question: 0,
25
27
  concept: 0,
26
28
  relationship: 0,
29
+ "tenant-policy": 0,
27
30
  },
28
31
  sensitiveExcluded: 0,
29
32
  sensitiveIncluded: 0,
30
33
  };
31
34
  const { schema, tables: tableMarkdowns, concepts } = sources;
35
+ // Names whose mention marks describable text as sensitive, schema-wide
36
+ // (ADR 0017): every table's text, concept, and tenant policy section is
37
+ // checked against them.
38
+ const sensitiveMentions = createMentionMatcher(sensitiveMentionNames(schema));
32
39
  for (const table of schema.tables) {
40
+ if (table.tracked === false) {
41
+ continue;
42
+ }
33
43
  const md = tableMarkdowns[table.id];
34
- const sensitiveColumnNames = table.columns
35
- .filter((c) => c.sensitive)
36
- .map((c) => c.name);
37
44
  if (table.sensitive && !includeSensitive) {
38
45
  stats.sensitiveExcluded++;
39
46
  }
40
47
  else {
41
- chunks.push(buildTableChunk(table, schema.schemaId, includeSensitive, schema));
48
+ const filter = describableFilter(sensitiveMentions, includeSensitive);
49
+ const tableChunk = buildTableChunk(table, schema.schemaId, includeSensitive, schema, filter);
50
+ // A non-sensitive table whose description/aliases/column headlines
51
+ // mention a sensitive column is only embedded verbatim in opt-in mode.
52
+ if (filter.dropped)
53
+ stats.sensitiveExcluded++;
54
+ // Opt-in: a sensitive table's chunk is emitted only because of it.
55
+ if (table.sensitive || filter.included)
56
+ stats.sensitiveIncluded++;
57
+ chunks.push(tableChunk);
42
58
  }
43
59
  // Column chunks — one per column. Identifier + type always; describable
44
- // fields only when not sensitive (or when opted in).
60
+ // fields only when not sensitive (or when opted in). A non-sensitive
61
+ // column whose describable fields name a sensitive column is treated
62
+ // like a sensitive column's describable layer.
45
63
  for (const col of table.columns) {
46
64
  const colNote = readColumnNote(md, col.name);
47
65
  const colSensitive = col.sensitive || table.sensitive;
@@ -49,27 +67,46 @@ export function chunkSchema(sources, options = {}) {
49
67
  stats.sensitiveExcluded++;
50
68
  continue;
51
69
  }
52
- else if (colSensitive && includeSensitive) {
53
- if (col.description || (col.aliases && col.aliases.length) || (col.enum && col.enum.length) || colNote) {
54
- stats.sensitiveIncluded++;
55
- }
70
+ const describable = columnDescribableTexts(col, colNote);
71
+ if (colSensitive) {
72
+ // Opt-in mode: the default leaves this chunk out entirely.
73
+ stats.sensitiveIncluded++;
74
+ chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, true, true));
75
+ continue;
56
76
  }
57
- chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, includeSensitive));
77
+ const describableMentionsSensitive = describable.some((text) => sensitiveMentions.mentionsAny(text));
78
+ if (describableMentionsSensitive && !includeSensitive) {
79
+ // Keep the identifier + type; drop the whole describable layer.
80
+ stats.sensitiveExcluded++;
81
+ chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, false, false));
82
+ continue;
83
+ }
84
+ if (describableMentionsSensitive)
85
+ stats.sensitiveIncluded++;
86
+ chunks.push(buildColumnChunk(table, col, schema.schemaId, colNote, true, describableMentionsSensitive));
58
87
  }
59
88
  // CQL chunk — excluded entirely if the body mentions a sensitive column by name.
60
89
  if (table.commonQueryLanguage) {
61
- const mentionsSensitive = mentionsAnyName(table.commonQueryLanguage, sensitiveColumnNames);
90
+ const mentionsSensitive = sensitiveMentions.mentionsAny(table.commonQueryLanguage);
62
91
  const tableLevelSensitive = table.sensitive;
63
92
  const skip = (tableLevelSensitive || mentionsSensitive) && !includeSensitive;
93
+ // Counted per chunk: a long body splits into several.
94
+ const parts = splitLong(table.commonQueryLanguage, maxChars);
64
95
  if (skip) {
65
- stats.sensitiveExcluded++;
96
+ stats.sensitiveExcluded += parts.length;
66
97
  }
67
98
  else {
68
- if (tableLevelSensitive || mentionsSensitive) {
69
- stats.sensitiveIncluded++;
70
- }
71
- for (const part of splitLong(table.commonQueryLanguage, maxChars)) {
72
- chunks.push(buildCqlChunk(table, part.text, part.suffix, schema.schemaId, tableLevelSensitive || mentionsSensitive));
99
+ // The heading repeats the table's aliases; one naming a sensitive
100
+ // column is dropped like it is from the table chunk.
101
+ const filter = describableFilter(sensitiveMentions, includeSensitive);
102
+ const aliases = (table.aliases ?? []).filter((a) => filter.allow(a));
103
+ const sensitive = tableLevelSensitive || mentionsSensitive || filter.included;
104
+ if (filter.dropped)
105
+ stats.sensitiveExcluded += parts.length;
106
+ if (sensitive)
107
+ stats.sensitiveIncluded += parts.length;
108
+ for (const part of parts) {
109
+ chunks.push(buildCqlChunk(table, aliases, part.text, part.suffix, schema.schemaId, sensitive));
73
110
  }
74
111
  }
75
112
  }
@@ -77,8 +114,8 @@ export function chunkSchema(sources, options = {}) {
77
114
  if (md) {
78
115
  const questions = extractExampleQuestions(md);
79
116
  const businessContext = md.sections["Business context"]?.trim() ?? "";
80
- const questionsMentionSensitive = questions.some((q) => mentionsAnyName(q, sensitiveColumnNames));
81
- const bizMentionsSensitive = mentionsAnyName(businessContext, sensitiveColumnNames);
117
+ const questionsMentionSensitive = questions.some((q) => sensitiveMentions.mentionsAny(q));
118
+ const bizMentionsSensitive = sensitiveMentions.mentionsAny(businessContext);
82
119
  // Per-table sensitive gate: if table is sensitive OR mentions exist, skip
83
120
  // the *whole* describable layer for that table by default. Authoring rule
84
121
  // in the contract notes the chunker excludes whole chunks rather than
@@ -89,25 +126,34 @@ export function chunkSchema(sources, options = {}) {
89
126
  stats.sensitiveExcluded += questions.length;
90
127
  }
91
128
  else {
92
- if (table.sensitive || questionsMentionSensitive) {
129
+ // Every question heading repeats the primary entity; one naming a
130
+ // sensitive column is dropped like it is from the table chunk.
131
+ const filter = describableFilter(sensitiveMentions, includeSensitive);
132
+ const primaryEntity = filter.allow(table.primaryEntity) ? table.primaryEntity : undefined;
133
+ if (filter.dropped)
134
+ stats.sensitiveExcluded += questions.length;
135
+ // The questions are gated together, so in opt-in mode one naming a
136
+ // sensitive column makes every question chunk of the table sensitive.
137
+ const questionsSensitive = table.sensitive || questionsMentionSensitive || filter.included;
138
+ if (questionsSensitive)
93
139
  stats.sensitiveIncluded += questions.length;
94
- }
95
140
  questions.forEach((q, i) => {
96
- chunks.push(buildQuestionChunk(table, q, i + 1, schema.schemaId, table.sensitive || mentionsAnyName(q, sensitiveColumnNames)));
141
+ chunks.push(buildQuestionChunk(table, primaryEntity, q, i + 1, schema.schemaId, questionsSensitive));
97
142
  });
98
143
  }
99
144
  }
100
145
  // Business-context chunks are part of the describable layer; same gate.
101
146
  if (businessContext) {
102
147
  const skipBiz = (table.sensitive || bizMentionsSensitive) && !includeSensitive;
148
+ const parts = splitLong(businessContext, maxChars);
103
149
  if (skipBiz) {
104
- stats.sensitiveExcluded++;
150
+ stats.sensitiveExcluded += parts.length;
105
151
  }
106
152
  else {
107
153
  if (table.sensitive || bizMentionsSensitive) {
108
- stats.sensitiveIncluded++;
154
+ stats.sensitiveIncluded += parts.length;
109
155
  }
110
- for (const part of splitLong(businessContext, maxChars)) {
156
+ for (const part of parts) {
111
157
  chunks.push(buildBusinessContextChunk(table, part.text, part.suffix, schema.schemaId, table.sensitive || bizMentionsSensitive));
112
158
  }
113
159
  }
@@ -116,6 +162,9 @@ export function chunkSchema(sources, options = {}) {
116
162
  // Optional relationship chunks
117
163
  if (emitRelationships && table.relationships) {
118
164
  for (const rel of table.relationships) {
165
+ if (isUntrackedId(rel.from, schema) || isUntrackedId(rel.to, schema)) {
166
+ continue;
167
+ }
119
168
  const chunk = buildRelationshipChunk(table, rel, schema, schema.schemaId);
120
169
  if (chunk.sensitive && !includeSensitive) {
121
170
  stats.sensitiveExcluded++;
@@ -127,23 +176,44 @@ export function chunkSchema(sources, options = {}) {
127
176
  }
128
177
  }
129
178
  }
130
- // Concept chunks. Concepts that link to sensitive columns include link
131
- // metadata but their description is filtered if it names a sensitive col.
179
+ // Concept chunks. A concept that links to a sensitive column/table, or
180
+ // whose label/synonyms/description names a sensitive column (matched
181
+ // case-insensitively, like @askdb/enrich), is excluded by default.
132
182
  if (concepts?.frontmatter.concepts) {
133
- const allSensitiveColumnNames = collectSensitiveColumnNames(schema);
134
183
  for (const concept of concepts.frontmatter.concepts) {
135
- const conceptResult = buildConceptChunk(concept, schema.schemaId, schema, allSensitiveColumnNames, includeSensitive);
136
- if (conceptResult.excluded) {
184
+ const conceptResult = buildConceptChunk(concept, schema.schemaId, schema, sensitiveMentions);
185
+ if (conceptResult.sensitive && !includeSensitive) {
137
186
  stats.sensitiveExcluded++;
138
- if (includeSensitive) {
139
- stats.sensitiveIncluded++;
140
- chunks.push(conceptResult.chunk);
141
- }
187
+ continue;
142
188
  }
143
- else {
144
- if (conceptResult.sensitive)
145
- stats.sensitiveIncluded++;
146
- chunks.push(conceptResult.chunk);
189
+ if (conceptResult.sensitive)
190
+ stats.sensitiveIncluded++;
191
+ chunks.push(conceptResult.chunk);
192
+ }
193
+ }
194
+ // Tenant policy body chunks — one per H2 section. The policy is
195
+ // schema-level prose, so like concepts it is checked against every
196
+ // sensitive column in the schema; a section naming one is excluded by
197
+ // default.
198
+ if (sources.tenantPolicy) {
199
+ for (const section of tenantPolicySections(sources.tenantPolicy)) {
200
+ const sensitive = sensitiveMentions.mentionsAny(section.body);
201
+ const parts = splitLong(section.body, maxChars);
202
+ if (sensitive && !includeSensitive) {
203
+ stats.sensitiveExcluded += parts.length;
204
+ continue;
205
+ }
206
+ if (sensitive)
207
+ stats.sensitiveIncluded += parts.length;
208
+ for (const part of parts) {
209
+ chunks.push({
210
+ id: chunkId(schema.schemaId, `tenant-policy#${section.slug}${part.suffix}`),
211
+ type: "tenant-policy",
212
+ text: `${section.heading}\n${part.text}`,
213
+ schemaId: schema.schemaId,
214
+ refs: [],
215
+ sensitive,
216
+ });
147
217
  }
148
218
  }
149
219
  }
@@ -158,21 +228,28 @@ export function chunkSchema(sources, options = {}) {
158
228
  return { chunks, stats };
159
229
  }
160
230
  // ---------- chunk builders ----------
161
- function buildTableChunk(table, schemaId, includeSensitive, schema) {
231
+ function buildTableChunk(table, schemaId, includeSensitive, schema, filter) {
162
232
  const lines = [];
163
233
  const qualified = `${table.schema}.${table.name}`;
234
+ // Describable text that names a sensitive column is dropped by default and
235
+ // flags the chunk as sensitive when kept in opt-in mode.
236
+ const { allow } = filter;
164
237
  lines.push(`# ${qualified}`);
165
238
  if (!table.sensitive) {
166
- if (table.description)
239
+ if (allow(table.description))
167
240
  lines.push(table.description);
168
- if (table.aliases?.length)
169
- lines.push(`Aliases: ${table.aliases.join(", ")}`);
170
- if (table.primaryEntity)
241
+ const aliases = (table.aliases ?? []).filter((a) => allow(a));
242
+ if (aliases.length)
243
+ lines.push(`Aliases: ${aliases.join(", ")}`);
244
+ if (allow(table.primaryEntity))
171
245
  lines.push(`Primary entity: ${table.primaryEntity}`);
172
246
  }
173
247
  if (table.relationships?.length) {
174
248
  lines.push("Relationships:");
175
249
  for (const r of table.relationships) {
250
+ if (isUntrackedId(r.from, schema) || isUntrackedId(r.to, schema)) {
251
+ continue;
252
+ }
176
253
  if (!includeSensitive && (isSensitiveId(r.from, schema) || isSensitiveId(r.to, schema))) {
177
254
  continue;
178
255
  }
@@ -190,31 +267,36 @@ function buildTableChunk(table, schemaId, includeSensitive, schema) {
190
267
  flags.push("PK");
191
268
  flags.push(c.nullable ? "NULL" : "NOT NULL");
192
269
  const tag = c.sensitive || table.sensitive ? " (sensitive)" : "";
193
- const desc = !table.sensitive && !c.sensitive && c.description
270
+ const desc = !table.sensitive && !c.sensitive && allow(c.description)
194
271
  ? ` — ${c.description}`
195
272
  : "";
196
273
  lines.push(`- ${c.name} ${c.type} (${flags.join(" ")})${tag}${desc}`);
197
274
  }
198
275
  return {
199
- id: `chunk:${table.id}`,
276
+ id: chunkId(schemaId, table.id),
200
277
  type: "table",
201
278
  text: lines.join("\n").trim(),
202
279
  schemaId,
203
280
  refs: [table.id, ...referencedColumns],
204
- sensitive: table.sensitive,
281
+ sensitive: table.sensitive || filter.included,
205
282
  };
206
283
  }
207
- function buildColumnChunk(table, col, schemaId, columnNote, includeSensitive) {
208
- const sensitive = col.sensitive || table.sensitive;
284
+ /**
285
+ * @param includeDescribable emit description / aliases / enum values / note.
286
+ * @param describableSensitive the emitted describable layer references
287
+ * sensitive content (opt-in mode only); sets `chunk.sensitive`.
288
+ */
289
+ function buildColumnChunk(table, col, schemaId, columnNote, includeDescribable, describableSensitive) {
290
+ const columnSensitive = col.sensitive || table.sensitive;
209
291
  const flags = [];
210
292
  if (col.primaryKey)
211
293
  flags.push("PK");
212
294
  flags.push(col.nullable ? "NULL" : "NOT NULL");
213
295
  const lines = [];
214
296
  const qualified = `${table.schema}.${table.name}.${col.name}`;
215
- lines.push(`Column: ${qualified} ${col.type} (${flags.join(" ")})${sensitive ? " (sensitive)" : ""}`);
297
+ lines.push(`Column: ${qualified} ${col.type} (${flags.join(" ")})${columnSensitive ? " (sensitive)" : ""}`);
216
298
  lines.push(`Id: ${col.id}`);
217
- if (!sensitive || includeSensitive) {
299
+ if (includeDescribable) {
218
300
  if (col.description)
219
301
  lines.push(col.description);
220
302
  if (col.aliases?.length)
@@ -225,20 +307,30 @@ function buildColumnChunk(table, col, schemaId, columnNote, includeSensitive) {
225
307
  lines.push(`Note: ${columnNote}`);
226
308
  }
227
309
  return {
228
- id: `chunk:${col.id}`,
310
+ id: chunkId(schemaId, col.id),
229
311
  type: "column",
230
312
  text: lines.join("\n").trim(),
231
313
  schemaId,
232
314
  refs: [table.id, col.id],
233
- sensitive,
315
+ sensitive: columnSensitive || (includeDescribable && describableSensitive),
234
316
  };
235
317
  }
236
- function buildCqlChunk(table, body, suffix, schemaId, sensitive) {
237
- const aliasNote = table.aliases?.length
238
- ? ` (also: ${table.aliases.join(", ")})`
239
- : "";
318
+ function columnDescribableTexts(col, columnNote) {
319
+ const out = [];
320
+ if (col.description)
321
+ out.push(col.description);
322
+ if (col.aliases?.length)
323
+ out.push(...col.aliases);
324
+ if (col.enum?.length)
325
+ out.push(...col.enum.map(String));
326
+ if (columnNote)
327
+ out.push(columnNote);
328
+ return out;
329
+ }
330
+ function buildCqlChunk(table, aliases, body, suffix, schemaId, sensitive) {
331
+ const aliasNote = aliases.length ? ` (also: ${aliases.join(", ")})` : "";
240
332
  const text = `# ${table.schema}.${table.name}${aliasNote} — common query language\n${body.trim()}`;
241
- const id = `chunk:${table.id}#cql${suffix}`;
333
+ const id = chunkId(schemaId, `${table.id}#cql${suffix}`);
242
334
  return {
243
335
  id,
244
336
  type: "cql",
@@ -248,11 +340,11 @@ function buildCqlChunk(table, body, suffix, schemaId, sensitive) {
248
340
  sensitive,
249
341
  };
250
342
  }
251
- function buildQuestionChunk(table, question, index, schemaId, sensitive) {
252
- const entity = table.primaryEntity ? ` [${table.primaryEntity}]` : "";
343
+ function buildQuestionChunk(table, primaryEntity, question, index, schemaId, sensitive) {
344
+ const entity = primaryEntity ? ` [${primaryEntity}]` : "";
253
345
  const text = `# ${table.schema}.${table.name}${entity} — example question\n${question.trim()}`;
254
346
  return {
255
- id: `chunk:${table.id}#q:${index}`,
347
+ id: chunkId(schemaId, `${table.id}#q:${index}`),
256
348
  type: "question",
257
349
  text,
258
350
  schemaId,
@@ -263,7 +355,7 @@ function buildQuestionChunk(table, question, index, schemaId, sensitive) {
263
355
  function buildBusinessContextChunk(table, body, suffix, schemaId, sensitive) {
264
356
  const text = `# ${table.schema}.${table.name} — business context\n${body.trim()}`;
265
357
  return {
266
- id: `chunk:${table.id}#biz${suffix}`,
358
+ id: chunkId(schemaId, `${table.id}#biz${suffix}`),
267
359
  type: "table",
268
360
  text,
269
361
  schemaId,
@@ -279,9 +371,13 @@ function buildRelationshipChunk(table, rel, schema, schemaId) {
279
371
  const fromName = fromTable ? `${fromTable.schema}.${fromTable.name}` : fromCol.tableId;
280
372
  const toName = toTable ? `${toTable.schema}.${toTable.name}` : toCol.tableId;
281
373
  const text = `Relationship: ${fromName}.${fromCol.column} references ${toName}.${toCol.column}`;
282
- const sensitive = (fromTable?.sensitive ?? false) || (toTable?.sensitive ?? false);
374
+ // Sensitive when either side's table *or column* is sensitive.
375
+ const sensitive = (fromTable?.sensitive ?? false) ||
376
+ (toTable?.sensitive ?? false) ||
377
+ isSensitiveId(rel.from, schema) ||
378
+ isSensitiveId(rel.to, schema);
283
379
  return {
284
- id: `chunk:${rel.from}->${rel.to}`,
380
+ id: chunkId(schemaId, `${rel.from}->${rel.to}`),
285
381
  type: "relationship",
286
382
  text,
287
383
  schemaId,
@@ -289,36 +385,81 @@ function buildRelationshipChunk(table, rel, schema, schemaId) {
289
385
  sensitive,
290
386
  };
291
387
  }
292
- function buildConceptChunk(concept, schemaId, schema, allSensitiveColumnNames, includeSensitive) {
293
- const linkedSensitive = (concept.links ?? []).some((id) => isSensitiveId(id, schema));
294
- const descriptionMentionsSensitive = concept.description
295
- ? mentionsAnyName(concept.description, allSensitiveColumnNames)
296
- : false;
388
+ function buildConceptChunk(concept, schemaId, schema, sensitiveMentions) {
389
+ const links = (concept.links ?? []).filter((id) => !isUntrackedId(id, schema));
390
+ const linkedSensitive = links.some((id) => isSensitiveId(id, schema));
391
+ const textMentionsSensitive = [
392
+ concept.label,
393
+ ...(concept.synonyms ?? []),
394
+ concept.description ?? "",
395
+ ].some((text) => sensitiveMentions.mentionsAny(text));
396
+ const sensitive = linkedSensitive || textMentionsSensitive;
297
397
  const lines = [];
298
398
  lines.push(`# Concept: ${concept.label}`);
299
399
  lines.push(`Id: ${concept.id}`);
300
400
  if (concept.synonyms?.length)
301
401
  lines.push(`Synonyms: ${concept.synonyms.join(", ")}`);
302
- if (concept.links?.length)
303
- lines.push(`Links: ${concept.links.join(", ")}`);
304
- const excludeForSensitive = (linkedSensitive || descriptionMentionsSensitive) && !includeSensitive;
305
- if (concept.description && (!descriptionMentionsSensitive || includeSensitive)) {
402
+ if (links.length)
403
+ lines.push(`Links: ${links.join(", ")}`);
404
+ if (concept.description)
306
405
  lines.push(concept.description);
307
- }
308
406
  return {
309
407
  chunk: {
310
- id: `chunk:${concept.id}`,
408
+ id: chunkId(schemaId, concept.id),
311
409
  type: "concept",
312
410
  text: lines.join("\n").trim(),
313
411
  schemaId,
314
- refs: [concept.id, ...(concept.links ?? [])],
315
- sensitive: linkedSensitive || descriptionMentionsSensitive,
412
+ refs: [concept.id, ...links],
413
+ sensitive,
414
+ },
415
+ sensitive,
416
+ };
417
+ }
418
+ /** Tenant-policy sections to chunk: one per non-empty H2, else the whole body. */
419
+ function tenantPolicySections(tenantPolicy) {
420
+ const out = [];
421
+ for (const [sectionName, sectionBody] of Object.entries(tenantPolicy.sections)) {
422
+ if (!sectionBody?.trim())
423
+ continue;
424
+ out.push({
425
+ slug: sectionName.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
426
+ heading: `# Tenant policy — ${sectionName}`,
427
+ body: sectionBody.trim(),
428
+ });
429
+ }
430
+ if (out.length === 0 && tenantPolicy.body.trim()) {
431
+ out.push({ slug: "body", heading: "# Tenant policy", body: tenantPolicy.body.trim() });
432
+ }
433
+ return out;
434
+ }
435
+ /**
436
+ * Per-chunk gate for describable text (descriptions, aliases, primary
437
+ * entity) that the chunker drops piece by piece instead of excluding the
438
+ * whole chunk.
439
+ */
440
+ function describableFilter(sensitiveMentions, includeSensitive) {
441
+ let dropped = false;
442
+ let included = false;
443
+ return {
444
+ allow(text) {
445
+ if (!text)
446
+ return false;
447
+ if (!sensitiveMentions.mentionsAny(text))
448
+ return true;
449
+ if (includeSensitive)
450
+ included = true;
451
+ else
452
+ dropped = true;
453
+ return includeSensitive;
454
+ },
455
+ get dropped() {
456
+ return dropped;
457
+ },
458
+ get included() {
459
+ return included;
316
460
  },
317
- sensitive: linkedSensitive || descriptionMentionsSensitive,
318
- excluded: excludeForSensitive,
319
461
  };
320
462
  }
321
- // ---------- helpers ----------
322
463
  function readColumnNote(md, columnName) {
323
464
  const body = md?.sections["Column notes"];
324
465
  if (!body)
@@ -351,20 +492,6 @@ function extractExampleQuestions(md) {
351
492
  }
352
493
  return out;
353
494
  }
354
- function mentionsAnyName(text, names) {
355
- if (!text || names.length === 0)
356
- return false;
357
- for (const name of names) {
358
- // Word-boundary match (also matches when wrapped in backticks).
359
- const pattern = new RegExp(`(^|[^a-zA-Z0-9_])${escapeRegex(name)}([^a-zA-Z0-9_]|$)`);
360
- if (pattern.test(text))
361
- return true;
362
- }
363
- return false;
364
- }
365
- function escapeRegex(s) {
366
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
367
- }
368
495
  /** Split a long body on paragraph boundaries; suffix is `""` for single-chunk, `#bc:N` (1-indexed) otherwise. */
369
496
  function splitLong(body, maxChars) {
370
497
  const trimmed = body.trim();
@@ -402,16 +529,6 @@ function parseColumnId(columnId) {
402
529
  column: columnId.slice(hashIdx + 1),
403
530
  };
404
531
  }
405
- function collectSensitiveColumnNames(schema) {
406
- const names = new Set();
407
- for (const t of schema.tables) {
408
- for (const c of t.columns) {
409
- if (c.sensitive || t.sensitive)
410
- names.add(c.name);
411
- }
412
- }
413
- return Array.from(names);
414
- }
415
532
  function isSensitiveId(id, schema) {
416
533
  for (const t of schema.tables) {
417
534
  if (t.id === id)
@@ -423,4 +540,9 @@ function isSensitiveId(id, schema) {
423
540
  }
424
541
  return false;
425
542
  }
543
+ function isUntrackedId(id, schema) {
544
+ const tableId = id.includes("#") ? parseColumnId(id).tableId : id;
545
+ const table = schema.tables.find((t) => t.id === tableId);
546
+ return table?.tracked === false;
547
+ }
426
548
  //# sourceMappingURL=chunker.js.map