Add articulation realizer v2
- add deterministic ArticulationTarget realizer - add rhetorical move templates and predicate humanization - handle definition, comparison, correction, unknown, and empty targets - keep runtime ChatResponse path unchanged - add focused realizer tests
This commit is contained in:
parent
c68b0734a2
commit
58a06124bf
3 changed files with 252 additions and 0 deletions
102
generate/realizer.py
Normal file
102
generate/realizer.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""ArticulationRealizerV2 — deterministic template-based realization.
|
||||
|
||||
Converts an ArticulationTarget (ordered rhetorical steps from the graph
|
||||
planner) into a RealizedPlan: an ordered sequence of surface fragments
|
||||
joined into a single deterministic surface string.
|
||||
|
||||
Design constraints:
|
||||
- No LLM fallback
|
||||
- No broad grammar engine
|
||||
- Deterministic: same ArticulationTarget → same RealizedPlan, always
|
||||
- Composable: does not replace the existing realize() path yet
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from generate.graph_planner import (
|
||||
ArticulationStep,
|
||||
ArticulationTarget,
|
||||
PropositionGraph,
|
||||
RhetoricalMove,
|
||||
)
|
||||
from generate.intent import IntentTag
|
||||
from generate.templates import render_step
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RealizedFragment:
|
||||
node_id: str
|
||||
move: RhetoricalMove
|
||||
surface: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"node_id": self.node_id,
|
||||
"move": self.move.value,
|
||||
"surface": self.surface,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RealizedPlan:
|
||||
fragments: tuple[RealizedFragment, ...]
|
||||
surface: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"fragments": tuple(f.as_dict() for f in self.fragments),
|
||||
"surface": self.surface,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_obj(step: ArticulationStep, graph: PropositionGraph | None) -> str:
|
||||
"""Look up the object slot from the graph node matching this step."""
|
||||
if graph is None:
|
||||
return "..."
|
||||
for node in graph.nodes:
|
||||
if node.node_id == step.node_id:
|
||||
return node.obj
|
||||
return "..."
|
||||
|
||||
|
||||
def realize_target(
|
||||
target: ArticulationTarget,
|
||||
graph: PropositionGraph | None = None,
|
||||
) -> RealizedPlan:
|
||||
"""Realize an ArticulationTarget into a deterministic surface plan.
|
||||
|
||||
Each step is rendered through the template for its rhetorical move,
|
||||
then fragments are joined with sentence-level punctuation.
|
||||
|
||||
Returns an empty-but-valid RealizedPlan for empty/None targets.
|
||||
"""
|
||||
if target is None or not target.steps:
|
||||
return RealizedPlan(fragments=(), surface="")
|
||||
|
||||
fragments: list[RealizedFragment] = []
|
||||
for step in target.steps:
|
||||
obj = _resolve_obj(step, graph)
|
||||
move = step.move
|
||||
if move is RhetoricalMove.ASSERT and target.source_intent is IntentTag.CORRECTION:
|
||||
move = RhetoricalMove.CORRECT
|
||||
surface = render_step(
|
||||
move=move,
|
||||
subject=step.subject,
|
||||
predicate=step.predicate,
|
||||
obj=obj,
|
||||
)
|
||||
fragments.append(
|
||||
RealizedFragment(
|
||||
node_id=step.node_id,
|
||||
move=move,
|
||||
surface=surface,
|
||||
)
|
||||
)
|
||||
|
||||
joined = ". ".join(f.surface for f in fragments)
|
||||
if joined and not joined.endswith("."):
|
||||
joined += "."
|
||||
|
||||
return RealizedPlan(fragments=tuple(fragments), surface=joined)
|
||||
57
generate/templates.py
Normal file
57
generate/templates.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Deterministic surface templates for rhetorical moves.
|
||||
|
||||
Each template is a format string keyed by RhetoricalMove. Slots:
|
||||
{subject} — primary subject from the articulation step
|
||||
{predicate} — semantic predicate (e.g. "is_defined_as", "contrasts_with")
|
||||
{obj} — object slot from the graph node (may be "<pending>")
|
||||
|
||||
Templates are intentionally simple. The goal is structural correctness,
|
||||
not fluency — fluency comes in a later phase when the generation stream
|
||||
consumes these as constraints rather than final output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from generate.graph_planner import RhetoricalMove
|
||||
|
||||
|
||||
_PREDICATE_DISPLAY: dict[str, str] = {
|
||||
"is_defined_as": "is defined as",
|
||||
"is_caused_by": "is caused by",
|
||||
"has_steps": "has the following steps",
|
||||
"contrasts_with": "contrasts with",
|
||||
"corrects": "corrects",
|
||||
"recalls": "recalls",
|
||||
"is_verified_as": "is verified as",
|
||||
"addresses": "addresses",
|
||||
}
|
||||
|
||||
|
||||
def _humanize_predicate(predicate: str) -> str:
|
||||
return _PREDICATE_DISPLAY.get(predicate, predicate.replace("_", " "))
|
||||
|
||||
|
||||
_MOVE_TEMPLATES: dict[RhetoricalMove, str] = {
|
||||
RhetoricalMove.ASSERT: "{subject} {predicate_h} {obj}",
|
||||
RhetoricalMove.ELABORATE: "furthermore, {subject} {predicate_h} {obj}",
|
||||
RhetoricalMove.CONTRAST: "in contrast, {subject} {predicate_h} {obj}",
|
||||
RhetoricalMove.SEQUENCE: "next, {subject} {predicate_h} {obj}",
|
||||
RhetoricalMove.CORRECT: "correction: {subject} {predicate_h} {obj}",
|
||||
}
|
||||
|
||||
|
||||
def render_step(
|
||||
move: RhetoricalMove,
|
||||
subject: str,
|
||||
predicate: str,
|
||||
obj: str,
|
||||
) -> str:
|
||||
"""Render a single articulation step into a surface fragment."""
|
||||
template = _MOVE_TEMPLATES[move]
|
||||
predicate_h = _humanize_predicate(predicate)
|
||||
obj_display = obj if obj != "<pending>" else "..."
|
||||
return template.format(
|
||||
subject=subject,
|
||||
predicate_h=predicate_h,
|
||||
obj=obj_display,
|
||||
)
|
||||
93
tests/test_articulation_realizer_v2.py
Normal file
93
tests/test_articulation_realizer_v2.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Tests for ArticulationRealizerV2 — deterministic template-based realization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from generate.graph_planner import (
|
||||
ArticulationTarget,
|
||||
graph_from_intent,
|
||||
plan_articulation,
|
||||
)
|
||||
from generate.intent import IntentTag, classify_intent
|
||||
from generate.realizer import RealizedPlan, realize_target
|
||||
|
||||
|
||||
def _realize_from_prompt(prompt: str, *, prior_node_id: str | None = None) -> RealizedPlan:
|
||||
intent = classify_intent(prompt)
|
||||
graph = graph_from_intent(intent, prior_node_id=prior_node_id)
|
||||
target = plan_articulation(graph)
|
||||
return realize_target(target, graph)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Definition realizer mentions subject
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_definition_realizer_mentions_subject() -> None:
|
||||
plan = _realize_from_prompt("What is a multivector?")
|
||||
|
||||
assert len(plan.fragments) == 1
|
||||
assert "multivector" in plan.surface.lower()
|
||||
assert "is defined as" in plan.surface.lower()
|
||||
assert plan.surface.endswith(".")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Comparison realizer mentions both terms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_comparison_realizer_mentions_both_terms() -> None:
|
||||
plan = _realize_from_prompt("Compare MLX and PyTorch")
|
||||
|
||||
assert len(plan.fragments) == 2
|
||||
surface_lower = plan.surface.lower()
|
||||
assert "mlx" in surface_lower
|
||||
assert "pytorch" in surface_lower
|
||||
assert "in contrast" in surface_lower
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Correction realizer mentions prior or correction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_correction_realizer_mentions_prior_or_correction() -> None:
|
||||
plan = _realize_from_prompt(
|
||||
"No, that's wrong — it should be grade 2",
|
||||
prior_node_id="prev_p0",
|
||||
)
|
||||
|
||||
assert len(plan.fragments) == 1
|
||||
surface_lower = plan.surface.lower()
|
||||
assert "correction:" in surface_lower
|
||||
assert "corrects" in surface_lower
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Unknown or empty graph is bounded
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_unknown_or_empty_graph_is_bounded() -> None:
|
||||
empty_target = ArticulationTarget(steps=(), source_intent=IntentTag.UNKNOWN)
|
||||
plan = realize_target(empty_target, graph=None)
|
||||
|
||||
assert plan.surface == ""
|
||||
assert plan.fragments == ()
|
||||
|
||||
unknown_plan = _realize_from_prompt("xyzzy foobar")
|
||||
assert unknown_plan.surface
|
||||
assert len(unknown_plan.fragments) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Realizer output is deterministic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_realizer_output_is_deterministic() -> None:
|
||||
plan_a = _realize_from_prompt("What is light?")
|
||||
plan_b = _realize_from_prompt("What is light?")
|
||||
|
||||
assert plan_a.surface == plan_b.surface
|
||||
assert len(plan_a.fragments) == len(plan_b.fragments)
|
||||
for fa, fb in zip(plan_a.fragments, plan_b.fragments):
|
||||
assert fa.surface == fb.surface
|
||||
assert fa.move == fb.move
|
||||
assert fa.node_id == fb.node_id
|
||||
Loading…
Reference in a new issue