Files
devplacepy/devplacepy/services/bot/helpers.py
T
retoor f1bdefd834 feat: add _sanitize_mentions helper and apply to comment/reply truncation
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.
2026-07-04 23:08:41 +00:00

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"))