@panaversity/ksor 0.0.14 → 0.0.16
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/CHANGELOG.md +172 -0
- package/dist/cli.mjs +119 -24
- package/package.json +1 -1
- package/templates/scaffold/system/site/lib/denial-rule.ts +9 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,177 @@
|
|
|
1
1
|
# @panaversity/ksor
|
|
2
2
|
|
|
3
|
+
## 0.0.16
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 144aba8: Ingest says when a document's ordering key is one this record does not read
|
|
8
|
+
|
|
9
|
+
A record's reading order comes from the governed `order:` key alone. A corpus
|
|
10
|
+
arriving from Docusaurus, Hugo or Jekyll carries its own — `sidebar_position`,
|
|
11
|
+
`weight`, `nav_order` — and ksor ignored them in silence, falling back to file
|
|
12
|
+
name. That is a WRONG order, not a missing one, and it is the order served to
|
|
13
|
+
`llms.txt`, the rendered sidebar and the MCP `outline` alike.
|
|
14
|
+
|
|
15
|
+
Found on a real 81-document book where 73 files declared `sidebar_position`. Its
|
|
16
|
+
second chapter came out ninth; its preface came out eleventh. Nothing said why.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
plain-tree: 73 document(s) declare `sidebar_position`, which this record does not
|
|
20
|
+
read — reading order fell back to file name (about.md, how-to-sell.md,
|
|
21
|
+
thesis.md, and 70 more). Rename it to `order:` to keep the intended sequence.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
It reports on the same channel the adapter already uses for skipped files, where
|
|
25
|
+
the principle was already written down: a skip is reported, never silent. A
|
|
26
|
+
document that declares BOTH keys says nothing — `order:` wins, so nothing fell
|
|
27
|
+
back, and a warning there would only teach the reader to ignore the channel.
|
|
28
|
+
|
|
29
|
+
- 2e9c987: Ingest says what the navigation rule now is, not what it used to be
|
|
30
|
+
|
|
31
|
+
0.0.15 changed how a section is judged to be navigation — shape rather than
|
|
32
|
+
length — and left every sentence describing it behind. So a fresh `ksor ingest`
|
|
33
|
+
reported:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
not searchable: 1 of 5 chunk(s) (20%) are shorter than the navigation threshold
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
There is no navigation threshold any more, and the page in question was not
|
|
40
|
+
short: it was an index of links, which is exactly what the rule now catches. The
|
|
41
|
+
remedy was wrong in the same way — "lengthen these sections" is no longer how a
|
|
42
|
+
page becomes searchable, and padding a link list would not have made it one.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
not searchable: 1 of 5 chunk(s) (20%) read as navigation rather than content
|
|
46
|
+
FOUND ONLY BY NAME: knowledge/index — no searchable chunk at all; a page of
|
|
47
|
+
links reads as navigation; give it prose of its own, or reach it by slug
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Found by running the published artifact rather than by reading the diff. The
|
|
51
|
+
same stale description was corrected in the three other places it had been
|
|
52
|
+
copied to.
|
|
53
|
+
|
|
54
|
+
- 1e26c07: A YAML list in frontmatter no longer costs the document its title
|
|
55
|
+
|
|
56
|
+
The frontmatter reader emptied a document's ENTIRE metadata whenever a top-level
|
|
57
|
+
value opened with `[ { | > & * !`. One `authors: ["…"]` line beside the title,
|
|
58
|
+
and the title went with it — along with `order:` and `sor_id:`.
|
|
59
|
+
|
|
60
|
+
Found on a real 81-document book, where four chapters were served under names
|
|
61
|
+
derived from their filenames:
|
|
62
|
+
|
|
63
|
+
| served as | declared |
|
|
64
|
+
| ---------------------------- | --------------------------------------------------------------- |
|
|
65
|
+
| `Preface Agent Native` | `Preface: The Right Side of the Line` |
|
|
66
|
+
| `System Of Context` | `The System of Context: Connecting the Records to Real Work` |
|
|
67
|
+
| `Designing The Vertical Sor` | `Designing the Vertical System of Record from First Principles` |
|
|
68
|
+
|
|
69
|
+
Titles reach the site, `llms.txt` and the MCP `outline`, so this was wrong on
|
|
70
|
+
every surface at once, and silently.
|
|
71
|
+
|
|
72
|
+
The reader is documented as PyYAML-compatible and empties the map only where
|
|
73
|
+
PyYAML raises. PyYAML does not raise on a flow sequence — it parses it. Two
|
|
74
|
+
different things were being conflated:
|
|
75
|
+
|
|
76
|
+
- **invalid** — an unquoted `a: b: c`, a trailing `:`. PyYAML raises; the map is
|
|
77
|
+
still emptied, unchanged.
|
|
78
|
+
- **valid but not modelled here** — a flow sequence or mapping, a block scalar,
|
|
79
|
+
an anchor. PyYAML parses these. Only the KEY is beyond the reader now; the
|
|
80
|
+
document survives.
|
|
81
|
+
|
|
82
|
+
**One identity change to know about.** A document that declares `sor_id:`
|
|
83
|
+
_alongside_ such a value previously had that override silently dropped, so its
|
|
84
|
+
stable_id fell back to the path. The override now stands, on both surfaces
|
|
85
|
+
together — so re-ingesting changes the stable_id of exactly those documents, and
|
|
86
|
+
any takedown row keyed on the old path-derived id must be re-pointed. The site
|
|
87
|
+
and the kernel change in step, which is the property `stable-id-conformance`
|
|
88
|
+
exists to hold.
|
|
89
|
+
|
|
90
|
+
One governance guard gets quieter and no weaker: ingest used to REFUSE a
|
|
91
|
+
document declaring `visibility:` beside a flow list, because the map was emptied
|
|
92
|
+
and the tier silently defaulted. The cause is gone, so it ingests with the right
|
|
93
|
+
visibility; the refusal still stands for frontmatter PyYAML genuinely rejects.
|
|
94
|
+
|
|
95
|
+
- d4334c7: A quiz no longer swallows the explanation that precedes it
|
|
96
|
+
|
|
97
|
+
The previous release moved navigation from a length test to a shape test, so a
|
|
98
|
+
short fact stopped being mistaken for a link list. The rule that decides whether
|
|
99
|
+
a whole section is _a widget_ — a quiz, a slide embed — was left on the old
|
|
100
|
+
threshold: under 250 characters of teaching before the widget, and the entire
|
|
101
|
+
section was labelled `assessment` or `embed`, neither of which any search
|
|
102
|
+
returns.
|
|
103
|
+
|
|
104
|
+
So a section carrying a complete 180-character explanation followed by a
|
|
105
|
+
knowledge check lost the explanation too. Same defect as the last one, one path
|
|
106
|
+
over.
|
|
107
|
+
|
|
108
|
+
Both paths now ask the same question: is what comes BEFORE the widget actually
|
|
109
|
+
navigation-shaped? A heading with only a quiz under it is still a quiz. A link
|
|
110
|
+
list before a quiz is still a quiz. An explanation before a quiz is an
|
|
111
|
+
explanation, and stays searchable.
|
|
112
|
+
|
|
113
|
+
Found by ingesting a real 81-document curriculum corpus, where 610 chunks landed
|
|
114
|
+
as `assessment` and 186 as `embed` — together 79% of everything unsearchable in
|
|
115
|
+
that record.
|
|
116
|
+
|
|
117
|
+
`CHUNK_POLICY` moves to v7 (persisted provenance; the labels it names changed),
|
|
118
|
+
and `NAV_MAX_CHARS` is deleted — nothing reads it now. **Re-run `ksor ingest` to
|
|
119
|
+
pick this up**; unchanged content is not re-embedded.
|
|
120
|
+
|
|
121
|
+
## 0.0.15
|
|
122
|
+
|
|
123
|
+
### Patch Changes
|
|
124
|
+
|
|
125
|
+
- 5b076e6: Short documents reach search again — navigation is a shape, not a length
|
|
126
|
+
|
|
127
|
+
A record could be fully ingested, report "embedded 16, failed 0", and still be
|
|
128
|
+
unable to answer questions it plainly contained. Sections were classified as
|
|
129
|
+
navigation by LENGTH — anything under 250 characters — and navigation is
|
|
130
|
+
excluded from every retrieval arm. On a handbook that inverts the intent,
|
|
131
|
+
because a handbook's most valuable statements are its shortest.
|
|
132
|
+
|
|
133
|
+
Walked on 0.0.14 with three ordinary policy statements — a refund window, an
|
|
134
|
+
escalation path, a badge rule, 200-300 characters each. Three of four chunks
|
|
135
|
+
were unsearchable, and:
|
|
136
|
+
|
|
137
|
+
> **Q.** "how long does a buyer have to send something back"
|
|
138
|
+
> **A.** the scaffold's placeholder page — against a record stating _thirty days_
|
|
139
|
+
|
|
140
|
+
The answer was in the corpus, correctly ingested, readable by slug, and
|
|
141
|
+
unreachable by search.
|
|
142
|
+
|
|
143
|
+
Navigation is now decided by shape: a section is navigation when link lines are
|
|
144
|
+
most of it, or when what remains after them is too short to answer anything —
|
|
145
|
+
the same floor the serving predicate already applies. Length is no longer
|
|
146
|
+
consulted, so a 180-character link list is navigation and a 51-character fact is
|
|
147
|
+
not, which is the ordering length had backwards.
|
|
148
|
+
|
|
149
|
+
Measured on an authored handbook gold set with real embeddings, paired: short
|
|
150
|
+
substantive facts went **0/9 to 9/9 at rank 1**, the long-prose control held at
|
|
151
|
+
**4/4**, and the link-list page was returned **0** times. That last number is the
|
|
152
|
+
one that matters — admitting everything would have improved the first two and
|
|
153
|
+
made the product worse.
|
|
154
|
+
|
|
155
|
+
**To pick this up, re-run `ksor ingest`.** Chunks are re-classified on every
|
|
156
|
+
build and unchanged content is not re-embedded, so the upgrade costs a build,
|
|
157
|
+
not an embedding bill. `CHUNK_POLICY` moves to v6 because it is persisted
|
|
158
|
+
provenance and the behaviour it labels has changed.
|
|
159
|
+
|
|
160
|
+
- 5763e8b: Internal: a pool test that raced Postgres, and a comment that had it backwards
|
|
161
|
+
|
|
162
|
+
No adopter-visible behaviour changes.
|
|
163
|
+
|
|
164
|
+
`idle.db.test.ts` sampled `pg_stat_activity` immediately after a previous test's
|
|
165
|
+
`pool.end()`. Those are two different clocks — `end()` resolves when the client
|
|
166
|
+
socket closes, while the row disappears only once the server-side backend
|
|
167
|
+
actually exits — so the suite was order-coupled through the database and went
|
|
168
|
+
red in CI on a branch that changed nothing but a document. Each test now waits
|
|
169
|
+
for a quiet database before it starts, and states that it does.
|
|
170
|
+
|
|
171
|
+
The comment added in the previous release explaining the `env.example` guard fix
|
|
172
|
+
described the rename backwards: the TEMPLATE holds `env.example` and
|
|
173
|
+
`materialize.ts` maps it to `.env.example` on emit, not the other way round.
|
|
174
|
+
|
|
3
175
|
## 0.0.14
|
|
4
176
|
|
|
5
177
|
### Patch Changes
|
package/dist/cli.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
16
16
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
17
|
import { parseArgs } from "node:util";
|
|
18
18
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
19
|
-
//#region ../content-gateway/dist/main-
|
|
19
|
+
//#region ../content-gateway/dist/main-BFWvQ6vY.mjs
|
|
20
20
|
/**
|
|
21
21
|
* A connection could not be ESTABLISHED in time — retryable.
|
|
22
22
|
*
|
|
@@ -430,7 +430,7 @@ const EMBED_DIM$1 = 1536;
|
|
|
430
430
|
const EMBED_TASK_DOCUMENT$1 = "RETRIEVAL_DOCUMENT";
|
|
431
431
|
const EMBED_TASK_QUERY$1 = "RETRIEVAL_QUERY";
|
|
432
432
|
/** bump ⇒ provenance (v5: CommonMark fences). All char limits count CODE POINTS (Python len parity). */
|
|
433
|
-
const CHUNK_POLICY$1 = "heading-aware-1500-content-only-
|
|
433
|
+
const CHUNK_POLICY$1 = "heading-aware-1500-content-only-v7";
|
|
434
434
|
/**
|
|
435
435
|
* The kernel's view of `instance.md` (adapted from oracle SC/instance.py
|
|
436
436
|
* under decision 11 — an adaptation, not a port: ksor has ONE instance file
|
|
@@ -4452,7 +4452,7 @@ async function withPgRetry(op, options = {}) {
|
|
|
4452
4452
|
throw lastError;
|
|
4453
4453
|
}
|
|
4454
4454
|
//#endregion
|
|
4455
|
-
//#region ../content/dist/commands-
|
|
4455
|
+
//#region ../content/dist/commands-B3-NrLiw.mjs
|
|
4456
4456
|
/**
|
|
4457
4457
|
* EVAL-LOCKED constants, quarried verbatim from the oracle
|
|
4458
4458
|
* (sor-agentfactory @ b554f91, config.py) — changing any of these is a
|
|
@@ -4467,7 +4467,7 @@ const EMBED_DIM = 1536;
|
|
|
4467
4467
|
const EMBED_TASK_DOCUMENT = "RETRIEVAL_DOCUMENT";
|
|
4468
4468
|
const EMBED_TASK_QUERY = "RETRIEVAL_QUERY";
|
|
4469
4469
|
/** bump ⇒ provenance (v5: CommonMark fences). All char limits count CODE POINTS (Python len parity). */
|
|
4470
|
-
const CHUNK_POLICY = "heading-aware-1500-content-only-
|
|
4470
|
+
const CHUNK_POLICY = "heading-aware-1500-content-only-v7";
|
|
4471
4471
|
const MAX_CHARS = 1500;
|
|
4472
4472
|
/** < Gemini's 2048-token embed input. */
|
|
4473
4473
|
const HARD_MAX_CHARS = 4e3;
|
|
@@ -7004,6 +7004,17 @@ function buildManifestFromTree(root, options) {
|
|
|
7004
7004
|
const files = [];
|
|
7005
7005
|
const sources = /* @__PURE__ */ new Map();
|
|
7006
7006
|
const skipped = [];
|
|
7007
|
+
/** foreign ordering key -> the documents that declare it and no `order:`. */
|
|
7008
|
+
const foreignOrder = /* @__PURE__ */ new Map();
|
|
7009
|
+
const noteForeignOrder = (meta, path) => {
|
|
7010
|
+
if (meta["order"] !== void 0 && meta["order"] !== null) return;
|
|
7011
|
+
for (const key of FOREIGN_ORDER_KEYS) {
|
|
7012
|
+
if (meta[key] === void 0 || meta[key] === null) continue;
|
|
7013
|
+
const seen = foreignOrder.get(key) ?? [];
|
|
7014
|
+
seen.push(path);
|
|
7015
|
+
foreignOrder.set(key, seen);
|
|
7016
|
+
}
|
|
7017
|
+
};
|
|
7007
7018
|
const fullPath = (relSegs, name) => `${rootPath}/${[...relSegs, name].join("/")}`;
|
|
7008
7019
|
const addFile = (nodeSid, fileSegs) => {
|
|
7009
7020
|
const rel = fileSegs.join("/");
|
|
@@ -7028,8 +7039,10 @@ function buildManifestFromTree(root, options) {
|
|
|
7028
7039
|
continue;
|
|
7029
7040
|
}
|
|
7030
7041
|
if (INDEX_NAMES.includes(f.name)) continue;
|
|
7042
|
+
const fileMeta = frontmatterMeta(f.text);
|
|
7043
|
+
noteForeignOrder(fileMeta, fullPath(relSegs, f.name));
|
|
7031
7044
|
ordered.push({
|
|
7032
|
-
order: orderValue(
|
|
7045
|
+
order: orderValue(fileMeta["order"]),
|
|
7033
7046
|
tie: tieKey(f.name),
|
|
7034
7047
|
entry: f
|
|
7035
7048
|
});
|
|
@@ -7041,6 +7054,7 @@ function buildManifestFromTree(root, options) {
|
|
|
7041
7054
|
}
|
|
7042
7055
|
const index = indexOf(d, fullPath(relSegs, d.name));
|
|
7043
7056
|
const dirMeta = index === null ? {} : frontmatterMeta(index.text);
|
|
7057
|
+
if (index !== null) noteForeignOrder(dirMeta, fullPath(relSegs, `${d.name}/${index.name}`));
|
|
7044
7058
|
ordered.push({
|
|
7045
7059
|
order: orderValue(dirMeta["order"]),
|
|
7046
7060
|
tie: tieKey(d.name),
|
|
@@ -7100,6 +7114,12 @@ function buildManifestFromTree(root, options) {
|
|
|
7100
7114
|
}
|
|
7101
7115
|
walk(root, [], null);
|
|
7102
7116
|
for (const s of skipped) onSkip(`plain-tree: skipped ${s}`);
|
|
7117
|
+
for (const [key, paths] of foreignOrder) {
|
|
7118
|
+
const rel = paths.map((x) => x.startsWith(`${rootPath}/`) ? x.slice(rootPath.length + 1) : x);
|
|
7119
|
+
const shown = rel.slice(0, 3).join(", ");
|
|
7120
|
+
const more = rel.length - Math.min(3, rel.length);
|
|
7121
|
+
onSkip(`plain-tree: ${rel.length} document(s) declare \`${key}\`, which this record does not read — reading order fell back to file name (${shown}${more > 0 ? `, and ${more} more` : ""}). Rename it to \`order:\` to keep the intended sequence.`);
|
|
7122
|
+
}
|
|
7103
7123
|
if (files.length === 0) throw new ManifestError(`plain-tree root ${rootPath} contains no Markdown`);
|
|
7104
7124
|
const manifest = {
|
|
7105
7125
|
format: 1,
|
|
@@ -7186,6 +7206,22 @@ function codePointCompare(a, b) {
|
|
|
7186
7206
|
}
|
|
7187
7207
|
return as.length - bs.length;
|
|
7188
7208
|
}
|
|
7209
|
+
/**
|
|
7210
|
+
* Ordering keys OTHER ecosystems read, which this record does not.
|
|
7211
|
+
*
|
|
7212
|
+
* Reading order here is the governed `order:` key alone (decision 9 retired the
|
|
7213
|
+
* predecessor's Docusaurus keys; the MCP door had been reading them). But a
|
|
7214
|
+
* corpus arriving from Docusaurus, Hugo or Jekyll carries its own, and ignoring
|
|
7215
|
+
* one silently produces a WRONG order rather than a missing one — filename
|
|
7216
|
+
* order, served to `llms.txt`, the sidebar and the `outline` tool alike. Found
|
|
7217
|
+
* on a real 81-document book where 73 files declared `sidebar_position` (#74).
|
|
7218
|
+
*/
|
|
7219
|
+
const FOREIGN_ORDER_KEYS = [
|
|
7220
|
+
"sidebar_position",
|
|
7221
|
+
"position",
|
|
7222
|
+
"weight",
|
|
7223
|
+
"nav_order"
|
|
7224
|
+
];
|
|
7189
7225
|
/** Re-exported so every reader of a document agrees where its frontmatter ENDS. */
|
|
7190
7226
|
const FRONTMATTER$1 = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/;
|
|
7191
7227
|
const YAML_BOOLS = {
|
|
@@ -7209,8 +7245,12 @@ const YAML_BOOLS = {
|
|
|
7209
7245
|
OFF: false
|
|
7210
7246
|
};
|
|
7211
7247
|
/**
|
|
7212
|
-
* Minimal PyYAML-compatible frontmatter reader
|
|
7213
|
-
* adapter consumes
|
|
7248
|
+
* Minimal PyYAML-compatible frontmatter reader. It parses every top-level
|
|
7249
|
+
* scalar; the adapter consumes `title`, `order` and `sor_id`, and reads the rest
|
|
7250
|
+
* only to WARN about them (see FOREIGN_ORDER_KEYS). The wording here named
|
|
7251
|
+
* `position` and `sidebar_position` until now, which is what this adapter read
|
|
7252
|
+
* before ordering became one governed key — the keys it names are the ones it
|
|
7253
|
+
* stopped reading. The
|
|
7214
7254
|
* kernel discards every other frontmatter key at build time (taxonomy comes
|
|
7215
7255
|
* from the manifest), so a YAML dependency would buy nothing (guard rule 5).
|
|
7216
7256
|
* Scope, deliberately narrow pending a shared markdown module: top-level
|
|
@@ -7268,11 +7308,11 @@ function scalarValue(raw) {
|
|
|
7268
7308
|
ok: true,
|
|
7269
7309
|
value: Number.parseFloat(plain.replaceAll("_", ""))
|
|
7270
7310
|
};
|
|
7271
|
-
if (
|
|
7272
|
-
ok:
|
|
7311
|
+
if (/^[|>&*!{[]/.test(plain)) return {
|
|
7312
|
+
ok: true,
|
|
7273
7313
|
value: null
|
|
7274
7314
|
};
|
|
7275
|
-
if (
|
|
7315
|
+
if (/:[ \t]/.test(plain) || plain.endsWith(":")) return {
|
|
7276
7316
|
ok: false,
|
|
7277
7317
|
value: null
|
|
7278
7318
|
};
|
|
@@ -7882,23 +7922,78 @@ function cleanBody(rawBody) {
|
|
|
7882
7922
|
function teachingBody(content) {
|
|
7883
7923
|
return pyStrip(pySplitLines(content, false).filter((ln) => !HEADING.test(ln)).join("\n"));
|
|
7884
7924
|
}
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7925
|
+
/**
|
|
7926
|
+
* A line that is navigation rather than prose: strip its list marker and what
|
|
7927
|
+
* remains is nothing but links.
|
|
7928
|
+
*
|
|
7929
|
+
* Deliberately narrow. A line with prose AROUND a link ("Claim within thirty
|
|
7930
|
+
* days; see the [expenses page](x) to file.") is prose, because the sentence is
|
|
7931
|
+
* the content and the link is incidental.
|
|
7932
|
+
*/
|
|
7933
|
+
const NAV_LINE = /^(?:[-*+]\s+|\d+[.)]\s+)?(?:\[[^\]]*\]\([^)]*\)|<https?:\/\/[^>]*>|https?:\/\/\S+)(?:[\s,;·|>—–-]*(?:\[[^\]]*\]\([^)]*\)|<https?:\/\/[^>]*>|https?:\/\/\S+))*[\s.,;:]*$/;
|
|
7934
|
+
/**
|
|
7935
|
+
* Is this segment NAVIGATION — a thing that points at content rather than
|
|
7936
|
+
* being content?
|
|
7937
|
+
*
|
|
7938
|
+
* The oracle answered this with length: under 250 code points meant nav.
|
|
7939
|
+
* On the curriculum corpus it was tuned against, that proxy holds — a short
|
|
7940
|
+
* segment there really is a link list. On a handbook it inverts, because a
|
|
7941
|
+
* handbook's most valuable statements are its shortest ("Six months, with a
|
|
7942
|
+
* written review at three and six"), and `nav` is excluded from search. Issue
|
|
7943
|
+
* #55, walked live on 0.0.14: three of four chunks in an ordinary policy
|
|
7944
|
+
* record were unsearchable, and a question the record plainly answered was
|
|
7945
|
+
* served the scaffold's placeholder instead.
|
|
7946
|
+
*
|
|
7947
|
+
* So the question is asked about SHAPE, which is what "navigation" always
|
|
7948
|
+
* meant. A segment is nav when link lines are most of it, or when what is left
|
|
7949
|
+
* after them is too little to answer anything (MIN_CONTENT_CHARS — the same
|
|
7950
|
+
* floor the serving predicate applies, so this never labels `prose` something
|
|
7951
|
+
* search would refuse to return anyway).
|
|
7952
|
+
*
|
|
7953
|
+
* Length is no longer consulted. A 180-character link list is nav and a
|
|
7954
|
+
* 51-character fact is prose, which is the ordering length got backwards.
|
|
7955
|
+
*/
|
|
7956
|
+
function isNavShaped(content) {
|
|
7957
|
+
const lines = pySplitLines(teachingBody(content), false).map((ln) => pyStrip(ln)).filter((ln) => ln !== "");
|
|
7958
|
+
if (lines.length === 0) return true;
|
|
7959
|
+
if (lines.filter((ln) => NAV_LINE.test(ln)).length * 2 > lines.length) return true;
|
|
7960
|
+
return cpLen(lines.filter((ln) => !NAV_LINE.test(ln)).join(" ")) < 24;
|
|
7891
7961
|
}
|
|
7892
|
-
/**
|
|
7893
|
-
*
|
|
7894
|
-
*
|
|
7895
|
-
|
|
7962
|
+
/**
|
|
7963
|
+
* Does a line-leading widget DOMINATE this span?
|
|
7964
|
+
*
|
|
7965
|
+
* The widget regexes match an opening tag only, so the tag's position is where
|
|
7966
|
+
* teaching stops and markup begins. The question is therefore about what comes
|
|
7967
|
+
* BEFORE it: if that is navigation-shaped, the span is the widget; if it is real
|
|
7968
|
+
* explanation, the widget is a minority of a teaching passage.
|
|
7969
|
+
*
|
|
7970
|
+
* This used to be a length test — 250 characters of teaching body before the
|
|
7971
|
+
* widget and the whole span became `assessment`, which no retrieval arm returns.
|
|
7972
|
+
* #55 moved navigation from length to shape and left this path behind, so a
|
|
7973
|
+
* section carrying 180 characters of real explanation before a `<Quiz>` lost the
|
|
7974
|
+
* explanation with it (issue #75).
|
|
7975
|
+
*/
|
|
7976
|
+
function dominantWidget(span) {
|
|
7896
7977
|
for (const [re, label] of [[JSX_ASSESS, "assessment"], [JSX_EMBED, "embed"]]) {
|
|
7897
7978
|
const m = re.exec(span);
|
|
7898
|
-
if (m !== null &&
|
|
7979
|
+
if (m !== null && isNavShaped(span.slice(0, m.index))) return label;
|
|
7899
7980
|
}
|
|
7900
7981
|
return null;
|
|
7901
7982
|
}
|
|
7983
|
+
function classify(content, headingPath) {
|
|
7984
|
+
const widget = dominantWidget(content);
|
|
7985
|
+
if (widget !== null) return widget;
|
|
7986
|
+
const leaf = headingPath.length > 0 ? headingPath[headingPath.length - 1] : "";
|
|
7987
|
+
if (content.includes("docs.google.com/presentation") || leaf.includes("Teaching Aid")) return "embed";
|
|
7988
|
+
if (isNavShaped(content)) return "nav";
|
|
7989
|
+
return "prose";
|
|
7990
|
+
}
|
|
7991
|
+
/** A segment dominated by a line-leading widget labels EVERY fragment — a
|
|
7992
|
+
* char-sliced widget must not leak as prose. Same question as `classify`, asked
|
|
7993
|
+
* of the whole segment rather than one piece of it. */
|
|
7994
|
+
function segmentMarkerType(span) {
|
|
7995
|
+
return dominantWidget(span);
|
|
7996
|
+
}
|
|
7902
7997
|
/** Walk lines; headings count only OUTSIDE fences; every line lands in exactly
|
|
7903
7998
|
* one segment (byte-exact). H1 records a title but never enters the path. */
|
|
7904
7999
|
function segmentText(text) {
|
|
@@ -8006,7 +8101,7 @@ function chunkText(text, maxChars = MAX_CHARS) {
|
|
|
8006
8101
|
} else prefix += seg.text;
|
|
8007
8102
|
continue;
|
|
8008
8103
|
}
|
|
8009
|
-
const segIsNav =
|
|
8104
|
+
const segIsNav = isNavShaped(seg.text);
|
|
8010
8105
|
const segMarker = segmentMarkerType(seg.text);
|
|
8011
8106
|
for (const piece of subsplit(seg.text, maxChars)) {
|
|
8012
8107
|
let sourceType;
|
|
@@ -8919,11 +9014,11 @@ async function ingestCommand(args) {
|
|
|
8919
9014
|
process.stdout.write(`ingest: generation ${report.generation} — ${report.nodes} nodes, ${report.chunks} chunks; embedded ${report.embedded}, carried ${report.carried}, failed ${report.failed}\n`);
|
|
8920
9015
|
if (report.unsearchable > 0) {
|
|
8921
9016
|
const pct = Math.round(report.unsearchable / Math.max(report.chunks, 1) * 100);
|
|
8922
|
-
process.stdout.write(` not searchable: ${report.unsearchable} of ${report.chunks} chunk(s) (${pct}%)
|
|
9017
|
+
process.stdout.write(` not searchable: ${report.unsearchable} of ${report.chunks} chunk(s) (${pct}%) read as navigation rather than content — stored and readable, but no search returns them\n`);
|
|
8923
9018
|
if (report.unsearchableSources.length > 0) {
|
|
8924
9019
|
const named = report.unsearchableSources.slice(0, 10).join(", ");
|
|
8925
9020
|
const more = report.unsearchableSources.length - Math.min(10, report.unsearchableSources.length);
|
|
8926
|
-
process.stdout.write(` FOUND ONLY BY NAME: ${named}${more > 0 ? `, and ${more} more` : ""} — no searchable chunk at all;
|
|
9021
|
+
process.stdout.write(` FOUND ONLY BY NAME: ${named}${more > 0 ? `, and ${more} more` : ""} — no searchable chunk at all — a page of links reads as navigation; give it prose of its own, or reach it by slug
|
|
8927
9022
|
`);
|
|
8928
9023
|
}
|
|
8929
9024
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panaversity/ksor",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "Knowledge System of Record — compile governed markdown into a static site for people and an MCP server for AI agents, with citations and measured abstention.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"abstention",
|
|
@@ -136,8 +136,16 @@ function readScalar(raw: string): ScalarRead {
|
|
|
136
136
|
// override — which is what the kernel does. Kept in step with `scalarValue`
|
|
137
137
|
// in ingest/adapters/plain-tree.ts and bound to it by
|
|
138
138
|
// `stable-id-conformance.test.ts`.
|
|
139
|
+
// VALID YAML this reader does not model: a flow sequence or mapping, a block
|
|
140
|
+
// scalar, an anchor/alias/tag. PyYAML parses every one — the DOCUMENT is fine
|
|
141
|
+
// and only this KEY is beyond the reader, so it must not empty the map.
|
|
142
|
+
// Checked BEFORE the ": " test, because a flow mapping legitimately contains
|
|
143
|
+
// one (`meta: {a: 1}`).
|
|
144
|
+
// `typed` rather than `refused`: the key exists but is not a string, so this
|
|
145
|
+
// map (which holds strings) omits it and no override is taken — exactly what
|
|
146
|
+
// the kernel now does with `value: null` (issue #78).
|
|
147
|
+
if (/^[|>&*!{[]/.test(plain)) return { kind: "typed", value: "" };
|
|
139
148
|
if (/:[ \t]/.test(plain) || plain.endsWith(":")) return { kind: "refused", value: "" };
|
|
140
|
-
if (/^[|>&*!{[]/.test(plain)) return { kind: "refused", value: "" };
|
|
141
149
|
if (YAML_TYPED.test(plain)) return { kind: "typed", value: "" };
|
|
142
150
|
return { kind: "string", value: plain };
|
|
143
151
|
}
|