feat: add backup CLI commands, AI correction/modifier services, and timezone-aware date display
DevPlace CI / test (push) Failing after 25m18s
DevPlace CI / test (push) Failing after 25m18s
- Add `devplace backups` CLI subcommands (list, run, prune, clear) with job enqueueing and orphan cleanup - Introduce `BACKUPS_DIR` and `BACKUP_STAGING_DIR` config paths for backup storage - Implement `schedule_correction` and `schedule_modification` calls in content creation, comment creation, and comment editing flows - Add `DEFAULT_CORRECTION_PROMPT` and `DEFAULT_MODIFIER_PROMPT` config constants for AI content processing - Document timezone-aware date display using `local_dt`/`dt_ago` Jinja globals with client-side `Intl` localization - Update README with AI content correction/modifier support in direct messages via `@ai` inline instructions - Add `track_action(user["uid"], "vote")` call on upvote in `apply_vote`
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(
|
||||
name: str, description: str, required: bool = False, kind: str = "string"
|
||||
) -> Param:
|
||||
return Param(
|
||||
name=name,
|
||||
location="body",
|
||||
description=description,
|
||||
required=required,
|
||||
type=kind,
|
||||
)
|
||||
|
||||
|
||||
AI_CORRECTION_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="ai_correction_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Show the user's AI content correction setting and prompt",
|
||||
description=(
|
||||
"Returns whether automatic AI content correction is enabled for the user and the "
|
||||
"correction instruction in use. Use this before changing the setting."
|
||||
),
|
||||
handler="ai_correction",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="ai_correction_set",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Enable or disable AI content correction and set its prompt",
|
||||
description=(
|
||||
"Turns automatic AI content correction on or off for the user. When enabled, prose the "
|
||||
"user authors (posts, comments, projects, gists, direct messages, profile bio) is "
|
||||
"rewritten using the supplied instruction and the user's own API key. By default the "
|
||||
"correction runs in the background (applied just after saving); pass sync=true to apply it "
|
||||
"synchronously so the save waits for the correction. "
|
||||
"Pass 'prompt' to change the correction instruction; omit it to keep the current one."
|
||||
),
|
||||
handler="ai_correction",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
arg(
|
||||
"enabled",
|
||||
"true to enable automatic correction, false to disable it.",
|
||||
required=True,
|
||||
kind="boolean",
|
||||
),
|
||||
arg(
|
||||
"sync",
|
||||
"true to apply correction synchronously (the save waits), false for background. "
|
||||
"Omit to keep the current mode.",
|
||||
kind="boolean",
|
||||
),
|
||||
arg(
|
||||
"prompt",
|
||||
"The correction instruction (max 2000 chars). Omit to keep the current one.",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(
|
||||
name: str, description: str, required: bool = False, kind: str = "string"
|
||||
) -> Param:
|
||||
return Param(
|
||||
name=name,
|
||||
location="body",
|
||||
description=description,
|
||||
required=required,
|
||||
type=kind,
|
||||
)
|
||||
|
||||
|
||||
AI_MODIFIER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="ai_modifier_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Show the user's AI modifier setting and prompt",
|
||||
description=(
|
||||
"Returns whether the AI modifier is enabled for the user and the instruction in use. "
|
||||
"The modifier runs only when authored text contains an inline '@ai <instruction>' "
|
||||
"directive: it executes that instruction and replaces the marked part. Use this before "
|
||||
"changing the setting."
|
||||
),
|
||||
handler="ai_modifier",
|
||||
requires_auth=True,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="ai_modifier_set",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Enable or disable the AI modifier and set its prompt",
|
||||
description=(
|
||||
"Turns the AI modifier on or off for the user. When enabled, prose the user authors "
|
||||
"(posts, comments, projects, gists, direct messages, profile bio) is rewritten using the "
|
||||
"supplied instruction and the user's own API key, but ONLY where the text contains an "
|
||||
"inline '@ai <instruction>' directive: that part is executed and the '@ai' marker removed. "
|
||||
"By default the modification runs synchronously (the save waits); pass sync=false to apply "
|
||||
"it in the background just after saving. Pass 'prompt' to change the instruction; omit it to "
|
||||
"keep the current one."
|
||||
),
|
||||
handler="ai_modifier",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
arg(
|
||||
"enabled",
|
||||
"true to enable the AI modifier, false to disable it.",
|
||||
required=True,
|
||||
kind="boolean",
|
||||
),
|
||||
arg(
|
||||
"sync",
|
||||
"true to apply the modification synchronously (the save waits), false for background. "
|
||||
"Omit to keep the current mode.",
|
||||
kind="boolean",
|
||||
),
|
||||
arg(
|
||||
"prompt",
|
||||
"The modifier instruction (max 2000 chars). Omit to keep the current one.",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1415,6 +1415,96 @@ ACTIONS: tuple[Action, ...] = (
|
||||
params=(confirm(),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backups_overview",
|
||||
method="GET",
|
||||
path="/admin/backups/data",
|
||||
summary="Backup dashboard: storage usage, backups, and schedules (admin only)",
|
||||
description=(
|
||||
"Returns JSON: per-path storage usage (database, uploads, attachments, project files, "
|
||||
"keys, zips, deepsearch, container workspaces, backups), total data-directory size, total "
|
||||
"stored-backup size and count, disk usage (total/used/free/percent), every backup archive "
|
||||
"(target, status, size, file count, sha256 checksum, created/completed time, download_url), "
|
||||
"and every configured backup schedule. Use this for any question about backups, data "
|
||||
"footprint, disk space, or what is scheduled."
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_run",
|
||||
method="POST",
|
||||
path="/admin/backups/run",
|
||||
summary="Start a backup of a data target (admin only)",
|
||||
description=(
|
||||
"Enqueues an async backup job and returns its job uid and status_url. Target is one of: "
|
||||
"database (consistent SQLite snapshot plus Devii databases), uploads (attachments and "
|
||||
"project files), keys (VAPID keys and config), or full (database, uploads, and keys in one "
|
||||
"archive). Poll backup_status with the returned uid until status is done, then read the "
|
||||
"download_url. The job runs off the request path and never blocks the server."
|
||||
),
|
||||
params=(
|
||||
body("target", "One of database, uploads, keys, full.", required=True),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_status",
|
||||
method="GET",
|
||||
path="/admin/backups/jobs/{uid}",
|
||||
summary="Check the status of a backup job (admin only)",
|
||||
description=(
|
||||
"Returns JSON for one backup job: status (pending, running, done, failed), target, "
|
||||
"backup_uid, download_url (when done), sha256, archive size, file count, and timestamps. "
|
||||
"Poll this after backup_run."
|
||||
),
|
||||
params=(path("uid", "Backup job uid returned by backup_run."),),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_delete",
|
||||
method="POST",
|
||||
path="/admin/backups/{uid}/delete",
|
||||
summary="Permanently delete a backup archive (admin only)",
|
||||
description=(
|
||||
"Removes a backup archive file and its record. This is irreversible and reclaims disk "
|
||||
"space. The uid is a backup uid (from backups_overview), not a job uid."
|
||||
),
|
||||
params=(path("uid", "Backup uid to delete."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_schedule_create",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/create",
|
||||
summary="Create a recurring backup schedule (admin only)",
|
||||
description=(
|
||||
"Creates a schedule that fires backups automatically. kind is 'interval' (use every_seconds, "
|
||||
"minimum 60) or 'cron' (use a 5-field cron expression in 'minute hour dom month dow'). "
|
||||
"keep_last rotates older backups of this schedule, keeping only the newest N (0 keeps all). "
|
||||
"target is one of database, uploads, keys, full."
|
||||
),
|
||||
params=(
|
||||
body("name", "Human-readable schedule name.", required=True),
|
||||
body("target", "One of database, uploads, keys, full.", required=True),
|
||||
body("kind", "interval or cron.", required=True),
|
||||
body("every_seconds", "Seconds between runs when kind=interval (min 60)."),
|
||||
body("cron", "Cron expression when kind=cron, e.g. '0 3 * * *'."),
|
||||
body("keep_last", "Keep only the newest N backups of this schedule (0 = all)."),
|
||||
),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="backup_schedule_delete",
|
||||
method="POST",
|
||||
path="/admin/backups/schedules/{uid}/delete",
|
||||
summary="Delete a backup schedule (admin only)",
|
||||
description=(
|
||||
"Removes a backup schedule so it stops firing. Existing backup archives are kept; only the "
|
||||
"schedule is deleted."
|
||||
),
|
||||
params=(path("uid", "Backup schedule uid."), confirm()),
|
||||
requires_admin=True,
|
||||
),
|
||||
Action(
|
||||
name="restore_media",
|
||||
method="POST",
|
||||
|
||||
@@ -50,6 +50,8 @@ CONFIRM_REQUIRED = {
|
||||
"admin_reset_all_ai_quota",
|
||||
"admin_reset_guest_ai_quota",
|
||||
"admin_reset_user_ai_quota",
|
||||
"backup_delete",
|
||||
"backup_schedule_delete",
|
||||
"notification_reset",
|
||||
"db_insert_row",
|
||||
"db_update_row",
|
||||
@@ -285,6 +287,12 @@ class Dispatcher:
|
||||
from ..notification import NotificationController
|
||||
|
||||
self._notification = NotificationController(owner_kind, owner_id)
|
||||
from ..ai_correction import AiCorrectionController
|
||||
|
||||
self._ai_correction = AiCorrectionController(owner_kind, owner_id)
|
||||
from ..ai_modifier import AiModifierController
|
||||
|
||||
self._ai_modifier = AiModifierController(owner_kind, owner_id)
|
||||
self._virtual_tools = virtual_tools
|
||||
self._behavior = behavior
|
||||
self._read_files: set[tuple[str, str]] = set()
|
||||
@@ -434,6 +442,12 @@ class Dispatcher:
|
||||
if action.handler == "notification":
|
||||
return await self._notification.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "ai_correction":
|
||||
return await self._ai_correction.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "ai_modifier":
|
||||
return await self._ai_modifier.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "behavior":
|
||||
if self._behavior is None:
|
||||
return error_result(
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import AiCorrectionController
|
||||
|
||||
__all__ = ["AiCorrectionController"]
|
||||
@@ -0,0 +1,94 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.config import DEFAULT_CORRECTION_PROMPT
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
logger = logging.getLogger("devii.ai_correction")
|
||||
|
||||
|
||||
class AiCorrectionController:
|
||||
def __init__(self, owner_kind: str, owner_id: str) -> None:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "ai_correction_get":
|
||||
return self._get()
|
||||
if name == "ai_correction_set":
|
||||
return self._set(arguments)
|
||||
raise ToolInputError(f"Unknown AI correction tool: {name}")
|
||||
|
||||
def _require_user(self) -> None:
|
||||
if self._owner_kind != "user":
|
||||
raise ToolInputError(
|
||||
"AI content correction is only available for signed-in users."
|
||||
)
|
||||
|
||||
def _coerce_bool(self, raw: Any) -> bool:
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
return str(raw).strip().lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _value(self, arguments: dict[str, Any]) -> bool:
|
||||
return self._coerce_bool(arguments.get("enabled"))
|
||||
|
||||
def _user(self) -> dict[str, Any]:
|
||||
user = get_table("users").find_one(uid=self._owner_id)
|
||||
if not user:
|
||||
raise ToolInputError("User not found.")
|
||||
return user
|
||||
|
||||
def _get(self) -> str:
|
||||
self._require_user()
|
||||
user = self._user()
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"enabled": bool(user.get("ai_correction_enabled")),
|
||||
"sync": bool(user.get("ai_correction_sync")),
|
||||
"prompt": user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _set(self, arguments: dict[str, Any]) -> str:
|
||||
self._require_user()
|
||||
user = self._user()
|
||||
enabled = 1 if self._value(arguments) else 0
|
||||
if "sync" in arguments and arguments.get("sync") is not None:
|
||||
sync = 1 if self._coerce_bool(arguments.get("sync")) else 0
|
||||
else:
|
||||
sync = 1 if user.get("ai_correction_sync") else 0
|
||||
prompt_raw = arguments.get("prompt")
|
||||
if prompt_raw is None:
|
||||
prompt = user.get("ai_correction_prompt") or DEFAULT_CORRECTION_PROMPT
|
||||
else:
|
||||
prompt = str(prompt_raw).strip()[:2000] or DEFAULT_CORRECTION_PROMPT
|
||||
get_table("users").update(
|
||||
{
|
||||
"uid": self._owner_id,
|
||||
"ai_correction_enabled": enabled,
|
||||
"ai_correction_sync": sync,
|
||||
"ai_correction_prompt": prompt,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
from devplacepy.utils import clear_user_cache
|
||||
|
||||
clear_user_cache(self._owner_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"enabled": bool(enabled),
|
||||
"sync": bool(sync),
|
||||
"prompt": prompt,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from .controller import AiModifierController
|
||||
|
||||
__all__ = ["AiModifierController"]
|
||||
@@ -0,0 +1,94 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.config import DEFAULT_MODIFIER_PROMPT
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
logger = logging.getLogger("devii.ai_modifier")
|
||||
|
||||
|
||||
class AiModifierController:
|
||||
def __init__(self, owner_kind: str, owner_id: str) -> None:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "ai_modifier_get":
|
||||
return self._get()
|
||||
if name == "ai_modifier_set":
|
||||
return self._set(arguments)
|
||||
raise ToolInputError(f"Unknown AI modifier tool: {name}")
|
||||
|
||||
def _require_user(self) -> None:
|
||||
if self._owner_kind != "user":
|
||||
raise ToolInputError(
|
||||
"AI modifier is only available for signed-in users."
|
||||
)
|
||||
|
||||
def _coerce_bool(self, raw: Any) -> bool:
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
return str(raw).strip().lower() in ("true", "1", "yes", "on")
|
||||
|
||||
def _value(self, arguments: dict[str, Any]) -> bool:
|
||||
return self._coerce_bool(arguments.get("enabled"))
|
||||
|
||||
def _user(self) -> dict[str, Any]:
|
||||
user = get_table("users").find_one(uid=self._owner_id)
|
||||
if not user:
|
||||
raise ToolInputError("User not found.")
|
||||
return user
|
||||
|
||||
def _get(self) -> str:
|
||||
self._require_user()
|
||||
user = self._user()
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"enabled": bool(user.get("ai_modifier_enabled")),
|
||||
"sync": bool(user.get("ai_modifier_sync")),
|
||||
"prompt": user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _set(self, arguments: dict[str, Any]) -> str:
|
||||
self._require_user()
|
||||
user = self._user()
|
||||
enabled = 1 if self._value(arguments) else 0
|
||||
if "sync" in arguments and arguments.get("sync") is not None:
|
||||
sync = 1 if self._coerce_bool(arguments.get("sync")) else 0
|
||||
else:
|
||||
sync = 1 if user.get("ai_modifier_sync") else 0
|
||||
prompt_raw = arguments.get("prompt")
|
||||
if prompt_raw is None:
|
||||
prompt = user.get("ai_modifier_prompt") or DEFAULT_MODIFIER_PROMPT
|
||||
else:
|
||||
prompt = str(prompt_raw).strip()[:2000] or DEFAULT_MODIFIER_PROMPT
|
||||
get_table("users").update(
|
||||
{
|
||||
"uid": self._owner_id,
|
||||
"ai_modifier_enabled": enabled,
|
||||
"ai_modifier_sync": sync,
|
||||
"ai_modifier_prompt": prompt,
|
||||
},
|
||||
["uid"],
|
||||
)
|
||||
from devplacepy.utils import clear_user_cache
|
||||
|
||||
clear_user_cache(self._owner_id)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"enabled": bool(enabled),
|
||||
"sync": bool(sync),
|
||||
"prompt": prompt,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .actions.ai_correction_actions import AI_CORRECTION_ACTIONS
|
||||
from .actions.ai_modifier_actions import AI_MODIFIER_ACTIONS
|
||||
from .actions.avatar_actions import AVATAR_ACTIONS
|
||||
from .actions.behavior_actions import BEHAVIOR_ACTIONS
|
||||
from .actions.catalog import ACTIONS
|
||||
@@ -34,6 +36,8 @@ CATALOG = Catalog(
|
||||
+ CUSTOMIZATION_ACTIONS
|
||||
+ BEHAVIOR_ACTIONS
|
||||
+ NOTIFICATION_ACTIONS
|
||||
+ AI_CORRECTION_ACTIONS
|
||||
+ AI_MODIFIER_ACTIONS
|
||||
+ VIRTUAL_TOOL_ACTIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -401,6 +401,10 @@ class DeviiSession:
|
||||
finally:
|
||||
if not cancelled and epoch == self._turn_epoch:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
if self.owner_kind == "user" and self.channel != "docs" and not error:
|
||||
from devplacepy.utils import track_action
|
||||
|
||||
track_action(self.owner_id, "devii")
|
||||
|
||||
def _builtin_tools(self) -> list[dict[str, Any]]:
|
||||
schemas = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
|
||||
Reference in New Issue
Block a user