feat(generate): add surface.py — SentenceAssembler (ADR-0012)
Implement SentenceAssembler for generating coherent surface sentences from articulation plans and token sequences.
This commit is contained in:
parent
3711fad448
commit
565c48bdf0
1 changed files with 289 additions and 0 deletions
289
generate/surface.py
Normal file
289
generate/surface.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""generate/surface.py — SentenceAssembler (ADR-0012)
|
||||
|
||||
Bridges ArticulationPlan + GenerationResult.tokens into a fully coherent
|
||||
surface sentence. This is the final realisation stage: every prior layer
|
||||
(field walk, proposition formation, articulation) feeds here.
|
||||
|
||||
Contract:
|
||||
Input: ArticulationPlan — subject / predicate / object slots
|
||||
Sequence[str] — ordered token walk from generate()
|
||||
DialogueRole — assert | elaborate | question | refute
|
||||
output_language: str — BCP-47 language tag (default "en")
|
||||
Output: str — a single grammatical sentence
|
||||
|
||||
Determinism guarantee:
|
||||
Identical (plan, tokens, role, language) → identical output string.
|
||||
No randomness, no model calls, no state mutation.
|
||||
|
||||
Sentence templates by DialogueRole (English):
|
||||
assert → "{Subject} {predicate} {object}."
|
||||
elaborate → "{Subject} {predicate} {object} — {elaboration}."
|
||||
question → "Does {subject} {predicate} {object}?"
|
||||
refute → "{Subject} does not {predicate} {object}."
|
||||
|
||||
Elaboration material is drawn from the walk tokens that survive
|
||||
_STOP_SURFACES filtering, deduplicated, capped at _MAX_ELAB_TOKENS, and
|
||||
joined with commas. This keeps elaboration grounded in the actual
|
||||
manifold walk rather than invented content.
|
||||
|
||||
Language routing:
|
||||
Hebrew (he) — VSO order following articulation.py convention
|
||||
Greek (grc) — SOV order following articulation.py convention
|
||||
All others — SVO (English default)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence
|
||||
|
||||
from generate.articulation import ArticulationPlan
|
||||
from generate.dialogue import DialogueRole
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Tokens that carry no propositional content; excluded from elaboration.
|
||||
_STOP_SURFACES: frozenset[str] = frozenset({
|
||||
"it", "to", "a", "an", "the", "and", "or", "but", "in", "on",
|
||||
"at", "of", "for", "with", "by", "from", "is", "are", "was",
|
||||
"were", "be", "been", "being", "have", "has", "had", "do",
|
||||
"does", "did", "will", "would", "could", "should", "may",
|
||||
"might", "shall", "can", "word", "what", "who", "how",
|
||||
"why", "when", "which", "that", "this", "these", "those",
|
||||
})
|
||||
|
||||
_MAX_ELAB_TOKENS: int = 4 # max walk tokens woven into elaboration
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SentencePlan — immutable record of the assembled sentence components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentencePlan:
|
||||
"""Fully resolved sentence before punctuation is appended."""
|
||||
subject: str
|
||||
predicate_phrase: str
|
||||
object_phrase: str | None
|
||||
elaboration: str | None # None unless role == "elaborate"
|
||||
dialogue_role: str
|
||||
output_language: str
|
||||
surface: str # the final rendered sentence
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _cap(word: str) -> str:
|
||||
"""Capitalise the first codepoint; leave the rest as-is."""
|
||||
if not word:
|
||||
return word
|
||||
return word[0].upper() + word[1:]
|
||||
|
||||
|
||||
def _elaboration_tokens(
|
||||
tokens: Sequence[str],
|
||||
already_used: frozenset[str],
|
||||
) -> list[str]:
|
||||
"""Pick walk tokens that add propositional content beyond the slots."""
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for tok in tokens:
|
||||
low = tok.lower()
|
||||
if (
|
||||
low in _STOP_SURFACES
|
||||
or low in already_used
|
||||
or low in seen
|
||||
or not tok.isalpha()
|
||||
):
|
||||
continue
|
||||
seen.add(low)
|
||||
result.append(tok)
|
||||
if len(result) >= _MAX_ELAB_TOKENS:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def _assemble_en(
|
||||
subject: str,
|
||||
predicate: str,
|
||||
object_: str | None,
|
||||
elab_tokens: list[str],
|
||||
role: str,
|
||||
) -> str:
|
||||
"""English SVO sentence assembly."""
|
||||
subj = _cap(subject)
|
||||
obj = object_ or ""
|
||||
|
||||
if role == "assert":
|
||||
parts = [subj, predicate]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
return " ".join(parts) + "."
|
||||
|
||||
if role == "elaborate":
|
||||
parts = [subj, predicate]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
base = " ".join(parts)
|
||||
if elab_tokens:
|
||||
elab = ", ".join(elab_tokens)
|
||||
return f"{base} — {elab}."
|
||||
return base + "."
|
||||
|
||||
if role == "question":
|
||||
# "Does subject predicate object?"
|
||||
parts = ["Does", subject, predicate]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
return " ".join(parts) + "?"
|
||||
|
||||
if role == "refute":
|
||||
# "Subject does not predicate object."
|
||||
parts = [subj, "does not", predicate]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
return " ".join(parts) + "."
|
||||
|
||||
# fallback — treat as assert
|
||||
parts = [subj, predicate]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
return " ".join(parts) + "."
|
||||
|
||||
|
||||
def _assemble_he(
|
||||
subject: str,
|
||||
predicate: str,
|
||||
object_: str | None,
|
||||
elab_tokens: list[str],
|
||||
role: str,
|
||||
) -> str:
|
||||
"""Hebrew VSO — verb precedes subject, object follows."""
|
||||
obj = object_ or ""
|
||||
if role == "question":
|
||||
parts = ["\u05d4\u05d0\u05dd", predicate, subject]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
return " ".join(parts) + "?"
|
||||
if role == "refute":
|
||||
parts = ["\u05dc\u05d0", predicate, subject]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
return " ".join(parts) + "."
|
||||
parts = [predicate, subject]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
base = " ".join(parts)
|
||||
if role == "elaborate" and elab_tokens:
|
||||
elab = ", ".join(elab_tokens)
|
||||
return f"{base} \u2014 {elab}."
|
||||
return base + "."
|
||||
|
||||
|
||||
def _assemble_grc(
|
||||
subject: str,
|
||||
predicate: str,
|
||||
object_: str | None,
|
||||
elab_tokens: list[str],
|
||||
role: str,
|
||||
) -> str:
|
||||
"""Ancient Greek SOV — subject, object, verb."""
|
||||
subj = _cap(subject)
|
||||
obj = object_ or ""
|
||||
if role == "question":
|
||||
parts = [subj]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
parts.append(predicate)
|
||||
return " ".join(parts) + ";"
|
||||
if role == "refute":
|
||||
neg = "\u03bf\u1f50" # ou
|
||||
parts = [subj, neg]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
parts.append(predicate)
|
||||
return " ".join(parts) + "."
|
||||
parts = [subj]
|
||||
if obj:
|
||||
parts.append(obj)
|
||||
parts.append(predicate)
|
||||
base = " ".join(parts)
|
||||
if role == "elaborate" and elab_tokens:
|
||||
elab = ", ".join(elab_tokens)
|
||||
return f"{base} \u2014 {elab}."
|
||||
return base + "."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SentenceAssembler — public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SentenceAssembler:
|
||||
"""Stateless assembler: maps (plan, tokens, role) to a surface sentence.
|
||||
|
||||
No field state is held. All inputs are consumed read-only.
|
||||
The same instance may be shared across sessions.
|
||||
"""
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
plan: ArticulationPlan,
|
||||
tokens: Sequence[str],
|
||||
role: DialogueRole = "assert",
|
||||
) -> SentencePlan:
|
||||
"""Produce a SentencePlan (with .surface) from an ArticulationPlan.
|
||||
|
||||
Steps:
|
||||
1. Extract slot strings from the plan.
|
||||
2. Filter elaboration tokens from the walk.
|
||||
3. Route to language-specific assembler.
|
||||
4. Return an immutable SentencePlan.
|
||||
"""
|
||||
subject = plan.subject or ""
|
||||
predicate = plan.predicate or ""
|
||||
object_ = plan.object or None
|
||||
lang = plan.output_language or "en"
|
||||
role_str = str(role)
|
||||
|
||||
# Collect slots already present so elaboration doesn't repeat them.
|
||||
used_slots: frozenset[str] = frozenset(
|
||||
w.lower()
|
||||
for w in [subject, predicate, object_]
|
||||
if w
|
||||
)
|
||||
elab_tokens = _elaboration_tokens(tokens, used_slots)
|
||||
|
||||
# Language dispatch
|
||||
if lang == "he":
|
||||
surface = _assemble_he(subject, predicate, object_, elab_tokens, role_str)
|
||||
elif lang == "grc":
|
||||
surface = _assemble_grc(subject, predicate, object_, elab_tokens, role_str)
|
||||
else:
|
||||
surface = _assemble_en(subject, predicate, object_, elab_tokens, role_str)
|
||||
|
||||
return SentencePlan(
|
||||
subject=subject,
|
||||
predicate_phrase=predicate,
|
||||
object_phrase=object_,
|
||||
elaboration=", ".join(elab_tokens) if elab_tokens else None,
|
||||
dialogue_role=role_str,
|
||||
output_language=lang,
|
||||
surface=surface,
|
||||
)
|
||||
|
||||
|
||||
# Module-level default instance (stateless — safe to share)
|
||||
default_assembler = SentenceAssembler()
|
||||
|
||||
|
||||
def assemble(
|
||||
plan: ArticulationPlan,
|
||||
tokens: Sequence[str],
|
||||
role: DialogueRole = "assert",
|
||||
) -> str:
|
||||
"""Convenience wrapper returning only the surface string."""
|
||||
return default_assembler.assemble(plan, tokens, role).surface
|
||||
Loading…
Reference in a new issue