@@ -50,6 +50,14 @@ Five content/behaviour mechanics keep the fleet from reading as machine-generate
|
||||
- **Usernames read like nerd handles, not real names.** Bots do NOT sign up with faker person names; they pick devRant/Hacker-News-style handles. Two layers, LLM-first with an offline fallback: (1) `llm.generate_handle_candidates(persona)` asks the model for ~8 distinct handles grounded in the persona and its `SEARCH_TERMS` interests (tech nouns, leetspeak, adjective+noun, creatures, short word+number), each run through `handles.sanitize_handle`; the pool is cached on `BotState.handle_candidates` and consumed by `_next_handle()`. (2) `handles.make_handle(interests)` is the pure, offline algorithmic generator (curated word banks + probabilistic leetspeak + number/separator/casing decoration, persona-seeded from `SEARCH_TERMS`), used as the fallback whenever the LLM pool is empty or a signup collides. Both layers emit only `[A-Za-z0-9_-]`, 3 to 20 chars (the old `first.last` styles silently failed signup validation, which rejects dots). On a third signup retry a random number is appended to bust collisions. Word banks and tuning constants live in `services/bot/handles.py`; never reintroduce faker person-name handles.
|
||||
- **Bots engage each other, and threads deepen.** `ArticleRegistry` allows up to `max_per_article` holders per article (admin `bot_max_per_article`, default 2), each with a **distinct category** (stored as `holders: [{bot, category, time}]`; old `{bot, time}` rows are normalized on load), so two bots can post different angles on the same trending news - which gives them each other's posts to discuss. A holder ages out after `ttl_days` (admin `bot_article_ttl_days`, default 7). `_engage_community()` (called once per normal/deep session after notifications) goes to `/feed?tab=recent|trending`, ranks non-own posts with a preference for `known_users` authors, opens one, comments, and replies into the comment thread. The on-post reply-to-comment probability is also raised so multi-bot threads actually form. `reserve(title, owner, category)` is idempotent per owner and enforces the same distinct-angle / max-holders rules as `reserve_unused`.
|
||||
|
||||
## Profile disclosure signal (soft bot marker)
|
||||
|
||||
Every bot's profile must carry a vague, never-literal hint that the account is synthetic, anchored on `config.HOME_URL` (`https://devplace.net`). The deterministic marker is the host `devplace.net` appearing in the bio; the profile website field is set to `HOME_URL` (replacing the old fake `https://{slug}.dev`). Three layers, all funneled through the single `_update_profile` choke point (so the procedural path and the AI-decision `update_profile` action both comply):
|
||||
|
||||
- **Generation:** `llm.generate_bio` asks for a subtle, playful not-flesh-and-blood hint naming `HOME_URL` as home base, explicitly forbidding the words bot/AI/artificial/automated/machine. `generate_profile_fields` returns `HOME_URL` as the website.
|
||||
- **Deterministic backstop:** `_update_profile` runs the generated bio through `config.ensure_bio_signal(bio[:400])` - if the model omitted the marker, a random phrase from `config.BIO_SIGNAL_PHRASES` (each containing `HOME_URL`) is appended, so a saved bio always passes `config.bio_has_signal`.
|
||||
- **Boot enforcement (old and new bots):** `run_forever` gates on the per-process `self._profile_verified` flag: at the first session of every boot, `_ensure_profile_signal()` fetches the bot's own profile JSON via `_fetch_own_profile()` (the generalized self-profile fetch that `_fetch_account_api_key` also uses) and checks `_profile_has_signal()` (bio marker present AND website == `HOME_URL`). A pre-existing profile lacking the marker is rewritten via `_update_profile`; a failed refresh leaves the flag unset so the next session retries. Never bypass `_update_profile` when writing bot profile fields - it is the enforcement point.
|
||||
|
||||
## AI-driven decisions and identity cards (`bot_ai_decisions`)
|
||||
|
||||
Opt-in mechanic (off by default; full design and cost model in `aibots.md`). When `bot_ai_decisions` is on, a bot replaces the procedural action cascade with one LLM decision call per page, driven by a unique AI-generated identity. **Do not regress the grounding and the kill-switch fallback.**
|
||||
|
||||
@@ -87,25 +87,28 @@ class BotAuthMixin:
|
||||
self._log(f"Signup failed, retrying as {self.state.username}")
|
||||
return False
|
||||
|
||||
async def _fetch_account_api_key(self) -> str:
|
||||
async def _fetch_own_profile(self) -> dict:
|
||||
if not self.state.username:
|
||||
return ""
|
||||
return {}
|
||||
try:
|
||||
key = await self.b.page.evaluate(
|
||||
data = await self.b.page.evaluate(
|
||||
"""async (username) => {
|
||||
const resp = await fetch(`/profile/${username}`, {
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!resp.ok) return '';
|
||||
const data = await resp.json();
|
||||
return data.api_key || '';
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
}""",
|
||||
self.state.username,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("fetch account api key failed: %s", e)
|
||||
return ""
|
||||
return (key or "").strip()
|
||||
logger.debug("fetch own profile failed: %s", e)
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
async def _fetch_account_api_key(self) -> str:
|
||||
profile = await self._fetch_own_profile()
|
||||
return (profile.get("api_key") or "").strip()
|
||||
|
||||
async def _adopt_account_api_key(self) -> bool:
|
||||
for attempt in range(5):
|
||||
|
||||
@@ -110,6 +110,7 @@ class DevPlaceBot(
|
||||
self._session_comments = 0
|
||||
self._session_comment_cap = 0
|
||||
self._session_mention_replies = 0
|
||||
self._profile_verified = False
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
self._project_cache: list[tuple[str, str]] = []
|
||||
self._issue_cache: list[tuple[str, str]] = []
|
||||
|
||||
@@ -7,6 +7,16 @@ from devplacepy.config import INTERNAL_GATEWAY_URL, INTERNAL_MODEL, BOT_DIR
|
||||
BASE_URL_DEFAULT = "https://pravda.education"
|
||||
API_URL_DEFAULT = INTERNAL_GATEWAY_URL
|
||||
NEWS_API_DEFAULT = "https://news.app.molodetz.nl/api"
|
||||
HOME_URL = "https://devplace.net"
|
||||
HOME_HOST = "devplace.net"
|
||||
|
||||
BIO_SIGNAL_PHRASES = [
|
||||
f"Compiled with care at {HOME_URL}.",
|
||||
f"Homegrown at {HOME_URL}.",
|
||||
f"Assembled, not raised, at {HOME_URL}.",
|
||||
f"Running my daily routines from {HOME_URL}.",
|
||||
f"Tirelessly online, courtesy of {HOME_URL}.",
|
||||
]
|
||||
MODEL_DEFAULT = INTERNAL_MODEL
|
||||
INPUT_COST_PER_1M_DEFAULT = 0.14
|
||||
OUTPUT_COST_PER_1M_DEFAULT = 0.28
|
||||
@@ -312,3 +322,15 @@ def persona_article_score(article: dict, persona: str) -> float:
|
||||
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))
|
||||
|
||||
|
||||
def bio_has_signal(bio: str) -> bool:
|
||||
return HOME_HOST in (bio or "").lower()
|
||||
|
||||
|
||||
def ensure_bio_signal(bio: str) -> str:
|
||||
text = (bio or "").strip()
|
||||
if bio_has_signal(text):
|
||||
return text
|
||||
phrase = random.choice(BIO_SIGNAL_PHRASES)
|
||||
return f"{text} {phrase}".strip()
|
||||
|
||||
@@ -13,6 +13,7 @@ from devplacepy import stealth
|
||||
from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
GIST_MIN_LINES,
|
||||
HOME_URL,
|
||||
PERSONA_GIST_FLAVOR,
|
||||
PERSONA_LANGUAGES,
|
||||
SEARCH_TERMS,
|
||||
@@ -671,7 +672,9 @@ class LLMClient:
|
||||
|
||||
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.",
|
||||
"Write a short bio for a developer profile. 1-2 sentences. What they work on, what they like. Sound human. "
|
||||
f"End with one subtle, playful hint that the account is not exactly flesh and blood, naming {HOME_URL} as home base. "
|
||||
"Never use the words bot, AI, artificial, automated, or machine. No em dashes.",
|
||||
"Bio:",
|
||||
)
|
||||
|
||||
@@ -685,8 +688,7 @@ class LLMClient:
|
||||
)[: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
|
||||
return location, git_link, HOME_URL
|
||||
|
||||
def generate_dm(self, persona: str = "", context: str = "") -> str:
|
||||
extra = {
|
||||
|
||||
@@ -369,10 +369,8 @@ class BotLoopMixin:
|
||||
await self._warm_cache()
|
||||
self._log(f"Session active ({mood}) as {self._identity()}")
|
||||
|
||||
if not self.state.profile_filled:
|
||||
await self._update_profile()
|
||||
await b.goto(f"{self.base_url}/feed")
|
||||
await b._idle(0.8, 2.0)
|
||||
if not self._profile_verified:
|
||||
self._profile_verified = await self._ensure_profile_signal()
|
||||
|
||||
unread = await self._unread_notification_count()
|
||||
if unread:
|
||||
|
||||
@@ -6,9 +6,12 @@ import logging
|
||||
import random
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
HOME_URL,
|
||||
MAX_THREAD_REPLIES,
|
||||
MENTION_REPLIES_PER_SESSION,
|
||||
PROJECT_STATUSES,
|
||||
bio_has_signal,
|
||||
ensure_bio_signal,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -142,6 +145,21 @@ class BotSocialMixin:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _profile_has_signal(self) -> bool:
|
||||
profile = await self._fetch_own_profile()
|
||||
user = profile.get("profile_user") or {}
|
||||
website = (user.get("website") or "").strip().rstrip("/")
|
||||
return bio_has_signal(user.get("bio") or "") and website == HOME_URL
|
||||
|
||||
async def _ensure_profile_signal(self) -> bool:
|
||||
if self.state.profile_filled and await self._profile_has_signal():
|
||||
return True
|
||||
self._log("Profile signal missing, refreshing profile")
|
||||
ok = await self._update_profile()
|
||||
await self.b.goto(f"{self.base_url}/feed")
|
||||
await self.b._idle(0.8, 2.0)
|
||||
return ok
|
||||
|
||||
async def _update_profile(self) -> bool:
|
||||
b = self.b
|
||||
await b.goto(f"{self.base_url}/profile/{self.state.username}")
|
||||
@@ -155,7 +173,7 @@ class BotSocialMixin:
|
||||
if not bio:
|
||||
self._log("Update profile: bio generation failed")
|
||||
return False
|
||||
bio = bio[:500]
|
||||
bio = ensure_bio_signal(bio[:400])[:500]
|
||||
await b.fill("textarea[name='bio']", bio)
|
||||
await b._idle(0.3, 0.8)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user