feat: add multi-channel Devii sessions with docs search mode and channel-aware conversation persistence
This commit is contained in:
@@ -248,7 +248,7 @@ class Dispatcher:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
self._fetch = FetchController(settings)
|
||||
self._docs = DocsController(settings)
|
||||
self._docs = DocsController(settings, is_admin=is_admin)
|
||||
self._cost = CostController(quota_provider=quota_provider)
|
||||
self._chunks = ChunkController(settings)
|
||||
self._rsearch = RsearchController(settings)
|
||||
|
||||
@@ -2,81 +2,32 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from ..agentic.lessons import tokenize
|
||||
from ..config import Settings
|
||||
from ..errors import NetworkError, ToolInputError
|
||||
from ..fetch.controller import STEALTH_HEADERS
|
||||
from ..text import truncate
|
||||
from ..errors import ToolInputError
|
||||
|
||||
logger = logging.getLogger("devii.docs")
|
||||
|
||||
DOCS_PATH = "/docs/download.md"
|
||||
HEADING = re.compile(r"^(#{1,3})\s+(.*)$")
|
||||
DEFAULT_MAX_RESULTS = 5
|
||||
SECTION_CHARS = 1800
|
||||
HEADING_WEIGHT = 3
|
||||
CONTENT_CHARS = 2400
|
||||
|
||||
|
||||
class DocsController:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
def __init__(self, settings: Settings, is_admin: bool = False) -> None:
|
||||
self._settings = settings
|
||||
self._sections: list[tuple[str, str]] | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._is_admin = is_admin
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name != "search_docs":
|
||||
raise ToolInputError(f"Unknown docs tool: {name}")
|
||||
return await self._search(arguments)
|
||||
|
||||
async def _load(self) -> list[tuple[str, str]]:
|
||||
async with self._lock:
|
||||
if self._sections is not None:
|
||||
return self._sections
|
||||
url = self._settings.base_url + DOCS_PATH
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers=STEALTH_HEADERS,
|
||||
follow_redirects=True,
|
||||
timeout=self._settings.fetch_timeout_seconds,
|
||||
) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
markdown = response.text
|
||||
except httpx.HTTPError as exc:
|
||||
raise NetworkError(
|
||||
f"Could not load documentation: {exc}", url=url
|
||||
) from exc
|
||||
self._sections = self._split(markdown)
|
||||
logger.info("Loaded %d documentation sections", len(self._sections))
|
||||
return self._sections
|
||||
|
||||
@staticmethod
|
||||
def _split(markdown: str) -> list[tuple[str, str]]:
|
||||
sections: list[tuple[str, str]] = []
|
||||
heading = "Overview"
|
||||
body: list[str] = []
|
||||
for line in markdown.splitlines():
|
||||
match = HEADING.match(line)
|
||||
if match:
|
||||
if body:
|
||||
sections.append((heading, "\n".join(body).strip()))
|
||||
body = []
|
||||
heading = match.group(2).strip()
|
||||
else:
|
||||
body.append(line)
|
||||
if body:
|
||||
sections.append((heading, "\n".join(body).strip()))
|
||||
return [(title, text) for title, text in sections if text]
|
||||
|
||||
async def _search(self, arguments: dict[str, Any]) -> str:
|
||||
from devplacepy import docs_search
|
||||
|
||||
query = str(arguments.get("query", "")).strip()
|
||||
if not query:
|
||||
raise ToolInputError("search_docs requires a query.")
|
||||
@@ -85,32 +36,13 @@ class DocsController:
|
||||
)
|
||||
max_results = max(1, min(max_results, 10))
|
||||
|
||||
sections = await self._load()
|
||||
terms = tokenize(query)
|
||||
scored: list[tuple[float, str, str]] = []
|
||||
for heading, text in sections:
|
||||
heading_tokens = set(tokenize(heading))
|
||||
body_tokens = tokenize(text)
|
||||
body_counts: dict[str, int] = {}
|
||||
for token in body_tokens:
|
||||
body_counts[token] = body_counts.get(token, 0) + 1
|
||||
score = 0.0
|
||||
for term in terms:
|
||||
score += body_counts.get(term, 0)
|
||||
if term in heading_tokens:
|
||||
score += HEADING_WEIGHT
|
||||
if score > 0:
|
||||
scored.append((score, heading, text))
|
||||
scored.sort(key=lambda item: item[0], reverse=True)
|
||||
|
||||
results = [
|
||||
{
|
||||
"title": heading,
|
||||
"score": round(score, 2),
|
||||
"content": truncate(text, SECTION_CHARS),
|
||||
}
|
||||
for score, heading, text in scored[:max_results]
|
||||
]
|
||||
results = docs_search.search_pages(
|
||||
query,
|
||||
user=None,
|
||||
is_admin=self._is_admin,
|
||||
limit=max_results,
|
||||
content_chars=CONTENT_CHARS,
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
|
||||
@@ -30,7 +30,7 @@ class DeviiHub:
|
||||
"ledger": UsageLedger(),
|
||||
"turns": TurnAudit(),
|
||||
}
|
||||
self._sessions: dict[tuple[str, str], DeviiSession] = {}
|
||||
self._sessions: dict[tuple[str, str, str], DeviiSession] = {}
|
||||
|
||||
@property
|
||||
def ledger(self) -> UsageLedger:
|
||||
@@ -44,15 +44,20 @@ class DeviiHub:
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
is_admin: bool = False,
|
||||
channel: str = "main",
|
||||
) -> DeviiSession:
|
||||
key = (owner_kind, owner_id)
|
||||
key = (owner_kind, owner_id, channel)
|
||||
session = self._sessions.get(key)
|
||||
if session is not None:
|
||||
return session
|
||||
settings, pricing = self._build(api_key, base_url, owner_kind, is_admin)
|
||||
llm = LLMClient(settings)
|
||||
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
|
||||
owned_db = db if owner_kind == "user" else memory_db()
|
||||
# The `docs` channel (Docii) is a self-contained documentation assistant: it gets its own
|
||||
# ephemeral stores so Devii's tasks, lessons, behavior and virtual tools never leak into it
|
||||
# (and a Docii reflection never pollutes the user's Devii memory). Only the conversation
|
||||
# thread persists, keyed per channel in the shared ConversationStore.
|
||||
owned_db = db if (owner_kind == "user" and channel == "main") else memory_db()
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
|
||||
@@ -70,22 +75,26 @@ class DeviiHub:
|
||||
behavior_store,
|
||||
self._stores,
|
||||
is_admin=is_admin,
|
||||
channel=channel,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
saved = self._stores["conversations"].load(owner_kind, owner_id)
|
||||
saved = self._stores["conversations"].load(owner_kind, owner_id, channel)
|
||||
if saved:
|
||||
session.restore_history(saved)
|
||||
self._sessions[key] = session
|
||||
logger.info(
|
||||
"Created session %s/%s (total %d)",
|
||||
"Created session %s/%s [%s] (total %d)",
|
||||
owner_kind,
|
||||
owner_id,
|
||||
channel,
|
||||
len(self._sessions),
|
||||
)
|
||||
return session
|
||||
|
||||
def find(self, owner_kind: str, owner_id: str) -> DeviiSession | None:
|
||||
return self._sessions.get((owner_kind, owner_id))
|
||||
def find(
|
||||
self, owner_kind: str, owner_id: str, channel: str = "main"
|
||||
) -> DeviiSession | None:
|
||||
return self._sessions.get((owner_kind, owner_id, channel))
|
||||
|
||||
def active_sessions(self) -> int:
|
||||
return len(self._sessions)
|
||||
|
||||
@@ -71,6 +71,30 @@ class LLMClient:
|
||||
)
|
||||
return message
|
||||
|
||||
async def complete_text(
|
||||
self, messages: list[dict[str, Any]], temperature: float = 0.0
|
||||
) -> str:
|
||||
payload = {
|
||||
"model": self._settings.ai_model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
try:
|
||||
response = await self._client.post(self._settings.ai_url, json=payload)
|
||||
except httpx.HTTPError as exc:
|
||||
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise LLMError(
|
||||
f"Model endpoint returned {response.status_code}: {self._reason(response)}"
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
content = data["choices"][0]["message"]["content"] or ""
|
||||
except (ValueError, KeyError, IndexError) as exc:
|
||||
raise LLMError("Model endpoint returned an unexpected response.") from exc
|
||||
record_usage(data.get("usage"))
|
||||
return content
|
||||
|
||||
async def summarize(self, text: str) -> str:
|
||||
payload = {
|
||||
"model": self._settings.ai_model,
|
||||
|
||||
@@ -85,9 +85,11 @@ class DeviiSession:
|
||||
behavior_store: Any,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
channel: str = "main",
|
||||
) -> None:
|
||||
self.owner_kind = owner_kind
|
||||
self.owner_id = owner_id
|
||||
self.channel = channel
|
||||
self.username = username
|
||||
self.settings = settings
|
||||
self.is_admin = is_admin
|
||||
@@ -127,8 +129,10 @@ class DeviiSession:
|
||||
virtual_tools=self.virtual_tools,
|
||||
behavior=self.behavior,
|
||||
)
|
||||
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
|
||||
self._system_prompt = _system_prompt_for(is_admin)
|
||||
self.tools = self._builtin_tools()
|
||||
self._system_prompt = (
|
||||
DOCS_SYSTEM_PROMPT if channel == "docs" else _system_prompt_for(is_admin)
|
||||
)
|
||||
self.agentic.bind(
|
||||
llm=llm,
|
||||
dispatcher=self.dispatcher,
|
||||
@@ -224,7 +228,7 @@ class DeviiSession:
|
||||
self._disconnected.clear()
|
||||
self.avatar.bind(self._avatar_request)
|
||||
self.browser.bind(self._client_request)
|
||||
if not self._started:
|
||||
if not self._started and self.channel == "main":
|
||||
self.scheduler.start()
|
||||
self._started = True
|
||||
if self._buffer:
|
||||
@@ -282,6 +286,8 @@ class DeviiSession:
|
||||
await self._llm.aclose()
|
||||
|
||||
async def bootstrap_greeting(self) -> str:
|
||||
if self.channel == "docs":
|
||||
return DOCS_GREETING
|
||||
if self.client.authenticated:
|
||||
return f"Signed in as {self.username}. Devii is operating your DevPlace account. How can I help?"
|
||||
return LOGIN_REQUEST
|
||||
@@ -307,7 +313,7 @@ class DeviiSession:
|
||||
self._buffer = []
|
||||
if self.persist_conversation:
|
||||
try:
|
||||
self._conv.clear(self.owner_kind, self.owner_id)
|
||||
self._conv.clear(self.owner_kind, self.owner_id, self.channel)
|
||||
except Exception: # noqa: BLE001 - clearing storage must not break the socket
|
||||
logger.exception(
|
||||
"Failed to clear conversation for %s/%s",
|
||||
@@ -354,7 +360,10 @@ class DeviiSession:
|
||||
if self.persist_conversation:
|
||||
try:
|
||||
self._conv.save(
|
||||
self.owner_kind, self.owner_id, self.agent._messages
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
self.agent._messages,
|
||||
self.channel,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - persistence must not break the socket
|
||||
logger.exception(
|
||||
@@ -376,6 +385,8 @@ class DeviiSession:
|
||||
async with self._lock:
|
||||
self._refresh_tools()
|
||||
self._refresh_system_prompt()
|
||||
if self.channel == "docs":
|
||||
await self._docs_topic_gate(text)
|
||||
reply = await self.agent.respond(text)
|
||||
if epoch == self._turn_epoch:
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
@@ -391,12 +402,26 @@ class DeviiSession:
|
||||
if not cancelled and epoch == self._turn_epoch:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
|
||||
def _builtin_tools(self) -> list[dict[str, Any]]:
|
||||
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
if self.channel == "docs":
|
||||
return [
|
||||
s
|
||||
for s in schemas
|
||||
if s.get("function", {}).get("name") in DOCS_TOOLS
|
||||
]
|
||||
return schemas
|
||||
|
||||
def _refresh_tools(self) -> None:
|
||||
builtin = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
if self.channel == "docs":
|
||||
self.tools[:] = self._builtin_tools()
|
||||
return
|
||||
virtual = self._virtual_tool_store.tool_schemas()
|
||||
self.tools[:] = builtin + virtual
|
||||
self.tools[:] = self._builtin_tools() + virtual
|
||||
|
||||
def _compose_system_prompt(self) -> str:
|
||||
if self.channel == "docs":
|
||||
return self._system_prompt
|
||||
body = self._behavior_store.text().strip()
|
||||
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
|
||||
return f"{self._system_prompt}\n\n{section}"
|
||||
@@ -406,6 +431,56 @@ class DeviiSession:
|
||||
if messages and messages[0].get("role") == "system":
|
||||
messages[0]["content"] = self._compose_system_prompt()
|
||||
|
||||
async def _docs_topic_gate(self, text: str) -> None:
|
||||
prior = [
|
||||
m
|
||||
for m in self.agent._messages
|
||||
if m.get("role") in ("user", "assistant") and m.get("content")
|
||||
]
|
||||
if not prior:
|
||||
return
|
||||
decision, reason = await self._classify_topic(prior, text)
|
||||
if decision == "new":
|
||||
messages = self.agent._messages
|
||||
if messages and messages[0].get("role") == "system":
|
||||
system = messages[0]
|
||||
else:
|
||||
system = {"role": "system", "content": self._compose_system_prompt()}
|
||||
self.agent._messages = [system]
|
||||
self._buffer = []
|
||||
if self.persist_conversation:
|
||||
try:
|
||||
self._conv.clear(self.owner_kind, self.owner_id, self.channel)
|
||||
except Exception: # noqa: BLE001 - clearing storage must not break the turn
|
||||
logger.exception(
|
||||
"Failed to clear docs conversation for %s/%s",
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
)
|
||||
await self._emit(
|
||||
{"type": "topic", "decision": decision, "reason": reason}, buffer=False
|
||||
)
|
||||
|
||||
async def _classify_topic(
|
||||
self, prior: list[dict[str, Any]], text: str
|
||||
) -> tuple[str, str]:
|
||||
recent = prior[-6:]
|
||||
convo = "\n".join(
|
||||
f"{m['role']}: {str(m['content'])[:300]}" for m in recent
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": TOPIC_CLASSIFIER_PROMPT},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Prior conversation:\n{convo}\n\nNew message:\n{text}\n\nClassify.",
|
||||
},
|
||||
]
|
||||
try:
|
||||
raw = await self._llm.complete_text(messages)
|
||||
except Exception: # noqa: BLE001 - on any failure, keep context (treat as follow-up)
|
||||
return "follow_up", ""
|
||||
return _parse_topic(raw)
|
||||
|
||||
def _quota_snapshot(self) -> dict[str, Any]:
|
||||
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
|
||||
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
|
||||
@@ -440,7 +515,12 @@ class DeviiSession:
|
||||
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
|
||||
try:
|
||||
if self.persist_conversation:
|
||||
self._conv.save(self.owner_kind, self.owner_id, self.agent._messages)
|
||||
self._conv.save(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
self.agent._messages,
|
||||
self.channel,
|
||||
)
|
||||
self._ledger.record(
|
||||
self.owner_kind,
|
||||
self.owner_id,
|
||||
@@ -615,6 +695,88 @@ NON_ADMIN_COST_RULE = (
|
||||
"quota used and the turn count."
|
||||
)
|
||||
|
||||
DOCS_TOOLS = frozenset({"search_docs"})
|
||||
|
||||
TOPIC_CLASSIFIER_PROMPT = (
|
||||
"You are a routing classifier for a documentation assistant. Decide whether the "
|
||||
"user's NEW message continues the PRIOR conversation (a follow-up: a clarification, "
|
||||
"a refinement, or another question about the same subject) or starts a NEW, "
|
||||
"unrelated topic that should begin from a clean slate.\n"
|
||||
"Respond with ONLY a JSON object and nothing else: "
|
||||
'{"decision": "follow_up" or "new", "reason": "<one short sentence explaining why>"}.'
|
||||
)
|
||||
|
||||
DOCS_GREETING = (
|
||||
"Hi, I am Docii, the DevPlace documentation assistant. Ask me anything about "
|
||||
"DevPlace and I will search the documentation to find your answer."
|
||||
)
|
||||
|
||||
DOCS_SYSTEM_PROMPT = (
|
||||
"You are Docii, the documentation assistant for DevPlace, a social network for "
|
||||
"software developers. You answer questions about DevPlace using ONLY its official "
|
||||
"documentation.\n\n"
|
||||
"YOU HAVE EXACTLY ONE TOOL: search_docs(query, max_results). It performs a keyword "
|
||||
"search over the documentation and returns the matching pages. Each result is an object "
|
||||
'with "title", "url" (e.g. /docs/authentication.html), "score" (BM25 relevance, higher '
|
||||
'means more relevant), and "content" (the page text).\n\n'
|
||||
"HARD RULES - follow every one, every turn:\n"
|
||||
"1. Ground every CLAIM ABOUT DEVPLACE (routes, endpoints, parameters, auth methods, "
|
||||
"settings, behavior, limits) in content returned by search_docs in THIS turn. Never state "
|
||||
"a platform fact from memory or assumption.\n"
|
||||
"2. For EVERY question, your FIRST action is to call search_docs with focused keywords "
|
||||
"drawn from the question (feature names, endpoint paths, nouns).\n"
|
||||
"3. READ the content in the results. If they do not fully and confidently answer the "
|
||||
"question, call search_docs AGAIN with different or more specific keywords (synonyms, "
|
||||
"related terms, the exact feature or route). Keep searching recursively - visiting the "
|
||||
"results, refining the query, searching again - until you have gathered enough grounded "
|
||||
"information to answer. Prefer several targeted searches over one broad search.\n"
|
||||
"4. Do not invent routes, endpoints, parameters, settings, or behavior that the docs did "
|
||||
"not state. The platform-specific facts in your answer must come from the docs.\n"
|
||||
"5. If, after several distinct searches, the documentation genuinely does not cover a "
|
||||
"needed FACT, say which part is undocumented - but still help as far as the docs allow "
|
||||
"(for example, write the code using the endpoints you DID find).\n"
|
||||
"6. Your only tool is search_docs and you perform no platform actions (no posting, "
|
||||
"account changes, file edits, or web browsing). Writing code and examples in your reply "
|
||||
"is allowed and encouraged.\n\n"
|
||||
"WRITING CODE - when the user asks for a script, code example, API client, bot, or "
|
||||
"snippet in ANY language, WRITE complete, runnable example code. Do NOT refuse merely "
|
||||
"because the literal source is not published in the docs - synthesize it from the "
|
||||
"documented API: take the base URL, endpoints, HTTP methods, parameters and "
|
||||
"authentication from your search_docs results, and write all the ordinary programming "
|
||||
"scaffolding (imports, language syntax, error handling, a main loop, comments) yourself. "
|
||||
"Put it in a fenced code block with a language tag. Never invent endpoints or parameters "
|
||||
"the docs do not describe; if a detail you need is missing, search for it first.\n\n"
|
||||
"LINKING - this is mandatory:\n"
|
||||
"- ACTIVE INLINE LINKING: whenever you mention a documented page, feature, endpoint, or "
|
||||
"concept in your prose, write it as a markdown link to that page's `url`, for example "
|
||||
"[authentication](/docs/authentication.html). Use the exact `url` from the search "
|
||||
"results. Never paste a bare URL - always a markdown link with descriptive text.\n"
|
||||
"- ALWAYS end every answer with a '## References' section: a markdown bullet list of the "
|
||||
"documentation pages you actually used to answer, each as a clickable markdown link "
|
||||
"followed by its relevance score, highest score first, e.g. "
|
||||
"`- [Authentication](/docs/authentication.html) - score 12.3`. Include only pages you "
|
||||
"used; do not invent links or scores.\n\n"
|
||||
"STYLE: concise, practical, technical. Use markdown."
|
||||
)
|
||||
|
||||
|
||||
def _parse_topic(raw: str) -> tuple[str, str]:
|
||||
import json
|
||||
import re
|
||||
|
||||
match = re.search(r"\{.*\}", raw or "", re.S)
|
||||
if not match:
|
||||
return "follow_up", ""
|
||||
try:
|
||||
data = json.loads(match.group())
|
||||
except (ValueError, TypeError):
|
||||
return "follow_up", ""
|
||||
decision = (
|
||||
"new" if str(data.get("decision", "")).strip().lower() == "new" else "follow_up"
|
||||
)
|
||||
reason = str(data.get("reason", "")).strip()[:200]
|
||||
return decision, reason
|
||||
|
||||
|
||||
def _system_prompt_for(is_admin: bool) -> str:
|
||||
if is_admin:
|
||||
|
||||
@@ -28,11 +28,13 @@ def _iso(moment: datetime) -> str:
|
||||
|
||||
|
||||
class ConversationStore:
|
||||
def load(self, owner_kind: str, owner_id: str) -> list[dict[str, Any]] | None:
|
||||
def load(
|
||||
self, owner_kind: str, owner_id: str, channel: str = "main"
|
||||
) -> list[dict[str, Any]] | None:
|
||||
if CONVERSATIONS not in db.tables:
|
||||
return None
|
||||
row = get_table(CONVERSATIONS).find_one(
|
||||
owner_kind=owner_kind, owner_id=owner_id
|
||||
owner_kind=owner_kind, owner_id=owner_id, channel=channel
|
||||
)
|
||||
if not row or not row.get("messages"):
|
||||
return None
|
||||
@@ -43,18 +45,23 @@ class ConversationStore:
|
||||
return None
|
||||
|
||||
def save(
|
||||
self, owner_kind: str, owner_id: str, messages: list[dict[str, Any]]
|
||||
self,
|
||||
owner_kind: str,
|
||||
owner_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
channel: str = "main",
|
||||
) -> None:
|
||||
now = _iso(_now())
|
||||
record = {
|
||||
"owner_kind": owner_kind,
|
||||
"owner_id": owner_id,
|
||||
"channel": channel,
|
||||
"messages": json.dumps(messages),
|
||||
"updated_at": now,
|
||||
}
|
||||
table = get_table(CONVERSATIONS)
|
||||
existing = (
|
||||
table.find_one(owner_kind=owner_kind, owner_id=owner_id)
|
||||
table.find_one(owner_kind=owner_kind, owner_id=owner_id, channel=channel)
|
||||
if CONVERSATIONS in db.tables
|
||||
else None
|
||||
)
|
||||
@@ -64,9 +71,11 @@ class ConversationStore:
|
||||
record["created_at"] = now
|
||||
table.insert(record)
|
||||
|
||||
def clear(self, owner_kind: str, owner_id: str) -> None:
|
||||
def clear(self, owner_kind: str, owner_id: str, channel: str = "main") -> None:
|
||||
if CONVERSATIONS in db.tables:
|
||||
get_table(CONVERSATIONS).delete(owner_kind=owner_kind, owner_id=owner_id)
|
||||
get_table(CONVERSATIONS).delete(
|
||||
owner_kind=owner_kind, owner_id=owner_id, channel=channel
|
||||
)
|
||||
|
||||
|
||||
class UsageLedger:
|
||||
|
||||
Reference in New Issue
Block a user