forked from retoor/devplacepy
docs: add CLAUDE.md with project guidelines and conventions, document new devii admin quota reset endpoints and CLI commands, update README with coverage CI and devii_admin_daily_usd config, add European date normalization to ProjectForm model
This commit is contained in:
@@ -12,7 +12,7 @@ from faker import Faker
|
||||
from devplacepy.services.bot.browser import BotBrowser
|
||||
from devplacepy.services.bot.config import (
|
||||
CATEGORIES, FEED_TOPICS, GIST_LANGUAGES, PERSONAS, PROJECT_STATUSES,
|
||||
PROJECT_TYPES, SEARCH_TERMS,
|
||||
PROJECT_TYPES, REACT_RATE_DEFAULT, REACT_RATES, SEARCH_TERMS,
|
||||
)
|
||||
from devplacepy.services.bot.llm import LLMClient
|
||||
from devplacepy.services.bot.news_fetcher import NewsFetcher
|
||||
@@ -777,56 +777,79 @@ class DevPlaceBot:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _reaction_content(self, bar: Any) -> str:
|
||||
try:
|
||||
container = bar.locator(
|
||||
"xpath=ancestor::article[contains(concat(' ',normalize-space(@class),' '),' post-card ')][1]"
|
||||
" | ancestor::div[contains(concat(' ',normalize-space(@class),' '),' comment ')][1]"
|
||||
).first
|
||||
if await container.count() > 0:
|
||||
text = (await container.inner_text() or "").strip()
|
||||
if text:
|
||||
return text[:1200]
|
||||
except Exception as e:
|
||||
logger.debug("_reaction_content container failed: %s", e)
|
||||
body = (await self.b.text(".post-content") or await self.b.text(".gist-detail-desc")
|
||||
or await self.b.text(".news-detail-content") or await self.b.text(".news-detail-desc")
|
||||
or await self.b.text(".project-detail-desc") or await self.b.text(".rendered-content")
|
||||
or await self.b.text("article") or "")
|
||||
return body[:1200]
|
||||
|
||||
async def _react_to_object(self) -> bool:
|
||||
b = self.b
|
||||
bars = b.page.locator(".reaction-bar[data-reaction-uid]")
|
||||
count = await bars.count()
|
||||
if count == 0:
|
||||
return False
|
||||
reacted = set(self.state.reacted_targets)
|
||||
decided = set(self.state.reacted_targets)
|
||||
react_rate = REACT_RATES.get(self.state.persona, REACT_RATE_DEFAULT)
|
||||
for i in range(min(count, 20)):
|
||||
bar = bars.nth(i)
|
||||
uid = await bar.get_attribute("data-reaction-uid") or ""
|
||||
rtype = await bar.get_attribute("data-reaction-type") or ""
|
||||
if not uid:
|
||||
if not uid or uid in decided:
|
||||
continue
|
||||
|
||||
self.state.reacted_targets.append(uid)
|
||||
if len(self.state.reacted_targets) > 2000:
|
||||
self.state.reacted_targets = self.state.reacted_targets[-1000:]
|
||||
|
||||
if random.random() > react_rate:
|
||||
self._log(f"Reaction: skipping {rtype} {uid[:12]} (persona reticence)")
|
||||
return False
|
||||
|
||||
snippet = await self._reaction_content(bar)
|
||||
if not snippet:
|
||||
self._log(f"Reaction: no content for {rtype} {uid[:12]}")
|
||||
return False
|
||||
emoji = await self._generate(self.llm.select_reaction, snippet, self.state.persona)
|
||||
if not emoji:
|
||||
self._log(f"Reaction: nothing fitting for {rtype} {uid[:12]}")
|
||||
return False
|
||||
|
||||
add_btn = bar.locator(".reaction-add-btn").first
|
||||
if await add_btn.count() == 0:
|
||||
continue
|
||||
palette_btns = bar.locator(".reaction-palette-btn")
|
||||
pcount = await palette_btns.count()
|
||||
if pcount == 0:
|
||||
continue
|
||||
order = list(range(pcount))
|
||||
random.shuffle(order)
|
||||
chosen_idx = None
|
||||
chosen_emoji = ""
|
||||
for idx in order:
|
||||
emoji = await palette_btns.nth(idx).get_attribute("data-reaction-emoji") or ""
|
||||
if emoji and f"{uid}|{emoji}" not in reacted:
|
||||
chosen_idx = idx
|
||||
chosen_emoji = emoji
|
||||
break
|
||||
if chosen_idx is None:
|
||||
continue
|
||||
return False
|
||||
palette_btn = bar.locator(
|
||||
f".reaction-palette-btn[data-reaction-emoji='{emoji}']"
|
||||
).first
|
||||
if await palette_btn.count() == 0:
|
||||
return False
|
||||
try:
|
||||
await add_btn.scroll_into_view_if_needed(timeout=2000)
|
||||
await b._idle(0.2, 0.6)
|
||||
await add_btn.click(timeout=3000)
|
||||
await b._idle(0.3, 0.7)
|
||||
await palette_btns.nth(chosen_idx).click(timeout=3000)
|
||||
await palette_btn.click(timeout=3000)
|
||||
except Exception as e:
|
||||
logger.debug("_react_to_object click failed: %s", e)
|
||||
continue
|
||||
return False
|
||||
try:
|
||||
await bar.locator(".reaction-chip.reacted").first.wait_for(state="visible", timeout=4000)
|
||||
except Exception as e:
|
||||
logger.debug("_react_to_object confirm failed: %s", e)
|
||||
self.state.reacted_targets.append(f"{uid}|{chosen_emoji}")
|
||||
if len(self.state.reacted_targets) > 2000:
|
||||
self.state.reacted_targets = self.state.reacted_targets[-1000:]
|
||||
self.state.reactions_cast += 1
|
||||
self._action("REACT", f"{chosen_emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})")
|
||||
self._action("REACT", f"{emoji} on {rtype} {uid[:12]} (total {self.state.reactions_cast})")
|
||||
await b._idle(1.0, 2.0)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -28,6 +28,18 @@ PERSONAS = [
|
||||
"mentor",
|
||||
]
|
||||
|
||||
REACT_RATES = {
|
||||
"enthusiastic_junior": 0.50,
|
||||
"hobbyist_maker": 0.40,
|
||||
"storyteller": 0.30,
|
||||
"mentor": 0.30,
|
||||
"rebel": 0.25,
|
||||
"academic_type": 0.20,
|
||||
"minimalist": 0.12,
|
||||
"grumpy_senior": 0.12,
|
||||
}
|
||||
REACT_RATE_DEFAULT = 0.20
|
||||
|
||||
CATEGORIES = ["devlog", "showcase", "question", "rant", "fun", "random"]
|
||||
|
||||
GIST_LANGUAGES = [
|
||||
|
||||
@@ -130,6 +130,35 @@ class LLMClient:
|
||||
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 = {
|
||||
|
||||
@@ -67,10 +67,24 @@ class Settings:
|
||||
daily_limit_usd: float
|
||||
|
||||
|
||||
def _default_ai_key() -> str:
|
||||
env_key = os.environ.get("DEVII_AI_KEY", "")
|
||||
if env_key:
|
||||
return env_key
|
||||
try:
|
||||
from devplacepy.database import internal_gateway_key
|
||||
key = internal_gateway_key()
|
||||
if key:
|
||||
return key
|
||||
except Exception: # noqa: BLE001 - no local DB / remote-only: fall back to a random key
|
||||
pass
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
return Settings(
|
||||
ai_url=os.environ.get("DEVII_AI_URL", DEFAULT_AI_URL),
|
||||
ai_key=os.environ.get("DEVII_AI_KEY", str(uuid.uuid4())),
|
||||
ai_key=_default_ai_key(),
|
||||
ai_model=os.environ.get("DEVII_AI_MODEL", DEFAULT_AI_MODEL),
|
||||
base_url=os.environ.get("DEVII_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
|
||||
timeout_seconds=float(os.environ.get("DEVII_TIMEOUT", DEFAULT_TIMEOUT_SECONDS)),
|
||||
@@ -119,6 +133,7 @@ FIELD_VERIFY_REQUIRED = "devii_verify_required"
|
||||
FIELD_MAX_ITERATIONS = "devii_max_iterations"
|
||||
FIELD_USER_DAILY_USD = "devii_user_daily_usd"
|
||||
FIELD_GUEST_DAILY_USD = "devii_guest_daily_usd"
|
||||
FIELD_ADMIN_DAILY_USD = "devii_admin_daily_usd"
|
||||
FIELD_GUESTS_ENABLED = "devii_guests_enabled"
|
||||
FIELD_PRICE_CACHE_HIT = "devii_price_cache_hit"
|
||||
FIELD_PRICE_CACHE_MISS = "devii_price_cache_miss"
|
||||
@@ -133,7 +148,17 @@ FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
|
||||
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", "devii_lessons.db")
|
||||
|
||||
|
||||
def build_settings(config: dict, base_url: str, api_key: str, owner_kind: str = "guest") -> Settings:
|
||||
def effective_daily_limit(config: dict, owner_kind: str, is_admin: bool = False) -> float:
|
||||
if is_admin:
|
||||
return float(config.get(FIELD_ADMIN_DAILY_USD, 0.0) or 0.0)
|
||||
if owner_kind == "guest":
|
||||
return float(config.get(FIELD_GUEST_DAILY_USD, 0.05) or 0.0)
|
||||
return float(config.get(FIELD_USER_DAILY_USD, 1.0) or 0.0)
|
||||
|
||||
|
||||
def build_settings(
|
||||
config: dict, base_url: str, api_key: str, owner_kind: str = "guest", is_admin: bool = False
|
||||
) -> Settings:
|
||||
configured_key = config[FIELD_AI_KEY] or str(uuid.uuid4())
|
||||
ai_key = api_key if (owner_kind == "user" and api_key) else configured_key
|
||||
return Settings(
|
||||
@@ -166,8 +191,5 @@ def build_settings(config: dict, base_url: str, api_key: str, owner_kind: str =
|
||||
rsearch_enabled=bool(config.get(FIELD_RSEARCH_ENABLED, True)),
|
||||
rsearch_url=(config.get(FIELD_RSEARCH_URL) or DEFAULT_RSEARCH_URL).rstrip("/"),
|
||||
rsearch_timeout_seconds=float(config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS),
|
||||
daily_limit_usd=float(
|
||||
(config.get(FIELD_GUEST_DAILY_USD, 0.05) if owner_kind == "guest"
|
||||
else config.get(FIELD_USER_DAILY_USD, 1.0)) or 0.0
|
||||
),
|
||||
daily_limit_usd=effective_daily_limit(config, owner_kind, is_admin),
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ from .tasks.store import TaskStore, memory_db
|
||||
|
||||
logger = logging.getLogger("devii.hub")
|
||||
|
||||
SettingsBuilder = Callable[[str, str, str], "tuple[Settings, Pricing]"]
|
||||
SettingsBuilder = Callable[[str, str, str, bool], "tuple[Settings, Pricing]"]
|
||||
|
||||
|
||||
class DeviiHub:
|
||||
@@ -42,7 +42,7 @@ class DeviiHub:
|
||||
session = self._sessions.get(key)
|
||||
if session is not None:
|
||||
return session
|
||||
settings, pricing = self._build(api_key, base_url, owner_kind)
|
||||
settings, pricing = self._build(api_key, base_url, owner_kind, is_admin)
|
||||
llm = LLMClient(settings)
|
||||
# Persistent, owner-isolated stores for signed-in users; ephemeral in-memory for guests.
|
||||
owned_db = db if owner_kind == "user" else memory_db()
|
||||
|
||||
@@ -62,6 +62,8 @@ class DeviiService(BaseService):
|
||||
minimum=0, help="Rolling 24-hour spend cap for a signed-in user.", group="Limits"),
|
||||
ConfigField(config.FIELD_GUEST_DAILY_USD, "Max USD per guest / 24h", type="float", default=0.05,
|
||||
minimum=0, help="Rolling 24-hour spend cap for an anonymous guest.", group="Limits"),
|
||||
ConfigField(config.FIELD_ADMIN_DAILY_USD, "Max USD per admin / 24h", type="float", default=0.0,
|
||||
minimum=0, help="Rolling 24-hour spend cap for administrators. 0 = unlimited.", group="Limits"),
|
||||
ConfigField(config.FIELD_GUESTS_ENABLED, "Allow guests", type="bool", default=True,
|
||||
help="When off, only signed-in users may use Devii.", group="Limits"),
|
||||
ConfigField(config.FIELD_PRICE_CACHE_HIT, "Price cache-hit / 1M", type="float", default=0.0028,
|
||||
@@ -117,16 +119,13 @@ class DeviiService(BaseService):
|
||||
output_per_m=float(cfg[config.FIELD_PRICE_OUTPUT]),
|
||||
)
|
||||
|
||||
def _build_settings(self, api_key: str, base_url: str, owner_kind: str = "guest"):
|
||||
def _build_settings(self, api_key: str, base_url: str, owner_kind: str = "guest", is_admin: bool = False):
|
||||
cfg = self.effective_config()
|
||||
settings = build_settings(cfg, base_url, api_key, owner_kind)
|
||||
settings = build_settings(cfg, base_url, api_key, owner_kind, is_admin)
|
||||
return settings, self._pricing(cfg)
|
||||
|
||||
def daily_limit_for(self, owner_kind: str) -> float:
|
||||
cfg = self.get_config()
|
||||
if owner_kind == "guest":
|
||||
return float(cfg[config.FIELD_GUEST_DAILY_USD])
|
||||
return float(cfg[config.FIELD_USER_DAILY_USD])
|
||||
def daily_limit_for(self, owner_kind: str, is_admin: bool = False) -> float:
|
||||
return config.effective_daily_limit(self.get_config(), owner_kind, is_admin)
|
||||
|
||||
def guests_enabled(self) -> bool:
|
||||
return bool(self.get_config()[config.FIELD_GUESTS_ENABLED])
|
||||
@@ -134,6 +133,15 @@ class DeviiService(BaseService):
|
||||
def spent_24h(self, owner_kind: str, owner_id: str) -> float:
|
||||
return self.hub().ledger.spent_24h(owner_kind, owner_id)
|
||||
|
||||
def reset_quota(self, owner_kind: str, owner_id: str) -> int:
|
||||
return self.hub().ledger.reset(owner_kind, owner_id)
|
||||
|
||||
def reset_guest_quotas(self) -> int:
|
||||
return self.hub().ledger.reset_owner_kind("guest")
|
||||
|
||||
def reset_all_quotas(self) -> int:
|
||||
return self.hub().ledger.reset_all()
|
||||
|
||||
async def run_once(self) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
@@ -161,6 +169,10 @@ class DeviiService(BaseService):
|
||||
{"label": "Spend 24h", "value": f"${spent:.4f}"},
|
||||
{"label": "User cap 24h", "value": f"${float(cfg[config.FIELD_USER_DAILY_USD]):.2f}"},
|
||||
{"label": "Guest cap 24h", "value": f"${float(cfg[config.FIELD_GUEST_DAILY_USD]):.2f}"},
|
||||
{"label": "Admin cap 24h", "value": (
|
||||
f"${float(cfg[config.FIELD_ADMIN_DAILY_USD]):.2f}"
|
||||
if float(cfg[config.FIELD_ADMIN_DAILY_USD]) > 0 else "unlimited"
|
||||
)},
|
||||
{"label": "Guests", "value": "on" if cfg[config.FIELD_GUESTS_ENABLED] else "off"},
|
||||
{"label": "Model", "value": cfg[config.FIELD_AI_MODEL]},
|
||||
]
|
||||
|
||||
@@ -128,6 +128,30 @@ class UsageLedger:
|
||||
cutoff = _iso(_now() - timedelta(hours=older_than_hours))
|
||||
return int(get_table(LEDGER).delete(created_at={"<": cutoff}))
|
||||
|
||||
def reset(self, owner_kind: str, owner_id: str) -> int:
|
||||
if LEDGER not in db.tables:
|
||||
return 0
|
||||
table = get_table(LEDGER)
|
||||
count = table.count(owner_kind=owner_kind, owner_id=owner_id)
|
||||
table.delete(owner_kind=owner_kind, owner_id=owner_id)
|
||||
return int(count)
|
||||
|
||||
def reset_owner_kind(self, owner_kind: str) -> int:
|
||||
if LEDGER not in db.tables:
|
||||
return 0
|
||||
table = get_table(LEDGER)
|
||||
count = table.count(owner_kind=owner_kind)
|
||||
table.delete(owner_kind=owner_kind)
|
||||
return int(count)
|
||||
|
||||
def reset_all(self) -> int:
|
||||
if LEDGER not in db.tables:
|
||||
return 0
|
||||
table = get_table(LEDGER)
|
||||
count = table.count()
|
||||
table.delete()
|
||||
return int(count)
|
||||
|
||||
|
||||
class TurnAudit:
|
||||
def record(self, record: dict[str, Any]) -> None:
|
||||
|
||||
Reference in New Issue
Block a user