diff --git a/deploy/dalidou/cron-backup.sh b/deploy/dalidou/cron-backup.sh new file mode 100644 index 0000000..fd81bf3 --- /dev/null +++ b/deploy/dalidou/cron-backup.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# +# deploy/dalidou/cron-backup.sh +# ------------------------------ +# Daily backup + retention cleanup via the AtoCore API. +# +# Intended to run from cron on Dalidou: +# +# # Daily at 03:00 UTC +# 0 3 * * * /srv/storage/atocore/app/deploy/dalidou/cron-backup.sh >> /var/log/atocore-backup.log 2>&1 +# +# What it does: +# 1. Creates a runtime backup (db + registry, no chroma by default) +# 2. Runs retention cleanup with --confirm to delete old snapshots +# 3. Logs results to stdout (captured by cron into the log file) +# +# Fail-open: exits 0 even on API errors so cron doesn't send noise +# emails. Check /var/log/atocore-backup.log for diagnostics. +# +# Environment variables: +# ATOCORE_URL default http://127.0.0.1:8100 +# ATOCORE_BACKUP_CHROMA default false (set to "true" for cold chroma copy) + +set -euo pipefail + +ATOCORE_URL="${ATOCORE_URL:-http://127.0.0.1:8100}" +INCLUDE_CHROMA="${ATOCORE_BACKUP_CHROMA:-false}" +TIMESTAMP="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +log() { printf '[%s] %s\n' "$TIMESTAMP" "$*"; } + +log "=== AtoCore daily backup starting ===" + +# Step 1: Create backup +log "Step 1: creating backup (chroma=$INCLUDE_CHROMA)" +BACKUP_RESULT=$(curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d "{\"include_chroma\": $INCLUDE_CHROMA}" \ + "$ATOCORE_URL/admin/backup" 2>&1) || { + log "ERROR: backup creation failed: $BACKUP_RESULT" + exit 0 +} +log "Backup created: $BACKUP_RESULT" + +# Step 2: Retention cleanup (confirm=true to actually delete) +log "Step 2: running retention cleanup" +CLEANUP_RESULT=$(curl -sf -X POST \ + -H "Content-Type: application/json" \ + -d '{"confirm": true}' \ + "$ATOCORE_URL/admin/backup/cleanup" 2>&1) || { + log "ERROR: cleanup failed: $CLEANUP_RESULT" + exit 0 +} +log "Cleanup result: $CLEANUP_RESULT" + +log "=== AtoCore daily backup complete ===" diff --git a/src/atocore/api/routes.py b/src/atocore/api/routes.py index 4dfe288..5ad8f80 100644 --- a/src/atocore/api/routes.py +++ b/src/atocore/api/routes.py @@ -49,6 +49,7 @@ from atocore.memory.service import ( ) from atocore.observability.logger import get_logger from atocore.ops.backup import ( + cleanup_old_backups, create_runtime_backup, list_runtime_backups, validate_backup, @@ -511,6 +512,7 @@ class InteractionRecordRequest(BaseModel): chunks_used: list[str] = [] context_pack: dict | None = None reinforce: bool = True + extract: bool = False @router.post("/interactions") @@ -536,6 +538,7 @@ def api_record_interaction(req: InteractionRecordRequest) -> dict: chunks_used=req.chunks_used, context_pack=req.context_pack, reinforce=req.reinforce, + extract=req.extract, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @@ -731,6 +734,25 @@ def api_list_backups() -> dict: } +class BackupCleanupRequest(BaseModel): + confirm: bool = False + + +@router.post("/admin/backup/cleanup") +def api_cleanup_backups(req: BackupCleanupRequest | None = None) -> dict: + """Apply retention policy to old backup snapshots. + + Dry-run by default. Pass ``confirm: true`` to actually delete. + Retention: last 7 daily, last 4 weekly (Sundays), last 6 monthly (1st). + """ + payload = req or BackupCleanupRequest() + try: + return cleanup_old_backups(confirm=payload.confirm) + except Exception as e: + log.error("admin_cleanup_failed", error=str(e)) + raise HTTPException(status_code=500, detail=f"Cleanup failed: {e}") + + @router.get("/admin/backup/{stamp}/validate") def api_validate_backup(stamp: str) -> dict: """Validate that a previously created backup is structurally usable.""" diff --git a/src/atocore/interactions/service.py b/src/atocore/interactions/service.py index fd45ad4..00374e8 100644 --- a/src/atocore/interactions/service.py +++ b/src/atocore/interactions/service.py @@ -63,6 +63,7 @@ def record_interaction( chunks_used: list[str] | None = None, context_pack: dict | None = None, reinforce: bool = True, + extract: bool = False, ) -> Interaction: """Persist a single interaction to the audit trail. @@ -163,6 +164,30 @@ def record_interaction( error=str(exc), ) + if extract and (response or response_summary): + try: + from atocore.memory.extractor import extract_candidates_from_interaction + from atocore.memory.service import create_memory + + candidates = extract_candidates_from_interaction(interaction) + for candidate in candidates: + try: + create_memory( + memory_type=candidate.memory_type, + content=candidate.content, + project=candidate.project, + confidence=candidate.confidence, + status="candidate", + ) + except ValueError: + pass # duplicate or validation error — skip silently + except Exception as exc: # pragma: no cover - extraction must never block capture + log.error( + "extraction_failed_on_capture", + interaction_id=interaction_id, + error=str(exc), + ) + return interaction