core/generate/intent_bridge.py
Shay bf8284fd47 Phase 2 — proposition-slot grounding for articulate_with_intent
Root cause: recalled_words was built from result.tokens (versor walk
neighbours) rather than the pack-resolved proposition slots. The walk
produces nearest-neighbour traversal artifacts; the proposition already
carries the correct subject/predicate/object from realize(). This made
ground_graph() fill <pending> obj slots with stop-word-adjacent tokens
instead of the actual answer content.

Fix — two changes, one new helper:

generate/intent_bridge.py
  • build_recalled_words_from_plan(plan, proposition, walk_tokens)
    Constructs the grounding tuple in priority order:
      1. plan.object  (ArticulationPlan — pack-resolved, already a word)
      2. proposition.object_  (Proposition — versor-decoded object slot)
      3. plan.predicate  (descriptive predicate word, richer than walk)
      4. plan.subject  (subject as last-resort semantic anchor)
      5. walk_tokens  (result.tokens alpha-filtered — supplemental backfill)
    Strips <pending>/<prior>/empty/non-alpha before deduplicating.
    Returns a deduplicated tuple in that priority order.
  • articulate_with_intent() gains an optional `proposition` param
    (typed as object to avoid import coupling at the call site).
    When provided, build_recalled_words_from_plan() is called to
    replace the raw recalled_words before ground_graph() runs.
    When omitted, behaviour is byte-identical to Phase 1 (backward
    compatible: all existing callers and tests pass unchanged).

chat/runtime.py
  • The single articulate_with_intent() call site now passes
    proposition=proposition so the bridge receives the full
    pack-resolved proposition for grounding. walk_tokens (the old
    recalled_words) are passed through as supplemental backfill.
  • No change to ChatResponse, TurnEvent, GenerationResult, or any
    ADR-gated schema.
2026-05-18 18:18:31 -07:00

350 lines
13 KiB
Python

