Introduce a new `_sanitize_mentions` method in `BotHelpersMixin` that deduplicates and removes self-mentions from text, using a compiled regex for `@handle` patterns. Apply this sanitizer before the 2000-character truncation in both `engage.py` comment posting and `social.py` reply posting to prevent duplicate or self-referential mentions from being cut off mid-handle. Additionally, fix profile URL parsing in `social.py` to strip trailing path segments, and refine `LLMClient.clean` to handle asterisks and underscores more precisely without breaking adjacent alphanumeric characters.
175 lines
6.4 KiB
Python
175 lines
6.4 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import asyncio
|
|
import logging
|
|
import random
|
|
import re
|
|
import time
|
|
from typing import Any, Optional
|
|
|
|
from devplacepy.services.bot.config import persona_article_score, pick_category
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_MENTION_RE = re.compile(r"@([A-Za-z0-9_-]+)")
|
|
|
|
|
|
class BotHelpersMixin:
|
|
def _identity(self) -> str:
|
|
return f"{self.state.username or '?'}/{self.state.persona}"
|
|
|
|
def _cost_tag(self) -> str:
|
|
return f"${self.llm.total_cost:.4f} / {self.llm.total_calls} calls"
|
|
|
|
def _notify(self, line: str) -> None:
|
|
if self.on_event:
|
|
try:
|
|
self.on_event(line)
|
|
except Exception as e:
|
|
logger.debug("on_event failed: %s", e)
|
|
|
|
@staticmethod
|
|
def _thread_id(url: str) -> str:
|
|
m = re.search(r"/(?:posts|gists|news|projects)/([^/?#]+)", url)
|
|
return m.group(1) if m else ""
|
|
|
|
def _sanitize_mentions(self, text: str) -> str:
|
|
own = (self.state.username or "").lower()
|
|
seen: set[str] = set()
|
|
|
|
def keep(match: "re.Match[str]") -> str:
|
|
handle = match.group(1)
|
|
lowered = handle.lower()
|
|
if lowered == own or lowered in seen:
|
|
return ""
|
|
seen.add(lowered)
|
|
return match.group(0)
|
|
|
|
cleaned = _MENTION_RE.sub(keep, text)
|
|
return re.sub(r"\s{2,}", " ", cleaned).strip()
|
|
|
|
def _sync_cost(self) -> None:
|
|
self.state.total_cost = self.llm.total_cost
|
|
self.state.total_calls = self.llm.total_calls
|
|
self.state.total_in_tokens = self.llm.total_in_tokens
|
|
self.state.total_out_tokens = self.llm.total_out_tokens
|
|
|
|
def _save(self) -> None:
|
|
self._sync_cost()
|
|
self.state.save(self.state_path)
|
|
|
|
def _log(self, msg: str) -> None:
|
|
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {msg}")
|
|
if len(self.state.log) > 500:
|
|
self.state.log = self.state.log[-250:]
|
|
logger.info("[%s] %s", self._identity(), msg)
|
|
|
|
def _action(self, tag: str, detail: str) -> None:
|
|
line = f"{tag}: {detail}"
|
|
self.state.log.append(f"[{time.strftime('%H:%M:%S')}] {line}")
|
|
if len(self.state.log) > 500:
|
|
self.state.log = self.state.log[-250:]
|
|
logger.info("[%s] %s (%s)", self._identity(), line, self._cost_tag())
|
|
self._notify(f"[{self._identity()}] {line}")
|
|
self.b.note(action=line)
|
|
asyncio.create_task(self.b.capture(tag.lower(), force=True))
|
|
|
|
def _bind_monitor(self) -> None:
|
|
self.b.bind_monitor(
|
|
self._slot,
|
|
username=self.state.username,
|
|
persona=self.state.persona,
|
|
)
|
|
|
|
async def _generate(self, fn: Any, *args: Any) -> Any:
|
|
try:
|
|
return await asyncio.to_thread(fn, *args)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e
|
|
)
|
|
return None
|
|
|
|
def _cost_summary(self, label: str) -> None:
|
|
self._notify(
|
|
f"[{self._identity()}] cost ({label}): ${self.llm.total_cost:.6f} "
|
|
f"over {self.llm.total_calls} calls (in={self.llm.total_in_tokens} out={self.llm.total_out_tokens})"
|
|
)
|
|
|
|
def _session_summary(self, mood: str, actions: int) -> None:
|
|
s = self.state
|
|
sess_cost = self.llm.total_cost - self._session_start_cost
|
|
sess_calls = self.llm.total_calls - self._session_start_calls
|
|
self._notify(
|
|
f"[{self._identity()}] session done mood={mood} actions={actions} "
|
|
f"posts={s.created_posts} comments={s.comments_posted} votes={s.votes_cast} "
|
|
f"session=${sess_cost:.4f}/{sess_calls} lifetime=${self.llm.total_cost:.4f}"
|
|
)
|
|
|
|
def _session_banner(self, mood: str, session_len: int) -> None:
|
|
self._notify(
|
|
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
|
|
)
|
|
|
|
def _pick_article(self, articles: list[dict]) -> Optional[dict]:
|
|
if not articles:
|
|
return None
|
|
ranked = sorted(
|
|
articles,
|
|
key=lambda a: persona_article_score(a, self.state.persona)
|
|
+ random.random(),
|
|
reverse=True,
|
|
)
|
|
top = ranked[: max(3, len(ranked) // 3)]
|
|
return random.choice(top)
|
|
|
|
async def _refill_cache_async(self, kind: str) -> None:
|
|
try:
|
|
if kind == "post":
|
|
articles = self.news.fetch()
|
|
a = self._pick_article(articles)
|
|
if a:
|
|
desc = a.get("description", "")[:500]
|
|
cat = pick_category(self.state.persona)
|
|
post_title = await asyncio.to_thread(
|
|
self.llm.generate_post_title,
|
|
a["title"],
|
|
self.state.persona,
|
|
cat,
|
|
)
|
|
content = await asyncio.to_thread(
|
|
self.llm.generate_post,
|
|
a["title"],
|
|
desc,
|
|
self.state.persona,
|
|
cat,
|
|
self.state.recent_post_titles,
|
|
)
|
|
self._post_cache.append(
|
|
(a["title"], post_title or "", desc, content, cat)
|
|
)
|
|
elif kind == "project":
|
|
t = await asyncio.to_thread(
|
|
self.llm.generate_project_title, self.state.persona
|
|
)
|
|
desc = await asyncio.to_thread(
|
|
self.llm.generate_project_desc, t, self.state.persona
|
|
)
|
|
self._project_cache.append((t, desc))
|
|
elif kind == "issue":
|
|
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
|
|
issue = await asyncio.to_thread(self.llm.generate_issue, topic)
|
|
self._issue_cache.append(issue)
|
|
except Exception as e:
|
|
logger.warning("Cache refill (%s) failed: %s", kind, e)
|
|
|
|
async def _warm_cache(self) -> None:
|
|
self._log("Warming content cache (background)...")
|
|
self._post_cache = []
|
|
self._project_cache = []
|
|
self._issue_cache = []
|
|
self.news.fetch()
|
|
asyncio.create_task(self._refill_cache_async("post"))
|
|
asyncio.create_task(self._refill_cache_async("project"))
|
|
asyncio.create_task(self._refill_cache_async("issue"))
|