chore: update dependencies and documentation
Update project dependencies, documentation, and supporting files: - i18n improvements for multilingual support - Admin dashboard enhancements - Documentation updates for Koha/Stripe and deployment - Server middleware and model updates - Package dependency updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
52cbbb1e3a
commit
6baa841e99
12 changed files with 1255 additions and 40 deletions
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
**Project:** Tractatus Framework
|
**Project:** Tractatus Framework
|
||||||
**Feature:** Phase 3 - Koha (Donation) System
|
**Feature:** Phase 3 - Koha (Donation) System
|
||||||
**Date:** 2025-10-08
|
**Date:** 2025-10-18 (Updated with automated scripts)
|
||||||
**Status:** Development
|
**Status:** ✅ Test Mode Active | Production Ready
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -18,6 +18,43 @@ The Koha donation system uses the existing Stripe account from `passport-consoli
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Quick Start (Automated Setup)
|
||||||
|
|
||||||
|
**✨ NEW: Automated setup scripts available!**
|
||||||
|
|
||||||
|
### Option A: Fully Automated Setup (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: Verify Stripe API connection
|
||||||
|
node scripts/test-stripe-connection.js
|
||||||
|
|
||||||
|
# Step 2: Create products and prices automatically
|
||||||
|
node scripts/setup-stripe-products.js
|
||||||
|
|
||||||
|
# Step 3: Server will restart automatically - prices now configured!
|
||||||
|
|
||||||
|
# Step 4: Test the complete integration
|
||||||
|
node scripts/test-stripe-integration.js
|
||||||
|
|
||||||
|
# Step 5: Set up webhooks for local testing (requires Stripe CLI)
|
||||||
|
./scripts/stripe-webhook-setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**That's it!** All products, prices, and configuration are set up automatically.
|
||||||
|
|
||||||
|
### What the Scripts Do
|
||||||
|
|
||||||
|
1. **test-stripe-connection.js** - Verifies test API keys work and shows existing products/prices
|
||||||
|
2. **setup-stripe-products.js** - Creates the Tractatus product and 3 monthly price tiers with multi-currency support
|
||||||
|
3. **test-stripe-integration.js** - Tests checkout session creation for both monthly and one-time donations
|
||||||
|
4. **stripe-webhook-setup.sh** - Guides you through Stripe CLI installation and webhook setup
|
||||||
|
|
||||||
|
### Option B: Manual Setup
|
||||||
|
|
||||||
|
Continue to Section 1 below for step-by-step manual instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1. Stripe Products to Create
|
## 1. Stripe Products to Create
|
||||||
|
|
||||||
### Product: "Tractatus Framework Support"
|
### Product: "Tractatus Framework Support"
|
||||||
|
|
@ -271,18 +308,27 @@ After setup, your `.env` should have:
|
||||||
STRIPE_SECRET_KEY=sk_test_51RX67k...
|
STRIPE_SECRET_KEY=sk_test_51RX67k...
|
||||||
STRIPE_PUBLISHABLE_KEY=pk_test_51RX67k...
|
STRIPE_PUBLISHABLE_KEY=pk_test_51RX67k...
|
||||||
|
|
||||||
# Webhook Secret (from Step 4)
|
# Webhook Secret (from Step 4 or Stripe CLI)
|
||||||
STRIPE_KOHA_WEBHOOK_SECRET=whsec_...
|
STRIPE_KOHA_WEBHOOK_SECRET=whsec_...
|
||||||
|
|
||||||
# Price IDs (from Step 3)
|
# Product ID (created by setup-stripe-products.js)
|
||||||
STRIPE_KOHA_5_PRICE_ID=price_...
|
STRIPE_KOHA_PRODUCT_ID=prod_TFusJH4Q3br8gA
|
||||||
STRIPE_KOHA_15_PRICE_ID=price_...
|
|
||||||
STRIPE_KOHA_50_PRICE_ID=price_...
|
# Price IDs (created by setup-stripe-products.js)
|
||||||
|
STRIPE_KOHA_5_PRICE_ID=price_1SJP2fGhfAwOYBrf9yrf0q8C
|
||||||
|
STRIPE_KOHA_15_PRICE_ID=price_1SJP2fGhfAwOYBrfNc6Nfjyj
|
||||||
|
STRIPE_KOHA_50_PRICE_ID=price_1SJP2fGhfAwOYBrf0A62TOpf
|
||||||
|
|
||||||
# Frontend URL
|
# Frontend URL
|
||||||
FRONTEND_URL=http://localhost:9000
|
FRONTEND_URL=http://localhost:9000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**✅ Current Status (2025-10-18):**
|
||||||
|
- Product and prices are already created in test mode
|
||||||
|
- .env file is configured with actual IDs
|
||||||
|
- Server integration tested and working
|
||||||
|
- Ready for frontend testing with test cards
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Testing the Integration
|
## 7. Testing the Integration
|
||||||
|
|
@ -437,27 +483,51 @@ Enable detailed Stripe logs:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Next Steps
|
## 11. Current Status & Next Steps
|
||||||
|
|
||||||
After completing this setup:
|
### ✅ Completed (2025-10-18)
|
||||||
|
|
||||||
1. ✅ Test donation flow end-to-end
|
1. ✅ Stripe test account configured with existing credentials
|
||||||
2. ✅ Create frontend donation form UI
|
2. ✅ Product "Tractatus Framework Support" created (prod_TFusJH4Q3br8gA)
|
||||||
3. ✅ Build transparency dashboard
|
3. ✅ Three monthly price tiers created with multi-currency support
|
||||||
4. ✅ Implement receipt email generation
|
4. ✅ .env file configured with actual product and price IDs
|
||||||
5. ✅ Add donor acknowledgement system
|
5. ✅ Backend API endpoints implemented and tested
|
||||||
6. ⏳ Deploy to production
|
6. ✅ Frontend donation form UI complete with i18n support
|
||||||
|
7. ✅ Checkout session creation tested for monthly and one-time donations
|
||||||
|
8. ✅ Automated setup scripts created for easy deployment
|
||||||
|
|
||||||
|
### ⏳ Pending
|
||||||
|
|
||||||
|
1. ⏳ Install Stripe CLI for local webhook testing
|
||||||
|
2. ⏳ Configure webhook endpoint and get signing secret
|
||||||
|
3. ⏳ Test complete payment flow with test cards in browser
|
||||||
|
4. ⏳ Build transparency dashboard data visualization
|
||||||
|
5. ⏳ Implement receipt email generation (Koha service has placeholder)
|
||||||
|
6. ⏳ Switch to production Stripe keys and test with real card
|
||||||
|
7. ⏳ Deploy to production server
|
||||||
|
|
||||||
|
### 🎯 Ready to Test
|
||||||
|
|
||||||
|
You can now test the donation system locally:
|
||||||
|
|
||||||
|
1. Visit http://localhost:9000/koha.html
|
||||||
|
2. Select a donation tier or enter custom amount
|
||||||
|
3. Fill in donor information
|
||||||
|
4. Use test card: 4242 4242 4242 4242
|
||||||
|
5. Complete checkout flow
|
||||||
|
6. Verify success page shows
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
**Issues:** Report in GitHub Issues
|
**Issues:** Report in GitHub Issues at https://github.com/yourusername/tractatus
|
||||||
**Questions:** Contact john.stroh.nz@pm.me
|
**Questions:** Contact john.stroh.nz@pm.me
|
||||||
**Stripe Docs:** https://stripe.com/docs/api
|
**Stripe Docs:** https://stripe.com/docs/api
|
||||||
|
**Test Cards:** https://stripe.com/docs/testing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2025-10-08
|
**Last Updated:** 2025-10-18
|
||||||
**Version:** 1.0
|
**Version:** 2.0 (Automated Setup)
|
||||||
**Status:** Ready for setup
|
**Status:** ✅ Test Mode Active | Ready for Webhook Setup
|
||||||
|
|
|
||||||
|
|
@ -560,6 +560,106 @@ Keep a deployment log in: `docs/deployments/YYYY-MM.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CRITICAL: HTML Caching Rules
|
||||||
|
|
||||||
|
**MANDATORY REQUIREMENT**: HTML files MUST be delivered fresh to users without requiring cache refresh.
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
Service worker caching HTML files caused deployment failures where users saw OLD content even after deploying NEW code. Users should NEVER need to clear cache manually.
|
||||||
|
|
||||||
|
### The Solution (Enforced as of 2025-10-17)
|
||||||
|
|
||||||
|
**Service Worker** (`public/service-worker.js`):
|
||||||
|
- HTML files: Network-ONLY strategy (never cache, always fetch fresh)
|
||||||
|
- Exception: `/index.html` only for offline fallback
|
||||||
|
- Bump `CACHE_VERSION` constant whenever service worker logic changes
|
||||||
|
|
||||||
|
**Server** (`src/server.js`):
|
||||||
|
- HTML files: `Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0`
|
||||||
|
- This ensures browsers never cache HTML pages
|
||||||
|
- CSS/JS: Long cache OK (use version parameters for cache-busting)
|
||||||
|
|
||||||
|
**Version Manifest** (`public/version.json`):
|
||||||
|
- Update version number when deploying HTML changes
|
||||||
|
- Service worker checks this for updates
|
||||||
|
- Set `forceUpdate: true` for critical fixes
|
||||||
|
|
||||||
|
### Deployment Rules for HTML Changes
|
||||||
|
|
||||||
|
When deploying HTML file changes:
|
||||||
|
|
||||||
|
1. **Verify service worker never caches HTML** (except index.html)
|
||||||
|
```bash
|
||||||
|
grep -A 10 "HTML files:" public/service-worker.js
|
||||||
|
# Should show: Network-ONLY strategy, no caching
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify server sends no-cache headers**
|
||||||
|
```bash
|
||||||
|
grep -A 3 "HTML files:" src/server.js
|
||||||
|
# Should show: no-store, no-cache, must-revalidate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Bump version.json if critical content changed**
|
||||||
|
```bash
|
||||||
|
# Edit public/version.json
|
||||||
|
# Increment version: 1.1.2 → 1.1.3
|
||||||
|
# Update changelog
|
||||||
|
# Set forceUpdate: true
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **After deployment, verify headers in production**
|
||||||
|
```bash
|
||||||
|
curl -s -I https://agenticgovernance.digital/koha.html | grep -i cache-control
|
||||||
|
# Expected: no-store, no-cache, must-revalidate
|
||||||
|
|
||||||
|
curl -s https://agenticgovernance.digital/koha.html | grep "<title>"
|
||||||
|
# Verify correct content showing
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Test in incognito window**
|
||||||
|
- Open https://agenticgovernance.digital in fresh incognito window
|
||||||
|
- Verify new content loads immediately
|
||||||
|
- No cache refresh should be needed
|
||||||
|
|
||||||
|
### Testing Cache Behavior
|
||||||
|
|
||||||
|
**Before deployment:**
|
||||||
|
```bash
|
||||||
|
# Local: Verify server sends correct headers
|
||||||
|
curl -s -I http://localhost:9000/koha.html | grep cache-control
|
||||||
|
# Expected: no-store, no-cache
|
||||||
|
|
||||||
|
# Verify service worker doesn't cache HTML
|
||||||
|
grep "endsWith('.html')" public/service-worker.js -A 10
|
||||||
|
# Should NOT cache responses, only fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
**After deployment:**
|
||||||
|
```bash
|
||||||
|
# Production: Verify headers
|
||||||
|
curl -s -I https://agenticgovernance.digital/<file>.html | grep cache-control
|
||||||
|
|
||||||
|
# Production: Verify fresh content
|
||||||
|
curl -s https://agenticgovernance.digital/<file>.html | grep "<title>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Incident Prevention
|
||||||
|
|
||||||
|
**Lesson Learned** (2025-10-17 Koha Deployment):
|
||||||
|
- Deployed koha.html with reciprocal giving updates
|
||||||
|
- Service worker cached old version
|
||||||
|
- Users saw old content despite fresh deployment
|
||||||
|
- Required THREE deployment attempts to fix
|
||||||
|
- Root cause: Service worker was caching HTML with network-first strategy
|
||||||
|
|
||||||
|
**Prevention**:
|
||||||
|
- Service worker now enforces network-ONLY for all HTML (except offline index.html)
|
||||||
|
- Server enforces no-cache headers
|
||||||
|
- This checklist documents the requirement architecturally
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Deployment Best Practices
|
## Deployment Best Practices
|
||||||
|
|
||||||
### DO:
|
### DO:
|
||||||
|
|
@ -570,6 +670,8 @@ Keep a deployment log in: `docs/deployments/YYYY-MM.md`
|
||||||
- ✅ Document all deployments
|
- ✅ Document all deployments
|
||||||
- ✅ Keep rollback procedure tested and ready
|
- ✅ Keep rollback procedure tested and ready
|
||||||
- ✅ Communicate with team before major deployments
|
- ✅ Communicate with team before major deployments
|
||||||
|
- ✅ **CRITICAL: Verify HTML cache headers before and after deployment**
|
||||||
|
- ✅ **CRITICAL: Test in incognito window after HTML deployments**
|
||||||
|
|
||||||
### DON'T:
|
### DON'T:
|
||||||
- ❌ Deploy on Friday afternoon (limited time to fix issues)
|
- ❌ Deploy on Friday afternoon (limited time to fix issues)
|
||||||
|
|
@ -579,6 +681,8 @@ Keep a deployment log in: `docs/deployments/YYYY-MM.md`
|
||||||
- ❌ Deploy when tired or rushed
|
- ❌ Deploy when tired or rushed
|
||||||
- ❌ Deploy without ability to rollback
|
- ❌ Deploy without ability to rollback
|
||||||
- ❌ Forget to restart services after backend changes
|
- ❌ Forget to restart services after backend changes
|
||||||
|
- ❌ **CRITICAL: Never cache HTML files in service worker (except offline fallback)**
|
||||||
|
- ❌ **CRITICAL: Never ask users to clear their browser cache - fix it server-side**
|
||||||
|
|
||||||
### Deployment Timing Guidelines
|
### Deployment Timing Guidelines
|
||||||
|
|
||||||
|
|
|
||||||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -28,7 +28,7 @@
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"puppeteer": "^24.23.0",
|
"puppeteer": "^24.23.0",
|
||||||
"sanitize-html": "^2.11.0",
|
"sanitize-html": "^2.11.0",
|
||||||
"stripe": "^14.25.0",
|
"stripe": "^19.1.0",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.15",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1542,6 +1542,7 @@
|
||||||
"version": "18.19.129",
|
"version": "18.19.129",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.129.tgz",
|
||||||
"integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==",
|
"integrity": "sha512-hrmi5jWt2w60ayox3iIXwpMEnfUvOLJCRtrOPbHtH15nTjvO7uhnelvrdAs0dO0/zl5DZ3ZbahiaXEVb54ca/A==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~5.26.4"
|
"undici-types": "~5.26.4"
|
||||||
|
|
@ -8273,16 +8274,23 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stripe": {
|
"node_modules/stripe": {
|
||||||
"version": "14.25.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-14.25.0.tgz",
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-19.1.0.tgz",
|
||||||
"integrity": "sha512-wQS3GNMofCXwH8TSje8E1SE8zr6ODiGtHQgPtO95p9Mb4FhKC9jvXR2NUTpZ9ZINlckJcFidCmaTFV4P6vsb9g==",
|
"integrity": "sha512-FjgIiE98dMMTNssfdjMvFdD4eZyEzdWAOwPYqzhPRNZeg9ggFWlPXmX1iJKD5pPIwZBaPlC3SayQQkwsPo6/YQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": ">=8.1.0",
|
|
||||||
"qs": "^6.11.0"
|
"qs": "^6.11.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.*"
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">=16"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sucrase": {
|
"node_modules/sucrase": {
|
||||||
|
|
@ -8785,6 +8793,7 @@
|
||||||
"version": "5.26.5",
|
"version": "5.26.5",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@
|
||||||
"framework:init": "node scripts/session-init.js",
|
"framework:init": "node scripts/session-init.js",
|
||||||
"framework:watchdog": "node scripts/framework-watchdog.js",
|
"framework:watchdog": "node scripts/framework-watchdog.js",
|
||||||
"framework:check": "node scripts/pre-action-check.js",
|
"framework:check": "node scripts/pre-action-check.js",
|
||||||
"framework:recover": "node scripts/recover-framework.js"
|
"framework:recover": "node scripts/recover-framework.js",
|
||||||
|
"check:csp": "node scripts/check-csp-violations.js",
|
||||||
|
"fix:csp": "node scripts/fix-csp-violations.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai-safety",
|
"ai-safety",
|
||||||
|
|
@ -59,7 +61,7 @@
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"puppeteer": "^24.23.0",
|
"puppeteer": "^24.23.0",
|
||||||
"sanitize-html": "^2.11.0",
|
"sanitize-html": "^2.11.0",
|
||||||
"stripe": "^14.25.0",
|
"stripe": "^19.1.0",
|
||||||
"validator": "^13.15.15",
|
"validator": "^13.15.15",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -201,9 +201,12 @@ function showError(message) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<p class="text-red-600">${escapeHtml(message)}</p>
|
<p class="text-red-600">${escapeHtml(message)}</p>
|
||||||
<button onclick="loadMetrics()" class="mt-4 text-sm text-blue-600 hover:text-blue-700">
|
<button id="retry-load-btn" class="mt-4 text-sm text-blue-600 hover:text-blue-700">
|
||||||
Try Again
|
Try Again
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Add event listener to retry button
|
||||||
|
document.getElementById('retry-load-btn')?.addEventListener('click', loadMetrics);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -152,11 +152,20 @@ function renderSubscribers(subscriptions) {
|
||||||
${formatDate(sub.subscribed_at)}
|
${formatDate(sub.subscribed_at)}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button onclick="viewDetails('${sub._id}')" class="text-blue-600 hover:text-blue-900 mr-3">View</button>
|
<button class="view-details-btn text-blue-600 hover:text-blue-900 mr-3" data-id="${sub._id}">View</button>
|
||||||
<button onclick="deleteSubscriber('${sub._id}', '${escapeHtml(sub.email)}')" class="text-red-600 hover:text-red-900">Delete</button>
|
<button class="delete-subscriber-btn text-red-600 hover:text-red-900" data-id="${sub._id}" data-email="${escapeHtml(sub.email)}">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
// Add event listeners to buttons
|
||||||
|
tbody.querySelectorAll('.view-details-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => viewDetails(btn.dataset.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.querySelectorAll('.delete-subscriber-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => deleteSubscriber(btn.dataset.id, btn.dataset.email));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,12 @@ const I18n = {
|
||||||
'/leader.html': 'leader',
|
'/leader.html': 'leader',
|
||||||
'/implementer.html': 'implementer',
|
'/implementer.html': 'implementer',
|
||||||
'/about.html': 'about',
|
'/about.html': 'about',
|
||||||
'/faq.html': 'faq'
|
'/about/values.html': 'values',
|
||||||
|
'/about/values': 'values',
|
||||||
|
'/faq.html': 'faq',
|
||||||
|
'/koha.html': 'koha',
|
||||||
|
'/koha/transparency.html': 'transparency',
|
||||||
|
'/koha/transparency': 'transparency'
|
||||||
};
|
};
|
||||||
|
|
||||||
return pageMap[path] || 'homepage';
|
return pageMap[path] || 'homepage';
|
||||||
|
|
@ -101,24 +106,35 @@ const I18n = {
|
||||||
|
|
||||||
applyTranslations() {
|
applyTranslations() {
|
||||||
// Find all elements with data-i18n attribute
|
// Find all elements with data-i18n attribute
|
||||||
|
// Using innerHTML to preserve formatting like <em>, <strong>, <a> tags in translations
|
||||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||||
const key = el.dataset.i18n;
|
const key = el.dataset.i18n;
|
||||||
const translation = this.t(key);
|
const translation = this.t(key);
|
||||||
|
|
||||||
if (typeof translation === 'string') {
|
if (typeof translation === 'string') {
|
||||||
el.textContent = translation;
|
el.innerHTML = translation;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle data-i18n-html for HTML content
|
// Handle data-i18n-html for HTML content (kept for backward compatibility)
|
||||||
document.querySelectorAll('[data-i18n-html]').forEach(el => {
|
document.querySelectorAll('[data-i18n-html]').forEach(el => {
|
||||||
const key = el.dataset.i18nHtml;
|
const key = el.dataset.i18nHtml;
|
||||||
const translation = this.t(key);
|
const translation = this.t(key);
|
||||||
|
|
||||||
if (typeof translation === 'string') {
|
if (typeof translation === 'string') {
|
||||||
el.innerHTML = translation;
|
el.innerHTML = translation;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle data-i18n-placeholder for input placeholders
|
||||||
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||||
|
const key = el.dataset.i18nPlaceholder;
|
||||||
|
const translation = this.t(key);
|
||||||
|
|
||||||
|
if (typeof translation === 'string') {
|
||||||
|
el.placeholder = translation;
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async setLanguage(lang) {
|
async setLanguage(lang) {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ function securityHeadersMiddleware(req, res, next) {
|
||||||
[
|
[
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self'",
|
"script-src 'self'",
|
||||||
"style-src 'self' 'unsafe-inline'", // Tailwind requires inline styles
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", // Tailwind + Google Fonts
|
||||||
"img-src 'self' data: https:",
|
"img-src 'self' data: https:",
|
||||||
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com",
|
"font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com",
|
||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
|
|
|
||||||
494
src/models/DeliberationSession.model.js
Normal file
494
src/models/DeliberationSession.model.js
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
/**
|
||||||
|
* DeliberationSession Model
|
||||||
|
* Tracks multi-stakeholder deliberation for values conflicts
|
||||||
|
*
|
||||||
|
* AI-LED FACILITATION: This model tracks AI vs. human interventions
|
||||||
|
* and enforces safety mechanisms for AI-led deliberation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { getCollection } = require('../utils/db.util');
|
||||||
|
|
||||||
|
class DeliberationSession {
|
||||||
|
/**
|
||||||
|
* Create new deliberation session
|
||||||
|
*/
|
||||||
|
static async create(data) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
session_id: data.session_id || `deliberation-${Date.now()}`,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
status: 'pending', // "pending" | "in_progress" | "completed" | "paused" | "archived"
|
||||||
|
|
||||||
|
// Decision under deliberation
|
||||||
|
decision: {
|
||||||
|
description: data.decision?.description,
|
||||||
|
context: data.decision?.context || {},
|
||||||
|
triggered_by: data.decision?.triggered_by || 'manual',
|
||||||
|
scenario: data.decision?.scenario || null // e.g., "algorithmic_hiring_transparency"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Conflict analysis (AI-generated initially, can be refined by human)
|
||||||
|
conflict_analysis: {
|
||||||
|
moral_frameworks_in_tension: data.conflict_analysis?.moral_frameworks_in_tension || [],
|
||||||
|
value_trade_offs: data.conflict_analysis?.value_trade_offs || [],
|
||||||
|
affected_stakeholder_groups: data.conflict_analysis?.affected_stakeholder_groups || [],
|
||||||
|
incommensurability_level: data.conflict_analysis?.incommensurability_level || 'unknown', // "low" | "moderate" | "high" | "unknown"
|
||||||
|
analysis_source: data.conflict_analysis?.analysis_source || 'ai' // "ai" | "human" | "collaborative"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stakeholders participating in deliberation
|
||||||
|
stakeholders: (data.stakeholders || []).map(s => ({
|
||||||
|
id: s.id || new ObjectId().toString(),
|
||||||
|
name: s.name,
|
||||||
|
type: s.type, // "organization" | "individual" | "group"
|
||||||
|
represents: s.represents, // e.g., "Job Applicants", "AI Vendors", "Employers"
|
||||||
|
moral_framework: s.moral_framework || null, // e.g., "consequentialist", "deontological"
|
||||||
|
contact: {
|
||||||
|
email: s.contact?.email || null,
|
||||||
|
organization: s.contact?.organization || null,
|
||||||
|
role: s.contact?.role || null
|
||||||
|
},
|
||||||
|
participation_status: s.participation_status || 'invited', // "invited" | "confirmed" | "active" | "withdrawn"
|
||||||
|
consent_given: s.consent_given || false,
|
||||||
|
consent_timestamp: s.consent_timestamp || null
|
||||||
|
})),
|
||||||
|
|
||||||
|
// Deliberation rounds (4-round structure)
|
||||||
|
deliberation_rounds: data.deliberation_rounds || [],
|
||||||
|
|
||||||
|
// Outcome of deliberation
|
||||||
|
outcome: data.outcome || null,
|
||||||
|
|
||||||
|
// ===== AI SAFETY MECHANISMS =====
|
||||||
|
// Tracks AI vs. human facilitation actions
|
||||||
|
facilitation_log: data.facilitation_log || [],
|
||||||
|
|
||||||
|
// Human intervention tracking
|
||||||
|
human_interventions: data.human_interventions || [],
|
||||||
|
|
||||||
|
// Safety escalations
|
||||||
|
safety_escalations: data.safety_escalations || [],
|
||||||
|
|
||||||
|
// AI facilitation quality monitoring
|
||||||
|
ai_quality_metrics: {
|
||||||
|
stakeholder_satisfaction_scores: [], // Populated post-deliberation
|
||||||
|
fairness_scores: [], // Populated during deliberation
|
||||||
|
escalation_count: 0,
|
||||||
|
human_takeover_count: 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// Transparency report (auto-generated)
|
||||||
|
transparency_report: data.transparency_report || null,
|
||||||
|
|
||||||
|
// Audit log (all actions)
|
||||||
|
audit_log: data.audit_log || [],
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
configuration: {
|
||||||
|
format: data.configuration?.format || 'hybrid', // "synchronous" | "asynchronous" | "hybrid"
|
||||||
|
visibility: data.configuration?.visibility || 'private_then_public', // "public" | "private_then_public" | "partial"
|
||||||
|
compensation: data.configuration?.compensation || 'volunteer', // "volunteer" | "500" | "1000"
|
||||||
|
ai_role: data.configuration?.ai_role || 'ai_led', // "minimal" | "assisted" | "ai_led"
|
||||||
|
output_framing: data.configuration?.output_framing || 'pluralistic_accommodation' // "recommendation" | "consensus" | "pluralistic_accommodation"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collection.insertOne(session);
|
||||||
|
return { ...session, _id: result.insertedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add deliberation round
|
||||||
|
*/
|
||||||
|
static async addRound(sessionId, roundData) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
|
||||||
|
const round = {
|
||||||
|
round_number: roundData.round_number,
|
||||||
|
round_type: roundData.round_type, // "position_statements" | "shared_values" | "accommodation" | "outcome"
|
||||||
|
started_at: new Date(),
|
||||||
|
completed_at: null,
|
||||||
|
facilitator: roundData.facilitator || 'ai', // "ai" | "human" | "collaborative"
|
||||||
|
|
||||||
|
// Contributions from stakeholders
|
||||||
|
contributions: (roundData.contributions || []).map(c => ({
|
||||||
|
stakeholder_id: c.stakeholder_id,
|
||||||
|
stakeholder_name: c.stakeholder_name,
|
||||||
|
timestamp: c.timestamp || new Date(),
|
||||||
|
content: c.content,
|
||||||
|
moral_framework_expressed: c.moral_framework_expressed || null,
|
||||||
|
values_emphasized: c.values_emphasized || []
|
||||||
|
})),
|
||||||
|
|
||||||
|
// AI-generated summaries and analysis
|
||||||
|
ai_summary: roundData.ai_summary || null,
|
||||||
|
ai_framework_analysis: roundData.ai_framework_analysis || null,
|
||||||
|
|
||||||
|
// Human notes/observations
|
||||||
|
human_notes: roundData.human_notes || null,
|
||||||
|
|
||||||
|
// Safety checks during this round
|
||||||
|
safety_checks: roundData.safety_checks || []
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ session_id: sessionId },
|
||||||
|
{
|
||||||
|
$push: { deliberation_rounds: round },
|
||||||
|
$set: { updated_at: new Date() }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.modifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record facilitation action (AI or human)
|
||||||
|
* SAFETY MECHANISM: Tracks who did what for transparency
|
||||||
|
*/
|
||||||
|
static async recordFacilitationAction(sessionId, action) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
|
||||||
|
const logEntry = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
actor: action.actor, // "ai" | "human"
|
||||||
|
action_type: action.action_type, // "prompt" | "summary" | "question" | "intervention" | "escalation"
|
||||||
|
round_number: action.round_number || null,
|
||||||
|
content: action.content,
|
||||||
|
reason: action.reason || null, // Why was this action taken?
|
||||||
|
stakeholder_reactions: action.stakeholder_reactions || [] // Optional: track if stakeholders respond well
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ session_id: sessionId },
|
||||||
|
{
|
||||||
|
$push: {
|
||||||
|
facilitation_log: logEntry,
|
||||||
|
audit_log: {
|
||||||
|
timestamp: new Date(),
|
||||||
|
action: 'facilitation_action_recorded',
|
||||||
|
actor: action.actor,
|
||||||
|
details: logEntry
|
||||||
|
}
|
||||||
|
},
|
||||||
|
$set: { updated_at: new Date() }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.modifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record human intervention (SAFETY MECHANISM)
|
||||||
|
* Called when human observer takes over from AI
|
||||||
|
*/
|
||||||
|
static async recordHumanIntervention(sessionId, intervention) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
|
||||||
|
const interventionRecord = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
intervener: intervention.intervener, // Name/ID of human who intervened
|
||||||
|
trigger: intervention.trigger, // "safety_concern" | "ai_error" | "stakeholder_request" | "quality_issue" | "manual"
|
||||||
|
round_number: intervention.round_number || null,
|
||||||
|
description: intervention.description,
|
||||||
|
ai_action_overridden: intervention.ai_action_overridden || null, // What AI was doing when intervention occurred
|
||||||
|
corrective_action: intervention.corrective_action, // What human did instead
|
||||||
|
stakeholder_informed: intervention.stakeholder_informed || false, // Were stakeholders told about the intervention?
|
||||||
|
resolution: intervention.resolution || null // How was the situation resolved?
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ session_id: sessionId },
|
||||||
|
{
|
||||||
|
$push: {
|
||||||
|
human_interventions: interventionRecord,
|
||||||
|
audit_log: {
|
||||||
|
timestamp: new Date(),
|
||||||
|
action: 'human_intervention',
|
||||||
|
details: interventionRecord
|
||||||
|
}
|
||||||
|
},
|
||||||
|
$inc: { 'ai_quality_metrics.human_takeover_count': 1 },
|
||||||
|
$set: { updated_at: new Date() }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.modifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record safety escalation (SAFETY MECHANISM)
|
||||||
|
* Called when concerning pattern detected (bias, harm, disengagement)
|
||||||
|
*/
|
||||||
|
static async recordSafetyEscalation(sessionId, escalation) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
|
||||||
|
const escalationRecord = {
|
||||||
|
timestamp: new Date(),
|
||||||
|
detected_by: escalation.detected_by, // "ai" | "human" | "stakeholder"
|
||||||
|
escalation_type: escalation.escalation_type, // "pattern_bias" | "stakeholder_distress" | "disengagement" | "hostile_exchange" | "ai_malfunction"
|
||||||
|
severity: escalation.severity, // "low" | "moderate" | "high" | "critical"
|
||||||
|
round_number: escalation.round_number || null,
|
||||||
|
description: escalation.description,
|
||||||
|
stakeholders_affected: escalation.stakeholders_affected || [],
|
||||||
|
immediate_action_taken: escalation.immediate_action_taken, // What was done immediately?
|
||||||
|
requires_session_pause: escalation.requires_session_pause || false,
|
||||||
|
resolved: escalation.resolved || false,
|
||||||
|
resolution_details: escalation.resolution_details || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
$push: {
|
||||||
|
safety_escalations: escalationRecord,
|
||||||
|
audit_log: {
|
||||||
|
timestamp: new Date(),
|
||||||
|
action: 'safety_escalation',
|
||||||
|
severity: escalation.severity,
|
||||||
|
details: escalationRecord
|
||||||
|
}
|
||||||
|
},
|
||||||
|
$inc: { 'ai_quality_metrics.escalation_count': 1 },
|
||||||
|
$set: { updated_at: new Date() }
|
||||||
|
};
|
||||||
|
|
||||||
|
// If critical severity or session pause required, auto-pause session
|
||||||
|
if (escalation.severity === 'critical' || escalation.requires_session_pause) {
|
||||||
|
updates.$set.status = 'paused';
|
||||||
|
updates.$set.paused_reason = escalationRecord.description;
|
||||||
|
updates.$set.paused_at = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ session_id: sessionId },
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.modifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set deliberation outcome
|
||||||
|
*/
|
||||||
|
static async setOutcome(sessionId, outcome) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
|
||||||
|
const outcomeRecord = {
|
||||||
|
decision_made: outcome.decision_made,
|
||||||
|
values_prioritized: outcome.values_prioritized || [],
|
||||||
|
values_deprioritized: outcome.values_deprioritized || [],
|
||||||
|
deliberation_summary: outcome.deliberation_summary,
|
||||||
|
consensus_level: outcome.consensus_level, // "full_consensus" | "strong_accommodation" | "moderate_accommodation" | "documented_dissent" | "no_resolution"
|
||||||
|
dissenting_perspectives: outcome.dissenting_perspectives || [],
|
||||||
|
justification: outcome.justification,
|
||||||
|
moral_remainder: outcome.moral_remainder || null, // What was sacrificed/lost?
|
||||||
|
generated_by: outcome.generated_by || 'ai', // "ai" | "human" | "collaborative"
|
||||||
|
finalized_at: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ session_id: sessionId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
outcome: outcomeRecord,
|
||||||
|
status: 'completed',
|
||||||
|
updated_at: new Date()
|
||||||
|
},
|
||||||
|
$push: {
|
||||||
|
audit_log: {
|
||||||
|
timestamp: new Date(),
|
||||||
|
action: 'outcome_set',
|
||||||
|
details: { consensus_level: outcome.consensus_level }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.modifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find session by ID
|
||||||
|
*/
|
||||||
|
static async findBySessionId(sessionId) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
return await collection.findOne({ session_id: sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find sessions by scenario
|
||||||
|
*/
|
||||||
|
static async findByScenario(scenario, options = {}) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
const { limit = 50, skip = 0 } = options;
|
||||||
|
|
||||||
|
return await collection
|
||||||
|
.find({ 'decision.scenario': scenario })
|
||||||
|
.sort({ created_at: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find sessions by status
|
||||||
|
*/
|
||||||
|
static async findByStatus(status, options = {}) {
|
||||||
|
const collection = await getCollection('deliberation_sessions');
|
||||||
|
const { limit = 50, skip = 0 } = options;
|
||||||
|
|
||||||
|
return await collection
|
||||||
|
.find({ status })
|
||||||
|
.sort({ created_at: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get AI safety metrics for session
|
||||||
|
* SAFETY MECHANISM: Monitors AI facilitation quality
|
||||||
|
*/
|
||||||
|
static async getAISafetyMetrics(sessionId) {
|
||||||
|
const session = await this.findBySessionId(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
session_id: sessionId,
|
||||||
|
status: session.status,
|
||||||
|
total_interventions: session.human_interventions.length,
|
||||||
|
total_escalations: session.safety_escalations.length,
|
||||||
|
critical_escalations: session.safety_escalations.filter(e => e.severity === 'critical').length,
|
||||||
|
ai_takeover_count: session.ai_quality_metrics.human_takeover_count,
|
||||||
|
facilitation_balance: {
|
||||||
|
ai_actions: session.facilitation_log.filter(a => a.actor === 'ai').length,
|
||||||
|
human_actions: session.facilitation_log.filter(a => a.actor === 'human').length
|
||||||
|
},
|
||||||
|
unresolved_escalations: session.safety_escalations.filter(e => !e.resolved).length,
|
||||||
|
stakeholder_satisfaction: session.ai_quality_metrics.stakeholder_satisfaction_scores,
|
||||||
|
recommendation: this._generateSafetyRecommendation(session)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate safety recommendation based on metrics
|
||||||
|
* SAFETY MECHANISM: Auto-flags concerning sessions
|
||||||
|
*/
|
||||||
|
static _generateSafetyRecommendation(session) {
|
||||||
|
const criticalCount = session.safety_escalations.filter(e => e.severity === 'critical').length;
|
||||||
|
const takeoverCount = session.ai_quality_metrics.human_takeover_count;
|
||||||
|
const unresolvedCount = session.safety_escalations.filter(e => !e.resolved).length;
|
||||||
|
|
||||||
|
if (criticalCount > 0 || unresolvedCount > 2) {
|
||||||
|
return {
|
||||||
|
level: 'critical',
|
||||||
|
message: 'Session requires immediate human review. Critical safety issues detected.',
|
||||||
|
action: 'pause_and_review'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (takeoverCount > 3 || session.safety_escalations.length > 5) {
|
||||||
|
return {
|
||||||
|
level: 'warning',
|
||||||
|
message: 'High intervention rate suggests AI facilitation quality issues.',
|
||||||
|
action: 'increase_human_oversight'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (takeoverCount === 0 && session.safety_escalations.length === 0) {
|
||||||
|
return {
|
||||||
|
level: 'excellent',
|
||||||
|
message: 'AI facilitation proceeding smoothly with no interventions.',
|
||||||
|
action: 'continue_monitoring'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
level: 'normal',
|
||||||
|
message: 'AI facilitation within normal parameters.',
|
||||||
|
action: 'continue_monitoring'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate transparency report
|
||||||
|
*/
|
||||||
|
static async generateTransparencyReport(sessionId) {
|
||||||
|
const session = await this.findBySessionId(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
session_id: sessionId,
|
||||||
|
generated_at: new Date(),
|
||||||
|
|
||||||
|
// Process transparency
|
||||||
|
process: {
|
||||||
|
format: session.configuration.format,
|
||||||
|
ai_role: session.configuration.ai_role,
|
||||||
|
total_rounds: session.deliberation_rounds.length,
|
||||||
|
duration_days: Math.ceil((new Date() - new Date(session.created_at)) / (1000 * 60 * 60 * 24))
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stakeholder participation
|
||||||
|
stakeholders: {
|
||||||
|
total: session.stakeholders.length,
|
||||||
|
confirmed: session.stakeholders.filter(s => s.participation_status === 'confirmed').length,
|
||||||
|
active: session.stakeholders.filter(s => s.participation_status === 'active').length,
|
||||||
|
withdrawn: session.stakeholders.filter(s => s.participation_status === 'withdrawn').length
|
||||||
|
},
|
||||||
|
|
||||||
|
// Facilitation transparency (AI vs. Human)
|
||||||
|
facilitation: {
|
||||||
|
total_actions: session.facilitation_log.length,
|
||||||
|
ai_actions: session.facilitation_log.filter(a => a.actor === 'ai').length,
|
||||||
|
human_actions: session.facilitation_log.filter(a => a.actor === 'human').length,
|
||||||
|
intervention_count: session.human_interventions.length,
|
||||||
|
intervention_triggers: this._summarizeInterventionTriggers(session.human_interventions)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Safety transparency
|
||||||
|
safety: {
|
||||||
|
escalations: session.safety_escalations.length,
|
||||||
|
by_severity: {
|
||||||
|
low: session.safety_escalations.filter(e => e.severity === 'low').length,
|
||||||
|
moderate: session.safety_escalations.filter(e => e.severity === 'moderate').length,
|
||||||
|
high: session.safety_escalations.filter(e => e.severity === 'high').length,
|
||||||
|
critical: session.safety_escalations.filter(e => e.severity === 'critical').length
|
||||||
|
},
|
||||||
|
resolved: session.safety_escalations.filter(e => e.resolved).length,
|
||||||
|
unresolved: session.safety_escalations.filter(e => !e.resolved).length
|
||||||
|
},
|
||||||
|
|
||||||
|
// Outcome transparency
|
||||||
|
outcome: session.outcome ? {
|
||||||
|
consensus_level: session.outcome.consensus_level,
|
||||||
|
generated_by: session.outcome.generated_by,
|
||||||
|
dissenting_perspectives_count: session.outcome.dissenting_perspectives.length,
|
||||||
|
values_in_tension: {
|
||||||
|
prioritized: session.outcome.values_prioritized,
|
||||||
|
deprioritized: session.outcome.values_deprioritized
|
||||||
|
}
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store report in session
|
||||||
|
await this.collection.updateOne(
|
||||||
|
{ session_id: sessionId },
|
||||||
|
{ $set: { transparency_report: report, updated_at: new Date() } }
|
||||||
|
);
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _summarizeInterventionTriggers(interventions) {
|
||||||
|
const triggers = {};
|
||||||
|
interventions.forEach(i => {
|
||||||
|
triggers[i.trigger] = (triggers[i.trigger] || 0) + 1;
|
||||||
|
});
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DeliberationSession;
|
||||||
503
src/models/Precedent.model.js
Normal file
503
src/models/Precedent.model.js
Normal file
|
|
@ -0,0 +1,503 @@
|
||||||
|
/**
|
||||||
|
* Precedent Model
|
||||||
|
* Stores completed deliberation sessions as searchable precedents
|
||||||
|
* for informing future values conflicts without dictating outcomes.
|
||||||
|
*
|
||||||
|
* PLURALISTIC PRINCIPLE: Precedents inform but don't mandate.
|
||||||
|
* Similar conflicts can be resolved differently based on context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { getCollection } = require('../utils/db.util');
|
||||||
|
|
||||||
|
class Precedent {
|
||||||
|
/**
|
||||||
|
* Create precedent from completed deliberation session
|
||||||
|
*/
|
||||||
|
static async createFromSession(sessionData) {
|
||||||
|
const collection = await getCollection('precedents');
|
||||||
|
|
||||||
|
const precedent = {
|
||||||
|
precedent_id: `precedent-${Date.now()}`,
|
||||||
|
created_at: new Date(),
|
||||||
|
|
||||||
|
// Link to original session
|
||||||
|
source_session_id: sessionData.session_id,
|
||||||
|
source_session_created: sessionData.created_at,
|
||||||
|
|
||||||
|
// Conflict description (searchable)
|
||||||
|
conflict: {
|
||||||
|
description: sessionData.decision.description,
|
||||||
|
scenario: sessionData.decision.scenario,
|
||||||
|
moral_frameworks_in_tension: sessionData.conflict_analysis.moral_frameworks_in_tension,
|
||||||
|
value_trade_offs: sessionData.conflict_analysis.value_trade_offs,
|
||||||
|
incommensurability_level: sessionData.conflict_analysis.incommensurability_level
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stakeholder composition (for pattern matching)
|
||||||
|
stakeholder_pattern: {
|
||||||
|
total_count: sessionData.stakeholders.length,
|
||||||
|
types: this._extractStakeholderTypes(sessionData.stakeholders),
|
||||||
|
represents: this._extractRepresentations(sessionData.stakeholders),
|
||||||
|
moral_frameworks: this._extractMoralFrameworks(sessionData.stakeholders)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Deliberation process (what worked, what didn't)
|
||||||
|
process: {
|
||||||
|
format: sessionData.configuration.format,
|
||||||
|
ai_role: sessionData.configuration.ai_role,
|
||||||
|
rounds_completed: sessionData.deliberation_rounds.length,
|
||||||
|
duration_days: Math.ceil((new Date(sessionData.outcome.finalized_at) - new Date(sessionData.created_at)) / (1000 * 60 * 60 * 24)),
|
||||||
|
|
||||||
|
// AI facilitation quality (for learning)
|
||||||
|
ai_facilitation_quality: {
|
||||||
|
intervention_count: sessionData.human_interventions.length,
|
||||||
|
escalation_count: sessionData.safety_escalations.length,
|
||||||
|
stakeholder_satisfaction_avg: this._calculateAverageSatisfaction(sessionData.ai_quality_metrics.stakeholder_satisfaction_scores)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Outcome (the accommodation reached)
|
||||||
|
outcome: {
|
||||||
|
decision_made: sessionData.outcome.decision_made,
|
||||||
|
consensus_level: sessionData.outcome.consensus_level,
|
||||||
|
values_prioritized: sessionData.outcome.values_prioritized,
|
||||||
|
values_deprioritized: sessionData.outcome.values_deprioritized,
|
||||||
|
moral_remainder: sessionData.outcome.moral_remainder,
|
||||||
|
dissenting_count: sessionData.outcome.dissenting_perspectives.length
|
||||||
|
},
|
||||||
|
|
||||||
|
// Key insights (extracted from deliberation)
|
||||||
|
insights: {
|
||||||
|
shared_values_discovered: this._extractSharedValues(sessionData.deliberation_rounds),
|
||||||
|
accommodation_strategies: this._extractAccommodationStrategies(sessionData.deliberation_rounds),
|
||||||
|
unexpected_coalitions: this._extractCoalitions(sessionData.deliberation_rounds),
|
||||||
|
framework_tensions_resolved: this._extractTensionResolutions(sessionData)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Searchable metadata
|
||||||
|
metadata: {
|
||||||
|
domain: this._inferDomain(sessionData.decision.scenario), // "employment", "healthcare", "content_moderation", etc.
|
||||||
|
decision_type: this._inferDecisionType(sessionData.conflict_analysis), // "transparency", "resource_allocation", "procedural", etc.
|
||||||
|
geographic_context: sessionData.decision.context.geographic || 'unspecified',
|
||||||
|
temporal_context: sessionData.decision.context.temporal || 'unspecified' // "emerging_issue", "established_issue", "crisis"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Usage tracking
|
||||||
|
usage: {
|
||||||
|
times_referenced: 0,
|
||||||
|
influenced_sessions: [], // Array of session_ids where this precedent was consulted
|
||||||
|
last_referenced: null
|
||||||
|
},
|
||||||
|
|
||||||
|
// Searchability flags
|
||||||
|
searchable: true,
|
||||||
|
tags: this._generateTags(sessionData),
|
||||||
|
|
||||||
|
// Archive metadata
|
||||||
|
archived: false,
|
||||||
|
archived_reason: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await collection.insertOne(precedent);
|
||||||
|
return { ...precedent, _id: result.insertedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search precedents by conflict pattern
|
||||||
|
* Returns similar past deliberations (not prescriptive, just informative)
|
||||||
|
*/
|
||||||
|
static async searchByConflict(query, options = {}) {
|
||||||
|
const collection = await getCollection('precedents');
|
||||||
|
const { limit = 10, skip = 0 } = options;
|
||||||
|
|
||||||
|
const filter = { searchable: true, archived: false };
|
||||||
|
|
||||||
|
// Match moral frameworks in tension
|
||||||
|
if (query.moral_frameworks && query.moral_frameworks.length > 0) {
|
||||||
|
filter['conflict.moral_frameworks_in_tension'] = { $in: query.moral_frameworks };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match scenario
|
||||||
|
if (query.scenario) {
|
||||||
|
filter['conflict.scenario'] = query.scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match domain
|
||||||
|
if (query.domain) {
|
||||||
|
filter['metadata.domain'] = query.domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match decision type
|
||||||
|
if (query.decision_type) {
|
||||||
|
filter['metadata.decision_type'] = query.decision_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match incommensurability level
|
||||||
|
if (query.incommensurability_level) {
|
||||||
|
filter['conflict.incommensurability_level'] = query.incommensurability_level;
|
||||||
|
}
|
||||||
|
|
||||||
|
const precedents = await collection
|
||||||
|
.find(filter)
|
||||||
|
.sort({ 'usage.times_referenced': -1, created_at: -1 }) // Most-used first, then most recent
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
return precedents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search precedents by stakeholder pattern
|
||||||
|
* Useful for "Has deliberation with similar stakeholders been done before?"
|
||||||
|
*/
|
||||||
|
static async searchByStakeholderPattern(pattern, options = {}) {
|
||||||
|
const collection = await getCollection('precedents');
|
||||||
|
const { limit = 10, skip = 0 } = options;
|
||||||
|
|
||||||
|
const filter = { searchable: true, archived: false };
|
||||||
|
|
||||||
|
// Match stakeholder types
|
||||||
|
if (pattern.types && pattern.types.length > 0) {
|
||||||
|
filter['stakeholder_pattern.types'] = { $all: pattern.types };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match representations (e.g., "Employers", "Job Applicants")
|
||||||
|
if (pattern.represents && pattern.represents.length > 0) {
|
||||||
|
filter['stakeholder_pattern.represents'] = { $in: pattern.represents };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match moral frameworks
|
||||||
|
if (pattern.moral_frameworks && pattern.moral_frameworks.length > 0) {
|
||||||
|
filter['stakeholder_pattern.moral_frameworks'] = { $in: pattern.moral_frameworks };
|
||||||
|
}
|
||||||
|
|
||||||
|
const precedents = await collection
|
||||||
|
.find(filter)
|
||||||
|
.sort({ 'usage.times_referenced': -1, created_at: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
return precedents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search precedents by tags (free-text search)
|
||||||
|
*/
|
||||||
|
static async searchByTags(tags, options = {}) {
|
||||||
|
const collection = await getCollection('precedents');
|
||||||
|
const { limit = 10, skip = 0 } = options;
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
searchable: true,
|
||||||
|
archived: false,
|
||||||
|
tags: { $in: tags }
|
||||||
|
};
|
||||||
|
|
||||||
|
const precedents = await collection
|
||||||
|
.find(filter)
|
||||||
|
.sort({ 'usage.times_referenced': -1, created_at: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
return precedents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get most similar precedent (composite scoring)
|
||||||
|
* Uses multiple dimensions to find best match
|
||||||
|
*/
|
||||||
|
static async findMostSimilar(querySession, options = {}) {
|
||||||
|
const { limit = 5 } = options;
|
||||||
|
|
||||||
|
// Get candidates from multiple search strategies
|
||||||
|
const conflictMatches = await this.searchByConflict({
|
||||||
|
moral_frameworks: querySession.conflict_analysis.moral_frameworks_in_tension,
|
||||||
|
scenario: querySession.decision.scenario,
|
||||||
|
incommensurability_level: querySession.conflict_analysis.incommensurability_level
|
||||||
|
}, { limit: 20 });
|
||||||
|
|
||||||
|
const stakeholderMatches = await this.searchByStakeholderPattern({
|
||||||
|
types: this._extractStakeholderTypes(querySession.stakeholders),
|
||||||
|
represents: this._extractRepresentations(querySession.stakeholders),
|
||||||
|
moral_frameworks: this._extractMoralFrameworks(querySession.stakeholders)
|
||||||
|
}, { limit: 20 });
|
||||||
|
|
||||||
|
// Combine and score
|
||||||
|
const candidateMap = new Map();
|
||||||
|
|
||||||
|
// Score conflict matches
|
||||||
|
conflictMatches.forEach(p => {
|
||||||
|
const score = this._calculateSimilarityScore(querySession, p);
|
||||||
|
candidateMap.set(p.precedent_id, { precedent: p, score, reasons: ['conflict_match'] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Score stakeholder matches (add to existing or create new)
|
||||||
|
stakeholderMatches.forEach(p => {
|
||||||
|
if (candidateMap.has(p.precedent_id)) {
|
||||||
|
const existing = candidateMap.get(p.precedent_id);
|
||||||
|
existing.score += this._calculateSimilarityScore(querySession, p) * 0.5; // Weight stakeholder match lower
|
||||||
|
existing.reasons.push('stakeholder_match');
|
||||||
|
} else {
|
||||||
|
const score = this._calculateSimilarityScore(querySession, p) * 0.5;
|
||||||
|
candidateMap.set(p.precedent_id, { precedent: p, score, reasons: ['stakeholder_match'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by score
|
||||||
|
const ranked = Array.from(candidateMap.values())
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
return ranked.map(r => ({
|
||||||
|
...r.precedent,
|
||||||
|
similarity_score: r.score,
|
||||||
|
match_reasons: r.reasons
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record that this precedent was referenced in a new session
|
||||||
|
*/
|
||||||
|
static async recordUsage(precedentId, referencingSessionId) {
|
||||||
|
const collection = await getCollection('precedents');
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ precedent_id: precedentId },
|
||||||
|
{
|
||||||
|
$inc: { 'usage.times_referenced': 1 },
|
||||||
|
$push: { 'usage.influenced_sessions': referencingSessionId },
|
||||||
|
$set: { 'usage.last_referenced': new Date() }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.modifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics on precedent usage
|
||||||
|
*/
|
||||||
|
static async getStatistics() {
|
||||||
|
const collection = await getCollection('precedents');
|
||||||
|
|
||||||
|
const [stats] = await collection.aggregate([
|
||||||
|
{ $match: { searchable: true, archived: false } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
total_precedents: { $sum: 1 },
|
||||||
|
avg_references: { $avg: '$usage.times_referenced' },
|
||||||
|
total_references: { $sum: '$usage.times_referenced' },
|
||||||
|
by_domain: { $push: '$metadata.domain' },
|
||||||
|
by_scenario: { $push: '$conflict.scenario' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]).toArray();
|
||||||
|
|
||||||
|
const byDomain = await collection.aggregate([
|
||||||
|
{ $match: { searchable: true, archived: false } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$metadata.domain',
|
||||||
|
count: { $sum: 1 },
|
||||||
|
avg_satisfaction: { $avg: '$process.ai_facilitation_quality.stakeholder_satisfaction_avg' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $sort: { count: -1 } }
|
||||||
|
]).toArray();
|
||||||
|
|
||||||
|
const byConsensusLevel = await collection.aggregate([
|
||||||
|
{ $match: { searchable: true, archived: false } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$outcome.consensus_level',
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]).toArray();
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: stats || { total_precedents: 0, avg_references: 0, total_references: 0 },
|
||||||
|
by_domain: byDomain,
|
||||||
|
by_consensus_level: byConsensusLevel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive precedent (make unsearchable but retain for records)
|
||||||
|
*/
|
||||||
|
static async archive(precedentId, reason) {
|
||||||
|
const collection = await getCollection('precedents');
|
||||||
|
|
||||||
|
const result = await collection.updateOne(
|
||||||
|
{ precedent_id: precedentId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
archived: true,
|
||||||
|
archived_reason: reason,
|
||||||
|
archived_at: new Date(),
|
||||||
|
searchable: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.modifiedCount > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HELPER METHODS (private) =====
|
||||||
|
|
||||||
|
static _extractStakeholderTypes(stakeholders) {
|
||||||
|
return [...new Set(stakeholders.map(s => s.type))];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _extractRepresentations(stakeholders) {
|
||||||
|
return [...new Set(stakeholders.map(s => s.represents))];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _extractMoralFrameworks(stakeholders) {
|
||||||
|
return [...new Set(stakeholders.map(s => s.moral_framework).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _calculateAverageSatisfaction(scores) {
|
||||||
|
if (!scores || scores.length === 0) return null;
|
||||||
|
return scores.reduce((sum, s) => sum + s.score, 0) / scores.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _extractSharedValues(rounds) {
|
||||||
|
// Look for Round 2 (shared values) contributions
|
||||||
|
const round2 = rounds.find(r => r.round_type === 'shared_values');
|
||||||
|
if (!round2) return [];
|
||||||
|
|
||||||
|
// Extract values mentioned across contributions
|
||||||
|
const values = [];
|
||||||
|
round2.contributions.forEach(c => {
|
||||||
|
if (c.values_emphasized) {
|
||||||
|
values.push(...c.values_emphasized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _extractAccommodationStrategies(rounds) {
|
||||||
|
// Look for Round 3 (accommodation) AI summary
|
||||||
|
const round3 = rounds.find(r => r.round_type === 'accommodation');
|
||||||
|
if (!round3 || !round3.ai_summary) return [];
|
||||||
|
|
||||||
|
// This would ideally parse the summary for strategies
|
||||||
|
// For now, return placeholder
|
||||||
|
return ['tiered_approach', 'contextual_variation', 'temporal_adjustment'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _extractCoalitions(rounds) {
|
||||||
|
// Identify unexpected stakeholder agreements
|
||||||
|
// This would require NLP analysis of contributions
|
||||||
|
// For now, return placeholder
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _extractTensionResolutions(sessionData) {
|
||||||
|
if (!sessionData.outcome) return [];
|
||||||
|
|
||||||
|
const resolutions = [];
|
||||||
|
sessionData.conflict_analysis.value_trade_offs.forEach(tradeoff => {
|
||||||
|
// Check if outcome addresses this trade-off
|
||||||
|
const prioritized = sessionData.outcome.values_prioritized.some(v => tradeoff.includes(v));
|
||||||
|
const deprioritized = sessionData.outcome.values_deprioritized.some(v => tradeoff.includes(v));
|
||||||
|
|
||||||
|
if (prioritized && deprioritized) {
|
||||||
|
resolutions.push({
|
||||||
|
tension: tradeoff,
|
||||||
|
resolution: 'balanced_accommodation'
|
||||||
|
});
|
||||||
|
} else if (prioritized) {
|
||||||
|
resolutions.push({
|
||||||
|
tension: tradeoff,
|
||||||
|
resolution: 'prioritized'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return resolutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _inferDomain(scenario) {
|
||||||
|
const domainMap = {
|
||||||
|
'algorithmic_hiring_transparency': 'employment',
|
||||||
|
'remote_work_pay': 'employment',
|
||||||
|
'content_moderation': 'platform_governance',
|
||||||
|
'healthcare_ai': 'healthcare',
|
||||||
|
'ai_content_labeling': 'creative_rights'
|
||||||
|
};
|
||||||
|
return domainMap[scenario] || 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
static _inferDecisionType(conflictAnalysis) {
|
||||||
|
const description = conflictAnalysis.value_trade_offs.join(' ').toLowerCase();
|
||||||
|
|
||||||
|
if (description.includes('transparency')) return 'transparency';
|
||||||
|
if (description.includes('resource') || description.includes('allocation')) return 'resource_allocation';
|
||||||
|
if (description.includes('procedure') || description.includes('process')) return 'procedural';
|
||||||
|
if (description.includes('privacy')) return 'privacy';
|
||||||
|
if (description.includes('safety')) return 'safety';
|
||||||
|
|
||||||
|
return 'unspecified';
|
||||||
|
}
|
||||||
|
|
||||||
|
static _generateTags(sessionData) {
|
||||||
|
const tags = [];
|
||||||
|
|
||||||
|
// Add scenario tag
|
||||||
|
if (sessionData.decision.scenario) {
|
||||||
|
tags.push(sessionData.decision.scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add moral framework tags
|
||||||
|
tags.push(...sessionData.conflict_analysis.moral_frameworks_in_tension.map(f => f.toLowerCase()));
|
||||||
|
|
||||||
|
// Add stakeholder representation tags
|
||||||
|
tags.push(...sessionData.stakeholders.map(s => s.represents.toLowerCase().replace(/ /g, '_')));
|
||||||
|
|
||||||
|
// Add outcome tag
|
||||||
|
if (sessionData.outcome) {
|
||||||
|
tags.push(sessionData.outcome.consensus_level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add AI quality tag
|
||||||
|
const interventions = sessionData.human_interventions.length;
|
||||||
|
if (interventions === 0) tags.push('smooth_ai_facilitation');
|
||||||
|
else if (interventions > 3) tags.push('challenging_ai_facilitation');
|
||||||
|
|
||||||
|
return [...new Set(tags)];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _calculateSimilarityScore(querySession, precedent) {
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
// Scenario match (high weight)
|
||||||
|
if (querySession.decision.scenario === precedent.conflict.scenario) {
|
||||||
|
score += 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Moral frameworks overlap (medium weight)
|
||||||
|
const queryFrameworks = new Set(querySession.conflict_analysis.moral_frameworks_in_tension);
|
||||||
|
const precedentFrameworks = new Set(precedent.conflict.moral_frameworks_in_tension);
|
||||||
|
const frameworkOverlap = [...queryFrameworks].filter(f => precedentFrameworks.has(f)).length;
|
||||||
|
score += (frameworkOverlap / Math.max(queryFrameworks.size, precedentFrameworks.size)) * 30;
|
||||||
|
|
||||||
|
// Incommensurability match (medium weight)
|
||||||
|
if (querySession.conflict_analysis.incommensurability_level === precedent.conflict.incommensurability_level) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stakeholder count similarity (low weight)
|
||||||
|
const countDiff = Math.abs(querySession.stakeholders.length - precedent.stakeholder_pattern.total_count);
|
||||||
|
score += Math.max(0, 10 - countDiff * 2);
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Precedent;
|
||||||
|
|
@ -11,6 +11,8 @@ const Resource = require('./Resource.model');
|
||||||
const ModerationQueue = require('./ModerationQueue.model');
|
const ModerationQueue = require('./ModerationQueue.model');
|
||||||
const User = require('./User.model');
|
const User = require('./User.model');
|
||||||
const GovernanceLog = require('./GovernanceLog.model');
|
const GovernanceLog = require('./GovernanceLog.model');
|
||||||
|
const DeliberationSession = require('./DeliberationSession.model');
|
||||||
|
const Precedent = require('./Precedent.model');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
Document,
|
Document,
|
||||||
|
|
@ -20,5 +22,7 @@ module.exports = {
|
||||||
Resource,
|
Resource,
|
||||||
ModerationQueue,
|
ModerationQueue,
|
||||||
User,
|
User,
|
||||||
GovernanceLog
|
GovernanceLog,
|
||||||
|
DeliberationSession,
|
||||||
|
Precedent
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,10 @@ app.use((req, res, next) => {
|
||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
res.setHeader('Expires', '0');
|
res.setHeader('Expires', '0');
|
||||||
}
|
}
|
||||||
// HTML files: Short cache, always revalidate
|
// HTML files: No cache (always fetch fresh - users must see updates immediately)
|
||||||
else if (path.endsWith('.html') || path === '/') {
|
else if (path.endsWith('.html') || path === '/') {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate'); // 5 minutes
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
}
|
}
|
||||||
// CSS and JS files: Longer cache (we use version parameters)
|
// CSS and JS files: Longer cache (we use version parameters)
|
||||||
else if (path.endsWith('.css') || path.endsWith('.js')) {
|
else if (path.endsWith('.css') || path.endsWith('.js')) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue