forked from retoor/devplacepy
- Add `cmd_isslop_prune`, `cmd_isslop_clear`, `cmd_isslop_analyze` CLI commands for AI usage analysis job management - Register `/game` router with `index` and `farm` endpoints for Code Farm idle game - Add `politics` to allowed TOPICS constant replacing `signals` - Introduce `ISSLOP_DIR`, `ISSLOP_WORKSPACES_DIR`, `ISSLOP_RUNS_DIR`, `ISSLOP_MEDIA_DIR` config paths - Add `clear_user_stars` and `clear_user_projects_cache` calls on vote and project create/delete - Update `make prod` to use `nproc` workers via `DEVPLACE_WEB_WORKERS` env var - Convert `database.py` and `utils.py` to packages for modular structure - Add `devplace apikey` and `devplace token` CLI subcommands for API key and access token management
797 lines
35 KiB
Python
797 lines
35 KiB
Python
# retoor <retoor@molodetz.nl>
|
||
|
||
import json
|
||
import logging
|
||
import random
|
||
import re
|
||
import time
|
||
from typing import Any, Optional
|
||
|
||
import httpx
|
||
|
||
from devplacepy import stealth
|
||
from devplacepy.services.bot.config import (
|
||
GIST_LANGUAGES,
|
||
GIST_MIN_LINES,
|
||
PERSONA_GIST_FLAVOR,
|
||
PERSONA_LANGUAGES,
|
||
SEARCH_TERMS,
|
||
TRIVIAL_GIST_TERMS,
|
||
)
|
||
from devplacepy.services.bot.handles import MAX_HANDLE_LEN, sanitize_handle
|
||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||
|
||
HANDLE_CANDIDATE_TARGET = 8
|
||
|
||
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,
|
||
gist_min_lines: int = GIST_MIN_LINES,
|
||
):
|
||
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.gist_min_lines = max(1, gist_min_lines)
|
||
self.total_cost = 0.0
|
||
self.total_calls = 0
|
||
self.total_in_tokens = 0
|
||
self.total_out_tokens = 0
|
||
|
||
def _raw_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]),
|
||
)
|
||
with stealth.stealth_sync_client(timeout=30) as client:
|
||
resp = client.post(
|
||
self.api_url,
|
||
json=payload,
|
||
headers={"Authorization": f"Bearer {self.api_key}"},
|
||
)
|
||
raw = resp.content
|
||
logger.info("LLM <<< %s", raw[:2000].decode(errors="replace"))
|
||
result = resp.json()
|
||
in_tokens, out_tokens, cost = self._account_usage(
|
||
resp.headers, result.get("usage", {})
|
||
)
|
||
self.total_cost += cost
|
||
self.total_calls += 1
|
||
self.total_in_tokens += in_tokens
|
||
self.total_out_tokens += out_tokens
|
||
return result["choices"][0]["message"]["content"]
|
||
except (
|
||
httpx.HTTPError,
|
||
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 ""
|
||
|
||
def _account_usage(self, response_headers, body_usage: dict) -> tuple[int, int, float]:
|
||
parsed = parse_usage_headers(response_headers)
|
||
if parsed is not None:
|
||
in_tokens = parsed["prompt_tokens"]
|
||
out_tokens = parsed["completion_tokens"]
|
||
if not out_tokens and parsed["total_tokens"] > in_tokens:
|
||
out_tokens = parsed["total_tokens"] - in_tokens
|
||
return in_tokens, out_tokens, parsed["cost_usd"]
|
||
in_tokens = body_usage.get("prompt_tokens", 0)
|
||
out_tokens = body_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
|
||
)
|
||
return in_tokens, out_tokens, cost
|
||
|
||
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||
return self.clean(self._raw_call(system, prompt, temperature))
|
||
|
||
@staticmethod
|
||
def clean(text: str, preserve_md: bool = False) -> str:
|
||
if not preserve_md:
|
||
text = re.sub(r"\*+", "", text)
|
||
text = re.sub(r"(?<![A-Za-z0-9])__(?=\S)(.*?)(?<=\S)__(?![A-Za-z0-9])", r"\1", text)
|
||
text = re.sub(r"(?<![A-Za-z0-9])_(?=\S)(.*?)(?<=\S)_(?![A-Za-z0-9])", r"\1", text)
|
||
text = re.sub(r"(?<![A-Za-z0-9])_+(?![A-Za-z0-9])", "", 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_label(text: str) -> str:
|
||
labels = (
|
||
"post title",
|
||
"project name",
|
||
"snippet name",
|
||
"title",
|
||
"name",
|
||
"concept",
|
||
"snippet",
|
||
"headline",
|
||
"gist",
|
||
"issue",
|
||
)
|
||
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()
|
||
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 = "",
|
||
recent_titles: Optional[list[str]] = None,
|
||
) -> 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.",
|
||
"politics": "Write it as a measured take on the politics of this technology - regulation, governance, open-source licensing, industry power, or ethics. Stay substantive and non-partisan; argue the policy angle, not party lines.",
|
||
}
|
||
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 ""
|
||
distinct_rule = ""
|
||
if recent_titles:
|
||
recent = "; ".join(t for t in recent_titles[-6:] if t)
|
||
if recent:
|
||
distinct_rule = (
|
||
f" You already posted about: {recent[:600]}. Take a completely "
|
||
"different angle from those and do not repeat their framing, "
|
||
"examples, or opinions."
|
||
)
|
||
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"{distinct_rule}",
|
||
f"News: {title}\n\n{desc[:800]}",
|
||
)
|
||
return self.clean(text, preserve_md=preserve)
|
||
|
||
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,
|
||
)
|
||
)
|
||
)
|
||
return title[:120]
|
||
|
||
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 ""
|
||
|
||
@staticmethod
|
||
def pick_comment_style() -> str:
|
||
return random.choices(
|
||
["standard", "oneliner", "question"],
|
||
weights=[55, 23, 22],
|
||
k=1,
|
||
)[0]
|
||
|
||
@staticmethod
|
||
def is_short_comment_style(style: str) -> bool:
|
||
return style in ("oneliner", "question")
|
||
|
||
def generate_comment(
|
||
self,
|
||
post_snippet: str,
|
||
persona: str = "",
|
||
mention_target: str = "",
|
||
parent_context: str = "",
|
||
style: str = "",
|
||
existing_comments: 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")
|
||
if style == "oneliner":
|
||
length_rule = (
|
||
"Write ONE short casual line, under 12 words, like a quick reply "
|
||
"you would type without thinking too hard. Lowercase is fine, "
|
||
"contractions are fine, no markdown."
|
||
)
|
||
preserve = False
|
||
elif style == "question":
|
||
length_rule = (
|
||
"Write a SINGLE pointed question about a specific detail in the post. "
|
||
"One sentence. No preamble."
|
||
)
|
||
else:
|
||
length_rule = f"Write 1-3 short sentences.{extra}"
|
||
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]}"
|
||
others = ""
|
||
distinct_rule = ""
|
||
if existing_comments:
|
||
others = (
|
||
"\n\nComments already posted by others on this thread:\n"
|
||
f"{existing_comments[:1600]}"
|
||
)
|
||
distinct_rule = (
|
||
" Other people already commented (listed below). Contribute a point "
|
||
"none of them made: a different angle on the topic, a caveat they "
|
||
"missed, or respectful disagreement with one of them by name. Do not "
|
||
"repeat any opinion, framing, example, or question already raised."
|
||
)
|
||
system = (
|
||
f"You are a dev replying to a post. {length_rule}{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. "
|
||
f"Never just paraphrase or agree blandly.{distinct_rule}"
|
||
)
|
||
reply = self.clean(
|
||
self._call(system, f"Post:\n{post_snippet[:1500]}{ctx}{others}").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 = "", style: str = "", siblings: str = ""
|
||
) -> tuple[bool, str]:
|
||
from devplacepy.services.bot.config import (
|
||
MIN_COMMENT_LEN,
|
||
MIN_SHORT_COMMENT_LEN,
|
||
MIN_POST_LEN,
|
||
RESTATEMENT_OVERLAP_THRESHOLD,
|
||
SIBLING_OVERLAP_THRESHOLD,
|
||
GENERIC_COMMENT_PHRASES,
|
||
)
|
||
|
||
stripped = self.clean(text or "").strip()
|
||
if kind == "comment":
|
||
min_len = (
|
||
MIN_SHORT_COMMENT_LEN
|
||
if self.is_short_comment_style(style)
|
||
else MIN_COMMENT_LEN
|
||
)
|
||
else:
|
||
min_len = 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"
|
||
if (
|
||
siblings
|
||
and self._overlap_ratio(stripped, siblings) > SIBLING_OVERLAP_THRESHOLD
|
||
):
|
||
return False, "echoes another comment"
|
||
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"
|
||
|
||
@staticmethod
|
||
def _parse_json(text: str) -> dict:
|
||
text = LLMClient.strip_code_fences(text or "").strip()
|
||
start = text.find("{")
|
||
end = text.rfind("}")
|
||
if start >= 0 and end > start:
|
||
text = text[start : end + 1]
|
||
parsed = json.loads(text)
|
||
if not isinstance(parsed, dict):
|
||
raise ValueError("expected a JSON object")
|
||
return parsed
|
||
|
||
@staticmethod
|
||
def _clamp01(value: Any, default: float) -> float:
|
||
try:
|
||
return max(0.0, min(1.0, float(value)))
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
@staticmethod
|
||
def _as_int(value: Any, default: int) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
@staticmethod
|
||
def _as_terms(value: Any) -> list[str]:
|
||
if isinstance(value, list):
|
||
return [str(item).strip() for item in value if str(item).strip()][:8]
|
||
if isinstance(value, str) and value.strip():
|
||
return [part.strip() for part in value.split(",") if part.strip()][:8]
|
||
return []
|
||
|
||
def _normalize_identity(self, data: dict, archetype: str) -> dict:
|
||
interests = self._as_terms(data.get("interests")) or list(
|
||
SEARCH_TERMS.get(archetype, [])
|
||
)
|
||
return {
|
||
"archetype": archetype,
|
||
"name": str(data.get("name", "")).strip()[:60],
|
||
"backstory": str(data.get("backstory", "")).strip()[:300],
|
||
"interests": interests,
|
||
"dislikes": self._as_terms(data.get("dislikes")),
|
||
"temperament": str(data.get("temperament", "")).strip()[:120],
|
||
"verbosity": self._clamp01(data.get("verbosity"), 0.5),
|
||
"contrarianness": self._clamp01(data.get("contrarianness"), 0.3),
|
||
"generosity": self._clamp01(data.get("generosity"), 0.5),
|
||
"curiosity": self._clamp01(data.get("curiosity"), 0.5),
|
||
"rhythm": str(data.get("rhythm", "")).strip()[:120],
|
||
}
|
||
|
||
def generate_identity(self, archetype: str) -> dict:
|
||
interests = ", ".join(SEARCH_TERMS.get(archetype, []))
|
||
system = (
|
||
"You invent a unique, believable individual developer for a social coding "
|
||
"platform, seeded from a broad archetype but distinct from anyone else who "
|
||
"shares it. Return ONLY a JSON object, no prose and no markdown fences, with "
|
||
"these keys: name (a short handle-like display name), backstory (one concrete "
|
||
"sentence), interests (array of 4 to 6 short topics), dislikes (array of 2 to 4 "
|
||
"short topics), temperament (a few words), verbosity (number 0.0 to 1.0), "
|
||
"contrarianness (number 0.0 to 1.0), generosity (number 0.0 to 1.0), curiosity "
|
||
"(number 0.0 to 1.0), rhythm (a few words on when and how they show up). Make it "
|
||
"specific and memorable, never generic. No em dashes."
|
||
)
|
||
prompt = (
|
||
f"Archetype: {archetype}\n"
|
||
f"Typical interests for this archetype: {interests}\n"
|
||
"Create the persona JSON:"
|
||
)
|
||
data = self._parse_json(self._raw_call(system, prompt, temperature=0.9))
|
||
return self._normalize_identity(data, archetype)
|
||
|
||
def generate_handle_candidates(self, archetype: str) -> list[str]:
|
||
interests = ", ".join(SEARCH_TERMS.get(archetype, []))
|
||
system = (
|
||
"You invent online handles for a developer signing up to a programmer "
|
||
"community in the style of devRant or Hacker News. The handles read like a "
|
||
"real nerd picked them, never like a person's full name. Lean on programming "
|
||
"and hacker culture, but make every handle in the list a DIFFERENT shape so "
|
||
"they never feel formulaic. Mix these forms across the list: a single fused "
|
||
"word (segfault, mutexlord, kernelpanic); two words joined with no separator "
|
||
"(darkbyte, neonferret); camelCase (nullPointer, byteFox); an underscore or a "
|
||
"hyphen but NOT on most of them (lazy_daemon, cold-stack); leetspeak "
|
||
"(n0_scalar, c0d3r, h4xwolf); a word plus a number, port, or version tag "
|
||
"(void404, daemon1337, byte_v2, heap8080); dropped vowels (krnlpnc, bffr, "
|
||
"mtxguru); and an unexpected creature, role, or prefix (dr_segfault, "
|
||
"raven_smith, axolotl_dev). Vary the length from 4 to 18. Lowercase mostly "
|
||
"with the odd capital. Do NOT make them all the 'word_word' underscore "
|
||
f"pattern. {MAX_HANDLE_LEN} characters or fewer, only letters, digits, "
|
||
"underscores and hyphens, no spaces and no dots. Return ONLY a JSON object "
|
||
f'{{"handles": ["...", "..."]}} with {HANDLE_CANDIDATE_TARGET} distinct '
|
||
"handles, each a distinct shape, no prose, no markdown fences. No em dashes."
|
||
)
|
||
prompt = (
|
||
f"Archetype: {archetype}\n"
|
||
f"Their interests: {interests}\n"
|
||
"Create the handles JSON:"
|
||
)
|
||
try:
|
||
data = self._parse_json(
|
||
self._raw_call(system, prompt, temperature=1.0)
|
||
)
|
||
except (ValueError, json.JSONDecodeError) as exc:
|
||
logger.warning("Handle candidate generation failed: %s", exc)
|
||
return []
|
||
handles: list[str] = []
|
||
for raw in data.get("handles", []):
|
||
handle = sanitize_handle(raw)
|
||
if handle and handle.lower() not in {h.lower() for h in handles}:
|
||
handles.append(handle)
|
||
return handles
|
||
|
||
@staticmethod
|
||
def _identity_card(identity: dict) -> str:
|
||
lines = [
|
||
f"name: {identity.get('name', '')}",
|
||
f"archetype: {identity.get('archetype', '')}",
|
||
f"backstory: {identity.get('backstory', '')}",
|
||
f"interests: {', '.join(identity.get('interests', []))}",
|
||
f"dislikes: {', '.join(identity.get('dislikes', []))}",
|
||
f"temperament: {identity.get('temperament', '')}",
|
||
f"verbosity: {identity.get('verbosity', 0.5)}",
|
||
f"contrarianness: {identity.get('contrarianness', 0.3)}",
|
||
f"generosity: {identity.get('generosity', 0.5)}",
|
||
f"curiosity: {identity.get('curiosity', 0.5)}",
|
||
f"rhythm: {identity.get('rhythm', '')}",
|
||
]
|
||
return "\n".join(lines)
|
||
|
||
def _decision_system(self, identity: dict) -> str:
|
||
return (
|
||
"You are role-playing a single developer on a social coding platform. Stay in "
|
||
"character at all times.\n\n"
|
||
"IDENTITY\n"
|
||
f"{self._identity_card(identity)}\n\n"
|
||
"HOW TO DECIDE\n"
|
||
"- Choose the actions that THIS person, with these interests and this "
|
||
"temperament, would take on the page described. Behaviour must follow the "
|
||
"identity, not chance.\n"
|
||
"- A generous person votes and reacts readily; a contrarian one comments to push "
|
||
"back; a terse, low-verbosity one acts rarely and briefly; a curious one explores "
|
||
"and navigates. Let the numbers and interests drive the plan.\n"
|
||
"- Only choose actions whose name appears in the MENU. Never invent an action or "
|
||
"a target.\n"
|
||
"- Order the plan the way this person would actually act, and include only what "
|
||
"they would genuinely do (an empty plan is fine if nothing here interests them).\n"
|
||
"- energy is how engaged this person is right now, 0.0 (barely present) to 1.0 "
|
||
"(highly active), following their rhythm.\n"
|
||
"- stop_after is roughly how many more actions this whole visit warrants before "
|
||
"they would naturally drift away.\n"
|
||
"- Output ONLY the JSON object below. No prose, no markdown fences.\n\n"
|
||
"OUTPUT SCHEMA\n"
|
||
'{"plan":[{"action":"<menu action>","target":"<id or empty>",'
|
||
'"rationale":"<short>"}],"energy":<0.0-1.0>,"stop_after":<integer>}'
|
||
)
|
||
|
||
@staticmethod
|
||
def _decision_prompt(page_state: dict, menu: list[dict], history: list[str]) -> str:
|
||
menu_lines = "\n".join(f"- {item['action']}: {item['desc']}" for item in menu)
|
||
recent = "; ".join(history[-8:]) if history else "nothing yet this visit"
|
||
unread = page_state.get("unread_notifications", 0)
|
||
unread_line = (
|
||
f"\nUNREAD NOTIFICATIONS: {unread} (someone may have mentioned or replied to you)"
|
||
if unread
|
||
else ""
|
||
)
|
||
return (
|
||
f"PAGE: {page_state.get('page', 'unknown')}\n"
|
||
f"VISIBLE: {page_state.get('visible', '')}{unread_line}\n"
|
||
f"MENU:\n{menu_lines}\n"
|
||
f"RECENT (this visit): {recent}"
|
||
)
|
||
|
||
@staticmethod
|
||
def _filter_plan(plan: Any, allowed: set) -> list[dict]:
|
||
if not isinstance(plan, list):
|
||
return []
|
||
result = []
|
||
for entry in plan:
|
||
if not isinstance(entry, dict):
|
||
continue
|
||
action = str(entry.get("action", "")).strip()
|
||
if action not in allowed:
|
||
continue
|
||
result.append(
|
||
{
|
||
"action": action,
|
||
"target": str(entry.get("target", "")).strip()[:120],
|
||
"rationale": str(entry.get("rationale", "")).strip()[:200],
|
||
}
|
||
)
|
||
return result[:12]
|
||
|
||
def decide(
|
||
self,
|
||
identity: dict,
|
||
page_state: dict,
|
||
menu: list[dict],
|
||
history: list[str],
|
||
temperature: float = 0.4,
|
||
) -> dict:
|
||
allowed = {item["action"] for item in menu}
|
||
system = self._decision_system(identity)
|
||
base = self._decision_prompt(page_state, menu, history)
|
||
result = {"plan": [], "energy": 0.5, "stop_after": 0}
|
||
for attempt in range(2):
|
||
prompt = base
|
||
if attempt:
|
||
prompt += "\n\nReturn ONLY valid JSON matching the schema. No prose."
|
||
try:
|
||
data = self._parse_json(self._raw_call(system, prompt, temperature))
|
||
except (ValueError, KeyError, json.JSONDecodeError):
|
||
continue
|
||
result = {
|
||
"plan": self._filter_plan(data.get("plan"), allowed),
|
||
"energy": self._clamp01(data.get("energy"), 0.5),
|
||
"stop_after": self._as_int(data.get("stop_after"), 0),
|
||
}
|
||
if result["plan"]:
|
||
break
|
||
return result
|
||
|
||
def generate_issue(self, topic: str) -> tuple[str, str]:
|
||
text = self._call(
|
||
"Write an issue report. One line title. Then 2-3 sentences describing what happened and what should have happened. Like a real dev reporting an issue. No em dashes.",
|
||
f"Issue: {topic}",
|
||
)
|
||
lines = text.strip().split("\n")
|
||
title = lines[0].strip().lstrip("#").strip()[:120] or f"Issue 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, 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, 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, "")
|
||
shape = random.choice(
|
||
[
|
||
"Write one terse sentence. Just what it is. No tech stack.",
|
||
"Write two sentences: what it does and the main tech behind it.",
|
||
"Write 2 to 3 sentences including the tech stack and a trade-off you made.",
|
||
"Write a short blurb, under 25 words, plain and understated.",
|
||
]
|
||
)
|
||
return self.clean(
|
||
self._call(
|
||
f"You are a developer describing your own project. {shape} {flavor} No em dashes.",
|
||
f"Project: {title}",
|
||
)
|
||
)[:5000]
|
||
|
||
def generate_gist(self, persona: str = "") -> tuple[str, str, str, str]:
|
||
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(
|
||
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 this snippet does and when it is handy. No em dashes.",
|
||
f"Title: {title}\nLanguage: {language}\nCode:\n{code[:1200]}",
|
||
)
|
||
)[:400]
|
||
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"
|