forked from retoor/devplacepy
514 lines
18 KiB
Python
514 lines
18 KiB
Python
# retoor <retoor@molodetz.nl>
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import logging
|
||
|
|
import time
|
||
|
|
from typing import Any, Awaitable, Callable, Optional
|
||
|
|
|
||
|
|
import uuid_utils
|
||
|
|
|
||
|
|
from .capabilities import (
|
||
|
|
CHANNEL_CLI,
|
||
|
|
CHANNEL_SITE_CHAT,
|
||
|
|
CHANNEL_TELEGRAM,
|
||
|
|
ChannelContext,
|
||
|
|
channel_id_for_session,
|
||
|
|
context_for,
|
||
|
|
)
|
||
|
|
from .markdown import project_markdown, project_plain_menu
|
||
|
|
from .parse import parse_plain_reply
|
||
|
|
from .schema import (
|
||
|
|
InteractionRequest,
|
||
|
|
InteractionResult,
|
||
|
|
count_input_fields,
|
||
|
|
validate_prompt_args,
|
||
|
|
)
|
||
|
|
|
||
|
|
logger = logging.getLogger("devii.interaction")
|
||
|
|
|
||
|
|
EmitFn = Callable[[dict[str, Any]], Awaitable[None]]
|
||
|
|
WaitFn = Callable[[str, dict[str, Any], float], Awaitable[Any]]
|
||
|
|
TelegramPresentFn = Callable[[dict[str, Any]], Awaitable[Any]]
|
||
|
|
PlainPresentFn = Callable[[str], Awaitable[str]]
|
||
|
|
|
||
|
|
|
||
|
|
class InteractionBroker:
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
session_channel: str = "main",
|
||
|
|
*,
|
||
|
|
owner_kind: str = "guest",
|
||
|
|
owner_id: str = "",
|
||
|
|
emit: Optional[EmitFn] = None,
|
||
|
|
wait_site: Optional[WaitFn] = None,
|
||
|
|
present_telegram: Optional[TelegramPresentFn] = None,
|
||
|
|
present_plain: Optional[PlainPresentFn] = None,
|
||
|
|
) -> None:
|
||
|
|
self._session_channel = session_channel
|
||
|
|
self._channel_id = channel_id_for_session(session_channel)
|
||
|
|
self._owner_kind = owner_kind
|
||
|
|
self._owner_id = owner_id
|
||
|
|
self._emit = emit
|
||
|
|
self._wait_site = wait_site
|
||
|
|
self._present_telegram = present_telegram
|
||
|
|
self._present_plain = present_plain
|
||
|
|
self._open: dict[str, dict[str, Any]] = {}
|
||
|
|
self._lock = asyncio.Lock()
|
||
|
|
self._single_flight = True
|
||
|
|
|
||
|
|
@property
|
||
|
|
def channel_id(self) -> str:
|
||
|
|
return self._channel_id
|
||
|
|
|
||
|
|
def set_channel_id(self, channel_id: str) -> None:
|
||
|
|
self._channel_id = channel_id
|
||
|
|
|
||
|
|
def open_id(self) -> str | None:
|
||
|
|
if not self._open:
|
||
|
|
return None
|
||
|
|
return next(iter(self._open))
|
||
|
|
|
||
|
|
def open_request(self) -> InteractionRequest | None:
|
||
|
|
open_id = self.open_id()
|
||
|
|
if not open_id:
|
||
|
|
return None
|
||
|
|
payload = self._open.get(open_id) or {}
|
||
|
|
request = payload.get("request_model")
|
||
|
|
if isinstance(request, InteractionRequest):
|
||
|
|
return request
|
||
|
|
raw = payload.get("request")
|
||
|
|
if isinstance(raw, dict):
|
||
|
|
try:
|
||
|
|
return InteractionRequest.model_validate(raw)
|
||
|
|
except Exception:
|
||
|
|
return None
|
||
|
|
return None
|
||
|
|
|
||
|
|
def answer_text(self, text: str) -> dict[str, Any] | None:
|
||
|
|
open_id = self.open_id()
|
||
|
|
if not open_id:
|
||
|
|
return None
|
||
|
|
request = self.open_request()
|
||
|
|
if request is None:
|
||
|
|
return {
|
||
|
|
"handled": True,
|
||
|
|
"status": "error",
|
||
|
|
"error": "Open interaction has no parseable schema.",
|
||
|
|
"interaction_id": open_id,
|
||
|
|
}
|
||
|
|
status, values, error = parse_plain_reply(request, text)
|
||
|
|
if status == "error":
|
||
|
|
return {
|
||
|
|
"handled": True,
|
||
|
|
"status": "error",
|
||
|
|
"error": error or "Could not parse reply.",
|
||
|
|
"interaction_id": open_id,
|
||
|
|
}
|
||
|
|
result = {
|
||
|
|
"status": status,
|
||
|
|
"interaction_id": open_id,
|
||
|
|
"values": values if status == "submitted" else {},
|
||
|
|
"meta": {
|
||
|
|
"adapter": self._channel_id,
|
||
|
|
"degraded": True,
|
||
|
|
"via": "text",
|
||
|
|
},
|
||
|
|
"error": error,
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
"handled": True,
|
||
|
|
"status": status,
|
||
|
|
"interaction_id": open_id,
|
||
|
|
"result": result,
|
||
|
|
}
|
||
|
|
|
||
|
|
def channel_context(self) -> ChannelContext:
|
||
|
|
owner_kind = getattr(self, "_owner_kind", "guest")
|
||
|
|
owner_id = getattr(self, "_owner_id", "")
|
||
|
|
return context_for(
|
||
|
|
self._channel_id,
|
||
|
|
open_interaction_id=self.open_id(),
|
||
|
|
owner_kind=owner_kind,
|
||
|
|
owner_id=owner_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
def bind(
|
||
|
|
self,
|
||
|
|
*,
|
||
|
|
emit: Optional[EmitFn] = None,
|
||
|
|
wait_site: Optional[WaitFn] = None,
|
||
|
|
present_telegram: Optional[TelegramPresentFn] = None,
|
||
|
|
present_plain: Optional[PlainPresentFn] = None,
|
||
|
|
) -> None:
|
||
|
|
if emit is not None:
|
||
|
|
self._emit = emit
|
||
|
|
if wait_site is not None:
|
||
|
|
self._wait_site = wait_site
|
||
|
|
if present_telegram is not None:
|
||
|
|
self._present_telegram = present_telegram
|
||
|
|
if present_plain is not None:
|
||
|
|
self._present_plain = present_plain
|
||
|
|
|
||
|
|
async def prompt(self, arguments: dict[str, Any]) -> InteractionResult:
|
||
|
|
request = validate_prompt_args(arguments)
|
||
|
|
async with self._lock:
|
||
|
|
if self._single_flight and self._open:
|
||
|
|
for open_id in list(self._open):
|
||
|
|
await self._finish(
|
||
|
|
open_id,
|
||
|
|
status="superseded",
|
||
|
|
values={},
|
||
|
|
error=None,
|
||
|
|
)
|
||
|
|
interaction_id = request.id or f"ix-{uuid_utils.uuid7().hex[:8]}"
|
||
|
|
if not interaction_id.startswith("ix-") and not request.id:
|
||
|
|
interaction_id = f"ix-{interaction_id}"
|
||
|
|
started = time.monotonic()
|
||
|
|
payload = {
|
||
|
|
"interaction_id": interaction_id,
|
||
|
|
"request": request.model_dump(),
|
||
|
|
"request_model": request,
|
||
|
|
"markdown": project_markdown(request, interaction_id),
|
||
|
|
"plain": project_plain_menu(request),
|
||
|
|
"channel_id": self._channel_id,
|
||
|
|
"started": started,
|
||
|
|
"future": asyncio.get_event_loop().create_future(),
|
||
|
|
}
|
||
|
|
self._open[interaction_id] = payload
|
||
|
|
logger.info(
|
||
|
|
"interaction open id=%s channel=%s widgets=%d",
|
||
|
|
interaction_id,
|
||
|
|
self._channel_id,
|
||
|
|
count_input_fields(request.widgets),
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
result = await self._present(interaction_id, request, payload)
|
||
|
|
except Exception as exc:
|
||
|
|
logger.exception("interaction failed id=%s", interaction_id)
|
||
|
|
result = InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status="error",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
values={},
|
||
|
|
meta={"adapter": self._channel_id, "degraded": True},
|
||
|
|
error=str(exc),
|
||
|
|
)
|
||
|
|
await self._resolve_local(interaction_id, result)
|
||
|
|
return result
|
||
|
|
|
||
|
|
latency_ms = int((time.monotonic() - started) * 1000)
|
||
|
|
if "latency_ms" not in result.meta:
|
||
|
|
result.meta["latency_ms"] = latency_ms
|
||
|
|
if "adapter" not in result.meta:
|
||
|
|
result.meta["adapter"] = self._channel_id
|
||
|
|
self._open.pop(interaction_id, None)
|
||
|
|
logger.info(
|
||
|
|
"interaction closed id=%s status=%s latency_ms=%s",
|
||
|
|
interaction_id,
|
||
|
|
result.status,
|
||
|
|
result.meta.get("latency_ms"),
|
||
|
|
)
|
||
|
|
return result
|
||
|
|
|
||
|
|
async def cancel(
|
||
|
|
self, interaction_id: str = "", reason: str = "cancelled"
|
||
|
|
) -> InteractionResult:
|
||
|
|
target = interaction_id or self.open_id()
|
||
|
|
if not target or target not in self._open:
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=target or "",
|
||
|
|
status="error",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
error="No open interaction to cancel.",
|
||
|
|
)
|
||
|
|
status = reason if reason in (
|
||
|
|
"cancelled",
|
||
|
|
"timeout",
|
||
|
|
"superseded",
|
||
|
|
"error",
|
||
|
|
"channel_changed",
|
||
|
|
) else "cancelled"
|
||
|
|
result = InteractionResult(
|
||
|
|
interaction_id=target,
|
||
|
|
status=status, # type: ignore[arg-type]
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
values={},
|
||
|
|
meta={"adapter": self._channel_id, "reason": reason},
|
||
|
|
)
|
||
|
|
await self._resolve_local(target, result)
|
||
|
|
return result
|
||
|
|
|
||
|
|
def resolve(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
*,
|
||
|
|
status: str,
|
||
|
|
values: dict[str, Any] | None = None,
|
||
|
|
error: str | None = None,
|
||
|
|
meta: dict[str, Any] | None = None,
|
||
|
|
) -> bool:
|
||
|
|
payload = self._open.get(interaction_id)
|
||
|
|
if payload is None:
|
||
|
|
return False
|
||
|
|
result = InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status=status, # type: ignore[arg-type]
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
values=values or {},
|
||
|
|
meta=meta or {"adapter": self._channel_id},
|
||
|
|
error=error,
|
||
|
|
)
|
||
|
|
future = payload.get("future")
|
||
|
|
if future is not None and not future.done():
|
||
|
|
future.set_result(result)
|
||
|
|
return True
|
||
|
|
|
||
|
|
async def _present(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
request: InteractionRequest,
|
||
|
|
payload: dict[str, Any],
|
||
|
|
) -> InteractionResult:
|
||
|
|
timeout = float(request.timeout_sec or 0)
|
||
|
|
if self._wait_site is not None and self._channel_id in (
|
||
|
|
CHANNEL_SITE_CHAT,
|
||
|
|
CHANNEL_TELEGRAM,
|
||
|
|
):
|
||
|
|
return await self._present_site(interaction_id, request, payload, timeout)
|
||
|
|
if self._channel_id == CHANNEL_TELEGRAM:
|
||
|
|
return await self._present_telegram_path(
|
||
|
|
interaction_id, request, payload, timeout
|
||
|
|
)
|
||
|
|
return await self._present_plain_path(
|
||
|
|
interaction_id, request, payload, timeout
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _present_site(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
request: InteractionRequest,
|
||
|
|
payload: dict[str, Any],
|
||
|
|
timeout: float,
|
||
|
|
) -> InteractionResult:
|
||
|
|
if self._wait_site is None:
|
||
|
|
return await self._present_plain_path(
|
||
|
|
interaction_id, request, payload, timeout, degraded=True
|
||
|
|
)
|
||
|
|
frame = {
|
||
|
|
"interaction_id": interaction_id,
|
||
|
|
"channel": self._channel_id,
|
||
|
|
"title": request.title,
|
||
|
|
"description": request.description,
|
||
|
|
"widgets": [w.model_dump() for w in request.widgets],
|
||
|
|
"submit_label": request.submit_label,
|
||
|
|
"cancel_label": request.cancel_label,
|
||
|
|
"cancelable": request.cancelable,
|
||
|
|
"timeout_sec": request.timeout_sec,
|
||
|
|
"markdown": payload["markdown"],
|
||
|
|
"plain": payload["plain"],
|
||
|
|
"request": request.model_dump(),
|
||
|
|
}
|
||
|
|
try:
|
||
|
|
raw = await self._wait_site(interaction_id, frame, timeout)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status="timeout",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
meta={
|
||
|
|
"adapter": self._channel_id,
|
||
|
|
"degraded": self._channel_id != CHANNEL_SITE_CHAT,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
return self._normalize_site_result(
|
||
|
|
interaction_id,
|
||
|
|
raw,
|
||
|
|
adapter=self._channel_id,
|
||
|
|
degraded=self._channel_id != CHANNEL_SITE_CHAT,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _present_telegram_path(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
request: InteractionRequest,
|
||
|
|
payload: dict[str, Any],
|
||
|
|
timeout: float,
|
||
|
|
) -> InteractionResult:
|
||
|
|
if self._present_telegram is not None:
|
||
|
|
try:
|
||
|
|
raw = await self._present_telegram(
|
||
|
|
{
|
||
|
|
"interaction_id": interaction_id,
|
||
|
|
"request": request.model_dump(),
|
||
|
|
"markdown": payload["markdown"],
|
||
|
|
"plain": payload["plain"],
|
||
|
|
"timeout_sec": request.timeout_sec,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return self._normalize_site_result(
|
||
|
|
interaction_id, raw, adapter=CHANNEL_TELEGRAM, degraded=True
|
||
|
|
)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status="timeout",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
meta={"adapter": CHANNEL_TELEGRAM, "degraded": True},
|
||
|
|
)
|
||
|
|
except Exception as exc:
|
||
|
|
logger.exception("telegram adapter failed, falling back to plain")
|
||
|
|
payload["plain_error"] = str(exc)
|
||
|
|
return await self._present_plain_path(
|
||
|
|
interaction_id, request, payload, timeout, degraded=True
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _present_plain_path(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
request: InteractionRequest,
|
||
|
|
payload: dict[str, Any],
|
||
|
|
timeout: float,
|
||
|
|
*,
|
||
|
|
degraded: bool = False,
|
||
|
|
) -> InteractionResult:
|
||
|
|
menu = payload["plain"]
|
||
|
|
if self._present_plain is not None:
|
||
|
|
try:
|
||
|
|
if timeout > 0:
|
||
|
|
reply = await asyncio.wait_for(
|
||
|
|
self._present_plain(menu), timeout=timeout
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
reply = await self._present_plain(menu)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status="timeout",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
meta={
|
||
|
|
"adapter": self._channel_id or CHANNEL_CLI,
|
||
|
|
"degraded": True,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
status, values, error = parse_plain_reply(request, reply)
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status=status, # type: ignore[arg-type]
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
values=values,
|
||
|
|
meta={
|
||
|
|
"adapter": self._channel_id or CHANNEL_CLI,
|
||
|
|
"degraded": True,
|
||
|
|
},
|
||
|
|
error=error,
|
||
|
|
)
|
||
|
|
future: asyncio.Future = payload["future"]
|
||
|
|
if self._emit is not None:
|
||
|
|
await self._emit(
|
||
|
|
{
|
||
|
|
"type": "interaction",
|
||
|
|
"id": interaction_id,
|
||
|
|
"mode": "plain",
|
||
|
|
"text": menu,
|
||
|
|
"markdown": payload["markdown"],
|
||
|
|
}
|
||
|
|
)
|
||
|
|
try:
|
||
|
|
if timeout > 0:
|
||
|
|
result = await asyncio.wait_for(future, timeout=timeout)
|
||
|
|
else:
|
||
|
|
result = await future
|
||
|
|
if isinstance(result, InteractionResult):
|
||
|
|
result.meta.setdefault("degraded", degraded or True)
|
||
|
|
return result
|
||
|
|
return self._normalize_site_result(
|
||
|
|
interaction_id, result, adapter=self._channel_id, degraded=True
|
||
|
|
)
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status="timeout",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
meta={"adapter": self._channel_id, "degraded": True},
|
||
|
|
)
|
||
|
|
|
||
|
|
def _normalize_site_result(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
raw: Any,
|
||
|
|
*,
|
||
|
|
adapter: str = CHANNEL_SITE_CHAT,
|
||
|
|
degraded: bool = False,
|
||
|
|
) -> InteractionResult:
|
||
|
|
if isinstance(raw, InteractionResult):
|
||
|
|
return raw
|
||
|
|
if not isinstance(raw, dict):
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status="error",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
error="Invalid interaction result payload.",
|
||
|
|
meta={"adapter": adapter, "degraded": degraded},
|
||
|
|
)
|
||
|
|
if raw.get("error") and not raw.get("status"):
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status="error",
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
error=str(raw.get("error")),
|
||
|
|
meta={"adapter": adapter, "degraded": degraded},
|
||
|
|
)
|
||
|
|
status = str(raw.get("status") or "submitted")
|
||
|
|
if status not in (
|
||
|
|
"submitted",
|
||
|
|
"cancelled",
|
||
|
|
"timeout",
|
||
|
|
"superseded",
|
||
|
|
"error",
|
||
|
|
"channel_changed",
|
||
|
|
):
|
||
|
|
status = "error"
|
||
|
|
values = raw.get("values") if isinstance(raw.get("values"), dict) else {}
|
||
|
|
meta = raw.get("meta") if isinstance(raw.get("meta"), dict) else {}
|
||
|
|
meta.setdefault("adapter", adapter)
|
||
|
|
meta.setdefault("degraded", degraded)
|
||
|
|
return InteractionResult(
|
||
|
|
interaction_id=str(raw.get("interaction_id") or interaction_id),
|
||
|
|
status=status, # type: ignore[arg-type]
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
values=values,
|
||
|
|
meta=meta,
|
||
|
|
error=str(raw["error"]) if raw.get("error") else None,
|
||
|
|
)
|
||
|
|
|
||
|
|
async def _finish(
|
||
|
|
self,
|
||
|
|
interaction_id: str,
|
||
|
|
*,
|
||
|
|
status: str,
|
||
|
|
values: dict[str, Any],
|
||
|
|
error: str | None,
|
||
|
|
) -> None:
|
||
|
|
result = InteractionResult(
|
||
|
|
interaction_id=interaction_id,
|
||
|
|
status=status, # type: ignore[arg-type]
|
||
|
|
channel_id=self._channel_id,
|
||
|
|
values=values,
|
||
|
|
meta={"adapter": self._channel_id},
|
||
|
|
error=error,
|
||
|
|
)
|
||
|
|
await self._resolve_local(interaction_id, result)
|
||
|
|
|
||
|
|
async def _resolve_local(
|
||
|
|
self, interaction_id: str, result: InteractionResult
|
||
|
|
) -> None:
|
||
|
|
payload = self._open.pop(interaction_id, None)
|
||
|
|
if payload is None:
|
||
|
|
return
|
||
|
|
future = payload.get("future")
|
||
|
|
if future is not None and not future.done():
|
||
|
|
future.set_result(result)
|