feat(telegram,core,backends,storage): telegram frontend with inbox, outbox, drafts and question buttons
This commit is contained in:
@@ -112,6 +112,11 @@ class ConversationTexts:
|
||||
|
||||
merge_prompt: str = _DEFAULT_MERGE_PROMPT
|
||||
interrupted: str = "прервано"
|
||||
answered: str = "Пользователь ответил: {answer}"
|
||||
unanswered: str = (
|
||||
"Пользователь не ответил за {minutes} мин. Вопрос ему показан текстом; "
|
||||
"заверши тёрн сейчас, ответ придёт следующим сообщением."
|
||||
)
|
||||
seed: Callable[[SeedContext], Awaitable[str | None] | str | None] | None = None
|
||||
|
||||
|
||||
@@ -130,6 +135,14 @@ class _Runner:
|
||||
turn_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Question:
|
||||
conversation_id: str
|
||||
turn_id: str | None
|
||||
questions: list[dict[str, Any]]
|
||||
answer: asyncio.Future[str]
|
||||
|
||||
|
||||
class Conversations:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -145,6 +158,7 @@ class Conversations:
|
||||
normal_window: float = 3600.0,
|
||||
idle_days: Sequence[int] = (2,),
|
||||
idle_interval: float = 3600.0,
|
||||
question_timeout: float = 600.0,
|
||||
) -> None:
|
||||
self._db = db
|
||||
self._agents = agents
|
||||
@@ -157,6 +171,8 @@ class Conversations:
|
||||
self._normal_window = normal_window
|
||||
self._idle_days = tuple(sorted(idle_days))
|
||||
self._idle_interval = idle_interval
|
||||
self._question_timeout = question_timeout
|
||||
self._questions: dict[str, _Question] = {}
|
||||
self._queue = InjectQueue(db)
|
||||
self._runners: dict[int, _Runner] = {}
|
||||
self._tasks: set[asyncio.Task[None]] = set()
|
||||
@@ -278,6 +294,17 @@ class Conversations:
|
||||
if other is not row and other.visible:
|
||||
other.visible = False
|
||||
session.add(other)
|
||||
same_window = await session.exec(
|
||||
select(ConversationBinding).where(
|
||||
ConversationBinding.frontend == frontend,
|
||||
ConversationBinding.external_id == external_id,
|
||||
ConversationBinding.conversation_id != conv.id,
|
||||
col(ConversationBinding.visible).is_(True),
|
||||
)
|
||||
)
|
||||
for other in same_window.all():
|
||||
other.visible = False
|
||||
session.add(other)
|
||||
if row is None:
|
||||
row = ConversationBinding(
|
||||
conversation_id=cast("int", conv.id),
|
||||
@@ -436,7 +463,15 @@ class Conversations:
|
||||
title: str | None = None,
|
||||
window: int | None = None,
|
||||
origin: str = "api",
|
||||
binding: tuple[str, str] | None = None,
|
||||
) -> Conversation:
|
||||
"""Create a conversation and queue its seed turn (§8.2).
|
||||
|
||||
``binding`` = ``(frontend, external_id)`` puts it into a window that
|
||||
already exists (a topic the user created) instead of asking the
|
||||
home frontend to ``materialize`` one. ``text`` rides with the seed
|
||||
as the first thing the user said, whatever the seed mode.
|
||||
"""
|
||||
if seed not in SEEDS:
|
||||
msg = f"unknown seed {seed!r}"
|
||||
raise ValueError(msg)
|
||||
@@ -465,7 +500,10 @@ class Conversations:
|
||||
origin=origin,
|
||||
session_id=session_id,
|
||||
)
|
||||
await self.materialize(conv)
|
||||
if binding is not None:
|
||||
await self.bind(conv, frontend=binding[0], external_id=binding[1])
|
||||
else:
|
||||
await self.materialize(conv)
|
||||
prompt = await self._seed_text(
|
||||
SeedContext(
|
||||
kind=kind, seed=seed, agent=agent, parent=parent, text=text, title=title
|
||||
@@ -619,6 +657,86 @@ class Conversations:
|
||||
)
|
||||
return row
|
||||
|
||||
# ---- §3.7 questions ------------------------------------------------
|
||||
|
||||
async def ask(self, key: str, payload: dict[str, Any]) -> str | None:
|
||||
"""``AskUserQuestion`` reached ``can_use_tool``: show it, wait for the answer.
|
||||
|
||||
Returns the answer text, or ``None`` when nobody answered within
|
||||
``question_timeout`` - the caller then tells the model to finish the
|
||||
turn, the frontend has already rendered the question as text.
|
||||
"""
|
||||
conv = await self.get(key)
|
||||
if conv is None:
|
||||
return None
|
||||
runner = self._runners.get(cast("int", conv.id))
|
||||
question_id = f"q_{uuid.uuid4().hex[:10]}"
|
||||
pending = _Question(
|
||||
conversation_id=key,
|
||||
turn_id=runner.turn_id if runner is not None else None,
|
||||
questions=list(payload.get("questions") or []),
|
||||
answer=asyncio.get_running_loop().create_future(),
|
||||
)
|
||||
self._questions[question_id] = pending
|
||||
await self._set_pending_question(conv, value=True)
|
||||
self._bus.publish(
|
||||
"question",
|
||||
conversation_id=key,
|
||||
turn_id=pending.turn_id,
|
||||
question_id=question_id,
|
||||
questions=pending.questions,
|
||||
timeout=self._question_timeout,
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(self._question_timeout):
|
||||
answer = await pending.answer
|
||||
except TimeoutError:
|
||||
self._bus.publish(
|
||||
"question.timeout",
|
||||
conversation_id=key,
|
||||
turn_id=pending.turn_id,
|
||||
question_id=question_id,
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
self._questions.pop(question_id, None)
|
||||
await self._set_pending_question(conv, value=False)
|
||||
self._bus.publish(
|
||||
"question.answered",
|
||||
conversation_id=key,
|
||||
turn_id=pending.turn_id,
|
||||
question_id=question_id,
|
||||
answer=answer,
|
||||
)
|
||||
return answer
|
||||
|
||||
def answer(self, question_id: str, answer: str) -> bool:
|
||||
pending = self._questions.get(question_id)
|
||||
if pending is None or pending.answer.done():
|
||||
return False
|
||||
pending.answer.set_result(answer)
|
||||
return True
|
||||
|
||||
def pending_question(self, key: str) -> tuple[str, list[dict[str, Any]]] | None:
|
||||
for question_id, pending in self._questions.items():
|
||||
if pending.conversation_id == key and not pending.answer.done():
|
||||
return question_id, pending.questions
|
||||
return None
|
||||
|
||||
def answer_text(self, answer: str | None) -> str:
|
||||
if answer is None:
|
||||
return self._texts.unanswered.format(
|
||||
minutes=round(self._question_timeout / 60)
|
||||
)
|
||||
return self._texts.answered.format(answer=answer)
|
||||
|
||||
async def _set_pending_question(self, conv: Conversation, *, value: bool) -> None:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.pending_question = value
|
||||
|
||||
with contextlib.suppress(LookupError):
|
||||
await self._update(conv, apply)
|
||||
|
||||
async def schedules(self, conv: Conversation | None = None) -> list[Schedule]:
|
||||
stmt = select(Schedule).order_by(col(Schedule.execute_at))
|
||||
if conv is not None:
|
||||
@@ -757,6 +875,7 @@ class Conversations:
|
||||
|
||||
async def clear(row: Conversation) -> None:
|
||||
row.running_turn = None
|
||||
row.pending_question = False
|
||||
|
||||
await self._update(conv, clear)
|
||||
note = f"тёрн {turn_id} оборван рестартом gateway"
|
||||
@@ -851,7 +970,8 @@ class Conversations:
|
||||
body = f"История родителя скопирована ({scope}); продолжай в ней."
|
||||
elif ctx.seed == "morning":
|
||||
body = "Хендаут не приехал."
|
||||
return f"{head}\n\n{body}" if body else head
|
||||
parts = [head, body, ctx.text if ctx.seed != "brief" else None]
|
||||
return "\n\n".join(p for p in parts if p)
|
||||
|
||||
def _runner(self, row_id: int) -> _Runner:
|
||||
runner = self._runners.get(row_id)
|
||||
@@ -933,8 +1053,10 @@ class Conversations:
|
||||
conversation_id=conv.external_id,
|
||||
turn_id=turn_id,
|
||||
item=head.id,
|
||||
item_origin=head.origin,
|
||||
source="queue",
|
||||
prompt=prompt,
|
||||
user_text=head.text,
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user