@polycode-projects/the-mechanical-code-talker 2.5.2 → 2.5.4
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/README.md +11 -7
- package/bin/tmct.mjs +7 -4
- package/corpus/LICENSES.json +7 -0
- package/corpus/reference/LICENSE-NOTICE +36 -0
- package/corpus/reference/README.md +19 -0
- package/corpus/reference/index.json.gz +0 -0
- package/corpus/reference/manifest.json +367 -0
- package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-02.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-03.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-05.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-06.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-07.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-09.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-0a.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-0b.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-0c.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-0d.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-0e.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-0f.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-12.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-13.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-14.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-15.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-16.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-18.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-19.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-1a.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-1b.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-1c.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-1d.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-1e.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-1f.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-21.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-22.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-23.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-24.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-26.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-27.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-28.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-29.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2a.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2b.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2d.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2e.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2f.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-30.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-31.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-32.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-33.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-34.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-35.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-36.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-37.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-38.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-39.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-3a.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-3b.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-3c.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-3d.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-3e.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-3f.jsonl.gz +0 -0
- package/package.json +5 -1
- package/src/adapters/corpus/reference-pack.mjs +113 -0
- package/src/adapters/memory/core.mjs +104 -10
- package/src/domain/ask.mjs +66 -9
- package/src/domain/cli-verbs.mjs +3 -3
- package/src/domain/codegraph.mjs +6 -0
- package/src/domain/dialogue-acts.mjs +160 -0
- package/src/domain/interpret/normalize.mjs +14 -2
- package/src/domain/interpret/strategies/keywords.mjs +10 -1
- package/src/domain/memory/trust.mjs +12 -0
- package/src/domain/reference-pack.mjs +72 -0
- package/src/domain/skos-view.mjs +111 -0
- package/src/domain/syllogise.mjs +647 -56
- package/src/services/chat.mjs +937 -142
- package/src/services/index.mjs +4 -1
- package/src/surfaces/web/chat-browser-entry.mjs +87 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +1187 -85
- package/src/tools/definitions.mjs +13 -0
- package/src/tools/handlers/index.mjs +2 -0
- package/src/tools/handlers/tmct-related.mjs +30 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// The closed dialogue-act vocabulary: tmct's turn types named to ISO 24617-2's
|
|
2
|
+
// communicative functions, using the standard's own camelCase data-category
|
|
3
|
+
// spellings. The router already sorts every turn into a lane, so the label a
|
|
4
|
+
// consumer attaches is a fixed lookup over a decision already made — a table,
|
|
5
|
+
// never a classifier, and no model anywhere.
|
|
6
|
+
//
|
|
7
|
+
// The honesty-preserving line runs through this table: tmct's miss is
|
|
8
|
+
// autoNegative in the autoFeedback dimension — the sender reporting that its
|
|
9
|
+
// OWN processing could not ground the turn — never a task answer. A flat
|
|
10
|
+
// intent set collapses that distinction; keeping it is why the standard is
|
|
11
|
+
// worth adopting over an ad-hoc set.
|
|
12
|
+
//
|
|
13
|
+
// The subset here covers what tmct's lanes produce. The standard's responsive
|
|
14
|
+
// accept/decline family is deliberately absent; a session extending the table
|
|
15
|
+
// should know the standard's own cross-wiring: accepting a request or a
|
|
16
|
+
// suggestion is a COMMISSIVE, while accepting an offer is a DIRECTIVE, and the
|
|
17
|
+
// address* functions are the general "dealing with" nodes those specialise.
|
|
18
|
+
//
|
|
19
|
+
// Source of the names: docs/references/schemas/iso-24617-2-dialogue-acts.md —
|
|
20
|
+
// a good-faith reading of the editors' LREC 2020 paper and the public DIS
|
|
21
|
+
// draft, not a quotation of the paywalled published text.
|
|
22
|
+
|
|
23
|
+
/** The standard's ten dimensions (second edition), camelCase per its Annex F
|
|
24
|
+
* data-category names. Every act in DIALOGUE_ACTS carries exactly one. */
|
|
25
|
+
export const DIALOGUE_ACT_DIMENSIONS = Object.freeze([
|
|
26
|
+
"task",
|
|
27
|
+
"autoFeedback",
|
|
28
|
+
"alloFeedback",
|
|
29
|
+
"turnManagement",
|
|
30
|
+
"timeManagement",
|
|
31
|
+
"discourseStructuring",
|
|
32
|
+
"ownCommunicationManagement",
|
|
33
|
+
"partnerCommunicationManagement",
|
|
34
|
+
"socialObligationsManagement",
|
|
35
|
+
"contactManagement",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Every communicative function tmct's lanes cover. General-purpose functions
|
|
39
|
+
* (questions, informs, directives, commissives) carry the dimension tmct
|
|
40
|
+
* annotates them in — task; the feedback and social functions carry the fixed
|
|
41
|
+
* dimension the standard gives them. `gloss` says what the function is on the
|
|
42
|
+
* standard's terms, with tmct's use in brackets where one exists. */
|
|
43
|
+
export const DIALOGUE_ACTS = Object.freeze({
|
|
44
|
+
propositionalQuestion: Object.freeze({
|
|
45
|
+
dimension: "task",
|
|
46
|
+
gloss: "a yes/no question about whether a proposition holds ('does X import Y?')",
|
|
47
|
+
}),
|
|
48
|
+
checkQuestion: Object.freeze({
|
|
49
|
+
dimension: "task",
|
|
50
|
+
gloss: "a yes/no question whose asker already expects the answer yes ('..., right?')",
|
|
51
|
+
}),
|
|
52
|
+
setQuestion: Object.freeze({
|
|
53
|
+
dimension: "task",
|
|
54
|
+
gloss: "a wh-question asking for the members of a set ('what does X import?')",
|
|
55
|
+
}),
|
|
56
|
+
choiceQuestion: Object.freeze({
|
|
57
|
+
dimension: "task",
|
|
58
|
+
gloss: "a question asking which of the listed alternatives holds ('is X a module or a class?')",
|
|
59
|
+
}),
|
|
60
|
+
inform: Object.freeze({
|
|
61
|
+
dimension: "task",
|
|
62
|
+
gloss: "a declarative telling the addressee something (a teach turn; also tmct explaining its own function)",
|
|
63
|
+
}),
|
|
64
|
+
answer: Object.freeze({
|
|
65
|
+
dimension: "task",
|
|
66
|
+
gloss: "an inform that discharges a question just asked (an answer grounded in the graph)",
|
|
67
|
+
}),
|
|
68
|
+
confirm: Object.freeze({
|
|
69
|
+
dimension: "task",
|
|
70
|
+
gloss: "an answer 'yes' to a check question",
|
|
71
|
+
}),
|
|
72
|
+
disconfirm: Object.freeze({
|
|
73
|
+
dimension: "task",
|
|
74
|
+
gloss: "an answer 'no' to a check question",
|
|
75
|
+
}),
|
|
76
|
+
agreement: Object.freeze({
|
|
77
|
+
dimension: "task",
|
|
78
|
+
gloss: "an inform stating that the speaker holds what the addressee just stated to be true",
|
|
79
|
+
}),
|
|
80
|
+
disagreement: Object.freeze({
|
|
81
|
+
dimension: "task",
|
|
82
|
+
gloss: "an inform stating that the speaker holds what the addressee just stated to be false",
|
|
83
|
+
}),
|
|
84
|
+
correction: Object.freeze({
|
|
85
|
+
dimension: "task",
|
|
86
|
+
gloss: "a disagreement that also supplies the replacement ('no, I meant Y')",
|
|
87
|
+
}),
|
|
88
|
+
request: Object.freeze({
|
|
89
|
+
dimension: "task",
|
|
90
|
+
gloss: "asks the addressee to perform an action ('solve it' — a goal turn)",
|
|
91
|
+
}),
|
|
92
|
+
instruct: Object.freeze({
|
|
93
|
+
dimension: "task",
|
|
94
|
+
gloss: "a request the addressee is expected to carry out without negotiation (a bare imperative)",
|
|
95
|
+
}),
|
|
96
|
+
suggestion: Object.freeze({
|
|
97
|
+
dimension: "task",
|
|
98
|
+
gloss: "puts an action forward as advisable without claiming authority over the addressee",
|
|
99
|
+
}),
|
|
100
|
+
offer: Object.freeze({
|
|
101
|
+
dimension: "task",
|
|
102
|
+
gloss: "commits the speaker to an action, conditional on the addressee wanting it",
|
|
103
|
+
}),
|
|
104
|
+
autoPositive: Object.freeze({
|
|
105
|
+
dimension: "autoFeedback",
|
|
106
|
+
gloss: "the sender reports its own processing of the previous turn succeeded (an acknowledgement)",
|
|
107
|
+
}),
|
|
108
|
+
autoNegative: Object.freeze({
|
|
109
|
+
dimension: "autoFeedback",
|
|
110
|
+
gloss: "the sender reports its own processing of the previous turn failed — tmct's honest miss",
|
|
111
|
+
}),
|
|
112
|
+
initialGreeting: Object.freeze({
|
|
113
|
+
dimension: "socialObligationsManagement",
|
|
114
|
+
gloss: "opens an exchange of greetings ('hi')",
|
|
115
|
+
}),
|
|
116
|
+
returnGreeting: Object.freeze({
|
|
117
|
+
dimension: "socialObligationsManagement",
|
|
118
|
+
gloss: "answers a greeting with a greeting",
|
|
119
|
+
}),
|
|
120
|
+
thanking: Object.freeze({
|
|
121
|
+
dimension: "socialObligationsManagement",
|
|
122
|
+
gloss: "expresses gratitude for something the addressee did",
|
|
123
|
+
}),
|
|
124
|
+
apology: Object.freeze({
|
|
125
|
+
dimension: "socialObligationsManagement",
|
|
126
|
+
gloss: "expresses regret for something the speaker did",
|
|
127
|
+
}),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/** The router-lane → dialogue-act lookup the chat surface attaches per turn: a
|
|
131
|
+
* deterministic map over the lane the router already picked. Keys name router
|
|
132
|
+
* lanes; values name DIALOGUE_ACTS entries. The honest-miss row is the one
|
|
133
|
+
* that must never drift: a miss is autoFeedback about tmct's own processing,
|
|
134
|
+
* not a task answer. */
|
|
135
|
+
export const LANE_DIALOGUE_ACTS = Object.freeze({
|
|
136
|
+
teach: "inform",
|
|
137
|
+
"ask-set": "setQuestion",
|
|
138
|
+
"ask-propositional": "propositionalQuestion",
|
|
139
|
+
goal: "request",
|
|
140
|
+
imperative: "instruct",
|
|
141
|
+
"honest-miss": "autoNegative",
|
|
142
|
+
greeting: "initialGreeting",
|
|
143
|
+
thanks: "thanking",
|
|
144
|
+
help: "inform",
|
|
145
|
+
// The guessing game's turns are task-dimension: a reply that discharges
|
|
146
|
+
// the other side's move (a hint, a win, a rebuttal) is an answer; tmct
|
|
147
|
+
// stating its own move (an opening, its next guess) is an inform.
|
|
148
|
+
"game-answer": "answer",
|
|
149
|
+
"game-inform": "inform",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/** The dialogue act a router lane resolves to, with its dimension — or null
|
|
153
|
+
* for a lane the lookup does not cover (the caller keeps its own naming). */
|
|
154
|
+
export function dialogueActForLane(lane) {
|
|
155
|
+
// hasOwn, so an inherited name ("constructor") is uncovered, never a hit
|
|
156
|
+
// found on the prototype chain.
|
|
157
|
+
if (typeof lane !== "string" || !Object.hasOwn(LANE_DIALOGUE_ACTS, lane)) return null;
|
|
158
|
+
const act = LANE_DIALOGUE_ACTS[lane];
|
|
159
|
+
return { act, dimension: DIALOGUE_ACTS[act].dimension };
|
|
160
|
+
}
|
|
@@ -153,14 +153,14 @@ const TELL_ME_WRAPPER_RE = /^tell\s+me\s+(.+?)\??$/i;
|
|
|
153
153
|
const KNOW_WRAPPER_RE = /^do\s+you\s+know\s+(.+?)\??$/i;
|
|
154
154
|
/** "i'd like to know <Q>" / "i want to know <Q>" -> "<Q>", same
|
|
155
155
|
* interrogative-remainder gate as KNOW_WRAPPER_RE. */
|
|
156
|
-
const WANT_KNOW_WRAPPER_RE = /^i(?:'d|\s+would)?\s+(?:like|want|need)\s+to\s+know\s+(.+?)\??$/i;
|
|
156
|
+
const WANT_KNOW_WRAPPER_RE = /^i(?:'d|\s+would)?\s+(?:(?:like|want|need)\s+to|wanna)\s+know\s+(.+?)\??$/i;
|
|
157
157
|
/** "i was wondering <Q>" / "i wondered <Q>" / "i'm curious <Q>" -> "<Q>", the
|
|
158
158
|
* same wrapper family and the same interrogative-remainder gate. The modal
|
|
159
159
|
* form ("could you tell me what a dog is") was already unwrapped; this one
|
|
160
160
|
* states the wish rather than asking, which is how it fell through every
|
|
161
161
|
* frame. The optional "if"/"whether"/"about" tail carries the same clause
|
|
162
162
|
* MODAL_WRAPPER_RE's own remainder does. */
|
|
163
|
-
const WONDERING_WRAPPER_RE = /^i(?:\s+was|\s+am|'m)?\s+(?:just\s+)?(?:wonder(?:ing|ed)
|
|
163
|
+
const WONDERING_WRAPPER_RE = /^i(?:\s+was|\s+am|'m)?\s+(?:just\s+)?(?:wonder(?:ing|ed)?|curious)(?:\s+(?:if|whether|about))?\s+(.+?)\??$/i;
|
|
164
164
|
/** EMBEDDED-QUESTION DE-INVERSION: the wrappers above unwrap "could you
|
|
165
165
|
* tell me what a dog is" down to the embedded clause "what a dog is",
|
|
166
166
|
* which keeps declarative word order — nothing downstream parses it. Fold
|
|
@@ -498,6 +498,18 @@ const PHRASING_FRAMES = Object.freeze([
|
|
|
498
498
|
// NEEDS-TESTS → the untested-module survey.
|
|
499
499
|
{ re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
|
|
500
500
|
|
|
501
|
+
// NO-TESTS → the same untested survey, attributive form. "no tests" would
|
|
502
|
+
// otherwise read as a literal object term ("…defines no tests") and the
|
|
503
|
+
// resulting empty answers as "it has none" to a coverage question.
|
|
504
|
+
{
|
|
505
|
+
re: /^(?:which|what|list|show(?:\s+me)?|find)?\s*(?:the\s+|all\s+)?([a-z][a-z-]*?)\s+(?:(?:that|which)\s+)?(?:have|has|having|with)\s+no\s+tests?\??$/i,
|
|
506
|
+
to: (m) => `untested ${m[1].toLowerCase()}`,
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
re: /^(?:which|what|list|show(?:\s+me)?|find)?\s*(?:the\s+|all\s+)?([a-z][a-z-]*?)\s+without\s+(?:any\s+)?tests?\??$/i,
|
|
510
|
+
to: (m) => `untested ${m[1].toLowerCase()}`,
|
|
511
|
+
},
|
|
512
|
+
|
|
501
513
|
// DOES-X-VERB-ANYTHING-ELSE → "what does X <verb>" (drops the placeholder
|
|
502
514
|
// "anything/something else" object, which otherwise made the two parse
|
|
503
515
|
// strategies disagree on the span). Anchored to VERB_TO_KIND so it can't
|
|
@@ -222,7 +222,16 @@ export function parseKeywordSpot(text, nlp = null) {
|
|
|
222
222
|
if (INHERITS_REVERSE_VERBS.includes(verbPhrase)) [subject, object] = [object, subject];
|
|
223
223
|
return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject, object });
|
|
224
224
|
}
|
|
225
|
-
if (afterText)
|
|
225
|
+
if (afterText) {
|
|
226
|
+
// A type word riding beside the named object ("what uses the Store
|
|
227
|
+
// CLASS") describes the OBJECT's grain — it says which "Store" is meant,
|
|
228
|
+
// not what class of thing may answer. Reading it as the result filter
|
|
229
|
+
// turned "what uses the Store class" into "which classes use Store" and
|
|
230
|
+
// answered "no classes" over a module-level graph. Only a type word on
|
|
231
|
+
// the wh-side of the verb ("which CLASSES use Store") filters the result.
|
|
232
|
+
const resultType = entityHit && entityHit.start >= verbHit.end ? null : entityType;
|
|
233
|
+
return stamp({ shape: "reverse", entityType: resultType, modifier, kind, object: afterText });
|
|
234
|
+
}
|
|
226
235
|
// "what is a kind of class": when the object is itself an entity-type noun, the
|
|
227
236
|
// entity match swallows the whole post-verb span as a grain qualifier, leaving
|
|
228
237
|
// afterText empty — re-read that span as the object instead.
|
|
@@ -33,6 +33,9 @@ function parseChatTagRest(rest) {
|
|
|
33
33
|
* ace:chat:<session>@<ts> -> { kind:"operator", createdAt:<ts>, sessionId:<session> }
|
|
34
34
|
* teach:chat:<session>@<ts> -> { kind:"teach", createdAt:<ts>, sessionId:<session> }
|
|
35
35
|
* web:<url> | url:<url> -> { kind:"web", url:<url> }
|
|
36
|
+
* reference:<pack>:<article>[@revid] -> { kind:"reference", pack, article }
|
|
37
|
+
* (split on the first two colons only; the article keeps any @revid and
|
|
38
|
+
* any spaces — "reference:simplewiki:Polar bear@912" stays one article)
|
|
36
39
|
* extracted:<file-basename> -> { kind:"extracted", name:<file-basename> }
|
|
37
40
|
* entailed:<rule> -> { kind:"entailed", rule:<rule> }
|
|
38
41
|
* chat:/session: refs map to the operator; an unknown tag -> null (no Source).
|
|
@@ -40,6 +43,14 @@ function parseChatTagRest(rest) {
|
|
|
40
43
|
export function provenanceTagToSource(tag) {
|
|
41
44
|
const t = String(tag || "").trim();
|
|
42
45
|
if (!t) return null;
|
|
46
|
+
if (t.startsWith("reference:")) {
|
|
47
|
+
// parsed from the FULL tag, not the whitespace-split head: an article
|
|
48
|
+
// title may contain spaces ("Polar bear@912").
|
|
49
|
+
const rest = t.slice("reference:".length);
|
|
50
|
+
const colon = rest.indexOf(":");
|
|
51
|
+
if (colon < 0) return { kind: "reference", pack: rest || "unknown", article: "" };
|
|
52
|
+
return { kind: "reference", pack: rest.slice(0, colon) || "unknown", article: rest.slice(colon + 1) };
|
|
53
|
+
}
|
|
43
54
|
const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
|
|
44
55
|
if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
|
|
45
56
|
if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
|
|
@@ -63,6 +74,7 @@ export const SOURCE_PRIOR = Object.freeze({
|
|
|
63
74
|
teach: 0.95,
|
|
64
75
|
provider: 0.9,
|
|
65
76
|
corpus: 0.7,
|
|
77
|
+
reference: 0.6,
|
|
66
78
|
corpusWeak: 0.55,
|
|
67
79
|
web: 0.4,
|
|
68
80
|
extracted: 0.45,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// reference-pack.mjs — the pure half of the shipped reference pack: the shard
|
|
2
|
+
// naming contract, the index/row shape validators every writer and reader
|
|
3
|
+
// share, the cited answer line, the provenance tag, and the pure part of the
|
|
4
|
+
// clean-miss gate. The pack itself is 64 gzipped JSONL shards plus a gzipped
|
|
5
|
+
// term index; loading them is I/O and lives in
|
|
6
|
+
// src/adapters/corpus/reference-pack.mjs.
|
|
7
|
+
|
|
8
|
+
import { fnv1aHex, normFactTerm } from "./hash.mjs";
|
|
9
|
+
import { loadLexicon, lookupNoun } from "./grammar/lexicon.mjs";
|
|
10
|
+
import { RELATION_TERM } from "./concept.mjs";
|
|
11
|
+
|
|
12
|
+
export const REFERENCE_PACK_NAME = "simplewiki";
|
|
13
|
+
export const REFERENCE_SHARD_COUNT = 64;
|
|
14
|
+
|
|
15
|
+
/** The shard a term's article row lives in: FNV-1a first byte mod 64, as the
|
|
16
|
+
* file basename "ref-00" … "ref-3f". Part of the pack's on-disk contract —
|
|
17
|
+
* the build script shards with THIS function, so the reader never scans. */
|
|
18
|
+
export function shardNameFor(term) {
|
|
19
|
+
const byte = parseInt(fnv1aHex(String(term ?? "")).slice(0, 2), 16);
|
|
20
|
+
return `ref-${(byte % REFERENCE_SHARD_COUNT).toString(16).padStart(2, "0")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const SHARD_NAME_RE = /^ref-[0-3][0-9a-f]$/;
|
|
24
|
+
|
|
25
|
+
/** An index entry {s, t, r}: the shard holding the row, the row's canonical
|
|
26
|
+
* term key (an alias entry points at its target's), and the revision id. */
|
|
27
|
+
export function isReferenceIndexEntry(e) {
|
|
28
|
+
return !!e && typeof e === "object"
|
|
29
|
+
&& typeof e.s === "string" && SHARD_NAME_RE.test(e.s)
|
|
30
|
+
&& typeof e.t === "string" && e.t.length > 0
|
|
31
|
+
&& Number.isInteger(e.r) && e.r > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A shard row: {term, title, text, summary, url, revid, isa?}. */
|
|
35
|
+
export function isReferenceArticleRow(row) {
|
|
36
|
+
if (!row || typeof row !== "object") return false;
|
|
37
|
+
for (const field of ["term", "title", "text", "summary", "url"]) {
|
|
38
|
+
if (typeof row[field] !== "string" || !row[field]) return false;
|
|
39
|
+
}
|
|
40
|
+
if (!Number.isInteger(row.revid) || row.revid <= 0) return false;
|
|
41
|
+
if (row.isa !== undefined && (typeof row.isa !== "string" || !row.isa)) return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The cited answer for a clean miss the pack could ground: the article's
|
|
46
|
+
* summary with its title, licence and revision-pinned URL always visible. */
|
|
47
|
+
export function renderReferenceAnswer(term, article) {
|
|
48
|
+
return `${term} — ${article.summary} (source: reference article "${article.title}", `
|
|
49
|
+
+ `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The provenance tag a fact stored from a pack article carries —
|
|
53
|
+
* memory/trust.mjs parses it back to { kind:"reference", pack, article }. */
|
|
54
|
+
export function referenceProvenanceTag(article) {
|
|
55
|
+
return `reference:${REFERENCE_PACK_NAME}:${article.title}@${article.revid}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The pure half of the clean-miss gate: is this term even a pack-lookup
|
|
59
|
+
* candidate, and under which key? Returns the pack key (the lexicon entry's
|
|
60
|
+
* lemma, normFactTerm-folded) or null when the term is empty, not plain
|
|
61
|
+
* words, a vague relation touch (RELATION_TERM owns those), or not in the
|
|
62
|
+
* lexicon at all — an unknown word is never a clean miss. The async half of
|
|
63
|
+
* the gate (parse succeeded, graph/memory genuinely empty on the term) stays
|
|
64
|
+
* with the caller. */
|
|
65
|
+
export function cleanMissReferenceTerm(term, lexicon = loadLexicon()) {
|
|
66
|
+
const t = normFactTerm(term);
|
|
67
|
+
if (!t || !/^[a-z][a-z' -]*$/.test(t)) return null;
|
|
68
|
+
if (RELATION_TERM[t]) return null;
|
|
69
|
+
const entry = lookupNoun(lexicon, t);
|
|
70
|
+
if (!entry) return null;
|
|
71
|
+
return normFactTerm(entry.lemma);
|
|
72
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// A derived SKOS concept-identity view over the store's bare-string terms.
|
|
2
|
+
//
|
|
3
|
+
// The corpus stores its term relations as reified facts whose subject/object are
|
|
4
|
+
// bare, normalised strings ("module", not "tmct:module") — normFactTerm strips
|
|
5
|
+
// the CURIE prefix at write time. SKOS S19/S20 fix the domain and range of
|
|
6
|
+
// skos:related to skos:Concept, so the relations cannot map onto SKOS while the
|
|
7
|
+
// terms are plain strings with no concept identity.
|
|
8
|
+
//
|
|
9
|
+
// buildSkosConceptView mints that identity WITHOUT touching storage: it reads the
|
|
10
|
+
// fact rows a store already returns, mints one skos:Concept per normalised term
|
|
11
|
+
// (or per synonym-merged group), and re-expresses mgx:synonym and mgx:relatedTo
|
|
12
|
+
// as SKOS. Nothing is written; the same store, read twice, yields the same view.
|
|
13
|
+
// The minted concepts ARE skos:Concepts by construction, so skos:related between
|
|
14
|
+
// them satisfies S19/S20. skos:prefLabel/altLabel carry no domain (S10/S11), so
|
|
15
|
+
// labelling the minted concepts is well-formed SKOS.
|
|
16
|
+
|
|
17
|
+
import { normFactTerm } from "./hash.mjs";
|
|
18
|
+
|
|
19
|
+
export const SKOS_NS = "http://www.w3.org/2004/02/skos/core#";
|
|
20
|
+
|
|
21
|
+
// mgx:antonym is deliberately absent: SKOS has no opposition relation, and
|
|
22
|
+
// asserting skos:related for an antonym would claim the association the corpus
|
|
23
|
+
// denies. It stays mgx: and reads as a bare string — the honest miss.
|
|
24
|
+
export const DEFAULT_RELATION_MAP = {
|
|
25
|
+
synonym: ["mgx:synonym"],
|
|
26
|
+
related: ["mgx:relatedTo", "mgx:similarTo"],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function buildSkosConceptView(rows, { conceptBase = "concept:", relationMap = DEFAULT_RELATION_MAP } = {}) {
|
|
30
|
+
const synonymPreds = new Set(relationMap.synonym || []);
|
|
31
|
+
const relatedPreds = new Set(relationMap.related || []);
|
|
32
|
+
|
|
33
|
+
const parent = new Map();
|
|
34
|
+
const ensure = (t) => { if (!parent.has(t)) parent.set(t, t); };
|
|
35
|
+
const find = (x) => {
|
|
36
|
+
let r = x;
|
|
37
|
+
while (parent.get(r) !== r) r = parent.get(r);
|
|
38
|
+
while (parent.get(x) !== r) { const next = parent.get(x); parent.set(x, r); x = next; }
|
|
39
|
+
return r;
|
|
40
|
+
};
|
|
41
|
+
const union = (a, b) => {
|
|
42
|
+
const ra = find(a), rb = find(b);
|
|
43
|
+
if (ra === rb) return;
|
|
44
|
+
if (ra < rb) parent.set(rb, ra); else parent.set(ra, rb); // smaller string wins — deterministic
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const relatedRaw = [];
|
|
48
|
+
for (const r of rows) {
|
|
49
|
+
const p = r.predicate;
|
|
50
|
+
if (!synonymPreds.has(p) && !relatedPreds.has(p)) continue;
|
|
51
|
+
const s = normFactTerm(r.subject), o = normFactTerm(r.object);
|
|
52
|
+
if (!s || !o) continue;
|
|
53
|
+
ensure(s); ensure(o);
|
|
54
|
+
if (synonymPreds.has(p)) union(s, o); else relatedRaw.push({ s, o });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const iriFor = (rep) => conceptBase + rep.replace(/ /g, "_");
|
|
58
|
+
const componentTerms = new Map();
|
|
59
|
+
for (const t of parent.keys()) {
|
|
60
|
+
const rep = find(t);
|
|
61
|
+
if (!componentTerms.has(rep)) componentTerms.set(rep, new Set());
|
|
62
|
+
componentTerms.get(rep).add(t);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const concepts = [];
|
|
66
|
+
for (const [rep, terms] of componentTerms) {
|
|
67
|
+
const sorted = [...terms].sort();
|
|
68
|
+
concepts.push({ id: iriFor(rep), prefLabel: rep, altLabels: sorted.filter((t) => t !== rep) });
|
|
69
|
+
}
|
|
70
|
+
concepts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
71
|
+
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
const related = [];
|
|
74
|
+
for (const { s, o } of relatedRaw) {
|
|
75
|
+
const cs = iriFor(find(s)), co = iriFor(find(o));
|
|
76
|
+
if (cs === co) continue; // a synonym merge collapsed both terms into one concept
|
|
77
|
+
// NUL-delimited pair key, same idiom as the store's fact ids — spelled as an
|
|
78
|
+
// escape so this file stays plain text (a literal NUL reads as binary to git)
|
|
79
|
+
const key = cs < co ? `${cs}\u0000${co}` : `${co}\u0000${cs}`;
|
|
80
|
+
if (seen.has(key)) continue;
|
|
81
|
+
seen.add(key);
|
|
82
|
+
related.push({ subject: cs, object: co });
|
|
83
|
+
}
|
|
84
|
+
related.sort((a, b) => (`${a.subject}${a.object}` < `${b.subject}${b.object}` ? -1 : 1));
|
|
85
|
+
|
|
86
|
+
const conceptIdForTerm = (term) => {
|
|
87
|
+
const t = normFactTerm(term);
|
|
88
|
+
return parent.has(t) ? iriFor(find(t)) : null;
|
|
89
|
+
};
|
|
90
|
+
return { concepts, related, conceptIdForTerm, namespace: SKOS_NS };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One term's SKOS neighbourhood out of a store's fact rows: its concept, the
|
|
94
|
+
* synonym group read as labels, and the skos:related neighbour concepts.
|
|
95
|
+
* `synonyms` is the group minus the queried term — the caller-facing "another
|
|
96
|
+
* word for X" answer. Null when the term mints no concept (unknown, or no
|
|
97
|
+
* synonym/related facts), so the caller keeps its honest miss. */
|
|
98
|
+
export function relatedForTerm(rows, term, options = {}) {
|
|
99
|
+
const view = buildSkosConceptView(rows, options);
|
|
100
|
+
const conceptId = view.conceptIdForTerm(term);
|
|
101
|
+
if (!conceptId) return null;
|
|
102
|
+
const byId = new Map(view.concepts.map((c) => [c.id, c]));
|
|
103
|
+
const concept = byId.get(conceptId);
|
|
104
|
+
const queried = normFactTerm(term);
|
|
105
|
+
const synonyms = [concept.prefLabel, ...concept.altLabels].filter((label) => label !== queried);
|
|
106
|
+
const related = view.related
|
|
107
|
+
.filter((r) => r.subject === conceptId || r.object === conceptId)
|
|
108
|
+
.map((r) => byId.get(r.subject === conceptId ? r.object : r.subject))
|
|
109
|
+
.filter(Boolean);
|
|
110
|
+
return { conceptId, prefLabel: concept.prefLabel, altLabels: concept.altLabels, synonyms, related };
|
|
111
|
+
}
|