@polycode-projects/the-mechanical-code-talker 1.0.4 → 1.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/package.json +1 -1
- package/src/ask.mjs +189 -16
- package/src/chat.mjs +41 -5
- package/src/interpret/normalize.mjs +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
package/src/ask.mjs
CHANGED
|
@@ -291,6 +291,14 @@ const NEST_SENTINEL = "zzinnerset";
|
|
|
291
291
|
// then"/"what about X though".
|
|
292
292
|
const PRED_LEAD_SKIP = new Set(["that", "which", "who", "are", "is", "was", "were", "do", "does", "also", "still", "both", "and", "then", "though"]);
|
|
293
293
|
const FRAME_WORDS = new Set(["which", "what", "who", "list", "show", "find", "give", "me", "us", "all"]);
|
|
294
|
+
// A bare copula leading a boolean branch ("...and ARE untested") is discourse glue,
|
|
295
|
+
// not part of the qualifier — dropped before a branch is tested/used as a
|
|
296
|
+
// qualifier-only atom (both the marker-gate probe below and the two atom-building
|
|
297
|
+
// folds — buildPredicateAtoms, parseRelationalOrQualified's own fold — apply it).
|
|
298
|
+
const COPULA_WORDS = new Set(["are", "is", "was", "were"]);
|
|
299
|
+
function dropLeadCopula(bw, blc) {
|
|
300
|
+
return blc.length && COPULA_WORDS.has(blc[0]) ? { bw: bw.slice(1), blc: blc.slice(1) } : { bw, blc };
|
|
301
|
+
}
|
|
294
302
|
|
|
295
303
|
const entityNoun = (w) => (ENTITY_TO_TYPE[w] ? { entityType: ENTITY_TO_TYPE[w], placeholder: false }
|
|
296
304
|
: (PLACEHOLDER_NOUNS.includes(w) ? { entityType: null, placeholder: true } : null));
|
|
@@ -312,6 +320,7 @@ function parseComposite(text, nlp) {
|
|
|
312
320
|
const w = splitWords(text);
|
|
313
321
|
const lc = w.map((x) => x.toLowerCase());
|
|
314
322
|
return parseExistence(w, lc)
|
|
323
|
+
|| parseQualifierCheck(w, lc)
|
|
315
324
|
|| parseNegation(text, nlp, 0)
|
|
316
325
|
|| parseForwardNegation(w, lc, nlp)
|
|
317
326
|
|| parseTemporal(w, lc, nlp, 0)
|
|
@@ -647,6 +656,43 @@ function parseExistence(w, lc) {
|
|
|
647
656
|
// different (relationship) question; leave it for the parsers below.
|
|
648
657
|
}
|
|
649
658
|
|
|
659
|
+
/** QUALIFIER-CHECK: "is <term> [a/an] <qualifier> [<kind>]?", "is <term> not
|
|
660
|
+
* <qualifier> …" — a single-ENTITY Yes/No property check ("is Task.title
|
|
661
|
+
* public", "is it exported", "is that class abstract"), reusing the SAME
|
|
662
|
+
* closed QUALIFIERS vocabulary and qualHolds() evaluator the attributive/
|
|
663
|
+
* predicative-survey filters already fold over a SET ("public methods",
|
|
664
|
+
* "which methods are public") — this is the missing single-entity sibling
|
|
665
|
+
* (0.9.15 Tier-1 single-touch playtest: "is it a public attribute?", a
|
|
666
|
+
* natural follow-up to a concept-force touch, had no recognizer at all and
|
|
667
|
+
* hit the bare grammar wall — even "is Task.title public", a concretely
|
|
668
|
+
* NAMED entity with no anaphora involved, walled the same way). Scoped
|
|
669
|
+
* tight: a leading "is"/"are", then TERM tokens up to the FIRST recognized
|
|
670
|
+
* qualifier word — a leading "the" and a trailing "a"/"an" article around
|
|
671
|
+
* the boundary are dropped, and a trailing decorative kind noun ("… public
|
|
672
|
+
* ATTRIBUTE") is simply never consumed, never required to agree with the
|
|
673
|
+
* resolved entity's real class. Guarded off "is/are THERE …" (parseExistence
|
|
674
|
+
* above owns that shape) and off any text with no qualifier word at all, so
|
|
675
|
+
* it can never swallow a genuine relationship/existence question. The term
|
|
676
|
+
* is resolved at EVAL time (a pronoun binds through the standing contextId,
|
|
677
|
+
* exactly like every other object term), never here. */
|
|
678
|
+
function parseQualifierCheck(w, lc) {
|
|
679
|
+
if (lc[0] !== "is" && lc[0] !== "are") return null;
|
|
680
|
+
if (lc[1] === "there") return null; // parseExistence's own shape
|
|
681
|
+
let qualIdx = -1;
|
|
682
|
+
let negated = false;
|
|
683
|
+
for (let i = 1; i < lc.length; i += 1) {
|
|
684
|
+
if (QUALIFIERS[lc[i]]) { qualIdx = i; negated = lc[i - 1] === "not"; break; }
|
|
685
|
+
}
|
|
686
|
+
if (qualIdx < 0) return null; // no qualifier word at all → not this shape
|
|
687
|
+
let termStart = 1;
|
|
688
|
+
if (lc[termStart] === "the") termStart += 1;
|
|
689
|
+
let termEnd = negated ? qualIdx - 1 : qualIdx;
|
|
690
|
+
if (termEnd > termStart && (lc[termEnd - 1] === "a" || lc[termEnd - 1] === "an")) termEnd -= 1;
|
|
691
|
+
const term = termEnd > termStart ? w.slice(termStart, termEnd).join(" ").trim() : "";
|
|
692
|
+
if (!term) return { node: "miss", reason: `"is/are <qualifier>" needs a named thing to check first` };
|
|
693
|
+
return { node: "qualCheck", term, qualifier: lc[qualIdx], negated };
|
|
694
|
+
}
|
|
695
|
+
|
|
650
696
|
/** Trailing "and that's the whole question" filler an aggregate/list tail can carry
|
|
651
697
|
* ("how many classes are there", "list functions in total", "which classes exist in
|
|
652
698
|
* the index") — a count/list over a bare kind is frequently phrased with such a tail,
|
|
@@ -930,7 +976,8 @@ function buildPredicateAtoms(entityType, subjPrefix, predLc, predWords, nlp, dep
|
|
|
930
976
|
const bw = branches[b];
|
|
931
977
|
const blc = bw.map((x) => x.toLowerCase());
|
|
932
978
|
const op = b === 0 ? "intersection" : ops[b - 1];
|
|
933
|
-
|
|
979
|
+
const qc = dropLeadCopula(bw, blc);
|
|
980
|
+
if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
|
|
934
981
|
if (blc[0] === "of" || blc[0] === "in") {
|
|
935
982
|
atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
|
|
936
983
|
continue;
|
|
@@ -1005,9 +1052,20 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1005
1052
|
if (predLc.length && RELATIVE_PRONOUNS.includes(predLc[0])) { relFlag = true; predLc = predLc.slice(1); predWords = predWords.slice(1); }
|
|
1006
1053
|
const membershipLed = predLc[0] === "of" || predLc[0] === "in";
|
|
1007
1054
|
const gerundLed = predLc.length > 0 && isGerundVerb(predLc[0]);
|
|
1055
|
+
// A boolean branch whose OWN content — past an optional leading copula ("and ARE
|
|
1056
|
+
// untested") — collapses to qualifier words alone is the compositional shape too
|
|
1057
|
+
// ("functions that call X and are untested": verb clause AND qualifier, same
|
|
1058
|
+
// subject). Probe with the same splitBoolean+QUALIFIERS fold the atoms loop below
|
|
1059
|
+
// uses, so a bare verb+verb boolean chain with NO qualifier signal anywhere
|
|
1060
|
+
// ("which classes extends Base and couples to logging") still has no marker here
|
|
1061
|
+
// and correctly stays on the legacy ambiguous-parse path, untouched.
|
|
1062
|
+
const boolQualLed = predWords.length > 0 && splitBoolean(predLc, predWords).branches.some((bw) => {
|
|
1063
|
+
const { blc } = dropLeadCopula(bw, bw.map((x) => x.toLowerCase()));
|
|
1064
|
+
return blc.length && blc.every((x) => QUALIFIERS[x]);
|
|
1065
|
+
});
|
|
1008
1066
|
// marker gate — the crux of backward-compat: without one of these, this is not a
|
|
1009
1067
|
// compositional query and we must NOT hijack it from the existing parser.
|
|
1010
|
-
if (!(quals.length || relFlag || membershipLed || gerundLed)) return null;
|
|
1068
|
+
if (!(quals.length || relFlag || membershipLed || gerundLed || boolQualLed)) return null;
|
|
1011
1069
|
|
|
1012
1070
|
// empty predicate → a bare qualified class ("public methods")
|
|
1013
1071
|
if (!predWords.length) {
|
|
@@ -1026,7 +1084,8 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1026
1084
|
const bw = branches[b];
|
|
1027
1085
|
const blc = bw.map((x) => x.toLowerCase());
|
|
1028
1086
|
const op = b === 0 ? "seed" : ops[b - 1];
|
|
1029
|
-
|
|
1087
|
+
const qc = dropLeadCopula(bw, blc);
|
|
1088
|
+
if (qc.blc.length && qc.blc.every((x) => QUALIFIERS[x])) { atoms.push({ op, kind: "qual", filters: qc.blc }); continue; }
|
|
1030
1089
|
if (blc[0] === "of" || blc[0] === "in") {
|
|
1031
1090
|
atoms.push({ op, kind: "set", ast: { node: "membership", entityType, term: bw.slice(1).join(" ") } });
|
|
1032
1091
|
continue;
|
|
@@ -1577,11 +1636,29 @@ function evalExists(graph, ast) {
|
|
|
1577
1636
|
return { compositeKind: "exists", entityType, term: null, scopeModule, scopeMatch, matches: pool };
|
|
1578
1637
|
}
|
|
1579
1638
|
|
|
1639
|
+
/** QUALIFIER-CHECK eval — resolve the term (a context pronoun binds through
|
|
1640
|
+
* contextId, exactly like resolveTermOrContext's every other caller), then
|
|
1641
|
+
* read the SAME qualHolds() predicate the set-filter path already uses.
|
|
1642
|
+
* `holds` here means "the STATEMENT AS ASKED is true" (negation already
|
|
1643
|
+
* folded in), so the renderer can answer Yes/No directly off it without
|
|
1644
|
+
* re-deriving the negation. An unresolved term is an honest miss, never a
|
|
1645
|
+
* guess — no different from any other named-object lookup. */
|
|
1646
|
+
function evalQualCheck(graph, ast, opts) {
|
|
1647
|
+
const { term, qualifier, negated } = ast;
|
|
1648
|
+
const r = resolveTermOrContext(graph, term, opts.contextId);
|
|
1649
|
+
if (r.unresolvedPronoun) return { compositeKind: "qualCheck", qualCheckMiss: "pronoun", term, matches: [] };
|
|
1650
|
+
if (!r.match) return { compositeKind: "qualCheck", qualCheckMiss: "unresolved", term, matches: [] };
|
|
1651
|
+
const rawHolds = qualHolds(graph, r.match, QUALIFIERS[qualifier]);
|
|
1652
|
+
const holds = negated ? !rawHolds : rawHolds;
|
|
1653
|
+
return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1580
1656
|
/** Compile any compositional AST to a result object traverse() returns for the
|
|
1581
1657
|
* simple path — {matches, …} plus compositeKind/compositeMiss flags render() reads. */
|
|
1582
1658
|
export function evalComposite(graph, ast, opts = {}) {
|
|
1583
1659
|
if (ast.node === "miss") return { compositeMiss: true, reason: ast.reason || null, matches: [] };
|
|
1584
1660
|
if (ast.node === "exists") return evalExists(graph, ast);
|
|
1661
|
+
if (ast.node === "qualCheck") return evalQualCheck(graph, ast, opts);
|
|
1585
1662
|
if (ast.node === "count") return { compositeKind: "count", count: evalSet(graph, ast.base, opts).length, entityType: ast.entityType, matches: [] };
|
|
1586
1663
|
if (ast.node === "list") return { compositeKind: "list", matches: evalSet(graph, ast.base, opts), entityType: ast.entityType, scoped: ast.scoped };
|
|
1587
1664
|
if (ast.node === "superlative") return evalSuperlative(graph, ast);
|
|
@@ -1655,6 +1732,27 @@ function renderComposite(parsed, result) {
|
|
|
1655
1732
|
}
|
|
1656
1733
|
return { content: `Yes — ${compositeList(result.matches)}${scopeSuffix}.`, miss: false, ambiguous: false, matches: result.matches };
|
|
1657
1734
|
}
|
|
1735
|
+
// qualCheck: "is <term> [a/an] <qualifier> […]" — the single-entity sibling of
|
|
1736
|
+
// "is there a/an <kind> …" above: a direct Yes/No over one already-named/-
|
|
1737
|
+
// focused individual, never a set listing. `result.holds` already has the
|
|
1738
|
+
// negation folded in (evalQualCheck), so the renderer states the actual truth
|
|
1739
|
+
// plainly — "No — X is tested." for "is X not tested" when X IS tested, never
|
|
1740
|
+
// an echo of the (now-false) question's own wording.
|
|
1741
|
+
if (result.compositeKind === "qualCheck") {
|
|
1742
|
+
if (result.qualCheckMiss === "pronoun") {
|
|
1743
|
+
return { content: `"${result.term}" needs a selected node to refer to — click a node first, or name it directly.`, miss: true, ambiguous: false };
|
|
1744
|
+
}
|
|
1745
|
+
if (result.qualCheckMiss === "unresolved") {
|
|
1746
|
+
return { content: `couldn't find "${result.term}" in the index to check.`, miss: true, ambiguous: false };
|
|
1747
|
+
}
|
|
1748
|
+
const label = result.subject.label;
|
|
1749
|
+
const truePhrase = result.negated ? `not ${result.qualifier}` : result.qualifier;
|
|
1750
|
+
const falsePhrase = result.negated ? result.qualifier : `not ${result.qualifier}`;
|
|
1751
|
+
return {
|
|
1752
|
+
content: `${result.holds ? "Yes" : "No"} — ${label} is ${result.holds ? truePhrase : falsePhrase}.`,
|
|
1753
|
+
miss: false, ambiguous: false, matches: result.matches,
|
|
1754
|
+
};
|
|
1755
|
+
}
|
|
1658
1756
|
if (result.compositeKind === "count") {
|
|
1659
1757
|
const noun = result.entityType ? nounFor(result.entityType, result.count) : (result.count === 1 ? "result" : "results");
|
|
1660
1758
|
return { content: `${result.count} ${noun}.`, miss: false, ambiguous: false, matches: [] };
|
|
@@ -1861,15 +1959,66 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
|
|
|
1861
1959
|
}
|
|
1862
1960
|
}
|
|
1863
1961
|
} else {
|
|
1864
|
-
|
|
1962
|
+
// Root-cause fix (Tier-2 playtest cycle 9, targeted substring-match sweep):
|
|
1963
|
+
// this raw containment check has no minimum-length floor, so a short
|
|
1964
|
+
// closed-vocabulary word is a near-certain ACCIDENTAL substring of SOME
|
|
1965
|
+
// real label — confirmed empirically against the shipped mini-webapp
|
|
1966
|
+
// fixture: "so"->sendJson, "or"->Store, "a"->Task, "is"->listTasks
|
|
1967
|
+
// (ambiguous), "in"->Logger.info, "on"->sendJson, "at"->createApp,
|
|
1968
|
+
// "to"->Store, all via this exact branch. Cycle 8 patched three of these
|
|
1969
|
+
// ("and"/"also"/"so"/"then"/"now" and bare "it") one word at a time at
|
|
1970
|
+
// individual chat.mjs CALL SITES (STACCATO_LEAKED_CONNECTIVES, the
|
|
1971
|
+
// pronoun-reuse guard) — necessary there because those bugs are about
|
|
1972
|
+
// FOCUS bookkeeping, not resolution per se, but leaving resolveObject
|
|
1973
|
+
// itself unguarded meant every OTHER caller (existence checks, expectedClass
|
|
1974
|
+
// lookups, etc.) stayed exposed to the same trap for every not-yet-hit
|
|
1975
|
+
// short word. Gating the containment check at the same floor tier 5's own
|
|
1976
|
+
// fuzzy pass already uses (`tLc.length >= 4` below) closes it at the
|
|
1977
|
+
// source. A whole-token component match (just below) is unaffected by this
|
|
1978
|
+
// floor — it requires an EXACT path/identifier segment equality, never a
|
|
1979
|
+
// raw substring, so a genuinely short real identifier ("db", "fs") is
|
|
1980
|
+
// still resolvable by literally matching a whole segment.
|
|
1981
|
+
const termComps = [...componentSet(t)];
|
|
1982
|
+
// A SLASHED term's final path segment, extension stripped ("src/nope.mjs"
|
|
1983
|
+
// -> "nope", "cover app/lib/b.mjs" -> "b") — the semantically load-bearing
|
|
1984
|
+
// FILENAME STEM, as opposed to a directory segment or a leaked verb noise
|
|
1985
|
+
// word. Isolated from the actual whitespace-delimited PATH TOKEN (not the
|
|
1986
|
+
// raw multi-word string as a whole) — "app/lib/f.mjs but untested" (a
|
|
1987
|
+
// trailing-noise leak, distinct from the leading-verb-noise shape above)
|
|
1988
|
+
// has no extension at the end of the whole string, so splitting the whole
|
|
1989
|
+
// string would strand "f.mjs but untested" as a bogus non-matching "stem";
|
|
1990
|
+
// finding the one token that itself contains "/" keeps the path term
|
|
1991
|
+
// intact regardless of what noise surrounds it on either side. Only
|
|
1992
|
+
// slash-shaped terms compute this; a bare identifier/multi-word query has
|
|
1993
|
+
// no path structure to anchor on, so it's null and the gate below is a
|
|
1994
|
+
// no-op for those (unaffected — original ANY-overlap behavior).
|
|
1995
|
+
const pathToken = tLc.split(/\s+/).find((tok) => tok.includes("/"));
|
|
1996
|
+
const slashStem = pathToken ? pathToken.split("/").pop().replace(/\.[a-z0-9]+$/, "") : null;
|
|
1865
1997
|
for (const m of pool) {
|
|
1866
1998
|
const label = String(m.label || "").toLowerCase();
|
|
1867
|
-
if (label.includes(tLc)) {
|
|
1999
|
+
if (tLc.length >= 4 && label.includes(tLc)) {
|
|
1868
2000
|
scored.push({ ind: m, score: 1000 - Math.abs(label.length - tLc.length) });
|
|
1869
2001
|
continue;
|
|
1870
2002
|
}
|
|
1871
|
-
const
|
|
1872
|
-
|
|
2003
|
+
const labelComps = componentSet(m.label);
|
|
2004
|
+
const overlap = termComps.filter((c) => labelComps.has(c)).length;
|
|
2005
|
+
// For a slashed term, the stem MUST be among the label's own components
|
|
2006
|
+
// — an ANY-overlap match otherwise lets a NONEXISTENT path land on a
|
|
2007
|
+
// real module that merely shares its generic directory/extension
|
|
2008
|
+
// segments ("src", "mjs") with every other module in the pool (found
|
|
2009
|
+
// via the existence recognizer's "is there a class in src/nope.mjs"
|
|
2010
|
+
// scope clause, Tier-2 playtest cycle 9: it ambiguously "matched"
|
|
2011
|
+
// src/core/model.mjs even though no such module exists — the same
|
|
2012
|
+
// accidental-match disease as the short-word substring bug just above,
|
|
2013
|
+
// just triggered by GENERIC components instead of raw containment).
|
|
2014
|
+
// A leaked leading VERB ("cover app/lib/b.mjs", "touch app/lib/f.mjs" —
|
|
2015
|
+
// router-interface.test.mjs's own frozen contract) still resolves: the
|
|
2016
|
+
// stem ("b"/"f") is a real component of the target label even though
|
|
2017
|
+
// "cover"/"touch" themselves never overlap anything, so overlap>0 and
|
|
2018
|
+
// the stem gate both hold.
|
|
2019
|
+
if (overlap > 0 && (!slashStem || labelComps.has(slashStem))) {
|
|
2020
|
+
scored.push({ ind: m, score: overlap * 10 });
|
|
2021
|
+
}
|
|
1873
2022
|
}
|
|
1874
2023
|
}
|
|
1875
2024
|
scored.sort((a, b) => b.score - a.score);
|
|
@@ -1890,12 +2039,24 @@ export function resolveObject(graph, term, { expectedClass = null } = {}) {
|
|
|
1890
2039
|
// browser `lookupByProseTokens` is an undeclared identifier — without the guard,
|
|
1891
2040
|
// ANY term reaching this tier threw a ReferenceError in the page instead of
|
|
1892
2041
|
// rendering the honest miss (a real, previously-untested viewer bug).
|
|
1893
|
-
// DOTTED terms never consult prose: "res.json" word-matches
|
|
1894
|
-
// own path tokens, which is the tier-3 phantom-path bug
|
|
1895
|
-
// side door — a dotted term names an identifier, and
|
|
1896
|
-
// label (tiers above) or the bounded fuzzy pass
|
|
2042
|
+
// DOTTED or SLASHED terms never consult prose: "res.json" word-matches
|
|
2043
|
+
// test/res.json.js's own path tokens, which is the tier-3 phantom-path bug
|
|
2044
|
+
// reappearing through a side door — a dotted term names an identifier, and
|
|
2045
|
+
// identifiers resolve by label (tiers above) or the bounded fuzzy pass
|
|
2046
|
+
// below, or they honestly miss. The SAME side door was open for SLASHED
|
|
2047
|
+
// path terms too (Tier-2 playtest cycle 9, existence-recognizer follow-up):
|
|
2048
|
+
// "src/nope.mjs" — a nonexistent module — no longer false-matches tier 3
|
|
2049
|
+
// (the AND-across-components fix just above), but fell through to THIS
|
|
2050
|
+
// prose tier and ambiguously "matched" real modules anyway, because
|
|
2051
|
+
// lookupByProseTokens scores by ANY-token overlap (sum-scored, by design,
|
|
2052
|
+
// for genuine prose ranking) and "src"/"mjs" are near-universal path/
|
|
2053
|
+
// extension tokens shared by every module in the pool — the identical
|
|
2054
|
+
// accidental-match disease, just one tier further down. A slash-shaped term
|
|
2055
|
+
// names a literal path exactly like a dotted term names a literal symbol;
|
|
2056
|
+
// neither is prose, so neither should ever reach the prose fallback.
|
|
2057
|
+
const pathShaped = dotted || tLc.includes("/");
|
|
1897
2058
|
let proseResult = null;
|
|
1898
|
-
const proseHits = !
|
|
2059
|
+
const proseHits = !pathShaped && typeof lookupByProseTokens === "function"
|
|
1899
2060
|
? lookupByProseTokens(graph.proseIndex, t).filter((h) => !expectedClass || graph.byId.get(h.id)?.class === expectedClass)
|
|
1900
2061
|
: [];
|
|
1901
2062
|
if (proseHits.length) {
|
|
@@ -2910,10 +3071,22 @@ export function relaxParse(graph, query, { nlp = undefined, contextId = null, pr
|
|
|
2910
3071
|
// own as vocabulary/scaffolding? Guards against a relaxation that drops the actual
|
|
2911
3072
|
// asked term and lets a bare marker slide into its place ("where is [X] defined" →
|
|
2912
3073
|
// "where is defined", "defined" is a WHERE_MARKER, never the thing being located).
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
3074
|
+
// A bare CONTEXT PRONOUN ("it"/"this"/"that"/"here"/…) is the one exception: it IS
|
|
3075
|
+
// the real, deliberate object here — it resolves through contextId, not through
|
|
3076
|
+
// vocabulary the grammar "already owns" as scaffolding — so it must count as a real
|
|
3077
|
+
// term rather than being mistaken for a dropped-into-place marker. Without this, a
|
|
3078
|
+
// relaxed candidate whose object survived layer 2 as a lone pronoun ("what else is
|
|
3079
|
+
// in that class" → drop "class"/"else" → "what does that contain") was rejected as
|
|
3080
|
+
// if it named nothing at all, even though the pronoun resolves to a real, answerable
|
|
3081
|
+
// focus (0.9.15 Tier-1 single-touch playtest).
|
|
3082
|
+
const hasRealTerm = (s) => {
|
|
3083
|
+
const whole = String(s || "").trim().toLowerCase();
|
|
3084
|
+
if (CONTEXT_PRONOUNS.includes(whole)) return true;
|
|
3085
|
+
return splitWords(whole).some((w) => {
|
|
3086
|
+
const lc = w.toLowerCase();
|
|
3087
|
+
return !CONTENT_VOCAB.has(lc) && !STRUCTURAL_WORDS.has(lc);
|
|
3088
|
+
});
|
|
3089
|
+
};
|
|
2917
3090
|
// Accept a relaxed attempt ONLY if it is a genuinely answerable parse (terms resolve)
|
|
2918
3091
|
// AND it renders a REAL positive answer — never another empty/miss (relaxation earns a
|
|
2919
3092
|
// win only by turning a miss into an answer, never a differently-worded miss) — and
|
package/src/chat.mjs
CHANGED
|
@@ -3300,7 +3300,13 @@ async function describeWrapperAnswer(query, { config, source, focus }) {
|
|
|
3300
3300
|
* known edge concept (RELATION_TERM), no curated definition, or the graph has NO
|
|
3301
3301
|
* edges of that kind (composeRelation's own honest-miss gate). Loads the definition
|
|
3302
3302
|
* from the shipped corpus/seon/relations.jsonl, so it works without per-repo memory
|
|
3303
|
-
* seeding. Lazy + failure-tolerated throughout. Returns { text, pending }
|
|
3303
|
+
* seeding. Lazy + failure-tolerated throughout. Returns { text, pending, kind } —
|
|
3304
|
+
* `kind` is the resolved RELATION_TERM canonical kind (imports/calls/…), the SAME
|
|
3305
|
+
* vocabulary GOAL_BY_KIND keys on, so a caller whose own envelope.parsed never
|
|
3306
|
+
* stood (this force's whole reason to exist — see relationTermOf/
|
|
3307
|
+
* isVagueRelationTouch's own docs) can still deduce the correct "Goal (inferred):
|
|
3308
|
+
* …" line instead of silently carrying forward a null goal from earlier in the
|
|
3309
|
+
* turn. */
|
|
3304
3310
|
async function relationForceAnswer(query, envelope, { graph, config, source, templates }) {
|
|
3305
3311
|
const rawTerm = relationTermOf(query, envelope);
|
|
3306
3312
|
if (!rawTerm) return null;
|
|
@@ -3308,8 +3314,9 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
|
|
|
3308
3314
|
try { ({ composeRelation, RELATION_TERM } = await import("./concept.mjs")); }
|
|
3309
3315
|
catch { return null; }
|
|
3310
3316
|
const term = String(rawTerm).toLowerCase();
|
|
3311
|
-
|
|
3312
|
-
|
|
3317
|
+
const kind = RELATION_TERM[term];
|
|
3318
|
+
if (!kind) return null; // not an enumerable edge concept — ordinary path owns it
|
|
3319
|
+
const definition = (await relationDefinitions()).get(kind) ?? null;
|
|
3313
3320
|
if (!definition) return null;
|
|
3314
3321
|
// Same graph-load fallback as conceptForceAnswer: the shell hands the loaded graph
|
|
3315
3322
|
// straight in; the pure runTurn(config) path loads it the way dispatchTool does.
|
|
@@ -3329,7 +3336,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
|
|
|
3329
3336
|
const pending = composed.remainder && composed.remainder.length
|
|
3330
3337
|
? { items: composed.remainder, noun: composed.noun }
|
|
3331
3338
|
: null;
|
|
3332
|
-
return { text, pending };
|
|
3339
|
+
return { text, pending, kind };
|
|
3333
3340
|
}
|
|
3334
3341
|
|
|
3335
3342
|
/** THE CONCEPT FORCE — compose the three-band answer (corpus/seon definition + real
|
|
@@ -3571,7 +3578,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
3571
3578
|
// SAME value the debug trace's own "goal:" line uses (one deduction, two
|
|
3572
3579
|
// presentations) — null here (no parse stood at all) means withGoalLine
|
|
3573
3580
|
// shows nothing, never a "Goal (inferred): unclear" line.
|
|
3574
|
-
const
|
|
3581
|
+
// `let`, not `const`: the RELATION CONCEPT FORCE (relationForceAnswer, below)
|
|
3582
|
+
// can answer a turn CORRECTLY with no envelope.parsed at all to deduce from —
|
|
3583
|
+
// a staccato relation-chain continuation whose leading connective never
|
|
3584
|
+
// itself parses ("and inherits?": ask()'s raw grammar has no production for
|
|
3585
|
+
// a bare relation word with no verb, exactly like the "cochange" vague-touch
|
|
3586
|
+
// gap composeRelation's own degrade fix addressed) still reaches the SAME
|
|
3587
|
+
// relation force a normally-parsed "what about inherits" would. Tier-2
|
|
3588
|
+
// playtest cycle 9, the Goal-line gap cycle 8 flagged: the answer content
|
|
3589
|
+
// was always correct here — only the cosmetic trailing "Goal (inferred): …"
|
|
3590
|
+
// line went missing, because it was computed once, this early, straight off
|
|
3591
|
+
// envelope.parsed and never revisited even when a LATER lane went on to
|
|
3592
|
+
// answer the turn through a completely different path. Reassigned at the
|
|
3593
|
+
// relation-force call site below (never overwritten with something worse:
|
|
3594
|
+
// only filled in from the SAME GOAL_BY_KIND table deduceGoalFromParsed
|
|
3595
|
+
// itself already uses for a normally-parsed relation query, so the two
|
|
3596
|
+
// never disagree on the cases where both would fire).
|
|
3597
|
+
let deduced = deduceGoalFromParsed(envelope?.parsed);
|
|
3575
3598
|
note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
|
|
3576
3599
|
// MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
|
|
3577
3600
|
// text AND only consulted on a would-miss, so a real graph query — a hit, an honest
|
|
@@ -3800,6 +3823,19 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
|
|
|
3800
3823
|
conceptPending = relation.pending;
|
|
3801
3824
|
note(trace, "lane: THE RELATION CONCEPT FORCE — the touched word named a known, edge-bearing relation kind");
|
|
3802
3825
|
note(trace, "source: relationForceAnswer over the loaded graph's own edges (not corpus)");
|
|
3826
|
+
// Goal-line gap fix (Tier-2 playtest cycle 9): this force just answered
|
|
3827
|
+
// the turn CORRECTLY over a query shape ask()'s own grammar may never
|
|
3828
|
+
// have parsed at all (a staccato relation-chain continuation whose
|
|
3829
|
+
// leading connective never itself parses, "and inherits?") — `deduced`
|
|
3830
|
+
// was computed way above, off envelope.parsed alone, and would
|
|
3831
|
+
// otherwise stay null forever here even though the answer is real.
|
|
3832
|
+
// relation.kind is the SAME GOAL_BY_KIND vocabulary a normally-parsed
|
|
3833
|
+
// relation query already deduces its goal line from, so this can never
|
|
3834
|
+
// disagree with the ordinary path on a case where both would fire.
|
|
3835
|
+
if (relation.kind && GOAL_BY_KIND[relation.kind]) {
|
|
3836
|
+
deduced = GOAL_BY_KIND[relation.kind];
|
|
3837
|
+
note(trace, `goal: ${deduced} (revised — the relation concept force answered where the raw parse never stood)`);
|
|
3838
|
+
}
|
|
3803
3839
|
}
|
|
3804
3840
|
}
|
|
3805
3841
|
}
|
|
@@ -44,6 +44,33 @@ const correctionRe = (table) => new RegExp(
|
|
|
44
44
|
const MISSPELLING_RE = correctionRe(MISSPELLINGS);
|
|
45
45
|
const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
|
|
46
46
|
|
|
47
|
+
/** "that class"/"this module"/"that function" (a context pronoun immediately
|
|
48
|
+
* followed by the SINGULAR kind noun it's already standing in for) -> the bare
|
|
49
|
+
* pronoun alone (0.9.15 Tier-1 single-touch playtest). "which class contains
|
|
50
|
+
* Task.complete" answers by NAMING the class ("… there is Task"); a curious
|
|
51
|
+
* user's very next turn naturally says "what is in that class", not the bare
|
|
52
|
+
* "what is in that" the grammar already understands. Left unstripped, the two
|
|
53
|
+
* parse strategies disagreed on the SPAN (grammar kept "that class" as one
|
|
54
|
+
* literal 2-word object term; keyword-spot split off "class" as an entityType
|
|
55
|
+
* keyword, leaving bare "that" as the object) — same PARSE, different shape,
|
|
56
|
+
* so merge.mjs's honest {ambiguousParse} tie fired even though a human reads
|
|
57
|
+
* this as one unambiguous sentence. Folding the kind noun away BEFORE either
|
|
58
|
+
* strategy runs leaves exactly one reading: CONTEXT_PRONOUNS' own existing
|
|
59
|
+
* focus-resolution (ask.mjs's resolveTermOrContext) then takes it from there,
|
|
60
|
+
* unchanged — this frame only removes the strategy disagreement, it does not
|
|
61
|
+
* touch how the pronoun itself resolves. Singular kind nouns only ("that
|
|
62
|
+
* classes" isn't grammatical, so plurals are never a real anaphora and are
|
|
63
|
+
* left alone); "one" is excluded ("that one" is already its own literal
|
|
64
|
+
* CONTEXT_PRONOUNS entry). */
|
|
65
|
+
const KIND_NOUN_ANAPHORA_RE = /\b(this|that)\s+(class|module|function|method|attribute|variable|file|commit)\b/gi;
|
|
66
|
+
|
|
67
|
+
// every relation verb phrase VERB_TO_KIND knows, as one alternation (longest-first
|
|
68
|
+
// so a multi-word verb like "inherit from" wins over its own leading word "inherit"
|
|
69
|
+
// appearing elsewhere) — feeds the DOES-X-VERB-ANYTHING-ELSE frame below, which needs
|
|
70
|
+
// to recognize "does <subject> <ANY closed relation verb> anything/something [else]"
|
|
71
|
+
// without hardcoding its own parallel verb list.
|
|
72
|
+
const VERB_ALTERNATION = Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|");
|
|
73
|
+
|
|
47
74
|
/** Just the curated MISSPELLINGS correction (ask-vocab.mjs), standalone — for a
|
|
48
75
|
* caller that needs typo-tolerant ANCHOR-WORD matching (a closed regex shape
|
|
49
76
|
* keyed on a literal "what"/"which"/"where" etc.) without running the rest of
|
|
@@ -158,6 +185,25 @@ const EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
|
|
|
158
185
|
* module" -> "describe store module"). */
|
|
159
186
|
const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
|
|
160
187
|
|
|
188
|
+
/** Leading STACCATO connective before an ALREADY well-formed question ("and
|
|
189
|
+
* what imports it", "so does it import anything else", "also where is X
|
|
190
|
+
* defined") -> the question alone (0.9.15 Tier-1 single-touch playtest). A
|
|
191
|
+
* rapid-fire drill-down naturally opens its next turn with a bare discourse
|
|
192
|
+
* connective (chat.mjs's own STACCATO_LEAKED_CONNECTIVES set: and/also/so/
|
|
193
|
+
* then/now, here joined by "but" for the same family); when what follows is
|
|
194
|
+
* ALREADY an interrogative lead or an auxiliary-question opener, the
|
|
195
|
+
* connective carries no query content of its own — conversational
|
|
196
|
+
* scaffolding, same species as the greeting/subordination preambles. Gated
|
|
197
|
+
* on the REMAINDER starting a real question, so a genuine mid-clause boolean
|
|
198
|
+
* composition ("classes that inherit from Base and are tested") is never
|
|
199
|
+
* touched — that "and" never sits at position 0 to begin with. Without this,
|
|
200
|
+
* "and what imports it" parsed as an "ask"-shape question with "and" itself
|
|
201
|
+
* MISREAD as the subject term — a miss the relaxation cascade can't rescue,
|
|
202
|
+
* because "and" is protected CONTENT_VOCAB (a boolean connective elsewhere)
|
|
203
|
+
* and the noise-strip layer never drops content vocab. */
|
|
204
|
+
const LEADING_CONNECTIVE_RE = /^(?:and|also|so|then|now|but)\s+(.+)$/i;
|
|
205
|
+
const QUESTION_AUX_LEAD_RE = /^(?:does|do|did|is|are|was|were|has|have|had|can|could|will|would|should)\b/i;
|
|
206
|
+
|
|
161
207
|
/** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
|
|
162
208
|
* repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
|
|
163
209
|
* please") peel fully. Pure and idempotent; unmatched text passes through. */
|
|
@@ -180,6 +226,11 @@ export function applyPreambleFrames(text) {
|
|
|
180
226
|
q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
|
|
181
227
|
}
|
|
182
228
|
}
|
|
229
|
+
m = q.match(LEADING_CONNECTIVE_RE);
|
|
230
|
+
if (m) {
|
|
231
|
+
const rest = m[1].trim();
|
|
232
|
+
if (INTERROGATIVE_LEAD_RE.test(rest) || QUESTION_AUX_LEAD_RE.test(rest)) q = rest;
|
|
233
|
+
}
|
|
183
234
|
if (q === before) break;
|
|
184
235
|
}
|
|
185
236
|
return q;
|
|
@@ -343,6 +394,7 @@ export function normalizeQuery(text) {
|
|
|
343
394
|
q = q.replace(CONTRACTION_RE, (m) => CONTRACTIONS[m.toLowerCase()]);
|
|
344
395
|
q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
345
396
|
q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
|
|
397
|
+
q = q.replace(KIND_NOUN_ANAPHORA_RE, (_, pron) => pron);
|
|
346
398
|
q = q.replace(G_DROP, "$1ing");
|
|
347
399
|
// closed preamble frames (greeting lead-in, modal wrapper, show/give-me
|
|
348
400
|
// bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
|
|
@@ -414,6 +466,23 @@ export const PHRASING_FRAMES = Object.freeze([
|
|
|
414
466
|
{ re: /^(?:the\s+)?(?:members?|methods?|attributes?|contents)\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
|
|
415
467
|
// "what's in X" / "what is in X" (contraction already expanded; sha handled above)
|
|
416
468
|
{ re: /^what\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
|
|
469
|
+
// "what else is in X" (0.9.15 Tier-1 single-touch playtest) — the natural
|
|
470
|
+
// "besides what I already know" drill-down after a members-of-class answer.
|
|
471
|
+
// Distinct from the "what else does X <verb>" family (which the compositional
|
|
472
|
+
// grammar already tolerates, dropping "else" as noise on its own): the "is
|
|
473
|
+
// in" idiom is NOT a compositional marker, so parseComposite never sees it and
|
|
474
|
+
// "what else is in X" fell through to the strategies with NO candidate at all
|
|
475
|
+
// (neither recognizes the bare "is in" idiom once "else" sits in front of it).
|
|
476
|
+
// The only rescue was the relaxation cascade's drop-unmatched layer — but that
|
|
477
|
+
// layer refuses to accept a relaxed reading that still renders an honest EMPTY
|
|
478
|
+
// (by design: relaxation must turn a miss into a real answer, never into
|
|
479
|
+
// another kind of miss), so a genuinely empty class ("what else is in
|
|
480
|
+
// Task.complete" — a method, no members) bottomed out at the bare grammar
|
|
481
|
+
// wall instead of the specific "no contains edges" receipt. Routing this
|
|
482
|
+
// frame onto the SAME direct "what does X contain" path the plain "what is
|
|
483
|
+
// in X" frame above already uses sidesteps the cascade's conservative gate
|
|
484
|
+
// entirely, so a real empty is reported honestly instead of walled.
|
|
485
|
+
{ re: /^what\s+else\s+is\s+(?:in|inside)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
|
|
417
486
|
|
|
418
487
|
// WHERE-DEFINED → "where is X defined". PAST TENSE ONLY ("what defined X", "what
|
|
419
488
|
// declared X"): the PRESENT "what defines X" already parses as a reverse-defines
|
|
@@ -512,6 +581,26 @@ export const PHRASING_FRAMES = Object.freeze([
|
|
|
512
581
|
// survey the bare "what is untested" frame lands on. Closed to the tests/coverage
|
|
513
582
|
// object, so it can't swallow a general "what needs X".
|
|
514
583
|
{ re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
|
|
584
|
+
|
|
585
|
+
// DOES-X-VERB-ANYTHING-ELSE → the plain forward "what does X <verb>" listing
|
|
586
|
+
// (0.9.15 Tier-1 single-touch playtest). A very natural drill-down follow-up
|
|
587
|
+
// after a relation answer — "does listTasks call anything else", "does
|
|
588
|
+
// src/handlers/tasks.mjs import something else" — used to dead-end: "anything"/
|
|
589
|
+
// "something" [else] is a placeholder standing in for "the rest of the list",
|
|
590
|
+
// not a real object term, but the two parse strategies disagreed on the SPAN
|
|
591
|
+
// (grammar kept "anything else" whole as the object, keyword-spot dropped
|
|
592
|
+
// "anything" and kept only "else"), landing on the {ambiguousParse} surface —
|
|
593
|
+
// two nonsense readings offered as if one might be right. "what does X <verb>"
|
|
594
|
+
// is the exact working canonical shape (see the MEMBERS-of-class frames above),
|
|
595
|
+
// so rewriting the whole closed pattern onto it sidesteps the disagreement
|
|
596
|
+
// instead of teaching either strategy's tokenizer to special-case "else".
|
|
597
|
+
// Anchored to the closed VERB_TO_KIND vocabulary so it can never swallow a
|
|
598
|
+
// genuine named object that happens to start with "any"/"some" (only the bare
|
|
599
|
+
// placeholder nouns "anything"/"something", optionally trailed by "else", match).
|
|
600
|
+
{
|
|
601
|
+
re: new RegExp(`^(?:do|does)\\s+(.+?)\\s+(${VERB_ALTERNATION})\\s+(?:anything|something)(?:\\s+else)?\\??$`, "i"),
|
|
602
|
+
to: (m) => `what does ${m[1]} ${m[2]}`,
|
|
603
|
+
},
|
|
515
604
|
]);
|
|
516
605
|
|
|
517
606
|
/** Apply the phrasing frames (members-of-class + where-defined) — first match wins
|