forked from retoor/devplacepy
feat: add soft delete and media tab for user attachments with admin restore
This commit is contained in:
@@ -59,6 +59,8 @@ class DevPlaceBot:
|
||||
self._pause_min = min(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._pause_max = max(cfg.action_pause_min, cfg.action_pause_max)
|
||||
self._break_scale = max(0.1, cfg.break_scale)
|
||||
self._ai_decisions = cfg.ai_decisions
|
||||
self._decision_temperature = cfg.decision_temperature
|
||||
self.faker = Faker()
|
||||
self.b = BotBrowser(headless=cfg.headless)
|
||||
self._post_cache: list[tuple[str, str, str, str, str]] = []
|
||||
@@ -66,6 +68,9 @@ class DevPlaceBot:
|
||||
self._bug_cache: list[tuple[str, str]] = []
|
||||
self._session_start_cost: float = 0.0
|
||||
self._session_start_calls: int = 0
|
||||
self._session_history: list[str] = []
|
||||
self._session_energy: float = 0.5
|
||||
self._session_stop_after: int = 0
|
||||
|
||||
def _identity(self) -> str:
|
||||
return f"{self.state.username or '?'}/{self.state.persona}"
|
||||
@@ -355,6 +360,294 @@ class DevPlaceBot:
|
||||
else:
|
||||
return ("deep", random.randint(15, 30), (14400, 43200))
|
||||
|
||||
async def _ensure_identity(self) -> None:
|
||||
if self.state.identity:
|
||||
return
|
||||
identity = await self._generate(
|
||||
self.llm.generate_identity, self.state.persona
|
||||
)
|
||||
if identity:
|
||||
self.state.identity = identity
|
||||
self._log(
|
||||
f"Identity card: {identity.get('name', '?')} ({self.state.persona})"
|
||||
)
|
||||
self._save()
|
||||
|
||||
async def _page_summary(self, page: str) -> str:
|
||||
b = self.b
|
||||
title = ""
|
||||
for sel in ("h1", ".post-title", ".content-title", "title"):
|
||||
title = (await b.text(sel) or "").strip()
|
||||
if title:
|
||||
break
|
||||
summary = f'title="{title[:100]}"' if title else "no title"
|
||||
try:
|
||||
comments = await b.page.locator(".comment, .comment-item").count()
|
||||
if comments:
|
||||
summary += f", {comments} comments"
|
||||
except Exception as e:
|
||||
logger.debug("page summary count failed: %s", e)
|
||||
return summary
|
||||
|
||||
async def _page_state(
|
||||
self,
|
||||
curl: str,
|
||||
page_text: str,
|
||||
*,
|
||||
did_post: bool,
|
||||
follows: int,
|
||||
gists: int,
|
||||
can_project: bool,
|
||||
can_post: bool,
|
||||
can_message: bool,
|
||||
) -> dict:
|
||||
pages = [
|
||||
("post", "/posts/" in curl),
|
||||
("profile", "/profile/" in curl),
|
||||
("news_detail", "/news/" in curl),
|
||||
("news_list", bool(re.search(r"/news(\?|$)", curl))),
|
||||
("project_detail", "/projects/" in curl),
|
||||
("projects_list", bool(re.search(r"/projects(\?|$)", curl))),
|
||||
("gist_detail", "/gists/" in curl),
|
||||
("gists_list", bool(re.search(r"/gists(\?|$)", curl))),
|
||||
("notifications", "/notifications" in curl),
|
||||
]
|
||||
page = next((name for name, hit in pages if hit), "feed")
|
||||
own_post = page == "post" and await self._is_own_post()
|
||||
mentioned = own_post and f"@{self.state.username.lower()}" in page_text
|
||||
return {
|
||||
"page": page,
|
||||
"url": curl,
|
||||
"visible": await self._page_summary(page),
|
||||
"own_post": own_post,
|
||||
"mentioned": mentioned,
|
||||
"did_post": did_post,
|
||||
"follows": follows,
|
||||
"gists": gists,
|
||||
"can_project": can_project,
|
||||
"can_post": can_post,
|
||||
"can_message": can_message,
|
||||
}
|
||||
|
||||
def _build_menu(self, page_state: dict) -> list[dict]:
|
||||
page = page_state["page"]
|
||||
menu: list[dict] = []
|
||||
|
||||
def add(action: str, desc: str) -> None:
|
||||
menu.append({"action": action, "desc": desc})
|
||||
|
||||
if page == "post":
|
||||
if not page_state["own_post"] or page_state["mentioned"]:
|
||||
add("comment", "write a comment on this post")
|
||||
add("reply", "reply to an existing comment in the thread")
|
||||
add("vote", "upvote this post")
|
||||
add("vote_comment", "upvote a good comment in the thread")
|
||||
add("vote_poll", "answer the attached poll if there is one")
|
||||
add("react", "add an emoji reaction to this post")
|
||||
elif page == "news_detail":
|
||||
add("comment", "write a comment reacting to this news article")
|
||||
elif page == "project_detail":
|
||||
add("vote", "star this project")
|
||||
add("react", "add an emoji reaction")
|
||||
add("comment", "write a comment on this project")
|
||||
elif page == "gist_detail":
|
||||
add("comment", "write a comment on this code snippet")
|
||||
add("vote", "star this gist")
|
||||
add("react", "add an emoji reaction")
|
||||
elif page == "profile":
|
||||
if not page_state["follows"]:
|
||||
add("follow", "follow this developer")
|
||||
if page_state["can_message"]:
|
||||
add("message", "send this developer a direct message")
|
||||
add("open_post", "open one of their posts to read it")
|
||||
elif page == "projects_list":
|
||||
add("vote", "star a project in the list")
|
||||
if page_state["can_project"]:
|
||||
add("create_project", "create a new project of your own")
|
||||
add("open_project", "open a project to read it")
|
||||
elif page == "gists_list":
|
||||
add("vote", "star a gist in the list")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_gist", "open a gist to read it")
|
||||
elif page == "news_list":
|
||||
add("open_news", "open a news article to read it")
|
||||
elif page == "notifications":
|
||||
add("check_notifications", "read and act on your notifications")
|
||||
else:
|
||||
add("open_post", "open a post from the feed to read it")
|
||||
add("vote", "upvote a post in the feed")
|
||||
add("react", "react to a post in the feed")
|
||||
add("vote_poll", "answer a poll in the feed")
|
||||
if page_state["can_post"] and not page_state["did_post"]:
|
||||
add("create_post", "write a new post reacting to a tech news article")
|
||||
if not page_state["gists"]:
|
||||
add("create_gist", "publish a new code snippet of your own")
|
||||
add("open_profile", "open another developer's profile")
|
||||
add("search", "search the platform for a topic you care about")
|
||||
add("browse", "browse a category or section")
|
||||
add("navigate", "move to another section: feed, projects, gists, or news")
|
||||
return menu
|
||||
|
||||
def _record_history(self, action: str, rationale: str) -> None:
|
||||
note = action.replace("_", " ")
|
||||
if rationale:
|
||||
note = f"{note} ({rationale[:60]})"
|
||||
self._session_history.append(note)
|
||||
if len(self._session_history) > 20:
|
||||
self._session_history = self._session_history[-20:]
|
||||
|
||||
async def _vote_for_page(self, page: str) -> bool:
|
||||
if page in ("project_detail", "projects_list"):
|
||||
return await self._vote_on_project()
|
||||
if page in ("gist_detail", "gists_list"):
|
||||
return await self._vote_on_gist()
|
||||
return await self._vote_on_feed()
|
||||
|
||||
async def _navigate_to(self, target: str) -> bool:
|
||||
routes = {
|
||||
"feed": "/feed",
|
||||
"projects": "/projects",
|
||||
"gists": "/gists",
|
||||
"news": "/news",
|
||||
"notifications": "/notifications",
|
||||
}
|
||||
path = routes.get((target or "").lower())
|
||||
if path:
|
||||
await self.b.goto(f"{self.base_url}{path}")
|
||||
return True
|
||||
return await self._random_nav_click()
|
||||
|
||||
async def _dispatch_action(self, action: str, target: str, page_state: dict) -> bool:
|
||||
if action == "navigate":
|
||||
return await self._navigate_to(target)
|
||||
if action == "vote":
|
||||
return await self._vote_for_page(page_state["page"])
|
||||
handlers = {
|
||||
"comment": self._comment_on_post,
|
||||
"reply": self._reply_to_random_comment,
|
||||
"vote_comment": self._vote_on_comments,
|
||||
"vote_poll": self._vote_on_poll,
|
||||
"react": self._react_to_object,
|
||||
"create_post": self._create_post,
|
||||
"create_gist": self._create_gist,
|
||||
"create_project": self._click_create_project,
|
||||
"follow": self._follow_user,
|
||||
"message": self._send_message,
|
||||
"open_post": self._click_post_link,
|
||||
"open_gist": self._click_gist_link,
|
||||
"open_profile": self._click_profile_link,
|
||||
"open_project": lambda: self._open_content("project"),
|
||||
"open_news": lambda: self._open_content("news"),
|
||||
"check_notifications": self._check_notifications,
|
||||
"search": lambda: self._search("feed"),
|
||||
"browse": self._browse_section,
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if handler is None:
|
||||
return False
|
||||
try:
|
||||
result = await handler()
|
||||
except Exception as e:
|
||||
logger.debug("dispatch %s failed: %s", action, e)
|
||||
return False
|
||||
return True if result is None else bool(result)
|
||||
|
||||
async def _run_plan(
|
||||
self, plan: list[dict], page_state: dict
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
actions = 0
|
||||
did_post = False
|
||||
projs = 0
|
||||
follows = 0
|
||||
gists = 0
|
||||
if not plan:
|
||||
await self._navigate_to("feed")
|
||||
return 0, did_post, projs, follows, gists
|
||||
for entry in plan:
|
||||
action = entry["action"]
|
||||
if action == "create_post" and (did_post or page_state["did_post"]):
|
||||
continue
|
||||
if action == "create_gist" and (gists or page_state["gists"]):
|
||||
continue
|
||||
if action == "create_project" and projs:
|
||||
continue
|
||||
if await self._dispatch_action(action, entry.get("target", ""), page_state):
|
||||
actions += 1
|
||||
self._record_history(action, entry.get("rationale", ""))
|
||||
if action == "create_post":
|
||||
did_post = True
|
||||
elif action == "create_project":
|
||||
projs += 1
|
||||
elif action == "follow":
|
||||
follows += 1
|
||||
elif action == "create_gist":
|
||||
gists += 1
|
||||
await b._idle(0.5, 1.5)
|
||||
return actions, did_post, projs, follows, gists
|
||||
|
||||
async def _cycle_ai(
|
||||
self,
|
||||
mood: str = "normal",
|
||||
*,
|
||||
session_posted: bool = False,
|
||||
session_projects: int = 0,
|
||||
session_follows: int = 0,
|
||||
session_gists: int = 0,
|
||||
can_project: bool = False,
|
||||
can_post: bool = False,
|
||||
can_message: bool = False,
|
||||
) -> tuple[int, bool, int, int, int]:
|
||||
b = self.b
|
||||
curl = await b.url()
|
||||
page_text = (await b.html()).lower()
|
||||
page_state = await self._page_state(
|
||||
curl,
|
||||
page_text,
|
||||
did_post=session_posted,
|
||||
follows=session_follows,
|
||||
gists=session_gists,
|
||||
can_project=can_project,
|
||||
can_post=can_post,
|
||||
can_message=can_message,
|
||||
)
|
||||
if page_state["page"] in (
|
||||
"post",
|
||||
"news_detail",
|
||||
"project_detail",
|
||||
"gist_detail",
|
||||
):
|
||||
await self._read_like_human(4, 90)
|
||||
menu = self._build_menu(page_state)
|
||||
decision = await self._generate(
|
||||
self.llm.decide,
|
||||
self.state.identity,
|
||||
page_state,
|
||||
menu,
|
||||
self._session_history,
|
||||
self._decision_temperature,
|
||||
)
|
||||
if not decision:
|
||||
decision = {"plan": [], "energy": self._session_energy, "stop_after": 0}
|
||||
self._session_energy = decision.get("energy", self._session_energy)
|
||||
self._session_stop_after = decision.get("stop_after", 0)
|
||||
actions, did_post, projs, follows, gists = await self._run_plan(
|
||||
decision["plan"], page_state
|
||||
)
|
||||
return (
|
||||
actions,
|
||||
did_post or session_posted,
|
||||
session_projects + projs,
|
||||
session_follows + follows,
|
||||
session_gists + gists,
|
||||
)
|
||||
|
||||
def _scaled_pause(self) -> int:
|
||||
base = random.randint(self._pause_min, self._pause_max)
|
||||
factor = 1.4 - 0.8 * self._session_energy
|
||||
return max(1, int(base * factor))
|
||||
|
||||
async def _vote_on_feed(self) -> bool:
|
||||
b = self.b
|
||||
sel = "button.vote-up, button.post-action-btn.vote-up, form[action*='/votes/'] button[type='submit']"
|
||||
@@ -2245,6 +2538,10 @@ class DevPlaceBot:
|
||||
self._session_start_cost = self.llm.total_cost
|
||||
self._session_start_calls = self.llm.total_calls
|
||||
self._session_banner(mood, session_len)
|
||||
self._session_history = []
|
||||
self._session_energy = 0.5
|
||||
self._session_stop_after = 0
|
||||
use_ai = False
|
||||
|
||||
try:
|
||||
await b.launch()
|
||||
@@ -2263,6 +2560,10 @@ class DevPlaceBot:
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
|
||||
if self._ai_decisions:
|
||||
await self._ensure_identity()
|
||||
use_ai = self._ai_decisions and bool(self.state.identity)
|
||||
|
||||
await self._warm_cache()
|
||||
self._log(f"Session active ({mood}) as {self._identity()}")
|
||||
|
||||
@@ -2305,7 +2606,7 @@ class DevPlaceBot:
|
||||
session_projects,
|
||||
session_follows,
|
||||
session_gists,
|
||||
) = await self._cycle(
|
||||
) = await (self._cycle_ai if use_ai else self._cycle)(
|
||||
mood=mood,
|
||||
session_posted=session_posted,
|
||||
session_projects=session_projects,
|
||||
@@ -2326,12 +2627,23 @@ class DevPlaceBot:
|
||||
self._save()
|
||||
crash_streak = 0
|
||||
|
||||
fatigue = await self._fatigue_check(session_actions)
|
||||
if fatigue:
|
||||
self._log(f"Fatigue ({fatigue}): ending session")
|
||||
break
|
||||
if use_ai:
|
||||
if (
|
||||
self._session_stop_after
|
||||
and session_actions >= self._session_stop_after
|
||||
):
|
||||
self._log(
|
||||
f"Energy spent (stop_after={self._session_stop_after}): ending session"
|
||||
)
|
||||
break
|
||||
intra_pause = self._scaled_pause()
|
||||
else:
|
||||
fatigue = await self._fatigue_check(session_actions)
|
||||
if fatigue:
|
||||
self._log(f"Fatigue ({fatigue}): ending session")
|
||||
break
|
||||
intra_pause = random.randint(self._pause_min, self._pause_max)
|
||||
|
||||
intra_pause = random.randint(self._pause_min, self._pause_max)
|
||||
self._log(
|
||||
f"Session pause {intra_pause}s [{session_actions}/{max_actions}] {self._cost_tag()}"
|
||||
)
|
||||
@@ -2368,6 +2680,9 @@ class DevPlaceBot:
|
||||
self._log(f"Recovery wait {break_s}s")
|
||||
else:
|
||||
break_s = max(5, int(random.randint(*break_range) * self._break_scale))
|
||||
if use_ai:
|
||||
factor = 1.5 - 1.0 * self._session_energy
|
||||
break_s = max(60, int(break_s * factor))
|
||||
break_h = break_s / 3600
|
||||
self._log(f"Session done. Break {break_h:.1f}h")
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ ACTION_PAUSE_MIN_SECONDS = 5
|
||||
ACTION_PAUSE_MAX_SECONDS = 30
|
||||
BREAK_SCALE_DEFAULT = 1.0
|
||||
|
||||
AI_DECISIONS_DEFAULT = False
|
||||
DECISION_TEMPERATURE_DEFAULT = 0.4
|
||||
|
||||
PERSONAS = [
|
||||
"enthusiastic_junior",
|
||||
"grumpy_senior",
|
||||
|
||||
@@ -5,12 +5,14 @@ import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.services.bot.config import (
|
||||
GIST_LANGUAGES,
|
||||
GIST_MIN_LINES,
|
||||
PERSONA_GIST_FLAVOR,
|
||||
PERSONA_LANGUAGES,
|
||||
SEARCH_TERMS,
|
||||
TRIVIAL_GIST_TERMS,
|
||||
)
|
||||
|
||||
@@ -40,7 +42,7 @@ class LLMClient:
|
||||
self.total_in_tokens = 0
|
||||
self.total_out_tokens = 0
|
||||
|
||||
def _call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
def _raw_call(self, system: str, prompt: str, temperature: float = 0.7) -> str:
|
||||
for attempt in range(3):
|
||||
try:
|
||||
payload = {
|
||||
@@ -80,7 +82,7 @@ class LLMClient:
|
||||
self.total_calls += 1
|
||||
self.total_in_tokens += in_tokens
|
||||
self.total_out_tokens += out_tokens
|
||||
return self.clean(result["choices"][0]["message"]["content"])
|
||||
return result["choices"][0]["message"]["content"]
|
||||
except (
|
||||
urllib.error.HTTPError,
|
||||
urllib.error.URLError,
|
||||
@@ -93,6 +95,9 @@ class LLMClient:
|
||||
time.sleep(2**attempt)
|
||||
return ""
|
||||
|
||||
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:
|
||||
@@ -341,6 +346,183 @@ class LLMClient:
|
||||
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)
|
||||
|
||||
@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"
|
||||
return (
|
||||
f"PAGE: {page_state.get('page', 'unknown')}\n"
|
||||
f"VISIBLE: {page_state.get('visible', '')}\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_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.",
|
||||
|
||||
@@ -22,3 +22,5 @@ class BotRuntimeConfig:
|
||||
action_pause_min: int = config.ACTION_PAUSE_MIN_SECONDS
|
||||
action_pause_max: int = config.ACTION_PAUSE_MAX_SECONDS
|
||||
break_scale: float = config.BREAK_SCALE_DEFAULT
|
||||
ai_decisions: bool = config.AI_DECISIONS_DEFAULT
|
||||
decision_temperature: float = config.DECISION_TEMPERATURE_DEFAULT
|
||||
|
||||
@@ -167,6 +167,24 @@ class BotsService(BaseService):
|
||||
help="Scales the between-session break. Below 1 makes the fleet more active (and costlier); above 1 calmer.",
|
||||
group="Pacing",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_ai_decisions",
|
||||
"AI-driven decisions",
|
||||
type="bool",
|
||||
default=config.AI_DECISIONS_DEFAULT,
|
||||
help="Let each bot's persona decide every action via the LLM instead of fixed probabilities. Adds one decision call per page; falls back to the procedural cascade when off.",
|
||||
group="Decisions",
|
||||
),
|
||||
ConfigField(
|
||||
"bot_decision_temperature",
|
||||
"Decision temperature",
|
||||
type="float",
|
||||
default=config.DECISION_TEMPERATURE_DEFAULT,
|
||||
minimum=0.0,
|
||||
maximum=2.0,
|
||||
help="Sampling temperature for the per-page decision call. Low values keep structured output stable; variety comes from the identity, not noise.",
|
||||
group="Decisions",
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -249,6 +267,8 @@ class BotsService(BaseService):
|
||||
action_pause_min=cfg["bot_action_pause_min_seconds"],
|
||||
action_pause_max=cfg["bot_action_pause_max_seconds"],
|
||||
break_scale=cfg["bot_break_scale"],
|
||||
ai_decisions=cfg["bot_ai_decisions"],
|
||||
decision_temperature=cfg["bot_decision_temperature"],
|
||||
)
|
||||
bot = DevPlaceBot(rc, on_event=self.log)
|
||||
task = asyncio.create_task(self._run_slot(slot, bot, cfg["bot_max_actions"]))
|
||||
|
||||
@@ -14,6 +14,7 @@ class BotState:
|
||||
password: str = ""
|
||||
uid: str = ""
|
||||
persona: str = ""
|
||||
identity: dict = field(default_factory=dict)
|
||||
log: list[str] = field(default_factory=list)
|
||||
known_users: list[str] = field(default_factory=list)
|
||||
known_posts: list[str] = field(default_factory=list)
|
||||
|
||||
@@ -734,6 +734,30 @@ ACTIONS: tuple[Action, ...] = (
|
||||
query("page", "Page number, 25 per page."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="list_media",
|
||||
method="GET",
|
||||
path="/profile/{username}?tab=media",
|
||||
summary="List a user's uploaded media, newest first",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
path("username", "Username whose media to list."),
|
||||
query("page", "Page number, 24 per page."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_media",
|
||||
method="POST",
|
||||
path="/media/{uid}/delete",
|
||||
summary="Delete one of your uploaded media attachments",
|
||||
description=(
|
||||
"Removes the attachment from the user's Media tab and from any post, project, or "
|
||||
"gist where it was attached. Show the user the exact media, get explicit "
|
||||
"confirmation, then call again with confirm=true."
|
||||
),
|
||||
requires_auth=True,
|
||||
params=(path("uid", "Attachment uid to delete."),),
|
||||
),
|
||||
Action(
|
||||
name="view_leaderboard",
|
||||
method="GET",
|
||||
|
||||
@@ -38,6 +38,7 @@ CONFIRM_REQUIRED = {
|
||||
"customize_reset",
|
||||
"project_delete_file",
|
||||
"delete_project",
|
||||
"delete_media",
|
||||
}
|
||||
|
||||
DESTRUCTIVE_COMMAND = re.compile(
|
||||
@@ -98,6 +99,12 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
|
||||
"Deleting a project permanently removes the project and ALL of its files. Ask the user "
|
||||
"to confirm this explicitly first, then call again with confirm=true."
|
||||
)
|
||||
if name == "delete_media":
|
||||
return ToolInputError(
|
||||
"Deleting media removes it from every display surface, including its parent post or "
|
||||
"project. Show the user the exact media, get explicit confirmation, then call again "
|
||||
"with confirm=true."
|
||||
)
|
||||
if (
|
||||
name == "container_instance_action"
|
||||
and str(arguments.get("action", "")).strip().lower() == "delete"
|
||||
|
||||
Reference in New Issue
Block a user