fix: normalize unicode escape sequences and reformat multi-line expressions across codebase
This commit is contained in:
+321
-102
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user