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:
parent
f1bdefd834
commit
9a8046ab2a
45
README.md
45
README.md
@ -19,7 +19,7 @@ Open `http://localhost:10500`.
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Backend | Python 3.13+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Backend | Python 3.12+, FastAPI, Uvicorn (multi-worker in production) |
|
||||
| Templates | Jinja2 (server-side rendered) |
|
||||
| Frontend | Pure ES6 JavaScript, one class per file |
|
||||
| Database | SQLite via `dataset` (auto-sync schema, `uid` PKs, WAL mode, 30s busy timeout) |
|
||||
@ -35,10 +35,10 @@ Open `http://localhost:10500`.
|
||||
devplacepy/
|
||||
main.py # FastAPI app, router registration
|
||||
config.py # Settings from env vars + .env
|
||||
database.py # dataset connection, index creation
|
||||
database/ # dataset connection, index creation (package)
|
||||
templating.py # Shared Jinja2 environment + globals
|
||||
avatar.py # Multiavatar generation, URL builder
|
||||
utils.py # Password hashing, session mgmt, time_ago, notification hook
|
||||
utils/ # Password hashing, session mgmt, time_ago, notification hook (package)
|
||||
models.py # Pydantic schemas
|
||||
push.py # Web push crypto, VAPID keys, encrypt/send/register
|
||||
routers/ # One file per domain (auth, feed, posts, push, ...)
|
||||
@ -63,8 +63,8 @@ devplacepy/
|
||||
| `/projects/{slug}/files` | Per-project filesystem: directory and file CRUD, upload, inline editing, and line-range operations (`lines` read, `replace-lines`, `insert-lines`, `delete-lines`, `append`) for surgical edits to large text files (public read, owner write; all writes refused while the project is read-only) |
|
||||
| `/zips` | Zip job status (`/zips/{uid}`) and archive download (`/zips/{uid}/download`); archives are queued via `/projects/{slug}/zip` and `/projects/{slug}/files/zip` |
|
||||
| `/forks` | Fork job status (`/forks/{uid}`); forks are queued via `/projects/{slug}/fork`. Any signed-in user can fork a project they can view into a new project they own; once the job finishes the response carries the new project URL |
|
||||
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence and gap analysis, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}` |
|
||||
| `/projects/{slug}/containers` | Admin per-project container manager: Dockerfile CRUD with immutable versions, async image builds, and container instance creation. Reachable from the project page via the admin-only **Containers** button |
|
||||
| `/tools` | Public developer tools. `/tools/seo` is **SEO Diagnostics**: audit any URL or sitemap and stream live progress over a websocket. Queue with `POST /tools/seo/run`, poll `GET /tools/seo/{uid}`, read the full report at `GET /tools/seo/{uid}/report`. `/tools/deepsearch` is **DeepSearch**: a multi-agent deep web researcher that crawls and indexes sources, synthesises a cited report with confidence and gap analysis, and lets you chat over the gathered evidence. Queue with `POST /tools/deepsearch/run`, poll `GET /tools/deepsearch/{uid}`, read the report at `GET /tools/deepsearch/{uid}/session`, export at `/export.{md,json,pdf}`. `/tools/isslop` is the **AI Usage Analyzer**: classify a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Queue with `POST /tools/isslop/run`, poll `GET /tools/isslop/{uid}` or the event trail at `GET /tools/isslop/{uid}/events`, read the report at `GET /tools/isslop/{uid}/report` (`.md` to download) and embed the SVG authenticity badge from `GET /tools/isslop/{uid}/badge.svg` |
|
||||
| `/projects/{slug}/containers` | Admin per-project container manager: create and control container instances, all running the shared prebuilt `ppy` image (there is no in-app image building). Reachable from the project page via the admin-only **Containers** button |
|
||||
| `/admin/containers` | Admin **Containers** manager: list, create, edit, and control every container instance across all projects (scoped by project visibility - instances attached to another administrator's hidden project are excluded). The list (`/admin/containers`) has inline start/stop/restart/terminal/edit/delete on each row and a create form (pick a project, optionally a run-as user, a boot language with a source editor, restart policy, start-on-boot, plus env/ports/limits/ingress). Each instance has a detail page (`/admin/containers/{uid}`) with lifecycle controls, live logs and metrics, an interactive terminal, schedules, ingress, workspace sync, and a status history, and an edit page (`/admin/containers/{uid}/edit`) |
|
||||
| `/p/{slug}` | Public ingress proxy (HTTP + WebSocket) to a running container instance's published port, opt-in per instance via `ingress_slug` |
|
||||
| `/profile` | Profile view, editing, a public **Media** tab (`?tab=media`) showing every attachment a user uploaded newest first, and a live **online / last-seen** presence indicator |
|
||||
@ -72,7 +72,7 @@ devplacepy/
|
||||
| `/uploads` | File upload endpoints: `POST /uploads/upload` (multipart), `POST /uploads/upload-url` (from URL); served at `/static/uploads/` |
|
||||
| `/admin/trash` | Admin **Trash**: review, restore, and permanently purge soft-deleted content (posts, comments, gists, projects, news, project files, attachments) across the platform |
|
||||
| `/notifications` | Notification list, mark read, live unread counts (`/notifications/counts`) |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. The `POST /messages/send` form remains as a no-JavaScript fallback |
|
||||
| `/messages` | Real-time direct messaging over WebSocket (`/messages/ws`): live bidirectional delivery, optimistic send, typing indicators, read receipts, and online/last-seen presence. Messages render through the shared content pipeline (emoji shortcodes, image and YouTube embeds, autolink, sanitization). AI content correction and the AI modifier apply to direct messages, so typing an inline `@ai <instruction>` in a message executes it and the resolved result appears live in the chat for both participants. An opened conversation loads its 500 most recent messages; older history is retained in the database. The `POST /messages/send` form remains as a no-JavaScript fallback |
|
||||
| `/votes` | Upvote/downvote on posts, comments, projects |
|
||||
| `/reactions` | Emoji reactions on posts, comments, gists, projects |
|
||||
| `/bookmarks` | Save/unsave content; `/bookmarks/saved` personal list |
|
||||
@ -99,7 +99,7 @@ Member progression is driven by activity and peer recognition.
|
||||
|
||||
- **Stars** are the net vote score (`upvotes - downvotes`) on a post, project, or gist. A member's total stars is the sum across all their content and is the basis for ranking.
|
||||
- **XP and levels.** Members earn XP for contributing: posting (10), commenting (2), publishing a project (15) or gist (5), receiving an upvote (5), and gaining a follower (5). Each level requires 100 XP (`level = 1 + xp // 100`). The profile shows the current level and progress to the next.
|
||||
- **Badges** are awarded once and never revoked, across several themed groups (First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels). They cover three kinds of achievement: **content and reputation milestones** (10/50/100 posts, 25/100/500 stars, 10/50/100 followers, comment and project and gist counts, following 10 people, 7/30/100-day activity streaks, reaching levels 5/10/25/50/100); **first-time feature use** (your first comment, project, gist, fork, archive download, SEO audit, DeepSearch, container, direct message, bookmark, reaction, star given, follow, upload, project file, issue, poll vote, profile customization, and first conversation with Devii); and **usage tiers** for several of those features (for example reading 1/5/15 documentation pages, or giving 50/250 stars). Each profile has a collapsible **Achievements** showcase that lists every badge grouped by theme, with earned ones highlighted and locked ones shown with their unlock condition, so there is always a next prize to chase.
|
||||
- **Badges** are awarded once and never revoked, across several themed groups (First steps, Explorer, Engagement, Content, Community, Reputation, Dedication, Levels). They cover three kinds of achievement: **content and reputation milestones** (10/50/100 posts, 25/100/500 stars, 10/50/100 followers, comment and project and gist counts, following 10 people, 7/30/100-day activity streaks, reaching levels 5/10/25/50/100); **first-time feature use** (your first comment, project, gist, fork, archive download, SEO audit, DeepSearch, AI usage analysis, container, direct message, bookmark, reaction, star given, follow, upload, project file, issue, poll vote, profile customization, and first conversation with Devii); and **usage tiers** for several of those features (for example reading 1/5/15 documentation pages, or giving 50/250 stars). Each profile has a collapsible **Achievements** showcase that lists every badge grouped by theme, with earned ones highlighted and locked ones shown with their unlock condition, so there is always a next prize to chase.
|
||||
- **Leaderboard** (`/leaderboard`) ranks the top 50 members by total stars (single page, no pagination); a member's own rank is shown on their profile.
|
||||
- **Contribution heatmap and streaks.** Each profile shows a 12-month activity heatmap and the current/longest daily streak, derived from post/comment/gist/project timestamps (no extra storage).
|
||||
- **Social graph listings.** Each profile has Followers and Following tabs that paginate the follow graph (25 per page) and show a follow/unfollow control for each person. The same data is available as JSON at `GET /profile/{username}/followers` and `GET /profile/{username}/following`.
|
||||
@ -141,11 +141,11 @@ The farm refreshes live over the pub/sub bus (a watered build appears on the own
|
||||
- **Private projects** - an owner can mark a project private so it is visible only to them (and administrators) and excluded from listings, profiles, search, the sitemap, and zip access. Set at creation or toggled later from the project page.
|
||||
- **Read-only projects** - an owner can mark a project read-only, making its entire virtual filesystem immutable: every write, edit, line-edit, move, delete, and upload is refused from all paths (the web UI, the HTTP API, the Devii agent, and container workspace sync) until read-only is turned off. Devii may toggle read-only only after the user explicitly confirms.
|
||||
|
||||
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils.py`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
|
||||
XP awards are wired at the existing content-creation, vote, and follow hook points in the routers and centralized in `award_xp()` / `check_milestone_badges()` (`devplacepy/utils/`). Existing accounts have their XP and levels backfilled once from prior activity at startup (`init_db()`).
|
||||
|
||||
## Vibe coding (Alpha, admin only)
|
||||
|
||||
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `PRAVDA_BASE_URL`, `PRAVDA_OPENAI_URL`, `PRAVDA_API_KEY`, `PRAVDA_USER_UID`, `PRAVDA_CONTAINER_NAME`, `PRAVDA_CONTAINER_UID`, and `PRAVDA_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
|
||||
Build software by talking to an AI agent instead of typing every line. Create a project for storage, attach a container to it (the shared `ppy` image, your files mounted at `/app`), start it, and open a terminal. The whole flow is drivable conversationally through Devii. Inside every container three agents ship preinstalled and run on **your own API key**, so all AI usage is metered to your account: **DevPlace Code (`dpc`)**, a coding agent in the same class as Claude Code; **`botje.py`**, a plug-and-play DevPlace bot you can copy and customise; and **`pagent`**, a minimal zero-dependency agent. Each container is launched with `DEVPLACE_BASE_URL`, `DEVPLACE_OPENAI_URL`, `DEVPLACE_API_KEY`, `DEVPLACE_USER_UID`, `DEVPLACE_CONTAINER_NAME`, `DEVPLACE_CONTAINER_UID`, and `DEVPLACE_INGRESS_URL` already set. Publish a container port to a public URL at `/p/<slug>` by setting an `ingress_slug` and `ingress_port` (ask Devii to do it at create time). The feature is in **Alpha** and currently limited to administrators; the full walkthrough, including a tutorial that vibes a web app and puts it online, is at `/docs/getting-started-vibing.html`.
|
||||
|
||||
## Admin: Audit Log
|
||||
|
||||
@ -195,7 +195,7 @@ Numeric values are floored to safe minimums so an invalid entry cannot lock out
|
||||
|
||||
The website uses a `session` cookie. For automation, every page and action also
|
||||
accepts three header-based methods, resolved centrally in `get_current_user`
|
||||
(`utils.py`) so they work everywhere with no per-route changes:
|
||||
(`utils/`) so they work everywhere with no per-route changes:
|
||||
|
||||
- **API key** - `X-API-KEY: <key>`
|
||||
- **Bearer** - `Authorization: Bearer <key>`
|
||||
@ -221,7 +221,7 @@ Every endpoint that renders a page or returns a redirect also speaks JSON, so an
|
||||
website does is automatable from the same URLs. A request gets JSON when it sends
|
||||
`Accept: application/json` or `Content-Type: application/json`; a normal browser navigation
|
||||
(`Accept: text/html`) always gets HTML, so existing behaviour is unchanged (the legacy
|
||||
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas.py` and built
|
||||
`X-Requested-With: fetch` AJAX header still drives the four engagement endpoints only). JSON responses are defined by Pydantic models in `devplacepy/schemas/` and built
|
||||
from the same context the templates use (sensitive user fields like `email`/`api_key`/
|
||||
`password_hash` are never exposed). Page GETs return the page payload; form actions return a
|
||||
uniform envelope `{ "ok": true, "redirect": "…", "data": {…} }`; errors return
|
||||
@ -379,6 +379,8 @@ and its full configuration are documented automatically - including future servi
|
||||
|
||||
`DeepsearchService` powers the public **Tools -> DeepSearch** researcher. Given a single research question it plans a set of diverse web search queries, crawls and reads the most relevant sources in a subprocess (plain HTTP first, headless-browser fallback for JavaScript-heavy pages, PDF documents streamed and text-extracted, every URL SSRF-guarded), de-duplicates content, and indexes everything into a per-session ChromaDB vector collection (embeddings via the AI gateway with a local fallback). A chain of agents (summarizer, critic, linker) then synthesises a cited report with a confidence score, source diversity and explicit gap analysis. Progress streams live over `WS /tools/deepsearch/{uid}/ws`; the report is at `/tools/deepsearch/{uid}/session` (HTML or JSON) and can be exported as Markdown, JSON or PDF. A grounded chat over the session's evidence runs at `WS /tools/deepsearch/{uid}/chat` using hybrid retrieval (vector + keyword/BM25). Runs can be paused, resumed or cancelled. A cross-session URL cache avoids re-fetching pages seen by earlier runs. CLI: `devplace deepsearch prune` / `devplace deepsearch clear`. ChromaDB, weasyprint, pypdf and Playwright are core dependencies.
|
||||
|
||||
`IsslopService` powers the public **Tools -> AI Usage Analyzer**, which classifies a git repository or website as AI slop, sophisticated AI-assisted work or genuine human work. Sources are acquired in an isolated subprocess (git URLs are probed with `git ls-remote` and shallow-cloned with a 3 GB guard; websites render in a stealth headless browser with an HTTP fallback, bounded by depth, file and byte caps), inventoried with exclusion rules, and scored by a multi-signal static engine (twenty-one detector families, 126 signal types across an origin axis and a quality-deficit axis). For a live website, the analyzer also opens the home page in a headless browser and inspects what actually renders: it fingerprints AI website-builder platforms directly (Lovable, Bolt.new, Framer and others) and checks the page's real computed styles, layout and build artifacts, not just its file contents. Representative files receive an AI review pass and images an AI-generation review through the internal gateway (model `molodetz`, internal key); the static engine stays authoritative when the gateway is unreachable. Every pipeline step is persisted as an ordered event trail and streamed live over the pub/sub topic `public.isslop.{uid}`. The verdict is an A-F authenticity grade with a human/AI split and one of five categories (`ai-slop`, `sophisticated-ai`, `human-clean`, `human-messy`, `uncertain`), published as a persistent report with an embeddable SVG badge. Members keep their analysis history on their account; guest history is session-bound and claimed by the account on the first signed-in visit. Admin settings (private-host allowance, AI/image review toggles, image cap, retention, concurrency) live on `/admin/services`. CLI: `devplace isslop analyze <url>` / `devplace isslop prune` / `devplace isslop clear`. Playwright plus playwright-stealth back the website crawler.
|
||||
|
||||
`BackupService` powers the admin **Admin -> Backups** dashboard, an enterprise-grade backup system that runs entirely as asynchronous jobs so it never impacts the running server. An administrator can back up one of four targets: the **database** (a consistent SQLite snapshot of the main database and the Devii task/lesson databases, taken with SQLite's online backup API so it is consistent under WAL), **uploads** (every attachment and project file), **keys and config** (VAPID keys), or the **full data directory** (database snapshot, uploads, and keys in one archive, excluding regenerable staging, locks, caches, and container workspaces). Each backup is compressed to a `tar.gz` in a stdlib subprocess off the request path and recorded with its size, file count, and a SHA-256 checksum. Archives live under `data/backups/` (sharded on the random uuid tail) and are served only through `/admin/backups/{uid}/download`, which is restricted to the **primary administrator** - the first user created with the Admin role. Every other administrator receives a 403 from the endpoint and sees the Download button disabled with the tooltip `Not available`; creating, running, deleting, and scheduling backups remain available to all administrators. The dashboard reports detailed storage usage - the size and file count of every major data area, the total data-directory footprint, the total size and count of stored backups, and disk usage (total, used, free, percent), computed in a worker thread and cached briefly so the page never blocks. Backups can be **scheduled** (CRUD) on an interval or 5-field cron expression with a `keep_last` rotation count that prunes older backups of the same schedule; the service evaluates schedules only on the lock-owning worker so each fires exactly once. Backup archives are permanent operational artifacts: job retention only removes the tracking row, never the archive, which is deleted only by an administrator, by schedule rotation, or via the CLI. CLI: `devplace backups list` / `devplace backups run <target>` / `devplace backups prune` / `devplace backups clear`. Devii tools: `backups_overview`, `backup_run`, `backup_status`, `backup_delete`, `backup_schedule_create`, `backup_schedule_delete` (all admin-only). The service creates and stores backups but does not restore them into a live server; restore is a documented manual procedure (stop the server, unpack the archive over the data directory, verify the checksum, restart).
|
||||
|
||||
### Adding a service
|
||||
@ -448,7 +450,7 @@ Configuration on the Services tab:
|
||||
| `bot_max_per_article` | `2` | How many bots may post about one article, each from a different angle |
|
||||
| `bot_article_ttl_days` | `7` | How long an article stays covered before it can be posted again |
|
||||
| `bot_gist_min_lines` | `6` | Reject generated snippets shorter than this many non-empty lines |
|
||||
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `30` | Idle pause window a bot takes after each action |
|
||||
| `bot_action_pause_min_seconds` / `bot_action_pause_max_seconds` | `5` / `45` | Idle pause window a bot takes after each action |
|
||||
| `bot_break_scale` | `1.0` | Multiplier on between-session breaks (below 1 = more active and costlier) |
|
||||
| `bot_ai_decisions` | disabled | Let each bot's AI-generated identity decide every action via the LLM instead of fixed probabilities (one decision call per page); see `aibots.md` |
|
||||
| `bot_decision_temperature` | `0.4` | Sampling temperature for the per-page decision call |
|
||||
@ -584,9 +586,9 @@ Configuration on the Services tab:
|
||||
|
||||
| Parameter | Default | Purpose |
|
||||
|-----------|---------|---------|
|
||||
| `devii_ai_url` | `https://openai.app.molodetz.nl/v1/chat/completions` | OpenAI-compatible reasoning endpoint |
|
||||
| `devii_ai_url` | `http://localhost:10500/openai/v1/chat/completions` | OpenAI-compatible reasoning endpoint (defaults to the internal gateway) |
|
||||
| `devii_ai_model` | `molodetz` | Model name |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`) | AI API key |
|
||||
| `devii_ai_key` | env fallback (`DEVII_AI_KEY`), then the gateway internal key | AI API key |
|
||||
| `devii_base_url` | this instance's origin | Platform Devii drives via each user's API key |
|
||||
| `devii_plan_required` / `devii_verify_required` | on / on | Enforce plan-first and verify-after-mutation |
|
||||
| `devii_max_iterations` | `40` | Tool-loop iterations per turn |
|
||||
@ -719,7 +721,7 @@ installable Progressive Web App. Push uses only standard libraries (`cryptograph
|
||||
|
||||
### Events
|
||||
|
||||
Every event flows through a single funnel - `create_notification()` in `utils.py` -
|
||||
Every event flows through a single funnel - `create_notification()` in `utils/` -
|
||||
which delivers on three independent channels, in-app, web push and Telegram, each gated by the
|
||||
recipient's preferences (see "Configurable notifications" below). Whenever the in-app
|
||||
channel delivers, the recipient's open browser also raises a live, click-through toast
|
||||
@ -790,8 +792,8 @@ every page load. `PushManager.js` owns registration, subscription, and the opt-i
|
||||
|
||||
### PWA
|
||||
|
||||
`manifest.json` (192/512 and maskable icons), `service-worker.js`, and an install
|
||||
button (`PwaInstaller.js`) make the app installable. The service worker uses a
|
||||
`manifest.json` (192/512 and maskable icons) and `service-worker.js` make the app
|
||||
installable via the browser's native install affordance. The service worker uses a
|
||||
network-first strategy for navigations and falls back to `static/offline.html` when
|
||||
offline. Installation requires a secure origin (HTTPS, or `localhost` for development).
|
||||
|
||||
@ -800,7 +802,6 @@ offline. Installation requires a secure origin (HTTPS, or `localhost` for develo
|
||||
| `devplacepy/push.py` | VAPID keys, payload encryption, send, register |
|
||||
| `devplacepy/routers/push.py` | `/push.json`, `/service-worker.js`, `/manifest.json` |
|
||||
| `static/js/PushManager.js` | Service-worker registration + subscribe + opt-in UI |
|
||||
| `static/js/PwaInstaller.js` | `beforeinstallprompt` capture + install button |
|
||||
| `static/service-worker.js` | Receives push, shows notification, offline fallback |
|
||||
| `static/manifest.json` | PWA manifest (icons, display, theme) |
|
||||
| `static/offline.html` | Offline fallback page |
|
||||
@ -846,7 +847,7 @@ Two background services bridge persisted state onto the bus so the interface upd
|
||||
|
||||
## Testing
|
||||
|
||||
- **932 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
|
||||
- **1959 tests** split into three tiers under `tests/`: `unit/` (pure in-process), `api/` (HTTP integration against the live server), and `e2e/` (Playwright browser)
|
||||
- **A directory tree that mirrors the path.** api/e2e follow the endpoint path - each route segment is a directory and the last segment is the file, `{param}` segments dropped (`GET /admin/ai-usage` -> `tests/e2e/admin/aiusage.py`, `GET /projects/{slug}/files/lines` -> `tests/api/projects/files/lines.py`). unit mirrors the source module path (`devplacepy/services/audit/store.py` -> `tests/unit/services/audit/store.py`). Run one tier with `make test-unit` / `make test-api` / `make test-e2e`
|
||||
- Playwright (NOT pytest-playwright plugin - conflicts, uninstall it)
|
||||
- Runs serially, one test at a time, in a single process (`make test`); the suite drives one uvicorn subprocess on port 10501 with its own temp database and `DEVPLACE_DATA_DIR`
|
||||
@ -912,12 +913,12 @@ The Container Manager drives the host Docker daemon, so `make docker-build`/`mak
|
||||
What the overlay (`docker-compose.containers.yml`) changes:
|
||||
|
||||
- **Docker CLI in the image** via the `INSTALL_DOCKER_CLI=true` build arg (the base image stays lean).
|
||||
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every build/run/exec is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
|
||||
- **Docker socket** mounted into the app container. This grants the app **root on the host** - every run/exec/lifecycle operation is admin-only, `--privileged` is never used, and all docker calls are argument-list subprocesses, but treat the whole feature as trusted-admins-only.
|
||||
- **Socket permissions:** the app runs as UID 1000, so the overlay adds the host `docker` group via `group_add`. `make` reads the gid straight from `/var/run/docker.sock` (`stat -c '%g'`), the exact group that owns the socket.
|
||||
- **Data dir at a consistent path (critical).** When the app (in its container) runs `docker run -v <path>:/app`, the daemon resolves `<path>` against the **host**, not the app container. So the workspace/data dir must be mounted at the **same absolute path** on host and in the container - the make targets set `DEVPLACE_DATA_DIR` to the project's `./data` (an absolute host path) and mount it at that identical path on both sides. (Build contexts go through the docker API as a tarball, so they can stay in the container's temp dir - only the `/app` bind mount needs path consistency.)
|
||||
- **Ingress reach:** published container ports live on the **host**, so the overlay sets `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` (with `extra_hosts: host-gateway`) so the `/p/<slug>` proxy can reach them. On a bare-metal `make prod` deploy the app is already on the host, so the default `127.0.0.1` works and no overlay is needed (just install the docker CLI and run the services).
|
||||
|
||||
Then enable **Container builds** and **Containers** on `/admin/services`. Builds default to `--network=host` (configurable on the service) so pip can reach PyPI; set the build network to empty to use the docker default.
|
||||
Then build the shared `ppy` image once with `make ppy` and enable **Containers** on `/admin/services`. There is no in-app image building; every instance runs that one prebuilt image.
|
||||
|
||||
### nginx specifics
|
||||
|
||||
@ -944,7 +945,7 @@ The version sits in the **path**, not a query string, because the frontend is un
|
||||
|
||||
### Bare-metal alternative
|
||||
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers 2 --proxy-headers`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
|
||||
`make prod` runs the same app without containers (`uvicorn ... --workers $(WEB_WORKERS) --proxy-headers`, where `WEB_WORKERS` defaults to `nproc`) from the project root, sharing the identical database and files. Note it binds port 10500, so it conflicts with the Docker front door on the same port - run one, or set a different `PORT`.
|
||||
|
||||
### Multi-worker safety
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user