forked from retoor/devplacepy
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e7126896b | ||
|
|
0ee6915fc5 |
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -65,7 +64,6 @@ def _targets() -> list[dict]:
|
||||
def _dashboard(can_download: bool) -> dict:
|
||||
backups = [_backup_payload(row, can_download) for row in store.list_backups()]
|
||||
schedules = store.list_schedules()
|
||||
threshold = int(os.environ.get("DISK_WARNING_PERCENT", "85"))
|
||||
return {
|
||||
"storage": store.compute_storage_stats(),
|
||||
"backups": backups,
|
||||
@@ -74,8 +72,6 @@ def _dashboard(can_download: bool) -> dict:
|
||||
"metrics": _metrics(backups),
|
||||
"generated_at": store.now_iso(),
|
||||
"can_download_backups": can_download,
|
||||
"disk_warnings": store.get_disk_warnings(threshold),
|
||||
"disk_warning_threshold": threshold,
|
||||
}
|
||||
|
||||
@router.get("/backups", response_class=HTMLResponse)
|
||||
|
||||
@@ -53,13 +53,6 @@ class BackupJobOut(_Out):
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class DiskWarningOut(_Out):
|
||||
path: str = ""
|
||||
used_pct: float = 0.0
|
||||
total_gb: float = 0.0
|
||||
used_gb: float = 0.0
|
||||
|
||||
|
||||
class BackupScheduleOut(_Out):
|
||||
uid: str = ""
|
||||
name: str = ""
|
||||
@@ -85,5 +78,3 @@ class BackupDashboardOut(_Out):
|
||||
generated_at: Optional[str] = None
|
||||
can_download_backups: bool = False
|
||||
admin_section: Optional[str] = None
|
||||
disk_warnings: list[DiskWarningOut] = []
|
||||
disk_warning_threshold: int = 85
|
||||
|
||||
@@ -29,7 +29,8 @@ Generic, lightweight, **fire-and-forget** offload for non-critical side-effects
|
||||
- **The gateway call is fail-soft.** `correct_text(api_key, prompt, text)` is synchronous (runs on the background worker thread), POSTs to `INTERNAL_GATEWAY_URL` with `model=INTERNAL_MODEL` via `stealth.stealth_sync_client`, authenticated with the user's own `Bearer` api_key (per-user attribution). It returns the ORIGINAL text on any error, empty output, or suspiciously large output (`len > len(text) * MAX_GROWTH_FACTOR + 200`, rejecting hallucinated expansion). `_run_correction` reads the row back, corrects each registry field that has non-blank text, writes only changed fields via `table.update(updates, ["uid"])` without touching `updated_at` (auto-correction is not a user edit) or the slug (slugs are permanent), and `clear_user_cache(user_uid)` when `table == "users"` so the corrected bio re-caches.
|
||||
- **Per-user usage aggregation (only on success).** `correct_text` returns `(text, usage)`; `usage` is parsed from the gateway's `X-Gateway-*` response headers (`_usage_from_headers`) whenever the upstream call returned 200 (cost was incurred, even if the corrected output was rejected), else `None` on any failure. `_usage_from_headers` captures the token and cost headers PLUS the timing headers `X-Gateway-Upstream-Latency-Ms` and `X-Gateway-Total-Latency-Ms` (as `upstream_latency_ms`/`total_latency_ms`), so each call's timing is metered. `_run_correction` accumulates the per-field `usage` into one `totals` dict and, when `totals["calls"] > 0`, makes ONE call to `database.add_correction_usage(user_uid, totals)` (a single `totals` dict, not positional args) - so a 2-field content item is a single aggregated write, and a failed/empty correction records nothing. `add_correction_usage` (and `add_modifier_usage`) delegate to the shared `database._add_usage(usage_table, user_uid, totals)`: a single atomic `INSERT ... ON CONFLICT(user_uid) DO UPDATE SET col = col + excluded.col` upsert against the `correction_usage` table (per-user running SUMS: `calls`/`prompt_tokens`/`completion_tokens`/`total_tokens`/`cost_usd`/`upstream_latency_ms`/`total_latency_ms`/`updated_at`, unique index `idx_correction_usage_user`, the two latency columns REAL default 0.0, all ensured in `init_db`). It is a derived counter table (NOT in `SOFT_DELETE_TABLES`, like `gateway_usage_ledger`) and is deliberately separate from `users` so accumulating never invalidates the auth/user cache. `database.get_correction_usage(user_uid)` (via `_get_usage`) returns the stored sums PLUS computed averages: `avg_tokens` (total_tokens/calls), `avg_upstream_latency_ms`, `avg_total_latency_ms`, `avg_tokens_per_second` (completion_tokens over total upstream seconds), and `avg_cost_usd`.
|
||||
- **Profile display:** `routers/profile/usage._correction_usage(uid, include_cost)` shapes it via the shared `_usage_view(data, include_cost)` (mirrors `_ai_quota`); `profile/index.py` builds it only for `is_owner or viewer_is_admin` and passes `include_cost=viewer_is_admin`, exposed as the `correction_usage` dict on the context and `ProfileOut`. The view surfaces the sums plus averages - `avg_tokens` (avg tokens/call), `avg_latency_ms` (avg upstream latency), `avg_total_latency_ms`, `avg_tokens_per_second` (avg speed), and `total_time_s` (total upstream seconds) - rendered as extra tiles on the card. **Financial gating:** tokens/call-count and the performance tiles show to the owner and admins; the dollar `cost_usd` and `avg_cost_usd` keys are present ONLY when `viewer_is_admin`, in BOTH the HTML card (`templates/profile.html`, `.correction-usage-*`) and the `respond(..., model=ProfileOut)` JSON (same rule as `_ai_quota`'s `spent_usd` - hiding it in the template alone would leak it to a member fetching their own profile as JSON). The card renders only when `correction_usage.calls` > 0.
|
||||
- **Import-cycle discipline.** `correction.py` imports only `stealth`, `config`, `database.get_table`/`add_correction_usage`, and `services.background.background` at module top; `clear_user_cache` is imported lazily inside `_run_correction`. Never import `content` or `utils` at module top.
|
||||
- **Markdown structure preservation (`services/markdown_preserve.py`).** Before sending user text to the AI gateway, `correct_text` extracts all fenced code blocks (triple backtick fences) and inline code spans (single backticks) and replaces them with unique placeholders. The AI only sees the sanitized prose. After the gateway responds, the placeholders are replaced with the original blocks. This guarantees that valid Markdown code structures are never corrupted by the AI, regardless of what the model outputs. The extraction is purely server-side and deterministic. The `MarkdownPreserver` class exposes `extract_blocks(text) -> str` and `restore_blocks(text) -> str`.
|
||||
- **Import-cycle discipline.** `correction.py` imports only `stealth`, `config`, `database.get_table`/`add_correction_usage`, `services.markdown_preserve.MarkdownPreserver`, and `services.background.background` at module top; `clear_user_cache` is imported lazily inside `_run_correction`. Never import `content` or `utils` at module top.
|
||||
- **Settings live on `users`:** three columns `ai_correction_enabled` (0/1), `ai_correction_sync` (0/1, default 0 = background), and `ai_correction_prompt` (text, default `config.DEFAULT_CORRECTION_PROMPT`), ensured in `database.backfill_api_keys()` (the user column-ensure block run by `init_db`) and seeded born-live in `utils._create_account`. The edit route is the owner-or-admin leaf `POST /profile/{username}/ai-correction` (`routers/profile/ai_correction.py`, `AiCorrectionForm{enabled, sync, prompt}`, audit key `profile.ai_correction`). The owner-only values are exposed on the profile page context and `ProfileOut` (`ai_correction_enabled`/`ai_correction_sync`/`ai_correction_prompt`, gated by `is_owner`), the UI block lives in `profile.html` (owner-only: enable checkbox, **Apply mode** select, prompt textarea) wired by `static/js/AiCorrection.js` (`app.aiCorrection`), and Devii drives it via the owner-scoped `ai_correction_get`/`ai_correction_set` tools (`services/devii/ai_correction/`, `handler="ai_correction"`, `requires_auth=True`, not confirm-gated - it is a reversible per-user toggle; `ai_correction_set` accepts `enabled`, optional `sync`, optional `prompt`).
|
||||
|
||||
## AI modifier (`services/ai_modifier.py`, `services/ai_context.py`)
|
||||
|
||||
@@ -13,6 +13,7 @@ from devplacepy.services.correction import (
|
||||
new_usage_totals,
|
||||
schedule_pending,
|
||||
)
|
||||
from devplacepy.services.markdown_preserve import MarkdownPreserver
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,6 +28,8 @@ def has_ai_directive(text: str | None) -> bool:
|
||||
def modify_text(
|
||||
api_key: str, prompt: str, text: str, context: str = ""
|
||||
) -> tuple[str, dict | None]:
|
||||
preserver = MarkdownPreserver()
|
||||
sanitized = preserver.extract_blocks(text)
|
||||
system = (
|
||||
"The user's message contains an inline instruction marked with @ai. "
|
||||
+ (prompt or DEFAULT_MODIFIER_PROMPT).strip()
|
||||
@@ -38,7 +41,13 @@ def modify_text(
|
||||
"\n\n# Context (use it to inform the result; never echo this block)\n"
|
||||
+ context
|
||||
)
|
||||
return gateway_complete(api_key, system, text, MODIFIER_TIMEOUT_SECONDS, None)
|
||||
result, usage = gateway_complete(
|
||||
api_key, system, sanitized, MODIFIER_TIMEOUT_SECONDS, None
|
||||
)
|
||||
if result != sanitized:
|
||||
restored = preserver.restore_blocks(result)
|
||||
return restored, usage
|
||||
return result, usage
|
||||
|
||||
|
||||
def schedule_modification(
|
||||
|
||||
@@ -34,10 +34,6 @@ class BackupService(JobService):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="backup", interval_seconds=15)
|
||||
try:
|
||||
store.seed_default_schedule()
|
||||
except Exception as exc:
|
||||
logger.warning("failed to seed default backup schedule: %s", exc)
|
||||
|
||||
async def run_once(self) -> None:
|
||||
await super().run_once()
|
||||
@@ -125,15 +121,6 @@ class BackupService(JobService):
|
||||
summary=f"backup {target} completed ({_human_bytes(stats['bytes_out'])})",
|
||||
links=[audit.job(job_uid)],
|
||||
)
|
||||
if schedule_uid:
|
||||
try:
|
||||
store.check_disk_and_alert()
|
||||
except Exception as exc:
|
||||
logger.warning("disk alert check failed: %s", exc)
|
||||
try:
|
||||
store.check_db_growth()
|
||||
except Exception as exc:
|
||||
logger.warning("db growth check failed: %s", exc)
|
||||
return {
|
||||
"backup_uid": backup_uid,
|
||||
"target": target,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
@@ -11,8 +9,6 @@ from devplacepy import config
|
||||
from devplacepy.database import _index, db, get_table
|
||||
from devplacepy.utils import generate_uid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BACKUP_TARGETS: dict[str, dict] = {
|
||||
"database": {
|
||||
"label": "Database",
|
||||
@@ -379,58 +375,6 @@ def _storage_paths() -> list[tuple[str, str, Path]]:
|
||||
]
|
||||
|
||||
|
||||
def seed_default_schedule() -> None:
|
||||
if "backup_schedules" not in db.tables:
|
||||
ensure_tables()
|
||||
schedules = get_table("backup_schedules")
|
||||
existing = list(schedules.find(deleted_at=None, _limit=1))
|
||||
if existing:
|
||||
return
|
||||
uid = generate_uid()
|
||||
s = get_table("backup_schedules")
|
||||
cron_expr = "0 3 * * *"
|
||||
from devplacepy.services.devii.tasks.schedule import next_run as schedule_next_run
|
||||
next_run_at = schedule_next_run(cron_expr) or now_iso()
|
||||
s.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"name": "Daily database backup",
|
||||
"target": "database",
|
||||
"kind": "cron",
|
||||
"every_seconds": 0,
|
||||
"cron": cron_expr,
|
||||
"enabled": 1,
|
||||
"keep_last": 14,
|
||||
"next_run_at": next_run_at,
|
||||
"last_run_at": "",
|
||||
"last_job_uid": "",
|
||||
"run_count": 0,
|
||||
"created_by": "system",
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"deleted_at": None,
|
||||
"deleted_by": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_disk_warnings(threshold: int = 85) -> list[dict]:
|
||||
stats = compute_storage_stats()
|
||||
disk = stats.get("disk", {})
|
||||
used_pct = disk.get("used_percent", 0.0)
|
||||
warnings = []
|
||||
if used_pct >= threshold:
|
||||
warnings.append(
|
||||
{
|
||||
"path": stats.get("data_dir", {}).get("path", str(config.DATA_DIR)),
|
||||
"used_pct": used_pct,
|
||||
"total_gb": round(disk.get("total_bytes", 0) / (1024**3), 1),
|
||||
"used_gb": round(disk.get("used_bytes", 0) / (1024**3), 1),
|
||||
}
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def compute_storage_stats() -> dict:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
@@ -491,131 +435,3 @@ def compute_storage_stats() -> dict:
|
||||
_storage_cache["data"] = data
|
||||
_storage_cache["at"] = now
|
||||
return data
|
||||
|
||||
|
||||
DB_GROWTH_HISTORY_FILE = None
|
||||
|
||||
|
||||
def _db_growth_path() -> Path:
|
||||
global DB_GROWTH_HISTORY_FILE
|
||||
if DB_GROWTH_HISTORY_FILE is None:
|
||||
DB_GROWTH_HISTORY_FILE = config.BACKUPS_DIR / "db_growth_history.json"
|
||||
return DB_GROWTH_HISTORY_FILE
|
||||
|
||||
|
||||
def _load_db_sizes() -> list[dict]:
|
||||
path = _db_growth_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (ValueError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _save_db_sizes(sizes: list[dict]) -> None:
|
||||
path = _db_growth_path()
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(sizes))
|
||||
except OSError as exc:
|
||||
logger.warning("failed to write db growth history: %s", exc)
|
||||
|
||||
|
||||
def _database_file_size() -> int:
|
||||
database_file = Path(str(config.DATABASE_URL).replace("sqlite:///", ""))
|
||||
try:
|
||||
return database_file.stat().st_size if database_file.exists() else 0
|
||||
except OSError:
|
||||
return 0
|
||||
|
||||
|
||||
def check_db_growth() -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
current = _database_file_size()
|
||||
if current <= 0:
|
||||
return
|
||||
|
||||
sizes = _load_db_sizes()
|
||||
last_entry = sizes[-1] if sizes else None
|
||||
now = now_iso()
|
||||
|
||||
sizes.append({"at": now, "size_bytes": current})
|
||||
_save_db_sizes(sizes)
|
||||
|
||||
if last_entry and last_entry.get("size_bytes", 0) > 0:
|
||||
try:
|
||||
last_at = datetime.fromisoformat(last_entry["at"])
|
||||
current_at = datetime.fromisoformat(now)
|
||||
days = (current_at - last_at).total_seconds() / 86400
|
||||
if days > 0:
|
||||
growth_pct = (current - last_entry["size_bytes"]) / last_entry["size_bytes"]
|
||||
daily_pct = growth_pct / days
|
||||
if daily_pct > 0.10:
|
||||
logger.warning(
|
||||
"database growth %.1f%%/day (%.0f MB -> %.0f MB over %.1f days)",
|
||||
daily_pct * 100,
|
||||
last_entry["size_bytes"] / (1024 * 1024),
|
||||
current / (1024 * 1024),
|
||||
days,
|
||||
)
|
||||
try:
|
||||
audit.record_system(
|
||||
"backup.db_growth_high",
|
||||
actor_kind="service",
|
||||
origin="scheduler",
|
||||
target_type="system",
|
||||
metadata={
|
||||
"size_bytes": current,
|
||||
"previous_size_bytes": last_entry["size_bytes"],
|
||||
"daily_growth_pct": round(daily_pct * 100, 1),
|
||||
"span_days": round(days, 2),
|
||||
},
|
||||
summary=(
|
||||
f"database growth {daily_pct * 100:.1f}%/day "
|
||||
f"({human_bytes(last_entry['size_bytes'])} -> {human_bytes(current)})"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("db growth audit failed: %s", exc)
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning("failed to parse db growth dates: %s", exc)
|
||||
|
||||
|
||||
DISK_ALERT_THRESHOLD = 85
|
||||
|
||||
|
||||
def check_disk_and_alert() -> None:
|
||||
from devplacepy.services.audit import record as audit
|
||||
|
||||
stats = compute_storage_stats()
|
||||
disk = stats.get("disk", {})
|
||||
used_pct = disk.get("used_percent", 0.0)
|
||||
if used_pct < DISK_ALERT_THRESHOLD:
|
||||
return
|
||||
logger.warning(
|
||||
"disk usage at %.1f%% (threshold %d%%)",
|
||||
used_pct,
|
||||
DISK_ALERT_THRESHOLD,
|
||||
)
|
||||
try:
|
||||
audit.record_system(
|
||||
"backup.disk.critical",
|
||||
actor_kind="service",
|
||||
origin="scheduler",
|
||||
target_type="system",
|
||||
metadata={
|
||||
"used_percent": used_pct,
|
||||
"used_bytes": disk.get("used_bytes", 0),
|
||||
"total_bytes": disk.get("total_bytes", 0),
|
||||
"free_bytes": disk.get("free_bytes", 0),
|
||||
},
|
||||
summary=(
|
||||
f"disk usage at {used_pct:.1f}% "
|
||||
f"({human_bytes(disk.get('used_bytes', 0))} / "
|
||||
f"{human_bytes(disk.get('total_bytes', 0))})"
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("disk alert audit failed: %s", exc)
|
||||
|
||||
@@ -81,7 +81,6 @@ class ContainerService(BaseService):
|
||||
def __init__(self):
|
||||
super().__init__(name="containers", interval_seconds=5)
|
||||
self._metric_tick = 0
|
||||
self._orphan_sweep_tick = 0
|
||||
self._booted = False
|
||||
self._last_sync_at = 0.0
|
||||
|
||||
@@ -108,6 +107,19 @@ class ContainerService(BaseService):
|
||||
|
||||
await self._periodic_sync(instances, by_uid)
|
||||
|
||||
for uid, row in by_uid.items():
|
||||
if uid not in known:
|
||||
try:
|
||||
await backend.rm(row.container_id, force=True)
|
||||
self.log(f"reaped orphan container {row.name}")
|
||||
_audit_reconcile(
|
||||
{"uid": uid, "name": row.name},
|
||||
"orphan_reap",
|
||||
f"reconciler reaped orphan container {row.name}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log(f"orphan rm failed for {row.name}: {exc}")
|
||||
|
||||
for inst in instances:
|
||||
try:
|
||||
await self._reconcile(backend, inst, by_uid.get(inst["uid"]))
|
||||
@@ -116,16 +128,6 @@ class ContainerService(BaseService):
|
||||
|
||||
await self._fire_schedules()
|
||||
|
||||
try:
|
||||
await self._sweep_orphaned_containers(backend, by_uid, known)
|
||||
except Exception as exc:
|
||||
self.log(f"orphan sweep failed: {exc}")
|
||||
|
||||
try:
|
||||
await self._cleanup_stale_workspaces(backend)
|
||||
except Exception as exc:
|
||||
self.log(f"workspace cleanup failed: {exc}")
|
||||
|
||||
self._metric_tick += 1
|
||||
if (
|
||||
self._metric_tick
|
||||
@@ -134,49 +136,6 @@ class ContainerService(BaseService):
|
||||
):
|
||||
await self._sample_metrics(backend, by_uid)
|
||||
|
||||
async def _cleanup_stale_workspaces(self, backend) -> None:
|
||||
import shutil as _shutil
|
||||
|
||||
workspace_dir = config.CONTAINER_WORKSPACES_DIR
|
||||
if not workspace_dir.is_dir():
|
||||
return
|
||||
instances = store.all_instances()
|
||||
active_uids = {inst["uid"] for inst in instances}
|
||||
for entry in workspace_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
uid = entry.name
|
||||
if uid in active_uids:
|
||||
continue
|
||||
try:
|
||||
_shutil.rmtree(entry)
|
||||
self.log(f"removed stale workspace {uid}")
|
||||
except Exception as exc:
|
||||
self.log(f"failed to remove workspace {uid}: {exc}")
|
||||
await backend.image_prune()
|
||||
|
||||
async def _sweep_orphaned_containers(self, backend, by_uid: dict, known: set) -> None:
|
||||
if self._orphan_sweep_tick % 60 != 0:
|
||||
self._orphan_sweep_tick += 1
|
||||
return
|
||||
self._orphan_sweep_tick += 1
|
||||
for uid, row in by_uid.items():
|
||||
if uid not in known:
|
||||
try:
|
||||
await backend.stop(row.container_id, timeout=10)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await backend.rm(row.container_id, force=True)
|
||||
self.log(f"removed orphan container {row.name}")
|
||||
_audit_reconcile(
|
||||
{"uid": uid, "name": row.name},
|
||||
"orphan_reap",
|
||||
f"sweeper removed orphan container {row.name}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log(f"orphan rm failed for {row.name}: {exc}")
|
||||
|
||||
async def _reconcile(self, backend, inst, ps) -> None:
|
||||
uid = inst["uid"]
|
||||
desired = inst["desired_state"]
|
||||
|
||||
@@ -15,6 +15,7 @@ from devplacepy.config import (
|
||||
)
|
||||
from devplacepy.database import add_correction_usage, get_table
|
||||
from devplacepy.services.background import background
|
||||
from devplacepy.services.markdown_preserve import MarkdownPreserver
|
||||
from devplacepy.services.openai_gateway.usage import parse_usage_headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -121,16 +122,22 @@ def gateway_complete(
|
||||
|
||||
|
||||
def correct_text(api_key: str, prompt: str, text: str) -> tuple[str, dict | None]:
|
||||
preserver = MarkdownPreserver()
|
||||
sanitized = preserver.extract_blocks(text)
|
||||
system = (
|
||||
"You are a text correction engine. Apply the correction instruction below to "
|
||||
"the user's message and return ONLY the resulting text, with no preamble, no "
|
||||
"explanation, no quotes and no code fences. Preserve the original meaning, "
|
||||
"language, line breaks and markdown. Correction instruction: "
|
||||
"explanation, no quotes and no code fences.\n"
|
||||
"Correction instruction: "
|
||||
+ (prompt or DEFAULT_CORRECTION_PROMPT).strip()
|
||||
)
|
||||
return gateway_complete(
|
||||
api_key, system, text, CORRECTION_TIMEOUT_SECONDS, MAX_GROWTH_FACTOR
|
||||
result, usage = gateway_complete(
|
||||
api_key, system, sanitized, CORRECTION_TIMEOUT_SECONDS, MAX_GROWTH_FACTOR
|
||||
)
|
||||
if result != sanitized:
|
||||
restored = preserver.restore_blocks(result)
|
||||
return restored, usage
|
||||
return result, usage
|
||||
|
||||
|
||||
def schedule_correction(
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
import typing
|
||||
|
||||
INLINE_CODE_RE = re.compile(r"`[^`\n]+`")
|
||||
FENCED_CODE_RE = re.compile(r"```\w*\n.*?```", re.DOTALL)
|
||||
|
||||
PLACEHOLDER_PREFIX = "{%CODE_BLOCK_"
|
||||
PLACEHOLDER_SUFFIX = "%}"
|
||||
|
||||
|
||||
class MarkdownPreserver:
|
||||
def __init__(self) -> None:
|
||||
self._blocks: list[str] = []
|
||||
self._placeholder_pattern = re.compile(
|
||||
re.escape(PLACEHOLDER_PREFIX) + r"(\d+)" + re.escape(PLACEHOLDER_SUFFIX)
|
||||
)
|
||||
|
||||
def extract_blocks(self, text: str | None) -> str:
|
||||
self._blocks.clear()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
result = text
|
||||
|
||||
while True:
|
||||
match = FENCED_CODE_RE.search(result)
|
||||
if match is None:
|
||||
break
|
||||
block = match.group(0)
|
||||
placeholder = f"{PLACEHOLDER_PREFIX}{len(self._blocks)}{PLACEHOLDER_SUFFIX}"
|
||||
self._blocks.append(block)
|
||||
result = result[: match.start()] + placeholder + result[match.end() :]
|
||||
|
||||
while True:
|
||||
match = INLINE_CODE_RE.search(result)
|
||||
if match is None:
|
||||
break
|
||||
block = match.group(0)
|
||||
placeholder = f"{PLACEHOLDER_PREFIX}{len(self._blocks)}{PLACEHOLDER_SUFFIX}"
|
||||
self._blocks.append(block)
|
||||
result = result[: match.start()] + placeholder + result[match.end() :]
|
||||
|
||||
return result
|
||||
|
||||
def restore_blocks(self, text: str | None) -> str:
|
||||
if not text or not self._blocks:
|
||||
return text or ""
|
||||
|
||||
def _replace(m: typing.Match) -> str:
|
||||
idx = int(m.group(1))
|
||||
if 0 <= idx < len(self._blocks):
|
||||
return self._blocks[idx]
|
||||
return m.group(0)
|
||||
|
||||
return self._placeholder_pattern.sub(_replace, text)
|
||||
@@ -3,34 +3,8 @@
|
||||
{{ super() }}
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/services.css') }}">
|
||||
<link rel="stylesheet" href="{{ static_url('/static/css/backups.css') }}">
|
||||
<style>
|
||||
.disk-warning {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #856404;
|
||||
}
|
||||
.disk-warning .icon {
|
||||
font-size: 1.4em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.disk-warning strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block admin_content %}
|
||||
{% for w in disk_warnings %}
|
||||
<div class="disk-warning" role="alert">
|
||||
<span class="icon">⚠</span>
|
||||
<span>Disk usage on <strong>{{ w.path }}</strong> is <strong>{{ w.used_pct }}%</strong> ({{ w.used_gb }} GB / {{ w.total_gb }} GB) - above {{ disk_warning_threshold }}% threshold. Consider cleaning up old data or increasing disk capacity.</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="admin-toolbar">
|
||||
<h2>Backups</h2>
|
||||
<div class="backup-controls">
|
||||
|
||||
@@ -33,7 +33,9 @@ The following prose fields are processed:
|
||||
| Your profile | bio |
|
||||
|
||||
Code and source files are **never** touched: a gist's source code, project files, and any code block
|
||||
are left exactly as written. The modifier is for prose only.
|
||||
are left exactly as written. The modifier is for prose only. Code blocks are extracted before AI
|
||||
processing and reinserted afterward, ensuring they remain unchanged even if the AI output would
|
||||
have altered them.
|
||||
|
||||
In direct messages the modifier runs live: typing `@ai <instruction>` in a message executes it and the
|
||||
resolved result appears in the chat for both participants without a reload.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
2026-07-19T09:01:55 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-19T09:01:55 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-19T09:01:55 INFO read task from file: /workspace/prompts/research-1.txt
|
||||
2026-07-19T09:01:55 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
2026-07-19T15:49:02 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-19T15:49:02 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-19T15:49:02 INFO read task from file: /workspace/prompts/research-2.txt
|
||||
2026-07-19T15:49:02 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
2026-07-19T16:41:42 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-19T16:41:42 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-19T16:41:42 INFO read task from file: /workspace/prompts/research-3.txt
|
||||
2026-07-19T16:41:42 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
2026-07-19T17:01:42 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-19T17:01:42 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-19T17:01:42 INFO read task from file: /workspace/prompts/research-4.txt
|
||||
2026-07-19T17:01:42 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
2026-07-19T17:25:26 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-19T17:25:26 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-19T17:25:26 INFO read task from file: /workspace/prompts/execution-1.txt
|
||||
2026-07-19T17:25:26 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
2026-07-19T17:44:59 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-19T17:44:59 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-19T17:44:59 INFO read task from file: /workspace/prompts/research-5.txt
|
||||
2026-07-19T17:44:59 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
2026-07-19T19:02:05 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-19T19:02:05 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-19T19:02:05 INFO read task from file: /workspace/prompts/execution-2.txt
|
||||
2026-07-19T19:02:05 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
2026-07-23T01:50:04 INFO logging initialised at /workspace/repo/dpc.log
|
||||
2026-07-23T01:50:04 DEBUG model=molodetz-pro fps=30
|
||||
2026-07-23T01:50:04 INFO read task from file: /workspace/prompts/execution-2.txt
|
||||
2026-07-23T01:50:04 INFO settings merged: model=<default> allow=0 deny=0 ask=0
|
||||
@@ -0,0 +1,116 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import unittest.mock
|
||||
|
||||
from devplacepy.services.ai_modifier import modify_text
|
||||
|
||||
|
||||
def test_modify_text_extracts_and_restores_code_blocks():
|
||||
captured = {}
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
captured["system"] = system
|
||||
captured["text"] = text
|
||||
return ("modification result", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.ai_modifier.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = modify_text(
|
||||
"test-key",
|
||||
"Make it more formal",
|
||||
"Some text\n```python\nx = 1\n```\nMore text",
|
||||
)
|
||||
|
||||
assert result == "modification result"
|
||||
|
||||
|
||||
def test_modify_text_preserves_fenced_code_blocks_from_ai_corruption():
|
||||
input_text = "Some text\n```python\nx = 1\n```\nMore text"
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
return ("Some text\n{%CODE_BLOCK_0%}\nMore corrected text", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.ai_modifier.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = modify_text(
|
||||
"test-key", "Make it more formal", input_text
|
||||
)
|
||||
|
||||
assert "```python" in result
|
||||
assert "x = 1" in result
|
||||
assert "corrected text" in result
|
||||
|
||||
|
||||
def test_modify_text_preserves_multiple_fenced_code_blocks():
|
||||
input_text = "A\n```python\nx = 1\n```\nB\n```js\ny = 2\n```\nC"
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
return ("A\n{%CODE_BLOCK_0%}\nX\n{%CODE_BLOCK_1%}\nZ", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.ai_modifier.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = modify_text(
|
||||
"test-key", "Make it more formal", input_text
|
||||
)
|
||||
|
||||
assert "```python" in result
|
||||
assert "x = 1" in result
|
||||
assert "```js" in result
|
||||
assert "y = 2" in result
|
||||
|
||||
|
||||
def test_modify_text_preserves_inline_code():
|
||||
input_text = "Use the `os.path.join` function for paths."
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
return ("Use {%CODE_BLOCK_0%} always.", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.ai_modifier.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = modify_text(
|
||||
"test-key", "Make it more formal", input_text
|
||||
)
|
||||
|
||||
assert "`os.path.join`" in result
|
||||
|
||||
|
||||
def test_modify_text_passes_plain_text_untouched():
|
||||
captured = {}
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
captured["text"] = text
|
||||
return ("modified plain text", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.ai_modifier.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = modify_text(
|
||||
"test-key", "Make it more formal", "Just some plain text."
|
||||
)
|
||||
|
||||
assert captured["text"] == "Just some plain text."
|
||||
assert result == "modified plain text"
|
||||
|
||||
|
||||
def test_modify_text_sanitized_text_has_no_code_blocks():
|
||||
captured = {}
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
captured["text"] = text
|
||||
return ("modified", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.ai_modifier.gateway_complete", fake_gateway
|
||||
):
|
||||
modify_text(
|
||||
"test-key",
|
||||
"Make it more formal",
|
||||
"Before\n```python\ncode\n```\nAfter\n`inline`",
|
||||
)
|
||||
|
||||
assert "```" not in captured["text"]
|
||||
assert "`inline`" not in captured["text"]
|
||||
@@ -0,0 +1,108 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import unittest.mock
|
||||
|
||||
from devplacepy.services.correction import correct_text
|
||||
|
||||
|
||||
def test_correct_text_extracts_and_restores_code_blocks():
|
||||
captured = {}
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
captured["system"] = system
|
||||
captured["text"] = text
|
||||
return ("correction result", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.correction.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = correct_text(
|
||||
"test-key", "Fix spelling", "Some text\n```python\nx = 1\n```\nMore text"
|
||||
)
|
||||
|
||||
assert result == "correction result"
|
||||
|
||||
|
||||
def test_correct_text_preserves_fenced_code_blocks_from_ai_corruption():
|
||||
input_text = "Some text\n```python\nx = 1\n```\nMore text"
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
return ("Some text\n{%CODE_BLOCK_0%}\nMore corrected text", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.correction.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = correct_text("test-key", "Fix spelling", input_text)
|
||||
|
||||
assert "```python" in result
|
||||
assert "x = 1" in result
|
||||
assert "corrected text" in result
|
||||
|
||||
|
||||
def test_correct_text_preserves_multiple_fenced_code_blocks():
|
||||
input_text = "A\n```python\nx = 1\n```\nB\n```js\ny = 2\n```\nC"
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
return ("A\n{%CODE_BLOCK_0%}\nX\n{%CODE_BLOCK_1%}\nZ", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.correction.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = correct_text("test-key", "Fix spelling", input_text)
|
||||
|
||||
assert "```python" in result
|
||||
assert "x = 1" in result
|
||||
assert "```js" in result
|
||||
assert "y = 2" in result
|
||||
|
||||
|
||||
def test_correct_text_preserves_inline_code():
|
||||
input_text = "Use the `os.path.join` function for paths."
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
return ("Use {%CODE_BLOCK_0%} always.", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.correction.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = correct_text("test-key", "Fix spelling", input_text)
|
||||
|
||||
assert "`os.path.join`" in result
|
||||
|
||||
|
||||
def test_correct_text_passes_plain_text_untouched():
|
||||
captured = {}
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
captured["text"] = text
|
||||
return ("corrected plain text", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.correction.gateway_complete", fake_gateway
|
||||
):
|
||||
result, _ = correct_text(
|
||||
"test-key", "Fix spelling", "Just some plain text."
|
||||
)
|
||||
|
||||
assert captured["text"] == "Just some plain text."
|
||||
assert result == "corrected plain text"
|
||||
|
||||
|
||||
def test_correct_text_sanitized_text_has_no_code_blocks():
|
||||
captured = {}
|
||||
|
||||
def fake_gateway(api_key, system, text, timeout, max_growth_factor):
|
||||
captured["text"] = text
|
||||
return ("corrected", {"calls": 1})
|
||||
|
||||
with unittest.mock.patch(
|
||||
"devplacepy.services.correction.gateway_complete", fake_gateway
|
||||
):
|
||||
correct_text(
|
||||
"test-key",
|
||||
"Fix spelling",
|
||||
"Before\n```python\ncode\n```\nAfter\n`inline`",
|
||||
)
|
||||
|
||||
assert "```" not in captured["text"]
|
||||
assert "`inline`" not in captured["text"]
|
||||
@@ -0,0 +1,87 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.markdown_preserve import MarkdownPreserver
|
||||
|
||||
|
||||
def test_extract_and_restore_round_trip():
|
||||
preserver = MarkdownPreserver()
|
||||
original = "Some text\n```python\nx = 1\n```\nMore text"
|
||||
sanitized = preserver.extract_blocks(original)
|
||||
assert "```" not in sanitized
|
||||
restored = preserver.restore_blocks(sanitized)
|
||||
assert restored == original
|
||||
|
||||
|
||||
def test_extract_empty_text():
|
||||
preserver = MarkdownPreserver()
|
||||
assert preserver.extract_blocks("") == ""
|
||||
assert preserver.extract_blocks(None) == ""
|
||||
|
||||
|
||||
def test_extract_no_code_blocks():
|
||||
preserver = MarkdownPreserver()
|
||||
text = "Just some plain text with no code."
|
||||
sanitized = preserver.extract_blocks(text)
|
||||
assert sanitized == text
|
||||
assert preserver.restore_blocks(sanitized) == text
|
||||
|
||||
|
||||
def test_extract_fenced_with_language():
|
||||
preserver = MarkdownPreserver()
|
||||
original = "Before\n```python\ndef foo():\n pass\n```\nAfter"
|
||||
sanitized = preserver.extract_blocks(original)
|
||||
assert "```" not in sanitized
|
||||
assert preserver.restore_blocks(sanitized) == original
|
||||
|
||||
|
||||
def test_extract_fenced_without_language():
|
||||
preserver = MarkdownPreserver()
|
||||
original = "Before\n```\ncode block\n```\nAfter"
|
||||
sanitized = preserver.extract_blocks(original)
|
||||
assert "```" not in sanitized
|
||||
assert preserver.restore_blocks(sanitized) == original
|
||||
|
||||
|
||||
def test_extract_multiple_fenced_blocks():
|
||||
preserver = MarkdownPreserver()
|
||||
original = "A\n```python\nx = 1\n```\nB\n```js\ny = 2\n```\nC"
|
||||
sanitized = preserver.extract_blocks(original)
|
||||
assert "```" not in sanitized
|
||||
assert preserver.restore_blocks(sanitized) == original
|
||||
|
||||
|
||||
def test_extract_inline_code():
|
||||
preserver = MarkdownPreserver()
|
||||
original = "Use the `os.path.join` function."
|
||||
sanitized = preserver.extract_blocks(original)
|
||||
assert "`" not in sanitized
|
||||
assert preserver.restore_blocks(sanitized) == original
|
||||
|
||||
|
||||
def test_extract_fenced_and_inline():
|
||||
preserver = MarkdownPreserver()
|
||||
original = "Text with `inline` and\n```python\ncode\n```\nmore `code` here."
|
||||
sanitized = preserver.extract_blocks(original)
|
||||
assert "`" not in sanitized
|
||||
assert "```" not in sanitized
|
||||
assert preserver.restore_blocks(sanitized) == original
|
||||
|
||||
|
||||
def test_restore_text_without_placeholders():
|
||||
preserver = MarkdownPreserver()
|
||||
preserver.extract_blocks("```python\nx\n```")
|
||||
result = preserver.restore_blocks("plain text with no tokens")
|
||||
assert result == "plain text with no tokens"
|
||||
|
||||
|
||||
def test_placeholder_uniqueness():
|
||||
preserver = MarkdownPreserver()
|
||||
original = "A\n```a\n1\n```\nB\n```b\n2\n```\nC\n```c\n3\n```"
|
||||
sanitized = preserver.extract_blocks(original)
|
||||
assert len(set(sanitized.split())) == len(sanitized.split())
|
||||
assert preserver.restore_blocks(sanitized) == original
|
||||
|
||||
|
||||
def test_empty_restore():
|
||||
preserver = MarkdownPreserver()
|
||||
assert preserver.restore_blocks("") == ""
|
||||
Reference in New Issue
Block a user