This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import json
import logging
import random
import re
import time
import urllib.error
import urllib.request
from devplacepy.services.bot.config import CATEGORIES, GIST_LANGUAGES
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):
if not api_key:
raise RuntimeError("LLM API key not set")
self.api_key = api_key
self.api_url = api_url
self.model = model
self.input_cost_per_1m = input_cost_per_1m
self.output_cost_per_1m = output_cost_per_1m
self.total_cost = 0.0
self.total_calls = 0
self.total_in_tokens = 0
self.total_out_tokens = 0
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
for attempt in range(3):
try:
payload = {
"model": self.model,
"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]))
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}"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
raw = resp.read()
logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
result = json.loads(raw)
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)
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:
logger.warning("LLM call attempt %d/3 failed: %s", attempt + 1, e)
if attempt == 2:
raise
time.sleep(2 ** attempt)
return ""
@staticmethod
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 = re.sub(r"\s{2,}", " ", text)
return text.strip()
@staticmethod
def strip_md(text: str) -> str:
return LLMClient.clean(text)[:200]
@staticmethod
def strip_code_fences(text: str) -> str:
text = text.strip()
if text.startswith("```"):
lines = text.split("\n")
if lines and lines[0].lstrip().startswith("```"):
lines = lines[1:]
if lines and lines[-1].strip().startswith("```"):
lines = lines[:-1]
text = "\n".join(lines)
return text.strip()
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.",
"hobbyist_maker": "Be casual and friendly. Mention side projects and tinkering. Use *italics* for tools/libraries.",
"academic_type": "Be precise and structured. Use *italics* for terminology. Well thought out arguments.",
"storyteller": "Use **bold** for key points. Write anecdotal, narrative style. Longer flowing sentences.",
"rebel": "Be informal. Skip punctuation sometimes. Use slang. Type like you're in a hurry.",
"mentor": "Be helpful and explanatory. Use **bold** for key takeaways. Include practical advice.",
"minimalist": "One paragraph. Short blunt declarative sentences. No markdown. No fluff.",
}
category_extras = {
"devlog": "Write it as a personal devlog entry - share your learning process and insights about this topic.",
"showcase": "Write it as a showcase - express genuine excitement about what makes this impressive.",
"question": "Write it as a discussion starter - ask thoughtful questions, invite others to share perspectives.",
"rant": "Write it as an opinionated rant - strong viewpoint, passionate criticism, but keep it substantive.",
"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."
category_extra = f" {category_extras.get(category, '')}" if category else ""
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. "
f"Add your own value: a concrete opinion, an implication, a personal experience, or a pointed question. "
f"Reference a specific detail rather than restating the headline. No em dashes. Under 300 words.",
f"News: {title}\n\n{desc[:800]}",
)
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,
)
for c in CATEGORIES:
if c in text.lower():
return c
return "random"
def select_reaction(self, content_snippet: str, persona: str = "") -> str:
from devplacepy.constants import REACTION_EMOJI
flavor = {
"enthusiastic_junior": "You react readily and warmly.",
"grumpy_senior": "You react rarely, only to genuinely notable content.",
"hobbyist_maker": "You react to anything hands-on, clever, or fun.",
"academic_type": "You react only to substantive, rigorous content.",
"minimalist": "You react very sparingly.",
"storyteller": "You react to anything with a human angle.",
"rebel": "You react to bold or contrarian takes.",
"mentor": "You react supportively to effort and learning.",
}.get(persona, "")
options = " ".join(REACTION_EMOJI)
verdict = self._call(
"You are a developer browsing a community feed. Decide whether a post deserves an "
"emoji reaction and which single emoji best fits its content and sentiment. "
f"{flavor} Reply with EXACTLY one of these emojis: {options} "
"or the word NONE if the content is mundane or would not move you to react. "
"Output only the emoji or NONE, nothing else.",
f"Post:\n{content_snippet[:1200]}",
temperature=0.4,
)
text = verdict or ""
for emoji in REACTION_EMOJI:
if emoji in text or emoji.replace("\ufe0f", "") in text:
return emoji
return ""
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.",
"rebel": "Super casual. Skip caps sometimes. Short.",
"mentor": "Be helpful. Use **bold** for key point.",
"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."
preserve = persona in ("enthusiastic_junior", "mentor", "storyteller")
mention_rule = ""
if mention_target:
mention_rule = (
f" Address @{mention_target} naturally in the first sentence "
f"(weave the @{mention_target} mention inline, do not append it at the end)."
)
ctx = ""
if parent_context:
ctx = f"\n\nReplying to this comment:\n{parent_context[:800]}"
system = (
f"You are a dev replying to a post. Write 1-3 short sentences.{extra}{mention_rule} "
"No em dashes. Reference a specific detail from the post. "
"Do not open with or rely on generic filler like 'great point', 'I agree', 'nice', "
"'thanks for sharing', or 'interesting'. Add something real: a concrete experience, "
"a caveat, a counterexample, respectful disagreement, or a pointed question. "
"Never just paraphrase or agree blandly."
)
reply = self.clean(
self._call(system, f"Post:\n{post_snippet[:1500]}{ctx}").strip(),
preserve_md=preserve,
)
return reply[:2000]
@staticmethod
def _overlap_ratio(text: str, context: str) -> float:
def tokens(s: str) -> list[str]:
return [w for w in re.findall(r"[a-z0-9]+", s.lower()) if len(w) > 3]
candidate = tokens(text)
source = set(tokens(context))
if not candidate or not source:
return 0.0
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]:
from devplacepy.services.bot.config import (
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:
return False, f"too short ({len(stripped)} < {min_len})"
lowered = stripped.lower()
if kind == "comment":
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:
return False, "restates the source"
verdict = self._call(
"You are a strict content quality reviewer for a developer community. "
"Reject text that is generic, low-effort, pure filler, or merely summarizes its "
"source without adding an opinion, experience, or insight. "
"Reply with exactly PASS or 'FAIL: <short reason>'.",
f"Type: {kind}\nText:\n{stripped[:1200]}",
temperature=0.0,
)
if verdict.strip().upper().startswith("PASS"):
return True, "ok"
return False, verdict.strip()[:120] or "judge rejected"
def generate_bug(self, topic: str) -> tuple[str, str]:
text = self._call(
"Write a bug report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting a bug. No em dashes.",
f"Bug: {topic}",
)
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())
return title, desc[:2000] or text[:500]
def generate_bio(self) -> str:
return self._call(
"Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. No em dashes.",
"Bio:",
)
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]
slug = re.sub(r"[^a-z0-9_-]", "", handle.lower()) or "dev"
git_link = f"https://github.com/{slug}"
website = f"https://{slug}.dev"
return location, git_link, website
def generate_dm(self, persona: str = "", context: str = "") -> str:
extra = {
"enthusiastic_junior": "Be friendly and excited.",
"grumpy_senior": "Be blunt but not rude.",
"rebel": "Be super casual.",
"mentor": "Be warm and encouraging.",
"storyteller": "Open with a small hook.",
"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]
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_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_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]
return title, description, language, code