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:
rob
2026-01-15 15:20:21 -04:00
co-authored by Claude Opus 4.5
parent d1eb4231a7
commit a5e0948881
14 changed files with 2958 additions and 0 deletions
@@ -83,6 +83,15 @@
</svg>
<span class="text-sm font-medium text-gray-700">Manage publishers</span>
</a>
{% if user.role == 'admin' %}
<a href="{{ url_for('web.admin_settings') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="text-sm font-medium text-gray-700">Registry settings</span>
</a>
{% endif %}
<a href="{{ url_for('web.dashboard') }}" class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<svg class="h-5 w-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"/>
@@ -0,0 +1,231 @@
{% extends "dashboard/base.html" %}
{% block dashboard_header %}
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Registry Settings</h1>
<p class="mt-1 text-gray-600">Configure vetting, moderation, and sync settings</p>
</div>
<div class="flex space-x-3">
<button onclick="resetAllSettings()" class="px-4 py-2 text-sm font-medium text-red-700 bg-red-100 rounded-lg hover:bg-red-200">
Reset All to Defaults
</button>
</div>
</div>
{% endblock %}
{% block dashboard_content %}
<div class="space-y-6">
<!-- Category Tabs -->
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8" aria-label="Settings categories">
{% for cat in categories %}
<button onclick="showCategory('{{ cat }}')"
id="tab-{{ cat }}"
class="category-tab {% if loop.first %}border-indigo-500 text-indigo-600{% else %}border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300{% endif %} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm capitalize">
{{ cat.replace('_', ' ') }}
</button>
{% endfor %}
</nav>
</div>
<!-- Settings Panels -->
{% for cat, cat_settings in settings_by_category.items() %}
<div id="panel-{{ cat }}" class="category-panel {% if not loop.first %}hidden{% endif %}">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-medium text-gray-900 capitalize">{{ cat.replace('_', ' ') }} Settings</h3>
</div>
<div class="divide-y divide-gray-200">
{% for setting in cat_settings %}
<div class="px-6 py-4 flex items-center justify-between">
<div class="flex-1 min-w-0 pr-4">
<p class="text-sm font-medium text-gray-900">{{ setting.key.split('.')[-1].replace('_', ' ').title() }}</p>
<p class="text-sm text-gray-500">{{ setting.description }}</p>
<p class="text-xs text-gray-400 mt-1">
Key: <code class="bg-gray-100 px-1 rounded">{{ setting.key }}</code>
{% if setting.updated_at %}
<span class="ml-2">Last updated: {{ setting.updated_at[:10] }} by {{ setting.updated_by or 'system' }}</span>
{% endif %}
</p>
</div>
<div class="flex items-center space-x-3">
{% if setting.value_type == 'bool' %}
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
id="setting-{{ setting.key|replace('.', '-') }}"
{% if setting.value %}checked{% endif %}
onchange="updateSetting('{{ setting.key }}', this.checked)"
class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
</label>
{% elif setting.value_type == 'float' %}
<div class="flex items-center space-x-2">
<input type="range"
id="range-{{ setting.key|replace('.', '-') }}"
min="0" max="1" step="0.05"
value="{{ setting.value }}"
oninput="document.getElementById('input-{{ setting.key|replace('.', '-') }}').value = this.value"
onchange="updateSetting('{{ setting.key }}', parseFloat(this.value))"
class="w-24 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
<input type="number"
id="input-{{ setting.key|replace('.', '-') }}"
min="0" max="1" step="0.01"
value="{{ setting.value }}"
onchange="updateSetting('{{ setting.key }}', parseFloat(this.value)); document.getElementById('range-{{ setting.key|replace('.', '-') }}').value = this.value"
class="w-20 px-2 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
</div>
{% elif setting.value_type == 'int' %}
<input type="number"
id="input-{{ setting.key|replace('.', '-') }}"
value="{{ setting.value }}"
onchange="updateSetting('{{ setting.key }}', parseInt(this.value))"
class="w-24 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
{% else %}
<input type="text"
id="input-{{ setting.key|replace('.', '-') }}"
value="{{ setting.value }}"
onchange="updateSetting('{{ setting.key }}', this.value)"
class="w-48 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-indigo-500 focus:border-indigo-500">
{% endif %}
<button onclick="resetSetting('{{ setting.key }}')"
title="Reset to default"
class="p-1 text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
<!-- Info Panel -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">About these settings</h3>
<div class="mt-2 text-sm text-blue-700">
<ul class="list-disc list-inside space-y-1">
<li><strong>Vetting:</strong> Controls how tools are automatically scored and approved/rejected</li>
<li><strong>Similarity:</strong> Thresholds for detecting duplicate or similar tools</li>
<li><strong>Sync:</strong> Fabric pattern synchronization settings</li>
<li><strong>Moderation:</strong> Rules for tool review workflow</li>
<li><strong>Rate Limits:</strong> API rate limiting configuration</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<script>
const token = '{{ token }}';
function showCategory(cat) {
// Hide all panels
document.querySelectorAll('.category-panel').forEach(p => p.classList.add('hidden'));
// Show selected panel
document.getElementById('panel-' + cat).classList.remove('hidden');
// Update tab styles
document.querySelectorAll('.category-tab').forEach(t => {
t.classList.remove('border-indigo-500', 'text-indigo-600');
t.classList.add('border-transparent', 'text-gray-500');
});
document.getElementById('tab-' + cat).classList.remove('border-transparent', 'text-gray-500');
document.getElementById('tab-' + cat).classList.add('border-indigo-500', 'text-indigo-600');
}
async function updateSetting(key, value) {
try {
const response = await fetch('/api/v1/admin/settings/' + key, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify({ value: value })
});
const data = await response.json();
if (response.ok) {
showToast('Setting updated successfully', 'success');
} else {
showToast(data.error || 'Failed to update setting', 'error');
}
} catch (err) {
showToast('Network error: ' + err.message, 'error');
}
}
async function resetSetting(key) {
if (!confirm('Reset this setting to its default value?')) return;
try {
const response = await fetch('/api/v1/admin/settings/' + key, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + token
}
});
const data = await response.json();
if (response.ok) {
showToast('Setting reset to default', 'success');
location.reload();
} else {
showToast(data.error || 'Failed to reset setting', 'error');
}
} catch (err) {
showToast('Network error: ' + err.message, 'error');
}
}
async function resetAllSettings() {
if (!confirm('Reset ALL settings to their default values? This cannot be undone.')) return;
try {
const response = await fetch('/api/v1/admin/settings/reset-all', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token
}
});
const data = await response.json();
if (response.ok) {
showToast('All settings reset to defaults', 'success');
location.reload();
} else {
showToast(data.error || 'Failed to reset settings', 'error');
}
} catch (err) {
showToast('Network error: ' + err.message, 'error');
}
}
function showToast(message, type) {
const toast = document.createElement('div');
toast.className = `fixed bottom-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white ${type === 'success' ? 'bg-green-600' : 'bg-red-600'} transition-opacity duration-300`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
</script>
{% endblock %}