feat: add ISSLOP AI usage analysis CLI commands, game router, and politics topic

- 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
This commit is contained in:
2026-07-06 03:57:47 +00:00
parent f1bdefd834
commit 9a8046ab2a
110 changed files with 1466 additions and 374 deletions
+1
View File
@@ -32,6 +32,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
"proxy": "ingress",
"seo": "tools",
"deepsearch": "tools",
"isslop": "tools",
"ai": "ai",
"database": "database",
"pubsub": "pubsub",
+6 -2
View File
@@ -102,7 +102,7 @@ REACT_RATES = {
}
REACT_RATE_DEFAULT = 0.20
CATEGORIES = ["devlog", "showcase", "question", "rant", "fun", "random"]
CATEGORIES = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
GIST_LANGUAGES = [
"python",
@@ -122,7 +122,7 @@ GIST_LANGUAGES = [
"lua",
]
FEED_TOPICS = ["devlog", "showcase", "question", "rant", "fun"]
FEED_TOPICS = ["devlog", "showcase", "question", "rant", "fun", "politics"]
PROJECT_TYPES = ["game", "game_asset", "software", "mobile_app", "website"]
SEARCH_TERMS = {
@@ -240,6 +240,7 @@ PERSONA_CATEGORY_WEIGHTS = {
"question": 1,
"showcase": 1,
"fun": 1,
"politics": 1,
},
"hobbyist_maker": {
"showcase": 3,
@@ -256,6 +257,7 @@ PERSONA_CATEGORY_WEIGHTS = {
"showcase": 1,
"rant": 1,
"fun": 1,
"politics": 1,
},
"minimalist": {
"random": 3,
@@ -280,6 +282,7 @@ PERSONA_CATEGORY_WEIGHTS = {
"random": 1,
"showcase": 1,
"devlog": 1,
"politics": 2,
},
"mentor": {
"devlog": 3,
@@ -288,6 +291,7 @@ PERSONA_CATEGORY_WEIGHTS = {
"random": 1,
"rant": 1,
"fun": 1,
"politics": 1,
},
}
+1
View File
@@ -196,6 +196,7 @@ class LLMClient:
"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.')}"
+7 -7
View File
@@ -415,13 +415,13 @@ def pravda_env(instance: dict) -> dict:
slug = instance.get("ingress_slug") or ""
ingress_url = (f"{base_url}/p/{slug}" if base_url else f"/p/{slug}") if slug else ""
return {
"PRAVDA_BASE_URL": base_url,
"PRAVDA_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
"PRAVDA_API_KEY": api_key,
"PRAVDA_USER_UID": instance.get("run_as_uid") or user_uid,
"PRAVDA_CONTAINER_NAME": instance.get("name") or "",
"PRAVDA_CONTAINER_UID": instance.get("uid") or "",
"PRAVDA_INGRESS_URL": ingress_url,
"DEVPLACE_BASE_URL": base_url,
"DEVPLACE_OPENAI_URL": f"{base_url}/openai/v1" if base_url else "",
"DEVPLACE_API_KEY": api_key,
"DEVPLACE_USER_UID": instance.get("run_as_uid") or user_uid,
"DEVPLACE_CONTAINER_NAME": instance.get("name") or "",
"DEVPLACE_CONTAINER_UID": instance.get("uid") or "",
"DEVPLACE_INGRESS_URL": ingress_url,
}
+3 -3
View File
@@ -1,7 +1,7 @@
" retoor <retoor@molodetz.nl>
" Self-contained config for the ppy container. No external plugins or managers:
" it works out of the box with the stock vim, and the AI edit feature uses only
" curl and the PRAVDA_* gateway env that every instance is launched with.
" curl and the DEVPLACE_* gateway env that every instance is launched with.
set nocompatible
filetype plugin indent on
@@ -70,7 +70,7 @@ function! s:GetVisualSelection() abort
endfunction
function! s:GatewayUrl() abort
let l:base = substitute($PRAVDA_OPENAI_URL, '/\+$', '', '')
let l:base = substitute($DEVPLACE_OPENAI_URL, '/\+$', '', '')
if empty(l:base)
return 'https://openai.app.molodetz.nl/v1/chat/completions'
elseif l:base =~# '/chat/completions$'
@@ -92,7 +92,7 @@ function! AiEditSelection() abort
endif
let l:prompt = l:instruction . "\n\nHere is the text:\n" . l:orig
\ . "\n\nOutput only the transformed text. No explanations, markdown, or code blocks."
let l:api_key = !empty($PRAVDA_API_KEY) ? $PRAVDA_API_KEY : $DEEPSEEK_API_KEY
let l:api_key = !empty($DEVPLACE_API_KEY) ? $DEVPLACE_API_KEY : $DEEPSEEK_API_KEY
let l:json = '{"model":"deepseek-chat","messages":[{"role":"user","content":' . json_encode(l:prompt) . '}]}'
let l:cmd = 'curl -sS -X POST ' . shellescape(s:GatewayUrl())
\ . ' -H ' . shellescape('Authorization: Bearer ' . l:api_key)
+4 -4
View File
@@ -49,14 +49,14 @@ from urllib.parse import quote, unquote, urlencode, urlparse, urlsplit, urlunspl
# ═══════════════════════════════════════════════════════════════════════════════
def _resolve_devplace_url() -> str:
base = os.environ.get("PRAVDA_BASE_URL", "").strip().rstrip("/")
base = os.environ.get("DEVPLACE_BASE_URL", "").strip().rstrip("/")
if base:
return base
return os.environ.get("DEVPLACE_URL", "https://devplace.net").strip().rstrip("/")
def _resolve_llm_endpoint() -> str:
base = os.environ.get("PRAVDA_OPENAI_URL", "").strip().rstrip("/")
base = os.environ.get("DEVPLACE_OPENAI_URL", "").strip().rstrip("/")
if not base:
base = "https://openai.app.molodetz.nl/v1"
return base if base.endswith("/chat/completions") else base + "/chat/completions"
@@ -64,7 +64,7 @@ def _resolve_llm_endpoint() -> str:
DEVPLACE_URL = _resolve_devplace_url()
DEVPLACE_API_KEY = (
os.environ.get("PRAVDA_API_KEY")
os.environ.get("DEVPLACE_API_KEY")
or os.environ.get("DEVPLACE_API_KEY")
or "019ea58c-fae0-7112-8025-e629a54104a4"
)
@@ -81,7 +81,7 @@ LLM_ENDPOINT = _resolve_llm_endpoint()
LLM_BASE_URL = LLM_ENDPOINT.rsplit("/chat/completions", 1)[0]
MODEL = "molodetz"
LLM_API_KEY = str(
os.environ.get("PRAVDA_API_KEY")
os.environ.get("DEVPLACE_API_KEY")
or os.environ.get("LLM_API_KEY")
or ""
)
+2 -2
View File
@@ -659,7 +659,7 @@ from typing import Any, Callable, Optional
from urllib.parse import urlencode, urlparse
def _resolve_llm_endpoint() -> str:
base = os.environ.get("PRAVDA_OPENAI_URL", "").strip().rstrip("/")
base = os.environ.get("DEVPLACE_OPENAI_URL", "").strip().rstrip("/")
if not base:
base = "https://openai.app.molodetz.nl/v1"
return base if base.endswith("/chat/completions") else base + "/chat/completions"
@@ -669,7 +669,7 @@ LLM_ENDPOINT = _resolve_llm_endpoint()
LLM_BASE_URL = LLM_ENDPOINT.rsplit("/chat/completions", 1)[0]
MODEL = "molodetz"
API_KEY = (
os.environ.get("PRAVDA_API_KEY")
os.environ.get("DEVPLACE_API_KEY")
or os.environ.get("LLM_API_KEY")
or str(uuid.uuid4())
)
Binary file not shown.
+5 -5
View File
@@ -41,14 +41,14 @@ from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, urljoin, urlparse
def _resolve_api_url() -> str:
base = os.environ.get("PRAVDA_OPENAI_URL", "").strip().rstrip("/")
base = os.environ.get("DEVPLACE_OPENAI_URL", "").strip().rstrip("/")
if not base:
return "https://openai.app.molodetz.nl/v1/chat/completions"
return base if base.endswith("/chat/completions") else base + "/chat/completions"
API_URL = _resolve_api_url()
API_KEY = os.environ.get("PRAVDA_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or ""
API_KEY = os.environ.get("DEVPLACE_API_KEY") or os.environ.get("DEEPSEEK_API_KEY") or ""
RSEARCH_BASE_URL = "https://rsearch.app.molodetz.nl"
RSEARCH_MODES = ("search", "chat", "describe", "health")
RSEARCH_MAX_BYTES = 8 * 1024 * 1024
@@ -1644,7 +1644,7 @@ async def describe_image(prompt: str, image_path: str):
api_key = API_KEY
if not api_key:
return json.dumps({"status": "error", "error": "PRAVDA_API_KEY or DEEPSEEK_API_KEY missing"})
return json.dumps({"status": "error", "error": "DEVPLACE_API_KEY or DEEPSEEK_API_KEY missing"})
payload = {
"model": DEFAULT_MODEL,
@@ -1811,7 +1811,7 @@ async def delegate(task: str, allowed_tools: Optional[list] = None):
"""
api_key = API_KEY
if not api_key:
return json.dumps({"status": "error", "error": "PRAVDA_API_KEY or DEEPSEEK_API_KEY missing"})
return json.dumps({"status": "error", "error": "DEVPLACE_API_KEY or DEEPSEEK_API_KEY missing"})
if allowed_tools:
sub_payloads = [
t for t in get_tool_payloads()
@@ -2567,7 +2567,7 @@ async def amain(headed: bool = False, prompt: str = "", timeout: float = 0) -> N
api_key = API_KEY
if not api_key:
md.print("**Error:** `PRAVDA_API_KEY` or `DEEPSEEK_API_KEY` missing.")
md.print("**Error:** `DEVPLACE_API_KEY` or `DEEPSEEK_API_KEY` missing.")
sys.exit(1)
clone_mode = os.environ.get("MAK_CLONE_MODE") == "1"
@@ -108,4 +108,58 @@ TOOLS_ACTIONS: tuple[Action, ...] = (
params=(path("uid", "DeepSearch job uid returned by deepsearch."),),
requires_auth=False,
),
Action(
name="isslop",
method="POST",
path="/tools/isslop/run",
summary="Classify a repository or website as AI slop or human work",
description=(
"Queues a background AI Usage Analyzer job and returns {uid, status_url, report_url}. "
"Poll the status with isslop_status until status is 'completed', then share the "
"authenticity grade, category, human/AI split and report_url. Accepts http(s), "
"git and ssh source URLs."
),
params=(
body("url", "Repository or website URL to classify.", required=True),
),
requires_auth=True,
),
Action(
name="isslop_status",
method="GET",
path="/tools/isslop/{uid}",
summary="Check a AI usage analysis and obtain its verdict once finished",
description=(
"Returns the analysis status. When status is 'completed', grade, category, "
"human_percent, ai_percent and report_url are populated; while 'pending' or "
"'running', poll again shortly."
),
params=(path("uid", "Analysis uid returned by isslop."),),
requires_auth=True,
),
Action(
name="isslop_report",
method="GET",
path="/tools/isslop/{uid}/report",
summary="Read a finished AI usage analysis report",
description=(
"Returns the full report for a finished analysis: authenticity grade, human/AI "
"split, markdown findings, per-file scores with signals, the image review and the "
"embeddable badge snippets. Use it after isslop_status reports status 'completed'."
),
params=(path("uid", "Analysis uid returned by isslop."),),
requires_auth=True,
),
Action(
name="isslop_list",
method="GET",
path="/tools/isslop/list",
summary="List the user's AI usage analyses",
description=(
"Returns the signed-in user's analysis history, newest first, each with its grade, "
"category, status and report_url."
),
params=(),
requires_auth=True,
),
)
@@ -55,7 +55,7 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
),
arg(
"run_as_uid",
"Optional DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
"Optional DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user, which is always pravda (uid 1000).",
),
arg(
"start_on_boot",
@@ -118,7 +118,7 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
arg("instance", "Instance name, slug, or uid.", required=True),
arg(
"run_as_uid",
"DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
"DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID); pass empty to clear. Does NOT change the container OS user (always pravda, uid 1000).",
),
arg("boot_language", "Boot source language: 'none', 'python', or 'bash'."),
arg("boot_script", "Boot source code body run on launch."),
@@ -287,7 +287,7 @@ class Dispatcher:
self._rsearch = RsearchController(settings, owner_kind, owner_id)
from ..container import ContainerController
self._container = ContainerController(client)
self._container = ContainerController(client, owner_id=owner_id)
from ..customization import CustomizationController
self._customization = CustomizationController(owner_kind, owner_id)
@@ -4,7 +4,7 @@ import json
import logging
from typing import Any
from devplacepy.database import get_table, resolve_by_slug
from devplacepy.database import get_table, get_users_by_uids, resolve_by_slug
from devplacepy.services.containers import api, store
from devplacepy.services.containers.api import ContainerError
from devplacepy.services.containers.runtime import get_backend
@@ -15,8 +15,9 @@ logger = logging.getLogger("devii.container")
class ContainerController:
def __init__(self, client: Any = None) -> None:
def __init__(self, client: Any = None, owner_id: str = "") -> None:
self._client = client
self._owner_id = owner_id
def _ingress_url(self, instance: dict):
slug = instance.get("ingress_slug")
@@ -29,11 +30,19 @@ class ContainerController:
def _actor_user(self) -> dict:
username = getattr(self._client, "username", None)
if username:
if username and username != "api-key":
user = get_table("users").find_one(username=username)
if user:
return user
return {"uid": "admin", "username": username or "admin"}
if self._owner_id:
user = get_users_by_uids([self._owner_id]).get(self._owner_id)
if user:
return user
return {
"uid": "admin",
"username": username or "admin",
"role": "Admin",
}
def _project(self, arguments: dict) -> dict:
from devplacepy.content import can_view_project
+8
View File
@@ -2,12 +2,16 @@
from __future__ import annotations
from devplacepy.cache import TTLCache
from devplacepy.utils import generate_uid
from .. import economy
from .common import GameError, _farms, _iso, _now, _plots, _update_farm
_leaderboard_cache = TTLCache(ttl=15, max_size=8)
def get_farm(user_uid: str) -> dict | None:
return _farms().find_one(user_uid=user_uid)
@@ -95,6 +99,9 @@ def upgrade_ci(user: dict) -> dict:
def leaderboard(limit: int = 25) -> list[dict]:
cached = _leaderboard_cache.get(f"top:{limit}")
if cached is not None:
return cached
farms = sorted(
_farms().find(),
key=economy.farm_score,
@@ -122,4 +129,5 @@ def leaderboard(limit: int = 25) -> list[dict]:
"score": economy.farm_score(farm),
}
)
_leaderboard_cache.set(f"top:{limit}", entries)
return entries