@polycode-projects/the-mechanical-code-talker 3.0.5 → 3.0.6
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 +6 -6
- package/corpus/child/README.md +2 -2
- package/corpus/child/index.json.gz +0 -0
- package/corpus/child/manifest.json +74 -74
- package/corpus/child/shards/child-00.jsonl.gz +0 -0
- package/corpus/child/shards/child-01.jsonl.gz +0 -0
- package/corpus/child/shards/child-02.jsonl.gz +0 -0
- package/corpus/child/shards/child-03.jsonl.gz +0 -0
- package/corpus/child/shards/child-04.jsonl.gz +0 -0
- package/corpus/child/shards/child-05.jsonl.gz +0 -0
- package/corpus/child/shards/child-06.jsonl.gz +0 -0
- package/corpus/child/shards/child-07.jsonl.gz +0 -0
- package/corpus/child/shards/child-08.jsonl.gz +0 -0
- package/corpus/child/shards/child-09.jsonl.gz +0 -0
- package/corpus/child/shards/child-0a.jsonl.gz +0 -0
- package/corpus/child/shards/child-0b.jsonl.gz +0 -0
- package/corpus/child/shards/child-0c.jsonl.gz +0 -0
- package/corpus/child/shards/child-0d.jsonl.gz +0 -0
- package/corpus/child/shards/child-0e.jsonl.gz +0 -0
- package/corpus/child/shards/child-0f.jsonl.gz +0 -0
- package/corpus/child/shards/child-10.jsonl.gz +0 -0
- package/corpus/child/shards/child-11.jsonl.gz +0 -0
- package/corpus/child/shards/child-12.jsonl.gz +0 -0
- package/corpus/child/shards/child-13.jsonl.gz +0 -0
- package/corpus/child/shards/child-14.jsonl.gz +0 -0
- package/corpus/child/shards/child-15.jsonl.gz +0 -0
- package/corpus/child/shards/child-16.jsonl.gz +0 -0
- package/corpus/child/shards/child-17.jsonl.gz +0 -0
- package/corpus/child/shards/child-18.jsonl.gz +0 -0
- package/corpus/child/shards/child-19.jsonl.gz +0 -0
- package/corpus/child/shards/child-1a.jsonl.gz +0 -0
- package/corpus/child/shards/child-1b.jsonl.gz +0 -0
- package/corpus/child/shards/child-1c.jsonl.gz +0 -0
- package/corpus/child/shards/child-1d.jsonl.gz +0 -0
- package/corpus/child/shards/child-1e.jsonl.gz +0 -0
- package/corpus/child/shards/child-1f.jsonl.gz +0 -0
- package/corpus/conceptnet/quality-filter.mjs +28 -4
- package/corpus/tier2/generate.mjs +4 -8
- package/corpus/tier2/human-large.jsonl +0 -2
- package/corpus/tier2/human-medium.jsonl +0 -2
- package/corpus/tier2/manifest.json +6 -6
- package/data/templates/constructions/digest-sentence-structures.toml +146 -1
- package/package.json +1 -1
- package/src/adapters/corpus/digest-bank.mjs +15 -3
- package/src/domain/digest/compose.mjs +142 -10
- package/src/domain/digest/config.json +5 -0
- package/src/domain/digest/structures.mjs +39 -14
- package/src/services/chat-page-viz.mjs +15 -4
- package/src/services/chat.mjs +118 -49
- package/src/services/extract-facts.mjs +46 -4
- package/src/services/research.mjs +80 -3
- package/src/surfaces/web/chat-browser-entry.mjs +23 -3
- package/src/surfaces/web/memory-ask-browser.bundle.js +108 -108
package/src/services/chat.mjs
CHANGED
|
@@ -273,9 +273,12 @@ function withNarration(result, trace, fallbackGoal) {
|
|
|
273
273
|
* above. Appending (not prepending) keeps the many tests that pin composed
|
|
274
274
|
* answers with a start-anchored regex intact.
|
|
275
275
|
*
|
|
276
|
-
* `result.goal` is set by runAsk
|
|
277
|
-
* (GOAL_BY_COMMAND, below)
|
|
278
|
-
*
|
|
276
|
+
* `result.goal` is set by runAsk, by runCommand's own mk()
|
|
277
|
+
* (GOAL_BY_COMMAND, below), and by the plainTurn call sites that already
|
|
278
|
+
* hold a precise goal string (a teach confirmation, a taxonomy/SKOS or
|
|
279
|
+
* memory-store lookup, a plan-lane step, and runAsk's own honest-decline
|
|
280
|
+
* early returns). A plain numeric count is the one turn type that stays
|
|
281
|
+
* silent — it never sets the field, so this is a no-op there by
|
|
279
282
|
* construction. Also a no-op when `result.goal` is null/empty, so an
|
|
280
283
|
* unclear turn never grows a "Goal (inferred): unclear" line.
|
|
281
284
|
*
|
|
@@ -917,7 +920,7 @@ async function answerMemoryClassQuery(memoryDir, query) {
|
|
|
917
920
|
let mem;
|
|
918
921
|
try { mem = await loadMemory(memoryDir); } catch { return null; }
|
|
919
922
|
const inds = (mem.individuals || []).filter((i) => (i.class || "") === cls);
|
|
920
|
-
if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}
|
|
923
|
+
if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.`, kind: "count" };
|
|
921
924
|
if (!inds.length) return { text: `I don't have any ${plural} stored yet.`, miss: true };
|
|
922
925
|
const factByLabel = cls === "Fact" ? new Map(readFactRows(mem).map((r) => [r.id, r])) : new Map();
|
|
923
926
|
const lines = inds.map((ind) => memoryClassLine(cls, ind, factByLabel));
|
|
@@ -2746,6 +2749,20 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
|
|
|
2746
2749
|
// are Ys" recall. Deliberately NARROW: only the SUBJECT gets a free pass past
|
|
2747
2750
|
// parseAce's closed lexicon-noun gate, never the OBJECT. ----
|
|
2748
2751
|
|
|
2752
|
+
/** Singular nouns that already END in a bare sibilant "-s" and so form their
|
|
2753
|
+
* plural with "-es" (bus -> buses, gas -> gases, lens -> lenses). When a
|
|
2754
|
+
* "-ses" word strips two letters onto one of these, the "-es" was the plural
|
|
2755
|
+
* marker and the stem is correct; every OTHER "-ses" word kept a silent "-e"
|
|
2756
|
+
* and singularizes by stripping one letter (see singularizeSurface). Doubled
|
|
2757
|
+
* "-ss" stems (class, glass, boss) are caught by pattern, so only single-"s"
|
|
2758
|
+
* stems need listing here. */
|
|
2759
|
+
const BARE_SIBILANT_S_NOUNS = new Set([
|
|
2760
|
+
"bus", "gas", "plus", "minus", "surplus", "lens", "bias", "atlas", "canvas",
|
|
2761
|
+
"virus", "bonus", "campus", "census", "chorus", "circus", "focus", "status",
|
|
2762
|
+
"iris", "genius", "sinus", "walrus", "cactus", "fungus", "radius", "nucleus",
|
|
2763
|
+
"alias", "crocus", "abacus", "syllabus", "octopus", "platypus",
|
|
2764
|
+
]);
|
|
2765
|
+
|
|
2749
2766
|
/** Naive plural → singular fold for the "some/a few Xs are Ys" surface forms
|
|
2750
2767
|
* (mirrors factTermVariants' own naive -es/-s stripping, below, but returns
|
|
2751
2768
|
* ONE canonical spelling to STORE rather than a lookup Set of candidates to
|
|
@@ -2763,11 +2780,27 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
|
|
|
2763
2780
|
function singularizeSurface(word) {
|
|
2764
2781
|
const w = String(word || "").trim();
|
|
2765
2782
|
if (/[a-z]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
|
|
2766
|
-
if (/
|
|
2783
|
+
if (/[a-z]ses$/i.test(w)) {
|
|
2784
|
+
// "-ses" is genuinely ambiguous. A base already ending in "-se" just adds
|
|
2785
|
+
// "-s" (collapse -> collapses, cause -> causes, rose -> roses), so the
|
|
2786
|
+
// singular strips ONE letter. A base ending in a bare sibilant "-s" adds
|
|
2787
|
+
// "-es" (bus -> buses, class -> classes, lens -> lenses), so the singular
|
|
2788
|
+
// strips TWO. Spelling alone can't separate every pair, so strip two only
|
|
2789
|
+
// when the two-char strip lands on a doubled "-ss" or a known bare-sibilant
|
|
2790
|
+
// "-s" noun; otherwise the base kept its silent "-e" and one letter comes
|
|
2791
|
+
// off. The other "-es" endings (-xes/-zes/-ches/-shes) keep the plain
|
|
2792
|
+
// two-char strip below.
|
|
2793
|
+
const stem = w.slice(0, -2);
|
|
2794
|
+
if (/ss$/i.test(stem) || BARE_SIBILANT_S_NOUNS.has(stem.toLowerCase())) return stem;
|
|
2795
|
+
return w.slice(0, -1);
|
|
2796
|
+
}
|
|
2797
|
+
if (/(xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
|
|
2767
2798
|
if (/[a-z]s$/i.test(w) && !/(?:ss|ous)$/i.test(w)) return w.slice(0, -1);
|
|
2768
2799
|
return w;
|
|
2769
2800
|
}
|
|
2770
2801
|
|
|
2802
|
+
export { singularizeSurface };
|
|
2803
|
+
|
|
2771
2804
|
/** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
|
|
2772
2805
|
* fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
|
|
2773
2806
|
* names the determiner, so the caller can tell a genuine "every" universal
|
|
@@ -2862,22 +2895,27 @@ async function isGroundedByFact(term, memoryDir, cache = null) {
|
|
|
2862
2895
|
}
|
|
2863
2896
|
|
|
2864
2897
|
/** Shared "is this term grounded in ANY sense" aggregate — a static lexicon
|
|
2865
|
-
* word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root,
|
|
2866
|
-
*
|
|
2867
|
-
* (isGroundedByFact, above)
|
|
2868
|
-
*
|
|
2869
|
-
*
|
|
2870
|
-
*
|
|
2871
|
-
*
|
|
2872
|
-
*
|
|
2873
|
-
|
|
2898
|
+
* word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, a
|
|
2899
|
+
* term already anchored by a previously taught isa-family fact
|
|
2900
|
+
* (isGroundedByFact, above), OR — when a code graph is supplied — a symbol
|
|
2901
|
+
* the code graph itself resolves (resolveSymbol, codegraph.mjs — the SAME
|
|
2902
|
+
* resolver /describe/`/members`/`/subclasses` already use, so "known to
|
|
2903
|
+
* describe" and "known to teach" can never disagree). Used by
|
|
2904
|
+
* unknownObjectFallback's subject/object groundedness checks below, where no
|
|
2905
|
+
* part-of-speech branching follows — just "known or not". (unknownSubjectFallback's
|
|
2906
|
+
* own object-known check, above/below, stays narrower and NOUN-specific — see
|
|
2907
|
+
* its own comment — so an object that's merely a known ADJECTIVE doesn't get
|
|
2908
|
+
* misrouted into the class/subClassOf branch instead of the property branch.) */
|
|
2909
|
+
async function isGroundedTerm(term, lex, memoryDir, cache = null, graph = null) {
|
|
2874
2910
|
const raw = String(term ?? "").trim();
|
|
2875
2911
|
if (!raw) return false;
|
|
2876
2912
|
if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
|
|
2877
2913
|
const { classify } = await import("../domain/grammar/lexicon.mjs");
|
|
2878
2914
|
if (classify(raw, lex)) return true;
|
|
2915
|
+
if (graph && resolveSymbol(graph, raw)?.match) return true;
|
|
2879
2916
|
return isGroundedByFact(raw, memoryDir, cache);
|
|
2880
2917
|
}
|
|
2918
|
+
export { isGroundedTerm };
|
|
2881
2919
|
|
|
2882
2920
|
/** The "both sides ungrounded" grounding NUDGE: reuses teachSuggestion's own
|
|
2883
2921
|
* "compute a hint, APPEND it to the existing honest-miss message, never
|
|
@@ -2899,15 +2937,15 @@ async function isGroundedTerm(term, lex, memoryDir, cache = null) {
|
|
|
2899
2937
|
* unchanged) whenever the payload doesn't fit the shape, or at least one
|
|
2900
2938
|
* side IS already grounded — a DIFFERENT, more specific reason it declined,
|
|
2901
2939
|
* where this nudge would be actively unhelpful noise. */
|
|
2902
|
-
async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null) {
|
|
2940
|
+
async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, graph = null) {
|
|
2903
2941
|
if (!memoryDir) return "";
|
|
2904
2942
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
2905
2943
|
if (!m) return "";
|
|
2906
2944
|
const [, , subjectRaw, , objectRaw] = m;
|
|
2907
2945
|
const { loadLexicon } = await import("../domain/grammar/lexicon.mjs");
|
|
2908
2946
|
const lex = lexicon || loadLexicon();
|
|
2909
|
-
if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache)) return "";
|
|
2910
|
-
if (await isGroundedTerm(objectRaw, lex, memoryDir, cache)) return "";
|
|
2947
|
+
if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return "";
|
|
2948
|
+
if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return "";
|
|
2911
2949
|
// Chaining the second term UNDER the first's now-grounded proper name
|
|
2912
2950
|
// ("every man is a john") is technically accepted by the grammar (once
|
|
2913
2951
|
// "john" is grounded, ANY term can be taught as a kind of it), but reads as
|
|
@@ -3070,7 +3108,7 @@ async function objectReadsAsNonNoun(word) {
|
|
|
3070
3108
|
return false;
|
|
3071
3109
|
}
|
|
3072
3110
|
}
|
|
3073
|
-
async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false }, cache = null) {
|
|
3111
|
+
async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false, graph = null }, cache = null) {
|
|
3074
3112
|
if (!memoryDir) return null;
|
|
3075
3113
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
3076
3114
|
if (!m) return null;
|
|
@@ -3084,9 +3122,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
|
|
|
3084
3122
|
if (!/^(?:every|each|all|any)$/i.test((det || "").trim()) && !classIntent) return null; // class-level mint needs a universal quantifier or an explicit kind-of infix
|
|
3085
3123
|
const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
|
|
3086
3124
|
const lex = lexicon || loadLexicon();
|
|
3087
|
-
const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
|
|
3125
|
+
const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph);
|
|
3088
3126
|
if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
|
|
3089
|
-
const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache);
|
|
3127
|
+
const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph);
|
|
3090
3128
|
if (objectGrounded) return null; // object already known — nothing to mint
|
|
3091
3129
|
if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
|
|
3092
3130
|
const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
|
|
@@ -3168,7 +3206,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
|
|
|
3168
3206
|
* otherwise provide. "the cache is bespoke" and "Mary is female" both carry
|
|
3169
3207
|
* one of those signals (the leading "the", and capitalization,
|
|
3170
3208
|
* respectively); "module is banana" carries none. */
|
|
3171
|
-
async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
|
|
3209
|
+
async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph = null }, cache = null) {
|
|
3172
3210
|
if (!memoryDir) return null;
|
|
3173
3211
|
const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
|
|
3174
3212
|
if (!m) return null;
|
|
@@ -3201,7 +3239,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
|
|
|
3201
3239
|
// a noun-shaped Y was already minted as a class upstream and never reaches
|
|
3202
3240
|
// this point.
|
|
3203
3241
|
const universalQuantifier = /^(?:every|each|all|any)$/i.test((det || "").trim());
|
|
3204
|
-
if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache))
|
|
3242
|
+
if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph))
|
|
3205
3243
|
&& (lookupAdjective(lex, objectRaw) || (await objectReadsAsNonNoun(objectRaw)))) {
|
|
3206
3244
|
const classSubject = /^are$/i.test(verb)
|
|
3207
3245
|
? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
|
|
@@ -3868,8 +3906,27 @@ const HABITUAL_VERB_EXCLUDE = new Set([
|
|
|
3868
3906
|
"please", "thanks", "kindly", "anyway", "though", "indeed", "maybe",
|
|
3869
3907
|
"perhaps", "still", "too", "also", "instead", "now", "then", "here", "there",
|
|
3870
3908
|
]);
|
|
3909
|
+
/** Sentence-initial ordinal/temporal discourse adverbs — the "First", "Then",
|
|
3910
|
+
* "Next" … that thread a narrative across sentences without belonging to the
|
|
3911
|
+
* clause they lead. A closed set: a connective in this slot carries sequence,
|
|
3912
|
+
* not content, so stripping it lets "First a cell grows." read as the same
|
|
3913
|
+
* capability teach the bare "a cell grows." already does. */
|
|
3914
|
+
const LEADING_DISCOURSE_ADVERBS = [
|
|
3915
|
+
"first", "second", "third", "then", "next", "finally",
|
|
3916
|
+
"later", "meanwhile", "afterward", "afterwards",
|
|
3917
|
+
];
|
|
3918
|
+
const LEADING_DISCOURSE_ADVERB_RE = new RegExp(
|
|
3919
|
+
`^(?:${LEADING_DISCOURSE_ADVERBS.join("|")})\\b\\s*,?\\s+`, "i",
|
|
3920
|
+
);
|
|
3921
|
+
/** Strip one leading ordinal/temporal discourse adverb (case-insensitive,
|
|
3922
|
+
* optional trailing comma) from the start of `text`, leaving the clause that
|
|
3923
|
+
* followed it. A word not in the closed set, or one with no clause after it,
|
|
3924
|
+
* is left untouched. */
|
|
3925
|
+
export function stripLeadingDiscourseAdverb(text) {
|
|
3926
|
+
return String(text || "").trim().replace(LEADING_DISCOURSE_ADVERB_RE, "");
|
|
3927
|
+
}
|
|
3871
3928
|
function matchBareHabitualTeach(text) {
|
|
3872
|
-
const t = String(text || "").trim();
|
|
3929
|
+
const t = stripLeadingDiscourseAdverb(String(text || "").trim());
|
|
3873
3930
|
const plural = t.match(/^(?:all\s+|every\s+)?([\w-]+s)\s+([a-z][\w-]*)[.!?]*$/i);
|
|
3874
3931
|
if (plural && !STRUCT_WORDS.has(plural[2].toLowerCase()) && !HABITUAL_VERB_EXCLUDE.has(plural[2].toLowerCase()) && !/[^s]s$/i.test(plural[2])) {
|
|
3875
3932
|
const subject = singularizeSurface(plural[1].toLowerCase());
|
|
@@ -4360,7 +4417,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
4360
4417
|
if (memoryDir && !QUESTION_LEAD_RE.test(conjSrc) && /\s+and\s+/i.test(conjSrc)
|
|
4361
4418
|
&& !(await hasMidSentenceInterrogative(conjSrc))) {
|
|
4362
4419
|
const rewrap = (half) => (wrapped != null ? `remember that ${half}` : half);
|
|
4363
|
-
const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, gameConfig });
|
|
4420
|
+
const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig });
|
|
4364
4421
|
const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
|
|
4365
4422
|
const shared = conjSrc.match(/^(.+?)\s+and\s+((?:is|are|has|have|can)\b.+)$/i);
|
|
4366
4423
|
const sharedSubject = shared ? shared[1].match(/^(.+?)\s+(?:is|are|has|have|can)\b/i)?.[1]?.trim() : null;
|
|
@@ -5145,7 +5202,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
5145
5202
|
if (canShape && canSingular !== canShape.subject) {
|
|
5146
5203
|
let canLex = lexicon;
|
|
5147
5204
|
if (!canLex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); canLex = loadLexicon(); }
|
|
5148
|
-
if (await isGroundedTerm(canSingular, canLex, memoryDir, cache)) {
|
|
5205
|
+
if (await isGroundedTerm(canSingular, canLex, memoryDir, cache, graph)) {
|
|
5149
5206
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
5150
5207
|
subject: canSingular, predicate: await capabilityPredicate(canShape.negated), object: canShape.verb,
|
|
5151
5208
|
});
|
|
@@ -5273,7 +5330,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
5273
5330
|
// every query-side variant fold) uses; a proper noun that only looks
|
|
5274
5331
|
// plural ("redis") falls back to its own spelling.
|
|
5275
5332
|
for (const subj of new Set([singularizeSurface(habitualTeach.subject), habitualTeach.subject])) {
|
|
5276
|
-
if (await isGroundedTerm(subj, habLex, memoryDir, cache)) {
|
|
5333
|
+
if (await isGroundedTerm(subj, habLex, memoryDir, cache, graph)) {
|
|
5277
5334
|
const stored = await teachFact(memoryDir, sessionId, {
|
|
5278
5335
|
subject: subj, predicate: await capabilityPredicate(habitualTeach.negated), object: habitualTeach.verb,
|
|
5279
5336
|
});
|
|
@@ -5294,7 +5351,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
5294
5351
|
// STATIC lexicon (or a prior taught fact) already grounds can mint a
|
|
5295
5352
|
// brand-new object term. See unknownObjectFallback's own docblock for the
|
|
5296
5353
|
// exact narrowing rules (the "both sides ungrounded" safety guard, etc.).
|
|
5297
|
-
const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent }, cache);
|
|
5354
|
+
const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent, graph }, cache);
|
|
5298
5355
|
if (objectFallback) return objectFallback;
|
|
5299
5356
|
// ADJECTIVE-MINT fallback: tried right after unknownObjectFallback
|
|
5300
5357
|
// declines, so a grounded subject (static
|
|
@@ -5303,7 +5360,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
5303
5360
|
// docblock for the exact narrowing rules (the "both sides ungrounded"
|
|
5304
5361
|
// safety guard, and why this must be a standalone function rather than
|
|
5305
5362
|
// nested inside unknownSubjectFallback).
|
|
5306
|
-
const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
|
|
5363
|
+
const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph }, cache);
|
|
5307
5364
|
if (adjectiveFallback) return adjectiveFallback;
|
|
5308
5365
|
// PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
|
|
5309
5366
|
// (a bare "X is deprecated" is never silently reified), and only after the
|
|
@@ -5366,7 +5423,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
|
|
|
5366
5423
|
// Grounding NUDGE: APPENDED, never a replacement, exactly like "did" above
|
|
5367
5424
|
// — see ungroundedPairHint's own docblock for why this is scoped to the
|
|
5368
5425
|
// "both sides ungrounded, fits the X is/are Y shape" case only.
|
|
5369
|
-
const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
|
|
5426
|
+
const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache, graph);
|
|
5370
5427
|
return {
|
|
5371
5428
|
text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
|
|
5372
5429
|
+ `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
|
|
@@ -11992,10 +12049,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
11992
12049
|
if (cmp) {
|
|
11993
12050
|
const [, form, cmpOp, clauseSubject, participle] = cmp;
|
|
11994
12051
|
const verb = participle.toLowerCase();
|
|
12052
|
+
const temporalGoal = "compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)";
|
|
11995
12053
|
const refMiss = (text) => {
|
|
11996
|
-
note(trace,
|
|
12054
|
+
note(trace, `goal: ${temporalGoal}`);
|
|
11997
12055
|
note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" could not compose a comparison; a specific miss names why, never the teach-offer cascade`);
|
|
11998
|
-
return plainTurn(query, text, { via: "miss", miss: true, focus });
|
|
12056
|
+
return plainTurn(query, text, { via: "miss", miss: true, focus, goal: temporalGoal });
|
|
11999
12057
|
};
|
|
12000
12058
|
const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form) : null;
|
|
12001
12059
|
if (!bound?.referent) {
|
|
@@ -12021,9 +12079,9 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12021
12079
|
const holds = cmpOp.toLowerCase() === "before" ? refDay < clauseDay : refDay > clauseDay;
|
|
12022
12080
|
const relation = refDay < clauseDay ? "came before" : refDay > clauseDay ? "came after" : "landed on the same day as";
|
|
12023
12081
|
const text = `${holds ? "Yes" : "No"} — ${bound.referent.label} (${refDay}) ${relation} ${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay}).`;
|
|
12024
|
-
note(trace,
|
|
12082
|
+
note(trace, `goal: ${temporalGoal}`);
|
|
12025
12083
|
note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" bound ${bound.referent.label} (${refDay}) through the discourse record; the embedded clause re-ran as its own when-question`);
|
|
12026
|
-
const turn = plainTurn(query, text, { via: "composed", miss: false, focus });
|
|
12084
|
+
const turn = plainTurn(query, text, { via: "composed", miss: false, focus, goal: temporalGoal });
|
|
12027
12085
|
const cited = [graph.byId?.get?.(bound.referent.ids[0]), freshCommit].filter(Boolean);
|
|
12028
12086
|
turn.detail = { traversal: `discourse ${bound.referent.ref} (${refDay}) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
|
|
12029
12087
|
return turn;
|
|
@@ -12043,7 +12101,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12043
12101
|
note(trace, "goal: recover a name history the index does not record (honest decline)");
|
|
12044
12102
|
note(trace, "lane: RENAME_HISTORY_RE — the index carries no rename data, so the calls-relation misread is refused by name");
|
|
12045
12103
|
return plainTurn(query, `I can't say what ${ent ? ent.label : `"${term}"`} was called before — this index records current names only, no rename history. ${named}${ent ? ` here; "who touched ${ent.label}" lists its recorded commits` : ""}.`, {
|
|
12046
|
-
via: "miss", miss: true, focus,
|
|
12104
|
+
via: "miss", miss: true, focus, goal: "recover a name history the index does not record (honest decline)",
|
|
12047
12105
|
});
|
|
12048
12106
|
}
|
|
12049
12107
|
}
|
|
@@ -12055,10 +12113,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12055
12113
|
{
|
|
12056
12114
|
const wikiTerm = wikipediaAskTerm(query);
|
|
12057
12115
|
if (wikiTerm) {
|
|
12058
|
-
|
|
12116
|
+
const wikiGoal = "read what Wikipedia says about a named term (explicit source request)";
|
|
12117
|
+
note(trace, `goal: ${wikiGoal}`);
|
|
12059
12118
|
if (!liveReference) {
|
|
12060
12119
|
note(trace, "lane: WIKIPEDIA ASK — the explicit request needs the network opt-in; live Wikipedia is off");
|
|
12061
|
-
return plainTurn(query, `live Wikipedia is off, so I won't reach the network. Turn it on with /wiki on (it fetches from en.wikipedia.org), then ask again.`, { via: "miss", miss: true, focus });
|
|
12120
|
+
return plainTurn(query, `live Wikipedia is off, so I won't reach the network. Turn it on with /wiki on (it fetches from en.wikipedia.org), then ask again.`, { via: "miss", miss: true, focus, goal: wikiGoal });
|
|
12062
12121
|
}
|
|
12063
12122
|
let liveKey = null;
|
|
12064
12123
|
try { liveKey = cleanMissLiveTerm(wikiTerm, lexicon ?? undefined); } catch { liveKey = null; }
|
|
@@ -12066,10 +12125,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12066
12125
|
if (live) {
|
|
12067
12126
|
await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
|
|
12068
12127
|
note(trace, `lane: WIKIPEDIA ASK — answered from a live en.wikipedia.org lookup, cited (article "${live.article.title}", revid ${live.article.revid})`);
|
|
12069
|
-
return plainTurn(query, live.text, { via: "reference", miss: false, focus });
|
|
12128
|
+
return plainTurn(query, live.text, { via: "reference", miss: false, focus, goal: wikiGoal });
|
|
12070
12129
|
}
|
|
12071
12130
|
note(trace, "lane: WIKIPEDIA ASK — no matching live article (no title, timeout, throttle, or drift-guard reject)");
|
|
12072
|
-
return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus });
|
|
12131
|
+
return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus, goal: wikiGoal });
|
|
12073
12132
|
}
|
|
12074
12133
|
}
|
|
12075
12134
|
// COLLECTIVE PLURAL SUBJECT — see COLLECTIVE_FORWARD_RE. Members are the
|
|
@@ -12095,9 +12154,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12095
12154
|
const text = labels.length
|
|
12096
12155
|
? `the ${stem} here are ${memberList} — together they ${verb}: ${joinList(labels)}.`
|
|
12097
12156
|
: `the ${stem} here are ${memberList} — none of them has ${verb} edges in the index.`;
|
|
12098
|
-
|
|
12157
|
+
const groupGoal = `read a forward relation over a module GROUP (${members.length} members), unioned with the set disclosed`;
|
|
12158
|
+
note(trace, `goal: ${groupGoal}`);
|
|
12099
12159
|
note(trace, `lane: COLLECTIVE_FORWARD_RE — "${stem}" resolved to ${members.length} modules; answered the union, never a silent single best-match`);
|
|
12100
|
-
const turn = plainTurn(query, text, { via: "composed", miss: !labels.length, focus });
|
|
12160
|
+
const turn = plainTurn(query, text, { via: "composed", miss: !labels.length, focus, goal: groupGoal });
|
|
12101
12161
|
turn.detail = { traversal: `${verb} edges unioned over ${memberList}`, matches: [...union.keys()].map((id) => graph.byId?.get?.(id)).filter(Boolean) };
|
|
12102
12162
|
return turn;
|
|
12103
12163
|
}
|
|
@@ -12122,7 +12182,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12122
12182
|
note(trace, "goal: recover a move history the index does not record (premise denied, current location cited)");
|
|
12123
12183
|
note(trace, "lane: MOVE_HISTORY_RE — no move data exists; the current location answers with the premise named");
|
|
12124
12184
|
return plainTurn(query, `this index records current locations only, so I can't confirm ${ent.label} moved anywhere.${located}`, {
|
|
12125
|
-
via: "composed", miss: false, focus,
|
|
12185
|
+
via: "composed", miss: false, focus, goal: "recover a move history the index does not record (premise denied, current location cited)",
|
|
12126
12186
|
});
|
|
12127
12187
|
}
|
|
12128
12188
|
}
|
|
@@ -12140,7 +12200,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12140
12200
|
note(trace, `lane: DECISION_RECALL_RE — routed to the folded-session recall surface, ${recalled ? "a relevant block answered" : "nothing relevant folded (honest miss)"}`);
|
|
12141
12201
|
return plainTurn(query, recalled
|
|
12142
12202
|
?? `I don't have a recorded decision about "${term}" — I keep facts and session transcripts, and nothing folded mentions deciding on it. "what did i ask before" lists the last session's questions.`, {
|
|
12143
|
-
via: recalled ? "recall" : "miss", miss: !recalled, focus,
|
|
12203
|
+
via: recalled ? "recall" : "miss", miss: !recalled, focus, goal: "recall a decision from the conversation record (session-recall surface)",
|
|
12144
12204
|
});
|
|
12145
12205
|
}
|
|
12146
12206
|
}
|
|
@@ -12152,7 +12212,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
12152
12212
|
const summary = await recallSummary(memoryDir);
|
|
12153
12213
|
note(trace, summary ? "source: memory/fold.mjs recallSummary" : "intermediate: no folded session blocks yet — nothing to recall");
|
|
12154
12214
|
return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
|
|
12155
|
-
via: "recall", miss: !summary, focus,
|
|
12215
|
+
via: "recall", miss: !summary, focus, goal: "recall what was discussed earlier (explicit recall phrasing)",
|
|
12156
12216
|
});
|
|
12157
12217
|
}
|
|
12158
12218
|
// An explicit "this file"/"that module" kind-noun scope signal is collapsed
|
|
@@ -13400,7 +13460,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
13400
13460
|
|
|
13401
13461
|
/** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
|
|
13402
13462
|
* { answer, logLines, record, focus } shape, recorded like any other turn. */
|
|
13403
|
-
function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null } = {}) {
|
|
13463
|
+
function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null, goal = null } = {}) {
|
|
13404
13464
|
const ts = new Date().toISOString();
|
|
13405
13465
|
return {
|
|
13406
13466
|
answer,
|
|
@@ -13412,6 +13472,10 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
|
|
|
13412
13472
|
canonical,
|
|
13413
13473
|
},
|
|
13414
13474
|
focus,
|
|
13475
|
+
// Absent by default, so an untouched plainTurn call stays trailer-free.
|
|
13476
|
+
// A caller that already holds a precise goal string passes it here, and
|
|
13477
|
+
// withGoalLine appends the "Goal (inferred): …" line for that turn.
|
|
13478
|
+
...(goal ? { goal } : {}),
|
|
13415
13479
|
};
|
|
13416
13480
|
}
|
|
13417
13481
|
|
|
@@ -13895,7 +13959,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
|
|
|
13895
13959
|
.map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
|
|
13896
13960
|
.join(", ")).join(" | "),
|
|
13897
13961
|
};
|
|
13898
|
-
return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
|
|
13962
|
+
return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical, goal: "teach/remember a new fact" });
|
|
13899
13963
|
}
|
|
13900
13964
|
const parse = parseAce(line, lex);
|
|
13901
13965
|
if (!parse || !parse.triples?.length || parse.residue?.length) return null;
|
|
@@ -13972,7 +14036,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
|
|
|
13972
14036
|
.map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
|
|
13973
14037
|
.join(", "),
|
|
13974
14038
|
};
|
|
13975
|
-
return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
|
|
14039
|
+
return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical, goal: "teach/remember a new fact" });
|
|
13976
14040
|
} catch {
|
|
13977
14041
|
return null; // grammar unavailable / write failed — fall through to the engine
|
|
13978
14042
|
}
|
|
@@ -14878,7 +14942,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
14878
14942
|
const step = await executePlanStep(planHolder, { memoryDir, sessionId, gameConfig: resolvedGameConfig });
|
|
14879
14943
|
note(trace, `goal: ${step.deduced}`);
|
|
14880
14944
|
note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
|
|
14881
|
-
const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus });
|
|
14945
|
+
const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus, goal: step.deduced });
|
|
14882
14946
|
stepTurn.lane = "imperative";
|
|
14883
14947
|
const rec = withLast(stepTurn, step.deduced);
|
|
14884
14948
|
rec.planState = planHolder.state;
|
|
@@ -14899,7 +14963,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
14899
14963
|
if (follow) {
|
|
14900
14964
|
note(trace, `goal: ${follow.deduced}`);
|
|
14901
14965
|
note(trace, `lane: ${follow.note}`);
|
|
14902
|
-
const rec = withLast(plainTurn(workingLine, follow.text, { via: "plan", focus }), follow.deduced);
|
|
14966
|
+
const rec = withLast(plainTurn(workingLine, follow.text, { via: "plan", focus, goal: follow.deduced }), follow.deduced);
|
|
14903
14967
|
rec.planState = planHolder.state;
|
|
14904
14968
|
return rec;
|
|
14905
14969
|
}
|
|
@@ -15021,7 +15085,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
15021
15085
|
note(trace, "goal: teach/remember a new fact (bare declarative taxonomy)");
|
|
15022
15086
|
note(trace, "lane: bareTaxonomyTeach — hyphenated-instance or article-led kind-of declarative, stored before the ask engine could parse it as a question");
|
|
15023
15087
|
const taxonomyTurn = plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus });
|
|
15024
|
-
if (!taxonomy.miss) taxonomyTurn.lane = "teach";
|
|
15088
|
+
if (!taxonomy.miss) { taxonomyTurn.lane = "teach"; taxonomyTurn.goal = "teach/remember a new fact"; }
|
|
15025
15089
|
return withLast(taxonomyTurn, "teach/remember a new fact");
|
|
15026
15090
|
}
|
|
15027
15091
|
}
|
|
@@ -15066,6 +15130,10 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
15066
15130
|
note(trace, `goal: ${goal}`);
|
|
15067
15131
|
note(trace, "lane: answerMemoryClassQuery — matched a memory-store class noun, answered off the .tmct/memory store's own individuals");
|
|
15068
15132
|
const turn = plainTurn(workingLine, memClass.text, { via: memClass.miss ? "miss" : "fact", miss: !!memClass.miss, focus });
|
|
15133
|
+
// A bare numeric count ("1 source.") stays silent, matching every other
|
|
15134
|
+
// count lane's tested contract — only a real list enumeration gets the
|
|
15135
|
+
// trailer. answerMemoryClassQuery serves both shapes through one lane.
|
|
15136
|
+
if (!memClass.miss && memClass.kind !== "count") turn.goal = goal;
|
|
15069
15137
|
if (memClass.pending) turn.detail = { traversal: null, matches: [], pending: memClass.pending };
|
|
15070
15138
|
return withLast(turn, goal);
|
|
15071
15139
|
}
|
|
@@ -15091,6 +15159,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
|
|
|
15091
15159
|
note(trace, `goal: ${goal}`);
|
|
15092
15160
|
note(trace, "lane: answerMembershipList — matched a bare 'list <noun>' over taught isa-facts whose OBJECT is that class");
|
|
15093
15161
|
const turn = plainTurn(workingLine, memberList.text, { via: memberList.miss ? "miss" : "fact", miss: !!memberList.miss, focus });
|
|
15162
|
+
if (!memberList.miss) turn.goal = goal;
|
|
15094
15163
|
if (memberList.pending) turn.detail = { traversal: null, matches: [], pending: memberList.pending };
|
|
15095
15164
|
return withLast(turn, goal);
|
|
15096
15165
|
}
|
|
@@ -53,7 +53,8 @@ import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
|
|
|
53
53
|
import { tmpdir } from "node:os";
|
|
54
54
|
import { basename, join, resolve } from "node:path";
|
|
55
55
|
|
|
56
|
-
import { runTurn, uuidv7 } from "./chat.mjs";
|
|
56
|
+
import { runTurn, uuidv7, stripLeadingDiscourseAdverb } from "./chat.mjs";
|
|
57
|
+
import { beginsWithVowelSound, grammarRules } from "./finish.mjs";
|
|
57
58
|
import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences.mjs";
|
|
58
59
|
import { loadMemory, readFactRows, appendFact } from "../adapters/memory/core.mjs";
|
|
59
60
|
import { loadConfig } from "../adapters/config.mjs";
|
|
@@ -125,6 +126,12 @@ const RELATIVE_PRONOUNS = new Set(["that", "which", "who", "whom", "whose"]);
|
|
|
125
126
|
// At most this many triples from one sentence — a bound so a run-on can never
|
|
126
127
|
// shatter into noise, not a first-wins cap.
|
|
127
128
|
const MAX_TRIPLES_PER_SENTENCE = 4;
|
|
129
|
+
// How far a copula object scan walks past an attributive-adjective compound
|
|
130
|
+
// (wink tokenizes "medium-sized" as NOUN + "-" + VERB and never re-fuses it) to
|
|
131
|
+
// reach the real head noun through a coordinate modifier list
|
|
132
|
+
// (", burrowing, nocturnal mammal"). A small, explicit bound: past it the object
|
|
133
|
+
// abstains rather than guess, so a long noun pile never mints a stray class.
|
|
134
|
+
const ATTRIBUTIVE_CHAIN_MAX_HOPS = 8;
|
|
128
135
|
|
|
129
136
|
/** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
|
|
130
137
|
* word's own normFactTerm (the optimistic tier mints unlisted content nouns
|
|
@@ -242,6 +249,25 @@ function optimisticTriplesPos(sentence, lexicon, nlp) {
|
|
|
242
249
|
if (!isNounish(j)) continue;
|
|
243
250
|
let hi = j;
|
|
244
251
|
while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
|
|
252
|
+
// A NOUN immediately followed by "-" then a VERB or ADJ is the left half of
|
|
253
|
+
// an attributive-adjective compound wink never re-fused ("medium-sized"),
|
|
254
|
+
// not the class. Walk forward through the coordinate modifier list (hyphens,
|
|
255
|
+
// commas, "and", further ADJ/VERB tokens) to the real head noun and re-point
|
|
256
|
+
// there; abstain if none appears within the bound, never mint the modifier.
|
|
257
|
+
if (values[hi + 1] === "-" && (pos[hi + 2] === "VERB" || pos[hi + 2] === "ADJ")) {
|
|
258
|
+
let head = null;
|
|
259
|
+
let k = hi + 1;
|
|
260
|
+
for (let hop = 0; hop < ATTRIBUTIVE_CHAIN_MAX_HOPS && k < values.length; hop += 1, k += 1) {
|
|
261
|
+
if (isNounish(k)) { head = k; break; }
|
|
262
|
+
const w = values[k]?.toLowerCase();
|
|
263
|
+
if (w === "-" || w === "," || w === "and" || pos[k] === "ADJ" || pos[k] === "VERB" || pos[k] === "CCONJ") continue;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
if (head === null) return null;
|
|
267
|
+
j = head;
|
|
268
|
+
hi = head;
|
|
269
|
+
while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
|
|
270
|
+
}
|
|
245
271
|
const headWord = String(values[hi]).toLowerCase();
|
|
246
272
|
const nextIsOf = values[hi + 1]?.toLowerCase() === "of";
|
|
247
273
|
if (!nextIsOf) return { label: entityRunAt(j), hi };
|
|
@@ -434,6 +460,17 @@ export function clauseCandidates(sentence, { nlp } = {}) {
|
|
|
434
460
|
// live focus, never a stale paragraph carry.
|
|
435
461
|
const PRONOUN_LEAD_RE = /^(?:they|it|these|those|this)\b\s*/i;
|
|
436
462
|
|
|
463
|
+
/** Re-article a bare carried subject so the retried sentence is a grammatical
|
|
464
|
+
* habitual surface the recognizer accepts: "cell" → "a cell", "orbit" → "an
|
|
465
|
+
* orbit". Uses the same vowel-sound-aware article rule (grammar-rules.toml)
|
|
466
|
+
* the chat recognizer's own capability rewrite uses, rather than a hardcoded
|
|
467
|
+
* "a". */
|
|
468
|
+
function articledSubject(subject) {
|
|
469
|
+
const articleRule = grammarRules().find((r) => r.kind === "article");
|
|
470
|
+
const article = articleRule && beginsWithVowelSound(subject, articleRule) ? "an" : "a";
|
|
471
|
+
return `${article} ${subject}`;
|
|
472
|
+
}
|
|
473
|
+
|
|
437
474
|
/** A readable predicate for canonical output: the local part of an rdfs:/ace:
|
|
438
475
|
* CURIE, otherwise the predicate verbatim. */
|
|
439
476
|
const readablePredicate = (predicate) => String(predicate).replace(/^[a-z]+:/i, "");
|
|
@@ -532,9 +569,14 @@ export async function ingestText(text, {
|
|
|
532
569
|
}
|
|
533
570
|
// Bounded pronoun carry: a "they/it/these/those/this …" sentence the
|
|
534
571
|
// recognizer skipped is retried once with the paragraph's last grounded
|
|
535
|
-
// subject in the pronoun's place.
|
|
536
|
-
|
|
537
|
-
|
|
572
|
+
// subject in the pronoun's place. A leading ordinal/temporal discourse
|
|
573
|
+
// adverb ("Then it splits.") is stripped first so the pronoun reaches
|
|
574
|
+
// the sentence front; the carried subject is re-articled ("a cell") so
|
|
575
|
+
// the retry is a grammatical habitual surface. Never a chat turn —
|
|
576
|
+
// ingest only.
|
|
577
|
+
const threaded = stripLeadingDiscourseAdverb(cleaned);
|
|
578
|
+
if (!rows && carrySubject && PRONOUN_LEAD_RE.test(threaded)) {
|
|
579
|
+
rows = await strictRows(threaded.replace(PRONOUN_LEAD_RE, `${articledSubject(carrySubject)} `));
|
|
538
580
|
}
|
|
539
581
|
if (rows) {
|
|
540
582
|
recognizedSentences += 1;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { normFactTerm } from "../domain/hash.mjs";
|
|
27
27
|
import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
|
|
28
|
+
import { defaultNlp } from "../domain/interpret/nlp-registry.mjs";
|
|
28
29
|
|
|
29
30
|
/** The search key a topic folds to: normFactTerm, then the lexicon lemma
|
|
30
31
|
* when the noun is known ("owls" → "owl") — the same fold the live
|
|
@@ -210,12 +211,87 @@ function queuedFolds(state) {
|
|
|
210
211
|
return seen;
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
// The lexical-token split a fan-out candidate and the seed topic both fold
|
|
215
|
+
// through before comparison — lowercased, split on anything that isn't a
|
|
216
|
+
// letter or digit, empty pieces dropped.
|
|
217
|
+
const LEXICAL_SPLIT_RE = /[^a-z0-9]+/i;
|
|
218
|
+
function lexicalTokens(text) {
|
|
219
|
+
return String(text || "").toLowerCase().split(LEXICAL_SPLIT_RE).filter(Boolean);
|
|
220
|
+
}
|
|
221
|
+
function sharesLexicalToken(title, seedTokens) {
|
|
222
|
+
if (!seedTokens.size) return false;
|
|
223
|
+
for (const tok of lexicalTokens(title)) if (seedTokens.has(tok)) return true;
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// A citation-shaped candidate: an ISBN/DOI prefix, a bare year, or a
|
|
228
|
+
// "Nth century" phrase — the shape raw Wikipedia link lists carry for their
|
|
229
|
+
// source apparatus rather than a kin article.
|
|
230
|
+
const CITATION_PREFIX_RE = /^(isbn|doi)[\s:.-]/i;
|
|
231
|
+
const CITATION_BARE_YEAR_RE = /^\d{3,4}$/;
|
|
232
|
+
const CITATION_CENTURY_RE = /\b\d{1,2}(?:st|nd|rd|th)\s+century\b/i;
|
|
233
|
+
function isCitationShaped(title) {
|
|
234
|
+
const t = String(title || "").trim();
|
|
235
|
+
if (!t) return false;
|
|
236
|
+
return CITATION_PREFIX_RE.test(t) || CITATION_BARE_YEAR_RE.test(t) || CITATION_CENTURY_RE.test(t);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const SINGLE_WORD_TITLE_RE = /^[A-Za-z][A-Za-z'-]*$/;
|
|
240
|
+
// A short, neutral sentence to tag a bare candidate word inside — wink-nlp
|
|
241
|
+
// reads an isolated capitalized word as PROPN with no sentence context to
|
|
242
|
+
// tell it otherwise, so the word is lowercased and read at this fixed slot
|
|
243
|
+
// inside real subject/verb/object context instead.
|
|
244
|
+
const NOUN_CARRIER_PREFIX = ["this", "is", "about", "the"];
|
|
245
|
+
const NOUN_CARRIER_SUFFIX = ["and", "its", "history"];
|
|
246
|
+
const NOUN_CARRIER_WORD_INDEX = NOUN_CARRIER_PREFIX.length;
|
|
247
|
+
|
|
248
|
+
/** True when `title` is a single word a general-English POS tagger reads as
|
|
249
|
+
* a common noun (hub articles like "Earth"/"Geology") rather than a proper
|
|
250
|
+
* noun (kin articles like "Hawaii") — false whenever `nlp` is unavailable,
|
|
251
|
+
* never a throw. */
|
|
252
|
+
function readsAsCommonNoun(title, nlp) {
|
|
253
|
+
if (!nlp || typeof nlp.posTags !== "function") return false;
|
|
254
|
+
const t = String(title || "").trim();
|
|
255
|
+
if (!SINGLE_WORD_TITLE_RE.test(t)) return false;
|
|
256
|
+
const carrier = [...NOUN_CARRIER_PREFIX, t.toLowerCase(), ...NOUN_CARRIER_SUFFIX];
|
|
257
|
+
let tags;
|
|
258
|
+
try { tags = nlp.posTags(carrier); } catch { return false; }
|
|
259
|
+
return Array.isArray(tags) && tags[NOUN_CARRIER_WORD_INDEX] === "NOUN";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Stable-sorts fan-out candidates into relevance tiers — never reorders
|
|
263
|
+
* within a tier, only reprioritizes between them — from information the
|
|
264
|
+
* runtime actually has (title strings, the seed topic, an optional POS
|
|
265
|
+
* tagger), never bench-only ground truth:
|
|
266
|
+
* 0. shares a lexical token with `seedTopic` ("Active volcano" ~ "Volcano");
|
|
267
|
+
* 1. everything else, in original document order (the fallback tier);
|
|
268
|
+
* 2. a single-word title a POS tagger reads as a common noun rather than a
|
|
269
|
+
* proper noun — the hub-article signal ("Earth", "Geology");
|
|
270
|
+
* 3. a citation-shaped title (ISBN/DOI prefix, bare year, "Nth century").
|
|
271
|
+
* With no `nlp` adapter registered, tier 2 never fires (everything that
|
|
272
|
+
* would have landed there stays in tier 1) — degrades gracefully, never
|
|
273
|
+
* throws, never drops a candidate. */
|
|
274
|
+
export function relevanceOrder(titles, seedTopic, nlp = null) {
|
|
275
|
+
const list = Array.isArray(titles) ? titles : [];
|
|
276
|
+
const seedTokens = new Set(lexicalTokens(seedTopic));
|
|
277
|
+
const tiers = [[], [], [], []];
|
|
278
|
+
for (const title of list) {
|
|
279
|
+
if (sharesLexicalToken(title, seedTokens)) { tiers[0].push(title); continue; }
|
|
280
|
+
if (isCitationShaped(title)) { tiers[3].push(title); continue; }
|
|
281
|
+
if (readsAsCommonNoun(title, nlp)) { tiers[2].push(title); continue; }
|
|
282
|
+
tiers[1].push(title);
|
|
283
|
+
}
|
|
284
|
+
return [...tiers[0], ...tiers[1], ...tiers[2], ...tiers[3]];
|
|
285
|
+
}
|
|
286
|
+
|
|
213
287
|
/** Queue `article`'s lead-section links at `fromDepth + 1`, subject to the run's
|
|
214
288
|
* depth ceiling, its per-fan-out cap and — crucially — its TOTAL node budget:
|
|
215
289
|
* the number added never pushes grounded+pending past `maxTopics`. Sets
|
|
216
290
|
* `state.nodeCapReached` when the budget (not the depth, not a lack of links)
|
|
217
|
-
* is what stopped the fan-out, so the progress line can say so.
|
|
218
|
-
*
|
|
291
|
+
* is what stopped the fan-out, so the progress line can say so. Candidates are
|
|
292
|
+
* relevance-ordered (relevanceOrder) before the fan-out cap truncates them, so
|
|
293
|
+
* a capped fetch keeps kin articles over generic hubs. Returns the titles it
|
|
294
|
+
* enqueued. */
|
|
219
295
|
async function enqueueFrom(state, article, fromDepth, provider) {
|
|
220
296
|
const childDepth = fromDepth + 1;
|
|
221
297
|
if (childDepth > runMaxDepth(state)) return [];
|
|
@@ -226,10 +302,11 @@ async function enqueueFrom(state, article, fromDepth, provider) {
|
|
|
226
302
|
if (want <= 0) { state.nodeCapReached = true; return []; }
|
|
227
303
|
let linked = null;
|
|
228
304
|
try { linked = await provider.linkedTitles(article.title, { limit: want + 2 }); } catch { linked = null; }
|
|
305
|
+
const ordered = relevanceOrder(linked || [], article.title, defaultNlp());
|
|
229
306
|
const seen = queuedFolds(state);
|
|
230
307
|
if (!state.depths) state.depths = {};
|
|
231
308
|
const added = [];
|
|
232
|
-
for (const title of
|
|
309
|
+
for (const title of ordered) {
|
|
233
310
|
const folded = normFactTerm(title);
|
|
234
311
|
if (!folded || seen.has(folded)) continue;
|
|
235
312
|
seen.add(folded);
|