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 \n News: { 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 \n Replying 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 } \n Text: \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 \n Earlier 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 } \n Language: { 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 } \n Description: { description } \n Language: { language } " ,
temperature = 0.4 ,
) ) [ : 4000 ]
return title , description , language , code