"""generate/intent_bridge.py — connects intent classification to the realizer.
Bridges the gap between chat/runtime.py's articulation path (which resolves
Proposition slot-versors into raw word tokens) and the intent-aware realizer
pipeline (realize_semantic / realize_target in realizer.py, which are fully
implemented but were never called from the chat hot path).
Design constraints:
- Deterministic: same input text + same field state → same surface
- No LLM fallback
- Falls back cleanly to the existing ArticulationPlan when the realizer
cannot produce a non-empty surface (OOV-heavy input, UNKNOWN intent
with no grounded obj slots)
- Does not alter the ArticulationPlan dataclass or ChatResponse structure;
only the .surface field is replaced when the bridge succeeds
Phase 1 instrumentation (observation-only)
``articulate_with_intent()`` emits one ``BridgeTraceRecord`` per call
through the module-level ``_TRACE_SINK`` when a sink has been attached via
``attach_bridge_trace_sink()``. When no sink is attached the emission
path is a pure no-op (single ``is None`` guard, no allocation). This
instruments the four dimensions named in the mastery plan's Phase 1.3.
Zero behavior change on all existing paths.
Phase 2 — proposition-slot grounding
``build_recalled_words_from_plan()`` constructs the grounding tuple
from pack-resolved proposition slots (primary) + walk tokens
(supplemental backfill), replacing the old walk-token-only source.
``articulate_with_intent()`` gains an optional ``proposition`` param;
when supplied, the proposition slots are used to ground the graph's
``<pending>`` obj slots before ``ground_graph()`` runs. Backward
compatible: existing callers that omit ``proposition`` are byte-
identical to Phase 1.
"""
from __future__ import annotations
from generate.articulation import ArticulationPlan
from generate.graph_planner import (
GraphEdge,
GraphNode,
PropositionGraph,
Relation,
ground_graph,
plan_articulation,
)
from generate.intent import DialogueIntent, IntentTag, classify_intent
from generate.realizer import RealizedPlan, realize_semantic
_PENDING = "<pending>"
_PRIOR = "<prior>"
_EMPTY_INDICATORS = frozenset({_PENDING, _PRIOR, "...", ""})
_STRIP_INDICATORS = frozenset({_PENDING, _PRIOR})
# ---------------------------------------------------------------------------
# Phase 1 — module-level trace sink (opt-in, observation-only)
# ---------------------------------------------------------------------------
_TRACE_SINK = None # type: object | None
_TRACE_INCLUDE_CONTENT: bool = False
def attach_bridge_trace_sink(
sink,
*,
include_content: bool = False,
) -> None:
"""Attach a :class:`generate.bridge_trace.BridgeTraceSink`.
After each call to ``articulate_with_intent()`` the runtime emits
one JSONL-formatted ``BridgeTraceRecord`` to *sink*. Passing
``None`` detaches.
``include_content`` opts surface text, recalled words, and slot
values into the emitted record. Default ``False`` preserves
redact-by-default (CLAUDE.md trust boundary): aggregation
pipelines get counts and flags without raw text.
"""
global _TRACE_SINK, _TRACE_INCLUDE_CONTENT
_TRACE_SINK = sink
_TRACE_INCLUDE_CONTENT = bool(include_content)
def detach_bridge_trace_sink() -> None:
"""Detach any attached trace sink (convenience alias for attach(None))."""
global _TRACE_SINK, _TRACE_INCLUDE_CONTENT
_TRACE_SINK = None
_TRACE_INCLUDE_CONTENT = False
def _emit_trace(
*,
intent_tag: str,
intent_subject: str,
plan: ArticulationPlan,
recalled_words: tuple[str, ...],
pre_ground_obj: str,
post_ground_obj: str,
bridge_surface: str,
bridge_useful: bool,
) -> None:
"""Emit one BridgeTraceRecord to the attached sink (no-op when None)."""
if _TRACE_SINK is None:
return
from generate.bridge_trace import BridgeTraceRecord, format_bridge_trace_jsonl
record = BridgeTraceRecord(
intent_tag=intent_tag,
intent_subject=intent_subject,
plan_subject=plan.subject or "",
plan_predicate=plan.predicate or "",
plan_object=plan.object or "",
recalled_words_len=len(recalled_words),
recalled_words_sample=recalled_words[:5] if _TRACE_INCLUDE_CONTENT else (),
pre_ground_obj=pre_ground_obj,
post_ground_obj=post_ground_obj,
bridge_surface=bridge_surface if _TRACE_INCLUDE_CONTENT else "",
bridge_useful=bridge_useful,
fallback_surface=plan.surface if _TRACE_INCLUDE_CONTENT else "",
)
line = format_bridge_trace_jsonl(
record,
include_content=_TRACE_INCLUDE_CONTENT,
)
_TRACE_SINK.emit(line)
# ---------------------------------------------------------------------------
# Phase 2 — proposition-slot grounding helper
# ---------------------------------------------------------------------------
def build_recalled_words_from_plan(
plan: ArticulationPlan,
proposition: object | None = None,
walk_tokens: tuple[str, ...] = (),
) -> tuple[str, ...]:
"""Build a grounding word tuple from pack-resolved proposition slots.
Priority order (highest to lowest):
1. ``plan.object`` — ArticulationPlan object slot (pack-resolved
surface word; the most direct answer token)
2. ``proposition.object_`` — Proposition object slot (versor-decoded;
present when the proposition was pack-grounded)
3. ``plan.predicate`` — descriptive predicate word (richer semantic
anchor than a random walk neighbour)
4. ``plan.subject`` — subject as last-resort anchor
5. ``walk_tokens`` — alpha-filtered result.tokens from generate()
(supplemental backfill; original Phase 1
source, now demoted to fill remaining slots)
Each candidate is stripped of leading/trailing whitespace and excluded
if it is empty, non-alphabetic, or one of the ``<pending>``/``<prior>``
sentinels. The final tuple is deduplicated (first-occurrence wins)
while preserving priority order.
Returns an empty tuple when no grounded candidates remain after
filtering, in which case the graph node retains its ``<pending>``
sentinel and ``_is_useful_surface`` will return False for that turn
— the correct honest-fallback behaviour.
"""
seen: set[str] = set()
result: list[str] = []
def _push(word: object) -> None:
if not word:
return
w = str(word).strip()
if not w or not w.isalpha():
return
if w in _STRIP_INDICATORS:
return
if w in seen:
return
seen.add(w)
result.append(w)
# 1. ArticulationPlan object
_push(plan.object)
# 2. Proposition object_ (access via getattr to avoid import coupling)
if proposition is not None:
_push(getattr(proposition, "object_", None))
# 3. Plan predicate
_push(plan.predicate)
# 4. Plan subject
_push(plan.subject)
# 5. Walk tokens as supplemental backfill
for tok in walk_tokens:
_push(tok)
return tuple(result)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def classify_intent_from_input(text: str) -> DialogueIntent:
"""Run the rule-based intent classifier against raw input text."""
return classify_intent(text)
def build_graph_from_input(text: str, plan: ArticulationPlan) -> PropositionGraph:
"""Public helper: classify intent and build the pre-generation PropositionGraph.
Returns the same graph that ``articulate_with_intent`` builds internally,
but without grounding ``<pending>`` slots — the result is suitable for
forward-constraint construction via ``build_graph_constraint`` BEFORE
``generate()`` runs (ADR-0046, ADR-0047).
"""
intent = classify_intent_from_input(text)
return _build_graph_from_intent(intent, plan)
def _build_graph_from_intent(intent: DialogueIntent, plan: ArticulationPlan) -> PropositionGraph:
"""Build a minimal PropositionGraph from a classified intent and an ArticulationPlan."""
from generate.graph_planner import _INTENT_PREDICATES # noqa: PLC0415
predicate = _INTENT_PREDICATES.get(intent.tag, "addresses")
subject = intent.subject or plan.subject or ""
obj = plan.object or plan.predicate or _PENDING
graph = PropositionGraph()
if intent.tag is IntentTag.COMPARISON:
secondary = intent.secondary_subject or plan.object or plan.predicate or obj
left = GraphNode(
node_id="p0",
subject=subject,
predicate=predicate,
obj=secondary,
source_intent=intent.tag,
)
right = GraphNode(
node_id="p1",
subject=secondary,
predicate=predicate,
obj=subject,
source_intent=intent.tag,
)
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
return graph.add_node(left).add_node(right).add_edge(edge)
root = GraphNode(
node_id="p0",
subject=subject,
predicate=predicate,
obj=obj,
source_intent=intent.tag,
)
return graph.add_node(root)
def _is_useful_surface(surface: str) -> bool:
"""Return True when the realized surface is non-empty and fully grounded."""
if not surface or not surface.strip():
return False
for indicator in _EMPTY_INDICATORS:
if indicator and indicator in surface:
return False
return True
def articulate_with_intent(
text: str,
plan: ArticulationPlan,
recalled_words: tuple[str, ...] = (),
*,
proposition: object | None = None,
) -> str:
"""Return an intent-aware surface string for *plan*, or "" if none can be produced.
Steps:
1. Classify intent from raw input *text*
2. Build a PropositionGraph from the intent + ArticulationPlan slot words
3. Ground <pending> obj slots:
Phase 2: when ``proposition`` is supplied, build the grounding
tuple from pack-resolved proposition slots (primary) + walk
tokens ``recalled_words`` (supplemental backfill) via
``build_recalled_words_from_plan()``.
Legacy: when ``proposition`` is None, use ``recalled_words``
directly (byte-identical to Phase 1 — backward compatible).
4. Plan articulation (topological walk)
5. Realize via realize_semantic() for intent-specific templates
6. Return the surface, or "" if the result is empty / ungrounded
The caller (chat/runtime.py) should fall back to the existing
ArticulationPlan.surface when this returns "".
"""
intent = classify_intent_from_input(text)
intent_tag_name = intent.tag.name if intent.tag is not None else "UNKNOWN"
intent_subject = intent.subject or ""
graph = _build_graph_from_intent(intent, plan)
# Record pre-grounding obj for the Phase 1 trace.
pre_ground_obj = graph.nodes[0].obj if graph.nodes else _PENDING
# Phase 2: build grounding words from proposition slots (primary) +
# walk tokens (supplemental). Falls back to raw recalled_words when
# proposition is not supplied (backward-compatible legacy path).
if proposition is not None:
effective_recalled = build_recalled_words_from_plan(
plan, proposition, walk_tokens=recalled_words
)
else:
effective_recalled = recalled_words
if effective_recalled:
graph = ground_graph(graph, effective_recalled)
# Record post-grounding obj for the Phase 1 trace.
post_ground_obj = graph.nodes[0].obj if graph.nodes else _PENDING
articulation_target = plan_articulation(graph)
realized: RealizedPlan = realize_semantic(articulation_target, graph)
if not realized.surface or not realized.fragments:
_emit_trace(
intent_tag=intent_tag_name,
intent_subject=intent_subject,
plan=plan,
recalled_words=effective_recalled,
pre_ground_obj=pre_ground_obj,
post_ground_obj=post_ground_obj,
bridge_surface="",
bridge_useful=False,
)
return ""
surface = realized.surface
useful = _is_useful_surface(surface)
_emit_trace(
intent_tag=intent_tag_name,
intent_subject=intent_subject,
plan=plan,
recalled_words=effective_recalled,
pre_ground_obj=pre_ground_obj,
post_ground_obj=post_ground_obj,
bridge_surface=surface,
bridge_useful=useful,
)
if not useful:
return ""
return surface