100 lines
3.4 KiB
Python
100 lines
3.4 KiB
Python
"""Persona (mode) resolution.
|
|
|
|
Modes are Turnstone personae whose slugs start with ``shellbound_``. A mode
|
|
argument is resolved as a case-insensitive shortest-unique prefix of that
|
|
slug. Zero matches means the argument is treated as part of the query; more
|
|
than one match (e.g. ``e`` for ``explain``/``expert``) is an ambiguity error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
SHELLBOUND_PREFIX = "shellbound_"
|
|
|
|
# Canonical modes this CLI knows about, used to detect "persona not yet
|
|
# provisioned on the server" so intent is never silently swallowed into the
|
|
# query.
|
|
KNOWN_MODES = {
|
|
"shell": "shellbound_shell",
|
|
"s": "shellbound_shell",
|
|
"answer": "shellbound_answer",
|
|
"a": "shellbound_answer",
|
|
"explain": "shellbound_explain",
|
|
"creative": "shellbound_creative",
|
|
"c": "shellbound_creative",
|
|
"poetic": "shellbound_poetic",
|
|
"p": "shellbound_poetic",
|
|
"expert": "shellbound_expert",
|
|
}
|
|
|
|
|
|
class AmbiguousPersona(ValueError):
|
|
def __init__(self, fragment: str, candidates: list[str]):
|
|
self.fragment = fragment
|
|
self.candidates = candidates
|
|
super().__init__(
|
|
f"'{fragment}' is ambiguous ({len(candidates)} personae: "
|
|
f"{', '.join(sorted(candidates))}); supply more characters"
|
|
)
|
|
|
|
|
|
def shellbound_personas(raw: list[dict]) -> list[dict]:
|
|
return [p for p in raw if p.get("name", "").startswith(SHELLBOUND_PREFIX)]
|
|
|
|
|
|
def interactions(raw: list[dict]) -> list[dict]:
|
|
return [p for p in raw if "interactive" in (p.get("applies_to_kinds") or [])]
|
|
|
|
|
|
def can_expected_mode(fragment: str) -> bool:
|
|
return fragment.strip().lower() in KNOWN_MODES
|
|
|
|
|
|
def resolve_mode(raw: list[dict], fragment: str) -> Optional[dict]:
|
|
"""Resolve a mode fragment to a ``shellbound_*`` persona.
|
|
|
|
Returns ``None`` when the fragment matches nothing (it should then be
|
|
treated as the start of the query). Raises :class:`AmbiguousPersona`
|
|
when more than one persona matches.
|
|
"""
|
|
fragment = fragment.strip()
|
|
if not fragment:
|
|
return None
|
|
low = fragment.lower()
|
|
matches: list[dict] = []
|
|
for p in shellbound_personas(raw):
|
|
name = p["name"]
|
|
if name.lower() == low or name.lower().startswith(SHELLBOUND_PREFIX + low):
|
|
matches.append(p)
|
|
if len(matches) > 1:
|
|
raise AmbiguousPersona(fragment, [p["name"] for p in matches])
|
|
return matches[0] if matches else None
|
|
|
|
|
|
def resolve_name(raw: list[dict], name: str) -> dict:
|
|
"""Resolve an explicit ``--persona`` value (exact name or unique prefix)."""
|
|
name = name.strip()
|
|
if not name:
|
|
raise ValueError("empty persona name")
|
|
low = name.lower()
|
|
matches = [
|
|
p for p in interactions(raw)
|
|
if p["name"].lower() == low or p["name"].lower().startswith(low)
|
|
]
|
|
if not matches:
|
|
available = ", ".join(sorted(p["name"] for p in raw))
|
|
raise ValueError(f"unknown persona '{name}' (available: {available})")
|
|
if len(matches) > 1:
|
|
raise AmbiguousPersona(name, [p["name"] for p in matches])
|
|
return matches[0]
|
|
|
|
|
|
def default_persona_name(raw: list[dict]) -> Optional[str]:
|
|
"""Preferred default persona (answer) with fallbacks."""
|
|
sl = [p["name"] for p in shellbound_personas(raw)]
|
|
for candidate in ("shellbound_answer", "shellbound_shell"):
|
|
if candidate in sl:
|
|
return candidate
|
|
default = next((p["name"] for p in raw if p.get("is_default")), None)
|
|
return default |