forked from retoor/devplacepy
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:
@@ -21,6 +21,9 @@ from devplacepy.cli.jobs import (
|
||||
cmd_forks_clear,
|
||||
cmd_seo_prune,
|
||||
cmd_seo_clear,
|
||||
cmd_isslop_prune,
|
||||
cmd_isslop_clear,
|
||||
cmd_isslop_analyze,
|
||||
cmd_seo_meta_prune,
|
||||
cmd_seo_meta_clear,
|
||||
cmd_deepsearch_prune,
|
||||
@@ -65,6 +68,9 @@ __all__ = [
|
||||
"cmd_forks_clear",
|
||||
"cmd_seo_prune",
|
||||
"cmd_seo_clear",
|
||||
"cmd_isslop_prune",
|
||||
"cmd_isslop_clear",
|
||||
"cmd_isslop_analyze",
|
||||
"cmd_seo_meta_prune",
|
||||
"cmd_seo_meta_clear",
|
||||
"cmd_deepsearch_prune",
|
||||
|
||||
@@ -210,6 +210,104 @@ def cmd_deepsearch_clear(args):
|
||||
print(f"Cleared {len(jobs)} DeepSearch job(s) and their collections")
|
||||
|
||||
|
||||
def cmd_isslop_prune(args):
|
||||
from datetime import datetime, timezone
|
||||
from devplacepy.services.jobs import queue
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
removed = 0
|
||||
for job in queue.list_jobs(kind="isslop", status=queue.DONE):
|
||||
expires_at = job.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
expiry = datetime.fromisoformat(expires_at)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if expiry < now:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
removed += 1
|
||||
_audit_cli("cli.isslop.prune", f"CLI pruned {removed} expired AI usage analysis jobs", metadata={"count": removed})
|
||||
print(f"Pruned {removed} expired AI usage analysis job(s) (reports persist)")
|
||||
|
||||
|
||||
def cmd_isslop_clear(args):
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
|
||||
jobs = queue.list_jobs(kind="isslop")
|
||||
for job in jobs:
|
||||
get_table("jobs").delete(uid=job["uid"])
|
||||
analyses = list(get_table(store.TABLE_ANALYSES).find())
|
||||
for analysis in analyses:
|
||||
store.purge_analysis(analysis["uid"])
|
||||
_audit_cli(
|
||||
"cli.isslop.clear",
|
||||
f"CLI cleared {len(analyses)} AI usage analyses and {len(jobs)} job rows",
|
||||
metadata={"analyses": len(analyses), "jobs": len(jobs)},
|
||||
)
|
||||
print(f"Cleared {len(analyses)} AI usage analysis(es), their reports and {len(jobs)} job row(s)")
|
||||
|
||||
|
||||
def cmd_isslop_analyze(args):
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from devplacepy.config import ISSLOP_WORKSPACES_DIR, ensure_data_dirs
|
||||
from devplacepy.database import INTERNAL_GATEWAY_URL, internal_gateway_key
|
||||
from devplacepy.models import IsslopRunForm
|
||||
from devplacepy.services.jobs.isslop import store
|
||||
from devplacepy.services.jobs.isslop.acquisition.workspace import remove_workspace, workspace_for
|
||||
from devplacepy.services.jobs.isslop.config import settings_from_payload
|
||||
from devplacepy.services.jobs.isslop.events import KIND_DONE, KIND_ERROR
|
||||
from devplacepy.services.jobs.isslop.persistence import EventPersister
|
||||
from devplacepy.services.jobs.isslop.pipeline import run_pipeline
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
url = IsslopRunForm(url=args.url).url
|
||||
ensure_data_dirs()
|
||||
uid = generate_uid()
|
||||
settings = settings_from_payload(
|
||||
{
|
||||
"url": url,
|
||||
"llm_endpoint": INTERNAL_GATEWAY_URL,
|
||||
"api_key": internal_gateway_key(),
|
||||
"allow_private": bool(args.allow_private),
|
||||
"media_dir": str(store.media_dir_for(uid)),
|
||||
}
|
||||
)
|
||||
store.create_analysis(uid, url, "system", "cli")
|
||||
persister = EventPersister(uid)
|
||||
store.update_analysis(uid, status="running")
|
||||
workspace = workspace_for(ISSLOP_WORKSPACES_DIR, url, uid)
|
||||
|
||||
async def run() -> int:
|
||||
failed = False
|
||||
try:
|
||||
async for event in run_pipeline(url, workspace, settings):
|
||||
persister.apply(event)
|
||||
if args.json:
|
||||
print(event.to_json(), flush=True)
|
||||
else:
|
||||
print(f"[{event.kind}] {event.message}", flush=True)
|
||||
if event.kind == KIND_ERROR:
|
||||
failed = True
|
||||
if event.kind == KIND_DONE and not args.json:
|
||||
print(f"Report: /tools/isslop/{uid}/report")
|
||||
print(f"Badge: /tools/isslop/{uid}/badge.svg")
|
||||
finally:
|
||||
remove_workspace(workspace)
|
||||
return 1 if failed else 0
|
||||
|
||||
exit_code = asyncio.run(run())
|
||||
_audit_cli(
|
||||
"cli.isslop.analyze",
|
||||
f"CLI AI usage analysis of {url}",
|
||||
metadata={"uid": uid, "failed": bool(exit_code)},
|
||||
)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
|
||||
def register_jobs(subparsers):
|
||||
zips = subparsers.add_parser("zips", help="Zip archive job management")
|
||||
zips_sub = zips.add_subparsers(title="action", dest="action")
|
||||
@@ -265,3 +363,21 @@ def register_jobs(subparsers):
|
||||
"clear", help="Delete every DeepSearch session and job row"
|
||||
)
|
||||
deepsearch_clear.set_defaults(func=cmd_deepsearch_clear)
|
||||
|
||||
isslop = subparsers.add_parser("isslop", help="AI Usage Analyzer job management")
|
||||
isslop_sub = isslop.add_subparsers(title="action", dest="action")
|
||||
isslop_prune = isslop_sub.add_parser(
|
||||
"prune", help="Delete expired AI usage analysis job rows (analyses and reports persist)"
|
||||
)
|
||||
isslop_prune.set_defaults(func=cmd_isslop_prune)
|
||||
isslop_clear = isslop_sub.add_parser(
|
||||
"clear", help="Delete every AI usage analysis, its report and job rows"
|
||||
)
|
||||
isslop_clear.set_defaults(func=cmd_isslop_clear)
|
||||
isslop_analyze = isslop_sub.add_parser(
|
||||
"analyze", help="Run a AI usage analysis from the terminal and persist its report"
|
||||
)
|
||||
isslop_analyze.add_argument("url", help="Repository or website URL to classify")
|
||||
isslop_analyze.add_argument("--json", action="store_true", help="Emit raw JSON events")
|
||||
isslop_analyze.add_argument("--allow-private", action="store_true", dest="allow_private", help="Permit private and loopback hosts")
|
||||
isslop_analyze.set_defaults(func=cmd_isslop_analyze)
|
||||
|
||||
@@ -26,6 +26,10 @@ PLANNING_REPORTS_DIR = DATA_DIR / "planning_reports"
|
||||
DBAPI_DIR = DATA_DIR / "dbapi"
|
||||
DEEPSEARCH_DIR = DATA_DIR / "deepsearch"
|
||||
DEEPSEARCH_CHROMA_DIR = DEEPSEARCH_DIR / "chroma"
|
||||
ISSLOP_DIR = DATA_DIR / "isslop"
|
||||
ISSLOP_WORKSPACES_DIR = ISSLOP_DIR / "workspaces"
|
||||
ISSLOP_RUNS_DIR = ISSLOP_DIR / "runs"
|
||||
ISSLOP_MEDIA_DIR = ISSLOP_DIR / "media"
|
||||
KEYS_DIR = DATA_DIR / "keys"
|
||||
BOT_DIR = DATA_DIR / "bot"
|
||||
LOCKS_DIR = DATA_DIR / "locks"
|
||||
@@ -96,6 +100,10 @@ DATA_PATHS: dict[str, Path] = {
|
||||
"dbapi": DBAPI_DIR,
|
||||
"deepsearch": DEEPSEARCH_DIR,
|
||||
"deepsearch_chroma": DEEPSEARCH_CHROMA_DIR,
|
||||
"isslop": ISSLOP_DIR,
|
||||
"isslop_workspaces": ISSLOP_WORKSPACES_DIR,
|
||||
"isslop_runs": ISSLOP_RUNS_DIR,
|
||||
"isslop_media": ISSLOP_MEDIA_DIR,
|
||||
"keys": KEYS_DIR,
|
||||
"bot": BOT_DIR,
|
||||
"locks": LOCKS_DIR,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "signals"]
|
||||
TOPICS = ["devlog", "showcase", "question", "rant", "fun", "random", "politics"]
|
||||
|
||||
REACTION_EMOJI = [
|
||||
"\U0001f44d",
|
||||
|
||||
@@ -19,6 +19,7 @@ from devplacepy.database import (
|
||||
get_blocked_uids,
|
||||
get_poll_for_post,
|
||||
update_target_stars,
|
||||
clear_user_stars,
|
||||
get_target_owner_uid,
|
||||
resolve_object_url,
|
||||
soft_delete,
|
||||
@@ -121,6 +122,10 @@ def create_content_item(
|
||||
**fields,
|
||||
}
|
||||
)
|
||||
if table_name == "projects":
|
||||
from devplacepy.templating import clear_user_projects_cache
|
||||
|
||||
clear_user_projects_cache(user["uid"])
|
||||
award_rewards(user["uid"], xp, badge)
|
||||
if attachment_uids:
|
||||
link_attachments(attachment_uids, target_type, uid)
|
||||
@@ -209,6 +214,8 @@ def apply_vote(request, user: dict, target_type: str, target_uid: str, value: in
|
||||
update_target_stars(target_type, target_uid, net)
|
||||
|
||||
owner_uid = get_target_owner_uid(target_type, target_uid)
|
||||
if owner_uid:
|
||||
clear_user_stars(owner_uid)
|
||||
direction = "clear" if new_value == 0 else ("up" if new_value == 1 else "down")
|
||||
vote_links = [audit.target(target_type, target_uid)]
|
||||
if owner_uid and owner_uid != user["uid"]:
|
||||
@@ -570,9 +577,11 @@ def delete_content_item(
|
||||
soft_delete_engagement("comment", comment_uids, actor)
|
||||
if target_type == "project":
|
||||
from devplacepy.project_files import soft_delete_all_project_files
|
||||
from devplacepy.templating import clear_user_projects_cache
|
||||
|
||||
soft_delete_all_project_files(item["uid"], actor)
|
||||
soft_delete_fork_relations(item["uid"], actor)
|
||||
clear_user_projects_cache(item["user_uid"])
|
||||
soft_delete(table_name, actor, stamp=stamp, uid=item["uid"])
|
||||
logger.info(f"{table_name} {item['uid']} soft-deleted by {user['username']}")
|
||||
audit.record(
|
||||
|
||||
@@ -17,7 +17,7 @@ from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICAT
|
||||
from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_relations, delete_fork_relations
|
||||
from .follows import get_follow_counts, get_follow_list, get_following_among
|
||||
from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache
|
||||
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
|
||||
from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid
|
||||
from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids
|
||||
from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news
|
||||
from .attachments_data import get_attachments, get_attachments_by_type, get_news_images_by_uids, delete_attachment_record, delete_attachments, _delete_attachment_file, get_user_media, get_deleted_media
|
||||
@@ -191,6 +191,7 @@ __all__ = [
|
||||
"get_leaderboard",
|
||||
"get_user_rank",
|
||||
"get_user_stars",
|
||||
"clear_user_stars",
|
||||
"update_target_stars",
|
||||
"soft_delete_engagement",
|
||||
"delete_engagement",
|
||||
|
||||
@@ -16,7 +16,10 @@ VOTABLE_TARGETS: dict[str, str] = {
|
||||
STAR_TARGETS: set[str] = {"post", "project", "gist"}
|
||||
|
||||
|
||||
_authors_cache = TTLCache(ttl=300, max_size=200)
|
||||
_authors_cache = TTLCache(ttl=15, max_size=200)
|
||||
|
||||
|
||||
_stars_cache = TTLCache(ttl=15, max_size=2000)
|
||||
|
||||
|
||||
def _ranked_authors() -> list:
|
||||
@@ -84,7 +87,14 @@ def get_user_rank(user_uid: str):
|
||||
return _rank_map().get(user_uid)
|
||||
|
||||
|
||||
def clear_user_stars(user_uid: str) -> None:
|
||||
_stars_cache.pop(user_uid)
|
||||
|
||||
|
||||
def get_user_stars(user_uid: str) -> int:
|
||||
cached = _stars_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if "votes" not in db.tables:
|
||||
return 0
|
||||
target_union = " UNION ALL ".join(
|
||||
@@ -94,14 +104,17 @@ def get_user_stars(user_uid: str) -> int:
|
||||
)
|
||||
if not target_union:
|
||||
return 0
|
||||
total = 0
|
||||
for row in db.query(
|
||||
f"SELECT COALESCE(SUM(v.value), 0) AS s "
|
||||
f"FROM votes v JOIN ({target_union}) t ON v.target_uid = t.uid AND v.target_type = t.target_type "
|
||||
f"WHERE v.deleted_at IS NULL",
|
||||
u=user_uid,
|
||||
):
|
||||
return row["s"] or 0
|
||||
return 0
|
||||
total = row["s"] or 0
|
||||
break
|
||||
_stars_cache.set(user_uid, total)
|
||||
return total
|
||||
|
||||
|
||||
def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> None:
|
||||
@@ -110,7 +123,6 @@ def update_target_stars(target_type: str, target_uid: str, net_stars: int) -> No
|
||||
return
|
||||
if target_type in STAR_TARGETS:
|
||||
get_table(table_name).update({"uid": target_uid, "stars": net_stars}, ["uid"])
|
||||
_authors_cache.clear()
|
||||
|
||||
|
||||
def soft_delete_engagement(target_type: str, target_uids: list, deleted_by: str) -> None:
|
||||
|
||||
@@ -37,9 +37,11 @@ def init_db():
|
||||
_index(db, "users", "idx_users_api_key", ["api_key"])
|
||||
_index(db, "users", "idx_users_role", ["role", "created_at"])
|
||||
_index(db, "users", "idx_users_last_seen", ["last_seen"])
|
||||
_index(db, "users", "idx_users_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_user_uid", ["user_uid"])
|
||||
_index(db, "posts", "idx_posts_created_at", ["created_at"])
|
||||
_index(db, "posts", "idx_posts_topic", ["topic"])
|
||||
_index(db, "posts", "idx_posts_slug", ["slug"])
|
||||
if "posts" in tables:
|
||||
posts_table = get_table("posts")
|
||||
if not posts_table.has_column("tags"):
|
||||
@@ -120,6 +122,15 @@ def init_db():
|
||||
_index(db, "votes", "idx_votes_target", ["target_uid", "target_type"])
|
||||
_index(db, "messages", "idx_messages_sender", ["sender_uid"])
|
||||
_index(db, "messages", "idx_messages_receiver", ["receiver_uid"])
|
||||
_index(
|
||||
db, "messages", "idx_messages_conversation", ["sender_uid", "receiver_uid"]
|
||||
)
|
||||
_index(
|
||||
db,
|
||||
"messages",
|
||||
"idx_messages_conversation_rev",
|
||||
["receiver_uid", "sender_uid"],
|
||||
)
|
||||
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
|
||||
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
|
||||
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
|
||||
@@ -140,6 +151,7 @@ def init_db():
|
||||
projects.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "projects", "idx_projects_user", ["user_uid"])
|
||||
_index(db, "projects", "idx_projects_slug", ["slug"])
|
||||
_index(db, "projects", "idx_projects_private", ["is_private"])
|
||||
_index(db, "projects", "idx_projects_stars", ["stars"])
|
||||
_index(db, "projects", "idx_projects_type", ["project_type"])
|
||||
@@ -170,6 +182,7 @@ def init_db():
|
||||
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
|
||||
)
|
||||
_index(db, "badges", "idx_badges_user", ["user_uid"])
|
||||
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
|
||||
_index(db, "follows", "idx_follows_follower", ["follower_uid"])
|
||||
_index(db, "follows", "idx_follows_following", ["following_uid"])
|
||||
user_relations = get_table("user_relations")
|
||||
@@ -189,6 +202,7 @@ def init_db():
|
||||
_index(db, "password_resets", "idx_password_resets_token", ["token"])
|
||||
_index(db, "gists", "idx_gists_user_uid", ["user_uid"])
|
||||
_index(db, "gists", "idx_gists_language", ["language"])
|
||||
_index(db, "gists", "idx_gists_slug", ["slug"])
|
||||
attachments = get_table("attachments")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
@@ -244,6 +258,7 @@ def init_db():
|
||||
db["site_settings"].insert(
|
||||
{"uid": f"default_{key}", "key": key, "value": value}
|
||||
)
|
||||
_index(db, "site_settings", "idx_site_settings_key", ["key"])
|
||||
|
||||
news = get_table("news")
|
||||
for column, example in (
|
||||
@@ -272,6 +287,7 @@ def init_db():
|
||||
news.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "news", "idx_news_external_id", ["external_id"])
|
||||
_index(db, "news", "idx_news_slug", ["slug"])
|
||||
_index(db, "news", "idx_news_synced_at", ["synced_at"])
|
||||
_index(db, "news", "idx_news_status", ["status"])
|
||||
_index(db, "news", "idx_news_featured", ["featured"])
|
||||
@@ -457,6 +473,8 @@ def init_db():
|
||||
instances.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "instances", "idx_instances_project", ["project_uid"])
|
||||
_index(db, "instances", "idx_instances_slug", ["slug"])
|
||||
_index(db, "instances", "idx_instances_name", ["name"])
|
||||
_index(db, "instances", "idx_instances_state", ["desired_state", "status"])
|
||||
_index(db, "instances", "idx_instances_container", ["container_id"])
|
||||
_index(db, "instances", "idx_instances_ingress", ["ingress_slug"])
|
||||
@@ -791,6 +809,108 @@ def init_db():
|
||||
deepsearch_url_cache.create_column_by_example(column, example)
|
||||
_index(db, "deepsearch_url_cache", "idx_deepsearch_url_cache_hash", ["url_hash"])
|
||||
|
||||
isslop_analyses = get_table("isslop_analyses")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("owner_kind", ""),
|
||||
("owner_id", ""),
|
||||
("source_url", ""),
|
||||
("source_kind", ""),
|
||||
("status", ""),
|
||||
("created_at", ""),
|
||||
("finished_at", ""),
|
||||
("content_hash", ""),
|
||||
("grade", ""),
|
||||
("slop_score", 0.0),
|
||||
("origin_score", 0.0),
|
||||
("quality_deficit_score", 0.0),
|
||||
("human_percent", 0.0),
|
||||
("ai_percent", 0.0),
|
||||
("category", ""),
|
||||
("confidence", ""),
|
||||
("files_total", 0),
|
||||
("files_analyzed", 0),
|
||||
("error_message_text", ""),
|
||||
("detected_builder", ""),
|
||||
("dom_slop_score", 0.0),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not isslop_analyses.has_column(column):
|
||||
isslop_analyses.create_column_by_example(column, example)
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_owner", ["owner_kind", "owner_id", "created_at"])
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_status", ["status"])
|
||||
_index(db, "isslop_analyses", "idx_isslop_analyses_hash", ["content_hash"])
|
||||
|
||||
isslop_events = get_table("isslop_events")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("seq", 0),
|
||||
("kind", ""),
|
||||
("message", ""),
|
||||
("payload", ""),
|
||||
("created_at", ""),
|
||||
):
|
||||
if not isslop_events.has_column(column):
|
||||
isslop_events.create_column_by_example(column, example)
|
||||
_index(db, "isslop_events", "idx_isslop_events_analysis", ["analysis_uid", "seq"])
|
||||
|
||||
isslop_file_results = get_table("isslop_file_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("path", ""),
|
||||
("language", ""),
|
||||
("lines", 0),
|
||||
("origin_score", 0.0),
|
||||
("quality_deficit_score", 0.0),
|
||||
("category", ""),
|
||||
("signals", ""),
|
||||
("source", ""),
|
||||
):
|
||||
if not isslop_file_results.has_column(column):
|
||||
isslop_file_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_file_results", "idx_isslop_file_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_image_results = get_table("isslop_image_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("path", ""),
|
||||
("ai_probability", 0.0),
|
||||
("grade", ""),
|
||||
("verdict", ""),
|
||||
("image_kind", ""),
|
||||
("tells", ""),
|
||||
("description", ""),
|
||||
("thumb", ""),
|
||||
):
|
||||
if not isslop_image_results.has_column(column):
|
||||
isslop_image_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_image_results", "idx_isslop_image_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_dom_results = get_table("isslop_dom_results")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("url", ""),
|
||||
("detected_builder", ""),
|
||||
("signal_count", 0),
|
||||
("screenshot", ""),
|
||||
("signals", ""),
|
||||
):
|
||||
if not isslop_dom_results.has_column(column):
|
||||
isslop_dom_results.create_column_by_example(column, example)
|
||||
_index(db, "isslop_dom_results", "idx_isslop_dom_results_analysis", ["analysis_uid"])
|
||||
|
||||
isslop_reports = get_table("isslop_reports")
|
||||
for column, example in (
|
||||
("analysis_uid", ""),
|
||||
("markdown", ""),
|
||||
("model_used", ""),
|
||||
("generated_at", ""),
|
||||
):
|
||||
if not isslop_reports.has_column(column):
|
||||
isslop_reports.create_column_by_example(column, example)
|
||||
_index(db, "isslop_reports", "idx_isslop_reports_analysis", ["analysis_uid"], unique=True)
|
||||
|
||||
game_farms = get_table("game_farms")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
|
||||
@@ -35,6 +35,7 @@ SOFT_DELETE_TABLES = [
|
||||
"notification_preferences",
|
||||
"deepsearch_sessions",
|
||||
"deepsearch_messages",
|
||||
"isslop_analyses",
|
||||
"devrant_tokens",
|
||||
"access_tokens",
|
||||
"email_accounts",
|
||||
|
||||
@@ -458,7 +458,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
|
||||
@@ -22,8 +22,9 @@ distinguish the form type).
|
||||
The POST endpoints are **actions**: they accept form fields, set or clear the `session` cookie,
|
||||
and return a `302` redirect (or the JSON envelope for JSON callers).
|
||||
|
||||
**Sign-up requires a valid `g-recaptcha-response`** when reCAPTCHA is enabled. Use the JSON
|
||||
envelope to see validation errors as `{ "error": "validation", "fields": {...} }`.
|
||||
**Sign-up requires a unique `username` and `email`** plus a `confirm_password` that matches the
|
||||
password; **you log in with your `email` and password**. JSON callers receive validation errors
|
||||
as a `422` with the shape `{ "fields": {...}, "messages": [...] }`.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@@ -45,10 +46,10 @@ envelope to see validation errors as `{ "error": "validation", "fields": {...} }
|
||||
encoding="form",
|
||||
destructive=False,
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Username, 3-20 characters."),
|
||||
field("username", "form", "string", True, "alice", "Username, 3-32 characters (letters, numbers, hyphens, underscores)."),
|
||||
field("email", "form", "string", True, "alice@example.com", "Email address; must be unique and contain an @."),
|
||||
field("password", "form", "string", True, "mysecret", "Password, 6+ characters."),
|
||||
field("confirm_password", "form", "string", True, "mysecret", "Must match password."),
|
||||
field("g-recaptcha-response", "form", "string", False, "", "reCAPTCHA token when enabled."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
@@ -68,12 +69,13 @@ envelope to see validation errors as `{ "error": "validation", "fields": {...} }
|
||||
method="POST",
|
||||
path="/auth/login",
|
||||
title="Log in",
|
||||
summary="Authenticate with username and password. Sets the session cookie.",
|
||||
summary="Authenticate with email and password. Sets the session cookie.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("username", "form", "string", True, "alice", "Your username."),
|
||||
field("email", "form", "string", True, "alice@example.com", "Your registered email."),
|
||||
field("password", "form", "string", True, "mysecret", "Your password."),
|
||||
field("remember_me", "form", "string", False, "on", "Send 'on' to extend the session to the remember-me lifetime."),
|
||||
field("next", "form", "string", False, "", "Redirect target after login."),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -9,9 +9,10 @@ GROUP = {
|
||||
"intro": """
|
||||
# Container Manager
|
||||
|
||||
Build versioned Docker images for a project and run supervised container instances. Every endpoint is
|
||||
**administrator only** (running arbitrary Dockerfiles with docker socket access is root-equivalent).
|
||||
Mutations flip desired state; a single reconciler converges containers to it.
|
||||
Run supervised container instances for a project. There is no in-app image building: every instance
|
||||
runs one shared prebuilt image (`ppy:latest`) with the project's workspace mounted at `/app`. Every
|
||||
endpoint is **administrator only** (docker socket access is root-equivalent). Mutations flip desired
|
||||
state; a single reconciler converges containers to it.
|
||||
""",
|
||||
"endpoints": [
|
||||
endpoint(
|
||||
@@ -118,7 +119,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
"python app.py",
|
||||
"Optional boot command.",
|
||||
),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field(
|
||||
@@ -478,7 +479,7 @@ Mutations flip desired state; a single reconciler converges containers to it.
|
||||
params=[
|
||||
field("project_slug", "form", "string", True, "PROJECT_SLUG", "Project that becomes the /app root."),
|
||||
field("name", "form", "string", True, "staging", "Instance name."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (PRAVDA_API_KEY, PRAVDA_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("run_as_uid", "form", "string", False, "USER_UID", "DevPlace user uid whose identity and API key are injected (DEVPLACE_API_KEY, DEVPLACE_USER_UID). Does NOT change the container OS user (always pravda, uid 1000)."),
|
||||
field("boot_language", "form", "enum", False, "none", "Boot source language.", ["none", "python", "bash"]),
|
||||
field("boot_script", "form", "textarea", False, "print('hi')", "Boot source code run on launch (takes precedence over boot_command)."),
|
||||
field("boot_command", "form", "string", False, "python app.py", "Fallback boot command when no boot_script is set."),
|
||||
|
||||
@@ -344,7 +344,7 @@ four ways to sign requests.
|
||||
method="POST",
|
||||
path="/profile/{username}/notifications",
|
||||
title="Toggle a notification preference",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
summary="Enable or disable one notification type on one channel (in-app or push). Admins may target any user. Types: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
@@ -363,7 +363,7 @@ four ways to sign requests.
|
||||
"string",
|
||||
True,
|
||||
"vote",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue.",
|
||||
"One of: comment, reply, mention, vote, follow, message, badge, level, issue, reminder, harvest_stolen.",
|
||||
),
|
||||
field(
|
||||
"channel",
|
||||
|
||||
@@ -4,7 +4,7 @@ from .._shared import endpoint, field
|
||||
|
||||
GROUP = {
|
||||
"slug": "tools",
|
||||
"title": "Tools (SEO & DeepSearch)",
|
||||
"title": "Tools (SEO, DeepSearch & AI Usage Analyzer)",
|
||||
"intro": """
|
||||
# Tools: SEO Diagnostics & DeepSearch
|
||||
|
||||
@@ -17,6 +17,10 @@ technical, on-page, structured-data, Core Web Vitals, accessibility and AI-readi
|
||||
synthesises a cited report with confidence scoring and gap analysis, plus a grounded chat over
|
||||
the results.
|
||||
|
||||
**AI Usage Analyzer** classifies a git repository or website as AI slop, sophisticated AI-assisted
|
||||
work or genuine human work, and publishes a persistent report with an embeddable authenticity
|
||||
badge.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html). These are
|
||||
**capability URLs**: the job `uid` is an unguessable identifier, so anyone holding it can read the
|
||||
status and report.
|
||||
@@ -229,5 +233,190 @@ status and report.
|
||||
"export_pdf_url": "/tools/deepsearch/DEEPSEARCH_JOB_UID/export.pdf",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-run",
|
||||
method="POST",
|
||||
path="/tools/isslop/run",
|
||||
title="Queue a AI usage analysis",
|
||||
summary="Start a background authenticity analysis of a git repository or website. Returns the job uid plus status, events and report URLs.",
|
||||
auth="public",
|
||||
encoding="form",
|
||||
params=[
|
||||
field("url", "form", "string", True, "https://github.com/owner/repository", "Repository (http/git/ssh) or website URL to classify."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status_url": "/tools/isslop/ISSLOP_UID",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-list",
|
||||
method="GET",
|
||||
path="/tools/isslop/list",
|
||||
title="My AI usage analyses",
|
||||
summary="List the caller's analyses, newest first. Member history is account-bound; guest history is session-bound and claimed by the account on first signed-in call.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("limit", "query", "integer", False, "50", "Maximum analyses to return (1-200)."),
|
||||
],
|
||||
sample_response={
|
||||
"analyses": [
|
||||
{
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-status",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}",
|
||||
title="AI usage analysis status",
|
||||
summary="Poll an analysis. Once completed, grade, category and the human/AI split are populated.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid returned when the run was queued."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"source_kind": "git",
|
||||
"grade": "B",
|
||||
"slop_score": 31.2,
|
||||
"origin_score": 28.0,
|
||||
"quality_deficit_score": 22.5,
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"confidence": "medium",
|
||||
"files_total": 120,
|
||||
"files_analyzed": 96,
|
||||
"report_url": "/tools/isslop/ISSLOP_UID/report",
|
||||
"badge_url": "/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"events_url": "/tools/isslop/ISSLOP_UID/events",
|
||||
"topic": "public.isslop.ISSLOP_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-events",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/events",
|
||||
title="AI usage analysis event trail",
|
||||
summary="The persisted, ordered event trail of an analysis. Use ?after=SEQ to poll incrementally; live frames also stream on the pub/sub topic.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("after", "query", "integer", False, "0", "Return only events with a sequence number greater than this."),
|
||||
field("limit", "query", "integer", False, "2000", "Maximum events to return (1-5000)."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "running",
|
||||
"events": [
|
||||
{"seq": 1, "kind": "stage", "message": "Resolving source type", "data": {"stage": "resolve"}, "created_at": "2026-06-14T10:00:00+00:00"}
|
||||
],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report",
|
||||
title="AI usage analysis report",
|
||||
summary="Full report: verdict, markdown body, per-file results, image review and badge embeds. Negotiates HTML or JSON.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"status": "completed",
|
||||
"source_url": "https://github.com/owner/repository",
|
||||
"grade": "B",
|
||||
"human_percent": 71.4,
|
||||
"ai_percent": 28.6,
|
||||
"category": "human-clean",
|
||||
"markdown": "# Verdict...",
|
||||
"generator_model": "molodetz",
|
||||
"badge": {
|
||||
"badge_url": "https://devplace.example/tools/isslop/ISSLOP_UID/badge.svg",
|
||||
"report_url": "https://devplace.example/tools/isslop/ISSLOP_UID/report",
|
||||
"markdown": "[](...)",
|
||||
"html": "<a href=...><img src=.../></a>",
|
||||
},
|
||||
"files": [{"path": "src/main.py", "language": "python", "lines": 120, "origin_score": 35.0, "quality_deficit_score": 18.0, "category": "human-clean", "signals": []}],
|
||||
"images": [{"path": "assets/hero.png", "ai_probability": 84.0, "grade": "F", "verdict": "ai-generated", "image_kind": "illustration", "tells": ["waxy skin"], "description": "...", "thumb_url": "/tools/isslop/ISSLOP_UID/media/0f3a9c2d1b4e5a67.webp"}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-report-md",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/report.md",
|
||||
title="Download report markdown",
|
||||
summary="Download the full report as a markdown file.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid of a finished run."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-source",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/source",
|
||||
title="Annotated source of a flagged file",
|
||||
summary="The persisted source of a signal-bearing file with its signals, rendered with line numbers and highlighted findings (HTML) or as JSON. Linked from the report's file table, signal chips and prose.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("path", "query", "string", True, "src/libs/Env.ts", "Workspace-relative file path from the report."),
|
||||
field("line", "query", "integer", False, "12", "Line to focus and highlight."),
|
||||
],
|
||||
sample_response={
|
||||
"uid": "ISSLOP_UID",
|
||||
"path": "src/libs/Env.ts",
|
||||
"language": "typescript",
|
||||
"category": "human-clean",
|
||||
"origin_score": 24.0,
|
||||
"quality_deficit_score": 34.9,
|
||||
"source": "import { createEnv } from '@t3-oss/env-nextjs';...",
|
||||
"truncated": False,
|
||||
"signals": [{"code": "PUBLIC_ENV_SECRET", "title": "Secret exposed via public env variable", "severity": "strong", "line": 12}],
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-media",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/media/{name}",
|
||||
title="Reviewed image thumbnail",
|
||||
summary="Aspect-preserving WebP thumbnail of a reviewed image, persisted as evidence. The name comes from the report's images[].thumb_url.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
field("name", "path", "string", True, "0f3a9c2d1b4e5a67.webp", "Thumbnail file name from the report."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="tools-isslop-badge",
|
||||
method="GET",
|
||||
path="/tools/isslop/{uid}/badge.svg",
|
||||
title="Authenticity badge",
|
||||
summary="Embeddable SVG badge showing the human score and authenticity grade, linking to the report.",
|
||||
auth="public",
|
||||
params=[
|
||||
field("uid", "path", "string", True, "ISSLOP_UID", "Analysis uid."),
|
||||
],
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -16,10 +16,37 @@ _RENDER_BLOCK = re.compile(
|
||||
r'<div class="docs-content" data-render>(.*?)</div>', re.DOTALL
|
||||
)
|
||||
|
||||
_HEADING = re.compile(r"<h([23])>(.*?)</h\1>", re.DOTALL)
|
||||
|
||||
|
||||
def heading_slug(text: str) -> str:
|
||||
plain = html.unescape(re.sub(r"<[^>]+>", "", text)).strip().lower()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", plain).strip("-")
|
||||
return slug or "section"
|
||||
|
||||
|
||||
def _anchor_headings(rendered: str) -> str:
|
||||
seen: dict[str, int] = {}
|
||||
|
||||
def _inject(match: re.Match) -> str:
|
||||
level, inner = match.group(1), match.group(2)
|
||||
slug = heading_slug(inner)
|
||||
count = seen.get(slug, 0)
|
||||
seen[slug] = count + 1
|
||||
if count:
|
||||
slug = f"{slug}-{count}"
|
||||
return (
|
||||
f'<h{level} id="{slug}">{inner}'
|
||||
f'<a class="docs-heading-anchor" href="#{slug}" aria-label="Link to this section">#</a>'
|
||||
f"</h{level}>"
|
||||
)
|
||||
|
||||
return _HEADING.sub(_inject, rendered)
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _render_markdown(source: str) -> str:
|
||||
return _markdown(source)
|
||||
return _anchor_headings(_markdown(source))
|
||||
|
||||
|
||||
def _convert(match: re.Match) -> str:
|
||||
|
||||
+14
-1
@@ -104,6 +104,7 @@ from devplacepy.services.presence_relay import PresenceRelayService
|
||||
from devplacepy.services import presence
|
||||
from devplacepy.services.correction import PENDING_SCOPE_KEY
|
||||
from devplacepy.services.jobs.deepsearch.service import DeepsearchService
|
||||
from devplacepy.services.jobs.isslop.service import IsslopService
|
||||
from devplacepy.services.gitea.service import IssueTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.xmlrpc import XmlrpcService
|
||||
@@ -214,6 +215,7 @@ class UploadStaticFiles(StaticFiles):
|
||||
else "attachment"
|
||||
)
|
||||
response.headers["Content-Disposition"] = disposition
|
||||
response.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return response
|
||||
|
||||
|
||||
@@ -227,6 +229,16 @@ class CachedStaticFiles(StaticFiles):
|
||||
return response
|
||||
|
||||
|
||||
class FallbackStaticFiles(StaticFiles):
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
if Path(path).name == "service-worker.js":
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
else:
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
ensure_data_dirs()
|
||||
@@ -250,6 +262,7 @@ async def lifespan(app: FastAPI):
|
||||
service_manager.register(LiveViewRelayService())
|
||||
service_manager.register(PresenceRelayService())
|
||||
service_manager.register(DeepsearchService())
|
||||
service_manager.register(IsslopService())
|
||||
service_manager.register(IssueCreateService())
|
||||
service_manager.register(PlanningReportService())
|
||||
service_manager.register(IssueTrackerService())
|
||||
@@ -292,7 +305,7 @@ app.mount(
|
||||
CachedStaticFiles(directory=str(STATIC_DIR)),
|
||||
name="static_versioned",
|
||||
)
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
app.mount("/static", FallbackStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
@@ -460,6 +462,26 @@ class SeoRunForm(BaseModel):
|
||||
return text
|
||||
|
||||
|
||||
ISSLOP_URL_PATTERN = re.compile(r"^(https?://|git://|ssh://|git@)[\w./:@~^-]+$", re.IGNORECASE)
|
||||
ISSLOP_SINGLE_SLASH_PATTERN = re.compile(r"^(https?|git|ssh):/(?!/)", re.IGNORECASE)
|
||||
ISSLOP_SCHEME_PATTERN = re.compile(r"^[a-z][a-z0-9+.-]*://", re.IGNORECASE)
|
||||
|
||||
|
||||
class IsslopRunForm(BaseModel):
|
||||
url: str = Field(min_length=4, max_length=2048)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def url_scheme(cls, value):
|
||||
text = value.strip()
|
||||
text = ISSLOP_SINGLE_SLASH_PATTERN.sub(lambda match: f"{match.group(1)}://", text)
|
||||
if not ISSLOP_SCHEME_PATTERN.match(text) and not text.startswith("git@"):
|
||||
text = f"https://{text}"
|
||||
if not ISSLOP_URL_PATTERN.match(text):
|
||||
raise ValueError("URL must be an http(s), git or ssh source location")
|
||||
return text
|
||||
|
||||
|
||||
DEEPSEARCH_MIN_DEPTH = 1
|
||||
DEEPSEARCH_MAX_DEPTH = 4
|
||||
DEEPSEARCH_DEFAULT_DEPTH = 2
|
||||
|
||||
@@ -148,6 +148,18 @@ DOCS_PAGES = [
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "tools-isslop",
|
||||
"title": "AI Usage Analyzer",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
{
|
||||
"slug": "isslop-checks",
|
||||
"title": "AI Usage Analyzer checks",
|
||||
"kind": "prose",
|
||||
"section": SECTION_TOOLS,
|
||||
},
|
||||
# Claude Code - the native subagent, command, and workflow setup under .claude/
|
||||
{
|
||||
"slug": "claude",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@@ -93,18 +94,28 @@ async def issue_detail(request: Request, number: int):
|
||||
if not gitea_config().is_configured:
|
||||
raise not_found("Issue not found")
|
||||
client = runtime.get_client()
|
||||
try:
|
||||
issue = await client.get_issue(number)
|
||||
except GiteaError as exc:
|
||||
if exc.status == 404:
|
||||
issue_result, comments_result = await asyncio.gather(
|
||||
client.get_issue(number),
|
||||
client.list_comments(number),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(issue_result, GiteaError):
|
||||
if issue_result.status == 404:
|
||||
raise not_found("Issue not found")
|
||||
logger.warning("Could not load issue #%s: %s", number, exc)
|
||||
logger.warning("Could not load issue #%s: %s", number, issue_result)
|
||||
return tracker_unavailable(request)
|
||||
try:
|
||||
comments = await client.list_comments(number)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not load comments for issue #%s: %s", number, exc)
|
||||
if isinstance(issue_result, BaseException):
|
||||
raise issue_result
|
||||
issue = issue_result
|
||||
if isinstance(comments_result, BaseException):
|
||||
if not isinstance(comments_result, GiteaError):
|
||||
raise comments_result
|
||||
logger.warning(
|
||||
"Could not load comments for issue #%s: %s", number, comments_result
|
||||
)
|
||||
comments = []
|
||||
else:
|
||||
comments = comments_result
|
||||
|
||||
if user:
|
||||
mark_notifications_read_by_target(user["uid"], f"/issues?highlight={number}")
|
||||
|
||||
@@ -42,6 +42,8 @@ router = APIRouter()
|
||||
|
||||
MAX_WS_ATTACHMENTS = 5
|
||||
|
||||
CONVERSATION_MESSAGE_LIMIT = 500
|
||||
|
||||
def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
if "messages" not in db.tables:
|
||||
return
|
||||
@@ -54,64 +56,65 @@ def mark_conversation_read(user_uid: str, other_uid: str) -> None:
|
||||
clear_messages_cache(user_uid)
|
||||
|
||||
def get_conversations(user_uid: str):
|
||||
messages_table = get_table("messages")
|
||||
raw = list(messages_table.find(sender_uid=user_uid)) + list(
|
||||
messages_table.find(receiver_uid=user_uid)
|
||||
)
|
||||
seen = set()
|
||||
all_messages = []
|
||||
for m in raw:
|
||||
if m["uid"] not in seen:
|
||||
seen.add(m["uid"])
|
||||
all_messages.append(m)
|
||||
|
||||
blocked = get_blocked_uids(user_uid)
|
||||
conversation_map = {}
|
||||
other_uids = set()
|
||||
for msg in all_messages:
|
||||
other_uid = (
|
||||
msg["receiver_uid"] if msg["sender_uid"] == user_uid else msg["sender_uid"]
|
||||
if "messages" not in db.tables:
|
||||
return []
|
||||
latest = list(
|
||||
db.query(
|
||||
"SELECT * FROM ("
|
||||
" SELECT *,"
|
||||
" CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END AS other_uid,"
|
||||
" ROW_NUMBER() OVER ("
|
||||
" PARTITION BY CASE WHEN sender_uid = :me THEN receiver_uid ELSE sender_uid END"
|
||||
" ORDER BY created_at DESC, id DESC"
|
||||
" ) AS rn"
|
||||
" FROM messages"
|
||||
" WHERE sender_uid = :me OR receiver_uid = :me"
|
||||
") WHERE rn = 1 ORDER BY created_at DESC",
|
||||
me=user_uid,
|
||||
)
|
||||
)
|
||||
blocked = get_blocked_uids(user_uid)
|
||||
conversations = []
|
||||
other_uids = []
|
||||
for msg in latest:
|
||||
other_uid = msg["other_uid"]
|
||||
if other_uid in blocked:
|
||||
continue
|
||||
other_uids.add(other_uid)
|
||||
if (
|
||||
other_uid not in conversation_map
|
||||
or msg["created_at"] > conversation_map[other_uid]["last_message_at"]
|
||||
):
|
||||
conversation_map[other_uid] = {
|
||||
other_uids.append(other_uid)
|
||||
conversations.append(
|
||||
{
|
||||
"other_uid": other_uid,
|
||||
"other_user": None,
|
||||
"last_message": msg["content"],
|
||||
"last_message_at": msg["created_at"],
|
||||
"unread": msg["receiver_uid"] == user_uid and not msg["read"],
|
||||
}
|
||||
|
||||
)
|
||||
if other_uids:
|
||||
users_map = get_users_by_uids(list(other_uids))
|
||||
for uid, conv in conversation_map.items():
|
||||
conv["other_user"] = users_map.get(uid)
|
||||
|
||||
conversations = sorted(
|
||||
conversation_map.values(),
|
||||
key=lambda c: c["last_message_at"],
|
||||
reverse=True,
|
||||
)
|
||||
users_map = get_users_by_uids(other_uids)
|
||||
for conv in conversations:
|
||||
conv["other_user"] = users_map.get(conv["other_uid"])
|
||||
for conv in conversations:
|
||||
conv.pop("other_uid", None)
|
||||
return conversations
|
||||
|
||||
def get_conversation_messages(user_uid: str, other_uid: str):
|
||||
if other_uid in get_blocked_uids(user_uid):
|
||||
return [], None
|
||||
messages_table = get_table("messages")
|
||||
raw = list(messages_table.find(sender_uid=user_uid, receiver_uid=other_uid)) + list(
|
||||
messages_table.find(sender_uid=other_uid, receiver_uid=user_uid)
|
||||
if "messages" not in db.tables:
|
||||
return [], get_users_by_uids([other_uid]).get(other_uid)
|
||||
msgs = list(
|
||||
db.query(
|
||||
"SELECT * FROM messages"
|
||||
" WHERE (sender_uid = :me AND receiver_uid = :other)"
|
||||
" OR (sender_uid = :other AND receiver_uid = :me)"
|
||||
" ORDER BY created_at DESC, id DESC LIMIT :lim",
|
||||
me=user_uid,
|
||||
other=other_uid,
|
||||
lim=CONVERSATION_MESSAGE_LIMIT,
|
||||
)
|
||||
)
|
||||
seen = set()
|
||||
msgs = []
|
||||
for m in raw:
|
||||
if m["uid"] not in seen:
|
||||
seen.add(m["uid"])
|
||||
msgs.append(m)
|
||||
msgs.sort(key=lambda m: m["created_at"])
|
||||
msgs.reverse()
|
||||
|
||||
user_ids = list({m["sender_uid"] for m in msgs} | {other_uid})
|
||||
users_map = get_users_by_uids(user_ids)
|
||||
|
||||
@@ -40,7 +40,7 @@ from devplacepy.utils import (
|
||||
track_action,
|
||||
build_achievements,
|
||||
)
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.responses import respond, action_result, wants_json
|
||||
from devplacepy.schemas import ProfileOut
|
||||
from devplacepy.avatar import avatar_url, avatar_seed
|
||||
from devplacepy.seo import (
|
||||
@@ -181,16 +181,20 @@ async def profile_page(
|
||||
achievements = build_achievements({b["badge_name"] for b in badges})
|
||||
badge_total = sum(group["total"] for group in achievements)
|
||||
badge_earned = sum(group["earned"] for group in achievements)
|
||||
projects = list(
|
||||
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
projects = [p for p in projects if can_view_project(p, current_user)]
|
||||
gists_raw = list(
|
||||
get_table("gists").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
include_collections = wants_json(request)
|
||||
projects = []
|
||||
if tab == "projects" or include_collections:
|
||||
projects = list(
|
||||
get_table("projects").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
projects = [p for p in projects if can_view_project(p, current_user)]
|
||||
gists = []
|
||||
for g in gists_raw:
|
||||
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
|
||||
if tab == "gists" or include_collections:
|
||||
gists_raw = list(
|
||||
get_table("gists").find(user_uid=profile_user["uid"], deleted_at=None)
|
||||
)
|
||||
for g in gists_raw:
|
||||
gists.append({"gist": g, "time_ago": time_ago(g["created_at"])})
|
||||
posts_count = get_table("posts").count(
|
||||
user_uid=profile_user["uid"], deleted_at=None
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from . import deepsearch, index, seo
|
||||
from . import deepsearch, index, isslop, seo
|
||||
|
||||
router = index.router
|
||||
router.include_router(seo.router, prefix="/seo")
|
||||
router.include_router(deepsearch.router, prefix="/deepsearch")
|
||||
router.include_router(isslop.router, prefix="/isslop")
|
||||
|
||||
@@ -26,6 +26,17 @@ TOOLS = [
|
||||
"structured-data, performance, accessibility and AI-readiness checks."
|
||||
),
|
||||
},
|
||||
{
|
||||
"slug": "isslop",
|
||||
"name": "AI Usage Analyzer",
|
||||
"icon": "🧪",
|
||||
"url": "/tools/isslop",
|
||||
"description": (
|
||||
"Measure how a codebase or website was made: untouched AI defaults, AI steered by a "
|
||||
"knowing hand, or work no model would ever produce. Multi-signal analysis, image "
|
||||
"forensics and a shareable authenticity badge."
|
||||
),
|
||||
},
|
||||
{
|
||||
"slug": "deepsearch",
|
||||
"name": "DeepSearch",
|
||||
|
||||
@@ -86,6 +86,10 @@ from devplacepy.schemas.jobs import (
|
||||
SeoMetaOut,
|
||||
SeoReportOut,
|
||||
ZipJobOut,
|
||||
IsslopAnalysisOut,
|
||||
IsslopListOut,
|
||||
IsslopReportOut,
|
||||
IsslopSourceOut,
|
||||
)
|
||||
from devplacepy.schemas.backups import (
|
||||
BackupDashboardOut,
|
||||
|
||||
@@ -153,3 +153,75 @@ class DbQueryJobOut(_Out):
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopAnalysisOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
source_url: str = ""
|
||||
source_kind: str = ""
|
||||
grade: Optional[str] = None
|
||||
slop_score: Optional[float] = None
|
||||
origin_score: Optional[float] = None
|
||||
quality_deficit_score: Optional[float] = None
|
||||
human_percent: Optional[float] = None
|
||||
ai_percent: Optional[float] = None
|
||||
category: Optional[str] = None
|
||||
confidence: Optional[str] = None
|
||||
files_total: int = 0
|
||||
files_analyzed: int = 0
|
||||
detected_builder: Optional[str] = None
|
||||
dom_slop_score: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
report_url: Optional[str] = None
|
||||
badge_url: Optional[str] = None
|
||||
events_url: Optional[str] = None
|
||||
topic: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopListOut(_Out):
|
||||
analyses: list = []
|
||||
|
||||
|
||||
class IsslopReportOut(_Out):
|
||||
uid: str = ""
|
||||
status: str = ""
|
||||
source_url: str = ""
|
||||
source_kind: str = ""
|
||||
grade: Optional[str] = None
|
||||
slop_score: Optional[float] = None
|
||||
origin_score: Optional[float] = None
|
||||
quality_deficit_score: Optional[float] = None
|
||||
human_percent: Optional[float] = None
|
||||
ai_percent: Optional[float] = None
|
||||
category: Optional[str] = None
|
||||
confidence: Optional[str] = None
|
||||
files_total: int = 0
|
||||
files_analyzed: int = 0
|
||||
detected_builder: Optional[str] = None
|
||||
dom_slop_score: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
content_hash: Optional[str] = None
|
||||
markdown: str = ""
|
||||
generator_model: str = ""
|
||||
generated_at: Optional[str] = None
|
||||
badge: dict = {}
|
||||
files: list = []
|
||||
images: list = []
|
||||
dom_pages: list = []
|
||||
created_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class IsslopSourceOut(_Out):
|
||||
uid: str = ""
|
||||
path: str = ""
|
||||
language: str = ""
|
||||
category: str = ""
|
||||
origin_score: float = 0.0
|
||||
quality_deficit_score: float = 0.0
|
||||
source: str = ""
|
||||
truncated: bool = False
|
||||
signals: list = []
|
||||
|
||||
@@ -32,6 +32,7 @@ CATEGORY_BY_PREFIX: dict[str, str] = {
|
||||
"proxy": "ingress",
|
||||
"seo": "tools",
|
||||
"deepsearch": "tools",
|
||||
"isslop": "tools",
|
||||
"ai": "ai",
|
||||
"database": "database",
|
||||
"pubsub": "pubsub",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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.')}"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
)
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -510,7 +510,7 @@ img {
|
||||
.badge-rant { background: var(--topic-rant); color: var(--white); }
|
||||
.badge-fun { background: var(--topic-fun); color: #000; }
|
||||
.badge-random { background: var(--border-light); color: var(--text-secondary); }
|
||||
.badge-signals { background: var(--topic-signals); color: var(--white); }
|
||||
.badge-politics { background: var(--topic-politics); color: var(--white); }
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
|
||||
@@ -681,3 +681,94 @@ pre.code-pre > .code-gutter {
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.docs-content h2[id],
|
||||
.docs-content h3[id] {
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
.docs-heading-anchor {
|
||||
margin-left: 8px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.docs-content h2:hover .docs-heading-anchor,
|
||||
.docs-content h3:hover .docs-heading-anchor {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.docs-heading-anchor:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.docs-toc {
|
||||
margin: 0 0 24px;
|
||||
padding: 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.docs-toc-heading {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.docs-toc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.docs-toc-item a {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.docs-toc-item a:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.docs-toc-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.docs-toc-count {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.docs-toc-summary {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
--topic-question: #448aff;
|
||||
--topic-rant: #ff5252;
|
||||
--topic-fun: #ffab00;
|
||||
--topic-signals: #00bcd4;
|
||||
--topic-politics: #00bcd4;
|
||||
|
||||
--bg-gradient: linear-gradient(135deg, #080413 0%, #160a28 50%, #080413 100%);
|
||||
}
|
||||
|
||||
@@ -256,7 +256,9 @@ export class ApiTester {
|
||||
buildNotes() {
|
||||
if (!this.config.notes.length) return null;
|
||||
const wrap = this.el("div", { class: "endpoint-notes" });
|
||||
wrap.innerHTML = contentRenderer.render(this.config.notes.join("\n\n"));
|
||||
contentRenderer.ready.then(() => {
|
||||
wrap.innerHTML = contentRenderer.render(this.config.notes.join("\n\n"));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ export class CommentManager {
|
||||
const result = await Http.send(form.action, { content });
|
||||
text.dataset.raw = result.content;
|
||||
text.textContent = result.content;
|
||||
await contentRenderer.ready;
|
||||
contentRenderer.applyTo(text);
|
||||
text.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
|
||||
@@ -14,14 +14,16 @@ export class ContentEnhancer {
|
||||
}
|
||||
|
||||
initContentRenderer() {
|
||||
document.querySelectorAll("[data-render]").forEach((el) => {
|
||||
contentRenderer.applyTo(el);
|
||||
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
contentRenderer.ready.then(() => {
|
||||
document.querySelectorAll("[data-render]").forEach((el) => {
|
||||
contentRenderer.applyTo(el);
|
||||
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
});
|
||||
});
|
||||
contentRenderer.highlightAll();
|
||||
this.enhanceCodeBlocks();
|
||||
});
|
||||
contentRenderer.highlightAll();
|
||||
this.enhanceCodeBlocks();
|
||||
}
|
||||
|
||||
enhanceCodeBlocks(root = document) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { EMOJI_SHORTCODES } from "./emoji-shortcodes.js";
|
||||
|
||||
export class ContentRenderer {
|
||||
constructor() {
|
||||
this.emojiMap = EMOJI_SHORTCODES;
|
||||
this.emojiMap = {};
|
||||
this.emojiLoaded = false;
|
||||
this.imageExtRe = /\.(jpg|jpeg|png|gif|webp|svg|bmp|webp)(\?.*)?$/i;
|
||||
this.videoExtRe = /\.(mp4|webm|ogg|ogv|mov|m4v)(\?.*)?$/i;
|
||||
this.audioExtRe = /\.(mp3|wav|flac|ogg|aac|m4a|wma|opus)(\?.*)?$/i;
|
||||
@@ -257,4 +256,12 @@ export class ContentRenderer {
|
||||
}
|
||||
|
||||
export const contentRenderer = new ContentRenderer();
|
||||
contentRenderer.ready = import("./emoji-shortcodes.js")
|
||||
.then((mod) => {
|
||||
contentRenderer.emojiMap = mod.EMOJI_SHORTCODES;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
contentRenderer.emojiLoaded = true;
|
||||
});
|
||||
window.contentRenderer = contentRenderer;
|
||||
|
||||
@@ -6,6 +6,17 @@ import { assetUrl } from "./assetVersion.js";
|
||||
|
||||
const EMOJI_DATA_SOURCE = assetUrl("/static/vendor/emoji-picker-element/data.json");
|
||||
|
||||
let pickerElementModule = null;
|
||||
|
||||
function loadPickerElement() {
|
||||
if (!pickerElementModule) {
|
||||
pickerElementModule = import(
|
||||
assetUrl("/static/vendor/emoji-picker-element/index.js")
|
||||
).catch(() => {});
|
||||
}
|
||||
return pickerElementModule;
|
||||
}
|
||||
|
||||
export class EmojiPicker {
|
||||
constructor(textarea) {
|
||||
this.textarea = textarea;
|
||||
@@ -48,6 +59,7 @@ export class EmojiPicker {
|
||||
}
|
||||
|
||||
toggle() {
|
||||
loadPickerElement();
|
||||
DomUtils.toggle(this.wrapper);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export class Http {
|
||||
|
||||
static async _messageFrom(response) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const message = (data && data.error && data.error.message) || `request failed with status ${response.status}`;
|
||||
const message = (data && data.error && data.error.message)
|
||||
|| (data && Array.isArray(data.messages) && data.messages.length ? data.messages.join(" ") : "")
|
||||
|| `request failed with status ${response.status}`;
|
||||
return { data, message };
|
||||
}
|
||||
|
||||
|
||||
@@ -337,11 +337,13 @@ export class MessagesLayout {
|
||||
}
|
||||
|
||||
renderBody(el) {
|
||||
contentRenderer.applyTo(el);
|
||||
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
contentRenderer.ready.then(() => {
|
||||
contentRenderer.applyTo(el);
|
||||
el.querySelectorAll("img:not(.avatar-img)").forEach((img) => {
|
||||
img.dataset.lightbox = "";
|
||||
});
|
||||
contentRenderer.highlightAll();
|
||||
});
|
||||
contentRenderer.highlightAll();
|
||||
}
|
||||
|
||||
renderAttachments(attachments) {
|
||||
|
||||
@@ -12,6 +12,10 @@ export class AppContent extends Component {
|
||||
window.addEventListener("load", () => this.connectedCallback(), { once: true });
|
||||
return;
|
||||
}
|
||||
if (!contentRenderer.emojiLoaded) {
|
||||
contentRenderer.ready.then(() => this.connectedCallback());
|
||||
return;
|
||||
}
|
||||
this._rendered = true;
|
||||
this._source = this.textContent;
|
||||
this.classList.add("rendered-content");
|
||||
|
||||
@@ -12,6 +12,10 @@ export class AppTitle extends Component {
|
||||
window.addEventListener("load", () => this.connectedCallback(), { once: true });
|
||||
return;
|
||||
}
|
||||
if (!contentRenderer.emojiLoaded) {
|
||||
contentRenderer.ready.then(() => this.connectedCallback());
|
||||
return;
|
||||
}
|
||||
this._rendered = true;
|
||||
this.classList.add("rendered-title");
|
||||
const source = (this.textContent || "").trim();
|
||||
|
||||
@@ -10,4 +10,6 @@ import "./AppDialog.js";
|
||||
import "./AppContextMenu.js";
|
||||
import "./AppLightbox.js";
|
||||
import "./AppDocsChat.js";
|
||||
import "./AppIsslop.js";
|
||||
import "./AppIsslopRun.js";
|
||||
import "./ContainerTerminal.js";
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="DevPlace">
|
||||
|
||||
<link rel="modulepreload" href="{{ static_url('/static/js/Application.js') }}">
|
||||
<link rel="modulepreload" href="{{ static_url('/static/js/components/index.js') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/variables.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/base.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/components.css') }}">
|
||||
@@ -82,6 +84,7 @@
|
||||
<div class="dropdown-menu" id="tools-menu">
|
||||
<a href="/tools/seo" class="dropdown-item"><span class="icon">🔍</span> SEO Diagnostics</a>
|
||||
<a href="/tools/deepsearch" class="dropdown-item"><span class="icon">🧠</span> DeepSearch</a>
|
||||
<a href="/tools/isslop" class="dropdown-item"><span class="icon">🧪</span> AI Usage Analyzer</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if user %}
|
||||
@@ -139,6 +142,7 @@
|
||||
<div class="topnav-mobile-section-label">Tools</div>
|
||||
<a href="/tools/seo" class="topnav-mobile-link {{ nav_active(request, '/tools/seo') }}"><span class="icon">🔍</span> SEO Diagnostics</a>
|
||||
<a href="/tools/deepsearch" class="topnav-mobile-link {{ nav_active(request, '/tools/deepsearch') }}"><span class="icon">🧠</span> DeepSearch</a>
|
||||
<a href="/tools/isslop" class="topnav-mobile-link {{ nav_active(request, '/tools/isslop') }}"><span class="icon">🧪</span> AI Usage Analyzer</a>
|
||||
{% if user %}
|
||||
<a href="/messages" class="topnav-mobile-link {{ nav_active(request, '/messages') }}" data-counter="messages" data-counter-noun="messages" aria-label="{% if get_unread_messages(user['uid']) %}Messages, {{ get_unread_messages(user['uid']) }} unread{% else %}Messages, no unread{% endif %}"><span class="icon">✉️</span> Messages<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_messages(user["uid"]) == 0 %}hidden{% endif %} aria-hidden="true">{{ get_unread_messages(user["uid"]) }}</span></a>
|
||||
<a href="/notifications" class="topnav-mobile-link {{ nav_active(request, '/notifications') }}" data-counter="notifications" data-counter-noun="notifications" aria-label="{% if get_unread_count(user['uid']) %}Notifications, {{ get_unread_count(user['uid']) }} unread{% else %}Notifications, no unread{% endif %}"><span class="icon">🔔</span> Notifications<span class="nav-badge nav-badge-inline" data-counter-badge {% if get_unread_count(user["uid"]) == 0 %}hidden{% endif %} aria-hidden="true">{{ get_unread_count(user["uid"]) }}</span></a>
|
||||
@@ -218,7 +222,6 @@
|
||||
<script defer src="{{ static_url('/static/vendor/marked.umd.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/highlight.min.js') }}"></script>
|
||||
<script defer src="{{ static_url('/static/vendor/purify.min.js') }}"></script>
|
||||
<script type="module" src="{{ static_url('/static/vendor/emoji-picker-element/index.js') }}"></script>
|
||||
<script type="module" src="{{ static_url('/static/js/Application.js') }}"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
{{ custom_js_tag(request) }}
|
||||
|
||||
@@ -11,14 +11,17 @@ How a request flows through middleware to a router, how the database layer is bu
|
||||
|
||||
## Middleware
|
||||
|
||||
Four HTTP middlewares run as a stack around every request:
|
||||
Seven HTTP middlewares run as a stack around every request, listed outermost first. `response_timing` is the outermost, `refresh_db_snapshot` the innermost, and a `GZipMiddleware` (responses over 512 bytes) wraps the whole stack on top:
|
||||
|
||||
| Middleware | Responsibility |
|
||||
| Middleware (outermost first) | Responsibility |
|
||||
|------------|----------------|
|
||||
| `refresh_db_snapshot` | Calls `refresh_snapshot()` so each request sees committed data. |
|
||||
| `add_security_headers` | Sets `X-Content-Type-Options: nosniff` always, and `X-Robots-Tag: index, follow` unless the handler already set it (so pages can opt out of indexing). |
|
||||
| `rate_limit_middleware` | Per-IP limit for write methods (POST/PUT/DELETE/PATCH), held in an in-process `defaultdict`. Reads `rate_limit_per_minute` / `rate_limit_window_seconds` from `site_settings`, floored to `max(1, ...)`, and is worker-count-aware. `/openai` is excluded. |
|
||||
| `response_timing` | Stamps `request.state.request_start` (a `perf_counter()` reading) and sets an `X-Response-Time: <ms>ms` header on every response (the full request total). |
|
||||
| `track_presence` | For the resolved current user on every non-asset request, calls `presence.touch(uid)` (a throttled `last_seen` write). |
|
||||
| `maintenance_middleware` | When `maintenance_mode="1"`, returns a 503 `error.html` for non-admins, but always allows `/static`, `/avatar`, `/auth`, `/admin`, `/openai`, and admin users, so an operator can never lock themselves out. |
|
||||
| `rate_limit_middleware` | Per-IP limit for mutating methods (POST/PUT/DELETE/PATCH), held in an in-process `defaultdict`. Reads `rate_limit_per_minute` / `rate_limit_window_seconds` from `site_settings`, floored to `max(1, ...)`, and is worker-count-aware. `/openai` and `/xmlrpc` are excluded. |
|
||||
| `add_security_headers` | Sets `X-Content-Type-Options: nosniff` always, `X-Robots-Tag: index, follow` unless the handler already set it (so pages can opt out of indexing), plus HSTS, `Referrer-Policy`, a CSP and `X-Frame-Options: DENY` (except the `/p/` ingress), and no-store cache headers on `/admin`. |
|
||||
| `await_pending_corrections` | After the handler runs, awaits any pending AI correction/modifier futures parked on `request.scope` (sync apply mode). |
|
||||
| `refresh_db_snapshot` | Calls `refresh_snapshot()` so each request sees committed data. |
|
||||
|
||||
## Routing
|
||||
|
||||
@@ -31,15 +34,15 @@ Routers in `routers/` form a **directory tree that mirrors the endpoint (URL) pa
|
||||
/gists /news /uploads /openai /devii
|
||||
```
|
||||
|
||||
`auth/`, `profile/`, `issues/`, `docs/`, `admin/`, and `projects/` are packages; the rest are flat files. The whole `/projects` tree (project CRUD + `files.py` + the `containers/` subpackage) and the whole `/admin` tree (every admin sub-resource plus the folded-in `services` and `containers`) each mount from a single package. `seo.py`, `push.py`, and the `docs/` package mount without a prefix.
|
||||
Multi-sub-resource domains are packages (`auth/`, `profile/`, `issues/`, `docs/`, `admin/`, `projects/`, `tools/`, `devrant/`, `dbapi/`, `game/`); single-resource domains stay flat files. The whole `/projects` tree (project CRUD + `files.py` + the `containers/` subpackage) and the whole `/admin` tree (every admin sub-resource plus the folded-in `services` and `containers`) each mount from a single package. `seo.py`, `push.py`, `relations.py`, and the `docs/` package mount without a prefix.
|
||||
|
||||
**Aggregation rules.** A leaf that owns the domain's collection-root (`""`) route is the package's base router (FastAPI rejects an empty path included under an empty prefix), so the package `__init__` imports that leaf's `router` and includes the rest onto it. A router folded in from a deeper mount keeps its own paths and is included with a sub-prefix (`admin/__init__` includes `services.router` with `prefix="/services"`). Package-private helpers shared by leaves live in `_shared.py`. Within any router, **specific paths must be declared before catch-alls**: `/{username}/followers` before `/{username}`.
|
||||
|
||||
## Lifecycle and services
|
||||
|
||||
`@app.on_event("startup")` runs `init_db()` and certificate setup under an exclusive init lock (`fcntl.flock` on `devplace-init.lock`) so multiple workers serialize first-boot side effects. It then registers `NewsService`, `BotsService`, `GatewayService`, and `DeviiService`.
|
||||
The app uses a `lifespan` async context manager (not the deprecated `@app.on_event` hooks). On startup it runs `init_db()` and certificate setup under an exclusive init lock (`fcntl.flock` on `devplace-init.lock`) so multiple workers serialize first-boot side effects. It then registers every background service (`NewsService`, `GatewayService`, `DeviiService`, the job services, the pub/sub and relay services, `ContainerService`, and more). On shutdown it calls `service_manager.shutdown_all()` and stops the background queue.
|
||||
|
||||
Background services follow a **single-owner model**. Each worker tries to acquire an exclusive non-blocking lock on `devplace-services.lock`; exactly one wins, becomes the lock owner, and calls `service_manager.supervise()`. The others run no services. This is why `/devii/ws` is served only by the lock-owning worker (others close with 1013 so the client reconnects to the owner), and why news is never fetched twice. Setting `DEVPLACE_DISABLE_SERVICES=1` skips all of this (the test conftest does so).
|
||||
Background services follow a **single-owner model**. Each worker tries to acquire an exclusive non-blocking lock on `devplace-services.lock`; exactly one wins, becomes the lock owner, and calls `service_manager.supervise()`. The others run no services. This is why `/devii/ws` is served only by the lock-owning worker (a non-owner worker closes with the application code 4013, which the client treats as a silent fast retry until it lands on the owner; the disabled-service path uses the standard 1013), and why news is never fetched twice. Setting `DEVPLACE_DISABLE_SERVICES=1` skips all of this (the test conftest does so).
|
||||
|
||||
## Database
|
||||
|
||||
@@ -72,7 +75,7 @@ In-process TTL caches accelerate hot reads but are **never authoritative**: `_se
|
||||
## Models, schemas, and responses
|
||||
|
||||
- **`models.py`** holds the Pydantic `Form` input models. Routers declare `data: Annotated[SomeForm, Form()]`; FastAPI validates, and the global `RequestValidationError` handler re-renders auth pages with messages (400) or redirects other routes.
|
||||
- **`schemas.py`** holds the `*Out` response projections. They ignore extra keys, so the full context dict can be passed through, but only declared fields reach the JSON. `*Out` models never expose `password_hash`, `email`, or `api_key`.
|
||||
- **`schemas/`** (a package) holds the `*Out` response projections. They ignore extra keys, so the full context dict can be passed through, but only declared fields reach the JSON. `*Out` models never expose `password_hash`, `email`, or `api_key`.
|
||||
- **`responses.py`** provides content negotiation. `wants_json(request)` is true for `Accept: application/json` or `Content-Type: application/json`. Page GETs return `respond(request, "x.html", ctx, model=XOut)` (HTML or validated JSON); action POSTs return `action_result(request, url, data=...)` (a 302 redirect or `{ok, redirect, data}` JSON).
|
||||
|
||||
## Auth
|
||||
|
||||
@@ -35,8 +35,8 @@ Add new query and batch helpers to `database.py` rather than inlining SQL in rou
|
||||
## Resource conventions
|
||||
|
||||
- **Dates are DD/MM/YYYY (European).** Use the `format_date()` template global, or `time_ago()` which switches to DD/MM/YYYY past 30 days.
|
||||
- **Slug + UUID lookup.** Resources with slugs (posts, gists, news, projects) accept either the slug or the bare UUID. Slugs are generated by `make_combined_slug(title, uid)`, which prefixes the first 8 characters of the UUID; resolution is `resolve_by_slug()`.
|
||||
- **Ownership checks and cascade deletes.** Compare `resource["user_uid"] == user["uid"]` before edit or delete. Deletes must cascade: comments, then votes, then the resource.
|
||||
- **Slug + UUID lookup.** Resources with slugs (posts, gists, news, projects) accept either the slug or the bare UUID. Slugs are generated by `make_combined_slug(title, uid)`, which prefixes the last 12 hex characters of the UUID (its random tail, since a uuid7's leading bytes are a millisecond timestamp and would collide for same-title rows written in the same window); resolution is `resolve_by_slug()`.
|
||||
- **Ownership checks and cascade deletes.** Compare `resource["user_uid"] == user["uid"]` (via `content.is_owner`) before edit or delete. Edits stay owner-only; deletes additionally allow any admin (`is_owner(...) or is_admin(user)`). Every removal is a **soft delete**: it stamps `deleted_at`/`deleted_by` instead of dropping the row, and cascades comments, then votes and engagement, then the resource under one shared stamp so the event restores or purges atomically. Only garbage collection is a hard delete.
|
||||
- **Username** is 3 to 32 characters of letters, numbers, hyphens, and underscores. **Password** is a minimum of 6 characters, and that is the only rule: no forced complexity, mandatory symbols, or higher length minimums, which only push people toward weaker, reused, or written-down passwords. Security against online guessing is handled by the per-IP rate limit on the request pipeline, not by complexity rules at the keyboard; brute force over a rate-limited login is not a credible threat model.
|
||||
- **Avatars** are Multiavatar SVGs generated locally from the username seed, in-memory cached, served from `/avatar/multiavatar/{seed}`.
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ Every component **renders into the light DOM (no shadow root)** so the global CS
|
||||
| `dp-avatar` | Renders a user avatar. |
|
||||
| `dp-code` | Renders a syntax-highlighted code block. |
|
||||
| `dp-content` | Client-side content renderer for live content; server content uses the backend `render_content` Jinja global. |
|
||||
| `dp-title` | Inline title-sized companion to `dp-content` for live titles. |
|
||||
| `dp-upload` | The single file-upload button used everywhere. |
|
||||
| `dp-lightbox` | The single full-size image lightbox (`app.lightbox`), attribute-wired via one delegated listener. |
|
||||
| `dp-toast` | Transient notifications (`app.toast`). |
|
||||
| `dp-dialog` | Confirm / prompt / alert modals (`app.dialog`). |
|
||||
| `dp-context-menu` | Right-click and long-press menus (`app.contextMenu`). |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Async job services
|
||||
|
||||
The async job framework (`services/jobs/`) runs heavy, blocking work off the request path and hands the caller a result URL when it finishes. It builds on the [service framework](/docs/services-framework.html): a `JobService` is a `BaseService` whose `run_once` drives a queue of database-backed jobs. [ZipService](/docs/services-zip.html) is the first consumer.
|
||||
The async job framework (`services/jobs/`) runs heavy, blocking work off the request path and hands the caller a result URL when it finishes. It builds on the [service framework](/docs/services-framework.html): a `JobService` is a `BaseService` whose `run_once` drives a queue of database-backed jobs. [ZipService](/docs/services-zip.html) is the first consumer; the current kinds are `zip` (ZipService), `fork` (ForkService), `seo` (SeoService), `deepsearch` (DeepsearchService), and `seo_meta` (SeoMetaService).
|
||||
|
||||
> Audience: administrators and maintainers.
|
||||
|
||||
@@ -24,7 +24,7 @@ All job kinds share one `jobs` table, discriminated by a `kind` column. The row
|
||||
|
||||
## Single-owner processing
|
||||
|
||||
Processing happens only in the worker that owns the service lock, since only that worker calls `supervise()` and runs `run_once()`. With exactly one processor across the deployment, no cross-worker locking or atomic claim is needed. Status reads and downloads work from any worker because they read the shared database and the shared `static` filesystem.
|
||||
Processing happens only in the worker that owns the service lock, since only that worker calls `supervise()` and runs `run_once()`. With exactly one processor across the deployment, no cross-worker locking or atomic claim is needed. Status reads and downloads work from any worker because they read the shared database and the shared data directory (`config.DATA_DIR`, outside the package and not under `/static`; downloads are served by a `FileResponse` route, not the static mount).
|
||||
|
||||
`JobService.run_once` does four things each tick:
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ There is **no preprocessor (no SCSS or LESS), no CSS-in-JS, and no utility frame
|
||||
|
||||
## File organization
|
||||
|
||||
`base.html` always loads the core set: `variables.css`, `base.css`, `components.css`, the syntax-highlighting theme, then `markdown.css`, `mention.css`, `attachments.css`, and `engagement.css`. Page-specific stylesheets (`feed.css`, `post.css`, `gists.css`, `profile.css`, and so on) are loaded per page via the `{% block extra_head %}` block, so a page pays only for the CSS it needs.
|
||||
`base.html` always loads the core set: `variables.css`, `base.css`, `components.css`, the syntax-highlighting theme, then `markdown.css`, `mention.css`, `attachments.css`, `lightbox.css`, `media.css`, and `engagement.css`. Page-specific stylesheets (`feed.css`, `post.css`, `gists.css`, `profile.css`, and so on) are loaded per page via the `{% block extra_head %}` block, so a page pays only for the CSS it needs.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
|
||||
@@ -9,10 +9,10 @@ How a change moves from understanding to shipped. The workflow is ordered by dat
|
||||
## The feature workflow
|
||||
|
||||
1. **Understand.** Read the router for the area (a flat `routers/{area}.py`, or the leaf inside the `routers/{area}/` package that owns the endpoint), the template, the test file, and the matching `AGENTS.md` domain section before writing. Trace the existing input model, router, data helper, and response.
|
||||
2. **Data layer.** Add query and batch helpers in `database.py` (never inline N+1). Add the Pydantic `Form` model to `models.py` and the `*Out` response model to `schemas.py`. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
|
||||
2. **Data layer.** Add query and batch helpers in the `database/` package (never inline N+1). Add the Pydantic `Form` model to `models.py` and the `*Out` response model to the `schemas/` package. Schema auto-syncs via `dataset`; add indexes in `init_db()` with `CREATE INDEX IF NOT EXISTS`.
|
||||
3. **Server layer.** Write the handler with the right guard (`get_current_user` for public reads, `require_user` for member POSTs, `require_admin` for admin). Declare specific routes before catch-alls. Return HTML and JSON from one handler via `respond(..., model=XOut)`. Register any new router in `main.py`.
|
||||
4. **View layer.** Import the shared `templates`, extend `base.html`, put page CSS in `{% block extra_head %}` and page JS in `{% block extra_js %}`, and reuse partials and globals.
|
||||
5. **Agent and docs layer (the most-forgotten step).** If the route is something a user could ask the assistant to do, add an `Action` to the Devii catalog. Add an `endpoint()` entry to `docs_api.py`, and a prose page to `DOCS_PAGES` where relevant. Set SEO context for any new public page.
|
||||
5. **Agent and docs layer (the most-forgotten step).** If the route is something a user could ask the assistant to do, add an `Action` to the Devii catalog. Add an `endpoint()` entry to the `docs_api/` package, and a prose page to `DOCS_PAGES` where relevant. Set SEO context for any new public page.
|
||||
6. **Document and validate.** Update the documentation trio, then run the validation gates below.
|
||||
|
||||
## The four faces
|
||||
@@ -22,9 +22,9 @@ One handler is HTML, JSON, a Devii tool, and an API-docs entry at once. After to
|
||||
| Face | Where | Most-forgotten symptom |
|
||||
|------|-------|------------------------|
|
||||
| HTML | the Jinja template | route added but page not rendered |
|
||||
| JSON | the `*Out` schema in `schemas.py` | new context key silently dropped from JSON |
|
||||
| JSON | the `*Out` schema in the `schemas/` package | new context key silently dropped from JSON |
|
||||
| Devii tool | the catalog `Action` | feature exists for humans but is invisible to the assistant |
|
||||
| API docs | `endpoint()` in `docs_api.py` | endpoint undocumented |
|
||||
| API docs | `endpoint()` in the `docs_api/` package | endpoint undocumented |
|
||||
|
||||
## The documentation trio
|
||||
|
||||
|
||||
@@ -29,11 +29,11 @@ The recurring theme is deliberate minimalism: no JavaScript framework, no NPM, n
|
||||
devplacepy/
|
||||
main.py app, middleware, router registration, lifecycle
|
||||
config.py paths, env vars, constants
|
||||
database.py SQLite/dataset, init_db, batch helpers, caching
|
||||
database/ SQLite/dataset, init_db, batch helpers, caching (package)
|
||||
models.py Pydantic Form input models
|
||||
schemas.py Pydantic *Out response projections
|
||||
schemas/ Pydantic *Out response projections (package)
|
||||
responses.py respond() / action_result() / wants_json()
|
||||
utils.py auth guards, slugs, dates, gamification
|
||||
utils/ auth guards, slugs, dates, gamification (package)
|
||||
templating.py the shared templates instance + globals
|
||||
seo.py JSON-LD schema builders, base_seo_context
|
||||
content.py shared content lifecycle (create/edit/delete)
|
||||
@@ -49,7 +49,7 @@ devplacepy/
|
||||
Every feature in DevPlace is **one data source fanning out into several consumers**. A single route handler is, at the same time:
|
||||
|
||||
1. **HTML** - `respond(request, template, ctx)` renders a Jinja template for browsers.
|
||||
2. **JSON** - the same `respond(..., model=XOut)` returns JSON when the client asks. The `*Out` schema in `schemas.py` is the gate: a context key not declared on `*Out` is dropped from the JSON, even though the template still sees it.
|
||||
2. **JSON** - the same `respond(..., model=XOut)` returns JSON when the client asks. The `*Out` schema in the `schemas/` package is the gate: a context key not declared on `*Out` is dropped from the JSON, even though the template still sees it.
|
||||
3. **Devii tool** - an `Action` in the Devii catalog lets the assistant call the same capability.
|
||||
4. **API docs** - an `endpoint()` entry documents it for developers.
|
||||
|
||||
|
||||
@@ -34,9 +34,10 @@ and a `result` of `success`, `failure`, or `denied`. A second table,
|
||||
`audit_log_links`, records each related object with a labelled relation, so an event
|
||||
can reference any number of objects.
|
||||
|
||||
The complete catalogue of event keys (155 across authentication, content, votes,
|
||||
projects, files, news, admin, services, containers, ingress, AI, the Devii agent, the
|
||||
CLI, rewards, and security) is enumerated in `events.md` at the repository root.
|
||||
The complete catalogue of event keys (201 across 35 domains including authentication,
|
||||
content, votes, projects, files, news, admin, services, containers, ingress, AI, the
|
||||
Devii agent, the CLI, rewards, and security) is enumerated in `events.md` at the
|
||||
repository root.
|
||||
|
||||
## Security model
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ job moving from pending to running to done; when it completes a download link ap
|
||||
also be created from the command line with `devplace backups run <target>` (the running server
|
||||
processes the enqueued job), or by asking Devii to back up a target.
|
||||
|
||||
**Downloading is restricted to the primary administrator** - the earliest-created Admin, resolved by
|
||||
`database.get_primary_admin_uid` / `utils.is_primary_admin`. `GET /admin/backups/{uid}/download`
|
||||
returns `403` for every other administrator, and the `download_url` is withheld from them at every
|
||||
endpoint (their Download control renders disabled). Any admin may still create, schedule, and delete
|
||||
backups.
|
||||
|
||||
## Scheduling and rotation
|
||||
|
||||
A backup schedule fires backups automatically. Each schedule has:
|
||||
|
||||
@@ -21,7 +21,7 @@ Heavy dependencies (`playwright`, `faker`) are imported lazily inside the launch
|
||||
|
||||
## A bot's identity
|
||||
|
||||
On first launch a bot generates a human-looking handle with `faker` (varied styles: `first`, `first.last`, `firstlast`, `flast`, and occasionally a short word suffix like `.dev` or `_lab` - never a numeric suffix, which reads as a bot farm), an email, and a password, then registers through the signup form; on later sessions it logs in. On a signup collision it regenerates a fresh handle rather than appending digits. It also draws a stable browser fingerprint once (a real desktop user-agent and viewport from a varied pool) and persists it, so its requests do not all share one user-agent. Identity, persona, fingerprint, and counters persist to `~/.devplace_bots/state_slot{N}.json`, so a bot keeps the same account and history across restarts. The persona is chosen once at random from the eight available and never changes.
|
||||
On first launch a bot picks a nerd-style handle - primarily from an LLM candidate list (`generate_handle_candidates`: fused words, camelCase, the odd underscore or hyphen, leetspeak, a number or version tag, creatures and roles), falling back to the procedural `make_handle` (tech nouns, adjectives, creatures, and roles seeded from the persona's interests, with an optional number or version-tag suffix). Its email and password come from `faker`. It registers through the signup form; on later sessions it logs in. On a signup collision it takes the next fresh candidate, and after repeated collisions appends a random number (truncated to 20 characters). It also draws a stable browser fingerprint once (a real desktop user-agent and viewport from a varied pool) and persists it, so its requests do not all share one user-agent. Identity, persona, fingerprint, and counters persist to `config.BOT_DIR/state_slot{N}.json` (`data/bot/state_slot{N}.json` under the data dir), so a bot keeps the same account and history across restarts. The persona is chosen once at random from the eight available and never changes.
|
||||
|
||||
To keep the fleet from waking all at once, each bot waits a random `bot_startup_jitter_seconds` delay (default up to four hours) before its first session, spreading activity across the day instead of bursting at service start.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ What an operator can tune, how a setting reaches a running bot, the cost model b
|
||||
|
||||
## Configuration fields
|
||||
|
||||
All fields are edited on `/admin/services/bots`, grouped into sections the admin UI renders automatically. The first three groups are operational settings; the **Behavior** and **Pacing** groups are the realism and cost-pacing dials.
|
||||
All fields are edited on `/admin/services/bots`, grouped into sections the admin UI renders automatically. The first three groups are operational settings; the **Behavior** and **Pacing** groups are the realism and cost-pacing dials, and the **Decisions** group switches between procedural and AI-driven action selection.
|
||||
|
||||
**Fleet**
|
||||
|
||||
@@ -20,7 +20,7 @@ All fields are edited on `/admin/services/bots`, grouped into sections the admin
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|-------|---------|---------|
|
||||
| `bot_base_url` | the instance | The DevPlace site the bots browse and post to. |
|
||||
| `bot_base_url` | `pravda.education` | The DevPlace site the bots browse and post to. |
|
||||
| `bot_news_api` | molodetz news API | Source of articles the bots react to. |
|
||||
|
||||
**LLM**
|
||||
@@ -50,6 +50,13 @@ All fields are edited on `/admin/services/bots`, grouped into sections the admin
|
||||
| `bot_break_scale` | 1.0 | 0.1 to 10.0 | Multiplier on the between-session break. Below 1 makes the fleet more active and costlier; above 1 calmer. |
|
||||
| `bot_startup_jitter_seconds` | 14400 | 0 to 86400 | Each bot waits a random delay up to this many seconds before its first session, so the fleet spreads out instead of all waking together at service start. |
|
||||
|
||||
**Decisions**
|
||||
|
||||
| Field | Default | Range | Meaning |
|
||||
|-------|---------|-------|---------|
|
||||
| `bot_ai_decisions` | off | - | 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. |
|
||||
| `bot_decision_temperature` | 0.4 | 0.0 to 2.0 | Sampling temperature for the per-page decision call. Low values keep structured output stable; variety comes from the identity, not noise. |
|
||||
|
||||
Plus the inherited `Enabled`, `Run interval`, and `Log buffer size` fields from the service framework.
|
||||
|
||||
## How a setting reaches a bot
|
||||
@@ -75,8 +82,8 @@ The fleet publishes live metrics via `collect_metrics()` (shown on the Services
|
||||
|
||||
## State and files
|
||||
|
||||
- Per-bot state: `~/.devplace_bots/state_slot{N}.json` - identity, persona, counters, de-duplication sets, and cumulative cost. Written atomically after each cycle.
|
||||
- Article registry: `~/.dpbot_article_registry.json` - the flock-locked, multi-holder reservation file (see [Engagement](/docs/bots-engagement.html)).
|
||||
- Per-bot state: `config.BOT_DIR/state_slot{N}.json` (`data/bot/state_slot{N}.json` under the data dir) - identity, persona, counters, de-duplication sets, and cumulative cost. Written atomically after each cycle.
|
||||
- Article registry: `config.BOT_DIR/article_registry.json` - the flock-locked, multi-holder reservation file (see [Engagement](/docs/bots-engagement.html)).
|
||||
|
||||
Bots touch no database tables; they browse the target site over real HTTP exactly as a member would.
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ The clearest tell of the earlier fleet was the post title: the raw news headline
|
||||
|
||||
## Categories
|
||||
|
||||
The six categories are `devlog`, `showcase`, `question`, `rant`, `fun`, `random`. They are chosen per persona (see [Personas](/docs/bots-personas.html)) and passed into the post generator as a category-specific instruction (a devlog reads as a learning journey, a rant as substantive criticism, and so on).
|
||||
The seven categories are `devlog`, `showcase`, `question`, `rant`, `fun`, `random`, `politics`. They are chosen per persona (see [Personas](/docs/bots-personas.html)) and passed into the post generator as a category-specific instruction (a devlog reads as a learning journey, a rant as substantive criticism, a politics post as a measured policy take, and so on).
|
||||
|
||||
## Gists
|
||||
|
||||
|
||||
@@ -25,11 +25,11 @@ Persona-specific instruction fragments are appended to the system prompt of each
|
||||
| `mentor` | Explanatory, practical advice, bold takeaways. |
|
||||
| `minimalist` | One paragraph, short declarative sentences, no markdown. |
|
||||
|
||||
Whether the generated markdown is preserved or stripped also depends on the persona (the `minimalist` and `grumpy_senior` voices have their markdown removed to match their plain style).
|
||||
Whether the generated markdown is preserved or stripped also depends on the persona (the `grumpy_senior`, `minimalist`, and `rebel` voices have their markdown removed to match their plain style).
|
||||
|
||||
## Axis 2: post category
|
||||
|
||||
What a bot writes about a piece of news depends on its personality, not only on the news. `config.pick_category(persona)` is a persona-weighted random choice over the six categories (`random.choices` using `PERSONA_CATEGORY_WEIGHTS`). This replaced an earlier content classifier; modelling the reaction as a property of the person is more in-character and one LLM call cheaper. The dominant categories per persona:
|
||||
What a bot writes about a piece of news depends on its personality, not only on the news. `config.pick_category(persona)` is a persona-weighted random choice over that persona's weighted categories (`random.choices` using `PERSONA_CATEGORY_WEIGHTS`). This replaced an earlier content classifier; modelling the reaction as a property of the person is more in-character and one LLM call cheaper. The dominant categories per persona:
|
||||
|
||||
| Persona | Leans toward |
|
||||
|---------|--------------|
|
||||
@@ -42,7 +42,7 @@ What a bot writes about a piece of news depends on its personality, not only on
|
||||
| `rebel` | rant |
|
||||
| `mentor` | devlog, question |
|
||||
|
||||
Every persona retains a non-zero weight on every category, so the bias is a tendency, not a rule.
|
||||
Each persona keeps several categories in play, so the bias is a tendency, not a rule; some categories, like politics, are only weighted for the personas they suit.
|
||||
|
||||
## Axis 3: gist language and flavour
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Subagents
|
||||
|
||||
The ten files in `.claude/agents/` are Claude Code **project subagents**. Each is a
|
||||
Markdown file with YAML frontmatter (`name`, `description`, `tools`, `model`) and a
|
||||
body that is the subagent's full system prompt. Each enforces exactly one quality
|
||||
dimension and nothing else, and each carries the same accuracy doctrine: confirm
|
||||
every finding against the source, actively disprove false positives, cross-reference
|
||||
every consumer before changing anything, and never reduce functionality to satisfy a
|
||||
rule.
|
||||
The fourteen files in `.claude/agents/` are Claude Code **project subagents**. Each is
|
||||
a Markdown file with YAML frontmatter (`name`, `description`, `tools`, `model`) and a
|
||||
body that is the subagent's full system prompt. Twelve are single-dimension
|
||||
**maintainers**: each enforces exactly one quality dimension and nothing else, and
|
||||
each carries the same accuracy doctrine: confirm every finding against the source,
|
||||
actively disprove false positives, cross-reference every consumer before changing
|
||||
anything, and never reduce functionality to satisfy a rule. The remaining two are the
|
||||
constructive `feature-builder` and the live-API `DevPlace` operator.
|
||||
|
||||
## The fleet
|
||||
|
||||
These ten maintainers are the orchestrated fleet that `/maintenance` and the `/fleet`
|
||||
workflow run across every quality dimension, in canonical order.
|
||||
|
||||
| Subagent | Dimension |
|
||||
|----------|-----------|
|
||||
| `security-maintainer` | Authorization on every route, private-resource gating, read-only file guards, input validation, XSS controls. |
|
||||
@@ -24,6 +28,25 @@ rule.
|
||||
| `seo-maintainer` | Public pages carry the right search metadata and appear in the sitemap. |
|
||||
| `test-maintainer` | Routes without an integration test get one written. It never runs the suite. |
|
||||
|
||||
## Additional maintainers
|
||||
|
||||
Two more single-dimension maintainers live in `.claude/agents/` but are invoked on
|
||||
their own rather than as part of the orchestrated fleet run.
|
||||
|
||||
| Subagent | Dimension |
|
||||
|----------|-----------|
|
||||
| `background-maintainer` | Every non-response-critical side-effect (audit, XP and rewards, notifications, mention and admin fan-out) is deferred through the in-process background queue at the right choke point, response-critical work and cache invalidation stay inline, and external or async work uses a JobService. |
|
||||
| `locust-maintainer` | Every load-testable route has a weighted `locustfile.py` task, the file compiles clean, and routes that must not be load tested stay excluded. It edits the locustfile but never runs a load test. |
|
||||
|
||||
## Non-maintainer agents
|
||||
|
||||
The last two subagents are not reviewers.
|
||||
|
||||
| Subagent | Role |
|
||||
|----------|------|
|
||||
| `feature-builder` | The constructive counterpart to the maintainers: it researches the task, then writes a new feature or extends an existing one across the full fan-out (data layer, server, view, agent, docs, SEO, tests), and reports what must be restarted to go live. |
|
||||
| `DevPlace` | A live API operator for the DevPlace instance at pravda.education: it reads the running OpenAPI schema and carries out a given task by calling that API, cleaning up its temporary files when done. |
|
||||
|
||||
## Modes
|
||||
|
||||
Each subagent operates in one of two modes, chosen by how it is invoked.
|
||||
|
||||
@@ -37,6 +37,12 @@ These are the lighter scaffolds that complement the build
|
||||
| `/audit-event <key>` | Adds an audit event end to end: the `events.md` key, the `category_for` mapping, and the recorder call at the mutation. |
|
||||
| `/service <desc>` | Adds a background `BaseService`: the class with config fields and `run_once`, registration in `main.py`, and docs. |
|
||||
|
||||
## Accessibility
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/aria` | A WCAG 2.2 AA+ accessibility specialist: audits and upgrades the site section by section with semantic HTML and ARIA for screen-reader users, working through the pages and components until the whole site is covered. |
|
||||
|
||||
## Operate and verify the UI
|
||||
|
||||
These drive the local `rclaude` toolset against a running dev server.
|
||||
|
||||
@@ -13,7 +13,7 @@ feature work, all expressed in Claude Code's own primitives.
|
||||
|
||||
| Path | Primitive | Purpose |
|
||||
|------|-----------|---------|
|
||||
| `.claude/agents/*.md` | Subagents | Ten single-dimension reviewers, one per quality dimension. See [Subagents](/docs/claude-agents.html). |
|
||||
| `.claude/agents/*.md` | Subagents | Twelve single-dimension maintainers plus a feature builder and a live-API operator. See [Subagents](/docs/claude-agents.html). |
|
||||
| `.claude/commands/*.md` | Slash commands | Lifecycle commands for understanding, building, verifying, testing, and operating. See [Commands](/docs/claude-commands.html). |
|
||||
| `.claude/workflows/*.js` | Workflows | Deterministic multi-agent scripts for auditing and for building features. See [Workflows](/docs/claude-workflows.html). |
|
||||
| `.claude/settings.local.json` | Settings | Local permission configuration. |
|
||||
|
||||
@@ -114,9 +114,9 @@ each quest marks the ones that are ready.
|
||||
|
||||
## Fertilizer
|
||||
|
||||
Fertilizing a growing build spends coins to **halve its remaining time**. The cost scales with how
|
||||
much time is left (about 0.3 coins per remaining second, minimum 5 coins), so it is cheapest near
|
||||
the end of a build. Each plot in the state reports its current `fertilize_cost`.
|
||||
Fertilizing a growing build spends coins to **halve its remaining time**. The cost scales with the
|
||||
build's coin value and with how much time you skip, so a build with a long way to go costs more and
|
||||
fertilizing is cheapest near the end. Each plot in the state reports its current `fertilize_cost`.
|
||||
|
||||
## Watering a neighbour (cooperation)
|
||||
|
||||
@@ -143,6 +143,25 @@ multiplies with the Optimizer perk. Refactoring is the long-term progression and
|
||||
largest factor in the leaderboard. The state reports `prestige`, `prestige_multiplier`, and
|
||||
`prestige_available`.
|
||||
|
||||
Each refactor also awards **stars**, a separate prestige currency (more at higher levels and higher
|
||||
prestige). Stars are never reset and are spent on permanent **Legacy upgrades**.
|
||||
|
||||
## Legacy upgrades
|
||||
|
||||
Stars buy Legacy upgrades, permanent boosts that persist through every future refactor. There are
|
||||
five:
|
||||
|
||||
| Legacy upgrade | Effect | Max level |
|
||||
|----------------|--------|:---------:|
|
||||
| 🤖 CI Bot | Auto-collect ready builds when you open your farm | 1 |
|
||||
| 💎 Tech Debt Payoff | +10% harvest coins per level, stacks with refactor | 10 |
|
||||
| 🏎️ Bare-Metal | +5% base build speed per level | 8 |
|
||||
| 🗂️ Monorepo | +1 starting plot after each refactor per level | 4 |
|
||||
| 🛡️ Branch Protection | +30s steal grace and -5% steal loss per level | 5 |
|
||||
|
||||
The live state reports your `stars` balance and each legacy upgrade's current level and the exact
|
||||
star cost of its next level.
|
||||
|
||||
## Leaderboard and scoring
|
||||
|
||||
The leaderboard ranks the top farms by a composite score. Refactors dominate the formula, then
|
||||
@@ -191,11 +210,12 @@ your **API key** in an `X-API-KEY` header (or `Authorization: Bearer`). Your key
|
||||
| `POST` | `/game/daily` | Claim the daily bonus. |
|
||||
| `POST` | `/game/quests/claim` | Claim a completed quest by `quest` kind. |
|
||||
| `POST` | `/game/prestige` | Refactor at level 10 or above. |
|
||||
| `POST` | `/game/legacy` | Buy a legacy upgrade (`key`) with stars. |
|
||||
| `POST` | `/game/farm/{username}/water` | Water a neighbour's build in `slot`. |
|
||||
| `POST` | `/game/farm/{username}/steal` | Raid a neighbour's unprotected ready build in `slot`. |
|
||||
|
||||
POST bodies are form encoded (`application/x-www-form-urlencoded`). Form fields are `slot` (an
|
||||
integer plot index), `crop`, `perk`, and `quest` where the table notes them. Every POST returns
|
||||
integer plot index), `crop`, `perk`, `quest`, and `key` (a legacy upgrade) where the table notes them. Every POST returns
|
||||
the full farm under `farm`, so one call both performs the action and gives you the new state.
|
||||
|
||||
### Reading the state
|
||||
|
||||
@@ -23,7 +23,8 @@ Source: `static/js/components/AppContent.js`.
|
||||
- Equivalent to `data-render` on a server-rendered element, but as a self-contained element you
|
||||
drop in directly.
|
||||
- A copy button appears in the top-right corner on hover (and on focus) that copies the element's
|
||||
original markdown source to the clipboard, mirroring the code-block copy button.
|
||||
original markdown source to the clipboard, mirroring the code-block copy button. Add the
|
||||
`no-copy` boolean attribute to suppress it.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Source: `static/js/components/AppToast.js`.
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `show(message, options)` | Display `message`. Options: `type` (`info`, `success`, `error`; default `info`) and `ms` (lifetime in milliseconds, default `3000`). Returns the toast element. |
|
||||
| `show(message, options)` | Display `message`. Options: `type` (`info`, `success`, `warning`, `error`; default `info`), `ms` (lifetime in milliseconds, default `3000`), and either `url` (navigate on click) or `onClick` (handler) to make the toast clickable. Returns the toast element. |
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -15,9 +15,9 @@ is available everywhere, including these documentation pages.
|
||||
applies to them directly, matching the existing `devii-*` elements.
|
||||
- **Base class.** Most extend `Component` (`static/js/components/Component.js`), a thin
|
||||
`HTMLElement` subclass with attribute helpers (`attr`, `boolAttr`, `intAttr`).
|
||||
- **Singletons.** Behavioural singletons (`dp-dialog`, `dp-context-menu`, `dp-toast`) are
|
||||
created once by `Application.js` and reachable as `app.dialog`, `app.contextMenu`, and
|
||||
`app.toast`.
|
||||
- **Singletons.** Behavioural singletons (`dp-dialog`, `dp-context-menu`, `dp-toast`,
|
||||
`dp-lightbox`) are created once by `Application.js` and reachable as `app.dialog`,
|
||||
`app.contextMenu`, `app.toast`, and `app.lightbox`.
|
||||
|
||||
## Catalog
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
# Your dashboard
|
||||
|
||||
Live data from this DevPlace instance. Everything here is searchable from the
|
||||
[documentation search](/docs/search.html). See [Profiles & Social Graph](/docs/profiles.html)
|
||||
and [Posts, Comments, Projects, Gists & News](/docs/content.html) for the APIs behind this data.
|
||||
[documentation search](/docs/search.html). Browse the full [documentation](/docs/index.html) for the
|
||||
features and APIs behind this data.
|
||||
|
||||
{% if facts.user %}{% set u = facts.user %}
|
||||
## {{ u.username }}
|
||||
|
||||
@@ -6,18 +6,22 @@ multi-worker model. See also [Devii internals](/docs/devii-internals.html).
|
||||
|
||||
## Owner model
|
||||
|
||||
Every session is keyed by an **owner** tuple `(owner_kind, owner_id)`:
|
||||
Every session is keyed by an **owner-and-channel** tuple `(owner_kind, owner_id, channel)`:
|
||||
|
||||
- `user` / user uid - a signed-in DevPlace account. The agent authenticates to this instance with
|
||||
that user's API key, so it operates the user's own account.
|
||||
- `guest` / `devii_guest` cookie - an anonymous sandbox session.
|
||||
- `channel` - an independent conversation thread: `main` (the floating terminal), `docs` (the
|
||||
in-page Docii documentation assistant, selected with `?channel=docs`), or `telegram`. The channel
|
||||
splits only the conversation thread; tasks, lessons, behavior, virtual tools, and the 24h quota
|
||||
stay owner-scoped and shared across a given owner's channels.
|
||||
|
||||
Owner scoping is the spine of Devii: conversations, tasks, lessons, the usage ledger, and the audit
|
||||
log are all filtered by owner, so nothing is shared between accounts.
|
||||
|
||||
## DeviiHub
|
||||
|
||||
`DeviiHub` holds the process-wide state: one `DeviiSession` per owner (`get_or_create`), the shared
|
||||
`DeviiHub` holds the process-wide state: one `DeviiSession` per owner-and-channel (`get_or_create`), the shared
|
||||
`LLMClient`, the `ConversationStore`/`UsageLedger`/`TurnAudit`, and housekeeping (`gc_idle`, ledger
|
||||
prune). For a signed-in user it builds a `PlatformClient` with the user's API key and lazily
|
||||
**rehydrates** the saved conversation; a guest gets an unauthenticated client with no persistence.
|
||||
|
||||
@@ -6,7 +6,7 @@ How to configure and operate Devii. See also [Devii internals](/docs/devii-inter
|
||||
## Service configuration
|
||||
|
||||
Devii runs as `DeviiService` (a background service) and is configured on
|
||||
[Background Services](/docs/services.html) at `/admin/services`. The fields, grouped:
|
||||
[Services overview](/docs/services-overview.html) at `/admin/services`. The fields, grouped:
|
||||
|
||||
**AI**
|
||||
- `devii_ai_url` - the OpenAI-compatible chat endpoint Devii reasons with.
|
||||
@@ -22,9 +22,11 @@ Devii runs as `DeviiService` (a background service) and is configured on
|
||||
- `devii_plan_required` / `devii_verify_required` - enforce the plan-first and verify-after-mutation gates.
|
||||
- `devii_max_iterations` - upper bound on tool-loop iterations per turn.
|
||||
- `devii_allow_eval` - allow `run_js` in the user's browser.
|
||||
- `devii_browser_control` - allow the mutating browser-control tools (click, fill, set, submit, press_key, run_sequence); the read-only browser tools stay available when off.
|
||||
|
||||
**Limits**
|
||||
- `devii_user_daily_usd` / `devii_guest_daily_usd` - rolling 24h spend caps.
|
||||
- `devii_user_daily_usd` / `devii_guest_daily_usd` - rolling 24h spend caps for members and guests.
|
||||
- `devii_admin_daily_usd` - the administrator cap (default `0` = unlimited, so admins are exempt by default).
|
||||
- `devii_guests_enabled` - allow anonymous guests.
|
||||
|
||||
**Pricing**
|
||||
@@ -51,8 +53,10 @@ its services card.
|
||||
|---|---|
|
||||
| `WS /devii/ws` | The terminal session (served only by the service-lock-owning worker). |
|
||||
| `GET /devii/` | The standalone Devii page. |
|
||||
| `GET /devii/session` | Bootstrap: `{loggedIn, username, enabled, guestsEnabled}`; sets the guest cookie. |
|
||||
| `GET /devii/usage` | `{spent_24h, limit, turns_today, owner_kind}` for the widget. |
|
||||
| `GET /devii/session` | Bootstrap: `{loggedIn, username, enabled, guestsEnabled, baseUrl}`; sets the guest cookie. |
|
||||
| `GET /devii/usage` | `{used_pct, turns_today, owner_kind}` for the widget, plus `spent_24h` and `limit` for administrators only (money is admin-only). |
|
||||
| `GET /devii/adopt` | Single-use redirect that sets the browser `session` cookie after the terminal agent logs in. |
|
||||
| `WS /devii/ws?channel=docs` | The docs-only Docii conversation thread (allowlisted channel). |
|
||||
| `POST /devii/clippy/ai/chat` | Same-origin AI proxy for the avatar. |
|
||||
| `GET /admin/analytics` | Admin-only aggregate analytics (JSON) that `site_analytics` calls; see the [Admin API](/docs/admin.html). |
|
||||
|
||||
|
||||
@@ -13,19 +13,21 @@ also [Architecture and sessions](/docs/devii-architecture.html) and [Tools and s
|
||||
| `devii_turns` | `(owner_kind, owner_id, started_at)` | Per-turn audit: truncated prompt and reply, iterations, tool-call count, cost, error. |
|
||||
| `devii_tasks` | `(owner_kind, owner_id)` | Autonomous tasks and reminders (schedule, status, next run, plus a `notify` flag and captured `tz`). Owner-scoped. A signed-in user's rows are run by the lock-owner service, so reminders survive restarts. |
|
||||
| `devii_lessons` | `(owner_kind, owner_id)` | The self-learning memory: observation, conclusion, next action, tags. BM25-searched, owner-isolated. |
|
||||
| `devii_behavior` | `(owner_kind, owner_id)` | One row per owner: the persistent `# TRUTH RULES AND BEHAVIOR` system-prompt section Devii self-configures via `update_behavior`. |
|
||||
| `devii_virtual_tools` | `(owner_kind, owner_id)` | The user-defined tools (name, description, stored prompt, input hint, enabled) added to Devii's tool list per owner. |
|
||||
| `email_accounts` | `(owner_kind, owner_id, label)` | IMAP/SMTP connection settings the `email_*` tools use, one row per named account. Soft-deletable; the password is stored as written and never returned by a tool. Signed-in users only. |
|
||||
|
||||
Indexes for these are created idempotently in `database.init_db()`.
|
||||
|
||||
## Persistent vs ephemeral
|
||||
|
||||
- **Signed-in users:** conversations, tasks, and lessons persist in the main DB, survive
|
||||
restarts, and rehydrate on reconnect. A pending task or reminder is re-armed at boot by the
|
||||
- **Signed-in users:** conversations, tasks, lessons, self-configured behavior, and user-defined
|
||||
tools persist in the main DB, survive restarts, and rehydrate on reconnect. A pending task or reminder is re-armed at boot by the
|
||||
lock-owner service and fires even if the user never reopens Devii; a `notify` reminder then raises
|
||||
a `reminder` notification and toast. The user's browser timezone is persisted to `users.timezone`
|
||||
for correct wall-clock-to-UTC scheduling.
|
||||
- **Guests:** tasks and lessons use a fresh **in-memory** database per session, never written to
|
||||
disk; conversations are not persisted at all. State is gone when the guest session ends, so a
|
||||
- **Guests:** tasks, lessons, behavior, and user-defined tools use a fresh **in-memory** database
|
||||
per session, never written to disk; conversations are not persisted at all. State is gone when the guest session ends, so a
|
||||
guest reminder runs only while that session stays connected and never sends a notification.
|
||||
|
||||
## Owner isolation
|
||||
|
||||
@@ -20,8 +20,8 @@ It runs as a configurable background service (`DeviiService`) configured on `/ad
|
||||
```
|
||||
devplacepy/services/devii/
|
||||
service.py DeviiService (BaseService): config fields, limits, metrics, the hub
|
||||
hub.py DeviiHub: one DeviiSession per owner; shared stores
|
||||
session.py DeviiSession: websocket(s), turns, persistence, broadcast
|
||||
hub.py DeviiHub: one DeviiSession per owner-and-channel; shared stores
|
||||
session/ DeviiSession (core.py) + prompts: websocket(s), turns, persistence, broadcast
|
||||
agent.py Agent: the ReAct driver + system prompt + lesson recall
|
||||
registry.py CATALOG: the full tool catalog (union of *_ACTIONS)
|
||||
config.py Settings dataclass, ConfigField keys, build_settings()
|
||||
@@ -29,10 +29,11 @@ devplacepy/services/devii/
|
||||
http_client.py PlatformClient (operates the account over the platform API)
|
||||
store.py ConversationStore, UsageLedger, TurnAudit (main DB)
|
||||
cli.py the `devii` console script
|
||||
actions/ spec (Action/Param/Catalog), catalog (platform endpoints), dispatcher,
|
||||
and *_actions for each local handler
|
||||
actions/ spec (Action/Param/Catalog), catalog/ (platform endpoints, one module per
|
||||
domain), dispatcher, and *_actions for each local handler
|
||||
agentic/ ReAct loop, AgenticController, LessonStore, state, compaction
|
||||
tasks/ TaskStore, Scheduler, TaskController (autonomous tasks)
|
||||
behavior/ virtual_tools/ customization/ notification/ email/ container/ rsearch/
|
||||
chunks/ cost/ fetch/ docs/ avatar/ client/ local tool controllers
|
||||
```
|
||||
|
||||
|
||||
@@ -33,6 +33,15 @@ JSON, 403 for non-admins; see the [Admin API](/docs/admin.html)). Devii reaches
|
||||
`site_analytics` catalog tool, so the platform enforces admin access; the agent never queries the
|
||||
database directly for it.
|
||||
|
||||
## Primary-administrator-only database tools
|
||||
|
||||
The read-only database tools (`db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`,
|
||||
`db_query`, `db_design_query`) carry `requires_primary_admin=True`, so they are offered only to the
|
||||
**primary administrator** (the oldest Admin account). Their schema is withheld from every other
|
||||
session and the dispatcher independently refuses them, so a member, a guest, or a junior admin never
|
||||
sees them and Devii is unaware they exist. They are strictly read-only (SELECT-validated) and never
|
||||
write to the database.
|
||||
|
||||
## Web fetch
|
||||
|
||||
`fetch_url` sends realistic browser headers, reduces pages to text, caps size, and refuses private,
|
||||
|
||||
@@ -9,8 +9,9 @@ the model's tool schema, the dispatch routing, and this reference. See also
|
||||
Devii is a full agentic operator: it can run an entire user account, read and write a project's
|
||||
virtual filesystem line by line, drive the user's own browser, call any external HTTP/JSON API,
|
||||
manage Docker containers, learn lessons across sessions, schedule autonomous work, and let users
|
||||
mint their own tools in natural language. As of this build the catalog exposes **154 actions**
|
||||
across sixteen handlers.
|
||||
mint their own tools in natural language. As of this build the catalog exposes **255 actions**
|
||||
across twenty-two handlers. The per-handler tables below cover the primary tools of each handler and
|
||||
are not an exhaustive enumeration of every action.
|
||||
|
||||
## How tools are declared
|
||||
|
||||
@@ -47,11 +48,17 @@ oldest Admin account only (the database API tools), gated the same way.
|
||||
| `client` | the browser | `get_page_context`, `run_js`, navigation, highlights, toasts, and `open_terminal` - run in the user's own browser; `run_js` is gated by `devii_allow_eval`. |
|
||||
| `container` | Docker (admin) | The `container_*` tools manage supervised container instances on the shared `ppy` image - admin only. |
|
||||
| `email` | external mailbox | The `email_*` tools connect to the user's OWN external mailbox over IMAP/SMTP (Python stdlib, run off-thread), not this platform. Signed-in users only; gated by `devii_email_enabled`. Per-owner credentials live in `email_accounts` and the mail host is SSRF-guarded. |
|
||||
| `behavior` | self-configuration | `update_behavior` persists the owner's `# TRUTH RULES AND BEHAVIOR` system-prompt section (signed-in owner-scoped, self-prompted). |
|
||||
| `notification` | preferences | `notification_list` / `notification_set` / `notification_reset` read and write the owner's per-type per-channel notification preferences. |
|
||||
| `ai_correction` | content correction | `ai_correction_get` / `ai_correction_set` read and write the owner's AI content-correction settings. |
|
||||
| `ai_modifier` | content modifier | `ai_modifier_get` / `ai_modifier_set` read and write the owner's `@ai` modifier settings. |
|
||||
| `telegram` | Telegram | `telegram_send` pushes a message to the owner's paired Telegram from a turn or scheduled task. |
|
||||
|
||||
## Complete action reference
|
||||
|
||||
Every action in the catalog, grouped by handler. Mutating platform calls and irreversible actions
|
||||
(deletes, visibility/read-only flips, destructive container commands) additionally pass through the
|
||||
The primary tools of the catalog, grouped by handler (this is a representative reference, not an
|
||||
exhaustive list of all 255 actions). Mutating platform calls and irreversible actions (deletes,
|
||||
visibility/read-only flips, destructive container commands) additionally pass through the
|
||||
dispatcher's confirmation gate before they run.
|
||||
|
||||
Every content delete (`delete_post`, `delete_comment`, `delete_gist`, `delete_project`,
|
||||
@@ -73,7 +80,7 @@ through them. The platform API enforces the rest: an **administrator** operating
|
||||
| `signup` | public | Create a new account. |
|
||||
| `forgot_password` | public | Request a password reset email. |
|
||||
| `reset_password` | public | Reset a password using a reset token. |
|
||||
| `view_profile` | auth | View a user profile. |
|
||||
| `view_profile` | public | View a user profile. |
|
||||
| `search_users` | auth | Search for users by name. |
|
||||
| `update_profile` | auth | Update the current user's profile. |
|
||||
| `regenerate_api_key` | auth | Issue a new API key and invalidate the current one. |
|
||||
@@ -170,32 +177,39 @@ through them. The platform API enforces the rest: an **administrator** operating
|
||||
|
||||
| Tool | Scope | What it does |
|
||||
|---|---|---|
|
||||
| `admin_overview` | auth | View the admin overview. |
|
||||
| `admin_overview` | admin | View the admin overview. |
|
||||
| `site_analytics` | admin | Site-wide aggregate analytics in one call. |
|
||||
| `ai_usage` | admin | AI gateway usage, cost, latency, and reliability metrics. |
|
||||
| `admin_list_users` | auth | List users for administration. |
|
||||
| `admin_set_user_role` | auth | Set a user's role. |
|
||||
| `admin_set_user_password` | auth | Set a user's password. |
|
||||
| `admin_toggle_user` | auth | Toggle a user's active state. |
|
||||
| `admin_get_settings` | auth | View site settings. |
|
||||
| `admin_save_settings` | auth | Save site settings. |
|
||||
| `admin_list_news` | auth | List news for administration. |
|
||||
| `admin_toggle_news` | auth | Toggle a news article. |
|
||||
| `admin_publish_news` | auth | Publish a news article. |
|
||||
| `admin_landing_news` | auth | Set a news article as landing content. |
|
||||
| `admin_delete_news` | auth | Delete a news article (admin-only endpoint; soft delete, confirmation required). |
|
||||
| `admin_list_services` | auth | View managed services. |
|
||||
| `admin_services_data` | auth | Live status, metrics, and log tail for every service. |
|
||||
| `admin_service_status` | auth | Live status, metrics, and log tail for one service. |
|
||||
| `admin_start_service` | auth | Start a managed service. |
|
||||
| `admin_stop_service` | auth | Stop a managed service. |
|
||||
| `admin_run_service` | auth | Run a managed service once. |
|
||||
| `admin_clear_service_logs` | auth | Clear a managed service's logs. |
|
||||
| `admin_config_service` | auth | Update a managed service's configuration. |
|
||||
| `admin_list_users` | admin | List users for administration. |
|
||||
| `admin_set_user_role` | admin | Set a user's role. |
|
||||
| `admin_set_user_password` | admin | Set a user's password. |
|
||||
| `admin_toggle_user` | admin | Toggle a user's active state. |
|
||||
| `admin_get_settings` | admin | View site settings. |
|
||||
| `admin_save_settings` | admin | Save site settings. |
|
||||
| `admin_list_news` | admin | List news for administration. |
|
||||
| `admin_toggle_news` | admin | Toggle a news article. |
|
||||
| `admin_publish_news` | admin | Publish a news article. |
|
||||
| `admin_landing_news` | admin | Set a news article as landing content. |
|
||||
| `admin_delete_news` | admin | Delete a news article (soft delete, confirmation required). |
|
||||
| `admin_list_services` | admin | View managed services. |
|
||||
| `admin_services_data` | admin | Live status, metrics, and log tail for every service. |
|
||||
| `admin_service_status` | admin | Live status, metrics, and log tail for one service. |
|
||||
| `admin_start_service` | admin | Start a managed service. |
|
||||
| `admin_stop_service` | admin | Stop a managed service. |
|
||||
| `admin_run_service` | admin | Run a managed service once. |
|
||||
| `admin_clear_service_logs` | admin | Clear a managed service's logs. |
|
||||
| `admin_config_service` | admin | Update a managed service's configuration. |
|
||||
|
||||
The `admin_*` endpoints are role-enforced by the platform itself (they return 403 to non-admins), so
|
||||
they carry `requires_auth` rather than `requires_admin`; only the read-only aggregate tools
|
||||
(`site_analytics`, `ai_usage`) and the financial `cost_stats` are withheld at the schema level.
|
||||
The `admin_*` tools carry `requires_admin=True`, so they are gated twice: their schema is withheld
|
||||
from non-administrators and the dispatcher independently refuses them for a non-admin owner (the
|
||||
platform endpoints also return 403 to non-admins as a third layer). The financial `cost_stats` is
|
||||
admin-only in the same way. Beyond the tools above the catalog also exposes further admin-only
|
||||
`http` actions - audit-log (`audit_log`, `audit_event`), backups (`backups_overview`, `backup_run`,
|
||||
`backup_status`, `backup_delete`, `backup_schedule_create`, `backup_schedule_delete`), AI gateway
|
||||
routing (`gateway_providers`, `gateway_models`, `gateway_provider_set`, `gateway_provider_delete`,
|
||||
`gateway_model_set`, `gateway_model_delete`), AI-quota resets, media restore/purge, and the
|
||||
primary-administrator-only read-only database tools (`db_list_tables`, `db_table_schema`,
|
||||
`db_list_rows`, `db_get_row`, `db_query`, `db_design_query`).
|
||||
|
||||
### Web and external APIs (`fetch`, `rsearch`)
|
||||
|
||||
@@ -349,6 +363,7 @@ to keep calling itself.
|
||||
|---|---|---|
|
||||
| `container_list_instances` | admin | List a project's container instances and their status. |
|
||||
| `container_create_instance` | admin | Create and start an instance on the shared `ppy` image with the project files at `/app`. |
|
||||
| `container_configure_instance` | admin | Edit an instance's run-as user, boot language/script/command, restart policy, and limits. |
|
||||
| `container_instance_action` | admin | Control an instance: start, stop, restart, pause, resume, delete, or sync (delete is confirmed). |
|
||||
| `container_logs` | admin | Read the recent logs of a running instance. |
|
||||
| `container_exec` | admin | Run a one-shot command inside a running instance (destructive commands are confirmed). |
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
> experience points, levels, the full badge catalogue, the activity-tracking engine behind
|
||||
> first-use and usage-tier badges, every place a badge is awarded, and how to extend it.
|
||||
|
||||
DevPlace rewards participation with three layered mechanics, all centralised in `devplacepy/utils.py`
|
||||
DevPlace rewards participation with three layered mechanics, all centralised in the `devplacepy/utils`
|
||||
package (`utils/rewards.py` for XP and milestones, `utils/badges.py` for the catalogue and tracking)
|
||||
and wired into the existing content, engagement, and feature hooks. Nothing writes badges or XP
|
||||
directly - every award goes through the helpers below so milestone checks and notifications are never
|
||||
skipped.
|
||||
@@ -22,7 +23,7 @@ the test suite the queue runs inline so awards are deterministic.
|
||||
## XP and levels
|
||||
|
||||
Members earn XP at the existing content and engagement hook points. The amounts are constants in
|
||||
`utils.py`:
|
||||
`utils/rewards.py`:
|
||||
|
||||
| Action | Constant | XP |
|
||||
|--------|----------|----|
|
||||
@@ -144,12 +145,24 @@ Every badge, its icon, the group it belongs to, and how it is earned. Metadata l
|
||||
| 📅 | Dedicated | Maintain a 30-day activity streak |
|
||||
| 🚀 | Unstoppable | Maintain a 100-day activity streak |
|
||||
|
||||
### Code Farm
|
||||
|
||||
See [Code Farm](/docs/code-farm.html) for the game itself.
|
||||
|
||||
| Icon | Badge | How to earn |
|
||||
|------|-------|-------------|
|
||||
| 🌱 | Green Thumb | Harvest your first build on the Code Farm (`harvest`, 1) |
|
||||
| 🌾 | Master Farmer | Harvest 50 builds on the Code Farm (`harvest`, 50) |
|
||||
| 💧 | Good Neighbor | Water a neighbour's build (`water`, 1) |
|
||||
| 🥷 | Cat Burglar | Steal a ready build from another farm (`harvest_stolen`, 1) |
|
||||
| 🚨 | Robbed | Have a build stolen from your farm (`got_stolen_from`, 1) |
|
||||
|
||||
### Levels and membership
|
||||
|
||||
| Icon | Badge | How to earn |
|
||||
|------|-------|-------------|
|
||||
| ❖ | Level 5 / 10 / 25 / 50 / 100 | Reach the matching level (`LEVEL_BADGES`) |
|
||||
| ✦ | Member | Join DevPlace (granted at registration) |
|
||||
| ❖ | Level 5 / 10 / 25 / 50 / 100 | Reach the matching level (`LEVEL_BADGES`, group `Levels`) |
|
||||
| ✦ | Member | Join DevPlace (granted at registration, group `Milestones`) |
|
||||
|
||||
## The activity-tracking engine
|
||||
|
||||
@@ -193,6 +206,10 @@ first-use badge):
|
||||
| `issue` | count | 1 Bug Reporter |
|
||||
| `poll` | count | 1 Pollster |
|
||||
| `profile` | count | 1 Profiled |
|
||||
| `harvest` | count | 1 Green Thumb, 50 Master Farmer |
|
||||
| `water` | count | 1 Good Neighbor |
|
||||
| `harvest_stolen` | count | 1 Cat Burglar |
|
||||
| `got_stolen_from` | count | 1 Robbed |
|
||||
|
||||
## Where each badge is awarded
|
||||
|
||||
@@ -215,7 +232,9 @@ first-use badge):
|
||||
| `issue` | `routers/issues/create.py` | issue filing enqueue |
|
||||
| `poll` | `routers/polls.py` | a poll vote cast |
|
||||
| `profile` | `routers/profile/index.py` | profile saved with a non-empty field |
|
||||
| `devii` | `services/devii/session.py` | a completed Devii turn (user owner, non-docs channel) |
|
||||
| `devii` | `services/devii/session/core.py` | a completed Devii turn (user owner, non-docs channel) |
|
||||
| `harvest` | `routers/game/index.py` | harvest a ready build on your own farm |
|
||||
| `water` / `harvest_stolen` / `got_stolen_from` | `routers/game/farm.py` | water a neighbour, steal a build (thief), or be stolen from (victim) |
|
||||
|
||||
Guests are never tracked: the hooks only fire for an authenticated user. The Docii documentation
|
||||
channel is excluded from the `devii` badge.
|
||||
|
||||
@@ -100,7 +100,7 @@ at `/usr/bin/dpc` and ready to use the moment your container starts.
|
||||
dpc "build a small FastAPI app in app.py that serves a JSON health check at /"
|
||||
```
|
||||
|
||||
`dpc` reads your API key from the container environment (`PRAVDA_API_KEY`) and talks
|
||||
`dpc` reads your API key from the container environment (`DEVPLACE_API_KEY`) and talks
|
||||
to the platform AI gateway, so **all of its AI usage is metered through your own
|
||||
account**. There is no separate key to manage and nothing to configure: it is plug
|
||||
and play.
|
||||
@@ -112,15 +112,15 @@ inside the container read them to reach the platform and to attribute AI spend.
|
||||
|
||||
| Variable | What it contains |
|
||||
|----------|------------------|
|
||||
| `PRAVDA_BASE_URL` | The public base URL of this DevPlace instance. |
|
||||
| `PRAVDA_OPENAI_URL` | The AI gateway endpoint, `PRAVDA_BASE_URL` + `/openai/v1`. |
|
||||
| `PRAVDA_API_KEY` | The API key used for every AI call. Spend is metered to this account. |
|
||||
| `PRAVDA_USER_UID` | The DevPlace user id whose identity the container carries. |
|
||||
| `PRAVDA_CONTAINER_NAME` | The instance's name. |
|
||||
| `PRAVDA_CONTAINER_UID` | The instance's unique id. |
|
||||
| `PRAVDA_INGRESS_URL` | The public URL of this container when ingress is set, otherwise empty. |
|
||||
| `DEVPLACE_BASE_URL` | The public base URL of this DevPlace instance. |
|
||||
| `DEVPLACE_OPENAI_URL` | The AI gateway endpoint, `DEVPLACE_BASE_URL` + `/openai/v1`. |
|
||||
| `DEVPLACE_API_KEY` | The API key used for every AI call. Spend is metered to this account. |
|
||||
| `DEVPLACE_USER_UID` | The DevPlace user id whose identity the container carries. |
|
||||
| `DEVPLACE_CONTAINER_NAME` | The instance's name. |
|
||||
| `DEVPLACE_CONTAINER_UID` | The instance's unique id. |
|
||||
| `DEVPLACE_INGRESS_URL` | The public URL of this container when ingress is set, otherwise empty. |
|
||||
|
||||
The key that lands in `PRAVDA_API_KEY` is resolved in order from the instance's
|
||||
The key that lands in `DEVPLACE_API_KEY` is resolved in order from the instance's
|
||||
**run-as user**, then its creator, then the project owner. You can point a container
|
||||
at a specific account by asking Devii to set `run_as_uid` when creating or
|
||||
configuring it. The OS user stays `pravda`; only the identity and key change.
|
||||
@@ -128,7 +128,7 @@ configuring it. The OS user stays `pravda`; only the identity and key change.
|
||||
## botje.py - the plug-and-play bot
|
||||
|
||||
`botje.py` (installed at `/usr/bin/botje.py`) is a complete, ready-to-run DevPlace
|
||||
bot. Start it with no arguments and it logs in with your `PRAVDA_API_KEY`, then
|
||||
bot. Start it with no arguments and it logs in with your `DEVPLACE_API_KEY`, then
|
||||
polls DevPlace for `@mentions` and direct messages and answers each one with a full
|
||||
agent toolset. Give it a task on the command line and it runs that single task and
|
||||
exits.
|
||||
@@ -148,8 +148,8 @@ play inside a container:
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `PRAVDA_API_KEY` | Auth for both DevPlace and the AI gateway (already set). |
|
||||
| `PRAVDA_BASE_URL` | Which DevPlace instance to talk to (already set). |
|
||||
| `DEVPLACE_API_KEY` | Auth for both DevPlace and the AI gateway (already set). |
|
||||
| `DEVPLACE_BASE_URL` | Which DevPlace instance to talk to (already set). |
|
||||
| `BOT_USERNAME` | The bot's own username, so it ignores its own posts. |
|
||||
| `MENTION_POLL_SECONDS` | How often it checks for mentions (default 30). |
|
||||
| `DM_POLL_SECONDS` | How often it checks for direct messages (default 10). |
|
||||
@@ -190,8 +190,8 @@ Two values control it:
|
||||
|
||||
or on an existing instance by recreating it with the ingress fields, or by asking
|
||||
Devii to configure the ports and ingress. The resulting URL is
|
||||
`PRAVDA_BASE_URL` + `/p/vibe-lab`, which is also placed in the container's
|
||||
`PRAVDA_INGRESS_URL` so your app can self-reference its own public address.
|
||||
`DEVPLACE_BASE_URL` + `/p/vibe-lab`, which is also placed in the container's
|
||||
`DEVPLACE_INGRESS_URL` so your app can self-reference its own public address.
|
||||
|
||||
## Tutorial: vibe a web app and put it online
|
||||
|
||||
@@ -220,7 +220,7 @@ dpc "create app.py: a Flask app that serves an HTML page listing inspirational
|
||||
`dpc` writes `app.py` and `quotes.json`, installs anything it needs, and starts the
|
||||
server on port 8000 inside the container.
|
||||
|
||||
**4. Visit your live app.** Open `PRAVDA_BASE_URL` + `/p/quote-wall` in your browser.
|
||||
**4. Visit your live app.** Open `DEVPLACE_BASE_URL` + `/p/quote-wall` in your browser.
|
||||
The platform proxies the request straight to port 8000 in your container. Add a
|
||||
quote in the form and watch it persist.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ make dev # uvicorn --reload on port 10500
|
||||
```
|
||||
|
||||
Open `http://localhost:10500`. The first account you register becomes the administrator.
|
||||
`make prod` runs the two-worker production server on the same port.
|
||||
`make prod` runs the production server with one worker per CPU core on the same port.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -25,8 +25,8 @@ devplacepy/ the application package
|
||||
main.py mounts routers, middleware, startup/shutdown
|
||||
routers/ one directory per URL domain (mirrors the endpoint tree)
|
||||
templates/ Jinja2 pages; static/ holds CSS and ES6 modules
|
||||
database.py SQLite via dataset; query and batch helpers
|
||||
models.py Pydantic Form models schemas.py *Out JSON models
|
||||
database/ SQLite via dataset; query and batch helpers
|
||||
models.py Pydantic Form models schemas/ *Out JSON models
|
||||
services/ background + async-job services, Devii, containers
|
||||
tests/ unit / api / e2e, mirroring the route or source path
|
||||
```
|
||||
|
||||
@@ -6,17 +6,19 @@ DevPlace notifies you when something involves you: a comment on your post, a rep
|
||||
mention, an upvote on your work, a new follower, a direct message, a badge, a level-up, or an update
|
||||
on an issue you filed. You decide which reach you, and how.
|
||||
|
||||
Each notification type is delivered on two independent channels:
|
||||
Each notification type is delivered on three independent channels:
|
||||
|
||||
- **In-app** - the notification appears on DevPlace (the bell in the top navigation and the
|
||||
`/notifications` page). While you have DevPlace open, an enabled in-app notification also raises a
|
||||
live, click-through toast in real time, and the unread bell and message badges update instantly,
|
||||
so you see it without reloading.
|
||||
- **Push** - a native web push notification is sent to the devices where you enabled push.
|
||||
- **Telegram** - the notification is delivered to your paired Telegram chat. This channel works only
|
||||
once you have connected Telegram, and it is **off by default** for every type.
|
||||
|
||||
The channels are independent: you can keep a type in-app but silence its push, or the reverse. The
|
||||
live toast rides the in-app channel: silence a type's in-app box and it neither lands in your bell nor
|
||||
pops a toast.
|
||||
The channels are independent: you can keep a type in-app but silence its push (or the reverse), and
|
||||
enable Telegram for only the types you care about. The live toast rides the in-app channel: silence a
|
||||
type's in-app box and it neither lands in your bell nor pops a toast.
|
||||
|
||||
## Where to find it
|
||||
|
||||
@@ -24,8 +26,9 @@ Open your profile and choose the **Notifications** tab, or go straight to
|
||||
`/profile/{{ uname }}?tab=notifications`. The tab is private: only you (and administrators) can see or
|
||||
change your settings.
|
||||
|
||||
Each notification type is one row with two checkboxes, **In-app** and **Push**. Ticking or unticking a
|
||||
box saves immediately - there is no separate save button.
|
||||
Each notification type is one row with three switches, **In-app**, **Push**, and **Telegram**. The
|
||||
Telegram switch stays disabled until you connect Telegram. Ticking or unticking a switch saves
|
||||
immediately - there is no separate save button.
|
||||
|
||||
## What each type covers
|
||||
|
||||
@@ -41,12 +44,14 @@ box saves immediately - there is no separate save button.
|
||||
| Level-ups | you reach a new level |
|
||||
| Issue tracker | there is an update on an issue report you filed |
|
||||
| Reminders | a reminder or scheduled task you asked Devii to run fires |
|
||||
| Farm raids | someone steals a ready build from your Code Farm |
|
||||
|
||||
## Defaults
|
||||
|
||||
Every type and channel is **on** until you turn it off, so notifications work out of the box. A type
|
||||
you have never changed follows the platform default; once you tick or untick a box, your choice is
|
||||
remembered and no longer follows later changes to the default.
|
||||
In-app and push are **on** for every type until you turn them off, so notifications work out of the
|
||||
box; Telegram is **off** by default and delivers only once you connect it. A type you have never
|
||||
changed follows the platform default; once you change a switch, your choice is remembered and no
|
||||
longer follows later changes to the default.
|
||||
|
||||
Use **Reset to defaults** at the bottom of the tab to clear all of your choices and return to the
|
||||
platform defaults.
|
||||
@@ -55,7 +60,8 @@ platform defaults.
|
||||
|
||||
Toggling **Push** for a type only takes effect once you have enabled push notifications on the device
|
||||
with the bell-with-slash button in the top navigation or on the `/notifications` page. Until then,
|
||||
push has nowhere to be delivered. See [Push notifications](/docs/push.html) for the device-side setup.
|
||||
push has nowhere to be delivered. Likewise, the **Telegram** switch only delivers once you have paired
|
||||
Telegram - see [Devii on Telegram](/docs/telegram.html) to connect your account.
|
||||
|
||||
## Ask Devii
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ A logout, ban, role change, or settings save in one worker is therefore honored
|
||||
|
||||
## Rate limiting
|
||||
|
||||
The rate limiter counts per IP in a per-process structure, so N workers would otherwise allow N times the configured limit. Each worker instead enforces `ceil(limit / DEVPLACE_WEB_WORKERS)`, so the aggregate across workers matches the admin-configured value without a database write on every request. `DEVPLACE_WEB_WORKERS` must equal the actual `--workers` count; it is set in `make prod` (2) and the Dockerfile (2), and defaults to 1 for single-worker development.
|
||||
The rate limiter counts per IP in a per-process structure, so N workers would otherwise allow N times the configured limit. Each worker instead enforces `ceil(limit / DEVPLACE_WEB_WORKERS)`, so the aggregate across workers matches the admin-configured value without a database write on every request. `DEVPLACE_WEB_WORKERS` must equal the actual `--workers` count; `make prod` derives both from the box core count (`nproc`, override with `make prod WEB_WORKERS=N`), while the Docker image pins both to 2; it defaults to 1 for single-worker development.
|
||||
|
||||
## First-boot serialization
|
||||
|
||||
|
||||
@@ -80,5 +80,5 @@ Back up `data/devplace.db` (and its `-wal`/`-shm`) before a risky change; the sc
|
||||
|
||||
## Bare-metal alternative
|
||||
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers 2 --proxy-headers`) from the project root, sharing the same database and files. It binds port 10500 directly, conflicting with the Docker front door on that port - run one or the other, or set a different `PORT`. Keep `DEVPLACE_WEB_WORKERS` in lockstep with `--workers`; see [Multi-worker and concurrency](/docs/production-concurrency.html) for why.
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to the box core count via `nproc`) from the project root, sharing the same database and files. It binds port 10500 directly, conflicting with the Docker front door on that port - run one or the other, or set a different `PORT`. Keep `DEVPLACE_WEB_WORKERS` in lockstep with `--workers`; see [Multi-worker and concurrency](/docs/production-concurrency.html) for why.
|
||||
</div>
|
||||
|
||||
@@ -54,14 +54,14 @@ and the reconciler executes them. Schedules use the shared cron/interval/one-tim
|
||||
stop instances. One-shot exec runs over HTTP; an interactive shell runs over a PTY-backed WebSocket on
|
||||
the lock owner.
|
||||
|
||||
Each launch also injects per-instance `PRAVDA_*` env vars (`PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`,
|
||||
`PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`) so code inside the
|
||||
Each launch also injects per-instance `DEVPLACE_*` env vars (`DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`,
|
||||
`DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`) so code inside the
|
||||
container - and `pagent` - can call back into the platform and the AI gateway authenticated as the user.
|
||||
|
||||
## Runtime data and operations
|
||||
|
||||
All generated data lives OUTSIDE the `devplacepy/` package and is never served via `/static`:
|
||||
persistent workspaces under `config.DATA_DIR/container_workspaces` (default `var/`, configurable with
|
||||
persistent workspaces under `config.DATA_DIR/container_workspaces` (default `data/`, configurable with
|
||||
the `DEVPLACE_DATA_DIR` env var - point it at a volume in production). The docker daemon must be able to
|
||||
bind-mount `DATA_DIR` for the `/app` mount. The service is disabled by default (it needs the docker
|
||||
socket); an administrator enables **Containers** on `/admin/services`, and the `ppy` image must be built
|
||||
@@ -89,7 +89,7 @@ the docker socket (root on the host - trusted admins only), installs the docker
|
||||
`INSTALL_DOCKER_CLI` build arg, and adds the host docker group (gid read from the socket via
|
||||
`stat -c '%g' /var/run/docker.sock`). Two docker-in-docker details matter. First, the data dir must be
|
||||
bind-mounted at the **same absolute path** on host and in the app container (make sets
|
||||
`DEVPLACE_DATA_DIR` to the project's own `./var`) because `docker run -v` resolves the source against
|
||||
`DEVPLACE_DATA_DIR` to the project's own `./data`) because `docker run -v` resolves the source against
|
||||
the host. Second, the ingress proxy reaches a published port through the container's own docker bridge
|
||||
gateway plus the published host port: the reconciler records each instance's `container_ip` and
|
||||
`container_gateway` from `docker inspect`, and the `/p/<slug>` proxy dials `gateway:host_port` by
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Reading service data
|
||||
|
||||
Live status and metrics for every background service are available over HTTP, admin-only, and refreshed every few seconds from the `service_state` table. See also the [Services overview](/docs/services.html).
|
||||
Live status and metrics for every background service are available over HTTP, admin-only, and refreshed every few seconds from the `service_state` table. See also the [Services overview](/docs/services-overview.html).
|
||||
|
||||
> Audience: administrators and maintainers. All endpoints here require an administrator; non-admins get redirected or a 403.
|
||||
|
||||
@@ -38,7 +38,7 @@ Returns `{ "service": { ... } }` with the same `describe()` shape for one servic
|
||||
|
||||
## What is in each metrics block
|
||||
|
||||
The gateway and the bots report rich metrics; Devii reports session and spend counters; news reports none (its health is the status and log tail).
|
||||
The gateway and the bots report rich metrics; Devii reports session and spend counters; news reports its AI usage cards (calls, tokens, cost, and averages) alongside the status and log tail.
|
||||
|
||||
**`openai` (AI gateway)** reports a runtime block (`requests`, `errors`, `in_flight`, `peak_in_flight`, `vision_calls`, `last_status`, `last_latency_ms`, `pool`, `circuit_open`) and a rolled-up 24h summary (`requests`, `success_pct`, `error_pct`, `cost_hour`, `cost_24h`, `tokens_24h`, `avg_latency_ms`, `avg_tps`, `peak_concurrency`, `top_model`, `top_caller`). Presented as a labelled stats list.
|
||||
|
||||
@@ -46,6 +46,8 @@ The gateway and the bots report rich metrics; Devii reports session and spend co
|
||||
|
||||
**`bots`** reports a stats list (bots running, fleet cost, cost rate, projected 24h cost, LLM calls, tokens in and out, posts, comments, votes) plus a per-bot table (bot, user, persona, status, posts, comments, votes, calls, cost).
|
||||
|
||||
**`news`** reports a `stats` block of AI usage cards (grading and formatting calls, tokens, cost, and per-call averages) rolled up from the `news_usage` table; its run health is still the status, last and next run, and the log tail.
|
||||
|
||||
## Deeper AI analytics (gateway)
|
||||
|
||||
The headline gateway numbers are summaries. The full analytics, the same data shown on the `/admin/ai-usage` page, come from:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Service framework
|
||||
|
||||
Two files make up the framework: `services/base.py` (the `ConfigField` and `BaseService` classes) and `services/manager.py` (the `ServiceManager` singleton). Together they give every service identical configuration, supervision, persistence, and monitoring. See also the [Services overview](/docs/services.html) and [Reading service data](/docs/services-data.html).
|
||||
Two files make up the framework: `services/base.py` (the `ConfigField` and `BaseService` classes) and `services/manager.py` (the `ServiceManager` singleton). Together they give every service identical configuration, supervision, persistence, and monitoring. See also the [Services overview](/docs/services-overview.html) and [Reading service data](/docs/services-data.html).
|
||||
|
||||
> Audience: administrators and maintainers.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="docs-content" data-render>
|
||||
# NewsService
|
||||
|
||||
`name` `news` - enabled by default - 3600s interval (minimum 60s). It is a fully automatic, zero-maintenance import pipeline: it fetches developer news from a configured feed, cleans each article, fetches and perceptually compares the images to reject placeholders and detect uniqueness, grades each article deterministically, and auto-rotates the best ones to Featured and Landing. Source: `services/news.py`. See also [Reading service data](/docs/services-data.html).
|
||||
`name` `news` - enabled by default - 3600s interval (minimum 60s). It is a fully automatic, zero-maintenance import pipeline: it fetches developer news from a configured feed, cleans each article, fetches and perceptually compares the images to reject placeholders and detect uniqueness, grades each article deterministically, reformats every valid one into clean Markdown, and auto-rotates the best ones to Featured and Landing. Source: `services/news/service.py`. See also [Reading service data](/docs/services-data.html).
|
||||
|
||||
> Audience: administrators and maintainers.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
1. Reads the source URL, grading URL, model, and threshold from configuration.
|
||||
2. Fetches the feed over HTTP (30s timeout) and reads its `articles` list. On error it logs and returns.
|
||||
3. Loads the set of already-synced external ids from `news_sync` so seen articles are skipped.
|
||||
4. For each new article it cleans the text, fetches up to 5 candidate images, perceptually hashes them, grades the article on the cleaned text, computes a final score, decides published versus draft and Featured, upserts the `news` row, records the sync state, and stores the images with their hashes.
|
||||
4. For each new article it cleans the text, fetches up to 5 candidate images, perceptually hashes them, grades the article on the cleaned text, computes a final score, reformats a valid article into clean Markdown (when `news_format_enabled` is on, fail-soft to the cleaned original), decides published versus draft and Featured, upserts the `news` row, records the sync state, and stores the images with their hashes.
|
||||
5. After the per-article loop it rotates Landing: the top Featured unique-image articles from the recent window are shown on the landing page and the rest of the service-managed set is cleared.
|
||||
6. Logs a summary: new, updated, draft, rejected, grading-failed, and skipped counts.
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
- `news_grade_prompt` (text) - the exact grading rubric sent to the model; defaults to the specification below and is editable on the service page. The cleaned Title, Description and Content are appended automatically.
|
||||
- `news_grade_threshold` (int, default 7, range 1 to 10) - articles whose effective score (AI grade plus the unique-image bonus minus the thin-content penalty) reaches this publish, below drafts.
|
||||
- `news_ai_key` (secret) - defaults to the `NEWS_AI_KEY` env var, then the gateway internal key.
|
||||
- `news_format_enabled` (bool, default on, group AI formatting) - when on, every valid article is reformatted into clean Markdown after grading.
|
||||
- `news_format_prompt` (text, group AI formatting) - the instruction sent to the AI to reformat each cleaned article into Markdown; the Title and body are appended automatically. It must preserve every fact and output only the reformatted Markdown body.
|
||||
|
||||
Plus the inherited Enabled, Run interval, and Log buffer size fields.
|
||||
|
||||
@@ -74,8 +76,9 @@ Content: ...
|
||||
| `news` | The article rows: `uid`, `slug`, `external_id`, `title`, `description` (cleaned, capped 5000), `url`, `image_url` (the chosen unique image), `has_unique_image`, `source_name`, `grade` (effective score), `ai_grade` (raw AI grade), `status` (`published` or `draft`), `featured`, `featured_locked`, `landing_locked`, `show_on_landing`, `content` (cleaned, capped 10000), `author`, `article_published`, `synced_at`. Existing rows are updated in place by `external_id` (locked Featured stays untouched). |
|
||||
| `news_sync` | One row per external id tracking `status` (`graded`, `grading_failed`, or `rejected_quality:<reason>`) and `synced_at`, so an article is graded only once. |
|
||||
| `news_images` | Images fetched per article, keyed by `news_uid`, each with `url`, `alt_text`, `phash`, `width`, `height`, and `is_placeholder`; replaced wholesale when the article is updated. |
|
||||
| `news_usage` | Single-row rolling AI usage totals (grading and formatting calls, tokens, cost, and latency) accumulated per run via `add_news_usage`; surfaced as the service `stats` metric cards. |
|
||||
|
||||
## Data it offers
|
||||
|
||||
NewsService does not override `collect_metrics()`, so its `metrics` block is empty. Health is read from the framework fields surfaced at `GET /admin/services/data`: `status` (`running` / `stopped` / `stalled`), `last_run`, `next_run`, `heartbeat`, and the `log_buffer`, where each run records its new/updated/draft/failed/skipped summary. The graded articles are the product, visible under `/news` and in the `news` table.
|
||||
NewsService overrides `collect_metrics()` to return a `stats` block of AI usage cards rolled up from the `news_usage` table (grading and formatting calls, tokens, cost, and the per-call averages, the same shape as the correction and modifier usage tables). Framework health is surfaced alongside it at `GET /admin/services/data`: `status` (`running` / `stopped` / `stalled`), `last_run`, `next_run`, `heartbeat`, and the `log_buffer`, where each run records its new/updated/draft/failed/skipped summary. The graded articles are the product, visible under `/news` and in the `news` table.
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="docs-content" data-render>
|
||||
# Services overview
|
||||
|
||||
DevPlace runs four long-lived background services inside the FastAPI process: the **AI gateway**, the **Devii assistant**, the **news fetcher**, and the **bot fleet**. They share one small framework (`BaseService` plus a `ServiceManager` singleton), so every service is configured, supervised, monitored, and persisted the same way and appears automatically on `/admin/services` with no extra wiring.
|
||||
DevPlace runs a fleet of long-lived background services inside the FastAPI process. Four of them are the principal, user-facing ones documented here: the **AI gateway**, the **Devii assistant**, the **news fetcher**, and the **bot fleet** (the rest are job runners, relays, and integrations covered on their own pages). They all share one small framework (`BaseService` plus a `ServiceManager` singleton), so every service is configured, supervised, monitored, and persisted the same way and appears automatically on `/admin/services` with no extra wiring.
|
||||
|
||||
> Audience: administrators and maintainers. These pages are hidden from members and guests in the sidebar, the search index, and the documentation export. They describe internal mechanics, live data, and money.
|
||||
|
||||
@@ -45,7 +45,7 @@ Every service exposes a `metrics` dictionary through `describe()`, surfaced live
|
||||
|---------|------------------------------|
|
||||
| `openai` | requests, errors, in-flight and peak concurrency, vision calls, last status and latency, circuit state, plus rolled-up 24h cost, tokens, success rate, average latency and TPS, top model and caller. |
|
||||
| `devii` | active sessions, open connections, 24h spend, the user/guest/admin caps, whether guests are allowed, and the model name. |
|
||||
| `news` | no custom metrics; status, last and next run, and the log tail report its health. |
|
||||
| `news` | AI usage cards (grading and formatting calls, tokens, cost, and averages) rolled up from `news_usage`, plus status, last and next run, and the log tail for run health. |
|
||||
| `bots` | bots running, fleet cost, cost rate, projected 24h cost, LLM calls, tokens in and out, posts, comments, votes, and a per-bot table. |
|
||||
|
||||
For the deep AI analytics behind the gateway headline numbers (per-hour buckets, percentiles, cost projections, per-user usage), see [GatewayService (AI)](/docs/services-gateway.html) and the `/admin/ai-usage` page.
|
||||
|
||||
@@ -17,7 +17,7 @@ A zip job payload carries a `source` object with a `type`. The only implemented
|
||||
{"source": {"type": "project_tree", "project_uid": "...", "path": "src"}}
|
||||
```
|
||||
|
||||
An empty `path` archives the whole project; a non-empty `path` archives that file or directory subtree. `ZipService._materialize` dispatches on the type and calls `project_files.export_to_dir`, which reconstructs the virtual project filesystem (inline text rows plus on-disk binary blobs) onto a staging directory under the runtime data dir (`config.DATA_DIR/zip_staging/{job_uid}`, default `var/`, outside the package), with a traversal guard on every written path. Materialization runs inside the worker via `asyncio.to_thread`, never in the request handler.
|
||||
An empty `path` archives the whole project; a non-empty `path` archives that file or directory subtree. `ZipService._materialize` dispatches on the type and calls `project_files.export_to_dir`, which reconstructs the virtual project filesystem (inline text rows plus on-disk binary blobs) onto a staging directory under the runtime data dir (`config.DATA_DIR/zip_staging/{job_uid}`, default `data/`, outside the package), with a traversal guard on every written path. Materialization runs inside the worker via `asyncio.to_thread`, never in the request handler.
|
||||
|
||||
## Subprocess compression
|
||||
|
||||
|
||||
@@ -95,5 +95,5 @@ The **Media** trash (`/admin/media`) is the attachment-specific view of the same
|
||||
- Restore and purge are admin UI endpoints under `/admin/trash`; the member-facing delete endpoints
|
||||
live on each content type's API page.
|
||||
- The data-layer primitives are `soft_delete`, `soft_delete_in`, `restore`, `purge`, `restore_event`,
|
||||
and `purge_event` in `database.py`.
|
||||
and `purge_event` in the `database` package (`database/soft_delete.py`).
|
||||
</div>
|
||||
|
||||
@@ -19,12 +19,12 @@ Layered from the page backdrop up to interactive surfaces.
|
||||
|
||||
| Token | Value | Use it for | Do not |
|
||||
|-------|-------|-----------|--------|
|
||||
| `--bg-primary` | `#0f0a1a` | The page backdrop (`body`). | Put cards or inputs directly on it without a surface token. |
|
||||
| `--bg-secondary` | `#1a1028` | The top nav, dropdowns, the mobile panel, demo frames. | Use as a card body in the content area. |
|
||||
| `--bg-card` | `#221436` | Cards, panels, the standard content surface. | Use for the page backdrop. |
|
||||
| `--bg-card-hover` | `#2a1a42` | Hover state of cards, list rows, and ghost buttons. | Use as a resting background. |
|
||||
| `--bg-input` | `#1a1028` | Inputs, textareas, selects. | Use as a content card surface. |
|
||||
| `--bg-modal` | `#1a1028` | Modal card body. | Use outside modals. |
|
||||
| `--bg-primary` | `#080413` | The page backdrop (`body`). | Put cards or inputs directly on it without a surface token. |
|
||||
| `--bg-secondary` | `#120821` | The top nav, dropdowns, the mobile panel, demo frames. | Use as a card body in the content area. |
|
||||
| `--bg-card` | `#1a1030` | Cards, panels, the standard content surface. | Use for the page backdrop. |
|
||||
| `--bg-card-hover` | `#241640` | Hover state of cards, list rows, and ghost buttons. | Use as a resting background. |
|
||||
| `--bg-input` | `#140b26` | Inputs, textareas, selects. | Use as a content card surface. |
|
||||
| `--bg-modal` | `#1a1030` | Modal card body. | Use outside modals. |
|
||||
| `--bg-gradient` | violet gradient | Large hero / landing surfaces only. | Apply to small components. |
|
||||
|
||||
## Accent
|
||||
@@ -32,8 +32,8 @@ Layered from the page backdrop up to interactive surfaces.
|
||||
| Token | Value | Use it for | Do not |
|
||||
|-------|-------|-----------|--------|
|
||||
| `--accent` | `#ff6b35` | The single primary action, active nav link, links, the voted star, focus border. | A second decorative colour, large fills, or more than one primary action per view. |
|
||||
| `--accent-hover` | `#e55a2b` | Hover state of accent surfaces and links. | Resting state. |
|
||||
| `--accent-light` | `rgba(255,107,53,.1)` | The tinted background behind an *active* nav item or self-highlight row. | Body text (too low contrast). |
|
||||
| `--accent-hover` | `#ff7d4d` | Hover state of accent surfaces and links. | Resting state. |
|
||||
| `--accent-light` | `rgba(255,107,53,.12)` | The tinted background behind an *active* nav item or self-highlight row. | Body text (too low contrast). |
|
||||
|
||||
## Text
|
||||
|
||||
@@ -41,7 +41,7 @@ A strict three-step hierarchy. Never introduce a fourth text shade.
|
||||
|
||||
| Token | Value | Use it for |
|
||||
|-------|-------|-----------|
|
||||
| `--text-primary` | `#f0e8f8` | Headings and body content. |
|
||||
| `--text-primary` | `#f4eefb` | Headings and body content. |
|
||||
| `--text-secondary` | `#b8a8d0` | Supporting copy, labels, secondary buttons. |
|
||||
| `--text-muted` | `#7a6a90` | Timestamps, counts, hints, metadata. |
|
||||
|
||||
@@ -49,11 +49,11 @@ A strict three-step hierarchy. Never introduce a fourth text shade.
|
||||
|
||||
| Token | Value | Use it for |
|
||||
|-------|-------|-----------|
|
||||
| `--border` | `#2e2040` | The default hairline on cards, inputs, dividers. |
|
||||
| `--border-light` | `#3a2a50` | Hover/emphasis borders. |
|
||||
| `--radius` | `8px` | Buttons, inputs, small controls. |
|
||||
| `--radius-lg` | `12px` | Cards and panels. |
|
||||
| `--radius-xl` | `16px` | Large feature surfaces. |
|
||||
| `--border` | `rgba(255,255,255,.08)` | The default hairline on cards, inputs, dividers. |
|
||||
| `--border-light` | `rgba(255,255,255,.14)` | Hover/emphasis borders. |
|
||||
| `--radius` | `12px` | Buttons, inputs, small controls. |
|
||||
| `--radius-lg` | `18px` | Cards and panels. |
|
||||
| `--radius-xl` | `24px` | Large feature surfaces. |
|
||||
| `--shadow-sm` / `--shadow` / `--shadow-lg` | (shadows) | Card hover, raised panels, modals respectively. |
|
||||
|
||||
## Status colours (semantic only)
|
||||
@@ -78,7 +78,7 @@ Used exclusively for topic badges (`.badge-devlog`, `.badge-showcase`, …) and
|
||||
| `--topic-question` | `#448aff` | Question |
|
||||
| `--topic-rant` | `#ff5252` | Rant |
|
||||
| `--topic-fun` | `#ffab00` | Fun |
|
||||
| `--topic-signals` | `#00bcd4` | Signals |
|
||||
| `--topic-politics` | `#00bcd4` | Politics |
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -121,16 +121,16 @@ The reference below is rendered live from the tokens, so it always reflects the
|
||||
<div class="component-demo">
|
||||
<div class="component-demo-title">Live palette</div>
|
||||
<div class="sg-swatch-grid">
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-primary</span><span class="sg-swatch-hex">#0f0a1a</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-secondary</span><span class="sg-swatch-hex">#1a1028</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card</span><span class="sg-swatch-hex">#221436</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card-hover</span><span class="sg-swatch-hex">#2a1a42</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-primary</span><span class="sg-swatch-hex">#080413</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-secondary</span><span class="sg-swatch-hex">#120821</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card</span><span class="sg-swatch-hex">#1a1030</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-bg-card-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--bg-card-hover</span><span class="sg-swatch-hex">#241640</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-accent"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent</span><span class="sg-swatch-hex">#ff6b35</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-accent-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent-hover</span><span class="sg-swatch-hex">#e55a2b</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-text-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-primary</span><span class="sg-swatch-hex">#f0e8f8</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-accent-hover"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--accent-hover</span><span class="sg-swatch-hex">#ff7d4d</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-text-primary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-primary</span><span class="sg-swatch-hex">#f4eefb</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-text-secondary"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-secondary</span><span class="sg-swatch-hex">#b8a8d0</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-text-muted"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--text-muted</span><span class="sg-swatch-hex">#7a6a90</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-border"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--border</span><span class="sg-swatch-hex">#2e2040</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-border"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--border</span><span class="sg-swatch-hex">rgba(255,255,255,.08)</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-success"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--success</span><span class="sg-swatch-hex">#4caf50</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-warning"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--warning</span><span class="sg-swatch-hex">#ff9800</span></span></div>
|
||||
<div class="sg-swatch"><span class="sg-chip sg-chip-danger"></span><span class="sg-swatch-info"><span class="sg-swatch-name">--danger</span><span class="sg-swatch-hex">#e53935</span></span></div>
|
||||
@@ -146,7 +146,7 @@ The reference below is rendered live from the tokens, so it always reflects the
|
||||
<span class="badge badge-question">Question</span>
|
||||
<span class="badge badge-rant">Rant</span>
|
||||
<span class="badge badge-fun">Fun</span>
|
||||
<span class="badge badge-signals">Signals</span>
|
||||
<span class="badge badge-politics">Politics</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Auto-filling responsive grid for galleries such as projects:
|
||||
```css
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
```
|
||||
@@ -92,12 +92,12 @@ Use the spacing scale, not arbitrary values. Card padding is `1rem`; the gap bet
|
||||
| Token | Value |
|
||||
|-------|-------|
|
||||
| `--space-xs` | `0.25rem` |
|
||||
| `--space-sm` | `0.375rem` |
|
||||
| `--space-sm` | `0.5rem` |
|
||||
| `--space-base` | `0.5rem` |
|
||||
| `--space-md` | `0.75rem` |
|
||||
| `--space-lg` | `1rem` |
|
||||
| `--space-xl` | `1.25rem` |
|
||||
| `--space-2xl` | `1.5rem` |
|
||||
| `--space-xl` | `1.5rem` |
|
||||
| `--space-2xl` | `2rem` |
|
||||
|
||||
## Acceptable and not
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ The interface works from a 320px phone to a wide desktop without a separate mobi
|
||||
The viewport is declared once in `base.html` and must not be changed:
|
||||
|
||||
```html
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
```
|
||||
|
||||
## The standard breakpoint ladder
|
||||
|
||||
@@ -8,9 +8,8 @@ Every test workflow has a `make` target, so commands stay short and identical ac
|
||||
Install the package with its development extras (`pytest`, Playwright, coverage) and the browser binary once:
|
||||
|
||||
```bash
|
||||
make install # pip install -e .
|
||||
pip install -e ".[dev]" # test dependencies
|
||||
python -m playwright install chromium # browser used by the E2E suite
|
||||
make install # pip install -e . AND playwright install chromium
|
||||
pip install -e ".[dev]" # test dependencies (pytest, coverage, requests)
|
||||
```
|
||||
|
||||
## Correctness suite
|
||||
|
||||
@@ -5,7 +5,7 @@ How DevPlace verifies itself: an end-to-end browser suite, in-process unit tests
|
||||
|
||||
## Three tiers, a directory tree that mirrors the path
|
||||
|
||||
The `tests/` directory holds the correctness suite: 932 tests in three category directories by *what each test exercises*. Within each tier the **directory structure mirrors the path** - one segment per directory, the last segment is the file:
|
||||
The `tests/` directory holds the correctness suite: roughly 1,959 tests in three category directories by *what each test exercises*. Within each tier the **directory structure mirrors the path** - one segment per directory, the last segment is the file:
|
||||
|
||||
- **`tests/api/` - HTTP integration tests.** `requests`/`httpx` drive a real Uvicorn server (`app_server`) and assert on status, JSON, and headers without a browser. **Organised by endpoint path.**
|
||||
- **`tests/e2e/` - end-to-end (Playwright).** A real Chromium browser drives the same Uvicorn server, covering anything a user sees or clicks. **Organised by endpoint path.**
|
||||
|
||||
@@ -37,9 +37,9 @@
|
||||
<span class="icon">🎮</span>
|
||||
Fun
|
||||
</a>
|
||||
<a href="/feed?topic=signals" class="sidebar-link {% if current_topic == 'signals' %}active{% endif %}" data-topic="signals">
|
||||
<span class="icon">📡</span>
|
||||
Signals
|
||||
<a href="/feed?topic=politics" class="sidebar-link {% if current_topic == 'politics' %}active{% endif %}" data-topic="politics">
|
||||
<span class="icon">🏛</span>
|
||||
Politics
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ templates.env.globals["devrant_endpoints"] = devrant_endpoints
|
||||
|
||||
_unread_cache = TTLCache(ttl=10, max_size=1000)
|
||||
_messages_cache = TTLCache(ttl=10, max_size=1000)
|
||||
_projects_cache = TTLCache(ttl=10, max_size=1000)
|
||||
|
||||
|
||||
def clear_unread_cache(user_uid: str) -> None:
|
||||
@@ -84,6 +85,10 @@ def clear_messages_cache(user_uid: str) -> None:
|
||||
_messages_cache.pop(user_uid)
|
||||
|
||||
|
||||
def clear_user_projects_cache(user_uid: str) -> None:
|
||||
_projects_cache.pop(user_uid)
|
||||
|
||||
|
||||
def _cached_count(cache: TTLCache, table_name: str, cache_key: str, **filters) -> int:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
@@ -103,8 +108,13 @@ def jinja_unread_messages(user_uid: str) -> int:
|
||||
|
||||
|
||||
def jinja_user_projects(user_uid: str) -> list[dict]:
|
||||
cached = _projects_cache.get(user_uid)
|
||||
if cached is not None:
|
||||
return cached
|
||||
projects = get_table("projects")
|
||||
return list(projects.find(user_uid=user_uid))
|
||||
rows = list(projects.find(user_uid=user_uid, deleted_at=None))
|
||||
_projects_cache.set(user_uid, rows)
|
||||
return rows
|
||||
|
||||
|
||||
templates.env.globals["get_unread_count"] = jinja_unread_count
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user