Add admin configuration system and M3 content automation
Admin Configuration System: - Add registry_settings table to database schema - Create settings.py module with 17 configurable settings across 5 categories (vetting, similarity, sync, moderation, rate_limits) - Add admin API endpoints for settings management (list/get/set/reset) - Create admin settings web UI page with tabbed categories - Add CLI commands: cmdforge registry config list/get/set - Wire settings page link into admin index M3 Content Automation Scripts: - scrutiny.py: Quality scoring with 5 weighted checks - similarity.py: TF-IDF based duplicate detection - vet_pipeline.py: Combined vetting with auto-decision logic - fabric_sync.py: Scheduled Fabric pattern synchronization - fabric-sync.service/timer: systemd units for scheduled sync Settings available: - Vetting thresholds (approve/reject) and check weights - Similarity detection thresholds (duplicate/similar/related) - Fabric sync configuration (enabled, interval, auto-approve) - Moderation settings (require review, auto-approve private) - Rate limiting configuration Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -2932,6 +2932,163 @@ def create_app() -> Flask:
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
# ─── Admin Settings API ───────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/admin/settings", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_list_settings() -> Response:
|
||||
"""List all configurable settings with current values."""
|
||||
from .settings import get_all_settings
|
||||
|
||||
category = request.args.get("category")
|
||||
settings = get_all_settings(g.db)
|
||||
|
||||
if category:
|
||||
settings = [s for s in settings if s["category"] == category]
|
||||
|
||||
# Group by category
|
||||
categories = {}
|
||||
for s in settings:
|
||||
cat = s["category"]
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(s)
|
||||
|
||||
return jsonify({
|
||||
"data": settings,
|
||||
"categories": categories,
|
||||
"available_categories": list(categories.keys()),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/<path:key>", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_setting(key: str) -> Response:
|
||||
"""Get a single setting value."""
|
||||
from .settings import get_setting, DEFAULT_SETTINGS
|
||||
|
||||
value = get_setting(g.db, key)
|
||||
if value is None:
|
||||
return error_response("NOT_FOUND", f"Setting '{key}' not found", 404)
|
||||
|
||||
# Find metadata
|
||||
setting_meta = None
|
||||
for s in DEFAULT_SETTINGS:
|
||||
if s.key == key:
|
||||
setting_meta = s
|
||||
break
|
||||
|
||||
return jsonify({
|
||||
"key": key,
|
||||
"value": value,
|
||||
"value_type": setting_meta.value_type if setting_meta else "string",
|
||||
"description": setting_meta.description if setting_meta else "",
|
||||
"category": setting_meta.category if setting_meta else "general",
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/<path:key>", methods=["PUT"])
|
||||
@require_admin
|
||||
def admin_update_setting(key: str) -> Response:
|
||||
"""Update a setting value."""
|
||||
from .settings import set_setting, get_setting
|
||||
|
||||
data = request.get_json()
|
||||
if not data or "value" not in data:
|
||||
return error_response("VALIDATION_ERROR", "Missing 'value' in request body")
|
||||
|
||||
value = data["value"]
|
||||
success = set_setting(g.db, key, value, updated_by=g.user_slug)
|
||||
|
||||
if not success:
|
||||
return error_response(
|
||||
"VALIDATION_ERROR",
|
||||
f"Invalid setting key '{key}' or invalid value",
|
||||
400,
|
||||
)
|
||||
|
||||
# Log the change
|
||||
log_audit(
|
||||
"update_setting",
|
||||
"setting",
|
||||
key,
|
||||
{"old_value": get_setting(g.db, key), "new_value": value},
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"key": key,
|
||||
"value": get_setting(g.db, key),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/<path:key>", methods=["DELETE"])
|
||||
@require_admin
|
||||
def admin_reset_setting(key: str) -> Response:
|
||||
"""Reset a setting to its default value."""
|
||||
from .settings import reset_setting, get_setting, DEFAULT_SETTINGS
|
||||
|
||||
# Check if it's a valid setting
|
||||
valid = any(s.key == key for s in DEFAULT_SETTINGS)
|
||||
if not valid:
|
||||
return error_response("NOT_FOUND", f"Setting '{key}' not found", 404)
|
||||
|
||||
reset_setting(g.db, key)
|
||||
|
||||
log_audit("reset_setting", "setting", key, {})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"key": key,
|
||||
"value": get_setting(g.db, key),
|
||||
"message": "Setting reset to default",
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/reset-all", methods=["POST"])
|
||||
@require_admin
|
||||
def admin_reset_all_settings() -> Response:
|
||||
"""Reset all settings to defaults."""
|
||||
from .settings import reset_all_settings
|
||||
|
||||
count = reset_all_settings(g.db)
|
||||
|
||||
log_audit("reset_all_settings", "settings", "all", {"count": count})
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"reset_count": count,
|
||||
"message": f"Reset {count} settings to defaults",
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/settings/vetting", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_get_vetting_config() -> Response:
|
||||
"""Get current vetting configuration (for moderators)."""
|
||||
from .settings import get_vetting_config
|
||||
|
||||
return jsonify(get_vetting_config(g.db))
|
||||
|
||||
@app.route("/api/v1/admin/settings/similarity", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_get_similarity_config() -> Response:
|
||||
"""Get current similarity detection configuration."""
|
||||
from .settings import get_similarity_config
|
||||
|
||||
return jsonify(get_similarity_config(g.db))
|
||||
|
||||
@app.route("/api/v1/admin/settings/sync", methods=["GET"])
|
||||
@require_admin
|
||||
def admin_get_sync_config() -> Response:
|
||||
"""Get Fabric sync configuration."""
|
||||
from .settings import get_sync_config
|
||||
|
||||
return jsonify(get_sync_config(g.db))
|
||||
|
||||
@app.route("/api/v1/admin/settings/moderation", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_get_moderation_config() -> Response:
|
||||
"""Get moderation configuration."""
|
||||
from .settings import get_moderation_config
|
||||
|
||||
return jsonify(get_moderation_config(g.db))
|
||||
|
||||
# ─── Reviews & Ratings API ────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["POST"])
|
||||
|
||||
@@ -383,6 +383,19 @@ CREATE TABLE IF NOT EXISTS tool_usage (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_tool ON tool_usage(tool_id);
|
||||
|
||||
-- Admin Configuration Settings
|
||||
CREATE TABLE IF NOT EXISTS registry_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
value_type TEXT DEFAULT 'string',
|
||||
description TEXT,
|
||||
category TEXT DEFAULT 'general',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_category ON registry_settings(category);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
"""Registry settings management.
|
||||
|
||||
Provides configurable settings for vetting, moderation, and sync.
|
||||
Settings are stored in the database and can be modified via admin API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .db import query_one, query_all
|
||||
|
||||
|
||||
@dataclass
|
||||
class Setting:
|
||||
"""A single configuration setting."""
|
||||
key: str
|
||||
value: Any
|
||||
value_type: str # string, int, float, bool, json
|
||||
description: str
|
||||
category: str
|
||||
updated_at: Optional[str] = None
|
||||
updated_by: Optional[str] = None
|
||||
|
||||
|
||||
# Default settings with descriptions
|
||||
DEFAULT_SETTINGS: List[Setting] = [
|
||||
# Vetting thresholds
|
||||
Setting(
|
||||
key="vetting.approve_threshold",
|
||||
value=0.8,
|
||||
value_type="float",
|
||||
description="Quality score threshold for auto-approval (0.0-1.0)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.reject_threshold",
|
||||
value=0.3,
|
||||
value_type="float",
|
||||
description="Quality score threshold for auto-rejection (0.0-1.0)",
|
||||
category="vetting",
|
||||
),
|
||||
|
||||
# Vetting weights
|
||||
Setting(
|
||||
key="vetting.weight.honesty",
|
||||
value=0.25,
|
||||
value_type="float",
|
||||
description="Weight for honesty check (description matches behavior)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.transparency",
|
||||
value=0.20,
|
||||
value_type="float",
|
||||
description="Weight for transparency check (no obfuscation)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.scope",
|
||||
value=0.15,
|
||||
value_type="float",
|
||||
description="Weight for scope check (appropriate boundaries)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.efficiency",
|
||||
value=0.15,
|
||||
value_type="float",
|
||||
description="Weight for efficiency check (prompt quality)",
|
||||
category="vetting",
|
||||
),
|
||||
Setting(
|
||||
key="vetting.weight.safety",
|
||||
value=0.25,
|
||||
value_type="float",
|
||||
description="Weight for safety check (no dangerous patterns)",
|
||||
category="vetting",
|
||||
),
|
||||
|
||||
# Similarity detection
|
||||
Setting(
|
||||
key="similarity.duplicate_threshold",
|
||||
value=0.9,
|
||||
value_type="float",
|
||||
description="Similarity score to consider tools as duplicates (0.0-1.0)",
|
||||
category="similarity",
|
||||
),
|
||||
Setting(
|
||||
key="similarity.similar_threshold",
|
||||
value=0.7,
|
||||
value_type="float",
|
||||
description="Similarity score to flag tools as similar (0.0-1.0)",
|
||||
category="similarity",
|
||||
),
|
||||
Setting(
|
||||
key="similarity.related_threshold",
|
||||
value=0.5,
|
||||
value_type="float",
|
||||
description="Similarity score to flag tools as related (0.0-1.0)",
|
||||
category="similarity",
|
||||
),
|
||||
|
||||
# Fabric sync
|
||||
Setting(
|
||||
key="sync.enabled",
|
||||
value=False,
|
||||
value_type="bool",
|
||||
description="Enable scheduled Fabric pattern sync",
|
||||
category="sync",
|
||||
),
|
||||
Setting(
|
||||
key="sync.interval_hours",
|
||||
value=24,
|
||||
value_type="int",
|
||||
description="Hours between Fabric sync runs",
|
||||
category="sync",
|
||||
),
|
||||
Setting(
|
||||
key="sync.auto_approve",
|
||||
value=False,
|
||||
value_type="bool",
|
||||
description="Auto-approve synced Fabric patterns (skip moderation)",
|
||||
category="sync",
|
||||
),
|
||||
Setting(
|
||||
key="sync.default_provider",
|
||||
value="claude",
|
||||
value_type="string",
|
||||
description="Default AI provider for synced patterns",
|
||||
category="sync",
|
||||
),
|
||||
|
||||
# Moderation
|
||||
Setting(
|
||||
key="moderation.require_review",
|
||||
value=True,
|
||||
value_type="bool",
|
||||
description="Require manual review for public tools",
|
||||
category="moderation",
|
||||
),
|
||||
Setting(
|
||||
key="moderation.auto_approve_private",
|
||||
value=True,
|
||||
value_type="bool",
|
||||
description="Auto-approve private/unlisted tools",
|
||||
category="moderation",
|
||||
),
|
||||
Setting(
|
||||
key="moderation.max_pending_per_user",
|
||||
value=10,
|
||||
value_type="int",
|
||||
description="Maximum pending tools per user before rate limiting",
|
||||
category="moderation",
|
||||
),
|
||||
|
||||
# Rate limits (can override defaults)
|
||||
Setting(
|
||||
key="rate_limit.publish.limit",
|
||||
value=20,
|
||||
value_type="int",
|
||||
description="Max publish requests per window",
|
||||
category="rate_limits",
|
||||
),
|
||||
Setting(
|
||||
key="rate_limit.publish.window",
|
||||
value=3600,
|
||||
value_type="int",
|
||||
description="Publish rate limit window in seconds",
|
||||
category="rate_limits",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _convert_value(value: str, value_type: str) -> Any:
|
||||
"""Convert stored string value to appropriate type."""
|
||||
if value_type == "int":
|
||||
return int(value)
|
||||
elif value_type == "float":
|
||||
return float(value)
|
||||
elif value_type == "bool":
|
||||
return value.lower() in ("true", "1", "yes")
|
||||
elif value_type == "json":
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
def _serialize_value(value: Any, value_type: str) -> str:
|
||||
"""Serialize value for storage."""
|
||||
if value_type == "bool":
|
||||
return "true" if value else "false"
|
||||
elif value_type == "json":
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def get_setting(conn: sqlite3.Connection, key: str) -> Optional[Any]:
|
||||
"""Get a single setting value.
|
||||
|
||||
Returns the default if not set in database.
|
||||
"""
|
||||
row = query_one(
|
||||
conn,
|
||||
"SELECT value, value_type FROM registry_settings WHERE key = ?",
|
||||
[key],
|
||||
)
|
||||
|
||||
if row:
|
||||
return _convert_value(row["value"], row["value_type"])
|
||||
|
||||
# Return default if exists
|
||||
for setting in DEFAULT_SETTINGS:
|
||||
if setting.key == key:
|
||||
return setting.value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_settings_by_category(conn: sqlite3.Connection, category: str) -> Dict[str, Any]:
|
||||
"""Get all settings for a category as a dict."""
|
||||
result = {}
|
||||
|
||||
# Start with defaults
|
||||
for setting in DEFAULT_SETTINGS:
|
||||
if setting.category == category:
|
||||
result[setting.key] = setting.value
|
||||
|
||||
# Override with DB values
|
||||
rows = query_all(
|
||||
conn,
|
||||
"SELECT key, value, value_type FROM registry_settings WHERE category = ?",
|
||||
[category],
|
||||
)
|
||||
for row in rows:
|
||||
result[row["key"]] = _convert_value(row["value"], row["value_type"])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_all_settings(conn: sqlite3.Connection) -> List[Dict[str, Any]]:
|
||||
"""Get all settings with their current values and metadata."""
|
||||
result = []
|
||||
|
||||
# Get all DB settings
|
||||
db_settings = {}
|
||||
rows = query_all(conn, "SELECT * FROM registry_settings")
|
||||
for row in rows:
|
||||
db_settings[row["key"]] = dict(row)
|
||||
|
||||
# Build combined list
|
||||
for default in DEFAULT_SETTINGS:
|
||||
if default.key in db_settings:
|
||||
row = db_settings[default.key]
|
||||
result.append({
|
||||
"key": default.key,
|
||||
"value": _convert_value(row["value"], row["value_type"]),
|
||||
"value_type": row["value_type"],
|
||||
"description": default.description,
|
||||
"category": default.category,
|
||||
"updated_at": row["updated_at"],
|
||||
"updated_by": row["updated_by"],
|
||||
"is_default": False,
|
||||
})
|
||||
else:
|
||||
result.append({
|
||||
"key": default.key,
|
||||
"value": default.value,
|
||||
"value_type": default.value_type,
|
||||
"description": default.description,
|
||||
"category": default.category,
|
||||
"updated_at": None,
|
||||
"updated_by": None,
|
||||
"is_default": True,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def set_setting(
|
||||
conn: sqlite3.Connection,
|
||||
key: str,
|
||||
value: Any,
|
||||
updated_by: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Set a setting value.
|
||||
|
||||
Returns True if successful, False if key is not a valid setting.
|
||||
"""
|
||||
# Find the setting definition
|
||||
setting_def = None
|
||||
for s in DEFAULT_SETTINGS:
|
||||
if s.key == key:
|
||||
setting_def = s
|
||||
break
|
||||
|
||||
if not setting_def:
|
||||
return False
|
||||
|
||||
# Validate and convert value
|
||||
try:
|
||||
if setting_def.value_type == "int":
|
||||
value = int(value)
|
||||
elif setting_def.value_type == "float":
|
||||
value = float(value)
|
||||
# Validate range for thresholds
|
||||
if "threshold" in key or "weight" in key:
|
||||
if not 0.0 <= value <= 1.0:
|
||||
return False
|
||||
elif setting_def.value_type == "bool":
|
||||
if isinstance(value, str):
|
||||
value = value.lower() in ("true", "1", "yes")
|
||||
else:
|
||||
value = bool(value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
serialized = _serialize_value(value, setting_def.value_type)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO registry_settings (key, value, value_type, description, category, updated_at, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = excluded.updated_at,
|
||||
updated_by = excluded.updated_by
|
||||
""",
|
||||
[key, serialized, setting_def.value_type, setting_def.description,
|
||||
setting_def.category, now, updated_by],
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def reset_setting(conn: sqlite3.Connection, key: str) -> bool:
|
||||
"""Reset a setting to its default value (remove from DB)."""
|
||||
conn.execute("DELETE FROM registry_settings WHERE key = ?", [key])
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def reset_all_settings(conn: sqlite3.Connection) -> int:
|
||||
"""Reset all settings to defaults. Returns count of reset settings."""
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM registry_settings")
|
||||
count = cursor.fetchone()[0]
|
||||
conn.execute("DELETE FROM registry_settings")
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
|
||||
def get_vetting_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Get vetting configuration as a structured dict.
|
||||
|
||||
This is the main entry point for the scrutiny system to get config.
|
||||
"""
|
||||
return {
|
||||
"approve_threshold": get_setting(conn, "vetting.approve_threshold"),
|
||||
"reject_threshold": get_setting(conn, "vetting.reject_threshold"),
|
||||
"weights": {
|
||||
"honesty": get_setting(conn, "vetting.weight.honesty"),
|
||||
"transparency": get_setting(conn, "vetting.weight.transparency"),
|
||||
"scope": get_setting(conn, "vetting.weight.scope"),
|
||||
"efficiency": get_setting(conn, "vetting.weight.efficiency"),
|
||||
"safety": get_setting(conn, "vetting.weight.safety"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_similarity_config(conn: sqlite3.Connection) -> Dict[str, float]:
|
||||
"""Get similarity detection configuration."""
|
||||
return {
|
||||
"duplicate_threshold": get_setting(conn, "similarity.duplicate_threshold"),
|
||||
"similar_threshold": get_setting(conn, "similarity.similar_threshold"),
|
||||
"related_threshold": get_setting(conn, "similarity.related_threshold"),
|
||||
}
|
||||
|
||||
|
||||
def get_sync_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Get Fabric sync configuration."""
|
||||
return {
|
||||
"enabled": get_setting(conn, "sync.enabled"),
|
||||
"interval_hours": get_setting(conn, "sync.interval_hours"),
|
||||
"auto_approve": get_setting(conn, "sync.auto_approve"),
|
||||
"default_provider": get_setting(conn, "sync.default_provider"),
|
||||
}
|
||||
|
||||
|
||||
def get_moderation_config(conn: sqlite3.Connection) -> Dict[str, Any]:
|
||||
"""Get moderation configuration."""
|
||||
return {
|
||||
"require_review": get_setting(conn, "moderation.require_review"),
|
||||
"auto_approve_private": get_setting(conn, "moderation.auto_approve_private"),
|
||||
"max_pending_per_user": get_setting(conn, "moderation.max_pending_per_user"),
|
||||
}
|
||||
Reference in New Issue
Block a user