Two new intent shapes + composers turn the runtime's corpus
density into operator-visible articulation. Both consult the
cross-corpus aggregator from ADR-0064; no new ratification needed.
P3.3 — chat/narrative_surface.py + IntentTag.NARRATIVE.
Classifier patterns (registered BEFORE generic DEFINITION):
^tell\s+me\s+about\s+
^describe\s+
^what\s+(?:can|do)\s+you\s+(?:say|know)\s+about\s+
narrative_grounded_surface(subject, max_clauses=4) walks every
reviewed chain rooted on subject across all registered teaching
corpora. Dedupes by (connective, object) — cause + verification
carrying the same predicate emit one clause, not two. Sorts by
(intent, connective, object) for replay stability.
Surface format:
"{X} — narrative-grounded ({corpus_ids}): {dX1}; {dX2}.
{X} {conn1} {O1} ({dO1}); {X} {conn2} {O2} ({dO2}).
No session evidence yet."
Cross-corpus subjects (e.g. mother in relations_v2) emit
narrative-grounded (relations_chains_v2) tag; cognition subjects
emit cognition_chains_v1 tag. Multi-corpus subjects (when
applicable) emit composite "corpus_a + corpus_b" tag.
P3.4 — chat/example_surface.py + IntentTag.EXAMPLE.
Classifier patterns:
^(?:give|show)\s+(?:me\s+)?an?\s+(?:example|instance)\s+of\s+
^example\s+of\s+
example_grounded_surface(object_lemma, max_examples=3) walks chains
where the lemma is the OBJECT — inverts the typical subject-keyed
access pattern. Dedupes by subject; sorts by (intent, subject,
connective).
Surface format:
"{X} — example-grounded ({corpus_ids}): {dX1}.
Example: {subj1} {conn1} {X}; {subj2} {conn2} {X}.
No session evidence yet."
Cross-cutting:
- Both intents added to _OOV_INTENT_TAGS — fall through to OOV
invitation when subject is unknown (Phase 2 gradient discipline).
- Both tagged grounding_source="teaching" (same provenance tier
as the existing teaching_grounded_surface).
- No prose generation, no new mutation surface.
Live verification:
> Tell me about truth.
[teaching] truth — narrative-grounded (cognition_chains_v1):
cognition.truth; logos.core. truth grounds knowledge
(cognition.knowledge); truth requires evidence (cognition.evidence).
> Give me an example of knowledge.
[teaching] knowledge — example-grounded (cognition_chains_v1):
cognition.knowledge. Example: truth grounds knowledge;
understanding requires knowledge; evidence grounds knowledge.
> Tell me about mother.
[teaching] mother — narrative-grounded (relations_chains_v2):
kinship.parent.female. mother precedes daughter (kinship.child.female).
> Describe photosynthesis.
[oov] I haven't learned 'photosynthesis' yet (intent: narrative). ...
ADR-0066 (this commit completes the ADR). 30 new tests passed.
Full lane: 2067 passed, 2 skipped, 0 failed in 2:32.
232 lines
9 KiB
Python
232 lines
9 KiB
Python
"""Dialogue intent classification.
|
|
|
|
Maps a raw prompt string to a typed intent tag. The classifier is rule-based
|
|
(prefix/pattern matching) — no ML dependency. Downstream, the intent selects
|
|
the proposition frame family and graph shape before generation begins.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass
|
|
from enum import Enum, unique
|
|
|
|
|
|
@unique
|
|
class IntentTag(Enum):
|
|
DEFINITION = "definition"
|
|
CAUSE = "cause"
|
|
PROCEDURE = "procedure"
|
|
COMPARISON = "comparison"
|
|
CORRECTION = "correction"
|
|
RECALL = "recall"
|
|
VERIFICATION = "verification"
|
|
TRANSITIVE_QUERY = "transitive_query"
|
|
FRAME_TRANSFER = "frame_transfer"
|
|
# P3.3 — "Tell me about X" / "Describe X" — multi-clause
|
|
# composer walks every chain rooted on X.
|
|
NARRATIVE = "narrative"
|
|
# P3.4 — "Give me an example of X" / "Show an instance of X" —
|
|
# reverse-chain composer surfaces chains where X is the object.
|
|
EXAMPLE = "example"
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DialogueIntent:
|
|
tag: IntentTag
|
|
subject: str
|
|
secondary_subject: str | None = None
|
|
relation: str | None = None # populated for TRANSITIVE_QUERY (ADR-0018)
|
|
frame: str | None = None # populated for FRAME_TRANSFER (compose_relations)
|
|
|
|
def requires_prior_turn(self) -> bool:
|
|
return self.tag is IntentTag.CORRECTION
|
|
|
|
|
|
_COMPARE_RE = re.compile(
|
|
r"^compare\s+(.+?)\s+(?:and|vs\.?|versus|with)\s+(.+)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Transitive-query forms (ADR-0018):
|
|
# "What does X <verb>?" -> (X, R) where R is any verb-like word
|
|
# "Where does X belong?" -> (X, belongs_to)
|
|
# The verb slot accepts any single word — `multi_relation_walk` in the
|
|
# operator layer handles unrecognised relations by falling back to a
|
|
# cross-relation traversal (rather than a strict literal-relation match).
|
|
_TRANSITIVE_QUERY_RE = re.compile(
|
|
r"^what\s+does\s+(?P<subject>[a-z][a-z\-]*(?:\s+[a-z][a-z\-]*)?)\s+"
|
|
r"(?P<relation>[a-z][a-z\-]*)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
# Frame-transfer form:
|
|
# "What does X R in Y?" -> compose_relations(triples, X, Y, R)
|
|
# This is the compositionality lane's `novel_pair_under_seen_relation`
|
|
# probe shape. Must be tried before the generic transitive-query rule
|
|
# so the "in Y" tail is not silently truncated.
|
|
_FRAME_TRANSFER_RE = re.compile(
|
|
r"^what\s+does\s+(?P<subject>[a-z][a-z\-]+)\s+"
|
|
r"(?P<relation>[a-z][a-z\-]+)(?P<rel_tail>\s+to)?\s+in\s+"
|
|
r"(?P<frame>[a-z][a-z\-]+)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
_BELONG_QUERY_RE = re.compile(
|
|
r"^where\s+does\s+(?P<subject>[a-z][a-z\-]*(?:\s+[a-z][a-z\-]*)?)\s+"
|
|
r"belong(?:s?)\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
# Normalisation of the relation surface form back to the bare relation
|
|
# vocabulary the teaching store carries (matches en_core_cognition_v1).
|
|
_RELATION_NORMALIZE: dict[str, str] = {
|
|
"precede": "precedes", "precedes": "precedes",
|
|
"cause": "causes", "causes": "causes",
|
|
"ground": "grounds", "grounds": "grounds",
|
|
"reveal": "reveals", "reveals": "reveals",
|
|
"mean": "means", "means": "means",
|
|
"follow": "follows", "follows": "follows",
|
|
"contrast": "contrasts_with", "contrast_with": "contrasts_with",
|
|
"contrasts_with": "contrasts_with", "contrasts with": "contrasts_with",
|
|
"produce": "produces", "produces": "produces",
|
|
}
|
|
|
|
_RULES: tuple[tuple[re.Pattern[str], IntentTag], ...] = (
|
|
# P3.3 — NARRATIVE patterns precede DEFINITION so "Tell me about X"
|
|
# does not accidentally classify as DEFINITION on the noun span.
|
|
(re.compile(r"^tell\s+me\s+about\s+", re.IGNORECASE), IntentTag.NARRATIVE),
|
|
(re.compile(r"^describe\s+", re.IGNORECASE), IntentTag.NARRATIVE),
|
|
(re.compile(r"^what\s+(?:can|do)\s+you\s+(?:say|know)\s+about\s+", re.IGNORECASE), IntentTag.NARRATIVE),
|
|
# P3.4 — EXAMPLE patterns precede DEFINITION for the same reason.
|
|
(re.compile(r"^(?:give|show)\s+(?:me\s+)?an?\s+(?:example|instance)\s+of\s+", re.IGNORECASE), IntentTag.EXAMPLE),
|
|
(re.compile(r"^example\s+of\s+", re.IGNORECASE), IntentTag.EXAMPLE),
|
|
(re.compile(r"^what\s+(?:is|are)\s+", re.IGNORECASE), IntentTag.DEFINITION),
|
|
(re.compile(r"^why\s+", re.IGNORECASE), IntentTag.CAUSE),
|
|
(re.compile(r"^how\s+(?:do|can|should|would)\s+(?:I|we|you)\s+", re.IGNORECASE), IntentTag.PROCEDURE),
|
|
(re.compile(r"^(?:is|are|does|do|can|could|would|should|was|were|has|have|will)\s+.+\??\s*$", re.IGNORECASE), IntentTag.VERIFICATION),
|
|
(re.compile(r"^(?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)", re.IGNORECASE), IntentTag.CORRECTION),
|
|
(re.compile(r"^remember\s+", re.IGNORECASE), IntentTag.RECALL),
|
|
)
|
|
|
|
|
|
# ADR-0049 — deterministic head-noun extraction from subject phrases.
|
|
#
|
|
# After a rule fires, the raw subject span often still carries auxiliary
|
|
# verbs, articles, or trailing punctuation:
|
|
#
|
|
# "What is a procedure?" -> raw subject "a procedure"
|
|
# "Why does light exist?" -> raw subject "does light exist"
|
|
# "Does memory require recall?" -> raw subject (whole prompt)
|
|
#
|
|
# Downstream consumers (graph_planner, ADR-0048 pack-grounded surface,
|
|
# future teaching-store inference) expect a clean lemma so they can
|
|
# match the ratified pack lexicon, build single-subject graphs, or
|
|
# consult the teaching store keyed by lemma.
|
|
#
|
|
# This normalizer is *pack-agnostic* — it does not load or consult any
|
|
# pack. It is a pure syntactic head-noun extractor: strip aux verbs,
|
|
# strip articles, return either the head noun (CAUSE / VERIFICATION)
|
|
# or the cleaned noun phrase (DEFINITION / RECALL / PROCEDURE).
|
|
_ARTICLES = frozenset({"a", "an", "the"})
|
|
_AUX_VERBS = frozenset({
|
|
"is", "are", "am", "was", "were", "be", "been", "being",
|
|
"does", "do", "did",
|
|
"has", "have", "had",
|
|
"can", "could", "would", "should", "shall", "will", "might", "may", "must",
|
|
})
|
|
|
|
|
|
def _normalize_subject(phrase: str, tag: IntentTag) -> str:
|
|
"""Strip aux verbs, articles, and trailing punctuation from a subject phrase.
|
|
|
|
For CAUSE and VERIFICATION the subject phrase typically contains the
|
|
full predicate ("does light exist"), and we return the head noun.
|
|
For DEFINITION / RECALL / PROCEDURE we keep multi-word noun phrases
|
|
intact (so e.g. "artificial intelligence" is preserved), only
|
|
stripping leading articles and trailing punctuation.
|
|
|
|
Falls back to the original phrase if normalization would empty it.
|
|
"""
|
|
if not phrase:
|
|
return phrase
|
|
cleaned = phrase.strip().rstrip("?.!").strip()
|
|
if not cleaned:
|
|
return ""
|
|
tokens = cleaned.split()
|
|
if not tokens:
|
|
return cleaned
|
|
|
|
if tag in (IntentTag.CAUSE, IntentTag.VERIFICATION):
|
|
while tokens and tokens[0].lower() in _AUX_VERBS:
|
|
tokens = tokens[1:]
|
|
|
|
while tokens and tokens[0].lower() in _ARTICLES:
|
|
tokens = tokens[1:]
|
|
|
|
if not tokens:
|
|
return cleaned
|
|
|
|
if tag in (IntentTag.CAUSE, IntentTag.VERIFICATION):
|
|
return tokens[0]
|
|
|
|
return " ".join(tokens)
|
|
|
|
|
|
def classify_intent(prompt: str) -> DialogueIntent:
|
|
text = prompt.strip()
|
|
if not text:
|
|
return DialogueIntent(tag=IntentTag.UNKNOWN, subject="")
|
|
|
|
compare_match = _COMPARE_RE.match(text)
|
|
if compare_match:
|
|
return DialogueIntent(
|
|
tag=IntentTag.COMPARISON,
|
|
subject=compare_match.group(1).strip(),
|
|
secondary_subject=compare_match.group(2).strip(),
|
|
)
|
|
|
|
frame_match = _FRAME_TRANSFER_RE.match(text)
|
|
if frame_match:
|
|
raw_relation = frame_match.group("relation").lower().strip()
|
|
# "X belong to in Y" — normalize to belongs_to since the optional
|
|
# " to" token after the relation indicates the same paraphrase
|
|
# the BELONG_QUERY rule handles for single-entity probes.
|
|
if frame_match.group("rel_tail") and raw_relation in {"belong", "belongs"}:
|
|
relation = "belongs_to"
|
|
else:
|
|
relation = _RELATION_NORMALIZE.get(raw_relation, raw_relation)
|
|
return DialogueIntent(
|
|
tag=IntentTag.FRAME_TRANSFER,
|
|
subject=frame_match.group("subject").strip(),
|
|
relation=relation,
|
|
frame=frame_match.group("frame").strip(),
|
|
)
|
|
|
|
transitive_match = _TRANSITIVE_QUERY_RE.match(text)
|
|
if transitive_match:
|
|
raw_relation = transitive_match.group("relation").lower().strip()
|
|
relation = _RELATION_NORMALIZE.get(raw_relation, raw_relation)
|
|
return DialogueIntent(
|
|
tag=IntentTag.TRANSITIVE_QUERY,
|
|
subject=transitive_match.group("subject").strip(),
|
|
relation=relation,
|
|
)
|
|
|
|
belong_match = _BELONG_QUERY_RE.match(text)
|
|
if belong_match:
|
|
return DialogueIntent(
|
|
tag=IntentTag.TRANSITIVE_QUERY,
|
|
subject=belong_match.group("subject").strip(),
|
|
relation="belongs_to",
|
|
)
|
|
|
|
for pattern, tag in _RULES:
|
|
match = pattern.match(text)
|
|
if match:
|
|
subject = text[match.end():].rstrip("?").strip()
|
|
if not subject:
|
|
subject = text
|
|
subject = _normalize_subject(subject, tag)
|
|
return DialogueIntent(tag=tag, subject=subject)
|
|
|
|
return DialogueIntent(tag=IntentTag.UNKNOWN, subject=text)
|