Merge pull request #666 from AssetOverflow/feat/q1-c-question-renderer
feat(epistemic): Q1-C — grounded-only question renderer (off-serving)
This commit is contained in:
commit
0401ec4b39
3 changed files with 420 additions and 0 deletions
19
core/epistemic_questions/__init__.py
Normal file
19
core/epistemic_questions/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""Q1-C — the grounded-only epistemic question renderer (off-serving).
|
||||||
|
|
||||||
|
Turns an ASK :class:`~core.epistemic_disclosure.limitation.LimitationAssessment`
|
||||||
|
into an :class:`~core.epistemic_questions.render.EpistemicQuestion` under the
|
||||||
|
wrong=0 grounded-rendering invariant (scoping §2). Render only; no bus delivery,
|
||||||
|
no served disposition, no serving — that is Q1-D.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.epistemic_questions.render import (
|
||||||
|
EpistemicQuestion,
|
||||||
|
render_question,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EpistemicQuestion",
|
||||||
|
"render_question",
|
||||||
|
]
|
||||||
195
core/epistemic_questions/render.py
Normal file
195
core/epistemic_questions/render.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""The grounded-only question renderer (Q1-C) — wrong=0-safe by construction.
|
||||||
|
|
||||||
|
This is the renderer rung of the Epistemic Disclosure ASK spine: given a
|
||||||
|
:class:`~core.epistemic_disclosure.limitation.LimitationAssessment` whose
|
||||||
|
``resolution_action == "ask_question"`` and which carries at least one typed
|
||||||
|
:class:`~core.epistemic_disclosure.limitation.MissingSlot`, produce an
|
||||||
|
:class:`EpistemicQuestion` — a rendered user-facing question, or an explicit
|
||||||
|
``question_unrenderable`` verdict. Nothing here delivers, serves, or chooses a
|
||||||
|
disposition (that is Q1-D); this is *only* surface realization of the residue.
|
||||||
|
|
||||||
|
**The wrong=0 invariant (scoping §2 / session §1.5.7) — the whole point.**
|
||||||
|
|
||||||
|
A question may name an entity, slot, unit, or relation only if it appears
|
||||||
|
*verbatim* in the assessment's ``grounded_terms``. When the grounded terms
|
||||||
|
lack what a question needs, degrade to a generic question or emit
|
||||||
|
``question_unrenderable`` — never a named guess.
|
||||||
|
|
||||||
|
Two substrate facts force the conservative policy below:
|
||||||
|
|
||||||
|
1. ``grounded_terms`` is empty for every assessment produced today — the readers
|
||||||
|
do not yet emit verbatim evidence on refusal (scoping §3, the substrate gap
|
||||||
|
Q1 must close first). So there is no grounded problem-entity to name.
|
||||||
|
2. A *missing* slot's referent is, by definition, absent from the comprehension
|
||||||
|
trace — the missing thing can never appear in ``grounded_terms`` even once
|
||||||
|
readers do emit evidence. ``grounded_terms`` can only supply *context*
|
||||||
|
entities, and binding a slot to its context entity needs an alignment step
|
||||||
|
that Q1-C does not have (a later rung).
|
||||||
|
|
||||||
|
**Chosen rendering policy — generic-structural, names zero problem entities.**
|
||||||
|
|
||||||
|
Because Q1-C can neither (today) read grounded context nor (ever, for the slot
|
||||||
|
itself) name the missing referent from grounded text, the only wrong=0-safe
|
||||||
|
artifact it can render is a *generic* question whose sole variable content is a
|
||||||
|
controlled English phrase for the slot's structural *type*
|
||||||
|
(``expected_unit_or_type``), drawn from the closed, audited
|
||||||
|
:data:`_CLOSED_TYPE_PHRASES` map below. Concretely the renderer:
|
||||||
|
|
||||||
|
- NEVER surfaces ``slot_name`` or ``binding_target`` — these are snake_case
|
||||||
|
structural identifiers, and user-facing prose must never come from them
|
||||||
|
(limitation.py ``MissingSlot`` docstring; session §1.5.7). ``slot_name`` is
|
||||||
|
also the field most likely to *read* like a fabricated entity (``ben_rate`` →
|
||||||
|
the forbidden "Ben"), so it is never touched.
|
||||||
|
- NEVER prettifies a snake_case identifier into a natural-language entity — no
|
||||||
|
capitalization, no possessive, no splitting on underscores.
|
||||||
|
- Translates ``expected_unit_or_type`` through the closed map only; an unmapped
|
||||||
|
type degrades to ``question_unrenderable`` (a ``renderability_gap``) rather
|
||||||
|
than dumping raw snake_case or guessing.
|
||||||
|
- Names no problem-specific entity at all. The closed type phrases ("a
|
||||||
|
whole-number count") are generic structural descriptors that assert nothing
|
||||||
|
about *this* problem — distinct from problem-specific names, which would need
|
||||||
|
grounding. This is the line scoping §2 draws ("generic, all terms grounded"
|
||||||
|
vs. the fabricated "Ben").
|
||||||
|
|
||||||
|
A post-render fabrication guard (:func:`_names_only_grounded`) re-checks that
|
||||||
|
every word in the rendered text is closed-vocabulary scaffold or appears in
|
||||||
|
``grounded_terms`` — defense in depth so a fabricated token can never escape even
|
||||||
|
if the template were later edited carelessly.
|
||||||
|
|
||||||
|
**Off-serving.** Imports nothing from ``generate.derivation`` or
|
||||||
|
``core.reliability_gate``; it cannot move the sealed GSM8K metric.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from core.epistemic_disclosure.limitation import (
|
||||||
|
LimitationAssessment,
|
||||||
|
MissingSlot,
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Closed, audited map from a family-pinned ``expected_unit_or_type`` to a
|
||||||
|
#: controlled English phrase. Keys are exactly the slot types that ship today
|
||||||
|
#: (``core.epistemic_disclosure.limitation._FAMILY_TO_MISSING_SLOTS``). A slot
|
||||||
|
#: whose type is absent here is NOT rendered — the renderer refuses with a
|
||||||
|
#: ``renderability_gap`` rather than surfacing raw snake_case. New slot types
|
||||||
|
#: earn a phrase here (with a test) when their family lands, never by guessing.
|
||||||
|
_CLOSED_TYPE_PHRASES: dict[str, str] = {
|
||||||
|
"count_int": "a whole-number count",
|
||||||
|
"measured_unit_int": "a whole-number quantity",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: The fixed question scaffold. The only hole is the closed type phrase; every
|
||||||
|
#: other word is constant, problem-independent English. It names no entity.
|
||||||
|
_TEMPLATE = (
|
||||||
|
"To answer this, one more value is still needed — {phrase} — that the "
|
||||||
|
"problem does not state. What is it?"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Machine reasons for an :class:`EpistemicQuestion`. Closed set.
|
||||||
|
_REASON_RENDERED = "rendered"
|
||||||
|
_REASON_NOT_ASK = "not_ask"
|
||||||
|
_REASON_NO_SLOT = "no_missing_slot"
|
||||||
|
_REASON_MULTI_SLOT = "multi_slot_not_supported"
|
||||||
|
_REASON_RENDERABILITY_GAP = "renderability_gap"
|
||||||
|
_REASON_FABRICATION_GUARD = "fabrication_guard"
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens(text: str) -> set[str]:
|
||||||
|
"""Lowercased maximal alphabetic runs — the unit the fabrication guard checks."""
|
||||||
|
return set(re.findall(r"[a-z]+", text.lower()))
|
||||||
|
|
||||||
|
|
||||||
|
#: Every word the renderer is allowed to emit *without* grounding: the scaffold
|
||||||
|
#: words plus the words of every closed type phrase. Built once at import.
|
||||||
|
_ALLOWED_SCAFFOLD_WORDS: frozenset[str] = frozenset(
|
||||||
|
_tokens(_TEMPLATE.replace("{phrase}", " "))
|
||||||
|
| {w for phrase in _CLOSED_TYPE_PHRASES.values() for w in _tokens(phrase)}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EpistemicQuestion:
|
||||||
|
"""The rendered ASK artifact, or an explicit unrenderable verdict.
|
||||||
|
|
||||||
|
``slot`` is the bound :class:`MissingSlot` the question is about — present
|
||||||
|
whenever a slot was selected, ``None`` when the assessment carried no slot to
|
||||||
|
bind (non-ASK, or zero slots). ``text`` is the rendered question, or ``None``
|
||||||
|
when ``unrenderable``. ``reason`` is a closed-set machine string (one of the
|
||||||
|
``_REASON_*`` constants) explaining the verdict; for a renderable question it
|
||||||
|
is :data:`_REASON_RENDERED`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
slot: MissingSlot | None
|
||||||
|
text: str | None
|
||||||
|
unrenderable: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def _unrenderable(reason: str, slot: MissingSlot | None = None) -> EpistemicQuestion:
|
||||||
|
"""A ``question_unrenderable`` verdict with no text."""
|
||||||
|
return EpistemicQuestion(slot=slot, text=None, unrenderable=True, reason=reason)
|
||||||
|
|
||||||
|
|
||||||
|
def _names_only_grounded(text: str, grounded_terms: tuple[str, ...]) -> bool:
|
||||||
|
"""True iff every word in ``text`` is closed-vocab scaffold or grounded.
|
||||||
|
|
||||||
|
The wrong=0 guard, enforced post-render as defense in depth: a fabricated
|
||||||
|
entity (a word neither in the closed scaffold/phrase vocabulary nor verbatim
|
||||||
|
in ``grounded_terms``) makes this return ``False``, and the renderer refuses.
|
||||||
|
With today's empty ``grounded_terms`` and a fully closed-vocab template this
|
||||||
|
holds by construction; the guard exists so that can never silently change.
|
||||||
|
"""
|
||||||
|
allowed = _ALLOWED_SCAFFOLD_WORDS | _tokens(" ".join(grounded_terms))
|
||||||
|
return _tokens(text) <= allowed
|
||||||
|
|
||||||
|
|
||||||
|
def render_question(assessment: LimitationAssessment) -> EpistemicQuestion:
|
||||||
|
"""Render a single-slot generic ASK question, or refuse to render.
|
||||||
|
|
||||||
|
Strictly single-slot: Q1-C renders only when the assessment carries
|
||||||
|
*exactly one* missing slot. The fixed template asserts "one more value is
|
||||||
|
still needed" — a globally-quantified claim that exactly one value is
|
||||||
|
missing — so rendering the first of several slots and ignoring the rest
|
||||||
|
would make that sentence subtly false (it would imply the single rendered
|
||||||
|
value closes the gap when others remain). Rather than weaken the template to
|
||||||
|
an honest-but-vaguer plural, a multi-slot assessment refuses with
|
||||||
|
``multi_slot_not_supported`` (slot ``None``); one-question-per-slot fan-out
|
||||||
|
is a later rung. The renderer also refuses (``question_unrenderable``) when
|
||||||
|
the assessment is not an ASK, carries no slot, or the slot's structural type
|
||||||
|
is outside the closed phrase map. It NEVER fabricates a natural-language
|
||||||
|
entity name — see the module docstring for the policy and the wrong=0
|
||||||
|
rationale.
|
||||||
|
"""
|
||||||
|
if assessment.resolution_action != "ask_question":
|
||||||
|
return _unrenderable(_REASON_NOT_ASK)
|
||||||
|
if not assessment.missing_slots:
|
||||||
|
return _unrenderable(_REASON_NO_SLOT)
|
||||||
|
if len(assessment.missing_slots) > 1:
|
||||||
|
# Strictly single-slot: the template claims exactly one value is
|
||||||
|
# missing, which is false when several slots remain. Refuse rather than
|
||||||
|
# render the first and silently drop the rest.
|
||||||
|
return _unrenderable(_REASON_MULTI_SLOT, slot=None)
|
||||||
|
|
||||||
|
slot = assessment.missing_slots[0]
|
||||||
|
phrase = _CLOSED_TYPE_PHRASES.get(slot.expected_unit_or_type)
|
||||||
|
if phrase is None:
|
||||||
|
# Unknown structural type: refuse rather than surface raw snake_case.
|
||||||
|
return _unrenderable(_REASON_RENDERABILITY_GAP, slot=slot)
|
||||||
|
|
||||||
|
text = _TEMPLATE.format(phrase=phrase)
|
||||||
|
if not _names_only_grounded(text, assessment.grounded_terms):
|
||||||
|
# Unreachable by construction; the guard is the wrong=0 backstop.
|
||||||
|
return _unrenderable(_REASON_FABRICATION_GUARD, slot=slot)
|
||||||
|
|
||||||
|
return EpistemicQuestion(
|
||||||
|
slot=slot, text=text, unrenderable=False, reason=_REASON_RENDERED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EpistemicQuestion",
|
||||||
|
"render_question",
|
||||||
|
]
|
||||||
206
tests/test_epistemic_question_render.py
Normal file
206
tests/test_epistemic_question_render.py
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
"""Q1-C — the grounded-only question renderer (off-serving).
|
||||||
|
|
||||||
|
Pins the wrong=0 grounded-rendering invariant (scoping §2 / session §1.5.7): a
|
||||||
|
rendered question may name a problem entity only if it appears verbatim in
|
||||||
|
``grounded_terms``; otherwise the renderer degrades to a generic structural
|
||||||
|
question or emits ``question_unrenderable``. ``grounded_terms`` is empty
|
||||||
|
everywhere in production today, so the renderable path is exercised here with the
|
||||||
|
closed structural-type vocabulary only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from core.epistemic_disclosure.limitation import (
|
||||||
|
LimitationAssessment,
|
||||||
|
MissingSlot,
|
||||||
|
)
|
||||||
|
from core.epistemic_questions import EpistemicQuestion, render_question
|
||||||
|
from core.epistemic_questions.render import (
|
||||||
|
_REASON_MULTI_SLOT,
|
||||||
|
_REASON_NO_SLOT,
|
||||||
|
_REASON_NOT_ASK,
|
||||||
|
_REASON_RENDERABILITY_GAP,
|
||||||
|
_names_only_grounded,
|
||||||
|
)
|
||||||
|
from core.epistemic_state import EpistemicState
|
||||||
|
|
||||||
|
|
||||||
|
def _ask_assessment(
|
||||||
|
slots: tuple[MissingSlot, ...],
|
||||||
|
grounded_terms: tuple[str, ...] = (),
|
||||||
|
) -> LimitationAssessment:
|
||||||
|
"""An ``ask_question`` assessment carrying the given typed residue."""
|
||||||
|
return LimitationAssessment(
|
||||||
|
limitation_kind="missing_information",
|
||||||
|
resolution_action="ask_question",
|
||||||
|
epistemic_state=EpistemicState.UNDETERMINED,
|
||||||
|
owner_organ="r2_constraint",
|
||||||
|
blocking_reason="missing_total_count",
|
||||||
|
missing_slots=slots,
|
||||||
|
grounded_terms=grounded_terms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TOTAL_COUNT_SLOT = MissingSlot(
|
||||||
|
slot_name="total_count",
|
||||||
|
expected_unit_or_type="count_int",
|
||||||
|
binding_target="collective_unit_total",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the wrong=0 invariant: nothing named that is not grounded -----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_with_slot_and_empty_grounded_renders_generic_without_ungrounded_tokens() -> None:
|
||||||
|
"""ASK + one slot + empty grounded_terms → a generic question whose every
|
||||||
|
token is closed-vocab scaffold (nothing absent from grounded_terms is named).
|
||||||
|
"""
|
||||||
|
q = render_question(_ask_assessment((_TOTAL_COUNT_SLOT,)))
|
||||||
|
|
||||||
|
assert isinstance(q, EpistemicQuestion)
|
||||||
|
assert not q.unrenderable
|
||||||
|
assert q.slot == _TOTAL_COUNT_SLOT
|
||||||
|
assert q.text is not None
|
||||||
|
# The whole point: with empty grounded_terms, the text names nothing beyond
|
||||||
|
# the closed structural vocabulary.
|
||||||
|
assert _names_only_grounded(q.text, ())
|
||||||
|
# The closed type phrase for ``count_int`` is conveyed.
|
||||||
|
assert "whole-number count" in q.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_rendered_text_never_surfaces_snake_case_identifiers() -> None:
|
||||||
|
"""Neither ``slot_name`` nor ``binding_target`` (snake_case) reaches prose."""
|
||||||
|
q = render_question(_ask_assessment((_TOTAL_COUNT_SLOT,)))
|
||||||
|
|
||||||
|
assert q.text is not None
|
||||||
|
assert "total_count" not in q.text
|
||||||
|
assert "collective_unit_total" not in q.text
|
||||||
|
assert "count_int" not in q.text # the type is translated, never raw
|
||||||
|
|
||||||
|
|
||||||
|
# --- the fabrication guard: snake_case must never become an entity -------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fabrication_guard_slot_name_ben_rate_yields_no_named_ben() -> None:
|
||||||
|
"""A slot whose ``slot_name``/``binding_target`` is ``ben_rate`` must NOT
|
||||||
|
produce a question naming "Ben" — slot_name and binding_target are never
|
||||||
|
surfaced, prettified, or capitalized.
|
||||||
|
"""
|
||||||
|
ben_slot = MissingSlot(
|
||||||
|
slot_name="ben_rate",
|
||||||
|
expected_unit_or_type="count_int", # mapped → renders
|
||||||
|
binding_target="ben_rate",
|
||||||
|
)
|
||||||
|
q = render_question(_ask_assessment((ben_slot,)))
|
||||||
|
|
||||||
|
assert q.text is not None # renders (type is mapped)
|
||||||
|
assert "Ben" not in q.text
|
||||||
|
assert "ben" not in _tokens_lower(q.text)
|
||||||
|
assert "ben_rate" not in q.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_fabrication_guard_predicate_rejects_ungrounded_entity() -> None:
|
||||||
|
"""The guard predicate itself flags a fabricated name when not grounded, and
|
||||||
|
admits it once it is grounded verbatim.
|
||||||
|
"""
|
||||||
|
assert not _names_only_grounded("What is Ben rate?", ())
|
||||||
|
assert _names_only_grounded("What is Ben rate?", ("Ben", "rate"))
|
||||||
|
|
||||||
|
|
||||||
|
# --- non-ASK / no-slot / multi-slot / unmapped-type ---------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_ask_assessment_is_unrenderable() -> None:
|
||||||
|
refuse = LimitationAssessment(
|
||||||
|
limitation_kind="hard_boundary",
|
||||||
|
resolution_action="refuse_known_boundary",
|
||||||
|
epistemic_state=EpistemicState.UNDETERMINED,
|
||||||
|
owner_organ="r2_constraint",
|
||||||
|
blocking_reason="non_integer_solution",
|
||||||
|
missing_slots=(_TOTAL_COUNT_SLOT,), # present, but action is not ask
|
||||||
|
)
|
||||||
|
q = render_question(refuse)
|
||||||
|
|
||||||
|
assert q.unrenderable
|
||||||
|
assert q.text is None
|
||||||
|
assert q.slot is None
|
||||||
|
assert q.reason == _REASON_NOT_ASK
|
||||||
|
|
||||||
|
|
||||||
|
def test_ask_with_zero_slots_is_unrenderable() -> None:
|
||||||
|
q = render_question(_ask_assessment(()))
|
||||||
|
|
||||||
|
assert q.unrenderable
|
||||||
|
assert q.text is None
|
||||||
|
assert q.slot is None
|
||||||
|
assert q.reason == _REASON_NO_SLOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_slot_does_not_claim_all_missing_information_was_asked() -> None:
|
||||||
|
"""Two missing slots → refuse with ``multi_slot_not_supported``.
|
||||||
|
|
||||||
|
The template asserts "one more value is still needed" (exactly one). With two
|
||||||
|
slots that claim is false, so the renderer must NOT render the first and drop
|
||||||
|
the second — it refuses outright, naming no slot. This is the wrong=0-honest
|
||||||
|
choice: Q1-C is strictly single-slot, not first-of-many.
|
||||||
|
"""
|
||||||
|
second = MissingSlot(
|
||||||
|
slot_name="weighted_total",
|
||||||
|
expected_unit_or_type="measured_unit_int",
|
||||||
|
binding_target="weighted_total_value",
|
||||||
|
)
|
||||||
|
q = render_question(_ask_assessment((_TOTAL_COUNT_SLOT, second)))
|
||||||
|
|
||||||
|
assert q.unrenderable
|
||||||
|
assert q.text is None
|
||||||
|
assert q.slot is None
|
||||||
|
assert q.reason == _REASON_MULTI_SLOT
|
||||||
|
|
||||||
|
|
||||||
|
def test_unmapped_structural_type_degrades_to_renderability_gap() -> None:
|
||||||
|
"""An unknown ``expected_unit_or_type`` refuses rather than dumping raw
|
||||||
|
snake_case — the slot is bound but no text is produced.
|
||||||
|
"""
|
||||||
|
exotic = MissingSlot(
|
||||||
|
slot_name="mystery",
|
||||||
|
expected_unit_or_type="some_unmapped_type",
|
||||||
|
binding_target="mystery_target",
|
||||||
|
)
|
||||||
|
q = render_question(_ask_assessment((exotic,)))
|
||||||
|
|
||||||
|
assert q.unrenderable
|
||||||
|
assert q.text is None
|
||||||
|
assert q.slot == exotic
|
||||||
|
assert q.reason == _REASON_RENDERABILITY_GAP
|
||||||
|
|
||||||
|
|
||||||
|
# --- off-serving structural guard ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_renderer_is_off_serving() -> None:
|
||||||
|
"""The renderer must not import the sealed GSM8K serving substrate."""
|
||||||
|
src = Path(__file__).resolve().parents[1] / "core" / "epistemic_questions"
|
||||||
|
forbidden = ("generate.derivation", "core.reliability_gate")
|
||||||
|
for path in src.glob("*.py"):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
assert not any(
|
||||||
|
node.module == f or node.module.startswith(f + ".")
|
||||||
|
for f in forbidden
|
||||||
|
), f"{path.name} imports forbidden serving module {node.module}"
|
||||||
|
elif isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
assert not any(
|
||||||
|
alias.name == f or alias.name.startswith(f + ".")
|
||||||
|
for f in forbidden
|
||||||
|
), f"{path.name} imports forbidden serving module {alias.name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tokens_lower(text: str) -> set[str]:
|
||||||
|
import re
|
||||||
|
|
||||||
|
return set(re.findall(r"[a-z]+", text.lower()))
|
||||||
Loading…
Reference in a new issue