305 lines
9.6 KiB
Python
305 lines
9.6 KiB
Python
"""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)
|
|
|
|
# Empty-slot guard: if slot versors were missing, both subject and
|
|
# predicate will be empty. Fall back to the raw articulation surface
|
|
# rather than emitting a bare ".".
|
|
if not subject and not predicate:
|
|
fallback = plan.surface or " ".join(t for t in tokens if t)
|
|
return SentencePlan(
|
|
subject="",
|
|
predicate_phrase="",
|
|
object_phrase=object_,
|
|
elaboration=None,
|
|
dialogue_role=role_str,
|
|
output_language=lang,
|
|
surface=fallback,
|
|
)
|
|
|
|
# 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
|