forked from retoor/devplacepy
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:
+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"
|
||||
|
||||
Reference in New Issue
Block a user