feat: add /stop and /reset chat commands and bots internals docs section
Add two new chat commands (`/stop` and `/reset`) to the Devii WebSocket handler, enabling users to stop or reset a session via text input. Introduce a new "Bots internals" documentation section with six prose pages covering architecture, personas, content generation, engagement, realism, and configuration for the autonomous bot fleet. Extend the bot service with article scoring, category picking, configurable pause/break timing, and a `gist_min_lines` parameter for LLM client initialization.
This commit is contained in:
+161
-31
@@ -19,6 +19,8 @@ from devplacepy.services.bot.config import (
|
||||
REACT_RATE_DEFAULT,
|
||||
REACT_RATES,
|
||||
SEARCH_TERMS,
|
||||
persona_article_score,
|
||||
pick_category,
|
||||
)
|
||||
from devplacepy.services.bot.llm import LLMClient
|
||||
from devplacepy.services.bot.news_fetcher import NewsFetcher
|
||||
@@ -44,16 +46,22 @@ class DevPlaceBot:
|
||||
cfg.model,
|
||||
cfg.input_cost_per_1m,
|
||||
cfg.output_cost_per_1m,
|
||||
gist_min_lines=cfg.gist_min_lines,
|
||||
)
|
||||
self.llm.total_cost = self.state.total_cost
|
||||
self.llm.total_calls = self.state.total_calls
|
||||
self.llm.total_in_tokens = self.state.total_in_tokens
|
||||
self.llm.total_out_tokens = self.state.total_out_tokens
|
||||
self.news = NewsFetcher(cfg.news_api)
|
||||
self.registry = ArticleRegistry(cfg.registry_path)
|
||||
self.registry = ArticleRegistry(
|
||||
cfg.registry_path, cfg.max_per_article, cfg.article_ttl_days
|
||||
)
|
||||
self._pause_min = min(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._pause_max = max(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._break_scale = max(0.1, cfg.break_scale)
|
||||
self.faker = Faker()
|
||||
self.b = BotBrowser(headless=cfg.headless)
|
||||
self._post_cache: list[tuple[str, str, str, str]] = []
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
self._project_cache: list[tuple[str, str]] = []
|
||||
self._bug_cache: list[tuple[str, str]] = []
|
||||
self._session_start_cost: float = 0.0
|
||||
@@ -131,15 +139,31 @@ class DevPlaceBot:
|
||||
f"[{self._identity()}] session start mood={mood} target~{session_len} actions"
|
||||
)
|
||||
|
||||
def _pick_article(self, articles: list[dict]) -> Optional[dict]:
|
||||
if not articles:
|
||||
return None
|
||||
ranked = sorted(
|
||||
articles,
|
||||
key=lambda a: persona_article_score(a, self.state.persona)
|
||||
+ random.random(),
|
||||
reverse=True,
|
||||
)
|
||||
top = ranked[: max(3, len(ranked) // 3)]
|
||||
return random.choice(top)
|
||||
|
||||
async def _refill_cache_async(self, kind: str) -> None:
|
||||
try:
|
||||
if kind == "post":
|
||||
articles = self.news.fetch()
|
||||
if articles:
|
||||
a = random.choice(articles)
|
||||
a = self._pick_article(articles)
|
||||
if a:
|
||||
desc = a.get("description", "")[:500]
|
||||
cat = await asyncio.to_thread(
|
||||
self.llm.select_category, a["title"], desc[:600]
|
||||
cat = pick_category(self.state.persona)
|
||||
post_title = await asyncio.to_thread(
|
||||
self.llm.generate_post_title,
|
||||
a["title"],
|
||||
self.state.persona,
|
||||
cat,
|
||||
)
|
||||
content = await asyncio.to_thread(
|
||||
self.llm.generate_post,
|
||||
@@ -148,10 +172,16 @@ class DevPlaceBot:
|
||||
self.state.persona,
|
||||
cat,
|
||||
)
|
||||
self._post_cache.append((a["title"], desc, content, cat))
|
||||
self._post_cache.append(
|
||||
(a["title"], post_title or "", desc, content, cat)
|
||||
)
|
||||
elif kind == "project":
|
||||
t = (await asyncio.to_thread(self.llm.generate_project_title))[:200]
|
||||
desc = await asyncio.to_thread(self.llm.generate_project_desc, t)
|
||||
t = await asyncio.to_thread(
|
||||
self.llm.generate_project_title, self.state.persona
|
||||
)
|
||||
desc = await asyncio.to_thread(
|
||||
self.llm.generate_project_desc, t, self.state.persona
|
||||
)
|
||||
self._project_cache.append((t, desc))
|
||||
elif kind == "bug":
|
||||
topic = random.choice(["UI glitch", "Broken link", "Performance issue"])
|
||||
@@ -951,14 +981,27 @@ class DevPlaceBot:
|
||||
self._log("Create gist: modal not found")
|
||||
return False
|
||||
|
||||
generated = await self._generate(self.llm.generate_gist, self.state.persona)
|
||||
generated = None
|
||||
for attempt in range(2):
|
||||
candidate = await self._generate(self.llm.generate_gist, self.state.persona)
|
||||
if not candidate:
|
||||
continue
|
||||
cand_title, cand_desc, cand_lang, cand_code = candidate
|
||||
if not cand_code.strip():
|
||||
continue
|
||||
verdict = await self._generate(
|
||||
self.llm.gist_quality_check, cand_title, cand_code, cand_lang
|
||||
)
|
||||
if verdict is None or verdict[0]:
|
||||
generated = candidate
|
||||
break
|
||||
self.state.quality_rejections += 1
|
||||
action = "regenerating" if attempt == 0 else "skipping"
|
||||
self._log(f"Gist quality reject ({verdict[1]}), {action}")
|
||||
if not generated:
|
||||
self._log("Create gist: generation failed")
|
||||
self._log("Create gist: no quality candidate")
|
||||
return False
|
||||
title, desc, language, code = generated
|
||||
if not code.strip():
|
||||
self._log("Create gist: empty code generated")
|
||||
return False
|
||||
|
||||
title = self.llm.strip_md(title)[:200] or title[:200]
|
||||
if not await b.fill("#gist-title", title):
|
||||
@@ -1038,33 +1081,43 @@ class DevPlaceBot:
|
||||
|
||||
article_title = ""
|
||||
article_desc = ""
|
||||
post_title = ""
|
||||
|
||||
if self._post_cache:
|
||||
article_title, article_desc, content, topic = self._post_cache.pop(0)
|
||||
article_title, post_title, article_desc, content, topic = (
|
||||
self._post_cache.pop(0)
|
||||
)
|
||||
asyncio.create_task(self._refill_cache_async("post"))
|
||||
else:
|
||||
articles = self.news.fetch()
|
||||
topic = pick_category(self.state.persona)
|
||||
article = self.registry.reserve_unused(
|
||||
articles, self.state.username, self.llm.clean
|
||||
articles,
|
||||
self.state.username,
|
||||
self.llm.clean,
|
||||
topic,
|
||||
self.state.persona,
|
||||
)
|
||||
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]
|
||||
post_title = await self._generate(
|
||||
self.llm.generate_post_title,
|
||||
article["title"],
|
||||
self.state.persona,
|
||||
topic,
|
||||
)
|
||||
content = await self._generate(
|
||||
self.llm.generate_post,
|
||||
article["title"],
|
||||
desc,
|
||||
self.state.persona,
|
||||
topic or "random",
|
||||
topic,
|
||||
)
|
||||
if not content:
|
||||
self._log("Create post: generation failed/empty")
|
||||
return False
|
||||
topic = topic or "random"
|
||||
article_title = article["title"]
|
||||
article_desc = desc
|
||||
|
||||
@@ -1099,12 +1152,16 @@ 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):
|
||||
if not self.registry.reserve(clean_title, self.state.username, topic):
|
||||
self._log(
|
||||
f"Create post: article '{clean_title[:50]}...' already posted by another bot, skipping"
|
||||
f"Create post: article '{clean_title[:50]}...' already covered, skipping"
|
||||
)
|
||||
return False
|
||||
|
||||
type_title = self.llm.strip_md(post_title)[:120] if post_title else ""
|
||||
if not type_title:
|
||||
type_title = clean_title
|
||||
|
||||
ts = f"input[name='topic'][value='{topic}']"
|
||||
if await b.page.locator(ts).count() == 0:
|
||||
self._log(f"Create post: topic radio '{topic}' not found")
|
||||
@@ -1112,8 +1169,8 @@ class DevPlaceBot:
|
||||
await b.click(ts)
|
||||
await b._idle(0.2, 0.5)
|
||||
|
||||
if clean_title:
|
||||
ok = await b.fill("input[name='title']", clean_title)
|
||||
if type_title:
|
||||
ok = await b.fill("input[name='title']", type_title)
|
||||
if not ok:
|
||||
self._log("Create post: title input not found")
|
||||
await b._idle(0.2, 0.4)
|
||||
@@ -1150,7 +1207,7 @@ class DevPlaceBot:
|
||||
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:]}",
|
||||
f"{topic} [{self.state.created_posts}]: {type_title[:50] or content[:50]} -> {post_url[-45:]}",
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -1273,18 +1330,22 @@ class DevPlaceBot:
|
||||
raw_title, desc = self._project_cache.pop(0)
|
||||
asyncio.create_task(self._refill_cache_async("project"))
|
||||
else:
|
||||
raw_title = await self._generate(self.llm.generate_project_title)
|
||||
raw_title = await self._generate(
|
||||
self.llm.generate_project_title, self.state.persona
|
||||
)
|
||||
if not raw_title:
|
||||
self._log("Create project: title generation failed")
|
||||
return False
|
||||
raw_title = raw_title[:200]
|
||||
desc = await self._generate(self.llm.generate_project_desc, raw_title)
|
||||
desc = await self._generate(
|
||||
self.llm.generate_project_desc, raw_title, self.state.persona
|
||||
)
|
||||
if not desc:
|
||||
self._log("Create project: description generation failed")
|
||||
return False
|
||||
desc = desc[:5000]
|
||||
|
||||
title = self.llm.strip_md(raw_title)[:200] or raw_title[:200]
|
||||
title = self.llm.strip_md(raw_title)[:120] or raw_title[:120]
|
||||
types = ["game", "software", "mobile_app", "website", "game_asset"]
|
||||
ptype = random.choice(types)
|
||||
status = random.choice(PROJECT_STATUSES)
|
||||
@@ -1817,6 +1878,73 @@ class DevPlaceBot:
|
||||
await self.b._idle(0.8, 1.8)
|
||||
return True
|
||||
|
||||
async def _engage_community(self) -> bool:
|
||||
b = self.b
|
||||
tab = random.choice(["recent", "recent", "trending"])
|
||||
await b.goto(f"{self.base_url}/feed?tab={tab}")
|
||||
await b._idle(0.8, 1.8)
|
||||
links = b.page.locator("a[href*='/posts/']")
|
||||
count = await links.count()
|
||||
if count == 0:
|
||||
return False
|
||||
own = set(self.state.own_post_urls)
|
||||
known = {u.lower() for u in self.state.known_users if u}
|
||||
me = self.state.username.lower()
|
||||
ranked: list[tuple[float, int, str]] = []
|
||||
for i in range(min(count, 30)):
|
||||
link = links.nth(i)
|
||||
href = await link.get_attribute("href") or ""
|
||||
if "/posts/" not in href or len(href) < 20:
|
||||
continue
|
||||
if any(href in u for u in own):
|
||||
continue
|
||||
score = random.random()
|
||||
try:
|
||||
card = link.locator(
|
||||
"xpath=ancestor::*[contains(@class,'post-card') or self::article][1]"
|
||||
).first
|
||||
if await card.count() > 0:
|
||||
author = card.locator("a[href*='/profile/']").first
|
||||
if await author.count() > 0:
|
||||
ah = await author.get_attribute("href") or ""
|
||||
uname = (
|
||||
ah.split("/profile/")[-1].split("?")[0].split("/")[0].lower()
|
||||
)
|
||||
if uname and uname == me:
|
||||
continue
|
||||
if uname in known:
|
||||
score += 2.0
|
||||
except Exception:
|
||||
pass
|
||||
ranked.append((score, i, href))
|
||||
if not ranked:
|
||||
return False
|
||||
ranked.sort(key=lambda r: r[0], reverse=True)
|
||||
idx, href = ranked[0][1], ranked[0][2]
|
||||
try:
|
||||
await links.nth(idx).click(timeout=5000)
|
||||
except Exception as e:
|
||||
logger.debug("_engage_community open failed: %s", e)
|
||||
return False
|
||||
await b._idle(1.0, 2.0)
|
||||
if href and href not in self.state.known_posts:
|
||||
self.state.known_posts.append(href)
|
||||
await self._read_like_human(3, 60)
|
||||
acted = False
|
||||
if await self._comment_on_post():
|
||||
acted = True
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.6 and await self._reply_to_random_comment():
|
||||
acted = True
|
||||
await b._idle(0.4, 1.2)
|
||||
if random.random() < 0.7:
|
||||
await self._vote_on_feed()
|
||||
if random.random() < 0.5:
|
||||
await self._react_to_object()
|
||||
if acted:
|
||||
self._action("ENGAGE", f"community thread {href[-40:]}")
|
||||
return acted
|
||||
|
||||
async def _cycle(
|
||||
self,
|
||||
mood: str = "normal",
|
||||
@@ -1884,7 +2012,7 @@ class DevPlaceBot:
|
||||
if await self._comment_on_post():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
if random.random() < 0.35:
|
||||
if random.random() < 0.5:
|
||||
if await self._reply_to_random_comment():
|
||||
actions += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
@@ -2143,6 +2271,8 @@ class DevPlaceBot:
|
||||
await b._idle(0.5, 1.5)
|
||||
await self._check_notifications()
|
||||
await b._idle(1.0, 3.0)
|
||||
await self._engage_community()
|
||||
await b._idle(0.8, 2.0)
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
|
||||
session_actions = 0
|
||||
@@ -2201,7 +2331,7 @@ class DevPlaceBot:
|
||||
self._log(f"Fatigue ({fatigue}): ending session")
|
||||
break
|
||||
|
||||
intra_pause = random.randint(5, 30)
|
||||
intra_pause = random.randint(self._pause_min, self._pause_max)
|
||||
self._log(
|
||||
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
|
||||
)
|
||||
@@ -2237,7 +2367,7 @@ class DevPlaceBot:
|
||||
break_s = random.randint(30, 120)
|
||||
self._log(f"Recovery wait {break_s}s")
|
||||
else:
|
||||
break_s = random.randint(*break_range)
|
||||
break_s = max(5, int(random.randint(*break_range) * self._break_scale))
|
||||
break_h = break_s / 3600
|
||||
self._log(f"Session done. Break {break_h:.1f}h")
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL
|
||||
@@ -17,6 +18,11 @@ ARTICLE_REGISTRY_PATH = Path.home() / ".dpbot_article_registry.json"
|
||||
|
||||
PROJECT_STATUSES = ["In Development", "Released"]
|
||||
|
||||
ARTICLE_TTL_DAYS = 7
|
||||
ACTION_PAUSE_MIN_SECONDS = 5
|
||||
ACTION_PAUSE_MAX_SECONDS = 30
|
||||
BREAK_SCALE_DEFAULT = 1.0
|
||||
|
||||
PERSONAS = [
|
||||
"enthusiastic_junior",
|
||||
"grumpy_senior",
|
||||
@@ -103,3 +109,142 @@ GENERIC_COMMENT_PHRASES = [
|
||||
"this is great",
|
||||
"good read",
|
||||
]
|
||||
|
||||
GIST_MIN_LINES = 6
|
||||
|
||||
TRIVIAL_GIST_TERMS = [
|
||||
"ternary",
|
||||
"hello world",
|
||||
"hello, world",
|
||||
"fizzbuzz",
|
||||
"fizz buzz",
|
||||
"delete pointer",
|
||||
"free pointer",
|
||||
"getter",
|
||||
"setter",
|
||||
"increment",
|
||||
"decrement",
|
||||
"swap two",
|
||||
"swap variables",
|
||||
"is even",
|
||||
"is odd",
|
||||
"reverse a string",
|
||||
"reverse string",
|
||||
"print hello",
|
||||
"for loop",
|
||||
"while loop",
|
||||
"basic loop",
|
||||
"add two numbers",
|
||||
"sum two",
|
||||
"concatenate strings",
|
||||
"string concat",
|
||||
"variable declaration",
|
||||
"comment example",
|
||||
]
|
||||
|
||||
PERSONA_LANGUAGES = {
|
||||
"enthusiastic_junior": ["python", "javascript", "typescript", "html", "css"],
|
||||
"grumpy_senior": ["c", "cpp", "rust", "sql", "bash"],
|
||||
"hobbyist_maker": ["python", "cpp", "lua", "javascript", "bash"],
|
||||
"academic_type": ["rust", "python", "go", "sql", "cpp"],
|
||||
"minimalist": ["go", "c", "bash", "lua"],
|
||||
"storyteller": ["python", "ruby", "javascript", "sql"],
|
||||
"rebel": ["rust", "go", "typescript", "bash"],
|
||||
"mentor": ["python", "java", "typescript", "sql"],
|
||||
}
|
||||
|
||||
PERSONA_GIST_FLAVOR = {
|
||||
"enthusiastic_junior": "a handy helper you recently learned and were glad to discover",
|
||||
"grumpy_senior": "a battle-tested utility that sidesteps a common footgun",
|
||||
"hobbyist_maker": "a fun, hands-on snippet for a side project or hardware tinkering",
|
||||
"academic_type": "an elegant algorithm or data-structure trick",
|
||||
"minimalist": "a tiny, sharp, dependency-free utility",
|
||||
"storyteller": "a snippet born from a real debugging or production story",
|
||||
"rebel": "an unconventional approach that beats the obvious one",
|
||||
"mentor": "a clear, reusable helper worth teaching to a junior",
|
||||
}
|
||||
|
||||
PERSONA_CATEGORY_WEIGHTS = {
|
||||
"enthusiastic_junior": {
|
||||
"showcase": 3,
|
||||
"question": 3,
|
||||
"devlog": 2,
|
||||
"fun": 2,
|
||||
"random": 1,
|
||||
"rant": 1,
|
||||
},
|
||||
"grumpy_senior": {
|
||||
"rant": 4,
|
||||
"devlog": 2,
|
||||
"random": 2,
|
||||
"question": 1,
|
||||
"showcase": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
"hobbyist_maker": {
|
||||
"showcase": 3,
|
||||
"devlog": 3,
|
||||
"fun": 2,
|
||||
"random": 2,
|
||||
"question": 1,
|
||||
"rant": 1,
|
||||
},
|
||||
"academic_type": {
|
||||
"devlog": 3,
|
||||
"question": 3,
|
||||
"random": 2,
|
||||
"showcase": 1,
|
||||
"rant": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
"minimalist": {
|
||||
"random": 3,
|
||||
"rant": 2,
|
||||
"devlog": 2,
|
||||
"showcase": 1,
|
||||
"question": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
"storyteller": {
|
||||
"devlog": 4,
|
||||
"showcase": 2,
|
||||
"fun": 2,
|
||||
"random": 2,
|
||||
"question": 1,
|
||||
"rant": 1,
|
||||
},
|
||||
"rebel": {
|
||||
"rant": 4,
|
||||
"fun": 2,
|
||||
"question": 2,
|
||||
"random": 1,
|
||||
"showcase": 1,
|
||||
"devlog": 1,
|
||||
},
|
||||
"mentor": {
|
||||
"devlog": 3,
|
||||
"question": 3,
|
||||
"showcase": 2,
|
||||
"random": 1,
|
||||
"rant": 1,
|
||||
"fun": 1,
|
||||
},
|
||||
}
|
||||
|
||||
MAX_BOTS_PER_ARTICLE = 2
|
||||
|
||||
|
||||
def pick_category(persona: str) -> str:
|
||||
weights = PERSONA_CATEGORY_WEIGHTS.get(persona)
|
||||
if not weights:
|
||||
return random.choice(CATEGORIES)
|
||||
population = list(weights.keys())
|
||||
return random.choices(population, weights=list(weights.values()), k=1)[0]
|
||||
|
||||
|
||||
def persona_article_score(article: dict, persona: str) -> float:
|
||||
terms = SEARCH_TERMS.get(persona)
|
||||
if not terms:
|
||||
return 0.0
|
||||
haystack = f"{article.get('title', '')} {article.get('description', '')}".lower()
|
||||
return float(sum(1 for term in terms if term.lower() in haystack))
|
||||
|
||||
+137
-37
@@ -6,7 +6,13 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from devplacepy.services.bot.config import CATEGORIES, GIST_LANGUAGES
|
||||
from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
GIST_MIN_LINES,
|
||||
PERSONA_GIST_FLAVOR,
|
||||
PERSONA_LANGUAGES,
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,6 +25,7 @@ class LLMClient:
|
||||
model: str,
|
||||
input_cost_per_1m: float,
|
||||
output_cost_per_1m: float,
|
||||
gist_min_lines: int = GIST_MIN_LINES,
|
||||
):
|
||||
if not api_key:
|
||||
raise RuntimeError("LLM API key not set")
|
||||
@@ -27,6 +34,7 @@ class LLMClient:
|
||||
self.model = model
|
||||
self.input_cost_per_1m = input_cost_per_1m
|
||||
self.output_cost_per_1m = output_cost_per_1m
|
||||
self.gist_min_lines = max(1, gist_min_lines)
|
||||
self.total_cost = 0.0
|
||||
self.total_calls = 0
|
||||
self.total_in_tokens = 0
|
||||
@@ -89,7 +97,7 @@ class LLMClient:
|
||||
def clean(text: str, preserve_md: bool = False) -> str:
|
||||
if not preserve_md:
|
||||
text = re.sub(r"\*\*|__|\*|_", "", text)
|
||||
text = text.replace("-", "-").replace("–", "-")
|
||||
text = text.replace("—", "-").replace("–", "-")
|
||||
text = text.replace("‘", "'").replace("’", "'")
|
||||
text = text.replace("“", '"').replace("”", '"')
|
||||
text = re.sub(r"\s{2,}", " ", text)
|
||||
@@ -99,6 +107,36 @@ class LLMClient:
|
||||
def strip_md(text: str) -> str:
|
||||
return LLMClient.clean(text)[:200]
|
||||
|
||||
@staticmethod
|
||||
def strip_label(text: str) -> str:
|
||||
labels = (
|
||||
"post title",
|
||||
"project name",
|
||||
"snippet name",
|
||||
"title",
|
||||
"name",
|
||||
"concept",
|
||||
"snippet",
|
||||
"headline",
|
||||
"gist",
|
||||
"bug",
|
||||
)
|
||||
result = (text or "").strip().strip('"').strip("'").strip()
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
lowered = result.lower()
|
||||
for label in labels:
|
||||
if lowered.startswith(label + ":"):
|
||||
result = result[len(label) + 1 :].strip().strip('"').strip("'")
|
||||
changed = True
|
||||
break
|
||||
for sep in (" concept:", " description:", " desc:", " idea:"):
|
||||
idx = result.lower().find(sep)
|
||||
if idx > 0:
|
||||
result = result[:idx].strip()
|
||||
return result.strip().strip('"').strip("'").strip()
|
||||
|
||||
@staticmethod
|
||||
def strip_code_fences(text: str) -> str:
|
||||
text = text.strip()
|
||||
@@ -154,16 +192,33 @@ class LLMClient:
|
||||
)
|
||||
return self.clean(text, preserve_md=preserve)
|
||||
|
||||
def select_category(self, title: str, desc: str) -> str:
|
||||
text = self._call(
|
||||
"You are a classifier. Given tech news, choose the single best post category. Reply with ONLY the category name.",
|
||||
f"Categories:\n- devlog: learning journey, building something, personal dev experience\n- showcase: impressive release, new tool, achievement worth highlighting\n- question: uncertainty, asking for advice, curiosity about implications\n- rant: frustration, bad practices, criticism of industry trends\n- fun: amusing, surprising, entertaining but not deeply serious\n- random: anything else that doesn't clearly fit above\n\nNews: {title}\n\n{desc[:600]}",
|
||||
temperature=0.2,
|
||||
def generate_post_title(
|
||||
self, headline: str, persona: str = "", category: str = ""
|
||||
) -> str:
|
||||
style = {
|
||||
"enthusiastic_junior": "Sound excited and curious.",
|
||||
"grumpy_senior": "Sound blunt and a little skeptical.",
|
||||
"hobbyist_maker": "Sound casual and hands-on.",
|
||||
"academic_type": "Sound precise and measured.",
|
||||
"minimalist": "Keep it plain and very short.",
|
||||
"storyteller": "Hint at a story or an angle.",
|
||||
"rebel": "Sound provocative or contrarian.",
|
||||
"mentor": "Sound thoughtful and constructive.",
|
||||
}.get(persona, "")
|
||||
title = self.strip_label(
|
||||
self.clean(
|
||||
self._call(
|
||||
"You are a developer writing the title of a community forum post reacting to tech news. "
|
||||
"Write a natural, human title of 3 to 8 words in your own voice. "
|
||||
"Do not copy or paraphrase the full headline and do not restate every detail. "
|
||||
"No trailing punctuation unless it is a genuine question. No quotes. No label prefix. "
|
||||
f"No em dashes. {style}",
|
||||
f"Headline: {headline}\nYour post title:",
|
||||
temperature=0.8,
|
||||
)
|
||||
)
|
||||
)
|
||||
for c in CATEGORIES:
|
||||
if c in text.lower():
|
||||
return c
|
||||
return "random"
|
||||
return title[:120]
|
||||
|
||||
def select_reaction(self, content_snippet: str, persona: str = "") -> str:
|
||||
from devplacepy.constants import REACTION_EMOJI
|
||||
@@ -334,40 +389,85 @@ class LLMClient:
|
||||
)
|
||||
)[: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,
|
||||
)
|
||||
def generate_project_title(self, persona: str = "") -> str:
|
||||
return self.strip_label(
|
||||
self.clean(
|
||||
self._call(
|
||||
"Invent a short, catchy project name. 2 to 4 words. A tool, game, or app. "
|
||||
"Output only the name. No label, no colon, no description, no quotes. No em dashes.",
|
||||
"Project name:",
|
||||
temperature=0.9,
|
||||
)
|
||||
)
|
||||
)[:80]
|
||||
|
||||
def generate_project_desc(self, title: str) -> str:
|
||||
return self._call(
|
||||
"You are a developer. Write a compelling 2-3 sentence project description including tech stack and purpose.",
|
||||
f"Project: {title}",
|
||||
)
|
||||
def generate_project_desc(self, title: str, persona: str = "") -> str:
|
||||
flavor = {
|
||||
"grumpy_senior": "Keep it dry and matter of fact.",
|
||||
"academic_type": "Be precise about the approach and trade-offs.",
|
||||
"minimalist": "Two short sentences, no filler.",
|
||||
"rebel": "Be bold about why the usual approach is wrong.",
|
||||
"storyteller": "Open with the motivation behind it.",
|
||||
}.get(persona, "")
|
||||
return self.clean(
|
||||
self._call(
|
||||
"You are a developer. Write a compelling 2 to 3 sentence project description "
|
||||
f"including the tech stack and purpose. {flavor} No em dashes.",
|
||||
f"Project: {title}",
|
||||
)
|
||||
)[:5000]
|
||||
|
||||
def generate_gist(self, persona: str = "") -> tuple[str, str, str, str]:
|
||||
language = random.choice(GIST_LANGUAGES)
|
||||
title = self.clean(
|
||||
languages = PERSONA_LANGUAGES.get(persona) or GIST_LANGUAGES
|
||||
language = random.choice(languages)
|
||||
flavor = PERSONA_GIST_FLAVOR.get(persona, "a genuinely useful utility")
|
||||
code = self.strip_code_fences(
|
||||
self._call(
|
||||
"Name a short, useful code snippet. 2-5 words. No quotes. No markdown.",
|
||||
f"Language: {language}. Snippet name:",
|
||||
temperature=0.9,
|
||||
f"Write a short, correct, self-contained {language} snippet of 8 to 20 lines. "
|
||||
f"It should be {flavor}. Make it non-trivial and genuinely useful: no hello world, "
|
||||
"no bare language-feature demo, no textbook 101 example, no trivial one-liner, "
|
||||
"no basic getter or setter. Output ONLY raw code. No markdown fences. No commentary.",
|
||||
f"Language: {language}. Write the snippet:",
|
||||
temperature=0.7,
|
||||
)
|
||||
)[:4000]
|
||||
title = self.strip_label(
|
||||
self.clean(
|
||||
self._call(
|
||||
"Name this code snippet in 2 to 5 words, like a developer titling a gist. "
|
||||
"No quotes. No markdown. No label prefix. No em dashes.",
|
||||
f"Language: {language}\nCode:\n{code[:1200]}\nTitle:",
|
||||
temperature=0.7,
|
||||
)
|
||||
)
|
||||
)[: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}",
|
||||
"Write a one-sentence description of what this snippet does and when it is handy. No em dashes.",
|
||||
f"Title: {title}\nLanguage: {language}\nCode:\n{code[:1200]}",
|
||||
)
|
||||
)[: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
|
||||
|
||||
def gist_quality_check(
|
||||
self, title: str, code: str, language: str
|
||||
) -> tuple[bool, str]:
|
||||
lowered = (title or "").lower()
|
||||
for term in TRIVIAL_GIST_TERMS:
|
||||
if term in lowered:
|
||||
return False, f"trivial topic '{term}'"
|
||||
lines = [ln for ln in (code or "").splitlines() if ln.strip()]
|
||||
if len(lines) < self.gist_min_lines:
|
||||
return False, f"too few lines ({len(lines)} < {self.gist_min_lines})"
|
||||
verdict = self._call(
|
||||
"You are a strict reviewer for a developer community's shared code snippets. "
|
||||
"Reject snippets that are textbook 101 material, trivial one-liners, hello world, "
|
||||
"a bare language-feature demo, or something every developer already knows by heart. "
|
||||
"Accept only snippets an experienced developer would find non-obvious or worth bookmarking. "
|
||||
"Reply with exactly PASS or 'FAIL: <short reason>'.",
|
||||
f"Language: {language}\nTitle: {title}\nCode:\n{code[:1500]}",
|
||||
temperature=0.0,
|
||||
)
|
||||
if verdict.strip().upper().startswith("PASS"):
|
||||
return True, "ok"
|
||||
return False, verdict.strip()[:120] or "judge rejected"
|
||||
|
||||
@@ -7,12 +7,36 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
ARTICLE_TTL_DAYS,
|
||||
MAX_BOTS_PER_ARTICLE,
|
||||
persona_article_score,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArticleRegistry:
|
||||
def __init__(self, path: Path):
|
||||
def __init__(
|
||||
self,
|
||||
path: Path,
|
||||
max_per_article: int = MAX_BOTS_PER_ARTICLE,
|
||||
ttl_days: int = ARTICLE_TTL_DAYS,
|
||||
):
|
||||
self.path = path
|
||||
self.max_per_article = max(1, max_per_article)
|
||||
self.ttl_days = max(1, ttl_days)
|
||||
|
||||
@staticmethod
|
||||
def _normalize(meta: dict) -> dict:
|
||||
if "holders" in meta and isinstance(meta["holders"], list):
|
||||
return meta
|
||||
bot = meta.get("bot", "")
|
||||
time = meta.get("time", "")
|
||||
return {
|
||||
"holders": [{"bot": bot, "category": "", "time": time}],
|
||||
"time": time,
|
||||
}
|
||||
|
||||
def _lock(self) -> tuple:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -40,6 +64,7 @@ class ArticleRegistry:
|
||||
for title, meta in data.items():
|
||||
if not isinstance(meta, dict):
|
||||
continue
|
||||
meta = self._normalize(meta)
|
||||
ts = meta.get("time", "")
|
||||
try:
|
||||
age_days = (
|
||||
@@ -47,7 +72,7 @@ class ArticleRegistry:
|
||||
).total_seconds() / 86400
|
||||
except (ValueError, TypeError):
|
||||
age_days = 0
|
||||
if age_days < 7:
|
||||
if age_days < self.ttl_days:
|
||||
kept[title] = meta
|
||||
return fd, kept
|
||||
except Exception as e:
|
||||
@@ -63,34 +88,78 @@ class ArticleRegistry:
|
||||
except Exception as e:
|
||||
logger.debug("Failed to unlock article registry: %s", e)
|
||||
|
||||
def reserve(self, title: str, owner: str) -> bool:
|
||||
def _admit(self, entry: Optional[dict], owner: str, category: str) -> bool:
|
||||
if entry is None:
|
||||
return True
|
||||
holders = entry.get("holders", [])
|
||||
if any(h.get("bot") == owner for h in holders):
|
||||
return False
|
||||
if len(holders) >= self.max_per_article:
|
||||
return False
|
||||
if category and any(h.get("category") == category for h in holders):
|
||||
return False
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _hold(registry: dict, title: str, owner: str, category: str) -> None:
|
||||
now = datetime.now().isoformat()
|
||||
entry = registry.get(title)
|
||||
if entry is None:
|
||||
registry[title] = {
|
||||
"holders": [{"bot": owner, "category": category, "time": now}],
|
||||
"time": now,
|
||||
}
|
||||
return
|
||||
entry["holders"].append({"bot": owner, "category": category, "time": now})
|
||||
entry["time"] = now
|
||||
|
||||
def reserve(self, title: str, owner: str, category: str = "") -> bool:
|
||||
fd, registry = self._lock()
|
||||
if fd is None:
|
||||
return True
|
||||
try:
|
||||
if title in registry:
|
||||
entry = registry.get(title)
|
||||
if entry is not None and any(
|
||||
h.get("bot") == owner for h in entry.get("holders", [])
|
||||
):
|
||||
return True
|
||||
if not self._admit(entry, owner, category):
|
||||
return False
|
||||
registry[title] = {"bot": owner, "time": datetime.now().isoformat()}
|
||||
self._hold(registry, title, owner, category)
|
||||
self.path.write_text(json.dumps(registry, indent=2, default=str))
|
||||
return True
|
||||
finally:
|
||||
self._unlock(fd)
|
||||
|
||||
def reserve_unused(self, articles: list[dict], owner: str, clean) -> Optional[dict]:
|
||||
def reserve_unused(
|
||||
self,
|
||||
articles: list[dict],
|
||||
owner: str,
|
||||
clean,
|
||||
category: str = "",
|
||||
persona: str = "",
|
||||
) -> Optional[dict]:
|
||||
if not articles:
|
||||
return None
|
||||
fd, registry = self._lock()
|
||||
if fd is None:
|
||||
return random.choice(articles) if articles else None
|
||||
return articles[0]
|
||||
try:
|
||||
used = set(registry.keys())
|
||||
random.shuffle(articles)
|
||||
for a in articles:
|
||||
ranked = sorted(
|
||||
articles,
|
||||
key=lambda a: persona_article_score(a, persona) + random.random(),
|
||||
reverse=True,
|
||||
)
|
||||
for a in ranked:
|
||||
title = clean(a.get("title", "")[:200])
|
||||
if title and title not in used:
|
||||
registry[title] = {"bot": owner, "time": datetime.now().isoformat()}
|
||||
self.path.write_text(json.dumps(registry, indent=2, default=str))
|
||||
return a
|
||||
if not title:
|
||||
continue
|
||||
entry = registry.get(title)
|
||||
if not self._admit(entry, owner, category):
|
||||
continue
|
||||
self._hold(registry, title, owner, category)
|
||||
self.path.write_text(json.dumps(registry, indent=2, default=str))
|
||||
return a
|
||||
return None
|
||||
finally:
|
||||
self._unlock(fd)
|
||||
|
||||
@@ -16,3 +16,9 @@ class BotRuntimeConfig:
|
||||
output_cost_per_1m: float = config.OUTPUT_COST_PER_1M_DEFAULT
|
||||
headless: bool = True
|
||||
registry_path: Path = config.ARTICLE_REGISTRY_PATH
|
||||
max_per_article: int = config.MAX_BOTS_PER_ARTICLE
|
||||
article_ttl_days: int = config.ARTICLE_TTL_DAYS
|
||||
gist_min_lines: int = config.GIST_MIN_LINES
|
||||
action_pause_min: int = config.ACTION_PAUSE_MIN_SECONDS
|
||||
action_pause_max: int = config.ACTION_PAUSE_MAX_SECONDS
|
||||
break_scale: float = config.BREAK_SCALE_DEFAULT
|
||||
|
||||
@@ -107,6 +107,66 @@ class BotsService(BaseService):
|
||||
help="Used to compute live spend per bot and across the fleet.",
|
||||
group="LLM",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_max_per_article",
|
||||
"Bots per article",
|
||||
type="int",
|
||||
default=config.MAX_BOTS_PER_ARTICLE,
|
||||
minimum=1,
|
||||
maximum=5,
|
||||
help="How many bots may post about the same news article, each from a different angle.",
|
||||
group="Behavior",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_article_ttl_days",
|
||||
"Article cooldown (days)",
|
||||
type="int",
|
||||
default=config.ARTICLE_TTL_DAYS,
|
||||
minimum=1,
|
||||
maximum=90,
|
||||
help="How long an article stays covered before it can be posted about again.",
|
||||
group="Behavior",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_gist_min_lines",
|
||||
"Minimum gist lines",
|
||||
type="int",
|
||||
default=config.GIST_MIN_LINES,
|
||||
minimum=1,
|
||||
maximum=50,
|
||||
help="Reject generated code snippets shorter than this many non-empty lines.",
|
||||
group="Behavior",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_action_pause_min_seconds",
|
||||
"Min pause between actions (s)",
|
||||
type="int",
|
||||
default=config.ACTION_PAUSE_MIN_SECONDS,
|
||||
minimum=0,
|
||||
maximum=3600,
|
||||
help="Lower bound of the idle pause a bot takes after each action.",
|
||||
group="Pacing",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_action_pause_max_seconds",
|
||||
"Max pause between actions (s)",
|
||||
type="int",
|
||||
default=config.ACTION_PAUSE_MAX_SECONDS,
|
||||
minimum=1,
|
||||
maximum=7200,
|
||||
help="Upper bound of the idle pause a bot takes after each action.",
|
||||
group="Pacing",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_break_scale",
|
||||
"Break length multiplier",
|
||||
type="float",
|
||||
default=config.BREAK_SCALE_DEFAULT,
|
||||
minimum=0.1,
|
||||
maximum=10.0,
|
||||
help="Scales the between-session break. Below 1 makes the fleet more active (and costlier); above 1 calmer.",
|
||||
group="Pacing",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -183,6 +243,12 @@ class BotsService(BaseService):
|
||||
output_cost_per_1m=cfg["bot_output_cost_per_1m"],
|
||||
headless=cfg["bot_headless"],
|
||||
registry_path=config.ARTICLE_REGISTRY_PATH,
|
||||
max_per_article=cfg["bot_max_per_article"],
|
||||
article_ttl_days=cfg["bot_article_ttl_days"],
|
||||
gist_min_lines=cfg["bot_gist_min_lines"],
|
||||
action_pause_min=cfg["bot_action_pause_min_seconds"],
|
||||
action_pause_max=cfg["bot_action_pause_max_seconds"],
|
||||
break_scale=cfg["bot_break_scale"],
|
||||
)
|
||||
bot = DevPlaceBot(rc, on_event=self.log)
|
||||
task = asyncio.create_task(self._run_slot(slot, bot, cfg["bot_max_actions"]))
|
||||
|
||||
Reference in New Issue
Block a user