fix: normalize unicode escape sequences and reformat multi-line expressions across codebase

This commit is contained in:
2026-06-09 16:48:08 +00:00
parent 66dfda88bc
commit c4f2937415
175 changed files with 12660 additions and 4175 deletions
+321 -102
View File
@@ -11,8 +11,15 @@ from faker import Faker
from devplacepy.services.bot.browser import BotBrowser
from devplacepy.services.bot.config import (
CATEGORIES, FEED_TOPICS, GIST_LANGUAGES, PERSONAS, PROJECT_STATUSES,
PROJECT_TYPES, REACT_RATE_DEFAULT, REACT_RATES, SEARCH_TERMS,
CATEGORIES,
FEED_TOPICS,
GIST_LANGUAGES,
PERSONAS,
PROJECT_STATUSES,
PROJECT_TYPES,
REACT_RATE_DEFAULT,
REACT_RATES,
SEARCH_TERMS,
)
from devplacepy.services.bot.llm import LLMClient
from devplacepy.services.bot.news_fetcher import NewsFetcher
@@ -33,8 +40,11 @@ class DevPlaceBot:
if not self.state.persona:
self.state.persona = random.choice(PERSONAS)
self.llm = LLMClient(
cfg.api_key, cfg.api_url, cfg.model,
cfg.input_cost_per_1m, cfg.output_cost_per_1m,
cfg.api_key,
cfg.api_url,
cfg.model,
cfg.input_cost_per_1m,
cfg.output_cost_per_1m,
)
self.llm.total_cost = self.state.total_cost
self.llm.total_calls = self.state.total_calls
@@ -96,7 +106,9 @@ class DevPlaceBot:
try:
return await asyncio.to_thread(fn, *args)
except Exception as e:
logger.warning("LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e)
logger.warning(
"LLM generation failed (%s): %s", getattr(fn, "__name__", fn), e
)
return None
def _cost_summary(self, label: str) -> None:
@@ -116,7 +128,9 @@ class DevPlaceBot:
)
def _session_banner(self, mood: str, session_len: int) -> None:
self._notify(f"[{self._identity()}] session start mood={mood} target~{session_len} actions")
self._notify(
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
)
async def _refill_cache_async(self, kind: str) -> None:
try:
@@ -125,9 +139,15 @@ class DevPlaceBot:
if articles:
a = random.choice(articles)
desc = a.get("description", "")[:500]
cat = await asyncio.to_thread(self.llm.select_category, a["title"], desc[:600])
cat = await asyncio.to_thread(
self.llm.select_category, a["title"], desc[:600]
)
content = await asyncio.to_thread(
self.llm.generate_post, a["title"], desc, self.state.persona, cat,
self.llm.generate_post,
a["title"],
desc,
self.state.persona,
cat,
)
self._post_cache.append((a["title"], desc, content, cat))
elif kind == "project":
@@ -165,7 +185,9 @@ class DevPlaceBot:
]
handle = random.choice(styles)
if random.random() < 0.15:
suffix = random.choice([str(random.randint(1, 99)), str(random.randint(1970, 2005))])
suffix = random.choice(
[str(random.randint(1, 99)), str(random.randint(1970, 2005))]
)
sep = random.choice(["", "_", "."])
handle = f"{handle}{sep}{suffix}"
handle = re.sub(r"[^a-z0-9._-]", "", handle)[:20]
@@ -219,7 +241,9 @@ class DevPlaceBot:
self._log(f"Registered as {self.state.username}")
return True
if attempt >= 2:
self.state.username = f"{self._generate_handle()}{random.randint(10, 999)}"
self.state.username = (
f"{self._generate_handle()}{random.randint(10, 999)}"
)
else:
self.state.username = self._generate_handle()
self.state.email = self.faker.email()
@@ -233,7 +257,9 @@ class DevPlaceBot:
sec_per_word = 60.0 / wpm
curl = await self.b.url()
if "/posts/" in curl:
body = await self.b.text("article") or await self.b.text(".post-content") or ""
body = (
await self.b.text("article") or await self.b.text(".post-content") or ""
)
wc = len(body.split())
jitter = random.uniform(0.7, 1.25)
pause = min(max(wc * sec_per_word * jitter, min_s), max_s)
@@ -365,7 +391,9 @@ class DevPlaceBot:
self.state.voted_post_ids.append(post_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action("VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})")
self._action(
"VOTE", f"post {post_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -473,19 +501,31 @@ class DevPlaceBot:
self._log(f"Own {kind} - not commenting")
return False
if thread_id and thread_id in self.state.commented_post_ids and not mentioned_here:
if (
thread_id
and thread_id in self.state.commented_post_ids
and not mentioned_here
):
self._log(f"Already commented on {thread_id[:12]}, not repeating")
return False
post_body = (await b.text(".post-content") or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content") or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc") or await b.text(".rendered-content")
or await b.text("article") or "")
post_body = (
await b.text(".post-content")
or await b.text(".gist-detail-desc")
or await b.text(".news-detail-content")
or await b.text(".news-detail-desc")
or await b.text(".project-detail-desc")
or await b.text(".rendered-content")
or await b.text("article")
or ""
)
if not post_body:
self._log("Comment: no content found")
return False
known = list({u for u in self.state.known_users if u and u != self.state.username})
known = list(
{u for u in self.state.known_users if u and u != self.state.username}
)
mention_target = ""
if known and random.random() < 0.3:
mention_target = random.choice(known)
@@ -493,11 +533,17 @@ class DevPlaceBot:
comment = ""
for attempt in range(2):
candidate = await self._generate(
self.llm.generate_comment, post_body[:1500], self.state.persona, mention_target, "",
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mention_target,
"",
)
if not candidate:
continue
verdict = await self._generate(self.llm.quality_check, "comment", candidate, post_body[:1500])
verdict = await self._generate(
self.llm.quality_check, "comment", candidate, post_body[:1500]
)
if verdict is None or verdict[0]:
comment = candidate
break
@@ -562,7 +608,10 @@ class DevPlaceBot:
if kind == "news":
self.state.news_comments += 1
tag = "NEWS" if kind == "news" else "COMMENT"
self._action(tag, f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}")
self._action(
tag,
f"on {kind}{mention_log} [{self.state.comments_posted}]: {comment[:60]}",
)
await asyncio.sleep(2)
return True
self._log("Comment: submit button not found/enabled")
@@ -573,7 +622,9 @@ class DevPlaceBot:
curl = await b.url()
if "/posts/" not in curl:
return False
comments = b.page.locator(".comment, li.comment, article.comment, .comment-item")
comments = b.page.locator(
".comment, li.comment, article.comment, .comment-item"
)
count = await comments.count()
if count == 0:
return False
@@ -595,7 +646,9 @@ class DevPlaceBot:
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
pass
if not mentioner or mentioner.lower() == self.state.username.lower():
@@ -615,7 +668,11 @@ class DevPlaceBot:
continue
post_body = await b.text(".post-content") or await b.text("article") or ""
reply = await self._generate(
self.llm.generate_comment, post_body[:1200], self.state.persona, mentioner, text,
self.llm.generate_comment,
post_body[:1200],
self.state.persona,
mentioner,
text,
)
if not reply:
self._log("Reply: generation failed/empty")
@@ -647,7 +704,10 @@ class DevPlaceBot:
self.state.comments_posted += 1
if mentioner not in self.state.known_users:
self.state.known_users.append(mentioner)
self._action("REPLY", f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}")
self._action(
"REPLY",
f"to @{mentioner} [{self.state.comments_posted}]: {reply[:60]}",
)
await asyncio.sleep(2)
return True
return False
@@ -655,8 +715,10 @@ class DevPlaceBot:
async def _vote_on_gist(self) -> bool:
b = self.b
sel = ("form[action*='/votes/gist/'] button.gist-card-star, "
"form[action*='/votes/gist/'] button[type='submit']")
sel = (
"form[action*='/votes/gist/'] button.gist-card-star, "
"form[action*='/votes/gist/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
@@ -687,7 +749,9 @@ class DevPlaceBot:
self.state.voted_post_ids.append(gist_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action("VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})")
self._action(
"VOTE", f"gist {gist_id[:12] or '?'} (total {self.state.votes_cast})"
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -696,8 +760,10 @@ class DevPlaceBot:
async def _vote_on_project(self) -> bool:
b = self.b
sel = ("form[action*='/votes/project/'] button.project-star-btn, "
"form[action*='/votes/project/'] button[type='submit']")
sel = (
"form[action*='/votes/project/'] button.project-star-btn, "
"form[action*='/votes/project/'] button[type='submit']"
)
btns = b.page.locator(sel)
count = await btns.count()
if count == 0:
@@ -729,7 +795,10 @@ class DevPlaceBot:
self.state.voted_post_ids.append(project_id)
if len(self.state.voted_post_ids) > 1000:
self.state.voted_post_ids = self.state.voted_post_ids[-500:]
self._action("STAR", f"project {project_id[:12] or '?'} (total {self.state.votes_cast})")
self._action(
"STAR",
f"project {project_id[:12] or '?'} (total {self.state.votes_cast})",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -765,14 +834,19 @@ class DevPlaceBot:
logger.debug("_vote_on_poll click failed: %s", e)
continue
try:
await poll.locator(".poll-option.chosen").first.wait_for(state="visible", timeout=4000)
await poll.locator(".poll-option.chosen").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_vote_on_poll confirm failed: %s", e)
self.state.polls_voted.append(poll_uid)
if len(self.state.polls_voted) > 1000:
self.state.polls_voted = self.state.polls_voted[-500:]
self.state.poll_votes_cast += 1
self._action("POLL", f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})")
self._action(
"POLL",
f"voted {poll_uid[:12]} opt {option_uid[:8] or '?'} (total {self.state.poll_votes_cast})",
)
await b._idle(1.0, 2.0)
return True
return False
@@ -789,10 +863,16 @@ class DevPlaceBot:
return text[:1200]
except Exception as e:
logger.debug("_reaction_content container failed: %s", e)
body = (await self.b.text(".post-content") or await self.b.text(".gist-detail-desc")
or await self.b.text(".news-detail-content") or await self.b.text(".news-detail-desc")
or await self.b.text(".project-detail-desc") or await self.b.text(".rendered-content")
or await self.b.text("article") or "")
body = (
await self.b.text(".post-content")
or await self.b.text(".gist-detail-desc")
or await self.b.text(".news-detail-content")
or await self.b.text(".news-detail-desc")
or await self.b.text(".project-detail-desc")
or await self.b.text(".rendered-content")
or await self.b.text("article")
or ""
)
return body[:1200]
async def _react_to_object(self) -> bool:
@@ -822,7 +902,9 @@ class DevPlaceBot:
if not snippet:
self._log(f"Reaction: no content for {rtype} {uid[:12]}")
return False
emoji = await self._generate(self.llm.select_reaction, snippet, self.state.persona)
emoji = await self._generate(
self.llm.select_reaction, snippet, self.state.persona
)
if not emoji:
self._log(f"Reaction: nothing fitting for {rtype} {uid[:12]}")
return False
@@ -845,11 +927,16 @@ class DevPlaceBot:
logger.debug("_react_to_object click failed: %s", e)
return False
try:
await bar.locator(".reaction-chip.reacted").first.wait_for(state="visible", timeout=4000)
await bar.locator(".reaction-chip.reacted").first.wait_for(
state="visible", timeout=4000
)
except Exception as e:
logger.debug("_react_to_object confirm failed: %s", e)
self.state.reactions_cast += 1
self._action("REACT", f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})")
self._action(
"REACT",
f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})",
)
await b._idle(1.0, 2.0)
return True
return False
@@ -908,7 +995,9 @@ class DevPlaceBot:
return False
await b._idle(0.5, 1.2)
ok = await b.click("#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary")
ok = await b.click(
"#create-gist-modal button[type='submit'], #create-gist-modal .btn-primary"
)
if not ok:
self._log("Create gist: submit not found")
return False
@@ -918,7 +1007,10 @@ class DevPlaceBot:
if url not in self.state.own_post_urls:
self.state.own_post_urls.append(url)
self.state.gists_created += 1
self._action("GIST", f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}")
self._action(
"GIST",
f"{title[:40]} [{language}] [{self.state.gists_created}] -> {url[-50:]}",
)
return True
self._log("Create gist: no redirect to gist detail")
return False
@@ -953,14 +1045,22 @@ class DevPlaceBot:
asyncio.create_task(self._refill_cache_async("post"))
else:
articles = self.news.fetch()
article = self.registry.reserve_unused(articles, self.state.username, self.llm.clean)
article = self.registry.reserve_unused(
articles, self.state.username, self.llm.clean
)
if not article:
self._log("Create post: no unused articles available")
return False
desc = article.get("description", "")[:500]
topic = await self._generate(self.llm.select_category, article["title"], desc[:600])
topic = await self._generate(
self.llm.select_category, article["title"], desc[:600]
)
content = await self._generate(
self.llm.generate_post, article["title"], desc, self.state.persona, topic or "random",
self.llm.generate_post,
article["title"],
desc,
self.state.persona,
topic or "random",
)
if not content:
self._log("Create post: generation failed/empty")
@@ -969,17 +1069,25 @@ class DevPlaceBot:
article_title = article["title"]
article_desc = desc
verdict = await self._generate(self.llm.quality_check, "post", content, article_desc)
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), regenerating")
content = await self._generate(
self.llm.generate_post, article_title, article_desc, self.state.persona, topic,
self.llm.generate_post,
article_title,
article_desc,
self.state.persona,
topic,
)
if not content:
self._log("Create post: regeneration failed/empty")
return False
verdict = await self._generate(self.llm.quality_check, "post", content, article_desc)
verdict = await self._generate(
self.llm.quality_check, "post", content, article_desc
)
if verdict is not None and not verdict[0]:
self.state.quality_rejections += 1
self._log(f"Post quality reject ({verdict[1]}), skipping")
@@ -993,7 +1101,9 @@ class DevPlaceBot:
clean_title = self.llm.clean(article_title[:200]) if article_title else ""
if clean_title:
if not self.registry.reserve(clean_title, self.state.username):
self._log(f"Create post: article '{clean_title[:50]}...' already posted by another bot, skipping")
self._log(
f"Create post: article '{clean_title[:50]}...' already posted by another bot, skipping"
)
return False
ts = f"input[name='topic'][value='{topic}']"
@@ -1039,7 +1149,10 @@ class DevPlaceBot:
self.state.own_post_urls.append(post_url)
if "/posts/" in post_url and post_url not in self.state.known_posts:
self.state.known_posts.append(post_url)
self._action("POST", f"{topic} [{self.state.created_posts}]: {clean_title[:50] or content[:50]} -> {post_url[-45:]}")
self._action(
"POST",
f"{topic} [{self.state.created_posts}]: {clean_title[:50] or content[:50]} -> {post_url[-45:]}",
)
return True
async def _click_post_link(self) -> bool:
@@ -1137,7 +1250,9 @@ class DevPlaceBot:
if uname not in self.state.known_users:
self.state.known_users.append(uname)
self.state.profiles_viewed += 1
self._action("PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]")
self._action(
"PROFILE", f"viewed @{uname} [{self.state.profiles_viewed}]"
)
return True
except Exception as e:
logger.debug("_click_profile_link failed: %s", e)
@@ -1189,7 +1304,9 @@ class DevPlaceBot:
ok = await b.click("#create-project-modal .btn-primary")
if ok:
self.state.projects_created += 1
self._action("PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]")
self._action(
"PROJECT", f"{title} ({ptype}) [{self.state.projects_created}]"
)
await asyncio.sleep(2)
return True
return False
@@ -1205,7 +1322,15 @@ class DevPlaceBot:
if not await b.text("#create-bug-modal"):
return False
topic = random.choice(["UI glitch", "Broken link", "Performance issue", "Auth problem", "Formatting error"])
topic = random.choice(
[
"UI glitch",
"Broken link",
"Performance issue",
"Auth problem",
"Formatting error",
]
)
if self._bug_cache:
title, desc = self._bug_cache.pop(0)
asyncio.create_task(self._refill_cache_async("bug"))
@@ -1221,7 +1346,9 @@ class DevPlaceBot:
await b.fill("textarea[name='description']", desc[:2000])
await b._idle(0.5, 1.0)
ok = await b.click("button:has-text('Submit Report'), #create-bug-modal .btn-primary")
ok = await b.click(
"button:has-text('Submit Report'), #create-bug-modal .btn-primary"
)
if ok:
self.state.bugs_filed += 1
self._action("BUG", f"{title[:50]} [{self.state.bugs_filed}]")
@@ -1247,19 +1374,29 @@ class DevPlaceBot:
await b._idle(0.3, 0.8)
filled = ["bio"]
fields = await self._generate(self.llm.generate_profile_fields, self.state.username)
fields = await self._generate(
self.llm.generate_profile_fields, self.state.username
)
if fields:
location, git_link, website = fields
for field_name, value in (("location", location), ("git_link", git_link), ("website", website)):
for field_name, value in (
("location", location),
("git_link", git_link),
("website", website),
):
if not value:
continue
await b.click(f"[data-edit-field='{field_name}']")
await b._idle(0.2, 0.5)
if await b.fill(f"input[name='{field_name}'], textarea[name='{field_name}']", value):
if await b.fill(
f"input[name='{field_name}'], textarea[name='{field_name}']", value
):
filled.append(field_name)
await b._idle(0.2, 0.5)
ok = await b.click("form[action*='/profile/update'] button[type='submit'], button[type='submit']")
ok = await b.click(
"form[action*='/profile/update'] button[type='submit'], button[type='submit']"
)
if ok:
self._action("PROFILE", f"updated own profile ({', '.join(filled)})")
await asyncio.sleep(2)
@@ -1282,7 +1419,10 @@ class DevPlaceBot:
return False
try:
await form.first.click(timeout=3000)
self._action("FOLLOW", f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}")
self._action(
"FOLLOW",
f"{'followed' if text.lower() == 'follow' else 'unfollowed'} @{uname}",
)
await b._idle(1.0, 2.0)
return True
except Exception as e:
@@ -1325,7 +1465,9 @@ class DevPlaceBot:
href = href_attr
else:
try:
inner = el.locator("a[href*='/posts/'], a[href*='/profile/']").first
inner = el.locator(
"a[href*='/posts/'], a[href*='/profile/']"
).first
if await inner.count() > 0:
href = (await inner.get_attribute("href")) or ""
except Exception:
@@ -1345,7 +1487,9 @@ class DevPlaceBot:
or "replied to you" in tl
or "tagged you" in tl
)
items.append({"key": key, "href": href, "text": text, "is_mention": is_mention})
items.append(
{"key": key, "href": href, "text": text, "is_mention": is_mention}
)
if items:
break
return items
@@ -1358,9 +1502,14 @@ class DevPlaceBot:
if not notifications:
self._log("Notifications: none")
return True
unseen_mentions = [n for n in notifications
if n["is_mention"] and n["key"] not in self.state.notifications_seen]
self._log(f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)")
unseen_mentions = [
n
for n in notifications
if n["is_mention"] and n["key"] not in self.state.notifications_seen
]
self._log(
f"Notifications: {len(notifications)} ({len(unseen_mentions)} unseen mentions)"
)
self.state.mentions_received += len(unseen_mentions)
replied = 0
for n in unseen_mentions[:3]:
@@ -1373,12 +1522,18 @@ class DevPlaceBot:
self.state.notifications_seen = self.state.notifications_seen[-250:]
await b._idle(2.0, 5.0)
if replied == 0:
remaining = [n for n in notifications if n["key"] not in self.state.notifications_seen]
remaining = [
n
for n in notifications
if n["key"] not in self.state.notifications_seen
]
if remaining:
pick = random.choice(remaining)
target = pick["href"]
if target and not target.startswith("http"):
target = self.base_url + (target if target.startswith("/") else "/" + target)
target = self.base_url + (
target if target.startswith("/") else "/" + target
)
if target:
try:
await b.goto(target)
@@ -1387,7 +1542,9 @@ class DevPlaceBot:
logger.debug("notification goto failed: %s", e)
self.state.notifications_seen.append(pick["key"])
try:
mark = b.page.locator("button:has-text('Mark all read'), form[action*='/mark-all-read'] button")
mark = b.page.locator(
"button:has-text('Mark all read'), form[action*='/mark-all-read'] button"
)
if await mark.count() > 0:
await mark.first.click(timeout=3000)
await b._idle(0.5, 1.5)
@@ -1436,7 +1593,9 @@ class DevPlaceBot:
if await author_link.count() > 0:
ah = await author_link.get_attribute("href") or ""
if "/profile/" in ah:
mentioner = ah.split("/profile/")[-1].split("?")[0].split("/")[0]
mentioner = (
ah.split("/profile/")[-1].split("?")[0].split("/")[0]
)
except Exception:
mentioner = ""
try:
@@ -1445,18 +1604,26 @@ class DevPlaceBot:
except Exception as e:
logger.debug("scroll mention failed: %s", e)
try:
reply_btn = comment_loc.locator(".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')").first
reply_btn = comment_loc.locator(
".comment-action-btn, button:has-text('Reply'), a:has-text('Reply')"
).first
if await reply_btn.count() > 0:
await reply_btn.click(timeout=3000)
await b._idle(0.5, 1.5)
except Exception as e:
logger.debug("reply-btn click failed: %s", e)
try:
post_body = await b.text(".post-content") or await b.text("article") or snippet
post_body = (
await b.text(".post-content") or await b.text("article") or snippet
)
except Exception:
post_body = snippet
reply = await self._generate(
self.llm.generate_comment, post_body[:1500], self.state.persona, mentioner, parent_text,
self.llm.generate_comment,
post_body[:1500],
self.state.persona,
mentioner,
parent_text,
)
if not reply:
self._log("Mention reply: generation failed/empty")
@@ -1503,7 +1670,9 @@ class DevPlaceBot:
b = self.b
await b.goto(f"{self.base_url}/messages")
await b._idle()
conv = b.page.locator(".messages-conversations a, .conversation-item, .message-item, .messages-list a")
conv = b.page.locator(
".messages-conversations a, .conversation-item, .message-item, .messages-list a"
)
if await conv.count() == 0:
self._log("Messages: no conversations")
return True
@@ -1520,15 +1689,19 @@ class DevPlaceBot:
async def _compose_message(self, reply: bool = False) -> bool:
b = self.b
box = b.page.locator("form[action*='/messages/send'] input[name='content'], "
"input[name='content'][placeholder*='message'], "
"form[action*='/messages/send'] textarea[name='content']")
box = b.page.locator(
"form[action*='/messages/send'] input[name='content'], "
"input[name='content'][placeholder*='message'], "
"form[action*='/messages/send'] textarea[name='content']"
)
if await box.count() == 0:
self._log("Message: compose box not found")
return False
context = ""
try:
context = (await b.text(".messages-thread") or await b.text(".message-list") or "")[:400]
context = (
await b.text(".messages-thread") or await b.text(".message-list") or ""
)[:400]
except Exception:
context = ""
text = await self._generate(self.llm.generate_dm, self.state.persona, context)
@@ -1537,12 +1710,19 @@ class DevPlaceBot:
text = text[:500]
if not await self._type_like_human(
"form[action*='/messages/send'] input[name='content'], input[name='content'], "
"form[action*='/messages/send'] textarea[name='content']", text):
"form[action*='/messages/send'] textarea[name='content']",
text,
):
return False
await b._idle(0.4, 1.0)
if await b.click("button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"):
if await b.click(
"button.messages-send-btn, form[action*='/messages/send'] button[type='submit']"
):
self.state.messages_sent += 1
self._action("MESSAGE", f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}")
self._action(
"MESSAGE",
f"{'replied' if reply else 'sent'} [{self.state.messages_sent}]: {text[:50]}",
)
await asyncio.sleep(2)
return True
return False
@@ -1575,7 +1755,9 @@ class DevPlaceBot:
nav_links = b.page.locator(".topnav-links a, .topnav a, a.topnav-link")
count = await nav_links.count()
if count == 0:
nav_links = b.page.locator("a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']")
nav_links = b.page.locator(
"a[href='/feed'], a[href='/projects'], a[href='/messages'], a[href='/notifications']"
)
count = await nav_links.count()
if count == 0:
return False
@@ -1592,19 +1774,23 @@ class DevPlaceBot:
return False
async def _browse_section(self) -> bool:
section, query = random.choice([
("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
("feed", f"?topic={random.choice(FEED_TOPICS)}"),
("projects", f"?type={random.choice(PROJECT_TYPES)}"),
("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
])
section, query = random.choice(
[
("feed", f"?tab={random.choice(['trending', 'recent', 'following'])}"),
("feed", f"?topic={random.choice(FEED_TOPICS)}"),
("projects", f"?type={random.choice(PROJECT_TYPES)}"),
("gists", f"?language={random.choice(GIST_LANGUAGES)}"),
]
)
await self.b.goto(f"{self.base_url}/{section}{query}")
self._action("BROWSE", f"/{section}{query}")
await self.b._idle(0.8, 1.8)
return True
async def _search(self, section: str = "feed") -> bool:
terms = list(SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"]))
terms = list(
SEARCH_TERMS.get(self.state.persona, ["python", "rust", "ai", "web"])
)
for href in self.state.known_posts[-10:]:
slug = href.rsplit("/", 1)[-1]
words = [w for w in re.split(r"[-_]", slug) if len(w) > 4]
@@ -1617,10 +1803,18 @@ class DevPlaceBot:
await self.b._idle(0.8, 1.8)
return True
async def _cycle(self, mood: str = "normal", *, session_posted: bool = False,
session_projects: int = 0, session_follows: int = 0,
session_gists: int = 0, can_project: bool = False,
can_post: bool = False, can_message: bool = False) -> tuple[int, bool, int, int, int]:
async def _cycle(
self,
mood: str = "normal",
*,
session_posted: bool = False,
session_projects: int = 0,
session_follows: int = 0,
session_gists: int = 0,
can_project: bool = False,
can_post: bool = False,
can_message: bool = False,
) -> tuple[int, bool, int, int, int]:
b = self.b
curl = await b.url()
page_text = (await b.html()).lower()
@@ -1668,7 +1862,12 @@ class DevPlaceBot:
await self._random_nav_click()
await b._idle(1.0, 2.5)
else:
comment_p = {"lurking": 0.5, "casual": 0.75, "normal": 0.85, "deep": 0.9}[mood]
comment_p = {
"lurking": 0.5,
"casual": 0.75,
"normal": 0.85,
"deep": 0.9,
}[mood]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
@@ -1722,7 +1921,9 @@ class DevPlaceBot:
elif on_news_detail:
await self._read_like_human(4, 60)
comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[mood]
comment_p = {"lurking": 0.4, "casual": 0.6, "normal": 0.7, "deep": 0.8}[
mood
]
if random.random() < comment_p:
if await self._comment_on_post():
actions += 1
@@ -1837,7 +2038,9 @@ class DevPlaceBot:
clicked = True
if not clicked and random.random() < 0.12:
if random.random() < 0.5:
await self._search("feed" if random.random() < 0.7 else "projects")
await self._search(
"feed" if random.random() < 0.7 else "projects"
)
else:
await self._browse_section()
actions += 1
@@ -1940,9 +2143,13 @@ class DevPlaceBot:
sessions_since_project += 1
sessions_since_post += 1
sessions_since_message += 1
can_create_project = sessions_since_project >= 3 and random.random() < 0.3
can_create_project = (
sessions_since_project >= 3 and random.random() < 0.3
)
can_create_post = sessions_since_post >= 2 and random.random() < 0.5
can_create_message = sessions_since_message >= 2 and random.random() < 0.4
can_create_message = (
sessions_since_message >= 2 and random.random() < 0.4
)
while session_actions < max_actions:
if action_limit and lifetime_actions >= action_limit:
@@ -1950,11 +2157,21 @@ class DevPlaceBot:
self._save()
self._cost_summary("final")
return
taken, session_posted, session_projects, session_follows, session_gists = await self._cycle(
mood=mood, session_posted=session_posted,
session_projects=session_projects, session_follows=session_follows,
session_gists=session_gists, can_project=can_create_project,
can_post=can_create_post, can_message=can_create_message,
(
taken,
session_posted,
session_projects,
session_follows,
session_gists,
) = await self._cycle(
mood=mood,
session_posted=session_posted,
session_projects=session_projects,
session_follows=session_follows,
session_gists=session_gists,
can_project=can_create_project,
can_post=can_create_post,
can_message=can_create_message,
)
if session_posted:
sessions_since_post = 0
@@ -1973,7 +2190,9 @@ class DevPlaceBot:
break
intra_pause = random.randint(5, 30)
self._log(f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}")
self._log(
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
)
await asyncio.sleep(intra_pause)
session_ok = session_actions > 0
+4 -2
View File
@@ -21,8 +21,10 @@ class BotBrowser:
self._browser = await self._playwright.chromium.launch(
headless=self._headless,
args=[
"--disable-blink-features=AutomationControlled", "--no-first-run",
"--disable-dev-shm-usage", "--window-size=1280,900",
"--disable-blink-features=AutomationControlled",
"--no-first-run",
"--disable-dev-shm-usage",
"--window-size=1280,900",
],
)
self._context = await self._browser.new_context(
+39 -7
View File
@@ -43,8 +43,21 @@ REACT_RATE_DEFAULT = 0.20
CATEGORIES = ["devlog", "showcase", "question", "rant", "fun", "random"]
GIST_LANGUAGES = [
"python", "javascript", "typescript", "bash", "go", "rust",
"sql", "html", "css", "java", "c", "cpp", "ruby", "php", "lua",
"python",
"javascript",
"typescript",
"bash",
"go",
"rust",
"sql",
"html",
"css",
"java",
"c",
"cpp",
"ruby",
"php",
"lua",
]
FEED_TOPICS = ["devlog", "showcase", "question", "rant", "fun"]
@@ -53,7 +66,13 @@ PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
SEARCH_TERMS = {
"enthusiastic_junior": ["vibe coding", "first app", "react", "python", "ai tools"],
"grumpy_senior": ["rust", "perl", "legacy", "tech debt", "kubernetes"],
"hobbyist_maker": ["arduino", "raspberry pi", "side project", "3d printing", "game"],
"hobbyist_maker": [
"arduino",
"raspberry pi",
"side project",
"3d printing",
"game",
],
"academic_type": ["algorithms", "type theory", "distributed systems", "compilers"],
"minimalist": ["cli", "vim", "golang", "suckless"],
"storyteller": ["postmortem", "war story", "migration", "outage"],
@@ -66,8 +85,21 @@ MIN_POST_LEN = 120
RESTATEMENT_OVERLAP_THRESHOLD = 0.6
GENERIC_COMMENT_PHRASES = [
"great point", "good point", "i agree", "totally agree", "well said",
"nice post", "great post", "thanks for sharing", "interesting read",
"this is interesting", "so true", "couldn't agree more", "spot on",
"great article", "love this", "this is great", "good read",
"great point",
"good point",
"i agree",
"totally agree",
"well said",
"nice post",
"great post",
"thanks for sharing",
"interesting read",
"this is interesting",
"so true",
"couldn't agree more",
"spot on",
"great article",
"love this",
"this is great",
"good read",
]
+115 -45
View File
@@ -12,8 +12,14 @@ logger = logging.getLogger(__name__)
class LLMClient:
def __init__(self, api_key: str, api_url: str, model: str,
input_cost_per_1m: float, output_cost_per_1m: float):
def __init__(
self,
api_key: str,
api_url: str,
model: str,
input_cost_per_1m: float,
output_cost_per_1m: float,
):
if not api_key:
raise RuntimeError("LLM API key not set")
self.api_key = api_key
@@ -31,15 +37,26 @@ class LLMClient:
try:
payload = {
"model": self.model,
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"temperature": temperature,
}
logger.info("LLM >>> model=%s system=%s prompt=%s",
self.model, json.dumps(system[:500]), json.dumps(prompt[:500]))
logger.info(
"LLM >>> model=%s system=%s prompt=%s",
self.model,
json.dumps(system[:500]),
json.dumps(prompt[:500]),
)
data = json.dumps(payload).encode()
req = urllib.request.Request(
self.api_url, data=data,
headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}"},
self.api_url,
data=data,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
@@ -48,18 +65,24 @@ class LLMClient:
usage = result.get("usage", {})
in_tokens = usage.get("prompt_tokens", 0)
out_tokens = usage.get("completion_tokens", 0)
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + \
(out_tokens * self.output_cost_per_1m / 1_000_000)
cost = (in_tokens * self.input_cost_per_1m / 1_000_000) + (
out_tokens * self.output_cost_per_1m / 1_000_000
)
self.total_cost += cost
self.total_calls += 1
self.total_in_tokens += in_tokens
self.total_out_tokens += out_tokens
return self.clean(result["choices"][0]["message"]["content"])
except (urllib.error.HTTPError, urllib.error.URLError, json.JSONDecodeError, KeyError) as e:
except (
urllib.error.HTTPError,
urllib.error.URLError,
json.JSONDecodeError,
KeyError,
) as e:
logger.warning("LLM call attempt %d/3 failed: %s", attempt + 1, e)
if attempt == 2:
raise
time.sleep(2 ** attempt)
time.sleep(2**attempt)
return ""
@staticmethod
@@ -88,7 +111,9 @@ class LLMClient:
text = "\n".join(lines)
return text.strip()
def generate_post(self, title: str, desc: str, persona: str = "", category: str = "") -> str:
def generate_post(
self, title: str, desc: str, persona: str = "", category: str = ""
) -> str:
persona_extras = {
"enthusiastic_junior": "Be excited. Use **bold** for emphasis. Short excited sentences. End with a question sometimes.",
"grumpy_senior": "Be slightly cynical but helpful. Short blunt sentences. No fluff. Call out bad practices.",
@@ -107,9 +132,19 @@ class LLMClient:
"fun": "Write it lighthearted and playful, but stay tied to the actual tech topic. Humor about the technology itself, not off-topic jokes or lyrics.",
"random": "Be natural and conversational - share your thoughts like any casual discussion.",
}
persona_extra = f" {persona_extras.get(persona, 'Be casual. No markdown.')}" if persona else " Be casual. No markdown."
persona_extra = (
f" {persona_extras.get(persona, 'Be casual. No markdown.')}"
if persona
else " Be casual. No markdown."
)
category_extra = f" {category_extras.get(category, '')}" if category else ""
preserve = persona in ("enthusiastic_junior", "hobbyist_maker", "academic_type", "storyteller", "mentor")
preserve = persona in (
"enthusiastic_junior",
"hobbyist_maker",
"academic_type",
"storyteller",
"mentor",
)
text = self._call(
f"You are a dev writing a social media post reacting to tech news. Write 2-4 short paragraphs."
f"{persona_extra}{category_extra} Do not summarize the article; assume the reader already saw it. "
@@ -159,8 +194,13 @@ class LLMClient:
return emoji
return ""
def generate_comment(self, post_snippet: str, persona: str = "",
mention_target: str = "", parent_context: str = "") -> str:
def generate_comment(
self,
post_snippet: str,
persona: str = "",
mention_target: str = "",
parent_context: str = "",
) -> str:
extras = {
"enthusiastic_junior": "Be excited. Use **bold** for agreement. Short replies.",
"grumpy_senior": "Be blunt and short. No markdown. One sarcastic remark or actual advice.",
@@ -169,7 +209,11 @@ class LLMClient:
"storyteller": "Share a quick related story. Use *italics* for emphasis.",
"minimalist": "Shortest possible reply. One sentence max.",
}
extra = f" {extras.get(persona, 'Be casual. No markdown.')}" if persona else " Be casual. No markdown."
extra = (
f" {extras.get(persona, 'Be casual. No markdown.')}"
if persona
else " Be casual. No markdown."
)
preserve = persona in ("enthusiastic_junior", "mentor", "storyteller")
mention_rule = ""
if mention_target:
@@ -206,10 +250,16 @@ class LLMClient:
hits = sum(1 for w in candidate if w in source)
return hits / len(candidate)
def quality_check(self, kind: str, text: str, context: str = "") -> tuple[bool, str]:
def quality_check(
self, kind: str, text: str, context: str = ""
) -> tuple[bool, str]:
from devplacepy.services.bot.config import (
MIN_COMMENT_LEN, MIN_POST_LEN, RESTATEMENT_OVERLAP_THRESHOLD, GENERIC_COMMENT_PHRASES,
MIN_COMMENT_LEN,
MIN_POST_LEN,
RESTATEMENT_OVERLAP_THRESHOLD,
GENERIC_COMMENT_PHRASES,
)
stripped = self.clean(text or "").strip()
min_len = MIN_COMMENT_LEN if kind == "comment" else MIN_POST_LEN
if len(stripped) < min_len:
@@ -219,7 +269,10 @@ class LLMClient:
for phrase in GENERIC_COMMENT_PHRASES:
if phrase in lowered:
return False, f"generic phrase '{phrase}'"
if context and self._overlap_ratio(stripped, context) > RESTATEMENT_OVERLAP_THRESHOLD:
if (
context
and self._overlap_ratio(stripped, context) > RESTATEMENT_OVERLAP_THRESHOLD
):
return False, "restates the source"
verdict = self._call(
"You are a strict content quality reviewer for a developer community. "
@@ -240,7 +293,9 @@ class LLMClient:
)
lines = text.strip().split("\n")
title = lines[0].strip().lstrip("#").strip()[:120] or f"Bug report: {topic}"
desc = " ".join(line.lstrip("-* ").strip() for line in lines[1:] if line.strip())
desc = " ".join(
line.lstrip("-* ").strip() for line in lines[1:] if line.strip()
)
return title, desc[:2000] or text[:500]
def generate_bio(self) -> str:
@@ -250,10 +305,13 @@ class LLMClient:
)
def generate_profile_fields(self, handle: str) -> tuple[str, str, str]:
location = self.clean(self._call(
"Name one plausible city and country for a developer. Reply with ONLY 'City, Country'. No extra words.",
"Location:", temperature=0.9,
))[:80]
location = self.clean(
self._call(
"Name one plausible city and country for a developer. Reply with ONLY 'City, Country'. No extra words.",
"Location:",
temperature=0.9,
)
)[:80]
slug = re.sub(r"[^a-z0-9_-]", "", handle.lower()) or "dev"
git_link = f"https://github.com/{slug}"
website = f"https://{slug}.dev"
@@ -269,13 +327,19 @@ class LLMClient:
"minimalist": "One short sentence.",
}.get(persona, "Be casual and friendly.")
ctx = f"\n\nEarlier message:\n{context[:400]}" if context else ""
return self.clean(self._call(
f"Write one short, friendly direct message to another developer. 1-2 sentences. {extra} No em dashes.",
f"Write a DM to start or continue a chat.{ctx}",
))[:500]
return self.clean(
self._call(
f"Write one short, friendly direct message to another developer. 1-2 sentences. {extra} No em dashes.",
f"Write a DM to start or continue a chat.{ctx}",
)
)[:500]
def generate_project_title(self) -> str:
return self._call("Come up with a project name. 2-4 words. A tool, game, or app idea. No em dashes.", "Name:", temperature=0.9)
return self._call(
"Come up with a project name. 2-4 words. A tool, game, or app idea. No em dashes.",
"Name:",
temperature=0.9,
)
def generate_project_desc(self, title: str) -> str:
return self._call(
@@ -285,19 +349,25 @@ class LLMClient:
def generate_gist(self, persona: str = "") -> tuple[str, str, str, str]:
language = random.choice(GIST_LANGUAGES)
title = self.clean(self._call(
"Name a short, useful code snippet. 2-5 words. No quotes. No markdown.",
f"Language: {language}. Snippet name:",
temperature=0.9,
))[:120]
description = self.clean(self._call(
"Write a one-sentence description of what a code snippet does, like a dev sharing something handy. No em dashes.",
f"Snippet: {title}\nLanguage: {language}",
))[:400]
code = self.strip_code_fences(self._call(
f"Write a short, correct, self-contained {language} snippet of 5 to 20 lines for the description. "
"Output ONLY raw code. No markdown fences. No commentary.",
f"Title: {title}\nDescription: {description}\nLanguage: {language}",
temperature=0.4,
))[:4000]
title = self.clean(
self._call(
"Name a short, useful code snippet. 2-5 words. No quotes. No markdown.",
f"Language: {language}. Snippet name:",
temperature=0.9,
)
)[:120]
description = self.clean(
self._call(
"Write a one-sentence description of what a code snippet does, like a dev sharing something handy. No em dashes.",
f"Snippet: {title}\nLanguage: {language}",
)
)[:400]
code = self.strip_code_fences(
self._call(
f"Write a short, correct, self-contained {language} snippet of 5 to 20 lines for the description. "
"Output ONLY raw code. No markdown fences. No commentary.",
f"Title: {title}\nDescription: {description}\nLanguage: {language}",
temperature=0.4,
)
)[:4000]
return title, description, language, code
+6 -2
View File
@@ -15,11 +15,15 @@ class NewsFetcher:
self.fetched_at: float = 0.0
def fetch(self) -> list[dict]:
fresh = self.articles and (time.monotonic() - self.fetched_at) < self.TTL_SECONDS
fresh = (
self.articles and (time.monotonic() - self.fetched_at) < self.TTL_SECONDS
)
if fresh:
return self.articles
try:
req = urllib.request.Request(self.news_api, headers={"User-Agent": "dpbot/1.0"})
req = urllib.request.Request(
self.news_api, headers={"User-Agent": "dpbot/1.0"}
)
with urllib.request.urlopen(req, timeout=10) as resp:
articles = json.loads(resp.read()).get("articles", [])
if articles:
+3 -1
View File
@@ -42,7 +42,9 @@ class ArticleRegistry:
continue
ts = meta.get("time", "")
try:
age_days = (now - datetime.fromisoformat(ts)).total_seconds() / 86400
age_days = (
now - datetime.fromisoformat(ts)
).total_seconds() / 86400
except (ValueError, TypeError):
age_days = 0
if age_days < 7:
+124 -40
View File
@@ -21,30 +21,92 @@ class BotsService(BaseService):
"metrics per bot."
)
config_fields = [
ConfigField("bot_fleet_size", "Fleet size (bots)", type="int", default=1, minimum=0, maximum=20,
help="Number of concurrent bot accounts to run.", group="Fleet"),
ConfigField("bot_headless", "Headless browser", type="bool", default=True,
help="Run Chromium headless. Disable only for local debugging on a desktop.",
group="Fleet"),
ConfigField("bot_max_actions", "Max actions per bot", type="int", default=0, minimum=0,
help="Stop a bot after this many actions (0 = run indefinitely).", group="Fleet"),
ConfigField("bot_base_url", "Target site URL", type="url", default=config.BASE_URL_DEFAULT,
help="DevPlace instance the bots browse and post to.", group="Target"),
ConfigField("bot_news_api", "News API URL", type="url", default=config.NEWS_API_DEFAULT,
help="Source of articles the bots react to.", group="Target"),
ConfigField("bot_api_url", "LLM API URL", type="url", default=config.API_URL_DEFAULT,
help="OpenAI-compatible chat-completions endpoint.", group="LLM"),
ConfigField("bot_model", "LLM model", type="str", default=config.MODEL_DEFAULT,
help="Model name sent to the LLM API.", group="LLM"),
ConfigField("bot_api_key", "LLM API key", type="password", default="", secret=True,
help="Defaults to the gateway's internal key (the bots use the local AI gateway).",
group="LLM"),
ConfigField("bot_input_cost_per_1m", "Input cost per 1M tokens ($)", type="float",
default=config.INPUT_COST_PER_1M_DEFAULT, minimum=0,
help="Used to compute live spend per bot and across the fleet.", group="LLM"),
ConfigField("bot_output_cost_per_1m", "Output cost per 1M tokens ($)", type="float",
default=config.OUTPUT_COST_PER_1M_DEFAULT, minimum=0,
help="Used to compute live spend per bot and across the fleet.", group="LLM"),
ConfigField(
"bot_fleet_size",
"Fleet size (bots)",
type="int",
default=1,
minimum=0,
maximum=20,
help="Number of concurrent bot accounts to run.",
group="Fleet",
),
ConfigField(
"bot_headless",
"Headless browser",
type="bool",
default=True,
help="Run Chromium headless. Disable only for local debugging on a desktop.",
group="Fleet",
),
ConfigField(
"bot_max_actions",
"Max actions per bot",
type="int",
default=0,
minimum=0,
help="Stop a bot after this many actions (0 = run indefinitely).",
group="Fleet",
),
ConfigField(
"bot_base_url",
"Target site URL",
type="url",
default=config.BASE_URL_DEFAULT,
help="DevPlace instance the bots browse and post to.",
group="Target",
),
ConfigField(
"bot_news_api",
"News API URL",
type="url",
default=config.NEWS_API_DEFAULT,
help="Source of articles the bots react to.",
group="Target",
),
ConfigField(
"bot_api_url",
"LLM API URL",
type="url",
default=config.API_URL_DEFAULT,
help="OpenAI-compatible chat-completions endpoint.",
group="LLM",
),
ConfigField(
"bot_model",
"LLM model",
type="str",
default=config.MODEL_DEFAULT,
help="Model name sent to the LLM API.",
group="LLM",
),
ConfigField(
"bot_api_key",
"LLM API key",
type="password",
default="",
secret=True,
help="Defaults to the gateway's internal key (the bots use the local AI gateway).",
group="LLM",
),
ConfigField(
"bot_input_cost_per_1m",
"Input cost per 1M tokens ($)",
type="float",
default=config.INPUT_COST_PER_1M_DEFAULT,
minimum=0,
help="Used to compute live spend per bot and across the fleet.",
group="LLM",
),
ConfigField(
"bot_output_cost_per_1m",
"Output cost per 1M tokens ($)",
type="float",
default=config.OUTPUT_COST_PER_1M_DEFAULT,
minimum=0,
help="Used to compute live spend per bot and across the fleet.",
group="LLM",
),
]
def __init__(self):
@@ -56,6 +118,7 @@ class BotsService(BaseService):
def _resolve_api_key(self, cfg: dict) -> str:
from devplacepy.database import internal_gateway_key
return cfg["bot_api_key"] or internal_gateway_key()
@staticmethod
@@ -69,7 +132,9 @@ class BotsService(BaseService):
if not api_key:
if self._fleet:
await self._stop_fleet()
self.log("No LLM API key available (bot_api_key / gateway internal key); fleet idle")
self.log(
"No LLM API key available (bot_api_key / gateway internal key); fleet idle"
)
return
try:
self._ensure_importable()
@@ -106,6 +171,7 @@ class BotsService(BaseService):
def _launch_slot(self, slot: int, cfg: dict, api_key: str) -> None:
from devplacepy.services.bot.bot import DevPlaceBot
from devplacepy.services.bot.runtime import BotRuntimeConfig
rc = BotRuntimeConfig(
state_path=self._state_dir / f"state_slot{slot}.json",
base_url=cfg["bot_base_url"],
@@ -137,7 +203,9 @@ class BotsService(BaseService):
async def _stop_fleet(self) -> None:
slots = list(self._fleet)
if slots:
await asyncio.gather(*(self._stop_slot(slot) for slot in slots), return_exceptions=True)
await asyncio.gather(
*(self._stop_slot(slot) for slot in slots), return_exceptions=True
)
async def _stop_slot(self, slot: int) -> None:
entry = self._fleet.pop(slot, None)
@@ -187,18 +255,22 @@ class BotsService(BaseService):
posts += st.created_posts
comments += st.comments_posted
votes += st.votes_cast
rows.append([
f"bot{slot}",
(st.username if st else "") or "-",
(st.persona if st else "") or "-",
"running" if alive else "stopped",
st.created_posts if st else 0,
st.comments_posted if st else 0,
st.votes_cast if st else 0,
calls,
f"${cost:.4f}",
])
window, observed_cost, ready = self._sample_cost(datetime.now(timezone.utc), total_cost)
rows.append(
[
f"bot{slot}",
(st.username if st else "") or "-",
(st.persona if st else "") or "-",
"running" if alive else "stopped",
st.created_posts if st else 0,
st.comments_posted if st else 0,
st.votes_cast if st else 0,
calls,
f"${cost:.4f}",
]
)
window, observed_cost, ready = self._sample_cost(
datetime.now(timezone.utc), total_cost
)
cost_per_hour = observed_cost / window * 3600 if ready else 0.0
projected_24h = observed_cost / window * 86400 if ready else 0.0
rate_value = f"${cost_per_hour:.4f}/h" if ready else "warming up"
@@ -217,12 +289,24 @@ class BotsService(BaseService):
{"label": "Votes", "value": votes},
]
table = {
"columns": ["Bot", "User", "Persona", "Status", "Posts", "Comments", "Votes", "Calls", "Cost"],
"columns": [
"Bot",
"User",
"Persona",
"Status",
"Posts",
"Comments",
"Votes",
"Calls",
"Cost",
],
"rows": rows,
}
return {"stats": stats, "table": table}
def _sample_cost(self, now: datetime, total_cost: float) -> tuple[float, float, bool]:
def _sample_cost(
self, now: datetime, total_cost: float
) -> tuple[float, float, bool]:
if self._started_at is None:
self._cost_samples.clear()
self._cost_anchor = None